@hyperframes/studio 0.8.49 → 0.8.50
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/assets/{hyperframes-player-CqON2Rzo.js → hyperframes-player-CxxDxHTW.js} +1 -1
- package/dist/assets/{index-mIgSsKqs.js → index-DUAIWxkI.js} +1 -1
- package/dist/assets/{index-BGovDksE.js → index-Dt3qBI-9.js} +1 -1
- package/dist/assets/{index-Bgmx2h7W.js → index-n2ml4tUn.js} +218 -217
- package/dist/assets/index-pO6p0H27.css +1 -0
- package/dist/index.d.ts +12 -4
- package/dist/index.html +2 -2
- package/dist/index.js +5899 -5754
- package/dist/index.js.map +1 -1
- package/package.json +7 -7
- package/src/App.tsx +10 -8
- package/src/components/EditorShell.selectionSync.test.tsx +4 -0
- package/src/components/EditorShell.tsx +24 -0
- package/src/components/StudioOverlays.tsx +1 -5
- package/src/components/editor/PreviewGuides.test.tsx +88 -0
- package/src/components/editor/PreviewGuides.tsx +125 -0
- package/src/components/editor/SnapToolbar.test.tsx +21 -0
- package/src/components/editor/SnapToolbar.tsx +25 -1
- package/src/components/editor/domEditingAgentPrompt.ts +5 -0
- package/src/components/editor/previewGuidesStore.ts +26 -0
- package/src/components/nle/NLEPreview.test.ts +13 -0
- package/src/components/nle/NLEPreview.tsx +9 -4
- package/src/components/nle/PreviewPane.test.tsx +52 -0
- package/src/components/nle/PreviewPane.tsx +2 -0
- package/src/components/nle/TimelinePane.tsx +14 -0
- package/src/components/nle/useCanvasZOrderTimelineMirror.test.tsx +1 -1
- package/src/components/nle/useCanvasZOrderTimelineMirror.ts +3 -3
- package/src/hooks/appHotkeysDispatch.ts +11 -0
- package/src/hooks/timelineAudioGroupCreate.ts +2 -2
- package/src/hooks/timelineTrackVisibility.ts +8 -11
- package/src/hooks/useAppHotkeys.textEditing.test.tsx +1 -0
- package/src/hooks/useAppHotkeys.ts +3 -0
- package/src/hooks/useAskAgentModal.ts +2 -0
- package/src/hooks/useClipboard.test.ts +234 -0
- package/src/hooks/useClipboard.ts +264 -70
- package/src/hooks/useGroupCommits.test.ts +0 -1
- package/src/hooks/useRazorSplit.history.test.tsx +0 -1
- package/src/hooks/useRazorSplit.test.ts +0 -2
- package/src/hooks/useRazorSplit.test.tsx +0 -1
- package/src/hooks/useStudioContextValue.ts +5 -19
- package/src/hooks/useTimelineEditing.test.tsx +0 -1
- package/src/player/components/ClipContextMenu.tsx +87 -7
- package/src/player/components/Timeline.tsx +14 -14
- package/src/player/components/TimelineCanvas.tsx +30 -0
- package/src/player/components/TimelineGestureOverlay.test.tsx +35 -1
- package/src/player/components/TimelineGestureOverlay.tsx +1 -1
- package/src/player/components/TimelineLanes.test.tsx +22 -1
- package/src/player/components/TimelineOverlays.tsx +12 -0
- package/src/player/components/TimelineTypes.ts +4 -0
- package/src/player/components/timelineAuthoredMoveTarget.ts +1 -2
- package/src/player/components/timelineClipChildren.test.ts +2 -2
- package/src/player/components/timelineClipChildren.tsx +2 -1
- package/src/player/components/timelineClipDragCommit.ts +2 -2
- package/src/player/components/timelineDragDrop.test.tsx +31 -0
- package/src/player/components/timelineDragDrop.ts +30 -23
- package/src/player/components/timelineEditCapabilities.ts +1 -2
- package/src/player/components/timelineEditing.test.ts +0 -16
- package/src/player/components/timelineGroupEditing.test.ts +1 -3
- package/src/player/components/timelineGroupEditing.ts +0 -1
- package/src/player/components/timelineTrackDisplay.ts +1 -1
- package/src/player/components/timelineTrackPersistPipeline.test.ts +0 -53
- package/src/player/components/useTimelineClipContextMenu.ts +32 -0
- package/src/player/components/useTimelineTrackDerivations.ts +1 -1
- package/src/player/hooks/timelineDomBinding.test.ts +0 -3
- package/src/player/hooks/timelineSyncHydration.test.ts +18 -0
- package/src/player/hooks/timelineSyncHydration.ts +37 -18
- package/src/player/hooks/useTimelinePlayer.test.ts +3 -20
- package/src/player/hooks/useTimelineRowElements.test.ts +28 -0
- package/src/player/hooks/useTimelineRowElements.ts +17 -0
- package/src/player/hooks/useTimelineSyncCallbacks.emptyManifest.test.tsx +41 -0
- package/src/player/hooks/useTimelineSyncCallbacks.ts +9 -10
- package/src/player/lib/describeClips.test.ts +95 -0
- package/src/player/lib/describeClips.ts +103 -0
- package/src/player/lib/timelineDOM.test.ts +0 -80
- package/src/player/lib/timelineDOM.ts +1 -61
- package/src/player/lib/timelineElementHelpers.ts +2 -53
- package/src/player/store/playerStore.ts +5 -0
- package/src/player/store/timelineElement.ts +1 -3
- package/src/shims/canvasBrowserStub.js +50 -0
- package/src/utils/clipboardPayload.test.ts +42 -2
- package/src/utils/clipboardPayload.ts +57 -32
- package/src/utils/previewSafeMargins.test.ts +34 -0
- package/src/utils/previewSafeMargins.ts +10 -0
- package/src/utils/razorSplitTransaction.test.ts +0 -1
- package/src/utils/sdkCanvasComposition.test.ts +77 -0
- package/src/utils/studioUiPreferences.test.ts +43 -0
- package/src/utils/studioUiPreferences.ts +27 -6
- package/src/utils/timelineElementSplit.test.ts +1 -2
- package/src/utils/timelineElementSplit.ts +0 -1
- package/src/webmcp/tools/lookTools.test.ts +40 -0
- package/src/webmcp/tools/lookTools.ts +32 -1
- package/dist/assets/index-B9LfWNyM.css +0 -1
- package/src/components/StudioGlobalDragOverlay.tsx +0 -24
- package/src/player/hooks/useExpandedTimelineElements.test.ts +0 -774
- package/src/player/hooks/useExpandedTimelineElements.ts +0 -460
|
@@ -1,774 +0,0 @@
|
|
|
1
|
-
// @vitest-environment happy-dom
|
|
2
|
-
|
|
3
|
-
import React, { act, useEffect } from "react";
|
|
4
|
-
import { createRoot } from "react-dom/client";
|
|
5
|
-
import { afterEach, describe, expect, it } from "vitest";
|
|
6
|
-
import {
|
|
7
|
-
buildExpandedElements,
|
|
8
|
-
resolveTimelineExpansionRawId,
|
|
9
|
-
useExpandedTimelineElements,
|
|
10
|
-
} from "./useExpandedTimelineElements";
|
|
11
|
-
import { buildTimelineElementKey } from "../lib/timelineElementHelpers";
|
|
12
|
-
import { usePlayerStore, type TimelineElement } from "../store/playerStore";
|
|
13
|
-
import type { ClipManifestClip } from "../lib/playbackTypes";
|
|
14
|
-
|
|
15
|
-
globalThis.IS_REACT_ACT_ENVIRONMENT = true;
|
|
16
|
-
|
|
17
|
-
const clip = (over: Partial<ClipManifestClip>): ClipManifestClip => ({
|
|
18
|
-
id: "x",
|
|
19
|
-
label: "x",
|
|
20
|
-
start: 0,
|
|
21
|
-
duration: 1,
|
|
22
|
-
track: 0,
|
|
23
|
-
kind: "element",
|
|
24
|
-
tagName: "div",
|
|
25
|
-
compositionId: null,
|
|
26
|
-
parentCompositionId: null,
|
|
27
|
-
compositionSrc: null,
|
|
28
|
-
assetUrl: null,
|
|
29
|
-
...over,
|
|
30
|
-
});
|
|
31
|
-
|
|
32
|
-
const el = (over: Partial<TimelineElement>): TimelineElement => ({
|
|
33
|
-
id: "x",
|
|
34
|
-
start: 0,
|
|
35
|
-
duration: 1,
|
|
36
|
-
track: 0,
|
|
37
|
-
tag: "div",
|
|
38
|
-
...over,
|
|
39
|
-
});
|
|
40
|
-
|
|
41
|
-
function TimelineExpansionHarness({ onValue }: { onValue: (value: TimelineElement[]) => void }) {
|
|
42
|
-
const value = useExpandedTimelineElements();
|
|
43
|
-
useEffect(() => {
|
|
44
|
-
onValue(value);
|
|
45
|
-
}, [onValue, value]);
|
|
46
|
-
return null;
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
afterEach(() => {
|
|
50
|
-
document.body.innerHTML = "";
|
|
51
|
-
usePlayerStore.getState().reset();
|
|
52
|
-
});
|
|
53
|
-
|
|
54
|
-
describe("buildExpandedElements", () => {
|
|
55
|
-
it("rebases a 1-level child onto its sub-comp host (start + sourceFile)", () => {
|
|
56
|
-
// host s3 at absolute 16 → stats-panel.html; children live in that file.
|
|
57
|
-
const elements = [el({ id: "s3", start: 16, duration: 7, compositionSrc: "stats.html" })];
|
|
58
|
-
const manifest = [
|
|
59
|
-
clip({ id: "s3", start: 16, duration: 7, compositionSrc: "stats.html" }),
|
|
60
|
-
clip({ id: "stat-1", start: 16.5, duration: 5 }),
|
|
61
|
-
clip({ id: "stat-2", start: 16.9, duration: 5 }),
|
|
62
|
-
];
|
|
63
|
-
const parentMap = new Map([
|
|
64
|
-
["stat-1", "s3"],
|
|
65
|
-
["stat-2", "s3"],
|
|
66
|
-
]);
|
|
67
|
-
|
|
68
|
-
const out = buildExpandedElements(elements, manifest, parentMap, "s3", "s3");
|
|
69
|
-
const child = out.find((e) => e.domId === "stat-1")!;
|
|
70
|
-
expect(child.expandedParentStart).toBe(16);
|
|
71
|
-
expect(child.expandedHostKey).toBe("s3");
|
|
72
|
-
expect(child.sourceFile).toBe("stats.html");
|
|
73
|
-
});
|
|
74
|
-
|
|
75
|
-
it("keeps repeated same-source composition hosts as distinct move identities", () => {
|
|
76
|
-
const elements = [
|
|
77
|
-
el({
|
|
78
|
-
id: "host-a",
|
|
79
|
-
key: "index.html#host-a",
|
|
80
|
-
start: 0,
|
|
81
|
-
duration: 5,
|
|
82
|
-
compositionSrc: "scene.html",
|
|
83
|
-
}),
|
|
84
|
-
el({
|
|
85
|
-
id: "host-b",
|
|
86
|
-
key: "index.html#host-b",
|
|
87
|
-
start: 8,
|
|
88
|
-
duration: 5,
|
|
89
|
-
compositionSrc: "scene.html",
|
|
90
|
-
}),
|
|
91
|
-
];
|
|
92
|
-
const manifest = [
|
|
93
|
-
clip({ id: "host-a", start: 0, duration: 5, compositionSrc: "scene.html" }),
|
|
94
|
-
clip({ id: "child-a", start: 1, duration: 2 }),
|
|
95
|
-
clip({ id: "host-b", start: 8, duration: 5, compositionSrc: "scene.html" }),
|
|
96
|
-
clip({ id: "child-b", start: 9, duration: 2 }),
|
|
97
|
-
];
|
|
98
|
-
const parentMap = new Map([
|
|
99
|
-
["child-a", "host-a"],
|
|
100
|
-
["child-b", "host-b"],
|
|
101
|
-
]);
|
|
102
|
-
|
|
103
|
-
const childA = buildExpandedElements(elements, manifest, parentMap, "host-a", "host-a").find(
|
|
104
|
-
(element) => element.domId === "child-a",
|
|
105
|
-
);
|
|
106
|
-
const childB = buildExpandedElements(elements, manifest, parentMap, "host-b", "host-b").find(
|
|
107
|
-
(element) => element.domId === "child-b",
|
|
108
|
-
);
|
|
109
|
-
|
|
110
|
-
expect(childA?.sourceFile).toBe("scene.html");
|
|
111
|
-
expect(childB?.sourceFile).toBe("scene.html");
|
|
112
|
-
expect(childA?.expandedHostKey).toBe("index.html#host-a");
|
|
113
|
-
expect(childB?.expandedHostKey).toBe("index.html#host-b");
|
|
114
|
-
});
|
|
115
|
-
|
|
116
|
-
// fallow-ignore-next-line code-duplication
|
|
117
|
-
it("rebases a 2-level child onto its NESTED host, not the top-level scene", () => {
|
|
118
|
-
// top host A@10 (a.html) embeds host B@12 (b.html); child C lives in b.html.
|
|
119
|
-
// Edits must rebase onto B (12 / b.html), not A (10 / a.html).
|
|
120
|
-
const elements = [el({ id: "A", start: 10, duration: 8, compositionSrc: "a.html" })];
|
|
121
|
-
const manifest = [
|
|
122
|
-
clip({ id: "A", start: 10, duration: 8, compositionSrc: "a.html" }),
|
|
123
|
-
clip({ id: "B", start: 12, duration: 4, compositionSrc: "b.html" }),
|
|
124
|
-
clip({ id: "C", start: 13, duration: 2 }),
|
|
125
|
-
clip({ id: "C2", start: 14, duration: 1 }),
|
|
126
|
-
];
|
|
127
|
-
const parentMap = new Map([
|
|
128
|
-
["B", "A"],
|
|
129
|
-
["C", "B"],
|
|
130
|
-
["C2", "B"],
|
|
131
|
-
]);
|
|
132
|
-
|
|
133
|
-
// Expanding C's siblings: topLevel A, immediate parent B.
|
|
134
|
-
const out = buildExpandedElements(elements, manifest, parentMap, "A", "B");
|
|
135
|
-
const child = out.find((e) => e.domId === "C")!;
|
|
136
|
-
expect(child.expandedParentStart).toBe(12); // B's start, not A's 10
|
|
137
|
-
expect(child.sourceFile).toBe("b.html"); // B's file, not a.html
|
|
138
|
-
});
|
|
139
|
-
|
|
140
|
-
// fallow-ignore-next-line code-duplication
|
|
141
|
-
it("rebases a 3-level child onto its deepest host, not intermediate or top", () => {
|
|
142
|
-
// A@10 (a.html) → B@12 (b.html) → C@13 (c.html); leaf D lives in c.html.
|
|
143
|
-
// Edits must rebase onto C (13 / c.html), not B (12 / b.html) or A (10 / a.html).
|
|
144
|
-
const elements = [el({ id: "A", start: 10, duration: 8, compositionSrc: "a.html" })];
|
|
145
|
-
const manifest = [
|
|
146
|
-
clip({ id: "A", start: 10, duration: 8, compositionSrc: "a.html" }),
|
|
147
|
-
clip({ id: "B", start: 12, duration: 5, compositionSrc: "b.html" }),
|
|
148
|
-
clip({ id: "C", start: 13, duration: 3, compositionSrc: "c.html" }),
|
|
149
|
-
clip({ id: "D", start: 13.5, duration: 1 }),
|
|
150
|
-
clip({ id: "D2", start: 14, duration: 1 }),
|
|
151
|
-
];
|
|
152
|
-
const parentMap = new Map([
|
|
153
|
-
["B", "A"],
|
|
154
|
-
["C", "B"],
|
|
155
|
-
["D", "C"],
|
|
156
|
-
["D2", "C"],
|
|
157
|
-
]);
|
|
158
|
-
|
|
159
|
-
// Expanding D's siblings: topLevel A, immediate parent C.
|
|
160
|
-
const out = buildExpandedElements(elements, manifest, parentMap, "A", "C");
|
|
161
|
-
const child = out.find((e) => e.domId === "D")!;
|
|
162
|
-
expect(child.expandedParentStart).toBe(13); // C's start, not B's 12 or A's 10
|
|
163
|
-
expect(child.sourceFile).toBe("c.html"); // C's file, not b.html or a.html
|
|
164
|
-
});
|
|
165
|
-
|
|
166
|
-
it("keeps the middle host's row when drilling two levels deep", () => {
|
|
167
|
-
// A embeds B; C lives in B. Drilling into B must leave BOTH host rows
|
|
168
|
-
// standing: sparing only the top-level one drops B's row, and its keyframe
|
|
169
|
-
// lane goes with it because diamonds render per row.
|
|
170
|
-
const elements = [
|
|
171
|
-
el({ id: "A", domId: "A", start: 10, duration: 8, compositionSrc: "a.html" }),
|
|
172
|
-
el({ id: "B", domId: "B", start: 12, duration: 4, track: 1, compositionSrc: "b.html" }),
|
|
173
|
-
];
|
|
174
|
-
const manifest = [
|
|
175
|
-
clip({ id: "A", start: 10, duration: 8, compositionSrc: "a.html" }),
|
|
176
|
-
clip({ id: "B", start: 12, duration: 4, compositionSrc: "b.html" }),
|
|
177
|
-
clip({ id: "C", start: 13, duration: 2 }),
|
|
178
|
-
];
|
|
179
|
-
const parentMap = new Map([
|
|
180
|
-
["B", "A"],
|
|
181
|
-
["C", "B"],
|
|
182
|
-
]);
|
|
183
|
-
|
|
184
|
-
const out = buildExpandedElements(elements, manifest, parentMap, "A", "B");
|
|
185
|
-
const rows = out.map((e) => e.domId ?? e.id);
|
|
186
|
-
expect(rows).toContain("B");
|
|
187
|
-
// The child sits under its own host, not under the top-level row.
|
|
188
|
-
expect(rows.indexOf("C")).toBeGreaterThan(rows.indexOf("B"));
|
|
189
|
-
});
|
|
190
|
-
|
|
191
|
-
it("still drills a host that exists only in the manifest, without a row for it", () => {
|
|
192
|
-
// Same shape, but B has no store element, so there is no row to spare. The
|
|
193
|
-
// children stay anchored to the top-level row rather than vanishing.
|
|
194
|
-
const elements = [
|
|
195
|
-
el({ id: "A", domId: "A", start: 10, duration: 8, compositionSrc: "a.html" }),
|
|
196
|
-
];
|
|
197
|
-
const manifest = [
|
|
198
|
-
clip({ id: "A", start: 10, duration: 8, compositionSrc: "a.html" }),
|
|
199
|
-
clip({ id: "B", start: 12, duration: 4, compositionSrc: "b.html" }),
|
|
200
|
-
clip({ id: "C", start: 13, duration: 2 }),
|
|
201
|
-
];
|
|
202
|
-
const parentMap = new Map([
|
|
203
|
-
["B", "A"],
|
|
204
|
-
["C", "B"],
|
|
205
|
-
]);
|
|
206
|
-
|
|
207
|
-
const out = buildExpandedElements(elements, manifest, parentMap, "A", "B");
|
|
208
|
-
expect(out.map((e) => e.domId ?? e.id)).toEqual(["A", "C"]);
|
|
209
|
-
});
|
|
210
|
-
|
|
211
|
-
// Regression: an expanded child must share one identity (`key`) with the flat
|
|
212
|
-
// store element for the same DOM id. Before the fix the child key fell back to
|
|
213
|
-
// the colon form (`index.html:eyebrow:N`) while the store/selection used the
|
|
214
|
-
// hash form (`index.html#eyebrow`), so clicking an expanded child never
|
|
215
|
-
// highlighted it (isSelected compares the two keys).
|
|
216
|
-
it("keys expanded children in hash form, matching the flat store element", () => {
|
|
217
|
-
// Single composition (no sub-comps): scene `s1` with same-file children.
|
|
218
|
-
const elements = [el({ id: "s1", domId: "s1", start: 0, duration: 14 })];
|
|
219
|
-
const manifest = [
|
|
220
|
-
clip({ id: "s1", start: 0, duration: 14 }),
|
|
221
|
-
clip({ id: "eyebrow", start: 0, duration: 14 }),
|
|
222
|
-
clip({ id: "title", start: 0, duration: 14 }),
|
|
223
|
-
];
|
|
224
|
-
const parentMap = new Map([
|
|
225
|
-
["eyebrow", "s1"],
|
|
226
|
-
["title", "s1"],
|
|
227
|
-
]);
|
|
228
|
-
|
|
229
|
-
const out = buildExpandedElements(elements, manifest, parentMap, "s1", "s1");
|
|
230
|
-
const child = out.find((e) => e.domId === "eyebrow")!;
|
|
231
|
-
|
|
232
|
-
const expectedStoreKey = buildTimelineElementKey({
|
|
233
|
-
id: "eyebrow",
|
|
234
|
-
fallbackIndex: 0,
|
|
235
|
-
domId: "eyebrow",
|
|
236
|
-
selector: "#eyebrow",
|
|
237
|
-
sourceFile: undefined,
|
|
238
|
-
});
|
|
239
|
-
expect(expectedStoreKey).toBe("index.html#eyebrow");
|
|
240
|
-
expect(child.key).toBe("index.html#eyebrow");
|
|
241
|
-
expect(child.key).toBe(expectedStoreKey);
|
|
242
|
-
});
|
|
243
|
-
|
|
244
|
-
// Regression: a child row is built from a manifest clip, which carries none of
|
|
245
|
-
// the host element's attributes. Reading hidden off the manifest left every
|
|
246
|
-
// expanded row reporting itself visible, so the eye wrote data-hidden a second
|
|
247
|
-
// time instead of removing it and the element could never be shown again.
|
|
248
|
-
it("inherits hidden and locked state from the flat store element", () => {
|
|
249
|
-
const elements = [
|
|
250
|
-
el({ id: "s1", domId: "s1", start: 0, duration: 14 }),
|
|
251
|
-
el({
|
|
252
|
-
id: "eyebrow",
|
|
253
|
-
key: "index.html#eyebrow",
|
|
254
|
-
domId: "eyebrow",
|
|
255
|
-
start: 0,
|
|
256
|
-
duration: 14,
|
|
257
|
-
hidden: true,
|
|
258
|
-
timelineLocked: true,
|
|
259
|
-
}),
|
|
260
|
-
];
|
|
261
|
-
const manifest = [
|
|
262
|
-
clip({ id: "s1", start: 0, duration: 14 }),
|
|
263
|
-
clip({ id: "eyebrow", start: 0, duration: 14 }),
|
|
264
|
-
];
|
|
265
|
-
const parentMap = new Map([["eyebrow", "s1"]]);
|
|
266
|
-
|
|
267
|
-
const out = buildExpandedElements(elements, manifest, parentMap, "s1", "s1");
|
|
268
|
-
const child = out.find((e) => e.domId === "eyebrow")!;
|
|
269
|
-
expect(child.hidden).toBe(true);
|
|
270
|
-
expect(child.timelineLocked).toBe(true);
|
|
271
|
-
});
|
|
272
|
-
|
|
273
|
-
// The test above only covers a host with no compositionSrc, where the child's
|
|
274
|
-
// key falls back to the `index.html` scope and a flat store twin can exist. A
|
|
275
|
-
// REAL sub-composition scopes the child key to the sub-comp file, and clips
|
|
276
|
-
// whose parent composition is itself in the manifest are filtered out of the
|
|
277
|
-
// flat store before it is built (`processTimelineMessage`). So the twin never
|
|
278
|
-
// exists there and the inheritance above is dead code for the one case it was
|
|
279
|
-
// written for: the eye reported every hidden child visible, clicking it
|
|
280
|
-
// rewrote data-hidden, and nothing could be shown again.
|
|
281
|
-
it("reads hidden and locked off the live element when the child has no flat twin", () => {
|
|
282
|
-
// Only the host is in the flat store — exactly what the manifest filter leaves.
|
|
283
|
-
const elements = [
|
|
284
|
-
el({
|
|
285
|
-
id: "scene-2",
|
|
286
|
-
domId: "scene-2",
|
|
287
|
-
start: 3.25,
|
|
288
|
-
duration: 3.5,
|
|
289
|
-
compositionSrc: "scene-2.html",
|
|
290
|
-
}),
|
|
291
|
-
];
|
|
292
|
-
const manifest = [
|
|
293
|
-
clip({ id: "scene-2", start: 3.25, duration: 3.5, compositionSrc: "scene-2.html" }),
|
|
294
|
-
clip({ id: "scene-2-video", start: 3.25, duration: 3.5, parentCompositionId: "scene-2" }),
|
|
295
|
-
];
|
|
296
|
-
const parentMap = new Map([["scene-2-video", "scene-2"]]);
|
|
297
|
-
const hostState = new Map([["scene-2-video", { hidden: true, timelineLocked: true }]]);
|
|
298
|
-
|
|
299
|
-
const out = buildExpandedElements(
|
|
300
|
-
elements,
|
|
301
|
-
manifest,
|
|
302
|
-
parentMap,
|
|
303
|
-
"scene-2",
|
|
304
|
-
"scene-2",
|
|
305
|
-
[],
|
|
306
|
-
hostState,
|
|
307
|
-
);
|
|
308
|
-
const child = out.find((e) => e.domId === "scene-2-video")!;
|
|
309
|
-
// The child key is scoped to the sub-comp file, so no store element can match it.
|
|
310
|
-
expect(child.key).toBe("scene-2.html#scene-2-video");
|
|
311
|
-
expect(elements.some((element) => element.key === child.key)).toBe(false);
|
|
312
|
-
expect(child.hidden).toBe(true);
|
|
313
|
-
expect(child.timelineLocked).toBe(true);
|
|
314
|
-
});
|
|
315
|
-
|
|
316
|
-
// Sub-comp internals (group + pills) have no data-start, so they're not in the
|
|
317
|
-
// manifest. They arrive as DOM children and must still expand under their host.
|
|
318
|
-
it("expands DOM-only sub-comp children (no manifest clip) under the host", () => {
|
|
319
|
-
const elements = [
|
|
320
|
-
el({ id: "scene-host", start: 5, duration: 6, compositionSrc: "scene.html" }),
|
|
321
|
-
];
|
|
322
|
-
const manifest = [
|
|
323
|
-
clip({ id: "scene-host", start: 5, duration: 6, compositionSrc: "scene.html" }),
|
|
324
|
-
];
|
|
325
|
-
// pill-3 selected → parent group-1 → host scene-host. None of group-1/pills
|
|
326
|
-
// are in the manifest; they're DOM children with parent links.
|
|
327
|
-
const parentMap = new Map([
|
|
328
|
-
["group-1", "scene-host"],
|
|
329
|
-
["pill-1", "group-1"],
|
|
330
|
-
["pill-2", "group-1"],
|
|
331
|
-
["pill-3", "group-1"],
|
|
332
|
-
]);
|
|
333
|
-
const domClipChildren = [
|
|
334
|
-
{
|
|
335
|
-
id: "group-1",
|
|
336
|
-
parentId: "scene-host",
|
|
337
|
-
hostId: "scene-host",
|
|
338
|
-
label: "Group 1",
|
|
339
|
-
stackingContextId: "css:0",
|
|
340
|
-
},
|
|
341
|
-
{
|
|
342
|
-
id: "pill-1",
|
|
343
|
-
parentId: "group-1",
|
|
344
|
-
hostId: "scene-host",
|
|
345
|
-
label: "pill-1",
|
|
346
|
-
stackingContextId: "css:0.0",
|
|
347
|
-
},
|
|
348
|
-
{
|
|
349
|
-
id: "pill-2",
|
|
350
|
-
parentId: "group-1",
|
|
351
|
-
hostId: "scene-host",
|
|
352
|
-
label: "pill-2",
|
|
353
|
-
stackingContextId: "css:0.1",
|
|
354
|
-
},
|
|
355
|
-
{
|
|
356
|
-
id: "pill-3",
|
|
357
|
-
parentId: "group-1",
|
|
358
|
-
hostId: "scene-host",
|
|
359
|
-
label: "pill-3",
|
|
360
|
-
stackingContextId: "css:0.1",
|
|
361
|
-
},
|
|
362
|
-
];
|
|
363
|
-
|
|
364
|
-
// Expanding pill-3's siblings: topLevel scene-host, immediate parent group-1.
|
|
365
|
-
const out = buildExpandedElements(
|
|
366
|
-
elements,
|
|
367
|
-
manifest,
|
|
368
|
-
parentMap,
|
|
369
|
-
"scene-host",
|
|
370
|
-
"group-1",
|
|
371
|
-
domClipChildren,
|
|
372
|
-
);
|
|
373
|
-
const pills = out.filter((e) => e.domId?.startsWith("pill-"));
|
|
374
|
-
expect(pills).toHaveLength(3);
|
|
375
|
-
// Children span the host's bounds and rebase onto the host's file.
|
|
376
|
-
expect(pills[0]!.start).toBe(5);
|
|
377
|
-
expect(pills[0]!.duration).toBe(6);
|
|
378
|
-
expect(pills[0]!.sourceFile).toBe("scene.html");
|
|
379
|
-
expect(pills.map((pill) => pill.stackingContextId)).toEqual(["css:0.0", "css:0.1", "css:0.1"]);
|
|
380
|
-
// The host row survives the expansion; its children are added under it.
|
|
381
|
-
expect(out.some((e) => e.id === "scene-host")).toBe(true);
|
|
382
|
-
});
|
|
383
|
-
|
|
384
|
-
// fallow-ignore-next-line code-duplication
|
|
385
|
-
it("keeps the host row and appends its children directly below it", () => {
|
|
386
|
-
const elements = [
|
|
387
|
-
el({
|
|
388
|
-
id: "s3",
|
|
389
|
-
domId: "s3",
|
|
390
|
-
key: "index.html#s3",
|
|
391
|
-
start: 16,
|
|
392
|
-
duration: 7,
|
|
393
|
-
compositionSrc: "stats.html",
|
|
394
|
-
}),
|
|
395
|
-
el({ id: "outro", start: 23, duration: 3, track: 1 }),
|
|
396
|
-
];
|
|
397
|
-
const manifest = [
|
|
398
|
-
clip({ id: "s3", start: 16, duration: 7, compositionSrc: "stats.html" }),
|
|
399
|
-
clip({ id: "stat-1", start: 16.5, duration: 5 }),
|
|
400
|
-
clip({ id: "stat-2", start: 16.9, duration: 5 }),
|
|
401
|
-
];
|
|
402
|
-
const parentMap = new Map([
|
|
403
|
-
["stat-1", "s3"],
|
|
404
|
-
["stat-2", "s3"],
|
|
405
|
-
]);
|
|
406
|
-
|
|
407
|
-
const out = buildExpandedElements(elements, manifest, parentMap, "s3", "s3");
|
|
408
|
-
|
|
409
|
-
const hostIndex = out.findIndex((e) => e.id === "s3");
|
|
410
|
-
expect(hostIndex).toBeGreaterThanOrEqual(0);
|
|
411
|
-
// Host row untouched (same key → same keyframe lane), children nested under it.
|
|
412
|
-
expect(out[hostIndex]!.key).toBe("index.html#s3");
|
|
413
|
-
expect(out[hostIndex]!.track).toBe(0);
|
|
414
|
-
expect(out[hostIndex + 1]!.domId).toBe("stat-1");
|
|
415
|
-
expect(out[hostIndex + 2]!.domId).toBe("stat-2");
|
|
416
|
-
// Exactly one more row than the old substitution behaviour (host + 2 children + outro).
|
|
417
|
-
expect(out).toHaveLength(4);
|
|
418
|
-
});
|
|
419
|
-
|
|
420
|
-
it("keeps the host row present at every playhead position (keyframe lane repro)", () => {
|
|
421
|
-
// Live repro with no drag at all: seek 0 gave 3 diamonds, seek 7.68 gave 0,
|
|
422
|
-
// seek 0.2 gave 3. Diamonds render per row from keyframeCache.get(elementKey),
|
|
423
|
-
// so the whole lane went with the host row whenever the store-time drill-in
|
|
424
|
-
// substituted it for its children.
|
|
425
|
-
const elements = [
|
|
426
|
-
el({ id: "scene", domId: "scene", key: "index.html#scene", start: 0, duration: 12 }),
|
|
427
|
-
];
|
|
428
|
-
const manifest = [
|
|
429
|
-
clip({ id: "scene", start: 0, duration: 12, compositionSrc: "scene.html" }),
|
|
430
|
-
clip({ id: "headline", start: 0, duration: 12 }),
|
|
431
|
-
];
|
|
432
|
-
const parentMap = new Map([["headline", "scene"]]);
|
|
433
|
-
|
|
434
|
-
for (const currentTime of [0, 7.68, 0.2]) {
|
|
435
|
-
const rawId = resolveTimelineExpansionRawId({
|
|
436
|
-
selectedElementId: null,
|
|
437
|
-
currentTime,
|
|
438
|
-
manifest,
|
|
439
|
-
parentMap,
|
|
440
|
-
});
|
|
441
|
-
const rows = rawId
|
|
442
|
-
? buildExpandedElements(elements, manifest, parentMap, rawId, rawId)
|
|
443
|
-
: elements;
|
|
444
|
-
expect(rows.map((row) => row.key)).toContain("index.html#scene");
|
|
445
|
-
}
|
|
446
|
-
});
|
|
447
|
-
|
|
448
|
-
// Regression: DOM-only children were synthesized against the TOP-LEVEL element
|
|
449
|
-
// instead of the sub-comp host they actually live in, so every child row read
|
|
450
|
-
// the whole top-level window rather than its host's.
|
|
451
|
-
it("spans DOM-only children over their nested host's window, not the top-level one", () => {
|
|
452
|
-
const elements = [
|
|
453
|
-
el({ id: "scene-host", start: 0, duration: 20, compositionSrc: "scene.html" }),
|
|
454
|
-
];
|
|
455
|
-
const manifest = [
|
|
456
|
-
clip({ id: "scene-host", start: 0, duration: 20, compositionSrc: "scene.html" }),
|
|
457
|
-
clip({ id: "sub-host", start: 5, duration: 6, compositionSrc: "sub.html" }),
|
|
458
|
-
];
|
|
459
|
-
const parentMap = new Map([
|
|
460
|
-
["sub-host", "scene-host"],
|
|
461
|
-
["pill-1", "sub-host"],
|
|
462
|
-
]);
|
|
463
|
-
const domClipChildren = [
|
|
464
|
-
{
|
|
465
|
-
id: "pill-1",
|
|
466
|
-
parentId: "sub-host",
|
|
467
|
-
hostId: "sub-host",
|
|
468
|
-
label: "pill-1",
|
|
469
|
-
stackingContextId: "css:0.0",
|
|
470
|
-
},
|
|
471
|
-
];
|
|
472
|
-
|
|
473
|
-
const out = buildExpandedElements(
|
|
474
|
-
elements,
|
|
475
|
-
manifest,
|
|
476
|
-
parentMap,
|
|
477
|
-
"scene-host",
|
|
478
|
-
"sub-host",
|
|
479
|
-
domClipChildren,
|
|
480
|
-
);
|
|
481
|
-
const pill = out.find((e) => e.domId === "pill-1")!;
|
|
482
|
-
expect(pill.start).toBe(5);
|
|
483
|
-
expect(pill.duration).toBe(6);
|
|
484
|
-
expect(pill.sourceFile).toBe("sub.html");
|
|
485
|
-
});
|
|
486
|
-
});
|
|
487
|
-
|
|
488
|
-
describe("resolveTimelineExpansionRawId", () => {
|
|
489
|
-
it("returns null inside a childless top-level clip", () => {
|
|
490
|
-
const manifest = [clip({ id: "title", start: 0, duration: 4 })];
|
|
491
|
-
|
|
492
|
-
expect(
|
|
493
|
-
resolveTimelineExpansionRawId({
|
|
494
|
-
selectedElementId: null,
|
|
495
|
-
currentTime: 2,
|
|
496
|
-
manifest,
|
|
497
|
-
parentMap: new Map(),
|
|
498
|
-
}),
|
|
499
|
-
).toBeNull();
|
|
500
|
-
});
|
|
501
|
-
|
|
502
|
-
it("auto-expands an active composition with children when nothing is selected", () => {
|
|
503
|
-
const manifest = [
|
|
504
|
-
clip({ id: "scene", start: 1, duration: 5 }),
|
|
505
|
-
clip({ id: "headline", start: 1.5, duration: 2 }),
|
|
506
|
-
];
|
|
507
|
-
const parentMap = new Map([["headline", "scene"]]);
|
|
508
|
-
|
|
509
|
-
expect(
|
|
510
|
-
resolveTimelineExpansionRawId({
|
|
511
|
-
selectedElementId: null,
|
|
512
|
-
currentTime: 2,
|
|
513
|
-
manifest,
|
|
514
|
-
parentMap,
|
|
515
|
-
}),
|
|
516
|
-
).toBe("scene");
|
|
517
|
-
});
|
|
518
|
-
|
|
519
|
-
it("THE BUG: keeps a composition expanded with the playhead parked on its end", () => {
|
|
520
|
-
// Clip windows are half-open, so at the very end of the timeline the
|
|
521
|
-
// playhead was inside nothing and every expanded row collapsed.
|
|
522
|
-
const manifest = [
|
|
523
|
-
clip({ id: "scene", start: 0, duration: 12 }),
|
|
524
|
-
clip({ id: "headline", start: 0, duration: 12 }),
|
|
525
|
-
];
|
|
526
|
-
const parentMap = new Map([["headline", "scene"]]);
|
|
527
|
-
|
|
528
|
-
expect(
|
|
529
|
-
resolveTimelineExpansionRawId({
|
|
530
|
-
selectedElementId: null,
|
|
531
|
-
currentTime: 12,
|
|
532
|
-
manifest,
|
|
533
|
-
parentMap,
|
|
534
|
-
}),
|
|
535
|
-
).toBe("scene");
|
|
536
|
-
});
|
|
537
|
-
|
|
538
|
-
it("prefers the starting clip over the ending one on a shared seam", () => {
|
|
539
|
-
const manifest = [
|
|
540
|
-
clip({ id: "first", start: 0, duration: 5 }),
|
|
541
|
-
clip({ id: "first-child", start: 0, duration: 5 }),
|
|
542
|
-
clip({ id: "second", start: 5, duration: 5 }),
|
|
543
|
-
clip({ id: "second-child", start: 5, duration: 5 }),
|
|
544
|
-
];
|
|
545
|
-
const parentMap = new Map([
|
|
546
|
-
["first-child", "first"],
|
|
547
|
-
["second-child", "second"],
|
|
548
|
-
]);
|
|
549
|
-
|
|
550
|
-
expect(
|
|
551
|
-
resolveTimelineExpansionRawId({
|
|
552
|
-
selectedElementId: null,
|
|
553
|
-
currentTime: 5,
|
|
554
|
-
manifest,
|
|
555
|
-
parentMap,
|
|
556
|
-
}),
|
|
557
|
-
).toBe("second");
|
|
558
|
-
});
|
|
559
|
-
|
|
560
|
-
it("auto-expands the innermost active nested composition", () => {
|
|
561
|
-
const manifest = [
|
|
562
|
-
clip({ id: "outer", start: 0, duration: 10 }),
|
|
563
|
-
clip({ id: "inner", start: 2, duration: 5 }),
|
|
564
|
-
clip({ id: "leaf", start: 3, duration: 1 }),
|
|
565
|
-
];
|
|
566
|
-
const parentMap = new Map([
|
|
567
|
-
["inner", "outer"],
|
|
568
|
-
["leaf", "inner"],
|
|
569
|
-
]);
|
|
570
|
-
|
|
571
|
-
expect(
|
|
572
|
-
resolveTimelineExpansionRawId({
|
|
573
|
-
selectedElementId: null,
|
|
574
|
-
currentTime: 3.5,
|
|
575
|
-
manifest,
|
|
576
|
-
parentMap,
|
|
577
|
-
}),
|
|
578
|
-
).toBe("inner");
|
|
579
|
-
});
|
|
580
|
-
|
|
581
|
-
it("resolves an active composition from the current store time", () => {
|
|
582
|
-
const manifest = [
|
|
583
|
-
clip({ id: "scene", start: 0, duration: 5 }),
|
|
584
|
-
clip({ id: "headline", start: 1, duration: 2 }),
|
|
585
|
-
];
|
|
586
|
-
const parentMap = new Map([["headline", "scene"]]);
|
|
587
|
-
|
|
588
|
-
expect(
|
|
589
|
-
resolveTimelineExpansionRawId({
|
|
590
|
-
selectedElementId: null,
|
|
591
|
-
currentTime: 2,
|
|
592
|
-
manifest,
|
|
593
|
-
parentMap,
|
|
594
|
-
}),
|
|
595
|
-
).toBe("scene");
|
|
596
|
-
});
|
|
597
|
-
|
|
598
|
-
it("keeps inline children visible when the master timeline is playing", () => {
|
|
599
|
-
const elements = [
|
|
600
|
-
el({ id: "scene", domId: "scene", key: "index.html#scene", start: 0, duration: 5 }),
|
|
601
|
-
];
|
|
602
|
-
const manifest = [
|
|
603
|
-
clip({ id: "scene", start: 0, duration: 5, compositionSrc: "scene.html" }),
|
|
604
|
-
clip({ id: "headline", start: 1, duration: 2 }),
|
|
605
|
-
];
|
|
606
|
-
const parentMap = new Map([["headline", "scene"]]);
|
|
607
|
-
let rows: TimelineElement[] | undefined;
|
|
608
|
-
const host = document.createElement("div");
|
|
609
|
-
document.body.append(host);
|
|
610
|
-
const root = createRoot(host);
|
|
611
|
-
|
|
612
|
-
act(() => {
|
|
613
|
-
usePlayerStore.setState({
|
|
614
|
-
elements,
|
|
615
|
-
clipManifest: manifest,
|
|
616
|
-
clipParentMap: parentMap,
|
|
617
|
-
currentTime: 2,
|
|
618
|
-
isPlaying: true,
|
|
619
|
-
});
|
|
620
|
-
root.render(
|
|
621
|
-
React.createElement(TimelineExpansionHarness, {
|
|
622
|
-
onValue: (value) => (rows = value),
|
|
623
|
-
}),
|
|
624
|
-
);
|
|
625
|
-
});
|
|
626
|
-
|
|
627
|
-
expect(usePlayerStore.getState().isPlaying).toBe(true);
|
|
628
|
-
expect(rows?.map((row) => row.domId ?? row.id)).toEqual(["scene", "headline"]);
|
|
629
|
-
|
|
630
|
-
act(() => root.unmount());
|
|
631
|
-
});
|
|
632
|
-
|
|
633
|
-
it("keeps selected elements ahead of active composition auto-expansion", () => {
|
|
634
|
-
const manifest = [
|
|
635
|
-
clip({ id: "scene", start: 0, duration: 6 }),
|
|
636
|
-
clip({ id: "headline", start: 1, duration: 2 }),
|
|
637
|
-
clip({ id: "caption", start: 4, duration: 1 }),
|
|
638
|
-
];
|
|
639
|
-
const parentMap = new Map([
|
|
640
|
-
["headline", "scene"],
|
|
641
|
-
["caption", "scene"],
|
|
642
|
-
]);
|
|
643
|
-
|
|
644
|
-
expect(
|
|
645
|
-
resolveTimelineExpansionRawId({
|
|
646
|
-
selectedElementId: "caption",
|
|
647
|
-
currentTime: 1.5,
|
|
648
|
-
manifest,
|
|
649
|
-
parentMap,
|
|
650
|
-
}),
|
|
651
|
-
).toBe("caption");
|
|
652
|
-
});
|
|
653
|
-
});
|
|
654
|
-
|
|
655
|
-
describe("buildExpandedElements — collision-free synthetic rows (cross-file lane safety)", () => {
|
|
656
|
-
it("expanded children NEVER share a display track with an unrelated top-level clip", () => {
|
|
657
|
-
// The Deepwork regression: host on track 0 with two children used to put
|
|
658
|
-
// child #2 on integer track 1 — the same lane as index.html#foreign. Lane
|
|
659
|
-
// grouping merges purely by track number, so a gap-close on that "one"
|
|
660
|
-
// lane batch-persisted a foreign file's clip.
|
|
661
|
-
const elements = [
|
|
662
|
-
el({ id: "host", start: 0, duration: 20, track: 0, compositionSrc: "scene.html" }),
|
|
663
|
-
el({ id: "foreign", start: 20, duration: 5, track: 1 }),
|
|
664
|
-
];
|
|
665
|
-
const manifest = [
|
|
666
|
-
clip({ id: "host", start: 0, duration: 20, compositionSrc: "scene.html" }),
|
|
667
|
-
clip({ id: "c1", start: 0, duration: 5 }),
|
|
668
|
-
clip({ id: "c2", start: 10, duration: 5 }),
|
|
669
|
-
];
|
|
670
|
-
const parentMap = new Map([
|
|
671
|
-
["c1", "host"],
|
|
672
|
-
["c2", "host"],
|
|
673
|
-
]);
|
|
674
|
-
|
|
675
|
-
const out = buildExpandedElements(elements, manifest, parentMap, "host", "host");
|
|
676
|
-
const foreign = out.find((e) => e.id === "foreign")!;
|
|
677
|
-
const children = out.filter((e) => e.domId === "c1" || e.domId === "c2");
|
|
678
|
-
expect(children).toHaveLength(2);
|
|
679
|
-
for (const child of children) {
|
|
680
|
-
// No lane sharing with the foreign clip…
|
|
681
|
-
expect(child.track).not.toBe(foreign.track);
|
|
682
|
-
// …and structurally impossible to collide with ANY normalized (integer)
|
|
683
|
-
// lane: synthetic rows are strict fractions under the host's lane.
|
|
684
|
-
expect(Number.isInteger(child.track)).toBe(false);
|
|
685
|
-
expect(child.track).toBeGreaterThan(0);
|
|
686
|
-
expect(child.track).toBeLessThan(1);
|
|
687
|
-
}
|
|
688
|
-
// Distinct ordered rows per child.
|
|
689
|
-
expect(children[0].track).not.toBe(children[1].track);
|
|
690
|
-
});
|
|
691
|
-
|
|
692
|
-
/**
|
|
693
|
-
* A sub-composition that declares BOTH a group and its members keeps those
|
|
694
|
-
* members out of the flat store entirely — the store holds only the host.
|
|
695
|
-
* So "inherit membership from the flat twin" had nothing to inherit from,
|
|
696
|
-
* and the group produced no timeline row at all, for exactly the case group
|
|
697
|
-
* support was extended to cover. Verified against a real studio session
|
|
698
|
-
* before this test was written: the flat store held three elements (the
|
|
699
|
-
* panel, the sub-comp host and an ungrouped bed) and neither voice.
|
|
700
|
-
*/
|
|
701
|
-
it("takes group membership from the DOM child when there is no flat store twin", () => {
|
|
702
|
-
const elements = [
|
|
703
|
-
el({ id: "voices-host", start: 0, duration: 12, compositionSrc: "voices.html" }),
|
|
704
|
-
];
|
|
705
|
-
const manifest = [
|
|
706
|
-
clip({ id: "voices-host", start: 0, duration: 12, compositionSrc: "voices.html" }),
|
|
707
|
-
];
|
|
708
|
-
const parentMap = new Map([
|
|
709
|
-
["voice-1", "voices-host"],
|
|
710
|
-
["voice-2", "voices-host"],
|
|
711
|
-
]);
|
|
712
|
-
const domClipChildren = [
|
|
713
|
-
{
|
|
714
|
-
id: "voice-1",
|
|
715
|
-
parentId: "voices-host",
|
|
716
|
-
hostId: "voices-host",
|
|
717
|
-
label: "voice-1",
|
|
718
|
-
stackingContextId: "css:0",
|
|
719
|
-
audioGroup: "voiceover",
|
|
720
|
-
audioGroupLabel: "Voiceover",
|
|
721
|
-
audioGroupVolume: 0.8,
|
|
722
|
-
audioGroupHidden: false,
|
|
723
|
-
},
|
|
724
|
-
{
|
|
725
|
-
id: "voice-2",
|
|
726
|
-
parentId: "voices-host",
|
|
727
|
-
hostId: "voices-host",
|
|
728
|
-
label: "voice-2",
|
|
729
|
-
stackingContextId: "css:0",
|
|
730
|
-
audioGroup: "voiceover",
|
|
731
|
-
audioGroupLabel: "Voiceover",
|
|
732
|
-
audioGroupVolume: 0.8,
|
|
733
|
-
audioGroupHidden: false,
|
|
734
|
-
},
|
|
735
|
-
];
|
|
736
|
-
|
|
737
|
-
const out = buildExpandedElements(
|
|
738
|
-
elements,
|
|
739
|
-
manifest,
|
|
740
|
-
parentMap,
|
|
741
|
-
"voices-host",
|
|
742
|
-
"voices-host",
|
|
743
|
-
domClipChildren,
|
|
744
|
-
);
|
|
745
|
-
|
|
746
|
-
const voices = out.filter((e) => e.domId?.startsWith("voice-"));
|
|
747
|
-
expect(voices).toHaveLength(2);
|
|
748
|
-
for (const voice of voices) {
|
|
749
|
-
expect(voice.audioGroup).toBe("voiceover");
|
|
750
|
-
expect(voice.audioGroupLabel).toBe("Voiceover");
|
|
751
|
-
expect(voice.audioGroupVolume).toBeCloseTo(0.8, 6);
|
|
752
|
-
}
|
|
753
|
-
});
|
|
754
|
-
});
|
|
755
|
-
|
|
756
|
-
describe("sub-comp child rows never collide with a group anchor", () => {
|
|
757
|
-
/**
|
|
758
|
-
* A group row anchors at exactly `firstMemberTrack - 0.5`. The old child
|
|
759
|
-
* scheme `k / (n + 2)` hit 0.5 dead on for a host with TWO children (2/4),
|
|
760
|
-
* producing a duplicate row key and a duplicated group header.
|
|
761
|
-
*/
|
|
762
|
-
it("keeps every child strictly below the host's half-lane", () => {
|
|
763
|
-
for (const childCount of [1, 2, 3, 4, 7]) {
|
|
764
|
-
const fractions = Array.from(
|
|
765
|
-
{ length: childCount },
|
|
766
|
-
(_unused, i) => (0.5 * (i + 1)) / (childCount + 1),
|
|
767
|
-
);
|
|
768
|
-
expect(fractions.every((f) => f > 0 && f < 0.5)).toBe(true);
|
|
769
|
-
// Still distinct and ordered, which is what makes them usable as rows.
|
|
770
|
-
expect(new Set(fractions).size).toBe(childCount);
|
|
771
|
-
expect([...fractions].sort((a, b) => a - b)).toEqual(fractions);
|
|
772
|
-
}
|
|
773
|
-
});
|
|
774
|
-
});
|