Skip to content

Commit ad9d80d

Browse files
committed
test(sessions): pin detail panel before split
1 parent 2559a3f commit ad9d80d

1 file changed

Lines changed: 164 additions & 0 deletions

File tree

Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,164 @@
1+
// @vitest-environment jsdom
2+
/**
3+
* SessionDetailPanel.test.tsx — 拆件 A 的**安全网判据**(批 5 · T2 Step 1 · C9 逐字
4+
* 「无测试面的被拆文件先补行为级判据再拆」)。
5+
*
6+
* @ai-context: 该文件在批 5 开工时**无同名测试、全仓 0 处 import**(计划 §Task 2 的
7+
* 实测结论)⇒ 后面的「行为等价抽件」没有任何既有判据可依靠。本文件钉住
8+
* 四条**用户可见**的面板行为,抽件前后都必须逐字绿:
9+
* P1 默认视图是原文(每段一个 `seg-{sessionId}-{segId}` 锚点,计数 ==
10+
* `segments.length`;**含会话切换复位** `:64` 的 `useEffect` —— 变异
11+
* M1 实测证明:只把 `useState` 初值改成 `preview` **不会红**,因为该
12+
* effect 在挂载后立刻复位,把初值变异吞掉了 ⇒ 「默认视图是原文」这条
13+
* 行为的真身在 effect,不在初值)· P2 切换器存在且互斥切换(本任务仍是互斥渲染;
14+
* T10 会改成「默认视图常驻」)· P3 web 会话早退(无切换器、走
15+
* `WebArticleView`)· P4 工作台深链(`autoRefineTaskId` 到达即切预览,
16+
* 且 `onAutoTaskConsumed` **恰 1 次**)。
17+
*
18+
* @ai-context 仪器边界:只 mock **模块边界**(Tauri IPC 两模块 + 五个带 IPC 的子组件 +
19+
* `SessionScreenCards`),**不 mock `useSessionDetailData`** —— 真 hook 在
20+
* `viewMode` 变化时重渲、懒触发判定是真代码路径,正是 P1/P2 要覆盖的东西。
21+
* `invoke` 一律 reject(文案含 "not mocked"):真 hook 的三条取数走 catch
22+
* 降级分支(`quality` 保持 null、`glossary` 变 `[]`),**渲染结果确定且无
23+
* 异步 setState 竞态**(resolve 型桩会在用例结束后才 setState ⇒ act 噪声)。
24+
*/
25+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
26+
import { cleanup, fireEvent, render } from "@testing-library/react";
27+
import type { SessionDetail } from "../types";
28+
29+
const { invokeMock } = vi.hoisted(() => ({ invokeMock: vi.fn() }));
30+
vi.mock("@tauri-apps/api/core", () => ({
31+
invoke: invokeMock,
32+
convertFileSrc: (p: string) => `asset://localhost/${p}`,
33+
}));
34+
vi.mock("@tauri-apps/api/event", () => ({ listen: () => Promise.resolve(() => {}) }));
35+
36+
// 带 IPC / 重渲染面的子组件(各自有测试或不属本任务判据)→ 冻成薄桩
37+
vi.mock("./NotePreviewView", () => ({
38+
default: () => <div data-testid="note-preview-view" />,
39+
}));
40+
vi.mock("./WebArticleView", () => ({
41+
default: () => <div data-testid="web-article-view" />,
42+
}));
43+
vi.mock("./ImageGallery", () => ({ default: () => <div data-testid="image-gallery" /> }));
44+
vi.mock("./SecondPassPanel", () => ({
45+
default: () => <div data-testid="second-pass-panel" />,
46+
}));
47+
vi.mock("./ProofreadPanel", () => ({
48+
default: () => <div data-testid="proofread-panel" />,
49+
}));
50+
vi.mock("./session-detail/SessionScreenCards", () => ({
51+
default: () => <div data-testid="screen-cards" />,
52+
}));
53+
54+
import SessionDetailPanel from "./SessionDetailPanel";
55+
56+
function detailOf(over: Partial<SessionDetail> = {}): SessionDetail {
57+
return {
58+
session: {
59+
id: 1042,
60+
title: "化学反应速率",
61+
source_window: "Chrome",
62+
started_at: 1_700_000_000_000,
63+
ended_at: 1_700_000_600_000,
64+
status: "finished",
65+
kind: null,
66+
},
67+
segments: [
68+
{ id: 7001, session_id: 1042, start_ms: 0, end_ms: 2500, text: "第一段讲述", source: "subtitle", confidence: 0.9 },
69+
{ id: 7002, session_id: 1042, start_ms: 2500, end_ms: 5000, text: "第二段讲述", source: "asr", confidence: 0.7 },
70+
{ id: 7003, session_id: 1042, start_ms: 5000, end_ms: 8000, text: "第三段讲述", source: "fused", confidence: 0.8 },
71+
],
72+
ocr_blocks: [],
73+
screens: [],
74+
...over,
75+
};
76+
}
77+
78+
const noop = () => {};
79+
function propsOf(detail: SessionDetail, over: Record<string, unknown> = {}) {
80+
return {
81+
detail,
82+
fusing: false,
83+
degradedBanner: null,
84+
onToNote: noop,
85+
onRemove: noop,
86+
onRefreshDetail: noop,
87+
...over,
88+
};
89+
}
90+
91+
/** 段锚点集合:`seg-{sessionId}-{segId}` 形态的 id(屏卡锚点是 `ocr-…`,天然不混) */
92+
const segAnchors = (): string[] =>
93+
[...document.querySelectorAll("[id^='seg-']")].map((el) => el.id).sort();
94+
95+
const findButton = (label: string): HTMLButtonElement => {
96+
const hit = [...document.querySelectorAll("button")].find((b) => b.textContent?.includes(label));
97+
if (!hit) throw new Error(`找不到文案含「${label}」的按钮:实测 ${[...document.querySelectorAll("button")].map((b) => b.textContent).join(" | ")}`);
98+
return hit as HTMLButtonElement;
99+
};
100+
101+
beforeEach(() => {
102+
invokeMock.mockReset();
103+
invokeMock.mockImplementation((cmd: string) => Promise.reject(new Error(`invoke not mocked: ${cmd}`)));
104+
});
105+
afterEach(() => cleanup());
106+
107+
describe("SessionDetailPanel · 安全网(批 5 T2 Step 1;抽件前后逐字不变)", () => {
108+
it("P1 默认视图是原文:不点任何按钮,每段都在 DOM 里;切到预览后换会话 ⇒ 复位回原文", () => {
109+
const detail = detailOf();
110+
const view = render(<SessionDetailPanel {...propsOf(detail)} />);
111+
expect(segAnchors()).toEqual(["seg-1042-7001", "seg-1042-7002", "seg-1042-7003"]);
112+
expect(document.querySelectorAll("[id^='seg-']")).toHaveLength(detail.segments.length);
113+
// 原文视图的正文文本逐字在 DOM 里(不是"渲染不报错"这种弱判据)
114+
for (const seg of detail.segments) {
115+
expect(document.body.textContent).toContain(seg.text);
116+
}
117+
// `:64` 的复位 effect(裁决 D1:viewMode 状态留面板)—— 没有它,用户切到预览后
118+
// 换会话会停在预览视图(原文不默认可见)。这条是「默认视图是原文」的另一半。
119+
fireEvent.click(findButton("笔记预览"));
120+
expect(document.querySelector('[data-testid="note-preview-view"]')).not.toBeNull();
121+
const next = detailOf();
122+
next.session.id = 2043;
123+
next.segments = [{ id: 9001, session_id: 2043, start_ms: 0, end_ms: 1200, text: "新会话第一段", source: "asr", confidence: 0.5 }];
124+
view.rerender(<SessionDetailPanel {...propsOf(next)} />);
125+
expect(segAnchors()).toEqual(["seg-2043-9001"]);
126+
});
127+
128+
it("P2 切换器存在且会切:点「笔记预览」⇒ NotePreviewView 渲染 且 原文区不再可见(本任务仍是互斥渲染)", () => {
129+
render(<SessionDetailPanel {...propsOf(detailOf())} />);
130+
expect(document.querySelector('[data-testid="note-preview-view"]')).toBeNull();
131+
expect(document.querySelectorAll("[id^='seg-']").length).toBeGreaterThan(0);
132+
133+
fireEvent.click(findButton("笔记预览"));
134+
135+
expect(document.querySelector('[data-testid="note-preview-view"]')).not.toBeNull();
136+
expect(document.querySelectorAll("[id^='seg-']")).toHaveLength(0);
137+
// 切回原文:锚点全回来(互斥渲染的两个方向都判)
138+
fireEvent.click(findButton("原料视图"));
139+
expect(segAnchors()).toEqual(["seg-1042-7001", "seg-1042-7002", "seg-1042-7003"]);
140+
});
141+
142+
it("P3 web 会话早退:kind==='web' ⇒ 渲染 WebArticleView 且**切换器 0 个**(也没有段锚点)", () => {
143+
const detail = detailOf();
144+
detail.session.kind = "web";
145+
render(<SessionDetailPanel {...propsOf(detail)} />);
146+
expect(document.querySelector('[data-testid="web-article-view"]')).not.toBeNull();
147+
expect(findButtonSafe("笔记预览")).toBeNull();
148+
expect(findButtonSafe("原料视图")).toBeNull();
149+
expect(document.querySelectorAll("[id^='seg-']")).toHaveLength(0);
150+
});
151+
152+
it("P4 工作台深链:autoRefineTaskId 非空 ⇒ 切到 preview 且 onAutoTaskConsumed **恰 1 次**", () => {
153+
const onAutoTaskConsumed = vi.fn();
154+
render(<SessionDetailPanel {...propsOf(detailOf(), { autoRefineTaskId: 88, onAutoTaskConsumed })} />);
155+
expect(document.querySelector('[data-testid="note-preview-view"]')).not.toBeNull();
156+
expect(document.querySelectorAll("[id^='seg-']")).toHaveLength(0);
157+
expect(onAutoTaskConsumed).toHaveBeenCalledTimes(1);
158+
});
159+
});
160+
161+
/** P3 用:找不到按钮时返回 null(与 `findButton` 的"找不到即抛"刻意分开) */
162+
function findButtonSafe(label: string): HTMLButtonElement | null {
163+
return ([...document.querySelectorAll("button")].find((b) => b.textContent?.includes(label)) as HTMLButtonElement | undefined) ?? null;
164+
}

0 commit comments

Comments
 (0)