Skip to content

Commit f191ab9

Browse files
committed
feat(views): add session tri-track alignment view
1 parent 927aedf commit f191ab9

2 files changed

Lines changed: 435 additions & 0 deletions

File tree

Lines changed: 228 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,228 @@
1+
// @vitest-environment jsdom
2+
/**
3+
* SessionTriTrackView.test.tsx — 会话「三轨对齐」视图的**行为级契约**(批 5 · T7 · C2/C14/C15)。
4+
*
5+
* @ai-context 五条判据(对计划 Task 7 Step 3 的 T1–T5 逐条兑现,编号一一对应)。
6+
* **C14② 要求「每个视图组件的第一条判据是不 invoke」⇒ 文件里 `it` 的**物理首条**是 T4**
7+
* (计划表格里的第 4 行)—— 编号照计划、次序照裁决,两者不冲突。
8+
* T4 不 invoke 且不带 Tauri:① 注入 `invoke` spy 后把**三轨非空**与**整块空态**两条路径都渲染
9+
* 一遍 ⇒ `invoke` **零调用**(视图不取数的机器判据);② 源码级:`views/**` 的**生产文件**
10+
* `@tauri-apps` 出现 **0 次**(含 `import type`)—— 带阳性对照(已知有 `convertFileSrc` 的
11+
* `components/session-detail/SessionScreenCards.tsx` 必命中)与阴性对照(无意义串必 0)。
12+
* T1 条目数:三条轨各自的**直接子项数** == `segments` / `screens` / `ocr_blocks` 的长度;
13+
* 单轨为空时该轨**照常渲染**(列在、列内 0 条)—— 两套夹具,避免把长度写死。
14+
* T2 纵向顺序:同一 ms(9000)三条目在 DOM 里的顺序 = 转写 → 画面 → OCR
15+
* (`compareDocumentPosition` 判 `DOCUMENT_POSITION_FOLLOWING`);且每轨内按 ms 升序。
16+
* T3 空态走原语:三数组都空 ⇒ `EmptyState`(`.ed-empty` + 标题断言),**0 裸灰字节点**
17+
* (子树里任何元素的**行内 style 不得出现颜色字面量**);非空时不得出现空态(反向)。
18+
* T5 时间码格式:每个条目的**首个文本子节点**逐字等于 `fmtMs(ms)`;夹具含 `3_725_000 ms`
19+
* (> 1h ⇒ `fmtMs` 出 `1:02:05`,而「手写 `Math.floor(ms/1000)`」会出 `3725s`)⇒ 该夹具
20+
* 让「自己实现一个格式化」不可能冒充(夹具自证写在同一用例里)。
21+
*
22+
* @ai-context 仪器边界:jsdom **不是真浏览器** —— 三轨的真实视觉对齐(同 ms 是否落在同一水平线)
23+
* 属「视图密度观感」,按 C15 只登记、不验(见 T7 报告 §未验证)。本文件只断言 DOM 事实。
24+
* @ai-context 域口径(T4② 的扫描器):**排除 `*.test.tsx`**。理由不是"图省事":mock 一行
25+
* `vi.mock("@tauri-apps/api/core", …)` 必须写出**模块 id 字面量**(vitest 要求静态可分析),
26+
* 而本文件就在 `views/**` 里 ⇒ 不排除测试文件的话,**判据会被自己的 mock 行弄红**(自引用空真)。
27+
* 这与全仓每一支棘轮的域口径一致(`!/\.test\.tsx?$/`)。
28+
* 副作用:只挂 React 树 + 只读 `app/src/views/**` 与一个阳性对照文件;不写盘、不发请求。
29+
*/
30+
import { readFileSync, readdirSync, statSync } from "node:fs";
31+
import { dirname, join } from "node:path";
32+
import { fileURLToPath } from "node:url";
33+
import { cleanup, render } from "@testing-library/react";
34+
import { afterEach, describe, expect, it, vi } from "vitest";
35+
import type { SessionDetail } from "../../types";
36+
import { fmtMs } from "../../utils/fmt";
37+
38+
const { invokeMock } = vi.hoisted(() => ({ invokeMock: vi.fn() }));
39+
vi.mock("@tauri-apps/api/core", () => ({ invoke: invokeMock }));
40+
41+
import SessionTriTrackView from "./SessionTriTrackView";
42+
43+
const HERE = dirname(fileURLToPath(import.meta.url));
44+
const VIEWS = join(HERE, "..");
45+
const ROOT = '[data-testid="session-tritrack-view"]';
46+
47+
/** 三轨夹具:**故意乱序**(9000 在 0 之后、3_725_000 在最后)⇒ 排序是判据而不是巧合 */
48+
function detailOf(over: Partial<SessionDetail> = {}): SessionDetail {
49+
return {
50+
session: {
51+
id: 1042,
52+
title: "构图与调色",
53+
source_window: "Chrome",
54+
started_at: 1_700_000_000_000,
55+
ended_at: 1_700_003_800_000,
56+
status: "finished",
57+
kind: null,
58+
},
59+
segments: [
60+
{ id: 9001, session_id: 1042, start_ms: 0, end_ms: 2_500, text: "开场:构图三要素", source: "subtitle", confidence: 0.9 },
61+
{ id: 9002, session_id: 1042, start_ms: 3_725_000, end_ms: 3_728_000, text: "一小时后的复盘", source: "asr", confidence: 0.7 },
62+
{ id: 9003, session_id: 1042, start_ms: 9_000, end_ms: 12_000, text: "第二段:色轮", source: "fused", confidence: 0.8 },
63+
],
64+
screens: [
65+
{ session_id: 1042, screen_id: 1, first_seen_ms: 9_000, last_seen_ms: 12_000, title: "调色面板", body: ["色相环"], labels: [], image_ref: "full/a.webp", structure: [] },
66+
{ session_id: 1042, screen_id: 2, first_seen_ms: 3_725_000, last_seen_ms: 3_730_000, title: null, body: ["图层面板"], labels: [], image_ref: null, structure: [] },
67+
],
68+
ocr_blocks: [
69+
{ id: 5001, session_id: 1042, timestamp_ms: 9_000, text: "色相 / 饱和度", score: 0.93, region: "full" },
70+
{ id: 5002, session_id: 1042, timestamp_ms: 500, text: "课程封面", score: 0.81, region: "subtitle" },
71+
],
72+
...over,
73+
};
74+
}
75+
const EMPTY: Partial<SessionDetail> = { segments: [], screens: [], ocr_blocks: [] };
76+
77+
/** 渲染并返回「根 + 轨道查询器」(`cleanup` 由 afterEach 统一做) */
78+
function mount(over: Partial<SessionDetail> = {}): { root: Element; items: (track: string) => HTMLElement[] } {
79+
const { container } = render(<SessionTriTrackView detail={detailOf(over)} />);
80+
const root = container.querySelector(ROOT);
81+
if (!root) throw new Error(`没有渲染出 ${ROOT}`);
82+
const items = (track: string): HTMLElement[] => {
83+
const lane = root.querySelector(`[data-track="${track}"]`);
84+
if (!lane) throw new Error(`没有渲染出轨道 ${track}`);
85+
return [...lane.children] as HTMLElement[];
86+
};
87+
return { root, items };
88+
}
89+
afterEach(cleanup);
90+
91+
/** 递归收集 `dir` 下的文件(正斜杠相对路径 + 绝对路径) */
92+
function walk(dir: string, out: string[] = []): string[] {
93+
for (const name of readdirSync(dir)) {
94+
const abs = join(dir, name);
95+
if (statSync(abs).isDirectory()) walk(abs, out);
96+
else out.push(abs);
97+
}
98+
return out;
99+
}
100+
/** 相对 `views/` 的展示路径(域锚用 `registry.ts` 这种短名,跨仪器对拍时不带盘符) */
101+
const relOf = (abs: string): string => abs.slice(VIEWS.length + 1).replace(/\\/g, "/");
102+
103+
describe("T4 不 invoke 且不带 Tauri(C14② 的第一条判据)", () => {
104+
it("T4① 三轨非空 + 整块空态两条路径都渲染 ⇒ `invoke` 零调用;T4② `views/**` 生产文件 0 个 `@tauri-apps`(含 import type)", () => {
105+
const nonEmpty = mount();
106+
expect(nonEmpty.items("transcript")).toHaveLength(3);
107+
cleanup();
108+
mount(EMPTY);
109+
cleanup();
110+
expect(invokeMock, "视图自己取数了(C14② 的「不 invoke」当场破)").not.toHaveBeenCalled();
111+
112+
const prod = walk(VIEWS).filter((abs) => /\.tsx?$/.test(abs) && !/\.test\.tsx?$/.test(abs));
113+
// 域非空自证:扫描器要是把域走空了,「0 命中」就是空真
114+
expect(prod.length, "views/** 的生产文件数为 0(域过滤失效 ⇒ 判据空真)").toBeGreaterThan(0);
115+
expect(prod.map(relOf)).toContain("registry.ts");
116+
expect(prod.some((abs) => abs.endsWith("SessionTriTrackView.tsx")), "被测文件自己不在扫描域里").toBe(true);
117+
118+
const readers = (src: string): boolean =>
119+
src
120+
.split(/\r?\n/)
121+
// 先滤注释行(`//` / `*` / `/*` 起首):v1 用裸 `includes("@tauri-apps")` 时,
122+
// `registry.ts` 的**头注释**("零 `@tauri-apps`(C14②)")与两个并行单元的注释都被误判成 import
123+
// ⇒ 本判据要的是**模块边**,不是「文件里出现过这个词」。批 1 的「文本扫描先剥注释」纪律。
124+
.filter((line) => !/^\s*(\/\/|\*|\/\*)/.test(line))
125+
.some(
126+
(line) =>
127+
/\bfrom\s*["']@tauri-apps(\/[^"']*)?["']/.test(line) ||
128+
/\b(?:import|require)\s*\(\s*["']@tauri-apps/.test(line),
129+
);
130+
const hits = prod.filter((abs) => readers(readFileSync(abs, "utf8"))).map(relOf);
131+
expect(hits, "views/** 里出现了 @tauri-apps 的 import 边(视图层不许碰 IPC)").toEqual([]);
132+
// 双侧自证:同一支仪器在**已知存在该 import** 的样本上必须命中、在注释提及上必须 0、无意义串必须 0
133+
const positive = join(VIEWS, "..", "components", "session-detail", "SessionScreenCards.tsx");
134+
const positiveSrc = readFileSync(positive, "utf8");
135+
expect(readers(positiveSrc), "阳性对照没命中 ⇒ 扫描器不可信(0 命中不算数)").toBe(true);
136+
expect(
137+
readers('// import { invoke } from "@tauri-apps/api/core";\n/** 零 `@tauri-apps` import */'),
138+
"注释里的提及被当成了 import 边(假阳性通道)",
139+
).toBe(false);
140+
expect(readers('const s = "本层零 @tauri-apps 依赖";'), "裸字符串被当成了 import 边").toBe(false);
141+
expect(readers('import type { WebviewWindow } from "@tauri-apps/api/webviewWindow";'), "`import type` 没被算成边(C14② 明文要求含它)").toBe(true);
142+
});
143+
});
144+
145+
describe("T1 条目数:三轨各自的直接子项数 == 数据长度", () => {
146+
it("满轨夹具 3/2/2 各就各位;单轨为空时该轨照常渲染(列在、列内 0 条)", () => {
147+
const full = mount();
148+
expect(full.items("transcript")).toHaveLength(3);
149+
expect(full.items("screen")).toHaveLength(2);
150+
expect(full.items("ocr")).toHaveLength(2);
151+
// 每个条目都带对齐锚 `data-ms`(三轨共轴的唯一凭据)
152+
for (const track of ["transcript", "screen", "ocr"]) {
153+
expect(full.items(track).filter((el) => el.hasAttribute("data-ms"))).toHaveLength(full.items(track).length);
154+
}
155+
cleanup();
156+
157+
// 换一套长度(1/0/4):把长度写死的实现会在这里红
158+
const oneOfEach = detailOf().segments.slice(0, 1);
159+
const ocr = detailOf().ocr_blocks.concat(detailOf().ocr_blocks);
160+
const { items } = mount({ segments: oneOfEach, screens: [], ocr_blocks: ocr });
161+
expect(items("transcript")).toHaveLength(1);
162+
expect(items("screen"), "单轨为空被整块空态顶掉了(用户看不到「哪一轨是空的」)").toHaveLength(0);
163+
expect(items("ocr")).toHaveLength(ocr.length);
164+
});
165+
});
166+
167+
describe("T2 纵向顺序:同 ms 三条目 = 转写 → 画面 → OCR,且每轨内按 ms 升序", () => {
168+
it("ms=9000 的三条目在 DOM 里依次 FOLLOWING;三条轨的 data-ms 序列各自非降", () => {
169+
const { items } = mount();
170+
const at = (track: string): HTMLElement => {
171+
const el = items(track).find((x) => x.dataset.ms === "9000");
172+
if (!el) throw new Error(`轨道 ${track} 里没有 ms=9000 的条目`);
173+
return el;
174+
};
175+
const following = (a: Element, b: Element): boolean =>
176+
(a.compareDocumentPosition(b) & Node.DOCUMENT_POSITION_FOLLOWING) !== 0;
177+
expect(following(at("transcript"), at("screen")), "转写没有排在画面之前").toBe(true);
178+
expect(following(at("screen"), at("ocr")), "画面没有排在 OCR 之前").toBe(true);
179+
expect(following(at("transcript"), at("ocr")), "转写没有排在 OCR 之前").toBe(true);
180+
181+
for (const track of ["transcript", "screen", "ocr"]) {
182+
const mss = items(track).map((el) => Number(el.dataset.ms));
183+
expect(mss.length, `轨道 ${track} 是空的(升序判据会空真)`).toBeGreaterThan(1);
184+
expect(mss, `轨道 ${track} 没有按 ms 升序`).toEqual([...mss].sort((a, b) => a - b));
185+
}
186+
});
187+
});
188+
189+
describe("T3 空态走原语(不是裸灰字)", () => {
190+
it("三数组都空 ⇒ `.ed-empty` + 标题逐字,0 个带颜色字面量的行内 style 节点;非空时不得出现空态", () => {
191+
const { root } = mount(EMPTY);
192+
expect(root.classList.contains("ed-empty"), "空态没走 EmptyState 原语").toBe(true);
193+
expect(root.textContent ?? "").toContain("本会话三轨无内容");
194+
expect(root.querySelectorAll("[data-track]"), "空态里还渲染着轨道").toHaveLength(0);
195+
const COLOR = /#[0-9a-fA-F]{3,8}|rgba?\(|hsla?\(/;
196+
const colored = [...root.querySelectorAll("*")].filter((el) => COLOR.test(el.getAttribute("style") ?? ""));
197+
expect(colored.map((el) => el.outerHTML.slice(0, 80)), "空态里出现了裸色值(不许用行内 style 写灰字)").toEqual([]);
198+
cleanup();
199+
200+
const { root: full } = mount();
201+
expect(full.classList.contains("ed-empty"), "非空会话被渲染成空态").toBe(false);
202+
expect(full.querySelectorAll("[data-track]")).toHaveLength(3);
203+
});
204+
});
205+
206+
describe("T5 时间码格式:逐字等于 `fmtMs` 的输出", () => {
207+
it("每个条目首个子节点 == fmtMs(data-ms);含 >1h 夹具(1:02:05)使「自己实现一个格式化」无法冒充", () => {
208+
const detail = detailOf();
209+
const { items } = mount();
210+
for (const track of ["transcript", "screen", "ocr"]) {
211+
for (const el of items(track)) {
212+
const ms = Number(el.dataset.ms);
213+
const time = el.children[0];
214+
expect(time.textContent, `${track}@${ms}:时间码不是 fmtMs 的输出`).toBe(fmtMs(ms));
215+
}
216+
}
217+
// 夹具自证:这条 >1h 的段存在,且它的时间码是 h:mm:ss 形态(naive `ms/1000` 给不出它)
218+
const overHour = items("transcript").find((el) => el.dataset.ms === "3725000");
219+
expect(overHour, "夹具里没有 >1h 的转写段(时间码判据会退化成 mm:ss 同形态 ⇒ 无区分力)").toBeDefined();
220+
expect(overHour?.children[0].textContent).toBe(fmtMs(3_725_000));
221+
expect(overHour?.children[0].textContent).not.toBe(`${Math.floor(3_725_000 / 1000)}s`);
222+
// 画面轨的区间尾码同样经 fmtMs(`first_seen_ms`–`last_seen_ms`)
223+
for (const [i, screen] of detail.screens.entries()) {
224+
const el = items("screen").find((x) => x.dataset.ms === String(screen.first_seen_ms));
225+
expect(el?.children[1].textContent, `屏 ${i} 的区间尾码`).toBe(`– ${fmtMs(screen.last_seen_ms)}`);
226+
}
227+
});
228+
});

0 commit comments

Comments
 (0)