/** * Play, on the daily card and on every archive row: the three states the * button has, and the stash that carries a signed-out press through sign-in. * * The stash is real code against a fake localStorage, the same shape * lobby-invite.test.mjs uses for its own — peek without consuming, then * consume once, is genuine logic. Everything about the button is structural * (there is no DOM under `npm test`, and daily.ts cannot even be imported * here: it reads a JSON module and two image URL modules a bundler resolves). * That is the same trade lobby-screen.test.mjs makes, for the same reason: * every one of these renders fine when it is wrong. A button left enabled * after an attempt is spent looks exactly like one that is not, and only says * so on the press, by which point the API has already refused it. * * Run with `npm test`. */ import { test, beforeEach } from "node:test"; import assert from "node:assert/strict"; import { readFile } from "node:fs/promises"; import { fileURLToPath } from "node:url"; class MemoryStorage { constructor() { this.store = new Map(); } getItem(key) { return this.store.has(key) ? this.store.get(key) : null; } setItem(key, value) { this.store.set(key, String(value)); } removeItem(key) { this.store.delete(key); } } globalThis.localStorage = new MemoryStorage(); const { hasPendingDailyPlay, stashDailyPlay, takePendingDailyPlay } = await import("../src/daily-play-stash.ts"); beforeEach(() => { globalThis.localStorage.store.clear(); }); const sidesSource = await readFile( new URL("../src/content/sides.ts", import.meta.url), "utf8", ); test("nothing is pending before anything stashes", () => { assert.equal(hasPendingDailyPlay(), false); assert.equal(takePendingDailyPlay(), null); }); test("stashing makes it pending, and peeking does not consume it", () => { stashDailyPlay("parity"); assert.equal(hasPendingDailyPlay(), true); assert.equal( hasPendingDailyPlay(), true, "a peek must not consume the stash", ); }); test("taking returns the stashed slug exactly once", () => { stashDailyPlay("parity"); assert.equal(takePendingDailyPlay(), "parity"); assert.equal(hasPendingDailyPlay(), false, "the stash was not cleared"); assert.equal(takePendingDailyPlay(), null, "a second take must find nothing"); }); test("the daily stash and the lobby stash are different keys", async () => { // Both survive the same redirect and both live in localStorage. One key for // the two would have a shared lobby link resolve as a challenge slug, or the // other way round, and neither is a failure anything would report. const daily = await readFile( fileURLToPath(new URL("../src/daily-play-stash.ts", import.meta.url)), "utf8", ); const lobby = await readFile( fileURLToPath(new URL("../src/lobby-invite-stash.ts", import.meta.url)), "utf8", ); const key = (source) => /PENDING_KEY = "([^"]+)"/.exec(source)?.[1]; assert.ok(key(daily), "daily-play-stash.ts no longer names its key"); assert.ok(key(lobby), "lobby-invite-stash.ts no longer names its key"); assert.notEqual(key(daily), key(lobby), "the two stashes share a key"); }); test("a storage failure is swallowed, not thrown", () => { const real = globalThis.localStorage; globalThis.localStorage = { getItem() { throw new Error("blocked"); }, setItem() { throw new Error("blocked"); }, removeItem() { throw new Error("blocked"); }, }; try { assert.doesNotThrow(() => stashDailyPlay("parity")); assert.equal(hasPendingDailyPlay(), false); assert.equal(takePendingDailyPlay(), null); } finally { globalThis.localStorage = real; } }); // --- the button's three states (structural, see this file's own doc) ------- const web = fileURLToPath(new URL("../", import.meta.url)); const daily = await readFile(`${web}src/screens/daily.ts`, "utf8"); const archive = await readFile(`${web}src/screens/daily-archive.ts`, "utf8"); const router = await readFile(`${web}src/router.ts`, "utf8"); const control = daily.match( /export function playControl\(challenge: Challenge, day: string\): PlayControl \{[\s\S]*?\n\}/, ); test("playable: Play is pressable before the control plane has said anything", () => { assert.ok(control, "playControl moved, was renamed, or was never added"); const button = control[0].match(/const play = el\("button", \{[\s\S]*?\}\);/); assert.ok(button, "the Play button moved or was renamed"); assert.doesNotMatch( button[0], /disabled: true/, "Play starts disabled - the card is served from S3 and is up whenever " + "the bucket is, so a button that waits for /api/daily is a button " + "that is dead during an outage the rest of the card survives", ); }); test("attempted: a spent attempt disables Play and says what it scored", () => { assert.ok(control, "playControl moved, was renamed, or was never added"); const apply = control[0].match( /applyStatus\(status: DailyStatus\) \{[\s\S]*?\n {4}\},/, ); assert.ok(apply, "applyStatus moved or was restructured"); assert.match( apply[0], /if \(!scored \|\| !status\.attempted\) return;/, "applyStatus no longer leaves Play alone for an unspent attempt, or no " + "longer exempts a backlog challenge - a past fight costs no attempt " + "and can be replayed", ); assert.match( apply[0], /play\.disabled = true;/, "a spent attempt no longer disables Play", ); assert.match( apply[0], /number\.format\(status\.score\)/, "the note no longer says what the player scored - dailyStatus carries " + "it, and saying only that they are done throws it away", ); }); test("the card hands its dailyStatus answer to the button", () => { // The card already asks for the attempt count; the same answer carries // `attempted` and `score`. Dropping this wiring costs nothing visible - the // count still fills in, and Play simply stays pressable for a player who // has already had their go. // The wiring lives in challengeCard, which the front page and the archive // both render; dailyCard is now just "the one whose day is today". const card = daily.match(/export function challengeCard\([\s\S]*?\n\}/); assert.ok(card, "challengeCard moved or was renamed"); assert.match( card[0], /play\.applyStatus\(status\)/, "the card no longer tells its Play button what dailyStatus said", ); }); test("signed out: the first press is a sign-in that stashes the slug", () => { assert.ok(control, "playControl moved, was renamed, or was never added"); const handler = control[0].match( /play\.addEventListener\("click", \(\) => \{[\s\S]*?\n {2}\}\);/, ); assert.ok(handler, "Play's click handler moved"); assert.match( handler[0], /if \(!session\) \{/, "Play no longer checks for a session before starting anything", ); assert.match( handler[0], /openSignIn\(\{ onBeforeConnect: \(\) => stashDailyPlay\(challenge\.slug\) \}\)/, "a signed-out press no longer stashes the slug before the OAuth " + "redirect - the reader would come back to the front page instead of " + "into the fight they pressed for", ); assert.match( handler[0], /lobbyScreen\(session, \{ kind: "daily", slug: challenge\.slug \}\)/, "a signed-in press no longer opens the lobby screen's daily mode", ); }); test("the lobby screen is imported on the press, not by the front page", () => { // preview.ts's own note in this file: the front page must not carry the // match-setup screen in front of the hero for a button most readers of the // page never press. analytics.test.mjs holds the same line by size. assert.match( daily, /await import\("\.\/lobby"\)/, "the lobby screen is no longer imported lazily from the card", ); assert.doesNotMatch( daily, /^import .*from "\.\/lobby"/m, "the lobby screen is imported statically; it would ship ahead of the " + "front page for every reader", ); }); test("the archive carries the same control, and says it does not score", () => { // The archive stacks the same card the front page shows, so it carries the // control by construction rather than by repeating it. assert.match( archive, /challengeCard\(challenge, today\)/, "the archive rows no longer render the shared challenge card - this " + "file's own doc says a backlog challenge is playable, and nothing " + "here let anyone", ); assert.ok(control, "playControl moved, was renamed, or was never added"); assert.match( control[0], /textContent: scored \? "" : "Does not score\."/, "a challenge whose day has passed no longer says on its face that " + "playing it is not scored", ); }); test("a signed-out press comes back into the fight, not onto the front page", () => { // The OAuth callback lands on the bare root with no hash, so the stash is // the only thing that says where the reader was going. Peek to route, // consume in start() - the same split hasPendingCamo() and // hasPendingLobbyInvite() use. assert.match( router, /if \(hasPendingDailyPlay\(\)\) return "daily";/, "routeFromLocation no longer notices a pending daily press", ); const branch = router.match(/if \(route === "daily"\) \{[\s\S]*?\n {2}\}/); assert.ok(branch, "the router's daily branch moved or was restructured"); assert.match( branch[0], /const pending = takePendingDailyPlay\(\);/, "the daily route no longer consumes the stash - an abandoned sign-in " + "would leave one to hijack a later visit", ); assert.match( branch[0], /lobbyScreen\(player, \{ kind: "daily", slug: pending \}\)/, "a restored press no longer lands in the fight it was for", ); assert.match( branch[0], /markEntered\("lobby"\)/, "the router no longer syncs what is on screen - the daily mode has no " + "address of its own, so nothing else would", ); }); test("the team palette is blue for the player and warm for everyone else", () => { const sides = sidesSource; const colours = sides.match(/export const TEAM_COLOURS[\s\S]*?\n\];/); assert.ok( colours, "TEAM_COLOURS moved, was renamed, or stopped being exported", ); const triples = [...colours[0].matchAll(/\[(\d+), (\d+), (\d+)\]/g)].map( (m) => m.slice(1).map(Number), ); // One per team, not one per force: a force is a shade of its team's colour // (sideRgb), so this is how many *sides* can be told apart, not how many // lances. Two is the floor - the player and an opposition. assert.ok( triples.length >= 2, `TEAM_COLOURS has ${triples.length}; the player needs one and the ` + `opposition at least one`, ); const [player, ...opposition] = triples; assert.ok( player[2] > player[0] && player[2] > player[1], `the player's team is [${player}], which is not blue-dominant`, ); // What separates a red from the burnt orange this replaced: an orange has a // green channel well clear of its blue one. #b05c48 was 92 against 72. const first = opposition[0]; assert.ok( first[0] > first[1] && first[0] > first[2], `the first opposition team is [${first}], which is not red-dominant`, ); assert.ok( first[1] - first[2] <= 12, `the opposition is [${first}]: green sits ${first[1] - first[2]} ` + `above blue, which reads as orange rather than red`, ); }); test("a force is a shade of its team, not a colour of its own", () => { const sides = sidesSource; const fn = sides.match(/export function sideRgb\([\s\S]*?\n\}/); assert.ok(fn, "sideRgb moved or was renamed"); assert.match( fn[0], /if \(slot <= 0\) return base;/, "the first force of a team no longer wears its team's own colour, so a " + "one-force side would be shaded away from the colour it is named by", ); assert.match( fn[0], /Math\.ceil\(slot \/ 2\) \* \(slot % 2 === 1 \? 1 : -1\)/, "the shades no longer step outwards from the base - walking one way runs " + "four lances into the background or into the labels", ); });