This commit is contained in:
2026-10-21 14:42:59 +08:00
commit 954236d3f9
399 changed files with 85885 additions and 0 deletions

View File

@@ -0,0 +1,122 @@
// tests/unit/renderer/lib/multi-select-live-ids.test.ts
//
// 守 r193「删除 / 移入回收站只对**活着的** id 动手」契约。
//
// 现象(用户可复现):
// 1. 侧栏 Ctrl 多选 5 篇笔记;
// 2. 其中 1 篇在别处被删了(另一个窗口删 / 网盘同步删 / 回收站清理);
// 3. 回到应用,侧栏仍显示 5 篇被选中(那一行还在,因为列表还没刷新);
// 4. 按 Delete / 右键「移入回收站」⇒ 抛 NOT_FOUND ⇒ **串行循环中断** ⇒
// 只删了 3 篇,后面 2 篇没删,还弹一个「移入回收站失败」toast。
//
// 现象(两侧不对称版):
// 同样的操作在**文献侧**是好的 —— literature 侧删除前用
// `items.filter(r => selectedIds.has(r.id))` 过滤,死 id 被静默跳过;
// 笔记侧直接拿 selectedIds 全集去删 ⇒ 一边好用一边不好用。
//
// 本测锁:
// 1. liveSelectedIds:只返回列表里还活着的成员,按**列表顺序**;
// 2. 空选择集 → 空结果(不返回 undefined / 不抛);
// 3. 全部死 id → 空结果(而不是把死 id 原样吐出去);
// 4. 源码契约:两个列表组件的删除回调都必须经过 liveSelectedIds,
// 不得直接用 selectedIds 全集(防止以后又写回不对称的老写法)。
import { describe, expect, it } from 'vitest';
import { readFileSync } from 'fs';
import { join } from 'path';
import { liveSelectedIds } from '../../../../src/renderer/src/lib/multi-select';
const ROOT = process.cwd();
const NOTES = 'src/renderer/src/components/notes/NotesList.tsx';
const LIT = 'src/renderer/src/components/literature/LiteratureList.tsx';
interface Row {
id: string;
}
/**
* 取出某个 `useCallback(` 回调的**函数体**。
*
* audit-r254:旧实现用 `src.slice(src.indexOf(anchor))` +
* `src.slice(0, src.indexOf(endAnchor))` 切片 —— **锚点缺失时 indexOf
* 返回 -1**,而 `slice(-1)` 取的是**最后一个字符**、`slice(0, -1)` 取的是
* 「除末字符外的全文」⇒ 切片范围**静默漂移**:
* - 起始锚点丢了(回调改名/删除)⇒ 拿到空串 ⇒ 断言红(报错信息难读);
* - 结束锚点丢了(**依赖清单一改就会丢**,最常见的改动)⇒ 范围悄悄放大
* 成「从回调一直到文件尾」⇒ 断言 `toContain('liveSelectedIds(')` 可以
* 被**函数体之外**的同名文本满足 ⇒ 回调真的不用 liveSelectedIds 了,
* 守卫照样绿(假绿)。
*
* 这里改成:锚点缺失 / 顺序不对 ⇒ **直接失败**,让守卫真的失效;
* 结束锚点只认 useCallback 的收尾形态 `}, [`(不写死依赖清单,免得依赖项
* 一改守卫就假红 —— 那种红是「守卫跟不上实现」,不是「实现有病」)。
*/
function sliceCallbackBody(src: string, startAnchor: string): string {
const s = src.indexOf(startAnchor);
expect(s, `起始锚点缺失:${startAnchor}`).toBeGreaterThanOrEqual(0);
const e = src.indexOf('\n }, [', s);
expect(e, `结束锚点缺失(未找到 useCallback 收尾):${startAnchor}`).toBeGreaterThan(s);
return src.slice(s, e);
}
/**
* 剥掉注释 —— 断言必须落在**真代码**上。
*
* 实证(旧守卫的第二个假绿面):LiteratureList.tsx 的删除回调里有一行**注释**
* // r193:与 notes 侧共用 liveSelectedIds(死 id 静默跳过,别让
* 这行注释里含字面量 `liveSelectedIds(` ⇒ 旧守卫的
* `expect(body).toContain('liveSelectedIds(')` 可以**被注释满足** ⇒
* 把真正的调用换成 `Array.from(st.selectedIds)` 也不红。
*/
function stripComments(code: string): string {
return code.replace(/\/\*[\s\S]*?\*\//g, '').replace(/(^|[^:])\/\/.*$/gm, '$1');
}
describe('liveSelectedIds:作用集只含活着的成员(r193)', () => {
it('只返回列表里还活着的成员,按列表顺序', () => {
const items: Row[] = [{ id: 'a' }, { id: 'b' }, { id: 'c' }];
// 选择集里有 'b'(活着)+ 'zz'(不在列表里 ⇒ 死 id)
const out = liveSelectedIds(items, new Set(['b', 'zz']));
expect(out.map((x) => x.id)).toEqual(['b']);
});
it('选择集顺序与列表顺序不一致时,以**列表顺序**为准', () => {
const items: Row[] = [{ id: 'a' }, { id: 'b' }, { id: 'c' }];
const out = liveSelectedIds(items, new Set(['c', 'a']));
expect(out.map((x) => x.id)).toEqual(['a', 'c']);
});
it('空选择集 → 空结果', () => {
expect(liveSelectedIds([{ id: 'a' }], new Set<string>())).toEqual([]);
});
it('全是死 id → 空结果(不得把死 id 吐出去)', () => {
const out = liveSelectedIds([{ id: 'a' }], new Set(['x', 'y']));
expect(out).toEqual([]);
});
it('源码契约:两个域的删除回调都必须过滤成「活着的 id」', () => {
const notesSrc = readFileSync(join(ROOT, NOTES), 'utf8');
const litSrc = readFileSync(join(ROOT, LIT), 'utf8');
// notes 侧:handleTrashSelection
const notesTrashBody = stripComments(
sliceCallbackBody(notesSrc, 'const handleTrashSelection = useCallback('),
);
expect(notesTrashBody, 'notes 侧删除必须用 liveSelectedIds 过滤').toContain('liveSelectedIds(');
expect(
notesTrashBody,
'notes 侧不得再直接用 selectedIds 全集([...selectedIds])',
).not.toMatch(/\[\.\.\.\s*(?:useNotesStore\.getState\(\)\.)?selectedIds\]/);
// literature 侧:handleDeleteSelection
const litDeleteBody = stripComments(
sliceCallbackBody(litSrc, 'const handleDeleteSelection = useCallback('),
);
expect(litDeleteBody, 'literature 侧删除必须用 liveSelectedIds 过滤').toContain('liveSelectedIds(');
expect(
litDeleteBody,
'literature 侧也不得直接用 selectedIds 全集([...selectedIds])',
).not.toMatch(/\[\.\.\.\s*(?:useLiteratureStore\.getState\(\)\.)?selectedIds\]/);
});
});