Files
Epic-The-Musical-Timeline/tests/voyage.test.ts
T
Lucas WintherandClaude Opus 5.5 0456683d1b fix(app): controller cancels instead of queueing; Go ashore; focus, narrow and lyrics fixes
From the final review: a request cancelled mid-drop no longer sails under
the wrong frieze; new requests cancel running animations; Play voyage
no longer sticks during the first scene and stops on any other
navigation; the landing intro is revealed by the lift; Go ashore enters
a landing through the frieze; Back to sea keeps keyboard focus; the
timeline is compact below 900px; lyrics re-scroll after a tab switch;
the shared toon gradient is no longer disposed with a landing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 15:25:45 +01:00

191 lines
8.1 KiB
TypeScript

import { expect, test } from "bun:test";
import { transitionFor } from "../src/app/voyage";
import { createController, type ControllerWorld, type ControllerCurtain } from "../src/app/controller";
import { createStore, DEFAULT_STATE, type AppState } from "../src/app/state";
test("within a saga the ship sails, and landing songs land", () => {
expect(transitionFor("polyphemus", "survive")).toEqual({ transition: { kind: "sail" }, landing: "cave" });
expect(transitionFor("survive", "remember-them")).toEqual({ transition: { kind: "sail" }, landing: null });
});
test("into the next or previous saga the curtain drops with that saga's frieze", () => {
expect(transitionFor("warrior-of-the-mind", "polyphemus")).toEqual({ transition: { kind: "curtain", sagaId: "cyclops" }, landing: "cave" });
expect(transitionFor("polyphemus", "warrior-of-the-mind")).toEqual({ transition: { kind: "curtain", sagaId: "troy" }, landing: null });
});
test("jumps across two or more sagas fade", () => {
expect(transitionFor("storm", "god-games")).toEqual({ transition: { kind: "fade" }, landing: "arena" });
});
test("the same song is a no-op sail", () => {
expect(transitionFor("storm", "storm")).toEqual({ transition: { kind: "sail" }, landing: null });
});
/**
* Fakes that behave like the real modules: every animation is a pending promise the test releases; a new
* call on the curtain (any of drop/lift/fade) or a new sail resolves the running one at once, as the real
* curtain and world cancel. `setLanding(null)` is immediate, like the real world.
*/
function fakes() {
const log: string[] = [];
const pending: { what: string; resolve: () => void }[] = [];
const start = (what: string, group: string) => {
for (const p of pending.filter((p) => p.what.startsWith(group))) { p.resolve(); pending.splice(pending.indexOf(p), 1); }
log.push(what);
return new Promise<void>((resolve) => pending.push({ what: group + what, resolve }));
};
const world: ControllerWorld = {
sailTo: (id, o) => start(`sail:${id}${o?.instant ? "!" : ""}`, "W"),
setLanding: (id) => (id === null ? (log.push("land:null"), Promise.resolve()) : start(`land:${id}`, "L")),
setRibbons: () => {},
horizonFromFrieze: (y) => { log.push(`horizon:${y}`); },
};
const curtain: ControllerCurtain = {
drop: (saga, o) => start(`drop:${saga}${o?.landing ? `>${o.landing}` : ""}`, "C"),
lift: () => start("lift", "C"),
fade: () => start("fade", "C"),
groundY: () => 500,
};
const tick = async () => { for (let i = 0; i < 10; i++) await Promise.resolve(); };
/** Finish the oldest running animation whose log entry starts with `prefix`. */
const release = async (prefix: string) => {
const p = pending.find((p) => p.what.slice(1).startsWith(prefix));
if (!p) throw new Error(`nothing pending for ${prefix}; pending: ${pending.map((p) => p.what).join(", ")}`);
pending.splice(pending.indexOf(p), 1);
p.resolve();
await tick();
};
const settle = async () => { for (let i = 0; i < 40 && pending.length; i++) { pending.splice(0).forEach((p) => p.resolve()); await tick(); } };
return { log, world, curtain, release, settle, tick };
}
function setup(state: Partial<AppState> = {}, opts: { world?: boolean; reducedMotion?: boolean; holdMs?: number } = {}) {
const f = fakes();
const store = createStore({ ...DEFAULT_STATE, ...state }, { history: false });
const ctl = createController({
store, world: opts.world === false ? null : f.world, curtain: f.curtain,
reducedMotion: opts.reducedMotion ?? false, holdMs: opts.holdMs,
});
return { f, store, ctl };
}
test("a new request cancels the running sail instead of waiting for it", async () => {
const { f, store } = setup({ songId: "polyphemus" });
await f.settle();
store.set({ songId: "survive" });
await f.tick();
expect(f.log.at(-1)).toBe("sail:survive");
store.set({ songId: "remember-them" }); // survive's sail is still running
await f.tick();
expect(f.log).toContain("sail:remember-them");
});
test("cancelled during a curtain drop, the next step never sails under the wrong saga's frieze", async () => {
const { f, store, ctl } = setup({ songId: "full-speed-ahead" });
await f.settle();
f.log.length = 0;
store.set({ songId: "polyphemus" });
await f.tick();
expect(f.log).toEqual(["drop:cyclops>cave"]);
store.set({ songId: "just-a-man" }); // back in Troy while the Cyclops frieze is coming down
await f.tick();
// the frieze on screen is Cyclops', the target is Troy: Troy's frieze replaces it before anything lifts
expect(f.log[1]).toBe("drop:troy");
await f.settle();
expect(f.log).toContain("sail:just-a-man!");
expect(f.log.at(-1)).toBe("lift");
expect(ctl.settledSongId()).toBe("just-a-man");
expect(ctl.busy()).toBe(false);
});
test("rapid navigation, released one step at a time, ends at the last song with the stage uncovered", async () => {
const { f, store, ctl } = setup();
await f.settle();
for (const id of ["just-a-man", "polyphemus", "storm", "puppeteer", "god-games"]) {
store.set({ songId: id });
await f.tick();
await f.release(f.log.at(-1)!.split(/[:!>]/)[0]!);
}
await f.settle();
expect(ctl.settledSongId()).toBe("god-games");
expect(f.log.filter((l) => l.startsWith("sail:")).at(-1)).toMatch(/^sail:god-games/);
expect(f.log.at(-1)).toBe("lift");
expect(ctl.landing()).toBe("arena");
expect(ctl.busy()).toBe(false);
});
test("the landing starts before the curtain lifts, so the lift reveals its arrival", async () => {
const { f, store } = setup({ songId: "warrior-of-the-mind" });
await f.settle();
f.log.length = 0;
store.set({ songId: "polyphemus" });
await f.tick();
await f.release("drop");
await f.release("sail");
// land:cave is still running (its intro), and the lift has already begun
expect(f.log).toEqual(["drop:cyclops>cave", "sail:polyphemus!", "land:cave", "horizon:500", "lift"]);
});
test("the horizon is matched to the frieze's ground line only when a curtain lifts", async () => {
const { f, store } = setup();
await f.settle();
store.set({ songId: "just-a-man" });
await f.settle();
expect(f.log.some((l) => l.startsWith("horizon:"))).toBe(false);
store.set({ songId: "polyphemus" });
await f.settle();
expect(f.log).toContain("horizon:500");
expect(f.log.indexOf("horizon:500")).toBeLessThan(f.log.lastIndexOf("lift"));
});
test("without a world the controller still follows the songs (frieze-only browsing)", async () => {
const { f, store, ctl } = setup({}, { world: false });
store.set({ songId: "polyphemus" });
await f.settle();
expect(ctl.settledSongId()).toBe("polyphemus");
expect(f.log).toContain("drop:cyclops>cave");
});
test("reduced motion turns every scene change into a fade", async () => {
const { f, store } = setup({}, { reducedMotion: true });
store.set({ songId: "polyphemus" });
await f.settle();
expect(f.log).toContain("fade");
expect(f.log.some((l) => l.startsWith("drop:"))).toBe(false);
});
test("Back to sea leaves the landing without changing the song; Go ashore enters it through the frieze", async () => {
const { f, store, ctl } = setup({ songId: "survive" });
await f.settle();
expect(ctl.landing()).toBe("cave");
await ctl.leaveLanding();
expect(f.log.at(-1)).toBe("land:null");
expect(ctl.landing()).toBeNull();
expect(store.get().songId).toBe("survive");
f.log.length = 0;
const ashore = ctl.goAshore();
await f.settle();
await ashore;
expect(f.log).toEqual(["drop:cyclops>cave", "land:cave", "horizon:500", "lift"]);
expect(ctl.landing()).toBe("cave");
});
test("Play voyage advances even when pressed while the first scene is still arriving", async () => {
const { f, store, ctl } = setup({ songId: "survive" }, { holdMs: 5 });
ctl.play(); // the cave intro of the first paint is still running
await f.settle();
await Bun.sleep(30);
await f.settle();
expect(store.get().songId).not.toBe("survive");
ctl.stop();
});
test("any navigation the voyage didn't make stops Play voyage", async () => {
const { f, store, ctl } = setup({}, { holdMs: 1000 });
await f.settle();
ctl.play();
expect(ctl.playing()).toBe(true);
store.set({ songId: "storm" }); // e.g. a timeline click
expect(ctl.playing()).toBe(false);
});