Files
hyperframes/packages/core/src/studio-api/helpers/previewAdapter.test.ts
T
Vance IngallsandClaude Sonnet 4.6 3d7d7c0291 refactor(core,studio): extract draft-marker constants + R7 code-review fixes (Task 4) (#1292)
* refactor(core,studio): extract draft-marker constants to core (R7, Task 4)

Create draftMarkers.ts in core with 5 shared CSS custom property names and the
gesture DOM attribute. PreviewAdapter imports from draftMarkers.ts instead of
hardcoding strings. Adds @hyperframes/core/studio-api/draft-markers export
subpath. Studio's manualEditsTypes.ts re-exports the shared constants from core
so all existing call sites are unchanged.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(core): address R7 code-review findings (C1–C14, P6–P7)

- previewAdapter: auto-revert previous gesture in applyDraft (C3); clearDraftProps
  on commitPreview not just revertDraft (C4); isVisible NaN→visible for JSDOM (P7);
  remove redundant GestureState.hfId field (C12); remove Array.from (C14);
  extract clearDraftProps/revertGesture helpers (C5/C6)
- hfIdPersist: replace string-equality change detection with attribute count to
  avoid false-positive writes on single-quoted HTML (C1); re-read disk before
  write for TOCTOU guard (C7); remove normalizeHfIds wrapper (C11)
- preview.ts: remove dead null-check on normalizedDisk after diskMain guard (C9);
  catch path re-reads disk fresh instead of using stale pre-request snapshot (C8)
- hfIds.test.ts: replace tautological second stability test with cross-document
  content-keyed id stability test (P6)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(core): follow-up R7 review fixes — CSS.escape fallback, invariant docs, new edge-case tests

- hfIdPersist: remove ensureHfIds re-export (P2); add JSDoc invariant note;
  improve TOCTOU comment; pass err to console.warn
- preview.ts: split import — ensureHfIds from parsers/hfIds.js (not re-export)
- previewAdapter: CSS.escape + inline fallback for non-browser environments;
  add JSDoc for atTime caller-seek contract; add 0.01 opacity-threshold comment
- previewAdapter.test: rename atTime test to clarify adapter-does-not-seek;
  add nested-hf-root-without-id test; add resize→move prop-leak test;
  add revertDraft-after-commit no-op test

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* test(core): bundle-vs-disk id-stability test; comment double ensureHfIds (P3)

- preview.test: add "bundle returning untagged HTML gets same ids as disk" test —
  guards against id divergence when bundler reads a pre-write cache snapshot;
  content-keyed FNV1a minting ensures served ids == disk ids for same source HTML
- preview.ts: comment the second ensureHfIds call explaining it's intentional for
  adapter-injected elements and idempotent on the no-bundle path (P3 from miguel)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* docs(core): wire-contract comment on mintHfId + fallow suppressions (R7)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 01:13:54 -07:00

258 lines
12 KiB
TypeScript

// fallow-ignore-file code-duplication
/**
* T10 — PreviewAdapter contract (spec for R7).
*
* Converted from it.todo stubs. These tests FAIL until Task 3 implements
* createPreviewAdapter in ./previewAdapter.ts.
*
* Position resolution: elementFromPoint is always null in jsdom. All
* elementAtPoint tests inject a resolvePoint stub so the contract tested
* is filtering logic (root exclusion, data-hf-id ancestor walk,
* opacity-at-playhead), not geometry.
*
* CSS custom property names used below mirror the Studio constants from
* manualEditsTypes.ts — they will be shared with the PreviewAdapter
* implementation once the draft-marker module moves to core (Task 4).
*/
import { describe, it, expect, beforeEach } from "vitest";
import { createPreviewAdapter } from "./previewAdapter.js";
// ── DOM helpers ────────────────────────────────────────────────────────────
beforeEach(() => {
document.body.innerHTML = "";
});
/** Create + append an element to body; optionally set attrs and inline styles. */
function make(
tag: string,
attrs: Record<string, string> = {},
styles: Record<string, string> = {},
): HTMLElement {
const elem = document.createElement(tag);
for (const [k, v] of Object.entries(attrs)) elem.setAttribute(k, v);
for (const [k, v] of Object.entries(styles)) elem.style.setProperty(k, v);
document.body.appendChild(elem);
return elem;
}
function adapterWith(resolvePoint: (x: number, y: number) => Element | null) {
return createPreviewAdapter(document, { resolvePoint });
}
// ── elementAtPoint ─────────────────────────────────────────────────────────
describe("T10 — PreviewAdapter contract (spec for R7)", () => {
describe("elementAtPoint", () => {
it("returns null for the stage root (data-hf-root)", () => {
const root = make("div", { "data-hf-root": "true" });
const adapter = adapterWith(() => root);
expect(adapter.elementAtPoint(0, 0)).toBeNull();
});
it("returns the nearest ancestor with data-hf-id", () => {
const parent = make("div", { "data-hf-id": "hf-abcd" });
const child = document.createElement("span");
parent.appendChild(child);
const adapter = adapterWith(() => child);
expect(adapter.elementAtPoint(0, 0)).toBe(parent);
});
it("returns null when the hit element has no data-hf-id ancestor", () => {
const orphan = make("div");
const adapter = adapterWith(() => orphan);
expect(adapter.elementAtPoint(0, 0)).toBeNull();
});
it("skips elements whose currently-computed opacity is 0 (atTime is a caller-seek hint, not evaluated by the adapter)", () => {
const elem = make("div", { "data-hf-id": "hf-zzzz" }, { opacity: "0" });
const adapter = adapterWith(() => elem);
expect(adapter.elementAtPoint(0, 0, { atTime: 1.0 })).toBeNull();
});
it("returns null for nested data-hf-root without data-hf-id (treated same as outer stage root)", () => {
const outerRoot = make("div", { "data-hf-root": "true" });
const innerRoot = document.createElement("div");
innerRoot.setAttribute("data-hf-root", "true");
// no data-hf-id — no explicit id means no draggable target
outerRoot.appendChild(innerRoot);
const adapter = adapterWith(() => innerRoot);
expect(adapter.elementAtPoint(0, 0)).toBeNull();
});
});
// ── applyDraft / revertDraft ───────────────────────────────────────────
describe("applyDraft / revertDraft", () => {
it("applyDraft writes --hf-studio-* CSS props and sets the gesture marker", () => {
const target = make("div", { "data-hf-id": "hf-aaaa" });
const adapter = adapterWith(() => null);
adapter.applyDraft({ type: "move", hfId: "hf-aaaa", dx: 10, dy: 20 });
expect(target.style.getPropertyValue("--hf-studio-offset-x")).not.toBe("");
expect(target.hasAttribute("data-hf-studio-manual-edit-gesture")).toBe(true);
});
it("applyDraft accepts a move payload (dx/dy) and writes the translate draft", () => {
const target = make("div", { "data-hf-id": "hf-aaaa" });
const adapter = adapterWith(() => null);
adapter.applyDraft({ type: "move", hfId: "hf-aaaa", dx: 30, dy: 15 });
expect(target.style.getPropertyValue("--hf-studio-offset-x")).toBe("30px");
expect(target.style.getPropertyValue("--hf-studio-offset-y")).toBe("15px");
});
it("applyDraft accepts a resize payload (w/h) and writes the size draft", () => {
const target = make("div", { "data-hf-id": "hf-aaaa" });
const adapter = adapterWith(() => null);
adapter.applyDraft({ type: "resize", hfId: "hf-aaaa", w: 200, h: 100 });
expect(target.style.getPropertyValue("--hf-studio-width")).toBe("200px");
expect(target.style.getPropertyValue("--hf-studio-height")).toBe("100px");
});
it("revertDraft removes draft props and clears the gesture marker", () => {
const target = make("div", { "data-hf-id": "hf-aaaa" });
const adapter = adapterWith(() => null);
adapter.applyDraft({ type: "move", hfId: "hf-aaaa", dx: 10, dy: 20 });
adapter.revertDraft();
expect(target.style.getPropertyValue("--hf-studio-offset-x")).toBe("");
expect(target.style.getPropertyValue("--hf-studio-offset-y")).toBe("");
expect(target.hasAttribute("data-hf-studio-manual-edit-gesture")).toBe(false);
});
it("revertDraft restores original translate when an original was recorded", () => {
const target = make("div", { "data-hf-id": "hf-aaaa" });
target.style.setProperty("translate", "50px 0px");
const adapter = adapterWith(() => null);
adapter.applyDraft({ type: "move", hfId: "hf-aaaa", dx: 10, dy: 0 });
adapter.revertDraft();
expect(target.style.getPropertyValue("translate")).toBe("50px 0px");
});
});
// ── edge cases ─────────────────────────────────────────────────────────
describe("applyDraft edge cases (R7 implementation contract)", () => {
it("second applyDraft before revert/commit overwrites first draft — does not accumulate (dx/dy)", () => {
const target = make("div", { "data-hf-id": "hf-aaaa" });
const adapter = adapterWith(() => null);
adapter.applyDraft({ type: "move", hfId: "hf-aaaa", dx: 10, dy: 20 });
adapter.applyDraft({ type: "move", hfId: "hf-aaaa", dx: 5, dy: 15 });
expect(target.style.getPropertyValue("--hf-studio-offset-x")).toBe("5px");
expect(target.style.getPropertyValue("--hf-studio-offset-y")).toBe("15px");
});
it("resize → move switch clears width/height props — no cross-type prop leak", () => {
const target = make("div", { "data-hf-id": "hf-aaaa" });
const adapter = adapterWith(() => null);
adapter.applyDraft({ type: "resize", hfId: "hf-aaaa", w: 200, h: 100 });
adapter.applyDraft({ type: "move", hfId: "hf-aaaa", dx: 10, dy: 5 });
// move props set
expect(target.style.getPropertyValue("--hf-studio-offset-x")).toBe("10px");
expect(target.style.getPropertyValue("--hf-studio-offset-y")).toBe("5px");
// resize props cleared by the auto-revert before re-apply
expect(target.style.getPropertyValue("--hf-studio-width")).toBe("");
expect(target.style.getPropertyValue("--hf-studio-height")).toBe("");
});
it("revertDraft after commitPreview is a no-op — does not restore stale translate", () => {
const target = make("div", { "data-hf-id": "hf-aaaa" });
target.style.setProperty("translate", "50px 0px");
const adapter = adapterWith(() => null);
adapter.applyDraft({ type: "move", hfId: "hf-aaaa", dx: 10, dy: 0 });
adapter.commitPreview();
// simulate caller applying translate after commit
target.style.setProperty("translate", "10px 0px");
adapter.revertDraft(); // no gesture in flight — should be no-op
expect(target.style.getPropertyValue("translate")).toBe("10px 0px");
});
it("revertDraft is safe to call when no gesture is in progress (idempotent / no-op on empty marker)", () => {
const adapter = adapterWith(() => null);
expect(() => adapter.revertDraft()).not.toThrow();
expect(() => adapter.revertDraft()).not.toThrow();
});
it("elementAtPoint filtering is stable when inline opacity changes mid-drag — computed style re-evaluated per call", () => {
const elem = make("div", { "data-hf-id": "hf-zzzz" });
const adapter = adapterWith(() => elem);
expect(adapter.elementAtPoint(0, 0)).toBe(elem);
// simulates GSAP seeking to a time where the element is hidden
elem.style.setProperty("opacity", "0");
expect(adapter.elementAtPoint(0, 0)).toBeNull();
});
it("stage-root exclusion applies only to the outermost data-hf-root; nested sub-composition roots count as targets", () => {
const outerRoot = make("div", { "data-hf-root": "true" });
const innerRoot = document.createElement("div");
innerRoot.setAttribute("data-hf-root", "true");
innerRoot.setAttribute("data-hf-id", "hf-sub1");
outerRoot.appendChild(innerRoot);
const adapterOuter = adapterWith(() => outerRoot);
expect(adapterOuter.elementAtPoint(0, 0)).toBeNull();
const adapterInner = adapterWith(() => innerRoot);
expect(adapterInner.elementAtPoint(0, 0)).toBe(innerRoot);
});
});
// ── commitPreview ──────────────────────────────────────────────────────
describe("commitPreview", () => {
it("returns null when no gesture marker is present", () => {
const adapter = adapterWith(() => null);
expect(adapter.commitPreview()).toBeNull();
});
it("derives a moveElement patch from draft markers on commit", () => {
make("div", { "data-hf-id": "hf-aaaa" });
const adapter = adapterWith(() => null);
adapter.applyDraft({ type: "move", hfId: "hf-aaaa", dx: 30, dy: 15 });
const patch = adapter.commitPreview();
expect(patch).toEqual({ type: "moveElement", hfId: "hf-aaaa", dx: 30, dy: 15 });
});
it("derives a resize patch from draft markers on commit", () => {
make("div", { "data-hf-id": "hf-aaaa" });
const adapter = adapterWith(() => null);
adapter.applyDraft({ type: "resize", hfId: "hf-aaaa", w: 200, h: 100 });
const patch = adapter.commitPreview();
expect(patch).toEqual({ type: "resize", hfId: "hf-aaaa", width: 200, height: 100 });
});
it("clears the gesture marker after commit", () => {
const target = make("div", { "data-hf-id": "hf-aaaa" });
const adapter = adapterWith(() => null);
adapter.applyDraft({ type: "move", hfId: "hf-aaaa", dx: 10, dy: 0 });
adapter.commitPreview();
expect(target.hasAttribute("data-hf-studio-manual-edit-gesture")).toBe(false);
});
});
// ── getElementTimings ──────────────────────────────────────────────────
describe("getElementTimings", () => {
it("reads authored absolute times from data-start / data-end", () => {
make("div", { "data-hf-id": "hf-t1", "data-start": "0.5", "data-end": "2.0" });
const adapter = adapterWith(() => null);
const timings = adapter.getElementTimings();
expect(timings["hf-t1"]).toEqual({ start: 0.5, end: 2.0 });
});
it("ignores elements without data-hf-id", () => {
make("div", { "data-start": "0.5", "data-end": "2.0" }); // no data-hf-id
const adapter = adapterWith(() => null);
const timings = adapter.getElementTimings();
expect(Object.keys(timings)).toHaveLength(0);
});
it("returns a defined timing entry when data-hf-id is present but data-start / data-end are missing", () => {
make("div", { "data-hf-id": "hf-notimed" });
const adapter = adapterWith(() => null);
const timings = adapter.getElementTimings();
expect(timings["hf-notimed"]).toBeDefined();
expect(timings["hf-notimed"].start).toBeUndefined();
expect(timings["hf-notimed"].end).toBeUndefined();
});
});
});