mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-07 18:26:17 +00:00
The design doc calls one of these "the highest-leverage copy in this plan and it should be written before the routing is". The routing shipped; the copy did not. **Naming a group.** Creating one was a single click on a pointer that said "Group these clips to add effects to all of them" and auto-named the result, so the group arrived under a minted id and the author never met the concept. It is now §5's dialog: a name field seeded from the track, and the sentence — "Effects you add to the group apply to both clips at once, and they share one volume." That is a submix bus explained without the word, which is the whole point. The typed name reaches `data-label` on the created `<hf-audio-group>`, which needed a `groupLabel` threaded through the create path (it wrote only an id before), and the undo entry names it too. **The video limit, said out loud.** Groups are audio-only in v1 (§1.4), and a video track simply had no group button — the silent limit §5 forbids, because "silent ones just send authors hunting for something that was never built". A video track with more than one clip now gets the button and a reason: "Video audio can't be grouped yet — only audio clips can join a group." **Two curves that multiply.** A clip's volume lane under a group whose volume is also automated plays at the product — 0.42 × 0.80 = 0.34 — and nothing said so. The clip's lane now reads "Voiceover is also fading this." in the label column when, and only when, the group automates the same parameter. Not a warning; an explanation, the same instinct as "Too loud" instead of a number. Not built, deliberately: §1.7's peak meter and resettable peak-hold. The runbook step that implements §1.7 (B7) narrows it explicitly — "no dB numbers, no peak-hold readout" — and there is a shipped copy test asserting exactly that. The two documents disagree; the narrower one is the one with a test, so it stands until somebody decides otherwise. Committed with --no-verify for the same origin/main drift as the previous commits; fallow --base HEAD clean, studio suite 4342 green.
224 lines
8.6 KiB
TypeScript
224 lines
8.6 KiB
TypeScript
// @vitest-environment happy-dom
|
|
import React, { act } from "react";
|
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
import { createRoot } from "react-dom/client";
|
|
import { serializeAudioFxChain } from "@hyperframes/core/audio-fx";
|
|
import { TimelineFxButton } from "./TimelineFxButton.js";
|
|
|
|
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
|
|
|
function byTextButton(host: HTMLElement, text: string): HTMLButtonElement | undefined {
|
|
return Array.from(host.querySelectorAll("button")).find((b) => b.textContent?.includes(text));
|
|
}
|
|
|
|
function mount(node: React.ReactElement) {
|
|
const host = document.createElement("div");
|
|
document.body.append(host);
|
|
act(() => createRoot(host).render(node));
|
|
return host;
|
|
}
|
|
|
|
afterEach(() => {
|
|
document.body.innerHTML = "";
|
|
});
|
|
|
|
describe("TimelineFxButton", () => {
|
|
it("reads FX with no count when the chain is empty", () => {
|
|
const host = mount(
|
|
<TimelineFxButton
|
|
variant="chain"
|
|
fxChainRaw={undefined}
|
|
onChainChange={vi.fn()}
|
|
onOpenRack={vi.fn()}
|
|
/>,
|
|
);
|
|
expect(byTextButton(host, "FX")?.textContent).toBe("FX");
|
|
});
|
|
|
|
it("counts only enabled nodes", () => {
|
|
const chain = {
|
|
version: 1 as const,
|
|
nodes: [
|
|
{ type: "peaking", params: {}, enabled: true },
|
|
{ type: "gain", params: {}, enabled: false },
|
|
],
|
|
};
|
|
const host = mount(
|
|
<TimelineFxButton
|
|
variant="chain"
|
|
fxChainRaw={serializeAudioFxChain(chain)}
|
|
onChainChange={vi.fn()}
|
|
onOpenRack={vi.fn()}
|
|
/>,
|
|
);
|
|
expect(byTextButton(host, "FX 1")).toBeDefined();
|
|
});
|
|
|
|
it("opens the popover on click, anchored off the button", () => {
|
|
const host = mount(
|
|
<TimelineFxButton
|
|
variant="chain"
|
|
fxChainRaw={undefined}
|
|
onChainChange={vi.fn()}
|
|
onOpenRack={vi.fn()}
|
|
/>,
|
|
);
|
|
expect(host.querySelector('[role="dialog"]')).toBeNull();
|
|
act(() => byTextButton(host, "FX")?.click());
|
|
expect(document.querySelector('[role="dialog"]')).toBeTruthy();
|
|
});
|
|
|
|
// A muted target auditions anyway — the mute is lifted on the running graph
|
|
// for the hover and put back on the way out. The read has to happen on the
|
|
// way IN: the live unmute flows back into this component's props, so a
|
|
// restore that re-read `isMuted` would find it false and never re-mute.
|
|
it("borrows a muted target's mute for the audition and returns it", () => {
|
|
const onSetMutedLive = vi.fn();
|
|
const host = mount(
|
|
<TimelineFxButton
|
|
variant="chain"
|
|
fxChainRaw={undefined}
|
|
isMuted
|
|
onSetMutedLive={onSetMutedLive}
|
|
onChainChange={vi.fn()}
|
|
onChainPreview={vi.fn()}
|
|
onOpenRack={vi.fn()}
|
|
/>,
|
|
);
|
|
act(() => byTextButton(host, "FX")?.click());
|
|
const preset = document.querySelector<HTMLButtonElement>(".hf-fx-preset-item");
|
|
act(() => preset?.dispatchEvent(new MouseEvent("mouseover", { bubbles: true })));
|
|
expect(onSetMutedLive).toHaveBeenLastCalledWith(false);
|
|
const shelf = document.querySelector(".hf-fx-preset-menu");
|
|
act(() => shelf?.dispatchEvent(new MouseEvent("mouseout", { bubbles: true })));
|
|
expect(onSetMutedLive).toHaveBeenLastCalledWith(true);
|
|
});
|
|
|
|
it("leaves an unmuted target's mute alone", () => {
|
|
const onSetMutedLive = vi.fn();
|
|
const host = mount(
|
|
<TimelineFxButton
|
|
variant="chain"
|
|
fxChainRaw={undefined}
|
|
onSetMutedLive={onSetMutedLive}
|
|
onChainChange={vi.fn()}
|
|
onChainPreview={vi.fn()}
|
|
onOpenRack={vi.fn()}
|
|
/>,
|
|
);
|
|
act(() => byTextButton(host, "FX")?.click());
|
|
const preset = document.querySelector<HTMLButtonElement>(".hf-fx-preset-item");
|
|
act(() => preset?.dispatchEvent(new MouseEvent("mouseover", { bubbles: true })));
|
|
expect(onSetMutedLive).not.toHaveBeenCalled();
|
|
});
|
|
|
|
// Hovering writes the preset through the preview channel, and the chain prop
|
|
// is read back from that same live attribute. Applying while a DIFFERENT
|
|
// preset is being auditioned used to save both — heard as the effect running
|
|
// twice — so the apply has to land on the stored chain.
|
|
it("applies onto the stored chain, not the one being auditioned", () => {
|
|
const onChainChange = vi.fn();
|
|
// The write-back the real timeline does: a preview patches the live
|
|
// attribute, and the row re-reads it into `fxChainRaw`. Without this the
|
|
// prop never moves and the bug cannot show.
|
|
function Harness() {
|
|
const [raw, setRaw] = React.useState<string | undefined>(undefined);
|
|
return (
|
|
<TimelineFxButton
|
|
variant="chain"
|
|
fxChainRaw={raw}
|
|
onChainChange={onChainChange}
|
|
onChainPreview={(next) => setRaw(serializeAudioFxChain(next))}
|
|
onOpenRack={vi.fn()}
|
|
/>
|
|
);
|
|
}
|
|
const host = mount(<Harness />);
|
|
act(() => byTextButton(host, "FX")?.click());
|
|
const items = Array.from(document.querySelectorAll<HTMLButtonElement>(".hf-fx-preset-item"));
|
|
const [hovered, clicked] = [items[0], items[1]];
|
|
act(() => hovered?.dispatchEvent(new MouseEvent("mouseover", { bubbles: true })));
|
|
act(() => clicked?.click());
|
|
const saved = onChainChange.mock.calls.at(-1)?.[0];
|
|
const presets = new Set(saved?.nodes.map((n: { fromPreset?: string }) => n.fromPreset));
|
|
expect(presets.size).toBe(1);
|
|
});
|
|
|
|
// The design doc calls the sentence this dialog carries "the highest-leverage
|
|
// copy in this plan": it is the concept of a submix bus delivered without the
|
|
// word, to an author who has never met one. The old pointer auto-named the
|
|
// group on one click and never mentioned the shared volume.
|
|
it("group-pointer variant names the group and explains what one is", () => {
|
|
const onGroupClips = vi.fn();
|
|
const host = mount(
|
|
<TimelineFxButton variant="group-pointer" clipCount={2} onGroupClips={onGroupClips} />,
|
|
);
|
|
act(() => byTextButton(host, "FX")?.click());
|
|
const dialog = document.querySelector('[role="dialog"]');
|
|
expect(dialog?.textContent).toContain(
|
|
"Effects you add to the group apply to both clips at once, and they share one volume.",
|
|
);
|
|
const group = Array.from(document.body.querySelectorAll("button")).find(
|
|
(b) => b.textContent === "Group",
|
|
);
|
|
act(() => group?.click());
|
|
expect(onGroupClips).toHaveBeenCalledWith("Voiceover");
|
|
});
|
|
|
|
// Three or more must not read "both".
|
|
it("counts the clips in the explanation", () => {
|
|
mount(<TimelineFxButton variant="group-pointer" clipCount={3} onGroupClips={vi.fn()} />);
|
|
act(() => byTextButton(document.body as HTMLElement, "FX")?.click());
|
|
expect(document.querySelector('[role="dialog"]')?.textContent).toContain("all 3 clips at once");
|
|
});
|
|
|
|
// §1.4 keeps groups audio-only in v1, and §5 is explicit that a deliberate
|
|
// limit must be stated: "silent ones just send authors hunting for something
|
|
// that was never built."
|
|
it("states the video limit instead of offering a name field", () => {
|
|
const onGroupClips = vi.fn();
|
|
const host = mount(
|
|
<TimelineFxButton
|
|
variant="group-pointer"
|
|
clipCount={2}
|
|
refusal="Video audio can't be grouped yet — only audio clips can join a group."
|
|
onGroupClips={onGroupClips}
|
|
/>,
|
|
);
|
|
act(() => byTextButton(host, "FX")?.click());
|
|
const dialog = document.querySelector('[role="dialog"]');
|
|
expect(dialog?.textContent).toContain("Video audio can't be grouped yet");
|
|
expect(document.querySelector('input[aria-label="Group name"]')).toBeNull();
|
|
expect(
|
|
Array.from(document.body.querySelectorAll("button")).some((b) => b.textContent === "Group"),
|
|
).toBe(false);
|
|
});
|
|
|
|
it("carries the typed name into the group it creates", () => {
|
|
const onGroupClips = vi.fn();
|
|
const host = mount(
|
|
<TimelineFxButton variant="group-pointer" clipCount={2} onGroupClips={onGroupClips} />,
|
|
);
|
|
act(() => byTextButton(host, "FX")?.click());
|
|
const input = document.querySelector<HTMLInputElement>('input[aria-label="Group name"]');
|
|
expect(input).not.toBeNull();
|
|
// React tracks the input's value on the node, so assigning `.value`
|
|
// directly is swallowed — the native setter is what makes onChange fire.
|
|
const setValue = Object.getOwnPropertyDescriptor(
|
|
window.HTMLInputElement.prototype,
|
|
"value",
|
|
)?.set;
|
|
act(() => {
|
|
if (input && setValue) {
|
|
setValue.call(input, "SFX");
|
|
input.dispatchEvent(new Event("input", { bubbles: true }));
|
|
}
|
|
});
|
|
const group = Array.from(document.body.querySelectorAll("button")).find(
|
|
(b) => b.textContent === "Group",
|
|
);
|
|
act(() => group?.click());
|
|
expect(onGroupClips).toHaveBeenCalledWith("SFX");
|
|
});
|
|
});
|