Skip to content

Commit 8cc627d

Browse files
committed
feat(notes): 新增带证据三轨的视图片
新增视图件 app/src/views/note/NoteEvidenceTrackView.tsx(171 行,预算 ≤240)与判据件 NoteEvidenceTrackView.test.tsx(202 行,13 用例),并把 architecture.slots.test.ts 的 A6 tsc 探针追加第 6 项(计数断言 5 → 6)。 视图层兑现规格 §7.4 §A/§C 的三条口径: - E1 正向:**每个**段落节点都带 data-evidence-for(含无锚点段,段落轨 = 渲染器实际产出的 段落节点:非空行 + 连续派生序号,见导出的 evidenceLinesOf); - E1 反向:**只有带锚点段**才渲染证据列 ⇒「有锚点无证据列 = 缺陷」与「无锚点无证据列 = 正常」 两个方向各有一条具名判据(后者即变异体 M1 的靶心); - E2:命中 ⇒ 证据列逐字带 data-evidence-id(转写段轨 + OCR 块轨);容差内无候选 ⇒ 渲染模型层 唯一串「无证据」(不静默取最近的一个);§C 精修剥离段落锚点 ⇒ 走无锚点的一等状态(沉默的 正常态,不报缺陷、不回滚)。 渲染链复用 utils/markdownLine.ts(§D4:不新写第 3 支 markdown 解析器)⇒ 本件零 markdown 词法。 本提交不动注册表(NOTE_VIEWS / FROZEN_VIEW_KEYS 见下一条),故本视图此刻无人引用、不进产物: 真构建包体读数与 base 树逐字持平(37 chunk / 637,516 B / 未归族 0)。 变异体(导出树真跑,CONTROL 52/52 绿):M1 ⇒ 判据件 :99/:91/:160/:195 四条红(含卡片点名的 「无锚点段落不得出现证据列或告警」);M3 ⇒ architecture.slots.test.ts:97 报 TS2769。 注:M3 的牙口来自本提交追加的探针第 6 项 —— 不追加时该变异体实测全绿(无牙),故此项为必要改动。
1 parent ffb7e28 commit 8cc627d

3 files changed

Lines changed: 380 additions & 3 deletions

File tree

‎app/src/views/architecture.slots.test.ts‎

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -38,13 +38,17 @@ const REPO = join(APP, "..");
3838
const PROBE_DIR = join(REPO, ".superpowers", "sdd", "2026-09-12-frontend-redesign-batch5-view-layer", "tmp", "t15");
3939

