@hyperframes/studio 0.7.107 → 0.7.109

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.
Files changed (126) hide show
  1. package/dist/assets/{hyperframes-player-BLrfwq5o.js → hyperframes-player-BYgqAPib.js} +1 -1
  2. package/dist/assets/{index-BSBk5srs.js → index-Blxia-Lt.js} +1 -1
  3. package/dist/assets/{index-BA9yhzfR.js → index-CIRxodzK.js} +1 -1
  4. package/dist/assets/index-CfBGQdrX.js +428 -0
  5. package/dist/assets/index-DNkh9mbV.css +1 -0
  6. package/dist/index.d.ts +85 -2
  7. package/dist/index.html +2 -2
  8. package/dist/index.js +17696 -12512
  9. package/dist/index.js.map +1 -1
  10. package/package.json +7 -7
  11. package/src/components/StudioRightPanel.tsx +18 -1
  12. package/src/components/TimelineToolbar.test.tsx +46 -0
  13. package/src/components/TimelineToolbar.tsx +17 -0
  14. package/src/components/editor/PropertyPanel.test.tsx +5 -1
  15. package/src/components/editor/PropertyPanel.tsx +7 -23
  16. package/src/components/editor/PropertyPanelFlat.tsx +25 -0
  17. package/src/components/editor/audioFxSummary.test.ts +75 -0
  18. package/src/components/editor/audioFxSummary.ts +36 -0
  19. package/src/components/editor/audioFxTelemetry.test.ts +129 -0
  20. package/src/components/editor/audioFxTelemetry.ts +230 -0
  21. package/src/components/editor/propertyPanelAudioFxGroup.test.tsx +1830 -0
  22. package/src/components/editor/propertyPanelAudioFxGroup.tsx +299 -0
  23. package/src/components/editor/propertyPanelAudioFxGroupUtils.ts +21 -0
  24. package/src/components/editor/propertyPanelAutomation.test.ts +60 -0
  25. package/src/components/editor/propertyPanelAutomation.ts +97 -0
  26. package/src/components/editor/propertyPanelFlatMediaSection.tsx +37 -9
  27. package/src/components/editor/propertyPanelFlatProps.ts +1 -0
  28. package/src/components/editor/propertyPanelFxAddMenu.tsx +209 -0
  29. package/src/components/editor/propertyPanelFxBandRuler.tsx +72 -0
  30. package/src/components/editor/propertyPanelFxCarveModule.tsx +382 -0
  31. package/src/components/editor/propertyPanelFxControls.test.tsx +115 -0
  32. package/src/components/editor/propertyPanelFxControls.tsx +356 -0
  33. package/src/components/editor/propertyPanelFxEqModule.tsx +196 -0
  34. package/src/components/editor/propertyPanelFxFamily.ts +83 -0
  35. package/src/components/editor/propertyPanelFxNodeOpenBody.tsx +269 -0
  36. package/src/components/editor/propertyPanelFxNodeParams.tsx +112 -0
  37. package/src/components/editor/propertyPanelFxNodeRow.tsx +339 -0
  38. package/src/components/editor/propertyPanelFxPresetMenu.tsx +159 -0
  39. package/src/components/editor/propertyPanelFxPresetRun.tsx +215 -0
  40. package/src/components/editor/propertyPanelFxPresetStyle.test.ts +142 -0
  41. package/src/components/editor/propertyPanelFxPresetStyle.ts +216 -0
  42. package/src/components/editor/propertyPanelFxRackChain.tsx +202 -0
  43. package/src/components/editor/propertyPanelFxSection.test.tsx +1747 -0
  44. package/src/components/editor/propertyPanelFxSection.tsx +550 -0
  45. package/src/components/editor/propertyPanelFxSectionTypes.ts +91 -0
  46. package/src/components/editor/propertyPanelTypes.ts +5 -0
  47. package/src/components/editor/useFxAudition.ts +95 -0
  48. package/src/components/editor/useFxCarve.ts +0 -0
  49. package/src/components/editor/useFxChainObserved.ts +83 -0
  50. package/src/components/editor/useFxLevelling.ts +235 -0
  51. package/src/components/editor/useVolumeAutomation.test.tsx +122 -0
  52. package/src/components/editor/useVolumeAutomation.ts +54 -0
  53. package/src/contexts/DomEditContext.tsx +11 -0
  54. package/src/hooks/useAppHotkeys.test.ts +198 -0
  55. package/src/hooks/useAppHotkeys.ts +30 -3
  56. package/src/hooks/useAutomationSelectionKeyboard.test.tsx +429 -0
  57. package/src/hooks/useAutomationSelectionKeyboard.ts +371 -0
  58. package/src/hooks/useDomEditAttributeCommits.ts +90 -10
  59. package/src/hooks/useDomEditCommits.test.tsx +38 -0
  60. package/src/hooks/useDomEditCommits.ts +2 -0
  61. package/src/hooks/useDomEditSession.ts +2 -0
  62. package/src/hooks/useDomEditTextCommits.ts +2 -0
  63. package/src/hooks/useElementLifecycleOps.ts +3 -0
  64. package/src/hooks/useLivePlayheadTime.ts +49 -0
  65. package/src/hooks/usePreviewPersistence.ts +7 -0
  66. package/src/hooks/useTimelineEditing.ts +3 -0
  67. package/src/player/components/AutomationSelectionMenu.tsx +76 -0
  68. package/src/player/components/AutomationValueInput.tsx +51 -0
  69. package/src/player/components/LayerDisclosureRow.tsx +5 -4
  70. package/src/player/components/Timeline.test.ts +84 -0
  71. package/src/player/components/TimelineAutomationLane.test.tsx +1720 -0
  72. package/src/player/components/TimelineAutomationLane.tsx +674 -0
  73. package/src/player/components/TimelineAutomationLaneSlot.test.tsx +193 -0
  74. package/src/player/components/TimelineLanes.test.tsx +24 -5
  75. package/src/player/components/TimelineLanes.tsx +142 -154
  76. package/src/player/components/TimelineTrackHeader.test.tsx +212 -1
  77. package/src/player/components/TimelineTrackHeader.tsx +166 -6
  78. package/src/player/components/TimelineTrackRow.tsx +8 -1
  79. package/src/player/components/automationClipboard.test.ts +94 -0
  80. package/src/player/components/automationClipboard.ts +136 -0
  81. package/src/player/components/automationGestureKeys.test.ts +49 -0
  82. package/src/player/components/automationGestureKeys.ts +49 -0
  83. package/src/player/components/automationLaneData.test.ts +270 -0
  84. package/src/player/components/automationLaneData.ts +258 -0
  85. package/src/player/components/automationLaneDragMath.ts +194 -0
  86. package/src/player/components/automationLaneGeometry.test.ts +239 -0
  87. package/src/player/components/automationLaneGeometry.ts +287 -0
  88. package/src/player/components/automationLaneHeight.ts +15 -0
  89. package/src/player/components/automationLanePointer.ts +20 -0
  90. package/src/player/components/automationLaneSelection.test.ts +183 -0
  91. package/src/player/components/automationLaneSelection.ts +135 -0
  92. package/src/player/components/automationShapes.test.ts +82 -0
  93. package/src/player/components/automationShapes.ts +70 -0
  94. package/src/player/components/automationSimplify.test.ts +48 -0
  95. package/src/player/components/automationSimplify.ts +51 -0
  96. package/src/player/components/timelineClipGestureHandlers.ts +223 -0
  97. package/src/player/components/timelineLaneProps.ts +19 -0
  98. package/src/player/components/timelineLayout.ts +11 -5
  99. package/src/player/components/useAutomationEdgeStretch.test.ts +27 -0
  100. package/src/player/components/useAutomationEdgeStretch.ts +269 -0
  101. package/src/player/components/useAutomationLaneGestures.ts +537 -0
  102. package/src/player/components/useAutomationLanes.test.tsx +112 -0
  103. package/src/player/components/useAutomationLanes.ts +170 -0
  104. package/src/player/components/useAutomationRangeDrag.ts +114 -0
  105. package/src/player/components/useTimelineTrackLayout.test.ts +107 -1
  106. package/src/player/components/useTimelineTrackLayout.ts +83 -7
  107. package/src/player/hooks/useExpandedTimelineElements.ts +7 -0
  108. package/src/player/hooks/useTimelinePlayer.ts +13 -1
  109. package/src/player/lib/automationStoreSync.test.ts +83 -0
  110. package/src/player/lib/automationStoreSync.ts +56 -0
  111. package/src/player/lib/timelineDOM.test.ts +33 -0
  112. package/src/player/lib/timelineDOM.ts +12 -0
  113. package/src/player/store/automationSelectionSlice.test.ts +30 -0
  114. package/src/player/store/automationSelectionSlice.ts +54 -0
  115. package/src/player/store/playerStore.test.ts +15 -0
  116. package/src/player/store/playerStore.ts +54 -65
  117. package/src/player/store/timelineElement.ts +78 -0
  118. package/src/styles/studio.css +89 -0
  119. package/src/telemetry/agentRuntime.test.ts +45 -0
  120. package/src/telemetry/agentRuntime.ts +62 -0
  121. package/src/telemetry/system.ts +8 -0
  122. package/src/utils/studioFileHistory.test.ts +49 -0
  123. package/src/utils/studioFileHistory.ts +17 -1
  124. package/src/utils/studioTelemetry.ts +16 -10
  125. package/dist/assets/index-dF-CmLZu.js +0 -428
  126. package/dist/assets/index-zQ4JFwwB.css +0 -1
