From 212dcf942f7caac6427e56db4f644617832d1027 Mon Sep 17 00:00:00 2001 From: Vance Ingalls Date: Sat, 6 Jun 2026 13:30:14 -0700 Subject: [PATCH] test(core): add T1 round-trip idempotence suite for parse/serialize --- .../src/parsers/htmlParser.roundtrip.test.ts | 146 ++++++++++++++++++ 1 file changed, 146 insertions(+) create mode 100644 packages/core/src/parsers/htmlParser.roundtrip.test.ts diff --git a/packages/core/src/parsers/htmlParser.roundtrip.test.ts b/packages/core/src/parsers/htmlParser.roundtrip.test.ts new file mode 100644 index 000000000..a1df46c29 --- /dev/null +++ b/packages/core/src/parsers/htmlParser.roundtrip.test.ts @@ -0,0 +1,146 @@ +/** + * @vitest-environment jsdom + * + * T1 — parse→serialize round-trip (DOM/timing model only). + * Scope: GSAP script fidelity is T6 territory; these tests cover element structure and timing. + */ +import { describe, it, expect } from "vitest"; +import { existsSync, readdirSync, readFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +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 { + // 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", () => { + const html = ` +
+
Hello
+
World
+
+ `; + const parsed = parseHtml(html); + const reparsed = parseHtml(serialize(parsed)); + + expect(reparsed.elements).toHaveLength(parsed.elements.length); + expect(reparsed.elements.map((e) => e.id)).toEqual(parsed.elements.map((e) => e.id)); + }); + + it("preserves startTime and duration through one round-trip", () => { + const html = ` +
+ +
Foo
+
+ `; + const parsed = parseHtml(html); + const reparsed = parseHtml(serialize(parsed)); + + for (const orig of parsed.elements) { + const round = reparsed.elements.find((e) => e.id === orig.id); + expect(round).toBeDefined(); + expect(round?.startTime).toBe(orig.startTime); + expect(round?.duration).toBe(orig.duration); + } + }); + + it("preserves element types through one round-trip", () => { + const html = ` +
+
Hi
+ + + +
+ `; + const parsed = parseHtml(html); + const reparsed = parseHtml(serialize(parsed)); + + for (const orig of parsed.elements) { + const round = reparsed.elements.find((e) => e.id === orig.id); + expect(round).toBeDefined(); + expect(round?.type).toBe(orig.type); + } + }); + + it("is stable — serialize(parse(serialize(parse(html)))) equals serialize(parse(html))", () => { + const html = ` +
+ + +
+ `; + const parsed = parseHtml(html); + const once = serialize(parsed); + const twice = serialize(parseHtml(once)); + expect(twice).toBe(once); + }); + + it("handles an empty stage without throwing", () => { + const html = `
`; + const parsed = parseHtml(html); + expect(() => serialize(parsed)).not.toThrow(); + const reparsed = parseHtml(serialize(parsed)); + expect(reparsed.elements).toHaveLength(0); + }); +}); + +describe("T1 — registry block round-trips (DOM/timing)", () => { + const BLOCKS_DIR = join(dirname(fileURLToPath(import.meta.url)), "../../../../registry/blocks"); + + const blockNames = existsSync(BLOCKS_DIR) + ? readdirSync(BLOCKS_DIR, { withFileTypes: true }) + .filter((d) => d.isDirectory()) + .map((d) => d.name) + : []; + + for (const name of blockNames) { + it(`${name}: round-trip preserves element count`, () => { + const blockFile = join(BLOCKS_DIR, name, `${name}.html`); + expect(existsSync(blockFile)).toBe(true); + const html = readFileSync(blockFile, "utf8"); + const parsed = parseHtml(html); + const reparsed = parseHtml(serialize(parsed)); + expect(reparsed.elements).toHaveLength(parsed.elements.length); + }); + } + + // GSAP smoke: verify the includeScripts path survives parse→generate on a block known + // to contain