Files
hyperframes/packages/studio/src/player/components/TimelineGroupBusStrip.test.tsx
T
Vance IngallsandClaude Sonnet 5 5fd84c395b feat(studio,core): a volume and a living meter on the group row (#3290)
* feat(studio,core): a volume and a living meter on the group row

B7: the group bus strip — droppable, and deliberately minimal per the
casual-user design constraints (groups doc §5): a volume slider, a level
bar that moves with the sound, and the words "Too loud" when it clips. No
dB numbers, no peak-hold readout, no routing row.

Transport (core): groupInput() now routes each group through input -> [FX
chain or dry passthrough] -> output -> master, with one AnalyserNode per
group tapped off `output` (post-FX, so the meter reads what the bus
actually outputs) — fftSize 256, level not spectrum. groupLevel(groupId)
returns RMS-ish level 0..1 + a clipped flag off a reused per-group buffer
(no per-frame allocation), or null when the group is idle/unknown. The
runtime posts group-levels messages only while playing, piggybacking the
existing message channel rather than adding a new poll loop.

Studio: groupLevels.ts is a plain pub-sub store (mirrors liveTime.ts's
shape) fed by useTimelinePlayer's message handler via
parseGroupLevelsMessage; useGroupLevel throttles re-renders to ~33ms.
TimelineGroupBusStrip renders in the group row's own `∿` lane area
(STRIP_H, already sized in B2's row-height pipeline) — drag writes live
via onSetAudioGroupAttributeLive, release commits one undo entry via
onSetAudioGroupAttributeQuiet (packages/studio/src/hooks/
timelineAudioGroupVolume.ts, extracted from timelineTrackVisibility.ts to
stay under the 600-line cap; mirrors FxParamRow's live/commit split).
"Too loud" holds for ~2s after the last clipped block, tracked in the
component, not the transport. volumeByGroup mirrors labelByGroup in
useTimelineTrackDerivations.ts so the strip's slider round-trips the
group's own data-volume.

Fixed two pre-existing group-routing tests in webAudioTransport.test.ts
that hardcoded gain-node creation order/count — B7 inserts an extra
`output` gain node between the group's input and master (for the meter to
tap), which shifted node indices the tests asserted on directly.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* fix(studio,core): keep useTimelinePlayer under the size cap and the level buffer non-shared

Two CI gates, both from this branch's own additions.

`File size check`: `useTimelinePlayer.ts` sat at 599 lines on main and the
group-levels branch pushed it to 605 (cap 600). Extracted the `window.message`
router — which already carried a `fallow-ignore-next-line complexity` admitting
it had outgrown its home — into `previewMessageRouter.ts`, with the fixture
lease, sender check and protocol accept-gate collapsed into one
`acceptedPreviewMessage` so the listener is a flat dispatch and the suppression
is retired rather than moved. Same branches, same refs, no behaviour change;
the file lands at 561.

`Test: runtime contract`: `levelBuf: Float32Array` resolves to
`Float32Array<ArrayBufferLike>` under `tsconfig.runtime.json`, and
`getFloatTimeDomainData` will not take a possibly-shared buffer (TS2345).
Pinned the field to `Float32Array<ArrayBuffer>`, which is what
`new Float32Array(analyser.fftSize)` already produces.

Also drops `EditorShell.selectionSync.test.tsx`'s `vi.mock("./StudioFeedbackBar")`
— main deleted that component in favour of `feedback/StudioFeedbackCard`, and
touching this file for the group prop put the dangling path in fallow's scope.

---------

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-21 10:13:42 -07:00

147 lines
4.8 KiB
TypeScript

// @vitest-environment happy-dom
import React, { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { TimelineGroupBusStrip } from "./TimelineGroupBusStrip";
import { defaultTimelineTheme } from "./timelineTheme";
import { groupLevels } from "../store/groupLevels";
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
let container: HTMLDivElement;
let root: Root;
beforeEach(() => {
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
groupLevels.notify(new Map());
});
afterEach(() => {
act(() => root.unmount());
container.remove();
vi.useRealTimers();
});
function renderStrip(overrides: Partial<React.ComponentProps<typeof TimelineGroupBusStrip>> = {}) {
const onVolumeChange = vi.fn();
const onVolumeCommit = vi.fn();
act(() => {
root.render(
<TimelineGroupBusStrip
groupId="vo"
volume={1}
memberLabels={["vo-1", "vo-2"]}
onVolumeChange={onVolumeChange}
onVolumeCommit={onVolumeCommit}
theme={defaultTimelineTheme}
{...overrides}
/>,
);
});
return { onVolumeChange, onVolumeCommit };
}
function slider(): HTMLInputElement {
return container.querySelector('input[type="range"]') as HTMLInputElement;
}
const nativeInputValueSetter = Object.getOwnPropertyDescriptor(
window.HTMLInputElement.prototype,
"value",
)?.set;
/** React tracks the DOM's own value setter to detect real changes — a plain
* `input.value = ...` assignment is invisible to it, so onChange never
* fires. Go through the native setter, same as this codebase's other
* range/text input tests (e.g. propertyPanelFlatStyleSections.test.tsx). */
function setSliderValue(input: HTMLInputElement, value: string) {
nativeInputValueSetter?.call(input, value);
input.dispatchEvent(new Event("input", { bubbles: true }));
}
describe("TimelineGroupBusStrip", () => {
it('renders "Holds …" from the member labels, comma-joined', () => {
renderStrip({ memberLabels: ["vo-1", "vo-2"] });
expect(container.textContent).toContain("Holds vo-1, vo-2");
});
it("falls back to a neutral line when a group has no members yet", () => {
renderStrip({ memberLabels: [] });
expect(container.textContent).toContain("Holds nothing yet");
});
it("live-writes on every drag tick, but only commits once on release", () => {
const { onVolumeChange, onVolumeCommit } = renderStrip({ volume: 1 });
const input = slider();
act(() => setSliderValue(input, "1.2"));
act(() => setSliderValue(input, "1.5"));
expect(onVolumeChange).toHaveBeenCalledTimes(2);
expect(onVolumeChange).toHaveBeenLastCalledWith(1.5);
expect(onVolumeCommit).not.toHaveBeenCalled();
act(() => {
input.value = "1.5";
input.dispatchEvent(new PointerEvent("pointerup", { bubbles: true }));
});
expect(onVolumeCommit).toHaveBeenCalledTimes(1);
expect(onVolumeCommit).toHaveBeenCalledWith(1.5);
});
it("clamps the volume slider to the 0..2 range", () => {
const { onVolumeChange } = renderStrip();
const input = slider();
expect(input.min).toBe("0");
expect(input.max).toBe("2");
act(() => setSliderValue(input, "2"));
expect(onVolumeChange).toHaveBeenLastCalledWith(2);
});
it("the level bar tracks a live reading and shows nothing extra when it isn't clipping", () => {
vi.useFakeTimers();
renderStrip();
act(() => {
groupLevels.notify(new Map([["vo", { level: 0.4, clipped: false }]]));
vi.advanceTimersByTime(40); // past useGroupLevel's 33ms throttle
});
expect(container.textContent).not.toContain("Too loud");
});
it('shows "Too loud" while clipped and holds it for ~2s after clipping stops', () => {
vi.useFakeTimers();
renderStrip();
act(() => {
groupLevels.notify(new Map([["vo", { level: 0.9, clipped: true }]]));
vi.advanceTimersByTime(40);
});
expect(container.textContent).toContain("Too loud");
// Clipping stops — the warning must still hold for a couple seconds.
act(() => {
groupLevels.notify(new Map([["vo", { level: 0.2, clipped: false }]]));
vi.advanceTimersByTime(40);
});
expect(container.textContent).toContain("Too loud");
act(() => {
vi.advanceTimersByTime(2001);
});
expect(container.textContent).not.toContain("Too loud");
});
it("never renders a dB number anywhere in the strip (design constraint: no dB, no peak-hold readout)", () => {
vi.useFakeTimers();
renderStrip({ volume: 1.5 });
act(() => {
groupLevels.notify(new Map([["vo", { level: 0.9, clipped: true }]]));
vi.advanceTimersByTime(40);
});
expect(container.textContent ?? "").not.toMatch(/dB/i);
});
});