|
5 | 5 | * @ai-context: 组头渲染(组名下挂笔记)/chevron 收起展开(stopPropagation 不触发 |
6 | 6 | * 过滤)/组名点击=过滤切换(决策 1 语义)/搜索激活退化平铺/ |
7 | 7 | * 空组不渲染/未分组区收纳 group_id=null 笔记。 |
| 8 | + * @ai-context: REQ-315(批 6)追加——树视图 scope 内 置顶→手排→自动 排序生效 |
| 9 | + * (gap③)、组头排序与组侧栏同规则、右键上移/下移显式移动 |
| 10 | + * (自动组首移=快照转手排)。 |
8 | 11 | */ |
9 | 12 | import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; |
10 | 13 | import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; |
11 | 14 | import type { Note, NoteGroup } from "../types"; |
12 | 15 | import NoteListView from "./NoteListView"; |
13 | 16 |
|
| 17 | +const { invokeMock } = vi.hoisted(() => ({ invokeMock: vi.fn() })); |
| 18 | +vi.mock("@tauri-apps/api/core", () => ({ invoke: invokeMock })); |
| 19 | + |
14 | 20 | // REQ-287:NoteListView 现读写 localStorage 组折叠——每测清空防串扰 |
15 | | -beforeEach(() => { try { window.localStorage.clear(); } catch { /* jsdom 守卫 */ } }); |
| 21 | +beforeEach(() => { |
| 22 | + try { window.localStorage.clear(); } catch { /* jsdom 守卫 */ } |
| 23 | + invokeMock.mockReset(); |
| 24 | + invokeMock.mockImplementation(async (cmd: string) => { |
| 25 | + switch (cmd) { |
| 26 | + case "note_order_list": return []; |
| 27 | + case "note_group_order_list": return []; |
| 28 | + case "note_order_save": return null; |
| 29 | + case "note_order_clear": return true; |
| 30 | + default: return null; |
| 31 | + } |
| 32 | + }); |
| 33 | +}); |
16 | 34 |
|
17 | | -function makeNote(id: number, title: string, groupId?: number | null): Note { |
| 35 | +function makeNote(id: number, title: string, groupId?: number | null, pin = 0): Note { |
18 | 36 | return { |
19 | 37 | id, |
20 | 38 | title, |
21 | 39 | content: "", |
22 | 40 | source: "manual", |
23 | 41 | tags: "[]", |
24 | | - pin: 0, |
| 42 | + pin, |
25 | 43 | group_id: groupId, |
26 | 44 | created_at: 0, |
27 | 45 | updated_at: 0, |
@@ -55,8 +73,19 @@ const baseProps = { |
55 | 73 | onOpenSession: vi.fn(), |
56 | 74 | onBatchDelete: vi.fn().mockResolvedValue(true), |
57 | 75 | onGroupFilterChange: vi.fn(), |
| 76 | + onNotePinToggle: vi.fn(), |
| 77 | + onNoteEdit: vi.fn(), |
| 78 | + onNoteDelete: vi.fn(), |
| 79 | + onNoteMoved: vi.fn(), |
58 | 80 | }; |
59 | 81 |
|
| 82 | +/** 树组头下渲染行的可见顺序(data-testid note-row-{id} 序列) */ |
| 83 | +function rowOrderIn(bodyTestId: string): string[] { |
| 84 | + const body = screen.getByTestId(bodyTestId); |
| 85 | + return Array.from(body.querySelectorAll('[data-testid^="note-row-"]')) |
| 86 | + .map((el) => el.getAttribute("data-testid") ?? ""); |
| 87 | +} |
| 88 | + |
60 | 89 | afterEach(() => cleanup()); |
61 | 90 |
|
62 | 91 | describe("NoteListView 分组树(v0.15)", () => { |
@@ -109,3 +138,86 @@ describe("NoteListView 分组树(v0.15)", () => { |
109 | 138 | expect(screen.getByText("拍了")).toBeTruthy(); |
110 | 139 | }); |
111 | 140 | }); |
| 141 | + |
| 142 | +describe("NoteListView REQ-315 树视图排序/显式移动", () => { |
| 143 | + it("组内展示序:置顶区(更新时间降序)在前,其余保持更新时间序(pin 树视图生效)", async () => { |
| 144 | + const notes = [ |
| 145 | + makeNote(1, "普通-旧", 1, 0), |
| 146 | + makeNote(2, "置顶-旧", 1, 1), |
| 147 | + makeNote(3, "置顶-新", 1, 1), |
| 148 | + makeNote(4, "普通-新", 1, 0), |
| 149 | + ]; |
| 150 | + // updated_at:置顶-旧=5 > 置顶-新=2;普通-新=9 > 普通-旧=1 |
| 151 | + const stamped = notes.map((n, i) => ({ ...n, updated_at: [1, 5, 2, 9][i] })); |
| 152 | + render(<NoteListView notes={stamped} groups={groups} groupFilter={1} {...baseProps} />); |
| 153 | + await screen.findByTestId("note-tree-摄影"); |
| 154 | + expect(rowOrderIn("tree-body-摄影")).toEqual(["note-row-2", "note-row-3", "note-row-4", "note-row-1"]); |
| 155 | + }); |
| 156 | + |
| 157 | + it("手排 scope:置顶区在前,其余按 note_orders seq(load 序)", async () => { |
| 158 | + invokeMock.mockImplementation(async (cmd: string) => { |
| 159 | + if (cmd === "note_order_list") return [["g:1", 2], ["g:1", 1]]; // 行序 [2,1] |
| 160 | + if (cmd === "note_group_order_list") return []; |
| 161 | + return null; |
| 162 | + }); |
| 163 | + const notes = [makeNote(1, "手排一", 1), makeNote(2, "手排二", 1), makeNote(3, "置顶", 1, 1)]; |
| 164 | + render(<NoteListView notes={notes} groups={groups} groupFilter={1} {...baseProps} />); |
| 165 | + await screen.findByTestId("note-tree-摄影"); |
| 166 | + // 行序 [2,1] → 渲染 3(置顶) → 2 → 1 |
| 167 | + expect(rowOrderIn("tree-body-摄影")).toEqual(["note-row-3", "note-row-2", "note-row-1"]); |
| 168 | + }); |
| 169 | + |
| 170 | + it("右键上移/下移:自动组内首移 = 以可见序快照转手排(save 整表覆写)", async () => { |
| 171 | + const notes = [makeNote(1, "甲", 1), makeNote(2, "乙", 1), makeNote(3, "丙", 1)]; |
| 172 | + render(<NoteListView notes={notes} groups={groups} groupFilter={1} {...baseProps} />); |
| 173 | + await screen.findByTestId("note-tree-摄影"); |
| 174 | + // 自动区可见序=输入序 [1,2,3](桶内稳定)——「乙」(2) 非边界行 |
| 175 | + fireEvent.contextMenu(screen.getByTestId("note-row-2"), { clientX: 30, clientY: 40 }); |
| 176 | + expect(screen.getByTestId("ctx-move-up")).toBeTruthy(); |
| 177 | + expect(screen.getByTestId("ctx-move-down")).toBeTruthy(); |
| 178 | + expect(screen.getByTestId("ctx-pin").textContent).toContain("置顶"); |
| 179 | + // 下移 = 换位后整表覆写(自动组 → 快照转手排) |
| 180 | + fireEvent.click(screen.getByTestId("ctx-move-down")); |
| 181 | + await waitFor(() => { |
| 182 | + expect(invokeMock).toHaveBeenCalledWith("note_order_save", { scope: "g:1", noteIds: [1, 3, 2] }); |
| 183 | + }); |
| 184 | + }); |
| 185 | + |
| 186 | + it("右键置顶笔记:菜单为「取消置顶」,移动禁用且不落库", async () => { |
| 187 | + const notes = [makeNote(1, "置顶的", 1, 1), makeNote(2, "普通的", 1)]; |
| 188 | + render(<NoteListView notes={notes} groups={groups} groupFilter={1} {...baseProps} />); |
| 189 | + await screen.findByTestId("note-tree-摄影"); |
| 190 | + fireEvent.contextMenu(screen.getByTestId("note-row-1"), { clientX: 30, clientY: 40 }); |
| 191 | + expect(screen.getByTestId("ctx-pin").textContent).toContain("取消置顶"); |
| 192 | + // 置顶区按更新时间定序——上移/下移禁用(title 提示语义) |
| 193 | + const up = screen.getByTestId("ctx-move-up") as HTMLButtonElement; |
| 194 | + const down = screen.getByTestId("ctx-move-down") as HTMLButtonElement; |
| 195 | + expect(up.disabled).toBe(true); |
| 196 | + expect(down.disabled).toBe(true); |
| 197 | + fireEvent.click(up); |
| 198 | + expect(invokeMock).not.toHaveBeenCalledWith("note_order_save", expect.anything()); |
| 199 | + }); |
| 200 | + |
| 201 | + it("组头排序与侧栏同规则:置顶组段列最前(跨 kind 单遍规则)", async () => { |
| 202 | + const extra: NoteGroup[] = [ |
| 203 | + ...groups, |
| 204 | + { ...groups[0], id: 3, name: "化妆", pin: 1, updatedAt: 1 }, |
| 205 | + ]; |
| 206 | + const notes = [makeNote(1, "摄影笔记", 1), makeNote(3, "化妆笔记", 3)]; |
| 207 | + render(<NoteListView notes={notes} groups={extra} groupFilter={null} {...baseProps} />); |
| 208 | + await screen.findByTestId("note-tree-摄影"); |
| 209 | + // 置顶的「化妆」(id3) 段在「摄影」(id1) 段之前 |
| 210 | + const headers = Array.from(screen.getByTestId("note-tree-化妆").parentElement?.querySelectorAll('[data-testid^="note-tree-"]') ?? []); |
| 211 | + const idx = (name: string) => headers.findIndex((h) => h.getAttribute("data-testid") === `note-tree-${name}`); |
| 212 | + expect(idx("化妆")).toBeLessThan(idx("摄影")); |
| 213 | + }); |
| 214 | + |
| 215 | + it("平铺(搜索)态右键:不出现上移/下移(交互矩阵禁移动)", async () => { |
| 216 | + const notes = [makeNote(1, "拍了", 1), makeNote(2, "写了", 1)]; |
| 217 | + render(<NoteListView notes={notes} groups={groups} groupFilter={null} {...baseProps} keyword="拍" />); |
| 218 | + fireEvent.contextMenu(await screen.findByTestId("note-row-1"), { clientX: 30, clientY: 40 }); |
| 219 | + expect(screen.getByTestId("note-row-menu")).toBeTruthy(); |
| 220 | + expect(screen.queryByTestId("ctx-move-up")).toBeNull(); |
| 221 | + expect(screen.queryByTestId("ctx-move-down")).toBeNull(); |
| 222 | + }); |
| 223 | +}); |
0 commit comments