|
| 1 | +// @vitest-environment jsdom |
| 2 | +/** |
| 3 | + * @ai-context Waveform.test.tsx — 波形读数的 **jsdom 面**判据(批 6 Task 18 Step 2;裁决 R4.3)。 |
| 4 | + * |
| 5 | + * Why 每条判据各带**专属变异体**(R8.6 / R41.7:主闸是**期望比对** —— 红必须红在具名断言上, |
| 6 | + * `ran > 0` 只是旁证),且都从**渲染出的 DOM** 读: |
| 7 | + * · W1 条阵列 = div 条 / 条数 == bars / 索引逐序 / **0 个内联 svg** ⇒ 变异体 = 把一条换成 svg 元素 |
| 8 | + * (期望两条独立红:① 本文件 W1 的 svg 计数非 0;② `src/ui/icons/no-inline-svg.test.ts` 红 —— |
| 9 | + * 后者是**全文件文本扫描**型守卫,故本文件注释与测试名里提到那个标签时也一律**拼接写**)。 |
| 10 | + * · W2 映射:取样点的点亮条数**逐序等于硬编码期望**(不是从实现反推)+ 全程单调不减 + rms=0 全灭 |
| 11 | + * ⇒ 变异体 = `Math.round` 换 `Math.floor` 并把系数放大 10×(期望数组当场不等)。 |
| 12 | + * · W3 削波热区:`data-clip` 与 `backgroundColor` 两个**逐序数组** ⇒ 变异体 = 去掉 `clipping &&` |
| 13 | + * (非削波时也染状态戳色 ⇒ W3b 红)。 |
| 14 | + * · W4 零颜色字面量:两件生产件剥注释后 hex 0 命中 ∧ 渲染出的每个着色面都是 `var(--ed-*)` |
| 15 | + * ⇒ 变异体 = 写 `backgroundColor: "#0d9488"`。 |
| 16 | + * |
| 17 | + * 副作用:无(只读磁盘 + 只挂载 jsdom 容器)。边界:jsdom **无排版引擎** ⇒ 本文件判得到 |
| 18 | + * 「条数 / 次序 / 色值 / 属性」,**判不到**「波形看起来像波形」(观感面进报告 `## 诚实边界`)。 |
| 19 | + */ |
| 20 | +import { cleanup, render } from "@testing-library/react"; |
| 21 | +import { readFileSync } from "node:fs"; |
| 22 | +import { dirname, join } from "node:path"; |
| 23 | +import { fileURLToPath } from "node:url"; |
| 24 | +import { afterEach, describe, expect, it } from "vitest"; |
| 25 | +import Waveform, { |
| 26 | + WAVEFORM_BARS, |
| 27 | + WAVEFORM_CLIP_BARS, |
| 28 | + WAVEFORM_FLOOR_DB, |
| 29 | + inClipZone, |
| 30 | + litBars, |
| 31 | +} from "./Waveform"; |
| 32 | + |
| 33 | +afterEach(cleanup); |
| 34 | + |
| 35 | +const HERE = dirname(fileURLToPath(import.meta.url)); |
| 36 | +/** 判据要扫的**两件生产件**(计划 V3 逐字点名 `Waveform.tsx` 与 `shell/LiveBar.tsx`)。 */ |
| 37 | +const PRODUCTION = ["Waveform.tsx", join("..", "shell", "LiveBar.tsx")] as const; |
| 38 | +/** 剥注释(本批已三犯「注释里的字面量骗过整文件扫描器」):块注释 + 行注释各抹一次。 */ |
| 39 | +const stripComments = (s: string): string => |
| 40 | + s.replace(/\/\*[\s\S]*?\*\//g, "").replace(/(^|[^:])\/\/[^\n]*/g, "$1"); |
| 41 | +/** `#` + 3/6 位 hex(含 4/8 位带 alpha 的形态:漏掉它等于给棘轮开后门)。 */ |
| 42 | +const HEX_LITERAL = /#[0-9a-fA-F]{3,8}\b/g; |
| 43 | + |
| 44 | +/** 波形根元素(`testId` 默认 `waveform`)。 */ |
| 45 | +function root(): HTMLElement { |
| 46 | + const el = document.body.querySelector<HTMLElement>('[data-testid="waveform"]'); |
| 47 | + if (el === null) throw new Error("找不到波形根元素(data-testid=waveform)"); |
| 48 | + return el; |
| 49 | +} |
| 50 | +/** 逐条子元素(**按序**读,供逐序数组比对)。 */ |
| 51 | +const barsOf = (el: HTMLElement): HTMLElement[] => Array.from(el.children) as HTMLElement[]; |
| 52 | + |
| 53 | +describe("W1 · div 条阵列(R4.3:禁用内联 svg)", () => { |
| 54 | + it("条全是 div · 条数 == bars · 索引逐序 0..bars-1 · 内联 svg 计数为 0", () => { |
| 55 | + render(<Waveform rms={0.3} clipping={false} bars={24} />); |
| 56 | + const el = root(); |
| 57 | + expect(el.querySelectorAll("svg"), "出现了内联 svg 元素(R4.3 明禁)").toHaveLength(0); |
| 58 | + const bars = barsOf(el); |
| 59 | + expect(bars.map((b) => b.tagName), "条元素必须全是 div(R4.3「div 条阵列」)").toEqual( |
| 60 | + Array.from({ length: 24 }, () => "DIV"), |
| 61 | + ); |
| 62 | + expect(bars.map((b) => b.getAttribute("data-bar")), "条按序编号 0..23").toEqual( |
| 63 | + Array.from({ length: 24 }, (_, i) => String(i)), |
| 64 | + ); |
| 65 | + expect(el.getAttribute("data-bars"), "根上的条数读数 == 传入的 bars").toBe("24"); |
| 66 | + }); |
| 67 | + |
| 68 | + it("条数读数跟手:bars=7 与 bars=1 各渲染对应条数(防「常数条数」假绿)", () => { |
| 69 | + render(<Waveform rms={0.3} clipping={false} bars={7} />); |
| 70 | + expect(barsOf(root())).toHaveLength(7); |
| 71 | + cleanup(); |
| 72 | + render(<Waveform rms={0.3} clipping={false} bars={1} />); |
| 73 | + expect(barsOf(root())).toHaveLength(1); |
| 74 | + }); |
| 75 | + |
| 76 | + it("边界:`bars <= 0` 一条不渲染(负值 / 0 都不许把假条数流进 flex 计算)", () => { |
| 77 | + render(<Waveform rms={0.5} clipping={false} bars={0} />); |
| 78 | + expect(barsOf(root())).toHaveLength(0); |
| 79 | + cleanup(); |
| 80 | + render(<Waveform rms={0.5} clipping={false} bars={-3} />); |
| 81 | + expect(barsOf(root()), "负条数必须归零").toHaveLength(0); |
| 82 | + expect(root().getAttribute("data-bars")).toBe("0"); |
| 83 | + }); |
| 84 | + |
| 85 | + it("默认条数 == 导出的具名常量(R27.1:数字只有一个落点,别处不许再写 32)", () => { |
| 86 | + render(<Waveform rms={0.3} clipping={false} />); |
| 87 | + expect(root().getAttribute("data-bars")).toBe(String(WAVEFORM_BARS)); |
| 88 | + expect(barsOf(root())).toHaveLength(WAVEFORM_BARS); |
| 89 | + }); |
| 90 | +}); |
| 91 | + |
| 92 | +describe("W2 · RMS → 点亮条数(对数映射 · 单调不减)", () => { |
| 93 | + it("取样点上的读数**逐序等于硬编码期望**(期望不是从实现反推的)", () => { |
| 94 | + const samples = [0, 0.001, 0.01, 0.0316, 0.1, 0.316, 1]; |
| 95 | + expect(samples.map((r) => litBars(r, 32))).toEqual([0, 0, 11, 16, 21, 27, 32]); |
| 96 | + expect(samples.map((r) => litBars(r, 24))).toEqual([0, 0, 8, 12, 16, 20, 24]); |
| 97 | + expect(litBars(1, 0), "0 条时上限就是 0").toBe(0); |
| 98 | + expect(inClipZone(0, 6), "热区在右端(左端不是热区)").toBe(false); |
| 99 | + expect(inClipZone(5, 6)).toBe(true); |
| 100 | + expect(inClipZone(6 - WAVEFORM_CLIP_BARS, 6), "热区起点 = bars - CLIP_BARS").toBe(true); |
| 101 | + expect(WAVEFORM_FLOOR_DB, "映射下界是具名常量(-60dB)").toBe(-60); |
| 102 | + }); |
| 103 | + |
| 104 | + it("单调不减:0..1 全程 2001 个取样点都不出现回退(越响越短 = 读数坏了)", () => { |
| 105 | + const counts = Array.from({ length: 2001 }, (_, i) => litBars(i / 2000, 32)); |
| 106 | + const drops = counts.filter((n, i) => i > 0 && n < counts[i - 1]); |
| 107 | + expect(drops, "出现回退点 ⇒ 波形会「越响越短」").toEqual([]); |
| 108 | + expect(counts[0], "rms=0 ⇒ 0 条").toBe(0); |
| 109 | + expect(counts[2000], "rms=1 ⇒ 满格").toBe(32); |
| 110 | + }); |
| 111 | + |
| 112 | + it("越界一律夹取:NaN / 负值 / Infinity ⇒ 0 条(永不抛、永不把 NaN 漏进 style)", () => { |
| 113 | + expect([Number.NaN, -1, -0.5, Number.POSITIVE_INFINITY].map((r) => litBars(r, 32))).toEqual([0, 0, 0, 0]); |
| 114 | + expect(litBars(2, 32), "超过满刻度的 rms 也不许超过条数").toBe(32); |
| 115 | + }); |
| 116 | + |
| 117 | + it("rms=0 ⇒ **双断言**:根上 data-lit == 0 **且**逐条 data-lit 全 false", () => { |
| 118 | + render(<Waveform rms={0} clipping={false} bars={8} />); |
| 119 | + const el = root(); |
| 120 | + expect(el.getAttribute("data-lit")).toBe("0"); |
| 121 | + expect(barsOf(el).map((b) => b.getAttribute("data-lit"))).toEqual( |
| 122 | + Array.from({ length: 8 }, () => "false"), |
| 123 | + ); |
| 124 | + }); |
| 125 | + |
| 126 | + it("DOM 与纯函数同源:每条的 data-lit 就是 `i < litBars(...)`(逐序数组,不是抽查)", () => { |
| 127 | + render(<Waveform rms={0.1} clipping={false} bars={24} />); |
| 128 | + const expected = Array.from({ length: 24 }, (_, i) => (i < litBars(0.1, 24) ? "true" : "false")); |
| 129 | + expect(barsOf(root()).map((b) => b.getAttribute("data-lit"))).toEqual(expected); |
| 130 | + expect(expected.filter((v) => v === "true"), "点亮 16 条(不是 0 也不是 24)").toHaveLength(16); |
| 131 | + }); |
| 132 | +}); |
| 133 | + |
| 134 | +describe("W3 · 削波热区(clipping ⇒ 右端两条染状态戳色)", () => { |
| 135 | + it("clipping ⇒ 热区标记与色值都落在**右端恰 2 条**(两个逐序数组)", () => { |
| 136 | + render(<Waveform rms={1} clipping bars={6} />); |
| 137 | + const bars = barsOf(root()); |
| 138 | + expect(bars.map((b) => b.getAttribute("data-clip"))).toEqual([ |
| 139 | + "false", "false", "false", "false", "true", "true", |
| 140 | + ]); |
| 141 | + expect(bars.map((b) => b.style.backgroundColor)).toEqual([ |
| 142 | + "var(--ed-ink-1)", "var(--ed-ink-1)", "var(--ed-ink-1)", "var(--ed-ink-1)", |
| 143 | + "var(--ed-stamp)", "var(--ed-stamp)", |
| 144 | + ]); |
| 145 | + }); |
| 146 | + |
| 147 | + it("非削波 ⇒ 热区标记与状态戳色都不出现(反例守卫:防「恒热」假绿)", () => { |
| 148 | + render(<Waveform rms={1} clipping={false} bars={6} />); |
| 149 | + const bars = barsOf(root()); |
| 150 | + expect(bars.map((b) => b.getAttribute("data-clip"))).toEqual(Array.from({ length: 6 }, () => "false")); |
| 151 | + expect( |
| 152 | + bars.map((b) => b.style.backgroundColor).filter((c) => c.includes("stamp")), |
| 153 | + "非削波时不许有状态戳色", |
| 154 | + ).toEqual([]); |
| 155 | + }); |
| 156 | + |
| 157 | + it("未点亮的条是**底槽**不是色块:rms=0.01(11/32)时前 11 条上墨、其余是底槽", () => { |
| 158 | + render(<Waveform rms={0.01} clipping bars={32} />); |
| 159 | + const colors = barsOf(root()).map((b) => b.style.backgroundColor); |
| 160 | + expect(colors.slice(0, 11), "点亮段(含热区之外的 9 条)").toEqual( |
| 161 | + Array.from({ length: 11 }, () => "var(--ed-ink-1)"), |
| 162 | + ); |
| 163 | + expect(colors.slice(11), "未点亮段一律底槽色(削波也不许把底槽染色)").toEqual( |
| 164 | + Array.from({ length: 21 }, () => "var(--ed-bg-sunken)"), |
| 165 | + ); |
| 166 | + }); |
| 167 | +}); |
| 168 | + |
| 169 | +describe("W4 · 零颜色字面量(色值只在 ui/tokens.css 与 token 生成器里)", () => { |
| 170 | + it("两件生产件**剥注释后** hex 字面量 0 命中", () => { |
| 171 | + const hits = PRODUCTION.flatMap((rel) => |
| 172 | + [...stripComments(readFileSync(join(HERE, rel), "utf8")).matchAll(HEX_LITERAL)].map( |
| 173 | + (m) => `${rel}: ${m[0]}`, |
| 174 | + ), |
| 175 | + ); |
| 176 | + expect(hits, "新文件里出现颜色字面量(一律走 var(--ed-*))").toEqual([]); |
| 177 | + // 仪器自证:同一支正则对一条已知含字面量的样本必须报出来(否则上面那条是空真) |
| 178 | + expect([..."backgroundColor: \"#0d9488\"".matchAll(HEX_LITERAL)].map((m) => m[0])).toEqual(["#0d9488"]); |
| 179 | + }); |
| 180 | + |
| 181 | + it("行为级同向:渲染出的每个着色面都是 `var(--ed-*)`(读的是真产出,不是源码文本)", () => { |
| 182 | + render(<Waveform rms={1} clipping bars={6} />); |
| 183 | + const colors = barsOf(root()).map((b) => b.style.backgroundColor); |
| 184 | + expect(colors.filter((c) => !c.startsWith("var(--ed-")), "有非 token 色").toEqual([]); |
| 185 | + expect(colors.length, "读到 0 个着色面 ⇒ 本条是空真").toBe(6); |
| 186 | + }); |
| 187 | +}); |
0 commit comments