|
| 1 | +// @vitest-environment jsdom |
| 2 | +/** |
| 3 | + * SessionDetailHeader.test.tsx — 批 5 · T11(C13)的判据面(该文件**在批 5 开工时无同名测试**)。 |
| 4 | + * |
| 5 | + * @ai-context: C13 逐字「先补 1 条行为级判据(该文件无同名测试),判据形态 = 静态结构级 |
| 6 | + * (`position: sticky` ∧ `top` 来自 token)」⇒ 粘性改在**无网处**,本文件先立网。 |
| 7 | + * D1 **行为级**(改前就该绿、改后必须仍绿):标题/状态行/动作区可达 · ✎ 进改名 · |
| 8 | + * Esc 取消 · Enter 提交时 `update_session_title` 的**实参与调用时机**逐字不变 |
| 9 | + * (Enter 前 0 次 / Enter 后恰 1 次 / 同标题值 ⇒ 0 次)—— 粘性只许改样式, |
| 10 | + * **不许碰这条路径**(V2 的「invoke 调用点与时机零 diff」由 D1-c/D1-d 顶住)。 |
| 11 | + * D2/D3 **静态结构级**(改前应红、改后绿):`position: sticky` ∧ `top` 走 |
| 12 | + * **已在 `ui/tokens.css` 声明**的 token(不是裸数字、也不是编造的 var 名)∧ |
| 13 | + * `z-index` 走 `zIndex("raised")` 档位 ∧ 底色走 canvas token(防正文透出)。 |
| 14 | + * |
| 15 | + * @ai-context 仪器边界(**已写进 T11 报告的「未验证」单列**):jsdom **不排版** —— 它只把行内 |
| 16 | + * `style` 序列化成 attribute。`position: sticky` 与 `top` 是否**真的吸顶**、 |
| 17 | + * 4px 的观感、滚动时底色是否挡住正文,**本文件一行都测不出**;这是 C15「只能登记」 |
| 18 | + * 清单里的 `sticky 真实粘性`。⇒ D2/D3 是**结构判据**,不是粘性判据,**不许**当成 |
| 19 | + * 「粘性已验证」。 |
| 20 | + * @ai-context 已知分辨力缺口(诚实登记):`z-index` 的**数值**在 DOM 上与裸等值数字(`10`) |
| 21 | + * **不可分辨** ⇒ D3 只能证明「等于 raised 档、且不等于相邻档」;「源码里写的是档位 |
| 22 | + * 函数而非字面量」由 `ui/zIndex.guard.test.ts`(源码扫描,见其文件头 ④ 的口径) |
| 23 | + * 补 —— 两条仪器同批同变异体的读数见 T11 报告 §判据与变异。 |
| 24 | + */ |
| 25 | +import { readFileSync } from "node:fs"; |
| 26 | +import { dirname, join } from "node:path"; |
| 27 | +import { fileURLToPath } from "node:url"; |
| 28 | +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; |
| 29 | +import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; |
| 30 | +import type { SessionDetail } from "../../types"; |
| 31 | +import { zIndex } from "../../ui/zIndex"; |
| 32 | + |
| 33 | +const { invokeMock } = vi.hoisted(() => ({ invokeMock: vi.fn() })); |
| 34 | +vi.mock("@tauri-apps/api/core", () => ({ invoke: invokeMock })); |
| 35 | + |
| 36 | +import SessionDetailHeader from "./SessionDetailHeader"; |
| 37 | + |
| 38 | +const HERE = dirname(fileURLToPath(import.meta.url)); |
| 39 | + |
| 40 | +/** `ui/tokens.css` 里是否**声明**了该 token(`--ed-x:` / `--ed-x :` 两种写法都认) */ |
| 41 | +function declaresToken(name: string): boolean { |
| 42 | + const css = readFileSync(join(HERE, "..", "..", "ui", "tokens.css"), "utf8"); |
| 43 | + return new RegExp(`${name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\s*:`).test(css); |
| 44 | +} |
| 45 | + |
| 46 | +function detailOf(over: Partial<SessionDetail> = {}): SessionDetail { |
| 47 | + return { |
| 48 | + session: { |
| 49 | + id: 1042, |
| 50 | + title: "化学反应速率", |
| 51 | + source_window: "Chrome", |
| 52 | + started_at: 1_700_000_000_000, |
| 53 | + ended_at: 1_700_000_600_000, |
| 54 | + status: "finished", |
| 55 | + kind: null, |
| 56 | + }, |
| 57 | + segments: [ |
| 58 | + { id: 7001, session_id: 1042, start_ms: 0, end_ms: 2500, text: "第一段讲述", source: "subtitle", confidence: 0.9 }, |
| 59 | + { id: 7002, session_id: 1042, start_ms: 2500, end_ms: 5000, text: "第二段讲述", source: "asr", confidence: 0.7 }, |
| 60 | + ], |
| 61 | + ocr_blocks: [], |
| 62 | + screens: [], |
| 63 | + ...over, |
| 64 | + }; |
| 65 | +} |
| 66 | + |
| 67 | +const noop = () => {}; |
| 68 | +const Header = (over: Record<string, unknown> = {}) => ( |
| 69 | + <SessionDetailHeader |
| 70 | + detail={detailOf()} |
| 71 | + fusing={false} |
| 72 | + degradedBanner={null} |
| 73 | + onToNote={noop} |
| 74 | + onRemove={noop} |
| 75 | + onRefreshDetail={noop} |
| 76 | + {...over} |
| 77 | + /> |
| 78 | +); |
| 79 | + |
| 80 | +/** 粘性锚点的**行内** style(D2/D3 的唯一取数口;锚点不存在 ⇒ `getByTestId` 自己红,不静默返回空串) */ |
| 81 | +const headerStyle = (): CSSStyleDeclaration => (screen.getByTestId("session-header") as HTMLElement).style; |
| 82 | + |
| 83 | +/** 从 `var(--ed-x, fallback)` 里取出 token 名;形态不对 ⇒ 显式抛(断言失败要可归因) */ |
| 84 | +function tokenNameOf(value: string): string { |
| 85 | + const m = /^var\(\s*(--ed-[\w-]+)\s*(?:,[\s\S]*)?\)$/.exec(value); |
| 86 | + if (!m) throw new Error(`不是 token 形态:${JSON.stringify(value)}`); |
| 87 | + return m[1]; |
| 88 | +} |
| 89 | + |
| 90 | +beforeEach(() => { |
| 91 | + invokeMock.mockReset(); |
| 92 | + // 缺省 reject(与 `SessionDetailPanel.test.tsx` 同款):改名失败分支的断言只显式 resolve 的用例覆盖 |
| 93 | + invokeMock.mockImplementation((cmd: string) => Promise.reject(new Error(`invoke not mocked: ${cmd}`))); |
| 94 | +}); |
| 95 | +afterEach(() => cleanup()); |
| 96 | + |
| 97 | +describe("D1 · 行为级(改前就该绿;粘性改动不许动这条路径)", () => { |
| 98 | + it("D1-a 渲染即可达:标题 / 状态行 / 动作区 / ✎ 全在位,且渲染本身**零 IPC**", () => { |
| 99 | + render(<Header />); |
| 100 | + expect(screen.getByText("化学反应速率")).toBeTruthy(); |
| 101 | + expect(screen.getByTestId("session-rename-open")).toBeTruthy(); |
| 102 | + expect(screen.getByText("📝 转为笔记")).toBeTruthy(); |
| 103 | + expect(screen.getByText("删除")).toBeTruthy(); |
| 104 | + // 状态行是 header 的一部分(粘性后仍在头里,不被裁掉) |
| 105 | + expect(document.body.textContent).toContain("已完成 · 2 段转写 · 0 块画面"); |
| 106 | + expect(invokeMock).not.toHaveBeenCalled(); |
| 107 | + }); |
| 108 | + |
| 109 | + it("D1-b ✎ 进改名(预填原标题)· Esc 取消 ⇒ 输入框消失、标题回来、**零 IPC**", () => { |
| 110 | + render(<Header />); |
| 111 | + fireEvent.click(screen.getByTestId("session-rename-open")); |
| 112 | + const input = screen.getByTestId("session-title-input") as HTMLInputElement; |
| 113 | + expect(input.value).toBe("化学反应速率"); |
| 114 | + fireEvent.change(input, { target: { value: "改了一半就反悔" } }); |
| 115 | + fireEvent.keyDown(input, { key: "Escape" }); |
| 116 | + expect(screen.queryByTestId("session-title-input")).toBeNull(); |
| 117 | + expect(screen.getByText("化学反应速率")).toBeTruthy(); |
| 118 | + expect(invokeMock).not.toHaveBeenCalled(); |
| 119 | + }); |
| 120 | + |
| 121 | + it("D1-c Enter 提交:**时机**(Enter 前 0 次 → Enter 后恰 1 次)+ 实参逐字(已 trim)+ 成功后退出并重拉", async () => { |
| 122 | + const onRefreshDetail = vi.fn(); |
| 123 | + invokeMock.mockResolvedValue(undefined); |
| 124 | + render(<Header onRefreshDetail={onRefreshDetail} />); |
| 125 | + fireEvent.click(screen.getByTestId("session-rename-open")); |
| 126 | + const input = screen.getByTestId("session-title-input"); |
| 127 | + fireEvent.change(input, { target: { value: " 速率与平衡 " } }); |
| 128 | + expect(invokeMock, "改值本身不该发 IPC").toHaveBeenCalledTimes(0); |
| 129 | + fireEvent.keyDown(input, { key: "Enter" }); |
| 130 | + expect(invokeMock).toHaveBeenCalledTimes(1); |
| 131 | + expect(invokeMock).toHaveBeenCalledWith("update_session_title", { id: 1042, title: "速率与平衡" }); |
| 132 | + await waitFor(() => expect(screen.queryByTestId("session-title-input")).toBeNull()); |
| 133 | + expect(onRefreshDetail).toHaveBeenCalledWith(1042); |
| 134 | + expect(invokeMock, "成功后不该补发第二次 IPC").toHaveBeenCalledTimes(1); |
| 135 | + }); |
| 136 | + |
| 137 | + it("D1-d 不变量:trim 后与原标题相同 ⇒ **零 IPC** 且退出改名(拆分前的早退逐字保留)", () => { |
| 138 | + render(<Header />); |
| 139 | + fireEvent.click(screen.getByTestId("session-rename-open")); |
| 140 | + const input = screen.getByTestId("session-title-input"); |
| 141 | + fireEvent.change(input, { target: { value: " 化学反应速率 " } }); |
| 142 | + fireEvent.keyDown(input, { key: "Enter" }); |
| 143 | + expect(invokeMock).toHaveBeenCalledTimes(0); |
| 144 | + expect(screen.queryByTestId("session-title-input")).toBeNull(); |
| 145 | + }); |
| 146 | + |
| 147 | + it("D1-e 失败面未被顺手改动:reject ⇒ 留在改名态 + 错误行出现(逐字「改名失败: …」)", async () => { |
| 148 | + render(<Header />); |
| 149 | + fireEvent.click(screen.getByTestId("session-rename-open")); |
| 150 | + const input = screen.getByTestId("session-title-input"); |
| 151 | + fireEvent.change(input, { target: { value: "新标题" } }); |
| 152 | + fireEvent.keyDown(input, { key: "Enter" }); |
| 153 | + await waitFor(() => expect(document.body.textContent).toContain("改名失败: Error: invoke not mocked: update_session_title")); |
| 154 | + expect(screen.getByTestId("session-title-input")).toBeTruthy(); |
| 155 | + }); |
| 156 | +}); |
| 157 | + |
| 158 | +describe("D2 · 静态结构级(C13:`position: sticky` ∧ `top` 来自 token)", () => { |
| 159 | + it("D2-a `session-header` 是粘性头:`position: sticky`(行内 style 序列化里逐字可见)", () => { |
| 160 | + render(<Header />); |
| 161 | + expect(headerStyle().position).toBe("sticky"); |
| 162 | + expect(screen.getByTestId("session-header").getAttribute("style")).toContain("position: sticky"); |
| 163 | + }); |
| 164 | + |
| 165 | + it("D2-b `top` 来自 token:`var(--ed-…)` ∧ **该 token 在 tokens.css 已声明**(不许裸数字 / 编造 var 名)", () => { |
| 166 | + render(<Header />); |
| 167 | + const top = headerStyle().top; |
| 168 | + expect(top, `top 实测 ${JSON.stringify(top)}`).toMatch(/^var\(\s*--ed-[\w-]+/); |
| 169 | + expect(top, "top 不许是裸数字 / 裸像素").not.toMatch(/^-?[\d.]/); |
| 170 | + const name = tokenNameOf(top); |
| 171 | + expect(declaresToken(name), `top 引用的 ${name} 在 ui/tokens.css 里没有声明`).toBe(true); |
| 172 | + // 反例控制:编造的 var 名必须查不到(否则上一条是空真) |
| 173 | + expect(declaresToken("--ed-no-such-token-xyzq")).toBe(false); |
| 174 | + // 兜底值只许是**长度**(不是 `0` 那种"没写"的形态;兜底的存在理由见 T11 报告) |
| 175 | + expect(top).toContain(","); |
| 176 | + }); |
| 177 | + |
| 178 | + it("D2-c 底色走 canvas token(防正文从粘性头下透出;不许写色值字面量)", () => { |
| 179 | + render(<Header />); |
| 180 | + const bg = headerStyle().background; |
| 181 | + expect(declaresToken(tokenNameOf(bg)), `background = ${JSON.stringify(bg)}`).toBe(true); |
| 182 | + expect(bg, "底色不许出现色值字面量").not.toMatch(/#[0-9a-f]{3,8}|rgba?\(/i); |
| 183 | + }); |
| 184 | +}); |
| 185 | + |
| 186 | +describe("D3 · 层级走标尺(ADR-032 决策 5:不得写裸数字 z-index)", () => { |
| 187 | + it("D3 `session-header` 的 z-index = `zIndex(\"raised\")` 档位值,且与相邻档不同(有分辨力)", () => { |
| 188 | + render(<Header />); |
| 189 | + const z = headerStyle().zIndex; |
| 190 | + // 反例控制:仪器能分辨档位(否则「等于 raised」这条对任何值都成立 ⇒ 空真) |
| 191 | + expect(zIndex("raised")).not.toBe(zIndex("panel")); |
| 192 | + expect(z).toBe(String(zIndex("raised"))); |
| 193 | + expect(z).not.toBe(String(zIndex("panel"))); |
| 194 | + }); |
| 195 | +}); |
0 commit comments