@elabs-ai/components-ai 4.0.0 → 4.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +61 -11
- package/dist/{_audio-player-media-chrome-KA5DY54G.js → _audio-player-media-chrome-T3XVXWRZ.js} +8 -4
- package/dist/_audio-player-media-chrome-T3XVXWRZ.js.map +1 -0
- package/dist/{_flow-boundary-D63PJ65S.js → _flow-boundary-SHNWLQG5.js} +32 -43
- package/dist/_flow-boundary-SHNWLQG5.js.map +1 -0
- package/dist/{_persona-rive-RFR2EUWP.js → _persona-rive-JEG44YHX.js} +9 -5
- package/dist/_persona-rive-JEG44YHX.js.map +1 -0
- package/dist/index.d.ts +1109 -202
- package/dist/index.js +4867 -2878
- package/dist/index.js.map +1 -1
- package/package.json +31 -16
- package/src/_audio-player-media-chrome.tsx +102 -15
- package/src/_flow-boundary.tsx +68 -49
- package/src/_lazy-boundary-conformance.ts +38 -0
- package/src/_lazy-engine-boundary.tsx +61 -0
- package/src/_lazy-mermaid-absent.test.ts +53 -0
- package/src/_lazy-mermaid.test.ts +15 -0
- package/src/_lazy-mermaid.ts +24 -1
- package/src/_mermaid-error-panel.test.tsx +50 -0
- package/src/_mermaid-error-panel.tsx +66 -0
- package/src/_persona-rive.tsx +62 -10
- package/src/_streamdown-i18n.ts +21 -1
- package/src/_streamdown-safety.ts +170 -0
- package/src/agent-event.stories.tsx +97 -0
- package/src/agent-event.test.tsx +145 -0
- package/src/agent-event.tsx +187 -0
- package/src/agent-timeline.stories.tsx +9 -1
- package/src/agent.test.tsx +47 -0
- package/src/agent.tsx +28 -14
- package/src/agentic-workspace.stories.tsx +27 -20
- package/src/artifact.tsx +15 -12
- package/src/audio-player.test.tsx +78 -0
- package/src/audio-player.tsx +243 -56
- package/src/audio-visualizer.stories.tsx +126 -0
- package/src/audio-visualizer.test.tsx +438 -0
- package/src/audio-visualizer.tsx +367 -0
- package/src/canvas.stories.tsx +150 -1
- package/src/chat-shell.stories.tsx +18 -3
- package/src/chat.stories.tsx +16 -2
- package/src/code-block.stories.tsx +9 -1
- package/src/composer.stories.tsx +372 -9
- package/src/composer.test.tsx +357 -6
- package/src/composer.tsx +217 -35
- package/src/confirmation.stories.tsx +72 -1
- package/src/confirmation.test.tsx +216 -2
- package/src/confirmation.tsx +263 -3
- package/src/context-panel.stories.tsx +9 -1
- package/src/context-panel.tsx +2 -1
- package/src/conversation.stories.tsx +63 -2
- package/src/conversation.test.tsx +13 -0
- package/src/conversation.tsx +20 -2
- package/src/diff-view.stories.tsx +196 -0
- package/src/diff-view.test.tsx +188 -0
- package/src/diff-view.tsx +642 -0
- package/src/gallery.tsx +4 -2
- package/src/index.ts +14 -4
- package/src/jsx-preview.stories.tsx +2 -2
- package/src/markdown-view.stories.tsx +92 -1
- package/src/markdown-view.test.tsx +232 -1
- package/src/markdown-view.tsx +150 -6
- package/src/message-compare.stories.tsx +175 -0
- package/src/message-compare.test.tsx +207 -0
- package/src/message-compare.tsx +453 -0
- package/src/message-form.stories.tsx +29 -1
- package/src/message.stories.tsx +9 -1
- package/src/message.test.tsx +176 -0
- package/src/message.tsx +90 -4
- package/src/microcopy.test.tsx +40 -0
- package/src/{model-selector.stories.tsx → model-provider-logo.stories.tsx} +17 -8
- package/src/{model-selector.test.tsx → model-provider-logo.test.tsx} +10 -10
- package/src/model-provider-logo.tsx +149 -0
- package/src/permission-mode-select.stories.tsx +82 -0
- package/src/permission-mode-select.test.tsx +100 -0
- package/src/permission-mode-select.tsx +137 -0
- package/src/persona-missing-peer.test.tsx +54 -0
- package/src/persona.tsx +68 -22
- package/src/plan.stories.tsx +166 -0
- package/src/plan.test.tsx +267 -0
- package/src/plan.tsx +182 -20
- package/src/prompt-input-effort.stories.tsx +123 -0
- package/src/prompt-input-effort.test.tsx +83 -0
- package/src/prompt-input-effort.tsx +136 -0
- package/src/prompt-input-mode.stories.tsx +108 -0
- package/src/prompt-input-mode.test.tsx +99 -0
- package/src/prompt-input-mode.tsx +169 -0
- package/src/prompt-input-slash.stories.tsx +211 -0
- package/src/prompt-input-slash.test.tsx +262 -0
- package/src/prompt-input-slash.tsx +541 -0
- package/src/prompt-input.stories.tsx +2 -2
- package/src/reasoning.tsx +27 -13
- package/src/sandbox.stories.tsx +9 -1
- package/src/schema-display.tsx +5 -2
- package/src/selection-toolbar.stories.tsx +9 -1
- package/src/session-header.stories.tsx +128 -0
- package/src/session-header.test.tsx +138 -0
- package/src/session-header.tsx +243 -0
- package/src/session-status-bar.stories.tsx +73 -0
- package/src/session-status-bar.test.tsx +94 -0
- package/src/session-status-bar.tsx +165 -0
- package/src/snippet.stories.tsx +9 -1
- package/src/stack-trace.tsx +6 -2
- package/src/streamdown-i18n.test.tsx +1 -1
- package/src/task.stories.tsx +13 -3
- package/src/templates-ai-assistant.stories.tsx +21 -1
- package/src/token-usage.stories.tsx +24 -0
- package/src/token-usage.test.tsx +92 -0
- package/src/{context.tsx → token-usage.tsx} +67 -53
- package/src/tool-result-card.stories.tsx +9 -1
- package/src/tool.stories.tsx +13 -3
- package/src/tool.tsx +15 -8
- package/src/turn-status.stories.tsx +124 -0
- package/src/turn-status.test.tsx +74 -0
- package/src/turn-status.tsx +174 -0
- package/src/use-audio-level.ts +104 -0
- package/src/web-preview.tsx +8 -3
- package/dist/_audio-player-media-chrome-KA5DY54G.js.map +0 -1
- package/dist/_flow-boundary-D63PJ65S.js.map +0 -1
- package/dist/_interactive-terminal-xterm-ATJ5EW3G.js +0 -11
- package/dist/_interactive-terminal-xterm-ATJ5EW3G.js.map +0 -1
- package/dist/_persona-rive-RFR2EUWP.js.map +0 -1
- package/src/_interactive-terminal-xterm.ts +0 -32
- package/src/blocks-ai-composer.stories.tsx +0 -83
- package/src/context.stories.tsx +0 -16
- package/src/interactive-terminal.stories.tsx +0 -165
- package/src/interactive-terminal.test.tsx +0 -448
- package/src/interactive-terminal.tsx +0 -444
- package/src/model-selector.tsx +0 -225
- package/src/terminal.tsx +0 -244
|
@@ -0,0 +1,438 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { act, cleanup, fireEvent, render, screen } from "@testing-library/react";
|
|
3
|
+
import { ThemeProvider, useTheme } from "@elabs-ai/components-tokens";
|
|
4
|
+
|
|
5
|
+
import { AudioVisualizer } from "./audio-visualizer";
|
|
6
|
+
import { useAudioLevel } from "./use-audio-level";
|
|
7
|
+
|
|
8
|
+
// AudioVisualizer draws to a 2D canvas context, which jsdom does not
|
|
9
|
+
// implement (it returns `null`, same as every other canvas-drawing component
|
|
10
|
+
// in this repo — see packages/viewer/src/adapters/pdf/pdf-adapter.test.tsx).
|
|
11
|
+
// A bare object stands in so drawing calls resolve without throwing; nothing
|
|
12
|
+
// here asserts on pixels, only on the component's own state (the announced
|
|
13
|
+
// status text) and its refusal to crash without a real canvas.
|
|
14
|
+
beforeEach(() => {
|
|
15
|
+
// Re-spied every test: `afterEach` below calls `vi.restoreAllMocks()`,
|
|
16
|
+
// which restores the REAL jsdom implementation (not just mock state), so a
|
|
17
|
+
// `beforeAll`-only spy stops intercepting after the first test.
|
|
18
|
+
vi.spyOn(HTMLCanvasElement.prototype, "getContext").mockReturnValue({
|
|
19
|
+
clearRect: vi.fn(),
|
|
20
|
+
fillRect: vi.fn(),
|
|
21
|
+
beginPath: vi.fn(),
|
|
22
|
+
moveTo: vi.fn(),
|
|
23
|
+
lineTo: vi.fn(),
|
|
24
|
+
closePath: vi.fn(),
|
|
25
|
+
fill: vi.fn(),
|
|
26
|
+
canvas: { width: 320, height: 64 },
|
|
27
|
+
} as unknown as CanvasRenderingContext2D);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
afterEach(() => {
|
|
31
|
+
cleanup();
|
|
32
|
+
vi.restoreAllMocks();
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
const SPEECH_LEVELS = Array.from({ length: 8 }, (_, i) => (i % 2 === 0 ? 0.8 : 0.6));
|
|
36
|
+
const QUIET_LEVELS = Array.from({ length: 8 }, () => 0.01);
|
|
37
|
+
|
|
38
|
+
describe("AudioVisualizer — presentation-layer boundary (D5, issue #21)", () => {
|
|
39
|
+
it("renders with no microphone and no AudioContext available", () => {
|
|
40
|
+
const originalAudioContext = (window as { AudioContext?: unknown }).AudioContext;
|
|
41
|
+
// Deliberately deleting to simulate an environment with no Web Audio API.
|
|
42
|
+
// (No ESLint rule in this repo's config flags a dot-notation `delete` — the
|
|
43
|
+
// property is restored below regardless.)
|
|
44
|
+
delete (window as { AudioContext?: unknown }).AudioContext;
|
|
45
|
+
expect(() => render(<AudioVisualizer levels={SPEECH_LEVELS} />)).not.toThrow();
|
|
46
|
+
(window as { AudioContext?: unknown }).AudioContext = originalAudioContext;
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("never touches getUserMedia", () => {
|
|
50
|
+
const getUserMedia = vi.fn();
|
|
51
|
+
Object.defineProperty(navigator, "mediaDevices", {
|
|
52
|
+
configurable: true,
|
|
53
|
+
value: { getUserMedia },
|
|
54
|
+
});
|
|
55
|
+
render(<AudioVisualizer levels={SPEECH_LEVELS} loading />);
|
|
56
|
+
expect(getUserMedia).not.toHaveBeenCalled();
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("renders a canvas that is aria-hidden — the level is announced as text, not via the graphic", () => {
|
|
60
|
+
const { container } = render(<AudioVisualizer levels={SPEECH_LEVELS} />);
|
|
61
|
+
const canvas = container.querySelector('[data-slot="audio-visualizer-canvas"]');
|
|
62
|
+
expect(canvas).not.toBeNull();
|
|
63
|
+
expect(canvas).toHaveAttribute("aria-hidden", "true");
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it("carries the root data-slot", () => {
|
|
67
|
+
const { container } = render(<AudioVisualizer levels={SPEECH_LEVELS} />);
|
|
68
|
+
expect(container.querySelector('[data-slot="audio-visualizer"]')).not.toBeNull();
|
|
69
|
+
});
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
describe("AudioVisualizer — level announced to assistive tech", () => {
|
|
73
|
+
it('announces "Microphone active" once the average level clears the silence threshold', () => {
|
|
74
|
+
render(<AudioVisualizer levels={SPEECH_LEVELS} />);
|
|
75
|
+
expect(screen.getByRole("status")).toHaveTextContent("Microphone active");
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it('announces "No input detected" for a connected but quiet signal', () => {
|
|
79
|
+
render(<AudioVisualizer levels={QUIET_LEVELS} />);
|
|
80
|
+
expect(screen.getByRole("status")).toHaveTextContent("No input detected");
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it('announces "Microphone not connected" while loading, ignoring any stale levels', () => {
|
|
84
|
+
render(<AudioVisualizer levels={SPEECH_LEVELS} loading />);
|
|
85
|
+
expect(screen.getByRole("status")).toHaveTextContent("Microphone not connected");
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it("updates the SAME live region rather than remounting it when the state changes", () => {
|
|
89
|
+
const { rerender } = render(<AudioVisualizer levels={QUIET_LEVELS} />);
|
|
90
|
+
const region = screen.getByRole("status");
|
|
91
|
+
expect(region).toHaveTextContent("No input detected");
|
|
92
|
+
|
|
93
|
+
rerender(<AudioVisualizer levels={SPEECH_LEVELS} />);
|
|
94
|
+
expect(screen.getByRole("status")).toBe(region);
|
|
95
|
+
expect(region).toHaveTextContent("Microphone active");
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it("renders no status region when statusLabel is explicitly null", () => {
|
|
99
|
+
render(<AudioVisualizer levels={SPEECH_LEVELS} statusLabel={null} />);
|
|
100
|
+
expect(screen.queryByRole("status")).not.toBeInTheDocument();
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it("renders a caller-supplied statusLabel instead of the default", () => {
|
|
104
|
+
render(<AudioVisualizer levels={SPEECH_LEVELS} statusLabel="Listening…" />);
|
|
105
|
+
expect(screen.getByRole("status")).toHaveTextContent("Listening…");
|
|
106
|
+
});
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
describe("AudioVisualizer — barCount is normalized before it sizes any array (crash guard)", () => {
|
|
110
|
+
it("does not throw for a NaN barCount", () => {
|
|
111
|
+
expect(() => render(<AudioVisualizer levels={SPEECH_LEVELS} barCount={NaN} />)).not.toThrow();
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it("does not throw for an Infinity barCount, and falls back to the default bar count", () => {
|
|
115
|
+
const fillRect = vi.fn();
|
|
116
|
+
vi.spyOn(HTMLCanvasElement.prototype, "getContext").mockReturnValue({
|
|
117
|
+
clearRect: vi.fn(),
|
|
118
|
+
fillRect,
|
|
119
|
+
beginPath: vi.fn(),
|
|
120
|
+
moveTo: vi.fn(),
|
|
121
|
+
lineTo: vi.fn(),
|
|
122
|
+
closePath: vi.fn(),
|
|
123
|
+
fill: vi.fn(),
|
|
124
|
+
canvas: { width: 320, height: 64 },
|
|
125
|
+
} as unknown as CanvasRenderingContext2D);
|
|
126
|
+
expect(() =>
|
|
127
|
+
render(<AudioVisualizer levels={SPEECH_LEVELS} barCount={Infinity} />),
|
|
128
|
+
).not.toThrow();
|
|
129
|
+
// One `fillRect` per bar (see `drawBars`) — the default bar count, since
|
|
130
|
+
// `Infinity` cannot size the sample array itself.
|
|
131
|
+
expect(fillRect).toHaveBeenCalledTimes(32);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it("rounds and clamps a fractional/too-small barCount to a safe integer >= 2", () => {
|
|
135
|
+
const fillRect = vi.fn();
|
|
136
|
+
vi.spyOn(HTMLCanvasElement.prototype, "getContext").mockReturnValue({
|
|
137
|
+
clearRect: vi.fn(),
|
|
138
|
+
fillRect,
|
|
139
|
+
beginPath: vi.fn(),
|
|
140
|
+
moveTo: vi.fn(),
|
|
141
|
+
lineTo: vi.fn(),
|
|
142
|
+
closePath: vi.fn(),
|
|
143
|
+
fill: vi.fn(),
|
|
144
|
+
canvas: { width: 320, height: 64 },
|
|
145
|
+
} as unknown as CanvasRenderingContext2D);
|
|
146
|
+
// 0.4 alone would round-clamp to 2 under the OLD `Math.max(2, barCount)`
|
|
147
|
+
// too (2 > 0.4) — use a fractional count ABOVE 2 so only the fix's
|
|
148
|
+
// `Math.round` (not the pre-existing floor) makes this non-throwing.
|
|
149
|
+
render(<AudioVisualizer levels={SPEECH_LEVELS} barCount={5.7} />);
|
|
150
|
+
expect(fillRect).toHaveBeenCalledTimes(6);
|
|
151
|
+
});
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
describe("AudioVisualizer — status hysteresis around the silence threshold (no flapping)", () => {
|
|
155
|
+
// A uniform-value samples array resamples to that same constant, so the
|
|
156
|
+
// announced average is exactly the fill value — no need to reason about
|
|
157
|
+
// `resampleLevels`'s bucketing here. Values are chosen relative to the
|
|
158
|
+
// DEFAULT_SILENCE_THRESHOLD (0.04) and its ±25% hysteresis margin (0.01):
|
|
159
|
+
// clearly active (>=0.05), clearly silent (<0.03), and inside the dead
|
|
160
|
+
// zone [0.03, 0.05).
|
|
161
|
+
const ACTIVE_LEVELS = Array.from({ length: 8 }, () => 0.06);
|
|
162
|
+
const SILENT_LEVELS = Array.from({ length: 8 }, () => 0.01);
|
|
163
|
+
// Below the PLAIN threshold (0.04) but inside the dead zone — a naive
|
|
164
|
+
// `average >= threshold` check flips this to "silent", so this value locks
|
|
165
|
+
// the "coming from active, holds active" direction.
|
|
166
|
+
const DEAD_ZONE_BELOW_PLAIN_THRESHOLD = Array.from({ length: 8 }, () => 0.035);
|
|
167
|
+
// ABOVE the plain threshold but still inside the dead zone — a naive check
|
|
168
|
+
// flips this to "active", so this value locks the opposite, "coming from
|
|
169
|
+
// silent, holds silent" direction.
|
|
170
|
+
const DEAD_ZONE_ABOVE_PLAIN_THRESHOLD = Array.from({ length: 8 }, () => 0.045);
|
|
171
|
+
|
|
172
|
+
it("holds the 'active' status while the level dips into the dead zone, only flipping on a clear crossing", () => {
|
|
173
|
+
const { rerender } = render(<AudioVisualizer levels={ACTIVE_LEVELS} />);
|
|
174
|
+
expect(screen.getByRole("status")).toHaveTextContent("Microphone active");
|
|
175
|
+
|
|
176
|
+
rerender(<AudioVisualizer levels={DEAD_ZONE_BELOW_PLAIN_THRESHOLD} />);
|
|
177
|
+
// A graze inside the dead zone must NOT flip the announced status.
|
|
178
|
+
expect(screen.getByRole("status")).toHaveTextContent("Microphone active");
|
|
179
|
+
|
|
180
|
+
rerender(<AudioVisualizer levels={SILENT_LEVELS} />);
|
|
181
|
+
// A clear crossing past the margin still flips immediately.
|
|
182
|
+
expect(screen.getByRole("status")).toHaveTextContent("No input detected");
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
it("holds the 'silent' status while the level rises into the dead zone from below", () => {
|
|
186
|
+
const { rerender } = render(<AudioVisualizer levels={SILENT_LEVELS} />);
|
|
187
|
+
expect(screen.getByRole("status")).toHaveTextContent("No input detected");
|
|
188
|
+
|
|
189
|
+
rerender(<AudioVisualizer levels={DEAD_ZONE_ABOVE_PLAIN_THRESHOLD} />);
|
|
190
|
+
expect(screen.getByRole("status")).toHaveTextContent("No input detected");
|
|
191
|
+
|
|
192
|
+
rerender(<AudioVisualizer levels={ACTIVE_LEVELS} />);
|
|
193
|
+
expect(screen.getByRole("status")).toHaveTextContent("Microphone active");
|
|
194
|
+
});
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
describe("AudioVisualizer — reduced motion (colour is never the only channel either)", () => {
|
|
198
|
+
const REDUCED_MOTION_QUERY = "(prefers-reduced-motion: reduce)";
|
|
199
|
+
|
|
200
|
+
function mockPrefersReducedMotion(matches: boolean) {
|
|
201
|
+
Object.defineProperty(window, "matchMedia", {
|
|
202
|
+
configurable: true,
|
|
203
|
+
value: vi.fn().mockImplementation((query: string) => ({
|
|
204
|
+
matches: query === REDUCED_MOTION_QUERY ? matches : false,
|
|
205
|
+
media: query,
|
|
206
|
+
addEventListener: vi.fn(),
|
|
207
|
+
removeEventListener: vi.fn(),
|
|
208
|
+
})),
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
it("settles into the non-animating reduced-motion path and paints the current levels", () => {
|
|
213
|
+
mockPrefersReducedMotion(true);
|
|
214
|
+
const raf = vi.spyOn(window, "requestAnimationFrame");
|
|
215
|
+
const caf = vi.spyOn(window, "cancelAnimationFrame");
|
|
216
|
+
render(<AudioVisualizer levels={SPEECH_LEVELS} />);
|
|
217
|
+
// `useReducedMotion` resolves the OS media query inside its own effect, so
|
|
218
|
+
// the very first render pass can transiently see `false` before that
|
|
219
|
+
// effect commits — the same one-render lag every consumer of the hook has
|
|
220
|
+
// (Shimmer included). Any frame scheduled during that transient window is
|
|
221
|
+
// cancelled before it can fire, so no animation is left running once
|
|
222
|
+
// effects settle; that is the actual contract, not "never called".
|
|
223
|
+
expect(caf.mock.calls.length).toBe(raf.mock.calls.length);
|
|
224
|
+
expect(screen.getByRole("status")).toHaveTextContent("Microphone active");
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
it("schedules a smoothing animation frame when motion is allowed", () => {
|
|
228
|
+
mockPrefersReducedMotion(false);
|
|
229
|
+
const raf = vi.spyOn(window, "requestAnimationFrame").mockReturnValue(0);
|
|
230
|
+
render(<AudioVisualizer levels={SPEECH_LEVELS} />);
|
|
231
|
+
expect(raf).toHaveBeenCalled();
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
it("stops scheduling further animation frames once the smoothing has converged to the target — never loops forever on a settled signal", () => {
|
|
235
|
+
mockPrefersReducedMotion(false);
|
|
236
|
+
const callbacks: FrameRequestCallback[] = [];
|
|
237
|
+
let nextId = 1;
|
|
238
|
+
const raf = vi.spyOn(window, "requestAnimationFrame").mockImplementation((cb) => {
|
|
239
|
+
callbacks.push(cb);
|
|
240
|
+
return nextId++;
|
|
241
|
+
});
|
|
242
|
+
vi.spyOn(window, "cancelAnimationFrame").mockImplementation(() => {});
|
|
243
|
+
|
|
244
|
+
// Mount snaps the smoothing buffer straight to its target (see the
|
|
245
|
+
// component's own "seed/reset" comment), so a rerender with a genuinely
|
|
246
|
+
// DIFFERENT target is what actually gives the smoothing animation
|
|
247
|
+
// somewhere to go.
|
|
248
|
+
const { rerender } = render(<AudioVisualizer levels={QUIET_LEVELS} />);
|
|
249
|
+
rerender(<AudioVisualizer levels={SPEECH_LEVELS} />);
|
|
250
|
+
|
|
251
|
+
const callsBeforeFrames = raf.mock.calls.length;
|
|
252
|
+
// Manually pump up to 40 animation frames (real smoothing converges in
|
|
253
|
+
// ~14 at SMOOTHING_FACTOR=0.35 from this large a gap) by always invoking
|
|
254
|
+
// the MOST RECENTLY scheduled callback — mirroring how a browser would
|
|
255
|
+
// drive the same recursive `requestAnimationFrame(tick)` chain.
|
|
256
|
+
for (let i = 0; i < 40; i += 1) {
|
|
257
|
+
const cb = callbacks[callbacks.length - 1];
|
|
258
|
+
if (!cb) break;
|
|
259
|
+
act(() => {
|
|
260
|
+
cb(0);
|
|
261
|
+
});
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
const framesActuallyScheduled = raf.mock.calls.length - callsBeforeFrames;
|
|
265
|
+
// A never-converging loop would have scheduled all 40; the fix stops
|
|
266
|
+
// well before that once the displayed levels are within epsilon of target.
|
|
267
|
+
expect(framesActuallyScheduled).toBeGreaterThan(0);
|
|
268
|
+
expect(framesActuallyScheduled).toBeLessThan(40);
|
|
269
|
+
});
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
describe("AudioVisualizer — painted colour tracks the active theme, not a mount-time snapshot (F1)", () => {
|
|
273
|
+
// `resolveFillColor` reads `--color-primary`/`--primary` via
|
|
274
|
+
// `getComputedStyle(canvas)` — a value a REAL browser resolves by cascading
|
|
275
|
+
// down from wherever `data-theme` lives (normally `<html>`, which
|
|
276
|
+
// `ThemeProvider` writes in its own mount effect). jsdom implements neither
|
|
277
|
+
// the cascade nor custom-property inheritance (`getComputedStyle` only ever
|
|
278
|
+
// reflects an element's OWN inline style — verified against a live jsdom
|
|
279
|
+
// instance while investigating this bug), so this mock stands in for the
|
|
280
|
+
// browser's computed value while still exercising the REAL race: React
|
|
281
|
+
// runs a child's `useEffect` before its parent's, so `AudioVisualizer`'s
|
|
282
|
+
// paint effect (a child of `ThemeProvider`) fires before `ThemeProvider`'s
|
|
283
|
+
// own effect has written `data-theme` — reading whatever the un-themed
|
|
284
|
+
// `:root` fallback resolves to, exactly like the reported
|
|
285
|
+
// `rgb(57, 105, 217)`.
|
|
286
|
+
const ROOT_FALLBACK = "rgb(57, 105, 217)"; // :root's un-themed --primary
|
|
287
|
+
const THEME_PRIMARY: Record<string, string> = {
|
|
288
|
+
light: "rgb(10, 20, 30)",
|
|
289
|
+
dark: "rgb(200, 210, 220)",
|
|
290
|
+
};
|
|
291
|
+
|
|
292
|
+
let paintedColor: string | undefined;
|
|
293
|
+
|
|
294
|
+
beforeEach(() => {
|
|
295
|
+
paintedColor = undefined;
|
|
296
|
+
vi.spyOn(HTMLCanvasElement.prototype, "getContext").mockImplementation(
|
|
297
|
+
function mockGetContext() {
|
|
298
|
+
const ctx = {
|
|
299
|
+
clearRect: vi.fn(),
|
|
300
|
+
fillRect: vi.fn(() => {
|
|
301
|
+
paintedColor = ctx.fillStyle;
|
|
302
|
+
}),
|
|
303
|
+
beginPath: vi.fn(),
|
|
304
|
+
moveTo: vi.fn(),
|
|
305
|
+
lineTo: vi.fn(),
|
|
306
|
+
closePath: vi.fn(),
|
|
307
|
+
fill: vi.fn(() => {
|
|
308
|
+
paintedColor = ctx.fillStyle;
|
|
309
|
+
}),
|
|
310
|
+
canvas: { width: 320, height: 64 },
|
|
311
|
+
fillStyle: "",
|
|
312
|
+
};
|
|
313
|
+
return ctx as unknown as CanvasRenderingContext2D;
|
|
314
|
+
},
|
|
315
|
+
);
|
|
316
|
+
|
|
317
|
+
vi.spyOn(window, "getComputedStyle").mockImplementation((..._args: unknown[]) => {
|
|
318
|
+
const theme = document.documentElement.getAttribute("data-theme");
|
|
319
|
+
const primary = (theme && THEME_PRIMARY[theme]) || ROOT_FALLBACK;
|
|
320
|
+
return {
|
|
321
|
+
getPropertyValue: (prop: string) =>
|
|
322
|
+
prop === "--color-primary" || prop === "--primary" ? primary : "",
|
|
323
|
+
color: "",
|
|
324
|
+
} as CSSStyleDeclaration;
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
// `ThemeProvider` reads `window.matchMedia` on mount (OS reduced-motion
|
|
328
|
+
// tracking). Re-defined here, not just relied on from the setup file's
|
|
329
|
+
// default stub, because a sibling `describe` above permanently replaces
|
|
330
|
+
// `window.matchMedia` with a `vi.fn()` — `vi.restoreAllMocks()` in that
|
|
331
|
+
// block's own `afterEach` then resets (not removes) that mock function,
|
|
332
|
+
// so it starts returning `undefined` for every test that runs after it in
|
|
333
|
+
// this file. This block must render `ThemeProvider` correctly regardless
|
|
334
|
+
// of what ran before it.
|
|
335
|
+
Object.defineProperty(window, "matchMedia", {
|
|
336
|
+
writable: true,
|
|
337
|
+
configurable: true,
|
|
338
|
+
value: vi.fn().mockImplementation((query: string) => ({
|
|
339
|
+
matches: false,
|
|
340
|
+
media: query,
|
|
341
|
+
addEventListener: vi.fn(),
|
|
342
|
+
removeEventListener: vi.fn(),
|
|
343
|
+
})),
|
|
344
|
+
});
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
afterEach(() => {
|
|
348
|
+
document.documentElement.removeAttribute("data-theme");
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
it("paints the active theme's --primary once ThemeProvider has applied data-theme — never the :root fallback", async () => {
|
|
352
|
+
render(
|
|
353
|
+
<ThemeProvider defaultTheme="dark" storageKey={null}>
|
|
354
|
+
<AudioVisualizer loading />
|
|
355
|
+
</ThemeProvider>,
|
|
356
|
+
);
|
|
357
|
+
|
|
358
|
+
// The attribute IS on <html> by the time render() returns...
|
|
359
|
+
expect(document.documentElement.getAttribute("data-theme")).toBe("dark");
|
|
360
|
+
// ...but the fix's own correction (the MutationObserver callback, and the
|
|
361
|
+
// re-render it schedules) lands as a MICROTASK, same as it would in a
|
|
362
|
+
// real browser — flush it inside `act` before asserting the FINAL painted
|
|
363
|
+
// colour, not whatever was painted mid-race.
|
|
364
|
+
await act(async () => {
|
|
365
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
366
|
+
});
|
|
367
|
+
expect(paintedColor).not.toBe(ROOT_FALLBACK);
|
|
368
|
+
expect(paintedColor).toBe(THEME_PRIMARY.dark);
|
|
369
|
+
});
|
|
370
|
+
|
|
371
|
+
it("repaints to track a theme change after mount, rather than keeping the colour painted at mount", async () => {
|
|
372
|
+
function ThemeToggleButton() {
|
|
373
|
+
const { setTheme } = useTheme();
|
|
374
|
+
return (
|
|
375
|
+
<button type="button" onClick={() => setTheme("light")}>
|
|
376
|
+
switch to light
|
|
377
|
+
</button>
|
|
378
|
+
);
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
render(
|
|
382
|
+
<ThemeProvider defaultTheme="dark" storageKey={null}>
|
|
383
|
+
<AudioVisualizer loading />
|
|
384
|
+
<ThemeToggleButton />
|
|
385
|
+
</ThemeProvider>,
|
|
386
|
+
);
|
|
387
|
+
await act(async () => {
|
|
388
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
389
|
+
});
|
|
390
|
+
expect(paintedColor).toBe(THEME_PRIMARY.dark);
|
|
391
|
+
|
|
392
|
+
fireEvent.click(screen.getByRole("button", { name: "switch to light" }));
|
|
393
|
+
await act(async () => {
|
|
394
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
395
|
+
});
|
|
396
|
+
|
|
397
|
+
expect(paintedColor).toBe(THEME_PRIMARY.light);
|
|
398
|
+
});
|
|
399
|
+
});
|
|
400
|
+
|
|
401
|
+
describe("useAudioLevel — never requests the microphone itself", () => {
|
|
402
|
+
it("returns empty levels and never calls getUserMedia when no stream is provided", () => {
|
|
403
|
+
const getUserMedia = vi.fn();
|
|
404
|
+
Object.defineProperty(navigator, "mediaDevices", {
|
|
405
|
+
configurable: true,
|
|
406
|
+
value: { getUserMedia },
|
|
407
|
+
});
|
|
408
|
+
|
|
409
|
+
let result: ReturnType<typeof useAudioLevel> | undefined;
|
|
410
|
+
function Probe() {
|
|
411
|
+
result = useAudioLevel(null);
|
|
412
|
+
return null;
|
|
413
|
+
}
|
|
414
|
+
render(<Probe />);
|
|
415
|
+
|
|
416
|
+
expect(result).toEqual({ level: 0, levels: [] });
|
|
417
|
+
expect(getUserMedia).not.toHaveBeenCalled();
|
|
418
|
+
});
|
|
419
|
+
|
|
420
|
+
it("degrades to empty levels (never throws) when a stream is given but no Web Audio API exists", () => {
|
|
421
|
+
const originalAudioContext = (window as { AudioContext?: unknown }).AudioContext;
|
|
422
|
+
// Deliberately deleting to simulate an environment with no Web Audio API.
|
|
423
|
+
// (No ESLint rule in this repo's config flags a dot-notation `delete` — the
|
|
424
|
+
// property is restored below regardless.)
|
|
425
|
+
delete (window as { AudioContext?: unknown }).AudioContext;
|
|
426
|
+
|
|
427
|
+
const fakeStream = {} as MediaStream;
|
|
428
|
+
let result: ReturnType<typeof useAudioLevel> | undefined;
|
|
429
|
+
function Probe() {
|
|
430
|
+
result = useAudioLevel(fakeStream);
|
|
431
|
+
return null;
|
|
432
|
+
}
|
|
433
|
+
expect(() => render(<Probe />)).not.toThrow();
|
|
434
|
+
expect(result).toEqual({ level: 0, levels: [] });
|
|
435
|
+
|
|
436
|
+
(window as { AudioContext?: unknown }).AudioContext = originalAudioContext;
|
|
437
|
+
});
|
|
438
|
+
});
|