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>
191 lines
8.1 KiB
TypeScript
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);
|
|
});
|