Skip to content

Commit a41e943

Browse files
committed
feat(views): add session card flow view
1 parent f191ab9 commit a41e943

2 files changed

Lines changed: 377 additions & 0 deletions

File tree

Lines changed: 273 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,273 @@
1+
// @vitest-environment jsdom
2+
/**
3+
* SessionCardFlowView.test.tsx — 批 5 · T9 的**行为级判据 K1–K6**(每个新生产文件 ≥2 条,
4+
* 每条自带变异体:C15)。
5+
*
6+
* @ai-context 为什么这些判据长这样:
7+
* · **K1 不 invoke** 是 C14② 逐字要求的「每个视图组件的第一条判据」——注入 spy 断言 `invoke`
8+
* 零调用,**外加**源码级一条(`views/**` 零 `@tauri-apps`,含 `import type`),两条腿一行为一图级;
9+
* · **K2 复用证明**是本任务的核心(C3 逐字「抽单卡子件供两个视图复用」;规格 §5.1 的病灶是
10+
* 「同一件事两处写」)。仪器 = 把**真卡**包一层带 `data-testid="screen-card"` 的壳
11+
* (`vi.mock` + `vi.importActual`)⇒ ① 锚点数 == 屏数 证明「随屏渲染的恰是那个模块」
12+
* ② 标题/正文文本在全 DOM 各**恰出现 1 次** 证明「没有第二份卡渲染路径」。若只写 ①,
13+
* 「自己写一份 + 也 import 单卡」会漏网;两条腿合取才排除。
14+
* · **K3/K5** 把「另一种容器」变成可判的差:卡片计数(含 0 屏边界)+ **跨文件对拍**
15+
* (同一 fixture 下容器**有**结构徽标/块级明细/`ocr-` 锚点,本视图**都没有**)——
16+
* 两侧各有阳性对照,防「一个选择器静默失效 ⇒ 两边都空 ⇒ 对拍通过」。
17+
* · **K6** 钉「配图走容器注入的 `imageUrl()`」(视图自己 `convertFileSrc` 会同时破 K1 的图级腿)。
18+
* · **K4** 钉只读(0 button / 0 details / 0 toast 痕迹 / 0 框选遮罩)——框选与单屏 toast 是
19+
* 容器的职责(C3),本视图的 props 里根本没有它们。
20+
*
21+
* @ai-context 仪器边界(诚实清单):① 只 mock **模块边界**(`@tauri-apps/api/core`);真卡与
22+
* `SessionScreenCards`(只读 import,用于 K5 对拍)都跑真实实现。② 不设快照、不设源码文本断言
23+
* (C15)——唯一的源码级断言是 K1 的 `@tauri-apps` 扫描,它是 brief 硬要求④逐字点名的形态,
24+
* 且自带「剥注释三向 + 有意义串必中 + 无意义串必 0」四项自证。③ jsdom 测不出真实排版密度
25+
* (观感属 C15 的「只能登记」)。
26+
*/
27+
import { readdirSync, readFileSync, statSync } from "node:fs";
28+
import { dirname, join } from "node:path";
29+
import { fileURLToPath } from "node:url";
30+
import type { ComponentType } from "react";
31+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
32+
import { cleanup, render } from "@testing-library/react";
33+
import type { SessionDetail, SessionOcrBlock, SessionScreen } from "../../types";
34+
35+
const { invokeMock } = vi.hoisted(() => ({ invokeMock: vi.fn() }));
36+
vi.mock("@tauri-apps/api/core", () => ({
37+
invoke: invokeMock,
38+
convertFileSrc: (p: string) => `asset://localhost/${p}`,
39+
}));
40+
/**
41+
* K2 的仪器:**不替换真卡**,只在它外面加一层可计数的锚点(`vi.importActual` 取真实现)。
42+
* ⇒ 其它判据看到的仍是真卡的渲染结果(集成不被 mock 掉),而 K2 有了「这个模块被用了几次」的读数。
43+
*/
44+
vi.mock("../../components/session-detail/SessionScreenCard", async () => {
45+
const actual = await vi.importActual<{ default: ComponentType<{ screen: SessionScreen }> }>(
46+
"../../components/session-detail/SessionScreenCard",
47+
);
48+
return {
49+
default: ({ screen }: { screen: SessionScreen }) => (
50+
<div data-testid="screen-card">
51+
<actual.default screen={screen} />
52+
</div>
53+
),
54+
};
55+
});
56+
57+
import SessionCardFlowView from "./SessionCardFlowView";
58+
import SessionScreenCards from "../../components/session-detail/SessionScreenCards";
59+
60+
const SESSION_ID = 2048;
61+
const noop = () => {};
62+
63+
/** 三屏 fixture(与 T3 的容器 fixture 不同值:两文件互不依赖,跨文件对拍才有力) */
64+
function screenOf(over: Partial<SessionScreen>): SessionScreen {
65+
return {
66+
session_id: SESSION_ID,
67+
screen_id: 1,
68+
first_seen_ms: 1000,
69+
last_seen_ms: 4000,
70+
title: null,
71+
body: [],
72+
labels: [],
73+
image_ref: null,
74+
structure: [],
75+
...over,
76+
};
77+
}
78+
79+
const SCREENS: SessionScreen[] = [
80+
screenOf({
81+
screen_id: 1, first_seen_ms: 1000, last_seen_ms: 4000, title: "卡片流标题一",
82+
body: ["卡片流正文甲"], labels: ["标签甲"], image_ref: "screens/a.png",
83+
structure: [{ kind: "table", text: "表格原文", rendered: "|a|b|" }],
84+
}),
85+
// screen_id === null ⇒ 走 `i + 1` 兜底;无标题/无标签/无图/无结构(降级路径)
86+
screenOf({ screen_id: null, first_seen_ms: 7000, last_seen_ms: 9000, body: ["卡片流正文乙"] }),
87+
screenOf({
88+
screen_id: 3, first_seen_ms: 12000, last_seen_ms: 15000, title: "卡片流标题三",
89+
labels: ["标签丙", "标签丁"], image_ref: "screens/c.png",
90+
}),
91+
];
92+
93+
function blockOf(id: number, ms: number, text: string): SessionOcrBlock {
94+
return { id, session_id: SESSION_ID, timestamp_ms: ms, text, score: 0.9, region: "top" };
95+
}
96+
97+
/** 屏→块分组(调用方 memo 预构建的语义;本视图只取 `length`) */
98+
const BLOCKS: Map<number, SessionOcrBlock[]> = new Map([
99+
[1000, [blockOf(1, 1000, "块文本甲"), blockOf(2, 2000, "块文本乙")]],
100+
[12000, [blockOf(3, 12000, "块文本丙")]],
101+
]);
102+
103+
const DETAIL: SessionDetail = {
104+
session: {
105+
id: SESSION_ID, title: "卡片流会话", source_window: null,
106+
started_at: 1_700_000_000, ended_at: 1_700_000_600, status: "finished", kind: null,
107+
},
108+
segments: [], ocr_blocks: [...BLOCKS.values()].flat(), screens: SCREENS,
109+
};
110+
111+
/** 视图 props:`imageUrl` 默认给一个**注入式**实现(视图不许自己碰 Tauri —— K1/K6 都盯这一点) */
112+
function propsOf(over: Partial<Parameters<typeof SessionCardFlowView>[0]> = {}) {
113+
return {
114+
detail: DETAIL,
115+
imageUrl: (ref: string | null) => (ref === null ? null : `asset://injected/${ref}`),
116+
ocrBlocksByScreen: BLOCKS,
117+
...over,
118+
};
119+
}
120+
121+
/** 容器(`SessionScreenCards`)的 props —— 只为 K5 的跨文件对拍而构造 */
122+
function containerProps() {
123+
return {
124+
sessionId: SESSION_ID, kind: null as string | null, screens: SCREENS, ocrBlockCount: 3,
125+
baseUrl: "asset://localhost/screens", ocrBlocksByScreen: BLOCKS, selectingScreen: null as number | null,
126+
onSelectScreen: noop, panelToast: null as { screenKey: number; msg: string } | null,
127+
onShowToast: noop, onClearToast: noop,
128+
};
129+
}
130+
131+
const items = (): Element[] => [...document.querySelectorAll("[data-card-flow-item]")];
132+
const cards = (): Element[] => [...document.querySelectorAll("[data-testid='screen-card']")];
133+
const bodyText = (): string => document.body.textContent ?? "";
134+
/** 某个串在 DOM 文本里的出现次数(K2 腿②的仪器) */
135+
const occurrences = (needle: string): number => bodyText().split(needle).length - 1;
136+
137+
/** 剥注释(状态机;字符串内的 `//` 不动)。与 `ui/primitives/sliceScan.ts` 同口径的最小实现 */
138+
function stripComments(src: string): string {
139+
let out = "";
140+
let quote: string | null = null;
141+
for (let i = 0; i < src.length; i += 1) {
142+
const c = src.charAt(i);
143+
const next = src.charAt(i + 1);
144+
if (quote !== null) {
145+
out += c;
146+
if (c === "\\" && next !== undefined) { out += next; i += 1; }
147+
else if (c === quote) quote = null;
148+
} else if (c === '"' || c === "'" || c === "`") { quote = c; out += c; }
149+
else if (c === "/" && next === "/") { while (i < src.length && src.charAt(i) !== "\n") i += 1; out += "\n"; }
150+
else if (c === "/" && next === "*") {
151+
i += 2;
152+
while (i < src.length && !(src.charAt(i) === "*" && src.charAt(i + 1) === "/")) i += 1;
153+
i += 1;
154+
} else out += c;
155+
}
156+
return out;
157+
}
158+
159+
/** 源码里的 `@tauri-apps/*` 模块说明符(**先剥注释**;字符串说明符形态 ⇒ 静态与动态 import 都算) */
160+
function tauriSpecifiersIn(source: string): string[] {
161+
return [...stripComments(source).matchAll(/["'](@tauri-apps\/[^"']*)["']/g)].map((m) => m[1] ?? "");
162+
}
163+
164+
const HERE = dirname(fileURLToPath(import.meta.url));
165+
const SRC = join(HERE, "..", "..");
166+
/** `views/**` 的生产文件(**.test 排除**:测试要 `vi.mock("@tauri-apps/api/core")`,那是测试基建) */
167+
function viewSources(dir = join(SRC, "views")): string[] {
168+
return readdirSync(dir).flatMap((name) => {
169+
const p = join(dir, name);
170+
if (statSync(p).isDirectory()) return viewSources(p);
171+
return /\.tsx?$/.test(name) && !/\.test\.tsx?$/.test(name) ? [p] : [];
172+
});
173+
}
174+
175+
beforeEach(() => invokeMock.mockReset());
176+
afterEach(() => cleanup());
177+
178+
describe("SessionCardFlowView(批 5 T9)", () => {
179+
it("K1 不 invoke:渲染全程 `invoke` 零调用;且 `views/**` 生产文件零 `@tauri-apps`(含 import type)", () => {
180+
render(<SessionCardFlowView {...propsOf()} />);
181+
expect(invokeMock).toHaveBeenCalledTimes(0);
182+
// 图级腿:整目录扫描(不是只扫本文件 —— 视图层新增的 Tauri 边一律不许)
183+
expect(viewSources().map((f) => [f, tauriSpecifiersIn(readFileSync(f, "utf8"))] as const)
184+
.filter(([, sites]) => sites.length > 0)).toEqual([]);
185+
// 仪器四项自证:静态 / `import type` / 剥注释 / 无意义串
186+
expect(tauriSpecifiersIn('import { invoke } from "@tauri-apps/api/core";')).toEqual(["@tauri-apps/api/core"]);
187+
expect(tauriSpecifiersIn('import type { X } from "@tauri-apps/api/core";')).toEqual(["@tauri-apps/api/core"]);
188+
expect(tauriSpecifiersIn('// import { invoke } from "@tauri-apps/api/core";')).toEqual([]);
189+
expect(tauriSpecifiersIn('import { a } from "@nowhere/nothing";')).toEqual([]);
190+
// 阳性对照:同一支仪器在**已知有 convertFileSrc** 的容器源码上必须命中(域不是空的)
191+
expect(tauriSpecifiersIn(readFileSync(join(SRC, "components", "session-detail", "SessionScreenCards.tsx"), "utf8")))
192+
.toEqual(["@tauri-apps/api/core"]);
193+
});
194+
195+
it("K2 复用证明:卡内容只经 `SessionScreenCard` 到达 DOM(每屏恰一次),无第二份卡渲染", () => {
196+
render(<SessionCardFlowView {...propsOf()} />);
197+
// 腿①:单卡模块被渲染恰 `screens.length` 次(锚点在 `vi.mock` 壳上 —— 见文件头)
198+
expect(cards()).toHaveLength(SCREENS.length);
199+
expect(cards()[0]?.textContent).toContain("卡片流标题一");
200+
expect(cards()[1]?.textContent).toContain("卡片流正文乙");
201+
// 腿②:标题/正文文本各**恰出现 1 次** ⇒ 视图没有自己再写一份(否则 2 次)
202+
expect(occurrences("卡片流标题一")).toBe(1);
203+
expect(occurrences("卡片流正文甲")).toBe(1);
204+
// 阳性对照:同一支计数器在**确实出现多次**的串上必须数到 > 1(否则腿②是空真)
205+
expect(occurrences("屏 ")).toBe(SCREENS.length);
206+
});
207+
208+
it("K3 卡片数 = `detail.screens.length`(含 0 屏边界:0 卡 + 空态原语 + 根锚点仍在)", () => {
209+
render(<SessionCardFlowView {...propsOf()} />);
210+
expect(items()).toHaveLength(SCREENS.length);
211+
// 阳性对照:同一支选择器在**已知带该属性**的样本上必须命中(防「选择器失效 ⇒ 空集绿」)
212+
const probe = document.createElement("div");
213+
probe.setAttribute("data-card-flow-item", "");
214+
document.body.appendChild(probe);
215+
expect(items()).toHaveLength(SCREENS.length + 1);
216+
probe.remove();
217+
// 0 屏:不产出空卡;空态走 `EmptyState` 原语(`emptyStateRatchet` 的口径)
218+
cleanup();
219+
render(<SessionCardFlowView {...propsOf({ detail: { ...DETAIL, screens: [] } })} />);
220+
expect(items()).toHaveLength(0);
221+
expect(document.querySelector("[data-testid='session-card-flow-view']")).not.toBeNull();
222+
});
223+
224+
it("K4 只读:0 button / 0 details / 0 toast 痕迹 / 0 框选遮罩(框选与单屏 toast 归容器,C3)", () => {
225+
render(<SessionCardFlowView {...propsOf()} />);
226+
expect([...document.querySelectorAll("button")]).toHaveLength(0);
227+
expect([...document.querySelectorAll("details")]).toHaveLength(0);
228+
expect([...document.querySelectorAll("[data-testid*='toast']")]).toHaveLength(0);
229+
expect(document.querySelector('[title="拖拽框选要截取的结构区域"]')).toBeNull();
230+
// 阳性对照:把这三样装进 DOM,同一批选择器必须看得见(否则上面四条是空真)
231+
const probe = document.createElement("div");
232+
probe.innerHTML = '<button data-testid="probe-toast"></button><details></details>';
233+
document.body.appendChild(probe);
234+
expect([...document.querySelectorAll("button")]).toHaveLength(1);
235+
expect([...document.querySelectorAll("details")]).toHaveLength(1);
236+
expect([...document.querySelectorAll("[data-testid*='toast']")]).toHaveLength(1);
237+
probe.remove();
238+
});
239+
240+
it("K5 与 `SessionScreenCards` 不是同一形态的两份拷贝(跨文件对拍 + 两侧阳性对照)", () => {
241+
const first = render(<SessionScreenCards {...containerProps()} />);
242+
// 阳性对照:容器**确实**渲染结构徽标 / 块级明细 / `ocr-` 滚动锚点
243+
expect(bodyText()).toContain("📊 table");
244+
expect(bodyText()).toContain("块级明细(2 块,可复查误合并)");
245+
expect([...document.querySelectorAll("[id^='ocr-']")]).toHaveLength(SCREENS.length);
246+
expect(items()).toHaveLength(0); // 容器没有本视图的锚点
247+
first.unmount();
248+
249+
render(<SessionCardFlowView {...propsOf()} />);
250+
expect(bodyText()).not.toContain("📊 table"); // 无结构徽标
251+
expect(bodyText()).not.toContain("块级明细("); // 无块级明细展开
252+
expect([...document.querySelectorAll("[id^='ocr-']")]).toHaveLength(0); // 不复制 scrollIntoView 契约
253+
expect([...document.querySelectorAll("details")]).toHaveLength(0);
254+
expect(items()).toHaveLength(SCREENS.length);
255+
// 对拍不是「什么都不同」:共同的数据语义面(同一 fixture 的标题)两边都在
256+
expect(bodyText()).toContain("卡片流标题一");
257+
});
258+
259+
it("K6 配图走注入的 `imageUrl()`:实参 = 原始 `image_ref`;返回 `null` ⇒ 不渲染 img(降级)", () => {
260+
const imageUrl = vi.fn((ref: string | null) => (ref === null ? null : `asset://injected/${ref}`));
261+
render(<SessionCardFlowView {...propsOf({ imageUrl })} />);
262+
expect(imageUrl).toHaveBeenCalledTimes(SCREENS.length);
263+
expect(imageUrl.mock.calls.map((c) => c[0])).toEqual(["screens/a.png", null, "screens/c.png"]);
264+
const imgs = [...document.querySelectorAll("img")];
265+
expect(imgs).toHaveLength(2); // 中间那屏 `image_ref = null` ⇒ 无图位
266+
expect(imgs.map((el) => el.getAttribute("src")))
267+
.toEqual(["asset://injected/screens/a.png", "asset://injected/screens/c.png"]);
268+
// 反向:注入器对**非空** ref 也返回 null ⇒ 0 个 img(判据看返回值,不是看 `image_ref` 的字面)
269+
cleanup();
270+
render(<SessionCardFlowView {...propsOf({ imageUrl: () => null })} />);
271+
expect([...document.querySelectorAll("img")]).toHaveLength(0);
272+
});
273+
});

0 commit comments

Comments
 (0)