Spec — site test suite (Control Center and the event pages)¶
Status: not started; revise before building. None of this suite's own page tests are built. The site engine, merged 2026-10-06, built the
_tests/layout, runner, fixtures and helpers this spec shares, and unit tests that take the place of its parity layer; with the shared modules, the consistency layer is mostly not needed. Revise §4 against the engine's_tests/before starting. Written 2026-10-02 againstsage-match-control.github.ioat89a7476(live push switched on in the pages). Revised 2026-10-03 against9b49558: the attendance desk pages and the console's Attendance tab, and team rosters (the Teams tab in PickleDrive's hub and the console, level and gender in Match Finder) are onmain, and this page covers them. The sibling of thesage-tools-apitest suite, which covers the Cloud Run service and stops at its HTTP boundary.Overlaps the automated dry run. §2 is the line between them: this suite owns every check that runs on fixtures, and the dry run's rendering layer calls this suite's checks instead of building its own.
Revise before building: the site engine comes first. That spec builds
_tests/with the same runner, Playwright, router and clock as §4, and moves the live pages onto shared modules. Once it is implemented:
- this suite's consistency layer is mostly not needed, because no byte-identical blocks are left;
- its parity layer becomes that spec's unit tests;
- its page tests remain, run against the engine's shells.
Revise this spec after the engine's Phase 5.
Do not push any of this during an event, or in the days before one.
_tests/is never published, but a push tomainstill runs a Pages build.
Pin what Control Center and the event pages do today, in tests that run the pages' own code in a real browser against fixture data, so that a change to a page, or to one copy of a rule that exists in several pages, fails a test instead of surfacing on event day.
0. Read this first (implementer orientation)¶
Assume no knowledge of this project beyond this page.
Repos. D:\Personal\SAGE is a plain folder holding four git repos. This
spec adds a _tests/ folder, one .gitignore line and a pre-push hook to
sage-match-control.github.io (the GitHub Pages site), adds fixtures under its
existing _fixtures/, and changes no page. It reads one file from the
sibling sage-tools-api repo (§5.2) and changes nothing there.
What the site is. Hand-written, self-contained HTML. Each page is one file
with an inline <style> and one classic inline <script>: no modules, no
bundler, no package.json, no tests. Pages deploy on push to main. GitHub
Pages runs Jekyll over the repo (there is no .nojekyll, on purpose), and
Jekyll does not publish top-level folders whose names begin with _. That is
what keeps _templates/ and _fixtures/ off the live site, and it is why the
suite lives in _tests/: its package.json, tests and node_modules
are never published.
Facts checked against the code (each one shapes the design):
- Page functions are globals. Every page's script is a classic
<script>, so its top-levelfunctiondeclarations are properties ofwindow, and its top-levelconsts are visible to code evaluated in the page. A test can callsideIsBye(...)orteamMatchupResult(...)in the loaded page and get the page's real answer. No extraction or reimplementation is needed, and no page has to change. - Only some pages support
?fixture=.tools/control-center.html, PickleDrive's two pages and the attendance desk pages (the template and each event's copy) defineFIXTURE: onlocalhost/127.0.0.1,?fixture=<name>loads/_fixtures/...instead of the published data. Piggleball and both templates do not; they only testtypeof FIXTURE !== 'undefined'to keep the live channel off. So the suite does not rely on?fixture=. It answers the pages' real data URLs (https://sage-match-control.github.io/event-data/...) from inside the browser (§4.3), which works for every page, needs no page change, and tests the real URL each page builds. - Templates are runnable once their tokens are filled. Both templates'
index.htmlandschedule.htmlcontain{{TOKEN}}placeholders ({{EVENT_KEY}},{{EVENT_TITLE}},{{SCHEDULE_DAY_KEY}}, the club tokens in the dual meet, …;_templates/CLAUDE.md§3 lists them) and// EXAMPLEconfig arrays (DAYS,FACILITIES, …). The suite's static server fills the tokens with fixed test values on the fly (§4.3). Every new event is copied from a template, so testing the templates tests what the next event starts from. - Rules are copied by hand between pages. These are the copies:
| Rule | Copies | Listed in root CLAUDE.md? |
|---|---|---|
LIVE CHANNEL block (between // ==== LIVE CHANNEL and // ==== END LIVE CHANNEL ====) |
9 files: tools/control-center.html, PickleDrive's and Piggleball's index.html + schedule.html, both templates' index.html + schedule.html. Byte-identical by rule |
yes ("compare them with diff") |
| "Played" and "BYE" | sage-tools-api/src/sync/domain/facilityCompletion.mjs (facilityIsComplete) and the console's rowsToMatches (played = both scores present), sideIsBye, matchByeSide, computeFacilityProgress |
yes |
ATTENDANCE CLIENT block (createAttendanceView and its helpers) |
4 files: tools/control-center.html, _templates/attendance/attendance.html, Piggleball's and PickleDrive's attendance.html. Byte-identical by rule |
yes ("compare them with diff") |
| Team-event rules | events/pickledrive-anniversary-2026/index.html and the console's team type block. 21 functions exist under the same name in both (listed in §5.2) |
yes |
Team rosters (the snapshot's rosterCsv: parsing, order by level, the Teams tab's cards) |
PickleDrive's index.html (rowsToRoster, rosterOf, levelLabel) and the console (teamRowsToRoster, teamRosterOf, teamLevelLabel). Same logic, different names |
yes |
| Pair labels | the console's PAIRS (XD) and PickleDrive schedule.html's PAIR_SHORT (MXD), both built with numberRepeatedPairs. The MXD/XD difference is deliberate |
yes |
| Go-live rule | computeDayIsLive, earliestScheduleMinutesFrom, parseScheduleTimeToMinutes and GO_LIVE_LEAD_HOURS = 4 in every current event's index.html and both templates. The console has its own computeDayIsLive(_day) and parseScheduleTimeToMinutes |
no |
| Shared constants | POLL_INTERVAL_MS = 10000, LIVE_SAFETY_POLL_MS = 60000, GHPAGES_OWNER/GHPAGES_REPO, CLOUD_RUN_BASE_URL |
partly (CLAUDE.md's "hard-coded constants") |
Today the only check on any of these is a person remembering to run diff.
The ATTENDANCE CLIENT block comes from the
attendance spec, which also
added the console's Attendance tab and fixtures named
_fixtures/<event>/attendance-<facility>-<name>.csv (PickleDrive's
attendance-kingcourts-pre.csv, and a whole attendance-demo-2026 test
event with two venues).
- One page loads a script from a CDN at run time. The console's
Issue desk link loads qrcode-generator 1.4.4 from cdnjs, pinned
with an SRI hash (attConsoleLoadQr). The router serves it from a copy
committed at _tests/vendor/qrcode.min.js, byte-identical to the
pinned file so the hash still matches. shared-constants.test.mjs checks
that copy against the hash in the page, so a version bump in the page
cannot pass with a stale copy.
- Live/Hide and the sync-method switch are real writes. The console's
window.confirm prompts guard POST /sync/:day/live (isLive: false) and
POST /sync/live-push (enabled: false). A test that reached Cloud Run would
change production config, so the suite intercepts every request to Cloud
Run, GitHub and the Worker, and fails any test whose page tries to reach
a host it did not intercept (§4.3).
- Time drives behaviour. The 10 s poll, the 60 s safety poll while a live
socket is open, the 4-hour go-live lead, facility ETAs and "stale" warnings
all depend on the clock. The suite controls the page's clock (§4.2).
Rules that apply to every change (from the root CLAUDE.md and this repo's):
- No page changes. If a behaviour cannot be tested without changing a page, stop and record it in the hand-off (§10).
- Working copies use CRLF line endings. Keep them.
.gitignoreis a single rule today (**/*beta*). This spec adds exactly one more,_tests/node_modules/.beta.htmlscratch copies stay untracked and are never tested.- Cite a spec from outside
sage-docsassage-docs/docs/specs/.../site-test-suite-spec.md. - Documentation is written in the present tense.
How to work. One branch, site-tests, one commit per build step (§9). If
a test fails, the page is right until proven otherwise: this suite describes
it. Fix the test, not the page. If a behaviour looks wrong, or two copies of a
rule disagree, pin what each does today, mark it // KNOWN DIFFERENCE
(§6.1), and tell the owner.
1. Goals, non-goals and constraints¶
Goals
- Every hand-copied rule in the table above is checked mechanically: the byte-identical ones by text, the ported ones by running both copies on the same inputs and comparing the answers.
- Every current event-day page (§3) is loaded in a real browser on fixture data for each state an event goes through (before play, mid-play, finished, edge cases), and its key content is asserted, including the runbook's rehearsal checks.
- The live channel's behaviour is tested against a fake Worker: a pushed snapshot re-renders the page; a dropped socket falls back to polling.
- The runbook checks are a reusable module the automated dry run calls (§2).
- The suite stays current: a page change updates its tests in the same commit, and a new event cannot be added without being added to the suite (§8).
Non-goals
- Changing any page, including adding
?fixture=support, test hooks ordata-testidattributes. - Talking to any real service: GitHub, Cloud Run, the live Worker, Google Sheets. That is the dry run's preflight and rehearsal (§2).
- Visual regression (screenshot diffs), accessibility audits, cross-browser runs. Chromium only.
- The evergreen tools other than Control Center (calculator, bracket
generator, scoresheet generator,
tools/index.html),field-guide.html, finished events andevents/archives/. §11 says what comes next.
Constraint. The suite pins today's behaviour including known differences between copies. It must not "fix" a page to make a test pass.
2. Overlap with the automated dry run¶
The automated dry run (idea only, not started) proposes one command that runs the runbook's rehearsal in three layers. Its middle layer and this suite are largely the same work. Building both independently would produce two browser harnesses, two sets of fixtures, and two copies of the runbook's checks. That is the very duplication both specs exist to reduce.
| Dry run layer | What it does | Overlap with this suite | Who owns it |
|---|---|---|---|
| Preflight | GET /ping, GET /sync/config, POST /sync/:day against production; every facility recently synced; go-live state |
None. This suite never touches a real service | Dry run |
| Rendering | Builds a snapshot from the real one with matches A/B/C applied, opens the console and public pages on localhost, asserts the runbook's §1.2–§1.3 checks | Almost total. Same pages, same checks, same "real page code, never a reimplementation" principle, fixture data instead of live data | This suite. It provides the browser harness, the runbook checks (_tests/checks/runbook.mjs, §5.4) and committed A/B/C fixtures per event type. The dry run's rendering layer shrinks to: build a fixture from the real snapshot, then call runbook.mjs on it |
| Rehearsal | Edits the real facility sheet in an automated browser, waits for the snapshot to change, runs the rendering checks on live data, restores | Reuses the same checks, against live data instead of a fixture | Dry run. Its spikes (Google sign-in, cell targeting, restore) are untouched by this spec |
What follows from that split:
- One set of runbook checks.
runbook.mjstakes a Playwrightpagealready pointed at a page and a scenario ({ finished: <match A>, live: { match: <B>, court }, unmapped: <code C> }), and asserts the runbook's lines. It does not care where the data came from, so the dry run passes live data and this suite passes a fixture. - One browser library. This suite uses Playwright (§4.1). The dry run's
rendering layer, built on
runbook.mjs, uses it too. Its rehearsal layer still plans Puppeteer, whichsage-tools-apialready depends on. Whether the rehearsal moves to Playwright (which also has persistent profiles for the Google sign-in spike) is the dry run's decision, recorded there. - Fixtures. The dry run notes "only PickleDrive has fixtures today". This
suite commits fixtures for all three event types, including an A/B/C
runbookfixture per type (§4.4). That answers the dry run's open question of whether fixtures are committed per event: committed per type here, generated per event by the dry run and discarded. - Order. This suite has no blocking spikes and can be built first. The dry run's rendering layer then needs only a fixture generator.
The dry run spec carries a matching note.
3. Pages in scope¶
| Page | Type | Live channel | ?fixture= today |
Tested as |
|---|---|---|---|---|
tools/control-center.html |
all three | yes | yes | console, once per type |
events/pickledrive-anniversary-2026/index.html |
team | yes | yes | public page |
events/pickledrive-anniversary-2026/schedule.html |
team | yes | yes | schedule board |
events/piggleball-2026/index.html |
standard | yes | no | public page |
events/piggleball-2026/schedule.html |
standard | yes | no | schedule board |
_templates/standard-tournament-template/index.html, schedule.html |
standard | yes | no | public page, board (tokens filled) |
_templates/dual-meet-template/index.html, schedule.html |
dual meet | yes | no | public page, board (tokens filled) |
_templates/attendance/attendance.html |
any ("attendance": "desks") |
no | yes | desk page (tokens filled), on attendance-demo-2026 |
events/piggleball-2026/attendance.html |
standard | no | yes | desk page |
events/pickledrive-anniversary-2026/attendance.html |
team | no | yes | desk page |
Not in scope: events/pnf-x-bup-dual-meet/ and
events/pickle-for-sight-2026/ carry no LIVE CHANNEL block, which root
CLAUDE.md reserves for events that haven't finished, so they are frozen.
That includes Pickle for Sight's attendance.html, which predates the
ATTENDANCE CLIENT block and talks to its own attendance.gs web app.
events/archives/, tools/match-control.html (a redirect stub), the other
tools, and beta.html copies are out too. The page manifest (§8.2) lists every
HTML file in the repo with its status, so nothing is left out silently.
4. Strategy¶
4.1 Layers and tooling¶
| Layer | Folder | Needs a browser | What it checks |
|---|---|---|---|
| Consistency | _tests/consistency/ |
no | page text: byte-identical blocks, shared constants, leftover {{TOKEN}}s |
| Parity | _tests/parity/ |
yes | two or more copies of a rule, each run in its own page (or the server's module), give the same answers on the same inputs |
| Pages | _tests/pages/ |
yes | each page in §3, on each fixture, shows what it should, and its live channel and clock-driven behaviour work |
Runner: node:test, the same as sage-tools-api (Node 22.23.3 or later),
so the workspace has one test idiom. Browser: Playwright (the playwright
library, not @playwright/test's runner), Chromium only, pinned to an exact
version in _tests/package.json. It is a dev-only dependency in a folder
Jekyll never publishes, so nothing reaches the site.
Playwright over Puppeteer, despite Puppeteer already being in sage-tools-api,
because three things this suite needs are built in and would otherwise be
hand-made fakes, each a place for the harness itself to be wrong:
page.clockinstalls a controllable clock (Date, timers) before the page's script runs, so the 10 s poll and the 4-hour go-live lead are tested withclock.runFor(...)instead of real waiting.page.routeWebSocket(...)stands in for the live Worker: the test accepts the page's socket, sends it snapshots, and closes it on demand.page.route(...)answers HTTP requests by URL pattern, and Playwright's assertions wait for the page to settle instead of sleeping.
The consistency layer needs no browser and no install, so it runs on its own
(npm run test:fast) and lands first (§9 step 1).
4.2 Determinism¶
- Clock. Every browser test installs
page.clockat a fixed instant before navigation, in context timezoneAsia/Manila(the venues'), and moves time only withclock.runFor/clock.fastForward. Nothing sleeps. A test never reads the real time. - Network. Every request leaving
127.0.0.1is routed (§4.3). An unrouted request fails the test with its URL. Google Fonts requests are answered with an empty200so text renders in the fallback font without a network. - Viewport. 1280 × 800 by default. Public pages are also loaded at 375 × 812 for the overflow check (§5.3).
- Errors. Every browser test fails on any
pageerror(an uncaught exception) orconsole.errorfrom the page, unless the test expects that exact message. A page that throws silently on some fixture is the most likely event-day failure, and this rule catches it on every test for free. - Independent. One browser per test file, a fresh context per test, no
state carried between tests (the pages use
localStoragefor saved searches and day choice; a fresh context starts empty).
4.3 Helpers¶
_tests/
package.json # "type": "module", "private": true, devDependency playwright (exact)
helpers/
server.mjs # startSite(): static server over the repo root
browser.mjs # launch(), openPage(), the request router and error collector
fakeWorker.mjs # the live Worker, via page.routeWebSocket
extract.mjs # pulls a marked block or a `const NAME = <literal>` out of a page's text
fixtures.mjs # loads _fixtures/ files, builds config for an event key
pages.mjs # the page manifest (§8.2)
checks/runbook.mjs # the runbook checks (§5.4), shared with the dry run
vendor/qrcode.min.js # qrcode-generator 1.4.4, byte-identical to the console's SRI-pinned file
cases/played-bye.json # the played/BYE case table (§5.2)
consistency/*.test.mjs
parity/*.test.mjs
pages/*.test.mjs
server.mjs. startSite() serves the repo root on 127.0.0.1, port 0,
and returns { baseUrl, close }. It resolves an extensionless path to .html,
as GitHub Pages does (/events/<key>/schedule is how the console opens the
board). For any path under /_templates/ it substitutes every {{TOKEN}} from
the template's entry in pages.mjs (fixed test values such as
EVENT_KEY: "test-standard", EVENT_TITLE: "Test Standard Event", and
SCHEDULE_DAY_KEY set to the first key in the template's example DAYS) and
answers 500 naming any token it has no value for. It serves bytes otherwise
unchanged. It never serves beta files.
browser.mjs. openPage(browser, path, options) creates a context
(timezone, viewport), installs the clock at options.now, installs the router,
navigates, and waits for the page's first render. Options:
{
now: "2026-10-03T08:00:00+08:00", // required; there is no default clock
config: <events.json object>, // answers .../event-data/config/events.json
snapshots: { "<event>/<day>": <snapshot> }, // answers .../event-data/<event>/data/<day>.json
worker: "absent" | "refuses" | fakeWorker, // the live channel's fate (default "refuses")
cloudRun: { "<METHOD> <path>": handler }, // console and desk pages; see below
attendance: { "<sheetId>": <csv text> }, // answers the gviz ATTENDANCE export
viewport, expectErrors: [/.../],
}
The router answers https://sage-match-control.github.io/event-data/... from
config and snapshots (a snapshot not supplied answers 404, as GitHub
does for a day never published), records every request in
page.requests (method, URL, body), and fails the test on anything else
leaving 127.0.0.1. It also answers
https://docs.google.com/spreadsheets/d/<sheetId>/gviz/tq?…&sheet=ATTENDANCE
from attendance (an unknown sheet fails the test), and the cdnjs
qrcode-generator URL from _tests/vendor/qrcode.min.js (§0). For the
console and the desk pages, requests to CLOUD_RUN_BASE_URL go to
cloudRun handlers. An unhandled Cloud Run route fails the test, so no test
can reach the real API, let alone write config. The default handlers answer
GET /ping and GET /sync/config with fixed, plausible bodies, and
POST /auth/login with a fake token.
fakeWorker.mjs. createFakeWorker() returns an object that
openPage passes to page.routeWebSocket for the page's LIVE_BASE_URL
address. It records connections, and offers push(snapshot) (sends it to
every open socket in the message shape the LIVE CHANNEL block expects; the
implementer reads that shape from the block), close(code) and refuse().
"refuses" (the default) closes every connection at once, so a page falls
back to polling. Its own tests in _tests/consistency/ cannot cover it (it
needs a browser), so pages/live-channel.test.mjs starts with a case that
proves the fake connects and delivers on one page before any page relies on
it.
extract.mjs. block(text, startMarker, endMarker) returns the exact
bytes between the markers inclusive, and throws if either marker is missing
or appears twice. constLiteral(text, name) returns the source text of a
top-level const <name> = <literal>; and throws if it is absent or declared
twice.
4.4 Fixtures¶
Fixtures are snapshot JSON files in the shape event-data publishes. They
live under the existing _fixtures/<event-key>/<name>.json, so ?fixture=
still works by hand on the pages that support it. Test events are added to
_fixtures/config.json beside PickleDrive's.
| Event key | Type | Pages it feeds | Fixtures |
|---|---|---|---|
pickledrive-anniversary-2026 |
team | PickleDrive pages, console (team) | existing pre, finished, edge, qf-pre (all four carry rosterCsv) and attendance-kingcourts-pre.csv; new mid, runbook, and no-roster (a snapshot without rosterCsv, as published before 2.7.0) |
piggleball-2026 |
standard | Piggleball pages, console (standard) | new pre, mid, finished, edge, runbook, and an attendance-centro-atletico-pre.csv |
attendance-demo-2026 |
standard, "attendance": "console" |
console (Attendance tab); the attendance template, with the config the router serves switched to "desks" |
existing pre and two venues' attendance-*-pre.csv |
test-standard |
standard | the standard template | the same five, against the template's example DAYS/FACILITIES |
test-dual-meet |
dual meet | the dual-meet template, console (dual meet) | the same five, against the template's example config and test club codes |
pre: scheduled, nothing played or live.mid: some matches played, some live on courts, others pending.finished: every non-BYE match scored,completedAtstamped.edge: a BYE by team code, by each player name, mixed-caseBye, both sides BYE (the data error), an unparseable court, a duplicate court/time, a blankSchedule.runbook: exactly the rehearsal's state. Match A finished, match B live on a court, match C with an unmapped category code. Each fixture's matches A/B/C are named in_tests/fixtures.mjssorunbook.mjsgets them from one place.
Fixtures are built from real data shapes. For Piggleball, start from
event-data's published day file once it exists and strip it to the needed
state; until then, and for the templates, from the column list in
_templates/CLAUDE.md §4. A fixture never contains a real person's phone,
email or anything beyond the player names a published snapshot already
carries.
5. Coverage matrix¶
Every case below is required.
5.1 Consistency (no browser)¶
| File | Cases |
|---|---|
live-channel-identical.test.mjs |
every file containing // ==== LIVE CHANNEL has exactly one block (block() throws otherwise), and every block is byte-identical to the console's. On failure, the message names the file and the first differing line. The set of files carrying the block equals the manifest's liveChannel: true set (§8.2) |
shared-constants.test.mjs |
POLL_INTERVAL_MS, LIVE_SAFETY_POLL_MS, GO_LIVE_LEAD_HOURS, GHPAGES_OWNER, GHPAGES_REPO and CLOUD_RUN_BASE_URL have one value across every in-scope page that declares them (a page that does not declare one is fine; two values are not); _tests/vendor/qrcode.min.js hashes (SHA-384) to the integrity value in the console's attConsoleLoadQr |
attendance-client-identical.test.mjs |
the same rules as live-channel-identical for the ATTENDANCE CLIENT block, against the console's copy; the files carrying it equal the manifest's attendanceClient: true set |
no-leftover-tokens.test.mjs |
no file under events/ (outside archives/) contains {{. This is _templates/CLAUDE.md §2 step 3's grep, automated |
manifest.test.mjs |
the page manifest guards (§8.2) |
5.2 Parity (each copy run in its own page)¶
| File | Cases |
|---|---|
played-bye.test.mjs |
cases/played-bye.json holds matches CSVs. It starts with every case in sage-tools-api/verify-facility-completion.mjs: all non-BYE scored, partial, BYE by team code, by either player name, case-insensitive bye, a score removed, empty CSV, header-only CSV. Each CSV is run through the console's own parseCSV → rowsToMatches → computeFacilityProgress in the loaded console, and the console's "complete" (left === 0, which is what prints "All matches done") is compared with the server's facilityIsComplete(csv), imported from ../../sage-tools-api/src/sync/domain/facilityCompletion.mjs. If that sibling repo is absent, the server half is skipped with a message, not failed, and the console half still runs against the expected answers stored in the case file. Empty and header-only CSVs: the server says not complete; the console's left is 0 for both. Pin what each copy does, and what the facility card actually shows in that state (§5.3), as // KNOWN DIFFERENCE if they disagree, and report it |
team-rules.test.mjs |
PickleDrive's index.html and the console (team event loaded) open on the same fixture, for each of the six team fixtures. These rule functions are called in both pages with the same inputs, and the results compared as JSON: baseTeamOf, groupOf, isGroupTeamCode, pairOf, parseSideCode, sideOf, slotOf, stageOf, stageLabel, teamNameOf, teamMatchupResult, teamRankBracket, teamCourtsLabel, teamTimesLabel, pairLabel, sideLabel, numberRepeatedPairs. Inputs: every team code, matchup and group in the fixture. pairLabel/sideLabel compare after mapping the public page's MXD to XD (deliberate). The *HTML builders (teamMatchupCardHTML, teamMatchupRowHTML, teamResultHTML) are excluded because their markup differs by design, and so is rebuildTeamData (state, not a rule). A rule function listed here that is missing from either page fails the test, so a rename in one copy cannot drop it from the check |
team-roster.test.mjs |
PickleDrive's index.html and the console (team event loaded) on each team fixture that carries rosterCsv. The pairs are mapped by name: rowsToRoster ↔ teamRowsToRoster (on the fixture's parsed rosterCsv, plus a header-only CSV, a missing player column, extra columns, blank codes or names), rosterOf ↔ teamRosterOf (every team code: lowest level first, sheet order within a level, a non-numeric level last), levelLabel ↔ teamLevelLabel (3, 3.5, Open, ''). The roster card builders are excluded, as for the team rules. A missing function on either side fails the test |
pair-labels.test.mjs |
PickleDrive schedule.html's PAIR_SHORT equals the console's PAIRS[n].short for every pair number, after MXD → XD |
go-live.test.mjs |
computeDayIsLive(day, snapshot) in every in-scope index.html (both events, both templates) gives the same answer for a table of cases: isLive true/false override in either direction; auto before, at and after the 4-hour lead before the earliest Schedule time; a snapshot with no parseable time; a different calendar day. The clock is set per case. The console's computeDayIsLive(_day) and its parseScheduleTimeToMinutes are compared on the same cases where their inputs overlap. Where the console's variant differs by design, pin it as // KNOWN DIFFERENCE with the reason read from its comment |
5.3 Pages (browser, real pages, routed data)¶
Every page in §3, on every fixture of its type (a loop, one it per page ×
fixture):
- loads with no
pageerrorand noconsole.error(§4.2 rule 4) - renders its main content, not an empty or "error loading" state (the implementer reads each page for the element that proves it)
- makes no request outside the router (§4.2 rule 2)
- at 375 px wide, public pages and boards have no horizontal page scroll
(
document.documentElement.scrollWidth <= innerWidth)
Console (pages/console.test.mjs, once per type):
| Area | Cases |
|---|---|
| Event and day | the registry's events and days are listed from config; picking one loads its snapshot URL (asserted in page.requests) |
| Tabs and landing | the tabs read Mission Control, Awards, Attendance, Live Matches, Match Finder, Standings, Teams, in that order, where Attendance shows only for an event with an attendance setting and Teams only for a team event whose snapshot carries a roster (hidden on no-roster); picking a day lands on Mission Control (its tab active, only #organizerResults shown); each tab shows only its own container; a later poll does not move the operator off the tab they chose |
| Mission Control layout | top to bottom: Sign in, Facility sync status, Resync this day now directly under the venues, the CSV fallback checkbox and the one-facility row, the resync result box, Check connection and its own result box, Public site status, Sync method, Public pages (assert DOM order) |
| Facility progress | pre: done 0 / left = total; mid: the counts from the fixture; finished: "All matches done", and with completedAt the actual end; a facility whose syncedAt is old on event day shows the stale warning; BYE matches are not counted |
| Live Matches, Standings, Match Finder, Awards | the runbook checks (§5.4) on the runbook fixture, plus: every configured court shows a row; Awards reads "Pending" before any Final/Bronze is played and names finishers on finished |
| Sign-in | a wrong password (router answers 401) shows an error toast reading "wrong username or password" and no token is stored; a right one shows a Signed in until … toast and enables the operator controls; Sign out shows a Signed out. toast |
| Resync | posts POST /sync/<day> to CLOUD_RUN_BASE_URL with the bearer token (asserted in page.requests). The result appears in the box under the buttons, scrolled into view, as one labelled row per facility: Synced; This attempt failed. Still showing its previous data. (in facilitiesStale); Failed. Nothing published for it yet. (failed, not stale). Then a live-push row (Pushed to open pages (version n), or its fallback with the reason), a GitHub archive row only when archive.committed is false, and Took <n> s. The box is ok, warn (some stale) or error (any hard failure). A 502 whose error is Could not sync …: a; b lists a and b as separate items. Not signed in, or no day: an error toast, and nothing is sent |
| Live/Hide | choosing false opens the confirm prompt; dismissing it sends nothing; accepting it sends POST /sync/<day>/live with {"isLive":false}; auto and true send without a prompt. The outcome is a toast, not a box: a loading toast replaced by the result (one toast per action, same key), and the current-state line updates |
| Sync method | switching to GitHub only opens its confirm prompt and sends POST /sync/live-push {"enabled":false} only when accepted; the outcome is a toast |
| Check connection | the result appears in #orgConnResultBox, directly under the button, scrolled into view, as rows: Response time, Version (from the router's X-App-Version), Event registry (events.json, version <sha> for <sha>/remote; the fallback wording, as warn, for seed/fallback), and Live push (from /sync/config's live) |
| Result boxes | every finished box (resync, Check connection) has a close button that hides it; a box still showing its loading message has none; changing the event, the day or the tab hides and empties every box, while clicking the tab already selected does not; a resync whose response arrives after the day was changed shows its outcome as a toast, not in the box |
| Toasts | stack at the top of the viewport, newest first (inside it at 375 × 812 and 1280 × 800, whatever the scroll position); at most three; a toast with the same key replaces the last; ok disappears after about 4.5 s and error after about 9 s (clock.runFor), and the close button removes one at once; an error toast has role="alert"; a toast created while the page is hidden is visible once the page is shown |
| Readable errors | for each of these router replies, the text on screen contains the words, never { or a raw HTTP 4xx/HTTP 5xx followed by JSON: a Google body (HTTP 403 {"error":{"code":403,"message":"The caller does not have permission"}} → "refused access (403) — The caller does not have permission"), a GitHub body ({"message": …}), a Worker body ({"error":"unauthorized"}), a bare HTTP 500 ("Cloud Run had a server error (500)"), and timed out after 8000ms ("8 s"). Checked on resync, Live/Hide and sign-in |
| Schedule board link | Open schedule opens /events/<key>/schedule (extensionless) |
| Teams (team) | one card per team with a roster, grouped as the standings group them, with the player count; a card opens to its players, lowest level first, each with level and gender tags; cards left open stay open across a poll; Match Finder's result for a player shows their team's roster card with them highlighted |
Attendance (attendance-demo-2026) |
the roster from each venue's ATTENDANCE fixture; the category filter and jump; a person in two teams toggles in both places; withdrawn people hidden; Update roster posts POST /v1/days/<day>/attendance/reconciliations and shows each venue's result; marking someone sends PUT /v1/days/<day>/facilities/<facility>/attendance/<key> with {"present":true}. With the config switched to "desks", Issue desk link posts .../desk-links and draws a QR code from the vendored library |
Public pages and boards (pages/public.test.mjs):
| Area | Cases |
|---|---|
| Go-live | with isLive: "auto", before the 4-hour lead the page shows its not-live state and no scores; clock.runFor past the lead (no reload) switches to live; isLive: false hides it on the next poll; true shows it before the lead |
| Content | standings, live courts and schedule match the fixture for mid and finished; the team page shows matchup cards and group tables; the dual-meet template shows the club win summary |
Team rosters (PickleDrive index.html) |
the Teams tab as in the console's row above (hidden on no-roster); Match Finder's suggestions show names only, and a selected player's result shows their level and gender beside the name, plus their team's roster card with them highlighted |
| Board | courts as columns from the highest CourtAssignment; a live match highlighted; a duplicate court/time gets the +n badge; ?venue= narrows to one facility; ?courts= composes with it |
| Saved state | a search and the chosen day survive a reload in the same context (localStorage), and a fresh context starts clean |
Desk pages (pages/attendance.test.mjs: the template on attendance-demo-2026, then each event's copy):
| Area | Cases |
|---|---|
| Opening | no ?desk= token, or an expired one (its payload's exp before the clock): "Ask the operator for a desk link." and no further request; the event not "desks": "Check-in for this event is handled by staff."; a token for a day the event does not have: the "does not match a day" message; a ?desk= token is kept in localStorage and dropped from the URL |
| Marking | with a valid token, the roster loads from each venue's ATTENDANCE fixture (team events also read team names from the snapshot's standings); marking a person sends the PUT with the desk token as bearer and the row updates; the category filter and jump; withdrawn people hidden; a 401 reply adds "This desk link has expired." and a 403 "Ask the operator for a new desk link.", never raw JSON |
Live channel (pages/live-channel.test.mjs, on the console and one
public page per type):
- The fake Worker connects and delivers one snapshot (harness self-check, §4.3).
- With the socket open, a pushed snapshot re-renders the page without a
GitHub fetch (
page.requestsshows none between push and render). - With the socket open, GitHub is polled every
LIVE_SAFETY_POLL_MSand not everyPOLL_INTERVAL_MS(clock.runFor, count the requests). - Worker refuses: the page polls GitHub every
POLL_INTERVAL_MS. - Socket drops mid-session: polling resumes; a later reconnect (as the block implements it) stops the fast poll again.
- A pushed snapshot older than the one shown (
publishedAt) does not replace it, if the block implements that. If it does not, pin that it does replace it.
5.4 The runbook checks (checks/runbook.mjs)¶
Two exported functions, one per runbook section. Each is written against whatever page it is handed, so the dry run can call them on live data.
/** Runbook §1.2. `page` is the console, signed in, on the scenario's day. */
export async function checkConsole(page, { finished, live, unmapped }) { ... }
/** Runbook §1.3. `pages` are the board, the public page's Live tab and its Standings. */
export async function checkPublic({ board, live, standings }, { finished, live: liveMatch, unmapped }) { ... }
They assert, line for line, _templates/dry-run-checklist-template.md §1.2
and §1.3:
- Live Matches shows match A's final score; match B has a live pill on its court; every other configured court of that facility shows an idle placeholder, not a blank.
- Standings shows a warning banner naming C's unmapped code, not a silent "Other" bucket.
- Match Finder: searching a player from A returns their matches in schedule order with the right opponent and score state.
- Awards loads and lists every category, with "Pending" where nothing is decided.
- Board: courts and times render; B's court is in progress. Public Live matches the console. Public Standings shows the same warning and standings.
The runbook's Live/Hide line is a console-only action and is tested in §5.3,
not here: on live data it is a real write, and the dry run decides
separately whether it tests it at all. pages/runbook.test.mjs runs both
functions on each type's runbook fixture.
6. Rules for every test¶
- The page's code is the subject. Assertions read the DOM or call the page's own functions. A test never reimplements a page rule to compute an expected value, beyond fixed expected answers written out in the test or case file.
- No real network, no real waiting. §4.2.
- Pin, then report. When two copies disagree, or a page does something that looks wrong, pin what it does today, mark it as below, and list it in the hand-off. Do not change the page.
- Selectors by what a person sees. Prefer role and visible text
(
getByRole,getByText) over CSS classes, so a restyle doesn't break a test that isn't about style. Where a page offers neither, use the most stable id or class and say why in a comment. - One behaviour per
it.describe(<page>), thenit("<what a person sees>").
6.1 Known-difference marker¶
// KNOWN DIFFERENCE (played/BYE): the server says a header-only CSV is not complete;
// the console's computeFacilityProgress returns left === 0. The card shows "...".
grep -rn "KNOWN DIFFERENCE" _tests/ lists every disagreement between copies
the suite has found. Each one is the owner's decision: make the copies agree
(and flip the test in that commit), or record why they differ in root
CLAUDE.md's "kept in sync by hand" list.
7. Proof that the net works¶
With the suite green, make each breakage below in a scratch working copy, one at a time, confirm at least one test fails, and revert. The list goes in the commit message.
- Change one character inside Piggleball
schedule.html'sLIVE CHANNELblock. - Make the console's
sideIsByecheck only the team code. - Make the console count a match as played with one score.
- Change the tie-break in PickleDrive
index.html'steamMatchupResult. - Change
'WD'in PickleDriveschedule.html'sPAIR_SHORT. - Set
GO_LIVE_LEAD_HOURSto 2 in the standard template. - Remove the console's unmapped-code warning banner.
- Drop the
confirmbefore Live/Hide false in the console. - Make the live channel ignore pushed snapshots.
- Leave
{{VENUE}}in a page underevents/. - In the console, make
revealLiveTabsAfterLoadland on Live Matches again, or show a resync error's rawbody.errorwithoutfriendlyApiMessage. - Change one character inside Piggleball
attendance.html'sATTENDANCE CLIENTblock. - Make the console's
teamRosterOfsort highest level first.
8. Keeping the suite current¶
The same principle as the
sage-tools-api test suite's §9,
applied to the site.
8.1 The rule¶
A change to an in-scope page is not finished until its tests are. The test change goes in the same commit. In particular:
| Change | Tests in the same commit |
|---|---|
| A page's visible behaviour | the page test that pins it, edited or added |
| One copy of a hand-copied rule (§0 table) | the other copies changed too, and the consistency or parity test passes. If the copies are meant to differ from now on, a KNOWN DIFFERENCE and a line in root CLAUDE.md |
The LIVE CHANNEL or ATTENDANCE CLIENT block |
every copy, in one commit; live-channel-identical and attendance-client-identical are the diff |
| A new event instantiated from a template | its pages added to pages.mjs, its event to _fixtures/config.json, and pre/mid/finished/runbook fixtures, or a manifest entry reusing the template's fixtures when its config is the template's shape. Add this as a step in _templates/CLAUDE.md §2 |
| An event finishes | its pages' manifest status set to frozen and liveChannel: false, in the same commit that drops their LIVE CHANNEL block |
| A template change | the template's tests; an event already copied from it is not changed unless its own commit says so |
| A new page shape or snapshot field | a fixture that exercises it |
8.2 The guards¶
_tests/pages.mjs lists every .html file in the repo outside
node_modules and beta copies, each with a status:
export const pages = {
"tools/control-center.html": { status: "tested", type: ["dual-meet", "standard", "team"], liveChannel: true, attendanceClient: true },
"events/piggleball-2026/attendance.html": { status: "tested", type: "standard", event: "piggleball-2026", liveChannel: false, attendanceClient: true },
"events/piggleball-2026/index.html": { status: "tested", type: "standard", event: "piggleball-2026", liveChannel: true },
"_templates/standard-tournament-template/index.html": { status: "tested", type: "standard", event: "test-standard", liveChannel: true,
tokens: { EVENT_KEY: "test-standard", EVENT_TITLE: "Test Standard Event", /* … */ } },
"events/pnf-x-bup-dual-meet/index.html": { status: "frozen", reason: "finished; no LIVE CHANNEL" },
"tools/bracket-generator.html": { status: "untested", reason: "evergreen tool; site test spec §11" },
// … every other file
};
consistency/manifest.test.mjs fails when:
- an
.htmlfile exists thatpages.mjsdoes not list (a new event or page cannot slip in untested without someone writing down why); pages.mjslists a file that does not exist;- the files containing the
LIVE CHANNELmarker differ from those withliveChannel: true, or those containingATTENDANCE CLIENTfrom those withattendanceClient: true; - a
testedpage has nopages/test that loads it (each page test file exports the paths it covers); - a
testedevent page's event has nopre,mid,finishedandrunbookfixture.
8.3 Where the rule lives¶
- Root
CLAUDE.md, in thesage-match-control.github.iosection, replacing "No bundler, no package.json, no tests":
Tests live in
_tests/(never published: Jekyll skips_folders):npm test --prefix _tests. Every change to an in-scope page changes_tests/in the same commit; a new event gets its_tests/pages.mjsentry and fixtures. The hand-copied rules below are checked by the suite: change every copy, never make a test pass by editing one side.
The "kept in sync by hand" entries for the LIVE CHANNEL and
ATTENDANCE CLIENT blocks, played/BYE, the team-event rules and the team
rosters each gain "checked by _tests/". "Compare them
with diff" becomes "npm test --prefix _tests compares them". The go-live
rule is added to that list.
- .githooks/pre-push in the site repo runs npm run test:fast --prefix
_tests (no browser, a second or two) and, if _tests/node_modules exists,
the full suite. It refuses the push on failure. Setup per clone:
sage-docs/docs/technical/: asite-tests.mdpage (layers, commands, fixtures, the marker, the rule), indexed intechnical/README.mdand linked fromcontrol-center.md.
9. Build steps¶
Rule. The only files this spec touches outside _tests/ are
_fixtures/ (new fixtures, new events in config.json), .gitignore (one
line), .githooks/pre-push, _templates/CLAUDE.md (§8.1's new step), root
CLAUDE.md and the sage-docs pages in §8.3. No page. git diff --stat
against main shows no .html file.
- Consistency layer first.
_tests/package.json(no dependency yet),extract.mjs,pages.mjs, and §5.1. This lands on its own: it already replaces the handdiffand finds any drift that exists today. If it does (twoLIVE CHANNELcopies differ, say), report it; do not fix the page in this commit. - Add Playwright (exact version),
.gitignore's_tests/node_modules/,npm run setup(playwright install chromium), and the helpers in §4.3. Writelive-channel.test.mjscase 1 first: it proves the harness. - Fixtures (§4.4), then the parity layer (§5.2).
- The pages layer (§5.3) and
checks/runbook.mjs(§5.4). - Run the sabotage list (§7).
- The hook,
_templates/CLAUDE.md's new step, rootCLAUDE.md, thesage-docstechnical page. Update the automated dry run's rendering layer to point at the builtrunbook.mjs.
_tests/package.json scripts:
{
"test": "node --test \"consistency/**/*.test.mjs\" \"parity/**/*.test.mjs\" \"pages/**/*.test.mjs\"",
"test:fast": "node --test \"consistency/**/*.test.mjs\"",
"test:parity": "node --test \"parity/**/*.test.mjs\"",
"test:pages": "node --test \"pages/**/*.test.mjs\"",
"setup": "playwright install chromium"
}
10. Acceptance checklist and hand-off¶
-
npm test --prefix _testspasses on unmodified pages, on Node 22.23.3 or later, with no network access beyond127.0.0.1(test it offline). - Every case in §5 exists.
- All thirteen sabotage breakages were caught (§7).
-
git diff --statagainstmainshows no.htmlfile, and nothing outside the files listed in §9's rule. -
pages.mjslists every.htmlfile; the manifest guards pass and each was seen to fail when broken. - The pre-push hook refuses a push with a failing consistency test.
- The site's Pages build after the merge publishes nothing under
_tests/(checkhttps://sage-match-control.github.io/_tests/package.jsonis404). - Root
CLAUDE.md,_templates/CLAUDE.md, thesage-docstechnical page and the dry run spec are updated (§8.3, §9 step 6).
Hand-off report to the owner: test counts per layer; every
KNOWN DIFFERENCE found, with what each copy does; any page behaviour that
could not be tested without a page change; how long test:fast and the full
suite take. Then move this spec to implemented/ per docs/specs/README.md.
11. Out of scope, and what comes next¶
- Any page change, including test hooks,
data-testids or?fixture=support for more pages. - Real services. The dry run's preflight and rehearsal cover those (§2).
- Screenshot comparison, accessibility audits, browsers other than Chromium.
- Finished events and
events/archives/.
Next candidates, each its own spec when wanted:
- Tournament calculator → generator contract.
buildPlanCsv()produces the CSV the two Apps Script generators consume, andsage-tools-api'sapps-script/fixtures/holds real plan CSVs those generators are verified against. A test that the calculator, given the same inputs, still produces CSVs of that shape would join the two harnesses. - Bracket generator's verifiable draw. It is deterministic by design and so is well suited to pinned-output tests.
- Moving the copied rules into shared files. The architecture spec's Phase 7 decision. If it happens, the parity layer shrinks to ordinary unit tests of the shared files, and this suite's fixtures and page tests carry over unchanged.