diff --git a/lefthook.yml b/lefthook.yml index e6d83e199..05e77d2da 100644 --- a/lefthook.yml +++ b/lefthook.yml @@ -19,7 +19,10 @@ pre-commit: # fails on issues introduced by the branch, not inherited findings. fallow: glob: "packages/**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}" - run: bunx fallow audit --base origin/main --fail-on-issues + # Unset git worktree env vars: fallow creates a temp worktree internally and + # GIT_DIR/GIT_INDEX_FILE (set by git in worktree hook context) break that. + # env -u is safe in non-worktree contexts (no-op when var is unset). + run: env -u GIT_DIR -u GIT_INDEX_FILE -u GIT_WORK_TREE bunx fallow audit --base origin/main --fail-on-issues filesize: # Scoped to packages/studio — the 600 LOC limit is a studio architecture # standard enforced as part of the App.tsx decomposition work. Player and diff --git a/packages/core/src/parsers/htmlParser.roundtrip.test.ts b/packages/core/src/parsers/htmlParser.roundtrip.test.ts index a1df46c29..975ee6db3 100644 --- a/packages/core/src/parsers/htmlParser.roundtrip.test.ts +++ b/packages/core/src/parsers/htmlParser.roundtrip.test.ts @@ -9,25 +9,8 @@ import { existsSync, readdirSync, readFileSync } from "node:fs"; import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; import { parseHtml } from "./htmlParser.js"; +import { maxEndTime, serialize } from "./test-utils.js"; import { generateHyperframesHtml } from "../generators/hyperframes.js"; -import type { ParsedHtml } from "./htmlParser.js"; - -function maxEndTime(elements: ParsedHtml["elements"]): number { - if (elements.length === 0) return 0; - return Math.max(...elements.map((e) => e.startTime + e.duration)); -} - -function serialize(parsed: ParsedHtml): string { - // Fixed compositionId prevents Date.now() churn from masking structural instability. - // The compositionId generation instability itself is tracked as R1 (stable hf- ids). - return generateHyperframesHtml(parsed.elements, maxEndTime(parsed.elements), { - compositionId: "test-comp", - resolution: parsed.resolution, - styles: parsed.styles ?? undefined, - keyframes: parsed.keyframes, - stageZoomKeyframes: parsed.stageZoomKeyframes, - }); -} describe("T1 — parse→serialize round-trip (DOM/timing)", () => { it("preserves element count and ids through one round-trip", () => { diff --git a/packages/core/src/parsers/stableIds.test.ts b/packages/core/src/parsers/stableIds.test.ts index 757cb23e0..a3887f04f 100644 --- a/packages/core/src/parsers/stableIds.test.ts +++ b/packages/core/src/parsers/stableIds.test.ts @@ -13,23 +13,7 @@ */ import { describe, expect, it } from "vitest"; import { parseHtml } from "./htmlParser.js"; -import { generateHyperframesHtml } from "../generators/hyperframes.js"; -import type { ParsedHtml } from "./htmlParser.js"; - -function maxEndTime(elements: ParsedHtml["elements"]): number { - if (elements.length === 0) return 0; - return Math.max(...elements.map((e) => e.startTime + e.duration)); -} - -function serialize(parsed: ParsedHtml): string { - return generateHyperframesHtml(parsed.elements, maxEndTime(parsed.elements), { - compositionId: "test-comp", - resolution: parsed.resolution, - styles: parsed.styles ?? undefined, - keyframes: parsed.keyframes, - stageZoomKeyframes: parsed.stageZoomKeyframes, - }); -} +import { serialize } from "./test-utils.js"; describe("T2 — stable element ids (spec for R1)", () => { // --- Spec (red until R1) --- diff --git a/packages/core/src/parsers/test-utils.ts b/packages/core/src/parsers/test-utils.ts new file mode 100644 index 000000000..568b55004 --- /dev/null +++ b/packages/core/src/parsers/test-utils.ts @@ -0,0 +1,28 @@ +/** + * Shared test utilities for parser test suites (T1, T2, T6…). + * Import from here rather than duplicating helpers across test files. + * + * Not part of the public package exports — consumed only by *.test.ts files. + */ +import { generateHyperframesHtml } from "../generators/hyperframes.js"; +import type { ParsedHtml } from "./htmlParser.js"; + +export function maxEndTime(elements: ParsedHtml["elements"]): number { + if (elements.length === 0) return 0; + return Math.max(...elements.map((e) => e.startTime + e.duration)); +} + +/** + * Round-trip serialize helper. + * Fixed compositionId prevents Date.now() churn from masking structural instability. + * The compositionId generation instability itself is tracked as R1 (stable hf- ids). + */ +export function serialize(parsed: ParsedHtml): string { + return generateHyperframesHtml(parsed.elements, maxEndTime(parsed.elements), { + compositionId: "test-comp", + resolution: parsed.resolution, + styles: parsed.styles ?? undefined, + keyframes: parsed.keyframes, + stageZoomKeyframes: parsed.stageZoomKeyframes, + }); +} diff --git a/packages/core/tsconfig.json b/packages/core/tsconfig.json index cdd189fde..e1ef706c1 100644 --- a/packages/core/tsconfig.json +++ b/packages/core/tsconfig.json @@ -15,5 +15,12 @@ }, "files": ["src/runtime/mediaVolumeEnvelope.ts"], "include": ["src/**/*"], - "exclude": ["node_modules", "dist", "src/tests", "src/runtime", "**/*.test.ts"] + "exclude": [ + "node_modules", + "dist", + "src/tests", + "src/runtime", + "**/*.test.ts", + "src/parsers/test-utils.ts" + ] } diff --git a/packages/studio/src/components/editor/manualEditsDomPatches.test.ts b/packages/studio/src/components/editor/manualEditsDomPatches.test.ts index 028c2600d..428315829 100644 --- a/packages/studio/src/components/editor/manualEditsDomPatches.test.ts +++ b/packages/studio/src/components/editor/manualEditsDomPatches.test.ts @@ -7,8 +7,11 @@ import { STUDIO_OFFSET_Y_PROP, STUDIO_WIDTH_PROP, STUDIO_HEIGHT_PROP, + STUDIO_ROTATION_PROP, STUDIO_PATH_OFFSET_ATTR, STUDIO_BOX_SIZE_ATTR, + STUDIO_ROTATION_ATTR, + STUDIO_ROTATION_DRAFT_ATTR, STUDIO_ORIGINAL_TRANSLATE_ATTR, STUDIO_ORIGINAL_INLINE_TRANSLATE_ATTR, STUDIO_ORIGINAL_WIDTH_ATTR, @@ -24,13 +27,26 @@ import { STUDIO_ORIGINAL_SCALE_ATTR, STUDIO_ORIGINAL_TRANSFORM_ORIGIN_ATTR, STUDIO_ORIGINAL_DISPLAY_ATTR, + STUDIO_ORIGINAL_ROTATE_ATTR, + STUDIO_ORIGINAL_INLINE_ROTATE_ATTR, + STUDIO_ORIGINAL_ROTATION_TRANSFORM_ORIGIN_ATTR, STUDIO_ORIGINAL_TRANSFORM_DISPLAY_ATTR, } from "./manualEditsTypes"; +import { + STUDIO_MOTION_ATTR, + STUDIO_MOTION_ORIGINAL_TRANSFORM_ATTR, + STUDIO_MOTION_ORIGINAL_OPACITY_ATTR, + STUDIO_MOTION_ORIGINAL_VISIBILITY_ATTR, +} from "./studioMotionTypes"; import { buildPathOffsetPatches, buildClearPathOffsetPatches, buildBoxSizePatches, buildClearBoxSizePatches, + buildRotationPatches, + buildClearRotationPatches, + buildMotionPatches, + buildClearMotionPatches, } from "./manualEditsDomPatches"; /* ── helpers ── */ @@ -102,6 +118,13 @@ describe("buildPathOffsetPatches / buildClearPathOffsetPatches", () => { ]); }); + it("clear: empty STUDIO_ORIGINAL_INLINE_TRANSLATE_ATTR coerces to null (translate not set to empty string)", () => { + const e = div(); + e.setAttribute(STUDIO_ORIGINAL_INLINE_TRANSLATE_ATTR, ""); + const ops = buildClearPathOffsetPatches(e); + expect(ops.find((o) => o.property === "translate")?.value).toBeNull(); + }); + it("build/clear symmetry: clear addresses every {type,property} key that build emits", () => { const e = populatedPathEl(); assertClearCoversKeys(buildPathOffsetPatches(e), buildClearPathOffsetPatches(e)); @@ -187,22 +210,54 @@ describe("buildBoxSizePatches / buildClearBoxSizePatches", () => { ]); }); - it("clear: restores width and height from orig attrs, nulls all orig attrs", () => { + it("clear(populated): ops follow interleaved restore-then-null order for every orig attr", () => { + const ops = buildClearBoxSizePatches(populatedBoxEl()); + expect(ops).toEqual([ + { type: "inline-style", property: STUDIO_WIDTH_PROP, value: null }, + { type: "inline-style", property: STUDIO_HEIGHT_PROP, value: null }, + { type: "attribute", property: STUDIO_BOX_SIZE_ATTR, value: null }, + { type: "inline-style", property: "width", value: "250px" }, + { type: "attribute", property: STUDIO_ORIGINAL_WIDTH_ATTR, value: null }, + { type: "inline-style", property: "height", value: "150px" }, + { type: "attribute", property: STUDIO_ORIGINAL_HEIGHT_ATTR, value: null }, + { type: "inline-style", property: "min-width", value: "0px" }, + { type: "attribute", property: STUDIO_ORIGINAL_MIN_WIDTH_ATTR, value: null }, + { type: "inline-style", property: "min-height", value: "0px" }, + { type: "attribute", property: STUDIO_ORIGINAL_MIN_HEIGHT_ATTR, value: null }, + { type: "inline-style", property: "max-width", value: "none" }, + { type: "attribute", property: STUDIO_ORIGINAL_MAX_WIDTH_ATTR, value: null }, + { type: "inline-style", property: "max-height", value: "none" }, + { type: "attribute", property: STUDIO_ORIGINAL_MAX_HEIGHT_ATTR, value: null }, + { type: "inline-style", property: "flex-basis", value: "0px" }, + { type: "attribute", property: STUDIO_ORIGINAL_FLEX_BASIS_ATTR, value: null }, + { type: "inline-style", property: "flex-grow", value: "0" }, + { type: "attribute", property: STUDIO_ORIGINAL_FLEX_GROW_ATTR, value: null }, + { type: "inline-style", property: "flex-shrink", value: "1" }, + { type: "attribute", property: STUDIO_ORIGINAL_FLEX_SHRINK_ATTR, value: null }, + { type: "inline-style", property: "box-sizing", value: "content-box" }, + { type: "attribute", property: STUDIO_ORIGINAL_BOX_SIZING_ATTR, value: null }, + { type: "inline-style", property: "scale", value: "1" }, + { type: "attribute", property: STUDIO_ORIGINAL_SCALE_ATTR, value: null }, + { type: "inline-style", property: "transform-origin", value: "50% 50%" }, + { type: "attribute", property: STUDIO_ORIGINAL_TRANSFORM_ORIGIN_ATTR, value: null }, + { type: "inline-style", property: "display", value: "flex" }, + { type: "attribute", property: STUDIO_ORIGINAL_DISPLAY_ATTR, value: null }, + { type: "attribute", property: STUDIO_ORIGINAL_TRANSFORM_DISPLAY_ATTR, value: null }, + ]); + }); + + it("clear: empty orig attr coerces to null (style is removed rather than set to empty string)", () => { const e = div(); - e.setAttribute(STUDIO_ORIGINAL_WIDTH_ATTR, "200px"); - e.setAttribute(STUDIO_ORIGINAL_HEIGHT_ATTR, "100px"); + e.setAttribute(STUDIO_ORIGINAL_WIDTH_ATTR, ""); const ops = buildClearBoxSizePatches(e); - expect(ops).toEqual( - expect.arrayContaining([ - { type: "inline-style", property: STUDIO_WIDTH_PROP, value: null }, - { type: "inline-style", property: STUDIO_HEIGHT_PROP, value: null }, - { type: "attribute", property: STUDIO_BOX_SIZE_ATTR, value: null }, - { type: "inline-style", property: "width", value: "200px" }, - { type: "attribute", property: STUDIO_ORIGINAL_WIDTH_ATTR, value: null }, - { type: "inline-style", property: "height", value: "100px" }, - { type: "attribute", property: STUDIO_ORIGINAL_HEIGHT_ATTR, value: null }, - ]), - ); + expect(ops.find((o) => o.property === "width")?.value).toBeNull(); + }); + + it("clear: bare element emits only null ops — no style restores fire when orig attrs are absent", () => { + const ops = buildClearBoxSizePatches(div()); + // 3 fixed (studio-width, studio-height, box-size marker) + 14 attr-null pushes (one per BOX_SIZE_ORIG_ATTR) + expect(ops).toHaveLength(17); + expect(ops.every((op) => op.value === null)).toBe(true); }); it("build/clear symmetry: clear addresses every {type,property} key that build emits", () => { @@ -210,3 +265,131 @@ describe("buildBoxSizePatches / buildClearBoxSizePatches", () => { assertClearCoversKeys(buildBoxSizePatches(e), buildClearBoxSizePatches(e)); }); }); + +/* ── Rotation ────────────────────────────────────────────────────────────── */ + +describe("buildRotationPatches / buildClearRotationPatches", () => { + function populatedRotEl(): HTMLElement { + const e = div(); + e.style.setProperty(STUDIO_ROTATION_PROP, "45"); + e.style.setProperty("rotate", "45deg"); + e.style.setProperty("transform-origin", "left center"); + e.style.setProperty("display", "block"); + e.setAttribute(STUDIO_ORIGINAL_ROTATE_ATTR, "0deg"); + e.setAttribute(STUDIO_ORIGINAL_INLINE_ROTATE_ATTR, "0deg"); + e.setAttribute(STUDIO_ORIGINAL_ROTATION_TRANSFORM_ORIGIN_ATTR, "center center"); + e.setAttribute(STUDIO_ORIGINAL_TRANSFORM_DISPLAY_ATTR, "flex"); + return e; + } + + it("populated: captures rotation styles, attrs, and transform-display marker in declaration order", () => { + const ops = buildRotationPatches(populatedRotEl()); + expect(ops).toEqual([ + { type: "inline-style", property: STUDIO_ROTATION_PROP, value: "45" }, + { type: "inline-style", property: "rotate", value: "45deg" }, + { type: "inline-style", property: "transform-origin", value: "left center" }, + { type: "inline-style", property: "display", value: "block" }, + { type: "attribute", property: STUDIO_ROTATION_ATTR, value: "true" }, + { type: "attribute", property: STUDIO_ORIGINAL_ROTATE_ATTR, value: "0deg" }, + { type: "attribute", property: STUDIO_ORIGINAL_INLINE_ROTATE_ATTR, value: "0deg" }, + { + type: "attribute", + property: STUDIO_ORIGINAL_ROTATION_TRANSFORM_ORIGIN_ATTR, + value: "center center", + }, + { type: "attribute", property: STUDIO_ORIGINAL_TRANSFORM_DISPLAY_ATTR, value: "flex" }, + ]); + }); + + it("empty: bare element yields only the rotation marker", () => { + expect(buildRotationPatches(div())).toEqual([ + { type: "attribute", property: STUDIO_ROTATION_ATTR, value: "true" }, + ]); + }); + + it("clear: restores rotate and transform-origin from orig attrs, nulls draft attr", () => { + const e = div(); + e.setAttribute(STUDIO_ORIGINAL_INLINE_ROTATE_ATTR, "30deg"); + e.setAttribute(STUDIO_ORIGINAL_ROTATION_TRANSFORM_ORIGIN_ATTR, "top left"); + e.setAttribute(STUDIO_ORIGINAL_TRANSFORM_DISPLAY_ATTR, "grid"); + const ops = buildClearRotationPatches(e); + expect(ops).toEqual([ + { type: "inline-style", property: STUDIO_ROTATION_PROP, value: null }, + { type: "inline-style", property: "rotate", value: "30deg" }, + { type: "inline-style", property: "transform-origin", value: "top left" }, + { type: "attribute", property: STUDIO_ROTATION_ATTR, value: null }, + { type: "attribute", property: STUDIO_ROTATION_DRAFT_ATTR, value: null }, + { type: "attribute", property: STUDIO_ORIGINAL_ROTATE_ATTR, value: null }, + { type: "attribute", property: STUDIO_ORIGINAL_INLINE_ROTATE_ATTR, value: null }, + { type: "attribute", property: STUDIO_ORIGINAL_ROTATION_TRANSFORM_ORIGIN_ATTR, value: null }, + { type: "inline-style", property: "display", value: "grid" }, + { type: "attribute", property: STUDIO_ORIGINAL_TRANSFORM_DISPLAY_ATTR, value: null }, + ]); + }); + + it("clear: absent STUDIO_ORIGINAL_ROTATION_TRANSFORM_ORIGIN_ATTR yields null for transform-origin", () => { + const ops = buildClearRotationPatches(div()); + expect(ops.find((o) => o.property === "transform-origin")?.value).toBeNull(); + }); + + it("clear: empty STUDIO_ORIGINAL_INLINE_ROTATE_ATTR coerces to null (rotate not set to empty string)", () => { + const e = div(); + e.setAttribute(STUDIO_ORIGINAL_INLINE_ROTATE_ATTR, ""); + const ops = buildClearRotationPatches(e); + expect(ops.find((o) => o.property === "rotate")?.value).toBeNull(); + }); + + it("build/clear symmetry: clear addresses every {type,property} key that build emits", () => { + const e = populatedRotEl(); + assertClearCoversKeys(buildRotationPatches(e), buildClearRotationPatches(e)); + }); +}); + +/* ── Motion ──────────────────────────────────────────────────────────────── */ + +describe("buildMotionPatches / buildClearMotionPatches", () => { + const MOTION_JSON = '{"kind":"gsap-motion","start":0,"duration":1}'; + + function populatedMotionEl(): HTMLElement { + const e = div(); + e.setAttribute(STUDIO_MOTION_ATTR, MOTION_JSON); + e.setAttribute(STUDIO_MOTION_ORIGINAL_TRANSFORM_ATTR, "translateX(0)"); + e.setAttribute(STUDIO_MOTION_ORIGINAL_OPACITY_ATTR, "1"); + e.setAttribute(STUDIO_MOTION_ORIGINAL_VISIBILITY_ATTR, "visible"); + return e; + } + + it("populated: captures motion JSON and all three original attrs when motion attr is present", () => { + const ops = buildMotionPatches(populatedMotionEl()); + expect(ops).toEqual([ + { type: "attribute", property: STUDIO_MOTION_ATTR, value: MOTION_JSON }, + { + type: "attribute", + property: STUDIO_MOTION_ORIGINAL_TRANSFORM_ATTR, + value: "translateX(0)", + }, + { type: "attribute", property: STUDIO_MOTION_ORIGINAL_OPACITY_ATTR, value: "1" }, + { type: "attribute", property: STUDIO_MOTION_ORIGINAL_VISIBILITY_ATTR, value: "visible" }, + ]); + }); + + it("empty: returns [] when STUDIO_MOTION_ATTR is absent", () => { + expect(buildMotionPatches(div())).toEqual([]); + }); + + it("clear: always nulls all four motion attrs regardless of element state", () => { + const expected = [ + { type: "attribute", property: STUDIO_MOTION_ATTR, value: null }, + { type: "attribute", property: STUDIO_MOTION_ORIGINAL_TRANSFORM_ATTR, value: null }, + { type: "attribute", property: STUDIO_MOTION_ORIGINAL_OPACITY_ATTR, value: null }, + { type: "attribute", property: STUDIO_MOTION_ORIGINAL_VISIBILITY_ATTR, value: null }, + ]; + expect(buildClearMotionPatches(div())).toEqual(expected); + expect(buildClearMotionPatches(populatedMotionEl())).toEqual(expected); + }); + + it("build/clear symmetry: clear addresses every {type,property} key that build emits", () => { + const e = populatedMotionEl(); + assertClearCoversKeys(buildMotionPatches(e), buildClearMotionPatches(e)); + }); +});