mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-12 23:29:50 +00:00
* refactor(core): retire recast/babel, route all GSAP mutations to acorn (WS-E/3.F) - Delete gsapParser.ts (2595-line recast-based parser/writer) - Delete gsapParser.test.ts, gsapParser.stress.test.ts, gsapParser.test-helpers.ts - Add gsapParserExports.ts: re-export umbrella for gsap-parser subpath - Move SplitAnimationsOptions/SplitAnimationsResult to gsapSerialize.ts - executeGsapMutation: async->sync, static acorn imports replace loadGsapParser() - Fix 3 function name mismatches in files.ts switch cases - generators/hyperframes.ts: imports from gsapSerialize (blocker resolved) - gsapWriterAcorn.ts: SplitAnimationsOptions from gsapSerialize - Parity tests: recast oracle removed; acorn-only regression (14 pass) - Remove recast and @babel/parser from core/package.json Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(sdk): harden mutation handlers + widen variable API (code-review) Self-contained review fixes for the SDK-hotspot stack (#1569–#1573). The dispatch path (_dispatch → applyOp) never runs validateOp, so the new WS-D/WS-3.C guards were advisory-only; re-enforce them in the handlers. - addElement: null-guard the resolved parent (no more `as Element` masking a null → crash on unknown parent id); reject <script> and multi-root fragments via parseInsertableFragment instead of inserting raw markup / silently dropping extra roots. - addWithKeyframes / replaceWithKeyframes: bail on empty keyframes (no degenerate `keyframes: {}` tween) and when the animationId resolves to nothing (no silent degrade-to-add leaving a duplicate tween). - isObjectVariableValue: exclude arrays so an array override value can't be misclassified as a font/image object and written into the variable model. - Composition.setVariableValue: widen the public interface signature to `… | FontValue | ImageValue` to match the impl + EditOp (B2 object-valued variables were unreachable via the typed API). - mutate.gsap.test.ts: import addKeyframeToScript from gsap-writer-acorn — the gsap-parser subpath no longer re-exports write fns after recast retire, so the test threw at runtime (red suite). - Dedup: export EXCLUDED_TAGS from hfIds.ts and drop the verbatim HF_EXCLUDED_TAGS copy in mutate.ts. Adds guard regression tests. SDK 340/340, core hfIds 13/13, build green, fallow --gate new-only clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(sdk): variable-model dedup + undo/scoped-parent correctness; test honesty (code-review) Second batch of review fixes for the SDK-hotspot stack. - Variable model (#7, #13): extract readVariableDefault/writeVariableDefault into a shared engine/variableModel.ts used by both mutate.ts (forward) and apply-patches.ts (replay), so the model shape can't diverge. Add clearVariableDefault and make a `variable` remove patch DELETE the decl's `default` key — the exact inverse of a first-set on a default-less variable. Previously undo of such a set no-op'd and stranded the value. - addElement scoped parent (#8): record the caller's id verbatim (scoped "hf-host/hf-leaf" path or composition id) as the patch parentId instead of the bare data-hf-id, so redo/replay re-resolves the SAME parent via resolveScoped rather than the canonical top-level dup (or document.body). - resolveTimings honesty (#5): correct the header + test that claimed a live "preview == render" parity — neither path consumes the resolver yet (anchor inputs are Pacific/backend-deferred). It's a pure-function property, not a current guarantee. - GSAP writer parity (#12): the recast oracle was deleted in WS-3.F, leaving the WS-3.C keyframe ops comparing acorn output to itself. Pin them as golden inline snapshots and drop the now-dead recast scaffolding (replaceWithKfRecast, removeAnimRecast alias). Remaining pre-WS-3.C parity blocks noted as follow-up. Adds regression tests (undo of default-less variable; scoped-parent redo). SDK 342/342, core timingResolver+parity green, build + fallow --gate new-only clean. Not changed (need design / out of scope): #9 pre-#1569 persisted-override CSS replay (moot for unreleased data; proper fix is render-time CSS derivation), #11 replaceWithKeyframes stale positional id (mitigated by the missing-id no-op guard + type doc; full fix needs non-positional ids). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(sdk): replay CSS-prop derivation for legacy var overrides; stale-id selector guard (code-review) Final review-fix batch — the two items deferred from the prior pass. - #9 legacy variable-override CSS: applyOverrideSet now derives the `--{id}` CSS custom prop from any scalar `var.{id}` override on replay (and removes it for a null override). Sets written before the model/CSS split carried only `var.{id}`; without this, replaying them updated the JSON model but left `var(--{id})` bindings rendering the schema default. Replay-path only — the undo path (applyOne) is untouched, so #1569's separate-patch undo correctness is preserved. Object (font/image) values are never CSS, so they are skipped. - #11 stale positional id: replaceWithKeyframes now requires the located animation to still target the caller's `targetSelector`. Position-derived ids re-point after structural edits; a stale id resolving to a DIFFERENT element's tween previously got silently replaced. It now bails (no-op) unless the id still points at the expected selector. Adds regression tests (legacy var.{id}-only override restores CSS; object override writes no CSS; stale-id-wrong-selector replace is a no-op). SDK 345/345, build + fallow --gate new-only clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * refactor(core): gate acorn GSAP writer behind cutover flag; keep recast default (WS-3F) Product decision pivot: acorn no longer replaces recast as the GSAP writer. Recast remains the default server writer; acorn runs only when STUDIO_SDK_CUTOVER_ENABLED=true (or =1) is set server-side — the same env flag name as the client Vite var, so a single switch flips both sides. Changes: - Restore gsapParser.ts (recast writer) + test/stress/helper files deleted by 3F - Restore @babel/parser + recast deps in packages/core/package.json - Add isAcornGsapWriterEnabled() + loadGsapParser() to files.ts (lines 59-82) - Split executeGsapMutation into async dispatcher + executeGsapMutationRecast (recast, async via loadGsapParser) + executeGsapMutationAcorn (acorn, sync) - Dispatcher defaults to recast; acorn branch taken only when flag is on - Restore gsapWriter.parity.test.ts, gsapWriterParity.acorn.test.ts, and gsapWriterParity.corpus.test.ts to true recast-vs-acorn differential suites (not acorn-vs-itself) - Exempt gsapParser.ts in .fallowrc.jsonc health.ignore + ignoreExports (pre-existing complexity + barrel re-exports consumed outside diff scope) - Add fallow-ignore-file code-duplication to files.ts (intentional parallel switch bodies for two writers) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
1116 lines
42 KiB
TypeScript
1116 lines
42 KiB
TypeScript
/**
|
|
* T4 — Op contract tests for the Phase 3a dispatch boundary.
|
|
*
|
|
* Tests verify: correct DOM mutation, correct RFC 6902 forward patches,
|
|
* correct inverse patches (applying them restores the original state),
|
|
* and override-set key mapping.
|
|
*/
|
|
|
|
import { describe, it, expect } from "vitest";
|
|
import { parseMutable, getElementStyles, setElementStyles } from "./model.js";
|
|
import { applyOp, validateOp } from "./mutate.js";
|
|
import { applyPatchesToDocument, applyOverrideSet } from "./apply-patches.js";
|
|
import { pathToKey } from "./patches.js";
|
|
import { serializeDocument } from "./serialize.js";
|
|
|
|
// ─── Fixtures ────────────────────────────────────────────────────────────────
|
|
|
|
// No trailing semicolons in style attrs — serializeStyleAttr never adds them.
|
|
const BASE_HTML = `
|
|
<div data-hf-id="hf-stage" data-hf-root style="width: 1280px; height: 720px; background: #000" data-duration="5">
|
|
<h1 data-hf-id="hf-title" data-start="0" data-end="3" data-track-index="0"
|
|
style="color: #fff; font-size: 64px">Hello World</h1>
|
|
<img data-hf-id="hf-logo" src="/logo.png" alt="Logo" />
|
|
<div data-hf-id="hf-sub">
|
|
<span data-hf-id="hf-span" style="opacity: 0.5">sub text</span>
|
|
</div>
|
|
</div>
|
|
`.trim();
|
|
|
|
function fresh() {
|
|
return parseMutable(BASE_HTML);
|
|
}
|
|
|
|
/** Full HTML fixture with data-composition-variables for B1/B2 tests. */
|
|
const VARIABLES_HTML = `<!DOCTYPE html>
|
|
<html data-composition-id="c1" data-composition-duration="5" data-composition-variables='${JSON.stringify(
|
|
[
|
|
{ id: "brand-color-primary", type: "color", label: "Primary color", default: "#0066cc" },
|
|
{
|
|
id: "brand-font",
|
|
type: "font",
|
|
label: "Brand font",
|
|
default: "Inter",
|
|
source: "https://fonts.googleapis.com/css2?family=Inter",
|
|
default_name: "sans-serif",
|
|
default_source: "",
|
|
},
|
|
{ id: "brand-logo", type: "image", label: "Brand logo", default: "/logo.png" },
|
|
],
|
|
)}'>
|
|
<body>
|
|
<div data-hf-id="hf-stage" data-hf-root style="width: 1280px; height: 720px" data-duration="5">
|
|
</div>
|
|
</body></html>`;
|
|
|
|
function freshWithVars() {
|
|
return parseMutable(VARIABLES_HTML);
|
|
}
|
|
|
|
/** Read the default value for a variable id from the parsed document. */
|
|
function readVarDefault(parsed: ReturnType<typeof parseMutable>, id: string): unknown {
|
|
const raw = parsed.document.documentElement?.getAttribute("data-composition-variables");
|
|
if (!raw) return undefined;
|
|
const arr = JSON.parse(raw) as Array<{ id: string; default: unknown }>;
|
|
return arr.find((v) => v.id === id)?.default;
|
|
}
|
|
|
|
// ─── setStyle ────────────────────────────────────────────────────────────────
|
|
|
|
describe("setStyle", () => {
|
|
it("mutates existing style prop and emits replace patches", () => {
|
|
const parsed = fresh();
|
|
const result = applyOp(parsed, {
|
|
type: "setStyle",
|
|
target: "hf-title",
|
|
styles: { fontSize: "96px" },
|
|
});
|
|
expect(result.forward).toHaveLength(1);
|
|
expect(result.forward[0]).toEqual({
|
|
op: "replace",
|
|
path: "/elements/hf-title/inlineStyles/fontSize",
|
|
value: "96px",
|
|
});
|
|
expect(result.inverse[0]).toEqual({
|
|
op: "replace",
|
|
path: "/elements/hf-title/inlineStyles/fontSize",
|
|
value: "64px",
|
|
});
|
|
// DOM mutated
|
|
const el = parsed.document.querySelector('[data-hf-id="hf-title"]');
|
|
expect(el?.getAttribute("style")).toContain("font-size: 96px");
|
|
});
|
|
|
|
it("adds new style prop and emits add patch", () => {
|
|
const parsed = fresh();
|
|
const result = applyOp(parsed, {
|
|
type: "setStyle",
|
|
target: "hf-logo",
|
|
styles: { opacity: "0.8" },
|
|
});
|
|
expect(result.forward[0]?.op).toBe("add");
|
|
expect(result.inverse[0]?.op).toBe("remove");
|
|
});
|
|
|
|
it("removes style prop when value is null", () => {
|
|
const parsed = fresh();
|
|
const result = applyOp(parsed, {
|
|
type: "setStyle",
|
|
target: "hf-title",
|
|
styles: { color: null },
|
|
});
|
|
expect(result.forward[0]?.op).toBe("remove");
|
|
expect(result.inverse[0]?.op).toBe("add");
|
|
expect(result.inverse[0]?.value).toBe("#fff");
|
|
});
|
|
|
|
it("inverse patches restore original state", () => {
|
|
const parsed = fresh();
|
|
const before = serializeDocument(parsed);
|
|
const { inverse } = applyOp(parsed, {
|
|
type: "setStyle",
|
|
target: "hf-title",
|
|
styles: { fontSize: "96px", color: "#f00" },
|
|
});
|
|
applyPatchesToDocument(parsed, inverse);
|
|
expect(serializeDocument(parsed)).toBe(before);
|
|
});
|
|
|
|
it("applies to multiple targets", () => {
|
|
const parsed = fresh();
|
|
const result = applyOp(parsed, {
|
|
type: "setStyle",
|
|
target: ["hf-title", "hf-span"],
|
|
styles: { opacity: "1" },
|
|
});
|
|
expect(result.forward).toHaveLength(2);
|
|
});
|
|
|
|
it("override-set key maps correctly", () => {
|
|
const key = pathToKey("/elements/hf-title/inlineStyles/fontSize");
|
|
expect(key).toBe("hf-title.style.fontSize");
|
|
});
|
|
|
|
// Regression: a HYPHENATED (kebab) style key must derive its inverse against
|
|
// the camelCase-keyed store, or oldValue is null → undo deletes the prior
|
|
// value instead of restoring it, and a removal skips the inverse entirely.
|
|
it("derives correct inverse for a kebab style key (change)", () => {
|
|
const parsed = fresh();
|
|
const result = applyOp(parsed, {
|
|
type: "setStyle",
|
|
target: "hf-title",
|
|
styles: { "font-size": "96px" },
|
|
});
|
|
// inverse restores the prior 64px (replace), not a remove
|
|
expect(result.inverse[0]).toEqual({
|
|
op: "replace",
|
|
path: "/elements/hf-title/inlineStyles/fontSize",
|
|
value: "64px",
|
|
});
|
|
});
|
|
|
|
it("emits the inverse for a kebab style removal (no DOM/patch desync)", () => {
|
|
const parsed = fresh();
|
|
const result = applyOp(parsed, {
|
|
type: "setStyle",
|
|
target: "hf-title",
|
|
styles: { "font-size": null },
|
|
});
|
|
// removal must be recorded (forward remove + inverse add restoring 64px)
|
|
expect(result.forward[0]?.op).toBe("remove");
|
|
expect(result.inverse[0]).toEqual({
|
|
op: "add",
|
|
path: "/elements/hf-title/inlineStyles/fontSize",
|
|
value: "64px",
|
|
});
|
|
const el = parsed.document.querySelector('[data-hf-id="hf-title"]');
|
|
expect(el?.getAttribute("style") ?? "").not.toContain("font-size");
|
|
});
|
|
});
|
|
|
|
// ─── setText ─────────────────────────────────────────────────────────────────
|
|
|
|
describe("setText", () => {
|
|
it("updates text content and emits replace patch", () => {
|
|
const parsed = fresh();
|
|
const result = applyOp(parsed, {
|
|
type: "setText",
|
|
target: "hf-title",
|
|
value: "Goodbye World",
|
|
});
|
|
expect(result.forward[0]).toEqual({
|
|
op: "replace",
|
|
path: "/elements/hf-title/text",
|
|
value: "Goodbye World",
|
|
});
|
|
const el = parsed.document.querySelector('[data-hf-id="hf-title"]');
|
|
// text node should contain new value
|
|
expect(el?.textContent).toContain("Goodbye World");
|
|
});
|
|
|
|
it("inverse patches restore original text", () => {
|
|
const parsed = fresh();
|
|
const before = serializeDocument(parsed);
|
|
const { inverse } = applyOp(parsed, {
|
|
type: "setText",
|
|
target: "hf-title",
|
|
value: "Changed",
|
|
});
|
|
applyPatchesToDocument(parsed, inverse);
|
|
expect(serializeDocument(parsed)).toBe(before);
|
|
});
|
|
|
|
it("creates text node when element has no existing text node", () => {
|
|
const parsed = parseMutable(
|
|
'<div data-hf-id="hf-s" data-hf-root><span data-hf-id="hf-empty"></span></div>',
|
|
);
|
|
const result = applyOp(parsed, { type: "setText", target: "hf-empty", value: "Added" });
|
|
const el = parsed.document.querySelector('[data-hf-id="hf-empty"]');
|
|
expect(el?.textContent).toBe("Added");
|
|
expect(result.forward[0]?.op).toBe("replace");
|
|
expect(result.forward[0]?.value).toBe("Added");
|
|
});
|
|
|
|
it("override-set key maps correctly", () => {
|
|
expect(pathToKey("/elements/hf-title/text")).toBe("hf-title.text");
|
|
});
|
|
});
|
|
|
|
// ─── setAttribute ─────────────────────────────────────────────────────────────
|
|
|
|
describe("setAttribute", () => {
|
|
it("sets a new attribute and emits add patch", () => {
|
|
const parsed = fresh();
|
|
const result = applyOp(parsed, {
|
|
type: "setAttribute",
|
|
target: "hf-logo",
|
|
name: "src",
|
|
value: "/new-logo.png",
|
|
});
|
|
expect(result.forward[0]).toEqual({
|
|
op: "replace",
|
|
path: "/elements/hf-logo/attributes/src",
|
|
value: "/new-logo.png",
|
|
});
|
|
});
|
|
|
|
it("removes attribute when value is null", () => {
|
|
const parsed = fresh();
|
|
const result = applyOp(parsed, {
|
|
type: "setAttribute",
|
|
target: "hf-logo",
|
|
name: "alt",
|
|
value: null,
|
|
});
|
|
expect(result.forward[0]?.op).toBe("remove");
|
|
expect(result.inverse[0]?.value).toBe("Logo");
|
|
});
|
|
|
|
it("inverse patches restore original attribute", () => {
|
|
const parsed = fresh();
|
|
const before = serializeDocument(parsed);
|
|
const { inverse } = applyOp(parsed, {
|
|
type: "setAttribute",
|
|
target: "hf-logo",
|
|
name: "src",
|
|
value: "/changed.png",
|
|
});
|
|
applyPatchesToDocument(parsed, inverse);
|
|
expect(serializeDocument(parsed)).toBe(before);
|
|
});
|
|
});
|
|
|
|
// ─── setTiming ────────────────────────────────────────────────────────────────
|
|
|
|
describe("setTiming", () => {
|
|
it("updates start and recalculates end", () => {
|
|
const parsed = fresh();
|
|
const result = applyOp(parsed, {
|
|
type: "setTiming",
|
|
target: "hf-title",
|
|
start: 1,
|
|
});
|
|
const el = parsed.document.querySelector('[data-hf-id="hf-title"]');
|
|
expect(el?.getAttribute("data-start")).toBe("1");
|
|
// duration was 3 (0→3), so end = 1+3 = 4
|
|
expect(el?.getAttribute("data-end")).toBe("4");
|
|
const startPatch = result.forward.find((p) => p.path.endsWith("/start"));
|
|
expect(startPatch?.value).toBe(1);
|
|
});
|
|
|
|
it("updates duration and recalculates end", () => {
|
|
const parsed = fresh();
|
|
applyOp(parsed, { type: "setTiming", target: "hf-title", duration: 2 });
|
|
const el = parsed.document.querySelector('[data-hf-id="hf-title"]');
|
|
expect(el?.getAttribute("data-end")).toBe("2"); // start=0, duration=2 → end=2
|
|
});
|
|
|
|
it("inverse patches restore original timing", () => {
|
|
const parsed = fresh();
|
|
const before = serializeDocument(parsed);
|
|
const { inverse } = applyOp(parsed, {
|
|
type: "setTiming",
|
|
target: "hf-title",
|
|
start: 1,
|
|
duration: 2,
|
|
trackIndex: 1,
|
|
});
|
|
applyPatchesToDocument(parsed, inverse);
|
|
expect(serializeDocument(parsed)).toBe(before);
|
|
});
|
|
});
|
|
|
|
// ─── removeElement ───────────────────────────────────────────────────────────
|
|
|
|
describe("removeElement", () => {
|
|
it("removes element from DOM and emits remove patch", () => {
|
|
const parsed = fresh();
|
|
const result = applyOp(parsed, {
|
|
type: "removeElement",
|
|
target: "hf-span",
|
|
});
|
|
expect(result.forward[0]?.op).toBe("remove");
|
|
expect(result.forward[0]?.path).toBe("/elements/hf-span");
|
|
expect(parsed.document.querySelector('[data-hf-id="hf-span"]')).toBeNull();
|
|
});
|
|
|
|
it("inverse patch carries html and restore position", () => {
|
|
const parsed = fresh();
|
|
const { inverse } = applyOp(parsed, {
|
|
type: "removeElement",
|
|
target: "hf-span",
|
|
});
|
|
expect(inverse[0]?.op).toBe("add");
|
|
const val = inverse[0]?.value as {
|
|
html: string;
|
|
parentId: string | null;
|
|
siblingIndex: number;
|
|
};
|
|
expect(val.html).toContain("hf-span");
|
|
expect(val.parentId).toBe("hf-sub");
|
|
expect(val.siblingIndex).toBe(0);
|
|
});
|
|
|
|
it("applying inverse patch restores the element in correct parent", () => {
|
|
const parsed = fresh();
|
|
const { inverse } = applyOp(parsed, {
|
|
type: "removeElement",
|
|
target: "hf-span",
|
|
});
|
|
applyPatchesToDocument(parsed, inverse);
|
|
const restored = parsed.document.querySelector('[data-hf-id="hf-span"]');
|
|
expect(restored).not.toBeNull();
|
|
expect(restored?.parentElement?.getAttribute("data-hf-id")).toBe("hf-sub");
|
|
expect(restored?.getAttribute("style")).toBe("opacity: 0.5");
|
|
expect(restored?.textContent).toBe("sub text");
|
|
});
|
|
});
|
|
|
|
// ─── addElement ───────────────────────────────────────────────────────────────
|
|
|
|
describe("addElement", () => {
|
|
it("inserts element at specified parent+index and resolves via getElement-style lookup", () => {
|
|
const parsed = fresh();
|
|
const result = applyOp(parsed, {
|
|
type: "addElement",
|
|
parent: "hf-stage",
|
|
index: 0,
|
|
html: '<p class="new">inserted</p>',
|
|
});
|
|
expect(result.meta?.newId).toBeTruthy();
|
|
const newId = result.meta!.newId!;
|
|
const el = parsed.document.querySelector(`[data-hf-id="${newId}"]`);
|
|
expect(el).not.toBeNull();
|
|
expect(el?.tagName.toLowerCase()).toBe("p");
|
|
// Inserted at index 0 → first child of hf-stage
|
|
const stage = parsed.document.querySelector('[data-hf-id="hf-stage"]');
|
|
expect(stage?.firstElementChild?.getAttribute("data-hf-id")).toBe(newId);
|
|
});
|
|
|
|
it("insert at index >= childCount appends to parent", () => {
|
|
const parsed = fresh();
|
|
const stage = parsed.document.querySelector('[data-hf-id="hf-stage"]');
|
|
const countBefore = stage ? Array.from(stage.children).length : 0;
|
|
const result = applyOp(parsed, {
|
|
type: "addElement",
|
|
parent: "hf-stage",
|
|
index: 9999,
|
|
html: '<span class="tail">tail</span>',
|
|
});
|
|
const newId = result.meta!.newId!;
|
|
const stageAfter = parsed.document.querySelector('[data-hf-id="hf-stage"]');
|
|
expect(stageAfter?.lastElementChild?.getAttribute("data-hf-id")).toBe(newId);
|
|
expect(Array.from(stageAfter?.children ?? []).length).toBe(countBefore + 1);
|
|
});
|
|
|
|
it("minted id is unique vs all existing doc ids", () => {
|
|
const parsed = fresh();
|
|
const result = applyOp(parsed, {
|
|
type: "addElement",
|
|
parent: "hf-stage",
|
|
index: 0,
|
|
html: '<div class="unique-new">content</div>',
|
|
});
|
|
const newId = result.meta!.newId!;
|
|
// Must not collide with any pre-existing id
|
|
const existingIds = ["hf-stage", "hf-title", "hf-logo", "hf-sub", "hf-span"];
|
|
expect(existingIds).not.toContain(newId);
|
|
// Must appear exactly once in the document
|
|
const all = Array.from(parsed.document.querySelectorAll(`[data-hf-id="${newId}"]`));
|
|
expect(all).toHaveLength(1);
|
|
});
|
|
|
|
it("content-collision with existing element yields a distinct rehashed id", () => {
|
|
// Insert a fragment with identical content to an existing element → dup-rehash must yield a distinct id
|
|
const parsed = fresh();
|
|
// hf-logo is <img data-hf-id="hf-logo" src="/logo.png" alt="Logo" />
|
|
// Insert the same HTML without the data-hf-id so mintHfId runs fresh
|
|
const result = applyOp(parsed, {
|
|
type: "addElement",
|
|
parent: "hf-stage",
|
|
index: 0,
|
|
html: '<img src="/logo.png" alt="Logo" />',
|
|
});
|
|
const newId = result.meta!.newId!;
|
|
expect(newId).not.toBe("hf-logo");
|
|
expect(newId.startsWith("hf-")).toBe(true);
|
|
const el = parsed.document.querySelector(`[data-hf-id="${newId}"]`);
|
|
expect(el).not.toBeNull();
|
|
});
|
|
|
|
it("nested fragment: all new nodes get unique ids; root id returned", () => {
|
|
const parsed = fresh();
|
|
const result = applyOp(parsed, {
|
|
type: "addElement",
|
|
parent: "hf-stage",
|
|
index: 0,
|
|
html: '<div class="outer"><span class="inner-a">a</span><span class="inner-b">b</span></div>',
|
|
});
|
|
const rootId = result.meta!.newId!;
|
|
const root = parsed.document.querySelector(`[data-hf-id="${rootId}"]`);
|
|
expect(root).not.toBeNull();
|
|
// All children must have data-hf-id
|
|
const children = root ? Array.from(root.querySelectorAll("*")) : [];
|
|
for (const child of children) {
|
|
expect(child.getAttribute("data-hf-id")).toBeTruthy();
|
|
}
|
|
// All ids must be distinct
|
|
const allIds = [rootId, ...children.map((c) => c.getAttribute("data-hf-id") as string)];
|
|
expect(new Set(allIds).size).toBe(allIds.length);
|
|
});
|
|
|
|
it("forward patch is patchAdd; inverse patch is patchRemove — symmetry with removeElement", () => {
|
|
const parsed = fresh();
|
|
const result = applyOp(parsed, {
|
|
type: "addElement",
|
|
parent: "hf-sub",
|
|
index: 0,
|
|
html: '<em class="em">em text</em>',
|
|
});
|
|
expect(result.forward).toHaveLength(1);
|
|
expect(result.inverse).toHaveLength(1);
|
|
expect(result.forward[0]?.op).toBe("add");
|
|
expect(result.inverse[0]?.op).toBe("remove");
|
|
const newId = result.meta!.newId!;
|
|
expect(result.forward[0]?.path).toBe(`/elements/${newId}`);
|
|
expect(result.inverse[0]?.path).toBe(`/elements/${newId}`);
|
|
});
|
|
|
|
it("applying inverse patch removes the added element (undo)", () => {
|
|
const parsed = fresh();
|
|
const { inverse, meta } = applyOp(parsed, {
|
|
type: "addElement",
|
|
parent: "hf-stage",
|
|
index: 0,
|
|
html: '<div class="to-undo">undo me</div>',
|
|
});
|
|
const newId = meta!.newId!;
|
|
expect(parsed.document.querySelector(`[data-hf-id="${newId}"]`)).not.toBeNull();
|
|
applyPatchesToDocument(parsed, inverse);
|
|
expect(parsed.document.querySelector(`[data-hf-id="${newId}"]`)).toBeNull();
|
|
});
|
|
|
|
it("add → undo → redo: element returns with the same id (id stability)", () => {
|
|
const parsed = fresh();
|
|
// add
|
|
const { forward, inverse, meta } = applyOp(parsed, {
|
|
type: "addElement",
|
|
parent: "hf-stage",
|
|
index: 1,
|
|
html: '<section class="redo-test">redo</section>',
|
|
});
|
|
const newId = meta!.newId!;
|
|
// undo
|
|
applyPatchesToDocument(parsed, inverse);
|
|
expect(parsed.document.querySelector(`[data-hf-id="${newId}"]`)).toBeNull();
|
|
// redo (replay forward patches)
|
|
applyPatchesToDocument(parsed, forward);
|
|
const restored = parsed.document.querySelector(`[data-hf-id="${newId}"]`);
|
|
expect(restored).not.toBeNull();
|
|
expect(restored?.getAttribute("data-hf-id")).toBe(newId);
|
|
});
|
|
|
|
it("parent: null inserts at document body root level", () => {
|
|
// Use a simple fragment doc
|
|
const parsed = parseMutable(
|
|
'<div data-hf-id="hf-root" data-hf-root style="width:100px;height:100px"></div>',
|
|
);
|
|
const result = applyOp(parsed, {
|
|
type: "addElement",
|
|
parent: null,
|
|
index: 1,
|
|
html: '<aside class="body-child">aside</aside>',
|
|
});
|
|
const newId = result.meta!.newId!;
|
|
const el = parsed.document.querySelector(`[data-hf-id="${newId}"]`);
|
|
expect(el).not.toBeNull();
|
|
expect(el?.parentElement?.tagName.toLowerCase()).toBe("body");
|
|
});
|
|
|
|
it("serialize round-trip: addElement survives serialize()", () => {
|
|
const parsed = fresh();
|
|
const result = applyOp(parsed, {
|
|
type: "addElement",
|
|
parent: "hf-sub",
|
|
index: 0,
|
|
html: '<b class="bold">bold</b>',
|
|
});
|
|
const newId = result.meta!.newId!;
|
|
const serialized = serializeDocument(parsed);
|
|
expect(serialized).toContain(`data-hf-id="${newId}"`);
|
|
expect(serialized).toContain("bold");
|
|
});
|
|
|
|
// ─── validateOp ─────────────────────────────────────────────────────────────
|
|
|
|
it("validateOp: missing parent → E_TARGET_NOT_FOUND", () => {
|
|
const parsed = fresh();
|
|
const r = validateOp(parsed, {
|
|
type: "addElement",
|
|
parent: "hf-nonexistent",
|
|
index: 0,
|
|
html: "<div>x</div>",
|
|
});
|
|
expect(r.ok).toBe(false);
|
|
if (!r.ok) expect(r.code).toBe("E_TARGET_NOT_FOUND");
|
|
});
|
|
|
|
it("validateOp: negative index → E_INVALID_ARGS", () => {
|
|
const parsed = fresh();
|
|
const r = validateOp(parsed, {
|
|
type: "addElement",
|
|
parent: "hf-stage",
|
|
index: -1,
|
|
html: "<div>x</div>",
|
|
});
|
|
expect(r.ok).toBe(false);
|
|
if (!r.ok) expect(r.code).toBe("E_INVALID_ARGS");
|
|
});
|
|
|
|
it("validateOp: empty html → E_INVALID_HTML", () => {
|
|
const parsed = fresh();
|
|
const r = validateOp(parsed, {
|
|
type: "addElement",
|
|
parent: "hf-stage",
|
|
index: 0,
|
|
html: "",
|
|
});
|
|
expect(r.ok).toBe(false);
|
|
if (!r.ok) expect(r.code).toBe("E_INVALID_HTML");
|
|
});
|
|
|
|
it("validateOp: html with only text / zero element nodes → E_INVALID_HTML", () => {
|
|
const parsed = fresh();
|
|
const r = validateOp(parsed, {
|
|
type: "addElement",
|
|
parent: "hf-stage",
|
|
index: 0,
|
|
html: "just text no element",
|
|
});
|
|
expect(r.ok).toBe(false);
|
|
if (!r.ok) expect(r.code).toBe("E_INVALID_HTML");
|
|
});
|
|
|
|
it("validateOp: html containing <script> → E_INVALID_HTML", () => {
|
|
const parsed = fresh();
|
|
const r = validateOp(parsed, {
|
|
type: "addElement",
|
|
parent: "hf-stage",
|
|
index: 0,
|
|
html: "<div><script>alert(1)</scr" + "ipt></div>",
|
|
});
|
|
expect(r.ok).toBe(false);
|
|
if (!r.ok) expect(r.code).toBe("E_INVALID_HTML");
|
|
});
|
|
|
|
it("validateOp: parent: null is valid", () => {
|
|
const parsed = fresh();
|
|
const r = validateOp(parsed, {
|
|
type: "addElement",
|
|
parent: null,
|
|
index: 0,
|
|
html: "<div>body-level</div>",
|
|
});
|
|
expect(r.ok).toBe(true);
|
|
});
|
|
|
|
// The dispatch path runs applyOp WITHOUT validateOp, so the handler must
|
|
// re-enforce these guards itself (return EMPTY) rather than crash or insert.
|
|
it.each([
|
|
{ name: "unknown parent id (no crash)", parent: "hf-does-not-exist", html: "<div>x</div>" },
|
|
{
|
|
name: "fragment with <script> (never inserts raw markup)",
|
|
parent: "hf-stage",
|
|
html: "<div><scr" + "ipt>alert(1)</scr" + "ipt></div>",
|
|
},
|
|
{
|
|
name: "multi-root fragment (no silent drop of extra roots)",
|
|
parent: "hf-stage",
|
|
html: "<p>a</p><p>b</p>",
|
|
},
|
|
])("handler guard: $name → no-op", ({ parent, html }) => {
|
|
const result = applyOp(fresh(), { type: "addElement", parent, index: 0, html });
|
|
expect(result.forward).toHaveLength(0);
|
|
expect(result.meta?.newId).toBeUndefined();
|
|
});
|
|
|
|
// Regression: a scoped sub-comp parent ("hf-host/hf-leaf") whose bare leaf id
|
|
// also exists at top level. The forward patch must keep the scoped path so
|
|
// redo/replay re-inserts under the SAME parent (resolveScoped), not the
|
|
// canonical top-level dup.
|
|
it("scoped parent: forward patch keeps the scoped path so redo targets the right parent", () => {
|
|
const parsed = parseMutable(
|
|
'<div data-hf-id="hf-stage" data-hf-root style="width:100px;height:100px">' +
|
|
'<div data-hf-id="hf-host"><p data-hf-id="hf-leaf">in host</p></div>' +
|
|
'<p data-hf-id="hf-leaf">top-level dup</p>' +
|
|
"</div>",
|
|
);
|
|
const result = applyOp(parsed, {
|
|
type: "addElement",
|
|
parent: "hf-host/hf-leaf",
|
|
index: 0,
|
|
html: '<span class="ins">x</span>',
|
|
});
|
|
expect((result.forward[0]!.value as { parentId: string }).parentId).toBe("hf-host/hf-leaf");
|
|
const newId = result.meta!.newId!;
|
|
// undo, then redo: the element must return under the HOST's leaf, not the dup.
|
|
applyPatchesToDocument(parsed, result.inverse);
|
|
applyPatchesToDocument(parsed, result.forward);
|
|
const host = parsed.document.querySelector('[data-hf-id="hf-host"]');
|
|
const inserted = parsed.document.querySelector(`[data-hf-id="${newId}"]`);
|
|
expect(inserted).not.toBeNull();
|
|
expect(host?.contains(inserted as Node)).toBe(true);
|
|
});
|
|
});
|
|
|
|
// ─── setElementStyles (model helper) ──────────────────────────────────────────
|
|
|
|
describe("setElementStyles key normalization", () => {
|
|
function elWith(style: string): Element {
|
|
const parsed = parseMutable(`<div data-hf-id="hf-x" data-hf-root style="${style}"></div>`);
|
|
const el = parsed.document.querySelector('[data-hf-id="hf-x"]');
|
|
if (!el) throw new Error("fixture element missing");
|
|
return el;
|
|
}
|
|
|
|
it("removes a hyphenated property when value is null", () => {
|
|
const el = elWith("transform-origin: center center; opacity: 0.5");
|
|
setElementStyles(el, { "transform-origin": null });
|
|
const styles = getElementStyles(el);
|
|
expect(styles.transformOrigin).toBeUndefined();
|
|
expect(el.getAttribute("style")).not.toContain("transform-origin");
|
|
// sibling prop untouched
|
|
expect(styles.opacity).toBe("0.5");
|
|
});
|
|
|
|
it("removes a CSS custom property when value is null", () => {
|
|
const el = elWith("--brand-color: #f00; opacity: 0.5");
|
|
setElementStyles(el, { "--brand-color": null });
|
|
const styles = getElementStyles(el);
|
|
expect(styles["--brand-color"]).toBeUndefined();
|
|
expect(el.getAttribute("style")).not.toContain("--brand-color");
|
|
expect(styles.opacity).toBe("0.5");
|
|
});
|
|
|
|
it("sets a camelCase property and keeps existing props", () => {
|
|
const el = elWith("color: #fff");
|
|
setElementStyles(el, { fontSize: "96px" });
|
|
const styles = getElementStyles(el);
|
|
expect(styles.fontSize).toBe("96px");
|
|
expect(styles.color).toBe("#fff");
|
|
});
|
|
|
|
it("sets a hyphenated property under its camelCase key", () => {
|
|
const el = elWith("");
|
|
setElementStyles(el, { "transform-origin": "top left" });
|
|
expect(getElementStyles(el).transformOrigin).toBe("top left");
|
|
});
|
|
});
|
|
|
|
// ─── setVariableValue ─────────────────────────────────────────────────────────
|
|
|
|
describe("setVariableValue", () => {
|
|
it("sets CSS custom property on root element (fragment doc — compat)", () => {
|
|
const parsed = fresh();
|
|
const result = applyOp(parsed, {
|
|
type: "setVariableValue",
|
|
id: "brand-color-primary",
|
|
value: "#ff0000",
|
|
});
|
|
expect(result.forward[0]?.path).toBe("/variables/brand-color-primary");
|
|
expect(result.forward[0]?.value).toBe("#ff0000");
|
|
const root = parsed.document.querySelector("[data-hf-root]");
|
|
expect(root?.getAttribute("style")).toContain("--brand-color-primary: #ff0000");
|
|
});
|
|
|
|
it("override-set key maps correctly", () => {
|
|
expect(pathToKey("/variables/brand-color-primary")).toBe("var.brand-color-primary");
|
|
});
|
|
|
|
// B1 — drives the JSON model (data-composition-variables)
|
|
|
|
it("B1: scalar color round-trips through override-set and runtime JSON model", () => {
|
|
const parsed = freshWithVars();
|
|
const before = serializeDocument(parsed);
|
|
const result = applyOp(parsed, {
|
|
type: "setVariableValue",
|
|
id: "brand-color-primary",
|
|
value: "#ff0000",
|
|
});
|
|
expect(result.forward[0]?.path).toBe("/variables/brand-color-primary");
|
|
expect(result.forward[0]?.value).toBe("#ff0000");
|
|
// JSON model updated
|
|
expect(readVarDefault(parsed, "brand-color-primary")).toBe("#ff0000");
|
|
// CSS compat prop also written
|
|
const root = parsed.document.querySelector("[data-hf-root]");
|
|
expect(root?.getAttribute("style")).toContain("--brand-color-primary: #ff0000");
|
|
// inverse restores
|
|
applyPatchesToDocument(parsed, result.inverse);
|
|
expect(serializeDocument(parsed)).toBe(before);
|
|
});
|
|
|
|
it("B1: scalar inverse patch restores prior value (replace → old value)", () => {
|
|
const parsed = freshWithVars();
|
|
// Set once
|
|
applyOp(parsed, { type: "setVariableValue", id: "brand-color-primary", value: "#ff0000" });
|
|
const snap = serializeDocument(parsed);
|
|
// Set again
|
|
const result2 = applyOp(parsed, {
|
|
type: "setVariableValue",
|
|
id: "brand-color-primary",
|
|
value: "#00ff00",
|
|
});
|
|
expect(readVarDefault(parsed, "brand-color-primary")).toBe("#00ff00");
|
|
applyPatchesToDocument(parsed, result2.inverse);
|
|
expect(serializeDocument(parsed)).toBe(snap);
|
|
});
|
|
|
|
// B2 — object-valued font variable
|
|
|
|
it("B2: font {name,source} object round-trips through JSON model (no CSS prop)", () => {
|
|
const parsed = freshWithVars();
|
|
const fontValue = { name: "Roboto", source: "https://fonts.googleapis.com/css2?family=Roboto" };
|
|
const result = applyOp(parsed, {
|
|
type: "setVariableValue",
|
|
id: "brand-font",
|
|
value: fontValue,
|
|
});
|
|
expect(result.forward[0]?.path).toBe("/variables/brand-font");
|
|
expect(result.forward[0]?.value).toEqual(fontValue);
|
|
// JSON model updated
|
|
expect(readVarDefault(parsed, "brand-font")).toEqual(fontValue);
|
|
// NO CSS custom prop for object values
|
|
const root = parsed.document.querySelector("[data-hf-root]");
|
|
const style = root?.getAttribute("style") ?? "";
|
|
expect(style).not.toContain("--brand-font");
|
|
// override-set key holds the object (one var.{id} key, no sub-key explosion)
|
|
expect(pathToKey("/variables/brand-font")).toBe("var.brand-font");
|
|
});
|
|
|
|
it("B2: font inverse restores prior default (object → object)", () => {
|
|
const parsed = freshWithVars();
|
|
const before = serializeDocument(parsed);
|
|
const fontValue = { name: "Roboto", source: "https://fonts.googleapis.com/css2?family=Roboto" };
|
|
const result = applyOp(parsed, {
|
|
type: "setVariableValue",
|
|
id: "brand-font",
|
|
value: fontValue,
|
|
});
|
|
expect(readVarDefault(parsed, "brand-font")).toEqual(fontValue);
|
|
applyPatchesToDocument(parsed, result.inverse);
|
|
expect(serializeDocument(parsed)).toBe(before);
|
|
});
|
|
|
|
it("B2: image {url} object round-trips through JSON model (no CSS prop)", () => {
|
|
const parsed = freshWithVars();
|
|
const imgValue = { url: "https://example.com/brand-logo.png" };
|
|
const result = applyOp(parsed, {
|
|
type: "setVariableValue",
|
|
id: "brand-logo",
|
|
value: imgValue,
|
|
});
|
|
expect(result.forward[0]?.path).toBe("/variables/brand-logo");
|
|
expect(result.forward[0]?.value).toEqual(imgValue);
|
|
expect(readVarDefault(parsed, "brand-logo")).toEqual(imgValue);
|
|
const root = parsed.document.querySelector("[data-hf-root]");
|
|
expect(root?.getAttribute("style") ?? "").not.toContain("--brand-logo");
|
|
});
|
|
|
|
it("B2: image inverse restores prior default", () => {
|
|
const parsed = freshWithVars();
|
|
const before = serializeDocument(parsed);
|
|
const result = applyOp(parsed, {
|
|
type: "setVariableValue",
|
|
id: "brand-logo",
|
|
value: { url: "https://example.com/new-logo.png" },
|
|
});
|
|
applyPatchesToDocument(parsed, result.inverse);
|
|
expect(serializeDocument(parsed)).toBe(before);
|
|
});
|
|
|
|
it("B1/batch: multiple setVariableValue calls fold to independent overrides", () => {
|
|
const parsed = freshWithVars();
|
|
applyOp(parsed, { type: "setVariableValue", id: "brand-color-primary", value: "#ff0000" });
|
|
applyOp(parsed, {
|
|
type: "setVariableValue",
|
|
id: "brand-font",
|
|
value: { name: "Roboto", source: "https://fonts.googleapis.com/css2?family=Roboto" },
|
|
});
|
|
expect(readVarDefault(parsed, "brand-color-primary")).toBe("#ff0000");
|
|
expect(readVarDefault(parsed, "brand-font")).toEqual({
|
|
name: "Roboto",
|
|
source: "https://fonts.googleapis.com/css2?family=Roboto",
|
|
});
|
|
});
|
|
|
|
// Regression: a variable declared WITHOUT a `default` key. The forward set adds
|
|
// the default; undo must DELETE it (restore the no-default state), not strand
|
|
// the set value (apply-patches previously no-op'd the remove).
|
|
it("B1: undo of a set on a default-less variable restores the no-default state", () => {
|
|
const html = `<!DOCTYPE html><html data-composition-id="c1" data-composition-duration="5" data-composition-variables='${JSON.stringify(
|
|
[{ id: "brand-x", type: "color", label: "X" }],
|
|
)}'><body><div data-hf-id="hf-stage" data-hf-root style="width: 1280px; height: 720px" data-duration="5"></div></body></html>`;
|
|
const parsed = parseMutable(html);
|
|
const before = serializeDocument(parsed);
|
|
const result = applyOp(parsed, { type: "setVariableValue", id: "brand-x", value: "#ff0000" });
|
|
expect(readVarDefault(parsed, "brand-x")).toBe("#ff0000");
|
|
applyPatchesToDocument(parsed, result.inverse);
|
|
expect(readVarDefault(parsed, "brand-x")).toBeUndefined();
|
|
expect(serializeDocument(parsed)).toBe(before);
|
|
});
|
|
|
|
// #9: a legacy override set (only the `var.{id}` key, no paired style key, as
|
|
// written before the model/CSS split) must still restore the --{id} CSS prop
|
|
// on replay so `var(--{id})` bindings rehydrate. Object values write no CSS.
|
|
it("B1: applyOverrideSet derives the --{id} CSS prop from a var.{id}-only override", () => {
|
|
const parsed = freshWithVars();
|
|
applyOverrideSet(parsed, { "var.brand-color-primary": "#ff0000" });
|
|
expect(readVarDefault(parsed, "brand-color-primary")).toBe("#ff0000");
|
|
const root = parsed.document.querySelector("[data-hf-root]");
|
|
expect(root?.getAttribute("style")).toContain("--brand-color-primary: #ff0000");
|
|
});
|
|
|
|
it("B2: applyOverrideSet writes NO CSS prop for an object (font) override", () => {
|
|
const parsed = freshWithVars();
|
|
applyOverrideSet(parsed, { "var.brand-font": { name: "Roboto", source: "x" } });
|
|
expect(readVarDefault(parsed, "brand-font")).toEqual({ name: "Roboto", source: "x" });
|
|
const root = parsed.document.querySelector("[data-hf-root]");
|
|
expect(root?.getAttribute("style") ?? "").not.toContain("--brand-font");
|
|
});
|
|
});
|
|
|
|
// ─── setCompositionMetadata ───────────────────────────────────────────────────
|
|
|
|
describe("setCompositionMetadata", () => {
|
|
it("updates width, height, duration on root element", () => {
|
|
const parsed = fresh();
|
|
applyOp(parsed, {
|
|
type: "setCompositionMetadata",
|
|
width: 1920,
|
|
height: 1080,
|
|
duration: 10,
|
|
});
|
|
const root = parsed.document.querySelector("[data-hf-root]");
|
|
expect(root?.getAttribute("style")).toContain("width: 1920px");
|
|
expect(root?.getAttribute("style")).toContain("height: 1080px");
|
|
expect(root?.getAttribute("data-duration")).toBe("10");
|
|
});
|
|
|
|
it("inverse patches restore original metadata", () => {
|
|
const parsed = fresh();
|
|
const before = serializeDocument(parsed);
|
|
const { inverse } = applyOp(parsed, {
|
|
type: "setCompositionMetadata",
|
|
width: 1920,
|
|
height: 1080,
|
|
duration: 10,
|
|
});
|
|
applyPatchesToDocument(parsed, inverse);
|
|
expect(serializeDocument(parsed)).toBe(before);
|
|
});
|
|
});
|
|
|
|
// ─── moveElement ─────────────────────────────────────────────────────────────
|
|
|
|
describe("moveElement", () => {
|
|
it("sets data-x and data-y attributes (HF positioning convention)", () => {
|
|
const parsed = fresh();
|
|
const result = applyOp(parsed, {
|
|
type: "moveElement",
|
|
target: "hf-title",
|
|
x: 100,
|
|
y: 200,
|
|
});
|
|
const el = parsed.document.querySelector('[data-hf-id="hf-title"]');
|
|
expect(el?.getAttribute("data-x")).toBe("100");
|
|
expect(el?.getAttribute("data-y")).toBe("200");
|
|
expect(result.forward.some((p) => p.path.endsWith("/data-x"))).toBe(true);
|
|
expect(result.forward.some((p) => p.path.endsWith("/data-y"))).toBe(true);
|
|
});
|
|
|
|
it("inverse restores prior data-x/data-y", () => {
|
|
const parsed = fresh();
|
|
const el = parsed.document.querySelector('[data-hf-id="hf-title"]') as Element;
|
|
el.setAttribute("data-x", "50");
|
|
el.setAttribute("data-y", "75");
|
|
const result = applyOp(parsed, { type: "moveElement", target: "hf-title", x: 100, y: 200 });
|
|
applyPatchesToDocument(parsed, result.inverse);
|
|
expect(el.getAttribute("data-x")).toBe("50");
|
|
expect(el.getAttribute("data-y")).toBe("75");
|
|
});
|
|
});
|
|
|
|
// ─── validateOp (can()) ───────────────────────────────────────────────────────
|
|
|
|
describe("validateOp", () => {
|
|
it("returns ok:true for existing element", () => {
|
|
expect(validateOp(fresh(), { type: "setStyle", target: "hf-title", styles: {} }).ok).toBe(true);
|
|
});
|
|
|
|
it("returns ok:false / E_TARGET_NOT_FOUND for unknown element id", () => {
|
|
const r = validateOp(fresh(), { type: "setStyle", target: "hf-unknown", styles: {} });
|
|
expect(r.ok).toBe(false);
|
|
if (!r.ok) expect(r.code).toBe("E_TARGET_NOT_FOUND");
|
|
});
|
|
|
|
it("returns ok:true for setCompositionMetadata (no target)", () => {
|
|
expect(validateOp(fresh(), { type: "setCompositionMetadata", width: 100 }).ok).toBe(true);
|
|
});
|
|
});
|
|
|
|
// ─── Phase 3b ops — graceful when no GSAP script, feature-detectable ────────
|
|
|
|
describe("Phase 3b ops", () => {
|
|
it("applyOp throws when no GSAP script is present", () => {
|
|
expect(() =>
|
|
applyOp(fresh(), {
|
|
type: "addGsapTween",
|
|
target: "hf-title",
|
|
tween: { method: "from", properties: { opacity: 0 } },
|
|
}),
|
|
).toThrow();
|
|
});
|
|
|
|
it("validateOp returns ok:false / E_NO_GSAP_SCRIPT when no GSAP script present", () => {
|
|
const r1 = validateOp(fresh(), { type: "removeGsapTween", animationId: "tw-1" });
|
|
expect(r1.ok).toBe(false);
|
|
if (!r1.ok) expect(r1.code).toBe("E_NO_GSAP_SCRIPT");
|
|
const r2 = validateOp(fresh(), {
|
|
type: "addGsapTween",
|
|
target: "hf-title",
|
|
tween: { method: "from", properties: { opacity: 0 } },
|
|
});
|
|
expect(r2.ok).toBe(false);
|
|
if (!r2.ok) expect(r2.code).toBe("E_NO_GSAP_SCRIPT");
|
|
});
|
|
|
|
it("unrollDynamicAnimations rejects an empty element list (would delete the animation)", () => {
|
|
const parsed = parseMutable(
|
|
`<div data-hf-id="hf-r" data-hf-root></div>` +
|
|
`<script>var tl = gsap.timeline({ paused: true }); tl.to("#x", { x: 1 }, 0);</script>`,
|
|
);
|
|
const r = validateOp(parsed, {
|
|
type: "unrollDynamicAnimations",
|
|
animationId: "#x-to-0-position",
|
|
elements: [],
|
|
});
|
|
expect(r.ok).toBe(false);
|
|
if (!r.ok) expect(r.code).toBe("E_INVALID_ARGS");
|
|
});
|
|
|
|
it("materializeKeyframes rejects an empty keyframe list (would empty the animation)", () => {
|
|
const parsed = parseMutable(
|
|
`<div data-hf-id="hf-r" data-hf-root></div>` +
|
|
`<script>var tl = gsap.timeline({ paused: true }); tl.to("#x", { x: 1 }, 0);</script>`,
|
|
);
|
|
const r = validateOp(parsed, {
|
|
type: "materializeKeyframes",
|
|
animationId: "#x-to-0-position",
|
|
keyframes: [],
|
|
});
|
|
expect(r.ok).toBe(false);
|
|
if (!r.ok) expect(r.code).toBe("E_INVALID_ARGS");
|
|
});
|
|
|
|
it("setClassStyle no longer throws — implemented in Phase 3b", () => {
|
|
expect(() =>
|
|
applyOp(fresh(), {
|
|
type: "setClassStyle",
|
|
selector: ".box",
|
|
styles: { color: "red" },
|
|
}),
|
|
).not.toThrow();
|
|
});
|
|
});
|
|
|
|
// ─── setCompositionMetadata — data-width/data-height forced override ─────────
|
|
|
|
describe("setCompositionMetadata data-* channel", () => {
|
|
const ATTR_HTML = `
|
|
<div data-hf-id="hf-stage" data-hf-root data-width="1280" data-height="720" style="width: 1280px; height: 720px">
|
|
<h1 data-hf-id="hf-title">Hi</h1>
|
|
</div>
|
|
`.trim();
|
|
|
|
it("updates data-width/data-height when the composition carries them", () => {
|
|
const parsed = parseMutable(ATTR_HTML);
|
|
applyOp(parsed, { type: "setCompositionMetadata", width: 1920, height: 1080 });
|
|
const root = parsed.document.querySelector("[data-hf-root]");
|
|
expect(root?.getAttribute("data-width")).toBe("1920");
|
|
expect(root?.getAttribute("data-height")).toBe("1080");
|
|
expect(root?.getAttribute("style")).toContain("width: 1920px");
|
|
});
|
|
|
|
it("inverse restores both channels", () => {
|
|
const parsed = parseMutable(ATTR_HTML);
|
|
const before = serializeDocument(parsed);
|
|
const { inverse } = applyOp(parsed, { type: "setCompositionMetadata", width: 1920 });
|
|
applyPatchesToDocument(parsed, inverse);
|
|
expect(serializeDocument(parsed)).toBe(before);
|
|
});
|
|
|
|
it("does not mint data-* attributes on compositions without them", () => {
|
|
const parsed = fresh();
|
|
applyOp(parsed, { type: "setCompositionMetadata", width: 1920 });
|
|
const root = parsed.document.querySelector("[data-hf-root]");
|
|
expect(root?.hasAttribute("data-width")).toBe(false);
|
|
expect(root?.getAttribute("style")).toContain("width: 1920px");
|
|
});
|
|
});
|
|
|
|
// ─── reorderElements ─────────────────────────────────────────────────────────
|
|
|
|
describe("reorderElements", () => {
|
|
it("sets zIndex on each entry", () => {
|
|
const parsed = fresh();
|
|
applyOp(parsed, {
|
|
type: "reorderElements",
|
|
entries: [
|
|
{ target: "hf-title", zIndex: 2 },
|
|
{ target: "hf-logo", zIndex: 1 },
|
|
],
|
|
});
|
|
const title = parsed.document.querySelector("[data-hf-id='hf-title']") as HTMLElement | null;
|
|
const logo = parsed.document.querySelector("[data-hf-id='hf-logo']") as HTMLElement | null;
|
|
expect(title?.style.zIndex).toBe("2");
|
|
expect(logo?.style.zIndex).toBe("1");
|
|
});
|
|
|
|
it("inverse restores original zIndex values", () => {
|
|
const parsed = fresh();
|
|
const before = serializeDocument(parsed);
|
|
const { inverse } = applyOp(parsed, {
|
|
type: "reorderElements",
|
|
entries: [{ target: "hf-title", zIndex: 5 }],
|
|
});
|
|
applyPatchesToDocument(parsed, inverse);
|
|
expect(serializeDocument(parsed)).toBe(before);
|
|
});
|
|
|
|
it("validateOp returns ok:true for existing targets", () => {
|
|
const r = validateOp(fresh(), {
|
|
type: "reorderElements",
|
|
entries: [{ target: "hf-title", zIndex: 1 }],
|
|
});
|
|
expect(r.ok).toBe(true);
|
|
});
|
|
|
|
it("validateOp returns E_TARGET_NOT_FOUND for unknown target", () => {
|
|
const r = validateOp(fresh(), {
|
|
type: "reorderElements",
|
|
entries: [{ target: "hf-unknown", zIndex: 1 }],
|
|
});
|
|
expect(r.ok).toBe(false);
|
|
if (!r.ok) expect(r.code).toBe("E_TARGET_NOT_FOUND");
|
|
});
|
|
|
|
it("duplicate target collapses to last-wins and inverse restores cleanly", () => {
|
|
const parsed = fresh();
|
|
const before = serializeDocument(parsed);
|
|
const { forward, inverse } = applyOp(parsed, {
|
|
type: "reorderElements",
|
|
entries: [
|
|
{ target: "hf-title", zIndex: 2 },
|
|
{ target: "hf-title", zIndex: 9 },
|
|
],
|
|
});
|
|
const title = parsed.document.querySelector("[data-hf-id='hf-title']") as HTMLElement | null;
|
|
expect(title?.style.zIndex).toBe("9"); // last write wins
|
|
expect(forward.length).toBe(1); // one patch, not two on the same path
|
|
// Inverse must be applied in reverse order (session reverses single-dispatch
|
|
// inverse) to land back on the original, not the intermediate "2".
|
|
applyPatchesToDocument(parsed, [...inverse].reverse());
|
|
expect(serializeDocument(parsed)).toBe(before);
|
|
});
|
|
});
|