|
| 1 | +// @vitest-environment jsdom |
| 2 | +/** |
| 3 | + * @ai-context PhaseChrome.test.tsx — 相变 chrome 的两态叠层 + LIVE 仪表**接线宿主**的 jsdom 面判据 |
| 4 | + * (批 6 T19;规格 §6.3 / §8.4 / R4.4 / R46②)。 |
| 5 | + * |
| 6 | + * Why 每条判据各带**专属变异体**(R8.6 / R41.7:主闸是**期望比对**,`ran > 0` 只是旁证): |
| 7 | + * · C1 叠层**恰两层、逐序**(`[idle, live]`)且三层类名逐字来自导出的常量 |
| 8 | + * ⇒ 变异体 = 对调两层顺序(交叉淡入的方向语义随之反转)。 |
| 9 | + * · C2 三个相位下 `inert` 的**逐序矩阵**(含回切)⇒ 变异体 = 给两层写同一个 `inert={false}` |
| 10 | + * (不可见的那层会变成可 Tab 聚焦 = 「零 chrome」变成视觉假话)。 |
| 11 | + * · C3 常态 chrome 的 children 只落在 idle 层里 ⇒ 变异体 = 把 children 渲染到 live 层。 |
| 12 | + * · C4 三键各自打到**自己的**动作上、`onMark` 打的是**既有命令名** `save_user_screenshot` |
| 13 | + * (逐序计数数组)⇒ 变异体 = 把 `onStop` 接到 `pause()`(停止键会变成暂停)。 |
| 14 | + * · C5 `paused` 的两处语义(`data-paused` + 暂停键可访问名翻转)逐序 ⇒ 变异体 = 恒传 `paused={false}`。 |
| 15 | + * · C6 `elapsedMs` **只在采集中**走表(不采集时连计时器都不起)⇒ 变异体 = 删掉 `if (!active)` 那一支 |
| 16 | + * (不采集也在走表 = 仪表报假读数)。 |
| 17 | + * |
| 18 | + * 副作用:只挂 jsdom 容器;`@tauri-apps/api/{core,event}` 与**采集单一状态源**按仓内既有范式桩掉 |
| 19 | + * (`LiveBar.test.tsx` / `KnowledgePage.test.tsx` 同款),绝不连真后端。 |
| 20 | + * 边界:jsdom 无排版引擎 ⇒ 判得到「层序 / 类名 / 属性 / 接线」,**判不到**「真的交叉淡入了 220ms」 |
| 21 | + * 「真的零 chrome」(像素与合成器面进报告 `## 诚实边界`)。 |
| 22 | + */ |
| 23 | +import { act, cleanup, fireEvent, render } from "@testing-library/react"; |
| 24 | +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; |
| 25 | +import { invoke } from "@tauri-apps/api/core"; |
| 26 | +import { fmtMs } from "../utils/fmt"; |
| 27 | +import PhaseChrome, { |
| 28 | + LiveBarHost, |
| 29 | + PHASE_CHROME_CLASS, |
| 30 | + PHASE_IDLE_CLASS, |
| 31 | + PHASE_LIVE_CLASS, |
| 32 | +} from "./PhaseChrome"; |
| 33 | +import type { ShellPhase } from "./shellPhase"; |
| 34 | + |
| 35 | +/** 采集单一状态源的桩:`LiveBarHost` 只消费 `active` / `pausedReason` / 三个动作(其余面不碰)。 */ |
| 36 | +const host = vi.hoisted(() => ({ |
| 37 | + control: { |
| 38 | + active: false, |
| 39 | + pausedReason: null as "manual" | "media" | "foreground" | null, |
| 40 | + pause: vi.fn(() => Promise.resolve({ ok: true })), |
| 41 | + resume: vi.fn(() => Promise.resolve({ ok: true })), |
| 42 | + stop: vi.fn(() => Promise.resolve({ ok: true })), |
| 43 | + }, |
| 44 | +})); |
| 45 | +vi.mock("../hooks/useLiveCaptureControl", () => ({ useCaptureControl: () => host.control })); |
| 46 | +vi.mock("@tauri-apps/api/event", () => ({ listen: vi.fn(() => Promise.resolve(() => {})) })); |
| 47 | +vi.mock("@tauri-apps/api/core", () => ({ invoke: vi.fn(() => Promise.resolve("ok")) })); |
| 48 | + |
| 49 | +beforeEach(() => { |
| 50 | + host.control.active = false; |
| 51 | + host.control.pausedReason = null; |
| 52 | + host.control.pause.mockClear(); |
| 53 | + host.control.resume.mockClear(); |
| 54 | + host.control.stop.mockClear(); |
| 55 | + vi.mocked(invoke).mockClear(); |
| 56 | +}); |
| 57 | +afterEach(cleanup); |
| 58 | + |
| 59 | +/** 取一个必须存在的元素(缺了就抛 —— 不许用 `!` 把缺失静默成 `undefined`)。 */ |
| 60 | +function need(selector: string): HTMLElement { |
| 61 | + const el = document.body.querySelector<HTMLElement>(selector); |
| 62 | + if (el === null) throw new Error(`找不到元素:${selector}`); |
| 63 | + return el; |
| 64 | +} |
| 65 | +const byId = (id: string): HTMLElement => need(`[data-testid="${id}"]`); |
| 66 | +/** 一次快照:`[idle 层 inert?, live 层 inert?, 容器 data-phase]`(逐序比对用)。 */ |
| 67 | +const snapshot = (): readonly [boolean, boolean, string | null] => [ |
| 68 | + byId("phase-idle").hasAttribute("inert"), |
| 69 | + byId("phase-live").hasAttribute("inert"), |
| 70 | + byId("phase-chrome").getAttribute("data-phase"), |
| 71 | +]; |
| 72 | + |
| 73 | +describe("C1~C3 两态叠层的结构、`inert` 矩阵与 children 归属", () => { |
| 74 | + it("C1:容器恰两层、逐序 [phase-idle, phase-live],三层类名逐字 == 导出的常量", () => { |
| 75 | + const { container } = render( |
| 76 | + <PhaseChrome phase="idle"> |
| 77 | + <span data-testid="idle-probe">A′ 顶栏</span> |
| 78 | + </PhaseChrome>, |
| 79 | + ); |
| 80 | + const chrome = container.querySelector<HTMLElement>('[data-testid="phase-chrome"]'); |
| 81 | + if (chrome === null) throw new Error("相变容器没渲染出来"); |
| 82 | + const layers = [...chrome.children] as HTMLElement[]; |
| 83 | + expect(layers.map((l) => l.getAttribute("data-testid")), "两态叠层的层序变了(交叉淡入方向随之反转)").toEqual([ |
| 84 | + "phase-idle", |
| 85 | + "phase-live", |
| 86 | + ]); |
| 87 | + expect( |
| 88 | + [chrome.className, ...layers.map((l) => l.className)], |
| 89 | + "类名是**跨文件契约**:相位块的选择器靠它们命中(改一边必红)", |
| 90 | + ).toEqual([PHASE_CHROME_CLASS, PHASE_IDLE_CLASS, PHASE_LIVE_CLASS]); |
| 91 | + expect(layers.length, "多出第三层 = 容器高度出现第二个竞争者").toBe(2); |
| 92 | + }); |
| 93 | + |
| 94 | + it("C2:`inert` 在三个相位下的逐序矩阵(含回切)—— 不可见的那层必须不可聚焦", () => { |
| 95 | + const phases: readonly ShellPhase[] = ["idle", "capture", "review", "capture", "idle"]; |
| 96 | + const { rerender } = render( |
| 97 | + <PhaseChrome phase="idle"> |
| 98 | + <i /> |
| 99 | + </PhaseChrome>, |
| 100 | + ); |
| 101 | + const rows = phases.map((phase, i) => { |
| 102 | + if (i > 0) rerender(<PhaseChrome phase={phase}><i /></PhaseChrome>); |
| 103 | + return snapshot(); |
| 104 | + }); |
| 105 | + expect(rows, "idle ⇒ 常态层可交互;capture ⇒ 采集层可交互;review ⇒ 两层都不可交互").toEqual([ |
| 106 | + [false, true, "idle"], |
| 107 | + [true, false, "capture"], |
| 108 | + [true, true, "review"], |
| 109 | + [true, false, "capture"], |
| 110 | + [false, true, "idle"], |
| 111 | + ]); |
| 112 | + }); |
| 113 | + |
| 114 | + it("C3:常态 chrome(children)只落在 idle 层里,live 层里只有 LIVE 仪表", () => { |
| 115 | + render( |
| 116 | + <PhaseChrome phase="capture"> |
| 117 | + <span data-testid="idle-probe">A′ 顶栏</span> |
| 118 | + </PhaseChrome>, |
| 119 | + ); |
| 120 | + expect(byId("phase-idle").querySelector('[data-testid="idle-probe"]'), "常态 chrome 丢了").toBeTruthy(); |
| 121 | + expect(byId("phase-live").querySelector('[data-testid="idle-probe"]'), "常态 chrome 落进了采集层").toBeNull(); |
| 122 | + expect(byId("phase-live").querySelector('[data-testid="live-bar"]'), "采集层里没有 LIVE 仪表").toBeTruthy(); |
| 123 | + expect(byId("phase-idle").querySelector('[data-testid="live-bar"]')).toBeNull(); |
| 124 | + }); |
| 125 | +}); |
| 126 | + |
| 127 | +describe("C4~C6 LIVE 仪表接线宿主:三键 / 暂停语义 / 计时窗口", () => { |
| 128 | + it("C4:三键各自打到自己的动作上,`onMark` 打的是**既有命令名**(逐序计数数组)", () => { |
| 129 | + host.control.active = true; |
| 130 | + render(<LiveBarHost />); |
| 131 | + fireEvent.click(byId("live-pause")); |
| 132 | + fireEvent.click(byId("live-mark")); |
| 133 | + fireEvent.click(byId("live-stop")); |
| 134 | + expect([ |
| 135 | + host.control.pause.mock.calls.length, |
| 136 | + host.control.resume.mock.calls.length, |
| 137 | + host.control.stop.mock.calls.length, |
| 138 | + vi.mocked(invoke).mock.calls.map((c) => c[0]), |
| 139 | + ]).toEqual([1, 0, 1, ["save_user_screenshot"]]); |
| 140 | + }); |
| 141 | + |
| 142 | + it("C4 变体:暂停中按「继续」走 resume(不串台到 pause)", () => { |
| 143 | + host.control.active = true; |
| 144 | + host.control.pausedReason = "manual"; |
| 145 | + render(<LiveBarHost />); |
| 146 | + fireEvent.click(byId("live-pause")); |
| 147 | + expect([host.control.resume.mock.calls.length, host.control.pause.mock.calls.length]).toEqual([1, 0]); |
| 148 | + }); |
| 149 | + |
| 150 | + it("C5:`pausedReason` 逐序映射到 `data-paused` + 暂停键可访问名(null ⇒ 暂停 / 非 null ⇒ 继续)", () => { |
| 151 | + const cases: ReadonlyArray<"manual" | null> = [null, "manual", null]; |
| 152 | + const { rerender } = render(<LiveBarHost />); |
| 153 | + const rows = cases.map((reason, i) => { |
| 154 | + host.control.pausedReason = reason; |
| 155 | + if (i > 0) rerender(<LiveBarHost />); |
| 156 | + const bar = byId("live-bar"); |
| 157 | + return [bar.getAttribute("data-paused"), byId("live-pause").textContent ?? ""] as const; |
| 158 | + }); |
| 159 | + expect(rows).toEqual([ |
| 160 | + ["false", "暂停"], |
| 161 | + ["true", "继续"], |
| 162 | + ["false", "暂停"], |
| 163 | + ]); |
| 164 | + }); |
| 165 | + |
| 166 | + it("C6:`elapsedMs` 只在采集进行中走表(不采集时连计时器都不起)", () => { |
| 167 | + vi.useFakeTimers(); |
| 168 | + try { |
| 169 | + const { rerender } = render(<LiveBarHost />); |
| 170 | + act(() => { |
| 171 | + vi.advanceTimersByTime(5000); |
| 172 | + }); |
| 173 | + const idleReading = byId("live-elapsed").textContent ?? ""; |
| 174 | + host.control.active = true; |
| 175 | + rerender(<LiveBarHost />); |
| 176 | + act(() => { |
| 177 | + vi.advanceTimersByTime(3000); |
| 178 | + }); |
| 179 | + const liveReading = byId("live-elapsed").textContent ?? ""; |
| 180 | + expect([idleReading, liveReading], "不采集也在走表 = 仪表报假读数").toEqual([fmtMs(0), fmtMs(3000)]); |
| 181 | + expect([idleReading, liveReading]).toEqual(["00:00", "00:03"]); |
| 182 | + } finally { |
| 183 | + vi.useRealTimers(); |
| 184 | + } |
| 185 | + }); |
| 186 | +}); |
0 commit comments