diff --git a/simulator/e2e/tests/auto-advance.spec.ts b/simulator/e2e/tests/auto-advance.spec.ts index fb5e4cf..0eddd30 100644 --- a/simulator/e2e/tests/auto-advance.spec.ts +++ b/simulator/e2e/tests/auto-advance.spec.ts @@ -1,55 +1,56 @@ import { test, expect, Page } from "@playwright/test"; -// Auto-advance: after the dial sits idle for the interval, the altitude drifts one -// step deeper (descend), wrapping past the deepest. A default-on toggle disables it. -// The e2e injects a short interval via window.__hefAutoAdvanceMs before load. +// Auto-advance moves to the next altitude when the altitude's loop VIDEO completes a +// playthrough (not on a fixed timer). Descends one step + wraps; a default-on toggle +// disables it; manual drag / a playing morph defer it. The e2e simulates a completed +// loop by dispatching the loop element's "ended" (the wrap-point and ended share the +// same advance path). -async function boot(page: Page, intervalMs: number) { - await page.addInitScript((ms) => { (window as any).__hefAutoAdvanceMs = ms; }, intervalMs); +async function boot(page: Page) { await page.goto("/"); await page.waitForFunction(() => (window as any).__hefReady === true, null, { timeout: 45_000 }); await page.locator("#welcome-launch").click(); await page.waitForSelector("#welcome", { state: "detached", timeout: 10_000 }); + await expect(page.locator("#scale-name")).toContainText("cosmos"); +} +const state = (page: Page) => page.evaluate(() => (window as any).__hefState()); +const idx = async (page: Page) => (await state(page)).ringIndex as number; + +// Simulate the loop video finishing one playthrough. +async function completeLoopVideo(page: Page) { + await page.evaluate(() => { + const v = document.querySelector("#vid-loop") as HTMLVideoElement; + v.dataset.loopTail = "1"; // armed, as after a real landing + v.dispatchEvent(new Event("ended")); + }); } -const idx = (page: Page) => page.evaluate(() => (window as any).__hefState().ringIndex as number); - -test("auto-advances one altitude (descending) after the idle interval", async ({ page }) => { - await boot(page, 1200); - const n = await page.evaluate(() => (window as any).__hefState && (document.querySelectorAll(".dial-label").length)); +test("a completed loop video advances one altitude (descending, wraps)", async ({ page }) => { + await boot(page); + const n = await page.evaluate(() => document.querySelectorAll(".dial-label").length); const start = await idx(page); - // Wait past the interval + the morph animation for the landing. + await completeLoopVideo(page); await page.waitForFunction((s) => (window as any).__hefState().ringIndex !== s, start, { timeout: 8000 }); - const after = await idx(page); - expect(after).toBe((start + 1) % n); // descended exactly one, wraps + expect(await idx(page)).toBe((start + 1) % n); }); -test("Auto-advance off keeps the altitude parked", async ({ page }) => { - // Disable BEFORE launch so the idle timer never arms (deterministic — no race with - // the first interval firing during boot). - await page.addInitScript(() => { (window as any).__hefAutoAdvanceMs = 800; }); - await page.goto("/"); - await page.waitForFunction(() => (window as any).__hefReady === true, null, { timeout: 45_000 }); +test("Auto-advance off: a completed video stays on the altitude", async ({ page }) => { + await boot(page); await page.evaluate(() => { (document.querySelector("#auto-advance") as HTMLInputElement).checked = false; }); - await page.locator("#welcome-launch").click(); - await page.waitForSelector("#welcome", { state: "detached", timeout: 10_000 }); - const start = await idx(page); - await page.waitForTimeout(2200); // well past two 800ms intervals - expect(await idx(page)).toBe(start); // never moved + await completeLoopVideo(page); + await page.waitForTimeout(1500); + expect(await idx(page)).toBe(start); }); -test("the idle countdown resets on interaction (no advance right after a manual turn)", async ({ page }) => { - await boot(page, 1500); - // Immediately before the first interval elapses, interact (keyboard step). That step - // itself advances by 1; the reset then means no AUTO advance fires for another - // full interval — so shortly after, we should still be at exactly that +1. - await page.waitForTimeout(900); - const before = await idx(page); - await page.locator("#dial").focus(); - await page.locator("#dial").press("ArrowDown"); // manual descend + reset - await page.waitForFunction((b) => (window as any).__hefState().ringIndex !== b, before, { timeout: 4000 }); - const manual = await idx(page); - await page.waitForTimeout(900); // < the 1500ms interval - expect(await idx(page)).toBe(manual); // countdown reset → no extra auto step yet +test("keeps touring: a second completed video advances again", async ({ page }) => { + await boot(page); + const n = await page.evaluate(() => document.querySelectorAll(".dial-label").length); + const start = await idx(page); + await completeLoopVideo(page); + // wait until it has advanced AND settled (pos back on an integer detent) + await page.waitForFunction((s) => { const st = (window as any).__hefState(); return st.ringIndex !== s && Number.isInteger(st.pos); }, start, { timeout: 8000 }); + await completeLoopVideo(page); + await page.waitForFunction((a) => (window as any).__hefState().ringIndex === (a.start + 2) % a.n, { start, n }, { timeout: 8000 }); + expect(await idx(page)).toBe((start + 2) % n); }); diff --git a/simulator/e2e/tests/loop-recovery.spec.ts b/simulator/e2e/tests/loop-recovery.spec.ts index 0352614..1d38f5a 100644 --- a/simulator/e2e/tests/loop-recovery.spec.ts +++ b/simulator/e2e/tests/loop-recovery.spec.ts @@ -4,12 +4,16 @@ import { test, expect } from "@playwright/test"; // offset via a narrow timeupdate window. If that window is ever missed the clip // fires `ended` and — without a safety net — freezes on its last frame ("videos // aren't looping"). This asserts the `ended` safety net recovers playback. +// (With Auto-advance ON — the default — a completed video instead moves to the next +// altitude; that path is covered by auto-advance.spec.ts. Here we disable it to test +// the loop-back recovery in isolation.) test("loop recovers when a clip reaches `ended` (no freeze on last frame)", async ({ page }) => { await page.goto("/"); // Enter via the welcome screen (Video defaults on → the loop video loads). await page.waitForFunction(() => (window as any).__hefReady === true, null, { timeout: 45_000 }); await page.locator("#welcome-launch").click(); await page.waitForSelector("#welcome", { state: "detached", timeout: 10_000 }); + await page.evaluate(() => { const c = document.querySelector("#auto-advance") as HTMLInputElement; if (c) c.checked = false; }); // Wait for the loop video to actually have media before forcing the stuck state. await page.waitForFunction( () => { const v = document.getElementById("vid-loop") as HTMLVideoElement; return v && v.duration > 0; }, diff --git a/simulator/static/app.js b/simulator/static/app.js index 55ca8ab..b802d54 100644 --- a/simulator/static/app.js +++ b/simulator/static/app.js @@ -328,6 +328,7 @@ function ensureClipMedia() { // tail. The morph element (#vid) is driven directly by the scrub. loopVid.addEventListener("timeupdate", () => { if (loopVid.dataset.loopTail === "1" && loopVid.duration && loopVid.currentTime >= loopVid.duration - 0.08) { + if (autoAdvanceOnLoopComplete()) return; // completed a playthrough → next altitude try { loopVid.currentTime = loopStartFrame(); } catch (e) { /* not seekable */ } } }); @@ -339,6 +340,7 @@ loopVid.addEventListener("timeupdate", () => { // rarely hits this; loaded real machines do. loopVid.addEventListener("ended", () => { if (loopVid.dataset.loopTail !== "1") return; + if (autoAdvanceOnLoopComplete()) return; // completed a playthrough → next altitude try { loopVid.currentTime = loopStartFrame(); } catch (e) { /* not seekable */ } loopVid.play().catch(() => {}); }); @@ -848,7 +850,6 @@ function autoScrub(targetPos, perAltMs = PER_ALTITUDE_MS) { let wheelAccum = 0, wheelTimer = null; function onWheel(e) { e.preventDefault(); - bumpAutoAdvance(); wheelAccum += e.deltaY; clearTimeout(wheelTimer); wheelTimer = setTimeout(() => { @@ -858,26 +859,19 @@ function onWheel(e) { }, 90); } -// --- Auto-advance: after the dial sits idle for AUTO_ADVANCE_MS, drift one altitude -// deeper (descend; past the deepest it wraps back to the top), a slow kiosk tour. Any -// manual interaction restarts the idle countdown; a default-on toggle disables it. -// (E2E injects a short interval via window.__hefAutoAdvanceMs before load.) -const AUTO_ADVANCE_MS = (typeof window !== "undefined" && window.__hefAutoAdvanceMs) || 60000; -let autoAdvanceTimer = null; +// --- Auto-advance: when the altitude's base loop VIDEO completes one playthrough, drift +// one altitude deeper (descend; past the deepest it wraps back to the top) — a slow kiosk +// tour paced by the footage itself. A default-on toggle disables it; a manual drag or a +// playing morph defers the move (it happens on the NEXT completion instead). Hooked into +// the loop element's completion points (the timeupdate wrap + the ended safety net). function autoAdvanceOn() { const el = $("auto-advance"); return el ? el.checked : true; } -function scheduleAutoAdvance() { - clearTimeout(autoAdvanceTimer); - autoAdvanceTimer = null; - if (!autoAdvanceOn() || !videoEverOn) return; // only while the experience is running - autoAdvanceTimer = setTimeout(onAutoAdvance, AUTO_ADVANCE_MS); +function autoAdvanceOnLoopComplete() { + if (autoAdvanceOn() && videoEverOn && !dialDrag && !busy) { + autoScrub(Math.round(pos) + 1); // completed a playthrough → next altitude + return true; // advanced — caller skips the normal loop-back + } + return false; } -function onAutoAdvance() { - // Skip a beat (but keep the cadence) if the visitor is mid-drag or a morph is playing. - if (autoAdvanceOn() && videoEverOn && !dialDrag && !busy) autoScrub(Math.round(pos) + 1); - scheduleAutoAdvance(); -} -// Restart the idle countdown on any manual interaction (drag/wheel/key/knob). -function bumpAutoAdvance() { if (videoEverOn) scheduleAutoAdvance(); } // --- Altitude knob: the endless rotary encoder, drawn as a turnable dial --- // cosmos (highest) sits at the top; turning CLOCKWISE descends through the scales @@ -1086,7 +1080,6 @@ function setPos(next) { function onDialDown(e) { if (!ring || ring.scales.length < 2) return; e.preventDefault(); - bumpAutoAdvance(); dialDrag = { lastAng: dialAngle(e), accum: 0, moved: 0, target: e.target, restPos: pos }; } function onDialMove(e) { @@ -1113,7 +1106,6 @@ function onDialUp(e) { // short way to the CLOSEST one and settle there (frac 0 locks). The result is the // same as a tap/label-click: a grab-and-release always lands on a discrete altitude. autoScrub(Math.round(pos)); - bumpAutoAdvance(); } // Click a label → auto-scrub the SHORTEST signed way around the ring to that scale. @@ -1144,7 +1136,7 @@ function onDialKey(e) { case "End": jumpToScale(ring.scales.length - 1); break; default: handled = false; } - if (handled) { e.preventDefault(); bumpAutoAdvance(); } + if (handled) e.preventDefault(); } // --- Dev Mode: pool picker + live analysis, all under one toggle --- @@ -1695,7 +1687,6 @@ function beginExperience() { updateAudioLevelLabel(); applyAudio(); applyPlaySpeed(); - scheduleAutoAdvance(); // start the idle-drift countdown now that we're running } // Welcome screen ---------------------------------------------------------------- @@ -1770,9 +1761,7 @@ async function main() { initWelcome(); // wire the welcome-screen controls + Launch button renderScaleReadout(); // Sliders stream on "input"; the toggles fire on "change". - for (const id of ["left", "right", "mood"]) $(id).addEventListener("input", () => { bumpAutoAdvance(); debounced(); }); - // Auto-advance toggle: (re)start or cancel the idle-drift countdown on change. - $("auto-advance").addEventListener("change", scheduleAutoAdvance); + for (const id of ["left", "right", "mood"]) $(id).addEventListener("input", debounced); // Audio level dial: set the master soundtrack gain. Reconcile SYNCHRONOUSLY in this // input gesture so the first raise from 0 unlocks + plays on Safari (which blocks // play() outside a user gesture).