Something went wrong. Try again.
Web frontend and supporting services for lance.blue
Something went wrong. Try again.
48 kB · 1276 lines
JavaScript
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277/** * The lobby screen: seats, the two resolvers, Deploy, and chat. * * Structural, like challenge.ts's own checks (scenario-pick.test.mjs, * seat-shuffle.test.mjs, pilot-tile.test.mjs) — there is no DOM under * `npm test`, and none of what these assert fails at runtime. A miswired * callback still renders a screen that looks right and just does the wrong * thing on click; these hold the wiring in place by reading the source. * * The mocked-network behavior these wiring points rely on — that * connectLobby really does dispatch a `seats` event to onSeats, that * claimSeat really does send a tagged `claimSeat` request — is proved once, * against api.ts directly, in lobby-client.test.mjs. Re-proving it here * against a screen with no DOM to click would just be the same assertions * wearing a costume. * * Run with `npm test`. */import { test } from "node:test";import assert from "node:assert/strict";import { readFile } from "node:fs/promises";import { join } from "node:path";import { fileURLToPath } from "node:url";
const web = fileURLToPath(new URL("../", import.meta.url));const source = await readFile(join(web, "src/screens/lobby.ts"), "utf8");
test("lobbyScreen takes a LobbyEntry, not a bare ChallengeMode", () => { assert.match( source, /export function lobbyScreen\(\s*session:\s*Session,\s*entry:\s*LobbyEntry,?\s*\):\s*Node\[\]/, "lobbyScreen no longer takes the fresh/join LobbyEntry union", ); const entryType = source.match(/export type LobbyEntry =[\s\S]*?\n\n/); assert.ok(entryType, "LobbyEntry's type declaration moved or was renamed"); assert.match( entryType[0], /kind:\s*"fresh";\s*mode:\s*ChallengeMode/, "LobbyEntry no longer has a fresh case carrying a Mode", ); assert.match( entryType[0], /kind:\s*"join";\s*matchId:\s*string/, "LobbyEntry no longer has a join case carrying a matchId", ); assert.match( entryType[0], /kind:\s*"daily";\s*slug:\s*string/, "LobbyEntry no longer has a daily case carrying a slug - the daily " + "challenge is a mode of this screen, not a screen of its own", );});
test("only the fresh path narrows the pool by Mode; a join gets the whole catalog", () => { assert.match( source, /scenarioPool\(catalog, mode\)/, "the fresh path's scenario list is no longer narrowed by scenarioPool - " + "Duel would show every scenario, not just head-to-head ones", ); const joinBranch = source.match( /if \(entry\.kind === "join"\) \{[\s\S]*?\n {2}\} else \{/, ); assert.ok(joinBranch, "the join branch moved or was restructured"); assert.match( joinBranch[0], /pool = catalog;/, "a joiner no longer gets the unnarrowed catalog as its picker's pool", ); assert.doesNotMatch( joinBranch[0], /scenarioPool\(/, "a join must not narrow the pool by a Mode it was never given", );});
test("a join never calls openLobby - only the fresh path opens a new lobby row", () => { const joinBranch = source.match( /if \(entry\.kind === "join"\) \{[\s\S]*?\n {2}\} else \{/, ); assert.ok(joinBranch, "the join branch moved or was restructured"); assert.doesNotMatch( joinBranch[0], /openLobby\(/, "the join path must connect straight to the existing id, never open a new lobby row", ); assert.match( joinBranch[0], /connectLobby\(joinId, callbacks\)/, "the join path no longer connects straight to entry.matchId", );});
test("the lobby opens on the pool's first scenario, not the raw catalog's", () => { assert.match( source, /const first = pool\[0\];/, "the opening scenario no longer reads from the Mode-filtered pool", ); assert.match( source, /openLobby\(first\.path\)/, "openLobby is no longer called with the pool's first scenario", );});
test("Deploy starts disabled and only updateDeploy re-enables it", () => { // Unlike challenge.ts's Next (which starts enabled with nothing chosen // yet), a fresh lobby has zero claimed seats - Deploy must not be // clickable before the first seat is even taken. const button = source.match(/const deploy = el\("button", \{[^}]*\}\)/s); assert.ok(button, "the Deploy button moved or was renamed"); assert.match( button[0], /disabled: true/, "Deploy must start disabled - nothing is seated yet on a fresh lobby", ); assert.match( source, /function updateDeploy\(\)/, "the seat-completeness check moved or was renamed", );});
test("updateDeploy mirrors the server's every-slot-claimed rule", () => { const fn = source.match(/function updateDeploy\(\): void \{[\s\S]*?\n \}/); assert.ok(fn, "updateDeploy moved or was renamed"); assert.match( fn[0], /current\.slots\.every\(\s*\(slot\)\s*=>\s*seats\.some\(\(s\)\s*=>\s*s\.slot === slot\.name\)/, "updateDeploy no longer checks that every slot has a claimed seat", ); assert.match( fn[0], /waitingOn\(\)\.length > 0/, "updateDeploy no longer waits for the other players to be ready - " + "Deploy would offer a click the API answers with a 400", );});
test("readiness is asked of the other players only, and never of the bot", () => { const others = source.match( /function otherPlayers\(\): MatchPlayer\[\] \{[\s\S]*?\n \}/, ); assert.ok(others, "otherPlayers moved or was renamed"); assert.match( others[0], /seat\.control !== "bot"/, "a bot seat is counted as a player who has to press Ready - nobody is " + "behind it to press anything", ); assert.match( others[0], /const neverAsked = ownerDid \?\? session\.did;[\s\S]*seat\.did !== neverAsked/, "the lobby's owner is counted among the players it is waiting on - " + "pressing Deploy is their own readiness, and a joiner would be shown " + "a wait for a press that is never coming", );
const waiting = source.match( /function waitingOn\(\): MatchPlayer\[\] \{[\s\S]*?\n \}/, ); assert.ok(waiting, "waitingOn moved or was renamed"); assert.match( waiting[0], /!readyDids\.includes\(seat\.did!\)/, "waitingOn no longer filters by the readiness snapshot", );});
test("the Ready toggle sends the opposite of the API's own snapshot, and never assumes it took", () => { const handler = source.match( /readyToggle\.addEventListener\("click", \(\) => \{[\s\S]*?\n \}\);/, ); assert.ok(handler, "the Ready toggle's click handler moved or was renamed"); assert.match( handler[0], /socket\?\.setReady\(!readyDids\.includes\(session\.did\)\)/, "Ready no longer toggles against the API's own snapshot", ); assert.doesNotMatch( handler[0], /readyDids =/, "the click writes readyDids itself - the button must redraw from the " + "API's echo, the same as a claimed seat does", );
// Shown to a seated player and to nobody else: an unseated spectator's // readiness gates nothing on the API side. const render = source.match( /function renderReady\(\): void \{[\s\S]*?\n \}/, ); assert.ok(render, "renderReady moved or was renamed"); assert.match( render[0], /readyToggle\.hidden = !seated/, "the Ready button is no longer hidden from a caller with no seat", );});
test("the first presence snapshot announces nobody, and later ones diff both ways", () => { const fn = source.match( /function renderRoster\(next: Watcher\[\]\): void \{[\s\S]*?\n \}/, ); assert.ok(fn, "renderRoster moved or was renamed"); assert.match( fn[0], /const before = here;[\s\S]*?if \(before\) \{/, "the join/leave lines are no longer guarded on there having been a " + "previous snapshot - arriving to a room of four would announce four " + "people joining, which is a lie about when they got here", ); assert.match(fn[0], /joined\./, "the arrival line moved or was renamed"); assert.match(fn[0], /left\./, "the departure line moved or was renamed"); assert.match( fn[0], /appendSystemLine/, "join/leave lines are no longer drawn as system lines - a derived line " + "must not look like something a player said", );});
test("the roster is in the panel, above what is being said", () => { const panel = source.match( /const chatPanel = el\("aside", \{ className: "lobby-chat" \}[\s\S]*?\n {2}\]\);/, ); assert.ok(panel, "the chat panel moved or was renamed"); assert.ok( panel[0].indexOf("rosterList") < panel[0].indexOf("chatScroll"), "the roster is below the scrollback - who is here belongs above what " + "they are saying", );});
test("a held slot says so, and still offers the one way to fill it", () => { const actions = source.match( /function seatActions\(slotName: string\): HTMLElement \{[\s\S]*?\n \}/, ); assert.ok(actions, "seatActions moved or was renamed"); assert.match( actions[0], /if \(locked\) \{[\s\S]*?you\.disabled = true;[\s\S]*?bot\.disabled = true;/, "a held slot still offers the two answers the API refuses - they would " + "fail silently", ); assert.match( actions[0], /textContent: locked \? "Held for somebody"/, "a held slot no longer says it is held, so its two dead answers say " + "nothing about why", ); // Inviting stays: it is what a hold was for, and the API allows it. assert.match( actions[0], /inviteForm/, "the invite form is gone from an open force's answers - a hold is a " + "reservation, and inviting is how it gets filled", ); assert.doesNotMatch( source, /setLock/, "the Hold control is back - reserving a force for somebody who may " + "never arrive is the reverse of what an open force is for", );});
test("Fill is stamped and disabled rather than left off the control", () => { const actions = source.match( /function seatActions\(slotName: string\): HTMLElement \{[\s\S]*?\n \}/, ); assert.ok(actions, "seatActions moved or was renamed"); const fill = actions[0].slice(actions[0].indexOf("const fill =")); assert.match( fill, /disabled: true/, "Fill is pressable - matchmaking does not exist, and a control that " + "answers nothing is worse than one that says so", ); assert.match( fill, /soonStamp\("seat"\)/, "Fill lost the site's own coming-soon mark", ); assert.match( fill, /notReadyNote\(\)/, "Fill's label no longer carries the note the stamp cannot give a " + "screen reader", );});
test("the invite field is what Invite opens, and it keeps what was typed", () => { const actions = source.match( /function seatActions\(slotName: string\): HTMLElement \{[\s\S]*?\n \}/, ); assert.ok(actions, "seatActions moved or was renamed"); assert.match( actions[0], /inviteForm\.hidden = !inviting\.has\(slotName\)/, "the handle field is showing on every open force again - the screen is " + "mostly a roster, and that is one text input per force", ); assert.match( actions[0], /inviteInput\.value = inviting\.get\(slotName\) \?\? ""/, "a half-typed handle no longer survives a seat snapshot, which rebuilds " + "every card from scratch", );});
test("Make host is offered on somebody else's seat and never on your own", () => { const holder = source.match(/function seatHolder\([\s\S]*?\n \}/); assert.ok(holder, "seatHolder moved or was renamed"); assert.match( holder[0], /isBot \|\| !seatDid \|\| seatDid === session\.did \|\| seatDid === ownerDid/, "Make host is offered on your own seat, a bot's, or the host's own - " + "all three are refused by the API outright", ); assert.match( holder[0], /socket\?\.makeHost\(seatDid!\)/, "Make host no longer sends the transfer", );});
test("chat, the invite link and Ready are one floating panel, not three blocks in the card", () => { const panel = source.match( /const chatPanel = el\("aside", \{ className: "lobby-chat" \}[\s\S]*?\n {2}\]\);/, ); assert.ok(panel, "the chat panel moved or was renamed"); for (const [part, why] of [ ["copyLink", "the invite link left the chat panel"], ["chatScroll", "the chat scrollback left the chat panel"], ["chatForm", "the chat form left the chat panel"], ["readyToggle", "the Ready toggle left the chat panel"], ]) { assert.match(panel[0], new RegExp(part), why); }
const tree = source.match(/return \[\n {4}el\("h1"[\s\S]*?\n {2}\];/); assert.ok(tree, "the screen's returned node tree moved or was restructured"); const card = tree[0].slice(tree[0].indexOf('className: "card challenge"')); assert.ok( card.indexOf("chatPanel") > card.indexOf("status,"), "the chat panel is back inside the card - it floats over the screen, " + "which means it is a sibling of the card, not a block in it", ); assert.doesNotMatch( tree[0], /textContent: "Set up the match"/, "the card's heading is back - the card that sent the player here " + "already said what this is", );});
test("renderSeats groups the forces into one column per team", () => { // Allies belong together: Bug Company's three pirate lances are one side and // must read as one, not as three opponents spread across the width. A // scenario with no team data has one team per force, which is the same // layout this had before and the honest reading of not knowing. const fn = source.match(/function renderSeats\(\): void \{[\s\S]*?\n \}/); assert.ok(fn, "renderSeats moved or was renamed"); assert.match( fn[0], /for \(const slot of current\.slots\)/, "renderSeats no longer builds one card per current slot", ); assert.match( fn[0], /const key = teamOf\(slot\.name\)/, "renderSeats no longer groups the forces by the team they fight on", ); assert.match( fn[0], /column\.append\(forceCard\(slot, seat, drawing\)\)/, "a force is no longer appended into its team's own column", ); const team = source.match(/function teamOf\([\s\S]*?\n \}/); assert.ok(team, "teamOf moved or was renamed"); assert.match( team[0], /teamNow\(slotName\)/, "the grouping no longer follows the team a force will actually fight " + "on - a lobby that moved a seat would still be drawn in the columns " + "the scenario file asked for", ); assert.match( team[0], /team === null\s*\?\s*`slot:\$\{slotName\}`/, "a force whose team nothing knows no longer stands as its own team", );});
test("the team a force fights on is the lobby's answer, then the scenario's", () => { // The same `or` the API makes once, at launch, in lobby_seats_to_players. const now = source.match( /function teamNow\(slotName: string\): number \| null \{[\s\S]*?\n \}/, ); assert.ok(now, "teamNow moved or was renamed"); assert.match( now[0], /seat\?\.team \?\? scenarioTeam\(slotName\)/, "the override no longer wins over the scenario's own team", );
const fromFile = source.match( /function scenarioTeam\(slotName: string\): number \| null \{[\s\S]*?\n \}/, ); assert.ok(fromFile, "scenarioTeam moved or was renamed"); assert.match( fromFile[0], /daily\?\.sides\.find\(\(side\) => side\.name === slotName\)\?\.team/, "a daily's own index no longer supplies the team its file sets", );});
test("the team control sends the change and never guesses who may make it", () => { const control = source.match( /function teamControl\(slotName: string\): HTMLElement \{[\s\S]*?\n \}/, ); assert.ok(control, "teamControl moved or was renamed"); assert.match( control[0], /socket\?\.setTeam\(\s*slotName,\s*select\.value === "" \? null : Number\(select\.value\),?\s*\)/, "the control no longer sends the change - or no longer sends null for " + "the scenario's own answer, which is not the same as team 0", ); assert.doesNotMatch( control[0], /disabled/, "the control disables itself for somebody - only the API knows whether " + "this caller holds the seat or owns the lobby, the same posture Kick " + "and Deploy already take", ); assert.match( control[0], /if \(daily\) \{/, "a daily gets the control - its sides are the challenge, and changing " + "them would make it a different fight to the same score", );});
test("a Force card offers both seat resolvers when open, and neither when claimed", () => { const fn = source.match(/function forceCard\([\s\S]*?\n \}/); assert.ok(fn, "forceCard moved or was renamed"); assert.match( fn[0], /seat \? seatHolder\(seat\) : seatActions\(slot\.name\)/, "a claimed seat and an open one are no longer rendered differently", );});
test("a claimed seat's Kick button calls kickSeat with the seat's own slot", () => { const fn = source.match( /function seatHolder\(seat: MatchPlayer\)[\s\S]*?\n \}/, ); assert.ok(fn, "seatHolder moved or was renamed"); assert.match( fn[0], /socket\?\.kickSeat\(seat\.slot\)/, "Kick no longer calls kickSeat with the held seat's own slot", );});
test("Kick has no client-side ownership check - it is always rendered", () => { const fn = source.match( /function seatHolder\(seat: MatchPlayer\)[\s\S]*?\n \}/, ); assert.ok(fn, "seatHolder moved or was renamed"); assert.doesNotMatch( fn[0], /session\.did\s*===\s*(owner|ownerDid)/, "Kick should not be conditionally hidden by a client-side ownership " + "guess - the module doc explains why: this client cannot reliably " + "know the lobby's owner on the join path, so the server is the only " + "real check, the same posture Deploy already has", );});
test("a Force card names the slot, and its team only when a scenario says", () => { // A daily's index carries the real team, because the scenario file sets one // and that is what makes three pirate lances a single side. The hand-written // catalog carries none at all (services/api/src/matches/scenarios.rs), and // there the pill must still say so plainly rather than guess a grouping. const card = source.match(/function forceCard\([\s\S]*?\n \}/); assert.ok(card, "forceCard moved or was renamed"); assert.match( card[0], /className:\s*"force-card-name",\s*textContent:\s*slot\.name/, "the card no longer shows the slot's own name as the force's name", ); assert.match( card[0], /teamControl\(slot\.name\)/, "the card no longer carries a team indicator", ); const control = source.match(/function teamControl\([\s\S]*?\n \}/); assert.ok(control, "teamControl moved or was renamed"); assert.match( control[0], /textContent: team === null \? "Team: n\/a" : `Team \$\{team\}`/, "a daily's pill no longer reads as an honest stub when nothing knows - " + "it must not guess at an alliance the catalog does not carry", );});
test("a card's colour is the side's own, so blue is always yours", () => { // The colours are daily.ts's SIDE_CSS, the same list the challenge card // paints its board from: your force reads the same on both screens. const card = source.match(/function forceCard\([\s\S]*?\n \}/); assert.ok(card, "forceCard moved or was renamed"); assert.match( card[0], /card\.style\.setProperty\("--force-colour", sideCss\(ranks\.team, ranks\.slot\)\)/, "the card's colour no longer comes from the side it is on", ); const pick = source.match(/function ranksOf\([\s\S]*?\n \}/); assert.ok(pick, "ranksOf moved or was renamed"); assert.match( pick[0], /sideRanks\(daily,/, "a daily no longer groups its forces by MegaMek team - three lances on " + "one team must not read as three separate opponents", ); assert.match( pick[0], /seats\.find\(\(seat\) => seat\.did === session\.did\)\?\.slot/, "an ordinary lobby no longer colours by the seat you actually hold", );});
test("taking a seat and assigning the bot call the matching socket methods", () => { const fn = source.match( /function seatActions\(slotName: string\)[\s\S]*?\n \}/, ); assert.ok(fn, "seatActions moved or was renamed"); assert.match( fn[0], /socket\?\.claimSeat\(slotName\)/, '"You" no longer calls claimSeat', ); assert.match( fn[0], /socket\?\.assignBot\(slotName\)/, '"AI" no longer calls assignBot', );});
test("an open slot also offers a handle typeahead wired to inviteHandle", () => { const fn = source.match( /function seatActions\(slotName: string\)[\s\S]*?\n \}/, ); assert.ok(fn, "seatActions moved or was renamed"); assert.match( fn[0], /withHandleTypeahead\(inviteInput\)/, "the invite field is no longer wrapped with the handle typeahead", ); assert.match( fn[0], /socket\?\.inviteHandle\(slotName, handle\)/, "the invite form no longer calls the socket's inviteHandle", );});
test("every unit in a Force card is named in full, with what it is worth", () => { // The model is the half that tells a Locust LCT-1S from an LCT-1V, so the // whole designation is shown. Battle value comes from the scenario (it moves // with the crew) and weight from content/units.ts (it does not); either may // be missing, and a missing figure is drawn as a dash rather than a zero. const fn = source.match(/function unitList\([\s\S]*?\n \}/); assert.ok(fn, "unitList moved or was renamed"); assert.match( fn[0], /className:\s*"force-unit-designation",\s*textContent:\s*unit/, "a unit's row no longer carries the whole designation, model included", ); assert.match( fn[0], /figures\.length \? figures\.join\(" · "\) : "—"/, "a unit with nothing known about it no longer shows a dash", ); assert.match( fn[0], /className:\s*"unit-icon"/, "a unit's list entry no longer paints a small icon", );});
test("a force's totals are summed from its own rows, and marked when partial", () => { const fn = source.match(/function forceTotal\([\s\S]*?\n \}/); assert.ok(fn, "forceTotal moved or was renamed"); assert.match( fn[0], /knownBv\.reduce\(\(a, b\) => a \+ b, 0\)/, "the battle value total is no longer summed from the rows above it, so " + "the total and the list can now disagree", ); assert.match( fn[0], /knownBv\.length < units\.length \? "\+" : ""/, "a total built from an incomplete set is no longer marked as a floor", );});
test("camoForSlot resolves camo only for the caller's own claimed seat", () => { // Past the bot's own roll, which the botCamo lookup above it answers: // another player's choice is theirs to bring, and this client has no way to // know it before Deploy settles it. Those forces draw bare sprite grey. const fn = source.match( /async function camoForSlot\(slotName: string\)[\s\S]*?\n \}/, ); assert.ok(fn, "camoForSlot moved or was renamed"); assert.match( fn[0], /if \(!seat \|\| seat\.did !== session\.did\) return null;/, "camoForSlot no longer refuses every seat but the caller's own", );});
test("the separate scenario-preview panel is gone - the Force cards are its replacement", () => { // challenge.ts (unreached from either Matches card, see the module doc) // still mounts scenario/preview.ts's own scenarioPreview() panel - only // this screen's now-redundant copy of it was removed, in favour of the // same compositor feeding each card's own unit icons instead. assert.doesNotMatch( source, /previewMount|scenarioPreview\(\)|scenario-preview-mount/, "a second, now-redundant rendering of the current scenario's forces is " + "back - the Force cards already show the same units, in the same camo", );});
test("the camo picker is mounted and a user-initiated pick saves the account default", () => { assert.match( source, /const camo = camoPicker\(session\)/, "the shared camo picker is no longer mounted on this screen", ); const handler = source.match( /camo\.onChange = \(userInitiated\) => \{[\s\S]*?\n \};/, ); assert.ok(handler, "camo.onChange's handler moved or was renamed"); assert.match( handler[0], /if \(!userInitiated\) return;/, "an auto-preselected camo (no user click yet) must not write a default", ); assert.match( handler[0], /setDefaultCamo\(camo\.rkey \?\? null\)/, "a user-initiated pick no longer saves the account's default camo - " + "a claimed lobby seat has no per-seat camo field to carry one on " + "any other way", );});
test("the scenario preview is loaded lazily and never fatally, like challenge.ts's", () => { assert.match( source, /void import\("\.\.\/scenario\/preview"\)/, "the scenario preview chunk is no longer loaded lazily", ); assert.match( source, /\.catch\(\(error: unknown\) => \{\s*console\.warn\("lobby: the scenario preview could not load", error\);/, "a failed preview load is no longer swallowed - the lobby must still work without it", );});
test("one shared callbacks object is wired to all eleven lobby events, for both entry paths", () => { const callbacks = source.match(/const callbacks = \{[\s\S]*?\n {2}\};/); assert.ok(callbacks, "the shared callbacks object moved or was renamed"); assert.match( callbacks[0], /onOpen\(\)\s*\{/, "the socket-opened callback moved, was renamed, or was never added", ); assert.match( callbacks[0], /onOwner\(did: string\)\s*\{/, "the owner callback moved, was renamed, or was never added", ); assert.match( callbacks[0], /onScenario\(path: string\)\s*\{/, "the scenario callback moved or was renamed", ); assert.match( callbacks[0], /onSeats\(next: MatchPlayer\[\]\)\s*\{/, "the seats callback moved or was renamed", ); assert.match( callbacks[0], /onLocked\(slots: string\[\]\)\s*\{/, "the lock callback moved, was renamed, or was never added", ); assert.match( callbacks[0], /onPresence\(watchers: Watcher\[\]\)\s*\{/, "the presence callback moved, was renamed, or was never added", ); assert.match( callbacks[0], /onReady\(dids: string\[\]\)\s*\{/, "the readiness callback moved, was renamed, or was never added", ); assert.match( callbacks[0], /onDeployed\(newMatchId: string\)\s*\{/, "the deployed callback moved or was renamed", ); assert.match( callbacks[0], /onChat\(line: ChatLine\)\s*\{/, "the chat callback moved or was renamed", ); assert.match( callbacks[0], /onClosed\(\)\s*\{/, "the closed callback moved or was renamed", );
// Both entry paths pass this same object to connectLobby, rather than // each wiring its own copy that could quietly drift from the other's. const wireUps = [...source.matchAll(/connectLobby\([\w.]+, callbacks\)/g)]; assert.equal( wireUps.length, 2, "expected exactly two connectLobby(..., callbacks) call sites - the join path and the fresh path", );});
test("onOpen appends a locally-generated system line, never a chat send", () => { const callbacks = source.match(/const callbacks = \{[\s\S]*?\n {2}\};/); assert.ok(callbacks, "the shared callbacks object moved or was renamed"); const onOpen = callbacks[0].match(/onOpen\(\)\s*\{[\s\S]*?\n {4}\},/); assert.ok(onOpen, "onOpen moved or was restructured"); assert.match( onOpen[0], /appendSystemLine\(/, "onOpen no longer appends the connected-to-chat line", ); assert.doesNotMatch( onOpen[0], /socket\?\.|sendChat/, "the connection line must be purely local - never sent to the server", );
// onOpen is textually the callbacks object's first key, ahead of onChat - // the shape that puts the connection line first in the DOM, since both // appendSystemLine and appendChat only ever append to the end of // chatScroll (see the ordering proved against the wire in // lobby-client.test.mjs's "onOpen fires before any backlog or live chat // line" - there is no DOM under this test file to prove it directly, see // the module doc at the top of this file). const openIndex = callbacks[0].indexOf("onOpen("); const chatIndex = callbacks[0].indexOf("onChat("); assert.ok(openIndex >= 0 && chatIndex >= 0 && openIndex < chatIndex);});
test("appendSystemLine renders a chat-system line distinct from a spoken chat-line", () => { const fn = source.match( /function appendSystemLine\(text: string\): void \{[\s\S]*?\n \}/, ); assert.ok(fn, "appendSystemLine moved, was renamed, or was never added"); assert.match( fn[0], /className:\s*"chat-line chat-system"/, "the system line no longer carries chat-system alongside chat-line - " + "it needs both: chat-line for the scrollback's own layout rules, " + "chat-system so it reads as a notice, not something a player said", ); assert.doesNotMatch( fn[0], /speaker\(/, "the system line must not go through speaker() - it has no author", );});
test("onClosed tells a real drop apart from a link that never connected, and ignores a deliberate close", () => { const callbacks = source.match(/const callbacks = \{[\s\S]*?\n {2}\};/); assert.ok(callbacks, "the shared callbacks object moved or was renamed"); const onClosed = callbacks[0].match(/onClosed\(\)\s*\{[\s\S]*?\n {4}\},/); assert.ok(onClosed, "onClosed moved or was restructured"); assert.match( onClosed[0], /if \(left\) return;/, "onClosed no longer ignores a close this screen's own teardown caused", ); assert.match( onClosed[0], /connected\s*\?/, "onClosed no longer distinguishes a drop after connecting from a link that never connected", );});
test("chat is not gated on holding a seat", () => { // Anyone connected may talk - a lobby is where seats get arranged, and // requiring one to discuss which seat to take is backwards. There is no // seat lookup anywhere near the chat form to gate it on. const handler = source.match( /chatForm\.addEventListener\("submit", \(event\) => \{[\s\S]*?\n \}\);/, ); assert.ok(handler, "the chat form's submit handler moved"); assert.doesNotMatch( handler[0], /seats\.some|seats\.find/, "the chat form must not check the seat map before sending", );});
test("a chat line is trimmed and capped before it is ever sent", () => { const handler = source.match( /chatForm\.addEventListener\("submit", \(event\) => \{[\s\S]*?\n \}\);/, ); assert.ok(handler, "the chat form's submit handler moved"); assert.match( handler[0], /chatInput\.value\.trim\(\)/, "a chat line is no longer trimmed before sending", ); assert.match( handler[0], /text\.length > CHAT_MAX_CHARS/, "a chat line is no longer checked against CHAT_MAX_CHARS before sending", ); assert.match( handler[0], /socket\?\.sendChat\(text\)/, "the chat form no longer calls sendChat", ); assert.match( source, /maxLength: CHAT_MAX_CHARS/, "the chat input no longer mirrors CHAT_MAX_CHARS as its own max length", );});
test("a chat line - backlog or live - is appended, never causes a re-render", () => { const fn = source.match( /function appendChat\(line: ChatLine\): void \{[\s\S]*?\n \}/, ); assert.ok(fn, "appendChat moved or was renamed"); assert.doesNotMatch( fn[0], /render\(/, "appending a chat line must mutate the scrollback in place, not call " + "the app's top-level render()", );});
test("a deployed lobby lands on the waiting screen, from either path in", () => { // Both the socket's `deployed` event and a successful deployLobby() call // from clicking Deploy itself must land here - the click's own HTTP // response and the broadcast the API sends can arrive in either order. const matches = [ ...source.matchAll(/render\(\.\.\.waitingScreen\(session, (\w+)\)\)/g), ]; assert.equal( matches.length, 2, "expected exactly two ways into the waiting screen: the deployed event " + "and deployLobby()'s own success path", );});
test("Deploy launches through launch() and shows the server's message on failure", () => { const handler = source.match( /deploy\.addEventListener\("click", \(\) => \{[\s\S]*?\n \}\);/, ); assert.ok(handler, "Deploy's click handler moved"); assert.match( handler[0], /const launched = launch\(\);/, "Deploy no longer asks launch() what to start - the daily and lobby " + "paths would have to fork the whole handler instead", ); assert.match( handler[0], /err instanceof ApiError\s*\?\s*err\.message/, "a deploy failure no longer shows the server's own message", );});
test("launch() deploys a lobby, and starts a daily through its own endpoint", () => { const fn = source.match( /function launch\(\): Promise<string> \| null \{[\s\S]*?\n \}/, ); assert.ok(fn, "launch moved, was renamed, or was never added"); assert.match( fn[0], /startDaily\(daily\.slug, all, own\)/, "a daily no longer launches through POST /api/daily/<slug> - the lobby " + "path would open a lobby row for a fight that has no seats to arrange", ); assert.match( fn[0], /deployLobby\(id, all, own\)/, "an ordinary lobby no longer deploys through deployLobby", ); assert.match( fn[0], /pickedBlob\(entry\[1\]\.picker\)/, "the launch no longer carries the camo the player was actually shown - a " + "second roll on the way out would make the preview a lie", );});
test("the socket is closed on teardown, and marked left before it closes", () => { const teardown = source.match(/onTeardown\(\(\) => \{[\s\S]*?\n {2}\}\);/); assert.ok( teardown, "leaving the lobby screen no longer registers a teardown", ); assert.match( teardown[0], /left = true;/, "teardown no longer marks the screen as left before closing the socket - " + "onClosed would then mistake its own close() for a real drop", ); assert.match( teardown[0], /socket\?\.close\(\);/, "leaving the lobby screen no longer closes its socket", );});
test("the module doc no longer claims there is no seats UI or Deploy button", () => { const doc = source.slice(0, source.indexOf("export function lobbyScreen")); assert.doesNotMatch( doc, /no seats UI|no Deploy button/i, "the module doc still describes the screen this file replaced", );});
test("exactly one openLobby call happens per visit to this screen", () => { const calls = source.match(/openLobby\(/g) ?? []; assert.equal( calls.length, 1, "openLobby must be called exactly once per visit - calling it again " + "mid-session opens a second lobby row server-side", );});
test("only the two waitingScreen paths ever call the app's render() - Back leaves through the router instead", () => { // The load-bearing rule the chat/seats/scenario live-update wiring all // depend on: render() tears the screen down via onTeardown, which is what // closes this screen's socket. A live update that called it would close // the real socket out from under itself. Two call sites are legitimate: // the two paths into waitingScreen (the deployed event, and // deployLobby()'s own success handler) - see "a deployed lobby lands on // the waiting screen" above. Back used to be a third, direct render() // call; now that this screen has a real address, it fixes the address bar // and calls nav.start!() instead, so the router's own bookkeeping // (shown, shownLobbyId, the masthead marker) stays correct on the way out // too - see the next test. const calls = source.match(/\brender\(\.\.\./g) ?? []; assert.equal( calls.length, 2, "an unexpected number of render() call sites - either a live update " + "started re-rendering the whole screen, or one of the two " + "waitingScreen transitions went missing", );});
test("Back fixes the address to #matches and leaves through the router, not a direct render()", () => { const handler = source.match( /back\.addEventListener\("click", \(\) => \{[\s\S]*?\n {2}\}\);/, ); assert.ok(handler, "Back's click handler moved"); assert.match( handler[0], /window\.history\.replaceState\(/, "Back no longer fixes the address bar on its way out", ); assert.match( handler[0], /#matches`,?\s*\);/, "Back no longer replaces the address with #matches", ); assert.match( handler[0], /void nav\.start!\(\);/, "Back no longer re-invokes the router - see the module doc for why a " + "direct render(...nav.matches!(session)) is no longer enough now " + "that this screen has a real address", ); assert.doesNotMatch( handler[0], /render\(/, "Back must not call render() directly - nav.start!() is what renders " + "Matches now, after fixing the address first", );});
test("the fresh path syncs the router's shown state before rendering, and again once the id is known", () => { // matches.ts calls nav.markShown!("lobby") itself, with no id, the moment // it renders this screen directly (see matches.test... no such file - see // matches.ts itself). This screen's own responsibility is the second // call, once openLobby resolves and the address bar is fixed to match. const freshBranch = source.match( /\} else \{[\s\S]*?openLobby\(first\.path\)[\s\S]*?\n {2}\}/, ); assert.ok(freshBranch, "the fresh branch moved or was restructured"); assert.match( freshBranch[0], /window\.history\.replaceState\(/, "the fresh path no longer fixes the address bar once its id is known", ); assert.match( freshBranch[0], /nav\.markShown!\("lobby", id\)/, "the fresh path no longer tells the router its shown lobby id, once known", );});
// --- the daily challenge: the same screen, narrowed ------------------------//// Every one of these is a thing that renders perfectly well when it is wrong.// A seat select left in a daily still looks like a lobby, and a player who// swaps sides gets a different fight scored against everybody else's; a chat// box left in still draws, and just never has anyone in it.
test("a daily seats every one of its factions itself, in slot order", () => { const branch = source.match( /if \(entry\.kind === "daily"\) \{[\s\S]*?\n {2}\} else if/, ); assert.ok(branch, "the daily branch moved or was restructured"); assert.match( branch[0], /current = dailyScenario\(daily\)/, "a daily no longer fixes the scenario to the day's own fight", ); assert.match( branch[0], /seats = current\.slots\.map\(\(slot, index\) =>/, "a daily no longer seats every faction it fields - Bug Company has four, " + "and seating only two left the rest offering an open force's answers " + "Deploy disabled forever (updateDeploy wants every slot claimed)", ); assert.match( branch[0], /index === 0[\s\S]*?control: "human",[\s\S]*?did: session\.did/, "the player no longer takes the first slot - every daily scenario names " + "the player's faction first (services/api/src/matches/daily.rs's " + "PLAYER_SLOT), and the API seats it that way whatever this screen shows", ); assert.match( branch[0], /control: "bot",\s*did: null/, "the bot no longer takes every faction after the first", ); assert.doesNotMatch( branch[0], /team: [1-9]/, "a daily's seats carry a team override - its sides are the challenge, " + "and the scenario file is what says who is allied with whom", );});
test("a daily opens no lobby and connects no socket", () => { const branch = source.match( /if \(entry\.kind === "daily"\) \{[\s\S]*?\n {2}\} else if/, ); assert.ok(branch, "the daily branch moved or was restructured"); assert.doesNotMatch( branch[0], /openLobby\(|connectLobby\(|fetchScenarios\(/, "a daily must not open a lobby row, connect a socket, or fetch the " + "catalog - its scenario is compiled into the API and is not in it", );});
test("a daily offers no way to change the scenario or to move either seat", () => { const list = source.match( /function renderScenarioList\(\): void \{[\s\S]*?\n \}/, ); assert.ok(list, "renderScenarioList moved or was renamed"); const dailyList = list[0].match(/if \(daily\) \{[\s\S]*?\n {6}return;/); assert.ok(dailyList, "renderScenarioList no longer special-cases a daily"); assert.doesNotMatch( dailyList[0], /type: "radio"|setScenario/, "a daily still renders a scenario radio - picking another fight would " + "score a different one against everybody else's day", );
// Both seats are filled by the branch above, so seatActions is unreachable // in a daily; Kick is the one control that would still render on a claimed // seat, and it is what a player would use to unseat themselves and swap. const holder = source.match( /function seatHolder\(seat: MatchPlayer\)[\s\S]*?\n \}/, ); assert.ok(holder, "seatHolder moved or was renamed"); assert.match( holder[0], /\.\.\.\(daily \? \[\] : \[kick\]\)/, "Kick is back on a daily's seats - there is no lobby to kick anyone " + "from, and unseating yourself is how a fixed side stops being fixed", );});
test("a daily shows no chat and no invite link", () => { const tree = source.match(/return \[\n {4}el\("h1"[\s\S]*?\n {2}\];/); assert.ok(tree, "the screen's returned node tree moved or was restructured"); assert.match( tree[0], /\.\.\.\(daily \? \[\] : \[chatPanel\]\)/, "the chat panel is no longer dropped for a daily - it is one player " + "against a bot, and there is nobody on the other end to talk to, " + "invite, or wait for", ); assert.doesNotMatch( tree[0], /copyLink/, "the invite link is back in the card - it belongs to the chat panel, " + "which a daily does not render at all", );});
test("a daily paints its two forces in the card's own colours", () => { const fn = source.match( /async function camoForSlot\(slotName: string\)[\s\S]*?\n \}/, ); assert.ok(fn, "camoForSlot moved or was renamed"); assert.match( fn[0], /if \(daily\) return \(await ownCamo\(seat\)\) \?\? sideCamo\(slotName\);/, "a daily's forces no longer fall back to their side colour - the " + "opposition would draw bare sprite grey against the player's blue", ); const side = source.match( /function sideCamo\(slotName: string\): ImageData \{[\s\S]*?\n \}/, ); assert.ok(side, "sideCamo moved, was renamed, or was never added"); assert.match( side[0], /sideRgb\(team, slot\)/, "the lobby's colours are no longer the shared team palette - two lists " + "for the same forces is one list that can be wrong", );});
test("a camo change repaints one force, not every force on the screen", () => { // Monte Diablo fields twenty-four machines across four forces. Rebuilding // all of them because one bot lance changed camo re-composited every sprite // on the screen, which was slow enough to feel. const bot = source.match( /function botCamoFor\(slotName: string\) \{[\s\S]*?\n \}/, ); assert.ok(bot, "botCamoFor moved or was renamed"); assert.match( bot[0], /picker\.onChange = \(\) => repaint\.get\(slotName\)\?\.\(\)/, "a bot's camo change no longer repaints just the force it was chosen for", ); assert.doesNotMatch( bot[0], /renderSeats\(\)/, "a camo change rebuilds every force card again - that is the slow path " + "this replaced", ); const mine = source.match(/function repaintMine\([\s\S]*?\n \}/); assert.ok(mine, "repaintMine moved or was renamed"); assert.match( mine[0], /repaint\.get\(mine\)\?\.\(\)/, "the player's own camo choice no longer repaints only their own force", );});
test("a bot handed an open slot is dressed, once", () => { // challenge.ts dresses every AI slot without being asked. A lobby's bot // wore nothing until somebody found Shuffle, so the two screens disagreed // about what the same opposition looked like. const fn = source.match(/function dressNewBots\([\s\S]*?\n \}/); assert.ok(fn, "dressNewBots moved or was renamed"); assert.match( fn[0], /if \(seat\.control !== "bot"\) continue;/, "a human seat is being given a camo it did not ask for", ); assert.match( fn[0], /if \(botCamo\.has\(seat\.slot\)\) continue;/, "a force that already has a picker is given a new one - what the player " + "chose for it must survive the next seat update", ); assert.match( fn[0], /if \(before\.some\(\(held\) => held\.slot === seat\.slot\)\) continue;/, "a slot somebody already held is treated as newly open", ); // The roll has to follow the render, or it repaints a card that call is // about to replace. assert.match( source, /seats = next;\s*\n\s*renderSeats\(\);[\s\S]{0,200}?dressNewBots\(before, next\)/, "onSeats no longer dresses a bot arriving, or does it before the render " + "that builds the card it repaints", );});
test("a force card says where it deploys, in words", () => { // Monte Diablo shipped with two forces set to "Any" and it took playing it // to notice an enemy lance in the player's own zone. The card says it now. const fn = source.match(/function zoneLine\([\s\S]*?\n \}/); assert.ok(fn, "zoneLine moved or was renamed"); assert.match( fn[0], /daily\.sides\.find\(\(side\) => side\.name === slotName\)\?\.start/, "a daily's own deployment zone is no longer read", ); assert.match( fn[0], /current\?\.slots\.find\(\(s\) => s\.name === slotName\)\?\.deploy/, "a catalog scenario's deployment zone is no longer read", ); assert.match( source, /ANY: "anywhere on the board"/, "'Any' no longer reads as the warning it is - a force that deploys " + "anywhere can land inside somebody else's zone", );});
test("a catalog scenario's battle values come from its own report", () => { const fn = source.match(/function unitBv\([\s\S]*?\n \}/); assert.ok(fn, "unitBv moved or was renamed"); assert.match( fn[0], /current\?\.slots\.find\(\(s\) => s\.name === slotName\)/, "a catalog scenario's units no longer carry a battle value, so every " + "catalog force card would show a dash where the figure goes", );});
test("the invite link is disabled until a matchId exists, on both entry paths", () => { const copyLink = source.match(/const copyLink = el\("button", \{[^}]*\}\)/s); assert.ok(copyLink, "the copy-invite-link button moved or was renamed"); assert.match( copyLink[0], /disabled: true/, "the invite link must start disabled - there is no id to link to yet", ); assert.match( source, /copyLink\.disabled = false;/, "nothing ever re-enables the invite link once a matchId exists", ); const clickHandler = source.match( /copyLink\.addEventListener\("click", \(\) => \{[\s\S]*?\n {2}\}\);/, ); assert.ok(clickHandler, "the invite link's click handler moved"); assert.match( clickHandler[0], /window\.location\.origin/, "the copied link is no longer built from the page's own origin", ); assert.match( clickHandler[0], /#lobby\/\$\{encodeURIComponent\(id\)\}/, "the copied link no longer names the lobby's own #lobby/<id> address", ); assert.match( clickHandler[0], /navigator\.clipboard\s*\n?\s*\.writeText\(url\)/, "the invite link no longer copies the built URL to the clipboard", );});