|
| 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