test(core): previewAdapter contract failing tests (T10 spec for R7)#1286
Conversation
f2deb71 to
f81528b
Compare
2bd6c0a to
df9947d
Compare
f81528b to
2cb29b1
Compare
2cb29b1 to
fa6996c
Compare
df9947d to
af39793
Compare
fa6996c to
c125ecc
Compare
4261480 to
9ba8ee2
Compare
c125ecc to
7d1bf83
Compare
miguel-heygen
left a comment
There was a problem hiding this comment.
Clean TDD red-phase setup. 20 real assertions that fail on the throw-stub and will pass once Task 3 lands — exactly the right shape for a spec PR.
One design note worth tracking into Task 3: elementAtPoint accepts opts?: { atTime?: number } but the contract tests don't verify that atTime triggers any time-seek on the GSAP player — they just verify live-opacity filtering (by setting el.style.opacity between calls). That's the right thing to test in isolation, but it means atTime semantics are unspecified in the contract. If the intent is to seek-then-read, that behavior should be captured in a getElementTimings or adapter-integration test before R7 ships.
Otherwise ✅ on the spec.
james-russo-rames-d-jusso
left a comment
There was a problem hiding this comment.
T10 spec is well-shaped — clean TDD red-phase. The conversion from it.todo stubs to 20 real assertions tightens the contract, and the test bodies are minimal-fixture-per-case (the make() helper is the right level of indirection) so the reviewer can read each test independently.
Walked the 20 tests against the contract surface declared in the stub (previewAdapter.ts:1-28):
elementAtPoint— 4 tests cover root exclusion, ancestor walk, no-id-orphan, opacity skip.applyDraft/revertDraft— 5 tests cover move payload, resize payload, gesture marker, prop clear, original-translate restore.- Edge cases — 4 tests pin double-apply overwrite, idempotent revert, mid-drag opacity re-eval, outer-vs-nested stage-root.
commitPreview— 4 tests pin null-on-no-gesture, move patch, resize patch, marker clear.getElementTimings— 3 tests pin authored timing read, no-id ignore, defined-but-empty when id-present-but-no-timing.
That's 20 = 4+5+4+4+3, matching the body claim. The deletions (-52 on the test file) are pure it.todo placeholders going to real it() bodies — no coverage regression, just shape conversion.
Concerns
[atTime semantic is under-specified in the spec] Two of the tests pass { atTime: 1.0 } to elementAtPoint:
- L83-89: "skips elements whose computed opacity is 0 at the given playhead time" — sets
opacity: "0"inline, then expectsnullatatTime: 1.0. - L182-188: "elementAtPoint filtering is stable when playhead changes mid-drag — opacity re-evaluated per call" — mutates
elem.style.opacity = "0"between calls and re-checks.
Both tests assert behavior that can be satisfied by reading current getComputedStyle and ignoring atTime entirely — which is exactly what #1291's impl does. The spec doesn't actually pin "the value at atTime is what's used"; it pins "the current computed style is what's read, regardless of atTime." That's a perfectly defensible MVP contract (caller seeks the timeline to atTime before invoking), but the test names imply the adapter uses atTime for evaluation.
Two options to tighten the spec:
- Rename to make the current-read semantics explicit:
"skips elements whose currently-computed opacity is 0"and drop theatTimearg from these two tests (since it's unused by the contract being pinned). The interface keepsatTime?: numberas an unused-for-now hint. - Add an
it.todo(orit.skip) for the true atTime semantic — "uses GSAP timeline state atatTimeto evaluate opacity, not the current playhead" — so the contract carries the gap forward.
The second is better long-term; the first is fastest. Worth picking one before the spec is the contract that lives in main.
[Nested-root test is one assertion away from pinning a fragile heuristic] L189-201 verifies that an inner data-hf-root with data-hf-id IS a target. The contract this pins is "data-hf-id beats data-hf-root." But what about an inner data-hf-root without data-hf-id (a nested sub-comp that the author forgot to tag, or one that's tagged at a deeper level)? The current spec would return null for that case — same as the outer stage root — which is probably wrong for the use case (a nested sub-comp without an explicit id is still a draggable surface).
Either:
- Add a fourth assertion to the nested-roots test: a nested
data-hf-rootwith NOdata-hf-idreturnsnull— pins "any data-hf-root without data-hf-id is treated as stage-root, including nested." This documents the limitation as intentional. - Or: change the heuristic so only the OUTERMOST
data-hf-rootis excluded (e.g. by checking ancestor chain for anotherdata-hf-rootparent), and update both the impl and the test.
Picking one closes the latent ambiguity. The first is cheaper.
[createPreviewAdapter stub throws on call rather than at import] The stub at previewAdapter.ts:23-28:
export function createPreviewAdapter(
_document: Document,
_opts?: { resolvePoint?: (x: number, y: number) => Element | null },
): PreviewAdapter {
throw new Error("not implemented — Task 3");
}This is the correct red-phase shape (each test fails in its own assertion with a clear error rather than the whole file failing to load), but it means CI on #1286 alone reports 20 failing tests. If the merge model is "stack lands atomically through Graphite MQ," that's fine. If individual stack members can land independently — e.g. someone needs to revert #1289-#1292 but keep #1286 — main breaks for everyone pulling.
Worth confirming the merge-order guarantee, or noting in the PR body that this PR must NOT land alone (already says "20 fail on this branch, 20 pass after Task 3 (#1291) merges" in the test plan, but a banner-style "DO NOT LAND ALONE" in the description would help the MQ pickup decision).
Nits
[// fallow-ignore-file code-duplication] L1 lint suppression — assume this is a project-local linter directive (didn't see it elsewhere). If fallow is the typo'd intent of "follow" and this is a long-lived directive, it should be in a comment that lints don't strip. If it's a typo of an existing tool, worth normalizing.
[adapterWith helper hides the test's adapter creation] adapterWith at L46-48 wraps createPreviewAdapter(document, { resolvePoint }) — fine for hit-test paths, but tests that DON'T use hit-testing (applyDraft, revertDraft, commitPreview, getElementTimings) still call adapterWith(() => null). That's correct but reads slightly noisy. A second helper adapter() returning createPreviewAdapter(document) (no opts) for non-hit-test cases would tighten ~12 call sites. Cosmetic.
Questions
[Where does R7 plan getElementTimings get used?] The signature returns timings keyed by hfId, where the values can have start/end undefined — but the test L266-272 just verifies the entry "is defined." If the caller is going to treat missing-timing entries differently from absent-entry-entirely, the contract should be tighter ("returns the keys for all data-hf-id elements, with start/end undefined when not authored" vs "may or may not return such entries"). The current test allows both.
What I didn't verify
- That all 20 tests will pass on
#1291's impl. Walked the impl logic against each test, looks aligned, but I didn't run the test suite locally. - That
getComputedStyle(el).opacityreturns"1"in jsdom for elements without explicit opacity. The test "returns the nearest ancestor with data-hf-id" relies on this — if jsdom returns""instead,parseFloat("")→ NaN, and the #1291 impl'sparseFloat(...) || 0would treat it as opacity=0 → null. I trust jsdom returns "1" (standard behavior), butlinkedomis referenced in the file header — if the test runner is linkedom-backed instead of jsdom, this changes. - Whether the regression-shards in CI run unit tests too, or just the regression-style render fixtures. If unit tests live in a separate workflow that I'm not seeing in the rollup, the 20-fail state may not actually break CI.
Verdict
Solid test contract. The two contract-tightening items above are worth landing in this PR — they're the difference between "tests pin behavior" and "tests describe behavior accurately." Once those are sharpened, the spec is locked and #1291's impl just has to satisfy it. Ready from where I sit; leaving as a comment.
— Review by Rames D Jusso
miguel-heygen
left a comment
There was a problem hiding this comment.
Building on Rames's review.
Rames flagged three things I'd amplify:
Escalating: "DO NOT LAND ALONE" is not explicit enough in the PR body. The body says "20 tests fail on this branch, 20 pass after Task 3 merges" — that's correct but passive. If Graphite MQ ever picks up this PR in isolation (e.g. a rebase conflict drops one of the downstackers), CI on main fails for every downstream consumer. Add a bold ⚠️ Must merge atomically with #1289–#1292 via Graphite to the body description — MQ picks up from the body description metadata.
Confirming: nested inner data-hf-root without data-hf-id returns null — needs a pinned test. The current spec doesn't say whether a nested sub-composition data-hf-root with no data-hf-id is treated the same as the outermost stage root (correct) or as a valid drag target (wrong). Either add the fourth assertion to the nested-roots test now, or the ambiguity will produce a bug report later. Rames laid out both options — option 1 (pin the current null behavior) is the 3-line path.
New: getElementTimings "is defined" assertion is too loose. The test at L266-272 asserts timings["hf-notimed"] is defined but doesn't assert its shape ({start: undefined, end: undefined}). #1291's impl and #1292's NaN fix both happen to produce the same shape, but a future impl that returns timings["hf-notimed"] = { start: 0, end: 0 } (zeroed, not undefined) would also pass the test. One more assertion (expect(timings["hf-notimed"].start).toBeUndefined()) tightens the contract.
Everything else from Rames's pass is accurate. The spec is otherwise solid.
james-russo-rames-d-jusso
left a comment
There was a problem hiding this comment.
Round 2 — HEAD is unchanged at 7d1bf83 since my round-1 review. The round-2 work for the spec items I flagged (atTime semantic disambiguation in test names, nested data-hf-root without data-hf-id) landed in #1292's top commit d916901 rather than here:
previewAdapter.test.ts:67-72— test rename to"...currently-computed opacity is 0 (atTime is a caller-seek hint, not evaluated by the adapter)".previewAdapter.test.ts:74-82— new test"returns null for nested data-hf-root without data-hf-id (treated same as outer stage root)".previewAdapter.test.ts:143-152— new test"resize → move switch clears width/height props"(pins the cross-type prop leak I flagged on#1291).previewAdapter.test.ts:178-180— test rename to"...inline opacity changes mid-drag — computed style re-evaluated per call"+ drops the unusedatTimeargs.
So the spec-tightening is now in the stack — just at the top rather than here. My round-1 review on the test file (the contract-shape walkthrough + the red-phase / stub structure) still stands as-is for the code at this SHA.
Ready from where I sit; leaving as a comment.
— Review by Rames D Jusso
jrusso1020
left a comment
There was a problem hiding this comment.
Approved on behalf of James.
…review) Addresses Rames' review on #1270: clarifies that a pre-R1 clip authored with id="my-title" round-trips as data-hf-id="my-title" (non-hf-shaped but stable, exact-match) by design — targeting uses exact [data-hf-id="…"] match and does not require the hf- shape; legacy values re-mint only at the R7 write-back. Not a bug. Comment-only. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The original comment said legacy data-hf-id values "are re-minted only once the R7 write-back persists freshly-minted ids to source" — which is incorrect. ensureHfIds skips elements that already carry data-hf-id, so legacy values (e.g. data-hf-id="my-title") persist indefinitely and are NOT automatically re-minted. Exact-match targeting still works correctly. Update comment to reflect actual behaviour. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…eview) Addresses Rames' review on #1271: execDataAttrPattern returned the first regex match without checking for a second. A duplicate id/data-hf-id in source (id drift) would silently patch one element and leave the other stale. Now warns when more than one element matches. By the mint contract it should never fire. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…3 review) Adds test: "hfId match is authoritative — selector is not used as a narrowing filter". When hfId matches element A and selector points at element B, findTagByTarget returns A without consulting selector as a narrowing filter. Pins the intended behaviour so a future refactor cannot silently start narrowing by selector. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Elements now get data-hf-id minted by ensureHfIds; parser reads data-hf-id as model id, so HTML id attrs are no longer the model id. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Locks the preservation guarantee the write-back design depends on: a Studio edit targeting by id or selector (it never sends hfId) must not strip an existing data-hf-id, or the stable handle is destroyed by the next edit. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…review) Addresses review on #1272 (Miguel P3 + Rames): findTargetElement interpolated target.hfId raw into a [data-hf-id="..."] selector. Escape it (CSS attr-value injection guard) and warn when a hfId matches more than one element instead of silently patching an arbitrary one. Adds an injection-guard test. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
9ba8ee2 to
2e8a61d
Compare
7d1bf83 to
4fceb69
Compare
The base branch was changed.

Summary
elementAtPointancestor walk + stage-root exclusion,applyDraft/revertDraftCSS custom property drafts,commitPreviewpatch extraction, andgetElementTimingscreatePreviewAdapter— confirms tests are real (not tautological)Test plan
bunx vitest run src/studio-api/helpers/previewAdapter.test.ts— 20 fail on this branch, 20 pass after Task 3 (feat(core): implement createPreviewAdapter (R7, Task 3) #1291) merges