Skip to content

Commit 5d59e33

Browse files
committed
fix(components): 碎片归组候选改用 feed 地形组
控制方裁决(未决项①成立,派单缺陷在控制方):前一条把 `containerGroups` 当候选集 ⇒ 移组后碎片会被塞进「笔记容器组」,而碎片的自动归组走的是 feed ⇒ 静默的语义错位。 Rust 侧决定性证据(本单元独立复核,逐行读过): - `commands_groups.rs:36` 逐字「列出笔记组(含组内笔记数;terrain 可选过滤 container/feed)」; `:43-46` 白名单校验 `if t != "container" && t != "feed" { return Err(...) }` ⇒ feed 是一等地形。 - `commands_fragments.rs:344-366` `resolve_feed_topic_group`:碎片自动归组建的就是 feed 组 (`:355 terrain: "feed".to_string()`、`:349 find_topic_group(domain_tag, "feed")`、 `:361` 理由逐字「碎片 DomainTag 自动归组」)。 - 反向对照:`FeedFragmentList.tsx:109` 的「目标=笔记容器组」是 `promote_fragment_to_note` (升为笔记)的去向语义 —— 另一件事,不能当本命令的先例(该注释**未改动**)。 返工内容: 1. feed 清单加载搬进 `FragmentGroupAction.tsx` 自己: `invoke<NoteGroup[]>("list_note_groups", { terrain: "feed" })`(后端真源过滤) + 客户端再收一次口径 `list.filter((g) => g.terrain === "feed")`(防口径漂移混入容器组)。 2. 父层去掉 `groups` / `onNeedGroups` 两个 prop ⇒ `FeedFragmentList.tsx` **300 → 299**(净变化 −1)。 3. 头注里「本件不自建第二套加载器…两套加载器就是两套口径」那段改写:该理由只在**同一 terrain** 上成立;这里是**两个不同 terrain 的两个不同查询**(container=笔记去向 / feed=碎片归组), 不构成「两套口径」。改写后逐字写出上面三条 Rust 证据(含行号)。 4. 清单加载失败也走父层 `setErr`(可见行,不静默);`groups` 留 null ⇒ 可再点一次重试。 5. 「当前组」标签:组名查不到时**如实报 id**(`当前组 #N`),不许把「碎片还在容器组里」谎报成 「当前未归组」。 判据(新增 V7–V9): - `FeedFragmentList.test.tsx` 9 用例(件数不变,断言加强): V7 正控 = feed 组 11 出现在候选 + 查询载荷逐字 `{ terrain: "feed" }`; 🔴 V7 负控 = fixture 的 `terrain: "container"` 组(id 9)**不得出现**在候选里 (桩在 feed 查询上**故意多回一个容器组**,把客户端第二次收口的牙摆出来); 另有「当前组 #9 / 当前未归组」两条如实性断言。 - `countLines()`:`FeedFragmentList.tsx` **299** · `FragmentGroupAction.tsx` **155** · `FeedFragmentList.test.tsx` **299** ⇒ 全部 ≤300。 - 冻结键持平:宿主 nativeButton 6、边框 5、越界圆角 0、阴影 0、弱化灰 1、字号越界 8、三红 1、 `<Surface>` 1;新件六棘轮全 0、原生 button 0。 门禁(串行):line-limits --full exit 0(0 / 121 / 121)· docs-check exit 0(扫描 281 / 检查 181 + ✅ docs-check 通过)· check-command-registry exit 0(定义 311 / 注册 311 / 重复 0)· tsc --noEmit exit 0 · 守卫族 12 件 122 用例全绿 · 落点两件 29/29 绿。
1 parent 5f66281 commit 5d59e33

3 files changed

Lines changed: 75 additions & 44 deletions

File tree

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

Lines changed: 27 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,9 @@ const containerGroup: NoteGroup = {
3232
noteCount: 0, createdAt: 0, updatedAt: 0,
3333
};
3434

