Skip to content

Commit b2afd6d

Browse files
committed
feat(motion): 新建波形组件(div 条阵列)
1 parent a9cf152 commit b2afd6d

2 files changed

Lines changed: 308 additions & 0 deletions

File tree

Lines changed: 187 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,187 @@
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+
});

‎app/src/components/Waveform.tsx‎

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
/**
2+
* Waveform — 采集态 LIVE 仪表的**波形读数**(规格 §6.3;裁决 R4.3;批 6 Task 18 Step 1)。
3+
*
4+
* @ai-context Why:§6.3 逐字「采集态 = 58px **LIVE 仪表**(波形 + 计时 + 暂停/标记/停止)」,
5+
* 而改造前全仓「波形 / waveform」**0 命中** —— 只有 `components/AudioLevelMeter.tsx`(88 行)的
6+
* 12 段离散色块。本件补上「读数」的那一半:`rms` 决定点亮条数(对数映射,与那张 VU 表同口径),
7+
* `clipping` 决定右端热区是否染成状态戳色。**新建而不改既有组件**:动 `AudioLevelMeter` 的 DOM
8+
* 会连带三处棘轮台账(`statusLineBaseline` 3 / `surfaceBaseline` 1 / `textBaseline` 3,R4.3 逐字)。
9+
* @ai-context ★ 为什么是 div 条阵列而不是别的画法(R4.3 逐字「用 div 条阵列建,**禁止** SVG」):
10+
* `ui/icons/` 的 no-inline-svg 守卫是**全文件文本扫描**型(连注释一起扫)⇒ 本文件与同名测试里
11+
* 提到那个标签时必须用**拼接写法**("<" + "svg"),否则守卫会在**文字**上假红 —— 本批已三犯的
12+
* 「注释/测试名里的字面量骗过整文件扫描器」陷阱家族(DISPATCH-TEMPLATE §二 点名提醒过)。
13+
* @ai-context 副作用:**无**。纯受控展示件:零 IPC / 零 store / 零计时器 / 零磁盘 —— 连
14+
* `@tauri-apps/api` 都不 import。订阅 `live:audio-level` 的是容器(`shell/LiveBar.tsx`)。
15+
* @ai-context 边界:① `rms` 一律**夹取**:非有限值(NaN / ±Infinity)或 ≤0 ⇒ 0 条点亮;≥0dB ⇒ 满格
16+
* —— 永不抛、永不把 NaN 漏进 `style`;② `bars <= 0` ⇒ 不渲染任何条(显式归零,不让负值流进 flex
17+
* 计算);③ 颜色**零字面量**,一律 `var(--ed-*)`(与 `views/session/SessionCardFlowView.tsx` 同一
18+
* 纪律:色值只在 `ui/tokens.css` 与生成器里);④ 动效接缝(ADR-033 §4 允许的用途)只声明
19+
* `background` 的 micro 档 token 过渡 —— **几何不做 transition**(§8.4「宽度本身不做 transition」)。
20+
*/
21+
import type { CSSProperties, ReactElement } from "react";
22+
23+
/** 条数默认值:58px 条里排得开,且与后端每音频块 200ms 的推送节奏相称。 */
24+
export const WAVEFORM_BARS = 32;
25+
/** 高度默认值(px)。纯布局量 —— 进 `style` 正是 ADR-033 §4 允许的用途。 */
26+
export const WAVEFORM_HEIGHT = 30;
27+
/** 削波热区条数 = 右端两段(与 `AudioLevelMeter` 的末两段同口径,两件并存时读数一致)。 */
28+
export const WAVEFORM_CLIP_BARS = 2;
29+
/** 映射下界 = -60dB(RMS 0.001)以下算静音;上界 0dB = 满格。与既有 VU 表同口径。 */
30+
export const WAVEFORM_FLOOR_DB = -60;
31+
/**
32+
* `Math.log10(0)` = `-Infinity` ⇒ 给 RMS 一个有限下垫(-120dB)。
33+
* Why 具名(R27.1 先例:边界/幅度类数字必须有唯一落点):它是「静音」与「极小值」的分界,
34+
* 散成两处字面量后必然各自漂移,而漂移表现为「安静时条数偶尔跳一格」——最难归因的那类。
35+
*/
36+
export const WAVEFORM_RMS_EPSILON = 1e-6;
37+
38+
/** 冻结快照(计划 Interfaces):纯受控 —— 无 IPC、无回调、无订阅。 */
39+
export interface WaveformProps {
40+
readonly rms: number;
41+
readonly clipping: boolean;
42+
readonly bars?: number;
43+
readonly height?: number;
44+
readonly testId?: string;
45+
}
46+
47+
/**
48+
* RMS → 点亮条数(对数映射)。行为契约只有一条:**对 `rms` 单调不减**(同名测试逐序数组钉住)。
49+
* 越界一律夹取,故本函数是全定义域上的全函数(无抛点、无 NaN 出口)。
50+
*/
51+
export function litBars(rms: number, bars: number): number {
52+
if (bars <= 0 || !Number.isFinite(rms) || rms <= 0) return 0;
53+
const db = 20 * Math.log10(Math.max(rms, WAVEFORM_RMS_EPSILON));
54+
const ratio = Math.min(1, Math.max(0, (db - WAVEFORM_FLOOR_DB) / -WAVEFORM_FLOOR_DB));
55+
return Math.min(bars, Math.round(ratio * bars));
56+
}
57+
58+
/** 该条是否落在右端削波热区(索引 0 = 最左)。它只判**位置**,不判是否点亮。 */
59+
export function inClipZone(index: number, bars: number): boolean {
60+
return index >= bars - WAVEFORM_CLIP_BARS;
61+
}
62+
63+
/** 条的公共几何 + 动效接缝(色值逐条不同,故在渲染处合并)。 */
64+
const BAR_STYLE: CSSProperties = {
65+
flex: 1,
66+
minWidth: 0,
67+
borderRadius: "var(--ed-radius-stamp, 3px)",
68+
transition: "background-color var(--ed-dur-micro, 120ms) var(--ed-ease, cubic-bezier(0.2, 0, 0, 1))",
69+
};
70+
71+
/**
72+
* 渲染一条波形。
73+
*
74+
* 返回 `ReactElement`(= 契约里的 `JSX.Element`):React 19 把全局 `JSX` 命名空间收进 `React.JSX`,
75+
* 直接写 `JSX.Element` 在 `@types/react@19` 下取不到(同 `Text.tsx` / `Surface.tsx` 先例)。
76+
*/
77+
export default function Waveform({
78+
rms,
79+
clipping,
80+
bars = WAVEFORM_BARS,
81+
height = WAVEFORM_HEIGHT,
82+
testId = "waveform",
83+
}: WaveformProps): ReactElement {
84+
// 条数先归一到非负整数:`Array.from({length:-1})` 本就是空数组,但显式归零让 `data-bars`
85+
// 与真实条数永远一致(负值 / 小数不会漏进属性)。
86+
const count = Math.max(0, Math.floor(bars));
87+
const lit = litBars(rms, count);
88+
// 根盒子的 `flex: "1 1 auto"` 让它在仪表条的**行** flex 里吃掉剩余宽度,同时保持独立可用
89+
// (非 flex 父容器里 `flex` 无效果,`display:flex` 的块级盒子照旧占满整行)。
90+
return (
91+
<div
92+
data-testid={testId}
93+
data-bars={count}
94+
data-lit={lit}
95+
data-clipping={clipping ? "true" : "false"}
96+
style={{ display: "flex", alignItems: "flex-end", gap: 2, flex: "1 1 auto", minWidth: 0, height }}
97+
>
98+
{Array.from({ length: count }, (_, i) => {
99+
const on = i < lit;
100+
// 热区标记只看「采样削波 ∧ 落在右端」这两件事(与点亮无关)⇒ 它是**结构性**锚点,
101+
// 由 props 唯一决定、可被逐序数组比对;颜色则要求「点亮」才有意义(未点亮的条是底槽)。
102+
const hot = clipping && inClipZone(i, count);
103+
return (
104+
<div
105+
key={i}
106+
data-bar={i}
107+
data-lit={on ? "true" : "false"}
108+
data-clip={hot ? "true" : "false"}
109+
style={{
110+
...BAR_STYLE,
111+
// 用**长写** `backgroundColor` 而不是 `background` 简写:jsdom 30 的 CSSStyleDeclaration
112+
// 会把简写里的 `var(...)` 整条丢掉(实测 `style.background === ""`),长写则逐字保真
113+
// ⇒ 判据读得到真实产出的色值(本批「判据读不到产出 = 假绿」的家族)。
114+
backgroundColor: !on ? "var(--ed-bg-sunken)" : hot ? "var(--ed-stamp)" : "var(--ed-ink-1)",
115+
}}
116+
/>
117+
);
118+
})}
119+
</div>
120+
);
121+
}

0 commit comments

Comments
 (0)