Skip to content

Commit 5b61400

Browse files
committed
feat(notes): REQ-315 笔记置顶树视图生效与右键上移/下移(排序纯函数+措辞统一置顶)
1 parent 2c20d43 commit 5b61400

15 files changed

Lines changed: 688 additions & 52 deletions

‎app/src/components/NoteListView.test.tsx‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ beforeEach(() => {
4141
invokeMock.mockImplementation(async (cmd: string) => {
4242
switch (cmd) {
4343
case "note_order_list": return [];
44+
case "note_group_order_list": return []; // REQ-315:组序行(树组头排序)
4445
case "note_order_save": return null;
4546
case "note_order_clear": return true;
4647
case "move_note_to_group": return true;

‎app/src/components/NoteListView.tree.test.tsx‎

Lines changed: 115 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,23 +5,41 @@
55
* @ai-context: 组头渲染(组名下挂笔记)/chevron 收起展开(stopPropagation 不触发
66
* 过滤)/组名点击=过滤切换(决策 1 语义)/搜索激活退化平铺/
77
* 空组不渲染/未分组区收纳 group_id=null 笔记。
8+
* @ai-context: REQ-315(批 6)追加——树视图 scope 内 置顶→手排→自动 排序生效
9+
* (gap③)、组头排序与组侧栏同规则、右键上移/下移显式移动
10+
* (自动组首移=快照转手排)。
811
*/
912
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
1013
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
1114
import type { Note, NoteGroup } from "../types";
1215
import NoteListView from "./NoteListView";
1316

17+
const { invokeMock } = vi.hoisted(() => ({ invokeMock: vi.fn() }));
18+
vi.mock("@tauri-apps/api/core", () => ({ invoke: invokeMock }));
19+
1420
// 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+
});
1634

17-
function makeNote(id: number, title: string, groupId?: number | null): Note {
35+
function makeNote(id: number, title: string, groupId?: number | null, pin = 0): Note {
1836
return {
1937
id,
2038
title,
2139
content: "",
2240
source: "manual",
2341
tags: "[]",
24-
pin: 0,
42+
pin,
2543
group_id: groupId,
2644
created_at: 0,
2745
updated_at: 0,
@@ -55,8 +73,19 @@ const baseProps = {
5573
onOpenSession: vi.fn(),
5674
onBatchDelete: vi.fn().mockResolvedValue(true),
5775
onGroupFilterChange: vi.fn(),
76+
onNotePinToggle: vi.fn(),
77+
onNoteEdit: vi.fn(),
78+
onNoteDelete: vi.fn(),
79+
onNoteMoved: vi.fn(),
5880
};
5981

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+
6089
afterEach(() => cleanup());
6190

6291
describe("NoteListView 分组树(v0.15)", () => {
@@ -109,3 +138,86 @@ describe("NoteListView 分组树(v0.15)", () => {
109138
expect(screen.getByText("拍了")).toBeTruthy();
110139
});
111140
});
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

Comments
 (0)