From 31a3fd733f0228b5530a39a87acdfee3282a2337 Mon Sep 17 00:00:00 2001 From: BenStullsBets Date: Sat, 27 Jun 2026 15:47:25 -0700 Subject: [PATCH] =?UTF-8?q?test(e2e):=20scrub=20tier=20=E2=80=94=20current?= =?UTF-8?q?Time=20+=20gains=20track=20dial,=20reverse,=20commit-lock,=20wh?= =?UTF-8?q?eel?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- simulator/e2e/tests/altitude-lock.spec.ts | 76 +++++++++++++++-------- simulator/static/app.js | 4 ++ 2 files changed, 53 insertions(+), 27 deletions(-) diff --git a/simulator/e2e/tests/altitude-lock.spec.ts b/simulator/e2e/tests/altitude-lock.spec.ts index 1592c89..048db1c 100644 --- a/simulator/e2e/tests/altitude-lock.spec.ts +++ b/simulator/e2e/tests/altitude-lock.spec.ts @@ -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(); }); diff --git a/simulator/static/app.js b/simulator/static/app.js index d52260a..1885c4b 100644 --- a/simulator/static/app.js +++ b/simulator/static/app.js @@ -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) {