Files
hyperframes/packages/studio/src/components/EditorShell.selectionSync.test.tsx
T
Vance IngallsandClaude Sonnet 5 e1c9b50948 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>
2026-08-20 16:39:30 -07:00

111 lines
3.6 KiB
TypeScript

// @vitest-environment happy-dom
import React, { act } from "react";
import { createRoot } from "react-dom/client";
import { afterEach, describe, expect, it, vi } from "vitest";
import { EditorShell } from "./EditorShell";
const hookMocks = vi.hoisted(() => ({
useTimelineSelectionPreviewSync: vi.fn(),
}));
vi.mock("../hooks/useTimelineSelectionPreviewSync", () => hookMocks);
vi.mock("../contexts/StudioContext", () => ({
useStudioPlaybackContext: () => ({
captionEditMode: false,
refreshKey: 0,
refreshPreviewDocumentVersion: vi.fn(),
timelineElements: [],
}),
useStudioShellContext: () => ({
projectId: "project-1",
activeCompPath: "index.html",
setActiveCompPath: vi.fn(),
handlePreviewIframeRef: vi.fn(),
showToast: vi.fn(),
}),
}));
vi.mock("../contexts/DomEditContext", () => ({
useDomEditActionsContext: () => ({
handleTimelineElementSelect: vi.fn(),
buildDomSelectionForTimelineElement: vi.fn(),
applyDomSelection: vi.fn(),
applyMarqueeSelection: vi.fn(),
}),
useDomEditSelectionContext: () => ({
domEditSelection: null,
domEditGroupSelections: [],
}),
}));
vi.mock("./nle/NLEContext", () => ({
NLEProvider: ({ children }: { children: React.ReactNode }) => children,
useNLEContext: () => ({
compositionStack: [],
updateCompositionStack: vi.fn(),
containerRef: { current: null },
}),
}));
vi.mock("./nle/useTimelineEditCallbacks", () => ({
useTimelineEditCallbacks: () => ({}),
}));
vi.mock("./nle/PreviewPane", () => ({ PreviewPane: () => null }));
vi.mock("./nle/PreviewOverlays", () => ({ PreviewOverlays: () => null }));
vi.mock("./nle/TimelinePane", () => ({ TimelinePane: () => null }));
vi.mock("../captions/components/CaptionTimeline", () => ({ CaptionTimeline: () => null }));
vi.mock("./StudioFeedbackBar", () => ({ StudioFeedbackBar: () => null }));
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
afterEach(() => {
document.body.innerHTML = "";
hookMocks.useTimelineSelectionPreviewSync.mockClear();
});
describe("EditorShell timeline selection sync", () => {
it("keeps the timeline store mirrored into the preview selection", () => {
const host = document.createElement("div");
document.body.append(host);
const root = createRoot(host);
act(() => {
root.render(
<EditorShell
left={null}
right={null}
timelineToolbar={null}
renderClipContent={() => null}
handleTimelineElementDelete={vi.fn()}
handleTimelineAssetDrop={vi.fn()}
handleTimelineFileDrop={vi.fn()}
handleTimelineElementMove={vi.fn()}
handleTimelineElementsMove={vi.fn()}
handleTimelineElementResize={vi.fn()}
handleTimelineGroupResize={vi.fn()}
handleToggleTrackHidden={vi.fn()}
setAudioGroupAttribute={{ setLive: vi.fn(), setQuiet: vi.fn() }}
handleBlockedTimelineEdit={vi.fn()}
handleTimelineElementSplit={vi.fn()}
handleRazorSplit={vi.fn()}
handleRazorSplitAll={vi.fn()}
setCompIdToSrc={vi.fn()}
setCompositionLoading={vi.fn()}
shouldShowMotionPath={false}
shouldShowSelectedDomBounds={false}
/>,
);
});
expect(hookMocks.useTimelineSelectionPreviewSync).toHaveBeenCalledOnce();
expect(hookMocks.useTimelineSelectionPreviewSync).toHaveBeenCalledWith(
expect.objectContaining({
activeCompPath: "index.html",
timelineElements: [],
domEditSelection: null,
domEditGroupSelections: [],
}),
);
act(() => root.unmount());
});
});