/** * What Play stacks, and in what order. * * A match already going is what a player following Resume off the bar came * back for, so it is the top of the screen, the day's fight is under it, and * the Modes are under that. A player with nothing going gets the daily card at * the top instead — not by a condition, but because the in-progress section * removes itself when the list comes back empty, which is the other test * below. Nothing errors if the order flips — the screen still renders, and * still works, it just buries the thing the player was heading for — so the * order is held here. * * Structural, like lobby-screen.test.mjs. Not for want of a DOM this time but * for want of a resolver: the screens import each other extensionlessly * (`from "../api"`), which plain Node's ESM loader will not resolve, so a * screen module cannot be imported under `node --test` at all. api.ts can, and * lobby-client.test.mjs does, because nothing it imports at module scope needs * resolving. Giving the screens a real DOM and a resolver hook is * `web-testing`'s. * * Run with `npm test`. */ import { test } from "node:test"; import assert from "node:assert/strict"; import { readFile } from "node:fs/promises"; import { fileURLToPath } from "node:url"; const src = fileURLToPath(new URL("../src/", import.meta.url)); const matches = await readFile(`${src}screens/matches.ts`, "utf8"); const list = await readFile(`${src}screens/match-list.ts`, "utf8"); const record = await readFile(`${src}matches-page.ts`, "utf8"); const styles = await readFile(`${src}styles.css`, "utf8"); test("the live matches, then the day's fight, then the Modes", () => { const returned = matches.match(/return \[[\s\S]*?\n {2}\];/); assert.ok( returned, "matchesScreen's returned array moved or was restructured", ); const live = returned[0].indexOf("inProgress"); const daily = returned[0].indexOf("daily"); const modes = returned[0].indexOf("mode-rows"); assert.ok( live !== -1, "the in-progress section is no longer in what Play returns", ); assert.ok(daily !== -1, "the daily card is no longer in what Play returns"); assert.ok(modes !== -1, "the Mode cards are no longer in what Play returns"); assert.ok( live < daily, "the daily card is above the matches in progress - a player following " + "Resume off the bar lands on today's fight instead of on the match " + "they already have going", ); assert.ok( daily < modes, "the Modes are back above the day's fight, which is the one thing on " + "this screen that is only on today", ); }); test("the Modes are three that start a match, then two that do not", () => { const rows = [ ...matches.matchAll(/className: "mode-row" \}, \[([\s\S]*?)\n {6}\]\)/g), ]; assert.equal(rows.length, 2, "Play no longer builds exactly two Mode rows"); const [live, soon] = rows.map((row) => row[1]); assert.equal( (live.match(/modeCard\(/g) ?? []).length, 3, "the first Mode row is no longer three cards, so they no longer sit at a " + "third of the row each", ); assert.doesNotMatch( live, /futureModeCard\(/, "a Mode with nothing behind it is in the row of the ones that work", ); assert.equal( (soon.match(/futureModeCard\(/g) ?? []).length, 2, "the second Mode row is no longer the two unbuilt Modes", ); assert.doesNotMatch( soon, /[^e]modeCard\(/, "a Mode that starts a match is in the row of the ones that do not", ); }); test("Play names nothing its own blocks have not already named", () => { assert.doesNotMatch( matches, /pageHead\(/, 'Play has a page heading again - "Play" over a screen the bar already ' + "said was Play", ); assert.doesNotMatch( matches, /Start a match/, "the Start a match heading is back over the Mode cards, which say what " + "they start on their own faces", ); }); test("Play's width is keyed on something that does not remove itself", () => { // The trap: `.matches` is the obvious hook and the wrong one. It is the // section that deletes itself when nothing is live, so a rule hung on it // drops the screen back to the reading measure at exactly the moment the // daily card - a board and two columns of text - arrives at the top. const wide = styles.match( /body:has\(([^)]*)\):has\(\.daily\) \{\n {2}--shell: 56rem;/, ); assert.ok( wide, "no rule widens Play for the daily card - the board and the ground tile " + "fold onto the blurb at the narrower measure", ); assert.equal( wide[1], ".mode-rows", "Play's wide measure is keyed on something other than the Mode rows, " + "which are the one block on this screen that is never taken away", ); assert.match( styles, /body:has\(\.mode-rows\) \{\n {2}--shell: 46rem;/, "Play has no measure of its own for the nothing-scheduled case, so it " + "falls back to the reading measure with five Mode cards across it", ); // Operations shows the same list and no Modes, and keeps the narrow one. assert.match( styles, /body:has\(\.matches\) \{\n {2}--shell: 46rem;/, "the match list's own measure is gone, so Operations follows the reading " + "measure instead", ); }); test("an empty list takes the whole section away rather than filling it", () => { assert.match( matches, /matchList\([\s\S]*?inProgress\.remove\(\)/, "Play no longer removes its in-progress section when the list comes back " + "with nothing live - an empty box holds the top of the screen", ); assert.doesNotMatch( matches, /No matches in progress/, "Play is back to saying it has no matches in progress, which is the box " + "the removal above exists to avoid", ); }); test("only the kept-nothing case is the caller's; a failed read still says so", () => { assert.match( list, /empty: string \| \(\(\) => void\)/, "matchList's empty case no longer takes a callback as well as a line", ); const filled = list.match(/if \(shown\.length === 0\) \{[\s\S]*?\n {6}\}/); assert.ok(filled, "the kept-nothing branch moved or was restructured"); assert.match( filled[0], /else empty\(\);/, "a callback passed as `empty` is no longer called, so the screen that " + "removes its section never hears that there is nothing in it", ); // The list's own catch, not scenarioNames' — that one is above it in the // file and swallows its failure by design. const failed = list.match(/Promise\.all\(\[read\(\)[\s\S]*?\n {4}\}\);/); assert.ok(failed, "the read of the list moved or was restructured"); assert.match( failed[0], /\.catch\([\s\S]*?className: "error"/, "a failed read no longer says so in place - whichever screen it is, the " + "player is told they have no matches when the truth is nobody knows", ); }); test("a player's own record says it in a line, since it has no other section", () => { // The page is that list, so removing it would leave a heading over nothing — // and the line differs by whose record it is, because "play one and it lands // here" is not something to tell a visitor about somebody else. assert.match(record, /own\s*\?\s*"No matches yet\./); assert.match(record, /:\s*"This player has not finished a match yet\."/); }); test("a player's record is read from the public route, not the session's", () => { // /api/matches is the caller's own list. A page about somebody else that // read it would show the reader their own matches under a stranger's name. assert.ok( !/listMatches\b/.test(record), "the matches page reads the session-scoped list", ); assert.match(record, /listPlayerMatches\(asked\)/); });