Skip to content

Commit 25a4a2e

Browse files
committed
feat(shell): 落采集态 LIVE 仪表条
1 parent b2afd6d commit 25a4a2e

2 files changed

Lines changed: 340 additions & 0 deletions

File tree

‎app/src/shell/LiveBar.test.tsx‎

Lines changed: 202 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,202 @@
1+
// @vitest-environment jsdom
2+
/**
3+
* @ai-context LiveBar.test.tsx — 采集态 LIVE 仪表条的 **jsdom 面**判据(批 6 Task 18 Step 4)。
4+
*
5+
* Why 每条判据各带**专属变异体**(R8.6 / R41.7:主闸是**期望比对**,`ran > 0` 只是旁证):
6+
* · L1 高度消费 **T17 落真的新 token**:根元素 `style.height` 逐字 == `var(--ed-nav-h-live, 58px)`
7+
* ∧ 该 token 在真源 `ui/tokens.css` 里定值 58px ∧ **旧 token 仍是 56px**(R4.1 的要点 = 不改旧值
8+
* 就能拿到采集态 58px ⇒ 零既有断言改动)⇒ 变异体 = 改回 `var(--ed-nav-h, 56px)`。
9+
* · L2 四件落点齐备(波形 / 计时 / 暂停 / 标记 / 停止),计时经 `utils/fmt.ts` 的 **`fmtMs` 唯一出口**
10+
* ⇒ 变异体 = 把 `fmtMs(elapsedMs)` 换成字面量 `"00:00"`。
11+
* · L3 三键各自的回调**各被调用一次**且不串台(逐序计数数组)⇒ 变异体 = 对调「标记 / 停止」两个 handler。
12+
* · L4 `paused` 的三处语义(`data-paused` / 琥珀徽标**仅在暂停时存在** / 暂停键可访问名翻转)
13+
* ⇒ 变异体 = 去掉徽标的存在性门控(恒渲染 ⇒ 未暂停态红)。
14+
* · L5 `live:audio-level` 的接线与**卸载退订**(照 `AudioLevelMeter.tsx:50-56` 的既有范式)
15+
* ⇒ 变异体 = 删掉 cleanup 里的 `void unlisten.then((fn) => fn())`。
16+
* · L6 环境层落点在**三档下都存在**(§8.5:节能档只留响应层 ⇒ 脉冲必须仍被 eco 块覆写到 1ms/1 次)
17+
* ∧ 标准档周期落在 §8.1 的 **2–6s 带** ⇒ 变异体 = 从 className 里摘掉脉冲修饰类。
18+
*
19+
* 副作用:无(只挂载 jsdom 容器;`@tauri-apps/api/event` 的 `listen` **按仓内既有范式桩掉**,
20+
* 绝不连真后端)。边界:jsdom 无排版引擎 ⇒ 判得到「属性 / 文案 / 类名 / 交叉文件契约」,
21+
* **判不到**「真的 58px」「真的零 chrome」(像素面归 headless 抽检与批 8;报告 `## 诚实边界` 单列)。
22+
*/
23+
import { act, cleanup, fireEvent, render } from "@testing-library/react";
24+
import { readFileSync } from "node:fs";
25+
import { dirname, join } from "node:path";
26+
import { fileURLToPath } from "node:url";
27+
import { afterEach, describe, expect, it, vi } from "vitest";
28+
import { listen } from "@tauri-apps/api/event";
29+
import { AMBIENT_ITEMS } from "../motion/env";
30+
import LiveBar, { LIVE_BAR_HEIGHT, LIVE_PAUSE_LABELS, LIVE_PULSE_CLASS } from "./LiveBar";
31+
import type { LiveBarProps } from "./LiveBar";
32+
33+
/** 订阅按仓内既有范式桩掉(`confirmMigration.test.tsx` / `KnowledgePage.test.tsx` 同款)。 */
34+
vi.mock("@tauri-apps/api/event", () => ({ listen: vi.fn(() => Promise.resolve(() => {})) }));
35+
36+
afterEach(() => {
37+
cleanup();
38+
vi.mocked(listen).mockClear();
39+
});
40+
41+
const HERE = dirname(fileURLToPath(import.meta.url));
42+
/** 唯一真源产物(T17 落真的 58px 在这里,不在任何 `.tsx` 里)。 */
43+
const TOKENS_CSS = readFileSync(join(HERE, "..", "ui", "tokens.css"), "utf8");
44+
/** T13 落的环境层 seam 与三档块都在这里(`.css` 不在门禁视野 ⇒ 交叉文件绑定只能由判据承担)。 */
45+
const MOTION_CSS = readFileSync(join(HERE, "..", "ui", "primitives", "motion.css"), "utf8");
46+
47+
/** `live:audio-level` 的载荷(与组件内的接口同形;测试侧独立声明,避免从实现里取类型)。 */
48+
type LevelEvent = { payload: { rms: number; clipping: boolean } };
49+
50+
const noop = (): void => undefined;
51+
52+
/** 挂载一条仪表并返回它的根元素(`data-testid="live-bar"`)。 */
53+
function renderBar(props: Partial<LiveBarProps> = {}): HTMLElement {
54+
render(<LiveBar elapsedMs={0} paused={false} onTogglePause={noop} onMark={noop} onStop={noop} {...props} />);
55+
const el = document.body.querySelector<HTMLElement>('[data-testid="live-bar"]');
56+
if (el === null) throw new Error("找不到仪表条根元素(data-testid=live-bar)");
57+
return el;
58+
}
59+
60+
const byId = (id: string): HTMLElement => {
61+
const el = document.body.querySelector<HTMLElement>(`[data-testid="${id}"]`);
62+
if (el === null) throw new Error(`找不到 ${id}`);
63+
return el;
64+
};
65+
66+
/** 最近一次订阅的 `[事件名, 回调]`(`listen` 在挂载 effect 里被同步调用)。 */
67+
function lastSubscription(): [unknown, (e: LevelEvent) => void] {
68+
const calls = vi.mocked(listen).mock.calls;
69+
expect(calls.length, "本组件根本没订阅 live:audio-level").toBeGreaterThan(0);
70+
return calls[calls.length - 1] as unknown as [unknown, (e: LevelEvent) => void];
71+
}
72+
73+
/** `motion.css` 里某条规则的规则体(`selector {` → 首个 `}`)。 */
74+
function ruleBody(selector: string): string {
75+
const at = MOTION_CSS.indexOf(`${selector} {`);
76+
if (at < 0) throw new Error(`motion.css 里找不到规则 ${selector}`);
77+
return MOTION_CSS.slice(at, MOTION_CSS.indexOf("}", at));
78+
}
79+
80+
describe("L1 · 高度消费 T17 的新 token(逐字)", () => {
81+
it("根元素 style.height 逐字 == var(--ed-nav-h-live, 58px),且该 token 在真源里定值 58px", () => {
82+
const el = renderBar();
83+
expect(el.style.height, "高度必须逐字走新 token(连同值兜底)").toBe(LIVE_BAR_HEIGHT);
84+
expect(LIVE_BAR_HEIGHT).toBe("var(--ed-nav-h-live, 58px)");
85+
expect(TOKENS_CSS, "真源里没有 --ed-nav-h-live: 58px ⇒ 上面的兜底变成唯一真源").toContain(
86+
"--ed-nav-h-live: 58px;",
87+
);
88+
expect(TOKENS_CSS, "R4.1:旧 token 必须仍是 56px(改它会让既有兜底绑定判据无处成立)").toContain(
89+
"--ed-nav-h: 56px;",
90+
);
91+
});
92+
});
93+
94+
describe("L2 · 四件落点齐备(波形 / 计时 / 暂停 / 标记 / 停止)", () => {
95+
it("波形子件在位 · 三键文案逐序 · 计时经 fmtMs 唯一出口(三个读数)", () => {
96+
const el = renderBar({ elapsedMs: 0 });
97+
expect(el.querySelector('[data-testid="live-waveform"]'), "波形(R4.3 的承载面)不在条上").not.toBeNull();
98+
expect(["live-pause", "live-mark", "live-stop"].map((id) => byId(id).textContent)).toEqual([
99+
LIVE_PAUSE_LABELS.running, "标记", "停止",
100+
]);
101+
expect(byId("live-elapsed").textContent).toBe("00:00");
102+
cleanup();
103+
renderBar({ elapsedMs: 65_000 });
104+
expect(byId("live-elapsed").textContent, "65s ⇒ mm:ss").toBe("01:05");
105+
cleanup();
106+
renderBar({ elapsedMs: 3_600_000 });
107+
expect(byId("live-elapsed").textContent, "≥1h ⇒ h:mm:ss(fmtMs 的第二分支)").toBe("1:00:00");
108+
});
109+
});
110+
111+
describe("L3 · 三键各自的回调(各一次 · 不串台)", () => {
112+
it("逐键点击 ⇒ 逐序计数数组 [暂停, 标记, 停止] 各出现一次", () => {
113+
const calls: string[] = [];
114+
renderBar({
115+
onTogglePause: () => calls.push("pause"),
116+
onMark: () => calls.push("mark"),
117+
onStop: () => calls.push("stop"),
118+
});
119+
fireEvent.click(byId("live-pause"));
120+
expect(calls, "点暂停键只许触发 onTogglePause").toEqual(["pause"]);
121+
fireEvent.click(byId("live-mark"));
122+
expect(calls).toEqual(["pause", "mark"]);
123+
fireEvent.click(byId("live-stop"));
124+
expect(calls, "三键各一次、顺序 = 点击顺序(串台 / 漏接都红在这条)").toEqual([
125+
"pause", "mark", "stop",
126+
]);
127+
});
128+
});
129+
130+
describe("L4 · paused 的三处语义(受控:本件不自己翻转状态)", () => {
131+
it("未暂停:data-paused=false · 无琥珀徽标 · 暂停键文案 = 暂停", () => {
132+
const el = renderBar({ paused: false });
133+
expect(el.getAttribute("data-paused")).toBe("false");
134+
expect(el.querySelector('[data-testid="live-paused-badge"]'), "未暂停时不许有暂停徽标").toBeNull();
135+
expect(byId("live-pause").textContent).toBe(LIVE_PAUSE_LABELS.running);
136+
});
137+
138+
it("暂停:data-paused=true · 徽标在且走琥珀族类 `ed-text--due`(R4.5)· 文案翻成「继续」", () => {
139+
const el = renderBar({ paused: true });
140+
expect(el.getAttribute("data-paused")).toBe("true");
141+
const badge = byId("live-paused-badge");
142+
expect(badge.className.split(/\s+/), "暂停徽标必须走 --ed-due 族(R4.5 逐字「采集暂停色也是琥珀族」)").toContain(
143+
"ed-text--due",
144+
);
145+
expect(byId("live-pause").textContent, "播放/暂停控制换可访问名(不用 aria-pressed)").toBe(
146+
LIVE_PAUSE_LABELS.paused,
147+
);
148+
});
149+
});
150+
151+
describe("L5 · live:audio-level 接线(内部订阅)与卸载退订", () => {
152+
it("订阅名逐字 · 载荷接到波形(data-lit / data-clipping)", () => {
153+
renderBar();
154+
const [event, handler] = lastSubscription();
155+
expect(event, "订阅的事件名逐字").toBe("live:audio-level");
156+
act(() => handler({ payload: { rms: 1, clipping: true } }));
157+
const wave = byId("live-waveform");
158+
expect(wave.getAttribute("data-clipping")).toBe("true");
159+
expect(wave.getAttribute("data-lit"), "rms=1 ⇒ 满格(载荷真的到了波形上)").toBe(wave.getAttribute("data-bars"));
160+
act(() => handler({ payload: { rms: 0, clipping: false } }));
161+
expect(byId("live-waveform").getAttribute("data-lit")).toBe("0");
162+
});
163+
164+
it("卸载 ⇒ 调用 unlisten 恰一次(照 AudioLevelMeter.tsx:50-56 的清理范式,防卸载后 setState)", async () => {
165+
const off = vi.fn();
166+
vi.mocked(listen).mockImplementationOnce(
167+
(() => Promise.resolve(off)) as unknown as typeof listen,
168+
);
169+
const { unmount } = render(
170+
<LiveBar elapsedMs={0} paused={false} onTogglePause={noop} onMark={noop} onStop={noop} />,
171+
);
172+
unmount();
173+
await act(async () => {
174+
await Promise.resolve();
175+
});
176+
expect(off, "卸载没有退订(`void unlisten.then((fn) => fn())` 被删掉了?)").toHaveBeenCalledTimes(1);
177+
});
178+
});
179+
180+
describe("L6 · 环境层落点在**三档下都存在**(§8.5)+ 周期守 §8.1 的 2–6s 带", () => {
181+
it("脉冲修饰类落在真 DOM 上,且它=T13 登记的环境层 seam(keyframes ↔ 选择器逐序绑定)", () => {
182+
const el = renderBar();
183+
expect(el.className.split(/\s+/), "脉冲落点不在根元素上 ⇒ 采集态「不呼吸」").toContain(LIVE_PULSE_CLASS);
184+
expect(
185+
AMBIENT_ITEMS.map((i) => `${i.keyframes}@${i.selector}`),
186+
"环境层登记表里没有这条 seam ⇒ 本件消费的是个未登记的名字",
187+
).toContain(`ed-capture-pulse@.${LIVE_PULSE_CLASS}`);
188+
});
189+
190+
it("三档块都覆写它(eco ⇒ 1ms/1 次即止)· reduced-motion 名单含它 · 标准档周期 ∈ [2000, 6000]ms", () => {
191+
expect(MOTION_CSS).toContain(`html[data-motion="eco"] .${LIVE_PULSE_CLASS}`);
192+
expect(MOTION_CSS).toContain(`html[data-motion="rich"] .${LIVE_PULSE_CLASS}`);
193+
expect(MOTION_CSS, "reduced-motion 名单漏了它 ⇒ 关掉动效的用户仍会看到脉冲").toContain(
194+
`.${LIVE_PULSE_CLASS},`,
195+
);
196+
const body = ruleBody(`.${LIVE_PULSE_CLASS}`);
197+
const period = /animation:\s*ed-capture-pulse\s+calc\(var\(--[a-z-]+,\s*(\d+)ms\)\s*\*\s*(\d+)\)/.exec(body);
198+
expect(period, "标准档周期必须从唯一真源以 calc() 倍数派生(R16.3④:不新造时长字面量)").not.toBeNull();
199+
const periodMs = Number(period?.[1]) * Number(period?.[2]);
200+
expect(periodMs >= 2000 && periodMs <= 6000, `环境层周期 ${periodMs}ms 必须落在 §8.1 的 2–6s 带`).toBe(true);
201+
});
202+
});

