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