Files
hyperframes/packages/studio/src/player/hooks/useExpandedTimelineElements.test.ts
T
Miguel Ángel 2b65b4efce fix(studio): harden composition timeline reliability (#2615)
* fix(studio): preserve composition playback continuity

* feat(studio): drag compositions into the timeline

* fix(studio): collapse expanded composition move aliases

* fix(studio): make timeline cuts atomic

* fix(studio): group inspector gesture history

* test(studio): cover masked text selection

* fix(studio): harden composition timeline reliability

* fix(studio): satisfy CI source gates

* fix(studio): harden composition mutation requests
2026-07-17 14:15:30 -04:00

381 lines
13 KiB
TypeScript

import { describe, expect, it } from "vitest";
import {
buildExpandedElements,
resolveTimelineExpansionRawId,
} from "./useExpandedTimelineElements";
import { buildTimelineElementKey } from "../lib/timelineElementHelpers";
import type { TimelineElement } from "../store/playerStore";
import type { ClipManifestClip } from "../lib/playbackTypes";
const clip = (over: Partial<ClipManifestClip>): ClipManifestClip => ({
id: "x",
label: "x",
start: 0,
duration: 1,
track: 0,
kind: "element",
tagName: "div",
compositionId: null,
parentCompositionId: null,
compositionSrc: null,
assetUrl: null,
...over,
});
const el = (over: Partial<TimelineElement>): TimelineElement => ({
id: "x",
start: 0,
duration: 1,
track: 0,
tag: "div",
...over,
});
describe("buildExpandedElements", () => {
it("rebases a 1-level child onto its sub-comp host (start + sourceFile)", () => {
// host s3 at absolute 16 → stats-panel.html; children live in that file.
const elements = [el({ id: "s3", start: 16, duration: 7, compositionSrc: "stats.html" })];
const manifest = [
clip({ id: "s3", start: 16, duration: 7, compositionSrc: "stats.html" }),
clip({ id: "stat-1", start: 16.5, duration: 5 }),
clip({ id: "stat-2", start: 16.9, duration: 5 }),
];
const parentMap = new Map([
["stat-1", "s3"],
["stat-2", "s3"],
]);
const out = buildExpandedElements(elements, manifest, parentMap, "s3", "s3");
const child = out.find((e) => e.domId === "stat-1")!;
expect(child.expandedParentStart).toBe(16);
expect(child.expandedHostKey).toBe("s3");
expect(child.sourceFile).toBe("stats.html");
});
it("keeps repeated same-source composition hosts as distinct move identities", () => {
const elements = [
el({
id: "host-a",
key: "index.html#host-a",
start: 0,
duration: 5,
compositionSrc: "scene.html",
}),
el({
id: "host-b",
key: "index.html#host-b",
start: 8,
duration: 5,
compositionSrc: "scene.html",
}),
];
const manifest = [
clip({ id: "host-a", start: 0, duration: 5, compositionSrc: "scene.html" }),
clip({ id: "child-a", start: 1, duration: 2 }),
clip({ id: "host-b", start: 8, duration: 5, compositionSrc: "scene.html" }),
clip({ id: "child-b", start: 9, duration: 2 }),
];
const parentMap = new Map([
["child-a", "host-a"],
["child-b", "host-b"],
]);
const childA = buildExpandedElements(elements, manifest, parentMap, "host-a", "host-a").find(
(element) => element.domId === "child-a",
);
const childB = buildExpandedElements(elements, manifest, parentMap, "host-b", "host-b").find(
(element) => element.domId === "child-b",
);
expect(childA?.sourceFile).toBe("scene.html");
expect(childB?.sourceFile).toBe("scene.html");
expect(childA?.expandedHostKey).toBe("index.html#host-a");
expect(childB?.expandedHostKey).toBe("index.html#host-b");
});
// fallow-ignore-next-line code-duplication
it("rebases a 2-level child onto its NESTED host, not the top-level scene", () => {
// top host A@10 (a.html) embeds host B@12 (b.html); child C lives in b.html.
// Edits must rebase onto B (12 / b.html), not A (10 / a.html).
const elements = [el({ id: "A", start: 10, duration: 8, compositionSrc: "a.html" })];
const manifest = [
clip({ id: "A", start: 10, duration: 8, compositionSrc: "a.html" }),
clip({ id: "B", start: 12, duration: 4, compositionSrc: "b.html" }),
clip({ id: "C", start: 13, duration: 2 }),
clip({ id: "C2", start: 14, duration: 1 }),
];
const parentMap = new Map([
["B", "A"],
["C", "B"],
["C2", "B"],
]);
// Expanding C's siblings: topLevel A, immediate parent B.
const out = buildExpandedElements(elements, manifest, parentMap, "A", "B");
const child = out.find((e) => e.domId === "C")!;
expect(child.expandedParentStart).toBe(12); // B's start, not A's 10
expect(child.sourceFile).toBe("b.html"); // B's file, not a.html
});
// fallow-ignore-next-line code-duplication
it("rebases a 3-level child onto its deepest host, not intermediate or top", () => {
// A@10 (a.html) → B@12 (b.html) → C@13 (c.html); leaf D lives in c.html.
// Edits must rebase onto C (13 / c.html), not B (12 / b.html) or A (10 / a.html).
const elements = [el({ id: "A", start: 10, duration: 8, compositionSrc: "a.html" })];
const manifest = [
clip({ id: "A", start: 10, duration: 8, compositionSrc: "a.html" }),
clip({ id: "B", start: 12, duration: 5, compositionSrc: "b.html" }),
clip({ id: "C", start: 13, duration: 3, compositionSrc: "c.html" }),
clip({ id: "D", start: 13.5, duration: 1 }),
clip({ id: "D2", start: 14, duration: 1 }),
];
const parentMap = new Map([
["B", "A"],
["C", "B"],
["D", "C"],
["D2", "C"],
]);
// Expanding D's siblings: topLevel A, immediate parent C.
const out = buildExpandedElements(elements, manifest, parentMap, "A", "C");
const child = out.find((e) => e.domId === "D")!;
expect(child.expandedParentStart).toBe(13); // C's start, not B's 12 or A's 10
expect(child.sourceFile).toBe("c.html"); // C's file, not b.html or a.html
});
// Regression: an expanded child must share one identity (`key`) with the flat
// store element for the same DOM id. Before the fix the child key fell back to
// the colon form (`index.html:eyebrow:N`) while the store/selection used the
// hash form (`index.html#eyebrow`), so clicking an expanded child never
// highlighted it (isSelected compares the two keys).
it("keys expanded children in hash form, matching the flat store element", () => {
// Single composition (no sub-comps): scene `s1` with same-file children.
const elements = [el({ id: "s1", domId: "s1", start: 0, duration: 14 })];
const manifest = [
clip({ id: "s1", start: 0, duration: 14 }),
clip({ id: "eyebrow", start: 0, duration: 14 }),
clip({ id: "title", start: 0, duration: 14 }),
];
const parentMap = new Map([
["eyebrow", "s1"],
["title", "s1"],
]);
const out = buildExpandedElements(elements, manifest, parentMap, "s1", "s1");
const child = out.find((e) => e.domId === "eyebrow")!;
const expectedStoreKey = buildTimelineElementKey({
id: "eyebrow",
fallbackIndex: 0,
domId: "eyebrow",
selector: "#eyebrow",
sourceFile: undefined,
});
expect(expectedStoreKey).toBe("index.html#eyebrow");
expect(child.key).toBe("index.html#eyebrow");
expect(child.key).toBe(expectedStoreKey);
});
// Sub-comp internals (group + pills) have no data-start, so they're not in the
// manifest. They arrive as DOM children and must still expand under their host.
it("expands DOM-only sub-comp children (no manifest clip) under the host", () => {
const elements = [
el({ id: "scene-host", start: 5, duration: 6, compositionSrc: "scene.html" }),
];
const manifest = [
clip({ id: "scene-host", start: 5, duration: 6, compositionSrc: "scene.html" }),
];
// pill-3 selected → parent group-1 → host scene-host. None of group-1/pills
// are in the manifest; they're DOM children with parent links.
const parentMap = new Map([
["group-1", "scene-host"],
["pill-1", "group-1"],
["pill-2", "group-1"],
["pill-3", "group-1"],
]);
const domClipChildren = [
{
id: "group-1",
parentId: "scene-host",
hostId: "scene-host",
label: "Group 1",
stackingContextId: "css:0",
},
{
id: "pill-1",
parentId: "group-1",
hostId: "scene-host",
label: "pill-1",
stackingContextId: "css:0.0",
},
{
id: "pill-2",
parentId: "group-1",
hostId: "scene-host",
label: "pill-2",
stackingContextId: "css:0.1",
},
{
id: "pill-3",
parentId: "group-1",
hostId: "scene-host",
label: "pill-3",
stackingContextId: "css:0.1",
},
];
// Expanding pill-3's siblings: topLevel scene-host, immediate parent group-1.
const out = buildExpandedElements(
elements,
manifest,
parentMap,
"scene-host",
"group-1",
domClipChildren,
);
const pills = out.filter((e) => e.domId?.startsWith("pill-"));
expect(pills).toHaveLength(3);
// Children span the host's bounds and rebase onto the host's file.
expect(pills[0]!.start).toBe(5);
expect(pills[0]!.duration).toBe(6);
expect(pills[0]!.sourceFile).toBe("scene.html");
expect(pills.map((pill) => pill.stackingContextId)).toEqual(["css:0.0", "css:0.1", "css:0.1"]);
// The host row is replaced by its children.
expect(out.some((e) => e.domId === "scene-host")).toBe(false);
});
});
describe("resolveTimelineExpansionRawId", () => {
it("returns null when paused inside a childless top-level clip", () => {
const manifest = [clip({ id: "title", start: 0, duration: 4 })];
expect(
resolveTimelineExpansionRawId({
selectedElementId: null,
isPlaying: false,
currentTime: 2,
manifest,
parentMap: new Map(),
}),
).toBeNull();
});
it("auto-expands an active composition with children when paused and nothing is selected", () => {
const manifest = [
clip({ id: "scene", start: 1, duration: 5 }),
clip({ id: "headline", start: 1.5, duration: 2 }),
];
const parentMap = new Map([["headline", "scene"]]);
expect(
resolveTimelineExpansionRawId({
selectedElementId: null,
isPlaying: false,
currentTime: 2,
manifest,
parentMap,
}),
).toBe("scene");
});
it("auto-expands the innermost active nested composition when paused", () => {
const manifest = [
clip({ id: "outer", start: 0, duration: 10 }),
clip({ id: "inner", start: 2, duration: 5 }),
clip({ id: "leaf", start: 3, duration: 1 }),
];
const parentMap = new Map([
["inner", "outer"],
["leaf", "inner"],
]);
expect(
resolveTimelineExpansionRawId({
selectedElementId: null,
isPlaying: false,
currentTime: 3.5,
manifest,
parentMap,
}),
).toBe("inner");
});
it("does not auto-expand an active composition while playing", () => {
const manifest = [
clip({ id: "scene", start: 0, duration: 5 }),
clip({ id: "headline", start: 1, duration: 2 }),
];
const parentMap = new Map([["headline", "scene"]]);
expect(
resolveTimelineExpansionRawId({
selectedElementId: null,
isPlaying: true,
currentTime: 2,
manifest,
parentMap,
}),
).toBeNull();
});
it("keeps selected elements ahead of paused active composition auto-expansion", () => {
const manifest = [
clip({ id: "scene", start: 0, duration: 6 }),
clip({ id: "headline", start: 1, duration: 2 }),
clip({ id: "caption", start: 4, duration: 1 }),
];
const parentMap = new Map([
["headline", "scene"],
["caption", "scene"],
]);
expect(
resolveTimelineExpansionRawId({
selectedElementId: "caption",
isPlaying: false,
currentTime: 1.5,
manifest,
parentMap,
}),
).toBe("caption");
});
});
describe("buildExpandedElements — collision-free synthetic rows (cross-file lane safety)", () => {
it("expanded children NEVER share a display track with an unrelated top-level clip", () => {
// The Deepwork regression: host on track 0 with two children used to put
// child #2 on integer track 1 — the same lane as index.html#foreign. Lane
// grouping merges purely by track number, so a gap-close on that "one"
// lane batch-persisted a foreign file's clip.
const elements = [
el({ id: "host", start: 0, duration: 20, track: 0, compositionSrc: "scene.html" }),
el({ id: "foreign", start: 20, duration: 5, track: 1 }),
];
const manifest = [
clip({ id: "host", start: 0, duration: 20, compositionSrc: "scene.html" }),
clip({ id: "c1", start: 0, duration: 5 }),
clip({ id: "c2", start: 10, duration: 5 }),
];
const parentMap = new Map([
["c1", "host"],
["c2", "host"],
]);
const out = buildExpandedElements(elements, manifest, parentMap, "host", "host");
const foreign = out.find((e) => e.id === "foreign")!;
const children = out.filter((e) => e.domId === "c1" || e.domId === "c2");
expect(children).toHaveLength(2);
for (const child of children) {
// No lane sharing with the foreign clip…
expect(child.track).not.toBe(foreign.track);
// …and structurally impossible to collide with ANY normalized (integer)
// lane: synthetic rows are strict fractions under the host's lane.
expect(Number.isInteger(child.track)).toBe(false);
expect(child.track).toBeGreaterThan(0);
expect(child.track).toBeLessThan(1);
}
// Distinct ordered rows per child.
expect(children[0].track).not.toBe(children[1].track);
});
});