‎app/src/shell/LiveBar.tsx‎

Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
/**
2+
* LiveBar — 采集态 LIVE 仪表条(规格 §6.3;裁决 R4.4 / R4.5;批 6 Task 18 Step 3)。
3+
*
4+
* @ai-context Why:§6.3 逐字「采集态 = 58px **LIVE 仪表**(波形 + 计时 + 暂停/标记/停止)」
5+
* ⇒ 本件就是那条 58px 条的**本体**:波形(`components/Waveform.tsx`,div 条阵列 · R4.3)+
6+
* 计时(`utils/fmt.ts` 的 `fmtMs` **唯一出口**)+ 暂停 / 标记 / 停止三键。高度消费 T17 落真的
7+
* `--ed-nav-h-live`(R4.1:`--ed-nav-h` 保持 56px 不动、采集态另开一支 ⇒ 零既有断言改动),
8+
* 且**逐字消费并带同值兜底**。
9+
* @ai-context ★ 订阅形态(计划「诚实边界」④ 要求逐字写明选了哪一个及理由):**本组件内部订阅**
10+
* `live:audio-level`,载荷 `{ rms: number; clipping: boolean }`(与 `components/AudioLevelMeter.tsx`
11+
* 的 :19-22 逐字同形;后端每音频块 200ms 推一次)。理由:T18 的边界是 **新建 4 件、既有文件零改动**
12+
* (计划 Files 表把 `App.tsx` / `ClassroomCapturePanel.tsx` 都列成「零改动」,热文件表更把 `App.tsx`
13+
* 划给 T17 唯一)⇒ 今天**没有**注入 `rms` 的容器,冻结快照 `LiveBarProps` 里也没有 `rms` 槽
14+
* (不许改冻结快照)⇒ 订阅只能落在本件内部。清理照 `AudioLevelMeter.tsx:50-56` 的既有范式
15+
* (`void unlisten.then((fn) => fn())`,避免卸载后 `setState`)。
16+
* @ai-context 副作用:① 订阅 `live:audio-level`(挂载订阅 / 卸载退订);② 写 DOM 的 `data-paused` 与
17+
* 四个 `data-testid` 锚点。**不读 store、不写磁盘、不起计时器** —— `elapsedMs` 的时间源(墙钟 vs
18+
* 后端事件)由容器注入,不在本任务判据内(计划「诚实边界」③)。
19+
* @ai-context 边界:① 本件**只管仪表本体**:相位 CSS(域导航隐藏 / 源列保留)归 T19、相变凝固动效
20+
* 归 T29 ⇒ 本任务结束时它**没有生产调用点**(「渲染即 58px」是本件唯一的空间契约);
21+
* ② `paused` 是**受控**的(本件不翻转自己的状态,只上抛 `onTogglePause`);
22+
* ③ 暂停键的按下态**不用 `aria-pressed`**,走「可访问名翻转(暂停 ↔ 继续)+ `data-paused` +
23+
* 琥珀徽标」三处。Why:`ui/primitives/Button` 的属性面是**冻结的受控槽**(无 `aria-pressed`、无
24+
* `...rest` 透传),而 `components/**` 在 `nativeButton.ratchet` 的域内 ⇒ 自造裸 `<button>` 会让
25+
* 棘轮因**新文件**当场红;且播放/暂停控制在 WAI-ARIA 的惯用做法本就是**换可访问名**而非按下态
26+
* (按下态适用于静音一类**模式**开关)。琥珀徽标 = R4.5 逐字点名的「暂停徽标」,也是 T29
27+
* 「琥珀退去」要作用的那个元素。
28+
*/
29+
import { useEffect, useState } from "react";
30+
import type { ReactElement } from "react";
31+
import { listen } from "@tauri-apps/api/event";
32+
import { Button, StatusLine, Text } from "../ui/primitives";
33+
import { Icon } from "../ui/icons";
34+
import { fmtMs } from "../utils/fmt";
35+
import Waveform from "../components/Waveform";
36+
37+
/**
38+
* 采集态顶栏高度:**消费 T17 落真的新 token**(R4.1)。兜底 58px 与真源同值
39+
* (`ui/tokens.css` 的 `--ed-nav-h-live: 58px`)—— 变量未定义时 CSS 不报错、只静默失效。
40+
*/
41+
export const LIVE_BAR_HEIGHT = "var(--ed-nav-h-live, 58px)";
42+
/** 采集脉冲的落点类名 = T13 落的环境层 seam(`.ed-status` 的**修饰类**,三档块与 reduced 名单都含它)。 */
43+
export const LIVE_PULSE_CLASS = "ed-status--live-pulse";
44+
/** 暂停键两档文案(逐字):换可访问名 = 播放/暂停控制的惯用形态(见文件头边界③)。 */
45+
export const LIVE_PAUSE_LABELS = { running: "暂停", paused: "继续" } as const;
46+
47+
/** `live:audio-level` 的载荷(与 `components/AudioLevelMeter.tsx` 的接口逐字同形)。 */
48+
interface AudioLevelPayload {
49+
rms: number;
50+
clipping: boolean;
51+
}
52+
53+
/** 冻结快照(计划 Interfaces):容器注入计时与三个动作,本件不起计时器、不建 IPC 命令。 */
54+
export interface LiveBarProps {
55+
readonly elapsedMs: number;
56+
readonly paused: boolean;
57+
readonly onTogglePause: () => void;
58+
readonly onMark: () => void;
59+
readonly onStop: () => void;
60+
}
61+
62+
/**
63+
* 渲染采集态 LIVE 仪表条。
64+
*
65+
* 返回 `ReactElement`(= 契约里的 `JSX.Element`):React 19 把全局 `JSX` 命名空间收进 `React.JSX`,
66+
* 直接写 `JSX.Element` 在 `@types/react@19` 下取不到(同 `Text.tsx` / `Surface.tsx` 先例)。
67+
*/
68+
export default function LiveBar({
69+
elapsedMs,
70+
paused,
71+
onTogglePause,
72+
onMark,
73+
onStop,
74+
}: LiveBarProps): ReactElement {
75+
const [level, setLevel] = useState<AudioLevelPayload>({ rms: 0, clipping: false });
76+
77+
useEffect(() => {
78+
const unlisten = listen<AudioLevelPayload>("live:audio-level", (e) => {
79+
setLevel({ rms: e.payload.rms, clipping: e.payload.clipping });
80+
});
81+
return () => {
82+
void unlisten.then((fn) => fn());
83+
};
84+
}, []);
85+
86+
// `style` 只承担**布局**(高/宽/内距/裁切)—— `.ed-status--live-pulse` 的规则只有 `animation:`
87+
// 一条,本盒子不声明 `ed-status` 基类 ⇒ 不存在「用 style 覆盖类语义」(ADR-033 §4)。
88+
return (
89+
<div
90+
className={LIVE_PULSE_CLASS}
91+
data-testid="live-bar"
92+
data-paused={paused ? "true" : "false"}
93+
style={{
94+
height: LIVE_BAR_HEIGHT,
95+
display: "flex",
96+
alignItems: "center",
97+
width: "100%",
98+
boxSizing: "border-box",
99+
padding: "0 var(--ed-space-16, 16px)",
100+
overflow: "hidden",
101+
}}
102+
>
103+
{/* 语义(`role="status"`)与基类由 `StatusLine` 原语承载;仪表本体走它的 `action` 纯插槽
104+
(该槽**不额外包 DOM、不加类** —— 见 `StatusLine.tsx` 边界④)。 */}
105+
<StatusLine
106+
kind="info"
107+
testId="live-meter"
108+
action={
109+
<>
110+
<Waveform rms={level.rms} clipping={level.clipping} testId="live-waveform" />
111+
<Text as="span" size={4} font="mono" tone="ink-2" testId="live-elapsed">
112+
{fmtMs(elapsedMs)}
113+
</Text>
114+
{paused ? (
115+
<Text as="span" size={5} tone="due" testId="live-paused-badge">
116+
已暂停
117+
</Text>
118+
) : null}
119+
<Button variant="secondary" size="sm" testId="live-pause" onClick={onTogglePause}
120+
icon={<Icon name={paused ? "play" : "pause"} size={16} />}>
121+
{paused ? LIVE_PAUSE_LABELS.paused : LIVE_PAUSE_LABELS.running}
122+
</Button>
123+
<Button variant="ghost" size="sm" testId="live-mark" onClick={onMark}
124+
icon={<Icon name="check" size={16} />}>
125+
标记
126+
</Button>
127+
<Button variant="ghost" size="sm" testId="live-stop" onClick={onStop}
128+
icon={<Icon name="stop" size={16} />}>
129+
停止
130+
</Button>
131+
</>
132+
}
133+
>
134+
LIVE
135+
</StatusLine>
136+
</div>
137+
);
138+
}

0 commit comments

Comments
 (0)