Skip to content

Commit 4bee9d7

Browse files
committed
test(sessions): pin screen cards before split
1 parent e579df6 commit 4bee9d7

1 file changed

Lines changed: 218 additions & 0 deletions

File tree

Lines changed: 218 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,218 @@
1+
// @vitest-environment jsdom
2+
/**
3+
* SessionScreenCards.test.tsx — 拆件 B 的**安全网判据**(批 5 · T3 Step 1)。
4+
*
5+
* @ai-context: 为什么先立判据再抽件:`SessionScreenCards.tsx` 是批 5 开工时**无同名测试、
6+
* 全仓测试 0 处 import** 的文件(计划 §Task 3 实测),而 C3 要求把它退化为
7+
* 「每屏一卡的列表容器」并抽出 `SessionScreenCard` 单卡 —— 没有判据的抽件无法证明
8+
* 「行为等价」。本文件钉住**今天**的五条用户可见行为,**抽件前后都必须逐字绿**。
9+
*
10+
* @ai-context **C16-STOP2 的强化口径(控制方已裁)**:C3 原文写「抽件后 `data-testid` 集合不变」,
11+
* 但该文件今天 `data-testid` **实测 0 处** ⇒ 那是**空真**。改用强化口径:
12+
* ① id 锚点集合恰等(S1)② 卡片计数(S2)③ 逐字文案(S3)④ 展开态行为(S4)
13+
* ⑤ 框选态留容器的可观测面(S5)+ **阳性对照**(S1/T1 各自证明仪器有牙)。
14+
* **不许**退化成「渲染不报错」这类弱判据(控制方逐字)。
15+
* T1 仍保留 `data-testid` 集合断言(今天为**空集**)⇒ **若日后新增 `data-testid`,
16+
* 该集合判据自动生效**(新增即必须同步 `FROZEN_TESTIDS` 并触发一次评审)。
17+
* 另:本文件**不设源码文本断言、不设快照**(C15 纪律)。
18+
*
19+
* @ai-context 仪器边界:只 mock **模块边界**(`@tauri-apps/api/core`:`invoke` 冻成 mock、
20+
* `convertFileSrc` 给可预测串)。框选 overlay(`BoxSelectOverlay`)**不 mock** ——
21+
* 「框选态留容器」正是 S5 要判的东西。S4 的原生 `<details>` 翻转行为已用
22+
* `tmp/t3/probe-details-toggle.mjs` 实测 jsdom 30.0.1 会翻(不是靠记忆)。
23+
*/
24+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
25+
import { cleanup, fireEvent, render } from "@testing-library/react";
26+
import type { SessionOcrBlock, SessionScreen } from "../../types";
27+
28+
const { invokeMock } = vi.hoisted(() => ({ invokeMock: vi.fn() }));
29+
vi.mock("@tauri-apps/api/core", () => ({
30+
invoke: invokeMock,
31+
convertFileSrc: (p: string) => `asset://localhost/${p}`,
32+
}));
33+
34+
import SessionScreenCards from "./SessionScreenCards";
35+
36+
const SESSION_ID = 1042;
37+
const noop = () => {};
38+
39+
/** 三屏 fixture:① 有标题/标签/结构/配图 ② `screen_id === null`(走 `i + 1` 兜底)且无图 ③ 只有标签+图 */
40+
function screenOf(over: Partial<SessionScreen>): SessionScreen {
41+
return {
42+
session_id: SESSION_ID,
43+
screen_id: 1,
44+
first_seen_ms: 1000,
45+
last_seen_ms: 4000,
46+
title: null,
47+
body: [],
48+
labels: [],
49+
image_ref: null,
50+
structure: [],
51+
...over,
52+
};
53+
}
54+
55+
const SCREENS: SessionScreen[] = [
56+
screenOf({
57+
screen_id: 1, first_seen_ms: 1000, last_seen_ms: 4000, title: "第一屏标题",
58+
body: ["正文甲", "正文乙"], labels: ["标签一", "标签二"], image_ref: "screens/a.png",
59+
structure: [{ kind: "table", text: "表格原文", rendered: "|a|b|" }],
60+
}),
61+
screenOf({ screen_id: null, first_seen_ms: 2500, last_seen_ms: 6000, body: ["正文丙"] }),
62+
screenOf({ screen_id: 3, first_seen_ms: 9000, last_seen_ms: 12000, labels: ["标签三"], image_ref: "screens/c.png" }),
63+
];
64+
65+
/** 屏号兜底(`screen_id === null` ⇒ `i + 1`)与时间码的**逐字**期望(S2 用) */
66+
const HEADERS = ["📄 屏 1 · 00:01 – 00:04", "📄 屏 2 · 00:02 – 00:06", "📄 屏 3 · 00:09 – 00:12"];
67+
68+
/** 锚点集合(排序后)——**唯一 DOM 锚点**:`id={`ocr-${sessionId}-${first_seen_ms}`}`(`:89`) */
69+
const ANCHORS = ["ocr-1042-1000", "ocr-1042-2500", "ocr-1042-9000"];
70+
71+
function blocksOf(): Map<number, SessionOcrBlock[]> {
72+
return new Map([
73+
[1000, [
74+
{ id: 1, session_id: SESSION_ID, timestamp_ms: 1000, text: "块文本甲", score: 0.9, region: "top" },
75+
{ id: 2, session_id: SESSION_ID, timestamp_ms: 2500, text: "块文本乙", score: 0.8, region: "top" },
76+
]],
77+
[9000, [{ id: 3, session_id: SESSION_ID, timestamp_ms: 9000, text: "块文本丙", score: 0.7, region: "bottom" }]],
78+
]);
79+
}
80+
81+
function propsOf(over: Record<string, unknown> = {}) {
82+
return {
83+
sessionId: SESSION_ID,
84+
kind: null as string | null,
85+
screens: SCREENS,
86+
ocrBlockCount: 3,
87+
baseUrl: "asset://localhost/screens",
88+
ocrBlocksByScreen: blocksOf(),
89+
selectingScreen: null as number | null,
90+
onSelectScreen: noop,
91+
panelToast: null as { screenKey: number; msg: string } | null,
92+
onShowToast: noop,
93+
onClearToast: noop,
94+
...over,
95+
};
96+
}
97+
98+
/** 屏卡容器的 id 集合(**排序后**;S1 的仪器) */
99+
const anchors = (): string[] => [...document.querySelectorAll("[id^='ocr-']")].map((el) => el.id).sort();
100+
/** 屏卡容器(**文档顺序**;S2 的仪器) */
101+
const cards = (): Element[] => [...document.querySelectorAll("[id^='ocr-']")];
102+
const cardText = (i: number): string => cards()[i]?.textContent ?? "";
103+
const bodyText = (): string => document.body.textContent ?? "";
104+
const buttonWith = (label: string): HTMLButtonElement[] =>
105+
[...document.querySelectorAll("button")].filter((b) => (b.textContent ?? "").includes(label)) as HTMLButtonElement[];
106+
107+
beforeEach(() => invokeMock.mockReset());
108+
afterEach(() => cleanup());
109+
110+
describe("SessionScreenCards · 安全网(批 5 T3 Step 1;抽件前后逐字不变)", () => {
111+
it("S1 id 锚点集合恰等:逐屏 `ocr-{sessionId}-{first_seen_ms}`(跨文件契约,SessionsPage 的 scrollIntoView 消费)", () => {
112+
render(<SessionScreenCards {...propsOf()} />);
113+
expect(anchors()).toEqual(ANCHORS);
114+
expect(new Set(anchors()).size).toBe(SCREENS.length); // 无重复锚点(无「同屏两卡」)
115+
// 阳性对照:同一支选择器在**已知带 id 的样本**上必须命中 —— 防「选择器静默失效 ⇒ 空集绿」
116+
const probe = document.createElement("div");
117+
probe.id = "ocr-probe-1";
118+
document.body.appendChild(probe);
119+
expect(anchors()).toContain("ocr-probe-1");
120+
probe.remove();
121+
// 锚点由**容器**给出且格式逐字:换 sessionId ⇒ 每条锚点整体换(抽件后仍由容器拼)
122+
cleanup();
123+
render(<SessionScreenCards {...propsOf({ sessionId: 777 })} />);
124+
expect(anchors()).toEqual(ANCHORS.map((a) => a.replace("1042", "777")));
125+
});
126+
127+
it("S2 卡片数 = screens.length(每屏一块,无隐藏空卡)且逐块与 fixture 一一对应", () => {
128+
render(<SessionScreenCards {...propsOf()} />);
129+
expect(cards()).toHaveLength(SCREENS.length);
130+
// 逐块对拍:第 i 块的文本里必须有第 i 屏的屏头(屏号兜底 `i + 1` 与时间码都逐字)
131+
for (let i = 0; i < SCREENS.length; i += 1) {
132+
expect(cardText(i), `第 ${i} 块与第 ${i} 屏不对应`).toContain(HEADERS[i]);
133+
}
134+
// 0 屏 ⇒ 0 块(空态不产出空卡)
135+
cleanup();
136+
render(<SessionScreenCards {...propsOf({ screens: [] })} />);
137+
expect(cards()).toHaveLength(0);
138+
});
139+
140+
it("S3 逐字文案:h3(两种 kind)· 空态(两种 kind)· 正文 · 标签 · 结构 · 块级明细标题", () => {
141+
render(<SessionScreenCards {...propsOf()} />);
142+
expect(document.querySelector("h3")?.textContent).toBe("画面要点(关键帧纯图) · 3 屏");
143+
expect(bodyText()).toContain("第一屏标题");
144+
expect(bodyText()).toContain("正文甲");
145+
expect(bodyText()).toContain("标签:标签一 · 标签二");
146+
expect(bodyText()).toContain("[table] |a|b|");
147+
expect(bodyText()).toContain("📊 table");
148+
expect(bodyText()).toContain("块级明细(2 块,可复查误合并)");
149+
expect(bodyText()).toContain("[00:01] 块文本甲");
150+
expect(bodyText()).toContain("✂ 框选截取");
151+
// 0 屏 + 非图文会话 ⇒ 空态文案(逐字)
152+
cleanup();
153+
render(<SessionScreenCards {...propsOf({ screens: [] })} />);
154+
expect(document.querySelector("p")?.textContent).toBe("本会话无关键帧图");
155+
// 图文会话(kind === "photo"):h3 文案 + 原始块数提示 + 空态文案都换一套(逐字)
156+
cleanup();
157+
render(<SessionScreenCards {...propsOf({ kind: "photo", ocrBlockCount: 7 })} />);
158+
expect(document.querySelector("h3")?.textContent).toBe("画面要点(OCR) · 3 屏(原始 7 块)");
159+
cleanup();
160+
render(<SessionScreenCards {...propsOf({ kind: "photo", screens: [] })} />);
161+
expect(document.querySelector("p")?.textContent).toBe("本会话无画面识别内容");
162+
});
163+
164+
it("S4 展开态行为:原生 `<details>` 的 `open` 点击 summary 后翻转,且块级明细文本始终在 DOM", () => {
165+
render(<SessionScreenCards {...propsOf()} />);
166+
const details = [...document.querySelectorAll("details")] as HTMLDetailsElement[];
167+
expect(details, "只有 1000 / 9000 两屏有 OCR 块 ⇒ 恰两个块级明细").toHaveLength(2);
168+
const first = details[0]!;
169+
const summary = first.querySelector("summary")!;
170+
expect(summary.textContent).toBe("块级明细(2 块,可复查误合并)");
171+
expect(first.open).toBe(false);
172+
expect(first.textContent).toContain("[00:01] 块文本甲"); // 闭合态也在 DOM(原生 details 语义)
173+
fireEvent.click(summary);
174+
expect(first.open).toBe(true);
175+
fireEvent.click(summary);
176+
expect(first.open).toBe(false);
177+
});
178+
179+
it("S5 框选态留容器:✂ ⇒ onSelectScreen(first_seen_ms) + onClearToast 各 1 次;overlay 只在选中屏出现", () => {
180+
const onSelectScreen = vi.fn();
181+
const onClearToast = vi.fn();
182+
const view = render(<SessionScreenCards {...propsOf({ onSelectScreen, onClearToast })} />);
183+
const overlayTitle = '[title="拖拽框选要截取的结构区域"]';
184+
expect(document.querySelector(overlayTitle)).toBeNull();
185+
const frame = buttonWith("✂ 框选截取");
186+
expect(frame, "无图屏(2500)不出现 ✂ ⇒ 有图的 1000 / 9000 两屏各一个").toHaveLength(2);
187+
fireEvent.click(frame[0]!);
188+
expect(onSelectScreen).toHaveBeenCalledTimes(1);
189+
expect(onSelectScreen).toHaveBeenCalledWith(1000);
190+
expect(onClearToast).toHaveBeenCalledTimes(1);
191+
// 选中态由**容器**的 `selectingScreen` prop 驱动 ⇒ overlay 出现,且只在该屏
192+
view.rerender(<SessionScreenCards {...propsOf({ selectingScreen: 1000, onSelectScreen, onClearToast })} />);
193+
expect(document.querySelectorAll(overlayTitle)).toHaveLength(1);
194+
});
195+
});
196+
197+
/**
198+
* `data-testid` 冻结表(**今天为空集** —— C16-STOP2 实测该文件 0 处)。
199+
* ⇒ 「集合不变」今天是空真,这正是改用 S1–S5 强化口径的原因;但这条判据**留着**:
200+
* 日后谁新增 `data-testid`,这里立刻红,必须同步本表(并触发一次评审)。
201+
*/
202+
const FROZEN_TESTIDS: readonly string[] = [];
203+
204+
const collectTestIds = (root: ParentNode): string[] =>
205+
[...root.querySelectorAll("[data-testid]")].map((el) => el.getAttribute("data-testid") ?? "").sort();
206+
207+
describe("T1 data-testid 集合冻结(附注判据:若日后新增,集合判据自动生效)", () => {
208+
it("今天 = 冻结表(空集);同一支收集器在已知样本上必须命中(阳性对照)", () => {
209+
render(<SessionScreenCards {...propsOf()} />);
210+
expect(collectTestIds(document.body)).toEqual([...FROZEN_TESTIDS]);
211+
const probe = document.createElement("div");
212+
probe.setAttribute("data-testid", "t3-instrument-probe");
213+
document.body.appendChild(probe);
214+
expect(collectTestIds(document.body), "收集器静默失效 ⇒ 上一条是空真").toEqual(["t3-instrument-probe"]);
215+
probe.remove();
216+
expect(collectTestIds(document.body)).toEqual([...FROZEN_TESTIDS]);
217+
});
218+
});

0 commit comments

Comments
 (0)