Files
hyperframes/packages/studio/src/hooks/timelineMoveAdapter.test.ts
T
ukimsanov 19139b91ed fix(studio): make vertical lane moves persist correctly and harden the z/lane pipeline
Vertical clip moves committed in the store but never survived: two persist
bugs plus a runtime renumber all fought the stable-track-lanes model.

- timelineMoveAdapter deliberately stripped the track from lane-reorder
  persists ('z-only reorder path' — the old z-driven lane model). Lane =
  authored data-track-index now: lane-reorder and track-insert both persist
  the track; plain timing moves omit it to stay SDK-fast-path eligible.
- Display lanes and file tracks are different coordinate spaces:
  normalizeToZones packs sparse authored tracks (1,2,... or gaps, or DOM-index
  fallbacks) onto contiguous display lanes, and lane edits persisted the LANE
  number — silently re-targeting the wrong row in any non-0-contiguous file.
  Elements now record their authoredTrack when remapped; a lane change
  persists the target lane's authored track (store stays in lane space).
- The runtime split same-track clips of different kinds (video vs caption
  div) onto separate renumbered tracks at discovery, so authored indices
  never round-tripped ('drop onto an existing track' bounced back). Removed:
  data-track-index is honored verbatim (render never reads it); kind-based
  row presentation belongs in the display layer if ever wanted.

Adversarial review fixes on the same pipeline:
- runtime: parseInt(attr) || fallback dropped authored track 0 for GSAP and
  overlay clips (parseAuthoredTrack helper honors 0)
- single-clip move fallback persisted only data-start — lane changes snapped
  back on reload (now passes the track to the patch builder)
- lane-change z-sync candidate ignored a multi-selection's time shift, so
  patches were computed against stale overlap sets
- track insert around a locked clip persisted a colliding renumber (the next
  normalize merged lanes); the insert is now refused with a warning
- computeStackingPatches compared leaf z across CSS stacking contexts, where
  ancestor z decides paint order; the sync now partitions by
  stackingContextId and never patches across contexts

Timeline geometry (user-reported):
- fit zoom leaves 20% trailing headroom (FIT_ZOOM_HEADROOM in
  timelineLayout.ts; single fit-pps source, so ruler/lanes/playhead/drag all
  inherit it)
- playhead line center now sits exactly on GUTTER + t*pps at every zoom
  (wrapper had shrink-wrapped to the 9px diamond, off-centering the line);
  ruler ticks center on their timestamp
- ruler: frame-mode steps snap to whole frames (no duplicate labels), hour
  steps added for far zoom-out, tick positions computed as exact multiples
  (no float drift)
2026-07-13 16:48:52 -07:00

82 lines
3.3 KiB
TypeScript

import { describe, expect, it, vi } from "vitest";
import type { TimelineElement } from "../player";
import { persistTimelineMoveEditsAtomically } from "./timelineMoveAdapter";
type MoveArgs = Parameters<typeof persistTimelineMoveEditsAtomically>;
const element = (id: string, track: number): TimelineElement => ({
id,
key: id,
tag: "div",
start: 0,
duration: 2,
track,
});
const twoLaneEdits = (bTrack: number): MoveArgs[0] => [
{ element: element("a", 0), updates: { start: 1, track: 1 } },
{ element: element("b", bTrack), updates: { start: 3, track: 2 } },
];
const movedPair = (edits: MoveArgs[0]) => [
{ element: edits[0].element, start: 1, track: 1 },
{ element: edits[1].element, start: 3, track: 2 },
];
const runMove = async (edits: MoveArgs[0], coalesceKey: MoveArgs[1], intent: MoveArgs[2]) => {
const handleTimelineGroupMove = vi.fn().mockResolvedValue(undefined);
await persistTimelineMoveEditsAtomically(edits, coalesceKey, intent, {
handleTimelineGroupMove,
});
return handleTimelineGroupMove;
};
describe("persistTimelineMoveEditsAtomically", () => {
it("persists two vertical edits as one group with the gesture coalesce key", async () => {
const edits = twoLaneEdits(1);
const handleTimelineGroupMove = await runMove(edits, "clip-lane-move:7", "track-insert");
expect(handleTimelineGroupMove).toHaveBeenCalledTimes(1);
expect(handleTimelineGroupMove).toHaveBeenCalledWith(movedPair(edits), {
coalesceKey: "clip-lane-move:7",
});
});
it("omits track attrs for plain timing moves (keeps the SDK fast path eligible)", async () => {
const edit = { element: element("a", 0), updates: { start: 1, track: 0 } };
const handleTimelineGroupMove = await runMove([edit], undefined, "timing");
expect(handleTimelineGroupMove).toHaveBeenCalledWith([{ element: edit.element, start: 1 }], {
coalesceKey: undefined,
});
});
it("persists the track attr for a single lane reorder (stable track lanes)", async () => {
// Lane = authored data-track-index; a vertical move that never hits disk
// snaps back on the next normalize, so the lane change MUST persist.
const edit = { element: element("a", 0), updates: { start: 1, track: 1 } };
const handleTimelineGroupMove = await runMove([edit], "clip-lane-move:7", "lane-reorder");
expect(handleTimelineGroupMove).toHaveBeenCalledWith(
[{ element: edit.element, start: 1, track: 1 }],
{ coalesceKey: "clip-lane-move:7" },
);
});
it("persists track attrs for a multi-selection lane drag (stable track lanes)", async () => {
const edits = twoLaneEdits(2);
const handleTimelineGroupMove = await runMove(edits, "clip-lane-move:7", "lane-reorder");
expect(handleTimelineGroupMove).toHaveBeenCalledWith(movedPair(edits), {
coalesceKey: "clip-lane-move:7",
});
});
it("rejects without retrying individual members when the atomic batch fails", async () => {
const failure = new Error("batch failed");
const handleTimelineGroupMove = vi.fn().mockRejectedValue(failure);
await expect(
persistTimelineMoveEditsAtomically(twoLaneEdits(1), "clip-lane-move:7", "track-insert", {
handleTimelineGroupMove,
}),
).rejects.toBe(failure);
expect(handleTimelineGroupMove).toHaveBeenCalledTimes(1);
});
});