From a890d093e19ce9c51183af741b5e335d9348bc3c Mon Sep 17 00:00:00 2001 From: Vance Ingalls Date: Sun, 7 Jun 2026 10:38:48 -0700 Subject: [PATCH] test(core): add T2 stable id spec for parse-to-hf id contract (before R1) (#1245) * test(studio): add T4 op-contract stubs for editor dispatch boundary * test(core): add T2 stable id spec for parse-to-hf id contract (before R1) * test(core): mark pre-R1 spec tests as it.fails so CI passes The three [spec] tests document intended R1 behavior that the parser does not yet implement. Using it.fails() makes them green while the spec is pre-R1; they will flip red again once R1 lands and starts returning hf- prefixed ids. Co-Authored-By: Claude Sonnet 4.6 --------- Co-authored-by: Claude Sonnet 4.6 --- packages/core/src/parsers/stableIds.test.ts | 134 ++++++++++++++++++++ 1 file changed, 134 insertions(+) create mode 100644 packages/core/src/parsers/stableIds.test.ts diff --git a/packages/core/src/parsers/stableIds.test.ts b/packages/core/src/parsers/stableIds.test.ts new file mode 100644 index 000000000..757cb23e0 --- /dev/null +++ b/packages/core/src/parsers/stableIds.test.ts @@ -0,0 +1,134 @@ +/** + * @vitest-environment jsdom + * + * T2 — Stable id spec (spec for R1). + * + * These tests define what "stable hf- id" means BEFORE R1 implements it. + * They are intentionally red until R1 lands. + * + * Currently failing (spec): tests 1, 2, 3 — parser assigns `element-N` not `hf-xxxx`. + * Currently passing (baseline): tests 4, 5, 6, 7 — these already hold and must not regress. + * + * Scope: id assignment and stability only. Round-trip fidelity is T1 territory. + */ +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, + }); +} + +describe("T2 — stable element ids (spec for R1)", () => { + // --- Spec (red until R1) --- + + it.fails("[spec] elements without an id get a hf- prefixed id at parse", () => { + const html = `
+ +
Text
+
`; + const { elements } = parseHtml(html); + for (const el of elements) { + expect(el.id).toMatch(/^hf-/); + } + }); + + it.fails("[spec] generated hf- ids match /^hf-[a-z0-9]{4}$/", () => { + const html = `
+
X
+ +
`; + const { elements } = parseHtml(html); + const noPreExistingId = elements.filter((e) => e.id !== "stage"); + for (const el of noPreExistingId) { + expect(el.id).toMatch(/^hf-[a-z0-9]{4}$/); + } + }); + + it.fails("[spec] adding an element before existing ones does not change existing ids", () => { + const base = `
+
A
+
B
+
`; + const withPrepend = `
+
New
+
A
+
B
+
`; + const baseAlpha = parseHtml(base).elements.find((e) => e.name === "AlphaEl"); + const extendedAlpha = parseHtml(withPrepend).elements.find((e) => e.name === "AlphaEl"); + expect(baseAlpha).toBeDefined(); + expect(extendedAlpha).toBeDefined(); + // With counter-based ids: base AlphaEl = element-1, extended AlphaEl = element-2 — FAILS. + // With hf- stable ids: both = same hf-xxxx — PASSES (R1 target). + expect(extendedAlpha?.id).toBe(baseAlpha?.id); + }); + + // --- Baseline (already pass, must not regress) --- + + it("elements with an existing id keep it unchanged", () => { + const html = `
+
Hi
+
`; + const { elements } = parseHtml(html); + expect(elements.some((e) => e.id === "my-title")).toBe(true); + }); + + it("ids are deterministic: same input produces same ids on re-parse", () => { + const html = `
+
A
+
B
+
`; + const first = parseHtml(html).elements.map((e) => e.id); + const second = parseHtml(html).elements.map((e) => e.id); + expect(first).toEqual(second); + }); + + it("ids are unique within a document", () => { + const html = `
+
A
+
B
+
C
+
`; + const ids = parseHtml(html).elements.map((e) => e.id); + expect(new Set(ids).size).toBe(ids.length); + }); + + it("two elements with identical markup get distinct ids (no content-hash collision)", () => { + // Ensures R1's id derivation includes position or a sibling counter, + // not just content — two structurally identical elements must not collide. + const html = `
+
Same
+
Same
+
`; + const { elements } = parseHtml(html); + const ids = elements.map((e) => e.id); + expect(new Set(ids).size).toBe(ids.length); + }); + + it("ids survive a serialize → re-parse round-trip", () => { + const html = `
+
Content
+ +
`; + const original = parseHtml(html); + const reparsed = parseHtml(serialize(original)); + const origIds = original.elements.map((e) => e.id).sort(); + const roundIds = reparsed.elements.map((e) => e.id).sort(); + expect(roundIds).toEqual(origIds); + }); + + it.todo("sub-composition instances get scoped ids (compositionId/hf-x) — requires SDK session"); +});