Skip to content

Commit d479422

Browse files
committed
feat(views): add session proof view
1 parent 02dd20b commit d479422

2 files changed

Lines changed: 383 additions & 0 deletions

File tree

Lines changed: 215 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,215 @@
1+
// @vitest-environment jsdom
2+
/**
3+
* SessionProofView.test.tsx — 批 5 · T8「印样」视图(`SessionProofView`)的**行为级判据**。
4+
*
5+
* @ai-context 为什么是第一等交付物(C15):`SessionProofView` 是**新**生产文件,没有任何既有
6+
* 断言可依靠;「印样」的数据契约**规格未给**(侦察 §11.7),本批 T8 把它写死在 `SessionProofView`
7+
* 的文件头 —— 本文件逐条钉住那份契约里**可机器判定**的部分。五条判据各带一个变异体(在导出树里
8+
* 跑,先 `ran===true` 再断言 `red`),另加「只改文案/间距 ⇒ 必须仍绿」的反例守卫(证明本文件
9+
* 不是快照或文本断言)。
10+
*
11+
* @ai-context 判据清单(编号与计划 Task 8 Step 2 的 P 表**同号**;顺序按 C14② 把「不 invoke」提到
12+
* 第一条,计划表里它是 P5):
13+
* P5 **不 invoke**:`@tauri-apps/api/core` 的 `invoke` 零调用(视图不自己取数)。
14+
* P1 印张数 = `screens.length` · 顺序 = `screens` 给定顺序 · 序号 = `screen_id ?? index+1` ·
15+
* 区间时间码与 `fmtMs` 逐字一致 · `screens` 为空 ⇒ 0 张印张 + `EmptyState`(根仍在)。
16+
* P2 `image_ref` 为空 ⇒ **不出** `<img>`;非空且 `imageUrl()` 解析得出 ⇒ `src` **逐字等于**它的
17+
* 返回;非空但解析不出(null)⇒ 同样不出图;**图注位恒在**(3/3);`imageUrl` 只被非空 ref 调用。
18+
* P3 正文按区间归属:每张印张出现的转写文本集合与顺序 == 与该区间重叠的段(逐字 + 升序 + 并列按 id)。
19+
* P4 只读:全件 0 个 `<button>` / 0 个 `<details>`(印样不得退化成卡片流的复制品)。
20+
*
21+
* @ai-context 仪器边界:**只 mock 模块边界**(`@tauri-apps/api/core`)。`imageUrl` 用**注入的 spy**
22+
* —— 它的返回值刻意带 `INJECTED://` 前缀,与 mock 里 `convertFileSrc` 的 `TAURI-ASSET://` 前缀
23+
* 可区分:**视图若绕过注入槽自己转 URL(计划 P5 的变异体形态),P2 的 `src` 断言立刻红**。
24+
* 断言只用原生 DOM API(本仓**无 `jest-dom`、无 `user-event`**)⇒ 没有 `toBeInTheDocument()` 之类。
25+
*
26+
* @ai-context 诚实边界(细节见 `task-8-report.md` §7):本文件判**结构与内容**,不判观感 ——
27+
* 印张密度、剪报底纹的实际对比度、长会话滚动流畅度、真实图片能否加载(jsdom 不载图)都不在此列。
28+
*/
29+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
30+
import type { Mock } from "vitest";
31+
import { cleanup, render } from "@testing-library/react";
32+
import type { SessionDetail, SessionScreen, SessionSegment } from "../../types";
33+
import { fmtMs } from "../../utils/fmt";
34+
35+
const { invokeMock, convertFileSrcMock } = vi.hoisted(() => ({
36+
invokeMock: vi.fn(),
37+
convertFileSrcMock: vi.fn((p: string) => `TAURI-ASSET://${p}`),
38+
}));
39+
vi.mock("@tauri-apps/api/core", () => ({ invoke: invokeMock, convertFileSrc: convertFileSrcMock }));
40+
41+
import SessionProofView from "./SessionProofView";
42+
43+
/** 注入的图片 URL 解析器(容器给的那一支)——前缀刻意与 `convertFileSrc` 不同(见文件头) */
44+
const IMAGE_PREFIX = "INJECTED://";
45+
/** 后端在 `image_ref` 存在、但图集不可用时给出的 ref(`imageUrl` 对它返回 null) */
46+
const UNRESOLVABLE_REF = "full/unresolvable.webp";
47+
48+
function screenOf(over: Partial<SessionScreen> & Pick<SessionScreen, "first_seen_ms" | "last_seen_ms">): SessionScreen {
49+
return {
50+
session_id: 1042,
51+
screen_id: null,
52+
title: null,
53+
body: [],
54+
labels: [],
55+
image_ref: null,
56+
structure: [],
57+
...over,
58+
};
59+
}
60+
61+
function segmentOf(id: number, start_ms: number, end_ms: number, text: string): SessionSegment {
62+
return { id, session_id: 1042, start_ms, end_ms, text, source: "asr", confidence: 0.8 };
63+
}
64+
65+
/**
66+
* 夹具(`screens` 3 张 / `segments` 6 段,**刻意乱序**)——覆盖契约的全部边界:
67+
* · S1 `[0, 5000)` 有图 · S2 `[5000, 12000)` 无 `screen_id`、无图 · S3 `[12000, 12000)` **零宽屏**
68+
* (退化为点 12000)且 `image_ref` 解析不出 URL;
69+
* · `边界段 [2500,5000)` 与 S1/S2 首尾相接 ⇒ **只属 S1**(半开区间);
70+
* · `跨屏段 [8000,13000)` 同时属 S2 与 S3(重叠归属 ⇒ 两张印张都出现);
71+
* · `并列段 [8000,9000)` 与 `跨屏段` **同 `start_ms`** ⇒ 靠 `id` 升序定序(7004 在 7006 前);
72+
* · `屏外段 [20000,21000)` 与任何屏都不重叠 ⇒ 本视图**不出现**(原文视图才承载它)。
73+
*/
74+
const SCREENS: readonly SessionScreen[] = [
75+
screenOf({ first_seen_ms: 0, last_seen_ms: 5000, screen_id: 1, title: "开场", image_ref: "full/s1.webp" }),
76+
screenOf({ first_seen_ms: 5000, last_seen_ms: 12000 }),
77+
screenOf({ first_seen_ms: 12000, last_seen_ms: 12000, screen_id: 3, image_ref: UNRESOLVABLE_REF }),
78+
];
79+
const SEGMENTS: readonly SessionSegment[] = [
80+
segmentOf(7006, 8000, 9000, "并列段"),
81+
segmentOf(7003, 5000, 8000, "中段讲述"),
82+
segmentOf(7005, 20000, 21000, "屏外段"),
83+
segmentOf(7001, 0, 2500, "开场白"),
84+
segmentOf(7004, 8000, 13000, "跨屏段"),
85+
segmentOf(7002, 2500, 5000, "边界段"),
86+
];
87+
88+
function detailOf(over: Partial<SessionDetail> = {}): SessionDetail {
89+
return {
90+
session: {
91+
id: 1042,
92+
title: "化学反应速率",
93+
source_window: "Chrome",
94+
started_at: 1_700_000_000_000,
95+
ended_at: 1_700_000_600_000,
96+
status: "finished",
97+
kind: null,
98+
},
99+
segments: [...SEGMENTS],
100+
ocr_blocks: [],
101+
screens: [...SCREENS],
102+
...over,
103+
};
104+
}
105+
106+
/** 注入槽(容器给的那一支)的函数形态 */
107+
type ImageUrlFn = (ref: string | null) => string | null;
108+
109+
/** 注入槽的 spy:`INJECTED://<ref>`;对 `UNRESOLVABLE_REF` 返回 null(模拟「无图集/拉取失败」) */
110+
function imageUrlOf(): Mock<ImageUrlFn> {
111+
return vi.fn<ImageUrlFn>((ref) => (ref === null || ref === UNRESOLVABLE_REF ? null : `${IMAGE_PREFIX}${ref}`));
112+
}
113+
114+
interface Rendered {
115+
readonly view: ReturnType<typeof render>;
116+
readonly imageUrl: Mock<ImageUrlFn>;
117+
}
118+
function renderView(detail: SessionDetail = detailOf(), imageUrl = imageUrlOf()): Rendered {
119+
const view = render(<SessionProofView detail={detail} imageUrl={imageUrl} />);
120+
return { view, imageUrl };
121+
}
122+
123+
/** 全部印张(DOM 顺序) */
124+
const sheets = (): HTMLElement[] => [...document.querySelectorAll<HTMLElement>("[data-proof-sheet]")];
125+
/** 某张印张的正文段文本(DOM 顺序,逐字) */
126+
const bodyTextsOf = (sheet: HTMLElement): string[] =>
127+
[...sheet.querySelectorAll<HTMLElement>("[data-proof-body] p")].map((p) => p.textContent ?? "");
128+
/** 某张印张的图注位文本 */
129+
const figureTextOf = (sheet: HTMLElement): string =>
130+
sheet.querySelector<HTMLElement>("[data-proof-figure]")?.textContent ?? "";
131+
132+
beforeEach(() => {
133+
invokeMock.mockReset();
134+
invokeMock.mockImplementation((cmd: string) => Promise.reject(new Error(`invoke not mocked: ${cmd}`)));
135+
convertFileSrcMock.mockClear();
136+
});
137+
afterEach(() => cleanup());
138+
139+
describe("SessionProofView(批 5 T8 · P5 起 = C14② 的第一条判据)", () => {
140+
it("P5 不 invoke:渲染整块(含空态分支)后 `invoke` 零调用 —— 视图不自己取数", () => {
141+
renderView();
142+
expect(invokeMock).not.toHaveBeenCalled();
143+
cleanup();
144+
// 空态分支同样不取数(两个 return 路径都要判,否则「空态里偷偷拉一次」不会被发现)
145+
renderView(detailOf({ screens: [], segments: [] }));
146+
expect(invokeMock).not.toHaveBeenCalled();
147+
});
148+
149+
it("P1 印张数/顺序/序号/区间时间码;`screens` 为空 ⇒ 0 张印张 + EmptyState(根仍在)", () => {
150+
renderView();
151+
const root = document.querySelector('[data-testid="session-proof-view"]');
152+
expect(root).not.toBeNull();
153+
expect(sheets()).toHaveLength(SCREENS.length);
154+
// 顺序 = `screens` 的给定顺序(不重排);锚点值逐字 = first_seen_ms
155+
expect(sheets().map((s) => s.getAttribute("data-proof-sheet"))).toEqual(["0", "5000", "12000"]);
156+
// 序号 = `screen_id ?? index + 1`(S2 无 screen_id ⇒ 用序号 2)
157+
const numbers = sheets().map((s) => s.querySelector("p")?.textContent);
158+
expect(numbers).toEqual(["印张 1", "印张 2", "印张 3"]);
159+
// 区间时间码逐字 = `fmtMs(first) – fmtMs(last)`(防「自己实现一个格式化」)
160+
for (const [i, screen] of SCREENS.entries()) {
161+
expect(
162+
sheets()[i].textContent,
163+
`第 ${i + 1} 张印张的时间码与 fmtMs 不一致`,
164+
).toContain(`${fmtMs(screen.first_seen_ms)} – ${fmtMs(screen.last_seen_ms)}`);
165+
}
166+
167+
cleanup();
168+
renderView(detailOf({ screens: [], segments: [] }));
169+
expect(sheets()).toHaveLength(0);
170+
expect(document.querySelector('[data-testid="session-proof-view"]')).not.toBeNull();
171+
expect(document.querySelector('[data-testid="session-proof-empty"]')).not.toBeNull();
172+
expect(document.body.textContent).toContain("本会话无画面要点");
173+
});
174+
175+
it("P2 配图:`image_ref` 为空 ⇒ 无 `<img>`;非空 ⇒ `src` 逐字 = 注入 `imageUrl()` 的返回;图注位恒在", () => {
176+
const { imageUrl } = renderView();
177+
const imgs = [...document.querySelectorAll<HTMLImageElement>("img")];
178+
// 只有 S1 出图:S2 的 `image_ref` 是 null、S3 的 ref 解析不出 URL(两者都不许出图)
179+
expect(imgs).toHaveLength(1);
180+
expect(imgs[0].getAttribute("src")).toBe(`${IMAGE_PREFIX}full/s1.webp`);
181+
// `src` 必须来自**注入槽**:mock 的 `convertFileSrc` 前缀不同 ⇒ 绕过注入槽自己转 URL 时这里红
182+
expect(convertFileSrcMock).not.toHaveBeenCalled();
183+
// 注入槽只被**非空 ref** 调用(空 ref 的答案视图自己就知道)
184+
expect(imageUrl.mock.calls.map((c) => c[0])).toEqual(["full/s1.webp", UNRESOLVABLE_REF]);
185+
// 图注位恒在(3/3):无图的两张印张保留降级文案,而不是整块消失
186+
expect(sheets().map(figureTextOf)).toEqual(["配图 1", "本屏未归档配图", "本屏未归档配图"]);
187+
for (const sheet of [sheets()[1], sheets()[2]]) {
188+
expect(sheet.querySelectorAll("img")).toHaveLength(0);
189+
}
190+
});
191+
192+
it("P3 正文按区间归属:每张印张的段集合与顺序(逐字)—— 含首尾相接、跨屏重叠、并列同 ms、屏外段", () => {
193+
renderView();
194+
// 期望值是**字面量真值**(不是把源码谓词抄一遍):边界规则见夹具注释
195+
expect(sheets().map(bodyTextsOf)).toEqual([
196+
["开场白", "边界段"],
197+
["中段讲述", "跨屏段", "并列段"],
198+
["跨屏段"],
199+
]);
200+
// 屏外段不出现(印样是派生形态;「原文不丢」由默认原文视图承载)
201+
expect(document.body.textContent).not.toContain("屏外段");
202+
// 正文段总数 = 2(S1)+ 3(S2)+ 1(S3),其中「跨屏段」在两张印张上各出一次(重叠归属)
203+
expect(document.querySelectorAll("[data-proof-body] p")).toHaveLength(2 + 3 + 1);
204+
});
205+
206+
it("P4 只读:全件 0 个 `<button>` / 0 个 `<details>`(印样不得长成卡片流的复制品)", () => {
207+
renderView();
208+
expect(document.querySelectorAll("button")).toHaveLength(0);
209+
expect(document.querySelectorAll("details")).toHaveLength(0);
210+
expect(document.querySelectorAll("summary")).toHaveLength(0);
211+
// 也没有框选 / 单屏 toast 的痕迹(那些是原文视图里 SessionScreenCards 的面)
212+
expect(document.querySelectorAll('[data-testid*="toast"]')).toHaveLength(0);
213+
expect(document.body.innerHTML).not.toContain("box-select");
214+
});
215+
});

0 commit comments

Comments
 (0)