Skip to content

Commit 82ca3d8

Browse files
committed
feat(notes): 笔记页三栏布局 + 收件箱动线 + ⓘ 弹层(v0.12.2 Phase 1)
- NoteGroupPanel 三合一职责拆分:GroupSidebar(240px 筛选侧栏,组行单击=仅过滤) + RouteInfoPopover(ⓘ 弹层四区:人话归因+明细默认折叠/改判/组管理/周契约); noteTree 树形合并下线(v0.11.5 树模式被三栏取代) - 收件箱恒常首项(计数):FeedFragmentList 改全量 active 碎片卡——升笔记轻确认 (标题预填首句+归组下拉→onPromoted 右侧自动打开)/升卡/删除二次确认/空态三归宿 - NotesPage 三栏编排 view: notes|inbox 原位切换;新建去 prompt 即编辑聚焦首行落未归组 - 组件测试基建:jsdom + testing-library(vitest include 扩 .tsx,按文件切环境); 新增 10 测(routeReason/inbox 纯函数 + 收件箱状态机 4 + ⓘ 弹层 4),共 67 通过
1 parent 483ad4e commit 82ca3d8

18 files changed

Lines changed: 1975 additions & 927 deletions

‎app/package-lock.json‎

Lines changed: 687 additions & 8 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎app/package.json‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
{
1+
{
22
"name": "app",
33
"private": true,
44
"version": "0.12.1",
@@ -24,9 +24,12 @@
2424
},
2525
"devDependencies": {
2626
"@tauri-apps/cli": "^2",
27+
"@testing-library/dom": "^10.4.1",
28+
"@testing-library/react": "^16.3.2",
2729
"@types/react": "^19.1.8",
2830
"@types/react-dom": "^19.1.6",
2931
"@vitejs/plugin-react": "^4.6.0",
32+
"jsdom": "^30.0.1",
3033
"typescript": "~5.8.3",
3134
"vite": "^7.0.4",
3235
"vitest": "^4.1.11"

‎app/src/components/FeatureFlagSetting.tsx‎

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
/**
2-
* FeatureFlagSetting — 功能预览开关面板(v0.11.1:feed 碎片捕获)。
2+
* FeatureFlagSetting — 功能开关面板(v0.11.1:feed 碎片捕获;v0.12.2 转正)。
33
*
4-
* @ai-context: v4 §11.3 交付层纪律——feed 能力功能开关默认关;本面板是
5-
* 用户唯一的开关入口(后端 capture_fragment 二次校验,不信前端隐藏)。
4+
* @ai-context: v0.11.1 预览纪律(默认关)→ v0.12.2 收件箱动线转正:本开关
5+
* 语义改为「快速记录入口」(默认开);后端 promote/delete 等
6+
* 命令二次校验仍保留(后端不信前端隐藏——switch 减面不减安全)。
67
* 开关状态经 get_feature_flags/set_feature_flag 读写(JSON 持久化)。
78
*/
89
import { useCallback, useEffect, useState } from "react";
@@ -45,19 +46,19 @@ export default function FeatureFlagSetting() {
4546
<label style={{ display: "flex", alignItems: "center", gap: 6, fontSize: 13, cursor: "pointer" }}>
4647
<input
4748
type="checkbox"
48-
checked={flags?.feedCapture ?? false}
49+
checked={flags?.feedCapture ?? true}
4950
disabled={!flags}
5051
onChange={() => void toggleFeed()}
5152
/>
52-
碎片快速捕获(feed 进料口)
53+
快速记录(收件箱碎片捕获入口)
5354
</label>
5455
<span style={{ fontSize: 11, color: "#9ca3af" }}>
55-
{flags?.feedCapture ? "已启用" : "默认关闭"}
56+
{flags?.feedCapture ? "已启用" : "已关闭"}
5657
</span>
5758
</div>
5859
<p style={{ fontSize: 12, color: "#9ca3af", margin: "6px 0 0" }}>
59-
启用后笔记页组侧栏出现碎片捕获框:几句话 + 可选截图,按领域自动归入主题组。
60-
碎片不是笔记——独立原料层,供组级复习与结算消费(v4 契约)。
60+
笔记页侧栏的「⚡ 快速记录」入口与收件箱的升笔记/升卡/删除操作。
61+
碎片不是笔记——独立原料层,升为笔记沉淀或升为闪卡复习(v0.12.2 收件箱动线)。
6162
</p>
6263
{status && <p style={{ fontSize: 12, color: "#dc2626", margin: "6px 0 0" }}>{status}</p>}
6364
</div>
Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,163 @@
1+
// @vitest-environment jsdom
2+
/**
3+
* FeedFragmentList.test.tsx — 收件箱状态机组件测试(v0.12.2 验收标准 2)。
4+
*
5+
* @ai-context: 状态机覆盖 捕获→升笔记→移除(升卡幂等语义 + 删除二次确认 +
6+
* 空态引导)——invoke 全 mock(不触碰真实后端),断言命令参数
7+
* 契约(fragmentId/title/groupId)与回调时序。
8+
*/
9+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
10+
import { cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
11+
import type { Fragment, Note, NoteGroup } from "../types";
12+
13+
const { invokeMock } = vi.hoisted(() => ({ invokeMock: vi.fn() }));
14+
vi.mock("@tauri-apps/api/core", () => ({
15+
invoke: invokeMock,
16+
convertFileSrc: (p: string) => `asset://${p}`,
17+
}));
18+
19+
import FeedFragmentList from "./FeedFragmentList";
20+
21+
/** 最小 Fragment 桩 */
22+
function frag(id: number, text: string, groupId: number | null = null): Fragment {
23+
return {
24+
id, text, imagePath: null, domainTag: null, groupId,
25+
source: "manual", status: "active", createdAt: 1000,
26+
};
27+
}
28+
29+
const containerGroup: NoteGroup = {
30+
id: 9, name: "化妆美妆", terrain: "container", kind: "topic", domainTag: "beauty",
31+
source: "route", seriesKey: null, routeReason: null, routeOverridden: 0,
32+
noteCount: 0, createdAt: 0, updatedAt: 0,
33+
};
34+
35+
const noteStub: Note = {
36+
id: 42, title: "晕染笔记", content: "眼影要晕染。第二步定妆。", source: "manual",
37+
tags: "[]", pin: 0, group_id: 9, created_at: 2000, updated_at: 2000,
38+
};
39+
40+
let dbFragments: Fragment[];
41+
42+
beforeEach(() => {
43+
dbFragments = [frag(1, "眼影要晕染。第二步定妆。", 9), frag(2, "单句灵感", null)];
44+
invokeMock.mockReset();
45+
invokeMock.mockImplementation(async (cmd: string, args: Record<string, unknown> = {}) => {
46+
switch (cmd) {
47+
case "list_fragments":
48+
return dbFragments;
49+
case "list_note_groups":
50+
return [containerGroup];
51+
case "promote_fragment_to_note":
52+
dbFragments = dbFragments.filter((f) => f.id !== args.fragmentId);
53+
return noteStub;
54+
case "promote_fragment_to_card":
55+
return 1;
56+
case "delete_fragment":
57+
dbFragments = dbFragments.filter((f) => f.id !== args.fragmentId);
58+
return true;
59+
case "resolve_fragment_image":
60+
return null;
61+
default:
62+
throw new Error(`unexpected command: ${cmd}`);
63+
}
64+
});
65+
});
66+
67+
afterEach(() => cleanup());
68+
69+
describe("FeedFragmentList 收件箱状态机", () => {
70+
it("捕获展示:碎片卡渲染 + 空态引导三种归宿", async () => {
71+
const { unmount } = render(<FeedFragmentList onChanged={vi.fn()} onPromoted={vi.fn()} />);
72+
// Arrange/Act:两条 fragments
73+
expect(await screen.findByTestId("fragment-card-1")).toBeTruthy();
74+
expect(screen.getByTestId("fragment-card-2")).toBeTruthy();
75+
unmount();
76+
// Arrange:空收件箱
77+
dbFragments = [];
78+
render(<FeedFragmentList onChanged={vi.fn()} onPromoted={vi.fn()} />);
79+
// Assert:空态文案(三种归宿引导)
80+
expect((await screen.findByTestId("inbox-empty")).textContent).toContain("升为笔记沉淀它");
81+
});
82+
83+
it("升笔记:表单预填首句 → 确认 → 碎片移除 + 父层打开新笔记", async () => {
84+
const onPromoted = vi.fn();
85+
const onChanged = vi.fn();
86+
render(<FeedFragmentList onChanged={onChanged} onPromoted={onPromoted} />);
87+
await screen.findByTestId("fragment-card-1");
88+
89+
// 打开轻确认表单:标题预填首句(可改),归组默认未归组
90+
fireEvent.click(screen.getByTestId("promote-note-1"));
91+
const titleInput = (await screen.findByTestId("promote-title")) as HTMLInputElement;
92+
expect(titleInput.value).toBe("眼影要晕染");
93+
const groupSelect = screen.getByTestId("promote-group") as HTMLSelectElement;
94+
expect(groupSelect.value).toBe("");
95+
96+
// 改标题 + 选组 → 确认
97+
fireEvent.change(titleInput, { target: { value: "晕染笔记" } });
98+
fireEvent.change(groupSelect, { target: { value: "9" } });
99+
fireEvent.click(screen.getByTestId("promote-confirm"));
100+
101+
// 命令契约:事务建笔记+删碎片(fragmentId/title/groupId)
102+
await waitFor(() => {
103+
expect(invokeMock).toHaveBeenCalledWith("promote_fragment_to_note", {
104+
fragmentId: 1, title: "晕染笔记", groupId: 9,
105+
});
106+
});
107+
// 碎片从收件箱移除(列表已重载)
108+
await waitFor(() => {
109+
expect(screen.queryByTestId("fragment-card-1")).toBeNull();
110+
});
111+
// 回调:父层右侧自动打开新笔记 + 刷新侧栏计数
112+
expect(onPromoted).toHaveBeenCalledWith(noteStub);
113+
expect(onChanged).toHaveBeenCalled();
114+
});
115+
116+
it("升闪卡:成功后刷新;已升级(返回 0)不打扰——幂等可重复触发", async () => {
117+
const onChanged = vi.fn();
118+
render(<FeedFragmentList onChanged={onChanged} onPromoted={vi.fn()} />);
119+
await screen.findByTestId("fragment-card-2");
120+
121+
// 首次:升卡成功(新卡 1 张)→ 刷新
122+
fireEvent.click(screen.getByTestId("promote-card-2"));
123+
await waitFor(() => {
124+
expect(invokeMock).toHaveBeenCalledWith("promote_fragment_to_card", { fragmentId: 2 });
125+
});
126+
await waitFor(() => expect(onChanged).toHaveBeenCalled());
127+
128+
// 幂等:再触发(backend 返回 0——已升级过)→ 不报错不刷新
129+
onChanged.mockClear();
130+
invokeMock.mockImplementation(async (cmd: string) => {
131+
if (cmd === "promote_fragment_to_card") return 0;
132+
if (cmd === "list_fragments") return dbFragments;
133+
if (cmd === "list_note_groups") return [containerGroup];
134+
if (cmd === "resolve_fragment_image") return null;
135+
throw new Error(`unexpected: ${cmd}`);
136+
});
137+
fireEvent.click(screen.getByTestId("promote-card-2"));
138+
await new Promise((r) => setTimeout(r, 0));
139+
expect(onChanged).not.toHaveBeenCalled();
140+
});
141+
142+
it("删除:二次确认后移除(取消则保留)", async () => {
143+
const confirmSpy = vi.spyOn(window, "confirm").mockReturnValue(true);
144+
render(<FeedFragmentList onChanged={vi.fn()} onPromoted={vi.fn()} />);
145+
await screen.findByTestId("fragment-card-2");
146+
147+
const card2 = screen.getByTestId("fragment-card-2");
148+
fireEvent.click(within(card2).getByText("🗑 删除"));
149+
await waitFor(() => {
150+
expect(invokeMock).toHaveBeenCalledWith("delete_fragment", { fragmentId: 2 });
151+
});
152+
await waitFor(() => expect(screen.queryByTestId("fragment-card-2")).toBeNull());
153+
154+
// 取消:确认框拒绝 → 不触发删除
155+
confirmSpy.mockReturnValue(false);
156+
invokeMock.mockClear();
157+
const card1 = screen.getByTestId("fragment-card-1");
158+
fireEvent.click(within(card1).getByText("🗑 删除"));
159+
await new Promise((r) => setTimeout(r, 0));
160+
expect(invokeMock).not.toHaveBeenCalledWith("delete_fragment", { fragmentId: 1 });
161+
confirmSpy.mockRestore();
162+
});
163+
});

0 commit comments

Comments
 (0)