35+
/** 碎片的家:feed 地形组(V7 正控;id 11 与容器组 9 并列,绝不共号) */
36+
const feedGroup: NoteGroup = { ...containerGroup, id: 11, name: "美妆碎片", terrain: "feed" };
37+
3538
const noteStub: Note = {
3639
id: 42, title: "晕染笔记", content: "眼影要晕染。第二步定妆。", source: "manual",
3740
tags: "[]", pin: 0, group_id: 9, created_at: 2000, updated_at: 2000,
@@ -47,7 +50,8 @@ beforeEach(() => {
4750
case "list_fragments":
4851
return dbFragments;
4952
case "list_note_groups":
50-
return [containerGroup];
53+
// 桩按 terrain 分派(promote 要容器组 / 碎片归组要 feed 组);🔴 feed 查询**故意多回一个容器组** = V7 负控
54+
return args.terrain === "feed" ? [feedGroup, containerGroup] : [containerGroup];
5155
case "promote_fragment_to_note":
5256
dbFragments = dbFragments.filter((f) => f.id !== args.fragmentId);
5357
return { note: noteStub, autoCleanedGroups: [] };
@@ -206,32 +210,29 @@ describe("FeedFragmentList 收件箱状态机", () => {
206210
});
207211
});
208212

209-
/**
210-
* 批 7 C11(规格 §9 第 46 条 `update_fragment_group`):片段行的归组入口。
211-
*
212-
* @ai-context 与 T20-D 的分工:`batch7UiWiring.test.tsx` 第 ⑦ 条管「生产侧恰有调用点」,
213-
* 本组管「点得到 + 载荷逐字 + 正/负控 + 失败不静默」。载荷键名按 Rust 真身走 camelCase
214-
* (`fragment_id` ⇒ `fragmentId`;计划 C11 那格写的 `id` 是勘误)。
215-
*/
213+
/** 批 7 C11:片段行归组入口。与 `batch7UiWiring.test.tsx` 第 ⑦ 条分工:那条管「生产侧有调用点」,
214+
* 本组管「点得到 + 载荷逐字 + 正/负控 + 失败不静默 + 候选只含 feed 组(V7)」。
215+
* 载荷键名按 Rust 真身 camelCase:`fragment_id` ⇒ `fragmentId`(计划 C11 那格写的 `id` 是勘误)。 */
216216
describe("批 7 C11 · 片段归组入口(update_fragment_group)", () => {
217-
it("入口是常规点击可达的真按钮;选中组 ⇒ 载荷逐字(负控:展开不发 · 另一行的 id 不出现)", async () => {
217+
it("入口是真按钮 · 候选只含 feed 组(负控:容器组 9 不出现)⇒ 选组载荷逐字", async () => {
218218
const onChanged = vi.fn();
219219
render(<FeedFragmentList onChanged={onChanged} onPromoted={vi.fn()} />);
220220
await screen.findByTestId("fragment-card-2");
221-
222-
// 正控①:入口是 Button 原语(真 <button> ⇒ 鼠标与键盘都到得了)
221+
// V7:查询按 feed 地形走(后端真源)+ 客户端再收一次口径 ⇒ 容器组 9 必须在候选之外
223222
const entry = screen.getByTestId("fragment-move-group-2");
224223
expect(entry.tagName, "归组入口必须是可点击的真按钮").toBe("BUTTON");
225224
fireEvent.click(entry);
226-
const option = await screen.findByTestId("fragment-move-group-2-9");
225+
const option = await screen.findByTestId("fragment-move-group-2-11");
226+
expect(invokeMock).toHaveBeenCalledWith("list_note_groups", { terrain: "feed" });
227+
expect(screen.queryByTestId("fragment-move-group-2-9"), "容器组混进了碎片候选(V7 负控)").toBeNull();
227228
// 负控①:只展开清单不发命令
228229
expect(invokeMock.mock.calls.map((c) => c[0]), "展开清单本身不得发命令").not.toContain("update_fragment_group");
229230

230-
// 正控②:选中组 9 ⇒ 载荷逐字(fragmentId = Rust 侧 fragment_id 的 camelCase)
231+
// 正控:选中 feed 组 11 ⇒ 载荷逐字(fragmentId = Rust 侧 fragment_id 的 camelCase)
231232
fireEvent.click(option);
232-
await waitFor(() => expect(invokeMock).toHaveBeenCalledWith("update_fragment_group", { fragmentId: 2, groupId: 9 }));
233+
await waitFor(() => expect(invokeMock).toHaveBeenCalledWith("update_fragment_group", { fragmentId: 2, groupId: 11 }));
233234
// 负控②:载荷里的 fragmentId 只能是点的那一行
234-
expect(invokeMock).not.toHaveBeenCalledWith("update_fragment_group", { fragmentId: 1, groupId: 9 });
235+
expect(invokeMock).not.toHaveBeenCalledWith("update_fragment_group", { fragmentId: 1, groupId: 11 });
235236
await waitFor(() => expect(onChanged).toHaveBeenCalled());
236237
});
237238

@@ -240,11 +241,13 @@ describe("批 7 C11 · 片段归组入口(update_fragment_group)", () => {
240241
await screen.findByTestId("fragment-card-1");
241242
fireEvent.click(screen.getByTestId("fragment-move-group-1"));
242243
const none = await screen.findByTestId("fragment-move-group-1-none");
243-
244-
// 负控:未归组的行(fragment 2)不出现「移出组」
244+
// 组名查不到时如实报 id(fragment 1 还在容器组 9 里)—— 不许谎报「未归组」
245+
expect(screen.getByTestId("fragment-move-group-1-current").textContent).toBe("当前组 #9");
246+
// 负控:未归组的行(fragment 2)不出现「移出组」,且如实报「当前未归组」
245247
fireEvent.click(screen.getByTestId("fragment-move-group-2"));
246-
await screen.findByTestId("fragment-move-group-2-9");
248+
await screen.findByTestId("fragment-move-group-2-11");
247249
expect(screen.queryByTestId("fragment-move-group-2-none"), "未归组的行不该有「移出组」").toBeNull();
250+
expect(screen.getByTestId("fragment-move-group-2-current").textContent).toBe("当前未归组");
248251

249252
// 正控:移出组 ⇒ groupId: null(Rust Option<i64> 的 None 语义)
250253
fireEvent.click(none);
@@ -254,23 +257,23 @@ describe("批 7 C11 · 片段归组入口(update_fragment_group)", () => {
254257
it("失败路径不静默:命令 reject ⇒ 收件箱错误行可见,且带后端原因(父层既有 setErr 形态)", async () => {
255258
invokeMock.mockImplementation(async (cmd: string) => {
256259
if (cmd === "list_fragments") return dbFragments;
257-
if (cmd === "list_note_groups") return [containerGroup];
260+
if (cmd === "list_note_groups") return [feedGroup, containerGroup];
258261
if (cmd === "resolve_fragment_image") return null;
259262
if (cmd === "update_fragment_group") throw new Error("feed 开关未开启");
260263
throw new Error(`unexpected command: ${cmd}`);
261264
});
262265
render(<FeedFragmentList onChanged={vi.fn()} onPromoted={vi.fn()} />);
263266
await screen.findByTestId("fragment-card-2");
264267
fireEvent.click(screen.getByTestId("fragment-move-group-2"));
265-
fireEvent.click(await screen.findByTestId("fragment-move-group-2-9"));
268+
fireEvent.click(await screen.findByTestId("fragment-move-group-2-11"));
266269
await waitFor(() => expect(screen.getByTestId("inbox-error").textContent).toContain("移动到组失败"));
267270
expect(screen.getByTestId("inbox-error").textContent, "后端原因必须落到可见行").toContain("feed 开关未开启");
268271
});
269272

270273
it("REQ-316:源组被自动清理 ⇒ 上抛组标题;无清理(空数组)⇒ 不打扰", async () => {
271274
const stub = (cleaned: string[]) => async (cmd: string): Promise<unknown> => {
272275
if (cmd === "list_fragments") return dbFragments;
273-
if (cmd === "list_note_groups") return [containerGroup];
276+
if (cmd === "list_note_groups") return [feedGroup, containerGroup];
274277
if (cmd === "resolve_fragment_image") return null;
275278
if (cmd === "update_fragment_group") return { moved: true, autoCleanedGroups: cleaned };
276279
throw new Error(`unexpected command: ${cmd}`);
@@ -280,18 +283,17 @@ describe("批 7 C11 · 片段归组入口(update_fragment_group)", () => {
280283
const { unmount } = render(<FeedFragmentList onChanged={vi.fn()} onPromoted={vi.fn()} onCleanNotice={onCleanNotice} />);
281284
await screen.findByTestId("fragment-card-2");
282285
fireEvent.click(screen.getByTestId("fragment-move-group-2"));
283-
fireEvent.click(await screen.findByTestId("fragment-move-group-2-9"));
286+
fireEvent.click(await screen.findByTestId("fragment-move-group-2-11"));
284287
await waitFor(() => expect(onCleanNotice).toHaveBeenCalledWith(["旧主题组"]));
285288
unmount();
286-
287289
// 负控:结果为空 ⇒ 零变化,不上抛(与 runPromote / runDelete 同向)
288290
const quiet = vi.fn();
289291
invokeMock.mockImplementation(stub([]));
290292
render(<FeedFragmentList onChanged={vi.fn()} onPromoted={vi.fn()} onCleanNotice={quiet} />);
291293
await screen.findByTestId("fragment-card-2");
292294
fireEvent.click(screen.getByTestId("fragment-move-group-2"));
293-
fireEvent.click(await screen.findByTestId("fragment-move-group-2-9"));
294-
await waitFor(() => expect(invokeMock).toHaveBeenCalledWith("update_fragment_group", { fragmentId: 2, groupId: 9 }));
295+
fireEvent.click(await screen.findByTestId("fragment-move-group-2-11"));
296+
await waitFor(() => expect(invokeMock).toHaveBeenCalledWith("update_fragment_group", { fragmentId: 2, groupId: 11 }));
295297
expect(quiet, "无清理 ⇒ 不上抛").not.toHaveBeenCalled();
296298
});
297299
});

‎app/src/components/FeedFragmentList.tsx‎

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -233,8 +233,7 @@ export default function FeedFragmentList({ width = 320, onChanged, onPromoted, o
233233
>
234234
🗑 删除
235235
</button>
236-
<FragmentGroupAction fragmentId={f.id} groupId={f.groupId} groups={containerGroups} onNeedGroups={loadGroupsIfNeeded}
237-
disabled={busy} onMoved={() => { void load().then(onChanged); }} onError={setErr} onCleanNotice={onCleanNotice} />
236+
<FragmentGroupAction fragmentId={f.id} groupId={f.groupId} disabled={busy} onMoved={() => { void load().then(onChanged); }} onError={setErr} onCleanNotice={onCleanNotice} />
238237
<span style={{ fontSize: 9, color: "#d1d5db", marginLeft: "auto" }}>
239238
{new Date(f.createdAt * 1000).toLocaleDateString()}
240239
</span>

‎app/src/components/FragmentGroupAction.tsx‎

Lines changed: 47 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -8,14 +8,29 @@
88
* @ai-context: 形态照同目录既有的零对话框纪律(FeedFragmentList 的升笔记轻确认)—— 点
99
* 「移动到组」就地铺开组清单,选中即发命令;不新造菜单 / 浮层原语
1010
* (ui/primitives 下没有 Menu),也不占用原生 button 棘轮的配额(一律 Button 原语)。
11-
* @ai-context: 组清单由父层 loadGroupsIfNeeded() 惰性加载后以 props 传入 —— 本件不自建第二套
12-
* 加载器(同一次 list_note_groups 的同一份结果;两套加载器就是两套口径)。
13-
* `groupId: null` = 移出组,与 Rust 侧 `Option<i64>` 的 None 语义逐字对齐。
11+
* @ai-context: 🔴 **候选集 = feed 地形组(碎片的家),不是笔记容器组**。三条实测依据:
12+
* ① `commands_groups.rs:36` 逐字「列出笔记组(含组内笔记数;terrain 可选过滤
13+
* container / feed)」;`:43-46` 的白名单校验是
14+
* `if t != "container" && t != "feed" { return Err(...) }` ⇒ feed 是**一等公民**
15+
* 地形(不是内部值、不是只读视图)。
16+
* ② `commands_fragments.rs:344-366` 的 `resolve_feed_topic_group` —— 碎片自动归组时
17+
* **显式**建的就是 feed 组:`:355` 是 `terrain: "feed".to_string()`、`:349` 是
18+
* `find_topic_group(domain_tag, "feed")`、`:361` 的理由逐字「碎片 DomainTag 自动归组」。
19+
* ③ 反向对照:同目录 `loadGroupsIfNeeded()` 的容器组过滤服务的是**升为笔记**
20+
* (`promote_fragment_to_note`)的去向语义(`FeedFragmentList.tsx:109` 注释逐字
21+
* 「目标=笔记容器组(feed 地形是碎片容器——不是笔记去向)」)—— 那是**另一件事**,
22+
* 不能当本命令的先例:碎片被移进容器组 = 静默的语义错位。
23+
* @ai-context: 本件**自带** feed 清单加载(`list_note_groups` 带 terrain 过滤「后端真源」+
24+
* 再收一次客户端口径「防御性」):它与父层 promote 的容器清单是**两个不同 terrain
25+
* 的两个不同查询**,故不构成「同一口径写两份」;也避免把只服务笔记去向的清单
26+
* 借来当归组候选。清单**首次展开才拉**(每行都预拉 = N 行列表发 N 次 IPC)。
27+
* @ai-context: `groupId: null` = 移出组,与 Rust 侧 `Option<i64>` 的 None 语义逐字对齐。
1428
* @ai-context: 三条出口都不静默:成功 ⇒ 收起 + 源组自动清理留痕(REQ-316 的 autoCleanedGroups,
15-
* 照 FeedFragmentList 的 runPromote 同一形态上抛);失败 ⇒ 交父层既有的 setErr
16-
* (落到 inbox-error 那条 StatusLine 上;空 catch 是安全红线)。
29+
* 照 FeedFragmentList 的 runPromote 同一形态上抛);命令失败与清单加载失败 ⇒ 都交
30+
* 父层既有的 setErr(落到 `inbox-error` 那条 StatusLine 上;空 catch 是安全红线)。
1731
* 边界:本件不做视觉决策 —— 边框 / 圆角 / 阴影 / 色值 / 字号一个字面量都没有,一律由 Button
18-
* 与 Text 原语的类承载。副作用:只在用户点击后发一次 IPC,不读 store、不写磁盘。
32+
* 与 Text 原语的类承载。副作用:首次展开发一次 `list_note_groups`,选中后发一次
33+
* `update_fragment_group`;不读 store、不写磁盘。
1934
*/
2035
import { useState } from "react";
2136
import { invoke } from "@tauri-apps/api/core";
@@ -28,10 +43,6 @@ interface Props {
2843
fragmentId: number;
2944
/** 当前组 id(null=未归组 —— 此时不出现「移出组」出口,与后端 None 语义同向) */
3045
groupId: number | null;
31-
/** 可选组清单(父层惰性加载;null=尚未加载 —— 不把「没加载」谎报成「没有组」) */
32-
groups: NoteGroup[] | null;
33-
/** 展开时惰性拉组(父层 loadGroupsIfNeeded —— 每行都预拉会让 N 行列表发 N 次 IPC) */
34-
onNeedGroups: () => void;
3546
/** 父层动作在飞(与同行其余出口共用同一把 busy 门) */
3647
disabled: boolean;
3748
/** 移组成功后的父层刷新(重载收件箱 + 侧栏计数) */
@@ -43,17 +54,34 @@ interface Props {
4354
}
4455

4556
export default function FragmentGroupAction({
46-
fragmentId, groupId, groups, onNeedGroups, disabled, onMoved, onError, onCleanNotice,
57+
fragmentId, groupId, disabled, onMoved, onError, onCleanNotice,
4758
}: Props) {
4859
const [open, setOpen] = useState(false);
4960
const [busy, setBusy] = useState(false);
50-
// 组名只在清单到手后才敢说;未加载时**不**猜(groupId 非空却显示「未归组」就是撒谎)
61+
/** feed 地形组清单(null=尚未加载 —— 不把「没加载」谎报成「没有组」;加载失败也留 null ⇒ 可重试) */
62+
const [groups, setGroups] = useState<NoteGroup[] | null>(null);
63+
const [loading, setLoading] = useState(false);
64+
// 组名只在清单到手后才敢说;名字查不到(例如碎片还留在容器组里)就**如实报 id**,不猜「未归组」
5165
const groupName = (groups ?? []).find((g) => g.id === groupId)?.name;
5266

53-
/** 展开/收起;**首次展开才拉组** */
67+
/** 清单加载:后端按 terrain 过滤(真源)+ 客户端再收一次口径(防漂移混入笔记容器组) */
68+
const loadGroups = async (): Promise<void> => {
69+
setLoading(true);
70+
try {
71+
const list = await invoke<NoteGroup[]>("list_note_groups", { terrain: "feed" });
72+
setGroups(list.filter((g) => g.terrain === "feed"));
73+
} catch (e) {
74+
onError(`组清单加载失败: ${e}`);
75+
} finally {
76+
setLoading(false);
77+
}
78+
};
79+
80+
/** 展开/收起;**首次展开才拉清单**(`groups === null` ⇒ 上次失败也能再点一次重试) */
5481
const toggle = (): void => {
55-
if (!open) onNeedGroups();
56-
setOpen(!open);
82+
const next = !open;
83+
setOpen(next);
84+
if (next && groups === null && !loading) void loadGroups();
5785
};
5886

5987
/** 选中一个目标(null=移出组);点当前组不产生伪写 */
@@ -89,7 +117,7 @@ export default function FragmentGroupAction({
89117
<span style={{ display: "flex", flexWrap: "wrap", gap: 4, alignItems: "center" }}>
90118
{groups !== null && (
91119
<Text size={2} tone="ink-3" testId={`fragment-move-group-${fragmentId}-current`}>
92-
{groupName === undefined ? "当前未归组" : `当前:${groupName}`}
120+
{groupName ?? (groupId === null ? "当前未归组" : `当前组 #${groupId}`)}
93121
</Text>
94122
)}
95123
{(groups ?? []).map((g) => (
@@ -116,7 +144,9 @@ export default function FragmentGroupAction({
116144
</Button>
117145
)}
118146
{groups !== null && groups.length === 0 && (
119-
<Text size={2} tone="ink-3">先在左侧建一个笔记组</Text>
147+
<Text size={2} tone="ink-3" testId={`fragment-move-group-${fragmentId}-hint`}>
148+
先在左侧建一个碎片组
149+
</Text>
120150
)}
121151
</span>
122152
)}

0 commit comments

Comments
 (0)