test(e2e): scrub tier — currentTime + gains track dial, reverse, commit-lock, wheel

Per plan Task 5. Replaces the .rev-asserting zoom-out test with a turn-back test
(same forward morph seeked backward, audio recedes, re-locks the start clip); adds
a detent-crossing commit+lock test via a new window.__hefState diagnostic seam.
Full tier (6 tests) green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
BenStullsBets
2026-06-27 15:47:25 -07:00
parent 8458ab59eb
commit 31a3fd733f
2 changed files with 53 additions and 27 deletions
+49 -27
View File
@@ -105,35 +105,57 @@ test("zoom in plays the matching morph, lands locked, and stays put while parked
expect((await morphs(page)).length).toBe(countAfterLanding); // no extra swap
});
test("zoom back out plays a reverse morph and lands locked", async ({ page }) => {
test("turn-back scrubs the same morph in reverse and re-locks the start clip", async ({ page }) => {
await boot(page);
const start = (await page.locator("#scale-name").textContent())!;
await enableAudio(page);
const box = (await page.locator("#dial").boundingBox())!;
const cx = box.x + box.width / 2, cy = box.y + box.height / 2;
const read = () => page.evaluate(() => ({
t: (document.querySelector("video") as HTMLVideoElement).currentTime,
ga: (document.querySelector("#aud") as HTMLAudioElement).volume,
gb: (document.querySelector("#aud-b") as HTMLAudioElement).volume,
name: document.querySelector("#scale-name")?.textContent,
morphs: ((window as any).__hefMorphs || []).slice(),
}));
// Zoom in one detent to get off cosmos (-> orbit)...
await wheelOnStage(page, 60);
await page.waitForFunction(
(s) => document.querySelector("#scale-name")?.textContent !== s,
start,
{ timeout: 20_000 },
);
const atOrbit = (await page.locator("#scale-name").textContent())!;
expect(atOrbit).toContain("orbit");
await page.mouse.move(cx, cy - 30);
await page.mouse.down();
await page.mouse.move(cx + 22, cy - 20, { steps: 10 }); // descend partway into the segment
await page.waitForFunction(() => (document.querySelector("video") as HTMLVideoElement).currentTime > 0, null, { timeout: 5000 });
const fwd = await read();
// The single segment morph is the descend/forward file (cosmos__orbit), NOT a `.rev`.
expect(fwd.morphs[fwd.morphs.length - 1]).toMatch(/^transitions\/cosmos.*\.mp4$/);
expect(fwd.morphs[fwd.morphs.length - 1]).not.toMatch(/\.rev\.mp4$/);
// ...then zoom back OUT to cosmos.
await wheelOnStage(page, -60);
await page.waitForFunction(
(s) => document.querySelector("#scale-name")?.textContent !== s,
atOrbit,
{ timeout: 20_000 },
);
await page.mouse.move(cx + 4, cy - 30, { steps: 10 }); // turn back toward the start
await page.waitForTimeout(120); // let the throttled rAF seek land
const back = await read();
await page.mouse.up();
const played = await morphs(page);
// The most recent morph is the zoom-out: a `.rev` companion. Its file is named
// for the descending pair (cosmos<...>__orbit<...>), so it starts "cosmos".
expect(played[played.length - 1]).toMatch(/^transitions\/cosmos.*\.rev\.mp4$/);
const landed = (await page.locator("#scale-name").textContent())!;
expect(landed).toContain("cosmos");
await page.waitForTimeout(2500);
expect(await page.locator("#scale-name").textContent()).toBe(landed); // locked
expect(back.t).toBeLessThan(fwd.t); // SAME morph file, seeking backward
expect(back.gb).toBeLessThan(fwd.gb); // next-scale audio receding
expect(back.name).toContain("cosmos"); // re-locked the altitude we left
});
test("crossing a detent commits and locks the destination clip", async ({ page }) => {
await boot(page);
const box = (await page.locator("#dial").boundingBox())!;
const cx = box.x + box.width / 2, cy = box.y + box.height / 2;
// Drag clockwise across one full detent (top -> right ~= 90deg = 1.25 detents),
// crossing integer 1 (orbit) so it commits, then hold past it (no auto-complete).
await page.mouse.move(cx, cy - 30);
await page.mouse.down();
await page.mouse.move(cx + 30, cy + 6, { steps: 14 });
await page.waitForFunction(() => (window as any).__hefState().ringIndex === 1, null, { timeout: 5000 });
const st = await page.evaluate(() => (window as any).__hefState());
expect(st.ringIndex).toBe(1); // committed to orbit
expect(String(st.activeClipId)).toMatch(/^orbit/); // locked the destination pool pick
const played = await page.evaluate(() => (window as any).__hefMorphs || []);
expect(played[0]).toMatch(/^transitions\/cosmos.*\.mp4$/);
expect(played[0]).not.toMatch(/\.rev\.mp4$/);
// Hold: the committed clip does not change while parked mid-gesture.
await page.waitForTimeout(800);
const st2 = await page.evaluate(() => (window as any).__hefState());
expect(st2.activeClipId).toBe(st.activeClipId);
await page.mouse.up();
});
+4
View File
@@ -636,6 +636,10 @@ let pos = 0; // continuous knob position; rest value == ringInd
let activeSeg = null; // { lo, clipLo, clipHi, file } for the segment being scrubbed
let seekPending = false; // throttle: at most one currentTime seek per animation frame
// Diagnostic seam (sibling of window.__hefMorphs): the committed altitude state, so
// E2E can assert the locked clip mid-scrub when #scale-name (settle-only) is stale.
if (typeof window !== "undefined") window.__hefState = () => ({ ringIndex, activeClipId, pos });
function dialStep() { return ring && ring.scales.length ? 360 / ring.scales.length : 360; }
function _xy(r, deg) {