4040
describe("A6 · 每个视图组件的 props 只来自 slot(**类型级** tsc 探针,不用文本匹配)", () => {
41-
/** 组件路径 → 它的 slot 类型(顺序即探针里的调用顺序) */
41+
/** 组件路径 → 它的 slot 类型(顺序即探针里的调用顺序)。
42+
* 批 8 T8 追加第 6 项(`views/note/NoteEvidenceTrackView.tsx`):🔴 这份名单是**硬编码**的
43+
* ⇒ 新视图**不会**自动进探针(「新增视图 = 自动被守卫覆盖」是错的预期)。追加它才让 T8 的 V3
44+
* (给视图加 slot 外**必填** prop ⇒ 探针报 `TS2769`)在**本件**上真的变红。 */
4245
const VIEWS: readonly (readonly [string, "SessionViewSlot" | "NoteViewSlot"])[] = [
4346
["views/session/SessionTriTrackView.tsx", "SessionViewSlot"],
4447
["views/session/SessionProofView.tsx", "SessionViewSlot"],
4548
["views/session/SessionCardFlowView.tsx", "SessionViewSlot"],
4649
["views/session/SessionNotePreview.tsx", "SessionViewSlot"],
4750
["views/note/NoteCardFlowView.tsx", "NoteViewSlot"],
51+
["views/note/NoteEvidenceTrackView.tsx", "NoteViewSlot"],
4852
];
4953
const nameOf = (f: string): string => `V${f.split("/").pop()!.replace(/\.[^.]*$/, "")}`;
5054
/** 从探针目录指回 `app/src/...`(用 `relative` 现算,不手数 `../` —— #65 的教训) */
@@ -60,7 +64,7 @@ describe("A6 · 每个视图组件的 props 只来自 slot(**类型级** tsc
6064
"",
6165
"declare const sessionSlot: SessionViewSlot;",
6266
"declare const noteSlot: NoteViewSlot;",
63-
"/** 5 个视图各一次:slot 能整体传给组件 ⇒ 组件的 props 是 slot 的子集 */",
67+
"/** 6 个视图各一次:slot 能整体传给组件 ⇒ 组件的 props 是 slot 的子集 */",
6468
"export const probes = [",
6569
...VIEWS.map(([f, s]) => ` createElement(${nameOf(f)}, ${s === "NoteViewSlot" ? "noteSlot" : "sessionSlot"}),`),
6670
"];",
@@ -75,7 +79,7 @@ describe("A6 · 每个视图组件的 props 只来自 slot(**类型级** tsc
7579
mkdirSync(PROBE_DIR, { recursive: true });
7680
writeFileSync(PROBE, src, "utf8");
7781
expect(readFileSync(PROBE, "utf8")).toBe(src);
78-
expect(src.split("createElement(").length - 1, "5 个视图必须各有一个探针调用").toBe(5);
82+
expect(src.split("createElement(").length - 1, "6 个视图必须各有一个探针调用").toBe(6);
7983
}, 30000);
8084

8185
it("② 判据:`tsc` 对探针 **0 诊断**(任何 props 溢出/改形 ⇒ `no overload matches`)+ 牙齿自证", () => {
Lines changed: 202 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,202 @@
1+
// @vitest-environment jsdom
2+
/**
3+
* NoteEvidenceTrackView.test.tsx — 批 8 T8 的**视图层**判据(规格 §7.4 §A 的 E1/E2/E3 + §B/§C)。
4+
*
5+
* @ai-context 三个判据面各有**独立 oracle**(不拿被测件自己的中间量当期望):
6+
* ① **E1 双向**:段落/锚点的 oracle = 模型层 `evidenceLinesOf` + `extractAnchors`,**并**用
7+
* **渲染链自己产出的芯片** `[data-ts-ms]`(`utils/html.renderTimestampAnchors` 的产物)作
8+
* 第二条见证 ⇒ 「有锚点无证据列 = 缺陷」与「无锚点无证据列 = 正常」两个方向都成判据。
9+
* ② **E2**:命中 / 失配的期望值**逐字写在断言里**(夹具的 `id` 与边界 ms 现算),不用被测件输出。
10+
* ③ **E3**:两个分母各自读 DOM;分母为 0 ⇒ 比值属性**整条缺席**(≠ 0)—— 「0」与「无定义」在
11+
* 同一支读法下必须可分辨。
12+
* @ai-context **§C 的两态是受控对比**(§17):`REFINE_PAIR` 两态的候选轨**逐字相同** ⇒ 读数之差
13+
* 只可能来自**锚点的存亡**;本件因此能逐条把差值归因到 §C 契约(分母甲不变 · 分母乙 5→2 ·
14+
* 命中 4→1),而不是归因到夹具换了。
15+
* @ai-context **本件不重复造 Tauri 边仪器**:视图零 `invoke` / 零 `@tauri-apps` 边由
16+
* `views/architecture.guard.test.ts` A3③ 在**整目录**(含本件新增的生产文件)上判(T8 报告给读数)。
17+
* 副作用:只挂 React 树;不写盘、不发请求。
18+
*/
19+
import { cleanup, render } from "@testing-library/react";
20+
import { afterEach, describe, expect, it } from "vitest";
21+
import type { Note } from "../../types";
22+
import NoteEvidenceTrackView, { evidenceLinesOf, type NoteEvidenceTracks } from "./NoteEvidenceTrackView";
23+
import { EVIDENCE_TOLERANCE_MS, NO_EVIDENCE_MARK, extractAnchors, type EvidenceLine } from "./noteEvidenceModel";
24+
import { ANCHOR_SWITCH, MANUAL_NOTE, OCR_DIRECT, REFINE_PAIR, STATIC_FIXTURES, WEB_ARTICLE } from "./noteEvidence.fixtures";
25+
26+
const noop = () => {};
27+
const noteOf = (content: string): Note => ({
28+
id: 1, title: "标题", content, source: "session", session_id: 42, rule_version: null, purify_stats: null,
29+
tags: "[]", properties: null, pin: 0, group_id: null, created_at: 1, updated_at: 2,
30+
});
31+
/** 段落集合 → `notes.content`(夹具是行数组;空行不被派生 ⇒ 逐行 join 即同一集合) */
32+
const contentOf = (lines: readonly EvidenceLine[]): string => lines.map((l) => l.text).join("\n");
33+
const mount = (content: string, evidence?: NoteEvidenceTracks): HTMLElement =>
34+
render(<NoteEvidenceTrackView note={noteOf(content)} onTaskToggle={noop} onImageOpen={noop} evidence={evidence} />).container;
35+
36+
/** 段落节点 = 带 `data-evidence-for` 的行(E1 正向的唯一载体) */
37+
const rowsOf = (root: Element): Element[] => [...root.querySelectorAll("[data-evidence-for]")];
38+
const ordinalsOf = (root: Element): (string | null)[] => rowsOf(root).map((r) => r.getAttribute("data-evidence-for"));
39+
const cellCount = (root: Element): number => root.querySelectorAll("[data-evidence-cell]").length;
40+
const marksOf = (root: Element): string[] => [...root.querySelectorAll("[data-evidence-missing]")].map((e) => e.textContent ?? "");
41+
const idsOf = (root: Element): (string | null)[] => [...root.querySelectorAll("[data-evidence-id]")].map((e) => e.getAttribute("data-evidence-id"));
42+
const chipsIn = (root: Element): number => root.querySelectorAll("[data-ts-ms]").length;
43+
/** E3 的一格读数;**读不到即抛**(缺格不许静默退化成空真) */
44+
const coverOf = (root: Element, kind: "derived" | "anchored") => {
45+
const el = root.querySelector(`[data-coverage="${kind}"]`);
46+
if (el === null) throw new Error(`没有 data-coverage="${kind}" 这一格 ⇒ E3 的两个分母没有并列渲染`);
47+
return {
48+
hits: el.getAttribute("data-coverage-hits"),
49+
denominator: el.getAttribute("data-coverage-denominator"),
50+
ratio: el.getAttribute("data-coverage-ratio"),
51+
};
52+
};
53+
const TRACKS = (f: { segments: readonly { id: number; startMs: number }[]; ocr: readonly { id: number; timestampMs: number }[] }) =>
54+
({ segments: f.segments, ocr: f.ocr });
55+
56+
afterEach(cleanup);
57+
58+
describe("E1 正向:**每段**都有 data-evidence-for(含无锚点段)· 序号连续 = 渲染器产出的段落数", () => {
59+
it("七组夹具(§B 静态三类 + 开关两态 + §C 精修两态):节点数与序号逐字相等", () => {
60+
const cases: readonly (readonly [string, readonly EvidenceLine[]])[] = [
61+
["OCR_DIRECT", OCR_DIRECT.lines], ["WEB", WEB_ARTICLE.lines], ["MANUAL", MANUAL_NOTE.lines],
62+
["SWITCH.on", ANCHOR_SWITCH.on], ["SWITCH.off", ANCHOR_SWITCH.off],
63+
["REFINE.before", REFINE_PAIR.before], ["REFINE.after", REFINE_PAIR.after],
64+
];
65+
for (const [name, lines] of cases) {
66+
const root = mount(contentOf(lines));
67+
expect(rowsOf(root).length, `${name}:段落节点数必须 == 派生段落数`).toBe(lines.length);
68+
expect(ordinalsOf(root), `${name}:派生序号必须逐段连续(无锚点段同样有号)`).toEqual(lines.map((_, i) => String(i)));
69+
cleanup();
70+
}
71+
});
72+
73+
it("反例守卫:段落数跟着夹具变(写死节点数的实现会在这里红)· 空行不产出段落节点", () => {
74+
expect(ordinalsOf(mount("甲段。\n\n乙段。")), "空行经行级渲染链产出空串 ⇒ 不构成段落(E3 分母甲口径)").toEqual(["0", "1"]);
75+
cleanup();
76+
expect(ordinalsOf(mount("只有一个段落。"))).toEqual(["0"]);
77+
expect(evidenceLinesOf("甲\n\n乙").map((l) => l.paragraphIndex), "派生函数独立可算:空行剔除 + 序号连续").toEqual([0, 1]);
78+
});
79+
});
80+
81+
describe("E1 反向:**只有带锚点段**才渲染证据列(两个方向各自成判据)", () => {
82+
it("缺陷向:有锚点 ⇒ **必有**证据列(模型抽锚 + 渲染链芯片两条独立见证)", () => {
83+
const root = mount(contentOf(ANCHOR_SWITCH.on), TRACKS(ANCHOR_SWITCH));
84+
const anchored = extractAnchors(ANCHOR_SWITCH.on).map((a) => a.paragraphIndex);
85+
expect(anchored, "夹具自证:on 态必须有锚点段(否则下面的判据空真)").toEqual([0, 1, 2]);
86+
expect(chipsIn(root), "渲染链产出的芯片数 == 锚点数(第二条见证,独立于模型)").toBe(anchored.length);
87+
for (const i of anchored) {
88+
const row = root.querySelector(`[data-evidence-for="${i}"]`);
89+
expect(row?.querySelectorAll("[data-evidence-cell]").length, `第 ${i} 段有锚点却**没有**证据列(E1 的缺陷向)`).toBe(1);
90+
}
91+
expect(cellCount(root), "证据列数恰 = 带锚点段数").toBe(anchored.length);
92+
});
93+
94+
it("正常向:无锚点 ⇒ **必无**证据列且**无「无证据」告警**(§B 三类 + 开关 off 态)", () => {
95+
const cases = [...STATIC_FIXTURES, { kind: "switch.off", lines: ANCHOR_SWITCH.off, segments: ANCHOR_SWITCH.segments, ocr: ANCHOR_SWITCH.ocr }];
96+
for (const f of cases) {
97+
const root = mount(contentOf(f.lines), { segments: f.segments, ocr: f.ocr });
98+
expect(extractAnchors(f.lines), `${f.kind} 夹具自证:必须 0 锚点`).toEqual([]);
99+
expect(cellCount(root), `${f.kind}:无锚点段落不得出现证据列或告警`).toBe(0);
100+
expect(marksOf(root), `${f.kind}:无锚点段落不得出现「无证据」告警(无锚点是一等状态,不是异常)`).toEqual([]);
101+
cleanup();
102+
}
103+
});
104+
});
105+
106+
describe("E2 命中:证据列逐字带候选 id(转写段轨 + OCR 块轨)", () => {
107+
it("on 态三段各出**最近邻**:id 与轨道逐字(含 120 ms 抖动那段)", () => {
108+
const root = mount(contentOf(ANCHOR_SWITCH.on), TRACKS(ANCHOR_SWITCH));
109+
expect(idsOf(root), "证据 id 必须逐字 = 夹具的 session_segments.id / session_ocr_blocks.id").toEqual(["1", "11", "2", "12", "3", "13"]);
110+
expect([...root.querySelectorAll("[data-evidence-track]")].map((e) => e.getAttribute("data-evidence-track")), "两轨各自带轨标")
111+
.toEqual(["segment", "ocr", "segment", "ocr", "segment", "ocr"]);
112+
const row2 = root.querySelector('[data-evidence-for="2"]');
113+
expect(row2 === null ? null : [...row2.querySelectorAll("[data-evidence-id]")].map((e) => e.getAttribute("data-evidence-id")),
114+
"47000 那段:段 47120(距 120 ms)/ 块 47200(距 200 ms)—— 容差内取**最近邻**,不是数组首位").toEqual(["3", "13"]);
115+
});
116+
117+
it("OCR 轨为空(`ocr: []`)⇒ 只出转写段轨;命中**不**因补充轨缺席而退化成失配", () => {
118+
const root = mount(contentOf(ANCHOR_SWITCH.on), { segments: ANCHOR_SWITCH.segments, ocr: [] });
119+
expect(idsOf(root)).toEqual(["1", "2", "3"]);
120+
expect(marksOf(root)).toEqual([]);
121+
});
122+
});
123+
124+
describe("E2 失配:容差内无候选 ⇒ **显式**「无证据」标记(不许静默取最近的一个)", () => {
125+
it("§C 精修后的章节锚点落在容差外(99000 vs 最近段 47120)⇒ 显式标记且**不**回填最近段", () => {
126+
const root = mount(contentOf(REFINE_PAIR.after), TRACKS(REFINE_PAIR));
127+
const row = root.querySelector('[data-evidence-for="4"]');
128+
expect(row?.textContent, "容差内无候选必须出显式「无证据」标记").toContain(NO_EVIDENCE_MARK);
129+
expect(row?.querySelectorAll("[data-evidence-id]").length, "显式失配段**不得**回填最近的一个候选").toBe(0);
130+
expect(marksOf(root), "本态恰 1 条失配 ⇒ 标记也只有那一条").toEqual([NO_EVIDENCE_MARK]);
131+
});
132+
133+
it("容差边界两侧(DOM 侧复算):恰 == 容差 ⇒ 命中;容差 + 1 ms ⇒ 显式失配", () => {
134+
const LINE = "[⏱ 00:10]([[ts:10000]]) 边界段。";
135+
const inside = mount(LINE, { segments: [{ id: 1, startMs: 10000 + EVIDENCE_TOLERANCE_MS }] });
136+
expect(idsOf(inside), "恰在容差上必须命中(边界含等号)").toEqual(["1"]);
137+
cleanup();
138+
const outside = mount(LINE, { segments: [{ id: 2, startMs: 10000 + EVIDENCE_TOLERANCE_MS + 1 }] });
139+
expect(idsOf(outside), "容差 + 1 ms 不得命中").toEqual([]);
140+
expect(marksOf(outside), "容差外必须出显式标记(不是空白、也不是异常)").toEqual([NO_EVIDENCE_MARK]);
141+
});
142+
});
143+
144+
describe("§C 精修两态(受控对比):锚点消失 ⇒ 沉默的正常态,不报缺陷、不回滚", () => {
145+
const refined = (lines: readonly EvidenceLine[]): HTMLElement => mount(contentOf(lines), TRACKS(REFINE_PAIR));
146+
147+
it("两态同源:段落节点数与序号**逐段相等**(精修不增删段落)", () => {
148+
const b = refined(REFINE_PAIR.before);
149+
const a = refined(REFINE_PAIR.after);
150+
expect(ordinalsOf(a)).toEqual(ordinalsOf(b));
151+
expect(ordinalsOf(a)).toHaveLength(6);
152+
});
153+
154+
it("差值逐条归因到 §C:分母甲不变 · 分母乙 5→2 · 命中 4→1 · 证据列 5→2 · 脱锚 3 段沉默", () => {
155+
const b = refined(REFINE_PAIR.before);
156+
const a = refined(REFINE_PAIR.after);
157+
expect([coverOf(b, "derived").denominator, coverOf(a, "derived").denominator], "分母甲(派生段落)两态不变").toEqual(["6", "6"]);
158+
expect([coverOf(b, "anchored").denominator, coverOf(a, "anchored").denominator], "分母乙 5→2:差的 3 段 = 精修主动剥离段级锚点").toEqual(["5", "2"]);
159+
expect([coverOf(b, "derived").hits, coverOf(a, "derived").hits], "命中 4→1").toEqual(["4", "1"]);
160+
expect([cellCount(b), cellCount(a)], "证据列只在带锚点段上 ⇒ 5→2").toEqual([5, 2]);
161+
expect([chipsIn(b), chipsIn(a)], "渲染链侧的独立见证:锚点芯片 5→2").toEqual([5, 2]);
162+
for (const i of [1, 2, 3]) {
163+
const row = a.querySelector(`[data-evidence-for="${i}"]`);
164+
expect(chipsIn(row as Element), `after 态第 ${i} 段确实没有锚点芯片(夹具自证)`).toBe(0);
165+
expect(row?.querySelectorAll("[data-evidence-cell]").length, `精修后第 ${i} 段失去锚点 ⇒ 不得出现证据列(无锚点无列 = 正常)`).toBe(0);
166+
}
167+
expect(a.querySelectorAll('[role="alert"]').length, "脱锚段不得被报成缺陷(视图里没有 error 面)").toBe(0);
168+
expect(a.textContent, "视图渲染的是精修**后**的正文(没有回滚精修)").toContain("散粉压两遍,鼻翼用余粉带过。");
169+
});
170+
});
171+
172+
describe("E3 双分母并列:两个比值分别给出;分母 0 ⇒ 比值**无定义**(属性缺席,不写 0)", () => {
173+
it("on 态:全篇 3/4(0.750)· 带锚点 3/3(1.000)—— 两个比值必须可分辨", () => {
174+
const root = mount(contentOf(ANCHOR_SWITCH.on), TRACKS(ANCHOR_SWITCH));
175+
const d = coverOf(root, "derived");
176+
const a = coverOf(root, "anchored");
177+
expect([d.hits, d.denominator, d.ratio]).toEqual(["3", "4", "0.750"]);
178+
expect([a.hits, a.denominator, a.ratio]).toEqual(["3", "3", "1.000"]);
179+
expect(d.ratio, "两个比值必须可分辨,否则「只报一个」不会露馅").not.toBe(a.ratio);
180+
});
181+
182+
it("无锚点态:分母 0 ⇒ ratio 属性**整条缺席**;全篇 0/4 ⇒ ratio = 0.000(0 与 null 不可混)", () => {
183+
const root = mount(contentOf(ANCHOR_SWITCH.off), TRACKS(ANCHOR_SWITCH));
184+
const a = coverOf(root, "anchored");
185+
const d = coverOf(root, "derived");
186+
expect([a.hits, a.denominator, a.ratio], "分母 0 ⇒ 记 null(不写 0 冒充读数)").toEqual(["0", "0", null]);
187+
expect([d.hits, d.denominator, d.ratio], "分母非 0 ⇒ 0 是**真读数**(与 null 必须可分辨)").toEqual(["0", "4", "0.000"]);
188+
expect(root.textContent, "无定义那一格显示占位符,而不是 0/0 或 0%").toContain("—");
189+
});
190+
});
191+
192+
describe("注册表直载态(容器未接线):候选轨为空 ⇒ 有锚点段出**显式**失配(E2 的诚实读数)", () => {
193+
it("无 evidence 注入:有锚点段全部显式「无证据」;无锚点段仍**沉默**", () => {
194+
const root = mount(contentOf(ANCHOR_SWITCH.on));
195+
expect(cellCount(root), "三处有锚点 ⇒ 三处证据列(内容为显式失配,不假装命中)").toBe(3);
196+
expect(marksOf(root)).toEqual([NO_EVIDENCE_MARK, NO_EVIDENCE_MARK, NO_EVIDENCE_MARK]);
197+
expect(idsOf(root), "空候选轨 ⇒ 0 个证据 id").toEqual([]);
198+
cleanup();
199+
const staticRoot = mount(contentOf(OCR_DIRECT.lines));
200+
expect([cellCount(staticRoot), marksOf(staticRoot).length], "无锚点 ⇒ 静默(0 列 / 0 告警)").toEqual([0, 0]);
201+
});
202+
});

0 commit comments

Comments
 (0)