// tests/unit/renderer/store/select-range-visible-order.test.ts // // 守 audit-r243「Shift 范围选择的区间口径必须是**屏幕上可见的行序**」契约。 // // 现象(用户可复现,文献域): // 1. 侧栏切到「仅收藏」视图(只显示被收藏的 PDF); // 2. 在第 1 行按住 Shift 点第 3 行 → 高亮 3 行,看起来没问题; // 3. 但底层 store 的 items 里还有**没被收藏、被过滤掉**的文件。旧实现 // `selectRange` 切的是 items 序(未过滤),区间两端之间的**每一个** // items 元素都被塞进 selectedIds —— 包括用户**根本没看见**的行; // 4. 按 Delete → 确认框写「删除 N 个文件?」,随后把那些用户没看见、 // 也没打算选的文件**丢进回收站**。 // // 根因:store 数组序 ≠ 屏幕行序。列表是**树状 + 过滤 + 自定义排序**的: // - 文献域 visibleItems 走 favFiltered / 文件夹过滤 / displayOrderById; // - 笔记域 visibleItems 走同样的过滤链。 // store 的 items/notes 只是「磁盘事实」的全集。 // // 修法:selectRange 多收一个 visibleIds(渲染序),用它的下标切区间。 // 不传 visibleIds 时退回 store 序(向后兼容旧调用点)。 // // 本测锁三件事: // 1. 传 visibleIds 时,区间**只含可见行**(被过滤掉的行不在 selectedIds); // 2. 不传 visibleIds 时退回旧行为(store 序全量)—— 保证没接线的调用点 // 不会静默变成「只选一行」; // 3. 锚点不在可见序里 / 目标不在可见序里 ⇒ 退化成单选(不越界选)。 import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; vi.mock('../../../../src/renderer/src/store/workspace', () => ({ useWorkspaceStore: { getState: () => ({ pendingFocusNote: { hasPending: false, payload: null }, consumeFocusNoteRequest: () => {}, }), }, })); vi.mock('../../../../src/renderer/src/lib/toast', () => ({ useToast: { getState: () => ({ show: () => {} }), }, })); import { useNotesStore } from '../../../../src/renderer/src/store/notes'; import { useLiteratureStore } from '../../../../src/renderer/src/store/literature'; import type { LiteratureResource, ResourceId, StandaloneNoteSummary, } from '../../../../src/shared/types'; function note(id: string): StandaloneNoteSummary { return { id, relPath: `${id}.md`, filename: `${id}.md`, title: null, tags: null, mtime: 0 }; } function lit(id: string): LiteratureResource { return { id: id as ResourceId, kind: 'literature', title: id, mtime: 0, size: 0, indexedAt: 0, absPath: `C:/root/literature/${id}.pdf`, relPath: `${id}.pdf`, rootId: 'r1', extractStatus: 'ok', }; } describe('audit-r243:Shift 范围选择按可见行序切区间', () => { beforeEach(() => { (globalThis as unknown as { window: { api: unknown } }).window = { api: {} }; useNotesStore.setState({ notes: [], selectedIds: new Set(), selectedId: null, activeNoteFull: null, anchorId: null, clipboard: null, lastError: null, displayOrderById: {}, }); useLiteratureStore.setState({ items: [], selectedIds: new Set(), selectedId: null, anchorId: null, clipboard: null, loading: false, lastError: null, displayOrderById: {}, }); }); afterEach(() => { vi.restoreAllMocks(); }); it('笔记域:传 visibleIds 时区间不含被过滤掉的行', () => { // store 全量序:a b c d e;屏幕(搜索过滤后)只显示 a c e useNotesStore.setState({ notes: [note('a'), note('b'), note('c'), note('d'), note('e')], selectedIds: new Set(['a']), anchorId: 'a', }); useNotesStore.getState().selectRange('c', ['a', 'c', 'e']); // 旧实现会选到 a b c(含看不见的 b);正确结果只含 a c expect([...useNotesStore.getState().selectedIds].sort()).toEqual(['a', 'c']); }); it('文献域:传 visibleIds 时区间不含被过滤掉的行(仅收藏视图)', () => { // store 全量序:fav1 x1 fav2 x2 fav3;屏幕(仅收藏)只显示 fav1 fav2 fav3 useLiteratureStore.setState({ items: [lit('fav1'), lit('x1'), lit('fav2'), lit('x2'), lit('fav3')], selectedIds: new Set(['fav1']), anchorId: 'fav1', }); useLiteratureStore.getState().selectRange('fav3', ['fav1', 'fav2', 'fav3']); // 旧实现会选到 fav1 x1 fav2 x2 fav3(4 个看不见/不存在的行被删) expect([...useLiteratureStore.getState().selectedIds].sort()).toEqual([ 'fav1', 'fav2', 'fav3', ]); }); it('不传 visibleIds 时退回 store 序(旧调用点行为不变)', () => { useNotesStore.setState({ notes: [note('a'), note('b'), note('c')], selectedIds: new Set(['a']), anchorId: 'a', }); useNotesStore.getState().selectRange('c'); expect([...useNotesStore.getState().selectedIds].sort()).toEqual(['a', 'b', 'c']); }); it('锚点 / 目标不在可见序里 ⇒ 退化成单选(不越界选)', () => { useLiteratureStore.setState({ items: [lit('fav1'), lit('x1'), lit('fav2')], selectedIds: new Set(['x1']), anchorId: 'x1', }); // 锚点 x1 被过滤掉了 ⇒ 区间无意义 ⇒ 只选目标行 useLiteratureStore.getState().selectRange('fav2', ['fav1', 'fav2']); expect([...useLiteratureStore.getState().selectedIds]).toEqual(['fav2']); // 目标不在可见序里 ⇒ 同样退化 useLiteratureStore.setState({ selectedIds: new Set(['fav1']), anchorId: 'fav1' }); useLiteratureStore.getState().selectRange('x1', ['fav1', 'fav2']); expect([...useLiteratureStore.getState().selectedIds]).toEqual(['x1']); }); });