Files
hyperframes/packages/studio/src/player/components/timelineTrackOrder.test.ts
T
Miguel Angel Simon Sierra eb6a311d82 fix(studio): make a timeline lane a z-band so a vertical restack moves the row
Clips share a lane only when they carry the same z-index and don't overlap
in time. Previously a non-overlapping clip packed into the first
time-compatible row regardless of z, so restacking changed the z-index but
the clip's row never followed.
2026-07-08 23:46:29 -04:00

145 lines
5.4 KiB
TypeScript

import { describe, expect, it } from "vitest";
import type { TimelineElement } from "../store/playerStore";
import { buildStackingTimelineLayers, insertPreviewTrackOrder } from "./timelineTrackOrder";
// fallow-ignore-next-line complexity
function rowElement(input: {
id: string;
track?: number;
zIndex?: number;
hasExplicitZIndex?: boolean;
start?: number;
duration?: number;
tag?: string;
stackingContextId?: string | null;
parentCompositionId?: string | null;
compositionAncestors?: string[];
}): TimelineElement {
return {
id: input.id,
tag: input.tag ?? "div",
start: input.start ?? 0,
duration: input.duration ?? 1,
track: input.track ?? 0,
zIndex: input.zIndex ?? 0,
hasExplicitZIndex: input.hasExplicitZIndex ?? true,
stackingContextId: input.stackingContextId ?? "root",
parentCompositionId: input.parentCompositionId ?? null,
compositionAncestors: input.compositionAncestors ?? ["root"],
};
}
function rowIds(rows: readonly { elements: readonly TimelineElement[] }[]): string[][] {
return rows.map((row) => row.elements.map((element) => element.id));
}
describe("buildStackingTimelineLayers", () => {
it("splits non-overlapping clips into separate lanes when their z-index differs", () => {
// A lane is a z-band: differing z must land on different rows even when the
// clips don't overlap in time, so a vertical (z) restack actually moves the
// clip's row. Rows are ordered by descending z.
const result = buildStackingTimelineLayers([
rowElement({ id: "back", zIndex: 1, start: 0, duration: 1 }),
rowElement({ id: "front", zIndex: 10, start: 1, duration: 1 }),
]);
expect(rowIds(result.visualLayers)).toEqual([["front"], ["back"]]);
expect(result.visualLayers[0]?.zIndex).toBe(10);
});
it("packs non-overlapping clips into one lane when they share a z-index", () => {
const result = buildStackingTimelineLayers([
rowElement({ id: "back", zIndex: 5, start: 0, duration: 1 }),
rowElement({ id: "front", zIndex: 5, start: 1, duration: 1 }),
]);
expect(rowIds(result.visualLayers)).toEqual([["back", "front"]]);
expect(result.visualLayers[0]?.zIndex).toBe(5);
});
it("splits clips into separate lanes when they overlap in time", () => {
const result = buildStackingTimelineLayers([
rowElement({ id: "front", zIndex: 10, start: 0, duration: 2 }),
rowElement({ id: "back", zIndex: 1, start: 1, duration: 2 }),
]);
expect(rowIds(result.visualLayers)).toEqual([["front"], ["back"]]);
});
it("uses DOM order to break stacking ties before lane packing", () => {
const result = buildStackingTimelineLayers([
rowElement({ id: "first", track: 2, zIndex: 5, start: 0, duration: 2 }),
rowElement({ id: "second", track: 0, zIndex: 5, start: 1, duration: 2 }),
]);
expect(rowIds(result.visualLayers)).toEqual([["first"], ["second"]]);
});
it("packs auto-z clips by time instead of forcing one row per clip", () => {
const result = buildStackingTimelineLayers([
rowElement({ id: "a", zIndex: 0, hasExplicitZIndex: false, start: 0, duration: 1 }),
rowElement({ id: "b", zIndex: 0, hasExplicitZIndex: false, start: 1, duration: 1 }),
]);
expect(rowIds(result.visualLayers)).toEqual([["a", "b"]]);
});
it("does not merge equal z-index clips across stacking contexts", () => {
const result = buildStackingTimelineLayers([
rowElement({ id: "root", zIndex: 4, start: 0, duration: 1 }),
rowElement({
id: "nested",
zIndex: 4,
start: 1,
duration: 1,
stackingContextId: "scene",
parentCompositionId: "scene",
compositionAncestors: ["root", "scene"],
}),
]);
expect(rowIds(result.visualLayers)).toEqual([["root"], ["nested"]]);
});
it("returns audio clips as separate bottom rows without merging them into z layers", () => {
const result = buildStackingTimelineLayers([
rowElement({ id: "front", zIndex: 10 }),
rowElement({ id: "music-a", tag: "audio", track: 4 }),
rowElement({ id: "music-b", tag: "audio", track: 2 }),
]);
expect(rowIds(result.visualLayers)).toEqual([["front"]]);
expect(rowIds(result.audioLayers)).toEqual([["music-a"], ["music-b"]]);
expect(rowIds(result.rows)).toEqual([["front"], ["music-a"], ["music-b"]]);
});
it("orders rows by descending z-index with auto-z clips ranked at computed zero", () => {
const result = buildStackingTimelineLayers([
rowElement({ id: "auto-a", zIndex: 0, hasExplicitZIndex: false }),
rowElement({ id: "back", zIndex: -1 }),
rowElement({ id: "front", zIndex: 10 }),
rowElement({ id: "auto-b", zIndex: 0, hasExplicitZIndex: false }),
]);
expect(rowIds(result.visualLayers)).toEqual([["front"], ["auto-a"], ["auto-b"], ["back"]]);
});
it("keeps a row key stable when a clip's z-index changes but membership does not", () => {
const before = buildStackingTimelineLayers([rowElement({ id: "hero", zIndex: 1 })]);
const after = buildStackingTimelineLayers([rowElement({ id: "hero", zIndex: 20 })]);
expect(after.visualLayers[0]?.id).toBe(before.visualLayers[0]?.id);
});
});
describe("insertPreviewTrackOrder", () => {
it("inserts preview layer ids by target row index", () => {
expect(insertPreviewTrackOrder(["a", "b", "c"], "preview", 1)).toEqual([
"a",
"preview",
"b",
"c",
]);
});
});