Files
hyperframes/packages/studio/src/player/components/timelineGroupEditing.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

137 lines
5.5 KiB
TypeScript

import { describe, expect, it } from "vitest";
import type { TimelineElement } from "../store/playerStore";
import {
buildTimelineGroupResizeMembers,
resolveTimelineGroupResize,
resolveTimelineGroupResizeChanges,
} from "./timelineGroupEditing";
function el(id: string, over: Partial<TimelineElement> = {}): TimelineElement {
// domId gives a patchable target so getTimelineEditCapabilities().canTrim* is true.
return { id, key: id, tag: "video", start: 0, duration: 2, track: 0, domId: id, ...over };
}
function keys(...ids: string[]): ReadonlySet<string> {
return new Set(ids);
}
describe("buildTimelineGroupResizeMembers (legacy 36413da7f semantics)", () => {
it("returns null for a single-clip selection (no group forms)", () => {
const a = el("a");
expect(buildTimelineGroupResizeMembers([a], keys("a"), "a", "end")).toBeNull();
expect(buildTimelineGroupResizeMembers([a], new Set(), "a", "end")).toBeNull();
});
it("returns null when the grabbed clip is not part of the selection", () => {
const a = el("a");
const b = el("b");
expect(buildTimelineGroupResizeMembers([a, b], keys("a", "b"), "c", "end")).toBeNull();
});
it("degrades to single-clip (null) when ANY member is locked — locked clip never patched", () => {
const a = el("a");
const locked = el("b", { timelineLocked: true });
expect(buildTimelineGroupResizeMembers([a, locked], keys("a", "b"), "a", "end")).toBeNull();
});
it("degrades when a member is implicitly timed or has no patch target", () => {
const a = el("a");
const implicit = el("b", { timingSource: "implicit" });
const noTarget = el("c", { domId: undefined, selector: undefined });
expect(buildTimelineGroupResizeMembers([a, implicit], keys("a", "b"), "a", "end")).toBeNull();
expect(buildTimelineGroupResizeMembers([a, noTarget], keys("a", "c"), "a", "end")).toBeNull();
});
it("snapshots members and seeds start-edge media playbackStart to 0", () => {
const grabbed = el("a", { start: 2, duration: 2 });
const audio = el("b", { tag: "audio", start: 5, duration: 3 }); // playbackStart undefined
const members = buildTimelineGroupResizeMembers([grabbed, audio], keys("a", "b"), "a", "start");
expect(members).not.toBeNull();
expect(members!.map((m) => [m.key, m.start, m.duration, m.playbackStart])).toEqual([
["a", 2, 2, 0], // start-edge media seeds playbackStart to 0
["b", 5, 3, 0],
]);
});
it("seeds legacy composition offsets and advances them at playback rate", () => {
const a = el("a", { kind: "composition", tag: "div", start: 2, playbackRate: 2 });
const b = el("b", { kind: "composition", tag: "div", start: 5, playbackRate: 0.5 });
const members = buildTimelineGroupResizeMembers([a, b], keys("a", "b"), "a", "start")!;
expect(members.map((member) => member.playbackStart)).toEqual([0, 0]);
const changes = resolveTimelineGroupResizeChanges(members, "start", 1);
expect(changes.map((change) => change.playbackStart)).toEqual([2, 0.5]);
});
it("does not seed playbackStart on the END edge", () => {
const grabbed = el("a", { tag: "audio", start: 0, duration: 2 });
const b = el("b", { tag: "audio", start: 3, duration: 2 });
const members = buildTimelineGroupResizeMembers([grabbed, b], keys("a", "b"), "a", "end");
expect(members!.every((m) => m.playbackStart === undefined)).toBe(true);
});
});
describe("resolveTimelineGroupResizeChanges (rigid group patch set)", () => {
it("END edge: extends every member's duration by the shared delta", () => {
const members = buildTimelineGroupResizeMembers(
[el("a", { duration: 2 }), el("b", { duration: 3 })],
keys("a", "b"),
"a",
"end",
)!;
const changes = resolveTimelineGroupResizeChanges(members, "end", 0.5);
expect(changes.map((c) => [c.key, c.start, c.duration])).toEqual([
["a", 0, 2.5],
["b", 0, 3.5],
]);
});
it("END edge: rigid — the most-constrained member clamps the whole group", () => {
// b (0.2s) can only shrink 0.1 before hitting min duration, so the group as a
// whole shrinks 0.1 even though the grabbed clip asked for -0.5.
const members = buildTimelineGroupResizeMembers(
[el("a", { duration: 2 }), el("b", { duration: 0.2 })],
keys("a", "b"),
"a",
"end",
)!;
const changes = resolveTimelineGroupResizeChanges(members, "end", -0.5);
expect(changes.map((c) => c.duration)).toEqual([1.9, 0.1]);
});
it("START edge: shifts start + duration together across the group", () => {
const members = buildTimelineGroupResizeMembers(
[
el("a", { tag: "text", start: 2, duration: 2 }),
el("b", { tag: "text", start: 5, duration: 3 }),
],
keys("a", "b"),
"a",
"start",
)!;
const changes = resolveTimelineGroupResizeChanges(members, "start", -0.5);
expect(changes.map((c) => [c.key, c.start, c.duration])).toEqual([
["a", 1.5, 2.5],
["b", 4.5, 3.5],
]);
});
it("produces exactly the resolveTimelineGroupResize output, keyed per member", () => {
const members = buildTimelineGroupResizeMembers(
[el("a", { duration: 2 }), el("b", { duration: 4 })],
keys("a", "b"),
"a",
"end",
)!;
const raw = resolveTimelineGroupResize(members, "end", 0.75);
const changes = resolveTimelineGroupResizeChanges(members, "end", 0.75);
expect(
changes.map((c) => ({
start: c.start,
duration: c.duration,
playbackStart: c.playbackStart,
})),
).toEqual(raw.members);
});
});