@@ -0,0 +1,1747 @@
1
+ // @vitest-environment happy-dom
2
+ import { act } from "react";
3
+ import { afterEach, describe, expect, it, vi } from "vitest";
4
+ import {
5
+ defaultAudioFxParams,
6
+ getAudioFxDef,
7
+ HF_AUDIO_FX,
8
+ type HfAudioFxChain,
9
+ } from "@hyperframes/core/audio-fx";
10
+ import { DEFAULT_CARVE } from "@hyperframes/core/audio-carve";
11
+ import { BANDS, EFFECT_COPY, PRESET_PROBLEM } from "@hyperframes/core/audio-fx-copy";
12
+ import { HF_AUDIO_FX_JOBS, HF_AUDIO_FX_JOB_TYPES } from "@hyperframes/core/audio-fx-jobs";
13
+ import { audioFxProfileStrength } from "@hyperframes/core/audio-fx-profiles";
14
+ import { fxPresetStyle } from "./propertyPanelFxPresetStyle.js";
15
+ import { applyAudioFxPreset, getAudioFxPreset } from "@hyperframes/core/audio-fx-presets";
16
+
17
+ /**
18
+ * What a knob is CALLED in the panel, looked up rather than spelled out.
19
+ *
20
+ * The rack speaks the plain-language layer now, so a row is addressed by the
21
+ * parameter it belongs to and the copy decides the words. Hard-coding them here
22
+ * would make every copy edit a test edit, and these tests are about which row
23
+ * carries the automate button — not about how it reads.
24
+ */
25
+ function plainLabel(effectId: string, key: string): string {
26
+ const label = EFFECT_COPY[effectId]?.params[key]?.label;
27
+ if (!label) throw new Error(`no copy for ${effectId}.${key}`);
28
+ return label;
29
+ }
30
+ import { createRoot } from "react-dom/client";
31
+ import { FxSection } from "./propertyPanelFxSection.js";
32
+
33
+ (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
34
+
35
+ function renderInto(node: React.ReactElement) {
36
+ const host = document.createElement("div");
37
+ document.body.append(host);
38
+ const root = createRoot(host);
39
+ act(() => {
40
+ root.render(node);
41
+ });
42
+ return { host, root };
43
+ }
44
+
45
+ const chainOf = (...types: string[]): HfAudioFxChain => ({
46
+ version: 1,
47
+ nodes: types.map((t) => ({ type: t, enabled: true, params: defaultAudioFxParams(t) })),
48
+ });
49
+
50
+ /** A carve's own filters and level stage, plus one effect the author added. */
51
+ const carved = {
52
+ version: 1,
53
+ nodes: [
54
+ { type: "peaking", id: "n1", fromCarve: true, params: { frequency: 400, gain: -6, q: 1.4 } },
55
+ { type: "peaking", id: "n2", fromCarve: true, params: { frequency: 1600, gain: -9, q: 1.4 } },
56
+ { type: "gain", id: "n3", fromCarve: true, params: { gain: -6 } },
57
+ { type: "lowpass", id: "n4", params: { frequency: 8000, q: 0.7, poles: "2" } },
58
+ ],
59
+ } as unknown as HfAudioFxChain;
60
+
61
+ /** The first effect the author added, skipping the carve module that leads the rack. */
62
+ function fxCard(host: HTMLElement): HTMLElement {
63
+ return host.querySelector<HTMLElement>(".hf-fx-node:not(.hf-fx-carve-module)")!;
64
+ }
65
+
66
+ /** Open the carve module if something closed it — it starts open. */
67
+ function ensureCarveOpen(host: HTMLElement): HTMLElement {
68
+ const module = host.querySelector<HTMLElement>(".hf-fx-carve-module")!;
69
+ const head = module.querySelector<HTMLButtonElement>(".hf-fx-node-name")!;
70
+ if (head.getAttribute("aria-expanded") === "false") act(() => head.click());
71
+ return module;
72
+ }
73
+
74
+ function mount(overrides: Partial<Parameters<typeof FxSection>[0]> = {}) {
75
+ const onChainChange = vi.fn();
76
+ const onChainPreview = vi.fn();
77
+ const onCarveChange = vi.fn();
78
+ const { host, root } = renderInto(
79
+ <FxSection
80
+ chain={overrides.chain ?? { version: 1, nodes: [] }}
81
+ onChainChange={overrides.onChainChange ?? onChainChange}
82
+ onChainPreview={overrides.onChainPreview ?? onChainPreview}
83
+ carve={overrides.carve ?? null}
84
+ onCarveChange={overrides.onCarveChange ?? onCarveChange}
85
+ sourceOptions={overrides.sourceOptions ?? [{ id: "vo", label: "Voiceover" }]}
86
+ analysing={overrides.analysing}
87
+ disabled={overrides.disabled}
88
+ automatedTargets={overrides.automatedTargets}
89
+ onAutomateParam={overrides.onAutomateParam}
90
+ onRemoveParamAutomation={overrides.onRemoveParamAutomation}
91
+ onRemoveNodeAutomation={overrides.onRemoveNodeAutomation}
92
+ onRemoveNodesAutomation={overrides.onRemoveNodesAutomation}
93
+ onAutomatePreset={overrides.onAutomatePreset}
94
+ onRemovePresetAutomation={overrides.onRemovePresetAutomation}
95
+ onAuditionTransport={overrides.onAuditionTransport}
96
+ automatedPresets={overrides.automatedPresets}
97
+ onLevel={overrides.onLevel}
98
+ onRemoveLevel={overrides.onRemoveLevel}
99
+ levelled={overrides.levelled}
100
+ onAuditionLevel={overrides.onAuditionLevel}
101
+ auditioningLevel={overrides.auditioningLevel}
102
+ trackKind={overrides.trackKind}
103
+ />,
104
+ );
105
+ return { host, root, onChainChange, onChainPreview, onCarveChange };
106
+ }
107
+
108
+ const click = (el: Element | null | undefined) => {
109
+ if (!el) throw new Error("element not found");
110
+ act(() => {
111
+ (el as HTMLElement).click();
112
+ });
113
+ };
114
+ /**
115
+ * Open a module's Details, where every control that is not the primary one now
116
+ * lives — a module opens on one knob and the rest is one click away.
117
+ */
118
+ function openDetails(host: HTMLElement, index = 0): void {
119
+ const buttons = Array.from(host.querySelectorAll<HTMLButtonElement>(".hf-fx-node-details"));
120
+ const button = buttons[index];
121
+ if (!button) throw new Error("no Details disclosure to open");
122
+ act(() => button.click());
123
+ }
124
+
125
+ const byText = (host: HTMLElement, sel: string, text: string) =>
126
+ Array.from(host.querySelectorAll(sel)).find((e) => e.textContent?.trim() === text);
127
+
128
+ const openAddMenuItems = (host: HTMLElement) => {
129
+ click(host.querySelector(".hf-fx-add"));
130
+ return Array.from(host.querySelectorAll(".hf-fx-add-item")).map((e) => e.textContent?.trim());
131
+ };
132
+
133
+ /**
134
+ * A preset button, found by the preset it applies rather than by its words.
135
+ *
136
+ * The shelf leads with the complaint and follows with the name, so a button's
137
+ * text is two sentences and neither of them alone is what an author would call
138
+ * it. Addressing it by id keeps the test about what applying it does.
139
+ */
140
+ const presetButton = (host: HTMLElement, id: string): Element | undefined =>
141
+ Array.from(host.querySelectorAll(".hf-fx-preset-item")).find(
142
+ (e) =>
143
+ e.querySelector(".hf-fx-preset-name")?.textContent?.trim() === getAudioFxPreset(id)?.label,
144
+ );
145
+
146
+ /**
147
+ * React tracks an input's value on the DOM node, so assigning `.value` and
148
+ * dispatching looks like a no-op change and the handler never fires. Going
149
+ * through the native setter clears that tracker.
150
+ */
151
+ const typeInto = (input: HTMLInputElement, value: string) => {
152
+ const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")?.set;
153
+ act(() => {
154
+ setter?.call(input, value);
155
+ input.dispatchEvent(new Event("input", { bubbles: true }));
156
+ });
157
+ };
158
+
159
+ afterEach(() => {
160
+ vi.restoreAllMocks();
161
+ document.body.innerHTML = "";
162
+ });
163
+
164
+ describe("FxSection chain", () => {
165
+ it("says so when the track has no effects", () => {
166
+ const { host } = mount();
167
+ // "other", because the carve module is in the rack whenever a voice exists.
168
+ expect(host.querySelector(".hf-fx-empty")?.textContent).toMatch(/No other effects/);
169
+ });
170
+
171
+ it("offers every effect in the registry, grouped", () => {
172
+ // The add menu is generated, so a new effect upstream appears here with no
173
+ // change to the panel.
174
+ const { host } = mount();
175
+ const items = openAddMenuItems(host);
176
+ // Every effect is reachable, but not every one under its own name: the jobs
177
+ // stand in for the effect they are made of, because picking `peaking` is
178
+ // picking a machine and leaving the real decision for afterwards.
179
+ const standIns = HF_AUDIO_FX.filter((d) => HF_AUDIO_FX_JOB_TYPES.has(d.id));
180
+ expect(items).toHaveLength(HF_AUDIO_FX.length - standIns.length + HF_AUDIO_FX_JOBS.length);
181
+ for (const def of HF_AUDIO_FX) {
182
+ if (HF_AUDIO_FX_JOB_TYPES.has(def.id)) {
183
+ // Not offered as itself, and it must not be — two doors to the same
184
+ // effect, one of them the incoherent one, is worse than either alone.
185
+ expect(items).not.toContain(EFFECT_COPY[def.id]?.title);
186
+ continue;
187
+ }
188
+ // By the name the RACK will use, not the registry's — picking "High-pass"
189
+ // and getting a module called "Remove Rumble" is the inconsistency this
190
+ // whole layer exists to remove.
191
+ expect(items).toContain(EFFECT_COPY[def.id]?.title);
192
+ }
193
+ for (const job of HF_AUDIO_FX_JOBS) expect(items).toContain(job.label);
194
+ });
195
+
196
+ it("adds a job as an ordinary effect, already named and already aimed", () => {
197
+ // The range IS the module: one knob is honest here because the decision the
198
+ // knob depends on has already been made.
199
+ const { host, onChainChange } = mount({ chain: { version: 1, nodes: [] } });
200
+ click(host.querySelector(".hf-fx-add"));
201
+ click(byText(host, ".hf-fx-add-item", "Reduce Mud"));
202
+
203
+ const next = onChainChange.mock.calls[0]?.[0] as HfAudioFxChain;
204
+ expect(next.nodes).toHaveLength(1);
205
+ expect(next.nodes[0]?.type).toBe("peaking");
206
+ expect(next.nodes[0]?.label).toBe("Reduce Mud");
207
+ expect(next.nodes[0]?.params?.frequency).toBe(250);
208
+ });
209
+
210
+ it("shows two jobs over the same effect as the different things they are", () => {
211
+ // The whole point. Read down the rack, "Shape One Range" twice was two rows
212
+ // an author could not tell apart — and one of them was cutting while the
213
+ // other was boosting.
214
+ const { host } = mount({
215
+ chain: {
216
+ version: 1,
217
+ nodes: [
218
+ { type: "peaking", label: "Reduce Mud", params: { frequency: 250, gain: -3, q: 1.2 } },
219
+ { type: "peaking", label: "Add Clarity", params: { frequency: 3000, gain: 2.5, q: 1 } },
220
+ ],
221
+ } as unknown as HfAudioFxChain,
222
+ });
223
+ const names = Array.from(host.querySelectorAll(".hf-fx-node-name")).map((e) =>
224
+ e.textContent?.trim(),
225
+ );
226
+ expect(names).toContain("Reduce Mud");
227
+ expect(names).toContain("Add Clarity");
228
+ expect(names).not.toContain(EFFECT_COPY.peaking?.title);
229
+ });
230
+
231
+ it("adds an effect seeded with its declared defaults", () => {
232
+ // An effect with no derived knob arrives exactly as the registry declares
233
+ // it. The five that DO have one are seeded on their curve instead — see
234
+ // "adds a profiled effect on its curve" below.
235
+ const { host, onChainChange } = mount();
236
+ click(host.querySelector(".hf-fx-add"));
237
+ click(byText(host, ".hf-fx-add-item", EFFECT_COPY.delay?.title ?? ""));
238
+ expect(onChainChange).toHaveBeenCalledTimes(1);
239
+ const next = onChainChange.mock.calls[0]![0] as HfAudioFxChain;
240
+ expect(next.nodes).toHaveLength(1);
241
+ expect(next.nodes[0]!.type).toBe("delay");
242
+ expect(next.nodes[0]!.params).toEqual(defaultAudioFxParams("delay"));
243
+ });
244
+
245
+ it("renders a control for every parameter the effect declares", () => {
246
+ const { host } = mount({ chain: chainOf("compressor") });
247
+ const def = getAudioFxDef("compressor")!;
248
+ const labels = Array.from(host.querySelectorAll(".hf-fx-label")).map((e) =>
249
+ e.textContent?.trim(),
250
+ );
251
+ // Under Details: a compressor's face is one derived knob, and its seven real
252
+ // controls are one click in.
253
+ openDetails(host);
254
+ const opened = Array.from(host.querySelectorAll(".hf-fx-label")).map((e) =>
255
+ e.textContent?.trim(),
256
+ );
257
+ for (const p of def.params) expect(opened).toContain(plainLabel("compressor", p.key));
258
+ void labels;
259
+ });
260
+
261
+ it("uses a select for an enum parameter and a slider for a number", () => {
262
+ const { host } = mount({ chain: chainOf("saturate") });
263
+ // Its curve type is an enum and lives under Details, since saturation's one
264
+ // knob is derived rather than being any single parameter.
265
+ openDetails(host);
266
+ expect(host.querySelector(".hf-fx-select")).toBeTruthy();
267
+ expect(host.querySelector(".hf-fx-slider")).toBeTruthy();
268
+ });
269
+
270
+ it("bypasses without removing, so the settings survive", () => {
271
+ const { host, onChainChange } = mount({ chain: chainOf("peaking") });
272
+ click(fxCard(host).querySelector(".hf-fx-bypass"));
273
+ const next = onChainChange.mock.calls[0]![0] as HfAudioFxChain;
274
+ expect(next.nodes).toHaveLength(1);
275
+ expect(next.nodes[0]!.enabled).toBe(false);
276
+ expect(next.nodes[0]!.params).toEqual(defaultAudioFxParams("peaking"));
277
+ });
278
+
279
+ it("reorders, because chain order changes the sound", () => {
280
+ const { host, onChainChange } = mount({ chain: chainOf("peaking", "reverb") });
281
+ const downs = Array.from(host.querySelectorAll('.hf-fx-move[title="Move down"]'));
282
+ click(downs[0]);
283
+ const next = onChainChange.mock.calls[0]![0] as HfAudioFxChain;
284
+ expect(next.nodes.map((n) => n.type)).toEqual(["reverb", "peaking"]);
285
+ });
286
+
287
+ it("keeps a half-typed value with its own effect across a reorder", () => {
288
+ // Rows used to be keyed `${type}-${index}`, so two effects of the same type
289
+ // kept their keys through a reorder and React reused each row where it
290
+ // stood. The controls hold real state — a number field mid-edit is held as
291
+ // text — so the buffer stayed at the position and landed on whichever
292
+ // effect moved into it.
293
+ const peaking = (id: string, frequency: number) => ({
294
+ type: "peaking",
295
+ id,
296
+ enabled: true,
297
+ params: { ...defaultAudioFxParams("peaking"), frequency },
298
+ });
299
+ const a = peaking("pa", 400);
300
+ const b = peaking("pb", 1600);
301
+ const chainOfNodes = (...nodes: unknown[]): HfAudioFxChain =>
302
+ ({ version: 1, nodes }) as HfAudioFxChain;
303
+
304
+ const host = document.createElement("div");
305
+ document.body.append(host);
306
+ const root = createRoot(host);
307
+ const render = (chain: HfAudioFxChain) =>
308
+ act(() => {
309
+ root.render(
310
+ <FxSection
311
+ chain={chain}
312
+ onChainChange={vi.fn()}
313
+ onChainPreview={vi.fn()}
314
+ carve={null}
315
+ onCarveChange={vi.fn()}
316
+ sourceOptions={[]}
317
+ />,
318
+ );
319
+ });
320
+
321
+ render(chainOfNodes(a, b));
322
+ // Frequency is behind Details for a peaking node — the module opens on how
323
+ // much, now that picking the module is what picks the range.
324
+ openDetails(host);
325
+ // Only the first card is open, which is the one being edited.
326
+ const openFrequency = (): HTMLInputElement =>
327
+ host.querySelector<HTMLInputElement>(".hf-fx-node .hf-fx-number")!;
328
+
329
+ expect(openFrequency().value).toBe("400");
330
+ typeInto(openFrequency(), "123");
331
+ expect(openFrequency().value).toBe("123");
332
+
333
+ // The author moves that effect down; the other one takes the open slot.
334
+ render(chainOfNodes(b, a));
335
+
336
+ // Its own Details, not the one that was open: the disclosure is per module,
337
+ // so the effect arriving in the slot arrives closed like any other.
338
+ openDetails(host);
339
+ expect(openFrequency().value).toBe("1600");
340
+ });
341
+
342
+ /**
343
+ * The shelf leads with the complaint a preset answers, so offering the voice
344
+ * family on a music bed offers the author a problem they cannot have. The
345
+ * hiding is deliberately timid: only a name that plainly reads as music or as
346
+ * an effect loses anything, because a name is a hint and hiding what somebody
347
+ * came for costs more than one extra shelf to scroll past.
348
+ */
349
+ const shelfFamilies = (host: HTMLElement): string[] =>
350
+ Array.from(host.querySelectorAll(".hf-fx-preset-group-label")).map((e) =>
351
+ (e.textContent ?? "").trim(),
352
+ );
353
+
354
+ it("keeps the voice presets on a track whose name says nothing", () => {
355
+ const { host } = mount({ trackKind: "unknown" });
356
+ click(byText(host, "button", "Presets"));
357
+ expect(shelfFamilies(host)).toEqual(["Voice", "Fix", "Character", "Space"]);
358
+ });
359
+
360
+ it("keeps them when nothing classified the track at all", () => {
361
+ const { host } = mount({});
362
+ click(byText(host, "button", "Presets"));
363
+ expect(shelfFamilies(host)).toContain("Voice");
364
+ });
365
+
366
+ it("drops the voice shelf on a music bed, and on an effect", () => {
367
+ for (const kind of ["music", "sfx"] as const) {
368
+ const { host } = mount({ trackKind: kind });
369
+ click(byText(host, "button", "Presets"));
370
+ expect(shelfFamilies(host)).toEqual(["Fix", "Character", "Space"]);
371
+ // The rest of the shelf is untouched — this hides one family, it does not
372
+ // narrow the panel down to "repair".
373
+ expect(presetButton(host, "telephone")).toBeTruthy();
374
+ expect(presetButton(host, "voice-clean")).toBeUndefined();
375
+ }
376
+ });
377
+
378
+ it("applies a preset as ordinary nodes, tagged with where they came from", () => {
379
+ const { host, onChainChange } = mount({ chain: { version: 1, nodes: [] } });
380
+ click(byText(host, "button", "Presets"));
381
+ click(presetButton(host, "telephone"));
382
+
383
+ const next = onChainChange.mock.calls[0]![0] as HfAudioFxChain;
384
+ // The band, its honk and de-mud shaping, and the soft clip — a chain the
385
+ // author can now see and edit, not an opaque "telephone" setting.
386
+ expect(next.nodes.map((n) => n.type)).toEqual([
387
+ "highpass",
388
+ "highpass",
389
+ "lowpass",
390
+ "lowpass",
391
+ "peaking",
392
+ "peaking",
393
+ "saturate",
394
+ ]);
395
+ expect(next.nodes.every((n) => n.fromPreset === "telephone")).toBe(true);
396
+ // Every node needs an id or its parameters can never be automated.
397
+ expect(new Set(next.nodes.map((n) => n.id)).size).toBe(next.nodes.length);
398
+ });
399
+
400
+ it("names an effect for the job it does, and keeps the DSP name for inside", () => {
401
+ // The rack is read by somebody who has never opened a mixer. "Remove Rumble"
402
+ // is what they came here for; "High-pass" is a fact about the mechanism, so
403
+ // it waits until they open the module and ask.
404
+ const { host } = mount({ chain: chainOf("highpass") });
405
+ const node = fxCard(host);
406
+ const name = node.querySelector(".hf-fx-node-name")?.textContent?.trim();
407
+ expect(name).toBe(EFFECT_COPY.highpass?.title);
408
+ expect(name).not.toBe(getAudioFxDef("highpass")?.label);
409
+ // And a sentence under it, so the rack reads top to bottom.
410
+ expect(node.querySelector(".hf-fx-node-summary")?.textContent).toContain("Cutting everything");
411
+ // Open, it says what it is for and offers ONE knob — the rest is behind a
412
+ // disclosure, which is also the only place the DSP name appears.
413
+ expect(node.querySelector(".hf-fx-node-does")?.textContent).toBe(EFFECT_COPY.highpass?.does);
414
+ expect(node.querySelectorAll(".hf-fx-row")).toHaveLength(1);
415
+ const details = node.querySelector(".hf-fx-node-details");
416
+ expect(details?.textContent).toContain(getAudioFxDef("highpass")?.label);
417
+ expect(details?.getAttribute("aria-expanded")).toBe("false");
418
+
419
+ openDetails(host);
420
+ expect(node.querySelectorAll(".hf-fx-row").length).toBe(
421
+ getAudioFxDef("highpass")?.params.length,
422
+ );
423
+ });
424
+
425
+ it("draws the rack as a signal path, with both ends named", () => {
426
+ // Order is audible here, and a list does not look ordered. Numbering the
427
+ // steps and naming the two ends is what makes "move up" read as the most
428
+ // consequential control in the panel rather than a cosmetic one.
429
+ const { host } = mount({ chain: chainOf("highpass", "limiter") });
430
+ const terms = Array.from(host.querySelectorAll(".hf-fx-term")).map((e) => e.textContent);
431
+ expect(terms).toHaveLength(2);
432
+ expect(terms[0]).toContain("In");
433
+ expect(terms[1]).toContain("Out");
434
+ // Counted over what the rack SHOWS: the carve module leads it, so the first
435
+ // hand-built effect is 02.
436
+ const numbers = Array.from(host.querySelectorAll(".hf-fx-node-index")).map((e) =>
437
+ e.textContent?.trim(),
438
+ );
439
+ expect(numbers).toEqual(["02", "03"]);
440
+ });
441
+
442
+ it("draws a preset's nodes as the one thing that was added", () => {
443
+ // Applying a preset drops five rows into the rack with nothing saying they
444
+ // arrived together — the same failure the carve module exists to fix, one
445
+ // level down.
446
+ const { host, onChainChange } = mount({ chain: { version: 1, nodes: [] } });
447
+ click(byText(host, "button", "Presets"));
448
+ click(presetButton(host, "telephone"));
449
+ // Applying does not re-render this mount — the chain comes back as a prop —
450
+ // so the rack is read from what was written.
451
+ const applied = onChainChange.mock.calls[0]?.[0] as HfAudioFxChain | undefined;
452
+ const written = applied?.nodes ?? [];
453
+ const { host: after } = mount({ chain: { version: 1, nodes: written } });
454
+
455
+ const run = after.querySelector("[data-fx-preset='telephone']");
456
+ expect(run).toBeTruthy();
457
+ // The label carries a disclosure caret, so match the name inside it.
458
+ expect(run?.querySelector(".hf-fx-preset-run-label")?.textContent).toContain("Telephone");
459
+ expect(run?.querySelectorAll(".hf-fx-node")).toHaveLength(written.length);
460
+ });
461
+
462
+ describe("a preset is one thing to switch off or take away", () => {
463
+ /**
464
+ * The run's own Amount row — not a member module's.
465
+ *
466
+ * It is a direct child of the bracket; a member's rows are nested inside its
467
+ * own card, which is what makes the distinction structural rather than
468
+ * positional.
469
+ */
470
+ const amountRow = (host: HTMLElement): HTMLElement | null => {
471
+ const bracket = host.querySelector("[data-fx-preset='telephone']");
472
+ // A direct-child walk rather than `:scope >`, which happy-dom's matcher
473
+ // does not support — it returns nothing rather than erroring, which reads
474
+ // as "the control is missing".
475
+ return (Array.from(bracket?.children ?? []).find((c) => c.classList.contains("hf-fx-row")) ??
476
+ null) as HTMLElement | null;
477
+ };
478
+
479
+ /** A telephone preset applied, plus one hand-built effect beside it. */
480
+ const applied = (): HfAudioFxChain => {
481
+ const preset = getAudioFxPreset("telephone");
482
+ if (!preset) throw new Error("no telephone preset");
483
+ // Through the real applier: `fromPreset` is stamped there, not carried in
484
+ // the catalogue, and the tag is the whole basis of the bracket.
485
+ const withPreset = applyAudioFxPreset({ version: 1, nodes: [] }, preset);
486
+ return {
487
+ ...withPreset,
488
+ nodes: [
489
+ ...withPreset.nodes,
490
+ { type: "reverb", id: "own", enabled: true, params: defaultAudioFxParams("reverb") },
491
+ ],
492
+ };
493
+ };
494
+
495
+ it("switches the whole preset off in one gesture", () => {
496
+ // Reaching into five modules and toggling each is exactly the bookkeeping
497
+ // the bracket exists to remove.
498
+ const { host, onChainChange } = mount({ chain: applied() });
499
+ const run = host.querySelector("[data-fx-preset='telephone']");
500
+ click(run?.querySelector(".hf-fx-preset-run-toggle"));
501
+
502
+ const next = onChainChange.mock.calls[0]?.[0] as HfAudioFxChain;
503
+ // Amount, not `enabled`: the switch and the lane are the same value, so
504
+ // Off is one end of the control a ramp moves along rather than a second
505
+ // way of silencing the preset. Writing `enabled` would take the nodes out
506
+ // of the graph, which a lane cannot do part-way.
507
+ const members = next.nodes.filter((n) => n.fromPreset === "telephone");
508
+ expect(members.every((n) => n.presetAmount === 0)).toBe(true);
509
+ // Still in the graph, so the settings survive and it can come back.
510
+ expect(members.every((n) => n.enabled !== false)).toBe(true);
511
+ // And leaves what the author added themselves alone.
512
+ expect(next.nodes.find((n) => n.id === "own")?.presetAmount).toBeUndefined();
513
+ });
514
+
515
+ it("puts the whole preset half in", () => {
516
+ // The point of the blend: a preset is not only on or off, and the same
517
+ // value a lane ramps is one an author can just set.
518
+ const { host, onChainChange } = mount({ chain: applied() });
519
+ const input = amountRow(host)?.querySelector<HTMLInputElement>(".hf-fx-number");
520
+ if (!input) throw new Error("no amount control");
521
+ typeInto(input, "0.4");
522
+ act(() => input.dispatchEvent(new FocusEvent("focusout", { bubbles: true })));
523
+
524
+ const next = onChainChange.mock.calls.at(-1)?.[0] as HfAudioFxChain;
525
+ expect(
526
+ next.nodes.filter((n) => n.fromPreset === "telephone").every((n) => n.presetAmount === 0.4),
527
+ ).toBe(true);
528
+ });
529
+
530
+ it("switches back on rather than deleting, so the settings survive", () => {
531
+ const off = applied();
532
+ off.nodes = off.nodes.map((n) =>
533
+ n.fromPreset === "telephone" ? { ...n, presetAmount: 0 } : n,
534
+ );
535
+ const { host, onChainChange } = mount({ chain: off });
536
+ const toggle = host
537
+ .querySelector("[data-fx-preset='telephone']")
538
+ ?.querySelector(".hf-fx-preset-run-toggle");
539
+ expect(toggle?.getAttribute("aria-pressed")).toBe("false");
540
+ click(toggle);
541
+
542
+ const next = onChainChange.mock.calls[0]?.[0] as HfAudioFxChain;
543
+ const back = next.nodes.filter((n) => n.fromPreset === "telephone");
544
+ expect(back.every((n) => n.presetAmount === 1)).toBe(true);
545
+ // Nothing was thrown away.
546
+ expect(back).toHaveLength(off.nodes.filter((n) => n.fromPreset === "telephone").length);
547
+ });
548
+
549
+ it("reads as on while any of it is still applied", () => {
550
+ // Anything above zero is a preset that is doing something, and the switch
551
+ // has to offer to stop it rather than claim it has already stopped.
552
+ const partial = applied();
553
+ partial.nodes = partial.nodes.map((n) =>
554
+ n.fromPreset === "telephone" ? { ...n, presetAmount: 0.2 } : n,
555
+ );
556
+ const { host } = mount({ chain: partial });
557
+ expect(
558
+ host
559
+ .querySelector("[data-fx-preset='telephone']")
560
+ ?.querySelector(".hf-fx-preset-run-toggle")
561
+ ?.getAttribute("aria-pressed"),
562
+ ).toBe("true");
563
+ });
564
+
565
+ it("hands the whole preset to a lane, seeded where it already sits", () => {
566
+ // The reason a preset needs its own target at all: its nodes share no
567
+ // automatable parameter, and its worklet effects expose none. One lane on
568
+ // the blend is what lets a preset ramp in over time.
569
+ const onAutomatePreset = vi.fn();
570
+ const half = applied();
571
+ half.nodes = half.nodes.map((n) =>
572
+ n.fromPreset === "telephone" ? { ...n, presetAmount: 0.6 } : n,
573
+ );
574
+ const { host } = mount({ chain: half, onAutomatePreset });
575
+ click(amountRow(host)?.querySelector(".hf-fx-automate"));
576
+ // Seeded where it sits, so switching to a lane never changes the sound.
577
+ expect(onAutomatePreset).toHaveBeenCalledWith("telephone", 0.6);
578
+ });
579
+
580
+ it("shows an automated preset as driven rather than offering a slider", () => {
581
+ const { host } = mount({
582
+ chain: applied(),
583
+ automatedPresets: new Set(["telephone"]),
584
+ });
585
+ const row = amountRow(host);
586
+ expect(row?.hasAttribute("data-automated")).toBe(true);
587
+ expect(row?.querySelector<HTMLInputElement>('input[type="range"]')?.disabled).toBe(true);
588
+ });
589
+
590
+ it("takes the preset back out whole, with its lanes", () => {
591
+ const onRemoveNodesAutomation = vi.fn();
592
+ const { host, onChainChange } = mount({ chain: applied(), onRemoveNodesAutomation });
593
+ click(
594
+ host
595
+ .querySelector("[data-fx-preset='telephone']")
596
+ ?.querySelector(".hf-fx-preset-run-remove"),
597
+ );
598
+
599
+ const next = onChainChange.mock.calls[0]?.[0] as HfAudioFxChain;
600
+ expect(next.nodes.filter((n) => n.fromPreset === "telephone")).toEqual([]);
601
+ expect(next.nodes.map((n) => n.id)).toEqual(["own"]);
602
+ // ONE call carrying every node id, not one call per node: each write is
603
+ // computed from the same snapshot and replaces the whole attribute, so a
604
+ // loop kept only its last write and left the rest as orphans — which the
605
+ // next effect added would inherit along with the id.
606
+ expect(onRemoveNodesAutomation).toHaveBeenCalledTimes(1);
607
+ const [ids, presetId] = onRemoveNodesAutomation.mock.calls[0] ?? [];
608
+ expect((ids as string[]).length).toBeGreaterThan(1);
609
+ // And the whole-preset amount lane, which belongs to no node and would
610
+ // otherwise survive to be resurrected by re-applying the preset.
611
+ expect(presetId).toBe("telephone");
612
+ });
613
+ });
614
+
615
+ it("brackets only nodes a preset still sits next to", () => {
616
+ // Pulled apart by a reorder, they are no longer a unit — and a bracket
617
+ // around the gap would claim an adjacency the signal path does not have.
618
+ const { host } = mount({
619
+ chain: {
620
+ version: 1,
621
+ nodes: [
622
+ { type: "highpass", fromPreset: "telephone", params: defaultAudioFxParams("highpass") },
623
+ { type: "reverb", params: defaultAudioFxParams("reverb") },
624
+ { type: "lowpass", fromPreset: "telephone", params: defaultAudioFxParams("lowpass") },
625
+ ],
626
+ } as unknown as HfAudioFxChain,
627
+ });
628
+ const runs = Array.from(host.querySelectorAll("[data-fx-preset='telephone']"));
629
+ expect(runs).toHaveLength(2);
630
+ for (const run of runs) expect(run.querySelectorAll(".hf-fx-node")).toHaveLength(1);
631
+ });
632
+
633
+ it("letters each family differently, so the kind reads before the word does", () => {
634
+ // A rack of eight modules is eight lines of text. Reading it should not mean
635
+ // reading eight names — the shape of the line carries what KIND of module
636
+ // this is, and the word only confirms it.
637
+ const { host } = mount({ chain: chainOf("lowpass", "compressor", "saturate", "delay") });
638
+ const families = Array.from(host.querySelectorAll("[data-fx-family]")).map((e) =>
639
+ e.getAttribute("data-fx-family"),
640
+ );
641
+ // The carve module leads the rack and is smart; then the four registry ones.
642
+ expect(families).toEqual(["smart", "filter", "dynamics", "nonlinear", "time"]);
643
+
644
+ const names = Array.from(host.querySelectorAll(".hf-fx-node-name")).map((e) => e.className);
645
+ // Four families told apart by the sans, and the serif spent on the one that
646
+ // generates signal rather than measuring or shaping what is there.
647
+ expect(names.filter((c) => c.includes("font-serif"))).toHaveLength(1);
648
+ expect(names[3]).toContain("font-serif");
649
+ expect(new Set(names.map((c) => c.replace(/^.*?(?=font-)/, "")))).toHaveProperty("size", 5);
650
+ });
651
+
652
+ it("tints two modules of the same family apart without changing family", () => {
653
+ const { host } = mount({ chain: chainOf("lowpass", "highpass") });
654
+ const cards = Array.from(host.querySelectorAll<HTMLElement>("[data-fx-family='filter']"));
655
+ expect(cards).toHaveLength(2);
656
+ // Same hue, different step: two filters, visibly two modules.
657
+ const tints = cards.map((c) => c.style.borderLeftColor);
658
+ expect(tints[0]).not.toBe(tints[1]);
659
+ for (const tint of tints) expect(tint).toContain("205");
660
+ });
661
+
662
+ it("says where a filter is working, in the words the rack shares", () => {
663
+ // Frequencies mean nothing to somebody who has not been taught them, and the
664
+ // rack speaks entirely in them. The ruler is where they get taught.
665
+ const { host } = mount({
666
+ chain: {
667
+ version: 1,
668
+ nodes: [{ type: "highpass", params: { frequency: 250, q: 0.707, poles: "2" } }],
669
+ } as unknown as HfAudioFxChain,
670
+ });
671
+ const ruler = fxCard(host).querySelector(".hf-fx-ruler");
672
+ expect(ruler?.getAttribute("data-band")).toBe("Mud");
673
+ expect(ruler?.querySelector(".hf-fx-ruler-name")?.textContent).toBe("Mud");
674
+ // Every named range is on the bar, or it is not a shared ruler.
675
+ const segments = Array.from(ruler?.querySelectorAll<HTMLElement>(".hf-fx-ruler-band") ?? []);
676
+ expect(segments).toHaveLength(BANDS.length);
677
+ // Log-spaced, because hearing is. Rumble is 20-80 Hz — three tenths of one
678
+ // percent of the range linearly, and a fifth of it by ear. Laid out linearly
679
+ // the bottom six bands collapse into a sliver and the ruler teaches nothing.
680
+ const rumble = Number.parseFloat(segments[0]?.style.width ?? "0");
681
+ expect(rumble).toBeGreaterThan(10);
682
+ });
683
+
684
+ it("puts no ruler under an effect that does not act on a range", () => {
685
+ // A limiter has no frequency to place, and a bar under one would be a
686
+ // decoration claiming to be information.
687
+ const { host } = mount({ chain: chainOf("limiter") });
688
+ expect(fxCard(host).querySelector(".hf-fx-ruler")).toBeNull();
689
+ });
690
+
691
+ it("adds a profiled effect on its curve, not at registry defaults", () => {
692
+ // The registry's defaults are not a point on the profile's curve, so an
693
+ // effect seeded with them opened reading a strength it was not set to: a
694
+ // compressor arrived showing Evenness 0.67 with its make-up gain at 0 dB —
695
+ // the "quieter as you turn it up" bug the profiles exist to prevent, on the
696
+ // very first frame. Caught in a running studio, not by these tests.
697
+ const { host, onChainChange } = mount({ chain: { version: 1, nodes: [] } });
698
+ click(host.querySelector(".hf-fx-add"));
699
+ click(byText(host, ".hf-fx-add-item", EFFECT_COPY.compressor?.title ?? ""));
700
+
701
+ const written = onChainChange.mock.calls[0]?.[0] as HfAudioFxChain | undefined;
702
+ const added = written?.nodes[0]?.params ?? {};
703
+ expect(audioFxProfileStrength("compressor", added)).toBeCloseTo(0.5, 2);
704
+ // And the mechanism agrees with the knob rather than sitting at its default.
705
+ expect(added.makeup).not.toBe(defaultAudioFxParams("compressor").makeup);
706
+ });
707
+
708
+ it("gives a module with no single real control a derived one", () => {
709
+ // A compressor has seven controls and an author wants one, but no single one
710
+ // of them can be its face: threshold means nothing without ratio. So the
711
+ // knob is derived, and it sets all five at once.
712
+ const { host, onChainChange } = mount({ chain: chainOf("compressor") });
713
+ const node = fxCard(host);
714
+ expect(EFFECT_COPY.compressor?.primary).toBe("strength");
715
+ // One control on the open face, named for the outcome.
716
+ const rows = Array.from(node.querySelectorAll(".hf-fx-row"));
717
+ expect(rows).toHaveLength(1);
718
+ expect(rows[0]?.querySelector(".hf-fx-label")?.textContent).toBe("Evenness");
719
+ expect(node.querySelector(".hf-fx-node-details")).toBeTruthy();
720
+
721
+ // Moving it moves the mechanism underneath.
722
+ const before = defaultAudioFxParams("compressor");
723
+ typeInto(node.querySelector<HTMLInputElement>(".hf-fx-number")!, "0.9");
724
+ act(() =>
725
+ node
726
+ .querySelector(".hf-fx-number")
727
+ ?.dispatchEvent(new FocusEvent("focusout", { bubbles: true })),
728
+ );
729
+ const next = onChainChange.mock.calls.at(-1)?.[0] as HfAudioFxChain;
730
+ const params = next.nodes[0]?.params ?? {};
731
+ expect(params.ratio).not.toBe(before.ratio);
732
+ expect(params.threshold).not.toBe(before.threshold);
733
+ // And leaves alone what the author set under Details.
734
+ expect(params.knee).toBe(before.knee);
735
+ });
736
+
737
+ it("offers presets as the complaint they answer", () => {
738
+ const { host } = mount({ chain: { version: 1, nodes: [] } });
739
+ click(byText(host, "button", "Presets"));
740
+ const item = presetButton(host, "telephone");
741
+ expect(item?.querySelector(".hf-fx-preset-problem")?.textContent).toBe(
742
+ PRESET_PROBLEM.telephone,
743
+ );
744
+ // The name is still there, under it — which is how it gets learned.
745
+ expect(item?.querySelector(".hf-fx-preset-name")?.textContent).toBe("Telephone");
746
+ });
747
+
748
+ describe("folding a preset shut", () => {
749
+ const applied = (): HfAudioFxChain => {
750
+ const preset = getAudioFxPreset("telephone");
751
+ if (!preset) throw new Error("no telephone preset");
752
+ return applyAudioFxPreset({ version: 1, nodes: [] }, preset);
753
+ };
754
+ const bracket = (host: HTMLElement) => host.querySelector("[data-fx-preset='telephone']");
755
+
756
+ it("hides what it contains, and says how much is in there", () => {
757
+ // A preset is one thing the author added; once it is set, the seven
758
+ // modules inside are detail. Two presets in a rack was thirteen cards
759
+ // deep before anything hand-built appeared.
760
+ const { host } = mount({ chain: applied() });
761
+ const nodes = bracket(host)?.querySelectorAll(".hf-fx-node").length ?? 0;
762
+ expect(nodes).toBeGreaterThan(1);
763
+
764
+ click(bracket(host)?.querySelector(".hf-fx-preset-run-label"));
765
+ expect(bracket(host)?.querySelectorAll(".hf-fx-node")).toHaveLength(0);
766
+ // The count is what says it is still a chain rather than one opaque effect.
767
+ expect(bracket(host)?.querySelector(".hf-fx-preset-run-count")?.textContent).toBe(
768
+ String(nodes),
769
+ );
770
+ });
771
+
772
+ it("arrives open, so nobody has to discover it is a chain", () => {
773
+ const { host } = mount({ chain: applied() });
774
+ expect(bracket(host)?.hasAttribute("data-collapsed")).toBe(false);
775
+ expect(
776
+ bracket(host)?.querySelector(".hf-fx-preset-run-label")?.getAttribute("aria-expanded"),
777
+ ).toBe("true");
778
+ });
779
+
780
+ it("keeps the whole-preset controls reachable while folded", () => {
781
+ // Collapsing hides the detail, not the preset — switching it off or
782
+ // taking it out has to stay possible without unfolding first.
783
+ const { host } = mount({ chain: applied() });
784
+ click(bracket(host)?.querySelector(".hf-fx-preset-run-label"));
785
+ expect(bracket(host)?.querySelector(".hf-fx-preset-run-toggle")).toBeTruthy();
786
+ expect(bracket(host)?.querySelector(".hf-fx-preset-run-remove")).toBeTruthy();
787
+ });
788
+
789
+ it("gives each preset its own title treatment", () => {
790
+ // A preset is a character, and the point of Telephone or Megaphone is
791
+ // that you know what it sounds like before you play it. Type carries that.
792
+ const { host } = mount({ chain: applied() });
793
+ const label = bracket(host)?.querySelector<HTMLElement>(".hf-fx-preset-run-label");
794
+ const styled = fxPresetStyle("telephone");
795
+ expect(label?.className).toContain("tracking-[0.3em]");
796
+ expect(label?.style.color).toBeTruthy();
797
+ // And it differs from another preset's, or it is not a treatment.
798
+ expect(styled.type).not.toBe(fxPresetStyle("megaphone").type);
799
+ expect(styled.color).not.toBe(fxPresetStyle("megaphone").color);
800
+ });
801
+ });
802
+
803
+ describe("auditioning while the transport is paused", () => {
804
+ it("starts playback so a paused author can hear the preset at all", () => {
805
+ // The audition is written to the running graph, which is silent while the
806
+ // transport is paused — so without this, hovering a preset did nothing
807
+ // whatsoever unless the author happened to be mid-playback.
808
+ const onAuditionTransport = vi.fn();
809
+ const { host } = mount({ chain: chainOf("peaking"), onAuditionTransport });
810
+ click(byText(host, "button", "Presets"));
811
+ act(() => (presetButton(host, "telephone") as HTMLElement | null)?.focus());
812
+ expect(onAuditionTransport).toHaveBeenLastCalledWith(true);
813
+ });
814
+
815
+ it("stops it again on the way out", () => {
816
+ const onAuditionTransport = vi.fn();
817
+ const { host } = mount({ chain: chainOf("peaking"), onAuditionTransport });
818
+ click(byText(host, "button", "Presets"));
819
+ act(() => (presetButton(host, "telephone") as HTMLElement | null)?.focus());
820
+ act(() => {
821
+ host
822
+ .querySelector(".hf-fx-preset-menu")
823
+ ?.dispatchEvent(new FocusEvent("focusout", { bubbles: true }));
824
+ });
825
+ expect(onAuditionTransport).toHaveBeenLastCalledWith(false);
826
+ });
827
+
828
+ it("stops it when the preset is applied, rather than playing on", () => {
829
+ // The click means "keep this", not "and carry on playing from wherever
830
+ // the audition reached".
831
+ const onAuditionTransport = vi.fn();
832
+ const { host } = mount({ chain: { version: 1, nodes: [] }, onAuditionTransport });
833
+ click(byText(host, "button", "Presets"));
834
+ act(() => (presetButton(host, "telephone") as HTMLElement | null)?.focus());
835
+ click(presetButton(host, "telephone"));
836
+ expect(onAuditionTransport).toHaveBeenLastCalledWith(false);
837
+ });
838
+
839
+ it("stops it if the panel goes away mid-audition", () => {
840
+ const onAuditionTransport = vi.fn();
841
+ const { host, root } = mount({ chain: chainOf("peaking"), onAuditionTransport });
842
+ click(byText(host, "button", "Presets"));
843
+ act(() => (presetButton(host, "telephone") as HTMLElement | null)?.focus());
844
+ act(() => root.unmount());
845
+ expect(onAuditionTransport).toHaveBeenLastCalledWith(false);
846
+ });
847
+ });
848
+
849
+ describe("getting back out of a menu", () => {
850
+ /** Escape, from inside the section, the way a keystroke really arrives. */
851
+ const escape = (host: HTMLElement) =>
852
+ act(() => {
853
+ host
854
+ .querySelector(".hf-fx-section")
855
+ ?.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true }));
856
+ });
857
+
858
+ it("closes the preset shelf with the button that opened it", () => {
859
+ // Opening a menu used to hide both buttons, and picking something was the
860
+ // only thing that set them back — so an author who changed their mind had
861
+ // to add an effect they did not want, or deselect the clip.
862
+ const { host } = mount();
863
+ click(byText(host, "button", "Presets"));
864
+ expect(host.querySelector(".hf-fx-preset-menu")).toBeTruthy();
865
+
866
+ click(byText(host, "button", "Close"));
867
+ expect(host.querySelector(".hf-fx-preset-menu")).toBeNull();
868
+ expect(byText(host, "button", "Presets")).toBeTruthy();
869
+ });
870
+
871
+ it("closes the add menu the same way", () => {
872
+ const { host } = mount();
873
+ click(host.querySelector(".hf-fx-add"));
874
+ expect(host.querySelector(".hf-fx-add-menu")).toBeTruthy();
875
+
876
+ click(host.querySelector(".hf-fx-add"));
877
+ expect(host.querySelector(".hf-fx-add-menu")).toBeNull();
878
+ expect(byText(host, "button", "Add effect")).toBeTruthy();
879
+ });
880
+
881
+ it("opens one menu in place of the other", () => {
882
+ // Two open at once is two surfaces covering the rack, and neither says
883
+ // which one the next click belongs to.
884
+ const { host } = mount();
885
+ click(byText(host, "button", "Presets"));
886
+ click(host.querySelector(".hf-fx-add"));
887
+ expect(host.querySelector(".hf-fx-add-menu")).toBeTruthy();
888
+ expect(host.querySelector(".hf-fx-preset-menu")).toBeNull();
889
+
890
+ // And back the other way, which is a separate handler.
891
+ click(byText(host, "button", "Presets"));
892
+ expect(host.querySelector(".hf-fx-preset-menu")).toBeTruthy();
893
+ expect(host.querySelector(".hf-fx-add-menu")).toBeNull();
894
+ });
895
+
896
+ it("closes on Escape, which is what anyone reaches for first", () => {
897
+ const { host } = mount();
898
+ click(byText(host, "button", "Presets"));
899
+ escape(host);
900
+ expect(host.querySelector(".hf-fx-preset-menu")).toBeNull();
901
+
902
+ click(host.querySelector(".hf-fx-add"));
903
+ escape(host);
904
+ expect(host.querySelector(".hf-fx-add-menu")).toBeNull();
905
+ });
906
+
907
+ it("puts the chain back when a closing menu was auditioning", () => {
908
+ // Leaving the shelf by closing it is still leaving it, and an audition
909
+ // left playing is audible over a chain the document does not have.
910
+ const { host, onChainPreview } = mount({ chain: chainOf("peaking") });
911
+ click(byText(host, "button", "Presets"));
912
+ act(() => (presetButton(host, "telephone") as HTMLElement | null)?.focus());
913
+ click(byText(host, "button", "Close"));
914
+
915
+ const back = onChainPreview.mock.calls.at(-1)?.[0] as HfAudioFxChain;
916
+ expect(back.nodes.map((n) => n.type)).toEqual(["peaking"]);
917
+ });
918
+
919
+ it("leaves Escape alone when no menu is open", () => {
920
+ // The panel has its own Escape handling; swallowing the key when this has
921
+ // nothing to close would break it.
922
+ const { host } = mount();
923
+ let reached = false;
924
+ host.addEventListener("keydown", () => {
925
+ reached = true;
926
+ });
927
+ escape(host);
928
+ expect(reached).toBe(true);
929
+ });
930
+ });
931
+
932
+ it("shows a wave on a preset only when hovering it can be heard", () => {
933
+ // Hovering plays the preset, and playing is otherwise invisible — the panel
934
+ // looks identical whether the audition is sounding or the pointer is just
935
+ // resting there. Without an audition channel there is no audio to claim.
936
+ const { host } = mount({ chain: { version: 1, nodes: [] } });
937
+ click(byText(host, "button", "Presets"));
938
+ expect(presetButton(host, "telephone")?.querySelector(".hf-fx-preset-wave")).toBeTruthy();
939
+
940
+ // Rendered directly rather than through `mount`, which supplies a preview
941
+ // handler by default — the case being covered is a section that has none.
942
+ const { host: dry } = renderInto(
943
+ <FxSection
944
+ chain={{ version: 1, nodes: [] }}
945
+ onChainChange={vi.fn()}
946
+ carve={null}
947
+ onCarveChange={vi.fn()}
948
+ sourceOptions={[]}
949
+ />,
950
+ );
951
+ click(byText(dry, "button", "Presets"));
952
+ expect(presetButton(dry, "telephone")?.querySelector(".hf-fx-preset-wave")).toBeNull();
953
+ });
954
+
955
+ describe("hover-audition", () => {
956
+ /** Focus is the keyboard's hover, and both go through the same handler. */
957
+ const enter = (el: Element | null | undefined) => {
958
+ if (!el) throw new Error("element not found");
959
+ act(() => (el as HTMLElement).focus());
960
+ };
961
+ const leave = (host: HTMLElement, sel: string) =>
962
+ act(() => {
963
+ host.querySelector(sel)?.dispatchEvent(new FocusEvent("focusout", { bubbles: true }));
964
+ });
965
+
966
+ it("plays a preset without committing to it", () => {
967
+ const { host, onChainPreview, onChainChange } = mount({ chain: { version: 1, nodes: [] } });
968
+ click(byText(host, "button", "Presets"));
969
+ enter(presetButton(host, "telephone"));
970
+
971
+ const heard = onChainPreview.mock.calls.at(-1)?.[0] as HfAudioFxChain;
972
+ expect(heard.nodes.length).toBeGreaterThan(0);
973
+ expect(heard.nodes.every((n) => n.fromPreset === "telephone")).toBe(true);
974
+ // Heard, not written. Hovering is not a decision.
975
+ expect(onChainChange).not.toHaveBeenCalled();
976
+ });
977
+
978
+ it("puts the chain back on the way out", () => {
979
+ const { host, onChainPreview } = mount({ chain: chainOf("peaking") });
980
+ click(byText(host, "button", "Presets"));
981
+ enter(presetButton(host, "telephone"));
982
+ leave(host, ".hf-fx-preset-menu");
983
+
984
+ const back = onChainPreview.mock.calls.at(-1)?.[0] as HfAudioFxChain;
985
+ expect(back.nodes.map((n) => n.type)).toEqual(["peaking"]);
986
+ });
987
+
988
+ it("does not put the old chain back over the preset it just applied", () => {
989
+ // The audition WAS the preset, so reverting after the write is a race the
990
+ // author hears as it arriving and then leaving again.
991
+ //
992
+ // Applying closes the shelf, so the pointer never leaves it — the revert
993
+ // that would fire is the panel's own teardown, which is what unmounting
994
+ // here exercises. It is also the real route: applying a preset and then
995
+ // clicking another clip does exactly this.
996
+ const { host, root, onChainPreview } = mount({ chain: { version: 1, nodes: [] } });
997
+ click(byText(host, "button", "Presets"));
998
+ enter(presetButton(host, "telephone"));
999
+ const auditions = onChainPreview.mock.calls.length;
1000
+ click(presetButton(host, "telephone"));
1001
+ act(() => root.unmount());
1002
+
1003
+ expect(onChainPreview.mock.calls.length).toBe(auditions);
1004
+ });
1005
+
1006
+ it("puts the chain back if the panel goes away mid-audition", () => {
1007
+ // Deselecting the clip while hovering is not a decision either, and the
1008
+ // preview channel does not persist — so without this the author hears a
1009
+ // chain the document does not have until something else writes.
1010
+ const { host, root, onChainPreview } = mount({ chain: chainOf("peaking") });
1011
+ click(byText(host, "button", "Presets"));
1012
+ enter(presetButton(host, "telephone"));
1013
+ act(() => root.unmount());
1014
+
1015
+ const back = onChainPreview.mock.calls.at(-1)?.[0] as HfAudioFxChain;
1016
+ expect(back.nodes.map((n) => n.type)).toEqual(["peaking"]);
1017
+ });
1018
+
1019
+ it("survives the panel re-rendering under it, which playback does constantly", () => {
1020
+ // The group re-renders on every playhead tick to move the automation
1021
+ // readouts, handing down a fresh preview callback each time. A teardown
1022
+ // keyed on that callback ran on every tick, so an audition reverted itself
1023
+ // about thirty times a second — during playback, which is the only time
1024
+ // there is anything to audition.
1025
+ const { host, root, onChainPreview } = mount({ chain: chainOf("peaking") });
1026
+ click(byText(host, "button", "Presets"));
1027
+ enter(presetButton(host, "telephone"));
1028
+ const auditions = onChainPreview.mock.calls.length;
1029
+
1030
+ // Same behaviour, new identity — exactly what a tick hands down.
1031
+ act(() =>
1032
+ root.render(
1033
+ <FxSection
1034
+ chain={chainOf("peaking")}
1035
+ onChainChange={vi.fn()}
1036
+ onChainPreview={(next) => onChainPreview(next)}
1037
+ carve={null}
1038
+ onCarveChange={vi.fn()}
1039
+ sourceOptions={[{ id: "vo", label: "Voiceover" }]}
1040
+ />,
1041
+ ),
1042
+ );
1043
+
1044
+ expect(onChainPreview.mock.calls.length).toBe(auditions);
1045
+ });
1046
+
1047
+ it("auditions an effect the add menu is offering", () => {
1048
+ const { host, onChainPreview, onChainChange } = mount({ chain: chainOf("peaking") });
1049
+ click(byText(host, "button", "Add effect"));
1050
+ enter(byText(host, "button", EFFECT_COPY.reverb?.title ?? ""));
1051
+
1052
+ const heard = onChainPreview.mock.calls.at(-1)?.[0] as HfAudioFxChain;
1053
+ expect(heard.nodes.map((n) => n.type)).toEqual(["peaking", "reverb"]);
1054
+ expect(onChainChange).not.toHaveBeenCalled();
1055
+ });
1056
+
1057
+ it("asks for a levelling measurement on hover, and calls it off on the way out", () => {
1058
+ // The one module that cannot answer instantly: there is nothing to hear
1059
+ // until the track has been decoded and measured.
1060
+ const onAuditionLevel = vi.fn();
1061
+ const { host } = mount({
1062
+ chain: { version: 1, nodes: [] },
1063
+ onLevel: vi.fn(),
1064
+ onAuditionLevel,
1065
+ });
1066
+ click(byText(host, "button", "Add effect"));
1067
+ enter(byText(host, "button", "Even Out Levels"));
1068
+ expect(onAuditionLevel).toHaveBeenLastCalledWith(true);
1069
+ leave(host, ".hf-fx-add-menu");
1070
+ expect(onAuditionLevel).toHaveBeenLastCalledWith(false);
1071
+ });
1072
+ });
1073
+
1074
+ it("adds a preset to what is already there rather than replacing it", () => {
1075
+ const existing: HfAudioFxChain = {
1076
+ version: 1,
1077
+ nodes: [
1078
+ { type: "reverb", id: "mine", enabled: true, params: defaultAudioFxParams("reverb") },
1079
+ ],
1080
+ };
1081
+ const { host, onChainChange } = mount({ chain: existing });
1082
+ click(byText(host, "button", "Presets"));
1083
+ click(presetButton(host, "rumble-cut"));
1084
+
1085
+ const next = onChainChange.mock.calls[0]![0] as HfAudioFxChain;
1086
+ expect(next.nodes.map((n) => n.id)).toContain("mine");
1087
+ expect(next.nodes.map((n) => n.type)).toEqual(["reverb", "highpass"]);
1088
+ });
1089
+
1090
+ it("shows a preset node by the job it is doing, not its filter type", () => {
1091
+ const { host } = mount({
1092
+ chain: {
1093
+ version: 1,
1094
+ nodes: [
1095
+ {
1096
+ type: "peaking",
1097
+ id: "a",
1098
+ label: "Reduce Mud",
1099
+ enabled: true,
1100
+ params: defaultAudioFxParams("peaking"),
1101
+ },
1102
+ {
1103
+ type: "peaking",
1104
+ id: "b",
1105
+ label: "Add Clarity",
1106
+ enabled: true,
1107
+ params: defaultAudioFxParams("peaking"),
1108
+ },
1109
+ ],
1110
+ } as HfAudioFxChain,
1111
+ });
1112
+ const names = Array.from(host.querySelectorAll(".hf-fx-node-name")).map((e) =>
1113
+ e.textContent?.trim(),
1114
+ );
1115
+ // Without the label both rows read "Peaking EQ" and an author cannot tell
1116
+ // which one is cutting and which is lifting.
1117
+ expect(names).toContain("Reduce Mud");
1118
+ expect(names).toContain("Add Clarity");
1119
+ expect(names).not.toContain("Peaking EQ");
1120
+ });
1121
+
1122
+ it("adds a Tone EQ as three ordinary filters on one control surface", () => {
1123
+ const { host, onChainChange } = mount({ chain: { version: 1, nodes: [] } });
1124
+ click(host.querySelector(".hf-fx-add"));
1125
+ click(byText(host, ".hf-fx-add-composite", "Tone (EQ)"));
1126
+
1127
+ const next = onChainChange.mock.calls[0]![0] as HfAudioFxChain;
1128
+ expect(next.nodes.map((n) => n.type)).toEqual(["lowshelf", "peaking", "highshelf"]);
1129
+ expect(next.nodes.map((n) => n.label)).toEqual(["Bass", "Middle", "Treble"]);
1130
+ expect(next.nodes.every((n) => n.fromEq === "eq1")).toBe(true);
1131
+ });
1132
+
1133
+ it("shows the EQ as one module, not as its individual bands", () => {
1134
+ // The bands belong to the Tone module. Listing them again in the rack would
1135
+ // put the same filter on screen twice with two ways to edit it.
1136
+ const { host } = mount({
1137
+ chain: {
1138
+ version: 1,
1139
+ nodes: [
1140
+ {
1141
+ type: "lowshelf",
1142
+ id: "a",
1143
+ fromEq: "eq1",
1144
+ label: "Bass",
1145
+ enabled: true,
1146
+ params: defaultAudioFxParams("lowshelf"),
1147
+ },
1148
+ {
1149
+ type: "peaking",
1150
+ id: "b",
1151
+ fromEq: "eq1",
1152
+ label: "Middle",
1153
+ enabled: true,
1154
+ params: defaultAudioFxParams("peaking"),
1155
+ },
1156
+ {
1157
+ type: "highshelf",
1158
+ id: "c",
1159
+ fromEq: "eq1",
1160
+ label: "Treble",
1161
+ enabled: true,
1162
+ params: defaultAudioFxParams("highshelf"),
1163
+ },
1164
+ ],
1165
+ } as HfAudioFxChain,
1166
+ });
1167
+ expect(host.querySelectorAll(".hf-fx-eq-module")).toHaveLength(1);
1168
+ const names = Array.from(host.querySelectorAll(".hf-fx-node-name")).map((e) =>
1169
+ e.textContent?.trim(),
1170
+ );
1171
+ expect(names).toContain("Tone");
1172
+ expect(names).not.toContain("Bass");
1173
+ // Closed, it says what it is doing rather than listing three zeroes.
1174
+ expect(host.querySelector(".hf-fx-eq-summary")?.textContent).toMatch(/^Flat/);
1175
+ });
1176
+
1177
+ it("moves one band without persisting until the fader is released", () => {
1178
+ const { host, onChainChange, onChainPreview } = mount({
1179
+ chain: {
1180
+ version: 1,
1181
+ nodes: [
1182
+ {
1183
+ type: "lowshelf",
1184
+ id: "a",
1185
+ fromEq: "eq1",
1186
+ label: "Bass",
1187
+ enabled: true,
1188
+ params: defaultAudioFxParams("lowshelf"),
1189
+ },
1190
+ {
1191
+ type: "peaking",
1192
+ id: "b",
1193
+ fromEq: "eq1",
1194
+ label: "Middle",
1195
+ enabled: true,
1196
+ params: defaultAudioFxParams("peaking"),
1197
+ },
1198
+ {
1199
+ type: "highshelf",
1200
+ id: "c",
1201
+ fromEq: "eq1",
1202
+ label: "Treble",
1203
+ enabled: true,
1204
+ params: defaultAudioFxParams("highshelf"),
1205
+ },
1206
+ ],
1207
+ } as HfAudioFxChain,
1208
+ });
1209
+ // The carve module leads the rack, so its header is the first one — open
1210
+ // the EQ's own.
1211
+ click(host.querySelector(".hf-fx-eq-module .hf-fx-node-name"));
1212
+ const fader = host.querySelectorAll<HTMLInputElement>(".hf-fx-eq-fader")[0]!;
1213
+ expect(fader, "the EQ did not open").toBeTruthy();
1214
+ typeInto(fader, "4");
1215
+ // Heard, not written — a persisting write per drag event reloads the
1216
+ // composition and restarts the audio.
1217
+ expect(onChainPreview).toHaveBeenCalled();
1218
+ expect(onChainChange).not.toHaveBeenCalled();
1219
+
1220
+ act(() => fader.dispatchEvent(new Event("pointerup", { bubbles: true })));
1221
+ const next = onChainChange.mock.calls[0]![0] as HfAudioFxChain;
1222
+ expect(next.nodes.find((n) => n.label === "Bass")!.params!.gain).toBe(4);
1223
+ expect(next.nodes.find((n) => n.label === "Middle")!.params!.gain).toBe(0);
1224
+ });
1225
+
1226
+ it("offers levelling, and offers to take it away once it is there", () => {
1227
+ const onLevel = vi.fn();
1228
+ const onRemoveLevel = vi.fn();
1229
+ const { host } = mount({ onLevel, onRemoveLevel });
1230
+ click(host.querySelector(".hf-fx-add"));
1231
+ click(byText(host, ".hf-fx-add-composite", "Even Out Levels"));
1232
+ expect(onLevel).toHaveBeenCalledTimes(1);
1233
+
1234
+ const already = mount({ onLevel, onRemoveLevel, levelled: true });
1235
+ click(already.host.querySelector(".hf-fx-add"));
1236
+ // The same control, because adding a second levelling stage is never what
1237
+ // an author means by pressing it twice.
1238
+ click(byText(already.host, ".hf-fx-add-composite", "Remove levelling"));
1239
+ expect(onRemoveLevel).toHaveBeenCalledTimes(1);
1240
+ });
1241
+
1242
+ it("cannot move the ends past themselves", () => {
1243
+ const { host } = mount({ chain: chainOf("peaking", "reverb") });
1244
+ const ups = host.querySelectorAll('.hf-fx-move[title="Move up"]');
1245
+ const downs = host.querySelectorAll('.hf-fx-move[title="Move down"]');
1246
+ expect((ups[0] as HTMLButtonElement).disabled).toBe(true);
1247
+ expect((downs[1] as HTMLButtonElement).disabled).toBe(true);
1248
+ });
1249
+
1250
+ it("removes an effect", () => {
1251
+ const { host, onChainChange } = mount({ chain: chainOf("peaking", "reverb") });
1252
+ click(host.querySelector(".hf-fx-remove"));
1253
+ const next = onChainChange.mock.calls[0]![0] as HfAudioFxChain;
1254
+ expect(next.nodes.map((n) => n.type)).toEqual(["reverb"]);
1255
+ });
1256
+
1257
+ it("previews while dragging and only persists on release", () => {
1258
+ // Persisting on every input event refreshes the preview, which reloads the
1259
+ // composition and restarts audio — that is what made playback stutter.
1260
+ const { host, onChainChange, onChainPreview } = mount({ chain: chainOf("peaking") });
1261
+ // Details, because this is about the drag mechanics on a real control and
1262
+ // the frequency it asserts on is not the one knob the module opens with.
1263
+ openDetails(host);
1264
+ const slider = fxCard(host).querySelector<HTMLInputElement>(".hf-fx-slider")!;
1265
+ act(() => slider.dispatchEvent(new Event("pointerdown", { bubbles: true })));
1266
+ for (const v of ["5000", "10000", "15000"]) {
1267
+ const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")?.set;
1268
+ act(() => {
1269
+ setter?.call(slider, v);
1270
+ slider.dispatchEvent(new Event("input", { bubbles: true }));
1271
+ });
1272
+ }
1273
+ expect(onChainPreview.mock.calls.length).toBeGreaterThanOrEqual(3);
1274
+ expect(onChainChange).not.toHaveBeenCalled();
1275
+
1276
+ act(() => slider.dispatchEvent(new Event("pointerup", { bubbles: true })));
1277
+ expect(onChainChange).toHaveBeenCalledTimes(1);
1278
+ });
1279
+
1280
+ it("stays where it was dropped while the write is still coming back", () => {
1281
+ // The value only returns after the attribute is written and the selection
1282
+ // resynced — and for a carve, after the analysis that write kicks off. Dropping
1283
+ // back to the prop in that gap made the control snap to where the drag started
1284
+ // and then jump to where it ended.
1285
+ const { host } = mount({ chain: chainOf("peaking") });
1286
+ const card = fxCard(host);
1287
+ const slider = card.querySelector<HTMLInputElement>(".hf-fx-slider")!;
1288
+ const number = card.querySelector<HTMLInputElement>(".hf-fx-number")!;
1289
+ const before = number.value;
1290
+ const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")?.set;
1291
+ act(() => slider.dispatchEvent(new Event("pointerdown", { bubbles: true })));
1292
+ act(() => {
1293
+ setter?.call(slider, "8000");
1294
+ slider.dispatchEvent(new Event("input", { bubbles: true }));
1295
+ });
1296
+ const dragged = number.value;
1297
+ expect(dragged).not.toBe(before);
1298
+ // Release. The parent is a spy here, so the prop never updates — exactly the
1299
+ // window the snap happened in.
1300
+ act(() => slider.dispatchEvent(new Event("pointerup", { bubbles: true })));
1301
+ expect(number.value).toBe(dragged);
1302
+ });
1303
+
1304
+ it("adopts a value that comes back different from the one dragged to", () => {
1305
+ // Held only until there is newer information — a clamp upstream, an undo, or
1306
+ // any other write must still win over the gesture's own guess.
1307
+ const shared = {
1308
+ onChainChange: vi.fn(),
1309
+ onChainPreview: vi.fn(),
1310
+ carve: null,
1311
+ onCarveChange: vi.fn(),
1312
+ sourceOptions: [{ id: "vo", label: "Voiceover" }],
1313
+ };
1314
+ const { host, root } = renderInto(<FxSection {...shared} chain={chainOf("peaking")} />);
1315
+ openDetails(host);
1316
+ const slider = fxCard(host).querySelector<HTMLInputElement>(".hf-fx-slider")!;
1317
+ const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")?.set;
1318
+ act(() => slider.dispatchEvent(new Event("pointerdown", { bubbles: true })));
1319
+ act(() => {
1320
+ setter?.call(slider, "8000");
1321
+ slider.dispatchEvent(new Event("input", { bubbles: true }));
1322
+ });
1323
+ act(() => slider.dispatchEvent(new Event("pointerup", { bubbles: true })));
1324
+ // The write came back as something else entirely.
1325
+ act(() =>
1326
+ root.render(
1327
+ <FxSection
1328
+ {...shared}
1329
+ chain={{
1330
+ version: 1,
1331
+ nodes: [
1332
+ {
1333
+ type: "peaking",
1334
+ enabled: true,
1335
+ params: { ...defaultAudioFxParams("peaking"), frequency: 1234 },
1336
+ },
1337
+ ],
1338
+ }}
1339
+ />,
1340
+ ),
1341
+ );
1342
+ expect(fxCard(host).querySelector<HTMLInputElement>(".hf-fx-number")!.value).toBe("1234");
1343
+ });
1344
+
1345
+ it("commits an enum immediately, since a select has no drag", () => {
1346
+ const { host, onChainChange } = mount({ chain: chainOf("saturate") });
1347
+ openDetails(host);
1348
+ const select = fxCard(host).querySelector<HTMLSelectElement>(".hf-fx-select")!;
1349
+ const setter = Object.getOwnPropertyDescriptor(HTMLSelectElement.prototype, "value")?.set;
1350
+ act(() => {
1351
+ setter?.call(select, "atan");
1352
+ select.dispatchEvent(new Event("change", { bubbles: true }));
1353
+ });
1354
+ expect(onChainChange).toHaveBeenCalledTimes(1);
1355
+ });
1356
+
1357
+ it("clamps a typed value into the renderable range", () => {
1358
+ const { host, onChainChange } = mount({ chain: chainOf("peaking") });
1359
+ openDetails(host);
1360
+ const input = fxCard(host).querySelector<HTMLInputElement>(".hf-fx-number")!;
1361
+ typeInto(input, "999999");
1362
+ // React delegates onBlur through focusout, which is the event that bubbles.
1363
+ act(() => input.dispatchEvent(new FocusEvent("focusout", { bubbles: true })));
1364
+ const next = onChainChange.mock.calls.at(-1)![0] as HfAudioFxChain;
1365
+ expect(next.nodes[0]!.params!.frequency).toBe(20000);
1366
+ });
1367
+ });
1368
+
1369
+ describe("FxSection carve module", () => {
1370
+ /**
1371
+ * A carve is one thing an author turned on, not the six filters it happens to
1372
+ * compile to. Listed individually they read as hand-built effects: removable
1373
+ * one at a time, reorderable, each with knobs that the next strength change
1374
+ * overwrites without warning.
1375
+ */
1376
+ it("shows the carve's effects as one module, alongside hand-built ones", () => {
1377
+ const { host } = mount({ chain: carved });
1378
+ const rows = Array.from(host.querySelectorAll<HTMLElement>(".hf-fx-node"));
1379
+ // One row for the carve, one for the low-pass the author added.
1380
+ expect(rows).toHaveLength(2);
1381
+ expect(host.querySelector(".hf-fx-carve-module")).not.toBeNull();
1382
+ expect(host.querySelector(".hf-fx-carve-module")?.textContent).toContain("Voiceover carve");
1383
+ });
1384
+
1385
+ it("says what the module contains, since its parts are not listed", () => {
1386
+ const { host } = mount({ chain: carved });
1387
+ const text = host.querySelector(".hf-fx-carve-module")?.textContent ?? "";
1388
+ expect(text).toMatch(/2 bands/);
1389
+ expect(text).toMatch(/level/);
1390
+ });
1391
+
1392
+ it("offers one switch for the whole carve, not a bypass and a delete", () => {
1393
+ // The module is the unit: there is nothing meaningful between "carving" and
1394
+ // "not carving", and two buttons implied there was.
1395
+ const { host } = mount({ chain: carved });
1396
+ const module = host.querySelector<HTMLElement>(".hf-fx-carve-module")!;
1397
+ expect(module.querySelectorAll(".hf-fx-remove")).toHaveLength(0);
1398
+ expect(module.querySelectorAll(".hf-fx-carve-toggle")).toHaveLength(1);
1399
+ });
1400
+
1401
+ it("switching it off records that, rather than erasing the settings", () => {
1402
+ // An absent carve reads as never-configured, and a bed with one voice above it
1403
+ // is carved by default — so erasing would re-apply it on the next selection.
1404
+ const onCarveChange = vi.fn();
1405
+ const { host } = mount({
1406
+ chain: carved,
1407
+ carve: { ...DEFAULT_CARVE, sources: ["vo"] },
1408
+ onCarveChange,
1409
+ });
1410
+ act(() => host.querySelector<HTMLButtonElement>(".hf-fx-carve-toggle")!.click());
1411
+ expect(onCarveChange).toHaveBeenCalledWith(
1412
+ expect.objectContaining({ enabled: false, sources: ["vo"] }),
1413
+ );
1414
+ });
1415
+
1416
+ it("lists what each effect inside it is set to", () => {
1417
+ // Grouped is not hidden: the carve compiles to real filters and an author has
1418
+ // to be able to see where they landed. What they cannot do is edit them by
1419
+ // hand — strength owns those numbers — so the settings read out rather than
1420
+ // offering controls that the next adjustment would overwrite.
1421
+ const { host } = mount({ chain: carved });
1422
+ const module = ensureCarveOpen(host);
1423
+ const members = Array.from(module.querySelectorAll<HTMLElement>(".hf-fx-carve-member"));
1424
+ expect(members).toHaveLength(3);
1425
+ // Named by what tells them apart, the way the timeline lanes name them.
1426
+ expect(members.map((m) => m.querySelector(".hf-fx-carve-member-name")?.textContent)).toEqual([
1427
+ "Peaking EQ 400 Hz",
1428
+ "Peaking EQ 1.6 kHz",
1429
+ "Gain",
1430
+ ]);
1431
+ // And every one of its settings is visible.
1432
+ const first = members[0]!.textContent ?? "";
1433
+ expect(first).toContain("Gain");
1434
+ expect(first).toMatch(/-6(\.0)? dB/);
1435
+ expect(first).toMatch(/1\.4/); // Q
1436
+ });
1437
+
1438
+ it("reads the analysis out rather than offering controls over it", () => {
1439
+ // The module's own knobs — voice, strength, dynamic — are the carve's controls.
1440
+ // What the analysis produced is not editable by hand: strength owns those
1441
+ // numbers and the next adjustment would overwrite anything typed here.
1442
+ const { host } = mount({ chain: carved });
1443
+ const module = ensureCarveOpen(host);
1444
+ expect(module.querySelector(".hf-fx-carve-members")!.querySelectorAll("input")).toHaveLength(0);
1445
+ // The controls themselves are present, in the same card.
1446
+ expect(module.querySelectorAll(".hf-fx-carve-controls input").length).toBeGreaterThan(0);
1447
+ expect(module.querySelector(".hf-fx-carve-controls .hf-fx-carve-source")).not.toBeNull();
1448
+ });
1449
+
1450
+ it("says which of them the timeline is driving", () => {
1451
+ // A carve in dynamic mode automates every one of these, and that is where the
1452
+ // values come from — so the module has to point at the lane rather than look
1453
+ // like a static setting.
1454
+ const { host } = mount({
1455
+ chain: carved,
1456
+ automatedTargets: new Set(["fx.n1.gain", "fx.n3.gain"]),
1457
+ });
1458
+ const module = ensureCarveOpen(host);
1459
+ const automated = Array.from(module.querySelectorAll("[data-automated]"));
1460
+ expect(automated).toHaveLength(2);
1461
+ });
1462
+
1463
+ it("keeps the summary readable while collapsed", () => {
1464
+ const { host } = mount({ chain: carved });
1465
+ const module = host.querySelector<HTMLElement>(".hf-fx-carve-module")!;
1466
+ // Collapse it: the module opens by default now, because it holds the controls.
1467
+ act(() => module.querySelector<HTMLButtonElement>(".hf-fx-node-name")!.click());
1468
+ expect(module.querySelectorAll(".hf-fx-carve-member")).toHaveLength(0);
1469
+ expect(module.querySelectorAll(".hf-fx-carve-controls")).toHaveLength(0);
1470
+ expect(module.textContent).toContain("2 bands + level");
1471
+ });
1472
+
1473
+ it("offers no per-effect controls inside the module", () => {
1474
+ // Reordering or editing one band is meaningless: the next strength change
1475
+ // rewrites every one of them.
1476
+ const { host } = mount({ chain: carved });
1477
+ const module = host.querySelector(".hf-fx-carve-module")!;
1478
+ expect(module.querySelectorAll(".hf-fx-move")).toHaveLength(0);
1479
+ // One range in the card — Strength, the carve's own — and none per band.
1480
+ expect(module.querySelectorAll("input[type=range]")).toHaveLength(1);
1481
+ expect(module.querySelectorAll(".hf-fx-carve-members input[type=range]")).toHaveLength(0);
1482
+ });
1483
+ });
1484
+
1485
+ describe("FxSection carve", () => {
1486
+ it("is off by default and is not an entry in the chain", () => {
1487
+ const { host } = mount();
1488
+ expect(host.querySelector(".hf-fx-carve")).toBeTruthy();
1489
+ const items = openAddMenuItems(host);
1490
+ expect(items).not.toContain("Voiceover carve");
1491
+ });
1492
+
1493
+ it("presents itself on, at the default strength", () => {
1494
+ // A bed under a voice wants carving, so the module does not start switched off
1495
+ // waiting to be discovered.
1496
+ const { host } = mount();
1497
+ const module = host.querySelector<HTMLElement>(".hf-fx-carve-module")!;
1498
+ expect(module.querySelector(".hf-fx-carve-toggle")?.getAttribute("aria-pressed")).toBe("true");
1499
+ expect(
1500
+ Number(module.querySelector<HTMLInputElement>(".hf-fx-carve-controls .hf-fx-number")!.value),
1501
+ ).toBe(DEFAULT_CARVE.strength);
1502
+ // No dynamic switch: every carve follows the voice now, because a static one
1503
+ // thinned the bed through every pause and nobody wanted that once they heard both.
1504
+ expect(module.querySelector(".hf-fx-carve-dynamic")).toBeNull();
1505
+ });
1506
+
1507
+ it("lists every other audio track as something to make room for", () => {
1508
+ // Two candidates, so they are things to include rather than one readout.
1509
+ const { host } = mount({
1510
+ carve: { ...DEFAULT_CARVE },
1511
+ sourceOptions: [
1512
+ { id: "vo", label: "Voiceover" },
1513
+ { id: "nar", label: "Narration" },
1514
+ ],
1515
+ });
1516
+ const options = Array.from(host.querySelectorAll(".hf-fx-carve-sources label")).map((o) =>
1517
+ o.textContent?.trim(),
1518
+ );
1519
+ expect(options).toContain("Voiceover");
1520
+ expect(options).toContain("Narration");
1521
+ });
1522
+
1523
+ it("offers no analyse button — picking a voice is the whole gesture", () => {
1524
+ // A carve with a source and no filters is a setting nobody applied; the
1525
+ // button was a second step for something the panel already knew to do.
1526
+ const { host } = mount({ carve: { ...DEFAULT_CARVE, sources: ["vo"] } });
1527
+ expect(host.querySelector(".hf-fx-analyse")).toBeNull();
1528
+ // No button offering it. The card may still SAY the analysis has not happened —
1529
+ // that is a status, not a step to take.
1530
+ const buttons = Array.from(host.querySelectorAll("button")).map((b) => b.textContent ?? "");
1531
+ expect(buttons.filter((t) => /analys/i.test(t))).toHaveLength(0);
1532
+ });
1533
+
1534
+ it("says when it is working, since there is no button to grey out", () => {
1535
+ const { host } = mount({ carve: { ...DEFAULT_CARVE, sources: ["vo"] }, analysing: true });
1536
+ expect(host.querySelector(".hf-fx-carve-working")?.textContent).toMatch(/Analysing/i);
1537
+ // A spinner, not just a word: the analysis decodes both tracks and can take a
1538
+ // moment, and a static line reads as a state rather than as work in progress.
1539
+ expect(host.querySelector(".hf-fx-carve-spinner")).not.toBeNull();
1540
+ // Honours a reader who asked for less movement.
1541
+ expect(host.querySelector(".hf-fx-carve-spinner")?.getAttribute("class")).toContain(
1542
+ "motion-reduce:animate-none",
1543
+ );
1544
+ });
1545
+
1546
+ it("clears the previous analysis while a new one runs", () => {
1547
+ // Moving strength re-derives every one of those numbers, so leaving them up
1548
+ // shows settings that are already history as though they were in force.
1549
+ const { host } = mount({
1550
+ chain: carved,
1551
+ carve: { ...DEFAULT_CARVE, sources: ["vo"] },
1552
+ analysing: true,
1553
+ });
1554
+ const module = host.querySelector<HTMLElement>(".hf-fx-carve-module")!;
1555
+ expect(module.querySelectorAll(".hf-fx-carve-member")).toHaveLength(0);
1556
+ expect(module.querySelector(".hf-fx-carve-spinner")).not.toBeNull();
1557
+ // The controls stay put — only the analysis is in flight.
1558
+ expect(module.querySelector(".hf-fx-carve-controls .hf-fx-slider")).not.toBeNull();
1559
+ });
1560
+
1561
+ it("shows the analysis again once it lands", () => {
1562
+ const { host } = mount({ chain: carved, carve: { ...DEFAULT_CARVE, sources: ["vo"] } });
1563
+ const module = host.querySelector<HTMLElement>(".hf-fx-carve-module")!;
1564
+ expect(module.querySelectorAll(".hf-fx-carve-member").length).toBeGreaterThan(0);
1565
+ expect(module.querySelector(".hf-fx-carve-spinner")).toBeNull();
1566
+ });
1567
+
1568
+ it("disables everything when the panel is read-only", () => {
1569
+ const { host } = mount({ chain: chainOf("peaking"), disabled: true });
1570
+ for (const b of Array.from(host.querySelectorAll("button.hf-fx-bypass, .hf-fx-remove"))) {
1571
+ expect((b as HTMLButtonElement).disabled).toBe(true);
1572
+ }
1573
+ });
1574
+ });
1575
+
1576
+ describe("automation in the panel", () => {
1577
+ const automatable = (chain: HfAudioFxChain, over = {}) =>
1578
+ mount({
1579
+ chain,
1580
+ automatedTargets: new Set<string>(),
1581
+ onAutomateParam: vi.fn(),
1582
+ onRemoveParamAutomation: vi.fn(),
1583
+ ...over,
1584
+ });
1585
+
1586
+ const idChain = (type: string, id = "n1"): HfAudioFxChain => ({
1587
+ version: 1,
1588
+ nodes: [{ type, id, enabled: true, params: defaultAudioFxParams(type) }],
1589
+ });
1590
+
1591
+ const rowFor = (host: HTMLElement, label: string): HTMLElement | null => {
1592
+ for (const row of Array.from(host.querySelectorAll<HTMLElement>(".hf-fx-row"))) {
1593
+ if (row.querySelector(".hf-fx-label")?.textContent === label) return row;
1594
+ }
1595
+ return null;
1596
+ };
1597
+
1598
+ it("offers an automate button only for parameters an envelope can drive", () => {
1599
+ // Saturate: `output` is a make-up gain, but the curve's type and threshold
1600
+ // are rebuilt wholesale and cannot be scheduled.
1601
+ const { host } = automatable(idChain("saturate"));
1602
+ // Under Details, where the real parameters are: the derived knob on the open
1603
+ // face has no AudioParam behind it and nothing to automate.
1604
+ openDetails(host);
1605
+ expect(
1606
+ rowFor(host, plainLabel("saturate", "output"))?.querySelector(".hf-fx-automate"),
1607
+ ).toBeTruthy();
1608
+ expect(
1609
+ rowFor(host, plainLabel("saturate", "threshold"))?.querySelector(".hf-fx-automate"),
1610
+ ).toBeNull();
1611
+ });
1612
+
1613
+ it("offers nothing for a worklet effect, which exposes no AudioParams", () => {
1614
+ const { host } = automatable(idChain("compressor"));
1615
+ expect(host.querySelectorAll(".hf-fx-automate").length).toBe(0);
1616
+ });
1617
+
1618
+ it("asks to automate a parameter by node id and key", () => {
1619
+ const onAutomateParam = vi.fn();
1620
+ const { host } = automatable(idChain("lowpass", "n7"), { onAutomateParam });
1621
+ const button = rowFor(host, plainLabel("lowpass", "frequency"))!.querySelector(
1622
+ ".hf-fx-automate",
1623
+ ) as HTMLButtonElement;
1624
+ expect(button.hasAttribute("title")).toBe(false);
1625
+ act(() => button.click());
1626
+ expect(onAutomateParam).toHaveBeenCalledWith("n7", "frequency");
1627
+ });
1628
+
1629
+ it("disables an automated control, since a value typed here would be overwritten", () => {
1630
+ const { host } = automatable(idChain("lowpass"), {
1631
+ automatedTargets: new Set(["fx.n1.frequency"]),
1632
+ });
1633
+ const row = rowFor(host, plainLabel("lowpass", "frequency"))!;
1634
+ expect(row.querySelector<HTMLInputElement>('input[type="range"]')?.disabled).toBe(true);
1635
+ expect(row.querySelector<HTMLInputElement>('input[type="number"]')?.disabled).toBe(true);
1636
+ expect(row.hasAttribute("data-automated")).toBe(true);
1637
+ // A sibling parameter on the same effect stays editable — one click in,
1638
+ // which is where every control that is not the primary one lives.
1639
+ openDetails(host);
1640
+ const q = rowFor(host, plainLabel("lowpass", "q"))!;
1641
+ expect(q.querySelector<HTMLInputElement>('input[type="range"]')?.disabled).toBe(false);
1642
+ });
1643
+
1644
+ it("turns the automated parameter's button into a delete", () => {
1645
+ const onRemoveParamAutomation = vi.fn();
1646
+ const { host } = automatable(idChain("lowpass"), {
1647
+ automatedTargets: new Set(["fx.n1.frequency"]),
1648
+ onRemoveParamAutomation,
1649
+ });
1650
+ const button = rowFor(host, plainLabel("lowpass", "frequency"))!.querySelector(
1651
+ ".hf-fx-automate",
1652
+ ) as HTMLButtonElement;
1653
+ expect(button.getAttribute("aria-pressed")).toBe("true");
1654
+ expect(button.getAttribute("aria-label")).toMatch(/remove/i);
1655
+ // The wording lives in the Tooltip component, which only renders its bubble
1656
+ // on hover; the button itself carries no native title hover.
1657
+ expect(button.hasAttribute("title")).toBe(false);
1658
+ act(() => button.click());
1659
+ expect(onRemoveParamAutomation).toHaveBeenCalledWith("n1", "frequency");
1660
+ });
1661
+
1662
+ it("shows the wording in a tooltip bubble, not a native browser hover", async () => {
1663
+ vi.useFakeTimers();
1664
+ try {
1665
+ const { host } = automatable(idChain("lowpass"), {
1666
+ automatedTargets: new Set(["fx.n1.frequency"]),
1667
+ });
1668
+ const button = rowFor(host, plainLabel("lowpass", "frequency"))!.querySelector(
1669
+ ".hf-fx-automate",
1670
+ ) as HTMLButtonElement;
1671
+ // Tooltip positions itself from the trigger's box and gives up on a 0x0
1672
+ // one, which is every element in happy-dom.
1673
+ vi.spyOn(button, "getBoundingClientRect").mockReturnValue({
1674
+ x: 100,
1675
+ y: 300,
1676
+ left: 100,
1677
+ top: 300,
1678
+ right: 116,
1679
+ bottom: 316,
1680
+ width: 16,
1681
+ height: 16,
1682
+ toJSON: () => ({}),
1683
+ } as DOMRect);
1684
+ // React synthesises pointer-enter from pointerover and delegates focus via
1685
+ // focusin; focus is also how a keyboard user reaches the same tooltip.
1686
+ act(() => {
1687
+ button.dispatchEvent(new Event("focusin", { bubbles: true }));
1688
+ });
1689
+ act(() => {
1690
+ vi.advanceTimersByTime(600);
1691
+ });
1692
+ const bubble = document.querySelector('[role="tooltip"]');
1693
+ expect(bubble?.textContent).toBe("Automated");
1694
+ } finally {
1695
+ vi.useRealTimers();
1696
+ }
1697
+ });
1698
+
1699
+ it("cannot automate a node with no id, which a lane could not address", () => {
1700
+ const { host } = automatable({
1701
+ version: 1,
1702
+ nodes: [{ type: "lowpass", enabled: true, params: defaultAudioFxParams("lowpass") }],
1703
+ });
1704
+ expect(host.querySelectorAll(".hf-fx-automate").length).toBe(0);
1705
+ });
1706
+
1707
+ it("gives a newly added effect an id, so its parameters can be automated", () => {
1708
+ const onChainChange = vi.fn();
1709
+ const { host } = mount({ chain: { version: 1, nodes: [] }, onChainChange });
1710
+ const add = host.querySelector(".hf-fx-add") as HTMLButtonElement;
1711
+ act(() => add.click());
1712
+ const item = Array.from(host.querySelectorAll<HTMLButtonElement>(".hf-fx-add-item")).find(
1713
+ (b) => b.textContent === EFFECT_COPY.lowpass?.title,
1714
+ )!;
1715
+ act(() => item.click());
1716
+ expect(onChainChange.mock.calls[0][0].nodes[0].id).toBe("n1");
1717
+ });
1718
+ });
1719
+
1720
+ describe("voiceover carve visibility", () => {
1721
+ const carveBlock = (host: HTMLElement) => host.querySelector(".hf-fx-carve");
1722
+
1723
+ it("is hidden when the composition has no other audio track to listen to", () => {
1724
+ // Carve dips this bed where another track's voice sits. Alone, the control
1725
+ // could only offer an empty picker.
1726
+ const { host } = mount({ chain: chainOf("lowpass"), sourceOptions: [] });
1727
+ expect(carveBlock(host)).toBeNull();
1728
+ });
1729
+
1730
+ it("is shown once there is another audio track", () => {
1731
+ const { host } = mount({
1732
+ chain: chainOf("lowpass"),
1733
+ sourceOptions: [{ id: "vo", label: "vo" }],
1734
+ });
1735
+ expect(carveBlock(host)).toBeTruthy();
1736
+ });
1737
+
1738
+ it("stays shown for an existing carve whose voice track has gone", () => {
1739
+ // Otherwise the setting would keep dipping the bed from out of sight.
1740
+ const { host } = mount({
1741
+ chain: chainOf("lowpass"),
1742
+ sourceOptions: [],
1743
+ carve: { ...DEFAULT_CARVE, sources: ["vo"] },
1744
+ });
1745
+ expect(carveBlock(host)).toBeTruthy();
1746
+ });
1747
+ });