fix(studio-server): previewAdapter getElementTimings ignores relative data-start refs

Same bug class as the SDK's getElementTimings fix (#2092): data-start can be a
relative-reference expression ("intro", "intro + 2"), not just an absolute
number. The old code did a raw parseFloat on it, so any reference silently
resolved to undefined instead of an actual time.

Also: this function never read data-duration at all (only data-start/data-end
literally), so a reference to a duration-authored (not end-authored) clip was
unresolvable regardless of the parseFloat bug — resolving a reference needs
the target's END, which for a duration-authored clip requires start+duration.

Both fixed together via the shared parseStartExpression grammar parser
(@hyperframes/core/runtime/start-expression), with the same cycle-guard
pattern as the SDK fix. Reference resolution against other elements is scoped
to this file's existing findById (bare data-hf-id lookup).

6 new tests: duration-based end resolution, relative reference (with and
without offset), missing target, and a mutual-cycle termination check.
This commit is contained in:
Vance Ingalls
2026-07-09 11:27:53 -07:00
parent 1bc32bd47f
commit fbd21d5709
2 changed files with 112 additions and 8 deletions
@@ -253,5 +253,60 @@ describe("T10 — PreviewAdapter contract (spec for R7)", () => {
expect(timings["hf-notimed"].start).toBeUndefined();
expect(timings["hf-notimed"].end).toBeUndefined();
});
it("resolves data-duration into end when there is no data-end (never worked before)", () => {
make("div", { "data-hf-id": "hf-t1", "data-start": "1", "data-duration": "3" });
const adapter = adapterWith(() => null);
const timings = adapter.getElementTimings();
expect(timings["hf-t1"]).toEqual({ start: 1, end: 4 });
});
it("resolves a relative data-start reference ('ref + offset') instead of returning undefined", () => {
make("div", { "data-hf-id": "hf-intro", "data-start": "1", "data-duration": "3" });
make("div", { "data-hf-id": "hf-outro", "data-start": "hf-intro + 2", "data-duration": "1" });
const adapter = adapterWith(() => null);
const timings = adapter.getElementTimings();
// hf-intro ends at 4 (1 + 3); hf-outro starts 2s after that = 6.
expect(timings["hf-outro"]).toEqual({ start: 6, end: 7 });
});
it("resolves a bare reference (no offset) to the referenced element's end", () => {
make("div", { "data-hf-id": "hf-intro", "data-start": "1", "data-end": "4" });
make("div", {
"data-hf-id": "hf-right-after",
"data-start": "hf-intro",
"data-duration": "1",
});
const adapter = adapterWith(() => null);
const timings = adapter.getElementTimings();
expect(timings["hf-right-after"]).toEqual({ start: 4, end: 5 });
});
it("returns undefined start (not NaN) when the reference target doesn't exist", () => {
make("div", {
"data-hf-id": "hf-orphan",
"data-start": "hf-nonexistent + 5",
"data-duration": "2",
});
const adapter = adapterWith(() => null);
const timings = adapter.getElementTimings();
expect(timings["hf-orphan"].start).toBeUndefined();
});
it("terminates (not an infinite loop) on a mutual A <-> B reference cycle", () => {
make("div", { "data-hf-id": "hf-a", "data-start": "hf-b", "data-duration": "2" });
make("div", { "data-hf-id": "hf-b", "data-start": "hf-a", "data-duration": "3" });
const adapter = adapterWith(() => null);
// Unlike the SDK's static resolver (which fails safe to 0 and lets real
// durations propagate outward into arbitrary-but-finite numbers), this
// simpler resolver has no 0-fallback — an unresolvable `sv` poisons
// `resolveEnd` (it requires a finite start to add duration), so the whole
// cycle correctly reports "no defined timing" rather than a fabricated
// number. The guard's job is just termination; this call must return
// synchronously instead of hanging.
const timings = adapter.getElementTimings();
expect(timings["hf-a"].start).toBeUndefined();
expect(timings["hf-b"].start).toBeUndefined();
});
});
});