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