Skip to content

Commit 74d5592

Browse files
committed
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.
1 parent 4c8b88c commit 74d5592

2 files changed

Lines changed: 285 additions & 0 deletions

File tree

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
// @vitest-environment jsdom
2+
import { afterEach, describe, expect, it } from "vitest";
3+
import {
4+
applyStudioBoxSize,
5+
applyStudioPathOffset,
6+
readStudioBoxSize,
7+
reapplyPositionEditsAfterSeek,
8+
} from "./manualEditsDom";
9+
import { buildBoxSizePatches, buildPathOffsetPatches } from "./manualEditsDomPatches";
10+
import { createManualOffsetDragMember, applyManualOffsetDragCommit } from "./manualOffsetDrag";
11+
import type { PatchOperation } from "../../utils/sourcePatcher";
12+
import { splitTopLevelWhitespace } from "./manualEditsStyleHelpers";
13+
14+
/**
15+
* Center-anchored corner resize (CapCut model): the element scales about its
16+
* CENTER, which must stay planted across the whole gesture — including after
17+
* release, on every corner and at any rotation.
18+
*
19+
* This file lands here with ONLY the persist round-trip test, which exercises the
20+
* real apply → persist → reload symbols that already exist at this point in the
21+
* stack. The two center-anchor CONVERGENCE tests (the release-shift root cause)
22+
* drive the exported `computeNextResizeAnchor` accumulator, which is extracted from
23+
* the resize pointermove branch of `useDomEditOverlayGestures.ts`. That gesture code
24+
* lands later in the stack (with the canvas glue swap), so those two tests are added
25+
* to this file at that point — importing the real production helper rather than a
26+
* test-local copy, so they can never pass against a stand-in that drifts from the
27+
* shipped math.
28+
*/
29+
30+
afterEach(() => {
31+
document.body.innerHTML = "";
32+
});
33+
34+
/** Apply a built PatchOperation[] to a live element, mirroring sourcePatcher's
35+
* inline-style / attribute application — i.e. what the persisted source carries
36+
* when it is re-parsed into the DOM on the next preview load. */
37+
function applyPatchesToElement(el: HTMLElement, ops: PatchOperation[]): void {
38+
for (const op of ops) {
39+
if (op.type === "inline-style") {
40+
if (op.value === null) el.style.removeProperty(op.property);
41+
else el.style.setProperty(op.property, op.value);
42+
} else if (op.type === "attribute") {
43+
if (op.value === null) el.removeAttribute(op.property);
44+
else el.setAttribute(op.property, op.value);
45+
}
46+
}
47+
}
48+
49+
/** Net translate applied to an element, resolving the studio offset var()
50+
* expression to its px value so we compare the actually-rendered translation. */
51+
function resolvedTranslatePx(el: HTMLElement): { x: number; y: number } {
52+
const raw = el.style.getPropertyValue("translate").trim();
53+
if (!raw || raw === "none") return { x: 0, y: 0 };
54+
const vx = Number.parseFloat(el.style.getPropertyValue("--hf-studio-offset-x")) || 0;
55+
const vy = Number.parseFloat(el.style.getPropertyValue("--hf-studio-offset-y")) || 0;
56+
const parts = splitTopLevelWhitespace(raw);
57+
const parseAxis = (part: string, varVal: number): number => {
58+
if (part && part.includes("--hf-studio-offset")) return varVal;
59+
const n = Number.parseFloat(part);
60+
return Number.isFinite(n) ? n : 0;
61+
};
62+
return {
63+
x: parseAxis(parts[0] ?? "", vx),
64+
y: parseAxis(parts[1] ?? "", vy),
65+
};
66+
}
67+
68+
describe("center-anchored corner resize — no shift after release", () => {
69+
it("net translate after persist+reload equals the committed anchor offset (non-GSAP)", () => {
70+
// The committed offset flows through the real apply → persist → reload chain
71+
// unchanged (this hop was proved clean; the shift is upstream in the anchor
72+
// loop, tested with the gesture code, not in persistence).
73+
const el = document.createElement("div");
74+
el.style.setProperty("width", "200px");
75+
el.style.setProperty("height", "100px");
76+
document.body.appendChild(el);
77+
78+
const anchorDx = -30;
79+
const anchorDy = -18;
80+
const finalSize = { width: 240, height: 130 };
81+
82+
applyStudioBoxSize(el, finalSize);
83+
const memberResult = createManualOffsetDragMember({
84+
key: "k",
85+
selection: { element: el } as never,
86+
element: el,
87+
rect: { left: 0, top: 0, width: 240, height: 130, editScaleX: 1, editScaleY: 1 },
88+
});
89+
expect(memberResult.ok).toBe(true);
90+
if (!memberResult.ok) return;
91+
92+
const finalOffset = applyManualOffsetDragCommit(memberResult.member, anchorDx, anchorDy);
93+
94+
applyStudioBoxSize(el, finalSize);
95+
const patches = buildBoxSizePatches(el);
96+
applyStudioPathOffset(el, finalOffset);
97+
patches.push(...buildPathOffsetPatches(el));
98+
99+
expect(resolvedTranslatePx(el)).toEqual({ x: anchorDx, y: anchorDy });
100+
101+
// Persist → fresh element re-parsed from source → reload re-stamp.
102+
const reloaded = document.createElement("div");
103+
reloaded.style.setProperty("width", "200px");
104+
reloaded.style.setProperty("height", "100px");
105+
document.body.appendChild(reloaded);
106+
applyPatchesToElement(reloaded, patches);
107+
reapplyPositionEditsAfterSeek(reloaded.ownerDocument);
108+
109+
expect(resolvedTranslatePx(reloaded)).toEqual({ x: anchorDx, y: anchorDy });
110+
expect(readStudioBoxSize(reloaded)).toEqual(finalSize);
111+
});
112+
});
Lines changed: 173 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,173 @@
1+
// @vitest-environment happy-dom
2+
3+
import React, { act } from "react";
4+
import { createRoot } from "react-dom/client";
5+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
6+
import type { TimelineElement } from "../player";
7+
import { useRazorSplit } from "./useRazorSplit";
8+
import { createPersistentEditHistoryStore } from "./usePersistentEditHistory";
9+
import { createMemoryEditHistoryStorage } from "../utils/editHistoryStorage";
10+
import { createEmptyEditHistory } from "../utils/editHistory";
11+
12+
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
13+
14+
const ORIGINAL = `<div class="clip" id="clip1" data-start="0" data-duration="4">hi</div>`;
15+
const SPLIT =
16+
`<div class="clip" id="clip1" data-start="0" data-duration="2">hi</div>` +
17+
`<div class="clip" id="clip1-split" data-start="2" data-duration="2">hi</div>`;
18+
19+
const element: TimelineElement = {
20+
id: "clip1",
21+
tag: "div",
22+
start: 0,
23+
duration: 4,
24+
track: 0,
25+
domId: "clip1",
26+
sourceFile: "index.html",
27+
timingSource: "authored",
28+
};
29+
30+
type Split = (element: TimelineElement, splitTime: number) => Promise<void>;
31+
32+
interface Harness {
33+
disk: Record<string, string>;
34+
store: ReturnType<typeof createPersistentEditHistoryStore>;
35+
splitRef: { current: Split | undefined };
36+
root: ReturnType<typeof createRoot>;
37+
expected: string;
38+
}
39+
40+
const SPLIT_GSAP = SPLIT.replace(
41+
"</div>",
42+
"</div><script>window.__timelines={};const tl=gsap.timeline({paused:true});" +
43+
'tl.set("#clip1-split",{x:0},2);window.__timelines["c"]=tl;</script>',
44+
);
45+
46+
function mountRazorSplit(opts: { gsap?: boolean } = {}): Harness {
47+
const disk: Record<string, string> = { "index.html": ORIGINAL };
48+
const finalContent = opts.gsap ? SPLIT_GSAP : SPLIT;
49+
50+
const storage = createMemoryEditHistoryStorage();
51+
const store = createPersistentEditHistoryStore({
52+
projectId: "p1",
53+
storage,
54+
initialState: createEmptyEditHistory(),
55+
now: (() => {
56+
let t = 1000;
57+
return () => (t += 10);
58+
})(),
59+
onChange: () => {},
60+
});
61+
62+
// Faithful stand-in for the studio-server file-mutation endpoints: the server
63+
// writes the split to disk itself, then returns the patched content.
64+
const fetchMock = vi.fn(async (url: string, init?: RequestInit) => {
65+
const u = String(url);
66+
if (u.includes("/gsap-mutations/")) {
67+
if (opts.gsap) {
68+
// Mirror the server: rewrites the GSAP script for the new id, writes to
69+
// disk, returns the final content.
70+
disk["index.html"] = SPLIT_GSAP;
71+
return new Response(JSON.stringify({ ok: true, after: SPLIT_GSAP }), {
72+
status: 200,
73+
headers: { "Content-Type": "application/json" },
74+
});
75+
}
76+
// The fixture has no GSAP script — mirror the server's 400 response.
77+
return new Response(JSON.stringify({ error: "no GSAP script found in file" }), {
78+
status: 400,
79+
headers: { "Content-Type": "application/json" },
80+
});
81+
}
82+
if (u.includes("/file-mutations/split-element/")) {
83+
disk["index.html"] = SPLIT;
84+
return new Response(
85+
JSON.stringify({ ok: true, changed: true, content: SPLIT, newId: "clip1-split" }),
86+
{ status: 200, headers: { "Content-Type": "application/json" } },
87+
);
88+
}
89+
if (u.includes("/files/")) {
90+
return new Response(JSON.stringify({ content: disk["index.html"] }), {
91+
status: 200,
92+
headers: { "Content-Type": "application/json" },
93+
});
94+
}
95+
void init;
96+
throw new Error(`unexpected fetch: ${u}`);
97+
});
98+
vi.stubGlobal("fetch", fetchMock);
99+
100+
const splitRef: { current: Split | undefined } = { current: undefined };
101+
102+
function Component() {
103+
const { handleRazorSplit } = useRazorSplit({
104+
projectId: "p1",
105+
activeCompPath: "index.html",
106+
showToast: () => {},
107+
writeProjectFile: async (path, content) => {
108+
disk[path] = content;
109+
},
110+
recordEdit: store.recordEdit,
111+
domEditSaveTimestampRef: { current: 0 },
112+
reloadPreview: () => {},
113+
});
114+
splitRef.current = handleRazorSplit;
115+
return null;
116+
}
117+
118+
const host = document.createElement("div");
119+
document.body.append(host);
120+
const root = createRoot(host);
121+
act(() => {
122+
root.render(<Component />);
123+
});
124+
125+
return { disk, store, splitRef, root, expected: finalContent };
126+
}
127+
128+
async function undoViaDisk(harness: Harness) {
129+
return harness.store.undo({
130+
readFile: async (path) => harness.disk[path],
131+
writeFile: async (path, content) => {
132+
harness.disk[path] = content;
133+
},
134+
});
135+
}
136+
137+
afterEach(() => {
138+
document.body.innerHTML = "";
139+
vi.unstubAllGlobals();
140+
});
141+
142+
describe("useRazorSplit — split is undoable via edit history", () => {
143+
for (const gsap of [false, true]) {
144+
describe(gsap ? "with GSAP rewrite" : "plain HTML split", () => {
145+
let harness: Harness;
146+
beforeEach(() => {
147+
harness = mountRazorSplit({ gsap });
148+
});
149+
afterEach(() => {
150+
act(() => harness.root.unmount());
151+
});
152+
153+
it("records a single 'Split timeline clip' history entry that undo restores", async () => {
154+
await act(async () => {
155+
await harness.splitRef.current!(element, 2);
156+
});
157+
158+
// The split reached disk.
159+
expect(harness.disk["index.html"]).toBe(harness.expected);
160+
161+
// The split must be the top of the undo stack — not a prior/other entry.
162+
expect(harness.store.snapshot().canUndo).toBe(true);
163+
expect(harness.store.snapshot().undoLabel).toBe("Split timeline clip");
164+
165+
// Undo restores the exact pre-split file.
166+
const result = await undoViaDisk(harness);
167+
expect(result.ok).toBe(true);
168+
expect(result.label).toBe("Split timeline clip");
169+
expect(harness.disk["index.html"]).toBe(ORIGINAL);
170+
});
171+
});
172+
}
173+
});

0 commit comments

Comments
 (0)