|
1 | 1 | // @vitest-environment jsdom |
2 | 2 | /** |
3 | | - * NoteListView.test.tsx — 笔记列表勾选批量删除交互测试(v0.12.8 审查补测)。 |
| 3 | + * NoteListView.test.tsx — 笔记列表交互(REQ-287 v0.19.7)关键路径。 |
4 | 4 | * |
5 | | - * @ai-context: 覆盖关键路径——行勾选出现批量栏 / 全选三态 / 删除执行后清空勾选 |
6 | | - * (resolve=true)/ 取消确认保留勾选(resolve=false)/ 列表数据变化 |
7 | | - * 后裁剪不可见勾选(只删可见子集安全边界)。纯 UI 组件无 invoke。 |
| 5 | + * @ai-context: 覆盖——去 checkbox 后:单击=打开右栏(onSelect);Ctrl+单击= |
| 6 | + * 加/减选(不触发打开)→ 批量栏浮现;Shift+单击=按列表位置区间 |
| 7 | + * 选;批量选择模式(工具栏「选择」):行单击=勾选且不打开,Esc |
| 8 | + * 退出并清空;批量删除(resolve=true 清空选择);批量移动到组 |
| 9 | + * (选集菜单 → 组 → move_note_to_group 逐条)。invoke/事件全 mock。 |
8 | 10 | */ |
9 | | -import { afterEach, describe, expect, it, vi } from "vitest"; |
10 | | -import { cleanup, fireEvent, render, screen } from "@testing-library/react"; |
11 | | -import type { Note } from "../types"; |
| 11 | +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; |
| 12 | +import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; |
| 13 | +import type { Note, NoteGroup } from "../types"; |
12 | 14 | import NoteListView from "./NoteListView"; |
13 | 15 |
|
14 | | -function makeNote(id: number, title: string): Note { |
15 | | - return { |
16 | | - id, |
17 | | - title, |
18 | | - content: "", |
19 | | - source: "manual", |
20 | | - tags: "[]", |
21 | | - pin: 0, |
22 | | - created_at: 0, |
23 | | - updated_at: 0, |
24 | | - }; |
25 | | -} |
| 16 | +const invokeMock = vi.fn(); |
| 17 | +vi.mock("@tauri-apps/api/core", () => ({ invoke: (...a: unknown[]) => invokeMock(...a) })); |
26 | 18 |
|
27 | | -const notesA = [makeNote(1, "笔记一"), makeNote(2, "笔记二")]; |
28 | | - |
29 | | -const baseProps = { |
30 | | - keyword: "", |
31 | | - tagFilter: null, |
32 | | - sortMode: "updated-desc" as const, |
33 | | - allTags: [] as string[], |
34 | | - selectedId: null, |
35 | | - status: "", |
36 | | - onKeywordChange: vi.fn(), |
37 | | - onTagFilterChange: vi.fn(), |
38 | | - onSortModeChange: vi.fn(), |
39 | | - onSelect: vi.fn(), |
40 | | - onCreate: vi.fn(), |
41 | | - onRefresh: vi.fn(), |
42 | | - onOpenSession: vi.fn(), |
43 | | -}; |
| 19 | +function makeNote(id: number, title: string, groupId: number | null = null): Note { |
| 20 | + return { id, title, content: "", source: "manual", tags: "[]", pin: 0, group_id: groupId, created_at: 0, updated_at: 0 }; |
| 21 | +} |
| 22 | +const groups = [ |
| 23 | + { id: 1, name: "摄影", color: null, terrain: null, createdAt: 0, updatedAt: 0 }, |
| 24 | +] as unknown as NoteGroup[]; |
| 25 | +const notes = [makeNote(1, "笔记一", 1), makeNote(2, "笔记二", 1), makeNote(3, "笔记三", null)]; |
44 | 26 |
|
45 | | -function renderList(overrides: Partial<Parameters<typeof NoteListView>[0]> = {}) { |
46 | | - return render(<NoteListView notes={notesA} {...baseProps} onBatchDelete={vi.fn().mockResolvedValue(true)} {...overrides} />); |
| 27 | +function baseProps(over: Record<string, unknown> = {}) { |
| 28 | + return { |
| 29 | + width: 320, notes, groups, groupFilter: null, onGroupFilterChange: vi.fn(), |
| 30 | + keyword: "", tagFilter: null, sortMode: "updated-desc" as const, allTags: [], |
| 31 | + selectedId: null, status: "", noteColors: {}, tagColors: {}, |
| 32 | + onKeywordChange: vi.fn(), onTagFilterChange: vi.fn(), onSortModeChange: vi.fn(), |
| 33 | + onSelect: vi.fn(), onCreate: vi.fn(), onRefresh: vi.fn(), onOpenSession: vi.fn(), |
| 34 | + onBatchDelete: vi.fn(async () => true), onNoteMoved: vi.fn(), onCollapse: vi.fn(), |
| 35 | + ...over, |
| 36 | + } as Parameters<typeof NoteListView>[0]; |
47 | 37 | } |
48 | 38 |
|
49 | | -afterEach(() => cleanup()); |
| 39 | +beforeEach(() => { |
| 40 | + invokeMock.mockReset(); |
| 41 | + invokeMock.mockImplementation(async (cmd: string) => { |
| 42 | + switch (cmd) { |
| 43 | + case "note_order_list": return []; |
| 44 | + case "note_order_save": return null; |
| 45 | + case "note_order_clear": return true; |
| 46 | + case "move_note_to_group": return true; |
| 47 | + default: return null; |
| 48 | + } |
| 49 | + }); |
| 50 | +}); |
| 51 | +afterEach(cleanup); |
50 | 52 |
|
51 | | -describe("NoteListView 勾选批量删除", () => { |
52 | | - it("行勾选后出现批量栏,计数正确(Arrange/Act/Assert)", () => { |
53 | | - renderList(); |
54 | | - expect(screen.queryByText(/已选/)).toBeNull(); // 初始无批量栏 |
55 | | - fireEvent.click(screen.getAllByRole("checkbox")[0]); |
56 | | - expect(screen.getByText(/已选 1 个/)).toBeTruthy(); |
| 53 | +describe("NoteListView REQ-287 选择交互", () => { |
| 54 | + it("单击=打开右栏(onSelect);不出现批量栏", async () => { |
| 55 | + const onSelect = vi.fn(); |
| 56 | + render(<NoteListView {...baseProps({ onSelect })} />); |
| 57 | + fireEvent.click(await screen.findByTestId("note-row-1")); |
| 58 | + expect(onSelect).toHaveBeenCalledWith(notes[0]); |
| 59 | + expect(screen.queryByText(/已选/)).toBeNull(); |
57 | 60 | }); |
58 | 61 |
|
59 | | - it("全选框三态:勾一后全选,再点全选清空", () => { |
60 | | - renderList(); |
61 | | - fireEvent.click(screen.getAllByRole("checkbox")[0]); // 勾 1 行 → 批量栏出现 |
62 | | - const selectAll = screen.getByTitle("全选当前列表的笔记"); |
63 | | - expect((selectAll as HTMLInputElement).checked).toBe(false); |
64 | | - fireEvent.click(selectAll); // 未全选 → 全选 |
| 62 | + it("Ctrl+单击=加/减选(不打开)→ 批量栏浮现;Ctrl 再点=减选", async () => { |
| 63 | + const onSelect = vi.fn(); |
| 64 | + render(<NoteListView {...baseProps({ onSelect })} />); |
| 65 | + fireEvent.click(await screen.findByTestId("note-row-1"), { ctrlKey: true }); |
| 66 | + fireEvent.click(screen.getByTestId("note-row-2"), { ctrlKey: true }); |
| 67 | + expect(onSelect).not.toHaveBeenCalled(); |
65 | 68 | expect(screen.getByText(/已选 2 个/)).toBeTruthy(); |
66 | | - fireEvent.click(screen.getByTitle("全选当前列表的笔记")); // 已全选 → 清空 |
67 | | - expect(screen.queryByText(/已选/)).toBeNull(); |
| 69 | + fireEvent.click(screen.getByTestId("note-row-1"), { ctrlKey: true }); |
| 70 | + expect(screen.getByText(/已选 1 个/)).toBeTruthy(); |
68 | 71 | }); |
69 | 72 |
|
70 | | - it("删除执行成功(resolve=true)后清空勾选", async () => { |
71 | | - const onBatchDelete = vi.fn().mockResolvedValue(true); |
72 | | - renderList({ onBatchDelete }); |
73 | | - fireEvent.click(screen.getAllByRole("checkbox")[0]); |
74 | | - fireEvent.click(screen.getByText("批量删除")); |
75 | | - expect(onBatchDelete).toHaveBeenCalledWith([1]); |
76 | | - await vi.waitFor(() => expect(screen.queryByText(/已选/)).toBeNull()); |
| 73 | + it("Shift+单击=按列表位置区间选(锚=上次 Ctrl 点击行)", async () => { |
| 74 | + render(<NoteListView {...baseProps()} />); |
| 75 | + // 可见序=[未分组:3, 摄影:1,2]——锚 3(Ctrl),Shift 点 2 → 全段 {1,2,3} |
| 76 | + fireEvent.click(await screen.findByTestId("note-row-3"), { ctrlKey: true }); |
| 77 | + fireEvent.click(screen.getByTestId("note-row-2"), { shiftKey: true }); |
| 78 | + expect(screen.getByText(/已选 3 个/)).toBeTruthy(); |
77 | 79 | }); |
78 | 80 |
|
79 | | - it("取消确认(resolve=false)保留勾选", async () => { |
80 | | - const onBatchDelete = vi.fn().mockResolvedValue(false); |
81 | | - renderList({ onBatchDelete }); |
82 | | - fireEvent.click(screen.getAllByRole("checkbox")[0]); |
83 | | - fireEvent.click(screen.getByText("批量删除")); |
84 | | - await vi.waitFor(() => expect(onBatchDelete).toHaveBeenCalled()); |
85 | | - expect(screen.getByText(/已选 1 个/)).toBeTruthy(); |
| 81 | + it("批量选择模式:工具栏「选择」→ 行单击=勾选且不打开;Esc 退出并清空", async () => { |
| 82 | + const onSelect = vi.fn(); |
| 83 | + render(<NoteListView {...baseProps({ onSelect })} />); |
| 84 | + fireEvent.click(await screen.findByTestId("batch-mode-toggle")); |
| 85 | + expect(screen.getByText(/选择模式/)).toBeTruthy(); |
| 86 | + fireEvent.click(screen.getByTestId("note-row-1")); |
| 87 | + expect(onSelect).not.toHaveBeenCalled(); |
| 88 | + expect(screen.getByText(/选择模式(1)/)).toBeTruthy(); |
| 89 | + fireEvent.keyDown(window, { key: "Escape" }); |
| 90 | + await waitFor(() => { |
| 91 | + expect(screen.queryByText(/选择模式/)).toBeNull(); |
| 92 | + expect(screen.queryByText(/已选/)).toBeNull(); |
| 93 | + }); |
86 | 94 | }); |
87 | 95 |
|
88 | | - it("列表数据变化后裁剪不可见勾选(只删可见子集)", async () => { |
89 | | - const { rerender } = renderList(); |
90 | | - fireEvent.click(screen.getAllByRole("checkbox")[0]); |
91 | | - fireEvent.click(screen.getAllByRole("checkbox")[1]); |
92 | | - expect(screen.getByText(/已选 2 个/)).toBeTruthy(); |
93 | | - // 模拟切过滤视图/删除刷新:notes 只剩 1 条 → 勾选裁剪到可见子集 |
94 | | - const onBatchDelete = vi.fn().mockResolvedValue(true); |
95 | | - rerender(<NoteListView notes={[makeNote(1, "笔记一")]} {...baseProps} onBatchDelete={onBatchDelete} />); |
96 | | - expect(screen.getByText(/已选 1 个/)).toBeTruthy(); |
97 | | - fireEvent.click(screen.getByText("批量删除")); |
98 | | - expect(onBatchDelete).toHaveBeenCalledWith([1]); // 被裁剪的不可见笔记不在删除集 |
| 96 | + it("批量删除:按钮 → onBatchDelete(选集);resolve=true 后清空选择", async () => { |
| 97 | + const onBatchDelete = vi.fn(async () => true); |
| 98 | + render(<NoteListView {...baseProps({ onBatchDelete })} />); |
| 99 | + fireEvent.click(await screen.findByTestId("note-row-1"), { ctrlKey: true }); |
| 100 | + fireEvent.click(screen.getByTestId("batch-delete-btn")); |
| 101 | + await waitFor(() => expect(onBatchDelete).toHaveBeenCalledWith([1])); |
| 102 | + await waitFor(() => expect(screen.queryByText(/已选/)).toBeNull()); |
| 103 | + }); |
| 104 | + |
| 105 | + it("批量移动到组:选集含他组笔记 → 组 → move_note_to_group 落库", async () => { |
| 106 | + render(<NoteListView {...baseProps()} />); |
| 107 | + // 选 2(已在摄影)+ 3(未分组)→ 移到摄影只发未分组那条(已在组内跳过) |
| 108 | + fireEvent.click(await screen.findByTestId("note-row-2"), { ctrlKey: true }); |
| 109 | + fireEvent.click(screen.getByTestId("note-row-3"), { ctrlKey: true }); |
| 110 | + fireEvent.click(screen.getByTestId("batch-move-btn")); |
| 111 | + const menu = await screen.findByTestId("batch-context-menu"); |
| 112 | + expect(menu).toBeTruthy(); |
| 113 | + fireEvent.click(screen.getByText("📁 移动到组…")); |
| 114 | + fireEvent.click(await screen.findByText("📁 摄影")); |
| 115 | + await waitFor(() => { |
| 116 | + expect(invokeMock).toHaveBeenCalledWith("move_note_to_group", { noteId: 3, groupId: 1 }); |
| 117 | + expect(invokeMock).not.toHaveBeenCalledWith("move_note_to_group", { noteId: 2, groupId: 1 }); |
| 118 | + }); |
99 | 119 | }); |
100 | 120 | }); |
0 commit comments