feat(altitude): auto-advance when the altitude's video completes (not a timer)

Replace the fixed 60s idle timer with video-completion pacing: when the base loop
video finishes one playthrough (the timeupdate wrap point, or the ended safety
net), drift one altitude deeper (descend, wraps) — gated by the default-on toggle
and deferred during a manual drag or a playing morph. Removes the whole
AUTO_ADVANCE_MS/scheduleAutoAdvance/bump timer machinery and its interaction
resets. loop-recovery.spec disables the toggle to test the loop-back path in
isolation. auto-advance.spec reworked to the completion-driven model.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
BenStullsBets
2026-07-03 13:00:14 -07:00
parent c8416ef2e9
commit 34fb48cdec
3 changed files with 57 additions and 63 deletions
+38 -37
View File
@@ -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);
});
@@ -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; },