mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 23:03:09 +00:00
* feat(studio): timeline collision and placement model What: new pure module timelineCollision — zone-aware drop placement (clampTrackToZone, resolveZoneDropPlacement, resolveInsertRow, resolvePlacement, lane/overlap predicates) with its full test suite. Why: the no-overlap core of the NLE clip-drag engine; plain functions, no DOM, no React, no store writes. How: new files only; type-only imports from the existing playerStore. First runtime consumer arrives with the drag-engine PRs. Test plan: bunx vitest run timelineCollision.test.ts; tsc --noEmit; fallow audit clean (all exports test-consumed). * feat(studio): timeline magnetic snapping What: new pure module timelineSnapping — snap-target collection and pixel-threshold time snapping (collectTimelineSnapTargets, snapTimelineTime, snapMoveToTargets) with tests. Why: the magnet math for clip drags/trims, reviewable standalone. How: new files only; type-only playerStore imports; consumers land with the drag engine. Test plan: bunx vitest run timelineSnapping.test.ts; tsc --noEmit; fallow audit clean. * feat(studio): multi-clip drag preview math What: new pure module timelineMultiDragPreview — group-drag passenger offsets and clamped group deltas (isMultiDragActive, multiDragDeltaSeconds, multiDragPassengerOffsetPx, clampGroupMoveDelta) with tests. Why: the group-drag math, standalone and DOM-free. How: new files only; consumed later by TimelineLanes. Test plan: bunx vitest run timelineMultiDragPreview.test.ts; tsc --noEmit; fallow audit clean. * feat(studio): timeline z-stacking sync model What: new pure module timelineStackingSync — lane order ↔ z-index reconciliation (laneIsAbove, computeStackingPatches) with tests. Why: the single source of truth for how timeline lane order maps to canvas stacking; the ordering rules and tie-breaks live here. How: new files only; consumed later by timelineZones and the stacking-sync hook. Test plan: bunx vitest run timelineStackingSync.test.ts; tsc --noEmit; fallow audit clean. * feat(studio): timeline lane-zone model What: new pure module timelineZones — visual/audio track-zone classification (classifyZone) and normalizeToZones, which re-packs lanes into zone-consistent rows; tests cover the stacking/zones interaction. Why: completes the z-model started in the stacking-sync PR. How: new files; consumes isAudioTimelineElement (leaf-helpers PR) and computeStackingPatches (stacking-sync PR); type-only playerStore imports. Test plan: bunx vitest run timelineZones.test.ts; tsc --noEmit; fallow audit clean. * feat(studio): asset click policy and canvas nudge gate What: two small pure modules with tests — assetClickBehavior (click vs double-click policy for sidebar assets) and canvasNudgeGate (debounce gate for arrow-key canvas nudges). Why: policy dependencies of the upcoming asset card and nudge hook, reviewable as plain decision tables. How: new files only. Test plan: bunx vitest run on both test files; tsc --noEmit; fallow audit clean. * test(studio): characterization suites for resize commit and razor history What: two test-only suites pinning CURRENT behavior before the NLE swap: anchoredResizeReleaseShift.test.ts (manual-offset resize release commits) and useRazorSplit.history.test.tsx (razor split undo/redo history). Why: regression tripwires — the later glue-swap PRs must keep these green. How: test files only; they import existing main modules unchanged and pass against them as-is. Test plan: bunx vitest run on both suites; fallow audit clean. --------- Co-authored-by: ukimsanov <ular.kimsanov@heygen.com>
This commit is contained in:
co-authored by
ukimsanov
parent
8e5b18f740
commit
c6a508a9bc
@@ -0,0 +1,173 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import React, { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { TimelineElement } from "../player";
|
||||
import { useRazorSplit } from "./useRazorSplit";
|
||||
import { createPersistentEditHistoryStore } from "./usePersistentEditHistory";
|
||||
import { createMemoryEditHistoryStorage } from "../utils/editHistoryStorage";
|
||||
import { createEmptyEditHistory } from "../utils/editHistory";
|
||||
|
||||
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
const ORIGINAL = `<div class="clip" id="clip1" data-start="0" data-duration="4">hi</div>`;
|
||||
const SPLIT =
|
||||
`<div class="clip" id="clip1" data-start="0" data-duration="2">hi</div>` +
|
||||
`<div class="clip" id="clip1-split" data-start="2" data-duration="2">hi</div>`;
|
||||
|
||||
const element: TimelineElement = {
|
||||
id: "clip1",
|
||||
tag: "div",
|
||||
start: 0,
|
||||
duration: 4,
|
||||
track: 0,
|
||||
domId: "clip1",
|
||||
sourceFile: "index.html",
|
||||
timingSource: "authored",
|
||||
};
|
||||
|
||||
type Split = (element: TimelineElement, splitTime: number) => Promise<void>;
|
||||
|
||||
interface Harness {
|
||||
disk: Record<string, string>;
|
||||
store: ReturnType<typeof createPersistentEditHistoryStore>;
|
||||
splitRef: { current: Split | undefined };
|
||||
root: ReturnType<typeof createRoot>;
|
||||
expected: string;
|
||||
}
|
||||
|
||||
const SPLIT_GSAP = SPLIT.replace(
|
||||
"</div>",
|
||||
"</div><script>window.__timelines={};const tl=gsap.timeline({paused:true});" +
|
||||
'tl.set("#clip1-split",{x:0},2);window.__timelines["c"]=tl;</script>',
|
||||
);
|
||||
|
||||
function mountRazorSplit(opts: { gsap?: boolean } = {}): Harness {
|
||||
const disk: Record<string, string> = { "index.html": ORIGINAL };
|
||||
const finalContent = opts.gsap ? SPLIT_GSAP : SPLIT;
|
||||
|
||||
const storage = createMemoryEditHistoryStorage();
|
||||
const store = createPersistentEditHistoryStore({
|
||||
projectId: "p1",
|
||||
storage,
|
||||
initialState: createEmptyEditHistory(),
|
||||
now: (() => {
|
||||
let t = 1000;
|
||||
return () => (t += 10);
|
||||
})(),
|
||||
onChange: () => {},
|
||||
});
|
||||
|
||||
// Faithful stand-in for the studio-server file-mutation endpoints: the server
|
||||
// writes the split to disk itself, then returns the patched content.
|
||||
const fetchMock = vi.fn(async (url: string, init?: RequestInit) => {
|
||||
const u = String(url);
|
||||
if (u.includes("/gsap-mutations/")) {
|
||||
if (opts.gsap) {
|
||||
// Mirror the server: rewrites the GSAP script for the new id, writes to
|
||||
// disk, returns the final content.
|
||||
disk["index.html"] = SPLIT_GSAP;
|
||||
return new Response(JSON.stringify({ ok: true, after: SPLIT_GSAP }), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
// The fixture has no GSAP script — mirror the server's 400 response.
|
||||
return new Response(JSON.stringify({ error: "no GSAP script found in file" }), {
|
||||
status: 400,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
if (u.includes("/file-mutations/split-element/")) {
|
||||
disk["index.html"] = SPLIT;
|
||||
return new Response(
|
||||
JSON.stringify({ ok: true, changed: true, content: SPLIT, newId: "clip1-split" }),
|
||||
{ status: 200, headers: { "Content-Type": "application/json" } },
|
||||
);
|
||||
}
|
||||
if (u.includes("/files/")) {
|
||||
return new Response(JSON.stringify({ content: disk["index.html"] }), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
void init;
|
||||
throw new Error(`unexpected fetch: ${u}`);
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const splitRef: { current: Split | undefined } = { current: undefined };
|
||||
|
||||
function Component() {
|
||||
const { handleRazorSplit } = useRazorSplit({
|
||||
projectId: "p1",
|
||||
activeCompPath: "index.html",
|
||||
showToast: () => {},
|
||||
writeProjectFile: async (path, content) => {
|
||||
disk[path] = content;
|
||||
},
|
||||
recordEdit: store.recordEdit,
|
||||
domEditSaveTimestampRef: { current: 0 },
|
||||
reloadPreview: () => {},
|
||||
});
|
||||
splitRef.current = handleRazorSplit;
|
||||
return null;
|
||||
}
|
||||
|
||||
const host = document.createElement("div");
|
||||
document.body.append(host);
|
||||
const root = createRoot(host);
|
||||
act(() => {
|
||||
root.render(<Component />);
|
||||
});
|
||||
|
||||
return { disk, store, splitRef, root, expected: finalContent };
|
||||
}
|
||||
|
||||
async function undoViaDisk(harness: Harness) {
|
||||
return harness.store.undo({
|
||||
readFile: async (path) => harness.disk[path],
|
||||
writeFile: async (path, content) => {
|
||||
harness.disk[path] = content;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
document.body.innerHTML = "";
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe("useRazorSplit — split is undoable via edit history", () => {
|
||||
for (const gsap of [false, true]) {
|
||||
describe(gsap ? "with GSAP rewrite" : "plain HTML split", () => {
|
||||
let harness: Harness;
|
||||
beforeEach(() => {
|
||||
harness = mountRazorSplit({ gsap });
|
||||
});
|
||||
afterEach(() => {
|
||||
act(() => harness.root.unmount());
|
||||
});
|
||||
|
||||
it("records a single 'Split timeline clip' history entry that undo restores", async () => {
|
||||
await act(async () => {
|
||||
await harness.splitRef.current!(element, 2);
|
||||
});
|
||||
|
||||
// The split reached disk.
|
||||
expect(harness.disk["index.html"]).toBe(harness.expected);
|
||||
|
||||
// The split must be the top of the undo stack — not a prior/other entry.
|
||||
expect(harness.store.snapshot().canUndo).toBe(true);
|
||||
expect(harness.store.snapshot().undoLabel).toBe("Split timeline clip");
|
||||
|
||||
// Undo restores the exact pre-split file.
|
||||
const result = await undoViaDisk(harness);
|
||||
expect(result.ok).toBe(true);
|
||||
expect(result.label).toBe("Split timeline clip");
|
||||
expect(harness.disk["index.html"]).toBe(ORIGINAL);
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user