From c4c1a7e933697eb67646cb6cf0959524d65a06e3 Mon Sep 17 00:00:00 2001 From: Ricardo Oliveira Date: Thu, 24 Sep 2026 00:49:29 +0100 Subject: [PATCH 01/30] feat(bc-e2eh): scripts/e2e Playwright harness with UI-only fixtures and persona helpers Local gate only (make e2e, make e2e/deps); not in go/test or CI. Reuses scripts/screenshots/lib server/net/editor; finishMatch gains a root argument for the inline shiaijo editor. Co-Authored-By: Claude Opus 5.5 --- .github/pull_request_template.md | 1 + .gitignore | 6 + Makefile | 28 ++- scripts/e2e/README.md | 118 +++++++++++++ scripts/e2e/fixtures/clumsy.mjs | 248 +++++++++++++++++++++++++++ scripts/e2e/fixtures/competition.mjs | 49 ++++++ scripts/e2e/fixtures/devices.mjs | 24 +++ scripts/e2e/fixtures/public.mjs | 26 +++ scripts/e2e/fixtures/scores.mjs | 20 +++ scripts/e2e/fixtures/scoring.mjs | 52 ++++++ scripts/e2e/fixtures/setup.mjs | 47 +++++ scripts/e2e/fixtures/shiaijo.mjs | 48 ++++++ scripts/e2e/fixtures/shots.mjs | 23 +++ scripts/e2e/fixtures/test.mjs | 54 ++++++ scripts/e2e/fixtures/wizard.mjs | 102 +++++++++++ scripts/e2e/journeys/smoke.spec.mjs | 187 ++++++++++++++++++++ scripts/e2e/package-lock.json | 55 ++++++ scripts/e2e/package.json | 12 ++ scripts/e2e/playwright.config.mjs | 50 ++++++ scripts/screenshots/lib/editor.mjs | 14 +- 20 files changed, 1161 insertions(+), 3 deletions(-) create mode 100644 scripts/e2e/README.md create mode 100644 scripts/e2e/fixtures/clumsy.mjs create mode 100644 scripts/e2e/fixtures/competition.mjs create mode 100644 scripts/e2e/fixtures/devices.mjs create mode 100644 scripts/e2e/fixtures/public.mjs create mode 100644 scripts/e2e/fixtures/scores.mjs create mode 100644 scripts/e2e/fixtures/scoring.mjs create mode 100644 scripts/e2e/fixtures/setup.mjs create mode 100644 scripts/e2e/fixtures/shiaijo.mjs create mode 100644 scripts/e2e/fixtures/shots.mjs create mode 100644 scripts/e2e/fixtures/test.mjs create mode 100644 scripts/e2e/fixtures/wizard.mjs create mode 100644 scripts/e2e/journeys/smoke.spec.mjs create mode 100644 scripts/e2e/package-lock.json create mode 100644 scripts/e2e/package.json create mode 100644 scripts/e2e/playwright.config.mjs diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index d48cf1ece..9f6ce01dc 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -60,6 +60,7 @@ read the diff. See CONTRIBUTING and CLAUDE.md. - [ ] `make go/test` passes (lint + security scan + tests) - [ ] New/updated unit tests cover the change - [ ] Manual browser verification (for `web-mobile/` or `web/` changes): describe what you exercised +- [ ] `make e2e` green locally (any `web-mobile/` change) - [ ] Screenshots added above (REQUIRED for any UI-affecting change) - [ ] Docs updated under `docs/` for any new or changed user-facing feature, flag, command, or behavior (`make docs/build` passes) - [ ] Docs screenshots and videos recaptured for any change to what a user sees (`make docs/media SINCE=main`; the changed ones reviewed and copied into `docs/`, or "none changed") diff --git a/.gitignore b/.gitignore index d3c0e9c30..6404f6b5d 100644 --- a/.gitignore +++ b/.gitignore @@ -67,6 +67,8 @@ Thumbs.db !web/package-lock.json !scripts/screenshots/package.json !scripts/screenshots/package-lock.json +!scripts/e2e/package.json +!scripts/e2e/package-lock.json # Go testdata fixtures. These are test INPUTS, not build output: the blanket # *.json above would silently drop them, and a golden table that never lands # fails CI on a checkout that works locally. /**/ matches zero or more @@ -153,3 +155,7 @@ mp-*/ # Docs capture harness: staged output, compared by eye before it replaces a committed file. scripts/screenshots/out/ + +# E2E journeys (make e2e): per-run screenshots and Playwright's own artifacts. +# Its node_modules/ is covered by the bare node_modules/ rule above. +scripts/e2e/output/ diff --git a/Makefile b/Makefile index 1c4a34b91..8b5ba1782 100644 --- a/Makefile +++ b/Makefile @@ -25,7 +25,7 @@ else endif # Define phony targets -.PHONY: default help clean local/deps hooks/install go/fmt go/generate go/test go/build go/lint go/sec go/sec-tests go/vuln go/security js/deps js/lint js/sec js/outdated js/security js/check-imports js/validate examples docker/build docker/run pre-commit docs/deps docs/serve docs/open docs/build docs/linkcheck docs/prose docs/clean docs/screenshots docs/videos docs/media run run-mobile esbuild-jsx goreleaser/test release version +.PHONY: default help clean local/deps hooks/install go/fmt go/generate go/test go/build go/lint go/sec go/sec-tests go/vuln go/security js/deps js/lint js/sec js/outdated js/security js/check-imports js/validate examples docker/build docker/run pre-commit docs/deps docs/serve docs/open docs/build docs/linkcheck docs/prose docs/clean docs/screenshots docs/videos docs/media e2e e2e/deps run run-mobile esbuild-jsx goreleaser/test release version default: help ## Show help information (default) @@ -371,6 +371,32 @@ docs/media: go/build $(SHOTS_BROWSER_STAMP) ## Regenerate every captured screens @# those would start node and a browser twice over two disjoint halves. @node $(SHOTS_DIR)/run.mjs $(if $(KIND),'KIND=$(KIND)',) $(SHOTS_ARGS) +# Local e2e journeys (scripts/e2e, see its README.md). Like the capture harness +# above, Playwright lives in its own package.json rather than web-mobile's, and +# both pin the same Playwright release so they share one downloaded Chromium +# under ~/.cache/ms-playwright. Deliberately NOT part of go/test and not run in +# CI: a journey drives the real app through a browser, setup wizard and all. +E2E_DIR := scripts/e2e +E2E_DEPS_STAMP := $(E2E_DIR)/node_modules/.package-lock.json +E2E_BROWSER_STAMP := $(E2E_DIR)/node_modules/.chromium-installed + +$(E2E_DEPS_STAMP): $(E2E_DIR)/package.json $(E2E_DIR)/package-lock.json + @echo "Installing the e2e harness dependencies..." + @npm --prefix $(E2E_DIR) ci --no-audit --no-fund + @touch $@ + +$(E2E_BROWSER_STAMP): $(E2E_DEPS_STAMP) + @echo "Downloading the e2e browser..." + @cd $(E2E_DIR) && npx playwright install chromium + @touch $@ + +e2e/deps: $(E2E_BROWSER_STAMP) ## Install the e2e harness and its Chromium (once per worktree) + +# E2E_ARGS is handed to `playwright test` as is: a spec path, --grep, --headed, +# --project, ... e.g. make e2e E2E_ARGS='journeys/smoke.spec.mjs --headed' +e2e: go/build $(E2E_BROWSER_STAMP) ## Run the local e2e journeys (E2E_ARGS= passes a spec, --grep or --headed to playwright) + @cd $(E2E_DIR) && npx playwright test $(E2E_ARGS) + run: go/build ## Run the application locally @echo "Running $(BIN_NAME)..." $(BIN_PATH)/$(BIN_NAME) serve diff --git a/scripts/e2e/README.md b/scripts/e2e/README.md new file mode 100644 index 000000000..f8ba475e0 --- /dev/null +++ b/scripts/e2e/README.md @@ -0,0 +1,118 @@ +# E2E journeys + +Playwright journeys that drive the tournament app (`mobile-app`) in a real +browser, the way an operator and a spectator use it. + + make e2e # every journey + make e2e E2E_ARGS='journeys/smoke.spec.mjs' # one spec + make e2e E2E_ARGS='--grep "score on a shiaijo"' # one test by title + make e2e E2E_ARGS='journeys/smoke.spec.mjs --headed' # watch it run + make e2e/deps # install only + +`make e2e` builds `bin/bracket-creator` first, installs this directory's npm +dependencies and its Chromium on first use (stamped inside `node_modules/`, so +once per worktree), then runs `playwright test` here with `E2E_ARGS` passed +through unchanged. It is not part of `make go/test` and does not run in CI. Run +it before opening a PR that changes `web-mobile/`: the PR template asks. + +Playwright is pinned to the same release as `scripts/screenshots`, so the two +share one Chromium under `~/.cache/ms-playwright`. If that cache is removed the +stamp outlives it; run `npx playwright install chromium` here. + +## Layout + + playwright.config.mjs projects, timeouts, output + fixtures/test.mjs the `test` every journey imports (server, baseURL, page) + fixtures/devices.mjs the operator iPad and the spectator phone + fixtures/setup.mjs first-run tournament form, sign in, sign out + fixtures/wizard.mjs the create-competition wizard + fixtures/competition.mjs roster paste box, Generate / Discard draw, Start + fixtures/shiaijo.mjs /admin/shiaijo/:court (queue and inline editor) + fixtures/scores.mjs the Scores tab and its overlay editor + fixtures/scoring.mjs scoring an individual bout on either editor + fixtures/public.mjs the public viewer and the TV board + fixtures/clumsy.mjs the clumsy operator (below) + fixtures/shots.mjs per-step screenshots + journeys/*.spec.mjs the journeys + +Each fixture module owns the selectors for one surface, so a UI change breaks +one file. The wizard is the one most likely to move; `fixtures/wizard.mjs` is +the only file that knows its copy. + +## Projects and devices + +Two projects, both Chromium: + +- `operator`: an iPad Air in landscape (1180x820) with touch. Its page is + checked to match `(pointer: coarse)`, so the tap-target rules apply as on + the device. Every journey runs here unless its file is named + `*.public.spec.mjs`. +- `public`: a phone in portrait (390x844), for journeys about a public surface + alone (`*.public.spec.mjs`). + +An operator journey that checks a public surface on the side (the viewer +showing a result the operator just entered) opens a second context with +`PUBLIC_DEVICE` from `fixtures/devices.mjs`. + +## One server per worker + +`fixtures/test.mjs` starts one `mobile-app` per Playwright worker, on a free +port and a throwaway data folder, through `scripts/screenshots/lib/server.mjs`, +and stops it by PID when the worker ends. Tests in one worker share that +server, so a spec file that seeds once and then walks several tests declares +`test.describe.configure({ mode: 'serial' })`. Journeys navigate by path +(`page.goto('/admin')`); the host is the worker's server. + +## Seeding goes through the interface + +Every journey builds its tournament the way an operator does: the first-run +form, the real sign-in form, the create wizard, the roster paste box, Generate +draw and Start. The HTTP API is not a seeding path here, and neither are +hand-written files in the data folder. That is an operator ruling, made knowing +it couples every journey to the wizard's copy: the setup steps are part of what +a journey exercises, and a shortcut would skip the part most likely to break. +`scripts/screenshots` seeds over the API for a different reason (a capture is +about one screen, not the path to it) and its `api.mjs`, `seed.mjs` and the +`authAdmin` sign-in shortcut are not used here. + +Scoring goes through the score editors too. `scripts/screenshots/lib/editor.mjs` +owns the editor selector and the two-tap Finish; `fixtures/scoring.mjs` +re-exports them beside the ippon helpers. + +## The clumsy operator + +The review journeys play a distracted court operator: eyes on the shiaijo, +thumb on an iPad, often interrupted. `fixtures/clumsy.mjs` performs the +variants as real input: + +| Helper | Variant | +|---|---| +| `tapNeighbour(locator, direction)` | taps the control next to the intended one, found by layout, and returns which it hit | +| `doubleTap(locator)` | two taps inside 300ms | +| `retapWhileSaving(locator)` | a second tap while the button reads "Saving…" (writes are held briefly so the state is observable) | +| `interrupt(page, kind)` | `reload`, `back` (then forward), `hidden` (tab to background and back), `offline` (network down and back) | +| `hastyConfirm(page)` | answers the open confirm dialog with its most prominent button and returns its label | + +Each returns what happened. A journey records that as an audit row; whether the +outcome is acceptable is judged by a person from the screenshots, never +asserted. `hidden` needs a note: headless Chromium never changes page +visibility on its own, so the helper overrides `document.hidden` and +`document.visibilityState` and dispatches a real `visibilitychange` for each +edge, which is what the app's listeners read. + +## Output + +Per-step screenshots land in `output//`, numbered in the order they +were taken, and are replaced on the next run. Playwright's own traces and +failure screenshots land in `output/test-results/`. Both are gitignored. +There are no pixel comparisons: a screenshot is for a person to look at. + +## When a journey finds a bug + +A functional defect found by a journey is filed as a bead, and that step +becomes + + test.fixme(': ', ...) + +so the suite stays green while the defect is open. The PR that fixes the bug +removes the `fixme`, and the step then guards the fix. diff --git a/scripts/e2e/fixtures/clumsy.mjs b/scripts/e2e/fixtures/clumsy.mjs new file mode 100644 index 000000000..e6a6eb71e --- /dev/null +++ b/scripts/e2e/fixtures/clumsy.mjs @@ -0,0 +1,248 @@ +// The distracted, clumsy court operator (mp-yqxn.2's PERSONA AUDIT), as +// helpers a journey calls around its intended actions: +// +// V1 tapNeighbour the tap lands on the control next to the intended one +// V2 doubleTap two taps inside 300ms +// retapWhileSaving a second tap while the button still reads "Saving…" +// V3 interrupt reload / back / tab hidden / offline, then return +// V4 hastyConfirm a confirm dialog answered by its loudest button +// +// Every helper performs a real input on a real control and RETURNS what +// happened (which element was hit, how far apart the taps were, what the +// dialog said), so the journey can record it as an audit row. None of them +// asserts the app's behaviour: whether the outcome is correct is the journey's +// call, and judgement columns are formed by eye, never asserted. +// +// Taps go through page.touchscreen at the element's centre: the browser's own +// hit-testing decides what is tapped, as it does for a thumb. Every project in +// playwright.config.mjs has a touchscreen. + +// Controls a thumb can land on. +const INTERACTIVE = 'button, [role="button"], a[href], input:not([type="hidden"]), select, textarea, [role="checkbox"], [role="tab"], [role="switch"]'; + +// How far a mis-tap reaches: a neighbour further than this is not "the one +// next to it" but somewhere else on the screen. About one fingertip. +const REACH_PX = 96; + +async function centreOf(locator) { + await locator.scrollIntoViewIfNeeded(); + const box = await locator.boundingBox(); + if (!box) throw new Error(`clumsy: ${locator} has no box (not rendered)`); + return { x: box.x + box.width / 2, y: box.y + box.height / 2, box }; +} + +// V1. Tap the control ADJACENT to `locator` in `direction` ('left' | 'right' +// | 'up' | 'down') instead of it. The neighbour is found by layout, among +// every visible control on the page (not only DOM siblings: the other side's +// ippon button is in another subtree), as the nearest one that sits in that +// direction and overlaps the target's row (left/right) or column (up/down). +// It must be the topmost element at its own centre, so a control covered by +// an overlay is never picked. Throws when nothing is within reach: the +// journey asked for a neighbour that does not exist. +// +// Returns { intended, hit, gapPx }. +export async function tapNeighbour(locator, direction) { + const page = locator.page(); + await locator.scrollIntoViewIfNeeded(); + const found = await locator.evaluate((src, { direction, sel, reach }) => { + // Defined in here because it runs in the page. A short description of + // an element, for the audit row. + const describe = (el) => { + const text = (el.getAttribute('aria-label') || el.innerText || el.value || '').trim().replace(/\s+/g, ' '); + return { tag: el.tagName.toLowerCase(), label: text.slice(0, 80), testid: el.getAttribute('data-testid') || null, + className: typeof el.className === 'string' ? el.className : null, + disabled: !!el.disabled || el.getAttribute('aria-disabled') === 'true' }; + }; + const a = src.getBoundingClientRect(); + const horizontal = direction === 'left' || direction === 'right'; + let best = null; + for (const el of document.querySelectorAll(sel)) { + if (el === src || el.contains(src) || src.contains(el)) continue; + const r = el.getBoundingClientRect(); + if (r.width < 1 || r.height < 1) continue; + if (r.bottom <= 0 || r.right <= 0 || r.top >= innerHeight || r.left >= innerWidth) continue; + const cx = r.left + r.width / 2; + const cy = r.top + r.height / 2; + const top = document.elementFromPoint(cx, cy); + if (!top || !(top === el || el.contains(top))) continue; + // Overlap on the perpendicular axis, and strictly beyond the target on + // the requested one. + const overlaps = horizontal ? (r.top < a.bottom && r.bottom > a.top) : (r.left < a.right && r.right > a.left); + if (!overlaps) continue; + const gap = { right: r.left - a.right, left: a.left - r.right, down: r.top - a.bottom, up: a.top - r.bottom }[direction]; + if (gap < -1 || gap > reach) continue; + const drift = horizontal ? Math.abs(cy - (a.top + a.height / 2)) : Math.abs(cx - (a.left + a.width / 2)); + if (!best || gap < best.gap || (gap === best.gap && drift < best.drift)) best = { el, gap, drift, cx, cy }; + } + if (!best) return null; + return { intended: describe(src), hit: describe(best.el), gapPx: Math.round(best.gap), x: best.cx, y: best.cy }; + }, { direction, sel: INTERACTIVE, reach: REACH_PX }); + if (!found) throw new Error(`clumsy.tapNeighbour: no control within ${REACH_PX}px ${direction} of ${locator}`); + await page.touchscreen.tap(found.x, found.y); + const { x, y, ...row } = found; + return row; +} + +// V2. Two taps on the same spot inside 300ms, the impatient thumb. Returns +// { gapMs }, from the start of the first tap to the start of the second; +// `withinWindow` is false if this machine was too slow to land them inside +// 300ms, in which case the row did not test what it says. +export async function doubleTap(locator) { + const page = locator.page(); + const { x, y } = await centreOf(locator); + const t0 = Date.now(); + await page.touchscreen.tap(x, y); + const gapMs = Date.now() - t0; + await page.touchscreen.tap(x, y); + return { gapMs, withinWindow: gapMs < 300 }; +} + +// V2 (second form). Tap `locator`, then tap the same spot again while it reads +// "Saving…". A local server answers in milliseconds, far too fast for any +// thumb, so the write is held for `holdWritesMs` first (every non-GET /api +// request), which is what a venue's wifi does anyway. Returns +// { savingSeen, retapped, labelAfter }: savingSeen false means the button +// never showed "Saving…" and the second tap was not made. +export async function retapWhileSaving(locator, { holdWritesMs = 1200, savingText = /Saving…/ } = {}) { + const page = locator.page(); + const { x, y } = await centreOf(locator); + const handle = await locator.elementHandle(); + const hold = async (route) => { + if (route.request().method() === 'GET') return route.continue(); + await new Promise((r) => setTimeout(r, holdWritesMs)); + return route.continue().catch(() => {}); + }; + await page.route('**/api/**', hold); + try { + await page.touchscreen.tap(x, y); + const savingSeen = await page.waitForFunction( + ([el, src, flags]) => new RegExp(src, flags).test(el.textContent || ''), + [handle, savingText.source, savingText.flags], + { timeout: holdWritesMs, polling: 'raf' }, + ).then(() => true, () => false); + if (savingSeen) await page.touchscreen.tap(x, y); + // Let the held write go through before the route is removed. + await page.waitForFunction( + ([el, src, flags]) => !el.isConnected || !new RegExp(src, flags).test(el.textContent || ''), + [handle, savingText.source, savingText.flags], + { timeout: holdWritesMs + 15000 }, + ); + const labelAfter = await handle.evaluate((el) => (el.isConnected ? el.textContent.trim() : null)); + return { savingSeen, retapped: savingSeen, labelAfter }; + } finally { + await page.unroute('**/api/**', hold); + await handle.dispose(); + } +} + +// V3. Interrupt the operator mid-action, then bring them back. +// +// reload the page reloads (the iPad restored a discarded tab) +// back the browser Back button, then Forward to return +// hidden the tab goes to the background and comes back (a tab switch, +// the iPad sleeping). Headless Chromium never changes visibility +// by itself (measured: neither bringToFront on another page nor +// CDP Page.setWebLifecycleState moves document.visibilityState), +// so document.hidden and document.visibilityState are overridden +// on the page and a real `visibilitychange` event is dispatched +// for each edge. The app's own listeners (admin.jsx, app.jsx) +// read document.hidden, so they run exactly as on a device; their +// resume path reconnects the event stream, and `resumed` reports +// whether that reconnect request was seen. +// offline the network drops (context.setOffline), then returns +// +// `during(page)` runs while the operator is away (offline: the taps made +// without a network). Returns a record of what was observed. +export async function interrupt(page, kind, { awayMs = 600, during } = {}) { + const before = page.url(); + const away = async () => { + if (during) await during(page); + // The time away IS the scenario (the operator looked at the shiaijo), not + // a wait for the app. + await page.waitForTimeout(awayMs); + }; + switch (kind) { + case 'reload': { + await page.reload(); + return { kind, before, after: page.url() }; + } + case 'back': { + const back = await page.goBack(); + const landedOn = page.url(); + await away(); + if (back) await page.goForward(); + return { kind, before, landedOn, hadHistory: !!back, after: page.url() }; + } + case 'hidden': { + const setHidden = (hidden) => page.evaluate((h) => { + if (h) { + Object.defineProperty(document, 'hidden', { configurable: true, get: () => true }); + Object.defineProperty(document, 'visibilityState', { configurable: true, get: () => 'hidden' }); + } else { + delete document.hidden; + delete document.visibilityState; + } + document.dispatchEvent(new Event('visibilitychange')); + }, hidden); + await setHidden(true); + await away(); + const reconnect = page.waitForRequest((r) => r.url().includes('/api/events'), { timeout: 5000 }) + .then(() => true, () => false); + await setHidden(false); + return { kind, before, after: page.url(), resumed: await reconnect, method: 'document.hidden override + dispatched visibilitychange' }; + } + case 'offline': { + const context = page.context(); + await context.setOffline(true); + try { + await away(); + } finally { + await context.setOffline(false); + } + return { kind, before, after: page.url() }; + } + default: + throw new Error(`clumsy.interrupt: unknown kind "${kind}"`); + } +} + +// V4. Answer the open confirm dialog (confirmDialog in ui.jsx) the way an +// operator who does not read does: tap the most prominent button. Prominence +// is read from the rendered classes: danger over primary over a plain button +// over a ghost one, the larger button breaking a tie. Returns { label, +// prominence, title, message, cancelLabel }, so the audit row can say whether +// the loud button was the safe one. +export async function hastyConfirm(page) { + const dialog = page.locator('.modal[role="dialog"]').filter({ has: page.locator('.modal__foot') }).last(); + await dialog.waitFor({ state: 'visible' }); + const buttons = dialog.locator('.modal__foot button'); + const rank = (cls) => (/\bbtn--danger\b/.test(cls) ? 3 : /\bbtn--primary\b/.test(cls) ? 2 : /\bbtn--ghost\b/.test(cls) ? 0 : 1); + const PROMINENCE = ['ghost', 'plain', 'primary', 'danger']; + let best = null; + const labels = []; + for (let i = 0; i < await buttons.count(); i += 1) { + const b = buttons.nth(i); + const cls = (await b.getAttribute('class')) || ''; + const box = await b.boundingBox(); + const label = (await b.innerText()).trim(); + labels.push(label); + const score = [rank(cls), box ? box.width * box.height : 0]; + if (!best || score[0] > best.score[0] || (score[0] === best.score[0] && score[1] > best.score[1])) { + best = { b, label, score }; + } + } + if (!best) throw new Error('clumsy.hastyConfirm: the dialog has no buttons'); + const title = (await dialog.getAttribute('aria-label')) || ''; + const message = ((await dialog.locator('.dialog-msg').allInnerTexts())[0] || '').trim(); + const { x, y } = await centreOf(best.b); + await page.touchscreen.tap(x, y); + await dialog.waitFor({ state: 'hidden' }); + return { + label: best.label, + prominence: PROMINENCE[best.score[0]], + otherLabels: labels.filter((l) => l !== best.label), + title, + message, + }; +} + diff --git a/scripts/e2e/fixtures/competition.mjs b/scripts/e2e/fixtures/competition.mjs new file mode 100644 index 000000000..9894db26a --- /dev/null +++ b/scripts/e2e/fixtures/competition.mjs @@ -0,0 +1,49 @@ +// A competition's own admin pages: the roster paste box (Participants & +// seeds), Generate draw, Discard draw and Start competition. +import { expect } from '@playwright/test'; + +const compPath = (id, section = '') => `/admin/competition/${id}${section ? `/${section}` : ''}`; + +// Paste a roster into the Participants page's LinedTextarea and apply it. +// `rows` is a list of [name, dojo] pairs or of ready-made lines. Every row +// needs a dojo, and (name, dojo) must be unique: the roster save refuses +// either, and that refusal is not what this helper is for. +export async function pasteRoster(page, id, rows) { + const lines = rows.map((r) => (Array.isArray(r) ? r.join(', ') : r)); + if (!page.url().endsWith(compPath(id, 'participants'))) await page.goto(compPath(id, 'participants')); + const box = page.locator('.lined-textarea__area'); + await box.fill(lines.join('\n')); + // The page renders TWO identical "Apply changes" buttons, one above the + // box and one under the preview table; either applies. + await page.getByRole('button', { name: 'Apply changes' }).first().tap(); + // Applying the first roster moves the page on to the competition overview; + // the header's count is what says the roster landed. + await expect(page.getByText(new RegExp(`\\b${lines.length} (players|teams)\\b`)).first()).toBeVisible(); + await expect(page.getByText('Not applied yet')).toHaveCount(0); +} + +// Generate the draw from the competition header, and return once the +// competition reads "Draw ready" (the header then offers Discard draw). +export async function generateDraw(page, id) { + const btn = page.getByRole('button', { name: 'Generate draw' }); + if (!(await btn.isVisible())) await page.goto(compPath(id)); + await btn.tap(); + await expect(page.getByRole('button', { name: 'Discard draw' })).toBeVisible(); +} + +// Tap Discard draw. It asks through the app's confirm dialog, which is left +// open for the caller: answering it is the journey's decision (or the clumsy +// operator's, see hastyConfirm in clumsy.mjs). +export async function requestDiscardDraw(page) { + await page.getByRole('button', { name: 'Discard draw' }).tap(); + await expect(page.locator('.modal[role="dialog"] .modal__foot')).toBeVisible(); +} + +// Start the competition. There is no confirm: the tap starts it and lands on +// the Scores tab. +export async function startCompetition(page, id) { + const btn = page.getByRole('button', { name: /^Start competition/ }); + if (!(await btn.isVisible())) await page.goto(compPath(id)); + await btn.tap(); + await expect(page).toHaveURL(new RegExp(`${compPath(id, 'scores')}$`)); +} diff --git a/scripts/e2e/fixtures/devices.mjs b/scripts/e2e/fixtures/devices.mjs new file mode 100644 index 000000000..dfd5bc9b3 --- /dev/null +++ b/scripts/e2e/fixtures/devices.mjs @@ -0,0 +1,24 @@ +// The two devices every journey runs on. playwright.config.mjs builds its +// projects from these, and a journey that opens a SECOND context (the public +// viewer beside the operator's iPad) spreads the same constant into +// browser.newContext(), so a project and a hand-made context cannot drift. + +// iPad Air, landscape: the court table's device. hasTouch + isMobile is what +// makes `(pointer: coarse)` match, measured, so the 44px tap-floor rules in +// styles.css apply as they do on the device. fixtures/test.mjs re-checks it +// for every operator page and falls back to CDP touch emulation if a future +// Chromium stops honouring these two. +export const OPERATOR_DEVICE = { + viewport: { width: 1180, height: 820 }, + hasTouch: true, + isMobile: true, + deviceScaleFactor: 1, +}; + +// A phone, portrait: a spectator or a competitor following the draw. +export const PUBLIC_DEVICE = { + viewport: { width: 390, height: 844 }, + hasTouch: true, + isMobile: true, + deviceScaleFactor: 1, +}; diff --git a/scripts/e2e/fixtures/public.mjs b/scripts/e2e/fixtures/public.mjs new file mode 100644 index 000000000..955d1a41f --- /dev/null +++ b/scripts/e2e/fixtures/public.mjs @@ -0,0 +1,26 @@ +// The public surfaces: the competition viewer (/competition/:id) a spectator +// opens on a phone, and the TV board (/display?court=X) the venue projects. +// Neither needs a sign-in; call these on a context that never signed in. +import { expect } from '@playwright/test'; + +export async function openViewer(page, id) { + await page.goto(`/competition/${encodeURIComponent(id)}`); + await expect(page.locator('.viewer__body')).toBeVisible(); +} + +// A row of the viewer's "Recent results" list naming both competitors. +// The list is the `.vsched` block that follows its section title. +export function recentResult(page, { shiro, aka }) { + return page.locator('.section-title', { hasText: /^Recent results$/ }) + .locator('xpath=following-sibling::div[contains(@class, "vsched")][1]') + .locator('button.vsched-item') + .filter({ hasText: shiro }) + .filter({ hasText: aka }); +} + +export const tvBoard = (page) => page.getByTestId('tv-display-root'); + +export async function openTvBoard(page, court) { + await page.goto(`/display?court=${encodeURIComponent(court)}`); + await expect(tvBoard(page)).toContainText(`SHIAIJO ${court}`); +} diff --git a/scripts/e2e/fixtures/scores.mjs b/scripts/e2e/fixtures/scores.mjs new file mode 100644 index 000000000..06ab30dca --- /dev/null +++ b/scripts/e2e/fixtures/scores.mjs @@ -0,0 +1,20 @@ +// A competition's Scores tab (/admin/competition/:id/scores, +// admin_schedule_score_editor.jsx): every match as a row with a Score (or +// Correct) button that opens the OVERLAY score editor, the second mount site +// beside the shiaijo page's inline one. +import { expect } from '@playwright/test'; +import { EDITOR } from '../../screenshots/lib/editor.mjs'; + +export const scoreRows = (page) => page.locator('.score-edit-row'); + +// The first SCHEDULED match on `court`, opened in the overlay editor. +// Returns the row it opened, e.g. for reading the pairing. +export async function openScoreEditor(page, id, { court } = {}) { + await page.goto(`/admin/competition/${encodeURIComponent(id)}/scores`); + let rows = scoreRows(page).filter({ has: page.getByRole('button', { name: /^Score$/ }) }); + if (court) rows = rows.filter({ has: page.locator('.score-edit-row__court', { hasText: new RegExp(`^${court}\\b`) }) }); + const row = rows.first(); + await row.getByRole('button', { name: /^Score$/ }).tap(); + await expect(page.locator(EDITOR)).toBeVisible(); + return row; +} diff --git a/scripts/e2e/fixtures/scoring.mjs b/scripts/e2e/fixtures/scoring.mjs new file mode 100644 index 000000000..6f51b3c2e --- /dev/null +++ b/scripts/e2e/fixtures/scoring.mjs @@ -0,0 +1,52 @@ +// Scoring an INDIVIDUAL bout on the kendo board (admin_scoring_individual.jsx). +// The board is the same whether it is mounted inline on the shiaijo page +// (INLINE_EDITOR) or as the overlay dialog the Scores tab opens (EDITOR), so +// every helper takes the editor's selector. The team and kachinuki editors +// score differently and get their own helpers when a journey needs them. +// +// Start and Finish come from scripts/screenshots/lib/editor.mjs (startMatch, +// finishMatch), re-exported here so a journey imports one scoring module. +import { expect } from '@playwright/test'; +import { EDITOR, INLINE_EDITOR, finishMatch, startMatch } from '../../screenshots/lib/editor.mjs'; + +export { EDITOR, INLINE_EDITOR, finishMatch, startMatch }; + +const SIDE_NAME = { shiro: 'Shiro', aka: 'Aka' }; + +// An ippon button: one waza letter (M, K, D, T, H, and S on naginata) on a +// side of the board. Matched whole, since a substring test would be ambiguous +// the day a two-letter waza appears. +export const ipponButton = (page, side, waza, root = INLINE_EDITOR) => page.locator(root) + .locator(`.sb-side--${side} .ipt-btn`, { hasText: new RegExp(`^${waza}$`) }).first(); + +// The autosave indicator on the open editor ("Synced", "Syncing…", +// "Offline", ...; admin_scoring_autosave.jsx). +export const syncState = (page, root = INLINE_EDITOR) => page.locator(root).locator('.sync-pill__label').first(); + +// The slots a side's points fill, outside to inside. A filled slot is labelled +// " slot : remove " (tapping it clears the mark). +export const filledSlots = (page, side, root = INLINE_EDITOR) => page.locator(root) + .locator(`[aria-label^="${SIDE_NAME[side]} slot "]:not([aria-label$=": empty"])`); + +// Award one ippon and return once the board shows it and the autosave has +// landed. `side` is 'shiro' | 'aka'. +export async function awardIppon(page, side, waza = 'M', root = INLINE_EDITOR) { + const before = await filledSlots(page, side, root).count(); + await ipponButton(page, side, waza, root).tap(); + await expect(filledSlots(page, side, root)).toHaveCount(before + 1); + await expect(syncState(page, root)).toHaveText('Synced'); +} + +// The Finish button in its first state ("Finish" or "Finish + Start Next →"), +// and the armed state the first tap turns it into. +export const finishButton = (page, root = INLINE_EDITOR) => page.locator(root) + .locator('button').filter({ hasText: /^Finish( \+ Start Next|$)/ }).first(); +export const armedFinishButton = (page, root = INLINE_EDITOR) => page.locator(root) + .locator('button').filter({ hasText: /^Tap again to finish/ }).first(); + +// Tap Finish once: the two-tap guard arms and nothing is submitted yet. +export async function armFinish(page, root = INLINE_EDITOR) { + await finishButton(page, root).tap(); + await expect(armedFinishButton(page, root)).toBeVisible(); + return armedFinishButton(page, root); +} diff --git a/scripts/e2e/fixtures/setup.mjs b/scripts/e2e/fixtures/setup.mjs new file mode 100644 index 000000000..eff5bcf5f --- /dev/null +++ b/scripts/e2e/fixtures/setup.mjs @@ -0,0 +1,47 @@ +// The first-run tournament form, the admin sign-in form and Sign out. +// Every selector for those three lives here. +import { expect } from '@playwright/test'; + +export const PASSWORD = 'e2e-operator'; + +// A form row found by its visible label. These forms render +// `
` +// with no htmlFor, so the label names nothing and getByLabel cannot see it. +const field = (page, label) => page.locator('.field') + .filter({ has: page.locator('.field__label', { hasText: label }) }) + .first(); + +// The first-run "Welcome to Bracket Creator" form (app.jsx), shown on a +// server with no tournament yet. Creating one signs this page in, so a +// journey that wants the login form next calls signOut() then login(). +export async function createTournament(page, { + name = 'E2E Cup', venue = 'Test Hall', courts = 2, mode = 'Officiated', password = PASSWORD, +} = {}) { + await page.goto('/'); + await expect(page.getByRole('heading', { name: 'Welcome to Bracket Creator' })).toBeVisible(); + await field(page, 'Tournament Name').locator('input').fill(name); + await field(page, 'Venue').locator('input').fill(venue); + await field(page, 'Number of Shiaijo').locator('input').fill(String(courts)); + await page.getByRole('group', { name: 'Tournament type' }).getByRole('button', { name: mode }).tap(); + await field(page, 'Admin Password').locator('input').fill(password); + await page.getByRole('button', { name: 'Create Tournament' }).tap(); + await expect(page).toHaveURL(/\/admin$/); + await expect(page.getByRole('heading', { level: 1, name })).toBeVisible(); +} + +export async function signOut(page) { + await page.getByRole('button', { name: 'Sign out' }).tap(); + await expect(page.getByRole('button', { name: 'Sign out' })).toHaveCount(0); +} + +// Sign in through the real form: /admin on a signed-out page opens the +// "Admin sign in" dialog (app.jsx). Never the localStorage shortcut +// scripts/screenshots uses: the form is part of what a journey walks. +export async function login(page, password = PASSWORD) { + await page.goto('/admin'); + const form = page.locator('.modal.auth'); + await expect(form.getByText('Admin sign in')).toBeVisible(); + await form.locator('#admin-password').fill(password); + await form.getByRole('button', { name: 'Sign in' }).tap(); + await expect(page.getByRole('button', { name: 'Sign out' })).toBeVisible(); +} diff --git a/scripts/e2e/fixtures/shiaijo.mjs b/scripts/e2e/fixtures/shiaijo.mjs new file mode 100644 index 000000000..29a48563d --- /dev/null +++ b/scripts/e2e/fixtures/shiaijo.mjs @@ -0,0 +1,48 @@ +// The court operator's page, /admin/shiaijo/:court (admin_shiaijo.jsx): the +// queue on the left (Up next, Upcoming, Completed) and the inline score +// editor on the right. +import { expect } from '@playwright/test'; +import { INLINE_EDITOR } from '../../screenshots/lib/editor.mjs'; + +export async function openShiaijo(page, court) { + await page.goto(`/admin/shiaijo/${encodeURIComponent(court)}`); + await expect(page.getByRole('heading', { level: 1, name: `Shiaijo ${court}` })).toBeVisible(); +} + +export const upNextCard = (page) => page.locator('.shiaijo-upnext__card'); +export const completedRows = (page) => page.locator('.shiaijo-completed .shiaijo-qrow'); +export const inlineEditor = (page) => page.locator(INLINE_EDITOR); + +// The two names a match row shows, Shiro first. `.numbered-name__text` is the +// name alone, without the competitor number chip beside it. +export async function sides(row) { + const names = row.locator('.numbered-name__text'); + return { shiro: (await names.nth(0).textContent()).trim(), aka: (await names.nth(1).textContent()).trim() }; +} + +// The "MEN INDIVIDUAL · POOL A · MATCH 1 OF 3" eyebrow over the running +// match: what tells the operator which match the editor holds. +export async function editorIdentity(page) { + return (await inlineEditor(page).locator('.editor-modal__eyebrow').first().innerText()).trim(); +} + +// Start the Up next match the way the operator does, from its card, and +// return the pairing once the inline editor holds it. Only the card's own +// "Start match" is used: every Upcoming row carries one too. +export async function startUpNext(page) { + const card = upNextCard(page); + const pair = await sides(card); + await card.getByRole('button', { name: 'Start match' }).tap(); + const editor = inlineEditor(page); + await expect(editor.locator('.sb-side--shiro')).toContainText(pair.shiro); + await expect(editor.locator('.sb-side--aka')).toContainText(pair.aka); + return pair; +} + +// The newest completed match (the Completed list is oldest first, so the +// newest is its tail) with its score string, e.g. "M vs –". +export async function lastCompleted(page) { + const row = completedRows(page).last(); + await expect(row).toBeVisible(); + return { ...(await sides(row)), result: (await row.locator('.shiaijo-qrow__result').innerText()).trim() }; +} diff --git a/scripts/e2e/fixtures/shots.mjs b/scripts/e2e/fixtures/shots.mjs new file mode 100644 index 000000000..31da63120 --- /dev/null +++ b/scripts/e2e/fixtures/shots.mjs @@ -0,0 +1,23 @@ +// Per-step screenshots: output//-.png, numbered in the +// order they were taken so the folder reads as the walkthrough. They are for +// a human to judge (and for the PR body), never compared by a test. +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const OUTPUT = path.resolve(fileURLToPath(new URL('../output', import.meta.url))); + +// Empty the journey's folder and return its camera. Call once per spec file +// (in beforeAll), so a folder never mixes two runs. +export function journeyShots(journey) { + const dir = path.join(OUTPUT, journey); + fs.rmSync(dir, { recursive: true, force: true }); + fs.mkdirSync(dir, { recursive: true }); + let n = 0; + return async (page, step, { fullPage = false } = {}) => { + n += 1; + const file = path.join(dir, `${String(n).padStart(2, '0')}-${step}.png`); + await page.screenshot({ path: file, fullPage, animations: 'disabled', caret: 'hide' }); + return file; + }; +} diff --git a/scripts/e2e/fixtures/test.mjs b/scripts/e2e/fixtures/test.mjs new file mode 100644 index 000000000..09f9a8b5f --- /dev/null +++ b/scripts/e2e/fixtures/test.mjs @@ -0,0 +1,54 @@ +// The `test` every journey imports: Playwright's own, plus a server per +// worker and an operator page whose pointer is verified coarse. +import { test as base, expect } from '@playwright/test'; +import { start } from '../../screenshots/lib/server.mjs'; + +// Does this page match (pointer: coarse)? The operator's iPad does, and the +// tap-floor rules in styles.css key on it, so a journey judged at the wrong +// pointer measures the wrong targets. +export const isCoarse = (page) => page.evaluate(() => matchMedia('(pointer: coarse)').matches); + +export const test = base.extend({ + // One mobile-app server per WORKER, on a free port and a throwaway + // TOURNAMENT_DATA_DIR, started and stopped by scripts/screenshots' + // server.mjs (SIGTERM then SIGKILL by PID). Tests in a worker share it, so a + // spec that seeds once and walks several tests declares + // test.describe.configure({ mode: 'serial' }). + server: [async ({}, use) => { + let server; + try { + server = await start('mobile'); + } catch (err) { + throw new Error(`${err.message}\n(make e2e builds the binary before it runs the suite)`); + } + try { + await use(server); + } finally { + await server.stop(); + } + }, { scope: 'worker' }], + + // Every journey navigates by path; the host is the worker's server. + baseURL: async ({ server }, use) => { + await use(server.base); + }, + + // The operator project's page must really have a coarse pointer. hasTouch + + // isMobile (fixtures/devices.mjs) is what provides it today, measured. If + // that ever stops holding, turn on CDP touch emulation, which flips the + // media query on the next document, and fail loudly if even that does not. + page: async ({ page, context }, use, testInfo) => { + if (testInfo.project.name === 'operator' && !(await isCoarse(page))) { + const cdp = await context.newCDPSession(page); + await cdp.send('Emulation.setTouchEmulationEnabled', { enabled: true, maxTouchPoints: 5 }); + await page.reload(); + if (!(await isCoarse(page))) { + throw new Error('operator page is not (pointer: coarse) even with CDP touch emulation'); + } + testInfo.annotations.push({ type: 'coarse-pointer', description: 'CDP Emulation.setTouchEmulationEnabled' }); + } + await use(page); + }, +}); + +export { expect }; diff --git a/scripts/e2e/fixtures/wizard.mjs b/scripts/e2e/fixtures/wizard.mjs new file mode 100644 index 000000000..9e9de330e --- /dev/null +++ b/scripts/e2e/fixtures/wizard.mjs @@ -0,0 +1,102 @@ +// The create-competition wizard (admin_setup.jsx, /admin/create-competition). +// +// This is the ONE file that knows the wizard's copy. Every journey seeds its +// competitions through here (operator ruling: UI-only seeding), so a change to +// the wizard breaks this file and nothing else. The labels mirror +// competition_shape.jsx's LABEL_* constants and option lists. +import { expect } from '@playwright/test'; + +const KIND = { individual: 'Individual', team: 'Team' }; +const FORMAT = { + knockout: 'Knockout only', + mixed: 'Pools + Knockout', + league: 'League', + swiss: 'Swiss', +}; +const TEAM_MATCH_TYPE = { fixed: 'Regular', kachinuki: 'Kachinuki (winner stays on)' }; +const POOL_SIZE_MODE = { maximum: 'maximum', minimum: 'minimum' }; + +// A form row by its visible label. PillGroup, NumberField and TextField all +// render `.field > label.field__label` with no htmlFor (competition_fields.jsx), +// so getByLabel cannot reach them. Anchored, because "Pool match duration" +// and "Knockout match duration" share words with other labels. +const field = (page, label) => page.locator('.field') + .filter({ has: page.locator('.field__label', { hasText: new RegExp(`^${label}`) }) }) + .first(); + +// A pill in a group. The pills carry their state as the `is-active` class +// only (no aria-pressed), so that is what is read back. +const pill = (group, label) => group.locator('.radio-pill', { hasText: new RegExp(`^${escape(label)}$`) }); +const escape = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + +async function choose(page, groupLabel, optionLabel) { + const btn = pill(field(page, groupLabel), optionLabel); + await btn.tap(); + await expect(btn).toHaveClass(/\bis-active\b/); +} + +async function setNumber(page, label, value) { + const input = field(page, label).locator('input'); + await input.fill(String(value)); + await expect(input).toHaveValue(String(value)); +} + +// Create one competition and return its id (read from the URL the wizard +// lands on, /admin/competition//participants). Only the options given are +// touched; everything else keeps the wizard's default, which is itself part of +// what a journey walks. +// +// kind 'individual' | 'team' +// format 'knockout' | 'mixed' | 'league' | 'swiss' +// teamSize number (team only) +// teamMatchType 'fixed' | 'kachinuki' (team only) +// poolSizeMode 'maximum' | 'minimum' (mixed only) +// poolSize, poolWinners numbers (mixed only) +// courts e.g. ['A', 'B']: exactly these shiaijo end up selected +// numberPrefix up to 3 characters +// twoThirdPlaces boolean: "Award two joint 3rd places" (on by default; +// untick it for a bronze match) +export async function createCompetition(page, opts = {}) { + const { + name, kind, format, teamSize, teamMatchType, poolSizeMode, poolSize, poolWinners, + courts, numberPrefix, twoThirdPlaces, + } = opts; + await page.goto('/admin/create-competition'); + await expect(page.getByRole('heading', { level: 1, name: 'Add competition' })).toBeVisible(); + + if (name !== undefined) await field(page, 'Display name').locator('input').fill(name); + if (kind) await choose(page, 'Competition type', KIND[kind]); + if (format) await choose(page, 'Format', FORMAT[format]); + if (teamSize !== undefined) await setNumber(page, 'Team size', teamSize); + if (teamMatchType) await choose(page, 'Team match format', TEAM_MATCH_TYPE[teamMatchType]); + if (poolSizeMode) await choose(page, 'Pool size is a', POOL_SIZE_MODE[poolSizeMode]); + if (poolSize !== undefined) await setNumber(page, 'Players per pool', poolSize); + if (poolWinners !== undefined) await setNumber(page, 'Winners per pool', poolWinners); + if (twoThirdPlaces !== undefined) { + await page.getByRole('checkbox', { name: 'Award two joint 3rd places' }).setChecked(twoThirdPlaces); + } + if (courts) { + const group = field(page, 'Assigned shiaijo'); + const pills = group.locator('.radio-pill'); + // Select the wanted ones first, then drop the rest, so the selection is + // never empty on the way (the wizard refuses an empty or uneven count). + for (const want of [true, false]) { + for (let i = 0; i < await pills.count(); i += 1) { + const p = pills.nth(i); + const court = (await p.textContent()).trim().split(' ').pop(); + const active = /\bis-active\b/.test(await p.getAttribute('class')); + if (courts.includes(court) === want && active !== want) await p.tap(); + } + } + for (const court of courts) { + await expect(pill(group, `Shiaijo (court) ${court}`)).toHaveClass(/\bis-active\b/); + } + } + if (numberPrefix !== undefined) { + await field(page, 'Player number prefix').locator('input').fill(numberPrefix); + } + + await page.getByRole('button', { name: /^Create & continue/ }).tap(); + await expect(page).toHaveURL(/\/admin\/competition\/[^/]+\/participants$/); + return new URL(page.url()).pathname.split('/')[3]; +} diff --git a/scripts/e2e/journeys/smoke.spec.mjs b/scripts/e2e/journeys/smoke.spec.mjs new file mode 100644 index 000000000..eb466cb29 --- /dev/null +++ b/scripts/e2e/journeys/smoke.spec.mjs @@ -0,0 +1,187 @@ +// Smoke journey: proves the harness end to end, and every clumsy helper once. +// +// The operator creates the tournament, signs in through the form, builds an +// individual pools + knockout competition through the wizard, pastes a roster, +// draws, starts, and scores on /admin/shiaijo/A; a spectator's phone then sees +// the result. Everything is seeded through the interface. +// +// The clumsy variants are PERFORMED and RECORDED here (printed as AUDIT lines +// and attached as audit.json), never asserted: this spec only asserts what the +// seeding path and the one cross-surface result need. The knockout-mixed +// journey is where variants get judged. +import { test, expect, isCoarse } from '../fixtures/test.mjs'; +import { PUBLIC_DEVICE } from '../fixtures/devices.mjs'; +import { journeyShots } from '../fixtures/shots.mjs'; +import { createTournament, login, signOut } from '../fixtures/setup.mjs'; +import { createCompetition } from '../fixtures/wizard.mjs'; +import { generateDraw, pasteRoster, requestDiscardDraw, startCompetition } from '../fixtures/competition.mjs'; +import { + editorIdentity, inlineEditor, lastCompleted, openShiaijo, sides, startUpNext, upNextCard, +} from '../fixtures/shiaijo.mjs'; +import { + EDITOR, INLINE_EDITOR, armFinish, awardIppon, finishMatch, ipponButton, startMatch, syncState, +} from '../fixtures/scoring.mjs'; +import { openScoreEditor } from '../fixtures/scores.mjs'; +import { openTvBoard, openViewer, recentResult, tvBoard } from '../fixtures/public.mjs'; +import { doubleTap, hastyConfirm, interrupt, retapWhileSaving, tapNeighbour } from '../fixtures/clumsy.mjs'; + +// Eight entrants, four dojos, (name, dojo) unique. +const ROSTER = [ + ['Akira Tanaka', 'Gyokusen'], ['Ben Carter', 'Thames'], ['Chie Mori', 'Musashi'], + ['Dan Evans', 'Seishinkan'], ['Eri Kato', 'Gyokusen'], ['Finn Hale', 'Thames'], + ['Goro Abe', 'Musashi'], ['Hana Ito', 'Seishinkan'], +]; + +test.describe.configure({ mode: 'serial' }); + +test.describe('smoke', () => { + let shot; + test.beforeAll(() => { + shot = journeyShots('smoke'); + }); + + test('seed through the UI, score on a shiaijo, see it on a public phone', async ({ page, browser, baseURL }, testInfo) => { + const audit = []; + const record = (row) => { + audit.push(row); + console.log(`AUDIT ${JSON.stringify(row)}`); + }; + let compId; + let bout1; + + await test.step('create the tournament, sign out, sign in through the form', async () => { + await createTournament(page, { name: 'E2E Cup', courts: 2 }); + await signOut(page); + await login(page); + await shot(page, 'signed-in'); + }); + + await test.step('create an individual pools + knockout competition in the wizard', async () => { + compId = await createCompetition(page, { + name: 'Men Individual', kind: 'individual', format: 'mixed', courts: ['A', 'B'], numberPrefix: 'E', + }); + await shot(page, 'competition-created'); + }); + + await test.step('paste the roster', async () => { + await pasteRoster(page, compId, ROSTER); + await shot(page, 'roster-applied'); + }); + + await test.step('generate the draw, answer Discard draw hastily, generate again', async () => { + await generateDraw(page, compId); + await shot(page, 'draw-ready'); + await requestDiscardDraw(page); + await shot(page, 'discard-draw-confirm'); + const answered = await hastyConfirm(page); + const drawDiscarded = await page.getByRole('button', { name: 'Generate draw' }) + .waitFor({ state: 'visible', timeout: 10_000 }).then(() => true, () => false); + record({ variant: 'V4 hastyConfirm', action: 'Discard draw', ...answered, drawDiscarded }); + await generateDraw(page, compId); + }); + + await test.step('start the competition', async () => { + await startCompetition(page, compId); + await shot(page, 'started-scores-tab'); + }); + + await test.step('open shiaijo A on a coarse pointer', async () => { + await openShiaijo(page, 'A'); + expect(await isCoarse(page)).toBe(true); + const call = upNextCard(page).getByRole('button', { name: /Call to court|Call again|Calling…/ }); + const tapped = await doubleTap(call); + const labelAfter = (await call.innerText()).trim(); + record({ variant: 'V2 doubleTap', action: 'Call to court', ...tapped, labelAfter }); + await shot(page, 'shiaijo-A'); + }); + + await test.step('bout 1: start from Up next, interrupted three ways, one ippon, Finish', async () => { + const pair = await startUpNext(page); + const identity = await editorIdentity(page); + + const reload = await interrupt(page, 'reload'); + const backAfterReload = await inlineEditor(page).waitFor({ state: 'visible', timeout: 15_000 }) + .then(() => true, () => false); + record({ variant: 'V3 interrupt', action: 'running bout', ...reload, editorBack: backAfterReload, + sameMatch: backAfterReload && (await editorIdentity(page)) === identity }); + + await awardIppon(page, 'shiro', 'M'); + + const hidden = await interrupt(page, 'hidden'); + record({ variant: 'V3 interrupt', action: 'bout with one ippon', ...hidden, + sameMatch: (await editorIdentity(page)) === identity }); + + const back = await interrupt(page, 'back'); + const backAfterBack = await inlineEditor(page).waitFor({ state: 'visible', timeout: 15_000 }) + .then(() => true, () => false); + const shiroPoints = backAfterBack + ? await inlineEditor(page).locator('[aria-label^="Shiro slot "][aria-label*=": remove "]').count() : null; + record({ variant: 'V3 interrupt', action: 'bout with one ippon', ...back, editorBack: backAfterBack, + sameMatch: backAfterBack && (await editorIdentity(page)) === identity, shiroPointsKept: shiroPoints }); + await shot(page, 'bout-1-scored'); + + await finishMatch(page, INLINE_EDITOR); + bout1 = await lastCompleted(page); + expect(bout1).toMatchObject({ shiro: pair.shiro, aka: pair.aka }); + expect(bout1.result).toMatch(/^M\b/); + await shot(page, 'bout-1-finished'); + }); + + await test.step('bout 2: a neighbour tap, a tap while offline, a re-tap while saving', async () => { + // Finish + Start Next left bout 2 running in the same editor. + const editor = inlineEditor(page); + await expect.poll(() => sides(editor)).not.toEqual({ shiro: bout1.shiro, aka: bout1.aka }); + const pair = await sides(editor); + + const neighbour = await tapNeighbour(ipponButton(page, 'shiro', 'M'), 'right'); + await expect(syncState(page)).toHaveText('Synced'); + record({ variant: 'V1 tapNeighbour', action: 'Shiro M', ...neighbour }); + + let offlineState; + const offline = await interrupt(page, 'offline', { + during: async (p) => { + await ipponButton(p, 'aka', 'M').tap(); + // What the autosave pill settles on while the network is down: give + // it a moment to leave "Synced", and record whatever it shows. + await expect(syncState(p)).not.toHaveText('Synced', { timeout: 3000 }).catch(() => {}); + offlineState = (await syncState(p).innerText()).trim(); + }, + }); + const resynced = await expect(syncState(page)).toHaveText('Synced').then(() => true, () => false); + record({ variant: 'V3 interrupt', action: 'Aka M tapped offline', ...offline, stateWhileOffline: offlineState, resynced }); + await shot(page, 'bout-2-after-offline'); + + const armed = await armFinish(page); + const retap = await retapWhileSaving(armed); + const finished = await lastCompleted(page); + record({ variant: 'V2 retapWhileSaving', action: 'Finish (armed)', ...retap, + completedIsBout2: finished.shiro === pair.shiro && finished.aka === pair.aka, result: finished.result }); + await shot(page, 'bout-2-finished'); + }); + + await test.step('shiaijo B from the Scores tab: the overlay editor', async () => { + await openScoreEditor(page, compId, { court: 'B' }); + await startMatch(page); + await awardIppon(page, 'aka', 'K', EDITOR); + await shot(page, 'overlay-editor-B'); + await finishMatch(page); + }); + + await test.step('a spectator phone sees bout 1 on the viewer and the TV board', async () => { + const spectator = await browser.newContext({ ...PUBLIC_DEVICE, baseURL }); + try { + const phone = await spectator.newPage(); + await openViewer(phone, compId); + await expect(recentResult(phone, bout1)).toContainText(bout1.result); + await shot(phone, 'viewer-recent-results', { fullPage: true }); + await openTvBoard(phone, 'A'); + await expect(tvBoard(phone)).toContainText(bout1.shiro); + await shot(phone, 'tv-board-A'); + } finally { + await spectator.close(); + } + }); + + await testInfo.attach('audit.json', { body: JSON.stringify(audit, null, 2), contentType: 'application/json' }); + }); +}); diff --git a/scripts/e2e/package-lock.json b/scripts/e2e/package-lock.json new file mode 100644 index 000000000..0cc5423b2 --- /dev/null +++ b/scripts/e2e/package-lock.json @@ -0,0 +1,55 @@ +{ + "name": "bracket-creator-e2e", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "bracket-creator-e2e", + "devDependencies": { + "@playwright/test": "1.63.0" + } + }, + "node_modules/@playwright/test": { + "version": "1.63.0", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.63.0.tgz", + "integrity": "sha512-oxMK4vllB9RK5NQ2l1pq1IfOf2AvnEuj/vYGDj0H2nMtmtZpKtCwt/l00GEO6xjGfpBNAvjovvYdCm50dRQkpQ==", + "dev": true, + "dependencies": { + "playwright": "1.63.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/playwright": { + "version": "1.63.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.63.0.tgz", + "integrity": "sha512-+7ziBLidS4NaNCdt57SUDT+wYmmd5fmiQejUic/kb+YsYSCPyOOE9sebzMjNmQrsnNpDJqd4WHvV/8lfKfUDUg==", + "dev": true, + "dependencies": { + "playwright-core": "1.63.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/playwright-core": { + "version": "1.63.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.63.0.tgz", + "integrity": "sha512-rYCsBF/M5HjUch52bbtVONEFjv6Xu8sm8h72dNlR5bzIE1fvC/bxgspzkjSfU+MweEMmPM8KJebG6nnyxo5mCg==", + "dev": true, + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=20" + } + } + } +} diff --git a/scripts/e2e/package.json b/scripts/e2e/package.json new file mode 100644 index 000000000..a486bb8d7 --- /dev/null +++ b/scripts/e2e/package.json @@ -0,0 +1,12 @@ +{ + "name": "bracket-creator-e2e", + "private": true, + "type": "module", + "description": "Local Playwright journeys for the tournament app. Deliberately separate from web-mobile/package.json so a browser download never lands in js/deps or audit-ci's scope.", + "scripts": { + "test": "playwright test" + }, + "devDependencies": { + "@playwright/test": "1.63.0" + } +} diff --git a/scripts/e2e/playwright.config.mjs b/scripts/e2e/playwright.config.mjs new file mode 100644 index 000000000..4a85fa3f1 --- /dev/null +++ b/scripts/e2e/playwright.config.mjs @@ -0,0 +1,50 @@ +// Local e2e journeys for the tournament app. Run through `make e2e`, which +// builds bin/bracket-creator first; see README.md. +import { defineConfig } from '@playwright/test'; +import { OPERATOR_DEVICE, PUBLIC_DEVICE } from './fixtures/devices.mjs'; + +// Chromium refuses to run its sandbox as root, so the sandbox is dropped there +// and only there (the same rule as scripts/screenshots/run.mjs). +const asRoot = typeof process.getuid === 'function' && process.getuid() === 0; + +export default defineConfig({ + testDir: './journeys', + // Playwright wipes outputDir at the start of every run. Per-step journey + // screenshots live beside it in output// (fixtures/shots.mjs), so + // this directory only ever holds Playwright's own traces and failure shots. + outputDir: './output/test-results', + // A journey drives the create wizard, the roster and the draw through the + // interface before it reaches the step it is about, so a test is slow by + // design. Each worker boots its own server (fixtures/test.mjs), so files + // can run in parallel; tests inside a file run in order. + timeout: 240_000, + expect: { timeout: 15_000 }, + fullyParallel: false, + forbidOnly: true, + retries: 0, + reporter: 'list', + use: { + browserName: 'chromium', + actionTimeout: 15_000, + navigationTimeout: 30_000, + screenshot: 'only-on-failure', + trace: 'retain-on-failure', + launchOptions: { args: asRoot ? ['--no-sandbox'] : [] }, + }, + projects: [ + // The court table. Every journey that is about the operator runs here. + { + name: 'operator', + use: { ...OPERATOR_DEVICE }, + testIgnore: /\.public\.spec\.mjs$/, + }, + // A spectator's phone. A journey that is about a public surface alone is + // named *.public.spec.mjs and runs here. An operator journey that checks + // a public surface on the side opens its own PUBLIC_DEVICE context. + { + name: 'public', + use: { ...PUBLIC_DEVICE }, + testMatch: /\.public\.spec\.mjs$/, + }, + ], +}); diff --git a/scripts/screenshots/lib/editor.mjs b/scripts/screenshots/lib/editor.mjs index 626847f35..7de531cdb 100644 --- a/scripts/screenshots/lib/editor.mjs +++ b/scripts/screenshots/lib/editor.mjs @@ -10,6 +10,16 @@ // so the testid names the BACKDROP and the class names the dialog itself. export const EDITOR = '.editor-modal'; +// The same editor mounted INLINE, as the shiaijo page (/admin/shiaijo/:court) +// does: admin_scoring_individual.jsx renders `variant="inline"` as +// `
`, with no backdrop and no +// `.editor-modal` class, so EDITOR matches nothing there. finishMatch takes +// the editor's selector as `root` for that reason; the default keeps every +// overlay caller unchanged. startMatch does not: on that page "Start match" +// lives on the Up next card, outside the editor, and the card's button never +// leaves (it moves on to the next match), so startMatch's wait would not hold. +export const INLINE_EDITOR = '.scoring-panel'; + // Finish is a two-tap guard on the individual and fixed-order team editors: // the first tap arms the button ("Tap again to finish"), only the second // submits (admin_scoring_individual.jsx and admin_scoring_team.jsx, the @@ -22,8 +32,8 @@ export const EDITOR = '.editor-modal'; // mean. If it never appears the wait throws, because a Finish that did not arm // did not finish. Not for the engi editor ("Save result" commits in one tap) // or a correction ("Save correction" does not arm either). -export async function finishMatch(page) { - const modal = page.locator(EDITOR).first(); +export async function finishMatch(page, root = EDITOR) { + const modal = page.locator(root).first(); await modal.locator('button').filter({ hasText: /^Finish( \+ Start Next|$)/ }).first().click(); const armed = modal.locator('button').filter({ hasText: /^Tap again to finish/ }).first(); await armed.waitFor({ state: 'visible', timeout: 3000 }); From 2ace9f5f29988b0049dce20fba5fd5d79963f982 Mon Sep 17 00:00:00 2001 From: Ricardo Oliveira Date: Thu, 24 Sep 2026 00:54:00 +0100 Subject: [PATCH 02/30] fix(bc-e2eh): let concurrent e2e runs use separate results folders Each playwright run empties outputDir at start, so two runs in one checkout deleted each other's traces (ENOENT on context close). Co-Authored-By: Claude Opus 5.5 --- scripts/e2e/README.md | 4 ++++ scripts/e2e/playwright.config.mjs | 7 ++++++- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/scripts/e2e/README.md b/scripts/e2e/README.md index f8ba475e0..0e6777025 100644 --- a/scripts/e2e/README.md +++ b/scripts/e2e/README.md @@ -107,6 +107,10 @@ were taken, and are replaced on the next run. Playwright's own traces and failure screenshots land in `output/test-results/`. Both are gitignored. There are no pixel comparisons: a screenshot is for a person to look at. +Every run empties `output/test-results/` when it starts. Two runs at the same +time in one checkout would delete each other's traces mid-test, so give the +second one its own folder: `E2E_RESULTS_DIR=output/test-results-2 make e2e ...`. + ## When a journey finds a bug A functional defect found by a journey is filed as a bead, and that step diff --git a/scripts/e2e/playwright.config.mjs b/scripts/e2e/playwright.config.mjs index 4a85fa3f1..574a778f4 100644 --- a/scripts/e2e/playwright.config.mjs +++ b/scripts/e2e/playwright.config.mjs @@ -12,7 +12,12 @@ export default defineConfig({ // Playwright wipes outputDir at the start of every run. Per-step journey // screenshots live beside it in output// (fixtures/shots.mjs), so // this directory only ever holds Playwright's own traces and failure shots. - outputDir: './output/test-results', + // Two runs at once in one checkout must not share it, or the second run's + // wipe deletes the first run's traces mid-test (ENOENT on context close): + // give each concurrent run its own E2E_RESULTS_DIR. It is read from the + // environment, not derived from the pid, because every worker process + // re-reads this file and must resolve the same directory. + outputDir: process.env.E2E_RESULTS_DIR || './output/test-results', // A journey drives the create wizard, the roster and the draw through the // interface before it reaches the step it is about, so a test is slow by // design. Each worker boots its own server (fixtures/test.mjs), so files From d12ad9c5cac13e6205f5e3d9efba34eba88e1525 Mon Sep 17 00:00:00 2001 From: Ricardo Oliveira Date: Thu, 24 Sep 2026 02:21:13 +0100 Subject: [PATCH 03/30] feat(mp-yqxn.2): knockout/mixed operator journeys (individual, fixed-order team, kachinuki) Persona-audited journeys J1-J8; findings are test.fixme until their beads land. shots.mjs re-applies touch emulation after a fullPage shot, which otherwise drops (pointer: coarse) for the rest of the test. Co-Authored-By: Claude Opus 5.5 --- scripts/e2e/fixtures/individual.mjs | 220 +++ scripts/e2e/fixtures/kachinuki.mjs | 172 +++ scripts/e2e/fixtures/shots.mjs | 18 + scripts/e2e/fixtures/team.mjs | 249 ++++ .../knockout-mixed-individual.spec.mjs | 1123 +++++++++++++++ .../knockout-mixed-kachinuki.spec.mjs | 1213 +++++++++++++++++ .../e2e/journeys/knockout-mixed-team.spec.mjs | 1133 +++++++++++++++ 7 files changed, 4128 insertions(+) create mode 100644 scripts/e2e/fixtures/individual.mjs create mode 100644 scripts/e2e/fixtures/kachinuki.mjs create mode 100644 scripts/e2e/fixtures/team.mjs create mode 100644 scripts/e2e/journeys/knockout-mixed-individual.spec.mjs create mode 100644 scripts/e2e/journeys/knockout-mixed-kachinuki.spec.mjs create mode 100644 scripts/e2e/journeys/knockout-mixed-team.spec.mjs diff --git a/scripts/e2e/fixtures/individual.mjs b/scripts/e2e/fixtures/individual.mjs new file mode 100644 index 000000000..9a4955920 --- /dev/null +++ b/scripts/e2e/fixtures/individual.mjs @@ -0,0 +1,220 @@ +// Helpers for the INDIVIDUAL operator journeys (knockout-mixed-individual +// spec): the rosters, a deterministic winner rule, the audit log, tap-target +// measurement, and the few surfaces the shared fixtures do not own (the +// overlay editor's identity line, the shiaijo page's own confirm dialogs, the +// decision panel). Owned by reviewer A; the shared fixtures stay untouched. +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { expect } from '@playwright/test'; +import { EDITOR, INLINE_EDITOR } from './scoring.mjs'; + +// Twelve entrants for F1 (pools of 3, two through): six dojos, two members +// each, (name, dojo) unique. Names start with distinct letters so the +// "first name alphabetically wins" rule below gives every pool a strict +// order and never a tie-break bout. +export const ROSTER_12 = [ + ['Aoi Sato', 'Gyokusen'], ['Ben Carter', 'Thames'], ['Chie Mori', 'Musashi'], + ['Dan Evans', 'Seishinkan'], ['Eri Kato', 'Mumeishi'], ['Finn Hale', 'Hizen'], + ['Goro Abe', 'Gyokusen'], ['Hana Ito', 'Thames'], ['Ivo Nunes', 'Musashi'], + ['Jun Oda', 'Seishinkan'], ['Kai Ueda', 'Mumeishi'], ['Lea Roth', 'Hizen'], +]; +// Six for F1b (two pools of 3): the decision and Pools-tab journeys. +export const ROSTER_6 = [ + ['Mika Endo', 'Gyokusen'], ['Nils Berg', 'Thames'], ['Oto Kudo', 'Musashi'], + ['Pia Lund', 'Seishinkan'], ['Rin Saito', 'Mumeishi'], ['Sam Doyle', 'Hizen'], +]; +// Six more for F1c (two pools of 3): scored from the competition's Pools tab. +export const ROSTER_6B = [ + ['Bo Grant', 'Gyokusen'], ['Cy Novak', 'Thames'], ['Di Weller', 'Musashi'], + ['Ed Frost', 'Seishinkan'], ['Flo Adler', 'Mumeishi'], ['Gil Varga', 'Hizen'], +]; +// Small rosters for the FINDING tests, which seed their own competitions so +// each can run alone once its defect is fixed. +export const rosterOf = (prefix, n) => Array.from({ length: n }, (_, i) => [ + `${prefix} ${['Aki', 'Bea', 'Cal', 'Dee', 'Eli', 'Fay'][i]}`, ['Gyokusen', 'Thames', 'Musashi', 'Seishinkan', 'Mumeishi', 'Hizen'][i], +]); +// Four for each knockout-only fixture: two semi-finals, then the final (and +// the bronze match when joint 3rd places are off). +export const ROSTER_4_BRONZE = [ + ['Taro Ono', 'Gyokusen'], ['Uma Patel', 'Thames'], ['Vic Lange', 'Musashi'], ['Wen Zhao', 'Seishinkan'], +]; +export const ROSTER_4_JOINT = [ + ['Xan Moss', 'Gyokusen'], ['Yui Hara', 'Thames'], ['Zoe Quinn', 'Musashi'], ['Ada Pike', 'Seishinkan'], +]; + +// The deterministic winner of a pairing: the name that sorts first. Transitive, +// so a pool's standings come out strict and no tie-break bout is added. +export const winnerOf = ({ shiro, aka }) => (shiro.localeCompare(aka) <= 0 ? 'shiro' : 'aka'); + +// Audit rows. Every row is printed (so the run log carries the table) and kept +// for afterAll, which writes them to AUDIT_JSON, beside the journey's +// screenshots in the gitignored output folder, for the person writing up the +// review. Nothing here judges a row: judgement is by eye. +export const AUDIT_JSON = path.resolve(fileURLToPath(new URL('../output/knockout-mixed-individual/audit.json', import.meta.url))); +export function auditLog() { + const rows = []; + const record = (row) => { + rows.push(row); + console.log(`AUDIT ${JSON.stringify(row)}`); + }; + const flush = () => { + let prior = []; + try { prior = JSON.parse(fs.readFileSync(AUDIT_JSON, 'utf8')); } catch { prior = []; } + // A journey re-run replaces its own earlier rows and keeps the others. + const journeys = new Set(rows.map((r) => r.journey)); + const kept = prior.filter((r) => !journeys.has(r.journey)); + fs.mkdirSync(path.dirname(AUDIT_JSON), { recursive: true }); + fs.writeFileSync(AUDIT_JSON, JSON.stringify([...kept, ...rows], null, 2)); + }; + return { record, flush, rows }; +} + +// A control's rendered size, for the 44px coarse-pointer floor. +export async function tapSize(locator) { + const box = await locator.boundingBox(); + if (!box) return null; + return { w: Math.round(box.width), h: Math.round(box.height), meets44: box.width >= 44 && box.height >= 44 }; +} + +// The overlay editor's identity: its eyebrow (competition, pool/round) and its +// title line ("Shiaijo B · 10:20"), which is what says which court it is on. +export async function overlayIdentity(page) { + const ed = page.locator(EDITOR).first(); + const eyebrow = ((await ed.locator('.editor-modal__eyebrow').allInnerTexts())[0] || '').trim(); + const title = ((await ed.locator('.editor-modal__title').allInnerTexts())[0] || '').trim(); + return { eyebrow, title, court: (title.match(/Shiaijo\s+(\S+)/) || [])[1] || null }; +} + +// The same for the inline editor (shiaijo page, Bracket tab panel). +export async function inlineIdentity(page) { + const ed = page.locator(INLINE_EDITOR).first(); + const eyebrow = ((await ed.locator('.editor-modal__eyebrow').allInnerTexts())[0] || '').trim(); + const title = ((await ed.locator('.editor-modal__title').allInnerTexts())[0] || '').trim(); + return { eyebrow, title, court: (title.match(/Shiaijo\s+(\S+)/) || [])[1] || null }; +} + +// The two names an editor shows, Shiro first. +export async function editorSides(page, root = INLINE_EDITOR) { + const ed = page.locator(root).first(); + const shiro = (await ed.locator('.sb-side--shiro .numbered-name__text').first().innerText()).trim(); + const aka = (await ed.locator('.sb-side--aka .numbered-name__text').first().innerText()).trim(); + return { shiro, aka }; +} + +// The slot marks a side holds, in slot order ("M", "Ht", ...). +export async function slotMarks(page, side, root = INLINE_EDITOR) { + const name = side === 'shiro' ? 'Shiro' : 'Aka'; + const labels = await page.locator(root).first().locator(`[aria-label^="${name} slot "]`).evaluateAll( + (els) => els.map((e) => e.getAttribute('aria-label')), + ); + return labels.map((l) => (l.match(/: (?:remove )?(\S+)$/) || [])[1]).filter((v) => v && v !== 'empty'); +} + +// The shiaijo page's own confirm dialogs (Send back to queue, Move court) are +// not the shared confirmDialog, so clumsy.hastyConfirm cannot see them. Same +// rule here: tap the most prominent button, and say what it was. +export async function hastyShiaijoConfirm(page) { + const dialog = page.locator('.shiaijo-move-confirm[role="dialog"]').last(); + await dialog.waitFor({ state: 'visible' }); + const buttons = dialog.locator('.shiaijo-move-confirm__actions button'); + let best = null; + const labels = []; + for (let i = 0; i < await buttons.count(); i += 1) { + const b = buttons.nth(i); + const cls = (await b.getAttribute('class')) || ''; + const label = (await b.innerText()).trim(); + labels.push(label); + const rank = /\bbtn--danger\b/.test(cls) ? 3 : /\bbtn--primary\b/.test(cls) ? 2 : /\bbtn--ghost\b/.test(cls) ? 0 : 1; + if (!best || rank > best.rank) best = { b, label, rank }; + } + const title = ((await dialog.locator('h3').allInnerTexts())[0] || '').trim(); + const message = ((await dialog.locator('p').allInnerTexts())[0] || '').trim(); + await best.b.tap(); + await dialog.waitFor({ state: 'hidden' }); + return { label: best.label, prominence: ['ghost', 'plain', 'primary', 'danger'][best.rank], otherLabels: labels.filter((l) => l !== best.label), title, message }; +} + +// The decision panel (DecisionPrompt, admin_scoring_shared.jsx) is inline in +// the editor, not a dialog. +export const decisionButton = (page, kind, root = INLINE_EDITOR) => page.locator(root).first() + .getByTestId({ 'kiken-voluntary': 'scoring-modal-kiken-voluntary-button', 'kiken-injury': 'scoring-modal-kiken-injury-button', fusenpai: 'scoring-modal-fusenpai-button' }[kind]); +export const decisionPrompt = (page, root = INLINE_EDITOR) => page.locator(root).first().locator('form.decision-prompt'); + +// Open a decision, pick the side (or leave the default, the hasty operator's +// way when `side` is omitted), optionally type a reason, and Record. +export async function recordDecision(page, kind, { side, reason, root = INLINE_EDITOR } = {}) { + await decisionButton(page, kind, root).tap(); + const form = decisionPrompt(page, root); + await expect(form).toBeVisible(); + const defaultSide = await form.locator('input[name="decision-side"]:checked').getAttribute('value'); + if (side) await form.locator(`input[name="decision-side"][value="${side}"]`).check(); + if (reason) await form.getByTestId('decision-reason').fill(reason); + await form.getByRole('button', { name: 'Record' }).tap(); + return { defaultSide, chosen: side || defaultSide }; +} + +// Is the inline editor holding a RUNNING bout (no PRE-MATCH / CORRECTION pill)? +export async function inlineRunning(page) { + const ed = page.locator(INLINE_EDITOR).first(); + if (!(await ed.isVisible().catch(() => false))) return false; + const pill = ed.locator('.editor-head-pill'); + return (await pill.count()) === 0; +} + +// Make sure the shiaijo page has a running bout in its editor: when the court +// is idle ("Ready when you are"), start the Up next card's match. Returns the +// pairing, or null when the court has nothing left to start. +export async function ensureRunning(page) { + const deadline = Date.now() + 15_000; + while (Date.now() < deadline) { + if (await inlineRunning(page)) return editorSides(page); + const card = page.locator('.shiaijo-upnext__card'); + const idle = await page.locator('.shiaijo__placeholder').isVisible().catch(() => false); + if (idle && await card.isVisible().catch(() => false)) { + const start = card.getByRole('button', { name: 'Start match' }); + if (await start.isEnabled().catch(() => false)) await start.tap(); + } + await page.waitForTimeout(250); + } + return null; +} + +// Score the running bout on the shiaijo page for the deterministic winner (one +// men) and Finish (+ Start Next) through the two-tap guard. Returns the pairing. +export async function playRunningBout(page, { waza = 'M' } = {}) { + const pair = await ensureRunning(page); + if (!pair) throw new Error('playRunningBout: nothing running or startable on this court'); + const side = winnerOf(pair); + const ed = page.locator(INLINE_EDITOR).first(); + await ed.locator(`.sb-side--${side} .ipt-btn`, { hasText: new RegExp(`^${waza}$`) }).first().tap(); + await expect(ed.locator('.sync-pill__label').first()).toHaveText('Synced'); + const finish = ed.locator('button').filter({ hasText: /^Finish( \+ Start Next|$)/ }).first(); + await finish.tap(); + const armed = ed.locator('button').filter({ hasText: /^Tap again to finish/ }).first(); + await armed.waitFor({ state: 'visible', timeout: 3000 }); + await armed.tap(); + await expect(completedRowFor(page, pair)).toBeVisible(); + // Wait for the editor to let go of the finished bout (it moves on to the + // next one, or the court goes idle), so the caller never reads it as live. + await expect.poll(async () => { + if (!(await inlineRunning(page))) return 'moved'; + const now = await editorSides(page).catch(() => null); + return now && now.shiro === pair.shiro && now.aka === pair.aka ? 'same' : 'moved'; + }, { timeout: 10_000 }).toBe('moved'); + return { ...pair, winner: side }; +} + +// How many POOL bouts still wait in this court's queue (the Up next card and +// the Upcoming rows; not Later, not Completed). A pool bout reads +// "Match n of m" in its header, a knockout bout "Match n". +export async function queuedPoolBouts(page) { + const texts = await page.locator('.shiaijo-upnext__card, .shiaijo__queue .shiaijo-group:not(.shiaijo-pending) .shiaijo-qrow:not(.shiaijo-qrow--complete)') + .allInnerTexts(); + return texts.filter((t) => /Match \d+ of \d+/.test(t)).length; +} + +// The newest Completed row on the shiaijo page whose names match a pairing. +export function completedRowFor(page, { shiro, aka }) { + return page.locator('.shiaijo-completed .shiaijo-qrow').filter({ hasText: shiro }).filter({ hasText: aka }).last(); +} diff --git a/scripts/e2e/fixtures/kachinuki.mjs b/scripts/e2e/fixtures/kachinuki.mjs new file mode 100644 index 000000000..ba90349d6 --- /dev/null +++ b/scripts/e2e/fixtures/kachinuki.mjs @@ -0,0 +1,172 @@ +// The kachinuki (winner stays on) score sheet as the court operator drives it +// on /admin/shiaijo/:court: the per-match lineup panel, the current bout's +// ippon buttons, Tie, Encho, Record bout, End match, the fought bouts above +// the current one, and the correction doors (tap a fought bout, Reopen match, +// the court-busy requeue panel, Remove this bout). +// +// Selectors follow admin_scoring_team.jsx and admin_schedule_lineup.jsx. The +// individual board's helpers (fixtures/scoring.mjs) do not fit here: a team +// bout's sides are `.team-sub-match__side--`, not `.sb-side--`. +// Every tap goes through Locator.tap(), so the coarse-pointer hit test decides +// what is hit, as it does for a thumb. +import { expect } from '@playwright/test'; +import { INLINE_EDITOR } from '../../screenshots/lib/editor.mjs'; + +// Does the page still match (pointer: coarse)? Measured on this journey: after +// a fullPage screenshot (fixtures/shots.mjs with { fullPage: true }) the +// operator page no longer matches, so every tap-floor rule in styles.css +// silently switches off for the rest of the test. Put it back the way +// fixtures/test.mjs does (CDP touch emulation, which takes effect on the next +// document, hence the reload) and report whether it had been lost, so the +// audit can say which pointer a measurement was made at. +export async function ensureCoarse(page) { + const coarse = () => page.evaluate(() => matchMedia('(pointer: coarse)').matches); + if (await coarse()) return { coarseLost: false }; + const cdp = await page.context().newCDPSession(page); + await cdp.send('Emulation.setTouchEmulationEnabled', { enabled: true, maxTouchPoints: 5 }); + await page.reload(); + if (!(await coarse())) throw new Error('page is not (pointer: coarse) even with CDP touch emulation'); + return { coarseLost: true, restoredBy: 'CDP Emulation.setTouchEmulationEnabled + reload' }; +} + +export const editor = (page) => page.locator(INLINE_EDITOR).filter({ has: page.locator('.team-bouts-scroll') }).first(); + +// ---------------------------------------------------------------- lineup -- + +// The per-match lineup panel (MatchLineupPanel, the "Enter lineup" button on +// the Up next card). It is a fixed overlay with no dialog role, found by its +// heading. +export const lineupPanel = (page) => page.locator('div').filter({ + has: page.getByRole('heading', { name: 'Lineup for this match' }), +}).filter({ has: page.getByRole('button', { name: /Close|Done/ }) }).last(); + +// Open the lineup panel from the Up next card. +export async function openLineupFromUpNext(page) { + await page.locator('.shiaijo-upnext__card').getByRole('button', { name: 'Enter lineup' }).tap(); + await expect(page.getByRole('heading', { name: 'Lineup for this match' })).toBeVisible(); +} + +// Name every position of ONE team's lineup by typing (a typed name over a +// blank numbered slot names that slot's member, bc-dnst), then Save lineup. +// `teamName` picks the side editor by its heading text. Returns once the +// save has landed (the button leaves "Saving…"). `save: false` types the +// names and leaves the side unsaved. +export const lineupSide = (page, teamName) => page.locator('[data-testid^="match-lineup-side-"]').filter({ hasText: teamName }).first(); + +export async function typeLineup(page, teamName, names, { save: doSave = true } = {}) { + const side = lineupSide(page, teamName); + await expect(side).toBeVisible(); + const inputs = side.locator('input.pmf__input'); + await expect(inputs).toHaveCount(names.length); + for (let i = 0; i < names.length; i += 1) { + const box = inputs.nth(i); + await box.tap(); + await box.fill(names[i]); + await box.press('Enter'); + await expect(box).toHaveValue(names[i]); + } + if (!doSave) return side; + const save = side.getByRole('button', { name: /^(Save lineup|Saving…)$/ }); + await save.tap(); + await expect(side.getByRole('button', { name: 'Save lineup' })).toBeEnabled(); + return side; +} + +export async function closeLineupPanel(page) { + await page.getByRole('button', { name: /^(✕ Close|Done)$/ }).first().tap(); + await expect(page.getByRole('heading', { name: 'Lineup for this match' })).toHaveCount(0); +} + +// ----------------------------------------------------------------- bouts -- + +// Fought bouts, collapsed to read-only rows above the current one. +export const doneRows = (page) => editor(page).locator('.team-sub-match--readonly'); +// The live bout: the one editable row that is not a fought bout reopened for +// correction. +export const currentBout = (page) => editor(page) + .locator('.team-sub-match:not(.team-sub-match--readonly):not(.team-sub-match--correcting)').first(); +// A fought bout opened for correction. +export const correctingBout = (page) => editor(page).locator('.team-sub-match--correcting').first(); + +// An ippon button on a bout row. `side` is 'shiro' (left, the match's sideB) +// or 'aka' (right, sideA). +export const boutIpponButton = (row, side, waza = 'M') => row + .locator(`.team-sub-match__side--${side} .ipt-btn`, { hasText: new RegExp(`^${waza}$`) }).first(); + +// The filled centre slots of one side of a bout row. +export const boutFilled = (row, side) => row.locator(`.tsm-center-pts--${side} .editor-side__pt--filled`); + +export const tieButton = (row) => row.getByTestId('scoring-modal-tie-button'); +export const recordBoutButton = (page) => editor(page).getByRole('button', { name: /^(Record bout|Saving…)$/ }).first(); +export const endMatchButton = (page) => editor(page).getByTestId('kachinuki-end-match-button'); +export const enchoButton = (page) => editor(page).getByTestId('kachinuki-encho-button'); +export const removeBoutButton = (page) => editor(page).getByTestId('kachinuki-remove-bout-button'); +export const reopenButton = (page) => page.getByTestId('kachinuki-reopen-button'); +export const syncPill = (page) => editor(page).locator('.sync-pill__label').first(); + +// The two fighters on a bout row. The live row carries a name box per side; +// a fought row carries static text. The box shows the name as its value +// while closed, but while its list is open the value is the typed QUERY and +// the name moves to the placeholder (LineupNameInput in +// admin_scoring_shared.jsx: `value={open ? query : value}`, `placeholder= +// {value || "Add player…"}`), so an empty value falls back to the +// placeholder. +export async function boutNames(row) { + const read = async (side) => { + const cell = row.locator(`.team-sub-match__side--${side} .tsm-name`).first(); + const input = cell.locator('input.pmf__input'); + if (await input.count()) { + const v = (await input.inputValue()).trim(); + if (v) return v; + const ph = ((await input.getAttribute('placeholder')) || '').trim(); + return ph === 'Add player…' ? '' : ph; + } + return (await cell.locator('.tsm-name__static').first().innerText()).trim(); + }; + return { shiro: await read('shiro'), aka: await read('aka') }; +} + +// Who a fought row marks as the winner ('shiro' | 'aka' | ''), read from the +// winner class on the name. +export async function doneRowWinner(row) { + if (await row.locator('.team-sub-match__side--shiro .tsm-name__static--win').count()) return 'shiro'; + if (await row.locator('.team-sub-match__side--aka .tsm-name__static--win').count()) return 'aka'; + return ''; +} + +// The centre mark of a row (vs / X / (E)). +export const boutMiddle = async (row) => (await row.locator('.team-sub-match__score').first().innerText()).trim(); + +// Award one ippon on the live bout and wait for the board to show it. +export async function awardBoutIppon(page, side, waza = 'M') { + const row = currentBout(page); + const before = await boutFilled(row, side).count(); + await boutIpponButton(row, side, waza).tap(); + await expect(boutFilled(currentBout(page), side)).toHaveCount(before + 1); +} + +// Record bout: one tap. Returns once the bout has become a read-only row (the +// server appended the next pairing, or the tie retired both). +export async function recordBout(page) { + const before = await doneRows(page).count(); + await recordBoutButton(page).tap(); + await expect(doneRows(page)).toHaveCount(before + 1, { timeout: 15_000 }); + await expect(recordBoutButton(page)).not.toHaveText('Saving…'); +} + +// Win the live bout 2-0 (two men) for `side`, then Record bout. +export async function winBout(page, side) { + await awardBoutIppon(page, side, 'M'); + await awardBoutIppon(page, side, 'K'); + await recordBout(page); +} + +// End match: the two-tap guard. Returns the armed label the first tap showed. +export async function endMatch(page) { + const btn = endMatchButton(page); + await btn.tap(); + await expect(btn).toHaveText(/^Tap again/); + const armed = (await btn.innerText()).trim(); + await btn.tap(); + return armed; +} diff --git a/scripts/e2e/fixtures/shots.mjs b/scripts/e2e/fixtures/shots.mjs index 31da63120..411ecada6 100644 --- a/scripts/e2e/fixtures/shots.mjs +++ b/scripts/e2e/fixtures/shots.mjs @@ -7,6 +7,9 @@ import { fileURLToPath } from 'node:url'; const OUTPUT = path.resolve(fileURLToPath(new URL('../output', import.meta.url))); +// page -> the CDP session that re-applies touch emulation after a fullPage shot. +const touchSessions = new WeakMap(); + // Empty the journey's folder and return its camera. Call once per spec file // (in beforeAll), so a folder never mixes two runs. export function journeyShots(journey) { @@ -17,7 +20,22 @@ export function journeyShots(journey) { return async (page, step, { fullPage = false } = {}) => { n += 1; const file = path.join(dir, `${String(n).padStart(2, '0')}-${step}.png`); + const coarse = fullPage && await page.evaluate(() => matchMedia('(pointer: coarse)').matches); await page.screenshot({ path: file, fullPage, animations: 'disabled', caret: 'hide' }); + // A fullPage shot resizes the viewport and, restoring it, drops the touch + // emulation: measured, (pointer: coarse) reads false afterwards, so every + // tap-floor rule switches off for the rest of the test. Turning touch + // emulation back on restores it in place, no reload needed (measured). + // The CDP session is kept for the page's life: detaching it clears the + // emulation it set (measured), so a detach would undo the restore. + if (coarse) { + let cdp = touchSessions.get(page); + if (!cdp) { + cdp = await page.context().newCDPSession(page); + touchSessions.set(page, cdp); + } + await cdp.send('Emulation.setTouchEmulationEnabled', { enabled: true, maxTouchPoints: 5 }); + } return file; }; } diff --git a/scripts/e2e/fixtures/team.mjs b/scripts/e2e/fixtures/team.mjs new file mode 100644 index 000000000..38d14b893 --- /dev/null +++ b/scripts/e2e/fixtures/team.mjs @@ -0,0 +1,249 @@ +// Fixed-order TEAM competitions: naming team members, the three lineup entry +// points, and scoring a bout inside a team encounter. +// +// the Lineups page /admin/competition/:id/lineups (admin_lineup.jsx) +// writes the ROUND lineup, one team at a time +// the match panel "Enter lineup" / "Lineup" on the shiaijo queue +// (admin_schedule_lineup.jsx), writes the MATCH lineup +// the score sheet row the name box on each bout row of the team editor +// (admin_scoring_team.jsx), writes the MATCH lineup +// +// The shared scoring helpers (fixtures/scoring.mjs) scope to the INDIVIDUAL +// board's `.sb-side--*` wrappers, which the team sheet does not have, so the +// team sheet's own selectors live here. Every helper performs a real tap on a +// real control; none of them asserts app behaviour beyond "the tap landed". +import { expect } from '@playwright/test'; +import { createTournament, login } from './setup.mjs'; + +// FIK position names for a five-person team, in order; any other size uses +// bare numbers (admin_lineup.jsx positionsForSize). +export const FIK5 = ['Senpo', 'Jiho', 'Chuken', 'Fukusho', 'Taisho']; +export const positionLabels = (teamSize) => (teamSize === 5 ? FIK5 : Array.from({ length: teamSize }, (_, i) => String(i + 1))); + +// The team sheet's per-row position name, used in its aria-labels +// (admin_scoring_team.jsx positionLabelFor): FIK names at five, else "Match N". +export const sheetPositionLabel = (teamSize, idx) => (teamSize === 5 ? FIK5[idx] : `Match ${idx + 1}`); + +// Sign in, creating the tournament first if this server has none yet. Every +// test in a serial file can call it, whichever runs first. +export async function enterAdmin(page, { courts = 16 } = {}) { + await page.goto('/'); + const welcome = page.getByRole('heading', { name: 'Welcome to Bracket Creator' }); + const first = await welcome.waitFor({ state: 'visible', timeout: 5000 }).then(() => true, () => false); + if (first) { + await createTournament(page, { name: 'Team Cup', courts }); + return; + } + await login(page); +} + +// ---------------------------------------------------------------- Lineups page + +const overlineField = (page, text) => page.locator('label') + .filter({ has: page.locator('.overline', { hasText: new RegExp(`^${text}$`, 'i') }) }).first(); + +export const lineupsForm = (page) => page.getByTestId('lineup-form-root'); + +// Open the Lineups page on `team` (its display name) and `round` (1-based, +// as the page shows it). +export async function openLineups(page, id, { team, round = 1 } = {}) { + await page.goto(`/admin/competition/${encodeURIComponent(id)}/lineups`); + await expect(lineupsForm(page)).toBeVisible(); + if (team) { + const sel = overlineField(page, 'Team').locator('select'); + await sel.selectOption({ label: team }); + await expect(lineupsForm(page).locator('h2')).toContainText(team); + } + if (round !== 1) { + await overlineField(page, 'Round').locator('input').fill(String(round)); + } + await expect(lineupsForm(page)).toContainText(`Round ${round}`); + // The team members load separately from the lineup; wait for their list. + await expect(lineupsForm(page).getByText('Team members', { exact: true })).toBeVisible(); +} + +// The position's on the Lineups page ("Senpo player", "2 player"). export const lineupsSelect = (page, label) => lineupsForm(page).getByRole('combobox', { name: `${label} player` }); @@ -91,6 +105,8 @@ export async function lineupsStartAdd(page, label, name) { // Answer the open confirm dialog with its named button. export async function answerDialog(page, label) { const dialog = page.locator('.modal[role="dialog"]').filter({ has: page.locator('.modal__foot') }).last(); + // A confirm ignores a tap inside the bounce window of its opening. + await settled(dialog); await dialog.getByRole('button', { name: label }).tap(); await dialog.waitFor({ state: 'hidden' }); } @@ -116,6 +132,12 @@ export const lineupsSelected = async (page, label) => (await lineupsSelect(page, // One side of the panel: 0 = Shiro (left), 1 = Aka (right). export const panelSide = (page, i) => page.locator('[data-testid^="match-lineup-side-"]').nth(i); export const panelInput = (page, i, label) => panelSide(page, i).getByRole('textbox', { name: `${label} player`, exact: true }); +// Where the lineup a side shows comes from: "Lineup for this match", "Same as +// " or "Starting lineup" (LineupSourceLine, lineup_draft.jsx). +export const panelSource = (page, i) => panelSide(page, i).locator('[data-testid^="match-lineup-source-"]'); +// The side's Save lineup, which stays disabled until the operator changes +// something on that side and reads "Saving…" while the write is out. +export const panelSaveButton = (page, i) => panelSide(page, i).getByRole('button', { name: /^(Save lineup|Saving…)$/ }); // Open the Up next card's "Enter lineup". export async function openPanelFromUpNext(page) { @@ -147,9 +169,12 @@ export async function typeIntoNameBox(input, name) { await input.press('Enter'); } +// Tap one side's Save lineup and return once the button is back from "Saving…". +// It comes back disabled when the side was saved (nothing is left to save) and +// enabled when the save was refused, so the caller asserts which it meant. export async function panelSave(page, i) { - await panelSide(page, i).getByRole('button', { name: 'Save lineup' }).tap(); - await expect(panelSide(page, i).getByRole('button', { name: 'Save lineup' })).toBeEnabled(); + await panelSaveButton(page, i).tap(); + await expect(panelSide(page, i).getByRole('button', { name: 'Save lineup' })).toBeVisible(); } export async function closePanel(page) { @@ -238,6 +263,8 @@ export async function finishTeam(root) { await expect(btn).toHaveText(/^Finish( \+ Start Next →)?$/); await btn.tap(); await expect(btn).toHaveText(/^Tap again to finish/); + // The second tap counts once the arming tap's bounce window has passed. + await settled(btn); await btn.tap(); await expect(root.locator('button', { hasText: /^(Tap again to finish|Saving…)/ })).toHaveCount(0, { timeout: 15000 }); } diff --git a/scripts/e2e/journeys/knockout-mixed-individual.spec.mjs b/scripts/e2e/journeys/knockout-mixed-individual.spec.mjs index 21e85987b..38c4102bb 100644 --- a/scripts/e2e/journeys/knockout-mixed-individual.spec.mjs +++ b/scripts/e2e/journeys/knockout-mixed-individual.spec.mjs @@ -181,7 +181,7 @@ test.describe('knockout-mixed-individual', () => { row({ step: 'finish', action: 'Finish + Start Next', variant: 'V2 doubleTap', ...dblFinish, finishedByDoubleTap: finishedByDouble, stillArmed: armedLeft, shot: await shot(page, 'finish-double-tapped') }); if (!finishedByDouble) { - if (!armedLeft) await finishButton(page).tap(); + if (!armedLeft) await armFinish(page); await armedFinishButton(page).tap(); } const r1 = await lastCompleted(page); @@ -373,8 +373,9 @@ test.describe('knockout-mixed-individual', () => { }); // J2. Special decisions. On F1b (court E, pools): a kiken recorded against - // the WRONG side by a hasty operator (the prompt defaults to Shiro), its - // default-win chain, then the undo through the 409 decision_locked confirm; + // the WRONG side by a hasty operator (the prompt defaults to Shiro), the + // fusensho recorded from the withdrawn competitor's next match, then the undo + // through the 409 decision_locked confirm; // a fusenpai that auto-advances. On F2 (court C, knockout): a tied 1-1 bout // through encho (an unbounded counter) to hantei. test('J2 special decisions: kiken chain and undo, fusenpai, encho, hantei', async ({ page }) => { @@ -414,10 +415,10 @@ test.describe('knockout-mixed-individual', () => { row({ step: 'kiken', action: 'side radio (Which side withdrew?)', variant: 'size', ...(await tapSize(radio)), label: await tapSize(decisionPrompt(page).locator('label').first()) }); await decisionPrompt(page).getByRole('button', { name: 'Record' }).tap(); - const remaining = inlineEditor(page).locator('.remaining-matches'); - await expect(remaining).toBeVisible(); - const withdrawnShown = (await remaining.locator('div').first().innerText()).trim(); - const withdrawnName = withdrawnShown.replace(/^Remaining matches for\s*/, '').replace(/\s*✕$/, '').trim(); + // Recording a withdrawal changes only that match: no panel offers the + // competitor's other matches. The side the prompt preselected is the one + // recorded. + const withdrawnName = b1[defaultSide]; row({ step: 'kiken', action: 'Record (prompt left on its default side)', variant: 'V4 hasty (inline prompt)', intendedWithdrawer: `aka: ${b1.aka}`, defaultSide, recordedWithdrawer: withdrawnName, wrongSide: defaultSide !== 'aka', shot: await shot(page, 'kiken-recorded-default-side') }); @@ -428,13 +429,12 @@ test.describe('knockout-mixed-individual', () => { expect(kikenResult).toMatch(/○/); expect(kikenResult).toMatch(/Kiken/); - // The default-win chain. The operator looks up at the shiaijo for a - // moment before tapping anything in the remaining-matches panel. - const panelAtOnce = await remaining.isVisible().catch(() => false); + // The withdrawn competitor's other matches are closed one at a time, each + // from its own notice. The operator looks up at the shiaijo for a moment + // first: nothing is lost by it. await page.waitForTimeout(3000); - const panelAfter3s = await remaining.isVisible().catch(() => false); - row({ step: 'kiken chain', action: 'remaining-matches panel, 3s later', variant: 'V3 interrupt (looked away)', - panelAtOnce, panelAfter3s, editor: await inlineEditor(page).isVisible().catch(() => false), + row({ step: 'kiken chain', action: 'the console, 3s after the withdrawal', variant: 'V3 interrupt (looked away)', + editor: await inlineEditor(page).isVisible().catch(() => false), idle: await page.locator('.shiaijo__placeholder').isVisible().catch(() => false), shot: await shot(page, 'kiken-chain-after-3s') }); @@ -453,20 +453,20 @@ test.describe('knockout-mixed-individual', () => { pair: pairW, noStartButton: startCount === 0, notice: noticeText, shot: await shot(page, 'withdrawn-next-bout-barred') }); expect(startCount).toBe(0); - expect(noticeText).toBe(`${withdrawnName} withdrew: record the default win.`); + expect(noticeText).toBe(`${withdrawnName} withdrew: record the fusensho.`); - // Record the default win the way the operator now does: tap the notice's - // own button, then double-tap it (the impatient thumb) -- it locks itself - // on the first response, so only one decision must land. - const defaultWinBtn = notice.getByTestId('barred-match-default-win'); - row({ step: 'kiken chain', action: 'Record default win for the opponent (barred-match notice)', variant: 'size', ...(await tapSize(defaultWinBtn)) }); - const dblDefaultWin = await doubleTap(defaultWinBtn); + // Record the fusensho the way the operator does: tap the notice's own + // button, then double-tap it (the impatient thumb) -- it locks itself on + // the first response, so only one decision must land. + const fusenshoBtn = notice.getByTestId('barred-match-record-fusensho'); + row({ step: 'kiken chain', action: 'Record fusensho for the opponent (barred-match notice)', variant: 'size', ...(await tapSize(fusenshoBtn)) }); + const dblFusensho = await doubleTap(fusenshoBtn); const chainRow = completedRowFor(page, pairW); await expect(chainRow).toBeVisible({ timeout: 8000 }); const chainResult = (await chainRow.locator('.shiaijo-qrow__result').innerText()).trim(); - row({ step: 'kiken chain', action: 'Record default win for the opponent (barred-match notice)', variant: 'V2 doubleTap', ...dblDefaultWin, + row({ step: 'kiken chain', action: 'Record fusensho for the opponent (barred-match notice)', variant: 'V2 doubleTap', ...dblFusensho, result: chainResult, cardLeftQueue: !(await nextOfWithdrawn.isVisible().catch(() => false)), - shot: await shot(page, 'chain-default-win-recorded') }); + shot: await shot(page, 'chain-fusensho-recorded') }); // The notice records a fusensho: the winner shows two circles and the // Fus. mark sits beside them, the side that was present (sideMarks). expect(chainResult).toMatch(/○/); @@ -490,28 +490,13 @@ test.describe('knockout-mixed-individual', () => { hasty = await hastyConfirm(page); } await page.waitForTimeout(1500); - const undoPanel = await remaining.isVisible().catch(() => false); - const undoPanelText = undoPanel ? (await remaining.innerText()).replace(/\s+/g, ' ').slice(0, 240) : null; const undoneResult = (await completedRowFor(page, b1).locator('.shiaijo-qrow__result').innerText()).trim(); row({ step: 'kiken undo', action: 'Kiken - Voluntary on the other side (correction)', variant: 'V4 hastyConfirm (409 decision_locked)', - dialogShown: locked, ...(hasty || {}), result: undoneResult, remainingPanel: undoPanelText, + dialogShown: locked, ...(hasty || {}), result: undoneResult, shot: await shot(page, 'kiken-undo-forced') }); expect(locked).toBe(true); // Docs: the kiken now sits beside Aka, the real withdrawer. expect(undoneResult).toMatch(/○○ vs Kiken|○○.*Kiken$/); - let award = null; - if (undoPanel) { - const btn = remaining.getByRole('button', { name: 'Award default win to opponent' }); - if (await btn.count()) { - await btn.first().tap(); - await page.waitForTimeout(1500); - award = { error: ((await remaining.locator('div[style*="danger"]').allInnerTexts().catch(() => [])) || []).join(' | '), - left: await btn.count() }; - } - row({ step: 'kiken undo', action: 'Award default win to opponent (panel after the undo)', variant: 'correct path (the chain)', - panel: undoPanelText, award, shot: await shot(page, 'undo-chain-award') }); - await remaining.getByRole('button', { name: '✕' }).tap().catch(() => {}); - } const backToCourt = page.getByRole('button', { name: /Back to court/ }); if (await backToCourt.isVisible().catch(() => false)) await backToCourt.tap(); await page.waitForTimeout(1000); @@ -532,8 +517,7 @@ test.describe('knockout-mixed-individual', () => { const opp = live.aka === b1.aka ? 'shiro' : 'aka'; await ipponButton(page, opp, 'M').tap(); await settle(page); - await finishButton(page).tap(); - await armedFinishButton(page).tap(); + await (await armFinish(page)).tap(); await page.waitForTimeout(1500); const msg = (await page.locator('.toast, .pending-write-banner').allInnerTexts().catch(() => [])).join(' | '); const done = await completedRowFor(page, live).isVisible().catch(() => false); @@ -723,8 +707,7 @@ test.describe('knockout-mixed-individual', () => { // Network down; Finish (two taps). await context.setOffline(true); - await finishButton(page).tap(); - await armedFinishButton(page).tap(); + await (await armFinish(page)).tap(); await page.waitForTimeout(300); const at300 = { editor: await inlineEditor(page).isVisible().catch(() => false), @@ -1098,8 +1081,7 @@ test.describe('knockout-mixed-individual', () => { await settle(page); await context.setOffline(true); try { - await finishButton(page).tap(); - await armedFinishButton(page).tap(); + await (await armFinish(page)).tap(); await page.waitForTimeout(1000); await expect(page.getByText(/Not saved yet/)).toBeVisible(); } finally { diff --git a/scripts/e2e/journeys/knockout-mixed-kachinuki.spec.mjs b/scripts/e2e/journeys/knockout-mixed-kachinuki.spec.mjs index 7e8f24b1b..d9fd1c921 100644 --- a/scripts/e2e/journeys/knockout-mixed-kachinuki.spec.mjs +++ b/scripts/e2e/journeys/knockout-mixed-kachinuki.spec.mjs @@ -39,6 +39,7 @@ import { generateDraw, pasteRoster, startCompetition } from '../fixtures/competi import { openShiaijo, sides, upNextCard } from '../fixtures/shiaijo.mjs'; import { PUBLIC_DEVICE } from '../fixtures/devices.mjs'; import { openViewer } from '../fixtures/public.mjs'; +import { dwell, settled } from '../fixtures/pace.mjs'; // clumsy.hastyConfirm answers confirmDialog (ui.jsx) only. The other confirms // on this journey are the shiaijo page's own `.shiaijo-move-confirm`, the // inline reason prompt and the inline court-busy panel, which it cannot see; @@ -46,7 +47,7 @@ import { openViewer } from '../fixtures/public.mjs'; import { doubleTap, hastyConfirm, interrupt, retapWhileSaving, tapNeighbour } from '../fixtures/clumsy.mjs'; import { awardBoutIppon, boutFilled, ensureCoarse, boutIpponButton, boutMiddle, boutNames, closeLineupPanel, correctingBout, - currentBout, doneRowWinner, doneRows, editor, enchoButton, endMatch, endMatchButton, lineupSide, + closeFighterList, currentBout, doneRowWinner, doneRows, editor, enchoButton, enchoUndoButton, endMatch, endMatchButton, lineupSide, lineupSource, openLineupFromUpNext, recordBout, recordBoutButton, removeBoutButton, reopenButton, syncPill, tieButton, typeLineup, winBout, } from '../fixtures/kachinuki.mjs'; @@ -115,14 +116,21 @@ test.describe('J5 kachinuki from the court console', () => { const reload = await interruptC(page, 'reload'); const panelBack = await page.getByRole('heading', { name: 'Lineup for this match' }).isVisible(); if (!panelBack) await openLineupFromUpNext(page); + // The typed names come back as a draft kept in this tab, and the side says + // so once the lineup has been read; a notice that never shows is recorded as none. + const notice = lineupSide(page, pair.shiro).locator('[data-testid^="match-lineup-draft-"]'); + await notice.waitFor({ state: 'visible', timeout: 3000 }).catch(() => {}); const kept = await lineupSide(page, pair.shiro).locator('input.pmf__input').evaluateAll((els) => els.map((e) => e.value)); + const draftNotice = (await notice.innerText({ timeout: 1000 }).catch(() => '')).replace(/\s+/g, ' ').trim(); record({ step: 'M2 lineup', action: 'type five names, not saved', variant: 'V3 interrupt reload', ...reload, - panelStillOpen: panelBack, namesKept: kept, screenshot: await shot(page, 'v3-lineup-reload-unsaved') }); + panelStillOpen: panelBack, namesKept: kept, draftNotice, screenshot: await shot(page, 'v3-lineup-reload-unsaved') }); await typeLineup(page, pair.shiro, MEMBERS[pair.shiro], { save: false }); // V2: Save lineup double-tapped. const save = lineupSide(page, pair.shiro).getByRole('button', { name: 'Save lineup' }); const v2 = await doubleTap(save); - await expect(lineupSide(page, pair.shiro).getByRole('button', { name: 'Save lineup' })).toBeEnabled(); + // The first tap saves; the second finds the button busy or, once saved, off + // (nothing is left to save). Saved: the side shows the lineup as this match's own. + await expect(lineupSource(lineupSide(page, pair.shiro))).toHaveText('Lineup for this match'); await page.waitForTimeout(800); record({ step: 'M2 lineup', action: 'Save lineup', variant: 'V2 doubleTap', ...v2, toasts: await page.locator('.toast, [role="status"]').allInnerTexts().catch(() => []), @@ -178,18 +186,25 @@ test.describe('J5 kachinuki from the court console', () => { await test.step('bout 1: Shiro wins M K; a double tap, a neighbour tap, a reload before Record', async () => { const row = currentBout(page); - // V2 on an ippon: two taps inside 300ms on Shiro's M. + // V2 on an ippon: two taps inside 300ms on Shiro's M. The app ignores the + // second as a bounce, but the first mark makes the clear hint appear and the + // row moves, so the second tap can land on a name box instead and open its list. + const mBefore = await boutIpponButton(row, 'shiro', 'M').boundingBox(); const v2 = await doubleTap(boutIpponButton(row, 'shiro', 'M')); await page.waitForTimeout(300); + const mAfter = await boutIpponButton(currentBout(page), 'shiro', 'M').boundingBox(); const marks = await boutFilled(currentBout(page), 'shiro').allInnerTexts(); const v2Shot = await shot(page, 'v2-ippon-double-tap'); - // Recover: tap the second mark (the editor's "Tap a scored mark to clear it"). + // Recover: close a fighter list the second tap opened, then tap the second + // mark if there is one (the editor's "Tap a scored mark to clear it"). + const fighterListOpened = await closeFighterList(page); let recoverTaps = 0; while ((await boutFilled(currentBout(page), 'shiro').count()) > 1) { await boutFilled(currentBout(page), 'shiro').last().tap(); recoverTaps += 1; } - record({ step: 'M1 bout 1', action: 'Shiro M', variant: 'V2 doubleTap', ...v2, marksAfter: marks, + record({ step: 'M1 bout 1', action: 'Shiro M', variant: 'V2 doubleTap', ...v2, marksAfter: marks, fighterListOpened, + mMovedByPx: mBefore && mAfter ? Math.round(mAfter.y - mBefore.y) : null, recoverTaps, hintShown: await editor(page).getByTestId('team-scoring-clear-hint').isVisible(), screenshot: v2Shot }); // V1: the thumb lands on the button beside M. const v1 = await tapNeighbour(boutIpponButton(currentBout(page), 'shiro', 'M'), 'right'); @@ -277,9 +292,16 @@ test.describe('J5 kachinuki from the court console', () => { const v2 = await doubleTap(enchoButton(page)); await page.waitForTimeout(300); const eyebrow = (await editor(page).locator('.editor-modal__eyebrow').first().innerText()).trim(); - record({ step: 'M1 bout 4', action: 'Encho', variant: 'V2 doubleTap', ...v2, eyebrow, + // Undo encho appears beside Encho with the first tap and the row re-centres, so + // the second tap can land on it and take the period straight back. + const inOvertime = await enchoUndoButton(page).isVisible(); + record({ step: 'M1 bout 4', action: 'Encho', variant: 'V2 doubleTap', ...v2, eyebrow, inOvertimeAfterDoubleTap: inOvertime, enchoStillOffered: await enchoButton(page).isVisible(), middle: await boutMiddle(currentBout(page)), screenshot: await shot(page, 'v2-encho-double-tap') }); + // The rest of the bout is fought in overtime: put the period back when the + // double tap took it off. + if (!inOvertime) await enchoButton(page).tap(); + await expect(enchoUndoButton(page)).toBeVisible(); const hidden = await interruptC(page, 'hidden'); record({ step: 'M1 bout 4', action: 'encho running', variant: 'V3 interrupt hidden', ...hidden, pairing: await boutNames(currentBout(page)), middle: await boutMiddle(currentBout(page)), @@ -291,8 +313,19 @@ test.describe('J5 kachinuki from the court console', () => { expect(await doneRowWinner(doneRows(page).nth(3))).toBe('aka'); }); - await test.step('bout 5: the hurried double tap records M M for a win that was M K', async () => { - await doubleTap(boutIpponButton(currentBout(page), 'shiro', 'M')); + await test.step('bout 5: a hurried double tap lands one M, a second M tapped on purpose records M M for a win that was M K', async () => { + const tappedAt = Date.now(); + const dbl = await doubleTap(boutIpponButton(currentBout(page), 'shiro', 'M')); + // The app ignores the bounce of a tap: two taps inside its window land one M. + if (dbl.withinWindow) await expect(boutFilled(currentBout(page), 'shiro')).toHaveCount(1); + record({ step: 'M1 bout 5', action: 'Shiro M', variant: 'V2 doubleTap', ...dbl, + marksAfter: await boutFilled(currentBout(page), 'shiro').allInnerTexts() }); + // The mistake the later correction is about: the operator, meaning M K, + // taps M a second time, past the bounce window. + if ((await boutFilled(currentBout(page), 'shiro').count()) < 2) { + await dwell(page, tappedAt); + await boutIpponButton(currentBout(page), 'shiro', 'M').tap(); + } await expect(boutFilled(currentBout(page), 'shiro')).toHaveCount(2); await recordBout(page); await expectPairing(6, S[3], A[3]); @@ -747,11 +780,14 @@ test.describe('J5 kachinuki from the court console', () => { expect(upNext).toEqual(m2); }); - await test.step('M1 ended again (no reason owed, 9df3980a); M2 restarts with its score cleared', async () => { + await test.step('M1 ended again (no reason owed, 9df3980a); M2 restarts with its score kept', async () => { // Ending a reopened match asks for no reason: the same two-tap // arm/confirm guard every End match uses. await endMatch(page); - await expect(completedRow(page, m1)).toBeVisible(); + // M1 is complete again on the server. The console shows a result as soon as + // End match is tapped, before the server has answered, so a page loaded + // afresh is asked. + await completedOnServer(page, m1); // The operator carries straight on: Start match on the Up next card. await expect(upNextCard(page)).toBeVisible(); expect(await sides(upNextCard(page))).toEqual(m2); @@ -777,9 +813,11 @@ test.describe('J5 kachinuki from the court console', () => { live: await boutNames(currentBout(page)), screenshot: await shot(page, 'v5-m2-after-back-to-court') }); const marks = await boutFilled(currentBout(page), 'aka').count(); record({ step: 'M1 reopen, court busy', action: 'M2 restarted after the requeue', variant: 'result', - m2Bout1AkaMarks: marks, docsSay: 'Sending a match back to the queue clears any score already entered for it', + m2Bout1AkaMarks: marks, panelSays: 'Sending it back to the queue keeps any score already entered for it: it carries on from there when it is started again', screenshot: await shot(page, 'm2-restarted-on-A') }); - expect(marks).toBe(0); + // Sending a match back to the queue clears its verdict, never its points + // (operator ruling 2026-09-26): Aka's first M is still on bout 1. + expect(marks).toBe(1); }); }); @@ -907,7 +945,14 @@ test.describe('J5 kachinuki from the court console', () => { doneRows: await doneRows(page).count(), current: await boutNames(currentBout(page)), recordEnabled: await recordBoutButton(page).isEnabled(), screenshot: await shot(page, 'm2-v2-record-double-tap') }); } else { - await winBout(page, 'aka'); + // Bout 1 comes back with the M Aka scored before M2 was sent back to the + // queue (the points stay), so it needs only the K. + if ((await boutFilled(currentBout(page), 'aka').count()) === 0) { + await winBout(page, 'aka'); + } else { + await awardBoutIppon(page, 'aka', 'K'); + await recordBout(page); + } } } }); @@ -929,6 +974,8 @@ test.describe('J5 kachinuki from the court console', () => { const b = await endMatchButton(page).boundingBox(); record({ step: 'M2 end', action: 'End match (first tap arms)', variant: 'correct path', armed, endBox: b && `${Math.round(b.width)}x${Math.round(b.height)}`, screenshot: await shot(page, 'm2-end-armed') }); + // The second tap counts once the arming tap's bounce window has passed. + await settled(endMatchButton(page)); await endMatchButton(page).tap(); await expect(completedRow(page, m2)).toBeVisible(); await shot(page, 'm2-completed'); @@ -971,8 +1018,13 @@ test.describe('J5 kachinuki from the court console', () => { await awardBoutIppon(page, 'aka', 'M'); } await endMatchButton(page).tap(); - if (await page.locator('.reason-prompt').isVisible()) await hastyReasonPrompt(page); - else await endMatchButton(page).tap(); + if (await page.locator('.reason-prompt').isVisible()) { + await hastyReasonPrompt(page); + } else { + // The second tap counts once the arming tap's bounce window has passed. + await settled(endMatchButton(page)); + await endMatchButton(page).tap(); + } await expect(completedRow(page, m2)).toBeVisible(); await shot(page, 'm2-completed-again'); }); @@ -997,7 +1049,7 @@ test.describe('J5 kachinuki from the court console', () => { await expect(upNextCard(page)).toBeVisible(); }); - test('J5 final: lineups copied, the final starts on A', async ({ page }) => { + test('J5 final: lineups carried over, the final starts on A', async ({ page }) => { await login(page); await openShiaijo(page, 'A'); const final = await sides(upNextCard(page)); @@ -1005,18 +1057,24 @@ test.describe('J5 kachinuki from the court console', () => { expect([final.shiro, final.aka].sort()).toEqual([m1.shiro, m2.aka].sort()); finalPair = final; - await test.step('lineups for the final: Copy from previous match, double-tapped', async () => { + await test.step('lineups for the final: each team carries its previous match\'s lineup', async () => { await openLineupFromUpNext(page); + // There is nothing to copy: a team keeps the lineup of its previous team + // match until one is saved for this match, and each side says whose it is + // ("Same as "). With nothing changed, neither side offers a Save. const sidesEls = page.locator('[data-testid^="match-lineup-side-"]'); - const copy = sidesEls.nth(0).getByRole('button', { name: /Copy from previous match|Copying…/ }); - const v2 = await doubleTap(copy); - await page.waitForTimeout(1500); - const first = await sidesEls.nth(0).locator('input.pmf__input').evaluateAll((els) => els.map((e) => e.value)); - record({ step: 'final lineup', action: 'Copy from previous match', variant: 'V2 doubleTap', ...v2, lineup: first, - screenshot: await shot(page, 'final-copy-lineup-double-tap') }); - await sidesEls.nth(1).getByRole('button', { name: 'Copy from previous match' }).tap(); - await page.waitForTimeout(1500); - await shot(page, 'final-lineups-copied'); + const carried = []; + for (const [i, team] of [final.shiro, final.aka].entries()) { + const side = sidesEls.nth(i); + await expect(lineupSource(side)).toHaveText(/^Same as /); + const inputs = side.locator('input.pmf__input'); + for (const [n, name] of MEMBERS[team].entries()) await expect(inputs.nth(n)).toHaveValue(name); + await expect(side.getByRole('button', { name: 'Save lineup' })).toBeDisabled(); + carried.push({ team, source: (await lineupSource(side).innerText()).trim(), + lineup: await inputs.evaluateAll((els) => els.map((e) => e.value)) }); + } + record({ step: 'final lineup', action: 'open the lineup panel for the final', variant: 'carried over', carried, + screenshot: await shot(page, 'final-lineups-carried') }); await closeLineupPanel(page); }); @@ -1171,15 +1229,34 @@ function completedRow(page, pair) { return page.locator('.shiaijo-completed .shiaijo-qrow').filter({ hasText: pair.shiro }).filter({ hasText: pair.aka }).first(); } +// Waits until the server lists `pair` as completed on `court`, read by a page +// loaded afresh in the same context (so already signed in): the console on the +// page that tapped End match shows the result at once, before the server has +// answered, and keeps showing it if the server then holds the match unchanged. +async function completedOnServer(page, pair, court = 'A') { + const fresh = await page.context().newPage(); + try { + await expect.poll(async () => { + await openShiaijo(fresh, court); + return completedRow(fresh, pair).locator('.shiaijo-qrow__result').count(); + }, { timeout: 15_000, message: `${pair.shiro} v ${pair.aka} is completed on the server` }).toBeGreaterThan(0); + } finally { + await fresh.close(); + } +} + // V4 for the shiaijo page's own confirms (`.shiaijo-move-confirm`: Send back // to queue, Move to Shiaijo X). They are not confirmDialog (ui.jsx), so // clumsy.hastyConfirm cannot see them; same rule: tap the loudest button. async function hastyShiaijoConfirm(page) { const dialog = page.locator('.shiaijo-move-confirm'); await dialog.waitFor({ state: 'visible' }); + const shown = Date.now(); const r = await loudestIn(dialog.locator('.shiaijo-move-confirm__actions')); const title = (await dialog.locator('.shiaijo-move-confirm__title').innerText()).trim(); const message = (await dialog.locator('.shiaijo-move-confirm__body').innerText()).trim(); + // The confirm ignores a tap inside the bounce window of its opening (fixtures/pace.mjs). + await dwell(page, shown); await r.tap(); await dialog.waitFor({ state: 'hidden', timeout: 15_000 }); return { label: r.label, prominence: r.prominence, otherLabels: r.others, title, message }; diff --git a/scripts/e2e/journeys/knockout-mixed-team.spec.mjs b/scripts/e2e/journeys/knockout-mixed-team.spec.mjs index 5d082da0c..09b448ac1 100644 --- a/scripts/e2e/journeys/knockout-mixed-team.spec.mjs +++ b/scripts/e2e/journeys/knockout-mixed-team.spec.mjs @@ -69,8 +69,8 @@ async function seedF4(page, { name, court, prefix, teams }) { return id; } -// Name every position of `team`'s round-1 lineup on the Lineups page. -async function nameRound1(page, id, team, names, teamSize) { +// Name every position of `team`'s starting lineup on the Lineups page. +async function nameStartingLineup(page, id, team, names, teamSize) { await T.openLineups(page, id, { team }); const labels = T.positionLabels(teamSize); for (let i = 0; i < names.length; i += 1) await T.lineupsNameSlot(page, labels[i], names[i]); @@ -95,7 +95,7 @@ test.describe('knockout-mixed-team', () => { pair3 = await sides(upNextCard(page)); }); - // ---- Entry point 1: the Lineups page (round default) + // ---- Entry point 1: the Lineups page (the team's starting lineup) await test.step('F3 EP1 Lineups page: positions are bare numbers, name three slots', async () => { await T.openLineups(page, f3, { team: pair3.shiro }); // teamSize 3 shows bare numbers (positionsForSize). @@ -156,10 +156,15 @@ test.describe('knockout-mixed-team', () => { await T.lineupsPick(page, '3', '.5'); const r = await interrupt(page, 'reload'); await T.openLineups(page, f3, { team: pair3.shiro }); + // The unsaved picks come back as a draft kept in this tab, offered with + // Discard; they are only shown, never saved. + const draftNotice = T.lineupsForm(page).getByTestId('lineup-draft-notice'); + await expect(draftNotice).toContainText('Unsaved lineup changes restored'); const after = [await T.lineupsSelected(page, '1'), await T.lineupsSelected(page, '2'), await T.lineupsSelected(page, '3')]; await shot(page, 'f3-lineups-after-reload-unsaved', { fullPage: true }); record({ step: 'EP1 unsaved lineup', action: 'reload before Save', variant: 'V3 interrupt reload', ...r, positionsAfter: after, - note: 'names were minted by the confirms; the position picks were never saved' }); + draftNotice: (await draftNotice.innerText()).replace(/\s+/g, ' ').trim(), + note: 'names were minted by the confirms; the position picks come back as an unsaved draft' }); // Re-place the three and save, re-tapping Save while it reads "Saving…". await T.lineupsPick(page, '1', 'Aoki'); await T.lineupsPick(page, '2', 'Baba'); @@ -176,14 +181,14 @@ test.describe('knockout-mixed-team', () => { }); // ---- Entry point 2: the at-court match panel (match override) - await test.step('F3 EP2 match panel from Up next: what it shows of the round default', async () => { + await test.step('F3 EP2 match panel from Up next: the lineup in force and where it comes from', async () => { await openShiaijo(page, 'A'); await T.openPanelFromUpNext(page); const shiroBoxes = await Promise.all(['1', '2', '3'].map((l) => T.panelInput(page, 0, l).inputValue())); - const label = (await T.panelSide(page, 0).innerText()).includes('Inheriting round default'); - await shot(page, 'f3-panel-round-default'); - record({ step: 'EP2 open panel', action: 'Enter lineup (Up next)', variant: 'correct', inheritingLabel: label, shiroBoxes, - roundDefaultOnLineupsPage: ['Aoki', 'Baba', 'Chiba'], note: 'bc-lpfb when the boxes are empty' }); + const shiroSource = (await T.panelSource(page, 0).innerText()).trim(); + await shot(page, 'f3-panel-lineup-in-force'); + record({ step: 'EP2 open panel', action: 'Enter lineup (Up next)', variant: 'correct', shiroSource, shiroBoxes, + startingLineupOnLineupsPage: ['Aoki', 'Baba', 'Chiba'], note: 'bc-lpfb when the boxes are empty' }); }); await test.step('F3 EP2 Aka lineup typed in the panel; V1 neighbour of Aka Save; duplicate refusal', async () => { @@ -195,9 +200,11 @@ test.describe('knockout-mixed-team', () => { const box2Options = await T.panelSide(page, 1).locator('.pmf__option').allInnerTexts(); await T.dismissNameBox(page); await shot(page, 'f3-panel-aka-typed'); - // V1: the thumb lands left of Aka's Save lineup. Since bc-cpdc moved - // "Copy from previous match" to the top of each side, no control sits - // within reach there, so the row records that the hazard is gone. + // V1: the thumb lands left of Aka's Save lineup. What sits there depends on + // how many rows each side has (there is no "Copy from previous match" any + // more: a team carries its previous match's lineup); with three rows a side + // it is the clear x of Shiro's third name box, which empties that position. + // The row records what was hit, and the next step puts Shiro back. const akaSave = T.panelSide(page, 1).getByRole('button', { name: 'Save lineup' }); const nb = await tapNeighbour(akaSave, 'left').catch((e) => { if (!/no control within/.test(e.message)) throw e; @@ -206,11 +213,13 @@ test.describe('knockout-mixed-team', () => { await page.waitForTimeout(800); const shiroState = (await T.panelSide(page, 0).innerText()).replace(/\s+/g, ' '); await shot(page, 'f3-panel-v1-neighbour-of-aka-save'); - record({ step: 'EP2 save Aka', action: 'Save lineup (Aka)', variant: 'V1 tapNeighbour left (hazard gone since bc-cpdc)', ...nb, shiroSideAfter: shiroState.slice(0, 200), + record({ step: 'EP2 save Aka', action: 'Save lineup (Aka)', variant: 'V1 tapNeighbour left', ...nb, shiroSideAfter: shiroState.slice(0, 200), akaSaveTapBox: await T.tapBox(akaSave), box2OfferedWhileDaiAt1: box2Options }); // Then the intended tap. await T.panelSave(page, 1); - await expect(T.panelSide(page, 1)).toContainText('Override for this match'); + await expect(T.panelSource(page, 1)).toHaveText('Lineup for this match'); + // Saved: nothing is left to save on that side until it changes again. + await expect(T.panelSaveButton(page, 1)).toBeDisabled(); await shot(page, 'f3-panel-aka-saved'); const rename = T.panelSide(page, 1).getByRole('button', { name: 'Rename 1 player' }); record({ step: 'EP2 Rename', action: 'Rename control on the panel', variant: 'tap target', renameTapBox: await T.tapBox(rename) }); @@ -224,31 +233,51 @@ test.describe('knockout-mixed-team', () => { await shot(page, 'f3-panel-duplicate-refused'); record({ step: 'EP2 duplicate', action: 'type Dai at 2 (Dai at 1), Save', variant: 'refusal copy', copy, note: 'bc-lprf when it names 2, the position being typed, not 1' }); expect(copy).toMatch(/Dai is already at/); - // Repair: put Eto back. + // Repair: put Eto back. That is the lineup already saved, so nothing is + // left to save and Save lineup goes off again; the repair is two taps. await T.pickFromNameBox(T.panelInput(page, 1, '2'), 'Eto'); - await T.panelSave(page, 1); - await expect(T.panelSide(page, 1).locator('.alert--error')).toHaveCount(0); - record({ step: 'EP2 duplicate', action: 'repair', variant: 'recover', tapsToRecover: 3 }); + await expect(T.panelSaveButton(page, 1)).toBeDisabled(); + record({ step: 'EP2 duplicate', action: 'repair', variant: 'recover', tapsToRecover: 2, + refusalStillShown: await T.panelSide(page, 1).locator('.alert--error').count() }); }); - await test.step('F3 EP2 V1: Save lineup tapped on the SHIRO side, whose boxes the panel showed empty', async () => { - // The thumb meant Aka's Save and landed on Shiro's, whose boxes read - // empty even though the Lineups page holds a round default. - const shiroSave = T.panelSide(page, 0).getByRole('button', { name: 'Save lineup' }); - await shiroSave.tap(); - await page.waitForTimeout(800); - const shiroLabel = (await T.panelSide(page, 0).innerText()).includes('Override for this match'); - await shot(page, 'f3-panel-shiro-empty-saved'); + await test.step('F3 EP2 V1: the thumb lands on SHIRO\'s Save lineup, which has no change to save', async () => { + // The thumb meant Aka's Save and landed on Shiro's. First put Shiro's side + // back as it was loaded: the neighbour tap above may have cleared a + // position (the x beside a name box), which the operator mends by picking the + // fighter again. The recorded `repairedPositions` says whether it did. + const startingLineup = ['Aoki', 'Baba', 'Chiba']; + const repairedPositions = []; + for (const [i, name] of startingLineup.entries()) { + const box = T.panelInput(page, 0, String(i + 1)); + if ((await box.inputValue()) === name) continue; + await T.pickFromNameBox(box, name); + await expect(box).toHaveValue(name); + repairedPositions.push(String(i + 1)); + } + // Shiro's boxes show the starting lineup the Lineups page holds and nothing + // on that side differs from it, so Save lineup is off: there is no tap to land. + const shiroSave = T.panelSaveButton(page, 0); + await expect(shiroSave).toBeDisabled(); + const shiroSource = (await T.panelSource(page, 0).innerText()).trim(); + // No lineup of Shiro's own was written for this match. + const shiroOverride = shiroSource === 'Lineup for this match'; + expect(shiroOverride).toBe(false); + await shot(page, 'f3-panel-shiro-save-off'); await T.closePanel(page); - // What the score sheet now shows for Shiro's first position. + // What the score sheet shows for Shiro's first position. await upNextCard(page).getByRole('button', { name: 'Start match' }).tap(); const ed = inlineEditor(page); await expect(T.boutRow(ed, 1)).toBeVisible(); + // The sheet names the starting lineup's fighter, as the panel did (the + // name box fills once the lineup in force has been read). + await expect(T.rowNameBox(T.boutRow(ed, 1), 'shiro')).toHaveValue('Aoki'); + await expect(T.rowNameBox(T.boutRow(ed, 1), 'aka')).toHaveValue('Dai'); const r1 = await T.rowNameBox(T.boutRow(ed, 1), 'shiro').inputValue(); const r1aka = await T.rowNameBox(T.boutRow(ed, 1), 'aka').inputValue(); - await shot(page, 'f3-sheet-after-shiro-empty-save'); - record({ step: 'EP2 wrong Save', action: 'Save lineup (Shiro, boxes shown empty)', variant: 'V1 wrong side', shiroOverride: shiroLabel, - sheetRow1Shiro: r1, sheetRow1Aka: r1aka, roundDefaultRow1: 'Aoki' }); + await shot(page, 'f3-sheet-after-shiro-save-off'); + record({ step: 'EP2 wrong Save', action: 'Save lineup (Shiro, nothing changed)', variant: 'V1 wrong side', shiroSaveDisabled: true, shiroSource, + shiroOverride, repairedPositions, sheetRow1Shiro: r1, sheetRow1Aka: r1aka, startingLineupRow1: 'Aoki' }); }); // ---- Entry point 3: the score sheet row @@ -257,7 +286,8 @@ test.describe('knockout-mixed-team', () => { const row1 = T.boutRow(ed, 1); const box = T.rowNameBox(row1, 'shiro'); const boxTap = await T.tapBox(row1.locator('.team-sub-match__side--shiro .lineup-name__bar')); - // Whatever row 1 shows now, name it Aoki first (the intended fighter). + // Row 1 already names Aoki (the starting lineup's fighter); open its list + // as the operator would to confirm or change it. await box.tap(); await row1.locator('.pmf__option').first().waitFor({ state: 'visible', timeout: 3000 }).catch(() => {}); const offered = await row1.locator('.pmf__option').allInnerTexts(); @@ -286,11 +316,9 @@ test.describe('knockout-mixed-team', () => { record({ step: 'EP3 wrong fighter', action: 'pick row 1 Shiro', variant: 'V1 option below', offered, picked: wrongText, boxAfterWrong: afterWrong, labelAfterWrong, repaired: await box.inputValue(), tapsToRecover: 2, boxTapBox: boxTap }); - // Re-enter rows 2 and 3 (the empty Shiro override left them blank): - // the operator's cost of the wrong-side Save above. - await T.pickFromNameBox(T.rowNameBox(T.boutRow(ed, 2), 'shiro'), 'Baba'); + // Rows 2 and 3 still name the starting lineup's fighters: the Save the + // thumb landed on above had nothing to write, so it cost the operator nothing. await expect(T.rowNameBox(T.boutRow(ed, 2), 'shiro')).toHaveValue('Baba'); - await T.pickFromNameBox(T.rowNameBox(T.boutRow(ed, 3), 'shiro'), 'Chiba'); await expect(T.rowNameBox(T.boutRow(ed, 3), 'shiro')).toHaveValue('Chiba'); // One member at two rows: type Baba (row 2) into row 1. await T.typeIntoNameBox(box, 'Baba'); @@ -314,6 +342,24 @@ test.describe('knockout-mixed-team', () => { record({ step: 'three entry points', action: 'compare Shiro positions', variant: 'consistency', sheet, lineupsPage }); }); + await test.step('F3 the Lineups page edits one match\'s own lineup through "Lineup for"', async () => { + // Aka's lineup was saved for its first match at the court (entry point 2): + // the page shows it, as that match's own, when "Lineup for" names the match. + await T.openLineups(page, f3, { team: pair3.aka }); + const options = await T.lineupForOptions(page); + expect(options[0]).toBe(T.STARTING_LINEUP); + expect(options.length).toBeGreaterThan(1); + await T.openLineups(page, f3, { team: pair3.aka, lineupFor: options[1] }); + const names = [await T.lineupsSelected(page, '1'), await T.lineupsSelected(page, '2'), await T.lineupsSelected(page, '3')]; + await expect(T.lineupsForm(page).getByTestId('lineup-source')).toHaveText('Lineup for this match'); + await expect(T.lineupsForm(page).getByRole('button', { name: 'Save lineup' })).toBeDisabled(); + await shot(page, 'f3-lineups-for-a-match', { fullPage: true }); + record({ step: 'Lineup for', action: 'choose the first match in "Lineup for"', variant: 'correct', options, names }); + expect(names[0]).toContain('Dai'); + expect(names[1]).toContain('Eto'); + expect(names[2]).toContain('Fuji'); + }); + await test.step('F3 V4: a sixth member through "+ Add new member…" while two reserve slots are blank', async () => { // Position 3 of the Aka team holds Fuji (slot .3); its reserves .4 and // .5 have no name yet. The operator adds a substitute at position 3. @@ -439,7 +485,7 @@ test.describe('knockout-mixed-team', () => { // teamSize 5 shows the FIK position names. for (const l of T.FIK5) await expect(T.lineupsSelect(page, l)).toBeVisible(); await shot(page, 'f4-lineups-fik-names', { fullPage: true }); - await nameRound1(page, f4, pair4.shiro, ['Ito', 'Kato', 'Sato', 'Mori', 'Ueda'], 5); + await nameStartingLineup(page, f4, pair4.shiro, ['Ito', 'Kato', 'Sato', 'Mori', 'Ueda'], 5); const dup = await T.lineupsStartAdd(page, 'Taisho', 'Ito'); expect(dup.alert).toMatch(/Ito is already at Senpo/); await shot(page, 'f4-lineups-duplicate-refused', { fullPage: true }); @@ -447,7 +493,7 @@ test.describe('knockout-mixed-team', () => { await T.lineupsForm(page).getByRole('button', { name: 'Cancel' }).tap(); }); - await test.step('F4 EP2 panel on a first-round (semifinal) match: FIK names and the round default', async () => { + await test.step('F4 EP2 panel on a first-round (semifinal) match: FIK names and the starting lineup', async () => { await openShiaijo(page, 'C'); await T.openPanelFromUpNext(page); for (const l of T.FIK5) await expect(T.panelInput(page, 0, l)).toBeVisible(); @@ -1009,7 +1055,11 @@ test.describe('knockout-mixed-team', () => { // kept as a self-contained test so the fix can remove the fixme and keep // the step as its guard. Each seeds its own competition on its own shiaijo. - test.fixme('bc-lpfb: the match lineup panel shows a pool match\'s round-default lineup as empty', async ({ page }) => { + // The panel used to read "Inheriting round default" over empty boxes while the + // score sheet named that default's fighters. A team now carries the lineup of + // its previous team match (its starting lineup before its first), and the + // panel shows the lineup in force with the line saying where it comes from. + test('bc-lpfb: the match lineup panel shows the lineup the score sheet shows, and Save lineup waits for a change', async ({ page }) => { await T.enterAdmin(page); const id = await createCompetition(page, { name: 'B1 Pools', kind: 'team', format: 'mixed', teamSize: 3, teamMatchType: 'fixed', courts: ['G', 'H'], numberPrefix: 'G', @@ -1019,14 +1069,19 @@ test.describe('knockout-mixed-team', () => { await startCompetition(page, id); await openShiaijo(page, 'G'); const pair = await sides(upNextCard(page)); - await nameRound1(page, id, pair.shiro, ['Aoki', 'Baba', 'Chiba'], 3); + const names = ['Aoki', 'Baba', 'Chiba']; + await nameStartingLineup(page, id, pair.shiro, names, 3); await openShiaijo(page, 'G'); await T.openPanelFromUpNext(page); - // The panel says "Inheriting round default" and must show that default, - // as the score sheet does. - await expect(T.panelSide(page, 0)).toContainText('Inheriting round default'); - await expect(T.panelInput(page, 0, '1')).toHaveValue('Aoki'); - await expect(T.panelInput(page, 0, '3')).toHaveValue('Chiba'); + // The panel shows the starting lineup as the lineup in force, says so, and + // with nothing changed on Shiro's side offers no Save lineup. + await expect(T.panelSource(page, 0)).toHaveText(T.STARTING_LINEUP); + for (const [i, name] of names.entries()) await expect(T.panelInput(page, 0, String(i + 1))).toHaveValue(name); + await expect(T.panelSaveButton(page, 0)).toBeDisabled(); + await T.closePanel(page); + // The score sheet, once the match starts, names the same fighters. + const ed = await T.startUpNextTeam(page); + for (const [i, name] of names.entries()) await expect(T.rowNameBox(T.boutRow(ed, i + 1), 'shiro')).toHaveValue(name); }); test.fixme('bc-lprf: the match lineup panel\'s "already at" refusal names the wrong position', async ({ page }) => { @@ -1037,7 +1092,7 @@ test.describe('knockout-mixed-team', () => { await T.typeIntoNameBox(T.panelInput(page, 1, 'Senpo'), 'Dai'); await T.typeIntoNameBox(T.panelInput(page, 1, 'Jiho'), 'Eto'); await T.panelSave(page, 1); - await expect(T.panelSide(page, 1)).toContainText('Override for this match'); + await expect(T.panelSource(page, 1)).toHaveText('Lineup for this match'); // Dai holds Senpo; typing Dai at Jiho is refused naming where Dai IS // (team-tournaments.md: "refused with the position they hold"). await T.typeIntoNameBox(T.panelInput(page, 1, 'Jiho'), 'Dai'); From 83984183c911927b376ad3fedb444b90631aa05f Mon Sep 17 00:00:00 2001 From: Ricardo Oliveira Date: Wed, 7 Oct 2026 15:46:53 +0100 Subject: [PATCH 10/30] test(mp-yqxn.2): switch on the fixmes main's fixes closed; drop bc-kpnl's Main now carries #447, #450 and #453. Their beads' fixmes were run unskipped on the merged build: - Plain tests now, passing as written: bc-kfup, bc-htcr, bc-plcl (individual), bc-fsnp, bc-dhas (team), bc-emsl, bc-kheb, bc-sync (kachinuki). - bc-offl: the court header reads "Offline: 1 result not sent" while the Finish is queued (captured offline); the test looked for "Not saved yet". - bc-sbq: #447 made Send back to queue keep the score, and the confirm now says "Any score entered is kept". The test asserts that, then starts the bout again and finds its point still on the board. - bc-kpnl's two tests are removed: the panel they wait for was taken out by the 2026-09-26 ruling (recording a withdrawal changes only that match). Follow-on edits: - The two 800 ms waits that kept bc-dhas from erasing the daihyosen row are gone (J7 and bc-dhrp); bc-dhas's own test proves the fix. - bc-fsnp waits for the sync pill to read Synced instead of 1.5 s: since bc-sync the pill reads Syncing from the first tap. - J5 final plays on from the first unfought bout: bc-kheb, which runs before it in this serial file, records the final's bout 1. Five fixmes remain, all on open beads: bc-otpl, bc-kosc, bc-crpn, bc-lprf, bc-dhrp. make e2e: 28 passed, 5 skipped, EXIT 0. Co-Authored-By: Claude Opus 5.5 --- .../knockout-mixed-individual.spec.mjs | 33 ++++++---------- .../knockout-mixed-kachinuki.spec.mjs | 9 +++-- .../e2e/journeys/knockout-mixed-team.spec.mjs | 39 ++----------------- 3 files changed, 21 insertions(+), 60 deletions(-) diff --git a/scripts/e2e/journeys/knockout-mixed-individual.spec.mjs b/scripts/e2e/journeys/knockout-mixed-individual.spec.mjs index 38c4102bb..9442c2a2b 100644 --- a/scripts/e2e/journeys/knockout-mixed-individual.spec.mjs +++ b/scripts/e2e/journeys/knockout-mixed-individual.spec.mjs @@ -952,20 +952,7 @@ test.describe('knockout-mixed-individual', () => { return id; }; - test.fixme('bc-kpnl: after a kiken on the shiaijo console the remaining-matches panel stays until the operator closes it', async ({ page }) => { - await login(page); - await seedOwn(page, { name: 'Finding A1', format: 'mixed', poolSize: 3, poolWinners: 2, courts: ['G'], numberPrefix: 'FA' }, rosterOf('Aone', 6)); - await openShiaijo(page, 'G'); - await ensureRunning(page); - await recordDecision(page, 'kiken-voluntary', { side: 'aka' }); - const panel = inlineEditor(page).locator('.remaining-matches'); - await expect(panel).toBeVisible(); - await page.waitForTimeout(3000); - await expect(panel).toBeVisible(); - await expect(panel.getByRole('button', { name: 'Award default win to opponent' })).toHaveCount(1); - }); - - test.fixme('bc-kfup: a withdrawn competitor\'s remaining pool bout can be recorded as their default loss', async ({ page }) => { + test('bc-kfup: a withdrawn competitor\'s remaining pool bout can be recorded as their default loss', async ({ page }) => { await login(page); const id = await seedOwn(page, { name: 'Finding A2', format: 'mixed', poolSize: 3, poolWinners: 2, courts: ['H'], numberPrefix: 'FB' }, rosterOf('Atwo', 6)); await openShiaijo(page, 'H'); @@ -993,7 +980,7 @@ test.describe('knockout-mixed-individual', () => { await expect(inlineEditor(page).getByTestId('scoring-modal-encho-checkbox')).toBeVisible(); }); - test.fixme('bc-htcr: a hantei verdict can be corrected to the other side from the Correct editor', async ({ page }) => { + test('bc-htcr: a hantei verdict can be corrected to the other side from the Correct editor', async ({ page }) => { await login(page); await seedOwn(page, { name: 'Finding A4', format: 'knockout', courts: ['J'], numberPrefix: 'FD' }, rosterOf('Afour', 4)); await openShiaijo(page, 'J'); @@ -1020,7 +1007,7 @@ test.describe('knockout-mixed-individual', () => { await expect.poll(async () => (await done.locator('.shiaijo-qrow__result').innerText()).trim(), { timeout: 10_000 }).not.toBe(before); }); - test.fixme('bc-plcl: the Pools-tab score editor stays open through Start match and an ippon', async ({ page }) => { + test('bc-plcl: the Pools-tab score editor stays open through Start match and an ippon', async ({ page }) => { await login(page); const id = await seedOwn(page, { name: 'Finding A5', format: 'mixed', poolSize: 3, poolWinners: 2, courts: ['K'], numberPrefix: 'FE' }, rosterOf('Afive', 6)); await page.goto(`/admin/competition/${id}/pools`); @@ -1035,7 +1022,7 @@ test.describe('knockout-mixed-individual', () => { await page.waitForTimeout(1500); await expect(page.locator(EDITOR)).toBeVisible(); }); - test.fixme('bc-sbq: Send back to queue warns that the score will be discarded whenever a mark is on the board', async ({ page }) => { + test('bc-sbq: Send back to queue says the score is kept, and starting the bout again carries it on', async ({ page }) => { await login(page); await seedOwn(page, { name: 'Finding A6', format: 'mixed', poolSize: 3, poolWinners: 2, courts: ['L'], numberPrefix: 'FF' }, rosterOf('Asix', 6)); await openShiaijo(page, 'L'); @@ -1053,9 +1040,13 @@ test.describe('knockout-mixed-individual', () => { await page.getByRole('button', { name: 'Send back to queue' }).tap(); const dialog = page.locator('.shiaijo-move-confirm[role="dialog"]'); await expect(dialog).toBeVisible(); - await expect(dialog).toContainText('will be discarded'); - await expect(dialog).not.toContainText('nothing will be lost'); + await expect(dialog).toContainText('Any score entered is kept'); await page.unroute('**/api/**', slowReads); + await hastyShiaijoConfirm(page); + // The dialog's promise holds: started again, the bout still has its point. + const again = await ensureRunning(page); + expect(again).toEqual(pair); + await expect.poll(() => slotMarks(page, winnerOf(pair))).toEqual(['M']); }); test.fixme('bc-kosc: in pools + knockout, no knockout bout is scheduled before the last pool bout', async ({ page }) => { await login(page); @@ -1072,7 +1063,7 @@ test.describe('knockout-mixed-individual', () => { expect(koTimes.length).toBeGreaterThan(0); expect(koTimes[0] > poolTimes[poolTimes.length - 1]).toBe(true); }); - test.fixme('bc-offl: finishing a bout offline on the court console says the result is not saved yet', async ({ page, context }) => { + test('bc-offl: finishing a bout offline on the court console says the result is not saved yet', async ({ page, context }) => { await login(page); await seedOwn(page, { name: 'Finding A8', format: 'knockout', courts: ['N'], numberPrefix: 'FH' }, rosterOf('Aeight', 4)); await openShiaijo(page, 'N'); @@ -1083,7 +1074,7 @@ test.describe('knockout-mixed-individual', () => { try { await (await armFinish(page)).tap(); await page.waitForTimeout(1000); - await expect(page.getByText(/Not saved yet/)).toBeVisible(); + await expect(page.getByText('Offline: 1 result not sent')).toBeVisible(); } finally { await context.setOffline(false); } diff --git a/scripts/e2e/journeys/knockout-mixed-kachinuki.spec.mjs b/scripts/e2e/journeys/knockout-mixed-kachinuki.spec.mjs index d9fd1c921..5ebad953a 100644 --- a/scripts/e2e/journeys/knockout-mixed-kachinuki.spec.mjs +++ b/scripts/e2e/journeys/knockout-mixed-kachinuki.spec.mjs @@ -1104,7 +1104,7 @@ test.describe('J5 kachinuki from the court console', () => { // bc-emsl: a tap on an EMPTY mark slot (the "·" beside the scored // marks) is not a no-op: it clears the bout's Tie. - test.fixme('bc-emsl: tapping an empty mark slot clears the bout\'s Tie', async ({ page }) => { + test('bc-emsl: tapping an empty mark slot clears the bout\'s Tie', async ({ page }) => { await login(page); await openShiaijo(page, 'A'); const row = currentBout(page); @@ -1122,7 +1122,7 @@ test.describe('J5 kachinuki from the court console', () => { // bc-kheb: Encho on ONE kachinuki bout sets the encounter's overtime // count, so the header keeps reading "(E) OVERTIME" on every later bout. - test.fixme('bc-kheb: after one bout goes to encho, the header claims overtime on every later bout', async ({ page }) => { + test('bc-kheb: after one bout goes to encho, the header claims overtime on every later bout', async ({ page }) => { await login(page); await openShiaijo(page, 'A'); const row = currentBout(page); @@ -1138,7 +1138,7 @@ test.describe('J5 kachinuki from the court console', () => { // bc-sync: for the autosave's debounce window after a tap, the sync pill // already reads "Synced" although nothing has been sent; a reload in that // window loses the point with no warning (audit row "final bout 4"). - test.fixme('bc-sync: the sync pill reads "Synced" while a tapped point is still unsaved', async ({ page }) => { + test('bc-sync: the sync pill reads "Synced" while a tapped point is still unsaved', async ({ page }) => { await login(page); await openShiaijo(page, 'A'); const live = currentBout(page); @@ -1158,7 +1158,8 @@ test.describe('J5 kachinuki from the court console', () => { const S = MEMBERS[finalPair.shiro]; // Shiro keeps winning with whoever is on until End match; the operator // ends it when Aka is out of fighters. The Aka fighter changes each bout. - for (let i = 0; i < 5; i += 1) { + // Play on from the first unfought bout: bc-kheb above records bout 1. + for (let i = await doneRows(page).count(); i < 5; i += 1) { const live = currentBout(page); if (i === 3) { // V3: the tab reloads a moment after a point is tapped. What does the diff --git a/scripts/e2e/journeys/knockout-mixed-team.spec.mjs b/scripts/e2e/journeys/knockout-mixed-team.spec.mjs index 09b448ac1..f4ad2eeb9 100644 --- a/scripts/e2e/journeys/knockout-mixed-team.spec.mjs +++ b/scripts/e2e/journeys/knockout-mixed-team.spec.mjs @@ -957,9 +957,6 @@ test.describe('knockout-mixed-team', () => { await test.step('level the encounter and add the daihyosen at the court', async () => { await T.boutIppon(ed, 2, 'aka', 'M'); for (const n of [3, 4, 5]) await T.ensureTie(ed, n); - // Let the last Tie's autosave land first: added inside its 300ms - // debounce, the representative bout is erased by it (bc-dhas). - await page.waitForTimeout(800); await ed.getByTestId('scoring-modal-daihyosen-button').tap(); await expect(T.boutRow(ed, 'DH')).toBeVisible(); await expect(tvBoard(tv)).toContainText('(DH)'); @@ -1125,15 +1122,14 @@ test.describe('knockout-mixed-team', () => { } }); - test.fixme('bc-fsnp: a fusensho wipes the points the other side already scored, so its undo cannot bring them back after a reload', async ({ page }) => { + test('bc-fsnp: a fusensho wipes the points the other side already scored, so its undo cannot bring them back after a reload', async ({ page }) => { await T.enterAdmin(page); await seedF4(page, { name: 'B4 KO', court: 'K', prefix: 'L', teams: [['B4 Ume', 'Kita Dojo'], ['B4 Sakura', 'Minami Dojo']] }); await openShiaijo(page, 'K'); const ed = await T.startUpNextTeam(page); await T.boutIppon(ed, 1, 'shiro', 'K'); - // The kote is saved before the fusensho (the pill cannot say so: it - // reads Synced through the autosave's debounce). - await page.waitForTimeout(1500); + // The kote is saved before the fusensho. + await expect(T.syncPill(ed)).toHaveText('Synced'); // Shiro's fighter cannot continue; the bout goes to Aka by default. await T.rowFusensho(T.boutRow(ed, 1), 'aka').tap(); await expect(T.rowSlots(T.boutRow(ed, 1), 'aka')).toHaveText(['○', '○']); @@ -1181,8 +1177,6 @@ test.describe('knockout-mixed-team', () => { await openShiaijo(page, 'F'); const ed = await T.startUpNextTeam(page); for (const n of [1, 2, 3, 4, 5]) await T.ensureTie(ed, n); - // Past the last Tie's autosave, so bc-dhas does not erase the row. - await page.waitForTimeout(800); await ed.getByTestId('scoring-modal-daihyosen-button').tap(); const dh = T.boutRow(ed, 'DH'); await expect(dh).toBeVisible(); @@ -1192,7 +1186,7 @@ test.describe('knockout-mixed-team', () => { await expect(T.rowNameBox(dh, 'aka')).toBeVisible(); }); - test.fixme('bc-dhas: a representative bout added right after the last Tie is erased by that Tie\'s pending autosave', async ({ page }) => { + test('bc-dhas: a representative bout added right after the last Tie is erased by that Tie\'s pending autosave', async ({ page }) => { await T.enterAdmin(page); await seedF4(page, { name: 'B9 KO', court: 'L', prefix: 'T', teams: [['B9 Ume', 'Kita Dojo'], ['B9 Sakura', 'Minami Dojo']] }); await openShiaijo(page, 'L'); @@ -1206,29 +1200,4 @@ test.describe('knockout-mixed-team', () => { await page.reload(); await expect(T.boutRow(ed, 'DH')).toBeVisible(); }); - test.fixme('bc-kpnl: on the court console the withdrawn team\'s remaining-matches panel vanishes before a default win can be awarded', async ({ page }) => { - await T.enterAdmin(page); - const id = await createCompetition(page, { - name: 'B8 Pools', kind: 'team', format: 'mixed', teamSize: 3, teamMatchType: 'fixed', courts: ['O', 'P'], numberPrefix: 'V', - }); - await pasteRoster(page, id, SIX_TEAMS); - await generateDraw(page, id); - await startCompetition(page, id); - await openShiaijo(page, 'O'); - const ed = await T.startUpNextTeam(page); - const summary = ed.locator('.decision-disclosure__summary'); - await summary.scrollIntoViewIfNeeded(); - await summary.tap(); - await ed.getByTestId('scoring-modal-kiken-voluntary-button').tap(); - const prompt = ed.locator('form.decision-prompt'); - await prompt.locator('input[value="shiro"]').check(); - await prompt.locator('button.btn--primary').tap(); - // recording-decisions.md / the kiken default-win chain: the withdrawn - // team's remaining pool matches are offered for a default win, and the - // offer stays until the operator acts on it. - const award = page.getByRole('button', { name: 'Award default win to opponent' }); - await expect(award.first()).toBeVisible(); - await page.waitForTimeout(2000); - await expect(award.first()).toBeVisible(); - }); }); From 0427335d3e2492c67c5cfb13caaf1afe98306058 Mon Sep 17 00:00:00 2001 From: Ricardo Oliveira Date: Wed, 7 Oct 2026 15:57:04 +0100 Subject: [PATCH 11/30] test(mp-yqxn.2): bc-lprf's panel refusal is a plain test With the fixme removed the test passes on main: since 51e32fb9 (PR #458, "Lineup refusals name a position one way everywhere") the at-court lineup panel refuses a typed duplicate with "Dai is already at Senpo.", composed by lineup_resolver.jsx for every surface. The bead itself is still open. The four fixmes left fail at their own defect assertions with the fixme removed: bc-otpl (no encho checkbox), bc-kosc (a knockout time before the last pool time), bc-dhrp (no name box on the daihyosen row), bc-crpn (no Record bout after Start match, inside the serial flow). make e2e journeys/knockout-mixed-team: 9 passed, 1 skipped, EXIT 0. Co-Authored-By: Claude Opus 5.5 --- scripts/e2e/journeys/knockout-mixed-team.spec.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/e2e/journeys/knockout-mixed-team.spec.mjs b/scripts/e2e/journeys/knockout-mixed-team.spec.mjs index f4ad2eeb9..b4b087d70 100644 --- a/scripts/e2e/journeys/knockout-mixed-team.spec.mjs +++ b/scripts/e2e/journeys/knockout-mixed-team.spec.mjs @@ -1081,7 +1081,7 @@ test.describe('knockout-mixed-team', () => { for (const [i, name] of names.entries()) await expect(T.rowNameBox(T.boutRow(ed, i + 1), 'shiro')).toHaveValue(name); }); - test.fixme('bc-lprf: the match lineup panel\'s "already at" refusal names the wrong position', async ({ page }) => { + test('bc-lprf: the match lineup panel\'s "already at" refusal names the wrong position', async ({ page }) => { await T.enterAdmin(page); await seedF4(page, { name: 'B2 KO', court: 'I', prefix: 'I', teams: FOUR_TEAMS('B2') }); await openShiaijo(page, 'I'); From beecb6d7abe8d15681e622781d2bca6edc97c42e Mon Sep 17 00:00:00 2001 From: Ricardo Oliveira Date: Thu, 8 Oct 2026 08:42:10 +0100 Subject: [PATCH 12/30] test(mp-yqxn.2): follow PR #462 in the journeys; bc-otpl, bc-kosc, bc-crpn are plain tests Main merged #462 (bc-dsid, bc-otpl, bc-htsd, bc-dlck, bc-kosc, bc-crpn). The journeys now walk what an operator meets on that build. - bc-crpn: Correct is refused at the court console while a bout is live. J1, J2, J4 and J5 record the refusal (the row notice and the toast), then take the route the notice names: send the live bout back to the queue, correct, start it again. J5's court-busy panel is reached from the Scores tab, where a reopen can still meet a busy court, with its V1 and V4 variants kept. The bc-crpn test asserts the new refusal: Start match while a correction is open names the correction and starts nothing. - bc-dsid: the withdrawal and no-show prompt picks no side and Record is off until one is picked. The hasty variants record that, then the operator picks; fixtures/individual.mjs recordDecision now takes the side. - bc-htsd: the individual hantei buttons pick the side and Finish records it. - bc-dlck: the re-recorded kiken asserts no "Proceed anyway?" confirm shows. - bc-otpl and bc-kosc pass as written and are plain tests. - J5's M1 points won read 7-3 and then 6-3: bout 5's double tap scores one M since bc-dtip (#450). The old 8-3 and 7-3 counted the bounce's second M. - A banned phrase about the outcome of a withdrawal is gone from three audit labels, a J3 step title and the bc-kfup test title. One fixme left, on an open bead: bc-dhrp, which still fails at its own assertion. make e2e: 32 passed, 1 skipped, EXIT 0 (kachinuki re-run after the points fix: 11 passed). make go/test: EXIT 0. Co-Authored-By: Claude Opus 5.5 --- scripts/e2e/fixtures/individual.mjs | 52 ++++- .../knockout-mixed-individual.spec.mjs | 178 ++++++++++++------ .../knockout-mixed-kachinuki.spec.mjs | 151 ++++++++++----- .../e2e/journeys/knockout-mixed-team.spec.mjs | 95 +++++++--- 4 files changed, 337 insertions(+), 139 deletions(-) diff --git a/scripts/e2e/fixtures/individual.mjs b/scripts/e2e/fixtures/individual.mjs index 6799cd8b7..e4b136845 100644 --- a/scripts/e2e/fixtures/individual.mjs +++ b/scripts/e2e/fixtures/individual.mjs @@ -148,17 +148,28 @@ export const decisionButton = (page, kind, root = INLINE_EDITOR) => page.locator .getByTestId({ 'kiken-voluntary': 'scoring-modal-kiken-voluntary-button', 'kiken-injury': 'scoring-modal-kiken-injury-button', fusenpai: 'scoring-modal-fusenpai-button' }[kind]); export const decisionPrompt = (page, root = INLINE_EDITOR) => page.locator(root).first().locator('form.decision-prompt'); -// Open a decision, pick the side (or leave the default, the hasty operator's -// way when `side` is omitted), optionally type a reason, and Record. +// Open a decision, pick the side, optionally type a reason, and Record. The +// prompt picks no side for the operator and Record stays off until one is +// picked (bc-dsid), so `side` is required. Returns what the prompt showed: the +// side preselected (none), whether Record was off before the pick, and the +// label it read once the side was picked ("Record: AKA withdrew"). export async function recordDecision(page, kind, { side, reason, root = INLINE_EDITOR } = {}) { + if (!side) throw new Error('recordDecision: pick a side; the prompt preselects none (bc-dsid)'); await decisionButton(page, kind, root).tap(); const form = decisionPrompt(page, root); await expect(form).toBeVisible(); - const defaultSide = await form.locator('input[name="decision-side"]:checked').getAttribute('value'); - if (side) await form.locator(`input[name="decision-side"][value="${side}"]`).check(); + const checked = form.locator('input[name="decision-side"]:checked'); + const preselected = (await checked.count()) ? await checked.getAttribute('value') : null; + const record = form.locator('button[type="submit"]'); + const recordDisabledBeforePick = await record.isDisabled(); + // The radio covers its pill, so the tap lands where the thumb does. + const pick = form.locator(`input[name="decision-side"][value="${side}"]`); + await pick.tap(); + await expect(pick).toBeChecked(); if (reason) await form.getByTestId('decision-reason').fill(reason); - await form.getByRole('button', { name: 'Record' }).tap(); - return { defaultSide, chosen: side || defaultSide }; + const recordLabel = (await record.innerText()).trim(); + await record.tap(); + return { preselected, recordDisabledBeforePick, recordLabel, chosen: side }; } // Is the inline editor holding a RUNNING bout (no PRE-MATCH / CORRECTION pill)? @@ -216,6 +227,35 @@ export async function playRunningBout(page, { waza = 'M' } = {}) { return { ...pair, winner: side }; } +// Open the correction of a completed bout on the shiaijo page, the way the +// operator now must. The console never holds a live bout and an open +// correction at once (bc-crpn): while a bout runs, Correct is refused with a +// notice at the row and a toast, and the notice says what to do: send the live +// bout back to the queue (its score is kept), then correct. Returns the bout +// that was live (null when the court was idle) and what the refusal said. +export async function correctCompleted(page, pair) { + const done = completedRowFor(page, pair); + const live = (await inlineRunning(page)) ? await editorSides(page) : null; + await done.getByRole('button', { name: 'Correct' }).tap(); + let refusal = null; + if (live) { + const notice = done.locator('.shiaijo-upnext__error[role="alert"]'); + await expect(notice).toBeVisible(); + const toast = (await page.locator('.toast').allInnerTexts().catch(() => [])).join(' | '); + refusal = { + notice: (await notice.innerText()).trim(), toast, + editorStillOnLiveBout: (await inlineRunning(page)) && JSON.stringify(await editorSides(page)) === JSON.stringify(live), + }; + expect(refusal.notice).toMatch(/^Shiaijo \S+ is running .+\. Finish it or send it back to the queue first\. Then correct this match\.$/); + await page.getByRole('button', { name: 'Send back to queue' }).tap(); + refusal.sendBack = await hastyShiaijoConfirm(page); + await expect.poll(() => inlineRunning(page), { timeout: 10_000 }).toBe(false); + await done.getByRole('button', { name: 'Correct' }).tap(); + } + await expect(page.locator(INLINE_EDITOR).first().locator('.editor-head-pill')).toHaveText('CORRECTION'); + return { live, refusal }; +} + // How many POOL bouts still wait in this court's queue (the Up next card and // the Upcoming rows; not Later, not Completed). A pool bout reads // "Match n of m" in its header, a knockout bout "Match n". diff --git a/scripts/e2e/journeys/knockout-mixed-individual.spec.mjs b/scripts/e2e/journeys/knockout-mixed-individual.spec.mjs index 9442c2a2b..cc7bfb038 100644 --- a/scripts/e2e/journeys/knockout-mixed-individual.spec.mjs +++ b/scripts/e2e/journeys/knockout-mixed-individual.spec.mjs @@ -31,7 +31,7 @@ import { doubleTap, hastyConfirm, interrupt, retapWhileSaving, tapNeighbour } fr import { ROSTER_12, ROSTER_6, ROSTER_4_BRONZE, ROSTER_4_JOINT, ROSTER_6B, rosterOf, auditLog, tapSize, inlineIdentity, overlayIdentity, editorSides, slotMarks, winnerOf, inlineRunning, ensureRunning, playRunningBout, completedRowFor, - hastyShiaijoConfirm, decisionButton, decisionPrompt, recordDecision, queuedPoolBouts, + hastyShiaijoConfirm, decisionButton, decisionPrompt, recordDecision, queuedPoolBouts, correctCompleted, } from '../fixtures/individual.mjs'; test.describe.configure({ mode: 'serial' }); @@ -249,12 +249,19 @@ test.describe('knockout-mixed-individual', () => { expect((await inlineIdentity(page)).eyebrow).toMatch(/POOL/); await shot(page, 'court-A-last-pool-bout-running'); - // Correct a completed bout in place while the last pool bout runs. + // Correct a completed bout while the last pool bout runs. The console + // refuses it (bc-crpn): the notice at the row says to finish the live bout + // or send it back first. The operator sends it back (its score is kept), + // corrects, and starts it again. const firstDone = completedRowFor(page, running1); const correctBtn = firstDone.getByRole('button', { name: 'Correct' }); row({ step: 'correct', action: 'Correct (completed row)', variant: 'size', ...(await tapSize(correctBtn)) }); - await correctBtn.tap(); - await expect(inlineEditor(page).locator('.editor-head-pill')).toHaveText('CORRECTION'); + const lastPoolLive = await editorSides(page); + const lastPoolMarks = await slotMarks(page, winnerOf(lastPoolLive)); + const { live: refusedFor, refusal } = await correctCompleted(page, running1); + row({ step: 'correct', action: 'Correct while the last pool bout runs', variant: 'correct path (refused, bc-crpn)', + liveBout: refusedFor, ...refusal, shot: await shot(page, 'correct-refused-then-sent-back') }); + expect(refusedFor).toEqual(lastPoolLive); const corrId = await inlineIdentity(page); await shot(page, 'correcting-bout-1'); await ipponButton(page, w1, 'K').tap(); @@ -270,9 +277,13 @@ test.describe('knockout-mixed-individual', () => { editorCourt: corrId.court, stayedOnCorrection: stillCorrecting, result: (await completedRowFor(page, running1).locator('.shiaijo-qrow__result').innerText()).trim(), shot: await shot(page, 'correction-saved') }); if (stillCorrecting) await backToCourt.tap(); - await expect.poll(() => inlineRunning(page), { timeout: 15_000 }).toBe(true); - const lastPoolA = await editorSides(page); - await shot(page, 'back-to-live-bout'); + // Start the sent-back bout again: it is Up next, with the point it had. + const lastPoolA = await ensureRunning(page); + const marksAfterRestart = await slotMarks(page, winnerOf(lastPoolA)); + row({ step: 'correct', action: 'restart the bout sent back for the correction', variant: 'recovery', + restarted: lastPoolA, marksBefore: lastPoolMarks, marksAfterRestart, shot: await shot(page, 'back-to-live-bout') }); + expect(lastPoolA).toEqual(lastPoolLive); + expect(marksAfterRestart).toEqual(lastPoolMarks); // Court B plays out all its pool bouts, so court A's last bout is the one // that closes the pool phase. @@ -373,11 +384,12 @@ test.describe('knockout-mixed-individual', () => { }); // J2. Special decisions. On F1b (court E, pools): a kiken recorded against - // the WRONG side by a hasty operator (the prompt defaults to Shiro), the - // fusensho recorded from the withdrawn competitor's next match, then the undo - // through the 409 decision_locked confirm; + // the WRONG side by a hasty operator (the prompt picks no side, bc-dsid, so + // they take the first one offered), the fusensho recorded from the withdrawn + // competitor's next match, then the undo, which asks no confirm (bc-dlck); // a fusenpai that auto-advances. On F2 (court C, knockout): a tied 1-1 bout - // through encho (an unbounded counter) to hantei. + // through encho (an unbounded counter) to hantei, picked then Finished + // (bc-htsd). test('J2 special decisions: kiken chain and undo, fusenpai, encho, hantei', async ({ page }) => { test.setTimeout(240_000); const J = 'J2'; @@ -407,21 +419,47 @@ test.describe('knockout-mixed-individual', () => { await page.keyboard.press('Escape').catch(() => {}); // V4: the prompt is answered by its loud button without choosing a side. + // No side is picked for the operator and Record stays off until one is + // (bc-dsid), so the hasty tap records nothing. await decisionButton(page, 'kiken-voluntary').tap(); await expect(decisionPrompt(page)).toBeVisible(); await shot(page, 'kiken-prompt'); - const defaultSide = await decisionPrompt(page).locator('input[name="decision-side"]:checked').getAttribute('value'); + const preselected = await decisionPrompt(page).locator('input[name="decision-side"]:checked').count(); const radio = decisionPrompt(page).locator('input[name="decision-side"]').first(); - row({ step: 'kiken', action: 'side radio (Which side withdrew?)', variant: 'size', ...(await tapSize(radio)), + row({ step: 'kiken', action: 'side pill (Which side withdrew?)', variant: 'size', ...(await tapSize(radio)), label: await tapSize(decisionPrompt(page).locator('label').first()) }); - await decisionPrompt(page).getByRole('button', { name: 'Record' }).tap(); + const record = decisionPrompt(page).locator('button[type="submit"]'); + const recordOff = await record.isDisabled(); + const recordLabelBefore = (await record.innerText()).trim(); + const hint = (await decisionPrompt(page).getByTestId('decision-prompt-hint').innerText()).trim(); + await record.tap({ force: true }); + await page.waitForTimeout(500); + const stillOpen = await decisionPrompt(page).isVisible(); + const recordedByHastyTap = await completedRowFor(page, b1).isVisible().catch(() => false); + row({ step: 'kiken', action: 'Record with no side picked', variant: 'V4 hasty (inline prompt)', + sidePreselected: preselected > 0, recordDisabled: recordOff, label: recordLabelBefore, hint, promptStillOpen: stillOpen, + recorded: recordedByHastyTap, shot: await shot(page, 'kiken-record-without-side') }); + expect(preselected).toBe(0); + expect(recordOff).toBe(true); + expect(stillOpen).toBe(true); + expect(recordedByHastyTap).toBe(false); + // What the operator must do: pick a side. The hasty one takes the first + // pill offered (Shiro) without reading it, though the intended withdrawer + // is Aka; the Record button and the line above it now name the side. The + // wrong side is what the fusensho chain and the undo below then work from. + const pickedSide = 'shiro'; + await decisionPrompt(page).locator(`input[name="decision-side"][value="${pickedSide}"]`).tap(); + const recordLabel = (await record.innerText()).trim(); + const consequence = (await decisionPrompt(page).getByTestId('decision-prompt-consequence').innerText()).trim(); + await shot(page, 'kiken-first-side-picked'); + await record.tap(); // Recording a withdrawal changes only that match: no panel offers the - // competitor's other matches. The side the prompt preselected is the one - // recorded. - const withdrawnName = b1[defaultSide]; - row({ step: 'kiken', action: 'Record (prompt left on its default side)', variant: 'V4 hasty (inline prompt)', - intendedWithdrawer: `aka: ${b1.aka}`, defaultSide, recordedWithdrawer: withdrawnName, - wrongSide: defaultSide !== 'aka', shot: await shot(page, 'kiken-recorded-default-side') }); + // competitor's other matches. + const withdrawnName = b1[pickedSide]; + row({ step: 'kiken', action: 'pick the first side offered, then Record', variant: 'V4 hasty (inline prompt)', + intendedWithdrawer: `aka: ${b1.aka}`, pickedSide, recordLabel, consequence, recordedWithdrawer: withdrawnName, + wrongSide: pickedSide !== 'aka', shot: await shot(page, 'kiken-recorded-first-side') }); + expect(recordLabel).toBe('Record: SHIRO withdrew'); const kikenRow = completedRowFor(page, b1); await expect(kikenRow).toBeVisible(); const kikenResult = (await kikenRow.locator('.shiaijo-qrow__result').innerText()).trim(); @@ -478,30 +516,26 @@ test.describe('knockout-mixed-individual', () => { const moved = await ensureRunning(page); row({ step: 'kiken undo', action: 'court moves on: Up next started', variant: 'correct path', running: moved, involvesRealWithdrawer: !!moved && (moved.shiro === b1.aka || moved.aka === b1.aka) }); - await completedRowFor(page, b1).getByRole('button', { name: 'Correct' }).tap(); - await expect(inlineEditor(page).locator('.editor-head-pill')).toHaveText('CORRECTION'); + // Correct is refused while that bout runs (bc-crpn): send it back first. + const undoOpen = await correctCompleted(page, b1); + row({ step: 'kiken undo', action: 'Correct the kiken while the next bout runs', variant: 'correct path (refused, bc-crpn)', + liveBout: undoOpen.live, ...undoOpen.refusal }); await shot(page, 'kiken-correction-open'); - await recordDecision(page, 'kiken-voluntary', { side: 'aka', reason: 'wrong side recorded' }); - const lockedDialog = page.locator('.modal[role="dialog"]').filter({ has: page.locator('.modal__foot') }).last(); - const locked = await lockedDialog.waitFor({ state: 'visible', timeout: 8000 }).then(() => true, () => false); - let hasty = null; - if (locked) { - await shot(page, 'decision-locked-confirm'); - hasty = await hastyConfirm(page); - } - await page.waitForTimeout(1500); - const undoneResult = (await completedRowFor(page, b1).locator('.shiaijo-qrow__result').innerText()).trim(); - row({ step: 'kiken undo', action: 'Kiken - Voluntary on the other side (correction)', variant: 'V4 hastyConfirm (409 decision_locked)', - dialogShown: locked, ...(hasty || {}), result: undoneResult, - shot: await shot(page, 'kiken-undo-forced') }); - expect(locked).toBe(true); - // Docs: the kiken now sits beside Aka, the real withdrawer. - expect(undoneResult).toMatch(/○○ vs Kiken|○○.*Kiken$/); + const undo = await recordDecision(page, 'kiken-voluntary', { side: 'aka', reason: 'wrong side recorded' }); + // Docs: the kiken now sits beside Aka, the real withdrawer. No confirm + // asks first any more (bc-dlck): the correction changes only this match. + const undoneRow = completedRowFor(page, b1).locator('.shiaijo-qrow__result'); + await expect.poll(async () => (await undoneRow.innerText()).trim(), { timeout: 8000 }).toMatch(/○○ vs Kiken|○○.*Kiken$/); + const undoneResult = (await undoneRow.innerText()).trim(); + const confirmShown = await page.locator('.modal[role="dialog"]').filter({ has: page.locator('.modal__foot') }).isVisible().catch(() => false); + row({ step: 'kiken undo', action: 'Kiken - Voluntary on the other side (correction)', variant: 'correct path (no confirm, bc-dlck)', + ...undo, confirmShown, result: undoneResult, shot: await shot(page, 'kiken-undo-recorded') }); + expect(confirmShown).toBe(false); const backToCourt = page.getByRole('button', { name: /Back to court/ }); if (await backToCourt.isVisible().catch(() => false)) await backToCourt.tap(); await page.waitForTimeout(1000); const liveAfter = await inlineRunning(page) ? await editorSides(page) : null; - row({ step: 'kiken undo', action: 'back to the court after the forced undo', variant: 'V5 lost place', + row({ step: 'kiken undo', action: 'back to the court after the undo', variant: 'V5 lost place', running: liveAfter, runningHasNowIneligible: !!liveAfter && (liveAfter.shiro === b1.aka || liveAfter.aka === b1.aka), shot: await shot(page, 'after-undo-court-E') }); await page.goto(`/admin/competition/${comps.F1b}/pools`); @@ -597,12 +631,9 @@ test.describe('knockout-mixed-individual', () => { const enchoPopover = await page.getByText('Overtime, when a knockout match').isVisible().catch(() => false); row({ step: 'encho', action: 'tap the Overtime pill (centre)', variant: 'correct path', counterOpened: openedByCentre, glossaryPopoverOpened: enchoPopover, shot: await shot(page, 'overtime-pill-tapped') }); - if (!openedByCentre) { - await page.mouse.click(5, 5); - await inlineEditor(page).locator('.encho-pill__icon').tap(); - await expect(encho).toBeVisible(); - row({ step: 'encho', action: 'tap the Overtime pill icon instead', variant: 'recovery', taps: 2, counterOpened: true }); - } + // The word itself opens the counter (bc-otpl); the "?" beside it is the glossary. + expect(openedByCentre).toBe(true); + expect(enchoPopover).toBe(false); row({ step: 'encho', action: 'Encho started checkbox', variant: 'size', ...(await tapSize(encho)), label: await tapSize(inlineEditor(page).locator('.encho-row__label')) }); await encho.check(); @@ -627,14 +658,24 @@ test.describe('knockout-mixed-individual', () => { expect(minusDisabledAtFloor).toBe(true); await settle(page); - // Hantei: arm, then the thumb lands on the neighbour of SHIRO wins. + // Hantei: arm, then the thumb lands on the neighbour of SHIRO wins. A + // side button only picks (bc-htsd); Finish (two taps) records the pick. await inlineEditor(page).getByTestId('scoring-modal-hantei-arm').tap(); const shiroWins = inlineEditor(page).getByTestId('scoring-modal-hantei-shiro'); const akaWins = inlineEditor(page).getByTestId('scoring-modal-hantei-aka'); - row({ step: 'hantei', action: 'SHIRO wins / AKA wins', variant: 'size', shiro: await tapSize(shiroWins), aka: await tapSize(akaWins) }); + row({ step: 'hantei', action: 'SHIRO wins / AKA wins', variant: 'size', shiro: await tapSize(shiroWins), aka: await tapSize(akaWins), + labels: [(await shiroWins.innerText()).trim(), (await akaWins.innerText()).trim()] }); await shot(page, 'hantei-armed'); const nbHantei = await tapNeighbour(shiroWins, 'right'); + await page.waitForTimeout(1000); const semiRow = completedRowFor(page, semi); + const committedByPick = await semiRow.isVisible().catch(() => false); + const picked = { shiro: await shiroWins.getAttribute('aria-pressed'), aka: await akaWins.getAttribute('aria-pressed') }; + row({ step: 'hantei', action: 'SHIRO wins (intended)', variant: 'V1 tapNeighbour right', hit: nbHantei.hit.label, gapPx: nbHantei.gapPx, + picked, committedByPick, finishLabel: (await finishButton(page).innerText()).trim(), shot: await shot(page, 'hantei-neighbour-tapped') }); + expect(committedByPick).toBe(false); + // The hasty operator Finishes without reading which side is picked. + await finishMatch(page, INLINE_EDITOR); const committed = await semiRow.waitFor({ state: 'visible', timeout: 6000 }).then(() => true, () => false); const firstRead = committed ? (await semiRow.locator('.shiaijo-qrow__result').innerText()).trim() : null; const updatedInPlace = await expect.poll(async () => (await semiRow.locator('.shiaijo-qrow__result').innerText()).trim(), { timeout: 6000 }) @@ -644,26 +685,36 @@ test.describe('knockout-mixed-individual', () => { const wrongResult = (await semiRow.locator('.shiaijo-qrow__result').innerText()).trim(); row({ step: 'hantei', action: 'the recorded hantei result, read before and after a reload', variant: 'correct path', firstRead, htShownWithin6sWithoutReload: updatedInPlace, afterReload: wrongResult, shot: await shot(page, 'hantei-result-after-reload') }); - row({ step: 'hantei', action: 'SHIRO wins (intended)', variant: 'V1 tapNeighbour right', hit: nbHantei.hit.label, gapPx: nbHantei.gapPx, - committedImmediately: committed, result: wrongResult, shot: await shot(page, 'hantei-neighbour-tapped') }); + row({ step: 'hantei', action: 'Finish (two taps) on the neighbour\'s pick', variant: 'V4 hasty (did not read the pick)', + picked, committed, result: wrongResult }); // The recorded result reads as the docs describe: the winner's points, // Ht beside the winner, (E) in the centre. expect(wrongResult).toMatch(/Ht/); expect(wrongResult).toMatch(/\(E\)/); - // Recovery: Correct the semi-final and give hantei to Shiro. - await semiRow.getByRole('button', { name: 'Correct' }).tap(); - await expect(inlineEditor(page).locator('.editor-head-pill')).toHaveText('CORRECTION'); + // Recovery: Correct the semi-final and give hantei to Shiro. Finish + + // Start Next has started the other semi-final, so Correct is refused + // until it is sent back (bc-crpn). + const htOpen = await correctCompleted(page, semi); + row({ step: 'hantei', action: 'Correct the hantei while the next semi-final runs', variant: 'correct path (refused, bc-crpn)', + liveBout: htOpen.live, ...htOpen.refusal }); await shot(page, 'hantei-correction-open'); const armAgain = inlineEditor(page).getByTestId('scoring-modal-hantei-arm'); if (await armAgain.isVisible().catch(() => false)) await armAgain.tap(); await inlineEditor(page).getByTestId('scoring-modal-hantei-shiro').tap(); - await page.waitForTimeout(1500); - const reasonAsked = await inlineEditor(page).locator('.reason-prompt').isVisible().catch(() => false); - const toast = (await page.locator('.toast').allInnerTexts().catch(() => [])).join(' | '); - const fixed = (await completedRowFor(page, semi).locator('.shiaijo-qrow__result').innerText()).trim(); - row({ step: 'hantei', action: 'Correct -> SHIRO wins', variant: 'recovery', reasonAsked, toast, result: fixed, + await inlineEditor(page).getByRole('button', { name: 'Save correction' }).tap(); + const reasonPrompt = inlineEditor(page).locator('.reason-prompt'); + const reasonAsked = await reasonPrompt.waitFor({ state: 'visible', timeout: 4000 }).then(() => true, () => false); + if (reasonAsked) await reasonPrompt.locator('button.btn--primary').tap(); + await expect.poll(async () => (await semiRow.locator('.shiaijo-qrow__result').innerText()).trim(), { timeout: 10_000 }) + .not.toBe(wrongResult); + // The refusal's own toast may still be up; only a toast the save raised counts. + const toast = (await page.locator('.toast').allInnerTexts().catch(() => [])) + .filter((t) => t !== htOpen.refusal?.toast).join(' | '); + const fixed = (await semiRow.locator('.shiaijo-qrow__result').innerText()).trim(); + row({ step: 'hantei', action: 'Correct -> SHIRO wins -> Save correction', variant: 'recovery', reasonAsked, toast, result: fixed, shot: await shot(page, 'hantei-corrected') }); + expect(fixed).toMatch(/Ht/); }); // J6. Confirm + advance OFFLINE (F2, court C): the second semi-final is // finished with the network down. The write is queued, the local bracket @@ -952,7 +1003,7 @@ test.describe('knockout-mixed-individual', () => { return id; }; - test('bc-kfup: a withdrawn competitor\'s remaining pool bout can be recorded as their default loss', async ({ page }) => { + test('bc-kfup: a withdrawn competitor\'s remaining pool bout can be recorded as a fusenpai', async ({ page }) => { await login(page); const id = await seedOwn(page, { name: 'Finding A2', format: 'mixed', poolSize: 3, poolWinners: 2, courts: ['H'], numberPrefix: 'FB' }, rosterOf('Atwo', 6)); await openShiaijo(page, 'H'); @@ -971,7 +1022,7 @@ test.describe('knockout-mixed-individual', () => { await expect(page.locator('.score-edit-row').filter({ hasText: first.aka }).filter({ hasText: /Fus\./ })).toHaveCount(1); }); - test.fixme('bc-otpl: tapping the Overtime pill opens the encho counter, as the docs describe', async ({ page }) => { + test('bc-otpl: tapping the Overtime pill opens the encho counter, as the docs describe', async ({ page }) => { await login(page); await seedOwn(page, { name: 'Finding A3', format: 'knockout', courts: ['I'], numberPrefix: 'FC' }, rosterOf('Athree', 4)); await openShiaijo(page, 'I'); @@ -988,17 +1039,20 @@ test.describe('knockout-mixed-individual', () => { await ipponButton(page, 'shiro', 'M').tap(); await ipponButton(page, 'aka', 'K').tap(); await settle(page); + // A side button picks the hantei winner; Finish (two taps) records it (bc-htsd). await inlineEditor(page).getByTestId('scoring-modal-hantei-arm').tap(); await inlineEditor(page).getByTestId('scoring-modal-hantei-aka').tap(); + await finishMatch(page, INLINE_EDITOR); const done = completedRowFor(page, semi); await expect(done).toBeVisible(); await expect(done.locator('.shiaijo-qrow__result')).toContainText('Ht'); const before = (await done.locator('.shiaijo-qrow__result').innerText()).trim(); - await done.getByRole('button', { name: 'Correct' }).tap(); - await expect(inlineEditor(page).locator('.editor-head-pill')).toHaveText('CORRECTION'); + // Finish + Start Next started the other semi-final: send it back, then correct (bc-crpn). + await correctCompleted(page, semi); const arm = inlineEditor(page).getByTestId('scoring-modal-hantei-arm'); if (await arm.isVisible().catch(() => false)) await arm.tap(); await inlineEditor(page).getByTestId('scoring-modal-hantei-shiro').tap(); + await inlineEditor(page).getByRole('button', { name: 'Save correction' }).tap(); // Docs: a correction asks for a short reason, then applies. const reason = inlineEditor(page).locator('.reason-prompt'); if (await reason.isVisible().catch(() => false)) await reason.locator('button.btn--primary').tap(); @@ -1048,7 +1102,7 @@ test.describe('knockout-mixed-individual', () => { expect(again).toEqual(pair); await expect.poll(() => slotMarks(page, winnerOf(pair))).toEqual(['M']); }); - test.fixme('bc-kosc: in pools + knockout, no knockout bout is scheduled before the last pool bout', async ({ page }) => { + test('bc-kosc: in pools + knockout, no knockout bout is scheduled before the last pool bout', async ({ page }) => { await login(page); const id = await seedOwn(page, { name: 'Finding A7', format: 'mixed', poolSize: 3, poolWinners: 2, courts: ['M'], numberPrefix: 'FG' }, rosterOf('Aseven', 6)); await page.goto(`/admin/competition/${id}/scores`); diff --git a/scripts/e2e/journeys/knockout-mixed-kachinuki.spec.mjs b/scripts/e2e/journeys/knockout-mixed-kachinuki.spec.mjs index 5ebad953a..ecf73443e 100644 --- a/scripts/e2e/journeys/knockout-mixed-kachinuki.spec.mjs +++ b/scripts/e2e/journeys/knockout-mixed-kachinuki.spec.mjs @@ -10,11 +10,12 @@ // hikiwake, an encho, a taisho tie that exhausts a team, a fusensho walkover), // returns to a mis-scored earlier bout mid-encounter, then walks every // correction door the UI offers: Remove this bout, Reopen match on a completed -// encounter, the court-busy "Clear its score, queue it, and reopen" panel -// (M2, moved onto A, holds the court), and the refusal when the final that M1 -// feeds is already running. M2 ends the other way the rules allow, with the -// taisho drawing, staying on, and then being defeated. The final is played -// out and a spectator's phone sees the finished knockout. +// encounter, the court console refusing Correct while another bout is live on +// its court (bc-crpn), and from the Scores tab the court-busy "Queue it and +// reopen" panel (M2, moved onto A, holds the court) and the refusal when the +// final that M1 feeds is already running. M2 ends the other way the rules +// allow, with the taisho drawing, staying on, and then being defeated. The +// final is played out and a spectator's phone sees the finished knockout. // // Functional defects found here are test.fixme(': ...') blocks, // each a self-contained reproduction placed where the fixture supports it. @@ -39,6 +40,8 @@ import { generateDraw, pasteRoster, startCompetition } from '../fixtures/competi import { openShiaijo, sides, upNextCard } from '../fixtures/shiaijo.mjs'; import { PUBLIC_DEVICE } from '../fixtures/devices.mjs'; import { openViewer } from '../fixtures/public.mjs'; +import { scoreRows } from '../fixtures/scores.mjs'; +import { EDITOR } from '../../screenshots/lib/editor.mjs'; import { dwell, settled } from '../fixtures/pace.mjs'; // clumsy.hastyConfirm answers confirmDialog (ui.jsx) only. The other confirms // on this journey are the shiaijo page's own `.shiaijo-move-confirm`, the @@ -554,15 +557,16 @@ test.describe('J5 kachinuki from the court console', () => { const row = completedRow(page, m1); await expect(row).toBeVisible(); // Shiro won bouts 1, 5, 6 and 8 (the walkover's two maru), Aka 2 and 4: - // IV 4-2, PW 8-3. Does the court's own Completed record say so? - const want = /IV 4–2\s+PW 8–3/; + // IV 4-2, PW 7-3 (bout 5's double tap scores one M since bc-dtip; before + // it the bounce added a second). Does the court's own Completed record say so? + const want = /IV 4–2\s+PW 7–3/; const result = row.locator('.shiaijo-qrow__result'); const settled = await expect(result).toHaveText(want, { timeout: 6000 }).then(() => true, () => false); const shownAtFirst = (await result.innerText()).replace(/\s+/g, ' '); const staleShot = await shot(page, 'm1-completed'); await page.getByRole('button', { name: 'Refresh' }).tap(); await page.waitForTimeout(1500); - record({ step: 'M1 end', action: 'Completed row after End match', variant: 'result', expected: 'IV 4–2 PW 8–3', + record({ step: 'M1 end', action: 'Completed row after End match', variant: 'result', expected: 'IV 4–2 PW 7–3', shownFor6s: shownAtFirst, settledWithoutRefresh: settled, afterRefresh: (await completedRow(page, m1).locator('.shiaijo-qrow__result').innerText()).replace(/\s+/g, ' '), screenshot: staleShot }); @@ -701,9 +705,9 @@ test.describe('J5 kachinuki from the court console', () => { const immediately = (await row.locator('.shiaijo-qrow__result').innerText()).replace(/\s+/g, ' '); const immediateShot = await shot(page, 'm1-ended-again'); // Bout 8 is now a men (1 point) instead of the walkover (2 maru). - const settled = await expect(row.locator('.shiaijo-qrow__result')).toHaveText(/IV 4–2\s+PW 7–3/, { timeout: 8000 }) + const settled = await expect(row.locator('.shiaijo-qrow__result')).toHaveText(/IV 4–2\s+PW 6–3/, { timeout: 8000 }) .then(() => true, () => false); - record({ step: 'M1 reopen', action: 'ended again: Completed row', variant: 'result', expected: 'IV 4–2 PW 7–3', + record({ step: 'M1 reopen', action: 'ended again: Completed row', variant: 'result', expected: 'IV 4–2 PW 6–3', immediately, settledWithin8s: settled, after: (await row.locator('.shiaijo-qrow__result').innerText()).replace(/\s+/g, ' '), screenshot: immediateShot }); // Still Kawasemi's (Shiro's) win: the final keeps its side. @@ -713,7 +717,9 @@ test.describe('J5 kachinuki from the court console', () => { // The operator on A spots another mistake in M1, but the organiser has // already moved M2 onto A (shiaijo B's table is unstaffed) and it is running - // there. Reopening M1 meets the court-busy panel. + // there. The court console refuses Correct while a bout is live on its court + // (bc-crpn), so the organiser reopens M1 from the Scores tab, which meets the + // court-busy panel. test('J5 M1 reopened while M2 holds shiaijo A: the court-busy panel', async ({ page }) => { await login(page); @@ -740,15 +746,20 @@ test.describe('J5 kachinuki from the court console', () => { await expect(syncPill(page)).toHaveText('Synced'); }); + await test.step('Correct on M1 at the court console is refused while M2 runs', async () => { + const refused = await correctRefusedAtConsole(page, m1, m2); + record({ step: 'M1 reopen, court busy', action: 'Correct (Completed row) while M2 runs on A', variant: 'refused (bc-crpn)', + ...refused, screenshot: await shot(page, 'm1-correct-refused-m2-running') }); + }); + const conflict = page.getByTestId('kachinuki-reopen-conflict'); - await test.step('Correct M1 -> Reopen match: the court is busy', async () => { - await completedRow(page, m1).locator('.shiaijo-row__correct').tap(); - await expect(reopenButton(page)).toBeVisible(); + await test.step('Scores tab: Correct M1 -> Reopen match: the court is busy', async () => { + await openScoresCorrection(page, compId, m1); await reopenButton(page).tap(); await expect(conflict).toBeVisible(); const req = await page.getByTestId('kachinuki-reopen-requeue-button').boundingBox(); const leave = await page.getByTestId('kachinuki-reopen-conflict-dismiss').boundingBox(); - record({ step: 'M1 reopen, court busy', action: 'Reopen match while M2 runs on A', variant: 'correct path', + record({ step: 'M1 reopen, court busy', action: 'Reopen match (Scores tab) while M2 runs on A', variant: 'correct path', panel: (await conflict.innerText()).replace(/\s+/g, ' ').trim(), requeueBox: req && `${Math.round(req.width)}x${Math.round(req.height)}`, leaveBox: leave && `${Math.round(leave.width)}x${Math.round(leave.height)}`, @@ -759,7 +770,7 @@ test.describe('J5 kachinuki from the court console', () => { const v1 = await tapNeighbour(page.getByTestId('kachinuki-reopen-requeue-button'), 'right'); await page.waitForTimeout(300); const panelStill = await conflict.isVisible(); - record({ step: 'M1 reopen, court busy', action: 'Clear its score, queue it, and reopen', variant: 'V1 tapNeighbour right', + record({ step: 'M1 reopen, court busy', action: 'Queue it and reopen (Scores tab)', variant: 'V1 tapNeighbour right', ...v1, panelStill, screenshot: await shot(page, 'v1-requeue-neighbour') }); if (!panelStill) { await reopenButton(page).tap(); @@ -770,12 +781,17 @@ test.describe('J5 kachinuki from the court console', () => { await test.step('V4: the panel answered by its loudest button without reading', async () => { const loud = await hastyPanel(conflict); await expect(conflict).toHaveCount(0, { timeout: 15_000 }); + // The overlay follows the reopened match into bout mode. + await expect(page.locator(EDITOR).getByRole('button', { name: 'Record bout' })).toBeVisible({ timeout: 15_000 }); + const overlayShot = await shot(page, 'v4-requeue-hasty'); + // M1 back in play is the court console's to show: it is A's live bout + // again, with its bouts, and M2 is back in A's queue. + await openShiaijo(page, 'A'); await expect(recordBoutButton(page)).toBeVisible({ timeout: 15_000 }); const upNext = await upNextCard(page).isVisible() ? await sides(upNextCard(page)) : null; - record({ step: 'M1 reopen, court busy', action: 'court-busy panel', variant: 'V4 hasty (loudest button)', ...loud, - m1Reopened: await boutNames(currentBout(page)), upNext, - screenshot: await shot(page, 'v4-requeue-hasty') }); - // M1 is back in play with its bouts; M2 is back in A's queue. + record({ step: 'M1 reopen, court busy', action: 'court-busy panel (Scores tab)', variant: 'V4 hasty (loudest button)', ...loud, + m1Reopened: await boutNames(currentBout(page)), doneRows: await doneRows(page).count(), upNext, + screenshot: overlayShot, console: await shot(page, 'v4-requeue-hasty-console') }); expect(await doneRows(page).count()).toBe(7); expect(upNext).toEqual(m2); }); @@ -793,6 +809,9 @@ test.describe('J5 kachinuki from the court console', () => { expect(await sides(upNextCard(page))).toEqual(m2); await upNextCard(page).getByRole('button', { name: 'Start match' }).tap(); await page.waitForTimeout(1500); + // The reopen came from the Scores tab, so the console holds no + // correction: M2 is the panel's live bout at once (V5's old lost place, + // a started match hidden behind a pinned correction, is gone, bc-crpn). const afterStart = { upNextStill: await upNextCard(page).isVisible(), panelShows: (await editor(page).locator('.editor-modal__eyebrow').first().innerText().catch(() => '')).replace(/\s+/g, ' '), @@ -801,16 +820,12 @@ test.describe('J5 kachinuki from the court console', () => { backToCourt: await page.getByRole('button', { name: /Back to court/ }).count(), }; record({ step: 'M1 reopen, court busy', action: 'End M1 again, then Start match on Up next (M2)', variant: 'V5 lost place', - ...afterStart, screenshot: await shot(page, 'v5-m2-started-but-not-shown') }); - // Recover: "← Back to court" under the pinned correction. - let recoverTaps = 0; - if (!afterStart.m2Live && afterStart.backToCourt) { - await page.getByRole('button', { name: /Back to court/ }).tap(); - recoverTaps += 1; - } + ...afterStart, screenshot: await shot(page, 'v5-m2-started-after-m1-ended') }); await expect(recordBoutButton(page)).toBeVisible(); - record({ step: 'M1 reopen, court busy', action: 'recover the started M2', variant: 'V5 lost place', recoverTaps, - live: await boutNames(currentBout(page)), screenshot: await shot(page, 'v5-m2-after-back-to-court') }); + // Nothing to recover from: the row stays to say so. + record({ step: 'M1 reopen, court busy', action: 'recover the started M2', variant: 'V5 lost place', recoverTaps: 0, + live: await boutNames(currentBout(page)) }); + expect(await boutNames(currentBout(page))).toEqual({ shiro: MEMBERS[m2.shiro][0], aka: MEMBERS[m2.aka][0] }); const marks = await boutFilled(currentBout(page), 'aka').count(); record({ step: 'M1 reopen, court busy', action: 'M2 restarted after the requeue', variant: 'result', m2Bout1AkaMarks: marks, panelSays: 'Sending it back to the queue keeps any score already entered for it: it carries on from there when it is started again', @@ -1030,23 +1045,35 @@ test.describe('J5 kachinuki from the court console', () => { }); }); - // bc-crpn: while a finished match is pinned open for correction, Start - // match on the Up next card starts the next match but the panel keeps - // showing the correction: the running match appears nowhere on the page. - test.fixme('bc-crpn: Start match while a correction is pinned hides the match it started', async ({ page }) => { + // bc-crpn (fixed): the court console never holds a running match and an + // open correction at once. While a finished match is open for correction, + // Start match on the Up next card is refused with a notice at the card, the + // correction stays open and the queued match is not started. + test('bc-crpn: Start match while a correction is open is refused', async ({ page }) => { await login(page); await openShiaijo(page, 'A'); const next = await sides(upNextCard(page)); await completedRow(page, m1).locator('.shiaijo-row__correct').tap(); await expect(reopenButton(page)).toBeVisible(); - await upNextCard(page).getByRole('button', { name: 'Start match' }).tap(); - await expect(recordBoutButton(page)).toBeVisible(); - expect(await editor(page).locator('.team-sub-match__side--shiro input.pmf__input').first().inputValue()) - .toBe(MEMBERS[next.shiro][0]); - // Leave the final as it was: back in the queue, unscored. - await page.getByRole('button', { name: 'Send back to queue' }).tap(); - await page.locator('.shiaijo-move-confirm').getByRole('button', { name: 'Send back to queue' }).tap(); - await expect(upNextCard(page)).toBeVisible(); + const start = upNextCard(page).getByRole('button', { name: 'Start match' }); + await start.tap(); + const notice = upNextCard(page).locator('.shiaijo-upnext__error'); + await expect(notice).toHaveText(new RegExp( + `^Save the correction of .*${m1.shiro} vs ${m1.aka}, or leave it with Back to court, then start this match\\.$`)); + record({ step: 'bc-crpn', action: 'Start match (Up next) while M1 is open for correction', variant: 'refused', + notice: (await notice.innerText()).trim(), + toast: (await page.locator('.toast').allInnerTexts().catch(() => [])).join(' | '), + screenshot: await shot(page, 'bc-crpn-start-refused-during-correction') }); + // The correction stays open, and the final is still waiting in the queue. + await expect(reopenButton(page)).toBeVisible(); + await expect(recordBoutButton(page)).toHaveCount(0); + await expect(start).toBeVisible(); + expect(await sides(upNextCard(page))).toEqual(next); + // Leave the correction; the court is as it was, with the final up next. + await page.getByRole('button', { name: /Back to court/ }).tap(); + await expect(reopenButton(page)).toHaveCount(0); + await expect(notice).toHaveCount(0); + expect(await sides(upNextCard(page))).toEqual(next); }); test('J5 final: lineups carried over, the final starts on A', async ({ page }) => { @@ -1088,17 +1115,24 @@ test.describe('J5 kachinuki from the court console', () => { }); await test.step('Correct M1 -> Reopen while the final (fed by M1) runs', async () => { - await completedRow(page, m1).locator('.shiaijo-row__correct').tap(); - await expect(reopenButton(page)).toBeVisible(); + // The court console refuses the Correct itself while the final is live + // on A (bc-crpn). + const refused = await correctRefusedAtConsole(page, m1, final); + record({ step: 'final running', action: 'Correct M1 (Completed row) while the final runs on A', variant: 'refused (bc-crpn)', + ...refused, screenshot: await shot(page, 'm1-correct-refused-final-running') }); + // The Scores tab still offers the reopen; the server refuses it because + // the final M1 feeds is running. + await openScoresCorrection(page, compId, m1); await reopenButton(page).tap(); const err = page.getByTestId('kachinuki-reopen-error'); await expect(err).toBeVisible(); - record({ step: 'final running', action: 'Reopen M1 while its downstream final runs on the same court', variant: 'correct path', + record({ step: 'final running', action: 'Reopen M1 (Scores tab) while its downstream final runs', variant: 'correct path', message: (await err.innerText()).trim(), courtBusyPanel: await page.getByTestId('kachinuki-reopen-conflict').count(), screenshot: await shot(page, 'm1-reopen-refused-downstream') }); - // Back to the live final. - await page.getByRole('button', { name: /Back to court/ }).tap(); + // Back to the live final on the court console. + await openShiaijo(page, 'A'); await expect(recordBoutButton(page)).toBeVisible(); + expect(await boutFilled(currentBout(page), 'shiro').count()).toBe(1); }); }); @@ -1246,6 +1280,31 @@ async function completedOnServer(page, pair, court = 'A') { } } +// Correct on `pair`'s Completed row while `running` is live on the court: +// refused (bc-crpn). The notice sits at the row, the same sentence as the +// toast; no correction opens and the live bout keeps the panel. +async function correctRefusedAtConsole(page, pair, running, court = 'A') { + await completedRow(page, pair).locator('.shiaijo-row__correct').tap(); + const notice = completedRow(page, pair).locator('.shiaijo-upnext__error'); + await expect(notice).toHaveText(new RegExp(`^Shiaijo ${court} is running .*${running.shiro} vs ${running.aka}\\. ` + + 'Finish it or send it back to the queue first\\. Then correct this match\\.$')); + const toast = (await page.locator('.toast').allInnerTexts().catch(() => [])).join(' | '); + await expect(reopenButton(page)).toHaveCount(0); + await expect(recordBoutButton(page)).toBeVisible(); + return { notice: (await notice.innerText()).trim(), toast, correctionOpened: await reopenButton(page).count() > 0, + liveBout: await boutNames(currentBout(page)) }; +} + +// The organiser's door: Correct on `pair`'s row of the Scores tab opens the +// finished encounter in the overlay editor, which offers Reopen match. +async function openScoresCorrection(page, id, pair) { + await page.goto(`/admin/competition/${encodeURIComponent(id)}/scores`); + const row = scoreRows(page).filter({ hasText: pair.shiro }).filter({ hasText: pair.aka }).first(); + await row.getByRole('button', { name: /^Correct$/ }).tap(); + await expect(page.locator(EDITOR)).toBeVisible(); + await expect(reopenButton(page)).toBeVisible(); +} + // V4 for the shiaijo page's own confirms (`.shiaijo-move-confirm`: Send back // to queue, Move to Shiaijo X). They are not confirmDialog (ui.jsx), so // clumsy.hastyConfirm cannot see them; same rule: tap the loudest button. diff --git a/scripts/e2e/journeys/knockout-mixed-team.spec.mjs b/scripts/e2e/journeys/knockout-mixed-team.spec.mjs index b4b087d70..55b680ee0 100644 --- a/scripts/e2e/journeys/knockout-mixed-team.spec.mjs +++ b/scripts/e2e/journeys/knockout-mixed-team.spec.mjs @@ -24,6 +24,7 @@ import { EDITOR } from '../fixtures/scoring.mjs'; import { openScoreEditor } from '../fixtures/scores.mjs'; import { openTvBoard, openViewer, tvBoard } from '../fixtures/public.mjs'; import { doubleTap, hastyConfirm, interrupt, retapWhileSaving, tapNeighbour } from '../fixtures/clumsy.mjs'; +import { settled } from '../fixtures/pace.mjs'; import * as T from '../fixtures/team.mjs'; const SIX_TEAMS = [ @@ -408,7 +409,7 @@ test.describe('knockout-mixed-team', () => { header: header.replace(/\s+/g, ' '), shiroRow: standings.replace(/\s+/g, ' '), akaRow: standingsAka.replace(/\s+/g, ' ') }); }); - await test.step('F3: a team kiken in the pool and the default-win chain for its remaining matches', async () => { + await test.step('F3: a team kiken in the pool and its remaining matches', async () => { await openShiaijo(page, 'A'); const ed = inlineEditor(page); if (!(await T.boutRow(ed, 1).isVisible().catch(() => false))) await T.startUpNextTeam(page); @@ -432,7 +433,7 @@ test.describe('knockout-mixed-team', () => { await shot(page, 'f3-kiken-1500ms-later'); record({ step: 'pool kiken chain', action: 'the remaining-matches panel, 1.5 s later', variant: 'V5 lost place', stillThere, editorNow, upNext: await sides(upNextCard(page)).catch(() => null) }); - const award = panel.getByRole('button', { name: 'Award default win to opponent' }); + const award = panel.getByRole('button', { name: /opponent/ }); const awards = await award.count(); let nb = null; if (awards) { @@ -443,12 +444,12 @@ test.describe('knockout-mixed-team', () => { await shot(page, 'f3-kiken-after-neighbour-tap'); record({ step: 'pool kiken chain', action: `Kiken – Voluntary, Shiro (${pair.shiro}) withdraws`, variant: 'correct', panelShown: shown, panelText: panelText.slice(0, 300), awardButtons: awards, awardTapBox: awards ? await T.tapBox(award.first()).catch(() => null) : null }); - record({ step: 'pool kiken chain', action: 'Award default win to opponent', variant: 'V1 tapNeighbour up', ...(nb || {}), panelStillOpen: panelAfterSlip }); + record({ step: 'pool kiken chain', action: 'Award the remaining match to the opponent', variant: 'V1 tapNeighbour up', ...(nb || {}), panelStillOpen: panelAfterSlip }); let awarded = 0; if (panelAfterSlip) { while (await award.count()) { const dbl = awarded === 0 ? await doubleTap(award.first()) : (await award.first().tap(), null); - if (dbl) record({ step: 'pool kiken chain', action: 'Award default win to opponent', variant: 'V2 doubleTap', ...dbl }); + if (dbl) record({ step: 'pool kiken chain', action: 'Award the remaining match to the opponent', variant: 'V2 doubleTap', ...dbl }); awarded += 1; await page.waitForTimeout(1200); if (awarded > 4) break; @@ -824,25 +825,50 @@ test.describe('knockout-mixed-team', () => { if (!(await vol.isVisible())) await summary.tap(); await vol.tap(); await expect(prompt).toBeVisible(); - const preselected = await prompt.locator('input[type="radio"]:checked').getAttribute('value'); + // bc-dsid: no side is picked for the operator, and Record stays off + // until one is (recording-decisions.md). + const preselected = await prompt.locator('input[type="radio"]:checked').count(); + const hint = (await prompt.getByTestId('decision-prompt-hint').innerText()).trim(); await shot(page, 'sf2-kiken-prompt'); - const radioBox = await T.tapBox(prompt.locator('label', { has: page.locator('input[type="radio"]') }).first()); + const pill = (side) => prompt.locator('label.decision-prompt__side', { has: page.locator(`input[value="${side}"]`) }); + const radioBox = await T.tapBox(pill('shiro')); const loud = prompt.locator('button.btn--primary'); const loudLabel = (await loud.innerText()).trim(); + const loudDisabled = await loud.isDisabled(); + await expect(loud).toBeDisabled(); + // V4: the hasty tap on Record, which is off. A thumb, so a touch at its + // centre (a locator tap would wait for it to come on). + const completedBefore = await completedRows(page).count(); + const lb = await loud.boundingBox(); + await page.touchscreen.tap(lb.x + lb.width / 2, lb.y + lb.height / 2); + await page.waitForTimeout(800); + const promptStillOpen = await prompt.isVisible(); + const completedAfterHasty = await completedRows(page).count(); + await shot(page, 'sf2-kiken-hasty-record-off'); + record({ step: 'SF2 kiken', action: 'Record withdrawal before picking a side (intended: Aka)', variant: 'V4 hasty (loudest button)', + label: loudLabel, recordDisabled: loudDisabled, preselectedCount: preselected, hint, promptStillOpen, + recordedByHastyTap: completedAfterHasty !== completedBefore, promptPillTapBox: radioBox }); + expect(promptStillOpen).toBe(true); + // V1: the thumb aims at AKA's pill and lands on its neighbour, SHIRO's. + const nbPill = await tapNeighbour(pill('aka'), 'left'); + await expect(loud).toBeEnabled(); + const labelAfterPick = (await loud.innerText()).trim(); + const consequence = (await prompt.getByTestId('decision-prompt-consequence').innerText()).trim(); + const picked = await prompt.locator('input[type="radio"]:checked').getAttribute('value'); + await shot(page, 'sf2-kiken-side-picked'); + // Not reading the button, the operator taps Record. await loud.tap(); await page.waitForTimeout(1500); - const remaining = (await ed.locator('.remaining-matches').allInnerTexts()).join(' ').replace(/\s+/g, ' '); await shot(page, 'sf2-kiken-recorded'); // The withdrawal is marked Kiken beside the team on the bracket. await expect(page.getByText('Kiken', { exact: true }).first()).toBeVisible(); - record({ step: 'SF2 kiken', action: 'Record withdrawal (intended: Aka)', variant: 'V4 hasty (loudest button)', label: loudLabel, - promptRadioTapBox: radioBox, + record({ step: 'SF2 kiken', action: 'pick the side (aimed at Aka), then Record', variant: 'V1 tapNeighbour left', ...nbPill, + labelAfterPick, consequence, recordedAgainst: picked, completedRowResult: (await completedRows(page).last().locator('.shiaijo-qrow__result').innerText().catch(() => '')).trim(), - preselectedSide: preselected, recordedAgainst: preselected, remainingMatchesPanel: remaining.slice(0, 300), - note: 'the side picker preselects Shiro, so a hasty Record withdraws the wrong team' }); + note: 'the button names the side picked; an operator who does not read it still records the wrong team' }); }); - await test.step('SF2 after the hasty kiken: the final starts, then the operator notices and corrects', async () => { + await test.step('SF2 after the wrong-side kiken: the final starts, then the operator notices and corrects', async () => { const close = ed.locator('.remaining-matches button', { hasText: '✕' }); if (await close.count()) await close.first().tap(); // Not noticing, the operator starts the next match: the final. @@ -851,10 +877,28 @@ test.describe('knockout-mixed-team', () => { await T.startUpNextTeam(page); await shot(page, 'final-started-with-wrong-team'); record({ step: 'SF2 kiken recovery', action: 'final started before the mistake is noticed', variant: 'V5 lost place', finalPair: finalBefore }); - // Correct on the finished semifinal. + // Correct on the finished semifinal, while the final runs on the court. const row = completedRows(page).filter({ hasText: sf2.shiro }).filter({ hasText: sf2.aka }).last(); const result = (await row.locator('.shiaijo-qrow__result').innerText().catch(() => '')).trim(); await row.getByRole('button', { name: /Correct/ }).tap(); + // bc-crpn: Correct is refused while a bout is live on the court; the row + // names it and says to finish it or send it back first (scoring-a-match.md). + const notice = row.locator('.shiaijo-upnext__error[role="alert"]'); + await expect(notice).toBeVisible(); + const noticeText = (await notice.innerText()).trim(); + const toast = (await page.locator('.toast').allInnerTexts().catch(() => [])).join(' | '); + await shot(page, 'sf2-correct-refused-final-running'); + record({ step: 'SF2 kiken recovery', action: 'Correct on the semifinal while the final runs', variant: 'V5 lost place', notice: noticeText, toast, + editorHolds: await sides(ed).catch(() => null), correctionOpened: await ed.getByText('CORRECTION', { exact: true }).count() }); + expect(noticeText).toMatch(/^Shiaijo D is running .+\. Finish it or send it back to the queue first\. Then correct this match\.$/); + // The operator sends the final back (no score entered yet), then corrects. + await page.getByRole('button', { name: 'Send back to queue' }).tap(); + const revert = page.locator('.shiaijo-move-confirm[role="dialog"]'); + await settled(revert); + await revert.getByRole('button', { name: 'Send back to queue' }).tap(); + await expect(revert).toHaveCount(0); + await expect(upNextCard(page)).toBeVisible(); + await row.getByRole('button', { name: /Correct/ }).tap(); await expect(ed.getByText('CORRECTION', { exact: true }).or(ed.locator('.editor-modal__eyebrow', { hasText: 'MATCH 2' })).first()).toBeVisible(); const full = (await ed.innerText()).replace(/\s+/g, ' '); await shot(page, 'sf2-correct-after-kiken'); @@ -870,18 +914,15 @@ test.describe('knockout-mixed-team', () => { await prompt.locator('input[value="aka"]').check(); await shot(page, 'sf2-rekiken-aka-prompt'); await prompt.locator('button.btn--primary').tap(); - const dialog = page.locator('.modal[role="dialog"]').filter({ has: page.locator('.modal__foot') }); - const locked = await dialog.waitFor({ state: 'visible', timeout: 6000 }).then(() => true, () => false); - let hasty = null; - if (locked) { - await shot(page, 'sf2-decision-locked-confirm'); - hasty = await hastyConfirm(page); - } + // bc-dlck: no "A subsequent match ... Proceed anyway?" confirm follows; the + // final it feeds was sent back unscored, so nothing else asks either. await page.waitForTimeout(1500); + const confirmShown = await page.locator('.modal[role="dialog"]').filter({ has: page.locator('.modal__foot') }).count(); const errs = (await ed.locator('[style*="danger"], .alert--error').allInnerTexts()).join(' | '); await shot(page, 'sf2-after-rekiken'); - record({ step: 'SF2 kiken recovery', action: 'Kiken – Voluntary again, Aka, on the corrected semifinal', variant: 'V4 hastyConfirm (decision_locked)', - confirmShown: locked, ...(hasty || {}), errorsAfter: errs }); + record({ step: 'SF2 kiken recovery', action: 'Kiken – Voluntary again, Aka, on the corrected semifinal', variant: 'V4 hastyConfirm (no confirm since bc-dlck)', + confirmShown: confirmShown > 0, errorsAfter: errs, correctionStillOpen: await ed.getByText('CORRECTION', { exact: true }).count() }); + expect(confirmShown).toBe(0); await openShiaijo(page, 'D'); await page.waitForTimeout(1000); await shot(page, 'court-after-kiken-recovery'); @@ -893,9 +934,13 @@ test.describe('knockout-mixed-team', () => { await test.step('Final: fusenpai (a team did not show up)', async () => { await openShiaijo(page, 'D'); - if (!(await T.boutRow(ed, 1).isVisible().catch(() => false)) && (await upNextCard(page).count())) await T.startUpNextTeam(page); - await expect(T.boutRow(ed, 1)).toBeVisible(); + // The final was sent back to the queue; start it again from Up next. + // After the correction it holds SF2's Shiro, who won by Aka's kiken. + const finalPair = await sides(upNextCard(page)); + await T.startUpNextTeam(page); const pair = await sides(ed); + expect(pair).toEqual(finalPair); + expect([pair.shiro, pair.aka]).toContain(sf2.shiro); const summary = ed.locator('.decision-disclosure__summary'); await summary.scrollIntoViewIfNeeded(); await summary.tap(); @@ -1130,7 +1175,7 @@ test.describe('knockout-mixed-team', () => { await T.boutIppon(ed, 1, 'shiro', 'K'); // The kote is saved before the fusensho. await expect(T.syncPill(ed)).toHaveText('Synced'); - // Shiro's fighter cannot continue; the bout goes to Aka by default. + // Shiro's fighter cannot continue; the bout goes to Aka as a fusensho. await T.rowFusensho(T.boutRow(ed, 1), 'aka').tap(); await expect(T.rowSlots(T.boutRow(ed, 1), 'aka')).toHaveText(['○', '○']); // recording-decisions.md: "Any point the withdrawing side had already From d906054cbe5bc9044d7ce8a8855cc899ee75522c Mon Sep 17 00:00:00 2001 From: Ricardo Oliveira Date: Sun, 11 Oct 2026 00:37:07 +0100 Subject: [PATCH 13/30] test(mp-yqxn.2): bc-dhrp is a plain test; follow #463's winner label Main merged #463 (bc-aadv, bc-cdbl, bc-dhrp, bc-tmwn, bc-rrdr), #464 and #465, with Go 1.27.2 and excelize pinned to a commit carrying the fixes for the advisories that reddened the image scan. - bc-dhrp passes with the fixme removed: the daihyosen row now has a name box on each side. Renamed to what it checks. No fixme is left. - J7 reads the viewer's bracket through innerText, which now includes the screen-reader "Winner:" label beside the winner's tick (side_cell.jsx); the pattern allows it, and still asserts the tick beside the corrected winner. make e2e on the merged tree: 25 passed before the J7 fix (individual, kachinuki, smoke and the team tests before J7); the team spec after both changes: 10 passed. make go/test: EXIT 0 (golangci-lint v2.14.0 and gosec installed at the Makefile's pins, built with go1.27.2). Co-Authored-By: Claude Opus 5.5 --- scripts/e2e/journeys/knockout-mixed-team.spec.mjs | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/scripts/e2e/journeys/knockout-mixed-team.spec.mjs b/scripts/e2e/journeys/knockout-mixed-team.spec.mjs index 55b680ee0..57e29b90d 100644 --- a/scripts/e2e/journeys/knockout-mixed-team.spec.mjs +++ b/scripts/e2e/journeys/knockout-mixed-team.spec.mjs @@ -1082,8 +1082,10 @@ test.describe('knockout-mixed-team', () => { const bracketText = (await phone.locator('.viewer__body').innerText()).replace(/\s+/g, ' '); record({ step: 'every surface', action: 'viewer Bracket tab after the correction', variant: 'correct', bracket: bracketText.slice(0, 400), htOnBracket: /\bHt\b/.test(bracketText) }); - // The winner's check sits beside the corrected winner, Aka. - expect(bracketText).toMatch(new RegExp(`✓\\s*\\S*\\s*${pair.aka}`)); + // The winner's check sits beside the corrected winner, Aka. The tick + // carries a screen-reader "Winner:" label (side_cell.jsx), which + // innerText includes. + expect(bracketText).toMatch(new RegExp(`✓\\s*(?:Winner:\\s*)?\\S*\\s*${pair.aka}`)); }); } finally { await head.close(); @@ -1216,7 +1218,7 @@ test.describe('knockout-mixed-team', () => { await expect(page.locator('tr').filter({ hasText: pair.shiro }).first()).toContainText(/0\s*0\s*0\s*0\s*0\s*0\s*0\s*0\s*$/); }); - test.fixme('bc-dhrp: the daihyosen row offers no way to pick each team\'s representative', async ({ page }) => { + test('bc-dhrp: the daihyosen row lets each team pick its representative', async ({ page }) => { await T.enterAdmin(page); await seedF4(page, { name: 'B6 KO', court: 'F', prefix: 'R', teams: [['B6 Ume', 'Kita Dojo'], ['B6 Sakura', 'Minami Dojo']] }); await openShiaijo(page, 'F'); From 29e71bb3a26ee72f0d339e96448dbcf84267d4f0 Mon Sep 17 00:00:00 2001 From: Ricardo Oliveira Date: Sun, 11 Oct 2026 22:10:21 +0100 Subject: [PATCH 14/30] fix(mp-yqxn.2): address the code review of the e2e harness MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - One server per spec file (major): Playwright hands a worker one file after another, so the worker-scoped server greeted a second file with the first file's tournament, and the suite passed or failed on the machine's core count. serverForFile (worker scope) starts a fresh server when the file changes; server is test-scoped. Proven with --workers=1. - interrupt('back') decides "went back" from the URL: goBack() answers null for the SPA's pushState steps, so Forward was never pressed. - hastyConfirm and the shiaijo confirm wait on the dialog they answered (an element handle), not on a locator that re-resolves to a second dialog. - The bc-sync guard holds the score write and reads "Syncing…" while it is held, instead of racing the 300 ms debounce. - Every journey writes audit.json (fixtures/audit.mjs); attach() with a body never reached disk under the list reporter. - Plain bead tests are titled by what they guard, not by the old defect. - settle() waits for "Synced" instead of sleeping 700 ms on a claim bc-sync made false. - One coarse-pointer owner (fixtures/pointer.mjs: isCoarse, restoreCoarse in place, tapSize); ensureCoarse no longer reloads mid-bout. - One copy each of completedRowFor (newest row), the loudest-button ranking, the shiaijo confirm, the tap-size measure and the name-list close; sheetPositionLabel deleted. - Journey screenshots and audit.json follow E2E_RESULTS_DIR (shots.journeyDir), so two concurrent runs no longer empty each other's folders. make e2e: 33 passed. --workers=1, in two runs that each push one worker through a second file: 11 and 22 passed. make go/test: EXIT 0. Co-Authored-By: Claude Opus 5.5 --- scripts/e2e/README.md | 38 ++-- scripts/e2e/fixtures/audit.mjs | 32 +++ scripts/e2e/fixtures/clumsy.mjs | 103 ++++++--- scripts/e2e/fixtures/individual.mjs | 82 +------ scripts/e2e/fixtures/kachinuki.mjs | 36 +--- scripts/e2e/fixtures/pointer.mjs | 41 ++++ scripts/e2e/fixtures/shiaijo.mjs | 9 +- scripts/e2e/fixtures/shots.mjs | 41 ++-- scripts/e2e/fixtures/team.mjs | 45 ++-- scripts/e2e/fixtures/test.mjs | 71 +++--- .../knockout-mixed-individual.spec.mjs | 48 +++-- .../knockout-mixed-kachinuki.spec.mjs | 202 ++++++++---------- .../e2e/journeys/knockout-mixed-team.spec.mjs | 114 +++++----- scripts/e2e/journeys/smoke.spec.mjs | 21 +- scripts/e2e/playwright.config.mjs | 23 +- 15 files changed, 490 insertions(+), 416 deletions(-) create mode 100644 scripts/e2e/fixtures/audit.mjs create mode 100644 scripts/e2e/fixtures/pointer.mjs diff --git a/scripts/e2e/README.md b/scripts/e2e/README.md index 594361cd3..23b601ffd 100644 --- a/scripts/e2e/README.md +++ b/scripts/e2e/README.md @@ -33,7 +33,9 @@ stamp outlives it; run `npx playwright install chromium` here. fixtures/scoring.mjs scoring an individual bout on either editor fixtures/public.mjs the public viewer and the TV board fixtures/clumsy.mjs the clumsy operator (below) - fixtures/shots.mjs per-step screenshots + fixtures/pointer.mjs the coarse pointer: check, restore, tap-target size + fixtures/shots.mjs per-step screenshots, and each journey's folder + fixtures/audit.mjs audit rows (AUDIT lines and audit.json) journeys/*.spec.mjs the journeys Each fixture module owns the selectors for one surface, so a UI change breaks @@ -55,14 +57,18 @@ An operator journey that checks a public surface on the side (the viewer showing a result the operator just entered) opens a second context with `PUBLIC_DEVICE` from `fixtures/devices.mjs`. -## One server per worker +## One server per spec file -`fixtures/test.mjs` starts one `mobile-app` per Playwright worker, on a free -port and a throwaway data folder, through `scripts/screenshots/lib/server.mjs`, -and stops it by PID when the worker ends. Tests in one worker share that -server, so a spec file that seeds once and then walks several tests declares -`test.describe.configure({ mode: 'serial' })`. Journeys navigate by path -(`page.goto('/admin')`); the host is the worker's server. +`fixtures/test.mjs` starts one `mobile-app` per spec file, on a free port and +a throwaway data folder, through `scripts/screenshots/lib/server.mjs`, and +stops it by PID when the file is done. Playwright runs several files one after +another in one worker when there are more files than workers (`--workers=1` +runs them all in one), and each file starts with a fresh server, never the +previous file's tournament. Tests in one file share its server, so a spec file +that seeds once and then walks several tests declares +`test.describe.configure({ mode: 'serial' })`. A `beforeAll` hook asks for it +with `serverForFile(testInfo.file)`. Journeys navigate by path +(`page.goto('/admin')`); the host is the file's server. ## Seeding goes through the interface @@ -93,6 +99,7 @@ variants as real input: | `retapWhileSaving(locator)` | a second tap while the button reads "Saving…" (writes are held briefly so the state is observable) | | `interrupt(page, kind)` | `reload`, `back` (then forward), `hidden` (tab to background and back), `offline` (network down and back) | | `hastyConfirm(page)` | answers the open confirm dialog with its most prominent button and returns its label | +| `hastyShiaijoConfirm(page)` | the same for the shiaijo page's own confirms (Send back to queue, Move court) | Each returns what happened. A journey records that as an audit row; whether the outcome is acceptable is judged by a person from the screenshots, never @@ -109,21 +116,26 @@ End match), and a second tap on the same ippon button inside that window (`web-mobile/js/tap_guard.jsx`). Playwright's second tap is always inside it, so `fixtures/pace.mjs` holds the dwell (`TAP_DWELL_MS`) and every helper that taps into a fresh layer or confirms an arm waits it out: `login`, `hastyConfirm`, -`answerDialog`, `finishMatch`, `finishTeam`, `endMatch`, `awardBoutIppon`. A +`hastyShiaijoConfirm`, `answerDialog`, `finishMatch`, `finishTeam`, `endMatch`, `awardBoutIppon`. A journey that taps such a thing by hand does the same. `doubleTap` is the one helper that deliberately does not: it is the bounce, and the app is meant to ignore the second tap. ## Output -Per-step screenshots land in `output//`, numbered in the order they -were taken, and are replaced on the next run. Playwright's own traces and -failure screenshots land in `output/test-results/`. Both are gitignored. -There are no pixel comparisons: a screenshot is for a person to look at. +Each journey has a folder, `output//`. Its per-step screenshots land +there, numbered in the order they were taken, beside its `audit.json` (the +audit rows, also printed to the run log as `AUDIT {json}` lines), and are +replaced on the next run. Playwright's own traces and failure screenshots land +in `output/test-results/`. All of it is gitignored. There are no pixel +comparisons: a screenshot is for a person to look at. Every run empties `output/test-results/` when it starts. Two runs at the same time in one checkout would delete each other's traces mid-test, so give the second one its own folder: `E2E_RESULTS_DIR=output/test-results-2 make e2e ...`. +With `E2E_RESULTS_DIR` set, the journey folders move inside it too +(`/journeys//`), so neither run empties the other's. +A relative `E2E_RESULTS_DIR` is relative to this directory. ## When a journey finds a bug diff --git a/scripts/e2e/fixtures/audit.mjs b/scripts/e2e/fixtures/audit.mjs new file mode 100644 index 000000000..341cf8532 --- /dev/null +++ b/scripts/e2e/fixtures/audit.mjs @@ -0,0 +1,32 @@ +// Audit rows: what a journey observed of each action x clumsy variant, the raw +// material for the review's audit table. Nothing here judges a row: judgement +// is formed by eye, from the screenshots. +// +// Every row is printed as an `AUDIT {json}` line, so the run log carries the +// table (people grep run logs for it), and kept for flush(), which writes the +// rows to /audit.json beside the journey's screenshots +// (fixtures/shots.mjs owns the folder). Call flush() in afterAll, so a journey +// that fails part way still leaves the rows it recorded. +import fs from 'node:fs'; +import path from 'node:path'; +import { journeyDir } from './shots.mjs'; + +export function auditLog(journey) { + const file = path.join(journeyDir(journey), 'audit.json'); + const rows = []; + const record = (row) => { + rows.push(row); + console.log(`AUDIT ${JSON.stringify(row)}`); + }; + const flush = () => { + let prior = []; + try { prior = JSON.parse(fs.readFileSync(file, 'utf8')); } catch { prior = []; } + // A journey re-run replaces its own earlier rows (by their `journey` + // field) and keeps the others: one folder can hold several journeys' rows. + const journeys = new Set(rows.map((r) => r.journey)); + const kept = prior.filter((r) => !journeys.has(r.journey)); + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync(file, JSON.stringify([...kept, ...rows], null, 2)); + }; + return { record, flush, rows, file }; +} diff --git a/scripts/e2e/fixtures/clumsy.mjs b/scripts/e2e/fixtures/clumsy.mjs index d75d22ec5..20f817407 100644 --- a/scripts/e2e/fixtures/clumsy.mjs +++ b/scripts/e2e/fixtures/clumsy.mjs @@ -6,6 +6,8 @@ // retapWhileSaving a second tap while the button still reads "Saving…" // V3 interrupt reload / back / tab hidden / offline, then return // V4 hastyConfirm a confirm dialog answered by its loudest button +// hastyShiaijoConfirm the same for the shiaijo page's own confirms +// loudestButton the ranking both use, for any other panel // // Every helper performs a real input on a real control and RETURNS what // happened (which element was hit, how far apart the taps were, what the @@ -168,11 +170,15 @@ export async function interrupt(page, kind, { awayMs = 600, during } = {}) { return { kind, before, after: page.url() }; } case 'back': { - const back = await page.goBack(); + // Whether Back went anywhere is read from the URL, not from goBack()'s + // response: the SPA routes with history.pushState (router.jsx), and a + // same-document step answers null although it did go back. + await page.goBack(); const landedOn = page.url(); + const wentBack = landedOn !== before; await away(); - if (back) await page.goForward(); - return { kind, before, landedOn, hadHistory: !!back, after: page.url() }; + if (wentBack) await page.goForward(); + return { kind, before, landedOn, hadHistory: wentBack, after: page.url() }; } case 'hidden': { const setHidden = (hidden) => page.evaluate((h) => { @@ -207,47 +213,90 @@ export async function interrupt(page, kind, { awayMs = 600, during } = {}) { } } -// V4. Answer the open confirm dialog (confirmDialog in ui.jsx) the way an -// operator who does not read does: tap the most prominent button. Prominence -// is read from the rendered classes: danger over primary over a plain button -// over a ghost one, the larger button breaking a tie. Returns { label, -// prominence, title, message, cancelLabel }, so the audit row can say whether -// the loud button was the safe one. -export async function hastyConfirm(page) { - const dialog = page.locator('.modal[role="dialog"]').filter({ has: page.locator('.modal__foot') }).last(); - await dialog.waitFor({ state: 'visible' }); - const shown = Date.now(); - const buttons = dialog.locator('.modal__foot button'); - const rank = (cls) => (/\bbtn--danger\b/.test(cls) ? 3 : /\bbtn--primary\b/.test(cls) ? 2 : /\bbtn--ghost\b/.test(cls) ? 0 : 1); - const PROMINENCE = ['ghost', 'plain', 'primary', 'danger']; +// V4's ranking: the most prominent visible button inside `scope` (a Locator), +// the one an operator who does not read taps. Prominence is read from the +// rendered classes: danger over primary over a plain button over a ghost one, +// the larger button breaking a tie. Returns { button, label, prominence, +// otherLabels }, the last being the other visible buttons' labels. Throws when +// `scope` shows no button. +const PROMINENCE = ['ghost', 'plain', 'primary', 'danger']; +const prominenceRank = (cls) => (/\bbtn--danger\b/.test(cls) ? 3 : /\bbtn--primary\b/.test(cls) ? 2 : /\bbtn--ghost\b/.test(cls) ? 0 : 1); + +export async function loudestButton(scope) { + const buttons = scope.locator('button'); let best = null; const labels = []; for (let i = 0; i < await buttons.count(); i += 1) { const b = buttons.nth(i); + if (!(await b.isVisible())) continue; const cls = (await b.getAttribute('class')) || ''; const box = await b.boundingBox(); const label = (await b.innerText()).trim(); labels.push(label); - const score = [rank(cls), box ? box.width * box.height : 0]; + const score = [prominenceRank(cls), box ? box.width * box.height : 0]; if (!best || score[0] > best.score[0] || (score[0] === best.score[0] && score[1] > best.score[1])) { best = { b, label, score }; } } - if (!best) throw new Error('clumsy.hastyConfirm: the dialog has no buttons'); + if (!best) throw new Error(`clumsy.loudestButton: no visible button in ${scope}`); + return { + button: best.b, + label: best.label, + prominence: PROMINENCE[best.score[0]], + otherLabels: labels.filter((l) => l !== best.label), + }; +} + +// Wait until the dialog `handle` was pinned to has gone (hidden or detached), +// then let the handle go. A locator would re-resolve, so a second dialog the +// answer opens would be waited on instead of the one that was answered. +async function waitAnswered(handle) { + try { + await handle.waitForElementState('hidden', { timeout: 15_000 }); + } finally { + await handle.dispose(); + } +} + +// V4. Answer the open confirm dialog (confirmDialog in ui.jsx) the way an +// operator who does not read does: tap its loudest button (loudestButton). +// Returns { label, prominence, otherLabels, title, message }, so the audit row +// can say whether the loud button was the safe one. +export async function hastyConfirm(page) { + const dialog = page.locator('.modal[role="dialog"]').filter({ has: page.locator('.modal__foot') }).last(); + await dialog.waitFor({ state: 'visible' }); + const shown = Date.now(); + const handle = await dialog.elementHandle(); + const loud = await loudestButton(dialog.locator('.modal__foot')); const title = (await dialog.getAttribute('aria-label')) || ''; const message = ((await dialog.locator('.dialog-msg').allInnerTexts())[0] || '').trim(); - const { x, y } = await centreOf(best.b); + const { x, y } = await centreOf(loud.button); // A confirm ignores every tap inside the bounce window of its opening, so // "hasty" here is a hurried operator, never a tap before the guard lets one in. await dwell(page, shown); await page.touchscreen.tap(x, y); - await dialog.waitFor({ state: 'hidden' }); - return { - label: best.label, - prominence: PROMINENCE[best.score[0]], - otherLabels: labels.filter((l) => l !== best.label), - title, - message, - }; + await waitAnswered(handle); + const { button, ...row } = loud; + return { ...row, title, message }; +} + +// V4 for the shiaijo page's own confirms (`.shiaijo-move-confirm` in +// admin_shiaijo.jsx: Send back to queue, Move to Shiaijo X). They are not +// confirmDialog, so hastyConfirm cannot see them; the same rule answers them. +// Returns the same fields as hastyConfirm. +export async function hastyShiaijoConfirm(page) { + const dialog = page.locator('.shiaijo-move-confirm[role="dialog"]').last(); + await dialog.waitFor({ state: 'visible' }); + const shown = Date.now(); + const handle = await dialog.elementHandle(); + const loud = await loudestButton(dialog.locator('.shiaijo-move-confirm__actions')); + const title = ((await dialog.locator('.shiaijo-move-confirm__title').allInnerTexts())[0] || '').trim(); + const message = ((await dialog.locator('.shiaijo-move-confirm__body').allInnerTexts())[0] || '').trim(); + // The confirm ignores a tap inside the bounce window of its opening (fixtures/pace.mjs). + await dwell(page, shown); + await loud.button.tap(); + await waitAnswered(handle); + const { button, ...row } = loud; + return { ...row, title, message }; } diff --git a/scripts/e2e/fixtures/individual.mjs b/scripts/e2e/fixtures/individual.mjs index e4b136845..82956c7d8 100644 --- a/scripts/e2e/fixtures/individual.mjs +++ b/scripts/e2e/fixtures/individual.mjs @@ -1,14 +1,15 @@ // Helpers for the INDIVIDUAL operator journeys (knockout-mixed-individual -// spec): the rosters, a deterministic winner rule, the audit log, tap-target -// measurement, and the few surfaces the shared fixtures do not own (the -// overlay editor's identity line, the shiaijo page's own confirm dialogs, the -// decision panel). Owned by reviewer A; the shared fixtures stay untouched. -import fs from 'node:fs'; -import path from 'node:path'; -import { fileURLToPath } from 'node:url'; +// spec): the rosters, a deterministic winner rule, and the few surfaces the +// shared fixtures do not own (the overlay editor's identity line, the +// decision panel, playing and correcting a bout on the shiaijo page). The +// audit log (fixtures/audit.mjs), tap-target measurement (fixtures/pointer.mjs) +// and the hasty confirms (fixtures/clumsy.mjs) are shared with the other +// journeys. import { expect } from '@playwright/test'; import { EDITOR, INLINE_EDITOR } from './scoring.mjs'; -import { dwell, settled } from './pace.mjs'; +import { settled } from './pace.mjs'; +import { completedRowFor } from './shiaijo.mjs'; +import { hastyShiaijoConfirm } from './clumsy.mjs'; // Twelve entrants for F1 (pools of 3, two through): six dojos, two members // each, (name, dojo) unique. Names start with distinct letters so the @@ -48,39 +49,6 @@ export const ROSTER_4_JOINT = [ // so a pool's standings come out strict and no tie-break bout is added. export const winnerOf = ({ shiro, aka }) => (shiro.localeCompare(aka) <= 0 ? 'shiro' : 'aka'); -// Audit rows. Every row is printed (so the run log carries the table) and kept -// for afterAll, which writes them to AUDIT_JSON, beside the journey's -// screenshots in the gitignored output folder, for the person writing up the -// review. Nothing here judges a row: judgement is by eye. -export const AUDIT_JSON = path.resolve(fileURLToPath(new URL('../output/knockout-mixed-individual/audit.json', import.meta.url))); -export function auditLog() { - const rows = []; - const record = (row) => { - rows.push(row); - console.log(`AUDIT ${JSON.stringify(row)}`); - }; - const flush = () => { - let prior = []; - try { prior = JSON.parse(fs.readFileSync(AUDIT_JSON, 'utf8')); } catch { prior = []; } - // A journey re-run replaces its own earlier rows and keeps the others. - const journeys = new Set(rows.map((r) => r.journey)); - const kept = prior.filter((r) => !journeys.has(r.journey)); - fs.mkdirSync(path.dirname(AUDIT_JSON), { recursive: true }); - fs.writeFileSync(AUDIT_JSON, JSON.stringify([...kept, ...rows], null, 2)); - }; - return { record, flush, rows }; -} - -// A control's rendered size, for the 44px coarse-pointer floor. -// The page must still match (pointer: coarse) at the moment of measuring, or -// the 44px floor rules are off and the size reads low. -export async function tapSize(locator) { - expect(await locator.page().evaluate(() => matchMedia('(pointer: coarse)').matches)).toBe(true); - const box = await locator.boundingBox(); - if (!box) return null; - return { w: Math.round(box.width), h: Math.round(box.height), meets44: box.width >= 44 && box.height >= 44 }; -} - // The overlay editor's identity: its eyebrow (competition, pool/round) and its // title line ("Shiaijo B · 10:20"), which is what says which court it is on. export async function overlayIdentity(page) { @@ -115,33 +83,6 @@ export async function slotMarks(page, side, root = INLINE_EDITOR) { return labels.map((l) => (l.match(/: (?:remove )?(\S+)$/) || [])[1]).filter((v) => v && v !== 'empty'); } -// The shiaijo page's own confirm dialogs (Send back to queue, Move court) are -// not the shared confirmDialog, so clumsy.hastyConfirm cannot see them. Same -// rule here: tap the most prominent button, and say what it was. -export async function hastyShiaijoConfirm(page) { - const dialog = page.locator('.shiaijo-move-confirm[role="dialog"]').last(); - await dialog.waitFor({ state: 'visible' }); - const shown = Date.now(); - const buttons = dialog.locator('.shiaijo-move-confirm__actions button'); - let best = null; - const labels = []; - for (let i = 0; i < await buttons.count(); i += 1) { - const b = buttons.nth(i); - const cls = (await b.getAttribute('class')) || ''; - const label = (await b.innerText()).trim(); - labels.push(label); - const rank = /\bbtn--danger\b/.test(cls) ? 3 : /\bbtn--primary\b/.test(cls) ? 2 : /\bbtn--ghost\b/.test(cls) ? 0 : 1; - if (!best || rank > best.rank) best = { b, label, rank }; - } - const title = ((await dialog.locator('h3').allInnerTexts())[0] || '').trim(); - const message = ((await dialog.locator('p').allInnerTexts())[0] || '').trim(); - // The confirm ignores a tap inside the bounce window of its opening (fixtures/pace.mjs). - await dwell(page, shown); - await best.b.tap(); - await dialog.waitFor({ state: 'hidden' }); - return { label: best.label, prominence: ['ghost', 'plain', 'primary', 'danger'][best.rank], otherLabels: labels.filter((l) => l !== best.label), title, message }; -} - // The decision panel (DecisionPrompt, admin_scoring_shared.jsx) is inline in // the editor, not a dialog. export const decisionButton = (page, kind, root = INLINE_EDITOR) => page.locator(root).first() @@ -264,8 +205,3 @@ export async function queuedPoolBouts(page) { .allInnerTexts(); return texts.filter((t) => /Match \d+ of \d+/.test(t)).length; } - -// The newest Completed row on the shiaijo page whose names match a pairing. -export function completedRowFor(page, { shiro, aka }) { - return page.locator('.shiaijo-completed .shiaijo-qrow').filter({ hasText: shiro }).filter({ hasText: aka }).last(); -} diff --git a/scripts/e2e/fixtures/kachinuki.mjs b/scripts/e2e/fixtures/kachinuki.mjs index e7076c172..b0f6e7655 100644 --- a/scripts/e2e/fixtures/kachinuki.mjs +++ b/scripts/e2e/fixtures/kachinuki.mjs @@ -12,22 +12,17 @@ import { expect } from '@playwright/test'; import { INLINE_EDITOR } from '../../screenshots/lib/editor.mjs'; import { dwell, settled } from './pace.mjs'; +import { isCoarse, restoreCoarse } from './pointer.mjs'; -// Does the page still match (pointer: coarse)? Measured on this journey: after -// a fullPage screenshot (fixtures/shots.mjs with { fullPage: true }) the -// operator page no longer matches, so every tap-floor rule in styles.css -// silently switches off for the rest of the test. Put it back the way -// fixtures/test.mjs does (CDP touch emulation, which takes effect on the next -// document, hence the reload) and report whether it had been lost, so the -// audit can say which pointer a measurement was made at. +// Does the page still match (pointer: coarse)? If an interruption or a +// fullPage screenshot lost it, every tap-floor rule in styles.css would be off +// for the rest of the test, so put it back in place (fixtures/pointer.mjs, no +// reload, so the bout is not interrupted a second time) and report whether it +// had been lost, so the audit can say which pointer a measurement was made at. export async function ensureCoarse(page) { - const coarse = () => page.evaluate(() => matchMedia('(pointer: coarse)').matches); - if (await coarse()) return { coarseLost: false }; - const cdp = await page.context().newCDPSession(page); - await cdp.send('Emulation.setTouchEmulationEnabled', { enabled: true, maxTouchPoints: 5 }); - await page.reload(); - if (!(await coarse())) throw new Error('page is not (pointer: coarse) even with CDP touch emulation'); - return { coarseLost: true, restoredBy: 'CDP Emulation.setTouchEmulationEnabled + reload' }; + if (await isCoarse(page)) return { coarseLost: false }; + await restoreCoarse(page); + return { coarseLost: true, restoredBy: 'CDP Emulation.setTouchEmulationEnabled (in place)' }; } export const editor = (page) => page.locator(INLINE_EDITOR).filter({ has: page.locator('.team-bouts-scroll') }).first(); @@ -143,19 +138,6 @@ export async function doneRowWinner(row) { return ''; } -// A fighter name list left open on a bout row, by a tap that landed on a name -// box. Close it the way a thumb does, with a tap on the empty page margin (Escape -// would leave the box focused). Returns whether one was open. -export async function closeFighterList(page) { - const options = editor(page).locator('.pmf__option'); - const wasOpen = (await options.count()) > 0; - if (wasOpen) { - await page.touchscreen.tap(6, 500); - await expect(options).toHaveCount(0); - } - return wasOpen; -} - // The centre mark of a row (vs / X / (E)). export const boutMiddle = async (row) => (await row.locator('.team-sub-match__score').first().innerText()).trim(); diff --git a/scripts/e2e/fixtures/pointer.mjs b/scripts/e2e/fixtures/pointer.mjs new file mode 100644 index 000000000..07c033ab9 --- /dev/null +++ b/scripts/e2e/fixtures/pointer.mjs @@ -0,0 +1,41 @@ +// The operator's coarse pointer: reading it, putting it back, and measuring a +// tap target under it. +// +// The operator's iPad matches (pointer: coarse), and the tap-floor rules in +// styles.css key on it, so a journey judged at the wrong pointer measures the +// wrong targets. +import { expect } from '@playwright/test'; + +// Does this page match (pointer: coarse)? +export const isCoarse = (page) => page.evaluate(() => matchMedia('(pointer: coarse)').matches); + +// page -> the CDP session that holds its touch emulation. +const touchSessions = new WeakMap(); + +// Turn CDP touch emulation on for `page`, in place, and fail loudly if the +// page still does not match (pointer: coarse). Measured: it takes effect at +// once, on the current document (about:blank before the first navigation +// included), and holds across later navigations, so no reload is needed. The +// session is kept for the page's life: detaching it clears the emulation it +// set (measured), so a detach would undo the restore. +export async function restoreCoarse(page) { + let cdp = touchSessions.get(page); + if (!cdp) { + cdp = await page.context().newCDPSession(page); + touchSessions.set(page, cdp); + } + await cdp.send('Emulation.setTouchEmulationEnabled', { enabled: true, maxTouchPoints: 5 }); + if (!(await isCoarse(page))) { + throw new Error('page is not (pointer: coarse) even with CDP touch emulation'); + } +} + +// A control's rendered size, for the 44px coarse-pointer floor. The page must +// still match (pointer: coarse) at the moment of measuring, or the 44px floor +// rules are off and the size reads low, so that is asserted first. +export async function tapSize(locator) { + expect(await isCoarse(locator.page()), 'tap targets are measured under (pointer: coarse)').toBe(true); + const box = await locator.boundingBox(); + if (!box) return null; + return { w: Math.round(box.width), h: Math.round(box.height), meets44: box.width >= 44 && box.height >= 44 }; +} diff --git a/scripts/e2e/fixtures/shiaijo.mjs b/scripts/e2e/fixtures/shiaijo.mjs index 29a48563d..a70764cb8 100644 --- a/scripts/e2e/fixtures/shiaijo.mjs +++ b/scripts/e2e/fixtures/shiaijo.mjs @@ -39,8 +39,13 @@ export async function startUpNext(page) { return pair; } -// The newest completed match (the Completed list is oldest first, so the -// newest is its tail) with its score string, e.g. "M vs –". +// The newest Completed row whose names match a pairing (the Completed list is +// oldest first, so the newest is its tail). +export function completedRowFor(page, { shiro, aka }) { + return completedRows(page).filter({ hasText: shiro }).filter({ hasText: aka }).last(); +} + +// The newest completed match with its score string, e.g. "M vs –". export async function lastCompleted(page) { const row = completedRows(page).last(); await expect(row).toBeVisible(); diff --git a/scripts/e2e/fixtures/shots.mjs b/scripts/e2e/fixtures/shots.mjs index 411ecada6..1e2eb8537 100644 --- a/scripts/e2e/fixtures/shots.mjs +++ b/scripts/e2e/fixtures/shots.mjs @@ -1,41 +1,46 @@ -// Per-step screenshots: output//-.png, numbered in the +// Per-step screenshots: /-.png, numbered in the // order they were taken so the folder reads as the walkthrough. They are for // a human to judge (and for the PR body), never compared by a test. import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; +import { isCoarse, restoreCoarse } from './pointer.mjs'; -const OUTPUT = path.resolve(fileURLToPath(new URL('../output', import.meta.url))); +// scripts/e2e, the directory playwright.config.mjs sits in. +const E2E_DIR = path.resolve(fileURLToPath(new URL('..', import.meta.url))); -// page -> the CDP session that re-applies touch emulation after a fullPage shot. -const touchSessions = new WeakMap(); +// Where every journey's folder lives. With E2E_RESULTS_DIR set (a second run +// at the same time, see README.md) it is inside that run's own results folder, +// resolved the way Playwright resolves outputDir (relative to the config's +// directory), so two runs never empty each other's folders; Playwright empties +// that folder once, when the run starts, before any test writes. Unset, it is +// output/. +const JOURNEYS_ROOT = process.env.E2E_RESULTS_DIR + ? path.resolve(E2E_DIR, process.env.E2E_RESULTS_DIR, 'journeys') + : path.join(E2E_DIR, 'output'); + +// The one owner of a journey's output folder: its screenshots and its +// audit.json (fixtures/audit.mjs). `journey` may name a subfolder +// ('knockout-mixed-individual/J1'). +export const journeyDir = (journey) => path.join(JOURNEYS_ROOT, journey); // Empty the journey's folder and return its camera. Call once per spec file // (in beforeAll), so a folder never mixes two runs. export function journeyShots(journey) { - const dir = path.join(OUTPUT, journey); + const dir = journeyDir(journey); fs.rmSync(dir, { recursive: true, force: true }); fs.mkdirSync(dir, { recursive: true }); let n = 0; return async (page, step, { fullPage = false } = {}) => { n += 1; const file = path.join(dir, `${String(n).padStart(2, '0')}-${step}.png`); - const coarse = fullPage && await page.evaluate(() => matchMedia('(pointer: coarse)').matches); + const coarse = fullPage && await isCoarse(page); await page.screenshot({ path: file, fullPage, animations: 'disabled', caret: 'hide' }); // A fullPage shot resizes the viewport and, restoring it, drops the touch // emulation: measured, (pointer: coarse) reads false afterwards, so every - // tap-floor rule switches off for the rest of the test. Turning touch - // emulation back on restores it in place, no reload needed (measured). - // The CDP session is kept for the page's life: detaching it clears the - // emulation it set (measured), so a detach would undo the restore. - if (coarse) { - let cdp = touchSessions.get(page); - if (!cdp) { - cdp = await page.context().newCDPSession(page); - touchSessions.set(page, cdp); - } - await cdp.send('Emulation.setTouchEmulationEnabled', { enabled: true, maxTouchPoints: 5 }); - } + // tap-floor rule switches off for the rest of the test. Put it back in + // place (fixtures/pointer.mjs). + if (coarse) await restoreCoarse(page); return file; }; } diff --git a/scripts/e2e/fixtures/team.mjs b/scripts/e2e/fixtures/team.mjs index fb5d5350a..ac0ca4075 100644 --- a/scripts/e2e/fixtures/team.mjs +++ b/scripts/e2e/fixtures/team.mjs @@ -22,10 +22,6 @@ import { settled } from './pace.mjs'; export const FIK5 = ['Senpo', 'Jiho', 'Chuken', 'Fukusho', 'Taisho']; export const positionLabels = (teamSize) => (teamSize === 5 ? FIK5 : Array.from({ length: teamSize }, (_, i) => String(i + 1))); -// The team sheet's per-row position name, used in its aria-labels -// (admin_scoring_team.jsx positionLabelFor): FIK names at five, else "Match N". -export const sheetPositionLabel = (teamSize, idx) => (teamSize === 5 ? FIK5[idx] : `Match ${idx + 1}`); - // Sign in, creating the tournament first if this server has none yet. Every // test in a serial file can call it, whichever runs first. export async function enterAdmin(page, { courts = 16 } = {}) { @@ -102,13 +98,20 @@ export async function lineupsStartAdd(page, label, name) { return { dialog: await dialog.isVisible(), alert: (await alert.isVisible()) ? (await alert.innerText()).trim() : '' }; } -// Answer the open confirm dialog with its named button. +// Answer the open confirm dialog with its named button. The wait is on the +// dialog that was answered (its element handle), not on a re-resolving +// locator, which a second dialog opened by the answer would satisfy. export async function answerDialog(page, label) { const dialog = page.locator('.modal[role="dialog"]').filter({ has: page.locator('.modal__foot') }).last(); // A confirm ignores a tap inside the bounce window of its opening. await settled(dialog); - await dialog.getByRole('button', { name: label }).tap(); - await dialog.waitFor({ state: 'hidden' }); + const handle = await dialog.elementHandle(); + try { + await dialog.getByRole('button', { name: label }).tap(); + await handle.waitForElementState('hidden', { timeout: 15_000 }); + } finally { + await handle.dispose(); + } } // Name a position's blank seeded slot through "+ Add new member…" and the @@ -155,11 +158,19 @@ export async function pickFromNameBox(input, text) { await option.tap(); } -// Close an open name list the way a thumb does: a tap on empty page margin. -// (Escape leaves the box focused, and a focused box does not reopen its list -// on the next tap, which a touch operator never meets.) -export async function dismissNameBox(page) { - await page.touchscreen.tap(6, 500); +// A name list left open (by the operator, or by a tap that landed on a name +// box). Close it the way a thumb does, with a tap on the empty page margin: +// Escape leaves the box focused, and a focused box does not reopen its list +// on the next tap, which a touch operator never meets. Returns whether one was +// open. Any LineupNameInput: the panel, the team sheet, the kachinuki sheet. +export async function closeNameList(page) { + const options = page.locator('.pmf__option'); + const wasOpen = (await options.count()) > 0; + if (wasOpen) { + await page.touchscreen.tap(6, 500); + await expect(options).toHaveCount(0); + } + return wasOpen; } // Type a name into a LineupNameInput and commit it with Enter. @@ -268,13 +279,3 @@ export async function finishTeam(root) { await btn.tap(); await expect(root.locator('button', { hasText: /^(Tap again to finish|Saving…)/ })).toHaveCount(0, { timeout: 15000 }); } - -// Tap-target size of a control, for the audit's >= 44px rule. -// Measured only under (pointer: coarse), where the 44px tap-floor rules apply: -// the measurement asserts it first. -export async function tapBox(locator) { - const coarse = await locator.page().evaluate(() => matchMedia('(pointer: coarse)').matches); - expect(coarse, 'tap targets are measured under (pointer: coarse)').toBe(true); - const b = await locator.boundingBox(); - return b ? { w: Math.round(b.width), h: Math.round(b.height), ok: b.width >= 44 && b.height >= 44 } : null; -} diff --git a/scripts/e2e/fixtures/test.mjs b/scripts/e2e/fixtures/test.mjs index 09f9a8b5f..6b6dc6b08 100644 --- a/scripts/e2e/fixtures/test.mjs +++ b/scripts/e2e/fixtures/test.mjs @@ -1,50 +1,63 @@ -// The `test` every journey imports: Playwright's own, plus a server per -// worker and an operator page whose pointer is verified coarse. +// The `test` every journey imports: Playwright's own, plus a server per spec +// file and an operator page whose pointer is verified coarse. import { test as base, expect } from '@playwright/test'; import { start } from '../../screenshots/lib/server.mjs'; +import { isCoarse, restoreCoarse } from './pointer.mjs'; -// Does this page match (pointer: coarse)? The operator's iPad does, and the -// tap-floor rules in styles.css key on it, so a journey judged at the wrong -// pointer measures the wrong targets. -export const isCoarse = (page) => page.evaluate(() => matchMedia('(pointer: coarse)').matches); +export { isCoarse }; export const test = base.extend({ - // One mobile-app server per WORKER, on a free port and a throwaway + // One mobile-app server per SPEC FILE, on a free port and a throwaway // TOURNAMENT_DATA_DIR, started and stopped by scripts/screenshots' - // server.mjs (SIGTERM then SIGKILL by PID). Tests in a worker share it, so a - // spec that seeds once and walks several tests declares - // test.describe.configure({ mode: 'serial' }). - server: [async ({}, use) => { - let server; - try { - server = await start('mobile'); - } catch (err) { - throw new Error(`${err.message}\n(make e2e builds the binary before it runs the suite)`); - } + // server.mjs (SIGTERM then SIGKILL by PID). Playwright hands a worker one + // file after another, so a worker-scoped server would greet the second file + // with the first file's tournament; this fixture keeps at most one server, + // and `serverForFile(file)` stops it and starts a fresh one when the file + // changes. Tests in a file share theirs, so a spec that seeds once and walks + // several tests declares test.describe.configure({ mode: 'serial' }). A + // beforeAll hook (worker scope) asks for it with its own testInfo.file. + serverForFile: [async ({}, use) => { + let current = null; // { file, server } + const forFile = async (file) => { + if (current && current.file === file) return current.server; + if (current) { + const old = current; + current = null; + await old.server.stop(); + } + let server; + try { + server = await start('mobile'); + } catch (err) { + throw new Error(`${err.message}\n(make e2e builds the binary before it runs the suite)`); + } + current = { file, server }; + return server; + }; try { - await use(server); + await use(forFile); } finally { - await server.stop(); + if (current) await current.server.stop(); } }, { scope: 'worker' }], - // Every journey navigates by path; the host is the worker's server. + // The server of the file this test is in. + server: async ({ serverForFile }, use, testInfo) => { + await use(await serverForFile(testInfo.file)); + }, + + // Every journey navigates by path; the host is the file's server. baseURL: async ({ server }, use) => { await use(server.base); }, // The operator project's page must really have a coarse pointer. hasTouch + // isMobile (fixtures/devices.mjs) is what provides it today, measured. If - // that ever stops holding, turn on CDP touch emulation, which flips the - // media query on the next document, and fail loudly if even that does not. - page: async ({ page, context }, use, testInfo) => { + // that ever stops holding, turn on CDP touch emulation in place + // (restoreCoarse, which fails loudly if even that does not do it). + page: async ({ page }, use, testInfo) => { if (testInfo.project.name === 'operator' && !(await isCoarse(page))) { - const cdp = await context.newCDPSession(page); - await cdp.send('Emulation.setTouchEmulationEnabled', { enabled: true, maxTouchPoints: 5 }); - await page.reload(); - if (!(await isCoarse(page))) { - throw new Error('operator page is not (pointer: coarse) even with CDP touch emulation'); - } + await restoreCoarse(page); testInfo.annotations.push({ type: 'coarse-pointer', description: 'CDP Emulation.setTouchEmulationEnabled' }); } await use(page); diff --git a/scripts/e2e/journeys/knockout-mixed-individual.spec.mjs b/scripts/e2e/journeys/knockout-mixed-individual.spec.mjs index cc7bfb038..4644798cc 100644 --- a/scripts/e2e/journeys/knockout-mixed-individual.spec.mjs +++ b/scripts/e2e/journeys/knockout-mixed-individual.spec.mjs @@ -1,7 +1,7 @@ // Operator review journeys for INDIVIDUAL competitions (mp-yqxn.2, reviewer A). // // Every fixture is built through the interface in beforeAll (first-run form, -// wizard, roster paste box, Generate draw, Start), one server per worker: +// wizard, roster paste box, Generate draw, Start), on this file's own server: // F1 pools + knockout, 12 entrants, shiaijo A and B (J1) // F1b pools + knockout, 6 entrants, shiaijo E (J2 decisions) // F2 knockout only, joint 3rd UNTICKED (bronze), shiaijo C (J2 hantei, J6) @@ -9,12 +9,12 @@ // F1c pools + knockout, 6 entrants, shiaijo F (J8 Pools tab) // Each journey performs the correct path and the clumsy-operator variants // (fixtures/clumsy.mjs, plus fixtures/individual.mjs), records one AUDIT row -// per action x variant (written to output/knockout-mixed-individual/audit.json), -// and asserts only what the docs promise. Judgement (friction, legibility) is -// formed from the screenshots in output/knockout-mixed-individual/, never -// asserted here. The tests at the end are functional defects -// the journeys found; each seeds its own competition so it runs alone once -// its bead fixes the defect and the fixme is removed. +// per action x variant (written to the journey folder's audit.json, +// fixtures/audit.mjs), and asserts only what the docs promise. Judgement +// (friction, legibility) is formed from the screenshots in the same folder +// (fixtures/shots.mjs), never asserted here. The tests at the end +// are functional defects the journeys found, now fixed: each seeds its own +// competition, runs alone, and guards its fix. import { test, expect, isCoarse } from '../fixtures/test.mjs'; import { OPERATOR_DEVICE } from '../fixtures/devices.mjs'; import { journeyShots } from '../fixtures/shots.mjs'; @@ -22,33 +22,39 @@ import { createTournament, login } from '../fixtures/setup.mjs'; import { createCompetition } from '../fixtures/wizard.mjs'; import { generateDraw, pasteRoster, startCompetition } from '../fixtures/competition.mjs'; import { - openShiaijo, upNextCard, inlineEditor, sides, completedRows, lastCompleted, + openShiaijo, upNextCard, inlineEditor, sides, completedRows, completedRowFor, lastCompleted, } from '../fixtures/shiaijo.mjs'; import { INLINE_EDITOR, EDITOR, ipponButton, syncState, filledSlots, finishButton, armedFinishButton, armFinish, finishMatch, } from '../fixtures/scoring.mjs'; -import { doubleTap, hastyConfirm, interrupt, retapWhileSaving, tapNeighbour } from '../fixtures/clumsy.mjs'; import { - ROSTER_12, ROSTER_6, ROSTER_4_BRONZE, ROSTER_4_JOINT, ROSTER_6B, rosterOf, auditLog, tapSize, inlineIdentity, overlayIdentity, - editorSides, slotMarks, winnerOf, inlineRunning, ensureRunning, playRunningBout, completedRowFor, - hastyShiaijoConfirm, decisionButton, decisionPrompt, recordDecision, queuedPoolBouts, correctCompleted, + doubleTap, hastyConfirm, hastyShiaijoConfirm, interrupt, retapWhileSaving, tapNeighbour, +} from '../fixtures/clumsy.mjs'; +import { auditLog } from '../fixtures/audit.mjs'; +import { tapSize } from '../fixtures/pointer.mjs'; +import { + ROSTER_12, ROSTER_6, ROSTER_4_BRONZE, ROSTER_4_JOINT, ROSTER_6B, rosterOf, inlineIdentity, overlayIdentity, + editorSides, slotMarks, winnerOf, inlineRunning, ensureRunning, playRunningBout, + decisionButton, decisionPrompt, recordDecision, queuedPoolBouts, correctCompleted, } from '../fixtures/individual.mjs'; test.describe.configure({ mode: 'serial' }); const comps = {}; -const audit = auditLog(); +const audit = auditLog('knockout-mixed-individual'); -// Let the running bout's autosave land: the pill reads "Synced" during the -// 300ms debounce as well, so the pill alone does not prove the write went out. +// Let the running bout's autosave land. The pill reads "Syncing…" from the tap +// until the write has landed (bc-sync: the tap's handler registers the edit +// with API.notePendingEdit, and the pill has re-rendered by the time tap() +// returns), so "Synced" after a tap means the write went out and came back. async function settle(page, root = INLINE_EDITOR) { - await page.waitForTimeout(700); await expect(syncState(page, root)).toHaveText('Synced'); } test.describe('knockout-mixed-individual', () => { - test.beforeAll(async ({ browser, server }) => { + test.beforeAll(async ({ browser, serverForFile }, testInfo) => { test.setTimeout(240_000); + const server = await serverForFile(testInfo.file); const ctx = await browser.newContext({ ...OPERATOR_DEVICE, baseURL: server.base }); const page = await ctx.newPage(); await createTournament(page, { name: 'Individual Review Cup', courts: 15 }); @@ -138,7 +144,8 @@ test.describe('knockout-mixed-individual', () => { await settle(page); // V3 edge: a reload INSIDE the 300ms autosave debounce, right after a tap. - // The pill already reads "Synced" at that moment. + // The pill reads "Syncing…" from the tap (pillAtTap records what it shows), + // and the page-hide flush writes the pending edit before the page goes. await ipponButton(page, other, 'K').tap(); const pillAtTap = (await syncState(page).innerText()).trim(); await interrupt(page, 'reload'); @@ -993,8 +1000,9 @@ test.describe('knockout-mixed-individual', () => { if (advanced) expect((await inlineIdentity(page)).court).toBe('F'); } }); - // Functional defects found by the journeys above. Each seeds its own - // competition on its own shiaijo so it can run alone once fixed. + // Functional defects found by the journeys above, each now fixed and named + // by its bead. Each seeds its own competition on its own shiaijo, so it runs + // alone, and guards its fix. const seedOwn = async (page, opts, roster) => { const id = await createCompetition(page, { kind: 'individual', ...opts }); await pasteRoster(page, id, roster); diff --git a/scripts/e2e/journeys/knockout-mixed-kachinuki.spec.mjs b/scripts/e2e/journeys/knockout-mixed-kachinuki.spec.mjs index ecf73443e..7605d53d0 100644 --- a/scripts/e2e/journeys/knockout-mixed-kachinuki.spec.mjs +++ b/scripts/e2e/journeys/knockout-mixed-kachinuki.spec.mjs @@ -17,11 +17,11 @@ // allow, with the taisho drawing, staying on, and then being defeated. The // final is played out and a spectator's phone sees the finished knockout. // -// Functional defects found here are test.fixme(': ...') blocks, -// each a self-contained reproduction placed where the fixture supports it. -// Each was run once as a plain test() and failed at its own assertion. The -// tests after one tolerate what it leaves behind, so removing a fixme once -// its bead is fixed does not disturb them. Judgement findings, and behaviour +// Functional defects found here were filed as beads and kept as tests named +// after them (': ...'), each a self-contained reproduction placed +// where the fixture supports it: first as test.fixme, and, once the bead was +// fixed, as plain tests that guard the fix. The tests after one tolerate what +// it leaves behind. Judgement findings, and behaviour // the docs do not rule on (Send back to queue under a REOPENED encounter // wiping its bout log: the docs promise the log survives a reopen, and that // Send back to queue clears a running match's score), are recorded as audit @@ -30,27 +30,32 @@ // Asserted: only the functional outcomes the docs promise (who stays on, the // recorded result, the bracket advancing). The clumsy variants are PERFORMED // through fixtures/clumsy.mjs and RECORDED as audit rows (AUDIT lines in the -// log, audit.json attached); whether an outcome is acceptable is judged from -// the screenshots in output/knockout-mixed-kachinuki/, never asserted. +// log, and the journey folder's audit.json, fixtures/audit.mjs); whether an +// outcome is acceptable is judged from the screenshots in the same folder +// (fixtures/shots.mjs), never asserted. import { test, expect } from '../fixtures/test.mjs'; import { journeyShots } from '../fixtures/shots.mjs'; +import { auditLog } from '../fixtures/audit.mjs'; import { createTournament, login } from '../fixtures/setup.mjs'; import { createCompetition } from '../fixtures/wizard.mjs'; import { generateDraw, pasteRoster, startCompetition } from '../fixtures/competition.mjs'; -import { openShiaijo, sides, upNextCard } from '../fixtures/shiaijo.mjs'; +import { completedRowFor, openShiaijo, sides, upNextCard } from '../fixtures/shiaijo.mjs'; import { PUBLIC_DEVICE } from '../fixtures/devices.mjs'; import { openViewer } from '../fixtures/public.mjs'; import { scoreRows } from '../fixtures/scores.mjs'; import { EDITOR } from '../../screenshots/lib/editor.mjs'; import { dwell, settled } from '../fixtures/pace.mjs'; -// clumsy.hastyConfirm answers confirmDialog (ui.jsx) only. The other confirms -// on this journey are the shiaijo page's own `.shiaijo-move-confirm`, the -// inline reason prompt and the inline court-busy panel, which it cannot see; -// the local helpers at the end of this file answer those the same way. -import { doubleTap, hastyConfirm, interrupt, retapWhileSaving, tapNeighbour } from '../fixtures/clumsy.mjs'; +// clumsy.hastyConfirm answers confirmDialog (ui.jsx) and hastyShiaijoConfirm +// the shiaijo page's own `.shiaijo-move-confirm`. The inline reason prompt and +// the inline court-busy panel are neither; the local helpers at the end of +// this file answer those by the same ranking (clumsy.loudestButton). +import { + doubleTap, hastyConfirm, hastyShiaijoConfirm, interrupt, loudestButton, retapWhileSaving, tapNeighbour, +} from '../fixtures/clumsy.mjs'; +import { closeNameList } from '../fixtures/team.mjs'; import { awardBoutIppon, boutFilled, ensureCoarse, boutIpponButton, boutMiddle, boutNames, closeLineupPanel, correctingBout, - closeFighterList, currentBout, doneRowWinner, doneRows, editor, enchoButton, enchoUndoButton, endMatch, endMatchButton, lineupSide, lineupSource, + currentBout, doneRowWinner, doneRows, editor, enchoButton, enchoUndoButton, endMatch, endMatchButton, lineupSide, lineupSource, openLineupFromUpNext, recordBout, recordBoutButton, removeBoutButton, reopenButton, syncPill, tieButton, typeLineup, winBout, } from '../fixtures/kachinuki.mjs'; @@ -78,13 +83,10 @@ test.describe.configure({ mode: 'serial' }); test.describe('J5 kachinuki from the court console', () => { let shot; let compId; - const audit = []; // One audit row: step | action | variant | what happened (observed facts). // The judgement columns are formed from the screenshots, not here. - const record = (row) => { - audit.push(row); - console.log(`AUDIT ${JSON.stringify(row)}`); - }; + const audit = auditLog(JOURNEY); + const { record } = audit; // Pairings as the draw put them (read back, never assumed). let m1; // { shiro, aka } team names on M1 (court A) let m2; // on M2 (court B) @@ -94,9 +96,7 @@ test.describe('J5 kachinuki from the court console', () => { shot = journeyShots(JOURNEY); }); - test.afterAll(async ({}, testInfo) => { - await testInfo.attach('audit.json', { body: JSON.stringify(audit, null, 2), contentType: 'application/json' }); - }); + test.afterAll(() => audit.flush()); test('F5: seed a kachinuki knockout and name every team member at the court', async ({ page }) => { await createTournament(page, { name: 'Kachinuki Cup', courts: 2 }); @@ -200,7 +200,7 @@ test.describe('J5 kachinuki from the court console', () => { const v2Shot = await shot(page, 'v2-ippon-double-tap'); // Recover: close a fighter list the second tap opened, then tap the second // mark if there is one (the editor's "Tap a scored mark to clear it"). - const fighterListOpened = await closeFighterList(page); + const fighterListOpened = await closeNameList(page); let recoverTaps = 0; while ((await boutFilled(currentBout(page), 'shiro').count()) > 1) { await boutFilled(currentBout(page), 'shiro').last().tap(); @@ -355,10 +355,11 @@ test.describe('J5 kachinuki from the court console', () => { await recordBout(page); await expectPairing(7, S[3], A[4]); await shot(page, 'bout-7-open-seven-deep'); - // Harness defect, measured here: a fullPage screenshot leaves the page at - // a FINE pointer afterwards, so the coarse tap floors switch off. This - // journey takes fullPage shots only where a reload is harmless (or one - // follows anyway) and restores the coarse pointer straight after. + // Measured here: a fullPage screenshot leaves the page at a FINE pointer + // afterwards, which would switch the coarse tap floors off. The camera + // (fixtures/shots.mjs) puts the coarse pointer back in place, with no + // reload; this row records the pointer before and after the shot, and + // whether ensureCoarse still found it lost. const coarseBeforeFull = await page.evaluate(() => matchMedia('(pointer: coarse)').matches); await shot(page, 'bout-7-open-seven-deep-full', { fullPage: true }); const coarseAfterFull = await page.evaluate(() => matchMedia('(pointer: coarse)').matches); @@ -545,8 +546,7 @@ test.describe('J5 kachinuki from the court console', () => { const end = endMatchButton(page); const endBox = await end.boundingBox(); const v2end = await doubleTap(end); - const completed = await page.locator('.shiaijo-completed .shiaijo-qrow') - .filter({ hasText: m1.shiro }).filter({ hasText: m1.aka }).first() + const completed = await completedRowFor(page, m1) .waitFor({ state: 'visible', timeout: 8000 }).then(() => true, () => false); const endLabelAfter = completed ? null : (await end.innerText().catch(() => null)); record({ step: 'M1 end', action: 'End match (two-tap guard)', variant: 'V2 doubleTap', ...v2end, endBox, @@ -554,7 +554,7 @@ test.describe('J5 kachinuki from the court console', () => { if (!completed) { if (/^Tap again/.test(endLabelAfter || '')) await end.tap(); else await endMatch(page); } - const row = completedRow(page, m1); + const row = completedRowFor(page, m1); await expect(row).toBeVisible(); // Shiro won bouts 1, 5, 6 and 8 (the walkover's two maru), Aka 2 and 4: // IV 4-2, PW 7-3 (bout 5's double tap scores one M since bc-dtip; before @@ -568,7 +568,7 @@ test.describe('J5 kachinuki from the court console', () => { await page.waitForTimeout(1500); record({ step: 'M1 end', action: 'Completed row after End match', variant: 'result', expected: 'IV 4–2 PW 7–3', shownFor6s: shownAtFirst, settledWithoutRefresh: settled, - afterRefresh: (await completedRow(page, m1).locator('.shiaijo-qrow__result').innerText()).replace(/\s+/g, ' '), + afterRefresh: (await completedRowFor(page, m1).locator('.shiaijo-qrow__result').innerText()).replace(/\s+/g, ' '), screenshot: staleShot }); // The bracket advanced: the final on A now names M1's winner (Shiro). await expect(page.locator('.shiaijo-pending, .shiaijo-upnext').first()).toContainText(m1.shiro); @@ -584,13 +584,13 @@ test.describe('J5 kachinuki from the court console', () => { await test.step('Correct on the completed row opens the finished encounter', async () => { // V1: is there anything beside Correct for the thumb to land on? - const v1 = await tapNeighbour(completedRow(page, m1).locator('.shiaijo-row__correct'), 'left') + const v1 = await tapNeighbour(completedRowFor(page, m1).locator('.shiaijo-row__correct'), 'left') .catch((e) => ({ none: e.message.split('\n')[0] })); await page.waitForTimeout(300); - const cb = await completedRow(page, m1).locator('.shiaijo-row__correct').boundingBox(); + const cb = await completedRowFor(page, m1).locator('.shiaijo-row__correct').boundingBox(); record({ step: 'M1 reopen', action: 'Correct (Completed row)', variant: 'V1 tapNeighbour left', ...v1, correctBox: cb && `${Math.round(cb.width)}x${Math.round(cb.height)}`, screenshot: await shot(page, 'v1-correct-neighbour') }); - await completedRow(page, m1).locator('.shiaijo-row__correct').tap(); + await completedRowFor(page, m1).locator('.shiaijo-row__correct').tap(); await expect(reopenButton(page)).toBeVisible(); const rows = editor(page).locator('.team-sub-match'); record({ step: 'M1 reopen', action: 'Correct (Completed row)', variant: 'correct path', @@ -678,7 +678,7 @@ test.describe('J5 kachinuki from the court console', () => { const reload = await interruptC(page, 'reload'); await expect(editor(page)).toBeVisible(); const stillRunning = await recordBoutButton(page).isVisible(); - const endedByReload = await completedRow(page, m1).isVisible(); + const endedByReload = await completedRowFor(page, m1).isVisible(); record({ step: 'M1 reopen', action: 'End match armed', variant: 'V3 interrupt reload', ...reload, armedLabel, stillRunning, endedByReload, bout8: await boutNames(currentBout(page)), bout8Marks: await boutFilled(currentBout(page), 'shiro').allInnerTexts(), @@ -692,14 +692,14 @@ test.describe('J5 kachinuki from the court console', () => { // End for real, clumsily: two taps inside 300ms on the two-tap guard // (the same clumsy variant M1's first ending used above). const v2end = await doubleTap(endMatchButton(page)); - const completed = await completedRow(page, m1).waitFor({ state: 'visible', timeout: 8000 }).then(() => true, () => false); + const completed = await completedRowFor(page, m1).waitFor({ state: 'visible', timeout: 8000 }).then(() => true, () => false); const endLabelAfter = completed ? null : (await endMatchButton(page).innerText().catch(() => null)); record({ step: 'M1 reopen', action: 'End match (two-tap guard)', variant: 'V2 doubleTap', ...v2end, completedByDoubleTap: completed, endLabelAfter, screenshot: await shot(page, 'v2-end-match-double-tap-reopen') }); if (!completed) { if (/^Tap again/.test(endLabelAfter || '')) await endMatchButton(page).tap(); else await endMatch(page); } - const row = completedRow(page, m1); + const row = completedRowFor(page, m1); await expect(row).toBeVisible(); await expect(row.locator('.shiaijo-qrow__result')).toContainText('IV'); const immediately = (await row.locator('.shiaijo-qrow__result').innerText()).replace(/\s+/g, ' '); @@ -992,12 +992,12 @@ test.describe('J5 kachinuki from the court console', () => { // The second tap counts once the arming tap's bounce window has passed. await settled(endMatchButton(page)); await endMatchButton(page).tap(); - await expect(completedRow(page, m2)).toBeVisible(); + await expect(completedRowFor(page, m2)).toBeVisible(); await shot(page, 'm2-completed'); }); await test.step('the hurried operator reopens M2 and taps Send back to queue to get out', async () => { - await completedRow(page, m2).locator('.shiaijo-row__correct').tap(); + await completedRowFor(page, m2).locator('.shiaijo-row__correct').tap(); await reopenButton(page).tap(); await expect(recordBoutButton(page)).toBeVisible({ timeout: 15_000 }); const boutsBefore = await doneRows(page).count(); @@ -1040,7 +1040,7 @@ test.describe('J5 kachinuki from the court console', () => { await settled(endMatchButton(page)); await endMatchButton(page).tap(); } - await expect(completedRow(page, m2)).toBeVisible(); + await expect(completedRowFor(page, m2)).toBeVisible(); await shot(page, 'm2-completed-again'); }); }); @@ -1053,7 +1053,7 @@ test.describe('J5 kachinuki from the court console', () => { await login(page); await openShiaijo(page, 'A'); const next = await sides(upNextCard(page)); - await completedRow(page, m1).locator('.shiaijo-row__correct').tap(); + await completedRowFor(page, m1).locator('.shiaijo-row__correct').tap(); await expect(reopenButton(page)).toBeVisible(); const start = upNextCard(page).getByRole('button', { name: 'Start match' }); await start.tap(); @@ -1136,9 +1136,9 @@ test.describe('J5 kachinuki from the court console', () => { }); }); - // bc-emsl: a tap on an EMPTY mark slot (the "·" beside the scored - // marks) is not a no-op: it clears the bout's Tie. - test('bc-emsl: tapping an empty mark slot clears the bout\'s Tie', async ({ page }) => { + // bc-emsl (fixed): a tap on an EMPTY mark slot (the "·" beside the scored + // marks) used to clear the bout's Tie. It is a no-op now; this guards it. + test('bc-emsl: tapping an empty mark slot leaves the bout\'s Tie on', async ({ page }) => { await login(page); await openShiaijo(page, 'A'); const row = currentBout(page); @@ -1154,9 +1154,10 @@ test.describe('J5 kachinuki from the court console', () => { await awardBoutIppon(page, 'shiro', 'M'); }); - // bc-kheb: Encho on ONE kachinuki bout sets the encounter's overtime - // count, so the header keeps reading "(E) OVERTIME" on every later bout. - test('bc-kheb: after one bout goes to encho, the header claims overtime on every later bout', async ({ page }) => { + // bc-kheb (fixed): Encho on ONE kachinuki bout used to set the encounter's + // overtime count, so the header kept reading "(E) OVERTIME" on every later + // bout. The (E) now lives on that bout's row only; this guards it. + test('bc-kheb: after one bout goes to encho, the next bout\'s header does not claim overtime', async ({ page }) => { await login(page); await openShiaijo(page, 'A'); const row = currentBout(page); @@ -1169,18 +1170,41 @@ test.describe('J5 kachinuki from the court console', () => { await expect(editor(page).locator('.editor-modal__eyebrow').first()).not.toContainText(/overtime/i); }); - // bc-sync: for the autosave's debounce window after a tap, the sync pill - // already reads "Synced" although nothing has been sent; a reload in that - // window loses the point with no warning (audit row "final bout 4"). - test('bc-sync: the sync pill reads "Synced" while a tapped point is still unsaved', async ({ page }) => { + // bc-sync (fixed): the sync pill used to read "Synced" through the + // autosave's debounce window after a tap, although nothing had been sent, + // so a reload there lost the point with no warning (audit row "final bout + // 4"). It reads "Syncing…" from the tap until the write lands; this guards + // it. The score write is held, so the pill is read while the point is + // provably still unsaved, whatever this machine's timing. + test('bc-sync: the sync pill reads "Syncing…" while a tapped point is still unsaved', async ({ page }) => { await login(page); await openShiaijo(page, 'A'); const live = currentBout(page); - await boutIpponButton(live, 'aka', 'D').tap(); - await page.waitForTimeout(150); - await expect(syncPill(page)).not.toHaveText('Synced', { timeout: 100 }); - // Leave the bout as it was: take the point back. + const isScoreWrite = (url) => /\/api\/competitions\/[^/]+\/matches\/[^/]+\/score$/.test(url.pathname); + let release; + const released = new Promise((r) => { release = r; }); + const hold = async (route) => { + if (route.request().method() !== 'PUT') return route.continue(); + await released; + return route.continue().catch(() => {}); + }; + await page.route(isScoreWrite, hold); + try { + const sent = page.waitForRequest((r) => r.method() === 'PUT' && isScoreWrite(new URL(r.url()))); + // A test that fails before the write goes out must not leave this rejecting unheard. + sent.catch(() => {}); + await boutIpponButton(live, 'aka', 'D').tap(); + // From the tap: the edit waits out the debounce (nothing sent yet)... + await expect(syncPill(page)).toHaveText('Syncing…'); + // ...then its write is out, and held: still unsaved. + await sent; + await expect(syncPill(page)).toHaveText('Syncing…'); + } finally { + release(); + await page.unroute(isScoreWrite, hold); + } await expect(syncPill(page)).toHaveText('Synced'); + // Leave the bout as it was: take the point back. await boutFilled(currentBout(page), 'aka').last().tap(); await expect(boutFilled(currentBout(page), 'aka')).toHaveCount(0); }); @@ -1238,8 +1262,7 @@ test.describe('J5 kachinuki from the court console', () => { } expect((await boutNames(currentBout(page))).shiro).toBe(S[0]); await endMatch(page); - const done = page.locator('.shiaijo-completed .shiaijo-qrow').filter({ hasText: finalPair.shiro }).filter({ hasText: finalPair.aka }); - await expect(done).toBeVisible(); + await expect(completedRowFor(page, finalPair)).toBeVisible(); await shot(page, 'final-completed', { fullPage: true }); // A spectator's phone: the finished knockout on the public viewer. @@ -1259,11 +1282,6 @@ const m2State = {}; const pickState = {}; const correctionState = {}; -// A completed-list row naming both teams of a pairing. -function completedRow(page, pair) { - return page.locator('.shiaijo-completed .shiaijo-qrow').filter({ hasText: pair.shiro }).filter({ hasText: pair.aka }).first(); -} - // Waits until the server lists `pair` as completed on `court`, read by a page // loaded afresh in the same context (so already signed in): the console on the // page that tapped End match shows the result at once, before the server has @@ -1273,7 +1291,7 @@ async function completedOnServer(page, pair, court = 'A') { try { await expect.poll(async () => { await openShiaijo(fresh, court); - return completedRow(fresh, pair).locator('.shiaijo-qrow__result').count(); + return completedRowFor(fresh, pair).locator('.shiaijo-qrow__result').count(); }, { timeout: 15_000, message: `${pair.shiro} v ${pair.aka} is completed on the server` }).toBeGreaterThan(0); } finally { await fresh.close(); @@ -1284,8 +1302,8 @@ async function completedOnServer(page, pair, court = 'A') { // refused (bc-crpn). The notice sits at the row, the same sentence as the // toast; no correction opens and the live bout keeps the panel. async function correctRefusedAtConsole(page, pair, running, court = 'A') { - await completedRow(page, pair).locator('.shiaijo-row__correct').tap(); - const notice = completedRow(page, pair).locator('.shiaijo-upnext__error'); + await completedRowFor(page, pair).locator('.shiaijo-row__correct').tap(); + const notice = completedRowFor(page, pair).locator('.shiaijo-upnext__error'); await expect(notice).toHaveText(new RegExp(`^Shiaijo ${court} is running .*${running.shiro} vs ${running.aka}\\. ` + 'Finish it or send it back to the queue first\\. Then correct this match\\.$')); const toast = (await page.locator('.toast').allInnerTexts().catch(() => [])).join(' | '); @@ -1305,63 +1323,21 @@ async function openScoresCorrection(page, id, pair) { await expect(reopenButton(page)).toBeVisible(); } -// V4 for the shiaijo page's own confirms (`.shiaijo-move-confirm`: Send back -// to queue, Move to Shiaijo X). They are not confirmDialog (ui.jsx), so -// clumsy.hastyConfirm cannot see them; same rule: tap the loudest button. -async function hastyShiaijoConfirm(page) { - const dialog = page.locator('.shiaijo-move-confirm'); - await dialog.waitFor({ state: 'visible' }); - const shown = Date.now(); - const r = await loudestIn(dialog.locator('.shiaijo-move-confirm__actions')); - const title = (await dialog.locator('.shiaijo-move-confirm__title').innerText()).trim(); - const message = (await dialog.locator('.shiaijo-move-confirm__body').innerText()).trim(); - // The confirm ignores a tap inside the bounce window of its opening (fixtures/pace.mjs). - await dwell(page, shown); - await r.tap(); - await dialog.waitFor({ state: 'hidden', timeout: 15_000 }); - return { label: r.label, prominence: r.prominence, otherLabels: r.others, title, message }; -} - // V4 for the inline reason prompt (ReasonPrompt): its loudest button, with // whatever preset it offered by default. async function hastyReasonPrompt(page) { const prompt = page.locator('.reason-prompt'); await prompt.waitFor({ state: 'visible' }); const preset = await prompt.locator('select').first().inputValue().catch(() => ''); - const r = await loudestIn(prompt); - await r.tap(); + const { button, ...loud } = await loudestButton(prompt); + await button.tap(); await prompt.waitFor({ state: 'hidden', timeout: 15_000 }); - return { label: r.label, prominence: r.prominence, otherLabels: r.others, defaultReason: preset }; + return { ...loud, defaultReason: preset }; } // V4 for an inline panel (the court-busy conflict): its loudest button. async function hastyPanel(panel) { - const r = await loudestIn(panel); - await r.tap(); - return { label: r.label, prominence: r.prominence, otherLabels: r.others }; -} - -// The most prominent button in `scope`: danger over primary over plain over -// ghost, the larger breaking a tie (the same ranking as clumsy.hastyConfirm). -async function loudestIn(scope) { - const buttons = scope.locator('button'); - const rank = (cls) => (/\bbtn--danger\b/.test(cls) ? 3 : /\bbtn--primary\b/.test(cls) ? 2 : /\bbtn--ghost\b/.test(cls) ? 0 : 1); - const PROMINENCE = ['ghost', 'plain', 'primary', 'danger']; - let best = null; - const labels = []; - for (let i = 0; i < await buttons.count(); i += 1) { - const b = buttons.nth(i); - if (!(await b.isVisible())) continue; - const cls = (await b.getAttribute('class')) || ''; - const box = await b.boundingBox(); - const label = (await b.innerText()).trim(); - labels.push(label); - const score = [rank(cls), box ? box.width * box.height : 0]; - if (!best || score[0] > best.score[0] || (score[0] === best.score[0] && score[1] > best.score[1])) best = { b, label, score }; - } - if (!best) throw new Error('no visible button to answer hastily'); - return { - label: best.label, prominence: PROMINENCE[best.score[0]], others: labels.filter((l) => l !== best.label), - tap: () => best.b.tap(), - }; + const { button, ...loud } = await loudestButton(panel); + await button.tap(); + return loud; } diff --git a/scripts/e2e/journeys/knockout-mixed-team.spec.mjs b/scripts/e2e/journeys/knockout-mixed-team.spec.mjs index 57e29b90d..1a503fc66 100644 --- a/scripts/e2e/journeys/knockout-mixed-team.spec.mjs +++ b/scripts/e2e/journeys/knockout-mixed-team.spec.mjs @@ -6,25 +6,28 @@ // F4 team, 5 per team, knockout only (daihyosen is knockout-only) // // The clumsy operator's variants (fixtures/clumsy.mjs) are PERFORMED here and -// recorded as AUDIT lines (and audit.json per test); whether an outcome is -// acceptable is judged from the screenshots, never asserted. Only what the docs -// promise is asserted (docs/user-guide/court-operators/scoring-a-match.md, -// recording-decisions.md, organisers/team-tournaments.md). A step that -// exposes a functional defect is kept as its own self-contained -// test.fixme(': ...') below the journeys, so this file stays green -// and the step guards the fix once the fixme is removed. +// recorded as AUDIT lines (and an audit.json in each journey's folder, +// fixtures/audit.mjs); whether an outcome is acceptable is judged from the +// screenshots, never asserted. Only what the docs promise is asserted +// (docs/user-guide/court-operators/scoring-a-match.md, recording-decisions.md, +// organisers/team-tournaments.md). A step that exposed a functional defect is +// kept as its own self-contained ': ...' test below the journeys, +// first as test.fixme so this file stayed green, and, once fixed, as a plain +// test that guards the fix. import { test, expect } from '../fixtures/test.mjs'; import { OPERATOR_DEVICE, PUBLIC_DEVICE } from '../fixtures/devices.mjs'; import { journeyShots } from '../fixtures/shots.mjs'; import { login } from '../fixtures/setup.mjs'; import { createCompetition } from '../fixtures/wizard.mjs'; import { generateDraw, pasteRoster, startCompetition } from '../fixtures/competition.mjs'; -import { inlineEditor, openShiaijo, sides, upNextCard, completedRows } from '../fixtures/shiaijo.mjs'; +import { inlineEditor, openShiaijo, sides, upNextCard, completedRows, completedRowFor } from '../fixtures/shiaijo.mjs'; import { EDITOR } from '../fixtures/scoring.mjs'; import { openScoreEditor } from '../fixtures/scores.mjs'; import { openTvBoard, openViewer, tvBoard } from '../fixtures/public.mjs'; import { doubleTap, hastyConfirm, interrupt, retapWhileSaving, tapNeighbour } from '../fixtures/clumsy.mjs'; import { settled } from '../fixtures/pace.mjs'; +import { auditLog } from '../fixtures/audit.mjs'; +import { tapSize } from '../fixtures/pointer.mjs'; import * as T from '../fixtures/team.mjs'; const SIX_TEAMS = [ @@ -37,15 +40,14 @@ const FOUR_TEAMS = (tag) => [ ]; // A recorder per test: every audit observation is printed as an AUDIT line and -// attached as audit.json, the raw material for the audit table. -function recorder(journey) { - const rows = []; - const record = (row) => { - const r = { journey, ...row }; - rows.push(r); - console.log(`AUDIT ${JSON.stringify(r)}`); - }; - return { rows, record }; +// written, by the afterAll below, to the audit.json in the journey's own folder +// (`folder`, the one its screenshots go to), the raw material for the audit +// table. +const audits = []; +function recorder(journey, folder) { + const log = auditLog(folder); + audits.push(log); + return { record: (row) => log.record({ journey, ...row }) }; } // F3: teams of three, pools then knockout, on shiaijo A and B. @@ -81,11 +83,15 @@ async function nameStartingLineup(page, id, team, names, teamSize) { test.describe.configure({ mode: 'serial' }); test.describe('knockout-mixed-team', () => { + test.afterAll(() => { + for (const log of audits) log.flush(); + }); + // ------------------------------------------------------------------- J3 - test('J3 lineup entry from the three entry points, on F3 and F4', async ({ page }, testInfo) => { + test('J3 lineup entry from the three entry points, on F3 and F4', async ({ page }) => { test.setTimeout(600_000); const shot = journeyShots('J3-lineups'); - const { rows, record } = recorder('J3'); + const { record } = recorder('J3', 'J3-lineups'); await T.enterAdmin(page); let f3; @@ -108,7 +114,7 @@ test.describe('knockout-mixed-team', () => { await T.lineupsSelect(page, '1').selectOption('__add__'); await T.lineupsForm(page).getByRole('textbox', { name: 'New member name for 1' }).fill('Aoki'); const addBtn = T.lineupsForm(page).getByRole('button', { name: /^Add$/ }); - const addBox = await T.tapBox(addBtn); + const addBox = await tapSize(addBtn); const dbl = await doubleTap(addBtn); await page.waitForTimeout(400); const dialogs = await page.locator('.modal[role="dialog"]').count(); @@ -173,7 +179,7 @@ test.describe('knockout-mixed-team', () => { const saveBtn = T.lineupsForm(page).getByRole('button', { name: /Save lineup|Saving…/ }); const re = await retapWhileSaving(saveBtn); await expect(page.getByText('Lineup saved').first()).toBeVisible(); - record({ step: 'EP1 save', action: 'Save lineup', variant: 'V2 retapWhileSaving', ...re, saveTapBox: await T.tapBox(saveBtn) }); + record({ step: 'EP1 save', action: 'Save lineup', variant: 'V2 retapWhileSaving', ...re, saveTapBox: await tapSize(saveBtn) }); await page.reload(); await T.openLineups(page, f3, { team: pair3.shiro }); expect(await T.lineupsSelected(page, '1')).toContain('Aoki'); @@ -199,7 +205,7 @@ test.describe('knockout-mixed-team', () => { // Is a placed member offered again at another position? Open box 2. await T.panelInput(page, 1, '2').tap(); const box2Options = await T.panelSide(page, 1).locator('.pmf__option').allInnerTexts(); - await T.dismissNameBox(page); + await T.closeNameList(page); await shot(page, 'f3-panel-aka-typed'); // V1: the thumb lands left of Aka's Save lineup. What sits there depends on // how many rows each side has (there is no "Copy from previous match" any @@ -215,7 +221,7 @@ test.describe('knockout-mixed-team', () => { const shiroState = (await T.panelSide(page, 0).innerText()).replace(/\s+/g, ' '); await shot(page, 'f3-panel-v1-neighbour-of-aka-save'); record({ step: 'EP2 save Aka', action: 'Save lineup (Aka)', variant: 'V1 tapNeighbour left', ...nb, shiroSideAfter: shiroState.slice(0, 200), - akaSaveTapBox: await T.tapBox(akaSave), box2OfferedWhileDaiAt1: box2Options }); + akaSaveTapBox: await tapSize(akaSave), box2OfferedWhileDaiAt1: box2Options }); // Then the intended tap. await T.panelSave(page, 1); await expect(T.panelSource(page, 1)).toHaveText('Lineup for this match'); @@ -223,7 +229,7 @@ test.describe('knockout-mixed-team', () => { await expect(T.panelSaveButton(page, 1)).toBeDisabled(); await shot(page, 'f3-panel-aka-saved'); const rename = T.panelSide(page, 1).getByRole('button', { name: 'Rename 1 player' }); - record({ step: 'EP2 Rename', action: 'Rename control on the panel', variant: 'tap target', renameTapBox: await T.tapBox(rename) }); + record({ step: 'EP2 Rename', action: 'Rename control on the panel', variant: 'tap target', renameTapBox: await tapSize(rename) }); // One member at two positions: type Dai (at 1) into 2 and save. await T.typeIntoNameBox(T.panelInput(page, 1, '2'), 'Dai'); @@ -286,14 +292,14 @@ test.describe('knockout-mixed-team', () => { const ed = inlineEditor(page); const row1 = T.boutRow(ed, 1); const box = T.rowNameBox(row1, 'shiro'); - const boxTap = await T.tapBox(row1.locator('.team-sub-match__side--shiro .lineup-name__bar')); + const boxTap = await tapSize(row1.locator('.team-sub-match__side--shiro .lineup-name__bar')); // Row 1 already names Aoki (the starting lineup's fighter); open its list // as the operator would to confirm or change it. await box.tap(); await row1.locator('.pmf__option').first().waitFor({ state: 'visible', timeout: 3000 }).catch(() => {}); const offered = await row1.locator('.pmf__option').allInnerTexts(); await shot(page, 'f3-sheet-row1-list'); - await T.dismissNameBox(page); + await T.closeNameList(page); record({ step: 'EP3 open row 1 list', action: 'tap row 1 Shiro name box', variant: 'correct', offered }); // Name row 1 Aoki: from the list when it offers her, else by typing. if (offered.some((o) => o.includes('Aoki'))) await T.pickFromNameBox(box, 'Aoki'); @@ -443,7 +449,7 @@ test.describe('knockout-mixed-team', () => { const panelAfterSlip = await panel.isVisible().catch(() => false); await shot(page, 'f3-kiken-after-neighbour-tap'); record({ step: 'pool kiken chain', action: `Kiken – Voluntary, Shiro (${pair.shiro}) withdraws`, variant: 'correct', panelShown: shown, - panelText: panelText.slice(0, 300), awardButtons: awards, awardTapBox: awards ? await T.tapBox(award.first()).catch(() => null) : null }); + panelText: panelText.slice(0, 300), awardButtons: awards, awardTapBox: awards ? await tapSize(award.first()).catch(() => null) : null }); record({ step: 'pool kiken chain', action: 'Award the remaining match to the opponent', variant: 'V1 tapNeighbour up', ...(nb || {}), panelStillOpen: panelAfterSlip }); let awarded = 0; if (panelAfterSlip) { @@ -530,13 +536,12 @@ test.describe('knockout-mixed-team', () => { await shot(page, 'f4-lineups-aka-member-named-from-sheet', { fullPage: true }); }); - await testInfo.attach('audit.json', { body: JSON.stringify(rows, null, 2), contentType: 'application/json' }); }); // ------------------------------------------------------------------- J4 - test('J4 team scoring at the court (F4)', async ({ page }, testInfo) => { + test('J4 team scoring at the court (F4)', async ({ page }) => { test.setTimeout(600_000); const shot = journeyShots('J4-team-scoring'); - const { rows, record } = recorder('J4'); + const { record } = recorder('J4', 'J4-team-scoring'); await T.enterAdmin(page); const id = await seedF4(page, { name: 'Team KO J4', court: 'D', prefix: 'S', teams: FOUR_TEAMS('J4') }); await openShiaijo(page, 'D'); @@ -546,7 +551,7 @@ test.describe('knockout-mixed-team', () => { await test.step('start the first semifinal from Up next (double tap on Start match)', async () => { sf1 = await sides(upNextCard(page)); const start = upNextCard(page).getByRole('button', { name: 'Start match' }); - const startBox = await T.tapBox(start); + const startBox = await tapSize(start); const dbl = await doubleTap(start); await expect(T.boutRow(ed, 1)).toBeVisible(); await page.waitForTimeout(800); @@ -562,7 +567,7 @@ test.describe('knockout-mixed-team', () => { const row = T.boutRow(ed, 1); await T.boutIppon(ed, 1, 'shiro', 'M'); const k = T.rowIppon(row, 'shiro', 'K'); - const ipponBox = await T.tapBox(k); + const ipponBox = await tapSize(k); const dbl = await doubleTap(k); await expect(T.syncPill(ed)).toHaveText('Synced'); const slotsAfter = await T.rowSlots(row, 'shiro').allInnerTexts(); @@ -575,7 +580,7 @@ test.describe('knockout-mixed-team', () => { await expect(T.rowSlots(row, 'shiro')).toHaveCount(1); await expect(T.syncPill(ed)).toHaveText('Synced'); record({ step: 'bout 1', action: 'Shiro kote', variant: 'V2 doubleTap', ...dbl, slotsAfter, hint, tapsToRecover: extraCount, ipponTapBox: ipponBox, - slotTapBox: await T.tapBox(T.rowSlots(row, 'shiro').first()) }); + slotTapBox: await tapSize(T.rowSlots(row, 'shiro').first()) }); expect(await T.rowSlots(row, 'shiro').allInnerTexts()).toEqual(['M']); }); @@ -627,7 +632,7 @@ test.describe('knockout-mixed-team', () => { await expect(T.syncPill(ed)).toHaveText('Synced'); const restored = await T.rowSlots(row, 'shiro').allInnerTexts(); record({ step: 'bout 3', action: 'Fusensho Aka, then untoggle', variant: 'correct', fusenshoMarks: onAka, shiroAfterUntoggle: restored, - fusenshoTapBox: await T.tapBox(T.rowFusensho(row, 'aka')) }); + fusenshoTapBox: await tapSize(T.rowFusensho(row, 'aka')) }); expect(restored).toEqual(['K']); await expect(T.rowSlots(row, 'aka')).toHaveCount(0); @@ -734,7 +739,7 @@ test.describe('knockout-mixed-team', () => { await test.step('add the representative bout (daihyosen)', async () => { const add = ed.getByTestId('scoring-modal-daihyosen-button'); - const addBox = await T.tapBox(add); + const addBox = await tapSize(add); await add.tap(); const appeared = await T.boutRow(ed, 'DH').waitFor({ state: 'visible', timeout: 8000 }).then(() => true, () => false); await shot(page, 'daihyosen-added'); @@ -771,7 +776,7 @@ test.describe('knockout-mixed-team', () => { await expect(ed.getByTestId('team-daihyosen-ht-aka')).toHaveCount(0); await shot(page, 'hantei-shiro'); record({ step: 'hantei', action: 'SHIRO wins', variant: 'V1 tapNeighbour right', ...nb, akaHtShown: akaHt, tapsToRecover: 1, - hanteiTapBox: await T.tapBox(shiroWins), + hanteiTapBox: await tapSize(shiroWins), verdict: (await ed.locator('.team-summary__verdict').first().innerText()).trim(), dhMiddle: (await T.rowMiddle(T.boutRow(ed, 'DH')).innerText()).trim() }); // The centre never carries Ht: it stays (DH). @@ -819,7 +824,7 @@ test.describe('knockout-mixed-team', () => { await shot(page, 'sf2-kiken-neighbour'); await prompt.getByRole('button', { name: 'Cancel' }).tap(); record({ step: 'SF2 kiken', action: 'Kiken - Voluntary', variant: 'V1 tapNeighbour right', ...nb, promptOpened: openedTitle, tapsToRecover: 1, - kikenTapBox: await T.tapBox(vol) }); + kikenTapBox: await tapSize(vol) }); // The intended withdrawal is AKA's. The operator does not read the // prompt and taps its loudest button. if (!(await vol.isVisible())) await summary.tap(); @@ -831,7 +836,7 @@ test.describe('knockout-mixed-team', () => { const hint = (await prompt.getByTestId('decision-prompt-hint').innerText()).trim(); await shot(page, 'sf2-kiken-prompt'); const pill = (side) => prompt.locator('label.decision-prompt__side', { has: page.locator(`input[value="${side}"]`) }); - const radioBox = await T.tapBox(pill('shiro')); + const radioBox = await tapSize(pill('shiro')); const loud = prompt.locator('button.btn--primary'); const loudLabel = (await loud.innerText()).trim(); const loudDisabled = await loud.isDisabled(); @@ -878,7 +883,7 @@ test.describe('knockout-mixed-team', () => { await shot(page, 'final-started-with-wrong-team'); record({ step: 'SF2 kiken recovery', action: 'final started before the mistake is noticed', variant: 'V5 lost place', finalPair: finalBefore }); // Correct on the finished semifinal, while the final runs on the court. - const row = completedRows(page).filter({ hasText: sf2.shiro }).filter({ hasText: sf2.aka }).last(); + const row = completedRowFor(page, sf2); const result = (await row.locator('.shiaijo-qrow__result').innerText().catch(() => '')).trim(); await row.getByRole('button', { name: /Correct/ }).tap(); // bc-crpn: Correct is refused while a bout is live on the court; the row @@ -960,13 +965,12 @@ test.describe('knockout-mixed-team', () => { await expect(page.getByText('Fus.', { exact: true }).first()).toBeVisible(); }); - await testInfo.attach('audit.json', { body: JSON.stringify(rows, null, 2), contentType: 'application/json' }); }); // ------------------------------------------------------------------- J7 - test('J7 one result on every surface: operator, head table, viewer, TV board', async ({ page, browser, baseURL }, testInfo) => { + test('J7 one result on every surface: operator, head table, viewer, TV board', async ({ page, browser, baseURL }) => { test.setTimeout(600_000); const shot = journeyShots('J7-one-result'); - const { rows, record } = recorder('J7'); + const { record } = recorder('J7', 'J7-one-result'); await T.enterAdmin(page); const id = await seedF4(page, { name: 'Team KO J7', court: 'E', prefix: 'F', teams: [['J7 Asahi', 'Kita Dojo'], ['J7 Yuhi', 'Minami Dojo']] }); await openShiaijo(page, 'E'); @@ -1092,12 +1096,11 @@ test.describe('knockout-mixed-team', () => { await phoneCtx.close(); await tvCtx.close(); } - await testInfo.attach('audit.json', { body: JSON.stringify(rows, null, 2), contentType: 'application/json' }); }); // ------------------------------------------------------------ findings - // Each below is a functional defect a journey step exposed, - // kept as a self-contained test so the fix can remove the fixme and keep - // the step as its guard. Each seeds its own competition on its own shiaijo. + // Each below is a functional defect a journey step exposed, now + // fixed: its fixme went with the fix, and the self-contained test guards it. + // Each seeds its own competition on its own shiaijo. // The panel used to read "Inheriting round default" over empty boxes while the // score sheet named that default's fighters. A team now carries the lineup of @@ -1128,7 +1131,9 @@ test.describe('knockout-mixed-team', () => { for (const [i, name] of names.entries()) await expect(T.rowNameBox(T.boutRow(ed, i + 1), 'shiro')).toHaveValue(name); }); - test('bc-lprf: the match lineup panel\'s "already at" refusal names the wrong position', async ({ page }) => { + // The refusal used to name the position being typed (Jiho), not the one the + // member holds; this guards the fix. + test('bc-lprf: the match lineup panel\'s "already at" refusal names the position the member holds', async ({ page }) => { await T.enterAdmin(page); await seedF4(page, { name: 'B2 KO', court: 'I', prefix: 'I', teams: FOUR_TEAMS('B2') }); await openShiaijo(page, 'I'); @@ -1144,7 +1149,9 @@ test.describe('knockout-mixed-team', () => { await expect(T.panelSide(page, 1).locator('.alert--error')).toHaveText('Dai is already at Senpo.'); }); - test('bc-unfx: a bout not yet fought reads as a draw (X, "✓ Tie") on the sheet and the TV board', async ({ page, browser, baseURL }) => { + // A bout not yet fought used to read as a draw (X, "✓ Tie"); this guards + // the fix on the sheet and on the TV board. + test('bc-unfx: a bout not yet fought reads "vs" with Tie off, on the sheet and the TV board', async ({ page, browser, baseURL }) => { await T.enterAdmin(page); await seedF4(page, { name: 'B3 KO', court: 'J', prefix: 'J', teams: [['B3 Ume', 'Kita Dojo'], ['B3 Sakura', 'Minami Dojo']] }); await openShiaijo(page, 'J'); @@ -1169,7 +1176,9 @@ test.describe('knockout-mixed-team', () => { } }); - test('bc-fsnp: a fusensho wipes the points the other side already scored, so its undo cannot bring them back after a reload', async ({ page }) => { + // A fusensho used to wipe the points the other side had already scored, so + // its undo could not bring them back after a reload; this guards the fix. + test('bc-fsnp: a fusensho keeps the points the other side already scored, and its undo after a reload brings the score back', async ({ page }) => { await T.enterAdmin(page); await seedF4(page, { name: 'B4 KO', court: 'K', prefix: 'L', teams: [['B4 Ume', 'Kita Dojo'], ['B4 Sakura', 'Minami Dojo']] }); await openShiaijo(page, 'K'); @@ -1233,13 +1242,16 @@ test.describe('knockout-mixed-team', () => { await expect(T.rowNameBox(dh, 'aka')).toBeVisible(); }); - test('bc-dhas: a representative bout added right after the last Tie is erased by that Tie\'s pending autosave', async ({ page }) => { + // A representative bout added right after the last Tie used to be erased by + // that Tie's pending autosave; this guards the fix. + test('bc-dhas: a representative bout added right after the last Tie survives that Tie\'s autosave and a reload', async ({ page }) => { await T.enterAdmin(page); await seedF4(page, { name: 'B9 KO', court: 'L', prefix: 'T', teams: [['B9 Ume', 'Kita Dojo'], ['B9 Sakura', 'Minami Dojo']] }); await openShiaijo(page, 'L'); const ed = await T.startUpNextTeam(page); for (const n of [1, 2, 3, 4, 5]) await T.ensureTie(ed, n); - // The pill already reads Synced; the operator goes straight on. + // ensureTie saw the last Tie's write land (the pill back at Synced); the + // operator goes straight on. await ed.getByTestId('scoring-modal-daihyosen-button').tap(); await expect(T.boutRow(ed, 'DH')).toBeVisible(); await page.waitForTimeout(1500); diff --git a/scripts/e2e/journeys/smoke.spec.mjs b/scripts/e2e/journeys/smoke.spec.mjs index eb466cb29..4e06cafd9 100644 --- a/scripts/e2e/journeys/smoke.spec.mjs +++ b/scripts/e2e/journeys/smoke.spec.mjs @@ -6,12 +6,14 @@ // the result. Everything is seeded through the interface. // // The clumsy variants are PERFORMED and RECORDED here (printed as AUDIT lines -// and attached as audit.json), never asserted: this spec only asserts what the -// seeding path and the one cross-surface result need. The knockout-mixed -// journey is where variants get judged. +// and written to the journey folder's audit.json, fixtures/audit.mjs), never +// asserted: this spec only asserts what the seeding path and the one +// cross-surface result need. The knockout-mixed journeys are where variants +// get judged. import { test, expect, isCoarse } from '../fixtures/test.mjs'; import { PUBLIC_DEVICE } from '../fixtures/devices.mjs'; import { journeyShots } from '../fixtures/shots.mjs'; +import { auditLog } from '../fixtures/audit.mjs'; import { createTournament, login, signOut } from '../fixtures/setup.mjs'; import { createCompetition } from '../fixtures/wizard.mjs'; import { generateDraw, pasteRoster, requestDiscardDraw, startCompetition } from '../fixtures/competition.mjs'; @@ -36,16 +38,15 @@ test.describe.configure({ mode: 'serial' }); test.describe('smoke', () => { let shot; + const audit = auditLog('smoke'); + const { record } = audit; test.beforeAll(() => { shot = journeyShots('smoke'); }); - test('seed through the UI, score on a shiaijo, see it on a public phone', async ({ page, browser, baseURL }, testInfo) => { - const audit = []; - const record = (row) => { - audit.push(row); - console.log(`AUDIT ${JSON.stringify(row)}`); - }; + test.afterAll(() => audit.flush()); + + test('seed through the UI, score on a shiaijo, see it on a public phone', async ({ page, browser, baseURL }) => { let compId; let bout1; @@ -181,7 +182,5 @@ test.describe('smoke', () => { await spectator.close(); } }); - - await testInfo.attach('audit.json', { body: JSON.stringify(audit, null, 2), contentType: 'application/json' }); }); }); diff --git a/scripts/e2e/playwright.config.mjs b/scripts/e2e/playwright.config.mjs index 574a778f4..3a5b92238 100644 --- a/scripts/e2e/playwright.config.mjs +++ b/scripts/e2e/playwright.config.mjs @@ -9,19 +9,22 @@ const asRoot = typeof process.getuid === 'function' && process.getuid() === 0; export default defineConfig({ testDir: './journeys', - // Playwright wipes outputDir at the start of every run. Per-step journey - // screenshots live beside it in output// (fixtures/shots.mjs), so - // this directory only ever holds Playwright's own traces and failure shots. - // Two runs at once in one checkout must not share it, or the second run's - // wipe deletes the first run's traces mid-test (ENOENT on context close): - // give each concurrent run its own E2E_RESULTS_DIR. It is read from the - // environment, not derived from the pid, because every worker process - // re-reads this file and must resolve the same directory. + // Playwright wipes outputDir at the start of every run. Two runs at once in + // one checkout must not share it, or the second run's wipe deletes the first + // run's traces mid-test (ENOENT on context close): give each concurrent run + // its own E2E_RESULTS_DIR. It is read from the environment, not derived from + // the pid, because every worker process re-reads this file and must resolve + // the same directory. The journeys' own folders (per-step screenshots and + // audit.json, fixtures/shots.mjs) follow it: /journeys/ + // / when it is set, so a second run never empties the first run's + // folders either, and output//, beside output/test-results/, when + // it is not. outputDir: process.env.E2E_RESULTS_DIR || './output/test-results', // A journey drives the create wizard, the roster and the draw through the // interface before it reaches the step it is about, so a test is slow by - // design. Each worker boots its own server (fixtures/test.mjs), so files - // can run in parallel; tests inside a file run in order. + // design. Each spec file gets its own server (fixtures/test.mjs), so files + // can run in parallel, and a worker that runs several files starts a fresh + // one for each; tests inside a file run in order. timeout: 240_000, expect: { timeout: 15_000 }, fullyParallel: false, From 485706d845ae54075021be459e3425958557eed7 Mon Sep 17 00:00:00 2001 From: Ricardo Oliveira Date: Mon, 12 Oct 2026 00:04:32 +0100 Subject: [PATCH 15/30] test(bc-e2eh, bc-shcr, bc-mvsj): harness review fixes, console policy, shared-court journeys Replan revision 2 of PR #442 (operator decisions 2026-10-11). Harness review against main (review only, then fixed): - finishMatch/finishTeam judge the server's answer to the completing write (finishLanded): a refused or unanswered Finish fails instead of reading as landed. - ensureRunning returns null only when the court has nothing startable and throws when a tapped Start ran nothing; its callers assert, not branch. - Forms are found by label and role, pills read by aria-pressed, the lineup panel by its dialog role; stale comments about the app corrected. - Same-side ippon taps are paced (tapOnSide); slotMarks reads locked slots. - One owner each for the court-console and team-sheet selectors; dead exports removed; README layout, tap-guard and Finish notes corrected. Console policy: a console error or uncaught page error in any page of any context fails the test unless it is on CONSOLE_ALLOWLIST (fixtures/test.mjs), each entry narrow and with its reason (deliberate offline steps; 409 refusals the app shows as refused); warnings are recorded. New journeys (seeded through the UI by fixtures/shared-court.mjs): - shared-shiaijo.spec.mjs (bc-shcr): two competitions on one shiaijo, walked as the clumsy operator; asserts one bout at a time, no competition switch by itself, the advance staying in the scored competition, Send back keeping the score. - moved-match.spec.mjs (bc-mvsj): a waiting match moved to another court seen live on that court's console (two devices), with #453's decisions asserted. Suspected defects they found are recorded as audit rows, pending the operator's rulings. make e2e: 38 passed. make go/test: EXIT 0. Co-Authored-By: Claude Opus 5.5 --- scripts/e2e/README.md | 77 +- scripts/e2e/fixtures/audit.mjs | 17 +- scripts/e2e/fixtures/clumsy.mjs | 4 + scripts/e2e/fixtures/individual.mjs | 122 ++- scripts/e2e/fixtures/kachinuki.mjs | 65 +- scripts/e2e/fixtures/pace.mjs | 41 +- scripts/e2e/fixtures/scores.mjs | 3 +- scripts/e2e/fixtures/scoring.mjs | 117 ++- scripts/e2e/fixtures/setup.mjs | 20 +- scripts/e2e/fixtures/shared-court.mjs | 291 ++++++++ scripts/e2e/fixtures/shiaijo.mjs | 37 +- scripts/e2e/fixtures/team.mjs | 64 +- scripts/e2e/fixtures/test.mjs | 191 ++++- scripts/e2e/fixtures/wizard.mjs | 42 +- .../knockout-mixed-individual.spec.mjs | 112 +-- scripts/e2e/journeys/moved-match.spec.mjs | 328 ++++++++ scripts/e2e/journeys/shared-shiaijo.spec.mjs | 704 ++++++++++++++++++ scripts/e2e/journeys/smoke.spec.mjs | 8 +- scripts/screenshots/lib/editor.mjs | 14 +- 19 files changed, 1982 insertions(+), 275 deletions(-) create mode 100644 scripts/e2e/fixtures/shared-court.mjs create mode 100644 scripts/e2e/journeys/moved-match.spec.mjs create mode 100644 scripts/e2e/journeys/shared-shiaijo.spec.mjs diff --git a/scripts/e2e/README.md b/scripts/e2e/README.md index 23b601ffd..246b606ac 100644 --- a/scripts/e2e/README.md +++ b/scripts/e2e/README.md @@ -22,15 +22,27 @@ stamp outlives it; run `npx playwright install chromium` here. ## Layout playwright.config.mjs projects, timeouts, output - fixtures/test.mjs the `test` every journey imports (server, baseURL, page) + fixtures/test.mjs the `test` every journey imports (server, baseURL, + page) and the console policy (below) fixtures/devices.mjs the operator iPad and the spectator phone fixtures/setup.mjs first-run tournament form, sign in, sign out fixtures/pace.mjs the dwell the app's tap-bounce guard needs (below) fixtures/wizard.mjs the create-competition wizard fixtures/competition.mjs roster paste box, Generate / Discard draw, Start - fixtures/shiaijo.mjs /admin/shiaijo/:court (queue and inline editor) + fixtures/shiaijo.mjs /admin/shiaijo/:court (queue, inline editor, which + match an editor holds) fixtures/scores.mjs the Scores tab and its overlay editor - fixtures/scoring.mjs scoring an individual bout on either editor + fixtures/scoring.mjs scoring an individual bout on either editor, and + the Finish check every editor shares + fixtures/individual.mjs individual rosters, the decision panel, playing and + correcting a bout on the court console + fixtures/team.mjs team members, the three lineup entry points (the + Lineups page, the lineup panel, the score sheet + row) and the team score sheet's bout rows + fixtures/kachinuki.mjs the kachinuki sheet: bouts, Record bout, Encho, End + match, the correction doors + fixtures/shared-court.mjs two competitions on one shiaijo and a moved + match: seeding, the competition picker, the nudges fixtures/public.mjs the public viewer and the TV board fixtures/clumsy.mjs the clumsy operator (below) fixtures/pointer.mjs the coarse pointer: check, restore, tap-target size @@ -83,8 +95,15 @@ about one screen, not the path to it) and its `api.mjs`, `seed.mjs` and the `authAdmin` sign-in shortcut are not used here. Scoring goes through the score editors too. `scripts/screenshots/lib/editor.mjs` -owns the editor selector and the two-tap Finish; `fixtures/scoring.mjs` -re-exports them beside the ippon helpers. +owns the editor selectors and Start, which `fixtures/scoring.mjs` re-exports +beside the ippon helpers. Finish is `fixtures/scoring.mjs`'s own: a journey +taps it, on the inline editor as well as the overlay, and `finishMatch` (and +`finishTeam`, through the same check) returns only once the write landed. It +fails at once when the server does not answer the completing write within the +expect timeout, answers it with an error status, or answers `applied: false` +(the refusal a disarmed Finish follows). It then waits for the editor to let +the match go, and fails if the editor shows its not-saved banner or a new +error alert. ## The clumsy operator @@ -112,14 +131,46 @@ edge, which is what the app's listeners read. The app ignores a pointer tap that lands within 400ms of the layer it opened (a confirm, the sign-in dialog) or of the tap that armed a two-tap commit (Finish, -End match), and a second tap on the same ippon button inside that window -(`web-mobile/js/tap_guard.jsx`). Playwright's second tap is always inside it, so -`fixtures/pace.mjs` holds the dwell (`TAP_DWELL_MS`) and every helper that taps -into a fresh layer or confirms an arm waits it out: `login`, `hastyConfirm`, -`hastyShiaijoConfirm`, `answerDialog`, `finishMatch`, `finishTeam`, `endMatch`, `awardBoutIppon`. A -journey that taps such a thing by hand does the same. `doubleTap` is the one -helper that deliberately does not: it is the bounce, and the app is meant to -ignore the second tap. +End match), and a second ippon or foul tap on the same SIDE of a bout inside +that window, whichever button it lands on (`web-mobile/js/tap_guard.jsx`; a +tap on the other side counts at once). Playwright's second tap is always +inside it, so `fixtures/pace.mjs` holds the dwell (the screenshot harness's +`TAP_DWELL_MS`) and every helper that taps into a fresh layer, confirms an arm +or strikes for a side waits it out: `login`, `hastyConfirm`, +`hastyShiaijoConfirm`, `lineupsNameSlot`, `finishMatch`, `finishTeam`, +`endMatch`, `awardIppon`, `boutIppon`, `awardBoutIppon` (`tapOnSide` keeps the +time of the last tap on each side). A journey that taps such a thing by hand +does the same. `doubleTap` is the one helper that deliberately does not: it is +the bounce, and the app is meant to ignore the second tap. + +## The console policy + +`fixtures/test.mjs` listens to every page of every browser context a test +opens: the default page, and every context a spec opens itself with +`browser.newContext` (in the test or in a `beforeAll` hook; the `browser` +fixture is wrapped so none is missed). When the test ends: + +- a console **error** or an uncaught page error FAILS the test, unless it + matches `CONSOLE_ALLOWLIST` in that file; +- a console **warning** never fails: it is added to the spec's audit as a row + with `journey: "console"` when the spec keeps one, and printed as a + `CONSOLE {json}` line otherwise, and is listed on the test as a + `console-warning` annotation. + +Everything a test's pages logged is attached to it as `console.json`, with the +page it came from, whether the device was offline at the time, and, for a +failed request, the status and body of the `/api` answer. What a `beforeAll` +hook's pages log is checked with the first test of the file and marked as +such; what an `afterAll` hook's log is checked when the worker stops. + +The allowlist holds only noise a journey's scenario makes: requests the +browser could not send while a journey has the device offline, and the 409s of +a start or reopen the app sends through the server's gate and shows refused +(the court or the competitor busy, or the knockout match it feeds being +fought). +Each entry matches as narrowly as it can (the message, the URL, the refusal +code, offline only) and says why in one line. An error that looks like the +app's own fault does not go on it: the test stays red until a person decides. ## Output diff --git a/scripts/e2e/fixtures/audit.mjs b/scripts/e2e/fixtures/audit.mjs index 341cf8532..18e703457 100644 --- a/scripts/e2e/fixtures/audit.mjs +++ b/scripts/e2e/fixtures/audit.mjs @@ -9,8 +9,20 @@ // that fails part way still leaves the rows it recorded. import fs from 'node:fs'; import path from 'node:path'; +import { fileURLToPath } from 'node:url'; import { journeyDir } from './shots.mjs'; +// spec file -> the audits it made, so the console policy (fixtures/test.mjs) +// can add its warning rows to the audit of the test's own file. The file is +// read off the call stack: auditLog is always called while a spec file loads. +const bySpec = new Map(); +function callingSpec() { + const m = (new Error().stack || '').match(/((?:file:\/\/)?\/[^\s():]+\.spec\.mjs)/); + if (!m) return null; + return m[1].startsWith('file://') ? fileURLToPath(m[1]) : m[1]; +} +export const auditsFor = (specFile) => bySpec.get(specFile) || []; + export function auditLog(journey) { const file = path.join(journeyDir(journey), 'audit.json'); const rows = []; @@ -28,5 +40,8 @@ export function auditLog(journey) { fs.mkdirSync(path.dirname(file), { recursive: true }); fs.writeFileSync(file, JSON.stringify([...kept, ...rows], null, 2)); }; - return { record, flush, rows, file }; + const audit = { record, flush, rows, file }; + const spec = callingSpec(); + if (spec) bySpec.set(spec, [...auditsFor(spec), audit]); + return audit; } diff --git a/scripts/e2e/fixtures/clumsy.mjs b/scripts/e2e/fixtures/clumsy.mjs index 20f817407..55f97ee77 100644 --- a/scripts/e2e/fixtures/clumsy.mjs +++ b/scripts/e2e/fixtures/clumsy.mjs @@ -113,6 +113,10 @@ export async function retapWhileSaving(locator, { holdWritesMs = 1200, savingTex const hold = async (route) => { if (route.request().method() === 'GET') return route.continue(); await new Promise((r) => setTimeout(r, holdWritesMs)); + // While the write was held the page may have navigated or closed (a + // Finish + Start Next re-renders, a journey moves on), which cancels the + // request: continuing a route whose request is gone throws, and there is + // nothing left to let through. return route.continue().catch(() => {}); }; await page.route('**/api/**', hold); diff --git a/scripts/e2e/fixtures/individual.mjs b/scripts/e2e/fixtures/individual.mjs index 82956c7d8..6fb608983 100644 --- a/scripts/e2e/fixtures/individual.mjs +++ b/scripts/e2e/fixtures/individual.mjs @@ -1,14 +1,15 @@ // Helpers for the INDIVIDUAL operator journeys (knockout-mixed-individual // spec): the rosters, a deterministic winner rule, and the few surfaces the -// shared fixtures do not own (the overlay editor's identity line, the -// decision panel, playing and correcting a bout on the shiaijo page). The -// audit log (fixtures/audit.mjs), tap-target measurement (fixtures/pointer.mjs) -// and the hasty confirms (fixtures/clumsy.mjs) are shared with the other -// journeys. +// shared fixtures do not own (the decision panel, playing and correcting a +// bout on the shiaijo page). Which match an editor holds is read by +// fixtures/shiaijo.mjs editorIdentity. The audit log (fixtures/audit.mjs), +// tap-target measurement (fixtures/pointer.mjs) and the hasty confirms +// (fixtures/clumsy.mjs) are shared with the other journeys. import { expect } from '@playwright/test'; -import { EDITOR, INLINE_EDITOR } from './scoring.mjs'; -import { settled } from './pace.mjs'; -import { completedRowFor } from './shiaijo.mjs'; +import { INLINE_EDITOR, awardIppon, finishMatch } from './scoring.mjs'; +import { + UP_NEXT, WAITING_ROWS, completedRowFor, courtDone, courtIdle, inlineEditor, upNextCard, +} from './shiaijo.mjs'; import { hastyShiaijoConfirm } from './clumsy.mjs'; // Twelve entrants for F1 (pools of 3, two through): six dojos, two members @@ -49,23 +50,6 @@ export const ROSTER_4_JOINT = [ // so a pool's standings come out strict and no tie-break bout is added. export const winnerOf = ({ shiro, aka }) => (shiro.localeCompare(aka) <= 0 ? 'shiro' : 'aka'); -// The overlay editor's identity: its eyebrow (competition, pool/round) and its -// title line ("Shiaijo B · 10:20"), which is what says which court it is on. -export async function overlayIdentity(page) { - const ed = page.locator(EDITOR).first(); - const eyebrow = ((await ed.locator('.editor-modal__eyebrow').allInnerTexts())[0] || '').trim(); - const title = ((await ed.locator('.editor-modal__title').allInnerTexts())[0] || '').trim(); - return { eyebrow, title, court: (title.match(/Shiaijo\s+(\S+)/) || [])[1] || null }; -} - -// The same for the inline editor (shiaijo page, Bracket tab panel). -export async function inlineIdentity(page) { - const ed = page.locator(INLINE_EDITOR).first(); - const eyebrow = ((await ed.locator('.editor-modal__eyebrow').allInnerTexts())[0] || '').trim(); - const title = ((await ed.locator('.editor-modal__title').allInnerTexts())[0] || '').trim(); - return { eyebrow, title, court: (title.match(/Shiaijo\s+(\S+)/) || [])[1] || null }; -} - // The two names an editor shows, Shiro first. export async function editorSides(page, root = INLINE_EDITOR) { const ed = page.locator(root).first(); @@ -74,13 +58,16 @@ export async function editorSides(page, root = INLINE_EDITOR) { return { shiro, aka }; } -// The slot marks a side holds, in slot order ("M", "Ht", ...). +// The slot marks a side holds, in slot order ("M", "Ht", ...). A slot reads +// " slot : remove M" (a point), ": Ht" (the judges' decision), +// ": M, kiken" (a point a decision has locked) or ": empty" +// (fixtures/scoring.mjs filledSlots). export async function slotMarks(page, side, root = INLINE_EDITOR) { const name = side === 'shiro' ? 'Shiro' : 'Aka'; const labels = await page.locator(root).first().locator(`[aria-label^="${name} slot "]`).evaluateAll( (els) => els.map((e) => e.getAttribute('aria-label')), ); - return labels.map((l) => (l.match(/: (?:remove )?(\S+)$/) || [])[1]).filter((v) => v && v !== 'empty'); + return labels.map((l) => (l.match(/: (?:remove )?([^,\s]+)(?:, .+)?$/) || [])[1]).filter((v) => v && v !== 'empty'); } // The decision panel (DecisionPrompt, admin_scoring_shared.jsx) is inline in @@ -115,56 +102,60 @@ export async function recordDecision(page, kind, { side, reason, root = INLINE_E // Is the inline editor holding a RUNNING bout (no PRE-MATCH / CORRECTION pill)? export async function inlineRunning(page) { - const ed = page.locator(INLINE_EDITOR).first(); + const ed = inlineEditor(page).first(); if (!(await ed.isVisible().catch(() => false))) return false; const pill = ed.locator('.editor-head-pill'); return (await pill.count()) === 0; } -// Make sure the shiaijo page has a running bout in its editor: when the court -// is idle ("Ready when you are"), start the Up next card's match. Returns the -// pairing, or null when the court has nothing left to start. -export async function ensureRunning(page) { - const deadline = Date.now() + 15_000; - while (Date.now() < deadline) { - if (await inlineRunning(page)) return editorSides(page); - const card = page.locator('.shiaijo-upnext__card'); - const idle = await page.locator('.shiaijo__placeholder').isVisible().catch(() => false); - if (idle && await card.isVisible().catch(() => false)) { - const start = card.getByRole('button', { name: 'Start match' }); - // The card can leave while this runs (Finish + Start Next has just started - // the match it shows): a tap that finds it gone is not a failure, the loop - // looks again. - if (await start.isEnabled().catch(() => false)) await start.tap({ timeout: 2_000 }).catch(() => {}); +// Make sure the shiaijo page has a running bout in its editor, and return its +// pairing. When the court is idle ("Ready when you are") the Up next card's +// Start match is tapped, once. Returns null only when the court has nothing to +// start: idle with no startable Up next card (none, or one whose Start is off, +// e.g. a barred match), or done, held for a moment so a court between two +// renders is not taken for one with nothing left. Throws when Start was tapped +// and no bout runs by the deadline, or the console never settles. +const NOTHING_HELD_MS = 1_500; +export async function ensureRunning(page, { timeout = 15_000 } = {}) { + const start = upNextCard(page).getByRole('button', { name: 'Start match' }); + let tappedAt = null; + let nothingSince = null; + let outcome = null; + await expect(async () => { + if (await inlineRunning(page)) { outcome = 'running'; return; } + const idle = await courtIdle(page).isVisible().catch(() => false); + const done = !idle && await courtDone(page).isVisible().catch(() => false); + const startable = idle && (await start.count()) > 0 && await start.isEnabled({ timeout: 1_000 }).catch(() => false); + if (tappedAt === null && startable) { + // The card can leave under the tap (Finish + Start Next has just started + // the match it shows): a tap that finds it gone did not happen, and the + // next look reads the editor. + if (await start.tap({ timeout: 2_000 }).then(() => true, () => false)) tappedAt = Date.now(); + throw new Error('Start match tapped on the Up next card; waiting for the bout to run'); } - await page.waitForTimeout(250); - } - return null; + if (tappedAt !== null) throw new Error(`Start match was tapped ${Date.now() - tappedAt} ms ago and no bout is running`); + if (done || idle) { + nothingSince ??= Date.now(); + if (Date.now() - nothingSince >= NOTHING_HELD_MS) { outcome = 'nothing'; return; } + throw new Error('the court shows nothing to start; checking it stays so'); + } + nothingSince = null; + throw new Error('the court console shows neither a running bout, the idle placeholder nor the done state'); + }).toPass({ timeout, intervals: [250] }); + return outcome === 'running' ? editorSides(page) : null; } // Score the running bout on the shiaijo page for the deterministic winner (one -// men) and Finish (+ Start Next) through the two-tap guard. Returns the pairing. +// men) and Finish (+ Start Next) through the two-tap guard, which returns once +// the write has landed and the editor has let the bout go. Returns the +// pairing. export async function playRunningBout(page, { waza = 'M' } = {}) { const pair = await ensureRunning(page); if (!pair) throw new Error('playRunningBout: nothing running or startable on this court'); const side = winnerOf(pair); - const ed = page.locator(INLINE_EDITOR).first(); - await ed.locator(`.sb-side--${side} .ipt-btn`, { hasText: new RegExp(`^${waza}$`) }).first().tap(); - await expect(ed.locator('.sync-pill__label').first()).toHaveText('Synced'); - const finish = ed.locator('button').filter({ hasText: /^Finish( \+ Start Next|$)/ }).first(); - await finish.tap(); - const armed = ed.locator('button').filter({ hasText: /^Tap again to finish/ }).first(); - // The second tap counts once the arming tap's bounce window has passed. - await settled(armed); - await armed.tap(); + await awardIppon(page, side, waza); + await finishMatch(page); await expect(completedRowFor(page, pair)).toBeVisible(); - // Wait for the editor to let go of the finished bout (it moves on to the - // next one, or the court goes idle), so the caller never reads it as live. - await expect.poll(async () => { - if (!(await inlineRunning(page))) return 'moved'; - const now = await editorSides(page).catch(() => null); - return now && now.shiro === pair.shiro && now.aka === pair.aka ? 'same' : 'moved'; - }, { timeout: 10_000 }).toBe('moved'); return { ...pair, winner: side }; } @@ -193,7 +184,7 @@ export async function correctCompleted(page, pair) { await expect.poll(() => inlineRunning(page), { timeout: 10_000 }).toBe(false); await done.getByRole('button', { name: 'Correct' }).tap(); } - await expect(page.locator(INLINE_EDITOR).first().locator('.editor-head-pill')).toHaveText('CORRECTION'); + await expect(inlineEditor(page).first().locator('.editor-head-pill')).toHaveText('CORRECTION'); return { live, refusal }; } @@ -201,7 +192,6 @@ export async function correctCompleted(page, pair) { // the Upcoming rows; not Later, not Completed). A pool bout reads // "Match n of m" in its header, a knockout bout "Match n". export async function queuedPoolBouts(page) { - const texts = await page.locator('.shiaijo-upnext__card, .shiaijo__queue .shiaijo-group:not(.shiaijo-pending) .shiaijo-qrow:not(.shiaijo-qrow--complete)') - .allInnerTexts(); + const texts = await page.locator(`${UP_NEXT}, ${WAITING_ROWS}`).allInnerTexts(); return texts.filter((t) => /Match \d+ of \d+/.test(t)).length; } diff --git a/scripts/e2e/fixtures/kachinuki.mjs b/scripts/e2e/fixtures/kachinuki.mjs index b0f6e7655..ccc596841 100644 --- a/scripts/e2e/fixtures/kachinuki.mjs +++ b/scripts/e2e/fixtures/kachinuki.mjs @@ -7,12 +7,16 @@ // Selectors follow admin_scoring_team.jsx and admin_schedule_lineup.jsx. The // individual board's helpers (fixtures/scoring.mjs) do not fit here: a team // bout's sides are `.team-sub-match__side--`, not `.sb-side--`. -// Every tap goes through Locator.tap(), so the coarse-pointer hit test decides -// what is hit, as it does for a thumb. +// The bout row's controls and the lineup panel are the team sheet's +// (fixtures/team.mjs owns their selectors); this file adds what is kachinuki's +// own and names those for the current bout. Every tap goes through +// Locator.tap(), so the coarse-pointer hit test decides what is hit, as it +// does for a thumb. import { expect } from '@playwright/test'; -import { INLINE_EDITOR } from '../../screenshots/lib/editor.mjs'; import { dwell, settled } from './pace.mjs'; import { isCoarse, restoreCoarse } from './pointer.mjs'; +import { inlineEditor } from './shiaijo.mjs'; +import * as T from './team.mjs'; // Does the page still match (pointer: coarse)? If an interruption or a // fullPage screenshot lost it, every tap-floor rule in styles.css would be off @@ -25,34 +29,23 @@ export async function ensureCoarse(page) { return { coarseLost: true, restoredBy: 'CDP Emulation.setTouchEmulationEnabled (in place)' }; } -export const editor = (page) => page.locator(INLINE_EDITOR).filter({ has: page.locator('.team-bouts-scroll') }).first(); +export const editor = (page) => inlineEditor(page).filter({ has: page.locator('.team-bouts-scroll') }).first(); // ---------------------------------------------------------------- lineup -- -// The per-match lineup panel (MatchLineupPanel, the "Enter lineup" button on -// the Up next card). It is a fixed overlay with no dialog role, found by its -// heading. -export const lineupPanel = (page) => page.locator('div').filter({ - has: page.getByRole('heading', { name: 'Lineup for this match' }), -}).filter({ has: page.getByRole('button', { name: /Close|Done/ }) }).last(); - -// Open the lineup panel from the Up next card. -export async function openLineupFromUpNext(page) { - await page.locator('.shiaijo-upnext__card').getByRole('button', { name: 'Enter lineup' }).tap(); - await expect(page.getByRole('heading', { name: 'Lineup for this match' })).toBeVisible(); -} +// The per-match lineup panel (fixtures/team.mjs): opened from the Up next +// card, a side found by the team name it shows, where its lineup comes from, +// and Close. +export const openLineupFromUpNext = T.openPanelFromUpNext; +export const lineupSide = T.panelSideOf; +export const lineupSource = T.sideSource; +export const closeLineupPanel = T.closePanel; // Name every position of ONE team's lineup by typing (a typed name over a // blank numbered slot names that slot's member, bc-dnst), then Save lineup. // `teamName` picks the side editor by its heading text. Returns once the -// save has landed (the button leaves "Saving…"). `save: false` types the -// names and leaves the side unsaved. -export const lineupSide = (page, teamName) => page.locator('[data-testid^="match-lineup-side-"]').filter({ hasText: teamName }).first(); - -// Where the lineup a side shows comes from: "Lineup for this match", "Same as -// " or "Starting lineup" (LineupSourceLine, lineup_draft.jsx). -export const lineupSource = (side) => side.locator('[data-testid^="match-lineup-source-"]'); - +// save has landed (the side reads its lineup as this match's own). `save: +// false` types the names and leaves the side unsaved. export async function typeLineup(page, teamName, names, { save: doSave = true } = {}) { const side = lineupSide(page, teamName); await expect(side).toBeVisible(); @@ -74,11 +67,6 @@ export async function typeLineup(page, teamName, names, { save: doSave = true } return side; } -export async function closeLineupPanel(page) { - await page.getByRole('button', { name: /^(✕ Close|Done)$/ }).first().tap(); - await expect(page.getByRole('heading', { name: 'Lineup for this match' })).toHaveCount(0); -} - // ----------------------------------------------------------------- bouts -- // Fought bouts, collapsed to read-only rows above the current one. @@ -90,15 +78,12 @@ export const currentBout = (page) => editor(page) // A fought bout opened for correction. export const correctingBout = (page) => editor(page).locator('.team-sub-match--correcting').first(); -// An ippon button on a bout row. `side` is 'shiro' (left, the match's sideB) -// or 'aka' (right, sideA). -export const boutIpponButton = (row, side, waza = 'M') => row - .locator(`.team-sub-match__side--${side} .ipt-btn`, { hasText: new RegExp(`^${waza}$`) }).first(); - -// The filled centre slots of one side of a bout row. -export const boutFilled = (row, side) => row.locator(`.tsm-center-pts--${side} .editor-side__pt--filled`); - -export const tieButton = (row) => row.getByTestId('scoring-modal-tie-button'); +// A bout row's controls, the team sheet's (fixtures/team.mjs): an ippon +// button (`side` is 'shiro', left, the match's sideB, or 'aka', right, +// sideA), the filled centre slots of one side, and Tie. +export const boutIpponButton = T.rowIppon; +export const boutFilled = T.rowSlots; +export const tieButton = T.rowTie; export const recordBoutButton = (page) => editor(page).getByRole('button', { name: /^(Record bout|Saving…)$/ }).first(); export const endMatchButton = (page) => editor(page).getByTestId('kachinuki-end-match-button'); export const enchoButton = (page) => editor(page).getByTestId('kachinuki-encho-button'); @@ -106,7 +91,7 @@ export const enchoButton = (page) => editor(page).getByTestId('kachinuki-encho-b export const enchoUndoButton = (page) => editor(page).getByTestId('kachinuki-encho-undo-button'); export const removeBoutButton = (page) => editor(page).getByTestId('kachinuki-remove-bout-button'); export const reopenButton = (page) => page.getByTestId('kachinuki-reopen-button'); -export const syncPill = (page) => editor(page).locator('.sync-pill__label').first(); +export const syncPill = (page) => T.syncPill(editor(page)); // The two fighters on a bout row. The live row carries a name box per side; // a fought row carries static text. The box shows the name as its value @@ -139,7 +124,7 @@ export async function doneRowWinner(row) { } // The centre mark of a row (vs / X / (E)). -export const boutMiddle = async (row) => (await row.locator('.team-sub-match__score').first().innerText()).trim(); +export const boutMiddle = async (row) => (await T.rowMiddle(row).first().innerText()).trim(); // Award one ippon on the current bout and wait for the board to show it. A second // ippon on the same side only counts once the first one's bounce window has diff --git a/scripts/e2e/fixtures/pace.mjs b/scripts/e2e/fixtures/pace.mjs index a1d224d07..455e95290 100644 --- a/scripts/e2e/fixtures/pace.mjs +++ b/scripts/e2e/fixtures/pace.mjs @@ -1,24 +1,26 @@ // The pace of a deliberate tap. // // The app ignores a pointer tap that lands within TAP_BOUNCE_MS (400 ms, -// web-mobile/js/tap_guard.jsx) of one of two things: the layer it just opened -// (a confirm, the sign-in dialog, an edit dialog), and the tap that armed a -// two-tap commit (Finish, End match, Save result). That is how a bouncing +// web-mobile/js/tap_guard.jsx) of one of three things: the layer it just +// opened (a confirm, the sign-in dialog, an edit dialog), the tap that armed a +// two-tap commit (Finish, End match, Save result), and the previous ippon or +// foul tap on the same side of the same bout (acceptTap: the guard is per +// side, so a tap on the other side counts at once). That is how a bouncing // thumb is told from a second tap, and no person's second tap is ever that // close. Playwright's is, so a journey that taps into a layer the moment it -// shows, or taps an armed button the moment it arms, has the tap swallowed and -// then fails somewhere later with nothing pointing back here. +// shows, taps an armed button the moment it arms, or strikes twice for one +// side back to back, has the tap swallowed and then fails somewhere later with +// nothing pointing back here. // // The dwell below is therefore the operator's own pace, not a wait for the app: // nothing in the page says the window has passed, so it is timed from the -// moment the journey saw the layer or the arm. TAP_DWELL_MS is the screenshot -// harness's value, kept above the app's window, so both harnesses pace alike. +// moment the journey saw the layer or the arm, or made the tap. TAP_DWELL_MS +// is the screenshot harness's value, kept above the app's window, so both +// harnesses pace alike. import { TAP_DWELL_MS } from '../../screenshots/lib/editor.mjs'; -export { TAP_DWELL_MS }; - // Wait out what is left of the dwell since `since` (a Date.now() reading taken -// when the layer or the arm was seen). +// when the layer or the arm was seen, or the tap made). export async function dwell(page, since) { const left = TAP_DWELL_MS - (Date.now() - since); if (left > 0) await page.waitForTimeout(left); @@ -30,3 +32,22 @@ export async function settled(locator) { await locator.waitFor({ state: 'visible' }); await dwell(locator.page(), Date.now()); } + +// page -> Map(side key -> when the last tap on that side finished). +const sideTaps = new WeakMap(); + +// Tap `locator`, a scoring button on one side of a bout, after the dwell since +// the previous tap this page made on the same `side` (any key naming one side +// of one bout: 'shiro', 'bout 2 aka'). Taps on other sides are not held up. +// The tap's time is taken once it returns, which is no earlier than the app's +// own reading of it. +export async function tapOnSide(page, side, locator) { + let taps = sideTaps.get(page); + if (!taps) { + taps = new Map(); + sideTaps.set(page, taps); + } + if (taps.has(side)) await dwell(page, taps.get(side)); + await locator.tap(); + taps.set(side, Date.now()); +} diff --git a/scripts/e2e/fixtures/scores.mjs b/scripts/e2e/fixtures/scores.mjs index 06ab30dca..0a602b486 100644 --- a/scripts/e2e/fixtures/scores.mjs +++ b/scripts/e2e/fixtures/scores.mjs @@ -7,7 +7,8 @@ import { EDITOR } from '../../screenshots/lib/editor.mjs'; export const scoreRows = (page) => page.locator('.score-edit-row'); -// The first SCHEDULED match on `court`, opened in the overlay editor. +// The first match on `court` whose row offers Score (a scheduled or running +// one; a finished match's row offers Correct), opened in the overlay editor. // Returns the row it opened, e.g. for reading the pairing. export async function openScoreEditor(page, id, { court } = {}) { await page.goto(`/admin/competition/${encodeURIComponent(id)}/scores`); diff --git a/scripts/e2e/fixtures/scoring.mjs b/scripts/e2e/fixtures/scoring.mjs index 7867853b8..2c4246df3 100644 --- a/scripts/e2e/fixtures/scoring.mjs +++ b/scripts/e2e/fixtures/scoring.mjs @@ -1,17 +1,21 @@ // Scoring an INDIVIDUAL bout on the kendo board (admin_scoring_individual.jsx). // The board is the same whether it is mounted inline on the shiaijo page // (INLINE_EDITOR) or as the overlay dialog the Scores tab opens (EDITOR), so -// every helper takes the editor's selector. The team and kachinuki editors -// score differently and get their own helpers when a journey needs them. +// every helper takes the editor's selector, the inline one by default. The +// team and kachinuki editors score differently and get their own helpers +// (fixtures/team.mjs, fixtures/kachinuki.mjs); they share the Finish check +// below. // // The editor selectors and Start come from scripts/screenshots/lib/editor.mjs, -// re-exported here so a journey imports one scoring module. Finish is written -// here because a journey TAPS, as the operator does on the iPad, and takes the -// inline editor as well as the overlay; the library's finishMatch clicks, on the -// overlay only. Both wait out the bounce window (fixtures/pace.mjs). +// re-exported here so a journey imports one scoring module. Finish is this +// file's own, not that library's: a journey TAPS, as the operator does on the +// iPad, takes the inline editor as well as the overlay, and must know the +// write LANDED (the library's finishMatch clicks, on the overlay only, and +// returns once the button stops saving). import { expect } from '@playwright/test'; import { EDITOR, INLINE_EDITOR, startMatch } from '../../screenshots/lib/editor.mjs'; -import { settled } from './pace.mjs'; +import { settled, tapOnSide } from './pace.mjs'; +import { courtIdle, editorIdentity } from './shiaijo.mjs'; export { EDITOR, INLINE_EDITOR, startMatch }; @@ -27,16 +31,20 @@ export const ipponButton = (page, side, waza, root = INLINE_EDITOR) => page.loca // "Offline", ...; admin_scoring_autosave.jsx). export const syncState = (page, root = INLINE_EDITOR) => page.locator(root).locator('.sync-pill__label').first(); -// The slots a side's points fill, outside to inside. A filled slot is labelled -// " slot : remove " (tapping it clears the mark). +// The slots a side's points fill, outside to inside, that hold a mark. A slot +// is labelled " slot : remove " for a point (a tap clears it), +// " slot : Ht" for the judges' decision, and " slot : +// , " while a decision such as a kiken locks the scoreline; +// neither of the last two clears on a tap. An unfilled slot ends ": empty". export const filledSlots = (page, side, root = INLINE_EDITOR) => page.locator(root) .locator(`[aria-label^="${SIDE_NAME[side]} slot "]:not([aria-label$=": empty"])`); // Award one ippon and return once the board shows it and the autosave has -// landed. `side` is 'shiro' | 'aka'. +// landed. `side` is 'shiro' | 'aka'. A second strike for the same side waits +// out the bounce window of the first (fixtures/pace.mjs). export async function awardIppon(page, side, waza = 'M', root = INLINE_EDITOR) { const before = await filledSlots(page, side, root).count(); - await ipponButton(page, side, waza, root).tap(); + await tapOnSide(page, `${root} ${side}`, ipponButton(page, side, waza, root)); await expect(filledSlots(page, side, root)).toHaveCount(before + 1); await expect(syncState(page, root)).toHaveText('Synced'); } @@ -56,17 +64,82 @@ export async function armFinish(page, root = INLINE_EDITOR) { return armedFinishButton(page, root); } -// Finish in two taps and return once the write has landed. The label is "Finish -// + Start Next →" instead whenever another match waits on the same shiaijo, and -// that form leaves the editor open on the next match. Not for the engi editor -// ("Save result" arms too, but is its own button) or a correction ("Save -// correction" does not arm). -export async function finishMatch(page, root = EDITOR) { +// What an editor shows when a write did not save: its not-saved banner +// (notSavedText, the refused write's danger state) and an error alert such as +// the team sheet's "a bout has no result". +const NOT_SAVED = '.pending-write-banner--failed, .alert--error'; +const FINISH_LABEL = /^(Finish( \+ Start Next|$)|Tap again to finish|Saving…)/; + +const visibleTexts = (loc) => loc.evaluateAll((els) => els + .filter((e) => e.getClientRects().length > 0) + .map((e) => e.textContent.replace(/\s+/g, ' ').trim())); + +// What an editor reads before a Finish, for finishLanded to compare against: +// the match it holds and any not-saved line already on screen. Then the +// Finish write's answer is listened for: call this BEFORE the tap that +// submits. The write is the PUT /score that completes the match. +export async function beforeFinish(ed) { + const page = ed.page(); + const answer = page.waitForResponse((r) => r.request().method() === 'PUT' && /\/matches\/[^/]+\/score$/.test(r.url()) + && /"status":"completed"/.test(r.request().postData() || ''), { timeout: 15_000 }).then((r) => r, () => null); + return { ...(await editorIdentity(page, ed)), notSaved: await visibleTexts(ed.locator(NOT_SAVED)), answer }; +} + +// One reading of whether the editor has let the finished match go. +async function editorState(ed, before) { + const page = ed.page(); + const fresh = (await visibleTexts(ed.locator(NOT_SAVED)).catch(() => [])).filter((t) => !before.notSaved.includes(t)); + if (fresh.length) return { kind: 'not saved', detail: `the editor says: ${fresh.join(' | ')}` }; + if (!(await ed.isVisible().catch(() => false))) return { kind: 'landed', detail: 'the editor closed' }; + if (await courtIdle(page).isVisible().catch(() => false)) return { kind: 'landed', detail: 'the court is idle' }; + const now = await editorIdentity(page, ed).catch(() => null); + if (!now) return { kind: 'pending', detail: 'the editor is between two matches' }; + if (now.key !== before.key) return { kind: 'landed', detail: `the editor moved on to ${now.key}` }; + const pill = (await ed.locator('.editor-head-pill').allInnerTexts().catch(() => [])).join(' ').trim(); + if (pill) return { kind: 'landed', detail: `the match is no longer running (${pill})` }; + const labels = (await ed.locator('button').allInnerTexts()).map((t) => t.trim()).filter((t) => FINISH_LABEL.test(t)); + if (!labels.length) return { kind: 'landed', detail: 'Finish is no longer offered for the match' }; + return { kind: 'pending', detail: `the editor still holds the match and offers ${labels.join(', ')}` }; +} + +// Return once a Finish's second tap has LANDED, and fail if it did not. Two +// things are read, because neither is enough alone. The server's answer to +// the completing write: none within the expect timeout (queued, or never +// sent), an error status, or `applied: false` (the refusal shape: +// superseded, clock_skew) fails at once with what the server said; a refused +// write is also what makes an editor disarm Finish (writeWasRefused). Then +// the editor: it must let the match go (the overlay closes on a plain +// Finish; the inline editor holds another match or the court goes idle; +// Finish + Start Next holds the next match; or the editor keeps the match +// shown as finished), and it fails if a not-saved banner or a new error +// alert appears. The editor's own label is not taken as the answer: right +// after a landed Finish it can read plain "Finish" on the same match for a +// moment, until the list it is fed from catches up. `ed` is the editor +// Locator, `before` what beforeFinish read ahead of the submitting tap. +export async function finishLanded(ed, before) { + const res = await before.answer; + expect(res, `the Finish write on ${before.key} was answered by the server`).not.toBeNull(); + const body = await res.json().catch(() => null); + const refused = res.status() >= 400 || (body && body.applied === false); + expect(refused, `the server refused the Finish on ${before.key}: ${res.status()} ${JSON.stringify(body)}`).toBe(false); + let state = { kind: 'pending' }; + await expect.poll(async () => { + state = await editorState(ed, before); + return state.kind; + }, { message: `the editor let ${before.key} go after its Finish landed` }).not.toBe('pending'); + expect(state.kind, `after the Finish on ${before.key}: ${state.detail}`).toBe('landed'); + return state; +} + +// Finish in two taps and return once the write has landed (finishLanded). The +// label is "Finish + Start Next →" instead whenever another match waits on the +// same shiaijo, and that form leaves the editor on the next match. Not for the +// engi editor ("Save result" arms too, but is its own button) or a correction +// ("Save correction" does not arm). +export async function finishMatch(page, root = INLINE_EDITOR) { + const ed = page.locator(root).first(); const armed = await armFinish(page, root); + const before = await beforeFinish(ed); await armed.tap(); - // The button reads "Saving…" from the second tap until the server answers, - // then the editor closes (plain Finish) or moves to the next match, whose own - // Finish label is not the arm. Neither label is left once the write landed. - await page.locator(root).first().locator('button').filter({ hasText: /^(Tap again to finish|Saving…)/ }).first() - .waitFor({ state: 'hidden', timeout: 15000 }); + return finishLanded(ed, before); } diff --git a/scripts/e2e/fixtures/setup.mjs b/scripts/e2e/fixtures/setup.mjs index 3778cba44..c45d66ea8 100644 --- a/scripts/e2e/fixtures/setup.mjs +++ b/scripts/e2e/fixtures/setup.mjs @@ -3,14 +3,12 @@ import { expect } from '@playwright/test'; import { dwell } from './pace.mjs'; -export const PASSWORD = 'e2e-operator'; +const PASSWORD = 'e2e-operator'; -// A form row found by its visible label. These forms render -// `
` -// with no htmlFor, so the label names nothing and getByLabel cannot see it. -const field = (page, label) => page.locator('.field') - .filter({ has: page.locator('.field__label', { hasText: label }) }) - .first(); +// A form field by its visible label (each label is tied to its input with +// htmlFor, app.jsx). Anchored at the start: "Number of Shiaijo (courts)" and a +// locked server's "Admin Password (from ...)" carry more than the words. +const field = (page, label) => page.getByLabel(new RegExp(`^${label}`)); // The first-run "Welcome to Bracket Creator" form (app.jsx), shown on a // server with no tournament yet. Creating one signs this page in, so a @@ -20,11 +18,11 @@ export async function createTournament(page, { } = {}) { await page.goto('/'); await expect(page.getByRole('heading', { name: 'Welcome to Bracket Creator' })).toBeVisible(); - await field(page, 'Tournament Name').locator('input').fill(name); - await field(page, 'Venue').locator('input').fill(venue); - await field(page, 'Number of Shiaijo').locator('input').fill(String(courts)); + await field(page, 'Tournament Name').fill(name); + await field(page, 'Venue').fill(venue); + await field(page, 'Number of Shiaijo').fill(String(courts)); await page.getByRole('group', { name: 'Tournament type' }).getByRole('button', { name: mode }).tap(); - await field(page, 'Admin Password').locator('input').fill(password); + await field(page, 'Admin Password').fill(password); await page.getByRole('button', { name: 'Create Tournament' }).tap(); await expect(page).toHaveURL(/\/admin$/); await expect(page.getByRole('heading', { level: 1, name })).toBeVisible(); diff --git a/scripts/e2e/fixtures/shared-court.mjs b/scripts/e2e/fixtures/shared-court.mjs new file mode 100644 index 000000000..414b7efef --- /dev/null +++ b/scripts/e2e/fixtures/shared-court.mjs @@ -0,0 +1,291 @@ +// Two competitions on one shiaijo (bc-shcr) and a match moved between two +// courts (bc-mvsj): the seeding both journeys share, and the court console's +// cross-competition surfaces (the competition picker, the nudge lines, the +// "Another bout is running" alert, the done state) as one readable state. +// +// Seeding is UI-only, as every journey's is (README "Seeding goes through the +// interface"): the first-run form, the create wizard for EVERY competition +// before any roster is pasted (the realistic set-up order, and the one that +// decides each competition's default start time), then the roster paste box, +// Generate draw and Start for each. What the draw produced is read back from +// the Scores tab, never assumed. +import { expect } from '@playwright/test'; +import { createTournament, login } from './setup.mjs'; +import { createCompetition } from './wizard.mjs'; +import { generateDraw, pasteRoster, startCompetition } from './competition.mjs'; +import { hastyShiaijoConfirm } from './clumsy.mjs'; +import { OPERATOR_DEVICE, PUBLIC_DEVICE } from './devices.mjs'; +import { isCoarse, restoreCoarse } from './pointer.mjs'; +import { INLINE_EDITOR } from './scoring.mjs'; +import { UP_NEXT, WAITING_ROWS, completedRowFor, inlineEditor, waitingRows } from './shiaijo.mjs'; +import * as T from './team.mjs'; + +// A device of its own: an operator iPad (pointer verified coarse, as the +// `page` fixture does for the test's own page) or a spectator phone, on this +// file's server. Each is a separate browser context, so two of them are two +// devices: separate storage, separate event streams. The caller closes +// `page.context()`. +export async function operatorDevice(browser, base) { + const ctx = await browser.newContext({ ...OPERATOR_DEVICE, baseURL: base }); + const page = await ctx.newPage(); + if (!(await isCoarse(page))) await restoreCoarse(page); + return page; +} +export async function publicDevice(browser, base) { + const ctx = await browser.newContext({ ...PUBLIC_DEVICE, baseURL: base }); + return ctx.newPage(); +} + +// A mark left on a page's window, read back later: still there means the page +// was never reloaded or navigated away in between. +export const markPage = (page, mark) => page.evaluate((m) => { window.__e2eMark = m; }, mark); +export const pageMark = (page) => page.evaluate(() => window.__e2eMark || null); + +// Sign in, creating the tournament first when this file's server has none. +export async function enterTournament(page, { name = 'Shared Court Cup', courts = 2 } = {}) { + await page.goto('/'); + const welcome = page.getByRole('heading', { name: 'Welcome to Bracket Creator' }); + const first = await welcome.waitFor({ state: 'visible', timeout: 5000 }).then(() => true, () => false); + if (first) { + await createTournament(page, { name, courts }); + return { created: true }; + } + await login(page); + return { created: false }; +} + +// What the create wizard offers before a competition is created: its default +// Start time (stacked after the latest same-day competition, admin_setup.jsx) +// and the hint under the shiaijo choice. Read on the wizard page itself, the +// one createCompetition then fills; the stacked default arrives from an +// estimate request, so the value is read once it has stopped changing. +export async function wizardDefaults(page) { + await page.goto('/admin/create-competition'); + await expect(page.getByRole('heading', { level: 1, name: 'Add competition' })).toBeVisible(); + const input = page.locator('#create-start-time'); + let last = await input.inputValue(); + for (let i = 0; i < 6; i += 1) { + await page.waitForTimeout(300); + const now = await input.inputValue(); + if (now === last && i >= 2) break; + last = now; + } + const hint = await page.locator('.field__hint', { hasText: 'Different competitions can share' }).first() + .innerText().then((t) => t.trim(), () => null); + return { startTime: last, sharedCourtHint: hint }; +} + +// Create every competition first, then paste each roster, generate its draw and +// start it. `specs` is [{ key, opts, roster }] (opts as createCompetition takes +// them). Returns { ids: {key: id}, defaults: {key: wizardDefaults()} }. +export async function seedCompetitions(page, specs) { + const ids = {}; + const defaults = {}; + for (const s of specs) { + defaults[s.key] = await wizardDefaults(page); + ids[s.key] = await createCompetition(page, s.opts); + } + for (const s of specs) { + await pasteRoster(page, ids[s.key], s.roster); + await generateDraw(page, ids[s.key]); + await startCompetition(page, ids[s.key]); + } + return { ids, defaults }; +} + +// Every row of a competition's Scores tab as the operator reads it: time, +// match label, court, both names and the button it offers. The pairings and +// courts the draw produced come from here. +export async function readScoresTab(page, id) { + await page.goto(`/admin/competition/${encodeURIComponent(id)}/scores`); + await expect(page.locator('.score-edit-row').first()).toBeVisible(); + return page.locator('.score-edit-row').evaluateAll((rows) => rows.map((r) => { + const txt = (sel) => { + const el = r.querySelector(sel); + return el ? el.textContent.replace(/\s+/g, ' ').trim() : ''; + }; + const names = [...r.querySelectorAll('.numbered-name__text')].map((n) => n.textContent.trim()); + const btn = [...r.querySelectorAll('button')].map((b) => b.textContent.trim()).find((t) => t === 'Score' || t === 'Correct') || ''; + return { + time: txt('.score-edit-row__time'), + label: txt('.score-edit-row__matchno'), + court: txt('.score-edit-row__court').replace(/\s*▾$/, ''), + shiro: names[0] || '', + aka: names[1] || '', + status: r.classList.contains('score-edit-row--running') ? 'running' : (r.classList.contains('score-edit-row--complete') ? 'completed' : 'scheduled'), + button: btn, + }; + })); +} + +// The knockout slots a competition's Bracket tab prints on its cards ("M5 +// SHIAIJO A 09:40"), including the matches still waiting on a feeder, which +// the Scores tab does not list. +export async function bracketSlots(page, id) { + await page.goto(`/admin/competition/${encodeURIComponent(id)}/bracket`); + // The cards print the court in capitals through CSS, so the rendered text + // (innerText) is what is read. + const read = async () => (await page.locator('.page').first().innerText()).replace(/\s+/g, ' '); + await expect.poll(read).toMatch(/M\d+ SHIAIJO \S+ \d\d:\d\d/); + const text = await read(); + return [...text.matchAll(/M(\d+) SHIAIJO (\S+) (\d\d:\d\d)/g)].map((m) => ({ match: `M${m[1]}`, court: m[2], time: m[3] })); +} + +// ---------------------------------------------------------------- court console + +// The competition picker. It renders only while the court hosts more than one +// competition; with one, the officiating name is a plain heading. +export const competitionPicker = (page) => page.getByRole('combobox', { name: 'Select competition to officiate' }); +export const officiatingName = async (page) => (await page.locator('.shiaijo-officiating__name').first().innerText()).replace('▾', '').trim(); + +// Pick a competition with the picker (a native select: chosen, not tapped, as +// the iPad's own picker wheel does it). +export async function pickCompetition(page, name) { + await competitionPicker(page).selectOption({ label: name }); + await expect.poll(() => officiatingName(page)).toBe(name); +} + +// The nudge lines (data-testid shiaijo-nudge): the quiet "also waiting" line +// while the shown competition still has work here, the amber "Switch to" once +// it has none. Each is a button that switches only when tapped. +export const nudges = (page) => page.getByTestId('shiaijo-nudge'); +export async function nudgeLines(page) { + return nudges(page).evaluateAll((els) => els.map((e) => ({ + text: (e.querySelector('.shiaijo-nudge__text') || e).textContent.trim(), + quiet: e.classList.contains('shiaijo-nudge--also'), + amber: e.classList.contains('alert--warn'), + }))); +} + +// Everything the console says about what this court is doing, in one record +// for an audit row: the competition shown and the picker's choices, Up next, +// the queue, the live bout in the editor, the nudge lines, the "Another bout +// is running" alert and the done state. +export async function courtState(page) { + return page.evaluate(({ UP_NEXT, WAITING_ROWS, INLINE_EDITOR }) => { + const text = (el) => (el ? el.textContent.replace(/\s+/g, ' ').trim() : null); + const names = (el) => (el ? [...el.querySelectorAll('.numbered-name__text')].map((n) => n.textContent.trim()) : []); + const picker = document.querySelector('select[aria-label="Select competition to officiate"]'); + const card = document.querySelector(UP_NEXT); + const ed = document.querySelector(INLINE_EDITOR); + const rows = (sel) => [...document.querySelectorAll(sel)].map((r) => ({ head: text(r.querySelector('.shiaijo-qrow__time')), names: names(r) })); + const alsoRunning = document.querySelector('.shiaijo-also-running'); + const empty = document.querySelector('.shiaijo__main > .empty:not(.shiaijo__placeholder)'); + return { + officiating: text(document.querySelector('.shiaijo-officiating__name'))?.replace('▾', '').trim() || null, + pickerOptions: picker ? [...picker.options].map((o) => o.textContent.trim()) : null, + upNext: card ? { head: text(card.querySelector('.shiaijo-upnext__time')), names: names(card) } : null, + upcoming: rows(WAITING_ROWS), + later: rows('.shiaijo-pending .shiaijo-qrow'), + completed: rows('.shiaijo-completed .shiaijo-qrow').length, + editor: ed ? { + eyebrow: text(ed.querySelector('.editor-modal__eyebrow')), + court: ((ed.querySelector('.editor-modal__title') || {}).innerText || '').match(/Shiaijo\s+([A-Z]+)\b/i)?.[1] || null, + title: ((ed.querySelector('.editor-modal__title') || {}).innerText || '').replace(/\s+/g, ' ').trim(), + pill: text(ed.querySelector('.editor-head-pill')), + names: names(ed).slice(0, 2), + } : null, + nudges: [...document.querySelectorAll('[data-testid="shiaijo-nudge"]')].map((e) => ({ + text: text(e.querySelector('.shiaijo-nudge__text')), quiet: e.classList.contains('shiaijo-nudge--also'), + })), + alsoRunning: alsoRunning ? text(alsoRunning) : null, + done: empty ? text(empty) : null, + idle: !!document.querySelector('.shiaijo__placeholder'), + }; + }, { UP_NEXT, WAITING_ROWS, INLINE_EDITOR }); +} + +// The bout running in the console's editor, individual or team alike (the +// individual board's own side wrappers are not on the team sheet): its eyebrow, +// court and pairing, or null when the editor holds no running bout. +export async function liveBout(page) { + const s = await courtState(page); + if (!s.editor || s.editor.pill || s.editor.names.length < 2) return null; + return { eyebrow: s.editor.eyebrow, court: s.editor.court, shiro: s.editor.names[0], aka: s.editor.names[1] }; +} + +// Correct a completed bout on the court console while a bout of EITHER +// competition is live there (individual.mjs correctCompleted reads the live +// bout off the individual board only). Correct is refused while a bout is live +// (bc-crpn): the notice at the row says so, the operator sends the live bout +// back to the queue (its score is kept) and taps Correct again. Returns the +// live bout and what the refusal said. +export async function correctAcross(page, pair) { + const done = completedRowFor(page, pair); + const live = await liveBout(page); + await done.getByRole('button', { name: 'Correct' }).tap(); + let refusal = null; + if (live) { + const notice = done.locator('.shiaijo-upnext__error[role="alert"]'); + await expect(notice).toBeVisible(); + refusal = { notice: (await notice.innerText()).trim(), toast: (await page.locator('.toast').allInnerTexts().catch(() => [])).join(' | ') }; + expect(refusal.notice).toMatch(/^Shiaijo \S+ is running .+\. Finish it or send it back to the queue first\. Then correct this match\.$/); + await page.getByRole('button', { name: 'Send back to queue' }).tap(); + refusal.sendBack = await hastyShiaijoConfirm(page); + await expect.poll(() => liveBout(page), { timeout: 10_000 }).toBeNull(); + await done.getByRole('button', { name: 'Correct' }).tap(); + } + await expect(inlineEditor(page).first().locator('.editor-head-pill')).toHaveText('CORRECTION'); + return { live, refusal }; +} + +// A waiting row of the queue (Upcoming, not the Up next card) naming both +// competitors of a pairing. +export const upcomingRow = (page, { shiro, aka }) => waitingRows(page) + .filter({ hasText: shiro }).filter({ hasText: aka }).first(); + +// Open a row's court control (CourtPicker: "A ▾") and choose `court`. On the +// court console that opens the move confirm, left open for the caller; on the +// Scores tab and the schedule page the move is made at once. +export async function chooseCourt(row, court) { + await row.locator('button[aria-haspopup="listbox"]').first().tap(); + const list = row.page().getByRole('listbox', { name: 'Choose shiaijo' }); + await expect(list).toBeVisible(); + await list.getByRole('option', { name: court, exact: true }).tap(); +} + +// The matches the tournament-wide Score editor lists as running ("Now") on +// `court`, from a page that may be anywhere (it is navigated there): the head +// table's count of what is being fought, independent of the court console. +export async function runningOnCourt(page, court) { + await page.goto('/admin/score-editor'); + await expect(page.getByRole('heading', { level: 1, name: 'Score editor' })).toBeVisible(); + await page.getByRole('group', { name: 'Match status' }).getByRole('button', { name: 'Now' }).tap(); + await page.waitForTimeout(500); + const rows = await page.locator('.score-edit-row').evaluateAll((els) => els.map((r) => { + const names = [...r.querySelectorAll('.numbered-name__text')].map((n) => n.textContent.trim()); + const ct = r.querySelector('.score-edit-row__court'); + const comp = r.querySelector(':scope > div:first-child > div:last-child'); + return { + court: ct ? ct.textContent.replace(/\s*▾$/, '').trim() : '', + comp: comp ? comp.textContent.trim() : '', + label: (r.querySelector('.score-edit-row__matchno') || {}).textContent || '', + shiro: names[0] || '', aka: names[1] || '', + status: r.classList.contains('score-edit-row--running') ? 'running' : 'other', + }; + })); + return rows.filter((r) => r.court === court && r.status === 'running'); +} + +// ---------------------------------------------------------------- team match + +// Play the running TEAM match on the court console for its Shiro team (one men +// on every numbered bout) and Finish (+ Start Next) through the two-tap guard. +// Starts the Up next team match first when the court is idle. Returns the +// pairing played. +export async function playTeamMatch(page, { teamSize = 3 } = {}) { + const ed = inlineEditor(page); + if (!(await T.boutRow(ed, 1).isVisible().catch(() => false)) || (await ed.locator('.editor-head-pill').count()) > 0) { + await T.startUpNextTeam(page); + } + const names = await ed.locator('.numbered-name__text').allInnerTexts(); + const pair = { shiro: names[0].trim(), aka: names[1].trim() }; + for (let n = 1; n <= teamSize; n += 1) { + const row = T.boutRow(ed, n); + if ((await T.rowSlots(row, 'shiro').count()) === 0 && (await T.rowSlots(row, 'aka').count()) === 0) { + await T.boutIppon(ed, n, 'shiro', 'M'); + } + } + await T.finishTeam(ed); + return pair; +} diff --git a/scripts/e2e/fixtures/shiaijo.mjs b/scripts/e2e/fixtures/shiaijo.mjs index a70764cb8..ffd7cbc58 100644 --- a/scripts/e2e/fixtures/shiaijo.mjs +++ b/scripts/e2e/fixtures/shiaijo.mjs @@ -1,6 +1,7 @@ // The court operator's page, /admin/shiaijo/:court (admin_shiaijo.jsx): the // queue on the left (Up next, Upcoming, Completed) and the inline score -// editor on the right. +// editor on the right. Every selector for the court console's own surfaces +// lives here; the editor's insides belong to the scoring fixtures. import { expect } from '@playwright/test'; import { INLINE_EDITOR } from '../../screenshots/lib/editor.mjs'; @@ -9,9 +10,19 @@ export async function openShiaijo(page, court) { await expect(page.getByRole('heading', { level: 1, name: `Shiaijo ${court}` })).toBeVisible(); } -export const upNextCard = (page) => page.locator('.shiaijo-upnext__card'); +// The queue's parts, as selectors for a caller that combines them (or reads +// them inside the page) and as locators. WAITING_ROWS is Upcoming: the rows +// still to be played, not Later and not Completed. +export const UP_NEXT = '.shiaijo-upnext__card'; +export const WAITING_ROWS = '.shiaijo__queue .shiaijo-group:not(.shiaijo-pending) .shiaijo-qrow:not(.shiaijo-qrow--complete)'; +export const upNextCard = (page) => page.locator(UP_NEXT); +export const waitingRows = (page) => page.locator(WAITING_ROWS); export const completedRows = (page) => page.locator('.shiaijo-completed .shiaijo-qrow'); export const inlineEditor = (page) => page.locator(INLINE_EDITOR); +// "Ready when you are": nothing selected while matches remain. And the done +// state, " is complete on Shiaijo X", once none remain. +export const courtIdle = (page) => page.locator('.shiaijo__placeholder'); +export const courtDone = (page) => page.locator('.shiaijo__main > .empty:not(.shiaijo__placeholder)'); // The two names a match row shows, Shiro first. `.numbered-name__text` is the // name alone, without the competitor number chip beside it. @@ -20,10 +31,24 @@ export async function sides(row) { return { shiro: (await names.nth(0).textContent()).trim(), aka: (await names.nth(1).textContent()).trim() }; } -// The "MEN INDIVIDUAL · POOL A · MATCH 1 OF 3" eyebrow over the running -// match: what tells the operator which match the editor holds. -export async function editorIdentity(page) { - return (await inlineEditor(page).locator('.editor-modal__eyebrow').first().innerText()).trim(); +// Which match an editor holds, as the operator reads it: its eyebrow ("MEN +// INDIVIDUAL · POOL A · MATCH 1 OF 3"), its title line ("Shiaijo B · 10:20", +// which says the court) and the first two names it shows. `key` joins them, +// so two readings tell whether the editor moved to another match. The inline +// editor by default; pass EDITOR for the overlay the Scores tab opens, or any +// editor Locator. +export async function editorIdentity(page, root = INLINE_EDITOR) { + const ed = typeof root === 'string' ? page.locator(root).first() : root.first(); + const first = async (sel) => ((await ed.locator(sel).allInnerTexts())[0] || '').trim(); + const eyebrow = await first('.editor-modal__eyebrow'); + // The title's first line: the autosave pill beside it is not the match. + const title = (await first('.editor-modal__title')).split('\n')[0].trim(); + const names = (await ed.locator('.numbered-name__text').allInnerTexts()).slice(0, 2).map((t) => t.trim()); + return { + eyebrow, title, names, + court: (title.match(/Shiaijo\s+(\S+)/) || [])[1] || null, + key: [eyebrow, title, ...names].join(' | '), + }; } // Start the Up next match the way the operator does, from its card, and diff --git a/scripts/e2e/fixtures/team.mjs b/scripts/e2e/fixtures/team.mjs index ac0ca4075..2ff7b34fa 100644 --- a/scripts/e2e/fixtures/team.mjs +++ b/scripts/e2e/fixtures/team.mjs @@ -11,11 +11,16 @@ // // The shared scoring helpers (fixtures/scoring.mjs) scope to the INDIVIDUAL // board's `.sb-side--*` wrappers, which the team sheet does not have, so the -// team sheet's own selectors live here. Every helper performs a real tap on a -// real control; none of them asserts app behaviour beyond "the tap landed". +// team sheet's own selectors live here, for the kachinuki sheet as well +// (fixtures/kachinuki.mjs reads its bout rows through them), and so does the +// lineup panel. Every helper performs a real tap on a real control; none of +// them asserts app behaviour beyond "the tap landed", except Finish, which +// checks the write landed (fixtures/scoring.mjs finishLanded). import { expect } from '@playwright/test'; import { createTournament, login } from './setup.mjs'; -import { settled } from './pace.mjs'; +import { settled, tapOnSide } from './pace.mjs'; +import { beforeFinish, finishLanded } from './scoring.mjs'; +import { inlineEditor, upNextCard } from './shiaijo.mjs'; // FIK position names for a five-person team, in order; any other size uses // bare numbers (admin_lineup.jsx positionsForSize). @@ -101,7 +106,7 @@ export async function lineupsStartAdd(page, label, name) { // Answer the open confirm dialog with its named button. The wait is on the // dialog that was answered (its element handle), not on a re-resolving // locator, which a second dialog opened by the answer would satisfy. -export async function answerDialog(page, label) { +async function answerDialog(page, label) { const dialog = page.locator('.modal[role="dialog"]').filter({ has: page.locator('.modal__foot') }).last(); // A confirm ignores a tap inside the bounce window of its opening. await settled(dialog); @@ -132,19 +137,27 @@ export const lineupsSelected = async (page, label) => (await lineupsSelect(page, // ---------------------------------------------------------------- match panel -// One side of the panel: 0 = Shiro (left), 1 = Aka (right). -export const panelSide = (page, i) => page.locator('[data-testid^="match-lineup-side-"]').nth(i); +// The per-match lineup panel (MatchLineupPanel, admin_schedule_lineup.jsx), a +// dialog named "Lineup for this match", opened by "Enter lineup" on the +// shiaijo page's Up next card or the Scores tab. Its sides are found by index +// (0 = Shiro, left; 1 = Aka, right) or by the team name they show. +const lineupPanel = (page) => page.getByRole('dialog', { name: 'Lineup for this match' }); +const panelSides = (page) => lineupPanel(page).locator('[data-testid^="match-lineup-side-"]'); +export const panelSide = (page, i) => panelSides(page).nth(i); +export const panelSideOf = (page, teamName) => panelSides(page).filter({ hasText: teamName }).first(); export const panelInput = (page, i, label) => panelSide(page, i).getByRole('textbox', { name: `${label} player`, exact: true }); // Where the lineup a side shows comes from: "Lineup for this match", "Same as // " or "Starting lineup" (LineupSourceLine, lineup_draft.jsx). -export const panelSource = (page, i) => panelSide(page, i).locator('[data-testid^="match-lineup-source-"]'); +export const sideSource = (side) => side.locator('[data-testid^="match-lineup-source-"]'); +export const panelSource = (page, i) => sideSource(panelSide(page, i)); // The side's Save lineup, which stays disabled until the operator changes // something on that side and reads "Saving…" while the write is out. export const panelSaveButton = (page, i) => panelSide(page, i).getByRole('button', { name: /^(Save lineup|Saving…)$/ }); // Open the Up next card's "Enter lineup". export async function openPanelFromUpNext(page) { - await page.locator('.shiaijo-upnext__card').getByRole('button', { name: 'Enter lineup' }).tap(); + await upNextCard(page).getByRole('button', { name: 'Enter lineup' }).tap(); + await expect(lineupPanel(page)).toBeVisible(); await expect(panelSide(page, 1)).toBeVisible(); } @@ -159,10 +172,13 @@ export async function pickFromNameBox(input, text) { } // A name list left open (by the operator, or by a tap that landed on a name -// box). Close it the way a thumb does, with a tap on the empty page margin: -// Escape leaves the box focused, and a focused box does not reopen its list -// on the next tap, which a touch operator never meets. Returns whether one was -// open. Any LineupNameInput: the panel, the team sheet, the kachinuki sheet. +// box). Close it the way a thumb does, with a tap on the empty page margin. A +// box left focused that way still reopens its list on the next tap (bc-flst), +// so Escape would do as well while the list is open, but it is not what a +// touch operator does, and an Escape that arrives just after the list closed +// closes the layer behind it instead (the panel, or the editor). Returns +// whether one was open. Any LineupNameInput: the panel, the team sheet, the +// kachinuki sheet. export async function closeNameList(page) { const options = page.locator('.pmf__option'); const wasOpen = (await options.count()) > 0; @@ -189,8 +205,8 @@ export async function panelSave(page, i) { } export async function closePanel(page) { - await page.getByRole('button', { name: /✕ Close|Done/ }).first().tap(); - await expect(page.locator('[data-testid^="match-lineup-side-"]')).toHaveCount(0); + await lineupPanel(page).getByRole('button', { name: /^(✕ Close|Done)$/ }).tap(); + await expect(lineupPanel(page)).toHaveCount(0); } // ---------------------------------------------------------------- team sheet @@ -200,7 +216,7 @@ export const boutRow = (root, n) => root.locator('.team-sub-match').filter({ has: root.page().locator('.team-sub-match__pos-num', { hasText: new RegExp(`^${n}$`) }), }).first(); -export const rowSide = (row, side) => row.locator(`.team-sub-match__side--${side}`); +const rowSide = (row, side) => row.locator(`.team-sub-match__side--${side}`); export const rowIppon = (row, side, waza = 'M') => rowSide(row, side).locator('.ipt-btn', { hasText: new RegExp(`^${waza}$`) }).first(); export const rowFusensho = (row, side) => rowSide(row, side).getByTestId('scoring-modal-fusensho-button'); export const rowTie = (row) => row.getByTestId('scoring-modal-tie-button'); @@ -249,18 +265,20 @@ export async function sheetState(root, bouts = 5) { // the sheet shows its autosave pill only once the start has landed (before // that it reads PRE-MATCH, and a strike tapped then is not saved). export async function startUpNextTeam(page) { - await page.locator('.shiaijo-upnext__card').getByRole('button', { name: 'Start match' }).tap(); - const ed = page.locator('.scoring-panel'); + await upNextCard(page).getByRole('button', { name: 'Start match' }).tap(); + const ed = inlineEditor(page); await expect(boutRow(ed, 1)).toBeVisible(); await expect(syncPill(ed)).toBeVisible(); return ed; } -// Award one strike on a bout row and wait for the autosave to land. +// Award one strike on a bout row and wait for the autosave to land. A second +// strike for the same side of the same bout waits out the bounce window of the +// first (fixtures/pace.mjs). export async function boutIppon(root, n, side, waza = 'M') { const row = boutRow(root, n); const before = await rowSlots(row, side).count(); - await rowIppon(row, side, waza).tap(); + await tapOnSide(root.page(), `bout ${n} ${side}`, rowIppon(row, side, waza)); await expect(rowSlots(row, side)).toHaveCount(before + 1); await expect(syncPill(root)).toHaveText('Synced'); } @@ -268,14 +286,18 @@ export async function boutIppon(root, n, side, waza = 'M') { // The primary footer button in whatever state it is in. export const finishBtn = (root) => root.locator('.score-nav__actions button.btn--primary').first(); -// Finish in two taps and wait for the write to land. +// Finish in two taps and return once the write has landed, failing if it did +// not (fixtures/scoring.mjs finishLanded, which reads the same signs on the +// team sheet as on the individual board). export async function finishTeam(root) { + const ed = root.first(); const btn = finishBtn(root); await expect(btn).toHaveText(/^Finish( \+ Start Next →)?$/); await btn.tap(); await expect(btn).toHaveText(/^Tap again to finish/); // The second tap counts once the arming tap's bounce window has passed. await settled(btn); + const before = await beforeFinish(ed); await btn.tap(); - await expect(root.locator('button', { hasText: /^(Tap again to finish|Saving…)/ })).toHaveCount(0, { timeout: 15000 }); + return finishLanded(ed, before); } diff --git a/scripts/e2e/fixtures/test.mjs b/scripts/e2e/fixtures/test.mjs index 6b6dc6b08..4443c1709 100644 --- a/scripts/e2e/fixtures/test.mjs +++ b/scripts/e2e/fixtures/test.mjs @@ -1,11 +1,136 @@ // The `test` every journey imports: Playwright's own, plus a server per spec -// file and an operator page whose pointer is verified coarse. +// file, an operator page whose pointer is verified coarse, and the console +// policy every page of the test is held to. import { test as base, expect } from '@playwright/test'; import { start } from '../../screenshots/lib/server.mjs'; +import { auditsFor } from './audit.mjs'; import { isCoarse, restoreCoarse } from './pointer.mjs'; export { isCoarse }; +// ---------------------------------------------------------------- console -- +// +// The console policy (operator decision 2026-10-11). Every page of every +// browser context a journey opens is listened to: the default page, and every +// context a spec opens itself with browser.newContext, in a test or in a +// beforeAll hook. A console ERROR or an uncaught page error FAILS the test +// unless it matches CONSOLE_ALLOWLIST; a console WARNING is recorded (an audit +// row when the spec keeps an audit, else a CONSOLE line in the run log) and +// never fails. The check runs when the test ends, never at the first message, +// so a failing journey still leaves its screenshots, audit and trace. +// +// The allowlist is for noise a journey causes ON PURPOSE and nothing else. +// Each entry names the message as narrowly as it can, with one line saying +// which scenario makes it. An error that looks like the app's own fault is +// never added here: it is left failing for a person to decide. An entry's +// fields, all of which must match: +// +// text the message, as the browser or the app logs it +// url where it came from (a failed request's URL, or the script) +// code the app's refusal code in the failed /api answer's body +// whileOffline only while the page's context is offline (or just back) +const CONSOLE_ALLOWLIST = [ + { + reason: 'A journey takes the device offline on purpose (clumsy interrupt "offline", the offline-finish journeys); the browser logs each /api request it could not send.', + text: /^Failed to load resource: net::ERR_INTERNET_DISCONNECTED$/, + url: /\/api\//, + whileOffline: true, + }, + { + reason: 'The court console logs its queue refresh failing while the journey has the device offline (fetchCourtMatches, admin_shiaijo.jsx); it refreshes again once back online.', + text: /^Failed to fetch court matches TypeError: Failed to fetch\b/, + url: /\/dist\/admin_shiaijo\.js$/, + whileOffline: true, + }, + { + reason: 'A start or reopen the app sends through the server\'s gate and shows refused (toast, row notice, court-busy panel, reopen error): the shiaijo is running another bout, the competitor is fighting elsewhere, or the knockout match it feeds is being fought.', + text: /^Failed to load resource: the server responded with a status of 409 \(Conflict\)$/, + url: /\/api\/competitions\/[^/]+\/matches\/[^/]+\/(score|reopen)$/, + code: /^(court_busy|ineligible_competitor|downstream_knockout_running)$/, + }, +]; + +// The `error` field of a refusal body; read with a pattern, since the body is +// kept only in part. +const refusalCode = (body) => (/"error"\s*:\s*"([^"]+)"/.exec(body || '') || [])[1] || null; +const allowed = (e) => e.kind === 'console' && CONSOLE_ALLOWLIST.find((r) => r.text.test(e.text) + && (!r.url || r.url.test(e.url || '')) + && (!r.code || r.code.test(refusalCode(e.body) || '')) + && (!r.whileOffline || e.offline)); + +// One collector per worker. Pages report into `pending`, and each test's +// policy fixture drains it when the test ends: what a beforeAll hook's pages +// said lands in the first test of the file, marked as such. Two facts are kept +// beside each message so an allowlist entry can be narrow: whether the page's +// context was offline when it was logged (a journey's setOffline, or the +// moment after it came back, while the requests sent offline still fail), and, +// for the browser's "Failed to load resource" line, the /api answer it refers +// to (status and the start of its body, where the app's refusal code is). +const OFFLINE_GRACE_MS = 3_000; +function consoleCollector() { + const pending = []; + const failed = []; // { url, status, body, at } + const seen = new WeakSet(); + const offline = new WeakMap(); // context -> { now, until } + const wasOffline = (ctx) => { + const o = offline.get(ctx); + return !!o && (o.now || Date.now() <= o.until); + }; + const attachPage = (page) => { + if (seen.has(page)) return; + seen.add(page); + const pageUrl = () => { try { return page.url(); } catch { return null; } }; + const ctx = page.context(); + page.on('console', (msg) => { + const type = msg.type(); + if (type !== 'error' && type !== 'warning') return; + const loc = msg.location() || {}; + pending.push({ kind: 'console', type, text: msg.text(), url: loc.url || null, line: loc.lineNumber ?? null, + page: pageUrl(), offline: wasOffline(ctx), at: Date.now() }); + }); + page.on('pageerror', (err) => { + pending.push({ kind: 'pageerror', type: 'error', text: err.message, stack: err.stack || null, page: pageUrl(), + offline: wasOffline(ctx), at: Date.now() }); + }); + page.on('response', async (res) => { + if (res.status() < 400 || !/\/api\//.test(res.url())) return; + const at = Date.now(); + const body = await res.text().then((t) => t.slice(0, 1000), () => null); + failed.push({ url: res.url(), status: res.status(), body, at }); + }); + }; + const attachContext = (ctx) => { + if (seen.has(ctx)) return; + seen.add(ctx); + const setOffline = ctx.setOffline; + ctx.setOffline = async (on) => { + offline.set(ctx, { now: !!on, until: on ? Infinity : Date.now() + OFFLINE_GRACE_MS }); + await setOffline.call(ctx, on); + if (!on) offline.set(ctx, { now: false, until: Date.now() + OFFLINE_GRACE_MS }); + }; + ctx.on('page', attachPage); + for (const p of ctx.pages()) attachPage(p); + }; + // The answer a "Failed to load resource" line is about: the same URL, + // answered within a few seconds of it. + const answerFor = (e) => { + if (e.kind !== 'console' || !e.url || !/^Failed to load resource/.test(e.text)) return null; + const near = failed.filter((f) => f.url === e.url && Math.abs(f.at - e.at) < 5_000); + return near.length ? near[near.length - 1] : null; + }; + const drain = () => { + const out = pending.splice(0, pending.length).map((e) => { + const answer = answerFor(e); + return answer ? { ...e, status: answer.status, body: answer.body } : e; + }); + failed.splice(0, failed.length); + return out; + }; + return { attachPage, attachContext, drain }; +} + +const describeEntry = (e) => `${e.kind === 'pageerror' ? 'uncaught page error' : `console ${e.type}`} on ${e.page || '(no page)'}: ${e.text}${e.url ? ` (from ${e.url}${e.line != null ? `:${e.line}` : ''})` : ''}`; + export const test = base.extend({ // One mobile-app server per SPEC FILE, on a free port and a throwaway // TOURNAMENT_DATA_DIR, started and stopped by scripts/screenshots' @@ -41,6 +166,67 @@ export const test = base.extend({ } }, { scope: 'worker' }], + // The console policy's collector (above). Whatever is still pending when the + // worker ends came from an afterAll hook, after the last test's check: it is + // reported there, and an error fails the run. + consoleCollector: [async ({}, use) => { + const c = consoleCollector(); + await use(c); + const late = c.drain(); + for (const e of late) console.log(`CONSOLE ${JSON.stringify({ phase: 'after the last test (an afterAll hook)', ...e })}`); + const bad = late.filter((e) => e.type === 'error' && !allowed(e)); + if (bad.length) throw new Error(`console policy: ${bad.length} error(s) after the last test:\n${bad.map(describeEntry).join('\n')}`); + }, { scope: 'worker' }], + + // Every context made through this browser, a spec's own browser.newContext + // (and browser.newPage, which goes through it) as well as the default + // `context` fixture's, is attached to the collector. + browser: [async ({ browser, consoleCollector: c }, use) => { + const newContext = browser.newContext; + browser.newContext = async (...args) => { + const ctx = await newContext.apply(browser, args); + c.attachContext(ctx); + return ctx; + }; + try { + await use(browser); + } finally { + browser.newContext = newContext; + } + }, { scope: 'worker' }], + + // The default context goes through browser.newContext today; attached here + // as well so the policy does not depend on how Playwright builds it. + context: async ({ context, consoleCollector: c }, use) => { + c.attachContext(context); + await use(context); + }, + + // The check, at the end of every test (auto, so no journey has to ask). + consolePolicy: [async ({ consoleCollector: c }, use, testInfo) => { + const startedAt = Date.now(); + await use(); + const entries = c.drain().map((e) => (e.at < startedAt ? { ...e, phase: 'before this test (a beforeAll hook)' } : e)); + if (!entries.length) return; + await testInfo.attach('console.json', { body: JSON.stringify(entries, null, 2), contentType: 'application/json' }); + const audits = auditsFor(testInfo.file); + for (const e of entries.filter((x) => x.type === 'warning')) { + const row = { journey: 'console', test: testInfo.title, ...e }; + if (audits.length) for (const a of audits) a.record(row); + else console.log(`CONSOLE ${JSON.stringify(row)}`); + testInfo.annotations.push({ type: 'console-warning', description: describeEntry(e) }); + } + const errors = entries.filter((e) => e.type === 'error'); + for (const e of errors) { + const rule = allowed(e); + console.log(`CONSOLE ${JSON.stringify({ test: testInfo.title, allowed: rule ? rule.reason : null, ...e })}`); + } + const bad = errors.filter((e) => !allowed(e)); + if (bad.length) { + throw new Error(`console policy: ${bad.length} console error(s) not on CONSOLE_ALLOWLIST (fixtures/test.mjs):\n${bad.map(describeEntry).join('\n')}`); + } + }, { auto: true }], + // The server of the file this test is in. server: async ({ serverForFile }, use, testInfo) => { await use(await serverForFile(testInfo.file)); @@ -55,7 +241,8 @@ export const test = base.extend({ // isMobile (fixtures/devices.mjs) is what provides it today, measured. If // that ever stops holding, turn on CDP touch emulation in place // (restoreCoarse, which fails loudly if even that does not do it). - page: async ({ page }, use, testInfo) => { + page: async ({ page, consoleCollector: c }, use, testInfo) => { + c.attachPage(page); if (testInfo.project.name === 'operator' && !(await isCoarse(page))) { await restoreCoarse(page); testInfo.annotations.push({ type: 'coarse-pointer', description: 'CDP Emulation.setTouchEmulationEnabled' }); diff --git a/scripts/e2e/fixtures/wizard.mjs b/scripts/e2e/fixtures/wizard.mjs index 9e9de330e..52b7847a1 100644 --- a/scripts/e2e/fixtures/wizard.mjs +++ b/scripts/e2e/fixtures/wizard.mjs @@ -16,29 +16,27 @@ const FORMAT = { const TEAM_MATCH_TYPE = { fixed: 'Regular', kachinuki: 'Kachinuki (winner stays on)' }; const POOL_SIZE_MODE = { maximum: 'maximum', minimum: 'minimum' }; -// A form row by its visible label. PillGroup, NumberField and TextField all -// render `.field > label.field__label` with no htmlFor (competition_fields.jsx), -// so getByLabel cannot reach them. Anchored, because "Pool match duration" -// and "Knockout match duration" share words with other labels. -const field = (page, label) => page.locator('.field') - .filter({ has: page.locator('.field__label', { hasText: new RegExp(`^${label}`) }) }) - .first(); - -// A pill in a group. The pills carry their state as the `is-active` class -// only (no aria-pressed), so that is what is read back. -const pill = (group, label) => group.locator('.radio-pill', { hasText: new RegExp(`^${escape(label)}$`) }); +// The wizard's controls by their accessible names (competition_fields.jsx): a +// NumberField or TextField input is named by its label through htmlFor, and a +// PillGroup's row is a role="group" named by its label (aria-labelledby), each +// pill a button whose aria-pressed says whether it is chosen. Labels are +// matched from the start, because an optional field's label carries an +// "(optional)" suffix and "Format" is also the end of "Team match format". const escape = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +const input = (page, label) => page.getByLabel(new RegExp(`^${escape(label)}`)); +const group = (page, label) => page.getByRole('group', { name: new RegExp(`^${escape(label)}`) }); +const pill = (grp, label) => grp.getByRole('button', { name: label, exact: true }); async function choose(page, groupLabel, optionLabel) { - const btn = pill(field(page, groupLabel), optionLabel); + const btn = pill(group(page, groupLabel), optionLabel); await btn.tap(); - await expect(btn).toHaveClass(/\bis-active\b/); + await expect(btn).toHaveAttribute('aria-pressed', 'true'); } async function setNumber(page, label, value) { - const input = field(page, label).locator('input'); - await input.fill(String(value)); - await expect(input).toHaveValue(String(value)); + const box = input(page, label); + await box.fill(String(value)); + await expect(box).toHaveValue(String(value)); } // Create one competition and return its id (read from the URL the wizard @@ -64,7 +62,7 @@ export async function createCompetition(page, opts = {}) { await page.goto('/admin/create-competition'); await expect(page.getByRole('heading', { level: 1, name: 'Add competition' })).toBeVisible(); - if (name !== undefined) await field(page, 'Display name').locator('input').fill(name); + if (name !== undefined) await input(page, 'Display name').fill(name); if (kind) await choose(page, 'Competition type', KIND[kind]); if (format) await choose(page, 'Format', FORMAT[format]); if (teamSize !== undefined) await setNumber(page, 'Team size', teamSize); @@ -76,24 +74,24 @@ export async function createCompetition(page, opts = {}) { await page.getByRole('checkbox', { name: 'Award two joint 3rd places' }).setChecked(twoThirdPlaces); } if (courts) { - const group = field(page, 'Assigned shiaijo'); - const pills = group.locator('.radio-pill'); + const grp = group(page, 'Assigned shiaijo'); + const pills = grp.getByRole('button'); // Select the wanted ones first, then drop the rest, so the selection is // never empty on the way (the wizard refuses an empty or uneven count). for (const want of [true, false]) { for (let i = 0; i < await pills.count(); i += 1) { const p = pills.nth(i); const court = (await p.textContent()).trim().split(' ').pop(); - const active = /\bis-active\b/.test(await p.getAttribute('class')); + const active = (await p.getAttribute('aria-pressed')) === 'true'; if (courts.includes(court) === want && active !== want) await p.tap(); } } for (const court of courts) { - await expect(pill(group, `Shiaijo (court) ${court}`)).toHaveClass(/\bis-active\b/); + await expect(pill(grp, `Shiaijo (court) ${court}`)).toHaveAttribute('aria-pressed', 'true'); } } if (numberPrefix !== undefined) { - await field(page, 'Player number prefix').locator('input').fill(numberPrefix); + await input(page, 'Player number prefix').fill(numberPrefix); } await page.getByRole('button', { name: /^Create & continue/ }).tap(); diff --git a/scripts/e2e/journeys/knockout-mixed-individual.spec.mjs b/scripts/e2e/journeys/knockout-mixed-individual.spec.mjs index 4644798cc..b10d9afb1 100644 --- a/scripts/e2e/journeys/knockout-mixed-individual.spec.mjs +++ b/scripts/e2e/journeys/knockout-mixed-individual.spec.mjs @@ -23,6 +23,7 @@ import { createCompetition } from '../fixtures/wizard.mjs'; import { generateDraw, pasteRoster, startCompetition } from '../fixtures/competition.mjs'; import { openShiaijo, upNextCard, inlineEditor, sides, completedRows, completedRowFor, lastCompleted, + editorIdentity, UP_NEXT, } from '../fixtures/shiaijo.mjs'; import { INLINE_EDITOR, EDITOR, ipponButton, syncState, filledSlots, finishButton, armedFinishButton, armFinish, finishMatch, @@ -33,7 +34,7 @@ import { import { auditLog } from '../fixtures/audit.mjs'; import { tapSize } from '../fixtures/pointer.mjs'; import { - ROSTER_12, ROSTER_6, ROSTER_4_BRONZE, ROSTER_4_JOINT, ROSTER_6B, rosterOf, inlineIdentity, overlayIdentity, + ROSTER_12, ROSTER_6, ROSTER_4_BRONZE, ROSTER_4_JOINT, ROSTER_6B, rosterOf, editorSides, slotMarks, winnerOf, inlineRunning, ensureRunning, playRunningBout, decisionButton, decisionPrompt, recordDecision, queuedPoolBouts, correctCompleted, } from '../fixtures/individual.mjs'; @@ -103,7 +104,7 @@ test.describe('knockout-mixed-individual', () => { shot: await shot(page, 'start-double-tapped') }); expect(running1).toEqual(firstPair); - const id1 = await inlineIdentity(page); + const id1 = await editorIdentity(page); const w1 = winnerOf(running1); const other = w1 === 'shiro' ? 'aka' : 'shiro'; @@ -170,7 +171,7 @@ test.describe('knockout-mixed-individual', () => { for (const kind of ['reload', 'hidden', 'back', 'offline']) { const r = await interrupt(page, kind); const back = await expect.poll(() => inlineRunning(page), { timeout: 15_000 }).toBe(true).then(() => true, () => false); - const id = back ? await inlineIdentity(page) : null; + const id = back ? await editorIdentity(page) : null; const marks = back ? await slotMarks(page, w1) : null; row({ step: 'running bout, one ippon', action: 'interruption', variant: `V3 interrupt ${kind}`, ...r, editorBack: back, sameMatch: !!id && id.eyebrow === id1.eyebrow && id.court === 'A', marksKept: marks, @@ -197,7 +198,7 @@ test.describe('knockout-mixed-individual', () => { // Finish + Start Next started the next bout on THIS court. await expect.poll(() => inlineRunning(page), { timeout: 15_000 }).toBe(true); - const id2 = await inlineIdentity(page); + const id2 = await editorIdentity(page); const running2 = await editorSides(page); expect(id2.court).toBe('A'); expect(running2).not.toEqual(running1); @@ -216,7 +217,7 @@ test.describe('knockout-mixed-individual', () => { await page.waitForTimeout(500); row({ step: 'finish', action: 'armed Finish', variant: 'V1 tapNeighbour left', hit: nbFinish.hit ? nbFinish.hit.label : null, note: nbFinish.note, stillArmed: await armedFinishButton(page).isVisible().catch(() => false), - editorCourt: (await inlineIdentity(page)).court, shot: await shot(page, 'finish-neighbour-tapped') }); + editorCourt: (await editorIdentity(page)).court, shot: await shot(page, 'finish-neighbour-tapped') }); if (!(await armedFinishButton(page).isVisible().catch(() => false))) await armFinish(page); const retap = await retapWhileSaving(armedFinishButton(page)); await expect(completedRowFor(page, running2)).toBeVisible(); @@ -224,9 +225,9 @@ test.describe('knockout-mixed-individual', () => { const running3 = await editorSides(page); const completedCount = await completedRows(page).count(); row({ step: 'finish', action: 'armed Finish', variant: 'V2 retapWhileSaving', ...retap, - completedRows: completedCount, nextRunning: running3, court: (await inlineIdentity(page)).court, + completedRows: completedCount, nextRunning: running3, court: (await editorIdentity(page)).court, shot: await shot(page, 'finish-retapped-while-saving') }); - expect((await inlineIdentity(page)).court).toBe('A'); + expect((await editorIdentity(page)).court).toBe('A'); // Bout 3: an ippon, then "Send back to queue" answered hastily. await ipponButton(page, winnerOf(running3), 'M').tap(); @@ -246,6 +247,7 @@ test.describe('knockout-mixed-individual', () => { shot: await shot(page, 'send-back-hasty') }); // It is back in the queue; start it again and play it. const again = await ensureRunning(page); + expect(again, 'the sent-back bout is Up next and starts again').not.toBeNull(); row({ step: 'send back', action: 'restart the sent-back bout', variant: 'recovery', restarted: again, sameAsSentBack: !!again && again.shiro === running3.shiro && again.aka === running3.aka, marksAfterRestart: again ? await slotMarks(page, winnerOf(again)) : null }); @@ -253,7 +255,7 @@ test.describe('knockout-mixed-individual', () => { // Play out court A's pool bouts but the last one. for (let i = 0; i < 8 && (await queuedPoolBouts(page)) > 0; i += 1) await playRunningBout(page); await expect.poll(() => inlineRunning(page), { timeout: 15_000 }).toBe(true); - expect((await inlineIdentity(page)).eyebrow).toMatch(/POOL/); + expect((await editorIdentity(page)).eyebrow).toMatch(/POOL/); await shot(page, 'court-A-last-pool-bout-running'); // Correct a completed bout while the last pool bout runs. The console @@ -269,7 +271,7 @@ test.describe('knockout-mixed-individual', () => { row({ step: 'correct', action: 'Correct while the last pool bout runs', variant: 'correct path (refused, bc-crpn)', liveBout: refusedFor, ...refusal, shot: await shot(page, 'correct-refused-then-sent-back') }); expect(refusedFor).toEqual(lastPoolLive); - const corrId = await inlineIdentity(page); + const corrId = await editorIdentity(page); await shot(page, 'correcting-bout-1'); await ipponButton(page, w1, 'K').tap(); await inlineEditor(page).getByRole('button', { name: 'Save correction' }).tap(); @@ -286,6 +288,7 @@ test.describe('knockout-mixed-individual', () => { if (stillCorrecting) await backToCourt.tap(); // Start the sent-back bout again: it is Up next, with the point it had. const lastPoolA = await ensureRunning(page); + expect(lastPoolA, 'the bout sent back for the correction starts again').not.toBeNull(); const marksAfterRestart = await slotMarks(page, winnerOf(lastPoolA)); row({ step: 'correct', action: 'restart the bout sent back for the correction', variant: 'recovery', restarted: lastPoolA, marksBefore: lastPoolMarks, marksAfterRestart, shot: await shot(page, 'back-to-live-bout') }); @@ -298,7 +301,7 @@ test.describe('knockout-mixed-individual', () => { const bOrder = []; for (let i = 0; i < 12; i += 1) { const running = await inlineRunning(page); - const eyebrow = running ? (await inlineIdentity(page)).eyebrow : ''; + const eyebrow = running ? (await editorIdentity(page)).eyebrow : ''; if ((await queuedPoolBouts(page)) === 0 && !/POOL/.test(eyebrow)) break; const played = await playRunningBout(page); bOrder.push({ ...played, eyebrow: eyebrow || '(started from Up next)' }); @@ -340,7 +343,7 @@ test.describe('knockout-mixed-individual', () => { for (let i = 0; i < 2; i += 1) { const p = await ensureRunning(page); expect(p).not.toBeNull(); - const idk = await inlineIdentity(page); + const idk = await editorIdentity(page); expect(idk.court).toBe('A'); await playRunningBout(page); row({ step: `knockout bout ${i + 1}`, action: 'Finish + Start Next', variant: 'correct path', pair: p, eyebrow: idk.eyebrow, @@ -350,20 +353,21 @@ test.describe('knockout-mixed-individual', () => { // Docs (Score a match): on a laptop, Left / Right move to the previous or // next match on the court and Esc closes the editor. Tried on the console. + // A third knockout bout is running or Up next on A: the draw and the + // winner rule are fixed, so the court always has one here. const liveK = await ensureRunning(page); - if (liveK) { - const before = await inlineIdentity(page); - await inlineEditor(page).locator('.editor-modal__title').click(); - const seenK = []; - for (const key of ['ArrowRight', 'ArrowLeft', 'Escape']) { - await page.keyboard.press(key); - await page.waitForTimeout(300); - seenK.push({ key, eyebrow: (await inlineIdentity(page).catch(() => ({ eyebrow: null }))).eyebrow, - editor: await inlineEditor(page).isVisible().catch(() => false) }); - } - row({ step: 'console keys', action: 'ArrowRight / ArrowLeft / Esc on the court console', variant: 'correct path (docs: laptop keys)', - before: before.eyebrow, after: seenK, shot: await shot(page, 'console-keys') }); + expect(liveK, 'the next knockout bout on court A starts').not.toBeNull(); + const before = await editorIdentity(page); + await inlineEditor(page).locator('.editor-modal__title').click(); + const seenK = []; + for (const key of ['ArrowRight', 'ArrowLeft', 'Escape']) { + await page.keyboard.press(key); + await page.waitForTimeout(300); + seenK.push({ key, eyebrow: (await editorIdentity(page).catch(() => ({ eyebrow: null }))).eyebrow, + editor: await inlineEditor(page).isVisible().catch(() => false) }); } + row({ step: 'console keys', action: 'ArrowRight / ArrowLeft / Esc on the court console', variant: 'correct path (docs: laptop keys)', + before: before.eyebrow, after: seenK, shot: await shot(page, 'console-keys') }); // Scores tab overlay: Prev / Next and the arrow keys stay on the court. await page.goto(`/admin/competition/${comps.F1}/scores`); @@ -371,19 +375,19 @@ test.describe('knockout-mixed-individual', () => { .filter({ has: page.getByRole('button', { name: /^Score$/ }) }).first(); await rowB.getByRole('button', { name: /^Score$/ }).tap(); await expect(page.locator(EDITOR)).toBeVisible(); - const seen = [await overlayIdentity(page)]; + const seen = [await editorIdentity(page, EDITOR)]; await shot(page, 'scores-overlay-B'); const next = page.locator(EDITOR).locator('.score-nav__next'); const prev = page.locator(EDITOR).locator('.score-nav__prev'); row({ step: 'scores tab nav', action: 'Prev / Next', variant: 'size', next: await tapSize(next).catch(() => null), prev: await tapSize(prev).catch(() => null) }); for (let i = 0; i < 3 && await next.isVisible().catch(() => false); i += 1) { await next.tap(); - seen.push(await overlayIdentity(page)); + seen.push(await editorIdentity(page, EDITOR)); } for (const key of ['ArrowLeft', 'ArrowRight', 'ArrowLeft']) { await page.keyboard.press(key); await page.waitForTimeout(200); - seen.push(await overlayIdentity(page)); + seen.push(await editorIdentity(page, EDITOR)); } row({ step: 'scores tab nav', action: 'Next x3, ArrowLeft/Right', variant: 'correct path', courts: seen.map((s) => s.court), eyebrows: seen.map((s) => s.eyebrow), shot: await shot(page, 'scores-overlay-after-nav') }); @@ -407,7 +411,8 @@ test.describe('knockout-mixed-individual', () => { // Pool A's first bout. The competitor who withdraws is AKA. const b1 = await ensureRunning(page); - const id1 = await inlineIdentity(page); + expect(b1, 'a pool bout starts on this court').not.toBeNull(); + const id1 = await editorIdentity(page); expect(id1.eyebrow).toMatch(/POOL A/); for (const kind of ['kiken-voluntary', 'kiken-injury', 'fusenpai']) { row({ step: 'decision controls', action: kind, variant: 'size', ...(await tapSize(decisionButton(page, kind))) }); @@ -486,7 +491,7 @@ test.describe('knockout-mixed-individual', () => { // The withdrawn competitor's next bout is still in this court's queue, // now carrying the barred-match notice (bc-tmfn): Start match is gone // from that row, replaced by the notice's own one-tap resolution. - const nextOfWithdrawn = page.locator('.shiaijo-upnext__card, .shiaijo__queue .shiaijo-qrow:not(.shiaijo-qrow--complete)') + const nextOfWithdrawn = page.locator(`${UP_NEXT}, .shiaijo__queue .shiaijo-qrow:not(.shiaijo-qrow--complete)`) .filter({ hasText: withdrawnName }).first(); await expect(nextOfWithdrawn).toBeVisible(); const pairW = await sides(nextOfWithdrawn); @@ -521,6 +526,7 @@ test.describe('knockout-mixed-individual', () => { // (Up next started), then the operator realises Aka withdrew, not Shiro. await openShiaijo(page, 'E'); const moved = await ensureRunning(page); + expect(moved, 'the court moves on to the Up next bout').not.toBeNull(); row({ step: 'kiken undo', action: 'court moves on: Up next started', variant: 'correct path', running: moved, involvesRealWithdrawer: !!moved && (moved.shiro === b1.aka || moved.aka === b1.aka) }); // Correct is refused while that bout runs (bc-crpn): send it back first. @@ -569,13 +575,13 @@ test.describe('knockout-mixed-individual', () => { await hastyShiaijoConfirm(page); } } - const poolBCard = page.locator('.shiaijo-upnext__card, .shiaijo__queue .shiaijo-qrow:not(.shiaijo-qrow--complete)') + const poolBCard = page.locator(`${UP_NEXT}, .shiaijo__queue .shiaijo-qrow:not(.shiaijo-qrow--complete)`) .filter({ hasText: /Match 1 of 3/ }).filter({ hasNotText: withdrawnName }).last(); const b2 = await sides(poolBCard); await poolBCard.getByRole('button', { name: 'Start match' }).tap(); await expect.poll(() => inlineRunning(page), { timeout: 15_000 }).toBe(true); expect(await editorSides(page)).toEqual(b2); - const id2 = await inlineIdentity(page); + const id2 = await editorIdentity(page); await decisionButton(page, 'fusenpai').tap(); await expect(decisionPrompt(page)).toBeVisible(); await decisionPrompt(page).locator('input[name="decision-side"][value="aka"]').check(); @@ -591,7 +597,7 @@ test.describe('knockout-mixed-individual', () => { await expect(fusRow).toBeVisible(); const fusResult = (await fusRow.locator('.shiaijo-qrow__result').innerText()).trim(); const advanced = await expect.poll(() => inlineRunning(page), { timeout: 8000 }).toBe(true).then(() => true, () => false); - const id3 = advanced ? await inlineIdentity(page) : null; + const id3 = advanced ? await editorIdentity(page) : null; row({ step: 'fusenpai', action: 'Record (fusenpai, Aka did not show)', variant: 'V2 doubleTap', ...dblRecord, from: id2.eyebrow, result: fusResult, dialogAfterDoubleTap: dialogAfterDbl, dialog: dblDialog, advancedTo: id3 && id3.eyebrow, court: id3 && id3.court, shot: await shot(page, 'fusenpai-recorded') }); @@ -603,7 +609,8 @@ test.describe('knockout-mixed-individual', () => { // Knockout (F2, court C): a tied 1-1 semi-final, encho, hantei. await openShiaijo(page, 'C'); const semi = await ensureRunning(page); - expect((await inlineIdentity(page)).eyebrow).toMatch(/SEMIFINAL/); + expect(semi, 'a semi-final starts on C').not.toBeNull(); + expect((await editorIdentity(page)).eyebrow).toMatch(/SEMIFINAL/); await ipponButton(page, 'shiro', 'M').tap(); await ipponButton(page, 'aka', 'K').tap(); await settle(page); @@ -736,7 +743,7 @@ test.describe('knockout-mixed-individual', () => { // Semi-final 1 (M1) online first unless J2 already played it. await expect.poll(async () => (await inlineRunning(page)) || (await upNextCard(page).isVisible().catch(() => false)), { timeout: 15_000 }).toBe(true); let played = null; - const liveNow = (await inlineRunning(page)) ? (await inlineIdentity(page)).eyebrow : ''; + const liveNow = (await inlineRunning(page)) ? (await editorIdentity(page)).eyebrow : ''; const upNow = ((await page.locator('.shiaijo-upnext__time').allInnerTexts())[0] || ''); if (/MATCH 1\b/.test(liveNow) || (!liveNow && /Match 1\b/.test(upNow))) played = await playRunningBout(page); // Start the remaining semi-final from Up next unless it is already live. @@ -756,7 +763,7 @@ test.describe('knockout-mixed-individual', () => { .toBe(JSON.stringify(semi)); } row({ step: 'finish offline', action: 'the last semi-final is running', variant: 'correct path', semi }); - expect((await inlineIdentity(page)).eyebrow).toMatch(/SEMIFINAL/); + expect((await editorIdentity(page)).eyebrow).toMatch(/SEMIFINAL/); const w = winnerOf(semi); const winnerName = semi[w]; const loserName = semi[w === 'shiro' ? 'aka' : 'shiro']; @@ -779,9 +786,9 @@ test.describe('knockout-mixed-individual', () => { const banner = inlineEditor(page).locator('.pending-write-banner'); const bannerText = (await banner.allInnerTexts()).join(' | '); const editorStill = await inlineEditor(page).isVisible().catch(() => false); - const editorId = editorStill ? await inlineIdentity(page) : null; + const editorId = editorStill ? await editorIdentity(page) : null; const pill = editorStill ? ((await syncState(page).allInnerTexts())[0] || '').trim() : null; - const finalRow = page.locator('.shiaijo__queue').getByText(/Veterans Knockout · Match 3\b/).locator('xpath=ancestor::*[contains(@class,"shiaijo-qrow") or contains(@class,"shiaijo-upnext__card")][1]'); + const finalRow = page.locator('.shiaijo__queue').getByText(/Veterans Knockout · Match 3\b/).locator(`xpath=ancestor::*[contains(@class,"shiaijo-qrow") or contains(@class,"${UP_NEXT.slice(1)}")][1]`); const finalText = (await finalRow.allInnerTexts()).join(' ').replace(/\s+/g, ' '); const queueText = (await page.locator('.shiaijo__queue').innerText()).replace(/\s+/g, ' '); row({ step: 'finish offline', action: 'Finish (two taps) with the network down', variant: 'V3 interrupt offline', @@ -865,7 +872,7 @@ test.describe('knockout-mixed-individual', () => { await firstRow.tap(); await expect(page.locator(EDITOR)).toBeVisible(); const pair = await editorSides(page, EDITOR); - const idP = await overlayIdentity(page); + const idP = await editorIdentity(page, EDITOR); await shot(page, 'pools-tab-editor-open'); const start = page.locator(EDITOR).getByRole('button', { name: 'Start match' }); const startOffered = await start.isVisible().catch(() => false); @@ -986,19 +993,18 @@ test.describe('knockout-mixed-individual', () => { // the J2 attempt ran into a withdrawn competitor's unstartable bout. await openShiaijo(page, 'F'); const fb = await ensureRunning(page); - if (fb) { - await recordDecision(page, 'fusenpai', { side: 'aka' }); - await expect(completedRowFor(page, fb)).toBeVisible(); - const advanced = await expect.poll(async () => { - if (!(await inlineRunning(page))) return 'idle'; - const now = await editorSides(page); - return now.shiro === fb.shiro && now.aka === fb.aka ? 'same' : 'moved'; - }, { timeout: 10_000 }).toBe('moved').then(() => true, () => false); - row({ step: 'fusenpai (court F)', action: 'Record fusenpai on the console', variant: 'correct path (auto-advance)', - from: fb, advanced, court: advanced ? (await inlineIdentity(page)).court : null, - shot: await shot(page, 'fusenpai-auto-advance-F') }); - if (advanced) expect((await inlineIdentity(page)).court).toBe('F'); - } + expect(fb, 'court F has a bout to start').not.toBeNull(); + await recordDecision(page, 'fusenpai', { side: 'aka' }); + await expect(completedRowFor(page, fb)).toBeVisible(); + const advanced = await expect.poll(async () => { + if (!(await inlineRunning(page))) return 'idle'; + const now = await editorSides(page); + return now.shiro === fb.shiro && now.aka === fb.aka ? 'same' : 'moved'; + }, { timeout: 10_000 }).toBe('moved').then(() => true, () => false); + row({ step: 'fusenpai (court F)', action: 'Record fusenpai on the console', variant: 'correct path (auto-advance)', + from: fb, advanced, court: advanced ? (await editorIdentity(page)).court : null, + shot: await shot(page, 'fusenpai-auto-advance-F') }); + if (advanced) expect((await editorIdentity(page)).court).toBe('F'); }); // Functional defects found by the journeys above, each now fixed and named // by its bead. Each seeds its own competition on its own shiaijo, so it runs @@ -1016,6 +1022,7 @@ test.describe('knockout-mixed-individual', () => { const id = await seedOwn(page, { name: 'Finding A2', format: 'mixed', poolSize: 3, poolWinners: 2, courts: ['H'], numberPrefix: 'FB' }, rosterOf('Atwo', 6)); await openShiaijo(page, 'H'); const first = await ensureRunning(page); + expect(first, 'the first bout on H starts').not.toBeNull(); await recordDecision(page, 'kiken-voluntary', { side: 'aka' }); await expect(completedRowFor(page, first)).toBeVisible(); // The withdrawn competitor's next bout, from the Scores tab. @@ -1034,7 +1041,7 @@ test.describe('knockout-mixed-individual', () => { await login(page); await seedOwn(page, { name: 'Finding A3', format: 'knockout', courts: ['I'], numberPrefix: 'FC' }, rosterOf('Athree', 4)); await openShiaijo(page, 'I'); - await ensureRunning(page); + expect(await ensureRunning(page), 'a semi-final starts on I').not.toBeNull(); await inlineEditor(page).getByTestId('scoring-modal-encho-pill').tap(); await expect(inlineEditor(page).getByTestId('scoring-modal-encho-checkbox')).toBeVisible(); }); @@ -1044,6 +1051,7 @@ test.describe('knockout-mixed-individual', () => { await seedOwn(page, { name: 'Finding A4', format: 'knockout', courts: ['J'], numberPrefix: 'FD' }, rosterOf('Afour', 4)); await openShiaijo(page, 'J'); const semi = await ensureRunning(page); + expect(semi, 'a semi-final starts on J').not.toBeNull(); await ipponButton(page, 'shiro', 'M').tap(); await ipponButton(page, 'aka', 'K').tap(); await settle(page); @@ -1089,6 +1097,7 @@ test.describe('knockout-mixed-individual', () => { await seedOwn(page, { name: 'Finding A6', format: 'mixed', poolSize: 3, poolWinners: 2, courts: ['L'], numberPrefix: 'FF' }, rosterOf('Asix', 6)); await openShiaijo(page, 'L'); const pair = await ensureRunning(page); + expect(pair, 'a bout starts on this court').not.toBeNull(); // Venue wifi: the court's refresh after the autosave is slow to arrive. // The score is on the board (and saved) all the same. const slowReads = async (route) => { @@ -1130,6 +1139,7 @@ test.describe('knockout-mixed-individual', () => { await seedOwn(page, { name: 'Finding A8', format: 'knockout', courts: ['N'], numberPrefix: 'FH' }, rosterOf('Aeight', 4)); await openShiaijo(page, 'N'); const semi = await ensureRunning(page); + expect(semi, 'a semi-final starts on this court').not.toBeNull(); await ipponButton(page, winnerOf(semi), 'M').tap(); await settle(page); await context.setOffline(true); diff --git a/scripts/e2e/journeys/moved-match.spec.mjs b/scripts/e2e/journeys/moved-match.spec.mjs new file mode 100644 index 000000000..362f87278 --- /dev/null +++ b/scripts/e2e/journeys/moved-match.spec.mjs @@ -0,0 +1,328 @@ +// A WAITING match moved to another shiaijo mid-competition reaches the other +// court's console without a reload (bc-mvsj, operator request 2026-10-03). +// +// Two competitions in progress on two courts, a bout being fought on each: +// "Individual" (a league) on Shiaijo A and "Teams" (a team knockout) on +// Shiaijo B. Court A and court B are two devices (two browser contexts), and +// a third, the head table, is the "another device" the notes ask for. Court +// B's console is opened once and never reloaded: a mark left on its window is +// read back after every live change to prove it. +// +// Asserted, as shipped in PR #453 (operator decisions 2026-10-03): the move +// confirm names the match (both competitors); A's queue drops the match; B +// gains the competition picker without a reload, keeps showing Teams, keeps +// the moved match out of its own queue and names it in the quiet "also +// waiting" line; picking Individual shows it as Up next; B holds Teams while +// Teams still has work, even when a Teams bout finishes on another device; +// the amber "Switch to Individual" line appears once Teams has nothing left +// and switches only on the tap; a move made from the competition's Scores tab +// reaches both consoles the same way. +// +// Step 5 differs from the bead's text, which predates bc-sbq (PR #447): the +// console's OWN Start match never meets the court gate, because picking another +// bout sends the running one back to the queue with its score (operator ruling +// 2026-09-26). That shape is asserted (one bout running, the score kept). The +// server's refusal naming the running bout is reached through a door that does +// not defer: the competition's Scores tab on the same device. +import { test, expect } from '../fixtures/test.mjs'; +import { journeyShots } from '../fixtures/shots.mjs'; +import { auditLog } from '../fixtures/audit.mjs'; +import { login } from '../fixtures/setup.mjs'; +import { openShiaijo, upNextCard, sides, startUpNext } from '../fixtures/shiaijo.mjs'; +import { EDITOR } from '../fixtures/scoring.mjs'; +import { hastyShiaijoConfirm } from '../fixtures/clumsy.mjs'; +import { settled } from '../fixtures/pace.mjs'; +import { tapSize } from '../fixtures/pointer.mjs'; +import { ROSTER_6 } from '../fixtures/individual.mjs'; +import * as T from '../fixtures/team.mjs'; +import { + enterTournament, seedCompetitions, readScoresTab, operatorDevice, markPage, pageMark, + competitionPicker, officiatingName, pickCompetition, nudges, nudgeLines, courtState, + upcomingRow, chooseCourt, runningOnCourt, playTeamMatch, +} from '../fixtures/shared-court.mjs'; + +test.describe.configure({ mode: 'serial' }); + +const JOURNEY = 'moved-match'; +const audit = auditLog(JOURNEY); +const row = (r) => audit.record({ journey: 'MV', ...r }); +const TEAMS = [['Hayabusa', 'Kita Dojo'], ['Inoshishi', 'Minami Dojo'], ['Kitsune', 'Higashi Dojo'], ['Tanuki', 'Nishi Dojo']]; + +let A; // court A's iPad +let B; // court B's iPad, opened once and never reloaded +let H; // the head table +let ids; +let shot; +let moved; // the pairing moved from A to B + +// Court B's console has not reloaded or navigated since the mark was left. +async function bStillOpen() { + expect(await pageMark(B), 'court B was not reloaded').toBe('court-B-open'); + expect(new URL(B.url()).pathname).toBe('/admin/shiaijo/B'); +} + +const queueNames = (s) => [s.upNext, ...s.upcoming].filter(Boolean).map((r) => r.names.join(' vs ')); +const pairText = (p) => `${p.shiro} vs ${p.aka}`; + +test.describe('moved-match', () => { + test.beforeAll(async ({ browser, serverForFile }, testInfo) => { + test.setTimeout(240_000); + const server = await serverForFile(testInfo.file); + shot = journeyShots(JOURNEY); + A = await operatorDevice(browser, server.base); + await enterTournament(A, { name: 'Moved Match Cup', courts: 2 }); + const seeded = await seedCompetitions(A, [ + { key: 'IND', opts: { name: 'Individual', kind: 'individual', format: 'league', courts: ['A'], numberPrefix: 'I' }, roster: ROSTER_6.slice(0, 5) }, + { key: 'TEAMS', opts: { name: 'Teams', kind: 'team', format: 'knockout', teamSize: 3, teamMatchType: 'fixed', courts: ['B'], numberPrefix: 'T' }, roster: TEAMS }, + ]); + ids = seeded.ids; + row({ step: 'setup', action: 'wizard defaults, both competitions created before any roster', variant: 'seed', defaults: seeded.defaults }); + row({ step: 'setup', action: 'draw read back (Scores tabs)', variant: 'seed', + individual: await readScoresTab(A, ids.IND), teams: await readScoresTab(A, ids.TEAMS) }); + B = await operatorDevice(browser, server.base); + await login(B); + H = await operatorDevice(browser, server.base); + await login(H); + }); + + test.afterAll(async () => { + audit.flush(); + for (const p of [A, B, H]) if (p) await p.context().close(); + }); + + test('a waiting match moved from court A reaches court B without a reload', async () => { + test.setTimeout(600_000); + + // 1. A bout being fought on each court. + await openShiaijo(A, 'A'); + const indLive = await startUpNext(A); + await openShiaijo(B, 'B'); + const teamsSf1 = await sides(upNextCard(B)); + const edB = await T.startUpNextTeam(B); + await T.boutIppon(edB, 1, 'shiro', 'M'); + row({ step: '1 setup', action: 'a bout running on each court', variant: 'correct path', courtA: indLive, courtB: teamsSf1, + shotA: await shot(A, 'court-A-bout-running'), shotB: await shot(B, 'court-B-teams-running') }); + + // 2. Court B shows Teams alone: a plain title, no picker. + await expect.poll(() => officiatingName(B)).toBe('Teams'); + await expect(competitionPicker(B)).toHaveCount(0); + await markPage(B, 'court-B-open'); + row({ step: '2 court B before', action: 'officiating title on court B', variant: 'correct path', + title: await officiatingName(B), picker: false, state: await courtState(B) }); + + // 3. Court A moves a WAITING Individual match to B through the queue row's + // court control and the confirm, answered the way a hurried operator does. + // A row whose competitors are not the ones fighting on A now: a match + // with a competitor still on another court is refused at its start for + // that (step 8 walks it), which is not what this step is about. + const before = await courtState(A); + const busy = new Set([indLive.shiro, indLive.aka]); + const free = before.upcoming.filter((r) => !r.names.some((n) => busy.has(n))); + const last = free[free.length - 1]; + moved = { shiro: last.names[0], aka: last.names[1] }; + const qrow = upcomingRow(A, moved); + row({ step: '3 move', action: 'court control on the queue row', variant: 'size', + ...(await tapSize(qrow.locator('button[aria-haspopup="listbox"]').first())) }); + await chooseCourt(qrow, 'B'); + const dialog = A.locator('.shiaijo-move-confirm[role="dialog"]'); + await expect(dialog).toBeVisible(); + const confirmShot = await shot(A, 'move-confirm'); + const hasty = await hastyShiaijoConfirm(A); + row({ step: '3 move', action: 'Move to Shiaijo B confirm', variant: 'V4 hastyShiaijoConfirm', ...hasty, moved, confirmShot }); + expect(hasty.title).toBe('Move to Shiaijo B?'); + // PR #453 (a): the confirm names the match, both competitors. + expect(hasty.message).toBe(`${pairText(moved)} leaves Shiaijo A and joins the queue on Shiaijo B.`); + await expect.poll(async () => queueNames(await courtState(A)).includes(pairText(moved)), { timeout: 10_000 }).toBe(false); + row({ step: '3 move', action: "court A's queue after the move", variant: 'correct path', queue: queueNames(await courtState(A)), + shot: await shot(A, 'court-A-after-move') }); + + // 4. Court B, untouched: the picker appears, Teams stays, the moved match + // is not in B's queue and is named by the quiet line (PR #453 (b)). + await expect(competitionPicker(B)).toBeVisible({ timeout: 10_000 }); + await bStillOpen(); + const options = await competitionPicker(B).locator('option').allInnerTexts(); + const bAfter = await courtState(B); + const lines = await nudgeLines(B); + row({ step: '4 court B live', action: 'court B after the move (no reload)', variant: 'correct path', options, officiating: bAfter.officiating, + queue: queueNames(bAfter), nudges: lines, shot: await shot(B, 'court-B-picker-appeared') }); + expect(options.map((o) => o.trim()).sort()).toEqual(['Individual', 'Teams']); + expect(bAfter.officiating).toBe('Teams'); + expect(queueNames(bAfter)).not.toContain(pairText(moved)); + expect(lines).toEqual([{ text: '1 Individual match also waiting on this court.', quiet: true, amber: false }]); + + // 5. B picks Individual: the moved match is Up next. + await pickCompetition(B, 'Individual'); + await bStillOpen(); + expect(await sides(upNextCard(B))).toEqual(moved); + row({ step: '5 pick Individual', action: 'competition picker -> Individual', variant: 'correct path', + upNext: await sides(upNextCard(B)), editorStill: (await courtState(B)).editor, shot: await shot(B, 'court-B-individual-picked') }); + + // 5a. The server's refusal: the same device's other tab, the competition's + // Scores tab, starts the moved match while the Teams bout is fought. + const tab = await B.context().newPage(); + await tab.goto(`/admin/competition/${ids.IND}/scores`); + const movedScoreRow = tab.locator('.score-edit-row').filter({ hasText: moved.shiro }).filter({ hasText: moved.aka }).first(); + await movedScoreRow.getByRole('button', { name: /^Score$/ }).tap(); + await expect(tab.locator(EDITOR)).toBeVisible(); + await settled(tab.locator(EDITOR).getByRole('button', { name: 'Start match' })); + await tab.locator(EDITOR).getByRole('button', { name: 'Start match' }).tap(); + const refusal = tab.locator('.toast, .alert--error, [data-testid="start-refusal-notice"]').filter({ hasText: /Shiaijo B is running/ }).first(); + await expect(refusal).toBeVisible({ timeout: 10_000 }); + const refusalText = (await refusal.innerText()).replace(/\s+/g, ' ').trim(); + // The running bout as its own editor names it ("Teams · Semifinals · + // Match 1"); the refusal names it inside its competition ("Match 1 + // (Semifinals)", engine.OperatorMatchLabel), without the competition or the + // teams: recorded for the copy review (bc-shcr P2), not asserted. + const liveEyebrow = (await courtState(B)).editor.eyebrow; + const [, round, num] = liveEyebrow.match(/· (\w+) · Match (\d+)$/) || []; + row({ step: '5a start refused', action: 'Start match on the moved match from the Scores tab while Teams runs on B', variant: 'correct path (court gate)', + refusal: refusalText, runningBout: liveEyebrow, namesCompetition: refusalText.includes('Teams'), + namesTeams: refusalText.includes(teamsSf1.shiro) && refusalText.includes(teamsSf1.aka), + shot: await shot(tab, 'scores-tab-start-refused') }); + expect(refusalText).toContain(`Shiaijo B is running Match ${num} (${round}). Finish it or send it back to the queue first.`); + const runningAfterRefusal = await runningOnCourt(H, 'B'); + expect(runningAfterRefusal.map((r) => r.comp)).toEqual(['Teams']); + await tab.close(); + + // 5b. The console's own Start match: the Teams bout goes back to the queue + // with its score (bc-sbq) and the moved match starts. One bout runs on B. + await upNextCard(B).getByRole('button', { name: 'Start match' }).tap(); + await expect.poll(async () => ((await courtState(B)).editor || {}).names, { timeout: 15_000 }).toEqual([moved.shiro, moved.aka]); + const oneRunning = await runningOnCourt(H, 'B'); + const bLive = await courtState(B); + row({ step: '5b console start', action: 'Start match (Up next) while the Teams bout is fought', variant: 'correct path (bc-sbq: the running bout is sent back)', + runningOnB: oneRunning, alsoRunning: bLive.alsoRunning, nudges: bLive.nudges, shot: await shot(B, 'court-B-moved-match-started') }); + expect(oneRunning).toHaveLength(1); + expect(oneRunning[0]).toMatchObject({ comp: 'Individual', shiro: moved.shiro, aka: moved.aka }); + expect(bLive.alsoRunning).toBeNull(); + await bStillOpen(); + // Back as it was: the moved match goes back to the queue, Teams is picked + // and its semi-final starts again with the point it had. + await B.getByRole('button', { name: 'Send back to queue' }).tap(); + await hastyShiaijoConfirm(B); + await pickCompetition(B, 'Teams'); + expect(await sides(upNextCard(B))).toEqual(teamsSf1); + await T.startUpNextTeam(B); + await expect(T.rowSlots(T.boutRow(edB, 1), 'shiro')).toHaveText(['M']); + row({ step: '5b console start', action: 'Teams semi-final started again', variant: 'recovery', pointKept: true, + shot: await shot(B, 'court-B-teams-restarted-with-point') }); + + // 6. Teams' own bout finishes on ANOTHER device (the head table): court B + // stays on Teams, which still has work here. + await H.goto(`/admin/competition/${ids.TEAMS}/scores`); + const runningRow = H.locator('.score-edit-row.score-edit-row--running').first(); + await runningRow.getByRole('button', { name: /^Score$/ }).tap(); + const hed = H.locator(EDITOR); + await expect(T.boutRow(hed, 1)).toBeVisible(); + for (const n of [2, 3]) await T.boutIppon(hed, n, 'shiro', 'M'); + const headFinishLabel = (await T.finishBtn(hed).innerText()).trim(); + await T.finishTeam(hed); + await H.waitForTimeout(1500); + const bHeld = await courtState(B); + row({ step: '6 finish elsewhere', action: 'head table finishes the Teams semi-final', variant: 'two devices', headFinishLabel, + courtB: bHeld, shot: await shot(B, 'court-B-after-head-table-finish') }); + expect(bHeld.officiating).toBe('Teams'); + await bStillOpen(); + // Close the head table's editor if Finish + Start Next left it open. + if (await hed.isVisible().catch(() => false)) await hed.getByRole('button', { name: /✕ Close/ }).tap().catch(() => {}); + + // Court B plays the other semi-final itself (started by the head table's + // Finish + Start Next, or from Up next). + await expect.poll(async () => { + const s = await courtState(B); + return !!(s.upNext || (s.editor && !s.editor.pill)); + }, { timeout: 15_000 }).toBe(true); + const sf2 = await playTeamMatch(B); + row({ step: '6 finish elsewhere', action: 'court B plays the other semi-final', variant: 'correct path', pair: sf2 }); + + // The final finishes on the head table too. + await expect.poll(async () => { + await H.goto(`/admin/competition/${ids.TEAMS}/scores`); + return H.locator('.score-edit-row').filter({ has: H.getByRole('button', { name: /^Score$/ }) }).count(); + }, { timeout: 20_000 }).toBeGreaterThan(0); + await H.locator('.score-edit-row').filter({ has: H.getByRole('button', { name: /^Score$/ }) }).first() + .getByRole('button', { name: /^Score$/ }).tap(); + await expect(hed).toBeVisible(); + const startFinal = hed.getByRole('button', { name: 'Start match' }); + if (await startFinal.isVisible().catch(() => false)) { + await settled(startFinal); + await startFinal.tap(); + } + await expect(T.syncPill(hed)).toBeVisible(); + for (const n of [1, 2, 3]) await T.boutIppon(hed, n, 'shiro', 'M'); + await T.finishTeam(hed); + + // Teams has nothing left on B: B still shows Teams, now with the amber + // line, and switches only on the tap. + await expect.poll(async () => (await nudgeLines(B)).map((l) => l.amber), { timeout: 15_000 }).toEqual([true]); + await B.waitForTimeout(3000); + const bDone = await courtState(B); + const amber = await nudgeLines(B); + row({ step: '6 nudge', action: 'court B once Teams has nothing left, 3s later', variant: 'correct path', courtB: bDone, nudges: amber, + shot: await shot(B, 'court-B-switch-to-individual') }); + expect(bDone.officiating).toBe('Teams'); + expect(amber).toEqual([{ text: 'Switch to Individual: 1 match waiting on this court.', quiet: false, amber: true }]); + await bStillOpen(); + await nudges(B).first().tap(); + await expect.poll(() => officiatingName(B)).toBe('Individual'); + expect(await sides(upNextCard(B))).toEqual(moved); + row({ step: '6 nudge', action: 'tap the Switch line', variant: 'correct path', officiating: await officiatingName(B), + upNext: await sides(upNextCard(B)), shot: await shot(B, 'court-B-switched-by-tap') }); + await bStillOpen(); + + // 7. The other door: the head table moves another waiting Individual match + // from A to B on the competition's Scores tab (admin.jsx moveMatchCourt). + // Both consoles follow, neither reloaded. + await markPage(A, 'court-A-open'); + const aBefore = await courtState(A); + // One that involves a competitor fighting on A now, for step 8. + const second = [...aBefore.upcoming].reverse().find((r) => r.names.some((n) => busy.has(n))) || aBefore.upcoming[aBefore.upcoming.length - 1]; + const secondPair = { shiro: second.names[0], aka: second.names[1] }; + await H.goto(`/admin/competition/${ids.IND}/scores`); + const hRow = H.locator('.score-edit-row').filter({ hasText: secondPair.shiro }).filter({ hasText: secondPair.aka }).first(); + await chooseCourt(hRow, 'B'); + await H.waitForTimeout(400); + const confirmAsked = await H.locator('.modal[role="dialog"], .shiaijo-move-confirm').isVisible().catch(() => false); + await expect.poll(async () => queueNames(await courtState(A)).includes(pairText(secondPair)), { timeout: 10_000 }).toBe(false); + await expect.poll(async () => queueNames(await courtState(B)).includes(pairText(secondPair)), { timeout: 10_000 }).toBe(true); + expect(await pageMark(A)).toBe('court-A-open'); + await bStillOpen(); + row({ step: '7 other door', action: 'head table moves a waiting match A -> B from the Scores tab', variant: 'correct path', + moved: secondPair, confirmAsked, courtA: queueNames(await courtState(A)), courtB: queueNames(await courtState(B)), + shotA: await shot(A, 'court-A-after-scores-tab-move'), shotB: await shot(B, 'court-B-after-scores-tab-move') }); + + // 8. Recorded, not asserted: on B the operator starts the first moved match, + // then taps Start on the second, whose competitor is still fighting on A. + // The console sends the running bout back to the queue BEFORE the server + // answers the start (pickMatch's defer), and the start is refused for the + // competitor, not the court. + // The pairing's Start, on the Up next card or its Upcoming row, whichever + // holds it when the tap lands: a start just made moves the next pairing + // from its row onto the card while a hurried tap is on its way. + const startOf = async (pair) => upNextCard(B).filter({ hasText: pair.shiro }).filter({ hasText: pair.aka }) + .or(upcomingRow(B, pair)).first().getByRole('button', { name: 'Start match' }); + await (await startOf(moved)).tap(); + await expect.poll(async () => ((await courtState(B)).editor || {}).names, { timeout: 15_000 }).toEqual([moved.shiro, moved.aka]); + // 8a. At once, before the court's list has caught up with the start just made. + await (await startOf(secondPair)).tap(); + await B.waitForTimeout(2000); + const notice = async () => (await B.locator('.shiaijo-upnext__error[role="alert"]').allInnerTexts()).join(' | '); + row({ step: '8 second start', action: `Start match on ${pairText(secondPair)} at once after starting ${pairText(moved)}`, + variant: 'V2 hurried second start (inside the list refresh)', notice: await notice(), courtB: await courtState(B), + runningOnB: await runningOnCourt(H, 'B'), shot: await shot(B, 'court-B-second-start-at-once') }); + // 8b. Again once the list shows the first one running (Up next moved on), + // when 8a left it running: a quicker list makes 8a defer it already, and + // then 8a's row is this shape. + const stillRunning = (await runningOnCourt(H, 'B')).some((m) => m.shiro === moved.shiro && m.aka === moved.aka); + if (!stillRunning) { + row({ step: '8 competitor busy', action: '8a already sent the first moved match back', variant: 'note' }); + return; + } + await expect.poll(async () => ((await courtState(B)).upNext || {}).names, { timeout: 15_000 }).toEqual([secondPair.shiro, secondPair.aka]); + await (await startOf(secondPair)).tap(); + await B.waitForTimeout(2000); + row({ step: '8 competitor busy', action: `Start match on ${pairText(secondPair)} (one of them fights on A) while ${pairText(moved)} runs on B`, + variant: 'V5 lost place (the moved match\'s competitor is elsewhere)', notice: await notice(), courtB: await courtState(B), + runningOnB: await runningOnCourt(H, 'B'), shot: await shot(B, 'court-B-start-refused-competitor-busy') }); + }); +}); diff --git a/scripts/e2e/journeys/shared-shiaijo.spec.mjs b/scripts/e2e/journeys/shared-shiaijo.spec.mjs new file mode 100644 index 000000000..674f8bffb --- /dev/null +++ b/scripts/e2e/journeys/shared-shiaijo.spec.mjs @@ -0,0 +1,704 @@ +// A shiaijo shared by two competitions (bc-shcr), walked as the distracted, +// clumsy court operator on the iPad (fixtures/clumsy.mjs, V1-V5). +// +// Seeded through the interface (fixtures/shared-court.mjs): both competitions +// are created before any roster is pasted, then each gets its roster, draw and +// Start. +// SI "Shared Individual": pools of 3, two through, 12 entrants, Shiaijo A+B +// ST "Shared Team": team knockout, teams of 3 (Regular), 4 teams, Shiaijo A +// So Shiaijo A carries SI's pools A and B, SI's quarter-finals M1/M2, semi-final +// M5 and final M7, and all of ST. Shiaijo B carries SI's pools C and D, M3/M4 +// and M6. ST is then re-timed with Auto-schedule so its matches sit between +// SI's pool bouts on the clock: the automatic advance is then tested against an +// ST match that is EARLIER in clock time than the next SI bout. +// +// Asserted, because the operator rulings make it the correct behaviour +// (2026-09-26/27): only one bout runs on a shiaijo; the console never switches +// competition by itself; the automatic advance (Finish + Start Next, the start +// after a withdrawal or no-show) stays in the scored match's competition, on +// the console and on the Scores page; picking another bout sends the running +// one back to the queue with its score (bc-sbq); Correct is refused while a +// bout is live (bc-crpn). Everything else is an audit row with a screenshot, +// judged by a person: the picker's default, the nudge lines, the done state, +// the alert, the court_busy copy, the public queue order, the slot times. +// +// What changed since bc-shcr's plan (2026-09-27) and is walked as main has it: +// the console HOLDS the competition it shows (PR #453), so P7 is asserted as +// "held"; the receiving court names the other competition's waiting matches in +// a quiet line (#453); Correct is refused while a bout is live and Start while +// a correction is open (#462, bc-crpn), so S4 opens its correction by sending +// the live bout back first; the withdrawal prompt picks no side (bc-dsid). +import { test, expect } from '../fixtures/test.mjs'; +import { journeyShots } from '../fixtures/shots.mjs'; +import { auditLog } from '../fixtures/audit.mjs'; +import { login } from '../fixtures/setup.mjs'; +import { + openShiaijo, upNextCard, sides, startUpNext, completedRowFor, inlineEditor, editorIdentity, +} from '../fixtures/shiaijo.mjs'; +import { + EDITOR, INLINE_EDITOR, ipponButton, syncState, finishButton, armedFinishButton, armFinish, finishMatch, +} from '../fixtures/scoring.mjs'; +import { + doubleTap, hastyShiaijoConfirm, interrupt, loudestButton, retapWhileSaving, tapNeighbour, +} from '../fixtures/clumsy.mjs'; +import { dwell } from '../fixtures/pace.mjs'; +import { tapSize } from '../fixtures/pointer.mjs'; +import { openTvBoard, tvBoard } from '../fixtures/public.mjs'; +import { + ROSTER_12, winnerOf, editorSides, slotMarks, inlineRunning, ensureRunning, + playRunningBout, recordDecision, correctCompleted, queuedPoolBouts, +} from '../fixtures/individual.mjs'; +import * as T from '../fixtures/team.mjs'; +import { + enterTournament, seedCompetitions, readScoresTab, bracketSlots, operatorDevice, publicDevice, + competitionPicker, officiatingName, pickCompetition, nudges, nudgeLines, courtState, runningOnCourt, + playTeamMatch, liveBout, correctAcross, +} from '../fixtures/shared-court.mjs'; + +test.describe.configure({ mode: 'serial' }); + +const JOURNEY = 'shared-shiaijo'; +const audit = auditLog(JOURNEY); +const SI = 'Shared Individual'; +const ST = 'Shared Team'; +const TEAMS = [['Hayabusa', 'Kita Dojo'], ['Inoshishi', 'Minami Dojo'], ['Kitsune', 'Higashi Dojo'], ['Tanuki', 'Nishi Dojo']]; +// Auto-schedule start for ST: between SI's pool A and pool B bouts on A. +const ST_RESTART = '09:12'; + +let C; // the court A console: the clumsy operator's iPad, kept across tests +let H; // the head table (Scores pages, schedule): another device +let Bc; // the court B console: another device +let P; // a spectator's phone +let TV; // the venue TV board for Shiaijo A +let ids; +let shot; +const draw = {}; // what the draw produced, read back from the UI +const bouts = {}; // pairings met on the way, by name + +const row = (journey, r) => audit.record({ journey, ...r }); +const same = (a, b) => !!a && !!b && a.shiro === b.shiro && a.aka === b.aka; +const pairOf = (r) => ({ shiro: r.shiro, aka: r.aka }); +const settleOn = (page, root = INLINE_EDITOR) => expect(syncState(page, root)).toHaveText('Synced'); +// The eyebrow of the bout an editor holds names its competition first. +const compOfEyebrow = (eyebrow) => { + const e = (eyebrow || '').toUpperCase(); + if (e.startsWith(SI.toUpperCase())) return SI; + if (e.startsWith(ST.toUpperCase())) return ST; + return `unknown: ${eyebrow}`; +}; + +// The bout running in the console's editor (individual or team), with its +// competition and court. +async function liveOnConsole() { + const l = await liveBout(C); + return l ? { comp: compOfEyebrow(l.eyebrow), ...l } : null; +} + +// Finish the running SI bout (starting Up next when the court is idle) for the +// deterministic winner, adding a men only when the winner is not already ahead +// (a reopened bout keeps the marks it had). Finish (+ Start Next) through the +// two-tap guard. Returns the pairing. +async function finishSiBout(page) { + const pair = await ensureRunning(page); + if (!pair) throw new Error('finishSiBout: nothing running or startable'); + const w = winnerOf(pair); + const o = w === 'shiro' ? 'aka' : 'shiro'; + if ((await slotMarks(page, w)).length <= (await slotMarks(page, o)).length) { + await ipponButton(page, w, 'M').tap(); + await settleOn(page); + } + await finishMatch(page); + await expect(completedRowFor(page, pair)).toBeVisible(); + await expect.poll(async () => !same(await liveBout(page), pair), { timeout: 10_000 }).toBe(true); + return pair; +} + +// Wait until the console's editor holds a running bout other than `prev`. +async function nextLive(prev) { + let live = null; + await expect.poll(async () => { + live = await liveOnConsole(); + return !!live && !same(live, prev); + }, { timeout: 15_000 }).toBe(true); + return live; +} + +// Move a court on by one match: a barred match in its queue (a competitor +// this journey withdrew or recorded as a no-show earlier, who still qualified) +// is closed from its own notice with the fusensho it offers, the way the +// operator does; otherwise the running or Up next bout is played. Returns what +// was done. +async function advanceCourt(page) { + const fusensho = page.locator('.shiaijo__queue').getByTestId('barred-match-record-fusensho').first(); + if (!(await inlineRunning(page)) && await fusensho.isVisible().catch(() => false)) { + const label = (await fusensho.innerText()).trim(); + await fusensho.tap(); + await expect(fusensho).toBeHidden({ timeout: 10_000 }); + return { resolved: label }; + } + return { played: await playRunningBout(page) }; +} + +test.describe('shared-shiaijo', () => { + test.beforeAll(async ({ browser, serverForFile }, testInfo) => { + test.setTimeout(300_000); + const server = await serverForFile(testInfo.file); + shot = journeyShots(JOURNEY); + C = await operatorDevice(browser, server.base); + await enterTournament(C, { name: 'Shared Court Cup', courts: 2 }); + const seeded = await seedCompetitions(C, [ + { key: 'SI', opts: { name: SI, kind: 'individual', format: 'mixed', poolSize: 3, poolWinners: 2, courts: ['A', 'B'], numberPrefix: 'SI' }, roster: ROSTER_12 }, + { key: 'ST', opts: { name: ST, kind: 'team', format: 'knockout', teamSize: 3, teamMatchType: 'fixed', courts: ['A'], numberPrefix: 'ST' }, roster: TEAMS }, + ]); + ids = seeded.ids; + draw.defaults = seeded.defaults; + draw.si = await readScoresTab(C, ids.SI); + draw.st = await readScoresTab(C, ids.ST); + draw.siKnockout = await bracketSlots(C, ids.SI); + draw.stKnockout = await bracketSlots(C, ids.ST); + H = await operatorDevice(browser, server.base); + await login(H); + Bc = await operatorDevice(browser, server.base); + await login(Bc); + P = await publicDevice(browser, server.base); + const tvCtx = await browser.newContext({ viewport: { width: 1280, height: 720 }, baseURL: server.base }); + TV = await tvCtx.newPage(); + }); + + test.afterAll(async () => { + audit.flush(); + for (const p of [C, H, Bc, P, TV]) if (p) await p.context().close(); + }); + + // S1 the picker, S8 the slot times (P6), S7 the public queue before play. + test('S1 S8 S7: the shared court before the first bout', async () => { + test.setTimeout(300_000); + const r = (x) => row('S1-S8-S7', x); + + // S8 / P6: the defaults both competitions got, created before any roster. + const onA = [ + ...draw.si.filter((m) => m.court === 'A').map((m) => ({ comp: SI, match: m.label, time: m.time })), + ...draw.siKnockout.filter((m) => m.court === 'A').map((m) => ({ comp: SI, match: m.match, time: m.time })), + ...draw.stKnockout.filter((m) => m.court === 'A').map((m) => ({ comp: ST, match: m.match, time: m.time })), + ].sort((a, b) => a.time.localeCompare(b.time)); + const clash = onA.filter((x) => onA.some((y) => y.comp !== x.comp && y.time === x.time)); + r({ step: 'S8 slots', action: 'wizard default Start times (both created before any roster)', variant: 'P6 record', + si: draw.defaults.SI, st: draw.defaults.ST }); + r({ step: 'S8 slots', action: 'every slot on Shiaijo A after Start', variant: 'P6 record (D3)', slotsOnA: onA, sameTimeOnA: clash, + siKnockout: draw.siKnockout, stKnockout: draw.stKnockout }); + await H.goto(`/admin/competition/${ids.SI}/scores`); + await shot(H, 's8-si-scores-tab', { fullPage: true }); + await H.goto(`/admin/competition/${ids.ST}/scores`); + await shot(H, 's8-st-scores-tab', { fullPage: true }); + await H.goto('/admin/schedule'); + await expect(H.getByRole('heading', { level: 1, name: 'Tournament schedule' })).toBeVisible(); + await shot(H, 's8-schedule-page-defaults', { fullPage: true }); + + // S1: the court console on A offers both competitions. + await openShiaijo(C, 'A'); + await expect(competitionPicker(C)).toBeVisible(); + const s1 = await courtState(C); + r({ step: 'S1 picker', action: 'open /admin/shiaijo/A', variant: 'correct path', officiating: s1.officiating, options: s1.pickerOptions, + why: 'no bout running: the default is the first competition with a scheduled match on the court (effectiveCompId)', + upNext: s1.upNext, nudges: s1.nudges, shot: await shot(C, 's1-court-A-picker') }); + expect([...s1.pickerOptions].sort()).toEqual([SI, ST]); + // V1: the thumb meant the picker and landed beside it. + let nb = { hit: null, note: [] }; + for (const dir of ['left', 'down']) { + try { + nb = { ...(await tapNeighbour(competitionPicker(C), dir)), direction: dir }; + break; + } catch (e) { + nb.note.push(String(e.message).slice(0, 120)); + } + } + await C.waitForTimeout(600); + r({ step: 'S1 picker', action: 'competition picker', variant: 'V1 tapNeighbour (left, else down)', direction: nb.direction, hit: nb.hit, gapPx: nb.gapPx, note: nb.note, + url: new URL(C.url()).pathname, officiating: await officiatingName(C).catch(() => null), shot: await shot(C, 's1-picker-neighbour-tapped') }); + r({ step: 'S1 picker', action: 'competition picker', variant: 'size', ...(await tapSize(competitionPicker(C))) }); + if (new URL(C.url()).pathname !== '/admin/shiaijo/A') await openShiaijo(C, 'A'); + + // Court B hosts SI alone: a plain heading, no picker. + await openShiaijo(Bc, 'B'); + await expect.poll(() => officiatingName(Bc)).toBe(SI); + await expect(competitionPicker(Bc)).toHaveCount(0); + r({ step: 'S1 picker', action: 'open /admin/shiaijo/B (SI alone)', variant: 'correct path', officiating: await officiatingName(Bc), picker: false }); + + // S8: Auto-schedule competition re-times ST alone, from 09:12. + await H.goto('/admin/schedule'); + await H.locator('#auto-schedule-competition').selectOption({ label: ST }); + await H.locator('#auto-schedule-start').fill(ST_RESTART); + const spacing = await H.locator('#auto-match-duration').inputValue().catch(() => null); + await H.getByRole('button', { name: 'Auto-schedule competition' }).tap(); + await expect(H.getByRole('button', { name: 'Auto-schedule competition' })).toBeEnabled({ timeout: 15_000 }); + await H.waitForTimeout(800); + const siAfter = await readScoresTab(H, ids.SI); + draw.st = await readScoresTab(H, ids.ST); + draw.stKnockout = await bracketSlots(H, ids.ST); + r({ step: 'S8 slots', action: `Auto-schedule competition: ${ST} from ${ST_RESTART}`, variant: 'P6 record (D3)', spacing, + stAfter: draw.stKnockout, siUnchanged: JSON.stringify(siAfter.map((m) => m.time)) === JSON.stringify(draw.si.map((m) => m.time)) }); + await H.goto('/admin/schedule'); + await shot(H, 's8-schedule-page-after-auto-schedule', { fullPage: true }); + + // S7 / P8 / D2: the public surfaces for A before play. + await openTvBoard(TV, 'A'); + await TV.waitForTimeout(1000); + await P.goto('/schedule'); + await P.waitForTimeout(1000); + r({ step: 'S7 public', action: 'TV board A and the public schedule before play', variant: 'P8 record (D2)', + tv: (await tvBoard(TV).innerText()).replace(/\s+/g, ' ').slice(0, 500), + schedule: (await P.locator('body').innerText()).replace(/\s+/g, ' ').slice(0, 900), + tvShot: await shot(TV, 's7-tv-A-before-play'), scheduleShot: await shot(P, 's7-public-schedule-before-play', { fullPage: true }) }); + }); + + // S2 / S2b the automatic advance; S4a Correct while SI's own bout runs; S9 + // the Scores page; S3 switching competition mid-bout; S7 again (D2). + test('S2 S4a S2b S9 S3: running SI on the shared court, then switching to ST', async () => { + test.setTimeout(600_000); + const r = (x) => row('S2-S9-S3', x); + const siA = draw.si.filter((m) => m.court === 'A'); + const stSf = draw.st.filter((m) => m.court === 'A'); + await openShiaijo(C, 'A'); + await pickCompetition(C, SI); + + // S2: the first SI bout on A, then Finish + Start Next, double-tapped. + bouts.b1 = await startUpNext(C); + let live = await liveOnConsole(); + expect(live).toMatchObject({ comp: SI, court: 'A' }); + await ipponButton(C, winnerOf(bouts.b1), 'M').tap(); + await settleOn(C); + const fin = finishButton(C); + const finLabel = (await fin.innerText()).trim(); + const dbl = await doubleTap(fin); + await C.waitForTimeout(1000); + const committedByDouble = await completedRowFor(C, bouts.b1).isVisible().catch(() => false); + const armedAfter = await armedFinishButton(C).isVisible().catch(() => false); + r({ step: 'S2 advance', action: finLabel, variant: 'V2 doubleTap', ...dbl, committedByDouble, stillArmed: armedAfter, + shot: await shot(C, 's2-finish-double-tapped') }); + expect(finLabel).toBe('Finish + Start Next →'); + expect(committedByDouble).toBe(false); + expect(armedAfter).toBe(true); + await dwell(C, Date.now() - 1000); + await armedFinishButton(C).tap(); + await expect(completedRowFor(C, bouts.b1)).toBeVisible(); + live = await nextLive(bouts.b1); + bouts.b2 = pairOf(live); + r({ step: 'S2 advance', action: 'Finish + Start Next (confirmed)', variant: 'correct path', next: live, + stUpNextAt: stSf[0] && stSf[0].time, shot: await shot(C, 's2-next-si-bout') }); + expect(live).toMatchObject({ comp: SI, court: 'A' }); + expect(same(bouts.b2, pairOf(siA[1]))).toBe(true); + + // S4a: Correct the first bout while SI's next bout runs: refused (bc-crpn), + // the live bout sent back, the correction opened, then Back to court. + const marksB2 = await slotMarks(C, winnerOf(bouts.b2)); + const corr = await correctCompleted(C, bouts.b1); + r({ step: 'S4a correct', action: "Correct an SI bout while SI's next bout runs", variant: 'correct path (refused, bc-crpn)', + liveBout: corr.live, ...corr.refusal, shot: await shot(C, 's4a-correction-open') }); + expect(same(corr.live, bouts.b2)).toBe(true); + await C.getByRole('button', { name: /Back to court/ }).tap(); + await C.waitForTimeout(800); + const landed = await courtState(C); + r({ step: 'S4a correct', action: 'Back to court', variant: 'V5 lost place', officiating: landed.officiating, upNext: landed.upNext, + editor: landed.editor, idle: landed.idle, shot: await shot(C, 's4a-back-to-court') }); + const again = await ensureRunning(C); + expect(again, 'the bout sent back for the correction starts again').not.toBeNull(); + expect(same(again, bouts.b2)).toBe(true); + expect(await slotMarks(C, winnerOf(bouts.b2))).toEqual(marksB2); + + // S2b: Kiken - Voluntary on the running SI bout. Shiro withdraws: their + // other pool bout on A becomes barred, and the advance must skip it and + // start the next SI bout on A, never ST's match although ST's is earlier on + // the clock. + const withdrawn = bouts.b2.shiro; + const kiken = await recordDecision(C, 'kiken-voluntary', { side: 'shiro' }); + await expect(completedRowFor(C, bouts.b2)).toBeVisible(); + live = await nextLive(bouts.b2); + bouts.b3 = pairOf(live); + const barredRow = C.locator('.shiaijo__queue .shiaijo-qrow').filter({ hasText: withdrawn }).filter({ has: C.getByTestId('barred-match-notice') }).first(); + const barredText = await barredRow.getByTestId('barred-match-notice').innerText().then((t) => t.replace(/\s+/g, ' ').trim(), () => null); + const nextSiTime = (siA.find((m) => same(pairOf(m), bouts.b3)) || {}).time; + r({ step: 'S2b advance', action: 'Kiken - Voluntary (Shiro), side picked by the operator', variant: 'V4 hasty (inline prompt, bc-dsid)', + ...kiken, withdrawn, next: live, nextSiAt: nextSiTime, stEarliestAt: draw.st[0] && draw.st[0].time, barredRow: barredText, + shot: await shot(C, 's2b-advance-after-kiken') }); + expect(kiken.preselected).toBeNull(); + expect(live).toMatchObject({ comp: SI, court: 'A' }); + expect([live.shiro, live.aka]).not.toContain(withdrawn); + // The condition the step is about: ST's waiting match is earlier on the clock. + r({ step: 'S2b advance', action: 'clock check', variant: 'note', stEarlierThanNextSi: !!nextSiTime && draw.st[0].time < nextSiTime }); + + // S7 again (D2): what the public sees while the console runs SI. + await TV.waitForTimeout(800); + await P.reload(); + await P.waitForTimeout(1000); + r({ step: 'S7 public', action: 'TV board A and the public schedule while SI runs on A', variant: 'P8 record (D2)', + consoleRunsNext: 'the next SI bout on A (the console stays on SI)', + tv: (await tvBoard(TV).innerText()).replace(/\s+/g, ' ').slice(0, 500), + schedule: (await P.locator('body').innerText()).replace(/\s+/g, ' ').slice(0, 900), + tvShot: await shot(TV, 's7-tv-A-while-si-runs'), scheduleShot: await shot(P, 's7-public-schedule-while-si-runs', { fullPage: true }) }); + + // S9: the tournament-wide Scores page, All competitions. From the running + // SI bout, Finish + Start Next starts the next SI bout on A, past the + // barred one and past ST's rows. + await C.goto('/admin/score-editor'); + await expect(C.getByRole('heading', { level: 1, name: 'Score editor' })).toBeVisible(); + const listOnA = (await C.locator('.score-edit-row').evaluateAll((rows) => rows.map((x) => x.innerText.replace(/\s+/g, ' ').trim()))) + .filter((t) => / A ▾ /.test(` ${t} `)); + await C.locator('.score-edit-row.score-edit-row--running').filter({ hasText: bouts.b3.shiro }).getByRole('button', { name: /^Score$/ }).tap(); + await expect(C.locator(EDITOR)).toBeVisible(); + await ipponButton(C, winnerOf(bouts.b3), 'M', EDITOR).tap(); + await settleOn(C, EDITOR); + const s9Label = (await finishButton(C, EDITOR).innerText()).trim(); + await finishMatch(C, EDITOR); + let ov; + await expect.poll(async () => { + ov = { ...(await editorIdentity(C, EDITOR)), ...(await editorSides(C, EDITOR)) }; + return !same(ov, bouts.b3) && (await C.locator(EDITOR).locator('.editor-head-pill').count()) === 0; + }, { timeout: 15_000 }).toBe(true); + bouts.b4 = pairOf(ov); + const expectedB4 = siA.find((m) => m.time > nextSiTime && ![m.shiro, m.aka].includes(withdrawn)); + r({ step: 'S9 scores page', action: s9Label, variant: 'correct path', listOnA, next: ov, expected: expectedB4, + shot: await shot(C, 's9-scores-page-finish-next') }); + expect(s9Label).toBe('Finish + Start Next →'); + expect(compOfEyebrow(ov.eyebrow)).toBe(SI); + expect(ov.court).toBe('A'); + expect(same(bouts.b4, pairOf(expectedB4))).toBe(true); + // Prev / Next walk the court, both competitions (the operator taps them). + const walked = []; + const nav = (cls) => C.locator(EDITOR).locator(cls); + for (const cls of ['.score-nav__next', '.score-nav__next', '.score-nav__prev', '.score-nav__prev']) { + if (!(await nav(cls).isEnabled().catch(() => false))) continue; + await nav(cls).tap(); + await C.waitForTimeout(300); + walked.push((await editorIdentity(C, EDITOR)).eyebrow); + } + r({ step: 'S9 scores page', action: 'Next x2, Prev x2', variant: 'correct path (allowed to walk the court)', walked, + comps: walked.map(compOfEyebrow), shot: await shot(C, 's9-prev-next-walked') }); + // Back to the running SI bout, then a no-show: the next SI bout starts. + await C.locator(EDITOR).getByRole('button', { name: /✕ Close/ }).tap(); + await C.locator('.score-edit-row.score-edit-row--running').filter({ hasText: bouts.b4.shiro }).getByRole('button', { name: /^Score$/ }).tap(); + await expect(C.locator(EDITOR)).toBeVisible(); + const fus = await recordDecision(C, 'fusenpai', { side: 'aka', root: EDITOR }); + await expect.poll(async () => { + ov = { ...(await editorIdentity(C, EDITOR)), ...(await editorSides(C, EDITOR)) }; + return !same(ov, bouts.b4) && (await C.locator(EDITOR).locator('.editor-head-pill').count()) === 0; + }, { timeout: 15_000 }).toBe(true); + bouts.b5 = pairOf(ov); + r({ step: 'S9 scores page', action: 'Fusenpai (Aka) in the overlay', variant: 'correct path (after-decision advance)', ...fus, next: ov, + shot: await shot(C, 's9-after-fusenpai') }); + expect(compOfEyebrow(ov.eyebrow)).toBe(SI); + expect(ov.court).toBe('A'); + // SI's last startable bout on A: no Finish + Start Next (the barred bout + // and ST's rows are not next). + const lastLabel = (await finishButton(C, EDITOR).innerText()).trim(); + r({ step: 'S9 scores page', action: "SI's last startable bout on A", variant: 'correct path', finishLabel: lastLabel }); + expect(lastLabel).toBe('Finish'); + // Leave it running with a point, for S3. + await ipponButton(C, winnerOf(bouts.b5), 'M', EDITOR).tap(); + await settleOn(C, EDITOR); + await C.locator(EDITOR).getByRole('button', { name: /✕ Close/ }).tap(); + + // S3: back on the console, SI's bout running with a point. The operator + // switches the picker to ST and starts ST's Up next. + await openShiaijo(C, 'A'); + await expect.poll(async () => (await liveOnConsole()) && same(await liveOnConsole(), bouts.b5), { timeout: 15_000 }).toBe(true); + const marksB5 = await slotMarks(C, winnerOf(bouts.b5)); + await pickCompetition(C, ST); + const s3 = await courtState(C); + r({ step: 'S3 switch', action: `picker -> ${ST} while an SI bout runs with a point`, variant: 'correct path', editor: s3.editor, + upNext: s3.upNext, nudges: s3.nudges, shot: await shot(C, 's3-picked-st-si-running') }); + expect(same({ shiro: s3.editor.names[0], aka: s3.editor.names[1] }, bouts.b5)).toBe(true); + // P1: a reload drops the pick. + await interrupt(C, 'reload'); + await expect(C.getByRole('heading', { level: 1, name: 'Shiaijo A' })).toBeVisible(); + await C.waitForTimeout(1000); + r({ step: 'S3 switch', action: `reload with ${ST} picked and an SI bout running`, variant: 'V3 interrupt reload (P1)', + officiatingAfter: await officiatingName(C), shot: await shot(C, 's3-reload-after-pick') }); + if ((await officiatingName(C)) !== ST) await pickCompetition(C, ST); + // V1: the thumb lands beside ST's Start match. + const stCard = upNextCard(C); + const stPair = await sides(stCard); + let nbStart; + try { + nbStart = await tapNeighbour(stCard.getByRole('button', { name: 'Start match' }), 'right'); + } catch (e) { + nbStart = { hit: null, note: String(e.message).slice(0, 120) }; + } + await C.waitForTimeout(800); + const panelOpen = await C.locator('[data-testid^="match-lineup-side-"]').first().isVisible().catch(() => false); + r({ step: 'S3 switch', action: "Start match (ST's Up next)", variant: 'V1 tapNeighbour right', hit: nbStart.hit && nbStart.hit.label, + note: nbStart.note, lineupPanelOpened: panelOpen, stillRunning: await liveOnConsole(), shot: await shot(C, 's3-start-neighbour-tapped') }); + if (panelOpen) await T.closePanel(C); + // V2: Start match double-tapped. Count the match writes it sends. + // Each match write with when it left (ms after the first tap) and the + // status it was answered with. + const writes = []; + const t0 = Date.now(); + const onRes = (res) => { + const req = res.request(); + const u = new URL(req.url()); + if (req.method() !== 'GET' && /\/api\/competitions\/[^/]+\/matches\//.test(u.pathname)) { + writes.push(`${req.method()} ${decodeURIComponent(u.pathname.split('/matches/')[1])} -> ${res.status()} (+${Date.now() - t0}ms)`); + } + }; + C.on('response', onRes); + const dblStart = await doubleTap(stCard.getByRole('button', { name: 'Start match' })); + await expect.poll(async () => { + const l = await liveOnConsole(); + return !!l && l.comp === ST; + }, { timeout: 15_000 }).toBe(true); + await C.waitForTimeout(1500); + C.off('response', onRes); + const onAafterSwitch = await runningOnCourt(H, 'A'); + const s3b = await courtState(C); + r({ step: 'S3 switch', action: "Start match (ST's Up next) while the SI bout runs", variant: 'V2 doubleTap', ...dblStart, writes, + runningOnA: onAafterSwitch, alsoRunning: s3b.alsoRunning, nudges: s3b.nudges, shot: await shot(C, 's3-st-started') }); + expect(onAafterSwitch).toHaveLength(1); + expect(onAafterSwitch[0]).toMatchObject({ comp: ST, shiro: stPair.shiro, aka: stPair.aka }); + expect(s3b.alsoRunning).toBeNull(); + + // ST's semi-final played; Finish + Start Next stays in ST. + const sf1 = await playTeamMatch(C); + live = await nextLive(sf1); + r({ step: 'S3 switch', action: `Finish + Start Next on ${ST}'s semi-final`, variant: 'correct path', played: sf1, next: live, + shot: await shot(C, 's3-st-finish-next') }); + expect(live).toMatchObject({ comp: ST, court: 'A' }); + bouts.sf2 = pairOf(live); + await T.boutIppon(inlineEditor(C), 1, 'shiro', 'M'); + + // Back to SI: its sent-back bout is Up next with its point. Starting it + // sends ST's semi-final back; one bout runs, and the point is still there. + await pickCompetition(C, SI); + expect(same(await sides(upNextCard(C)), bouts.b5)).toBe(true); + await upNextCard(C).getByRole('button', { name: 'Start match' }).tap(); + await expect.poll(async () => same(await liveOnConsole(), bouts.b5), { timeout: 15_000 }).toBe(true); + const onAback = await runningOnCourt(H, 'A'); + const marksBack = await slotMarks(C, winnerOf(bouts.b5)); + r({ step: 'S3 switch', action: 'Start the sent-back SI bout (ST semi-final running)', variant: 'correct path (bc-sbq)', + runningOnA: onAback, marksBefore: marksB5, marksAfter: marksBack, shot: await shot(C, 's3-si-bout-back-with-point') }); + expect(onAback).toHaveLength(1); + expect(onAback[0]).toMatchObject({ comp: SI, shiro: bouts.b5.shiro, aka: bouts.b5.aka }); + expect(marksBack).toEqual(marksB5); + + // S2b tail: a no-show on SI's last startable bout on A. Nothing is left to + // start in SI here, so nothing starts: ST's waiting semi-final stays queued. + const fus2 = await recordDecision(C, 'fusenpai', { side: 'aka' }); + await expect(completedRowFor(C, bouts.b5)).toBeVisible(); + await C.waitForTimeout(3000); + const after = await courtState(C); + const onAnone = await runningOnCourt(H, 'A'); + r({ step: 'S2b advance', action: "Fusenpai on SI's last startable bout on A", variant: 'correct path (nothing to start)', ...fus2, + runningOnA: onAnone, court: after, shot: await shot(C, 's2b-nothing-left-to-start') }); + expect(onAnone).toHaveLength(0); + expect(after.officiating).toBe(SI); + }); + + // S4b Correct on SI while ST runs (P4); S6d/P2 the reopen court_busy panel; + // S5 the done state (P3) and the nudge (P7), with B's pools played meanwhile. + test('S4b S6d S5: correcting across competitions, then SI done on A', async () => { + test.setTimeout(600_000); + const r = (x) => row('S4b-S5', x); + if (!/\/admin\/shiaijo\/A$/.test(C.url())) await openShiaijo(C, 'A'); + + // ST's semi-final starts again from its Up next, with the point it had. + await pickCompetition(C, ST); + expect(same(await sides(upNextCard(C)), bouts.sf2)).toBe(true); + await T.startUpNextTeam(C); + const sf2Marks = await T.rowSlots(T.boutRow(inlineEditor(C), 1), 'shiro').allInnerTexts(); + r({ step: 'S4b correct', action: `${ST} semi-final started again`, variant: 'correct path (bc-sbq)', bout1Shiro: sf2Marks }); + expect(sf2Marks).toEqual(['M']); + // P4: SI's completed bouts are behind the picker while ST is shown. + const completedST = (await courtState(C)).completed; + await pickCompetition(C, SI); + const completedSI = (await courtState(C)).completed; + r({ step: 'S4b correct', action: `picker -> ${SI} to reach its completed bouts`, variant: 'P4 record', completedWhileST: completedST, + completedWhileSI: completedSI, shot: await shot(C, 's4b-si-completed-while-st-runs') }); + // Correct the kiken bout while ST's semi-final runs: refused, naming it. + const corr = await correctAcross(C, bouts.b2); + r({ step: 'S4b correct', action: `Correct the SI kiken while ${ST} runs`, variant: 'correct path (refused, bc-crpn; P2 copy)', + liveBout: corr.live, ...corr.refusal, namesCompetition: !!corr.refusal && corr.refusal.notice.includes(ST), + shot: await shot(C, 's4b-correction-open') }); + expect(same(corr.live, bouts.sf2)).toBe(true); + + // S6d: the head table starts ST's semi-final from ST's Scores tab while the + // console holds the correction open. + await H.goto(`/admin/competition/${ids.ST}/scores`); + await H.locator('.score-edit-row').filter({ hasText: bouts.sf2.shiro }).getByRole('button', { name: /^Score$/ }).tap(); + const hed = H.locator(EDITOR); + await expect(hed).toBeVisible(); + const startBtn = hed.getByRole('button', { name: 'Start match' }); + await startBtn.waitFor({ state: 'visible' }); + await dwell(H, Date.now()); + await startBtn.tap(); + await expect(T.syncPill(hed)).toBeVisible({ timeout: 15_000 }); + const alert = C.locator('.shiaijo-also-running'); + await expect(alert).toBeVisible({ timeout: 15_000 }); + r({ step: 'S6d reopen', action: `head table starts ${ST}'s semi-final while the console holds an SI correction`, variant: 'two devices', + alert: (await alert.innerText()).replace(/\s+/g, ' ').trim(), shot: await shot(C, 's6d-also-running-during-correction') }); + await hed.getByRole('button', { name: /✕ Close/ }).tap().catch(() => {}); + // Clear kiken and reopen: the court is busy, so the panel offers the remedy. + await inlineEditor(C).getByTestId('clear-withdrawal-reopen').tap(); + const conflict = inlineEditor(C).getByTestId('withdrawal-reopen-conflict'); + await expect(conflict).toBeVisible({ timeout: 10_000 }); + const conflictHead = (await conflict.locator('.reopen-conflict__head').innerText()).trim(); + await conflict.scrollIntoViewIfNeeded(); + const conflictShot = await shot(C, 's6d-reopen-court-busy-panel'); + // V4: the hurried operator taps the loudest button. + const loud = await loudestButton(conflict); + await loud.button.tap(); + await expect.poll(async () => same(await liveOnConsole(), bouts.b2), { timeout: 15_000 }).toBe(true); + const onA = await runningOnCourt(H, 'A'); + const stRows = await readScoresTab(H, ids.ST); + r({ step: 'S6d reopen', action: 'Clear kiken and reopen while ST runs', variant: 'V4 loudest button in the court_busy panel', conflictHead, + namesCompetition: conflictHead.includes(ST), loud: loud.label, others: loud.otherLabels, runningOnA: onA, + stSemi: stRows.find((m) => same(pairOf(m), bouts.sf2)), conflictShot, shot: await shot(C, 's6d-after-queue-it-and-reopen') }); + expect(onA).toHaveLength(1); + expect(onA[0]).toMatchObject({ comp: SI, shiro: bouts.b2.shiro, aka: bouts.b2.aka }); + // V5 / P1 / P7: the iPad reloads the tab now. + await interrupt(C, 'reload'); + await expect(C.getByRole('heading', { level: 1, name: 'Shiaijo A' })).toBeVisible(); + await C.waitForTimeout(1000); + r({ step: 'S6d reopen', action: 'reload right after the reopen', variant: 'V3/V5 interrupt reload (P1, P7)', + court: await courtState(C), shot: await shot(C, 's6d-reload') }); + if ((await officiatingName(C)) !== SI) await pickCompetition(C, SI); + + // Play SI's remaining bouts on A (the reopened one first). + const played = []; + for (let i = 0; i < 6; i += 1) { + const running = await liveOnConsole(); + if (!running && (await queuedPoolBouts(C)) === 0) break; + if (running && running.comp !== SI) break; + played.push(await finishSiBout(C)); + } + r({ step: 'S5 SI done on A', action: 'play the rest of SI on A', variant: 'correct path', played }); + + // S5 (ii): SI's pools on A are done; its quarter-finals here still wait on + // pool placeholders from B (hidden rows), and nothing runs on A. + await C.waitForTimeout(2500); + const ii = await courtState(C); + r({ step: 'S5 SI done on A', action: 'the console with SI pools done on A, B pools still to play', variant: 'P3 / P7 record', court: ii, + nudges: await nudgeLines(C), shot: await shot(C, 's5-ii-si-complete-on-A') }); + expect(ii.officiating).toBe(SI); + // P1 / P7: a reload with no pick now. + await interrupt(C, 'reload'); + await expect(C.getByRole('heading', { level: 1, name: 'Shiaijo A' })).toBeVisible(); + await C.waitForTimeout(1200); + r({ step: 'S5 SI done on A', action: 'reload (the explicit pick is dropped)', variant: 'V3 interrupt reload (P1)', + officiatingAfter: await officiatingName(C), shot: await shot(C, 's5-ii-after-reload') }); + if ((await officiatingName(C)) !== SI) await pickCompetition(C, SI); + + // Court B plays its pools on its own device; A's console follows unprompted. + const bPlayed = []; + for (let i = 0; i < 8 && (await queuedPoolBouts(Bc)) + ((await inlineRunning(Bc)) ? 1 : 0) > 0; i += 1) { + const id = (await inlineRunning(Bc)) ? (await editorIdentity(Bc)).eyebrow : ''; + if (id && !/POOL/.test(id)) break; + bPlayed.push(await playRunningBout(Bc)); + } + await expect.poll(async () => ((await courtState(C)).upNext || {}).head || '', { timeout: 20_000 }).toMatch(/Shared Individual · Match \d+$/); + const koOnA = await courtState(C); + r({ step: 'S5 SI knockout on A', action: 'court B finishes its pools; court A, untouched', variant: 'correct path (P7: held, no reload)', + bPlayed: bPlayed.length, court: koOnA, shot: await shot(C, 's5-quarter-finals-arrive-on-A') }); + expect(koOnA.officiating).toBe(SI); + + // Both courts play their quarter-finals; then B starts its semi-final (M6) + // and leaves it running with a point, and A plays its semi-final (M5), the + // Finish that gets there re-tapped while it saves (V2). + const quarters = []; + for (let i = 0; i < 2; i += 1) quarters.push({ court: 'B', ...(await advanceCourt(Bc)) }); + for (let i = 0; i < 2; i += 1) quarters.push({ court: 'A', ...(await advanceCourt(C)) }); + r({ step: 'S5 SI knockout on A', action: 'quarter-finals on both courts', variant: 'correct path', quarters }); + const m6 = await ensureRunning(Bc); + expect(m6).not.toBeNull(); + await ipponButton(Bc, winnerOf(m6), 'M').tap(); + await settleOn(Bc); + bouts.m6 = m6; + const m5 = await ensureRunning(C); + expect(m5).not.toBeNull(); + await ipponButton(C, winnerOf(m5), 'M').tap(); + await settleOn(C); + await armFinish(C); + const retap = await retapWhileSaving(armedFinishButton(C)); + await expect(completedRowFor(C, m5)).toBeVisible(); + r({ step: 'S5 SI knockout on A', action: 'Finish on SI semi-final M5', variant: 'V2 retapWhileSaving', pair: m5, ...retap }); + + // S5 (i): SI has nothing running or scheduled on A, its final waits on + // M6 (running on B): a Later row. 3 s later the console still shows SI. + await C.waitForTimeout(3000); + const i5 = await courtState(C); + const lines = await nudgeLines(C); + r({ step: 'S5 SI final waits', action: 'the console with SI waiting on a feeder from B', variant: 'P3 / P7 record', court: i5, nudges: lines, + shot: await shot(C, 's5-i-final-waits-on-B') }); + expect(i5.officiating).toBe(SI); + expect(i5.later.length).toBe(1); + // The switch happens on the tap, not before. + const amber = nudges(C).filter({ hasText: ST }).first(); + await expect(amber).toBeVisible(); + await amber.tap(); + await expect.poll(() => officiatingName(C)).toBe(ST); + r({ step: 'S5 SI final waits', action: `tap the nudge line (${ST})`, variant: 'correct path', officiating: await officiatingName(C), + upNext: (await courtState(C)).upNext, shot: await shot(C, 's5-switched-by-tap') }); + }); + + // S6a / P5: a RUNNING match moved onto a shiaijo where another bout runs. + test('S6a: a running match moved onto the busy shared court (P5)', async () => { + test.setTimeout(300_000); + const r = (x) => row('S6a', x); + if (!/\/admin\/shiaijo\/A$/.test(C.url())) await openShiaijo(C, 'A'); + if ((await officiatingName(C)) !== ST) await pickCompetition(C, ST); + // ST's semi-final runs on A; SI's semi-final M6 runs on B. + await T.startUpNextTeam(C); + expect((await liveOnConsole()).comp).toBe(ST); + // The head table moves M6 from B to A on the Tournament schedule page. + await H.goto('/admin/schedule'); + const bCol = H.locator('.tw-court').filter({ has: H.locator('.tw-court__title', { hasText: /^SHIAIJO B$/ }) }); + const m6Row = bCol.locator('.tw-match.tw-match--running').filter({ hasText: bouts.m6.shiro }).first(); + await expect(m6Row).toBeVisible(); + await m6Row.locator('button[aria-haspopup="listbox"]').tap(); + await H.getByRole('listbox', { name: 'Choose shiaijo' }).getByRole('option', { name: 'A', exact: true }).tap(); + await H.waitForTimeout(500); + const askedFirst = await H.locator('.modal[role="dialog"]').isVisible().catch(() => false); + const alert = C.locator('.shiaijo-also-running'); + const alertShown = await alert.waitFor({ state: 'visible', timeout: 10_000 }).then(() => true, () => false); + const onA = await runningOnCourt(H, 'A'); + r({ step: 'S6a move', action: 'schedule page: move running SI M6 from B onto A while ST runs on A', variant: 'P5 record', askedFirst, + runningOnA: onA, alert: alertShown ? (await alert.innerText()).replace(/\s+/g, ' ').trim() : null, + consoleShot: await shot(C, 's6a-two-running-on-A'), scheduleShot: await shot(H, 's6a-schedule-after-move', { fullPage: true }) }); + // Can either bout still take a point? The console's editor holds one of them. + const held = await liveOnConsole(); + const ed = inlineEditor(C); + let pointTried = null; + if (held && held.comp === ST) { + const before = await T.rowSlots(T.boutRow(ed, 2), 'shiro').count(); + await T.rowIppon(T.boutRow(ed, 2), 'shiro', 'M').tap(); + await C.waitForTimeout(2500); + pointTried = { comp: ST, before, after: await T.rowSlots(T.boutRow(ed, 2), 'shiro').count() }; + } else if (held) { + await ipponButton(C, winnerOf(held), 'K').tap(); + await C.waitForTimeout(2500); + pointTried = { comp: SI, marks: await slotMarks(C, winnerOf(held)) }; + } + const sync = await syncState(C).innerText().catch(() => null); + const toasts = (await C.locator('.toast, .pending-write-banner, .alert--error').allInnerTexts().catch(() => [])).join(' | ').replace(/\s+/g, ' '); + r({ step: 'S6a move', action: 'a point on the bout the console shows, two bouts running', variant: 'P5 record', held, pointTried, sync, + messages: toasts.slice(0, 400), shot: await shot(C, 's6a-point-with-two-running') }); + // Recovery the alert points at: send the shown bout back to the queue. + await C.getByRole('button', { name: 'Send back to queue' }).tap(); + const sent = await hastyShiaijoConfirm(C); + await C.waitForTimeout(1500); + const onAafter = await runningOnCourt(H, 'A'); + r({ step: 'S6a move', action: 'Send back to queue (the shown bout)', variant: 'V4 hastyShiaijoConfirm (recovery)', ...sent, + runningOnA: onAafter, court: await courtState(C), shot: await shot(C, 's6a-after-send-back') }); + expect(onAafter).toHaveLength(1); + // Was the point the board showed (with the pill at "Synced") stored? The + // match is queued now; an editor shows a queued match's kept score. + if (pointTried && pointTried.comp === ST) { + await H.goto(`/admin/competition/${ids.ST}/scores`); + await H.locator('.score-edit-row').filter({ hasText: held.shiro }).getByRole('button', { name: /^Score$/ }).tap(); + const hed = H.locator(EDITOR); + await expect(T.boutRow(hed, 2)).toBeVisible(); + const stored = await T.rowSlots(T.boutRow(hed, 2), 'shiro').count(); + r({ step: 'S6a move', action: 'the refused point, read back on the head table after the send back', variant: 'P5 record', + bout2ShiroOnBoardBefore: pointTried.after, bout2ShiroStored: stored, shot: await shot(H, 's6a-refused-point-read-back') }); + await hed.getByRole('button', { name: /✕ Close/ }).tap().catch(() => {}); + } + }); +}); diff --git a/scripts/e2e/journeys/smoke.spec.mjs b/scripts/e2e/journeys/smoke.spec.mjs index 4e06cafd9..2908233cb 100644 --- a/scripts/e2e/journeys/smoke.spec.mjs +++ b/scripts/e2e/journeys/smoke.spec.mjs @@ -104,13 +104,13 @@ test.describe('smoke', () => { const backAfterReload = await inlineEditor(page).waitFor({ state: 'visible', timeout: 15_000 }) .then(() => true, () => false); record({ variant: 'V3 interrupt', action: 'running bout', ...reload, editorBack: backAfterReload, - sameMatch: backAfterReload && (await editorIdentity(page)) === identity }); + sameMatch: backAfterReload && (await editorIdentity(page)).key === identity.key }); await awardIppon(page, 'shiro', 'M'); const hidden = await interrupt(page, 'hidden'); record({ variant: 'V3 interrupt', action: 'bout with one ippon', ...hidden, - sameMatch: (await editorIdentity(page)) === identity }); + sameMatch: (await editorIdentity(page)).key === identity.key }); const back = await interrupt(page, 'back'); const backAfterBack = await inlineEditor(page).waitFor({ state: 'visible', timeout: 15_000 }) @@ -118,7 +118,7 @@ test.describe('smoke', () => { const shiroPoints = backAfterBack ? await inlineEditor(page).locator('[aria-label^="Shiro slot "][aria-label*=": remove "]').count() : null; record({ variant: 'V3 interrupt', action: 'bout with one ippon', ...back, editorBack: backAfterBack, - sameMatch: backAfterBack && (await editorIdentity(page)) === identity, shiroPointsKept: shiroPoints }); + sameMatch: backAfterBack && (await editorIdentity(page)).key === identity.key, shiroPointsKept: shiroPoints }); await shot(page, 'bout-1-scored'); await finishMatch(page, INLINE_EDITOR); @@ -165,7 +165,7 @@ test.describe('smoke', () => { await startMatch(page); await awardIppon(page, 'aka', 'K', EDITOR); await shot(page, 'overlay-editor-B'); - await finishMatch(page); + await finishMatch(page, EDITOR); }); await test.step('a spectator phone sees bout 1 on the viewer and the TV board', async () => { diff --git a/scripts/screenshots/lib/editor.mjs b/scripts/screenshots/lib/editor.mjs index e02178bdc..4d07eec78 100644 --- a/scripts/screenshots/lib/editor.mjs +++ b/scripts/screenshots/lib/editor.mjs @@ -11,11 +11,15 @@ export const EDITOR = '.editor-modal'; // The same editor mounted INLINE, as the shiaijo page (/admin/shiaijo/:court) -// does: admin_scoring_individual.jsx renders `variant="inline"` as -// `
`, with no backdrop and no -// `.editor-modal` class, so EDITOR matches nothing there. The e2e journeys -// pass it as the editor's root (scripts/e2e/fixtures/scoring.mjs); finishMatch -// and startMatch below drive the overlay only. +// does: every editor's `variant="inline"` renders a `.scoring-panel` with no +// backdrop and no `.editor-modal` class (admin_scoring_individual.jsx and +// admin_scoring_engi.jsx as `scoring-panel editor-modal--compact`, +// admin_scoring_team.jsx as `scoring-panel scoring-panel--team`, and the +// lineup panel's inline variant in admin_schedule_lineup.jsx as `scoring-panel +// lineup-panel--inline`), so EDITOR matches nothing there and this matches all +// of them. The e2e journeys pass it as the editor's root +// (scripts/e2e/fixtures/scoring.mjs); finishMatch and startMatch below drive +// the overlay only. export const INLINE_EDITOR = '.scoring-panel'; // The editors ignore a repeat POINTER tap within their bounce window From d3894b430b0d20c1a032606457972e47314a7e52 Mon Sep 17 00:00:00 2001 From: Ricardo Oliveira Date: Mon, 12 Oct 2026 10:31:52 +0100 Subject: [PATCH 16/30] test(bc-shcr, bc-mvsj): fixmes for the shared-court rulings; stale journey notes Each bead confirmed from the shared-shiaijo and moved-match findings gets a test.fixme asserting the fixed behaviour through the UI, so its fix PR turns it into a plain test: bc-seqc, bc-pubq, bc-rnrm, bc-pace, bc-sypl and bc-mvrq in shared-shiaijo.spec.mjs; bc-stpf and bc-adna in moved-match.spec.mjs. Journey notes that described the removed Remaining matches panel (bc-kpnl, superseded by the do-not-cascade ruling) or Send back to queue clearing a score (bc-sbq ruled it keeps it) now say what main does. Co-Authored-By: Claude Opus 5.5 --- scripts/e2e/fixtures/individual.mjs | 4 +- .../knockout-mixed-kachinuki.spec.mjs | 8 +- .../e2e/journeys/knockout-mixed-team.spec.mjs | 7 +- scripts/e2e/journeys/moved-match.spec.mjs | 119 +++++++++- scripts/e2e/journeys/shared-shiaijo.spec.mjs | 207 +++++++++++++++++- 5 files changed, 330 insertions(+), 15 deletions(-) diff --git a/scripts/e2e/fixtures/individual.mjs b/scripts/e2e/fixtures/individual.mjs index 6fb608983..fe2b785f5 100644 --- a/scripts/e2e/fixtures/individual.mjs +++ b/scripts/e2e/fixtures/individual.mjs @@ -32,8 +32,8 @@ export const ROSTER_6B = [ ['Bo Grant', 'Gyokusen'], ['Cy Novak', 'Thames'], ['Di Weller', 'Musashi'], ['Ed Frost', 'Seishinkan'], ['Flo Adler', 'Mumeishi'], ['Gil Varga', 'Hizen'], ]; -// Small rosters for the FINDING tests, which seed their own competitions so -// each can run alone once its defect is fixed. +// Small rosters for the ': ...' tests, which seed their own +// competitions so each runs alone. export const rosterOf = (prefix, n) => Array.from({ length: n }, (_, i) => [ `${prefix} ${['Aki', 'Bea', 'Cal', 'Dee', 'Eli', 'Fay'][i]}`, ['Gyokusen', 'Thames', 'Musashi', 'Seishinkan', 'Mumeishi', 'Hizen'][i], ]); diff --git a/scripts/e2e/journeys/knockout-mixed-kachinuki.spec.mjs b/scripts/e2e/journeys/knockout-mixed-kachinuki.spec.mjs index 7605d53d0..f8dd62a93 100644 --- a/scripts/e2e/journeys/knockout-mixed-kachinuki.spec.mjs +++ b/scripts/e2e/journeys/knockout-mixed-kachinuki.spec.mjs @@ -21,11 +21,9 @@ // after them (': ...'), each a self-contained reproduction placed // where the fixture supports it: first as test.fixme, and, once the bead was // fixed, as plain tests that guard the fix. The tests after one tolerate what -// it leaves behind. Judgement findings, and behaviour -// the docs do not rule on (Send back to queue under a REOPENED encounter -// wiping its bout log: the docs promise the log survives a reopen, and that -// Send back to queue clears a running match's score), are recorded as audit -// rows and never asserted. +// it leaves behind. Judgement findings, and Send back to queue under a +// REOPENED encounter (since ruled by bc-sbq: it keeps the bout log and the +// score), are recorded as audit rows and never asserted. // // Asserted: only the functional outcomes the docs promise (who stays on, the // recorded result, the bracket advancing). The clumsy variants are PERFORMED diff --git a/scripts/e2e/journeys/knockout-mixed-team.spec.mjs b/scripts/e2e/journeys/knockout-mixed-team.spec.mjs index 1a503fc66..f277ecc46 100644 --- a/scripts/e2e/journeys/knockout-mixed-team.spec.mjs +++ b/scripts/e2e/journeys/knockout-mixed-team.spec.mjs @@ -428,6 +428,10 @@ test.describe('knockout-mixed-team', () => { const prompt = ed.locator('form.decision-prompt'); await prompt.locator('input[value="shiro"]').check(); await prompt.locator('button.btn--primary').tap(); + // The post-kiken Remaining matches panel was removed by the operator's + // "do not cascade" ruling (2026-09-26; bc-kpnl closed with it): `shown` + // records false and the award loop below taps nothing. Each remaining + // match is closed from its own notice when it comes up (the Start below). const panel = ed.locator('.remaining-matches'); const shown = await panel.waitFor({ state: 'visible', timeout: 8000 }).then(() => true, () => false); const panelText = shown ? (await panel.innerText({ timeout: 2000 }).catch(() => '')).replace(/\s+/g, ' ') : ''; @@ -468,7 +472,7 @@ test.describe('knockout-mixed-team', () => { const matchesText = (await page.locator('body').innerText()).replace(/\s+/g, ' '); const i = matchesText.indexOf('Pool A'); record({ step: 'pool kiken chain', action: 'Pools tab after the chain', variant: 'consistency', awardsTapped: awarded, - poolA: matchesText.slice(i, i + 700), note: 'bc-kpnl when the panel vanished and the match is still scheduled' }); + poolA: matchesText.slice(i, i + 700), note: 'the remaining match stays scheduled: the Remaining matches panel is gone ("do not cascade"), and the match is closed from its own notice' }); // The operator carries on at the court: Up next now holds the // withdrawn team's remaining match. await openShiaijo(page, 'A'); @@ -874,6 +878,7 @@ test.describe('knockout-mixed-team', () => { }); await test.step('SF2 after the wrong-side kiken: the final starts, then the operator notices and corrects', async () => { + // A leftover from the removed Remaining matches panel: matches nothing now. const close = ed.locator('.remaining-matches button', { hasText: '✕' }); if (await close.count()) await close.first().tap(); // Not noticing, the operator starts the next match: the final. diff --git a/scripts/e2e/journeys/moved-match.spec.mjs b/scripts/e2e/journeys/moved-match.spec.mjs index 362f87278..da5a2e8fe 100644 --- a/scripts/e2e/journeys/moved-match.spec.mjs +++ b/scripts/e2e/journeys/moved-match.spec.mjs @@ -29,16 +29,16 @@ import { journeyShots } from '../fixtures/shots.mjs'; import { auditLog } from '../fixtures/audit.mjs'; import { login } from '../fixtures/setup.mjs'; import { openShiaijo, upNextCard, sides, startUpNext } from '../fixtures/shiaijo.mjs'; -import { EDITOR } from '../fixtures/scoring.mjs'; +import { EDITOR, awardIppon, finishButton, finishMatch } from '../fixtures/scoring.mjs'; import { hastyShiaijoConfirm } from '../fixtures/clumsy.mjs'; import { settled } from '../fixtures/pace.mjs'; import { tapSize } from '../fixtures/pointer.mjs'; -import { ROSTER_6 } from '../fixtures/individual.mjs'; +import { ROSTER_6, recordDecision, slotMarks } from '../fixtures/individual.mjs'; import * as T from '../fixtures/team.mjs'; import { enterTournament, seedCompetitions, readScoresTab, operatorDevice, markPage, pageMark, competitionPicker, officiatingName, pickCompetition, nudges, nudgeLines, courtState, - upcomingRow, chooseCourt, runningOnCourt, playTeamMatch, + upcomingRow, chooseCourt, runningOnCourt, playTeamMatch, liveBout, } from '../fixtures/shared-court.mjs'; test.describe.configure({ mode: 'serial' }); @@ -63,6 +63,10 @@ async function bStillOpen() { const queueNames = (s) => [s.upNext, ...s.upcoming].filter(Boolean).map((r) => r.names.join(' vs ')); const pairText = (p) => `${p.shiro} vs ${p.aka}`; +const samePair = (a, b) => !!a && !!b && a.shiro === b.shiro && a.aka === b.aka; +// A pairing's Start, on the Up next card or its Upcoming row, whichever holds it. +const startButton = (page, pair) => upNextCard(page).filter({ hasText: pair.shiro }).filter({ hasText: pair.aka }) + .or(upcomingRow(page, pair)).first().getByRole('button', { name: 'Start match' }); test.describe('moved-match', () => { test.beforeAll(async ({ browser, serverForFile }, testInfo) => { @@ -295,7 +299,7 @@ test.describe('moved-match', () => { // then taps Start on the second, whose competitor is still fighting on A. // The console sends the running bout back to the queue BEFORE the server // answers the start (pickMatch's defer), and the start is refused for the - // competitor, not the court. + // competitor, not the court (bc-stpf; its fixme below asserts the fix). // The pairing's Start, on the Up next card or its Upcoming row, whichever // holds it when the tap lands: a start just made moves the next pairing // from its row onto the card while a hurried tap is on its way. @@ -325,4 +329,111 @@ test.describe('moved-match', () => { variant: 'V5 lost place (the moved match\'s competitor is elsewhere)', notice: await notice(), courtB: await courtState(B), runningOnB: await runningOnCourt(H, 'B'), shot: await shot(B, 'court-B-start-refused-competitor-busy') }); }); + + // bc-stpf (operator ruling 2026-10-12, option A: check that the start would + // be accepted before sending the running bout back; if it would be refused, + // nothing moves and the reason shows). Step 8's shape: the moved match runs + // on court B with a point, and the operator taps Start on a match one of + // whose competitors is still fighting on A. Fixed: the refusal shows and the + // moved match keeps running with its point. Then, with that competitor free + // (A's bout sent back), the same Start is accepted and sends the moved match + // back first (step 5b asserts a bout sent back this way keeps its score). + // Relies on what the main test leaves: A's first Individual bout still + // running, and step 7's match, which names one of its competitors, waiting + // on B. + test.fixme('bc-stpf: a Start the server would refuse leaves the running bout running and shows why', async () => { + const onA = await runningOnCourt(H, 'A'); + expect(onA, "court A's first Individual bout is still running").toHaveLength(1); + const busy = [onA[0].shiro, onA[0].aka]; + await openShiaijo(B, 'B'); + if ((await competitionPicker(B).count()) && (await officiatingName(B)) !== 'Individual') await pickCompetition(B, 'Individual'); + const runs = async (pair) => samePair(await liveBout(B), pair); + if (!(await runs(moved))) { + await startButton(B, moved).tap(); + await expect.poll(() => runs(moved), { timeout: 15_000 }).toBe(true); + } + await awardIppon(B, 'shiro', 'M'); + const marks = await slotMarks(B, 'shiro'); + // The waiting match whose competitor fights on A. + let blocked = null; + await expect.poll(async () => { + const s = await courtState(B); + const r = [s.upNext, ...s.upcoming].filter(Boolean).find((x) => x.names.some((n) => busy.includes(n))); + blocked = r ? { shiro: r.names[0], aka: r.names[1] } : null; + return !!blocked; + }, { timeout: 15_000 }).toBe(true); + await startButton(B, blocked).tap(); + const refusal = B.locator('[role="alert"], .toast').filter({ hasText: /is fighting now in .+ on Shiaijo A\. Finish that match first\./ }).first(); + await expect(refusal).toBeVisible({ timeout: 10_000 }); + await B.waitForTimeout(1500); + expect(await runs(moved), 'the moved match is still running on B').toBe(true); + expect(await slotMarks(B, 'shiro')).toEqual(marks); + const onB = await runningOnCourt(H, 'B'); + expect(onB).toHaveLength(1); + expect(onB[0]).toMatchObject({ shiro: moved.shiro, aka: moved.aka }); + // Accepted: A's bout goes back to the queue, so its competitors are free. + await openShiaijo(A, 'A'); + await A.getByRole('button', { name: 'Send back to queue' }).tap(); + await hastyShiaijoConfirm(A); + await expect.poll(() => liveBout(A), { timeout: 10_000 }).toBeNull(); + await startButton(B, blocked).tap(); + await expect.poll(() => runs(blocked), { timeout: 15_000 }).toBe(true); + const onBafter = await runningOnCourt(H, 'B'); + expect(onBafter).toHaveLength(1); + expect(onBafter[0]).toMatchObject({ shiro: blocked.shiro, aka: blocked.aka }); + }); + + // bc-adna (operator ruling 2026-10-12: "The operator's view is where scoring + // is done and has a queue. In Admin view, changes are not tied/blocked by + // the court status, there's no queue or advancing"). On the competition's + // Scores tab, with other Individual matches waiting on A: Finish reads plain + // Finish and finishing starts nothing, and a fusenpai recorded there starts + // nothing either, so no court_busy refusal follows it. The other half of the + // acceptance (an admin write is never refused for court status) is not + // exercised: reaching it needs a second bout running on the court, which + // bc-mvrq's fix rules out, and what an admin Start on a busy court then does + // is for the bead's plan to settle. The same fix retires shared-shiaijo's S9 + // assertions ("Finish + Start Next →" and the start after a fusenpai on the + // Scores page) and the Scores tab refusal in step 5a above. Runs after + // bc-stpf, but takes court A as it finds it: the running Individual bout + // there, or else one it starts from the Scores tab whose competitors are not + // fighting on B. + test.fixme('bc-adna: the Scores tab never starts a match by itself', async () => { + const onB = (await runningOnCourt(H, 'B')).flatMap((m) => [m.shiro, m.aka]); + const rows = await readScoresTab(H, ids.IND); + const waitingOnA = rows.filter((m) => m.court === 'A' && m.status === 'scheduled' && !onB.includes(m.shiro) && !onB.includes(m.aka)); + let first = rows.find((m) => m.court === 'A' && m.status === 'running'); + const startsHere = !first; + if (startsHere) first = waitingOnA.shift(); + expect(first, 'an Individual match on A to score').toBeTruthy(); + expect(waitingOnA.length, 'two more Individual matches wait on A after it').toBeGreaterThan(1); + const ed = H.locator(EDITOR); + const open = async (m) => { + await H.goto(`/admin/competition/${ids.IND}/scores`); + await H.locator('.score-edit-row').filter({ hasText: m.shiro }).filter({ hasText: m.aka }).first().getByRole('button', { name: /^Score$/ }).tap(); + await expect(ed).toBeVisible(); + }; + const start = async () => { + const btn = ed.getByRole('button', { name: 'Start match' }); + await settled(btn); + await btn.tap(); + await expect(finishButton(H, EDITOR)).toBeVisible(); + }; + await open(first); + if (startsHere) await start(); + await expect(finishButton(H, EDITOR)).toHaveText('Finish'); + await awardIppon(H, 'shiro', 'M', EDITOR); + await finishMatch(H, EDITOR); + await H.waitForTimeout(1500); + expect(await runningOnCourt(H, 'A'), 'nothing started on A after Finish').toHaveLength(0); + // A fusenpai on the next one, started by the operator. + const second = waitingOnA.find((m) => !samePair(m, first)); + await open(second); + await start(); + await recordDecision(H, 'fusenpai', { side: 'aka', root: EDITOR }); + await H.waitForTimeout(2000); + await expect(H.locator('.toast').filter({ hasText: /is running/ })).toHaveCount(0); + await expect.poll(async () => ((await readScoresTab(H, ids.IND)).find((m) => samePair(m, second)) || {}).status, { timeout: 10_000 }).toBe('completed'); + expect(await runningOnCourt(H, 'A'), 'nothing started on A after the fusenpai').toHaveLength(0); + }); }); diff --git a/scripts/e2e/journeys/shared-shiaijo.spec.mjs b/scripts/e2e/journeys/shared-shiaijo.spec.mjs index 674f8bffb..38092cefe 100644 --- a/scripts/e2e/journeys/shared-shiaijo.spec.mjs +++ b/scripts/e2e/journeys/shared-shiaijo.spec.mjs @@ -36,12 +36,12 @@ import { openShiaijo, upNextCard, sides, startUpNext, completedRowFor, inlineEditor, editorIdentity, } from '../fixtures/shiaijo.mjs'; import { - EDITOR, INLINE_EDITOR, ipponButton, syncState, finishButton, armedFinishButton, armFinish, finishMatch, + EDITOR, INLINE_EDITOR, ipponButton, syncState, filledSlots, finishButton, armedFinishButton, armFinish, finishMatch, } from '../fixtures/scoring.mjs'; import { doubleTap, hastyShiaijoConfirm, interrupt, loudestButton, retapWhileSaving, tapNeighbour, } from '../fixtures/clumsy.mjs'; -import { dwell } from '../fixtures/pace.mjs'; +import { dwell, settled } from '../fixtures/pace.mjs'; import { tapSize } from '../fixtures/pointer.mjs'; import { openTvBoard, tvBoard } from '../fixtures/public.mjs'; import { @@ -52,7 +52,7 @@ import * as T from '../fixtures/team.mjs'; import { enterTournament, seedCompetitions, readScoresTab, bracketSlots, operatorDevice, publicDevice, competitionPicker, officiatingName, pickCompetition, nudges, nudgeLines, courtState, runningOnCourt, - playTeamMatch, liveBout, correctAcross, + playTeamMatch, liveBout, correctAcross, chooseCourt, } from '../fixtures/shared-court.mjs'; test.describe.configure({ mode: 'serial' }); @@ -86,6 +86,14 @@ const compOfEyebrow = (eyebrow) => { if (e.startsWith(ST.toUpperCase())) return ST; return `unknown: ${eyebrow}`; }; +// A court's column on the public schedule (/schedule) and on the Tournament +// schedule page (/admin/schedule): both title it "SHIAIJO ". +const scheduleColumn = (page, court) => page.locator('.tw-court') + .filter({ has: page.locator('.tw-court__title', { hasText: new RegExp(`^SHIAIJO ${court}$`) }) }); +// The clock times a text shows (HH:MM) other than `planned`. +const timesOtherThan = (text, planned) => (text.match(/\b\d{1,2}:\d{2}\b/g) || []).filter((t) => t !== planned); +// The labels the editors' autosave pill takes (admin_scoring_autosave.jsx). +const SYNC_PILL = /^(Synced|Syncing…|Offline|Not saving)$/; // The bout running in the console's editor (individual or team), with its // competition and court. @@ -170,6 +178,29 @@ test.describe('shared-shiaijo', () => { for (const p of [C, H, Bc, P, TV]) if (p) await p.context().close(); }); + // bc-seqc (operator ruling 2026-10-12, bc-shcr D3: "By default, competitions + // should be one after the other and the start-time will need to be adjusted + // depending on how the day is going"). ST was created after SI and shares + // Shiaijo A with it, so by default every ST match on A comes after SI's last + // match there. First in the file because S1 re-times ST with Auto-schedule, + // after which the defaults can no longer be read; it only reads the Scores + // and Bracket tabs, so S1 starts from the same state. The wizard hint's new + // wording is the fix's to write: it is recorded here, not asserted. + test.fixme('bc-seqc: a competition sharing a shiaijo with an earlier one is scheduled after it by default', async () => { + const clock = (t) => /^\d\d:\d\d$/.test(t); + const siOnA = [ + ...(await readScoresTab(H, ids.SI)).filter((m) => m.court === 'A').map((m) => m.time), + ...(await bracketSlots(H, ids.SI)).filter((m) => m.court === 'A').map((m) => m.time), + ].filter(clock).sort(); + const stOnA = (await bracketSlots(H, ids.ST)).filter((m) => m.court === 'A').map((m) => m.time).filter(clock).sort(); + row('bc-seqc', { step: 'S8 slots', action: 'default slots on Shiaijo A', variant: 'fixme', siOnA, stOnA, + hint: draw.defaults.ST.sharedCourtHint, startTimes: { si: draw.defaults.SI.startTime, st: draw.defaults.ST.startTime } }); + expect(siOnA.length).toBeGreaterThan(0); + expect(stOnA.length).toBeGreaterThan(0); + const siLast = siOnA[siOnA.length - 1]; + expect(stOnA[0] > siLast, `${ST}'s first match on A (${stOnA[0]}) comes after ${SI}'s last there (${siLast})`).toBe(true); + }); + // S1 the picker, S8 the slot times (P6), S7 the public queue before play. test('S1 S8 S7: the shared court before the first bout', async () => { test.setTimeout(300_000); @@ -251,6 +282,34 @@ test.describe('shared-shiaijo', () => { tvShot: await shot(TV, 's7-tv-A-before-play'), scheduleShot: await shot(P, 's7-public-schedule-before-play', { fullPage: true }) }); }); + // bc-pubq (operator ruling 2026-10-12, bc-shcr D2: "The view should follow + // the queue the scoring operator has"). Before play, with ST re-timed between + // SI's pool bouts, the TV board for A names the match the court A console + // will start next and nothing of the competition the console is not + // running, and the public schedule shows ONE "Next up" on Shiaijo A, on that + // match. Relies on the state S1 leaves (nothing played, ST re-timed). It + // only navigates C, TV and P, and leaves P on /schedule and TV on A's board, + // as S2 expects. Today the schedule's second "Next up" (ST's semi-final) is + // what fails here; the TV naming ST's semi-final as NEXT was seen while SI + // ran (S7 inside S2), so in this state its check guards a regression. + test.fixme('bc-pubq: the TV board and the public schedule follow the court console\'s queue on a shared shiaijo', async () => { + await openShiaijo(C, 'A'); + const s = await courtState(C); + expect(s.upNext, 'the court A console has an Up next').not.toBeNull(); + const [shiro, aka] = s.upNext.names; + const otherNames = s.officiating === SI ? TEAMS.map((t) => t[0]) : ROSTER_12.map((p) => p[0]); + await openTvBoard(TV, 'A'); + await expect(tvBoard(TV)).toContainText(shiro); + await expect(tvBoard(TV)).toContainText(aka); + for (const name of otherNames) await expect(tvBoard(TV)).not.toContainText(name); + await P.goto('/schedule'); + const nextUp = scheduleColumn(P, 'A').locator('.tw-match') + .filter({ has: P.locator('.tw-match__queue', { hasText: /^Next up$/ }) }); + await expect(nextUp).toHaveCount(1); + await expect(nextUp).toContainText(shiro); + await expect(nextUp).toContainText(aka); + }); + // S2 / S2b the automatic advance; S4a Correct while SI's own bout runs; S9 // the Scores page; S3 switching competition mid-bout; S7 again (D2). test('S2 S4a S2b S9 S3: running SI on the shared court, then switching to ST', async () => { @@ -638,6 +697,92 @@ test.describe('shared-shiaijo', () => { upNext: (await courtState(C)).upNext, shot: await shot(C, 's5-switched-by-tap') }); }); + // bc-rnrm (operator ruling 2026-10-11 on PR #442 finding 5: "Run now - This + // seems to be the wrong button", read on 2026-10-12 as remove it). Relies on + // the state S4b-S5 leaves: SI's final waits on M6, running on B, so it is a + // Later row on A, the row that offered Run now; ST's final waits on its + // second semi-final the same way. Only switches the picker, and leaves the + // console on ST, as S6a expects. + test.fixme('bc-rnrm: no court console row offers Run now', async () => { + if (!/\/admin\/shiaijo\/A$/.test(C.url())) await openShiaijo(C, 'A'); + await pickCompetition(C, SI); + await expect(C.locator('.shiaijo-pending .shiaijo-qrow').first()).toBeVisible(); + await expect(C.getByRole('button', { name: 'Run now' })).toHaveCount(0); + await pickCompetition(C, ST); + await expect(C.getByRole('button', { name: 'Run now' })).toHaveCount(0); + }); + + // bc-pace (operator ruling 2026-10-12, bc-shcr D2: "The schedule also needs + // to adapt if the courts are running late or fast"). How the projection is + // computed and shown is planned on the bead before code, so this pins only + // the observable its acceptance names: once court A has played matches + // (SI's pools, quarter-finals and semi-final, all far off the plan's clock + // in a test run), + // its next match, ST's second semi-final, shows a time other than its + // stored one on the court console, the public schedule and the TV board + // (which shows no time at all today), while the Scores tab still shows the + // plan. Rewrite the time reads to the planned presentation when the bead is + // planned. Relies on the state S4b-S5 leaves (nothing running on A, ST's + // semi-final waiting there); it only reads, and leaves the console on ST. + test.fixme('bc-pace: court A\'s next match shows a time projected from the court\'s pace, the plan unchanged', async () => { + const planned = (draw.st.find((m) => same(pairOf(m), bouts.sf2)) || {}).time; + expect(planned, `${ST}'s second semi-final has a planned time`).toMatch(/^\d\d:\d\d$/); + const stored = ((await readScoresTab(H, ids.ST)).find((m) => same(pairOf(m), bouts.sf2)) || {}).time; + expect(stored, 'the Scores tab still shows the plan').toBe(planned); + if (!/\/admin\/shiaijo\/A$/.test(C.url())) await openShiaijo(C, 'A'); + if ((await officiatingName(C)) !== ST) await pickCompetition(C, ST); + expect(same(await sides(upNextCard(C)), bouts.sf2)).toBe(true); + expect(timesOtherThan(await upNextCard(C).innerText(), planned), 'a projected time on the console Up next card').not.toEqual([]); + await P.goto('/schedule'); + const sfRow = scheduleColumn(P, 'A').locator('.tw-match').filter({ hasText: bouts.sf2.shiro }).filter({ hasText: bouts.sf2.aka }).first(); + await expect(sfRow).toBeVisible(); + expect(timesOtherThan(await sfRow.innerText(), planned), 'a projected time on the public schedule row').not.toEqual([]); + await openTvBoard(TV, 'A'); + await expect(tvBoard(TV)).toContainText(bouts.sf2.shiro); + expect(timesOtherThan(await tvBoard(TV).innerText(), planned), 'a projected time on the TV board').not.toEqual([]); + }); + + // bc-sypl (operator ruling 2026-10-12: "You do not need the word/pill saying + // Synced because you have the Connected pill and it shows the queue of + // events"). No score editor shows the autosave pill, inline or overlay; a + // change not sent yet shows in the header alone, as the held-writes count + // beside the Connected pill, and goes once it is sent. Relies on the state + // S4b-S5 leaves: court B's console runs SI's semi-final M6, Shiro or Aka + // ahead by a men. It adds a kote for the other side while offline, which S6a + // and bc-mvrq do not depend on. Flipping it needs the harness to stop + // waiting on the pill in the same change (scoring.mjs syncState and + // awardIppon, team.mjs syncPill and boutIppon, this file's settleOn), as the + // bead says. + test.fixme('bc-sypl: score editors show no sync pill; the header shows what is not sent yet', async () => { + if (!/\/admin\/shiaijo\/B$/.test(Bc.url())) await openShiaijo(Bc, 'B'); + await expect.poll(async () => same(await liveBout(Bc), bouts.m6), { timeout: 15_000 }).toBe(true); + const ed = inlineEditor(Bc); + await expect(ed.getByText(SYNC_PILL)).toHaveCount(0); + const conn = Bc.locator('.topbar').getByRole('status').filter({ hasText: 'Connected' }); + const held = Bc.getByTestId('topbar-held'); + await expect(conn).toBeVisible(); + await expect(held).toHaveCount(0); + const other = winnerOf(bouts.m6) === 'shiro' ? 'aka' : 'shiro'; + const before = await filledSlots(Bc, other).count(); + await Bc.context().setOffline(true); + try { + await ipponButton(Bc, other, 'K').tap(); + await expect(filledSlots(Bc, other)).toHaveCount(before + 1); + await expect(held).toBeVisible(); + await expect(ed.getByText(SYNC_PILL)).toHaveCount(0); + } finally { + await Bc.context().setOffline(false); + } + await expect(held).toHaveCount(0, { timeout: 20_000 }); + await expect(conn).toBeVisible(); + // The overlay editor, on the head table, holds the same match. + await H.goto(`/admin/competition/${ids.SI}/scores`); + await H.locator('.score-edit-row.score-edit-row--running').filter({ hasText: bouts.m6.shiro }).getByRole('button', { name: /^Score$/ }).tap(); + await expect(H.locator(EDITOR)).toBeVisible(); + await expect(H.locator(EDITOR).getByText(SYNC_PILL)).toHaveCount(0); + await H.locator(EDITOR).getByRole('button', { name: /✕ Close/ }).tap(); + }); + // S6a / P5: a RUNNING match moved onto a shiaijo where another bout runs. test('S6a: a running match moved onto the busy shared court (P5)', async () => { test.setTimeout(300_000); @@ -701,4 +846,60 @@ test.describe('shared-shiaijo', () => { await hed.getByRole('button', { name: /✕ Close/ }).tap().catch(() => {}); } }); + + // bc-mvrq (operator ruling 2026-10-12: "warn about the move; the match then + // goes into the other shiaijo's queue as its NEXT match (it does not keep + // running there)"). S6a's shape again: ST's semi-final running on A, SI's + // M6 running on B, and the head table moves M6 onto A from the Tournament + // schedule page. Fixed: a confirm names the match and the court first; once + // confirmed M6 stops running, keeps its score (the Send back to queue rule) + // and is next in A's queue, and only ST's bout runs on A. The other doors + // (the schedule page's drag, the Scores row picker, the Bracket tab's panel + // picker) are pinned by the fix's own tests. Last in the file: S6a's send + // back leaves either M6 or ST's semi-final running on A, so this first puts + // the courts back into S6a's starting shape through the UI (the fix changes + // S6a as well, which records the move as it is today). + test.fixme('bc-mvrq: moving a running match to another shiaijo asks first, then queues it next there', async () => { + test.setTimeout(300_000); + if (!/\/admin\/shiaijo\/A$/.test(C.url())) await openShiaijo(C, 'A'); + // ST's semi-final runs on A; starting it sends a running M6 back to A's queue. + await pickCompetition(C, ST); + if (((await liveOnConsole()) || {}).comp !== ST) await T.startUpNextTeam(C); + await expect.poll(async () => ((await liveOnConsole()) || {}).comp, { timeout: 15_000 }).toBe(ST); + const stLive = await liveOnConsole(); + // M6, waiting on A, moves to B (a waiting move asks nothing) and starts there. + const m6ScoresRow = () => H.locator('.score-edit-row').filter({ hasText: bouts.m6.shiro }).filter({ hasText: bouts.m6.aka }).first(); + await H.goto(`/admin/competition/${ids.SI}/scores`); + await chooseCourt(m6ScoresRow(), 'B'); + await openShiaijo(Bc, 'B'); + await expect.poll(async () => same(await sides(upNextCard(Bc)).catch(() => null), bouts.m6), { timeout: 15_000 }).toBe(true); + expect(same(await ensureRunning(Bc), bouts.m6)).toBe(true); + const marks = { shiro: await slotMarks(Bc, 'shiro'), aka: await slotMarks(Bc, 'aka') }; + // The move: the running M6, from B's column of the schedule page onto A. + await H.goto('/admin/schedule'); + const m6Row = scheduleColumn(H, 'B').locator('.tw-match.tw-match--running').filter({ hasText: bouts.m6.shiro }).first(); + await expect(m6Row).toBeVisible(); + await m6Row.locator('button[aria-haspopup="listbox"]').tap(); + await H.getByRole('listbox', { name: 'Choose shiaijo' }).getByRole('option', { name: 'A', exact: true }).tap(); + const confirm = H.getByRole('dialog').filter({ hasText: 'Shiaijo A' }); + await settled(confirm); + await expect(confirm).toContainText(bouts.m6.shiro); + await expect(confirm).toContainText(bouts.m6.aka); + await confirm.getByRole('button', { name: /Move/ }).tap(); + await expect(confirm).toBeHidden(); + // One bout runs on A, ST's; none on B; the console shows no second bout. + const onA = await runningOnCourt(H, 'A'); + expect(onA).toHaveLength(1); + expect(onA[0]).toMatchObject({ comp: ST, shiro: stLive.shiro, aka: stLive.aka }); + expect(await runningOnCourt(H, 'B')).toHaveLength(0); + await expect(C.locator('.shiaijo-also-running')).toHaveCount(0); + // M6 is SI's next match on A, with the score it had. + await pickCompetition(C, SI); + await expect.poll(async () => same(await sides(upNextCard(C)).catch(() => null), bouts.m6), { timeout: 15_000 }).toBe(true); + await H.goto(`/admin/competition/${ids.SI}/scores`); + await m6ScoresRow().getByRole('button', { name: /^Score$/ }).tap(); + await expect(H.locator(EDITOR)).toBeVisible(); + expect({ shiro: await slotMarks(H, 'shiro', EDITOR), aka: await slotMarks(H, 'aka', EDITOR) }).toEqual(marks); + await H.locator(EDITOR).getByRole('button', { name: /✕ Close/ }).tap(); + }); }); From 7a5607b935dc77ce15c1db2a1c9db8f6d04b1b40 Mon Sep 17 00:00:00 2001 From: Ricardo Oliveira Date: Mon, 12 Oct 2026 11:17:28 +0100 Subject: [PATCH 17/30] test(bc-e2eh): J1 lets a mark removal land before the next strike A fresh-clone run lost J1's reset tap: removing Aka's M and tapping M again while the removal's write was out put back the value the server still showed, so the board read clean and the removal's SSE echo wiped the new M. J1's reset steps now wait for Synced first; they reset the bout and are not the place to test that race. Co-Authored-By: Claude Opus 5.5 --- .../e2e/journeys/knockout-mixed-individual.spec.mjs | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/scripts/e2e/journeys/knockout-mixed-individual.spec.mjs b/scripts/e2e/journeys/knockout-mixed-individual.spec.mjs index b10d9afb1..56b94d669 100644 --- a/scripts/e2e/journeys/knockout-mixed-individual.spec.mjs +++ b/scripts/e2e/journeys/knockout-mixed-individual.spec.mjs @@ -124,6 +124,7 @@ test.describe('knockout-mixed-individual', () => { await wrongSlot.tap(); await expect(filledSlots(page, w1)).toHaveCount(0); row({ step: 'award ippon', action: 'clear a wrong mark (tap the mark)', variant: 'recovery', taps: 1, slotSize, hintVisible }); + await settle(page); // the removal lands before the next strike (see the reset below) const dblIppon = await doubleTap(ipponButton(page, w1, 'M')); await expect(syncState(page)).toHaveText('Synced'); @@ -140,7 +141,10 @@ test.describe('knockout-mixed-individual', () => { marksBefore: afterDbl, marksAfter: afterDblClear, shot: await shot(page, 'mark-double-tapped') }); // Put the bout back to one men for the winner. while ((await filledSlots(page, w1).count()) > 1) await filledSlots(page, w1).first().tap(); - if ((await filledSlots(page, w1).count()) === 0) await ipponButton(page, w1, 'M').tap(); + if ((await filledSlots(page, w1).count()) === 0) { + await settle(page); // the same reset race as below: let a removal land first + await ipponButton(page, w1, 'M').tap(); + } await expect(filledSlots(page, w1)).toHaveCount(1); await settle(page); @@ -162,6 +166,11 @@ test.describe('knockout-mixed-individual', () => { await page.waitForTimeout(150); } } + // Let the removals land before the M: a tap that puts back the mark the + // server still shows, while the removal's write is out, is lost when that + // write's echo arrives (found by a fresh-clone run, 2026-10-12). This step + // only resets the bout; it is not the place to test that. + await settle(page); await ipponButton(page, w1, 'M').tap(); await expect(filledSlots(page, w1)).toHaveCount(1); await expect(filledSlots(page, other)).toHaveCount(0); From e644527ba1d8aa0c25f072db555c50acb286cd69 Mon Sep 17 00:00:00 2001 From: Ricardo Oliveira Date: Mon, 12 Oct 2026 12:00:41 +0100 Subject: [PATCH 18/30] fix(bc-ltap): a mark tapped back while its removal is saving is kept On the individual score editor, taking a mark off and tapping it back while the removal's autosave was out returned the board to the value the server still showed. The editor judged unsaved work by comparing the board with the server, so it read the board as untouched and adopted the removal's SSE echo over the newer tap; the tap's own save named no change. The reverse (add, then remove quickly) brought the mark back. The editor now records when the operator last edited the board (points, fouls, overtime, draw) in the server's clock frame and never adopts a server copy stamped before it, the rule the team sheet's rows already follow (rowTakenFromServer, bc-kclr). Adopting the server's copy clears the stamp. Representative-player picks are not board edits and keep their own adopt. Render test pins both orders and that a later change made elsewhere is still followed (fails without the fix); the e2e test holds the save 150 ms and checks the mark on the board and after a reload (fails 2 of 2 without the fix). One changed-groups fixture now stamps its server copies, as the server does. Co-Authored-By: Claude Opus 5.5 --- .../knockout-mixed-individual.spec.mjs | 64 ++++++++- ...hanged_groups_every_editor.render.test.jsx | 8 +- ...idual_editor_tap_back_kept.render.test.jsx | 133 ++++++++++++++++++ web-mobile/js/admin_scoring_individual.jsx | 31 +++- 4 files changed, 224 insertions(+), 12 deletions(-) create mode 100644 web-mobile/js/__tests__/render/individual_editor_tap_back_kept.render.test.jsx diff --git a/scripts/e2e/journeys/knockout-mixed-individual.spec.mjs b/scripts/e2e/journeys/knockout-mixed-individual.spec.mjs index 56b94d669..08802434a 100644 --- a/scripts/e2e/journeys/knockout-mixed-individual.spec.mjs +++ b/scripts/e2e/journeys/knockout-mixed-individual.spec.mjs @@ -166,10 +166,9 @@ test.describe('knockout-mixed-individual', () => { await page.waitForTimeout(150); } } - // Let the removals land before the M: a tap that puts back the mark the - // server still shows, while the removal's write is out, is lost when that - // write's echo arrives (found by a fresh-clone run, 2026-10-12). This step - // only resets the bout; it is not the place to test that. + // Let the removals land before the M. A fresh-clone run lost this tap to + // the removal's echo (bc-ltap, fixed in the same PR and guarded by its own + // test below); this step only resets the bout, so it does not race. await settle(page); await ipponButton(page, w1, 'M').tap(); await expect(filledSlots(page, w1)).toHaveCount(1); @@ -1160,4 +1159,61 @@ test.describe('knockout-mixed-individual', () => { await context.setOffline(false); } }); + + // bc-ltap (found by a fresh-clone run, 2026-10-12; operator: "This should be + // two events"): a mark taken off and tapped back while the removal's save is + // still out stays on the board and is sent, and so does the reverse. On + // localhost the removal's echo arrives within milliseconds of the save going + // out, so the save is held 150 ms, as a slow uplink would hold it. + test('bc-ltap: a mark tapped back while its removal is saving stays, and is saved', async ({ page }) => { + const shot = journeyShots('knockout-mixed-individual/bc-ltap'); + await login(page); + await seedOwn(page, { name: 'Finding A9', format: 'knockout', courts: ['O'], numberPrefix: 'FI' }, rosterOf('Anine', 4)); + await openShiaijo(page, 'O'); + expect(await ensureRunning(page), 'a semi-final starts on O').not.toBeNull(); + let holdNext = false; + await page.route('**/api/competitions/*/matches/*/score', async (route) => { + if (route.request().method() === 'PUT' && holdNext) { + holdNext = false; + await new Promise((r) => setTimeout(r, 150)); + } + await route.continue(); + }); + const scoreSent = () => page.waitForRequest((r) => r.method() === 'PUT' && /\/score$/.test(r.url())); + const akaMark = () => inlineEditor(page).locator('[aria-label^="Aka slot "][aria-label*=": remove "]').first(); + const marksAfterReload = async () => { + await page.reload(); + await expect.poll(() => inlineRunning(page), { timeout: 15_000 }).toBe(true); + return filledSlots(page, 'aka').count(); + }; + + await ipponButton(page, 'aka', 'M').tap(); + await settle(page); + // Remove, then the same mark back while the removal is out. + holdNext = true; + let sent = scoreSent(); + await akaMark().tap(); + await sent; + await ipponButton(page, 'aka', 'M').tap(); + await settle(page); + await page.waitForTimeout(1000); + await expect(filledSlots(page, 'aka'), 'the tapped-back mark stays').toHaveCount(1); + await shot(page, 'tapped-back-mark-kept'); + expect(await marksAfterReload(), 'and the server holds it').toBe(1); + await shot(page, 'tapped-back-mark-after-reload'); + + // The reverse: take it off, add it, and take it off again while the add is out. + await akaMark().tap(); + await settle(page); + holdNext = true; + sent = scoreSent(); + await ipponButton(page, 'aka', 'M').tap(); + await sent; + await akaMark().tap(); + await settle(page); + await page.waitForTimeout(1000); + await expect(filledSlots(page, 'aka'), 'the removal stays').toHaveCount(0); + expect(await marksAfterReload(), 'and the server holds no mark').toBe(0); + await page.unroute('**/api/competitions/*/matches/*/score'); + }); }); diff --git a/web-mobile/js/__tests__/render/changed_groups_every_editor.render.test.jsx b/web-mobile/js/__tests__/render/changed_groups_every_editor.render.test.jsx index 4c6ad0883..f4dc3ba67 100644 --- a/web-mobile/js/__tests__/render/changed_groups_every_editor.render.test.jsx +++ b/web-mobile/js/__tests__/render/changed_groups_every_editor.render.test.jsx @@ -445,9 +445,11 @@ describe('an adopted value is agreed, never re-sent', () => { const { rerender } = await mount(individual()); await pointerTap(ipponBtn('aka', 'M')); await settle(); - // Its own write comes back, then another device scores Shiro's point. - await act(async () => { rerender(editorFor(individual({ ipponsA: ['M'] }))); }); - await act(async () => { rerender(editorFor(individual({ ipponsA: ['M'], ipponsB: ['K'] }))); }); + // Its own write comes back, then another device scores Shiro's point; both + // copies are stamped after the tap, as the server's are (bc-ltap: a copy + // stamped before the operator's last edit never replaces the board). + await act(async () => { rerender(editorFor(individual({ ipponsA: ['M'], modifiedAt: Date.now() }))); }); + await act(async () => { rerender(editorFor(individual({ ipponsA: ['M'], ipponsB: ['K'], modifiedAt: Date.now() + 1 }))); }); expect(slotWith('Shiro', 'K'), 'the point recorded elsewhere is shown').toBeTruthy(); await startOvertime(); await settle(); diff --git a/web-mobile/js/__tests__/render/individual_editor_tap_back_kept.render.test.jsx b/web-mobile/js/__tests__/render/individual_editor_tap_back_kept.render.test.jsx new file mode 100644 index 000000000..c5e01c737 --- /dev/null +++ b/web-mobile/js/__tests__/render/individual_editor_tap_back_kept.render.test.jsx @@ -0,0 +1,133 @@ +// bc-ltap: on the individual editor, taking a mark off and tapping it back +// while the removal is still saving returned the board to the value the +// server still showed, so the editor read it as untouched, and the removal's +// echo then wiped the tap; the tap's own save named no change. The board now +// keeps every server copy stamped before the operator's last edit, the rule the +// team sheet's rows follow (bc-kclr). + +import React from 'react'; +import { render, act, fireEvent } from '@testing-library/react'; +import { describe, it, expect, vi, beforeAll, afterAll, beforeEach, afterEach } from 'vitest'; +import { installWindowStubs } from '../helpers/stub_globals.js'; + +const STUBBED_GLOBALS = { + isHikiwake: () => false, + arraysEqual: (a, b) => a.length === b.length && a.every((v, i) => v === b[i]), + isKikenDecision: () => false, + isTextEntry: () => false, + isInteractiveTarget: () => false, + confirmDialog: vi.fn().mockResolvedValue(true), + API: { + fetchCompetitionDetails: vi.fn(), + recordScore: vi.fn().mockResolvedValue(undefined), + recordDaihyosen: vi.fn(), + removeDaihyosen: vi.fn(), + putMatchLineup: vi.fn(), + recordDecision: vi.fn(), + }, + AdminLineupHelpers: { rosterFor: vi.fn().mockReturnValue([]) }, + compMatches: () => [], + Term: ({ children }) => {children}, + GlossaryHint: ({ name }) => , +}; + +let restoreGlobals; +let ScoreEditorModal; + +beforeAll(async () => { + restoreGlobals = installWindowStubs(STUBBED_GLOBALS); + await import('../../admin_scoring_modal.jsx'); + ScoreEditorModal = window.ScoreEditorModal; +}); + +afterAll(() => restoreGlobals()); + +const NOW = 1_800_000_000_000; +beforeEach(() => { vi.useFakeTimers(); vi.setSystemTime(NOW); }); +afterEach(() => { vi.useRealTimers(); }); + +// A running individual pool bout. No compId: the competition fetch stays out. +const bout = (overrides = {}) => ({ + id: 'm1', status: 'running', phase: 'pool', poolName: 'Pool 1', court: 'A', + sideA: { id: 'p1', name: 'Yamada' }, sideB: { id: 'p2', name: 'Tanaka' }, + ipponsA: [], ipponsB: [], modifiedAt: NOW - 5000, + ...overrides, +}); + +const el = (match, onSubmit) => ( + +); + +const marks = (c) => [...c.querySelectorAll('[aria-label*=" slot "]')] + .map((s) => s.getAttribute('aria-label')) + .filter((l) => /: remove /.test(l)); +const menButton = (side) => [...side.querySelectorAll('.ipt-btn')].find((b) => b.textContent.trim() === 'M'); +const tap = async (node) => { await act(async () => { fireEvent.click(node); }); }; +const wait = async (ms) => { await act(async () => { vi.advanceTimersByTime(ms); }); }; + +describe('a tap made while the previous save is out is never wiped by that save\'s echo (bc-ltap)', () => { + it('remove a mark, tap it back before the removal\'s echo: the mark stays and is saved', async () => { + const onSubmit = vi.fn().mockResolvedValue(undefined); + const stored = bout({ ipponsA: ['M'] }); + let r; + await act(async () => { r = render(el(stored, onSubmit)); }); + const [label] = marks(r.container); + expect(label, 'precondition: one M on the board').toMatch(/ slot \d: remove M$/); + const side = r.container.querySelector(label.startsWith('Aka') ? '.sb-side--aka' : '.sb-side--shiro'); + + await tap(r.container.querySelector(`[aria-label="${label}"]`)); + await wait(350); // the removal's autosave goes out + const removal = onSubmit.mock.calls.at(-1)?.[0]; + expect(removal?.ipponsA, 'precondition: the removal was saved').toEqual([]); + + vi.setSystemTime(NOW + 400); + await tap(menButton(side)); // the M again, while the removal is out + expect(marks(r.container), 'the tap shows').toHaveLength(1); + + // The removal's echo: the server now holds no mark, stamped at the removal. + await act(async () => { r.rerender(el(bout({ ipponsA: [], modifiedAt: NOW + 50 }), onSubmit)); }); + expect(marks(r.container), 'the echo of the earlier removal does not wipe the newer tap').toHaveLength(1); + + await wait(350); // the tap's own autosave + const save = onSubmit.mock.calls.at(-1)?.[0]; + expect(save?.ipponsA, 'the tap is sent').toEqual(['M']); + expect(save?.changed, 'and named as a change').toContain('points'); + }); + + it('add a mark, take it off before the add\'s echo: the board stays empty', async () => { + const onSubmit = vi.fn().mockResolvedValue(undefined); + let r; + await act(async () => { r = render(el(bout(), onSubmit)); }); + const aka = r.container.querySelector('.sb-side--aka'); + await tap(menButton(aka)); + await wait(350); + const [label] = marks(r.container); + vi.setSystemTime(NOW + 600); + await tap(r.container.querySelector(`[aria-label="${label}"]`)); + expect(marks(r.container)).toHaveLength(0); + + const added = onSubmit.mock.calls.at(-1)[0]; + await act(async () => { + r.rerender(el(bout({ ipponsA: added.ipponsA, ipponsB: added.ipponsB, modifiedAt: NOW + 50 }), onSubmit)); + }); + expect(marks(r.container), 'the echo of the earlier add does not bring the mark back').toHaveLength(0); + }); + + it('a change made elsewhere after the last tap is still followed', async () => { + const onSubmit = vi.fn().mockResolvedValue(undefined); + let r; + await act(async () => { r = render(el(bout(), onSubmit)); }); + await tap(menButton(r.container.querySelector('.sb-side--aka'))); + await wait(350); + const added = onSubmit.mock.calls.at(-1)[0]; + // The server copy carries this tap, then another device's K for the other + // side, stamped after the tap. + const later = bout({ ipponsA: added.ipponsA, ipponsB: added.ipponsB, modifiedAt: NOW + 50 }); + await act(async () => { r.rerender(el(later, onSubmit)); }); + const otherKey = (added.ipponsA || []).length ? 'ipponsB' : 'ipponsA'; + await act(async () => { + r.rerender(el({ ...later, [otherKey]: ['K'], modifiedAt: NOW + 5000 }, onSubmit)); + }); + expect(marks(r.container).some((l) => l.endsWith('remove K')), 'the later change shows').toBe(true); + }); +}); diff --git a/web-mobile/js/admin_scoring_individual.jsx b/web-mobile/js/admin_scoring_individual.jsx index ac4d7acc0..6473d3ad9 100644 --- a/web-mobile/js/admin_scoring_individual.jsx +++ b/web-mobile/js/admin_scoring_individual.jsx @@ -17,6 +17,7 @@ import { sameCompetitor } from './competitor_identity.jsx'; // is import-only so it can be reached directly (see its header). import { notLandedBanner, terminalFailureBanner, notSavedText, writeWasRefused, writeRetryable, decisionWord } from './write_result.jsx'; import { useArmedConfirm, useOpenedTapGuard, acceptTap, clearTap } from './tap_guard.jsx'; +import { serverNowMs } from './server_clock.jsx'; import { MAX_IPPONS_PER_SIDE, @@ -272,7 +273,7 @@ export function ScoreEditorModal({ match, onClose, onSubmit, onSubmitAndNext, on // put its stale name back. That is the case this closes. // // A pick the operator made stands until the server holds it. Each pick is - // saved as it is made (markScoringDirty in the pickers), so the save of one + // saved as it is made (markRunningWrite in the pickers), so the save of one // pick coming back must not undo a second pick made before it arrived. One // adopt per side, so a pick still being saved on one side does not stop the // other side following the server. An empty pick never counts as unsaved: @@ -306,13 +307,27 @@ export function ScoreEditorModal({ match, onClose, onSubmit, onSubmitAndNext, on const _autosaveSeenStampRef = useRefA(0); // bc-mrgc: what a write applied only in part kept in the match's history. const keptInHistory = useKeptInHistoryNote(); - const { markDirty: markScoringDirty, cancelDebounce: cancelScoringDebounce } = useDebouncedRunningWrite({ + const { markDirty: markRunningWrite, cancelDebounce: cancelScoringDebounce } = useDebouncedRunningWrite({ isRunningRef: _autosaveIsRunningRef, buildPatchRef: _autosaveBuildPatchRef, onSubmitRef: _autosaveOnSubmitRef, onWriteResult: keptInHistory.noteFromWrite, seenStampRef: _autosaveSeenStampRef, }); + // When the operator last edited the board here (the points, the fouls, the + // overtime count, the draw: what applyServerScore re-seeds), in the server's + // clock frame; undefined once the board holds the server's copy again. A + // server copy stamped before it cannot know that edit, so the adopt below + // keeps the board (adoptServerScore), the rule the team sheet's rows follow + // (rowTakenFromServer, bc-kclr). Comparing values alone is not enough: taking + // a mark off and tapping it back while the removal is still saving returns the + // board to the value the server still shows, which reads as untouched, and the + // removal's echo then wiped the tap (bc-ltap). + const boardEditedAtRef = useRefA(undefined); + const markScoringDirty = () => { + boardEditedAtRef.current = serverNowMs(); + markRunningWrite(); + }; useEffectA(() => { if (!m.compId) return; @@ -663,6 +678,7 @@ export function ScoreEditorModal({ match, onClose, onSubmit, onSubmitAndNext, on initialEnchoPeriods, initialIsDrawToggled, ]); const applyServerScore = () => { + boardEditedAtRef.current = undefined; setAPts(initialAPts); setBPts(initialBPts); setAFouls(initialAFouls); @@ -848,9 +864,14 @@ export function ScoreEditorModal({ match, onClose, onSubmit, onSubmitAndNext, on // isDirty: it reads the value from the render BEFORE the server change (see // useAdoptFromServer). It self-corrects: a re-seed makes the next render's // isDirty false again. + const adoptServerScore = () => { + const editedAt = boardEditedAtRef.current; + if (editedAt !== undefined && (m.modifiedAt || 0) < editedAt) return; + applyServerScore(); + }; useAdoptFromServer({ signature: serverScoreSig, - apply: applyServerScore, + apply: adoptServerScore, keepLocalEdits: true, isDirty, }); @@ -1071,7 +1092,7 @@ export function ScoreEditorModal({ match, onClose, onSubmit, onSubmitAndNext, on className="input" value={repPlayerB} disabled={submitting} - onChange={(e) => { setRepPlayerB(e.target.value); markScoringDirty(); }} + onChange={(e) => { setRepPlayerB(e.target.value); markRunningWrite(); }} style={{ padding: "6px 8px", fontSize: 14 }} > @@ -1085,7 +1106,7 @@ export function ScoreEditorModal({ match, onClose, onSubmit, onSubmitAndNext, on className="input" value={repPlayerA} disabled={submitting} - onChange={(e) => { setRepPlayerA(e.target.value); markScoringDirty(); }} + onChange={(e) => { setRepPlayerA(e.target.value); markRunningWrite(); }} style={{ padding: "6px 8px", fontSize: 14 }} > From 34e3a75c0002429bac2210c8a201cb210b777533 Mon Sep 17 00:00:00 2001 From: Ricardo Oliveira Date: Mon, 12 Oct 2026 18:20:32 +0100 Subject: [PATCH 19/30] test(bc-e2eh): make the journeys' assertions able to fail Code review of PR #442 found checks that passed without testing anything: - bc-tmfn tapped Finish twice inside the bounce window with errors swallowed; it now asserts the unscored-bout refusal on each tap. - bc-htcr and J2 checked only that Ht existed or the text changed; they now check Ht is in the corrected side's cell. - The kachinuki viewer check matched a team name shown before the final; it now waits for the final's winner mark. - J8 and J2 never asserted that the court moves on after a fusenpai recorded on the court console; they do now. - J1's Scores-tab Next could pass with no navigation; it now needs two distinct editors, all on court B. - moved-match, S4a and the shared-court "nothing started" checks read after a fixed sleep or compared empty with empty; they now wait for the event, score b2 first, and poll the whole window. - The shared-court double tap asserts the taps were a real double tap. - enterAdmin and enterTournament no longer take the sign-in branch when the first paint is slower than 5 s. - The individual spec's bead guards are no longer serial with the journeys, so a failing journey skips none of them. Docs review: the README no longer says clumsy outcomes are never asserted, lists the prerequisites and what "green" counts, and names both second-device kinds; the allowlist reason names a barred competitor. Co-Authored-By: Claude Opus 5.5 --- scripts/e2e/README.md | 27 ++-- scripts/e2e/fixtures/shared-court.mjs | 7 +- scripts/e2e/fixtures/team.mjs | 7 +- scripts/e2e/fixtures/test.mjs | 2 +- .../knockout-mixed-individual.spec.mjs | 131 +++++++++++++----- .../knockout-mixed-kachinuki.spec.mjs | 20 ++- .../e2e/journeys/knockout-mixed-team.spec.mjs | 17 ++- scripts/e2e/journeys/moved-match.spec.mjs | 5 +- scripts/e2e/journeys/shared-shiaijo.spec.mjs | 17 ++- 9 files changed, 176 insertions(+), 57 deletions(-) diff --git a/scripts/e2e/README.md b/scripts/e2e/README.md index 246b606ac..9f3d8b75f 100644 --- a/scripts/e2e/README.md +++ b/scripts/e2e/README.md @@ -15,6 +15,13 @@ once per worktree), then runs `playwright test` here with `E2E_ARGS` passed through unchanged. It is not part of `make go/test` and does not run in CI. Run it before opening a PR that changes `web-mobile/`: the PR template asks. +It needs Go (for the build), Node.js with npm, and a network connection on the +first run: `make e2e/deps` (or the first `make e2e`) downloads the npm packages +and Chromium, and the build fetches the vendored frontend libraries. After that +it runs offline. "Green" means no test failed: the `test.fixme` steps of open +beads (refer to [When a journey finds a bug](#when-a-journey-finds-a-bug)) are +reported as skipped. + Playwright is pinned to the same release as `scripts/screenshots`, so the two share one Chromium under `~/.cache/ms-playwright`. If that cache is removed the stamp outlives it; run `npx playwright install chromium` here. @@ -65,9 +72,11 @@ Two projects, both Chromium: - `public`: a phone in portrait (390x844), for journeys about a public surface alone (`*.public.spec.mjs`). -An operator journey that checks a public surface on the side (the viewer -showing a result the operator just entered) opens a second context with -`PUBLIC_DEVICE` from `fixtures/devices.mjs`. +An operator journey that needs a second device opens another context with one +of the two devices from `fixtures/devices.mjs`: `PUBLIC_DEVICE` for a public +surface on the side (the viewer showing a result the operator just entered), +`OPERATOR_DEVICE` for a second operator (the head table, or the console of +another court). ## One server per spec file @@ -120,9 +129,11 @@ variants as real input: | `hastyConfirm(page)` | answers the open confirm dialog with its most prominent button and returns its label | | `hastyShiaijoConfirm(page)` | the same for the shiaijo page's own confirms (Send back to queue, Move court) | -Each returns what happened. A journey records that as an audit row; whether the -outcome is acceptable is judged by a person from the screenshots, never -asserted. `hidden` needs a note: headless Chromium never changes page +Each returns what happened, and a journey records that as an audit row. Where +the docs or an operator ruling say what must happen (a double tap on an ippon +button lands one mark, an interruption leaves the operator on the same court), the journey +also asserts it, so a regression fails the suite; the rest is judged by a person +from the screenshots. `hidden` needs a note: headless Chromium never changes page visibility on its own, so the helper overrides `document.hidden` and `document.visibilityState` and dispatches a real `visibilitychange` for each edge, which is what the app's listeners read. @@ -166,8 +177,8 @@ such; what an `afterAll` hook's log is checked when the worker stops. The allowlist holds only noise a journey's scenario makes: requests the browser could not send while a journey has the device offline, and the 409s of a start or reopen the app sends through the server's gate and shows refused -(the court or the competitor busy, or the knockout match it feeds being -fought). +(the court busy, a competitor fighting elsewhere or barred by a withdrawal, or +the knockout match it feeds being fought). Each entry matches as narrowly as it can (the message, the URL, the refusal code, offline only) and says why in one line. An error that looks like the app's own fault does not go on it: the test stays red until a person decides. diff --git a/scripts/e2e/fixtures/shared-court.mjs b/scripts/e2e/fixtures/shared-court.mjs index 414b7efef..22b162c6a 100644 --- a/scripts/e2e/fixtures/shared-court.mjs +++ b/scripts/e2e/fixtures/shared-court.mjs @@ -44,9 +44,12 @@ export const pageMark = (page) => page.evaluate(() => window.__e2eMark || null); // Sign in, creating the tournament first when this file's server has none. export async function enterTournament(page, { name = 'Shared Court Cup', courts = 2 } = {}) { await page.goto('/'); + // The first-run form, or the public home of a tournament that exists: + // whichever the first paint shows, however long it takes. const welcome = page.getByRole('heading', { name: 'Welcome to Bracket Creator' }); - const first = await welcome.waitFor({ state: 'visible', timeout: 5000 }).then(() => true, () => false); - if (first) { + const home = page.locator('.viewer__body'); + await welcome.or(home).first().waitFor({ state: 'visible' }); + if (await welcome.isVisible()) { await createTournament(page, { name, courts }); return { created: true }; } diff --git a/scripts/e2e/fixtures/team.mjs b/scripts/e2e/fixtures/team.mjs index 2ff7b34fa..2aef96de9 100644 --- a/scripts/e2e/fixtures/team.mjs +++ b/scripts/e2e/fixtures/team.mjs @@ -31,9 +31,12 @@ export const positionLabels = (teamSize) => (teamSize === 5 ? FIK5 : Array.from( // test in a serial file can call it, whichever runs first. export async function enterAdmin(page, { courts = 16 } = {}) { await page.goto('/'); + // The first-run form, or the public home of a tournament that exists: + // whichever the first paint shows, however long it takes. const welcome = page.getByRole('heading', { name: 'Welcome to Bracket Creator' }); - const first = await welcome.waitFor({ state: 'visible', timeout: 5000 }).then(() => true, () => false); - if (first) { + const home = page.locator('.viewer__body'); + await welcome.or(home).first().waitFor({ state: 'visible' }); + if (await welcome.isVisible()) { await createTournament(page, { name: 'Team Cup', courts }); return; } diff --git a/scripts/e2e/fixtures/test.mjs b/scripts/e2e/fixtures/test.mjs index 4443c1709..339d826f3 100644 --- a/scripts/e2e/fixtures/test.mjs +++ b/scripts/e2e/fixtures/test.mjs @@ -43,7 +43,7 @@ const CONSOLE_ALLOWLIST = [ whileOffline: true, }, { - reason: 'A start or reopen the app sends through the server\'s gate and shows refused (toast, row notice, court-busy panel, reopen error): the shiaijo is running another bout, the competitor is fighting elsewhere, or the knockout match it feeds is being fought.', + reason: 'A start or reopen the app sends through the server\'s gate and shows refused (toast, row notice, court-busy panel, reopen error): the shiaijo is running another bout, a competitor is fighting elsewhere or barred by a withdrawal, or the knockout match it feeds is being fought.', text: /^Failed to load resource: the server responded with a status of 409 \(Conflict\)$/, url: /\/api\/competitions\/[^/]+\/matches\/[^/]+\/(score|reopen)$/, code: /^(court_busy|ineligible_competitor|downstream_knockout_running)$/, diff --git a/scripts/e2e/journeys/knockout-mixed-individual.spec.mjs b/scripts/e2e/journeys/knockout-mixed-individual.spec.mjs index 08802434a..c75a09a97 100644 --- a/scripts/e2e/journeys/knockout-mixed-individual.spec.mjs +++ b/scripts/e2e/journeys/knockout-mixed-individual.spec.mjs @@ -1,7 +1,8 @@ // Operator review journeys for INDIVIDUAL competitions (mp-yqxn.2, reviewer A). // -// Every fixture is built through the interface in beforeAll (first-run form, -// wizard, roster paste box, Generate draw, Start), on this file's own server: +// Every fixture is built through the interface (first-run form, wizard, +// roster paste box, Generate draw, Start), on this file's own server, in the +// journeys' beforeAll: // F1 pools + knockout, 12 entrants, shiaijo A and B (J1) // F1b pools + knockout, 6 entrants, shiaijo E (J2 decisions) // F2 knockout only, joint 3rd UNTICKED (bronze), shiaijo C (J2 hantei, J6) @@ -12,9 +13,15 @@ // per action x variant (written to the journey folder's audit.json, // fixtures/audit.mjs), and asserts only what the docs promise. Judgement // (friction, legibility) is formed from the screenshots in the same folder -// (fixtures/shots.mjs), never asserted here. The tests at the end -// are functional defects the journeys found, now fixed: each seeds its own -// competition, runs alone, and guards its fix. +// (fixtures/shots.mjs), never asserted here. The journeys run in order and +// share their fixtures, so a failing journey skips the ones after it. +// +// The tests at the end are functional defects the journeys found, +// now fixed: each seeds its own competition on its own shiaijo and guards its +// fix. They sit in a describe of their own that is NOT serial, so a failing +// journey skips none of them: Playwright then runs them in a fresh worker, +// which gets a fresh server (fixtures/test.mjs serverForFile), and the +// file-level beforeAll below creates the tournament again there. import { test, expect, isCoarse } from '../fixtures/test.mjs'; import { OPERATOR_DEVICE } from '../fixtures/devices.mjs'; import { journeyShots } from '../fixtures/shots.mjs'; @@ -39,11 +46,23 @@ import { decisionButton, decisionPrompt, recordDecision, queuedPoolBouts, correctCompleted, } from '../fixtures/individual.mjs'; -test.describe.configure({ mode: 'serial' }); - const comps = {}; const audit = auditLog('knockout-mixed-individual'); +// The tournament, once per worker. A worker runs this file on one server; a +// worker started after a failure gets a fresh, empty one, so this runs again +// there (see the header). +test.beforeAll(async ({ browser, serverForFile }, testInfo) => { + const server = await serverForFile(testInfo.file); + const ctx = await browser.newContext({ ...OPERATOR_DEVICE, baseURL: server.base }); + await createTournament(await ctx.newPage(), { name: 'Individual Review Cup', courts: 15 }); + await ctx.close(); +}); + +// File level, so the bead guards' rows (the console policy's warnings) are +// written as well. +test.afterAll(() => audit.flush()); + // Let the running bout's autosave land. The pill reads "Syncing…" from the tap // until the write has landed (bc-sync: the tap's handler registers the edit // with API.notePendingEdit, and the pill has re-rendered by the time tap() @@ -52,13 +71,27 @@ async function settle(page, root = INLINE_EDITOR) { await expect(syncState(page, root)).toHaveText('Synced'); } +// Which side's cell of a score string carries the Ht mark: 'shiro', 'aka' or +// null. A score string reads [Shiro cell] [middle] [Aka cell], the middle one +// of vs, X, (E), (DH), and Ht rides beside the winner, never in the middle +// ("M Ht (E) K" is Shiro's hantei, "M (E) K Ht" Aka's). +function htSide(score) { + const m = /^(.*?)(?:^|\s)(vs|X|\(E\)|\(DH\))(?:\s|$)(.*)$/.exec(score.trim()); + if (!m) return null; + if (/\bHt\b/.test(m[1])) return 'shiro'; + if (/\bHt\b/.test(m[3])) return 'aka'; + return null; +} + test.describe('knockout-mixed-individual', () => { + test.describe.configure({ mode: 'serial' }); + test.beforeAll(async ({ browser, serverForFile }, testInfo) => { test.setTimeout(240_000); const server = await serverForFile(testInfo.file); const ctx = await browser.newContext({ ...OPERATOR_DEVICE, baseURL: server.base }); const page = await ctx.newPage(); - await createTournament(page, { name: 'Individual Review Cup', courts: 15 }); + await login(page); // the file-level beforeAll created the tournament, in its own context const seed = async (key, opts, roster) => { comps[key] = await createCompetition(page, opts); await pasteRoster(page, comps[key], roster); @@ -73,8 +106,6 @@ test.describe('knockout-mixed-individual', () => { await ctx.close(); }); - test.afterAll(() => audit.flush()); - // J1. Individual pools -> knockout on /admin/shiaijo/A (F1). Every bout is // chained with Finish + Start Next, which must never leave shiaijo A, and // the clumsy variants are performed on each kind of action on the way. @@ -388,10 +419,17 @@ test.describe('knockout-mixed-individual', () => { const next = page.locator(EDITOR).locator('.score-nav__next'); const prev = page.locator(EDITOR).locator('.score-nav__prev'); row({ step: 'scores tab nav', action: 'Prev / Next', variant: 'size', next: await tapSize(next).catch(() => null), prev: await tapSize(prev).catch(() => null) }); + // Court B holds more matches than the first, so Next is offered (it is + // rendered only while there is a next match). Each tap must move the + // editor to another match before the reading is taken. + await expect(next).toBeVisible(); for (let i = 0; i < 3 && await next.isVisible().catch(() => false); i += 1) { + const was = seen[seen.length - 1].key; await next.tap(); + await expect.poll(async () => (await editorIdentity(page, EDITOR)).key, { message: 'Next moves the editor' }).not.toBe(was); seen.push(await editorIdentity(page, EDITOR)); } + expect(new Set(seen.map((s) => s.key)).size, 'Next showed at least two matches').toBeGreaterThanOrEqual(2); for (const key of ['ArrowLeft', 'ArrowRight', 'ArrowLeft']) { await page.keyboard.press(key); await page.waitForTimeout(200); @@ -604,15 +642,32 @@ test.describe('knockout-mixed-individual', () => { const fusRow = completedRowFor(page, b2); await expect(fusRow).toBeVisible(); const fusResult = (await fusRow.locator('.shiaijo-qrow__result').innerText()).trim(); - const advanced = await expect.poll(() => inlineRunning(page), { timeout: 8000 }).toBe(true).then(() => true, () => false); - const id3 = advanced ? await editorIdentity(page) : null; + // Recording it starts the court's next bout in the same competition, and + // skips every bout with a barred side. Two of the bouts left on E have + // one: the real withdrawer's (b1.aka) remaining pool A bout and the + // no-show's (b2.aka) other pool B bout. That leaves exactly one to start, + // pool B's bout between the side that was present and the third member. + const barred = [b1.aka, b2.aka]; + let advancedTo = null; + const advance = await expect.poll(async () => { + if (!(await inlineRunning(page))) return 'idle'; + const now = await editorSides(page); + if (now.shiro === b2.shiro && now.aka === b2.aka) return 'same'; + advancedTo = now; + return 'moved'; + }, { timeout: 8000 }).toBe('moved').then(() => null, (e) => e); + const id3 = advancedTo ? await editorIdentity(page) : null; row({ step: 'fusenpai', action: 'Record (fusenpai, Aka did not show)', variant: 'V2 doubleTap', ...dblRecord, from: id2.eyebrow, result: fusResult, dialogAfterDoubleTap: dialogAfterDbl, dialog: dblDialog, advancedTo: id3 && id3.eyebrow, court: id3 && id3.court, shot: await shot(page, 'fusenpai-recorded') }); // Docs: the winner shows two circles and Fus. sits beside the no-show. expect(fusResult).toMatch(/○/); expect(fusResult).toMatch(/Fus\./); - if (advanced) expect(id3.court).toBe('E'); + if (advance) throw advance; + expect(id3.court).toBe('E'); + expect(id3.eyebrow).toMatch(/POOL B/); + expect(barred, 'the bout started has no barred side').not.toContain(advancedTo.shiro); + expect(barred).not.toContain(advancedTo.aka); // Knockout (F2, court C): a tied 1-1 semi-final, encho, hantei. await openShiaijo(page, 'C'); @@ -710,8 +765,8 @@ test.describe('knockout-mixed-individual', () => { row({ step: 'hantei', action: 'Finish (two taps) on the neighbour\'s pick', variant: 'V4 hasty (did not read the pick)', picked, committed, result: wrongResult }); // The recorded result reads as the docs describe: the winner's points, - // Ht beside the winner, (E) in the centre. - expect(wrongResult).toMatch(/Ht/); + // Ht beside the winner (the side the neighbour tap picked), (E) in the centre. + expect(htSide(wrongResult)).toBe(picked.aka === 'true' ? 'aka' : 'shiro'); expect(wrongResult).toMatch(/\(E\)/); // Recovery: Correct the semi-final and give hantei to Shiro. Finish + @@ -728,15 +783,17 @@ test.describe('knockout-mixed-individual', () => { const reasonPrompt = inlineEditor(page).locator('.reason-prompt'); const reasonAsked = await reasonPrompt.waitFor({ state: 'visible', timeout: 4000 }).then(() => true, () => false); if (reasonAsked) await reasonPrompt.locator('button.btn--primary').tap(); - await expect.poll(async () => (await semiRow.locator('.shiaijo-qrow__result').innerText()).trim(), { timeout: 10_000 }) - .not.toBe(wrongResult); + // The verdict moved to Shiro: Ht now sits in Shiro's cell, still with (E). + await expect.poll(async () => htSide((await semiRow.locator('.shiaijo-qrow__result').innerText()).trim()), { timeout: 10_000 }) + .toBe('shiro'); // The refusal's own toast may still be up; only a toast the save raised counts. const toast = (await page.locator('.toast').allInnerTexts().catch(() => [])) .filter((t) => t !== htOpen.refusal?.toast).join(' | '); const fixed = (await semiRow.locator('.shiaijo-qrow__result').innerText()).trim(); row({ step: 'hantei', action: 'Correct -> SHIRO wins -> Save correction', variant: 'recovery', reasonAsked, toast, result: fixed, shot: await shot(page, 'hantei-corrected') }); - expect(fixed).toMatch(/Ht/); + expect(htSide(fixed)).toBe('shiro'); + expect(fixed).toMatch(/\(E\)/); }); // J6. Confirm + advance OFFLINE (F2, court C): the second semi-final is // finished with the network down. The write is queued, the local bracket @@ -997,26 +1054,33 @@ test.describe('knockout-mixed-individual', () => { bronzeMatchShown: /3RD PLACE MATCH/i.test(bracketText), shot: await shot(page, 'bracket-tab-semis-done', { fullPage: true }) }); expect(bracketText).not.toMatch(/3RD PLACE MATCH/i); - // Fusenpai auto-advance on a court where nothing is stuck (court F, F1c): - // the J2 attempt ran into a withdrawn competitor's unstartable bout. + // Fusenpai auto-advance on the court console of a court where nothing is + // barred yet (court F, F1c): recording it starts the court's next bout in + // the same competition, on the same court. await openShiaijo(page, 'F'); const fb = await ensureRunning(page); expect(fb, 'court F has a bout to start').not.toBeNull(); await recordDecision(page, 'fusenpai', { side: 'aka' }); await expect(completedRowFor(page, fb)).toBeVisible(); - const advanced = await expect.poll(async () => { + const advance = await expect.poll(async () => { if (!(await inlineRunning(page))) return 'idle'; const now = await editorSides(page); return now.shiro === fb.shiro && now.aka === fb.aka ? 'same' : 'moved'; - }, { timeout: 10_000 }).toBe('moved').then(() => true, () => false); + }, { timeout: 10_000 }).toBe('moved').then(() => null, (e) => e); + const idF = advance ? null : await editorIdentity(page); row({ step: 'fusenpai (court F)', action: 'Record fusenpai on the console', variant: 'correct path (auto-advance)', - from: fb, advanced, court: advanced ? (await editorIdentity(page)).court : null, - shot: await shot(page, 'fusenpai-auto-advance-F') }); - if (advanced) expect((await editorIdentity(page)).court).toBe('F'); + from: fb, advanced: !advance, court: idF && idF.court, shot: await shot(page, 'fusenpai-auto-advance-F') }); + if (advance) throw advance; + expect(idF.court).toBe('F'); + expect(idF.eyebrow).toMatch(/^CADETS INDIVIDUAL/); }); - // Functional defects found by the journeys above, each now fixed and named - // by its bead. Each seeds its own competition on its own shiaijo, so it runs - // alone, and guards its fix. +}); + +// Functional defects found by the journeys above, each now fixed and named by +// its bead. Each seeds its own competition on its own shiaijo and reads +// nothing the journeys left, so it runs alone and guards its fix. Not serial: +// a failure here, or in a journey, skips none of the others (see the header). +test.describe('knockout-mixed-individual bead guards', () => { const seedOwn = async (page, opts, roster) => { const id = await createCompetition(page, { kind: 'individual', ...opts }); await pasteRoster(page, id, roster); @@ -1041,7 +1105,7 @@ test.describe('knockout-mixed-individual', () => { await expect(page.locator(EDITOR)).toBeVisible(); const s = await editorSides(page, EDITOR); await recordDecision(page, 'fusenpai', { side: s.shiro === first.aka ? 'shiro' : 'aka', root: EDITOR }); - await expect(page.locator(EDITOR).getByText('already_ineligible')).toHaveCount(0); + // The fusenpai landed: the row reads Fus. (a refusal would leave it unrecorded). await expect(page.locator('.score-edit-row').filter({ hasText: first.aka }).filter({ hasText: /Fus\./ })).toHaveCount(1); }); @@ -1070,7 +1134,8 @@ test.describe('knockout-mixed-individual', () => { const done = completedRowFor(page, semi); await expect(done).toBeVisible(); await expect(done.locator('.shiaijo-qrow__result')).toContainText('Ht'); - const before = (await done.locator('.shiaijo-qrow__result').innerText()).trim(); + // Recorded for Aka: Ht in Aka's cell ("M vs K Ht"). + expect(htSide((await done.locator('.shiaijo-qrow__result').innerText()).trim())).toBe('aka'); // Finish + Start Next started the other semi-final: send it back, then correct (bc-crpn). await correctCompleted(page, semi); const arm = inlineEditor(page).getByTestId('scoring-modal-hantei-arm'); @@ -1080,9 +1145,9 @@ test.describe('knockout-mixed-individual', () => { // Docs: a correction asks for a short reason, then applies. const reason = inlineEditor(page).locator('.reason-prompt'); if (await reason.isVisible().catch(() => false)) await reason.locator('button.btn--primary').tap(); - await page.waitForTimeout(2000); - await expect(page.getByText(/correctionReason/)).toHaveCount(0); - await expect.poll(async () => (await done.locator('.shiaijo-qrow__result').innerText()).trim(), { timeout: 10_000 }).not.toBe(before); + // The verdict moved to Shiro: Ht now in Shiro's cell ("M Ht vs K"). + await expect.poll(async () => htSide((await done.locator('.shiaijo-qrow__result').innerText()).trim()), { timeout: 10_000 }) + .toBe('shiro'); }); test('bc-plcl: the Pools-tab score editor stays open through Start match and an ippon', async ({ page }) => { diff --git a/scripts/e2e/journeys/knockout-mixed-kachinuki.spec.mjs b/scripts/e2e/journeys/knockout-mixed-kachinuki.spec.mjs index f8dd62a93..eb9d6bac8 100644 --- a/scripts/e2e/journeys/knockout-mixed-kachinuki.spec.mjs +++ b/scripts/e2e/journeys/knockout-mixed-kachinuki.spec.mjs @@ -39,7 +39,7 @@ import { createCompetition } from '../fixtures/wizard.mjs'; import { generateDraw, pasteRoster, startCompetition } from '../fixtures/competition.mjs'; import { completedRowFor, openShiaijo, sides, upNextCard } from '../fixtures/shiaijo.mjs'; import { PUBLIC_DEVICE } from '../fixtures/devices.mjs'; -import { openViewer } from '../fixtures/public.mjs'; +import { openViewer, recentResult } from '../fixtures/public.mjs'; import { scoreRows } from '../fixtures/scores.mjs'; import { EDITOR } from '../../screenshots/lib/editor.mjs'; import { dwell, settled } from '../fixtures/pace.mjs'; @@ -217,7 +217,11 @@ test.describe('J5 kachinuki from the court console', () => { while ((await boutFilled(currentBout(page), 'shiro').count()) > 1) await boutFilled(currentBout(page), 'shiro').last().tap(); if ((await boutFilled(currentBout(page), 'shiro').count()) === 0) await awardBoutIppon(page, 'shiro', 'M'); await awardBoutIppon(page, 'shiro', 'K'); - // V3: the iPad reloads the tab before Record bout was tapped. + // V3: the iPad reloads the tab before Record bout was tapped. The wait + // only gives the autosave its moment and is not asserted: whether the + // marks survive the reload is the variant under audit (marksKept), and a + // lost mark fails the asserts after Record bout below (expectPairing, + // doneRowWinner). await expect(syncPill(page)).toHaveText('Synced').catch(() => {}); const reload = await interruptC(page, 'reload'); await expect(editor(page)).toBeVisible(); @@ -1261,14 +1265,22 @@ test.describe('J5 kachinuki from the court console', () => { expect((await boutNames(currentBout(page))).shiro).toBe(S[0]); await endMatch(page); await expect(completedRowFor(page, finalPair)).toBeVisible(); + // The console shows the result before the server has answered. + await completedOnServer(page, finalPair, 'A'); await shot(page, 'final-completed', { fullPage: true }); - // A spectator's phone: the finished knockout on the public viewer. + // A spectator's phone: the finished knockout on the public viewer. The + // final is in its Recent results, a result only a completed match gets, + // with Shiro's team marked as the winner (viewer_match.jsx, the side's + // `--w` class beside the "Winner:" text a screen reader hears). const spectator = await browser.newContext({ ...PUBLIC_DEVICE, baseURL }); try { const phone = await spectator.newPage(); await openViewer(phone, compId); - await expect(phone.locator('.viewer__body')).toContainText(finalPair.shiro); + const finalResult = recentResult(phone, finalPair); + await expect(finalResult).toContainText('Final'); + await expect(finalResult.locator('.vsched-item__side--w')).toHaveCount(1); + await expect(finalResult.locator('.vsched-item__side--shiro.vsched-item__side--w')).toContainText(finalPair.shiro); await shot(phone, 'viewer-knockout-complete', { fullPage: true }); } finally { await spectator.close(); diff --git a/scripts/e2e/journeys/knockout-mixed-team.spec.mjs b/scripts/e2e/journeys/knockout-mixed-team.spec.mjs index f277ecc46..66b64b64d 100644 --- a/scripts/e2e/journeys/knockout-mixed-team.spec.mjs +++ b/scripts/e2e/journeys/knockout-mixed-team.spec.mjs @@ -1222,11 +1222,20 @@ test.describe('knockout-mixed-team', () => { const pair = await sides(ed); await T.boutIppon(ed, 1, 'shiro', 'M'); await T.ensureTie(ed, 2); - // Bout 3 is never scored: Finish must not commit the encounter. - await page.waitForTimeout(1200); + // Bout 3 is never scored: Finish must not commit the encounter. The + // refusal comes on the first tap, before any arming, and says why. const finish = T.finishBtn(ed); - await finish.tap().catch(() => {}); - await finish.tap().catch(() => {}); + const refusal = ed.getByTestId('team-finish-unfinished-bouts'); + const unfinished = 'Bout 3 has no result. Record a score, a Tie, or a Fusensho before finishing.'; + await settled(finish); + await finish.tap(); + await expect(refusal).toHaveText(unfinished); + await expect(finish).toHaveText(/^Finish( \+ Start Next →)?$/); + // A second tap, past the bounce window of the first, is refused the same way. + await settled(finish); + await finish.tap(); + await expect(refusal).toHaveText(unfinished); + await expect(finish).toHaveText(/^Finish( \+ Start Next →)?$/); await page.goto(`/admin/competition/${id}/pools`); // The encounter is still unfinished, so no team has played a match yet. await expect(page.locator('tr').filter({ hasText: pair.shiro }).first()).toContainText(/0\s*0\s*0\s*0\s*0\s*0\s*0\s*0\s*$/); diff --git a/scripts/e2e/journeys/moved-match.spec.mjs b/scripts/e2e/journeys/moved-match.spec.mjs index da5a2e8fe..7e644535f 100644 --- a/scripts/e2e/journeys/moved-match.spec.mjs +++ b/scripts/e2e/journeys/moved-match.spec.mjs @@ -220,8 +220,11 @@ test.describe('moved-match', () => { await expect(T.boutRow(hed, 1)).toBeVisible(); for (const n of [2, 3]) await T.boutIppon(hed, n, 'shiro', 'M'); const headFinishLabel = (await T.finishBtn(hed).innerText()).trim(); + const completedOnB = (await courtState(B)).completed; await T.finishTeam(hed); - await H.waitForTimeout(1500); + // Court B has seen the finish (its Completed list holds it) before it is + // asked whether it stayed on Teams: read earlier, it would prove nothing. + await expect.poll(async () => (await courtState(B)).completed, { timeout: 15_000 }).toBeGreaterThan(completedOnB); const bHeld = await courtState(B); row({ step: '6 finish elsewhere', action: 'head table finishes the Teams semi-final', variant: 'two devices', headFinishLabel, courtB: bHeld, shot: await shot(B, 'court-B-after-head-table-finish') }); diff --git a/scripts/e2e/journeys/shared-shiaijo.spec.mjs b/scripts/e2e/journeys/shared-shiaijo.spec.mjs index 38092cefe..aedd005e9 100644 --- a/scripts/e2e/journeys/shared-shiaijo.spec.mjs +++ b/scripts/e2e/journeys/shared-shiaijo.spec.mjs @@ -334,6 +334,7 @@ test.describe('shared-shiaijo', () => { const armedAfter = await armedFinishButton(C).isVisible().catch(() => false); r({ step: 'S2 advance', action: finLabel, variant: 'V2 doubleTap', ...dbl, committedByDouble, stillArmed: armedAfter, shot: await shot(C, 's2-finish-double-tapped') }); + expect(dbl.withinWindow, `harness too slow: the two taps were ${dbl.gapMs} ms apart, not a double tap, so this row did not test the bounce guard`).toBe(true); expect(finLabel).toBe('Finish + Start Next →'); expect(committedByDouble).toBe(false); expect(armedAfter).toBe(true); @@ -348,8 +349,12 @@ test.describe('shared-shiaijo', () => { expect(same(bouts.b2, pairOf(siA[1]))).toBe(true); // S4a: Correct the first bout while SI's next bout runs: refused (bc-crpn), - // the live bout sent back, the correction opened, then Back to court. + // the live bout sent back, the correction opened, then Back to court. The + // live bout has a point first, so the send-back is seen to keep a score. + await ipponButton(C, winnerOf(bouts.b2), 'M').tap(); + await settleOn(C); const marksB2 = await slotMarks(C, winnerOf(bouts.b2)); + expect(marksB2).toEqual(['M']); const corr = await correctCompleted(C, bouts.b1); r({ step: 'S4a correct', action: "Correct an SI bout while SI's next bout runs", variant: 'correct path (refused, bc-crpn)', liveBout: corr.live, ...corr.refusal, shot: await shot(C, 's4a-correction-open') }); @@ -542,7 +547,15 @@ test.describe('shared-shiaijo', () => { // start in SI here, so nothing starts: ST's waiting semi-final stays queued. const fus2 = await recordDecision(C, 'fusenpai', { side: 'aka' }); await expect(completedRowFor(C, bouts.b5)).toBeVisible(); - await C.waitForTimeout(3000); + // Nothing starts across the whole window, not only at its end: a start + // landing late would pass a single read. The editor may still hold b5, + // just closed, for a moment. + const quietUntil = Date.now() + 3000; + while (Date.now() < quietUntil) { + const l = await liveOnConsole(); + expect(l === null || same(l, bouts.b5), `a start landed after the fusenpai: ${JSON.stringify(l)}`).toBe(true); + await C.waitForTimeout(250); + } const after = await courtState(C); const onAnone = await runningOnCourt(H, 'A'); r({ step: 'S2b advance', action: "Fusenpai on SI's last startable bout on A", variant: 'correct path (nothing to start)', ...fus2, From 2021f3cc593e3c92569471974c1a333cc7d88b2b Mon Sep 17 00:00:00 2001 From: Ricardo Oliveira Date: Mon, 12 Oct 2026 19:04:57 +0100 Subject: [PATCH 20/30] fix(bc-sdtp): a double tap on Start match starts one match On the court console the first start can land inside the 400 ms bounce window of its tap. The Up next card then shows the next match with its Start match enabled, and the bounce started it and sent the match just started back to the queue (found by PR #442's shared-shiaijo journey in a --workers=1 run: SF1 started, then SF1 sent back and SF2 started). Every Start match on the console (the Up next card and the queue rows) now shares one keyless bounce guard through tap_guard.jsx's acceptTap, as Call to court does (bc-cdbl). A tap after the window and a keyboard activation still start. The render test fails without the fix. Co-Authored-By: Claude Opus 5.5 --- .../start_match_double_tap.render.test.jsx | 153 ++++++++++++++++++ web-mobile/js/admin_shiaijo.jsx | 19 ++- 2 files changed, 169 insertions(+), 3 deletions(-) create mode 100644 web-mobile/js/__tests__/render/start_match_double_tap.render.test.jsx diff --git a/web-mobile/js/__tests__/render/start_match_double_tap.render.test.jsx b/web-mobile/js/__tests__/render/start_match_double_tap.render.test.jsx new file mode 100644 index 000000000..b38b34b09 --- /dev/null +++ b/web-mobile/js/__tests__/render/start_match_double_tap.render.test.jsx @@ -0,0 +1,153 @@ +// bc-sdtp: a double tap on the court console's Start match starts ONE match. +// +// The first start can land inside the bounce window of its tap; the Up next +// card then shows the next match, whose Start match is enabled, and the bounce +// used to start it, sending the match just started back to the queue. Every +// Start match on the console now shares one keyless bounce guard (acceptTap, +// tap_guard.jsx), so the bounce lands on nothing, whichever Start it hits. A +// deliberate tap after the window, and a keyboard activation, still start. +// +// Pointer taps pass detail: 1 (helpers/tap_events.js), or the guard exempts +// them and these tests could never go red. + +import React from 'react'; +import { render, act, screen, cleanup } from '@testing-library/react'; +import { describe, it, expect, vi, beforeAll, afterAll, beforeEach, afterEach } from 'vitest'; +import { installWindowStubs } from '../helpers/stub_globals.js'; +import { TAP_BOUNCE_MS } from '../../tap_guard.jsx'; +import { pointerTap, keyboardClick } from '../helpers/tap_events.js'; + +const STUBBED_GLOBALS = { + AdminTopbar: ({ children }) =>
{children}
, + Breadcrumbs: () => null, + ScoreEditorModal: (props) =>
, + CourtPicker: () => null, + BracketTree: () => null, + Icon: ({ name }) => {name}, + filterMatchesByCourt: (matches) => matches, + tournamentMatches: () => [], + API: { + fetchCompetitionDetails: vi.fn().mockResolvedValue(null), + sendAnnouncement: vi.fn(), + deleteAnnouncement: vi.fn(), + updateMatchTime: vi.fn(), + revertMatchToQueue: vi.fn(), + recordDecision: vi.fn().mockResolvedValue({ applied: true }), + }, + confirmDialog: vi.fn().mockResolvedValue(true), + PoolsViewer: () => null, + compMatches: () => [], +}; + +let restoreGlobals; +let AdminShiaijoPage; + +beforeAll(async () => { + restoreGlobals = installWindowStubs(STUBBED_GLOBALS); + await import('../../admin_shiaijo.jsx'); + AdminShiaijoPage = window.AdminShiaijoPage; +}); + +afterAll(() => restoreGlobals()); + +beforeEach(() => { + window.API.revertMatchToQueue = vi.fn(async () => ({ applied: true })); + vi.useFakeTimers(); +}); + +afterEach(() => { + cleanup(); + window.tournamentMatches = STUBBED_GLOBALS.tournamentMatches; + window.filterMatchesByCourt = STUBBED_GLOBALS.filterMatchesByCourt; + vi.useRealTimers(); +}); + +const wait = (ms) => act(async () => { vi.advanceTimersByTime(ms); }); + +const base = { compId: 'c1', court: 'A', status: 'scheduled', phase: 'pool', poolName: 'Pool 1' }; +const threeMatches = (firstStatus = 'scheduled') => [ + { ...base, id: 'm1', status: firstStatus, sideA: { id: 'p1', name: 'Yamada' }, sideB: { id: 'p2', name: 'Tanaka' } }, + { ...base, id: 'm2', sideA: { id: 'p3', name: 'Suzuki' }, sideB: { id: 'p4', name: 'Sato' } }, + { ...base, id: 'm3', sideA: { id: 'p5', name: 'Ito' }, sideB: { id: 'p6', name: 'Kato' } }, +]; + +// A new tournament object on every call, so rerender() re-derives the court's +// matches from window.tournamentMatches (the console memoises on it). +function consoleElement(onEditScore) { + return ( + + ); +} + +function showMatches(rerender, onEditScore, matches) { + window.tournamentMatches = () => matches; + rerender(consoleElement(onEditScore)); +} + +// The matches each start write named, in order. +const started = (onEditScore) => onEditScore.mock.calls.filter((c) => c[2] && c[2].status === 'running').map((c) => c[1]); +const upNextStart = () => screen.getAllByRole('button', { name: 'Start match' })[0]; + +// Start m1 from the Up next card and let the start land: the refetch shows m1 +// running, so the Up next card now holds m2 with its Start match enabled. +async function startFirstAndLand() { + window.tournamentMatches = () => threeMatches(); + window.filterMatchesByCourt = (matches) => matches; + const onEditScore = vi.fn().mockResolvedValue({ applied: true, status: 'running' }); + const { rerender } = render(consoleElement(onEditScore)); + await pointerTap(upNextStart()); + await act(async () => {}); + expect(started(onEditScore), 'precondition: the first tap started m1').toEqual(['m1']); + showMatches(rerender, onEditScore, threeMatches('running')); + await act(async () => {}); + return onEditScore; +} + +describe('Start match double tap on the court console (bc-sdtp)', () => { + it('the bounce of the first tap does not start the match the card shows next', async () => { + const onEditScore = await startFirstAndLand(); + await wait(30); + await pointerTap(upNextStart()); + await act(async () => {}); + expect(started(onEditScore), 'm2 is not started by the bounce').toEqual(['m1']); + expect(window.API.revertMatchToQueue, 'and m1 is not sent back to the queue').not.toHaveBeenCalled(); + }); + + it('a bounce landing on a queue row\'s Start match is ignored too', async () => { + const onEditScore = await startFirstAndLand(); + await wait(30); + // The queue lists m3 beside the Up next card's m2: its row has a Start match. + const rowStart = screen.getAllByRole('button', { name: 'Start match' }).at(-1); + await pointerTap(rowStart); + await act(async () => {}); + expect(started(onEditScore)).toEqual(['m1']); + expect(window.API.revertMatchToQueue).not.toHaveBeenCalled(); + }); + + it('a deliberate tap after the bounce window starts the next match', async () => { + const onEditScore = await startFirstAndLand(); + await wait(TAP_BOUNCE_MS + 50); + await pointerTap(upNextStart()); + await act(async () => {}); + expect(started(onEditScore)).toEqual(['m1', 'm2']); + }); + + it('a keyboard activation (detail 0) is never read as a bounce', async () => { + const onEditScore = await startFirstAndLand(); + await keyboardClick(upNextStart()); + await act(async () => {}); + expect(started(onEditScore)).toEqual(['m1', 'm2']); + }); +}); diff --git a/web-mobile/js/admin_shiaijo.jsx b/web-mobile/js/admin_shiaijo.jsx index 23ae43f26..e3150d706 100644 --- a/web-mobile/js/admin_shiaijo.jsx +++ b/web-mobile/js/admin_shiaijo.jsx @@ -780,6 +780,12 @@ function AdminShiaijoPage({ tournament, court: routeCourt, onBack, onEditScore, // set). startMatch is handed its own pick, which is exempt from the guard. const pickingRef = useRefSh(null); const callTapRef = useRefSh(null); + // bc-sdtp: one bounce guard for every Start match on this console (Up next + // and the queue rows), keyless, because the bounce lands on a DIFFERENT + // button: the first start can land inside the bounce window, the card then + // shows the next match, and a per-match key would let the bounce start it + // and send the match just started back to the queue. + const startTapRef = useRefSh(null); // The newest court call this console made per match, keyed by matchKey, as // { id, msg }: the announcement id (a promise of it, null if the send failed) // and the text sent, so the call can be withdrawn once its match leaves @@ -1507,6 +1513,13 @@ function AdminShiaijoPage({ tournament, court: routeCourt, onBack, onEditScore, } }; + // A Start match tap: the bounce of the previous Start tap is ignored + // (startTapRef, bc-sdtp), anything else goes to pickMatch. + const tapStart = (m, ev) => { + if (!acceptTap(startTapRef, ev)) return; + pickMatch(m); + }; + // The defer of the running bout and the start of the picked one, after // pickMatch's guards and its pickingRef entry (see pickMatch). const deferAndStart = async (m, pick) => { @@ -1949,7 +1962,7 @@ function AdminShiaijoPage({ tournament, court: routeCourt, onBack, onEditScore, and a running bout is sent back to the queue first, keeping its score (bc-sbq). With neither, it simply starts this one. */} - {isTeamMatch(upNext) && ( @@ -1994,7 +2007,7 @@ function AdminShiaijoPage({ tournament, court: routeCourt, onBack, onEditScore, label="Upcoming" subGroup matches={upcomingQueueMatches} courts={courts} onMoveCourt={requestMoveCourt} onMove={moveMatch} onEnterLineup={(m) => setLineupKey(matchKey(m))} - onPick={pickMatch} noticeFor={(m) => startNoticeFor(matchKey(m))} + onPick={tapStart} noticeFor={(m) => startNoticeFor(matchKey(m))} onCall={callToCourt} callingKey={callingKey} calledKey={calledKey} startingKey={startingKey} scheduled={filteredScheduled} password={password} @@ -2608,7 +2621,7 @@ export function ShiaijoQueueRow({ m, scheduled, courts, onMoveCourt, onMove, onE queue keeping its score (bc-sbq), then this match starts for scoring. bc-cse: never offered on a barred row -- BarredMatchNotice above owns its one-tap resolution instead, since the server would just refuse a Start. */} - {onPick && !barred && } + {onPick && !barred && }
)} {notice && !isComplete &&
{notice}
} From c64a12017ad31b6b6fa89e0ae8147087899ba0a0 Mon Sep 17 00:00:00 2001 From: Ricardo Oliveira Date: Mon, 12 Oct 2026 21:22:56 +0100 Subject: [PATCH 21/30] fix(bc-xrpl): answer an exact resend of a recorded finish as recorded When the server stored a Finish but the device never processed the answer (a dropped connection, a reload or sleep right after the tap, a reply past the 12 s wait), the queue resent it with the same stamp, as designed. The correction-reason rule read that identical copy as a correction of a finished match and answered 400, so the operator was told a recorded result was rejected (found by J6 in a fresh-clone run). Operator decision, option A: an exact resend of a completed score write (the stored result's own stamp, nothing it names differing from what is stored once read as the write path would land it) is answered as recorded, with the stored match and no correction reason, before any refusal, writing nothing to the match file (engine.ExactScoreReplay, the score counterpart of exactDecisionReplay). A same-stamp write that differs still needs a reason. The client also takes a settled queue entry out of browser storage at once, narrowing the window. CLAUDE.md's correction rule and the queue's replay sentence now agree; openapi and the network architecture page describe the answer. Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 8 +- docs/architecture/network-architecture.md | 2 +- internal/engine/exact_score_replay_test.go | 55 +++++ internal/engine/scoring_tx.go | 110 ++++++++++ internal/mobileapp/handlers_match.go | 12 +- internal/mobileapp/score_exact_replay_test.go | 203 ++++++++++++++++++ specs/openapi.yaml | 13 +- .../__tests__/queue_ordered_writes.test.jsx | 51 +++++ web-mobile/js/api_client.jsx | 22 +- 9 files changed, 461 insertions(+), 15 deletions(-) create mode 100644 internal/engine/exact_score_replay_test.go create mode 100644 internal/mobileapp/score_exact_replay_test.go diff --git a/CLAUDE.md b/CLAUDE.md index c5493b520..eab73b686 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -302,19 +302,19 @@ Operator ruling 2026-10-03: "Nothing should be dropped. All events must be order - **R4** (operator ruling 2026-10-04, replacing the implementer's back-to-running edge: "It needs a winner if the match is finished and is being corrected"): a scoring change that would leave a FINISHED knockout match tied, or an engi match (pool or knockout) with no valid flag count, is NOT applied. `deriveWinnerAfterMerge` reports false and changes nothing, and `mergeHold` (the one place a group is taken back out of a write) holds the scoring groups: the match keeps its recorded finish, winner and advanced winner, never goes back to running, and the change is in the history with `HoldReasonKnockoutNeedsWinner`/`HoldReasonEngiNeedsValidCount`. `MergeReport.NeedsWinner` reaches the answer as `heldReason: "needs_winner"` (`state.HeldReasonNeedsWinner`) beside `heldGroups`, with `NeedsWinnerMessage` on a fully held write, so the operator is told to correct the result with a winner. A pool or league tie becomes a completed draw (`hikiwake`). Nothing on the bracket write retracts a winner for it any more. - **S2, the other arrival order**: a finish whose `result` applies over scoring stored NEWER than it (a point made after the finish on a board that never saw it, arriving first) gets the verdict that scoring gives, through the same `deriveWinnerAfterMerge`, so both arrival orders end in the same state (status, winner, scoreline, stamps); the result is stamped with that newer scoring's stamp when the verdict moved (`verdictMoved`), as R3 stamps it. The finish's own hantei mark is carried onto the newer scoreline first (`carryHantei`, the S3 rule the other way round). A withdrawal is excluded (R2's `withdrawalOutranked` owns it). When the newer scoring would leave the match without the winner it needs (a knockout tie, an engi count that is not valid), stamp order says that scoring came after the finish and could not apply (R4), so the finish is APPLIED on its own scoreline and each newer scoring group is moved out of the match into the history as held (`mergeHold.displaceNewerScoring`, `MergeReport.Displaced`, one entry per stamp with door `displaced` and the R4 reason, written by `recordDisplacedHistory`, also from the engi branch); `ModifiedAt` falls back to the newest stamp left. Both arrival orders then reach the same state (status, winner, scoreline, stamps, next round) and both histories hold the tying change; the answer is applied and carries `displacedGroups` with `heldReason: "needs_winner"`. The same displacement covers a representative pick stamped after a representative-bout removal: when the removal applies, the pick is moved to the history (reason `HoldReasonRepBoutRemoved`, "the representative bout was removed"), and that answer carries `displacedGroups` with NO `heldReason`; the client asks `writeDisplacedForWinner` (write_result.jsx) before saying a displacement left a match without a winner. Only a finish whose payload does not carry the scoring it stood on (it names `result` alone and sends no scoreline) cannot be replayed: it is held (`needs_winner`) and the match stays as stored. Winners worked out again take a side's id from the stored match when the payload omits it (`mergeCtx.storedAID`/`storedBID`), since ids are backfilled only after the merge. - **The history** (internal/state/match_history.go): one append-only file per match, `competitions//history/.jsonl`, one JSON line per write that reached the merge: stamp, server `receivedAt`, door, session, changed groups, per-group outcome (`applied`/`unchanged`/`held`), the held groups' incoming values, any cleared withdrawal, and the `reason` when one rule held the write's groups rather than their stamps (`MergeReport.HoldReason`: `HoldReasonOlderRevision`, `HoldReasonEngiAtomic`, `HoldReasonFinishAtomic`, `HoldReasonKnockoutNeedsWinner`, `HoldReasonEngiNeedsValidCount`; the History view shows it as "Not applied: "). `StoreTx.AppendMatchHistory` stages the whole small file in the SAME transaction as the write (`LoadMatchHistory` reads it, read-your-own-writes), so an aborted transaction leaves no entry. Written at the point the write can no longer be rolled back (`recordWriteHistory`: after K3/requalification in `RecordMatchResultWithIneligibilityTx`, in `writeMatchResult`, and on the superseded paths), so a write refused by validation, a side mismatch or K3 leaves none; every direct writer above appends its own (`recordDirectHistory`) INSIDE the transaction that makes its write: the doors already in one (the reopens, the kachinuki bout remove, the downstream reopen) pass their `tx`, and the kachinuki advance, `OverrideBracketWinner` and `RevertMatchToQueue` each open a `WithTransaction` for the write and the entry together (`withBracketMatch` takes the store handle, as `withPoolMatch` does), so a process dying between the two leaves no match change without its line; a held override writes nothing and appends on its own. `recordDirectHistory` logs a failed append rather than returning it, so a failure of the append alone still lets the write commit without its entry (accepted). The history directory is created only under an existing competition directory (`ensureMatchHistoryDirLocked`), so a write after `DeleteCompetition` fails rather than resurrecting it; `DiscardDraw` deletes it (`DeleteMatchHistory`), since a regenerated draw reuses the match ids. A fully-held write must COMMIT its transaction for its entry to land: every door returns nil from its closure on `ErrMatchSuperseded` and answers after (the score/decision handlers always did; bulk-score, both daihyosen paths and `RecordMatchResult` were changed to). -- **The answers**: a write applied in part answers applied (the merged result) plus `heldGroups` (`scoreResponseWithReopened`, `withHeldGroups`, bulk-score's top-level `heldGroups`); a fully-held one keeps the 200 `{applied:false, reason:"superseded"}` shape the SPA keys on, plus `heldGroups` (`respondSuperseded`, `SupersededMessage`: "kept in the match's history"). Either answer adds `heldReason: "needs_winner"` when R4/S2 held them (`MergeReport.HeldReason`; a fully held one then carries `NeedsWinnerMessage`; bulk-score puts it on the entry), and an applied finish that moved stored newer scoring to the history adds `displacedGroups` (`MergeReport.DisplacedGroups`) with the same `heldReason` (score, daihyosen, bulk-score's `heldGroups[]` items). `ErrMatchSuperseded` is returned as `*SupersededError` carrying the held groups and that code (`errors.Is` still matches; `HeldGroupsOf`/`HeldReasonOf` read them). A completed write stamped OLDER than the stored `result` group over a finished match is not asked for a correction reason (`applyCorrectionReason`, `matchSnapshot.ResultStamp`): a write that completes the match is atomic with everything else it changes (`HoldReasonFinishAtomic`, mirroring `HoldReasonEngiAtomic`), so a stale finish is held WHOLE and kept in the history rather than landing its scoreline beside a verdict it never declared; an equal stamp is an exact replay and still needs one. The request-only fields (`Changed`, `WriteDoor`, `Merge`, `ClearsWithdrawal`) never ride on a stored copy: `MatchResult.ClearRequestFields` is the one list, called on the pool write's overwrite and on every pool-match copy the store hands out. +- **The answers**: a write applied in part answers applied (the merged result) plus `heldGroups` (`scoreResponseWithReopened`, `withHeldGroups`, bulk-score's top-level `heldGroups`); a fully-held one keeps the 200 `{applied:false, reason:"superseded"}` shape the SPA keys on, plus `heldGroups` (`respondSuperseded`, `SupersededMessage`: "kept in the match's history"). Either answer adds `heldReason: "needs_winner"` when R4/S2 held them (`MergeReport.HeldReason`; a fully held one then carries `NeedsWinnerMessage`; bulk-score puts it on the entry), and an applied finish that moved stored newer scoring to the history adds `displacedGroups` (`MergeReport.DisplacedGroups`) with the same `heldReason` (score, daihyosen, bulk-score's `heldGroups[]` items). `ErrMatchSuperseded` is returned as `*SupersededError` carrying the held groups and that code (`errors.Is` still matches; `HeldGroupsOf`/`HeldReasonOf` read them). A completed write stamped OLDER than the stored `result` group over a finished match is not asked for a correction reason (`applyCorrectionReason`, `matchSnapshot.ResultStamp`): a write that completes the match is atomic with everything else it changes (`HoldReasonFinishAtomic`, mirroring `HoldReasonEngiAtomic`), so a stale finish is held WHOLE and kept in the history rather than landing its scoreline beside a verdict it never declared. An equal stamp that changes nothing (every group the write changes, `effectiveChangedGroups`, equal to the stored value once read as the write would land it) is the finish already recorded, sent again because its answer was lost: `engine.ExactScoreReplay` (the score counterpart of `exactDecisionReplay`) lets it past the reason check, and the engine answers it as recorded (`answerExactScoreReplay`, in `RecordMatchResultWithIneligibilityTx` after the older-revision hold and before the engi dispatch, so before every refusal): the stored match, with a bracket match's court and schedule filled in, nothing written, and one history line whose groups all read unchanged. A same-stamp write that differs still needs a reason. The request-only fields (`Changed`, `WriteDoor`, `Merge`, `ClearsWithdrawal`) never ride on a stored copy: `MatchResult.ClearRequestFields` is the one list, called on the pool write's overwrite and on every pool-match copy the store hands out. - **The client (phase 2)**: every score write names its groups. `web-mobile/js/match_groups.jsx` is the ONE client owner of the names (pinned against `state.ScalarGroups`/`BoutGroup` by the shared table `internal/state/testdata/match_groups.json`: `TestMatchGroups_SharedTable` and `match_groups_table.test.jsx`), of the comparison (`changedGroups`, on WIRE shapes only: both sides go through `toBackendMatchResult`, the adopted copy via `matchWire` in api_serializers.jsx, so a placeholder slot, an Ht placement or a winner object never reads as a change; a field the write leaves out, e.g. an omitted correction reason or a silent representative bout's ippons, is not compared, and a bout row the write does not carry is never a change), of the union (`unionChanged`: no list on either side means every group and stays so) and of a group's words (`groupLabel`). `useChangedGroups(m)` (admin_scoring_autosave.jsx) is the one hook the three editors call; every patch goes through its `claim` (the individual and team `buildPatch`, a hantei included since bc-htsd, the engi save and autosave builder). A group counts against TWO baselines: the server value the editor last AGREED with for that group (seeded at mount, moved on at each build where local equals the prop, and by `claim.agree(group)` when an editor's adopt TAKES the server's value for that group, or when the team editor's withdrawal-removal Undo puts the whole board back to the server's (every bout group and both pick groups); an adopt that takes only PART of a group agrees it only when the editor's whole group then equals the server's (the individual editor's hantei adopt skips `points` while a point of its own is unsaved; a representative-side adopt agrees `rep` only when both sides then hold the server's picks); both baselines are kept per group), NOT the latest prop, or an edit held while another device changed a group the editor kept its own value for would put the old value back; and the last write the editor built, or a tap taken back before the first write's echo would name nothing. A reopen is a write the server makes, not a build, so when the match the editor renders leaves `completed` (a reopen, or a send back to the queue) the editor takes the result it now holds as agreed, that group alone (`useChangedGroups`): without it, ending a reopened match again with the verdict it had named nothing, and the server kept the match running with no error. It keys on the status, not on `reopenPending`: a reopen without a reason carries that stamp, but one a correction's reason comes with (a downstream reopen, a requalification, a pool-rank override) leaves the match scheduled with its points and no stamp, and an editor that watched only the stamp named no result for it. `startPatch` names `result` alone. The outbox keeps `changed` verbatim (persist, cross-tab, rehydrate, replay); a score write that takes a queued one's place claims the union (`_claimingQueuedGroups` in `_placeWrite`, and the absorb in `recordScore`, both against the one entry `_coalesceTarget` names); `_broadcastPatch` strips it. Answers: `writeHeldGroups`/`writePartlyHeld` (write_result.jsx) for a partial apply, which the editors answer with a quiet note (`useKeptInHistoryNote`/`KeptInHistoryNote`, worded by `heldGroupsNote`) and `editMatchScore` with a toast when the write closes the editor; a superseded write leads "Not applied", never "Not saved" (`SUPERSEDED_LEAD`, `notSavedText`'s `lead`, `supersededAlertText`). The editors' closed-by-default **History** disclosure (`MatchHistoryDisclosure`, match_history_view.jsx, `data-testid="match-history"`, hidden under `selfReport`) reads `GET /api/competitions/:id/matches/:mid/history` (`RegisterMatchHistoryHandler`, stamp order with an unstamped entry at its `receivedAt`, `[]` when none, 404 for an unknown competition or match, main-gated in self-run), readable values through `formatIpponsScore`/`withdrawalLabel`/`sideWord`. - **Phase 3, the remaining drop sites, each with one owner:** - **The running rev guard holds, never drops.** A running write with a lower `rev` than the same `revSession` already sent is answered by `holdOlderRevision` (handlers_match.go): one bare transaction, no court lock, no start gate, no correction check, and the high-water mark left alone. It calls `RecordMatchResultWithIneligibilityTx` with `engine.ForceOptions{HoldReason: engine.HoldReasonOlderRevision}`, whose `holdWriteTx` asks the merge owner on a copy with `mergeCtx.holdAll` (every changed group held, echoes excepted), records the history entry with the reason, and returns the superseded error; the answer is `respondSuperseded` with `heldGroups`. `{stale:true}` no longer exists anywhere (server, openapi, client: `_handleOkBody` and the admin toast that read it are gone). - - **The offline queue keeps, per match, a list of pending writes, each sent on its own** (api_client.jsx). Each pending write is its own entry, keyed `${base}${QUEUE_ENTRY_SEP}${id}` (`_newEntryKey`; the separator is `\u001f`), where the BASE is what the write is about (`_revKey` for a score or a decision, `override:…`, `lineup:…`; `_baseOf`). A key persisted by the previous build has no separator, so it is its own base: an old queue loads and replays unchanged, which is the migration. The ONE coalescing rule is `_coalesceTarget`: a score write takes the place of its match's LAST entry only when that entry is a score write not newer than it and not the page-hide copy of a write still being sent, and a RUNNING write only of a running one (`_placeWrite` then claims the union and carries a pending `kachinukiBoutFinal`); a LINEUP save joins a queued save of the same lineup (`joinQueuedLineupSave`, lineup_save.jsx: the union of `changed`, the later value per position): a lineup PUT carries no stamp the server could order it by, so a direct save could land before the queued one's replay and lose to it on a position both changed; the caller reads `{queued: true}` for the moment until the flush sends it. Nothing else replaces anything, so a running write never replaces a queued Finish or a decision, and a decision and a score are two entries. A pass sends entries in the ONE replay order, `_byEntryOrder` (the payload's `modifiedAt`, else `enqueuedAt`, then the key), but each entry on its own: one that does not settle (offline, 5xx, parked, re-stamped for skew, held by an open fetch) holds back nothing after it, and a new score, decision or override goes straight to the server whatever is queued, because the server merges by stamp, not arrival (bc-mrgc review C2: the old wait-behind rule let one write failing with a 5xx stop every later write for its match). The editors' "Retry now" on a queued Finish therefore sends it at once: landing, it drains the queued copy it claimed; failing, it takes that copy's place. A landed direct score write drains only the entry it claimed (`claimedEntry`), never a queued Finish or decision. The page-hide copy (`_keepInflightRunning`) is an entry of its own (`w.keptKey`); recordScore settles its OWN copy through the record it created (`inflightRecord`), even after a later running write took the map slot, and when it had one and its fetch answered it schedules a flush a microtask later, after the answer has drained its claimed entry. A queued answer says whether the write reached browser storage (`_queuedAnswer`: `persisted: false` only when `_persistQueue` failed, read before the enqueue kicks a flush). A write the server keeps refusing (past `SERVER_REJECTION_NOTICE_THRESHOLD`) can be discarded alone from its match's editor (`HeldWriteDiscard`, admin_scoring_shared.jsx; `API.heldWriteKeepsFailing`/`discardFailingHeldWrites`, that match's failing entries only, after a confirm). `resumeAfterAuth` gives the new password to every parked write AND every queued write still carrying a password the server refused, never to one queued with none (a self-run participant's). Every refusal on replay is reported: a decision's 409 (`already_ineligible` included) goes to the terminal-fail and `rejected` alert channels like any other, because the server answers an EXACT replay of a recorded decision (same decision, side and stamp: a send whose answer was lost) as recorded before any of its refusals (`exactDecisionReplay`, scoring_tx.go). A held finish that lands and moves a later change to the history (`displacedGroups`) counts as sent and raises one `displaced` information alert per pass. Nothing is dropped for its age: `QUEUE_TTL_MS` and the `expired` alert are gone, and an old entry is sent; the server orders it and keeps what loses in the history. Pinned by `queue_ordered_writes.test.jsx` and `sync_queue.test.jsx`. + - **The offline queue keeps, per match, a list of pending writes, each sent on its own** (api_client.jsx). Each pending write is its own entry, keyed `${base}${QUEUE_ENTRY_SEP}${id}` (`_newEntryKey`; the separator is `\u001f`), where the BASE is what the write is about (`_revKey` for a score or a decision, `override:…`, `lineup:…`; `_baseOf`). A key persisted by the previous build has no separator, so it is its own base: an old queue loads and replays unchanged, which is the migration. The ONE coalescing rule is `_coalesceTarget`: a score write takes the place of its match's LAST entry only when that entry is a score write not newer than it and not the page-hide copy of a write still being sent, and a RUNNING write only of a running one (`_placeWrite` then claims the union and carries a pending `kachinukiBoutFinal`); a LINEUP save joins a queued save of the same lineup (`joinQueuedLineupSave`, lineup_save.jsx: the union of `changed`, the later value per position): a lineup PUT carries no stamp the server could order it by, so a direct save could land before the queued one's replay and lose to it on a position both changed; the caller reads `{queued: true}` for the moment until the flush sends it. Nothing else replaces anything, so a running write never replaces a queued Finish or a decision, and a decision and a score are two entries. A pass sends entries in the ONE replay order, `_byEntryOrder` (the payload's `modifiedAt`, else `enqueuedAt`, then the key), but each entry on its own: one that does not settle (offline, 5xx, parked, re-stamped for skew, held by an open fetch) holds back nothing after it, and a new score, decision or override goes straight to the server whatever is queued, because the server merges by stamp, not arrival (bc-mrgc review C2: the old wait-behind rule let one write failing with a 5xx stop every later write for its match). An entry that settles (sent, or refused for good) leaves browser storage at once (`leaveQueue` in `_flushQueue`), not at the end of the pass, so a page that goes away while a later entry is still out does not send it again on the next load. The editors' "Retry now" on a queued Finish therefore sends it at once: landing, it drains the queued copy it claimed; failing, it takes that copy's place. A landed direct score write drains only the entry it claimed (`claimedEntry`), never a queued Finish or decision. The page-hide copy (`_keepInflightRunning`) is an entry of its own (`w.keptKey`); recordScore settles its OWN copy through the record it created (`inflightRecord`), even after a later running write took the map slot, and when it had one and its fetch answered it schedules a flush a microtask later, after the answer has drained its claimed entry. A queued answer says whether the write reached browser storage (`_queuedAnswer`: `persisted: false` only when `_persistQueue` failed, read before the enqueue kicks a flush). A write the server keeps refusing (past `SERVER_REJECTION_NOTICE_THRESHOLD`) can be discarded alone from its match's editor (`HeldWriteDiscard`, admin_scoring_shared.jsx; `API.heldWriteKeepsFailing`/`discardFailingHeldWrites`, that match's failing entries only, after a confirm). `resumeAfterAuth` gives the new password to every parked write AND every queued write still carrying a password the server refused, never to one queued with none (a self-run participant's). Every refusal on replay is reported: a decision's 409 (`already_ineligible` included) goes to the terminal-fail and `rejected` alert channels like any other, because the server answers an EXACT replay of a recorded decision (same decision, side and stamp: a send whose answer was lost) as recorded before any of its refusals (`exactDecisionReplay`, scoring_tx.go). A held finish that lands and moves a later change to the history (`displacedGroups`) counts as sent and raises one `displaced` information alert per pass. Nothing is dropped for its age: `QUEUE_TTL_MS` and the `expired` alert are gone, and an old entry is sent; the server orders it and keeps what loses in the history. Pinned by `queue_ordered_writes.test.jsx` and `sync_queue.test.jsx`. - **Override-winner** stamps and orders the `result` group (phase 1); a held assertion is answered `respondSuperseded(["result"])` (reason `superseded`, `heldGroups`), the client returns `heldGroups`, the Resolve feeders toast says it was kept in the history (`OVERRIDE_HELD_NOTICE`, write_result.jsx), and a queued one the server held counts into the pass's superseded alert as well as asking for a bracket resync. - **The engi finish** is stamped by `recordScore` like every write, and the server orders it: `engiFinishHeld` (engi.go) asks the merge on a copy built from the stored match (`engiFinishProbe`), BEFORE the downstream guard; the flags and the winner they decide are atomic (either held → both held, `HoldReasonEngiAtomic`, reached when the flags are newer than the stored count but the result is older than the stored one, e.g. after a later reopen; a stale finish over a COMPLETED match is held whole one step earlier, by `HoldReasonFinishAtomic`) -- except that the NEWEST COUNT DECIDES (operator ruling 2026-10-04): when a finish's result applies but a newer stored VALID count holds its flags, the finish closes the match with the winner that count gives, the stored count stays, its stamp stamps the result, and the finish's own count is kept in the history as held, so both arrival orders end alike (`TestMerge_EngiNewestValidCountDecidesInBothOrders`); a newer INVALID count is still moved to the history by the displacement above -- and an ECHO of the stored flags (same count, under an older stamp) is not a hold at all, so the finish still applies; held → history and superseded; applied → `recordEngiMatch` writes `result`+`flags` with the stamps the merge decided (`engiFinishHeld` carries the probe's `GroupStamps` down, `stampEngiChanges`/`ApplyMergedGroupStamps`), so both arrival orders reach the same stamps after a displacement; an unstamped writer still gets the server's time. - **Read-modify-writes are one transaction.** `InjectPoolDaihyosenMatches`, `InjectTiebreakerMatches` (the engi self-heal too), `GenerateLeagueTiebreakMatches` and `AdvanceSwissRound`'s append each read pool-matches.csv, decide, and save under ONE `WithTransaction` (standings through `computeStandingsFrom(tx)`, the tournament read before the tx, `appendWithSlots` for the new slots); a score landing meanwhile waits for the lock and goes on top. `MaybeAdvanceKachinuki` still reads without the lock, and its locked write re-checks (`kachinukiAdvanceStillHolds`: not completed, same status, stamp and bout log) and on `errKachinukiAdvanceStale` reads the match again and works the pairing out from it (`advanceKachinukiOnce`, up to `kachinukiAdvanceAttempts` times, standing down only then: a stand-down answered the operator's Record bout with no next pairing; a finish that landed is final on the re-read, so it is never reopened; the bracket branch uses `UpdateBracket` with `Bracket.MatchByID` so it can abort). `Engine.afterMatchRead` is the test-only seam at each read (`lost_update_window_test.go` lands a real write there). - **A rename is not a side mismatch.** `reconcileSides` accepts a payload side whose id equals the stored id whatever name it carries, keeping the stored (current) name on the side, on a winner named by the old name, and on every bout row field that names the side by it (`adoptCurrentSideName`: the representative-bout row, and a fixed-order or id-less kachinuki row whose winner is the team, which `state.SubBoutWinnerSide` would otherwise credit to nobody); only an id disagreement, or a name disagreement no id can settle, is `side_mismatch`. Only an id the CLIENT sent proves a rename: the handler's hantei/winner-id backfill (`backfillMatchIdentityForHantei`) fills a side id only beside the stored name it belongs to, so a payload naming someone else with no ids is still `side_mismatch`. -Accepted as they are: a replayed queued write applied in part tells nobody (the history has it), unless what was held would have left a finished match without a winner (`writeNeedsWinner`), which the replay reports like a superseded one. An exact decision replay adds a second history line that reads like the first ("Decision recorded", every group unchanged). A queued write the server keeps refusing with a 5xx is retried as long as the page is open and holds back no other write; the editors' **Discard held result** covers a terminal score write, and the admin topbar's held-writes count opens `HeldWritesPanel` (admin_shell.jsx), which lists every held write (`API.heldWrites()`) and discards any one that keeps failing (`API.discardHeldWrite(key)`; both doors go through `_discardIfFailing`, so a write only waiting for the connection is never discarded); a same-session reopen over a write the browser could not store shows the plain held notice. An answer that both held groups and moved a later change (`heldGroups` and `displacedGroups` with one `heldReason`) is read as the move's: the held part gets the plain newer-change note. `unsentWrites().total` counts entries, so two pending writes for one match count two; `terminal` still counts results. `InjectTiebreakerMatches` and `InjectPoolDaihyosenMatches` compute standings uncached, under the competition's lock, on each call. A partial apply on the self-run public sheet's finishing write closes the editor with no note. Every write re-stages its match's whole history file through the WAL, so a long match's history costs a rewrite per autosave; compaction is for a later phase. An R3 correction on a knockout match whose next match was already played is refused like any other knockout correction (`downstream_knockout_played`/`_running`). History entries recorded before phase 3 may still list an echo as held. A lineup save made while a save of the same lineup is queued reads `{queued: true}` until the flush sends it, a moment later when the device is online. A write whose transaction log committed but whose file write then failed (`state.ErrTxCommitted`: a disk that refuses writes) is answered 500 like any internal error, so the device keeps retrying until a write lands (operator decision 2026-10-04): the change is already kept, written on restart or by the next write the disk accepts, and `internalError` (errors.go) logs it as an ERROR naming the disk; a Discard of it does not stop it applying at restart. A completed correction whose result is held because a still-scoring board's LATER change (R3) moved the result's stamp first is held WHOLE, by `HoldReasonFinishAtomic`, even though in plain stamp order its own bouts would have applied before that later change: finish atomicity holds the correction together with the scoreline it stood on rather than letting its bouts land piecemeal beside a verdict a newer write already superseded. Nothing is lost (the held write is in the match's history as always), and the operator is told to check it rather than having the correction's bouts silently re-order themselves around a verdict that has since moved. Do not report this ordering as a bug to fix: it is finding 4's atomicity rule doing its job. +Accepted as they are: a replayed queued write applied in part tells nobody (the history has it), unless what was held would have left a finished match without a winner (`writeNeedsWinner`), which the replay reports like a superseded one. An exact decision replay adds a second history line that reads like the first ("Decision recorded", every group unchanged), and an exact finish replay likewise (every group unchanged). A queued write the server keeps refusing with a 5xx is retried as long as the page is open and holds back no other write; the editors' **Discard held result** covers a terminal score write, and the admin topbar's held-writes count opens `HeldWritesPanel` (admin_shell.jsx), which lists every held write (`API.heldWrites()`) and discards any one that keeps failing (`API.discardHeldWrite(key)`; both doors go through `_discardIfFailing`, so a write only waiting for the connection is never discarded); a same-session reopen over a write the browser could not store shows the plain held notice. An answer that both held groups and moved a later change (`heldGroups` and `displacedGroups` with one `heldReason`) is read as the move's: the held part gets the plain newer-change note. `unsentWrites().total` counts entries, so two pending writes for one match count two; `terminal` still counts results. `InjectTiebreakerMatches` and `InjectPoolDaihyosenMatches` compute standings uncached, under the competition's lock, on each call. A partial apply on the self-run public sheet's finishing write closes the editor with no note. Every write re-stages its match's whole history file through the WAL, so a long match's history costs a rewrite per autosave; compaction is for a later phase. An R3 correction on a knockout match whose next match was already played is refused like any other knockout correction (`downstream_knockout_played`/`_running`). History entries recorded before phase 3 may still list an echo as held. A lineup save made while a save of the same lineup is queued reads `{queued: true}` until the flush sends it, a moment later when the device is online. A write whose transaction log committed but whose file write then failed (`state.ErrTxCommitted`: a disk that refuses writes) is answered 500 like any internal error, so the device keeps retrying until a write lands (operator decision 2026-10-04): the change is already kept, written on restart or by the next write the disk accepts, and `internalError` (errors.go) logs it as an ERROR naming the disk; a Discard of it does not stop it applying at restart. A completed correction whose result is held because a still-scoring board's LATER change (R3) moved the result's stamp first is held WHOLE, by `HoldReasonFinishAtomic`, even though in plain stamp order its own bouts would have applied before that later change: finish atomicity holds the correction together with the scoreline it stood on rather than letting its bouts land piecemeal beside a verdict a newer write already superseded. Nothing is lost (the held write is in the match's history as always), and the operator is told to check it rather than having the correction's bouts silently re-order themselves around a verdict that has since moved. Do not report this ordering as a bug to fix: it is finding 4's atomicity rule doing its job. ### Write refusal and the clock frame (bc-lww1 / bc-cse) @@ -356,7 +356,7 @@ The public editor matches: `POST .../bulk-score` and `PUT .../quick-score` write without the participant's checks, so they are main-gated in self-run; bulk-score does judge each entry's representative picks on what the entry lands, in the entry's own transaction (a refused entry stores nothing and reports `reason: team_member_not_in_team`), and quick-score builds its rows server-side. -**A representative-bout add or remove holds the team editor's autosave** (bc-dhas). The add and remove go through `_daihyosenRequest` (api_client.jsx) on `_fetchJson`, which keeps one AbortController armed, on a timer rather than the wall clock, until the body is read. So a request the server never answers, or answers with a body that never completes, fails with the not-added/not-removed sentence instead of holding the write forever. The add and the remove are one sequence, `runRepBoutChange` (admin_scoring_team.jsx). It takes `hold()` before its pre-save. The pre-save is bounded by the same `FETCH_TIMEOUT_MS` (write_result.jsx), past which the change is reported as not answered, nothing is sent and the hold is released. Add and Remove both stay disabled while that save is still out (`submitting`), so no second change starts beside a pre-save given up on. The admin hosts' refetch after a write is bounded too (`refreshCompsBestEffort` asks `fetchCompetitions({ bounded: true })`), and one given up on at that deadline says nothing: `_fetchJson` rejects past its deadline with the one `timedOut` error however the abort surfaced, and `refreshFailureToast` (admin.jsx) returns no toast for it, because the hosts refresh after every autosave and on a slow link the toast would follow every few taps while the write reaches the page by push anyway. Any other refresh failure keeps the reload hint. `recordScore`, `recordDecision`, `overrideBracketWinner` and the queue's replay (`_flushQueue`, single-flight, so one stalled replay stopped every later flush) read their answers through `_fetchJson` too, so a body that stalls past the deadline is a network failure: the write comes back `{queued: true}` (the editor stays open, `writeKeepsEditorOpen`) and is replayed with the stamp it was sent with, safe if the server did store it because the write guard is last-write-wins and an equal stamp re-applies. The add saves the sheet first unless the match has finished, so a queued match starts through the score path's gates; the remove saves first only a running sheet with an edit owed. A debounce that fires while held defers its write, and the mounted editor calls `release()` from an effect on `daihyosenBusy`'s true-to-false edge, after the render that adopted the result. The adopt keeps the answer in the override, `{ match, at }`: a stamped answer the prop does not hold yet (`holdsAnswer`) becomes the match the sheet shows, with the answer's bout log, status and team-match overtime (the prop's winner and decision stay, since the client reshapes those), and it goes once the prop's bout log changes or its stamp reaches `at`; a change of the prop's STATUS re-judges it too (an effect on `match.status`, which releases it when the prop holds the same bout log and is not older than the answer), so a finish or reopen made elsewhere is not hidden behind it. `hold()` returns `settle(transform)`, called on every exit (`withServerDaihyosenRow` over the answer's row once landed, or over the last prop's when that prop already holds the answer; bare in `finally`): an edit owed when the editor UNMOUNTS during the request waits for it and is written with the outcome applied (the added row as returned, a removed one left out, unchanged on failure or refusal). A page going away (`pagehide`) meanwhile writes it at once, durably, as it stands, since nothing can wait for it; a tab merely hidden (`visibilitychange`) waits for the request like the unmount does and then writes durably with the outcome applied, since a write at once would put back a removed representative bout or wipe an added one. Accepted residual: a hidden page the system discards before the request answers, with no `pagehide`, loses that one tap. The pre-save (`saveRunningSheet`) repeats while a tap made during it is owed, reading the sheet through the autosave refs: the request is stamped when it is sent, so an edit made before it and written after it would lose the guard silently. +**A representative-bout add or remove holds the team editor's autosave** (bc-dhas). The add and remove go through `_daihyosenRequest` (api_client.jsx) on `_fetchJson`, which keeps one AbortController armed, on a timer rather than the wall clock, until the body is read. So a request the server never answers, or answers with a body that never completes, fails with the not-added/not-removed sentence instead of holding the write forever. The add and the remove are one sequence, `runRepBoutChange` (admin_scoring_team.jsx). It takes `hold()` before its pre-save. The pre-save is bounded by the same `FETCH_TIMEOUT_MS` (write_result.jsx), past which the change is reported as not answered, nothing is sent and the hold is released. Add and Remove both stay disabled while that save is still out (`submitting`), so no second change starts beside a pre-save given up on. The admin hosts' refetch after a write is bounded too (`refreshCompsBestEffort` asks `fetchCompetitions({ bounded: true })`), and one given up on at that deadline says nothing: `_fetchJson` rejects past its deadline with the one `timedOut` error however the abort surfaced, and `refreshFailureToast` (admin.jsx) returns no toast for it, because the hosts refresh after every autosave and on a slow link the toast would follow every few taps while the write reaches the page by push anyway. Any other refresh failure keeps the reload hint. `recordScore`, `recordDecision`, `overrideBracketWinner` and the queue's replay (`_flushQueue`, single-flight, so one stalled replay stopped every later flush) read their answers through `_fetchJson` too, so a body that stalls past the deadline is a network failure: the write comes back `{queued: true}` (the editor stays open, `writeKeepsEditorOpen`) and is replayed with the stamp it was sent with, safe if the server did store it because an exact resend of a recorded finish or decision is answered as recorded (`engine.ExactScoreReplay`, `exactDecisionReplay`), with no correction reason asked; a running write's equal stamp re-applies. The add saves the sheet first unless the match has finished, so a queued match starts through the score path's gates; the remove saves first only a running sheet with an edit owed. A debounce that fires while held defers its write, and the mounted editor calls `release()` from an effect on `daihyosenBusy`'s true-to-false edge, after the render that adopted the result. The adopt keeps the answer in the override, `{ match, at }`: a stamped answer the prop does not hold yet (`holdsAnswer`) becomes the match the sheet shows, with the answer's bout log, status and team-match overtime (the prop's winner and decision stay, since the client reshapes those), and it goes once the prop's bout log changes or its stamp reaches `at`; a change of the prop's STATUS re-judges it too (an effect on `match.status`, which releases it when the prop holds the same bout log and is not older than the answer), so a finish or reopen made elsewhere is not hidden behind it. `hold()` returns `settle(transform)`, called on every exit (`withServerDaihyosenRow` over the answer's row once landed, or over the last prop's when that prop already holds the answer; bare in `finally`): an edit owed when the editor UNMOUNTS during the request waits for it and is written with the outcome applied (the added row as returned, a removed one left out, unchanged on failure or refusal). A page going away (`pagehide`) meanwhile writes it at once, durably, as it stands, since nothing can wait for it; a tab merely hidden (`visibilitychange`) waits for the request like the unmount does and then writes durably with the outcome applied, since a write at once would put back a removed representative bout or wipe an added one. Accepted residual: a hidden page the system discards before the request answers, with no `pagehide`, loses that one tap. The pre-save (`saveRunningSheet`) repeats while a tap made during it is owed, reading the sheet through the autosave refs: the request is stamped when it is sent, so an edit made before it and written after it would lose the guard silently. **The persisted outbox is shared by every tab.** `_persistQueue` merges into what storage holds, keeping the newer copy (`enqueuedAt`) where two tabs hold the same ENTRY (the same key; two writes about one match are two entries since bc-mrgc phase 3, and both are kept), and every removal goes through `_dequeue`, which records it so the merge prunes it from storage. A bare `_writeQueue.delete` leaves the stored copy for the merge to keep, and the write is replayed on the next load. Each tab also follows the others' writes (`_onStoredQueueChanged`, on the `storage` event): it takes up an entry it does not hold, or holds an older copy of, after the rehydrate's checks (`_storedEntryProblem`), and drops its copy of one another tab took out of storage, so a write queued in a tab that then closed is still sent. A write two tabs hold can be sent by both, which is harmless (last-write-wins). A revoked password is scrubbed from every stored entry, another tab's included (`_parkStoredEntries`). diff --git a/docs/architecture/network-architecture.md b/docs/architecture/network-architecture.md index be6b373d8..a1c4ccd6d 100644 --- a/docs/architecture/network-architecture.md +++ b/docs/architecture/network-architecture.md @@ -153,7 +153,7 @@ Key client mechanisms (all in `web-mobile/js/api_client.jsx` + consumers): | Half-open / stalled sockets | 12s write timeouts, 35s SSE silence watchdog (armed at connect, not only `onopen`) | | Reconnect storms | exponential backoff + jitter (vs. a fixed delay) | | Lost writes | durable outbox persisted to `localStorage`, retried until it lands, with no age limit; survives tab refresh. Each pending write is its own entry, and each is sent on its own. A flush pass sends the entries in the order they were made (their `modifiedAt`), but one that does not land (offline, or a 5xx that keeps coming back) holds back nothing after it, and a new write goes straight to the server whatever is queued, so holding it back would gain nothing (refer to **Order of writes** in this table) and would let one failing write stop every write after it. A later running update never takes the place of a queued Finish or decision, and a decision and a score for the same match are both kept; only a score write folds into the score write queued just before it (a running update into a running update, a Finish into either, as when the editor re-sends a held Finish), naming every group either changed. A lineup save is the other write that folds: it takes the place of a queued save of the same lineup, because a lineup save restates the whole lineup and carries no time the server could order it by. A refusal on replay is always reported, never taken as success. Every tab of the app shares the stored outbox. A tab saving its queue merges into it: it adds its own writes, keeps the newer copy where two tabs hold the same entry, and removes only the writes it settled. Each tab also takes up the writes another tab stores, so a write queued in a tab that then closes is still sent by a tab left open. A write two tabs hold can be sent by both, which is harmless, because the server orders writes by their stamps (refer to **Order of writes** in this table) | -| Order of writes | nothing a device sends is thrown away, and changes are put in the order they were made (the write's `modifiedAt`), never the order they arrive. A device stamps a write with its estimate of the server's time, and never earlier than the match it was made against: two devices' estimates can differ by up to the 5 s the server accepts, so a change made a moment after a write stamped by a device running ahead would otherwise read as older than the write it changed. The stamp is therefore the later of the device's time and the stamp of the match as the operator saw it plus 1 ms (for an autosave, as it was at the tap), never more than 4 s past the device's time. A score write names the groups of the match it changes (`changed`: `points`, `result`, `encho`, `flags`, `rep`, each side's representative pick, `repPickA` and `repPickB`, or one bout row, `bout:`). The score editor works this out by comparing what it sends with the match it was showing. The server applies each named group only if the write is not older than the last change applied to that group, and never touches a group the write does not name, so two devices changing different things both land whatever order they arrive in. A named group a newer change outranks is not applied: its value is kept in the match's history (`GET /api/competitions/:id/matches/:mid/history`), which the score editors show under **History**. A write applied in part answers with `heldGroups`; one none of which applied answers `{"applied": false, "reason": "superseded"}` with the same list, and the editor says "Not applied" rather than "Not saved". A change that would leave a finished match without the winner it needs (a knockout match left tied, an engi match with no valid flag count) is not applied either: the match keeps its recorded result, the change is kept in the history, and the answer adds `heldReason: "needs_winner"`, on which the editor tells the operator to correct the result with a winner. A change from a board still scoring a match, or its overtime, that a kiken, fusenpai, or fusensho (a withdrawal, or a win recorded for a competitor barred elsewhere) already closed is held as well: that decision stands, and the history gives the reason. The other arrival order is answered applied: an older finish that arrives after a newer scoring change which would leave it without a winner is recorded, that later change is moved to the history (an entry of its own, at its own time), and the answer names it in `displacedGroups` with `heldReason: "needs_winner"` and no `heldGroups`; the editor says "Saved" and what was moved, and a held finish replayed this way raises one information alert. A representative pick made after its representative bout was removed is moved to the history the same way when the removal arrives, and that answer names it in `displacedGroups` with no `heldReason`. Where the outbox folds a queued running update into a later one, the later one names every group either changed. The same holds for the writes that are not score sheets: an older save the same device had already followed with a newer one (an out-of-order delivery after a reconnect) is kept whole in the history as an "older revision of this board"; a winner set by hand that a newer result outranks is kept in the history with the winner it named, and answered like a held score; an engi result is ordered by when it was saved, its flags and the winner they decide kept together. A queued decision whose first send landed but whose answer was lost is answered as recorded when it is sent again | +| Order of writes | nothing a device sends is thrown away, and changes are put in the order they were made (the write's `modifiedAt`), never the order they arrive. A device stamps a write with its estimate of the server's time, and never earlier than the match it was made against: two devices' estimates can differ by up to the 5 s the server accepts, so a change made a moment after a write stamped by a device running ahead would otherwise read as older than the write it changed. The stamp is therefore the later of the device's time and the stamp of the match as the operator saw it plus 1 ms (for an autosave, as it was at the tap), never more than 4 s past the device's time. A score write names the groups of the match it changes (`changed`: `points`, `result`, `encho`, `flags`, `rep`, each side's representative pick, `repPickA` and `repPickB`, or one bout row, `bout:`). The score editor works this out by comparing what it sends with the match it was showing. The server applies each named group only if the write is not older than the last change applied to that group, and never touches a group the write does not name, so two devices changing different things both land whatever order they arrive in. A named group a newer change outranks is not applied: its value is kept in the match's history (`GET /api/competitions/:id/matches/:mid/history`), which the score editors show under **History**. A write applied in part answers with `heldGroups`; one none of which applied answers `{"applied": false, "reason": "superseded"}` with the same list, and the editor says "Not applied" rather than "Not saved". A change that would leave a finished match without the winner it needs (a knockout match left tied, an engi match with no valid flag count) is not applied either: the match keeps its recorded result, the change is kept in the history, and the answer adds `heldReason: "needs_winner"`, on which the editor tells the operator to correct the result with a winner. A change from a board still scoring a match, or its overtime, that a kiken, fusenpai, or fusensho (a withdrawal, or a win recorded for a competitor barred elsewhere) already closed is held as well: that decision stands, and the history gives the reason. The other arrival order is answered applied: an older finish that arrives after a newer scoring change which would leave it without a winner is recorded, that later change is moved to the history (an entry of its own, at its own time), and the answer names it in `displacedGroups` with `heldReason: "needs_winner"` and no `heldGroups`; the editor says "Saved" and what was moved, and a held finish replayed this way raises one information alert. A representative pick made after its representative bout was removed is moved to the history the same way when the removal arrives, and that answer names it in `displacedGroups` with no `heldReason`. Where the outbox folds a queued running update into a later one, the later one names every group either changed. The same holds for the writes that are not score sheets: an older save the same device had already followed with a newer one (an out-of-order delivery after a reconnect) is kept whole in the history as an "older revision of this board"; a winner set by hand that a newer result outranks is kept in the history with the winner it named, and answered like a held score; an engi result is ordered by when it was saved, its flags and the winner they decide kept together. A queued decision or finish whose first send landed but whose answer was lost is answered as recorded when it is sent again: the same write under the same stamp changes nothing, so it is not asked for a correction reason and nothing is written | | Edits not yet sent | a score editor holds each edit for a short autosave window (300ms) before it writes it. The sync pill reads "Syncing…" from the first tap, not "Synced". If the editor goes away inside that window (closed, Prev/Next, another match picked, a court switch), the edit is written at once. If the tab is hidden or unloaded inside it, the edit is written straight into the outbox, so a reload does not lose it. An autosave carries the time of the tap, not the time it is sent, so it never outranks a result another device recorded after the tap. A running write already sent but not yet answered is also kept when the page goes away, which cancels its request; while the request is still open, that copy is not sent | | Server errors (5xx / 429), and 403 | write stays queued and keeps retrying until it lands, holding back no other write; after 10 consecutive rejections the operator gets a notice and the sync pill shows "Not saving", and the match's score editor offers **Discard held result**, which discards that write and nothing else after a confirm. The held-writes count in the admin topbar opens a list of every held write (score updates, results, decisions, winners set by hand, lineups), where any write that keeps failing can be discarded the same way, one at a time; a write only waiting for the connection cannot be discarded there. On this server 403 is never a bad credential (that is 401): it means the tournament is not configured yet, or is missing its password. Only an admin fixing the server state clears it, so signing in again cannot help and the write keeps retrying instead of parking | | Invalid credential (401) | the one 4xx a retry can fix: the write is parked, not discarded, stops retrying, and shows "Sign in to save"; signing in again re-sends it with the new credential, together with every other queued write still carrying the refused password (one queued after it, before its own 401 came back), so one sign-in is enough. A write queued without a password (a competitor's in a self-run event) is never given one | diff --git a/internal/engine/exact_score_replay_test.go b/internal/engine/exact_score_replay_test.go new file mode 100644 index 000000000..b0f8d179c --- /dev/null +++ b/internal/engine/exact_score_replay_test.go @@ -0,0 +1,55 @@ +package engine + +import ( + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/gitrgoliveira/bracket-creator/internal/state" +) + +// bc-xrpl: ExactScoreReplay tells the resend of a recorded finish (its +// answer lost) from a correction made under the same stamp. +func TestExactScoreReplay(t *testing.T) { + const at = 1_700_000_000_000 + stored := func() *state.MatchResult { + return &state.MatchResult{ + ID: "Pool A-0", SideA: "Alice", SideAID: "a", SideB: "Bob", SideBID: "b", + Status: state.MatchStatusCompleted, Winner: "Alice", WinnerID: "a", + IpponsA: []string{"M", "K"}, IpponsB: []string{}, ModifiedAt: at, + } + } + resend := func() *state.MatchResult { + return &state.MatchResult{ + SideA: "Alice", SideB: "Bob", Status: state.MatchStatusCompleted, Winner: "Alice", + IpponsA: []string{"M", "K"}, IpponsB: []string{}, ModifiedAt: at, + } + } + for _, tc := range []struct { + name string + change func(s, w *state.MatchResult) + want bool + }{ + {name: "the same write, ids filled in from the stored sides", change: func(_, _ *state.MatchResult) {}, want: true}, + {name: "naming only groups that are equal", change: func(_, w *state.MatchResult) { w.Changed = []string{state.GroupPoints, state.GroupResult} }, want: true}, + {name: "a stored correction reason the resend omits", change: func(s, _ *state.MatchResult) { s.CorrectionReason = "typo" }, want: true}, + {name: "another scoreline", change: func(_, w *state.MatchResult) { w.IpponsA = []string{"M", "D"} }}, + {name: "another winner", change: func(_, w *state.MatchResult) { w.Winner = "Bob" }}, + {name: "an older stamp", change: func(_, w *state.MatchResult) { w.ModifiedAt = at - 1 }}, + {name: "a newer stamp", change: func(_, w *state.MatchResult) { w.ModifiedAt = at + 1 }}, + {name: "no stamp", change: func(_, w *state.MatchResult) { w.ModifiedAt = 0 }}, + {name: "a running write", change: func(_, w *state.MatchResult) { w.Status = state.MatchStatusRunning }}, + {name: "a match not finished", change: func(s, _ *state.MatchResult) { s.Status = state.MatchStatusRunning }}, + {name: "another competitor", change: func(_, w *state.MatchResult) { w.SideB = "Carol" }}, + {name: "a winner id naming neither side", change: func(_, w *state.MatchResult) { w.WinnerID = "z" }}, + } { + t.Run(tc.name, func(t *testing.T) { + s, w := stored(), resend() + tc.change(s, w) + before := *w + assert.Equal(t, tc.want, ExactScoreReplay(s, w, true)) + assert.Equal(t, before, *w, "the write is judged on a copy") + }) + } + assert.False(t, ExactScoreReplay(nil, resend(), true)) +} diff --git a/internal/engine/scoring_tx.go b/internal/engine/scoring_tx.go index 9695a7828..88473ead3 100644 --- a/internal/engine/scoring_tx.go +++ b/internal/engine/scoring_tx.go @@ -109,6 +109,12 @@ func (e *Engine) RecordMatchResultWithIneligibilityTx(tx state.StoreTx, compID, if fo.HoldReason != "" { return nil, e.holdWriteTx(tx, compID, matchID, result, comp, fo.HoldReason) } + // An exact resend of a finish already recorded (a queued write whose + // first send landed but whose answer was lost) answers as recorded and + // writes nothing, before anything else judges it (bc-xrpl). + if replayed, err := e.answerExactScoreReplay(tx, compID, matchID, result, comp); err != nil || replayed { + return nil, err + } // Only a COMPLETING write goes through the engi recorder. Engi's flag-total // rule (odd, in {1,3,5}: a 3- or 5-referee panel cannot draw) can only be // satisfied by a real result, so routing every engi write through it @@ -1220,6 +1226,110 @@ func exactDecisionReplay(prior *state.MatchResult, decision, decisionBy string, prior.GroupStamp(state.GroupResult) == stamp } +// ExactScoreReplay reports whether incoming is a completing score write the +// stored match has already recorded: the score counterpart of +// exactDecisionReplay (bc-xrpl). A Finish whose first send landed but whose +// answer was lost (the page went away, the connection dropped, the fetch +// timed out) is sent again by the offline queue with the stamp it was sent +// with; that resend is the same write, so it answers as recorded, with no +// correction reason asked and nothing written. Exact means: both the stored +// match and the write are completed, the write is stamped, its stamp is the +// stored result group's, and no group it changes (its own list, or the +// default, effectiveChangedGroups) differs from the stored value once the +// write is read the way the write path would land it (the hansoku award, +// the stored side names and ids, the winner id, the stored correction +// reason). A same-stamp write that differs anywhere it changes is a +// correction and still needs its reason. nilSubsClear is the branch's +// (true for a pool match, false for a knockout one, see mergeCtx). +func ExactScoreReplay(stored, incoming *state.MatchResult, nilSubsClear bool) bool { + _, ok := exactScoreReplayProbe(stored, incoming, nilSubsClear) + return ok +} + +// exactScoreReplayProbe is ExactScoreReplay's judgement, handing back the +// write as the write path would land it (a copy: incoming is not changed), +// which the replay's history entry is merged from. +func exactScoreReplayProbe(stored, incoming *state.MatchResult, nilSubsClear bool) (*state.MatchResult, bool) { + if stored == nil || incoming == nil || incoming.ModifiedAt <= 0 || + incoming.Status != state.MatchStatusCompleted || + stored.Status != state.MatchStatusCompleted || + incoming.ModifiedAt != stored.GroupStamp(state.GroupResult) { + return nil, false + } + probe := *incoming + // Copies that keep nil apart from empty (a nil list is a writer with + // nothing to say, an empty one says zero). + if incoming.IpponsA != nil { + probe.IpponsA = append(make([]string, 0, len(incoming.IpponsA)), incoming.IpponsA...) + } + if incoming.IpponsB != nil { + probe.IpponsB = append(make([]string, 0, len(incoming.IpponsB)), incoming.IpponsB...) + } + probe.SubResults = state.CloneSubResults(incoming.SubResults) + probe.GroupStamps = state.CloneGroupStamps(incoming.GroupStamps) + if applyHansokuIppons(&probe) != nil { + return nil, false + } + if reconcileSides(&probe, storedSides{A: stored.SideA, B: stored.SideB, AID: stored.SideAID, BID: stored.SideBID}) { + return nil, false + } + if backfillMatchIdentity(&probe, stored, matchWriteForward) != nil { + return nil, false + } + if probe.CorrectionReason == "" { + probe.CorrectionReason = stored.CorrectionReason + } + for _, g := range effectiveChangedGroups(stored, &probe, nilSubsClear) { + if state.GroupDiffers(stored, &probe, g) { + return nil, false + } + } + return &probe, true +} + +// answerExactScoreReplay answers an exact resend of a recorded finish +// (ExactScoreReplay) as recorded: result becomes the stored match, exactly +// as a landed write leaves it, and nothing is written to the match file. Its +// history entry says the write arrived and changed nothing, as an exact +// decision replay's does. It runs before anything else judges the write, as +// the decision replay runs before the decision's refusals: a downstream or +// eligibility refusal would only misreport a result already recorded as one +// to resolve and send again. false, with result untouched, for any other +// write. +func (e *Engine) answerExactScoreReplay(tx state.StoreTx, compID, matchID string, result *state.MatchResult, comp *state.Competition) (bool, error) { + if result.Status != state.MatchStatusCompleted || result.ModifiedAt <= 0 { + return false, nil + } + prior, inPool, err := e.lookupExistingResultIn(tx, compID, matchID) + if err != nil { + return false, err + } + probe, ok := exactScoreReplayProbe(prior, result, inPool) + if !ok { + return false, nil + } + result.Merge = mergeMatchWrite(prior, probe, matchWriteForward, mergeCtx{comp: comp, knockout: !inPool, nilSubsClear: inPool}) + e.recordWriteHistory(tx, compID, matchID, result) + // A bracket match's prior came through bracketMatchAsResult, which omits + // the court and the schedule a landed write echoes (the decision + // replay's fill, bc-cse finding 5). + if !inPool && (prior.Court == "" || prior.ScheduledAt == "") { + if bracket, berr := tx.LoadBracket(compID); berr == nil { + if court, scheduledAt, found := bracketMatchCourtAndSchedule(bracket, matchID); found { + if prior.Court == "" { + prior.Court = court + } + if prior.ScheduledAt == "" { + prior.ScheduledAt = scheduledAt + } + } + } + } + *result = *prior + result.ClearRequestFields() + return true, nil +} + // holdWriteTx keeps a write whole in the match's history without applying any // of it, for ForceOptions.HoldReason. The merge owner decides what the write // changes and the values it holds (mergeMatchWrite with holdAll), on a copy, diff --git a/internal/mobileapp/handlers_match.go b/internal/mobileapp/handlers_match.go index cb9c32d2b..47133112e 100644 --- a/internal/mobileapp/handlers_match.go +++ b/internal/mobileapp/handlers_match.go @@ -2943,12 +2943,18 @@ func applyCorrectionReason(snap matchSnapshot, r *state.MatchResult) correctionC // (HoldReasonFinishAtomic, bc-mrgc Finding 4) -- its scoreline never // lands beside a verdict it never declared -- and keeps it in the // match's history. Demanding a reason here refused it before it got - // there, and it was lost (bc-mrgc review). An equal stamp is an - // exact replay, which applies, so it is still a correction to - // justify. + // there, and it was lost (bc-mrgc review). if r.ModifiedAt > 0 && r.ModifiedAt < snap.ResultStamp { return correctionCheck{StoredStatus: snap.Status, StoredDecision: snap.Decision} } + // An equal stamp with nothing it changes differing from what is + // stored is the finish already recorded, sent again because its + // answer was lost: the engine answers it as recorded and writes + // nothing (engine.ExactScoreReplay, bc-xrpl). A same-stamp write + // that differs is a correction, and still needs its reason. + if engine.ExactScoreReplay(snap.Stored, r, !snap.InBracket) { + return correctionCheck{StoredStatus: snap.Status, StoredDecision: snap.Decision} + } if r.CorrectionReason == "" { return correctionCheck{StoredStatus: snap.Status, StoredDecision: snap.Decision, Reject: missingCorrectionReasonError()} } diff --git a/internal/mobileapp/score_exact_replay_test.go b/internal/mobileapp/score_exact_replay_test.go new file mode 100644 index 000000000..574a192ee --- /dev/null +++ b/internal/mobileapp/score_exact_replay_test.go @@ -0,0 +1,203 @@ +package mobileapp + +// bc-xrpl: a completing score write the server stored but whose answer the +// client never read (the page went away, the connection dropped, the fetch +// timed out) is sent again by the offline queue with the SAME stamp. That +// resend is the write it already recorded, so it is answered as recorded +// (engine.ExactScoreReplay), with no correction reason asked and nothing +// written to the match file. A same-stamp write that differs from what is +// stored is still a correction, and still needs its reason. + +import ( + "encoding/json" + "net/http" + "os" + "path/filepath" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/gitrgoliveira/bracket-creator/internal/domain" + "github.com/gitrgoliveira/bracket-creator/internal/state" +) + +// exactReplayServer is a running individual match on court A, Alice v Bob, +// in a pool (Pool A-0) or a knockout (m-r1-0, feeding the final m-r2-0). It +// returns the match's id, the file that holds it and the data folder. +func exactReplayServer(t *testing.T, compID string, knockout bool) (http.Handler, *state.Store, string, string, string) { + t.Helper() + r, store, _, _, dataDir := setupTestRouter(t) + require.NoError(t, store.SaveTournament(&state.Tournament{Name: "T", Password: "", Courts: []string{"A"}})) + comp := &state.Competition{ + ID: compID, Name: compID, Kind: "individual", Format: state.CompFormatLeague, + Status: state.CompStatusPools, Courts: []string{"A"}, + } + if knockout { + comp.Format, comp.Status = state.CompFormatKnockout, state.CompStatusKnockout + } + require.NoError(t, store.SaveCompetition(comp)) + require.NoError(t, store.SaveParticipants(compID, []domain.Player{ + {ID: mgAliceID, Name: mgAlice, Dojo: "D1"}, + {ID: mgBobID, Name: mgBob, Dojo: "D2"}, + })) + if !knockout { + require.NoError(t, store.SavePoolMatches(compID, []state.MatchResult{ + {ID: "Pool A-0", SideA: mgAlice, SideAID: mgAliceID, SideB: mgBob, SideBID: mgBobID, Court: "A", Status: state.MatchStatusRunning}, + })) + runningRevStore.Delete(compID + ":Pool A-0") + return r, store, "Pool A-0", "pool-matches.csv", dataDir + } + require.NoError(t, store.SaveBracket(compID, &state.Bracket{Rounds: [][]state.BracketMatch{ + { + {ID: "m-r1-0", SideA: mgAlice, SideAID: mgAliceID, SideB: mgBob, SideBID: mgBobID, + Status: state.MatchStatusRunning, MatchNumber: 1, Court: "A", ScheduledAt: "10:00"}, + }, + { + {ID: "m-r2-0", SideA: "Winner of m-r1-0", MatchNumber: 2, Court: "A"}, + }, + }})) + runningRevStore.Delete(compID + ":m-r1-0") + return r, store, "m-r1-0", "bracket.json", dataDir +} + +// exactReplayFinish is the Finish an operator's score editor sends: Alice +// wins M-K, stamped at. +func exactReplayFinish(at int64) map[string]any { + return map[string]any{ + "sideA": mgAlice, "sideB": mgBob, "sideAId": mgAliceID, "sideBId": mgBobID, + "winner": mgAlice, "winnerId": mgAliceID, + "ipponsA": []string{"M", "K"}, "ipponsB": []string{}, + "hansokuA": 0, "hansokuB": 0, "decision": "", + "status": "completed", "modifiedAt": at, + } +} + +func TestScoreHandler_ExactResendOfAFinishIsAnsweredAsRecorded(t *testing.T) { + for _, tc := range []struct { + name string + knockout bool + // body shapes the resend: as the editor sends it, naming its groups, + // and with no winnerId (the client sends one only when the server + // supplied the side ids; the write fills it in from the sides). + body func(map[string]any) + }{ + {name: "pool", body: func(map[string]any) {}}, + {name: "pool, naming its groups", body: func(b map[string]any) { b["changed"] = []string{"points", "result"} }}, + {name: "pool, no winnerId", body: func(b map[string]any) { delete(b, "winnerId") }}, + {name: "knockout", knockout: true, body: func(map[string]any) {}}, + {name: "knockout, naming its groups", knockout: true, body: func(b map[string]any) { b["changed"] = []string{"points", "result"} }}, + } { + t.Run(tc.name, func(t *testing.T) { + const compID = "exact-replay" + r, store, mid, file, dataDir := exactReplayServer(t, compID, tc.knockout) + url := "/api/competitions/" + compID + "/matches/" + mid + "/score" + at := time.Now().UnixMilli() - 5_000 + + first := exactReplayFinish(at) + tc.body(first) + w := serveJSON(r, "PUT", url, first) + require.Equal(t, http.StatusOK, w.Code, "the first send records the finish: %s", w.Body.String()) + var recorded map[string]any + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &recorded)) + + path := filepath.Join(dataDir, "competitions", compID, file) + bytesBefore, err := os.ReadFile(path) + require.NoError(t, err) + versionBefore := store.FileVersion(compID, file) + + // The answer was lost: the queue sends the same write again. + resend := exactReplayFinish(at) + tc.body(resend) + w = serveJSON(r, "PUT", url, resend) + require.Equal(t, http.StatusOK, w.Code, "an exact resend is answered as recorded: %s", w.Body.String()) + var out map[string]any + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &out)) + assert.NotContains(t, out, "applied", "answered like a landed write, not a refusal") + assert.NotContains(t, out, "heldGroups") + assert.Equal(t, "completed", out["status"]) + assert.Equal(t, mgAlice, out["winner"]) + assert.Equal(t, mgAliceID, out["winnerId"]) + assert.Equal(t, []any{"M", "K"}, out["ipponsA"]) + assert.EqualValues(t, at, out["modifiedAt"]) + assert.Equal(t, "A", out["court"], "the answer carries the match's court, as the first one did") + assert.Equal(t, recorded["court"], out["court"]) + assert.Equal(t, recorded["scheduledAt"], out["scheduledAt"]) + + bytesAfter, err := os.ReadFile(path) + require.NoError(t, err) + assert.Equal(t, string(bytesBefore), string(bytesAfter), "nothing is written to the match file") + assert.Equal(t, versionBefore, store.FileVersion(compID, file), "nor is its version moved") + + history, err := store.LoadMatchHistory(compID, mid) + require.NoError(t, err) + require.Len(t, history, 2, "the resend has its own history line, as an exact decision replay does") + assert.NotEmpty(t, history[1].Outcomes) + for group, outcome := range history[1].Outcomes { + assert.Equal(t, state.HistoryOutcomeUnchanged, outcome, "group %s", group) + } + assert.Empty(t, history[1].Held) + }) + } +} + +func TestScoreHandler_SameStampFinishThatDiffersStillNeedsAReason(t *testing.T) { + for _, tc := range []struct { + name string + knockout bool + change func(map[string]any) + }{ + {name: "pool, another winner", change: func(b map[string]any) { + b["winner"], b["winnerId"], b["ipponsA"], b["ipponsB"] = mgBob, mgBobID, []string{}, []string{"M", "K"} + }}, + {name: "pool, another scoreline", change: func(b map[string]any) { b["ipponsA"] = []string{"M", "D"} }}, + {name: "knockout, another winner", knockout: true, change: func(b map[string]any) { + b["winner"], b["winnerId"], b["ipponsA"], b["ipponsB"] = mgBob, mgBobID, []string{}, []string{"M", "K"} + }}, + {name: "knockout, another scoreline", knockout: true, change: func(b map[string]any) { b["ipponsA"] = []string{"M", "D"} }}, + } { + t.Run(tc.name, func(t *testing.T) { + const compID = "exact-replay-differs" + r, _, mid, file, dataDir := exactReplayServer(t, compID, tc.knockout) + url := "/api/competitions/" + compID + "/matches/" + mid + "/score" + at := time.Now().UnixMilli() - 5_000 + + w := serveJSON(r, "PUT", url, exactReplayFinish(at)) + require.Equal(t, http.StatusOK, w.Code, w.Body.String()) + path := filepath.Join(dataDir, "competitions", compID, file) + bytesBefore, err := os.ReadFile(path) + require.NoError(t, err) + + changed := exactReplayFinish(at) + tc.change(changed) + w = serveJSON(r, "PUT", url, changed) + require.Equal(t, http.StatusBadRequest, w.Code, w.Body.String()) + assert.Contains(t, w.Body.String(), "correctionReason") + bytesAfter, err := os.ReadFile(path) + require.NoError(t, err) + assert.Equal(t, string(bytesBefore), string(bytesAfter), "the refused correction changes nothing") + + // With its reason it is the correction it is. + changed["correctionReason"] = "wrong winner entered" + w = serveJSON(r, "PUT", url, changed) + require.Equal(t, http.StatusOK, w.Code, w.Body.String()) + }) + } +} + +// A completed write with no stamp over a finished match is a correction like +// any other: it needs its reason (only a stamp can say it is the same write). +func TestScoreHandler_UnstampedFinishOverAFinishStillNeedsAReason(t *testing.T) { + const compID = "exact-replay-unstamped" + r, _, mid, _, _ := exactReplayServer(t, compID, false) + url := "/api/competitions/" + compID + "/matches/" + mid + "/score" + w := serveJSON(r, "PUT", url, exactReplayFinish(time.Now().UnixMilli()-5_000)) + require.Equal(t, http.StatusOK, w.Code, w.Body.String()) + + unstamped := exactReplayFinish(0) + delete(unstamped, "modifiedAt") + w = serveJSON(r, "PUT", url, unstamped) + require.Equal(t, http.StatusBadRequest, w.Code, w.Body.String()) + assert.Contains(t, w.Body.String(), "correctionReason") +} diff --git a/specs/openapi.yaml b/specs/openapi.yaml index 2753d4e2c..5f6fd38e8 100644 --- a/specs/openapi.yaml +++ b/specs/openapi.yaml @@ -2562,7 +2562,13 @@ paths: is NOT refused for one when it is completed again: a match can be reopened without any reason, and nothing is gated on that. The completion clears `reopenPending` and keeps a `correctionReason` - if one is sent. + if one is sent. An exact resend of the finish already recorded + (`completed`, the stored result's own `modifiedAt`, and nothing it + changes differing from what is stored: a queued send whose first + answer was lost) is not a correction either: it is answered as + recorded, with the stored MatchResult, and writes nothing. A + same-stamp write that differs from what is stored is a correction + and needs its reason. A `completed` write on a team match (not kachinuki) is also refused while a numbered bout (1..teamSize) has no result in @@ -6724,7 +6730,10 @@ components: description: > Audit justification for rewriting a result the operator had already declared final. Required when the write completes a match that is - already `completed` (a correction). Kept, but not required, when + already `completed` (a correction). Not required for an exact + resend of the finish already recorded (same `modifiedAt` as the + stored result, nothing it changes differing), which is answered as + recorded. Kept, but not required, when the write completes a match that carries `reopenPending`. Otherwise it is dropped, so the field always means "this rewrote a finalized result". A stored reason is carried forward across diff --git a/web-mobile/js/__tests__/queue_ordered_writes.test.jsx b/web-mobile/js/__tests__/queue_ordered_writes.test.jsx index e540005d4..ffc4aaa37 100644 --- a/web-mobile/js/__tests__/queue_ordered_writes.test.jsx +++ b/web-mobile/js/__tests__/queue_ordered_writes.test.jsx @@ -735,3 +735,54 @@ describe('a queued finish that moved a later change to the history', () => { expect(localStorage.getItem('bc_write_queue')).toBeNull(); }); }); + +describe('a write that leaves the queue leaves browser storage at once', () => { + // A queued write the server has answered must not wait in storage for + // the rest of the pass: a page that goes away meanwhile (a reload, the + // tab closed) leaves it there, and the next load sends it again. A Finish + // sent twice is refused the second time (an equal stamp over a finished + // match asks for a correction reason) and the operator is told a result + // that is recorded was not. + const twoFinishesOneHeld = async (status) => { + let release; + const sent = []; + let up = false; + global.fetch = vi.fn((url) => { + if (isClockPoll(url)) return Promise.reject(new TypeError('clock poll: not part of this test')); + if (!up) return Promise.reject(new TypeError('offline')); + sent.push(String(url)); + const answer = (code) => ({ ok: code >= 200 && code < 300, status: code, json: () => Promise.resolve(code === 200 ? { id: 'x' } : { error: 'refused' }) }); + if (String(url).includes('/m2/')) return new Promise((resolve) => { release = () => resolve(answer(200)); }); + return Promise.resolve(answer(status)); + }); + await API.recordScore('c1', 'm1', { status: 'completed', winner: 'A', ipponsA: ['M'] }, 'pw', null); + vi.advanceTimersByTime(1000); + await API.recordScore('c1', 'm2', { status: 'completed', winner: 'B', ipponsB: ['K'] }, 'pw', null); + await settleOfflinePass(); + expect(storedEntries().map(([, d]) => d.matchID)).toEqual(['m1', 'm2']); + up = true; + window.dispatchEvent(new Event('online')); + await tick(50); + return { sent, release: () => release() }; + }; + + it('a landed write is gone from storage while a later one is still being sent', async () => { + const { sent, release } = await twoFinishesOneHeld(200); + expect(sent.map((u) => u.split('/matches/')[1])).toEqual(['m1/score', 'm2/score']); + // m2 is still out: m1, answered, is no longer stored. + expect(storedEntries().map(([, d]) => d.matchID)).toEqual(['m2']); + release(); + await tick(50); + expect(localStorage.getItem('bc_write_queue')).toBeNull(); + }); + + it('a refused write is gone from storage while a later one is still being sent', async () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const { release } = await twoFinishesOneHeld(400); + expect(storedEntries().map(([, d]) => d.matchID)).toEqual(['m2']); + release(); + await tick(50); + expect(localStorage.getItem('bc_write_queue')).toBeNull(); + warnSpy.mockRestore(); + }); +}); diff --git a/web-mobile/js/api_client.jsx b/web-mobile/js/api_client.jsx index 945b3e94c..716f6ace1 100644 --- a/web-mobile/js/api_client.jsx +++ b/web-mobile/js/api_client.jsx @@ -1523,6 +1523,18 @@ async function _flushQueue() { // open: that fetch lands it or queues it anew, so sending the copy // too would send the write twice. if (_heldByOpenFetch(key, descriptor)) continue; + // Out of the queue AND out of storage at once, not at the end of + // the pass: a page that goes away while a later entry is still + // out (a reload, the tab closed) would leave this one stored, and + // the next load would send it again. A Finish sent twice is + // refused the second time (an equal stamp over a finished match + // asks for a correction reason), so the operator would be told a + // recorded result was not. + const leaveQueue = () => { + if (!_dequeue(key, descriptor)) return false; + _persistQueue(); + return true; + }; const { compID, matchID, payload, password, terminal, kind, method, url } = descriptor; // Running score writes (terminal=false) don't store method/url and // always PUT to the score endpoint. Terminal entries carry their own @@ -1541,7 +1553,7 @@ async function _flushQueue() { // channel above only reaches an operator who happens to have // this exact match open. _notifyQueueAlert({ kind: 'unreadable', count: 1, terminalCount: 1, compID, matchID }); - _dequeue(key, descriptor); + leaveQueue(); continue; } effectiveMethod = method; @@ -1608,7 +1620,7 @@ async function _flushQueue() { // the server state is right), here it is a failure // AND the local bracket is wrong. if (kind === 'override') _notifyBracketResync({ compID, matchID, reason: CLOCK_SKEW_REASON }); - _dequeue(key, descriptor); + leaveQueue(); continue; } await _restampQueuedEntryForSkew(descriptor); @@ -1725,7 +1737,7 @@ async function _flushQueue() { noteDefaultWinStands(body); lastSupersededMatch = { compID, matchID }; } - if (_dequeue(key, descriptor)) { + if (leaveQueue()) { // A result kept in the match's history did not land // (announced above). writeWasSuperseded is every // applied:false answer, a clock-skew refusal included @@ -1837,7 +1849,7 @@ async function _flushQueue() { detail: refusal.reason, ...(refusal.sentence ? { sentence: true } : {}), }); - _dequeue(key, descriptor); + leaveQueue(); } else { // Non-retryable response: 400 validation, 413, generic 409 // conflict, etc. NOTE neither auth-ish status reaches here any @@ -1878,7 +1890,7 @@ async function _flushQueue() { detail: refusal.reason, ...(refusal.sentence ? { sentence: true } : {}), }); - _dequeue(key, descriptor); + leaveQueue(); } } catch (_) { // fetch rejected (network down), or the request or its body From dfb552d8627fa09c09416490a22bf3a7193c64b0 Mon Sep 17 00:00:00 2001 From: Ricardo Oliveira Date: Tue, 13 Oct 2026 08:11:05 +0100 Subject: [PATCH 22/30] feat(bc-cmpw): one admin password; delete asks for it again; self-run rules Operator rulings 2026-10-12/13: - The separate destructive-ops password is gone. A signed-in organiser is never asked for the password again, except Delete competition, which sends the re-typed password in X-Confirm-Password and is refused (401) unless it matches, checked by the sign-in verifier. - Self-run: completing a competition needs no password; adding and editing participants is open to anyone; organiser setup, corrections and destructive actions need the main password (branding logo and league tie-break routes added to that list). - Self-run mode has no check-in: Competition.CheckInApplies is the one owner the draw, the Swiss field and the estimate ask; the three check-in writes answer 409 check_in_not_in_self_run; the SPA hides the toggle and the registration desk. - A competition with no knockout rounds cannot be completed while a match is unfinished, nor a Swiss one while rounds remain to draw. - TOURNAMENT_ADMIN_PASSWORD_HASH and a stored admin_password are ignored. Review fixes: the exact-replay probe runs the merge on a copy (held groups excluded) and covers kachinuki, engi and fixed-order teams; a self-run participant's exact resend of a landed finish is answered as recorded; the individual and team editors clear an edit stamp once a save built after it lands (shared landedAutosave). Docs state current behaviour throughout. Co-Authored-By: Claude Opus 5.5 --- docs/architecture/data-model.md | 8 +- docs/architecture/network-architecture.md | 4 +- docs/user-guide/commands/mobile-app.md | 1 + docs/user-guide/competitors/self-run.md | 6 +- docs/user-guide/organisers/operating-modes.md | 59 +- docs/user-guide/organisers/run-tournament.md | 10 +- internal/engine/checkin_self_run_test.go | 126 +++ internal/engine/competition.go | 46 +- internal/engine/estimate_schedule.go | 6 +- internal/engine/exact_score_replay_test.go | 17 +- internal/engine/scoring_tx.go | 257 +++--- internal/engine/swiss.go | 24 +- internal/mobileapp/auth_admin.go | 192 ----- internal/mobileapp/auth_admin_test.go | 189 ----- internal/mobileapp/confirm_password.go | 65 ++ internal/mobileapp/confirm_password_test.go | 138 ++++ internal/mobileapp/coverage_gap_test.go | 40 - internal/mobileapp/handlers_auth_admin.go | 120 --- .../mobileapp/handlers_auth_admin_test.go | 247 ------ internal/mobileapp/handlers_auth_config.go | 29 +- .../mobileapp/handlers_auth_config_test.go | 11 +- internal/mobileapp/handlers_competition.go | 95 ++- .../handlers_competition_estimate_test.go | 15 +- .../mobileapp/handlers_competition_test.go | 37 - internal/mobileapp/handlers_import.go | 4 +- .../handlers_kachinuki_roster_test.go | 2 +- .../mobileapp/handlers_lineup_changed_test.go | 2 +- internal/mobileapp/handlers_match.go | 47 +- internal/mobileapp/handlers_match_test.go | 2 +- internal/mobileapp/handlers_participants.go | 41 +- .../mobileapp/handlers_participants_test.go | 2 +- internal/mobileapp/handlers_reset_test.go | 2 +- internal/mobileapp/handlers_sponsors_test.go | 7 +- internal/mobileapp/handlers_test.go | 6 +- internal/mobileapp/handlers_tournament.go | 134 +--- internal/mobileapp/middleware.go | 141 ++-- internal/mobileapp/middleware_selfrun_test.go | 754 ++++++++++++------ internal/mobileapp/score_exact_replay_test.go | 249 ++++++ internal/mobileapp/self_run_daihyosen_test.go | 4 +- .../mobileapp/self_run_no_checkin_test.go | 112 +++ .../mobileapp/self_run_team_writes_test.go | 2 +- internal/mobileapp/server.go | 52 +- internal/state/check_in_applies_test.go | 45 ++ internal/state/models.go | 47 +- internal/state/participants.go | 3 +- scripts/screenshots/lib/api.mjs | 5 +- scripts/screenshots/recipes/public.mjs | 18 +- specs/004-elevated-password/spec.md | 17 +- specs/openapi.yaml | 317 +++----- .../js/__tests__/admin_elevated.test.jsx | 149 ---- .../create_tournament_validation.test.jsx | 16 +- .../delete_competition_confirm.test.jsx | 52 ++ .../js/__tests__/fighting_spirit.test.jsx | 11 +- .../js/__tests__/mp_turx_knockout.test.jsx | 6 +- .../no_admin_password_header.test.jsx | 50 ++ .../render/admin_competition.render.test.jsx | 112 ++- .../admin_create_competition.render.test.jsx | 17 + ...missing_id_disables_writes.render.test.jsx | 1 - .../admin_registration_desk.render.test.jsx | 21 +- .../admin_setup_shiaijo.render.test.jsx | 24 +- .../create_tournament_labels.render.test.jsx | 17 +- ...elete_competition_password.render.test.jsx | 126 +++ ...idual_editor_tap_back_kept.render.test.jsx | 23 + .../render/settings_mount_harness.jsx | 1 - .../render/sweep_form_labels.render.test.jsx | 7 +- ...amp_cleared_on_landed_save.render.test.jsx | 146 ++++ .../js/__tests__/self_run_no_checkin.test.jsx | 75 ++ web-mobile/js/admin.jsx | 14 +- web-mobile/js/admin_competition.jsx | 13 +- web-mobile/js/admin_competition_overview.jsx | 15 +- web-mobile/js/admin_competition_settings.jsx | 72 +- web-mobile/js/admin_helpers.jsx | 69 -- web-mobile/js/admin_participants.jsx | 15 +- web-mobile/js/admin_registration_desk.jsx | 38 +- web-mobile/js/admin_scoring_autosave.jsx | 22 +- web-mobile/js/admin_scoring_individual.jsx | 17 +- web-mobile/js/admin_scoring_team.jsx | 38 +- web-mobile/js/admin_setup.jsx | 79 +- web-mobile/js/admin_shell.jsx | 3 +- web-mobile/js/api_client.jsx | 102 +-- web-mobile/js/app.jsx | 74 +- web-mobile/js/competition_shape.jsx | 16 + web-mobile/js/ui.jsx | 59 +- web-mobile/js/viewer_competition.jsx | 2 +- web-mobile/js/viewer_home.jsx | 2 +- web-mobile/js/viewer_schedule.jsx | 4 +- web-mobile/js/viewer_watchlist_core.jsx | 7 +- 87 files changed, 2841 insertions(+), 2431 deletions(-) create mode 100644 internal/engine/checkin_self_run_test.go delete mode 100644 internal/mobileapp/auth_admin.go delete mode 100644 internal/mobileapp/auth_admin_test.go create mode 100644 internal/mobileapp/confirm_password.go create mode 100644 internal/mobileapp/confirm_password_test.go delete mode 100644 internal/mobileapp/handlers_auth_admin.go delete mode 100644 internal/mobileapp/handlers_auth_admin_test.go create mode 100644 internal/mobileapp/self_run_no_checkin_test.go create mode 100644 internal/state/check_in_applies_test.go delete mode 100644 web-mobile/js/__tests__/admin_elevated.test.jsx create mode 100644 web-mobile/js/__tests__/delete_competition_confirm.test.jsx create mode 100644 web-mobile/js/__tests__/no_admin_password_header.test.jsx create mode 100644 web-mobile/js/__tests__/render/delete_competition_password.render.test.jsx create mode 100644 web-mobile/js/__tests__/render/team_editor_row_stamp_cleared_on_landed_save.render.test.jsx create mode 100644 web-mobile/js/__tests__/self_run_no_checkin.test.jsx diff --git a/docs/architecture/data-model.md b/docs/architecture/data-model.md index 065b52822..abbdfe3f2 100644 --- a/docs/architecture/data-model.md +++ b/docs/architecture/data-model.md @@ -18,8 +18,12 @@ Inspectability, repairability and diffability matter more here than normalisatio * **Repairable.** An organiser can correct a wrong cell by hand, and the app picks it up on reload. * **Diffable.** The whole tournament state can be committed to version control or copied to a USB stick as a backup. Two copies can then be compared line by line. - One exception: `tournament.md` holds the tournament and admin passwords in plain text, so - strip or change them before sharing a copy. + One exception: `tournament.md` holds the admin password in plain text, so strip or + change it before sharing a copy. A `tournament.md` that carries an `admin_password` field + may hold another password in plain text there. The app ignores that field and drops it the + next time it writes the file; remove it by hand before sharing a copy that still has it. + Refer to + [Upgrading from an earlier release](../user-guide/organisers/operating-modes.md#upgrading-from-an-earlier-release). The trade this makes is described honestly in [section 6](#6-how-the-model-maps-onto-rows). diff --git a/docs/architecture/network-architecture.md b/docs/architecture/network-architecture.md index a1c4ccd6d..aee1e057a 100644 --- a/docs/architecture/network-architecture.md +++ b/docs/architecture/network-architecture.md @@ -152,8 +152,8 @@ Key client mechanisms (all in `web-mobile/js/api_client.jsx` + consumers): |---|---| | Half-open / stalled sockets | 12s write timeouts, 35s SSE silence watchdog (armed at connect, not only `onopen`) | | Reconnect storms | exponential backoff + jitter (vs. a fixed delay) | -| Lost writes | durable outbox persisted to `localStorage`, retried until it lands, with no age limit; survives tab refresh. Each pending write is its own entry, and each is sent on its own. A flush pass sends the entries in the order they were made (their `modifiedAt`), but one that does not land (offline, or a 5xx that keeps coming back) holds back nothing after it, and a new write goes straight to the server whatever is queued, so holding it back would gain nothing (refer to **Order of writes** in this table) and would let one failing write stop every write after it. A later running update never takes the place of a queued Finish or decision, and a decision and a score for the same match are both kept; only a score write folds into the score write queued just before it (a running update into a running update, a Finish into either, as when the editor re-sends a held Finish), naming every group either changed. A lineup save is the other write that folds: it takes the place of a queued save of the same lineup, because a lineup save restates the whole lineup and carries no time the server could order it by. A refusal on replay is always reported, never taken as success. Every tab of the app shares the stored outbox. A tab saving its queue merges into it: it adds its own writes, keeps the newer copy where two tabs hold the same entry, and removes only the writes it settled. Each tab also takes up the writes another tab stores, so a write queued in a tab that then closes is still sent by a tab left open. A write two tabs hold can be sent by both, which is harmless, because the server orders writes by their stamps (refer to **Order of writes** in this table) | -| Order of writes | nothing a device sends is thrown away, and changes are put in the order they were made (the write's `modifiedAt`), never the order they arrive. A device stamps a write with its estimate of the server's time, and never earlier than the match it was made against: two devices' estimates can differ by up to the 5 s the server accepts, so a change made a moment after a write stamped by a device running ahead would otherwise read as older than the write it changed. The stamp is therefore the later of the device's time and the stamp of the match as the operator saw it plus 1 ms (for an autosave, as it was at the tap), never more than 4 s past the device's time. A score write names the groups of the match it changes (`changed`: `points`, `result`, `encho`, `flags`, `rep`, each side's representative pick, `repPickA` and `repPickB`, or one bout row, `bout:`). The score editor works this out by comparing what it sends with the match it was showing. The server applies each named group only if the write is not older than the last change applied to that group, and never touches a group the write does not name, so two devices changing different things both land whatever order they arrive in. A named group a newer change outranks is not applied: its value is kept in the match's history (`GET /api/competitions/:id/matches/:mid/history`), which the score editors show under **History**. A write applied in part answers with `heldGroups`; one none of which applied answers `{"applied": false, "reason": "superseded"}` with the same list, and the editor says "Not applied" rather than "Not saved". A change that would leave a finished match without the winner it needs (a knockout match left tied, an engi match with no valid flag count) is not applied either: the match keeps its recorded result, the change is kept in the history, and the answer adds `heldReason: "needs_winner"`, on which the editor tells the operator to correct the result with a winner. A change from a board still scoring a match, or its overtime, that a kiken, fusenpai, or fusensho (a withdrawal, or a win recorded for a competitor barred elsewhere) already closed is held as well: that decision stands, and the history gives the reason. The other arrival order is answered applied: an older finish that arrives after a newer scoring change which would leave it without a winner is recorded, that later change is moved to the history (an entry of its own, at its own time), and the answer names it in `displacedGroups` with `heldReason: "needs_winner"` and no `heldGroups`; the editor says "Saved" and what was moved, and a held finish replayed this way raises one information alert. A representative pick made after its representative bout was removed is moved to the history the same way when the removal arrives, and that answer names it in `displacedGroups` with no `heldReason`. Where the outbox folds a queued running update into a later one, the later one names every group either changed. The same holds for the writes that are not score sheets: an older save the same device had already followed with a newer one (an out-of-order delivery after a reconnect) is kept whole in the history as an "older revision of this board"; a winner set by hand that a newer result outranks is kept in the history with the winner it named, and answered like a held score; an engi result is ordered by when it was saved, its flags and the winner they decide kept together. A queued decision or finish whose first send landed but whose answer was lost is answered as recorded when it is sent again: the same write under the same stamp changes nothing, so it is not asked for a correction reason and nothing is written | +| Lost writes | durable outbox persisted to `localStorage`, retried until it lands, with no age limit; survives tab refresh. Each pending write is its own entry, and each is sent on its own. A flush pass sends the entries in the order they were made (their `modifiedAt`), but one that does not land (offline, or a 5xx that keeps coming back) holds back nothing after it, and a new write goes straight to the server whatever is queued, so holding it back would gain nothing (refer to **Order of writes** in this table) and would let one failing write stop every write after it. A later running update never takes the place of a queued Finish or decision, and a decision and a score for the same match are both kept; only a score write folds into the score write queued just before it (a running update into a running update, a Finish into either, as when the editor re-sends a held Finish), naming every group either changed. A lineup save is the other write that folds: it joins a queued save of the same lineup, which then names every position either changed, with the later value where both changed one. A lineup save names only the positions it changed (`changed`), and the server lands only those, but it carries no time the server could order it by, so a save sent straight to the server could land before the queued one and lose to it on a position both changed. A refusal on replay is always reported, never taken as success. Every tab of the app shares the stored outbox. A tab saving its queue merges into it: it adds its own writes, keeps the newer copy where two tabs hold the same entry, and removes only the writes it settled. Each tab also takes up the writes another tab stores, so a write queued in a tab that then closes is still sent by a tab left open. A write two tabs hold can be sent by both, which is harmless, because the server orders writes by their stamps (refer to **Order of writes** in this table) | +| Order of writes | nothing a device sends is thrown away, and changes are put in the order they were made (the write's `modifiedAt`), never the order they arrive. A device stamps a write with its estimate of the server's time, and never earlier than the match it was made against: two devices' estimates can differ by up to the 5 s the server accepts, so a change made a moment after a write stamped by a device running ahead would otherwise read as older than the write it changed. The stamp is therefore the later of the device's time and the stamp of the match as the operator saw it plus 1 ms (for an autosave, as it was at the tap), never more than 4 s past the device's time. A score write names the groups of the match it changes (`changed`: `points`, `result`, `encho`, `flags`, `rep`, each side's representative pick, `repPickA` and `repPickB`, or one bout row, `bout:`). The score editor works this out by comparing what it sends with the match it was showing. The server applies each named group only if the write is not older than the last change applied to that group, and never touches a group the write does not name, so two devices changing different things both land whatever order they arrive in. A named group a newer change outranks is not applied: its value is kept in the match's history (`GET /api/competitions/:id/matches/:mid/history`), which the score editors show under **History**. A write applied in part answers with `heldGroups`; one none of which applied answers `{"applied": false, "reason": "superseded"}` with the same list, and the editor says "Not applied" rather than "Not saved". A change that would leave a finished match without the winner it needs (a knockout match left tied, an engi match with no valid flag count) is not applied either: the match keeps its recorded result, the change is kept in the history, and the answer adds `heldReason: "needs_winner"`, on which the editor tells the operator to correct the result with a winner. A change from a board still scoring a match, or its overtime, that a kiken, fusenpai, or fusensho (a withdrawal, or a win recorded for a competitor barred elsewhere) already closed is held as well: that decision stands, and the history gives the reason. The other arrival order is answered applied: an older finish that arrives after a newer scoring change which would leave it without a winner is recorded, that later change is moved to the history (an entry of its own, at its own time), and the answer names it in `displacedGroups` with `heldReason: "needs_winner"` and no `heldGroups`; the editor says "Saved" and what was moved, and a held finish replayed this way raises one information alert. A representative pick made after its representative bout was removed is moved to the history the same way when the removal arrives, and that answer names it in `displacedGroups` with no `heldReason`. Where the outbox folds a queued running update into a later one, the later one names every group either changed. The same holds for the writes that are not score sheets: an older save the same device had already followed with a newer one (an out-of-order delivery after a reconnect) is kept whole in the history as an "older revision of this board"; a winner set by hand that a newer result outranks is kept in the history with the winner it named, and answered like a held score; an engi result is ordered by when it was saved, its flags and the winner they decide kept together. A queued decision or finish whose first send landed but whose answer was lost is answered as recorded when it is sent again: the same write under the same stamp changes nothing, so it is not asked for a correction reason and nothing is written to the match; the match's history gains a line saying the write arrived and changed nothing | | Edits not yet sent | a score editor holds each edit for a short autosave window (300ms) before it writes it. The sync pill reads "Syncing…" from the first tap, not "Synced". If the editor goes away inside that window (closed, Prev/Next, another match picked, a court switch), the edit is written at once. If the tab is hidden or unloaded inside it, the edit is written straight into the outbox, so a reload does not lose it. An autosave carries the time of the tap, not the time it is sent, so it never outranks a result another device recorded after the tap. A running write already sent but not yet answered is also kept when the page goes away, which cancels its request; while the request is still open, that copy is not sent | | Server errors (5xx / 429), and 403 | write stays queued and keeps retrying until it lands, holding back no other write; after 10 consecutive rejections the operator gets a notice and the sync pill shows "Not saving", and the match's score editor offers **Discard held result**, which discards that write and nothing else after a confirm. The held-writes count in the admin topbar opens a list of every held write (score updates, results, decisions, winners set by hand, lineups), where any write that keeps failing can be discarded the same way, one at a time; a write only waiting for the connection cannot be discarded there. On this server 403 is never a bad credential (that is 401): it means the tournament is not configured yet, or is missing its password. Only an admin fixing the server state clears it, so signing in again cannot help and the write keeps retrying instead of parking | | Invalid credential (401) | the one 4xx a retry can fix: the write is parked, not discarded, stops retrying, and shows "Sign in to save"; signing in again re-sends it with the new credential, together with every other queued write still carrying the refused password (one queued after it, before its own 401 came back), so one sign-in is enough. A write queued without a password (a competitor's in a self-run event) is never given one | diff --git a/docs/user-guide/commands/mobile-app.md b/docs/user-guide/commands/mobile-app.md index 34b1e055e..3ec9da4fa 100644 --- a/docs/user-guide/commands/mobile-app.md +++ b/docs/user-guide/commands/mobile-app.md @@ -25,6 +25,7 @@ Refer to the [Tournament app guide](../organisers/run-tournament.md) for a full | `BIND_ADDRESS` | `--bind` default | Initial value for `--bind` if the flag isn't set. | | `LOCK_PASSWORD` | `--lock-password` default | `true` enables locked mode without passing the flag. | | `TOURNAMENT_PASSWORD_HASH` | locked mode auth | Bcrypt hash of the admin password. Required when `--lock-password` is set. Generate with [`hash-password`](hash-password.md). | +| `TOURNAMENT_ADMIN_PASSWORD_HASH` | nothing | Ignored. When it is set, the server logs a line at startup saying so. The admin password above covers every action. Refer to [Upgrading from an earlier release](../organisers/operating-modes.md#upgrading-from-an-earlier-release). | ## Authentication diff --git a/docs/user-guide/competitors/self-run.md b/docs/user-guide/competitors/self-run.md index 579bb8005..61f6bca6b 100644 --- a/docs/user-guide/competitors/self-run.md +++ b/docs/user-guide/competitors/self-run.md @@ -18,15 +18,15 @@ When the tournament is self-run, a public self-registration page is open for ind If the form says someone with your name is already registered, your name and dojo are already on the list and you are done. A namesake from another dojo registers normally. If you are a different person with the same name at the same dojo, ask the organiser to add you. -## Check in on the day +## Check-in -If the competition uses check-in, mark yourself present before the organiser generates the draw. Check-in happens at the **Registration desk**, which you open from the dashboard: it gathers the competitors of every competition that uses check-in, so find your name there and mark yourself present. Self-run mode does not require a password for this. Competitors who have not checked in are excluded from the draw. +Self-run mode has no check-in. There is nothing to mark on the day: the draw includes everyone on the roster, so make sure your name is on it before the organiser generates the draw. ## Report your own score You can record your match result directly from the public viewer, without the admin password. A result you enter is tagged as self-reported; a result entered by an operator is tagged as admin. -Only everyday match outcomes are available to self-reporters. Decisions that require an official ruling remain with the operator, and so do destructive actions such as editing the roster. For a full list of outcomes and what they mean, refer to [Recording decisions](../court-operators/recording-decisions.md). +Only everyday match outcomes are available to self-reporters. Decisions that require an official ruling remain with the operator, and so do destructive actions such as deleting a competition or discarding a draw. For a full list of outcomes and what they mean, refer to [Recording decisions](../court-operators/recording-decisions.md). When a knockout team match finishes level, you can add the representative bout ([daihyosen](../court-operators/recording-decisions.md#daihyosen)) from the score sheet, score it like any other bout, and finish the match on the winner it decides. A kachinuki match has no representative bout: a tied pairing that must produce a result is settled by encho on that same bout (refer to [Scoring a kachinuki encounter](../organisers/team-tournaments.md#scoring-a-kachinuki-encounter)). A representative bout added by mistake can be removed while it is still unscored, until the match finishes; after that, only the organiser can add or remove one. The score sheet does not offer a judges' decision (hantei): that stays with the organiser, on the representative bout as on every other bout. When the organiser records one, the score sheet shows the representative bout as decided and you cannot change it, but you can still finish the match on that decision. diff --git a/docs/user-guide/organisers/operating-modes.md b/docs/user-guide/organisers/operating-modes.md index ef807b14f..33700733e 100644 --- a/docs/user-guide/organisers/operating-modes.md +++ b/docs/user-guide/organisers/operating-modes.md @@ -8,13 +8,17 @@ Tournament mode controls who can score matches and manage competitions. You choo ### Officiated mode -Officiated mode is the default. Every action (scoring, check-in, starting, and completing competitions) requires the admin password. All recorded results carry the label "admin" to show they were entered by an operator. +Officiated mode is the default. Every action (scoring, check-in, starting, and completing competitions) requires the admin password. Once you are signed in, one action asks for the password again: **Delete competition** (in the competition's Settings) opens a dialog that names the competition and asks you to type the admin password. A wrong password leaves the dialog open with "Wrong password. The competition was not deleted." and you stay signed in. The server checks the password typed in the dialog, so a request that does not carry it is refused too. Every other action, discarding a draw and marking a competition invalid included, needs no second password. All recorded results carry the label "admin" to show they were entered by an operator. ### Self-run mode -In self-run mode, scoring, check-in, and starting competitions are open to anyone without a password, so competitors or table helpers can run and score their own matches. Completing a competition is irreversible, so it stays behind the destructive-ops password (refer to [Destructive-ops password](#destructive-ops-password)). A public self-registration page also becomes available for competitors to sign themselves up, for individual competitions only and only while the competition is still in setup, before the draw is generated. +In self-run mode, scoring and starting a competition are open to anyone without a password, so competitors or table helpers can run and score their own matches. Completing a competition needs no password either: in self-run mode the server accepts it from anyone. The **Complete competition** button is on the organiser's competition page, which you reach by signing in. A public self-registration page also becomes available for competitors to sign themselves up, for individual competitions only and only while the competition is still in setup, before the draw is generated. -Two kinds of action stay gated in self-run mode. Organiser setup (creating and editing competitions, tournament settings, seeds, scheduling, the **Lineups** page, match decisions such as kiken, and exports) still requires the admin password. Destructive actions (deleting a competition, discarding a draw, editing the roster, and completing a competition) require the destructive-ops password (refer to [Destructive-ops password](#destructive-ops-password)). +Organiser actions stay behind the admin password in self-run mode: creating and editing competitions, tournament settings, the tournament logo and sponsor images, seeds, scheduling, the **Lineups** page, match decisions such as kiken, league tie-breaker decisions (adding or removing tie-breaker matches, and accepting shared ranks), exports, fighting-spirit awards, and every action that destroys data (deleting or invalidating a competition, discarding a draw, resetting manual overrides, and importing competitions). + +The roster is open to anyone in self-run mode: anyone can add and edit a competition's participants, without the password, before the competition starts. + +Self-run mode has no check-in. The **Check-in tracking** setting is not offered, the **Registration desk** is not offered, the server refuses every check-in request, and the draw includes every participant on the roster. A competition keeps the check-in setting it was saved with, but a self-run tournament ignores it. Competitors can name the fighters in a team match from its score sheet, without the password. A team member picked for a bout, or a typed name, is saved with that match, and a typed name fills in a team member who has no name yet or adds a new one. Two changes stay with the organiser: the lineup of a match that has finished, and the name of a team member who already has one. @@ -22,9 +26,6 @@ Competitors also run the representative bout (daihyosen) of a tied knockout team Results in self-run mode carry a provenance label. A score entered without a password is tagged "self-reported"; a score entered by an authenticated operator is tagged "admin". Officiated mode always produces "admin" results. -!!! note - In file mode, self-run requires a destructive-ops password. Without one, destructive actions would be unprotected, so the app refuses to create or save a self-run tournament until a destructive-ops password is set (you can set it in the same step). - Refer to the [Competitor self-run guide](../competitors/self-run.md) for the attendee-facing workflow. ## Admin authentication mode @@ -67,50 +68,20 @@ In locked mode: Refer to the [`hash-password` command reference](../commands/hash-password.md) for full details. -## Destructive-ops password - -The destructive-ops password is a second, separately held credential that gates actions which are difficult to reverse. It lets table staff hold the main admin password for scoring, check-in, and match management, so they cannot accidentally or maliciously destroy data. - -Actions gated by the destructive-ops password include: - -- Deleting or invalidating a competition -- Discarding a generated draw -- Resetting manual overrides for a competition -- Adding, editing, or replacing participants -- Importing competitions -- Recording or editing fighting-spirit awards -- Completing a competition (completing is irreversible) - -The following are NOT gated by the destructive-ops password: scoring, match decisions, check-in, starting a competition, lineup management, or the `/reset` path. - -The admin UI prompts for the destructive-ops password each time you request a gated action. There is no cached session between prompts. - -### In file mode - -Set the destructive-ops password from **Admin > Edit details > Destructive-ops password**. The field is write-only and is never displayed. Changing it after it has been set requires supplying the current value first. While the field is unset, gated actions fall back to requiring the main admin password only. If you forget it, edit the `admin_password` field in `tournament.md` between rounds, as with any hand edit to the data folder. The main admin password is the `password` field in the same file. - -### In locked mode - -Supply the destructive-ops password as a bcrypt hash in the `TOURNAMENT_ADMIN_PASSWORD_HASH` environment variable. It cannot be changed through the UI. - -```bash -printf '%s' "$MY_DESTRUCTIVE_OPS_SECRET" | bracket-creator hash-password - -TOURNAMENT_PASSWORD_HASH='$2a$10$...main...' \ - TOURNAMENT_ADMIN_PASSWORD_HASH='$2a$10$...destructive...' \ - bracket-creator mobile-app --lock-password -f ./tournament-data -``` - -In locked mode, the destructive-ops gate is always active. If `TOURNAMENT_ADMIN_PASSWORD_HASH` is unset or malformed, the gated actions are refused regardless of the admin password supplied. A malformed hash does not prevent the server from starting, but no destructive action can be completed until a valid hash is provided. - ## Security notes -The admin and destructive-ops passwords provide privilege separation for shared-credential operation at an event. They are not a network-security boundary on their own. +The admin password is not a network-security boundary on its own. -Over plain HTTP, both passwords travel in clear text on every request. Anyone with filesystem access to `tournament.md` can read both passwords in file mode. +Over plain HTTP, the password travels in clear text on every request. Anyone with filesystem access to `tournament.md` can read it in file mode. For a real security boundary: - Run the server behind TLS. Refer to [Hosting](../install/hosting.md) for guidance. - Use locked mode for any deployment reachable from the internet. - In file mode, the password reset is unauthenticated: anyone who can reach the server can set a new admin password without knowing the current one, which locks out the operator or takes over the tournament. Locked mode disables reset entirely, so any internet-exposed deployment should use locked mode. + +### Upgrading from an earlier release + +The admin password is the only organiser password. Anyone who signs in with it can do every organiser action, including discarding a draw, and deleting a competition once they type the password again. If some staff should not be able to do this, give the tournament a new admin password and share it only with the people who should have it. + +A `tournament.md` that carries an `admin_password` field loads normally; the field is ignored. It may hold another password in plain text. The app drops the field the next time it writes `tournament.md`, for example when you change the tournament settings; until then, remove the line by hand before you share a copy of the data folder. The `TOURNAMENT_ADMIN_PASSWORD_HASH` environment variable is ignored: when it is set, the server logs a line at startup saying so, and you can remove it from your start-up scripts. diff --git a/docs/user-guide/organisers/run-tournament.md b/docs/user-guide/organisers/run-tournament.md index 2c98a61f2..c15bc6c43 100644 --- a/docs/user-guide/organisers/run-tournament.md +++ b/docs/user-guide/organisers/run-tournament.md @@ -83,7 +83,7 @@ A court call, made with **Call to court** on the court console, is announced the ## Registration desk -Open **Registration desk** from the dashboard to access the check-in surface for the welcome table. Check-in exists only for competitions with **Check-in tracking** turned on in their Settings, and this desk is the one place to do it: it lists every competitor across those competitions so a registration helper can mark participants present as they arrive. A competition with the setting off does not appear here, and its Ordering & seeding panel carries no check-in controls either. Refer to the [Check-in workflow](#check-in-workflow). +Open **Registration desk** from the dashboard to access the check-in surface for the welcome table. Check-in exists only for competitions with **Check-in tracking** turned on in their Settings, and this desk is the one place to do it: it lists every competitor across those competitions so a registration helper can mark participants present as they arrive. A competition with the setting off does not appear here, and its Ordering & seeding panel carries no check-in controls either. A self-run tournament has no check-in, so the dashboard offers no Registration desk there. Refer to the [Check-in workflow](#check-in-workflow). ## Set up a competition @@ -197,7 +197,7 @@ Click the pencil icon on any row to open the edit modal for that competitor. You ### Check-in workflow -Enable **Check-in tracking** in **Settings** for the competition. Competitors are checked in at the [Registration desk](#registration-desk), which works across every competition; the Ordering & seeding panel carries no check-in controls. +Enable **Check-in tracking** in **Settings** for the competition. Competitors are checked in at the [Registration desk](#registration-desk), which works across every competition; the Ordering & seeding panel carries no check-in controls. Self-run mode has no check-in: the setting is not offered and the draw includes every participant on the roster ([operating modes](operating-modes.md)). The check-in rule is opt-in: when you click **Generate draw**, if at least one participant is checked in, only checked-in participants join the draw and unchecked participants are excluded (their seeds are dropped). If nobody is checked in, everyone is included. @@ -251,13 +251,15 @@ For naginata and Engi-kyogi divisions, refer to [Naginata](naginata.md). The public viewer shows a competition's podium when it finishes, and a provisional ranking while it is still in progress. +A knockout or pools-then-knockout competition finishes when you tap **Complete competition** on its page. The button appears once every bracket match, the bronze match included when there is one, has been played, and completing cannot be undone. A league finishes by itself after its last match and any tie-breakers. In a team league, when a tie left after every ranking criterion is consequential, you first run or decline the tie-breaker; any other tie is left as a shared rank with no decision (refer to [Team standings and tie-breaks](team-tournaments.md#team-standings-and-tie-breaks)). A Swiss competition never finishes in this sense. After its last round, its organiser page shows a **Competition complete** notice and a link to the final standings, which the public viewer also shows, but there is no **Complete competition** button for it and it does not finish by itself, so the public viewer shows no podium for it. + The **Award two joint 3rd places** setting, on the competition's Settings tab, controls whether a competition awards two joint 3rd places or decides a single one with a bronze match. It is on by default (the standard kendo convention) and applies to knockout, pools-then-knockout, and league competitions. It is not shown for Swiss, which has no bracket and no bronze match. Naginata competitions default the setting off (a single 3rd, decided by a bronze match). You can turn it back on for a naginata competition too, and you can turn it off for a kendo competition that needs a single 3rd, for example a selection event with one bronze medal. Refer to [Naginata](naginata.md#third-place-match) for the bronze match itself. - **Knockout** (default: joint 3rds on): 1st place, 2nd place, and two equal 3rd places. There is no bronze match; both semi-final losers share third. - **Knockout with joint 3rds off**: a single 3rd place is decided by a bronze match. - **Pools + Knockout** (still in its pool phase): the viewer shows a provisional cross-pool ranking until the knockout decides the final places. -Operators see an all-competition winners view from the dashboard. You can also record optional **fighting-spirit** (敢闘賞) awards as free text; these appear on the viewer for all spectators. Saving awards requires the destructive-ops password in self-run mode; refer to [Operating modes](operating-modes.md#destructive-ops-password). +Operators see an all-competition winners view from the dashboard. You can also record optional **fighting-spirit** (敢闘賞) awards as free text; these appear on the viewer for all spectators. Saving awards is an organiser action, so it needs the admin password in self-run mode too; refer to [Operating modes](operating-modes.md#self-run-mode). **League competitions** derive the podium from final standings. In an individual league, any tie within the top three places triggers a short ippon-shobu tie-breaker automatically, so the competition never closes with an unearned tie. Engi kata competitions never hold supplementary bouts; they rank by wins, then accumulated flags (refer to [Naginata](naginata.md#standings)). The one exception is 3rd place: with **Award two joint 3rd places** enabled, competitors tied entirely for third share the position instead, with no decider. In a team league, the operator chooses whether to run a tie-breaker or accept a tie at any position; refer to [Team standings and tie-breaks](team-tournaments.md#team-standings-and-tie-breaks). @@ -287,7 +289,7 @@ When the **Public URL** is set and competitors have assigned numbers, each print Tournament state is stored as plain files inside the data folder you specified with `--folder`. You can hand-edit these files between rounds when a correction is needed: -- `tournament.md`: YAML front-matter with the tournament name, date, venue, court count, and the admin password and destructive-ops password. +- `tournament.md`: YAML front-matter with the tournament name, date, venue, court count, and the admin password. - `competitions//config.md`: YAML front-matter with competition kind, format, pool settings, and courts. - `competitions//participants.csv`: one participant per line with name, optional display name (zekken), dojo, and optional dan grade. diff --git a/internal/engine/checkin_self_run_test.go b/internal/engine/checkin_self_run_test.go new file mode 100644 index 000000000..10ac867a9 --- /dev/null +++ b/internal/engine/checkin_self_run_test.go @@ -0,0 +1,126 @@ +package engine + +import ( + "testing" + + "github.com/gitrgoliveira/bracket-creator/internal/state" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// Operator ruling 2026-10-13: "Self run mode has no check-in." The draw, the +// Swiss field and the schedule estimate all ask Competition.CheckInApplies, so +// a competition whose stored CheckInEnabled is true still draws everyone in a +// self-run tournament, while an officiated one keeps filtering. + +func saveTournamentMode(t *testing.T, store *state.Store, mode string) { + t.Helper() + require.NoError(t, store.SaveTournament(&state.Tournament{ + Name: "Cup", Courts: []string{"A"}, Mode: mode, + })) +} + +var checkInRoster = []string{"Alice", "Bob", "Charlie", "Dave", "Eve"} +var checkInFour = map[string]bool{"Alice": true, "Bob": true, "Charlie": true, "Dave": true} + +func TestSelfRun_KnockoutDraw_IgnoresCheckIn(t *testing.T) { + eng, store, _ := setupTestEngine(t) + saveTournamentMode(t, store, state.TournamentModeSelfRun) + compID := "selfrun-knockout" + createTestCompetition(t, store, compID, "knockout", 0) + enableCheckIn(t, store, compID) + saveParticipantsWithCheckIn(t, store, compID, checkInRoster, checkInFour) + + require.NoError(t, eng.StartCompetition(compID)) + + bracket, err := store.LoadBracket(compID) + require.NoError(t, err) + names := bracketLeafNames(bracket) + assert.Len(t, names, 5, "self-run draws the whole roster") + assert.Contains(t, names, "Eve", "a participant who is not checked in is still drawn") + + comp, err := store.LoadCompetition(compID) + require.NoError(t, err) + assert.True(t, comp.CheckInEnabled, "the stored setting is not rewritten") +} + +func TestSelfRun_PoolDraw_IgnoresCheckIn(t *testing.T) { + eng, store, _ := setupTestEngine(t) + saveTournamentMode(t, store, state.TournamentModeSelfRun) + compID := "selfrun-pools" + createTestCompetition(t, store, compID, "league", 3) + enableCheckIn(t, store, compID) + saveParticipantsWithCheckIn(t, store, compID, + []string{"Alice", "Bob", "Charlie", "Dave", "Eve", "Frank"}, + map[string]bool{"Alice": true, "Bob": true, "Charlie": true, "Dave": true}) + + require.NoError(t, eng.StartCompetition(compID)) + + pools, err := store.LoadPools(compID) + require.NoError(t, err) + assert.Len(t, poolPlayerNames(pools), 6, "self-run draws the whole roster") +} + +func TestOfficiated_KnockoutDraw_StillFiltersByCheckIn(t *testing.T) { + eng, store, _ := setupTestEngine(t) + saveTournamentMode(t, store, state.TournamentModeOfficiated) + compID := "officiated-knockout" + createTestCompetition(t, store, compID, "knockout", 0) + enableCheckIn(t, store, compID) + saveParticipantsWithCheckIn(t, store, compID, checkInRoster, checkInFour) + + require.NoError(t, eng.StartCompetition(compID)) + + bracket, err := store.LoadBracket(compID) + require.NoError(t, err) + names := bracketLeafNames(bracket) + assert.Len(t, names, 4) + assert.NotContains(t, names, "Eve") +} + +func TestSelfRun_SwissRoundOne_IgnoresCheckIn(t *testing.T) { + eng, store, _ := setupTestEngine(t) + saveTournamentMode(t, store, state.TournamentModeSelfRun) + compID := "selfrun-swiss" + require.NoError(t, store.SaveCompetition(&state.Competition{ + ID: compID, Name: "Swiss", Kind: "individual", Format: state.CompFormatSwiss, + SwissRounds: 3, Courts: []string{"A"}, StartTime: "09:00", Status: "setup", + CheckInEnabled: true, + })) + saveParticipantsWithCheckIn(t, store, compID, + []string{"Alice", "Bob", "Charlie", "Dave", "Eve", "Frank"}, + map[string]bool{"Alice": true, "Bob": true, "Charlie": true, "Dave": true}) + + matches, err := eng.GenerateSwissRound(compID, 1) + require.NoError(t, err) + + seen := map[string]bool{} + for _, m := range matches { + seen[m.SideA] = true + seen[m.SideB] = true + } + assert.Contains(t, seen, "Eve") + assert.Contains(t, seen, "Frank") +} + +func TestSelfRun_ScheduleEstimate_CountsWholeRoster(t *testing.T) { + eng, store, _ := setupTestEngine(t) + saveTournamentMode(t, store, state.TournamentModeSelfRun) + compID := "selfrun-estimate" + require.NoError(t, store.SaveCompetition(&state.Competition{ + ID: compID, Format: state.CompFormatKnockout, Kind: "individual", + Courts: []string{"A"}, StartTime: "09:00", KnockoutMatchDurationSeconds: 300, + Status: state.CompStatusSetup, CheckInEnabled: true, + })) + // 8 registered, 4 checked in: the estimate must count all 8 (7 bouts). + saveParticipantsWithCheckIn(t, store, compID, + []string{"Alice", "Bob", "Charlie", "Dave", "Eve", "Frank", "Grace", "Hank"}, + map[string]bool{"Alice": true, "Bob": true, "Charlie": true, "Dave": true}) + + est, err := eng.EstimateScheduleForCompetition(compID) + require.NoError(t, err) + + comp, _ := store.LoadCompetition(compID) + tourn, _ := store.LoadTournament() + assert.Equal(t, EstimateForCounts(0, 7, comp, tourn).TotalDurationMinutes, est.TotalDurationMinutes) +} diff --git a/internal/engine/competition.go b/internal/engine/competition.go index 77ff52b45..a6e2fc9ea 100644 --- a/internal/engine/competition.go +++ b/internal/engine/competition.go @@ -15,6 +15,36 @@ import ( // element-wise equal (used by StartCompetition's mid-pipeline // settings-drift check). nil and empty slices are treated as // equivalent, both mean "no courts" from the config's POV. +// PoolMatchesSettled reports whether a competition's pool-matches.csv rows +// (pool, league and Swiss matches alike) are all played: every match is +// completed, and a supplementary tie-break or representative bout also names +// a winner, since one decided without a winner (a hikiwake) leaves the tie it +// was played for unresolved. It is the rule a league is completed by +// (maybeAutoCompletePoolsRunning) and the one POST /complete asks of a +// competition with no knockout rounds to check. Vacuously true with no rows. +func PoolMatchesSettled(matches []state.MatchResult) bool { + for _, m := range matches { + if m.Status != state.MatchStatusCompleted { + return false + } + if (IsPoolDaihyosenMatchID(m.ID) || IsTiebreakerMatchID(m.ID)) && m.Winner == "" { + return false + } + } + return true +} + +// SwissRoundsToDraw reports how many of a Swiss competition's configured +// rounds have not been drawn yet: the inverse of the bound AdvanceSwissRound +// refuses a further round by. 0 for any other format, and for a Swiss +// competition with no configured round count. +func SwissRoundsToDraw(comp *state.Competition) int { + if comp == nil || comp.Format != state.CompFormatSwiss || comp.SwissRounds <= 0 || comp.SwissCurrentRound >= comp.SwissRounds { + return 0 + } + return comp.SwissRounds - comp.SwissCurrentRound +} + func courtsEqual(a, b []string) bool { if len(a) != len(b) { return false @@ -462,13 +492,8 @@ func (e *Engine) maybeAutoCompletePoolsRunning(compID string, comp *state.Compet if ferr != nil { return nil, ferr } - for _, m := range freshMatches { - if m.Status != state.MatchStatusCompleted { - return nil, nil - } - if (IsPoolDaihyosenMatchID(m.ID) || IsTiebreakerMatchID(m.ID)) && m.Winner == "" { - return nil, nil - } + if !PoolMatchesSettled(freshMatches) { + return nil, nil } comp.Status = state.CompStatusComplete return comp, nil @@ -1149,8 +1174,9 @@ func (e *Engine) runDrawPipeline(id string) error { if err != nil { return err } - // Exclude participants who have not checked in (mp-w7x), but ONLY when the - // competition has check-in tracking enabled (comp.CheckInEnabled). The rest + // Exclude participants who have not checked in (mp-w7x), but ONLY when + // check-in applies to the competition (comp.CheckInApplies: its setting is + // on and the tournament is not self-run, which has no check-in). The rest // of the stack masks checkedIn behind this flag (the viewer derives // checkedIn as `checkInEnabled && p.checkedIn`), so a stale/imported // checked_in marker on a competition that doesn't use check-in must not @@ -1170,7 +1196,7 @@ func (e *Engine) runDrawPipeline(id string) error { // same-named participants by dojo. var excludedByCheckIn map[string]bool fullRoster := players - if comp.CheckInEnabled { + if comp.CheckInApplies(tourn) { excludedByCheckIn = checkInExcludedKeys(players) players = filterCheckedIn(players) } diff --git a/internal/engine/estimate_schedule.go b/internal/engine/estimate_schedule.go index 2ab7a93d2..395fb65f2 100644 --- a/internal/engine/estimate_schedule.go +++ b/internal/engine/estimate_schedule.go @@ -26,7 +26,7 @@ func (e *Engine) EstimateScheduleForCompetition(compID string) (ScheduleEstimate } // Step 3: derive participant count. - playerCount, err := e.estimateParticipantCount(comp) + playerCount, err := e.estimateParticipantCount(comp, tournament) if err != nil { return ScheduleEstimate{}, err } @@ -63,12 +63,12 @@ func (e *Engine) EstimateScheduleForCompetition(compID string) (ScheduleEstimate // estimateParticipantCount returns the number of participants for a // competition, applying the check-in filter when enabled (mirroring // runDrawPipeline's filterCheckedIn opt-in semantics). -func (e *Engine) estimateParticipantCount(comp *state.Competition) (int, error) { +func (e *Engine) estimateParticipantCount(comp *state.Competition, tournament *state.Tournament) (int, error) { players, err := e.store.LoadParticipants(comp.ID, comp.EffectiveWithZekkenName()) if err != nil { return 0, err } - if comp.CheckInEnabled { + if comp.CheckInApplies(tournament) { players = filterCheckedIn(players) } return len(players), nil diff --git a/internal/engine/exact_score_replay_test.go b/internal/engine/exact_score_replay_test.go index b0f8d179c..68f05845a 100644 --- a/internal/engine/exact_score_replay_test.go +++ b/internal/engine/exact_score_replay_test.go @@ -42,14 +42,27 @@ func TestExactScoreReplay(t *testing.T) { {name: "a match not finished", change: func(s, _ *state.MatchResult) { s.Status = state.MatchStatusRunning }}, {name: "another competitor", change: func(_, w *state.MatchResult) { w.SideB = "Carol" }}, {name: "a winner id naming neither side", change: func(_, w *state.MatchResult) { w.WinnerID = "z" }}, + // R3: a point made after the finish landed; the resend's points are + // held by the merge (the stored ones are newer), so they are not its + // to differ in. + {name: "points the merge would hold, changed since", change: func(s, w *state.MatchResult) { + s.GroupStamps = map[string]int64{state.GroupPoints: at + 5_000, state.GroupResult: at} + s.ModifiedAt = at + 5_000 + w.IpponsA = []string{"M"} + }, want: true}, + {name: "points changed since, and another winner", change: func(s, w *state.MatchResult) { + s.GroupStamps = map[string]int64{state.GroupPoints: at + 5_000, state.GroupResult: at} + s.ModifiedAt = at + 5_000 + w.IpponsA, w.IpponsB, w.Winner = []string{}, []string{"M", "K"}, "Bob" + }}, } { t.Run(tc.name, func(t *testing.T) { s, w := stored(), resend() tc.change(s, w) before := *w - assert.Equal(t, tc.want, ExactScoreReplay(s, w, true)) + assert.Equal(t, tc.want, ExactScoreReplay(s, w, nil, true)) assert.Equal(t, before, *w, "the write is judged on a copy") }) } - assert.False(t, ExactScoreReplay(nil, resend(), true)) + assert.False(t, ExactScoreReplay(nil, resend(), nil, true)) } diff --git a/internal/engine/scoring_tx.go b/internal/engine/scoring_tx.go index 88473ead3..e32069fb2 100644 --- a/internal/engine/scoring_tx.go +++ b/internal/engine/scoring_tx.go @@ -52,6 +52,7 @@ import ( "errors" "fmt" "log" + "slices" "sort" "time" @@ -216,88 +217,9 @@ func (e *Engine) RecordMatchResultWithIneligibilityTx(tx state.StoreTx, compID, // stores: a start's own representative-bout row is discarded above, and a // winner derived from it would stand beside a stored bout that says // otherwise. - deriveDaihyosenWinner(result) - - // The bout rows as the writer sent them, before the server's own changes - // below (the kachinuki merge, the default-win padding). A writer that - // named its groups did not name those: noteServerBoutChanges adds them, - // or the merge would put the stored rows back over them. - writerSubs := state.CloneSubResults(result.SubResults) - - // Kachinuki bout logs merge BY POSITION rather than replace wholesale - // (ACID: a client whose local log is behind the server must never - // destroy server-appended bouts). Applied here at the entry point, - // BEFORE the pool/bracket write primitives, so the rollback path - // below (which replays `prior` through those primitives) still - // restores the pre-write state exactly. A merge-time rejection (e.g. a - // kachinuki-exhaustion write ending on a tied bout, mp-gmcg review R2) - // returns BEFORE any write primitive, so nothing is persisted. - if merr := applyKachinukiMerge(comp, prior, result); merr != nil { - return nil, merr - } - - // Default-win bout padding (bc-tmfn follow-up): a non-kachinuki team - // match a default-win ruling (any kiken, fusenpai, or fusensho: - // domain.IsDefaultWinDecisionStr) closes before every numbered bout has - // a result of its own -- most commonly a kiken declared before the - // match's first bout was ever scored -- gets an empty SubMatchResult row - // (Position only) for every missing position 1..TeamSize, via - // state.NeedsDefaultWinBoutPadding / state.PadDefaultWinBoutPositions, the - // SAME predicate and function the legacy-load repair uses - // (state.EnsureLegacyUpgraded). Without a row for every position, - // state.DefaultWinCreditSide's readers (TeamResultFrom, - // accrueTeamSubResults, the Excel export) have nothing to range over for - // the positions nobody fought and silently credit them zero. - // - // Gated on PRIOR's sides and unreadable flag, never result's: a client - // score payload commonly omits SideA/SideB and relies on reconcileSides - // (inside applyPoolWrite/applyBracketResultIn, called later via - // writeToPoolOrBracket) to backfill them from the stored row -- reading - // result.SideA/SideB here would skip padding on exactly that common case - // (a bare kiken payload). Likewise a stored SubResults cell that failed - // to parse must never be padded over: NeedsDefaultWinBoutPadding's - // subResultsUnreadable check keeps this branch from turning an empty - // result.SubResults into a non-empty padded one, which would defeat - // applyPoolWrite's own raw-bytes preservation (it only keeps - // SubResultsRaw when the incoming SubResults is still empty) and destroy - // the only copy of the corrupt cell. prior is guaranteed non-nil here: - // the lookupExistingResult error above already returned on a miss. - // - // The decision tested is result's own, OR -- covering a CORRECTION that - // KEEPS a stored withdrawal ruling -- prior's: such a write's own - // Decision is either empty (a score sheet states no decision) or an - // explicit "hikiwake" (KeepsWithdrawalRuling's own two accepted shapes), - // and applyPoolWrite/applyBracketResultIn only reinstate the stored - // decision AFTER this point (preserveWithdrawalRuling), so testing - // result.Decision alone would miss it here -- and nothing downstream - // pads again once this function returns. A correction can legitimately - // send fewer SubResults rows than TeamSize (e.g. a bout-level edit that - // only touches the rows it corrects), so this is the one place that - // still needs to catch it up. - // - // Excluded: a bye (either side empty -- a bye never carries a - // default-win decision in practice, but the padding shape assumes two - // real teams either way) and a pool daihyosen/tiebreaker row - // (IsPoolDaihyosenMatchID / IsTiebreakerMatchID), which is scored as a - // single individual representative bout, never the team's own numbered - // positions. Both checked inside NeedsDefaultWinBoutPadding. - if comp != nil && comp.TeamSize >= 2 && !comp.IsKachinuki() { - // KeepsWithdrawalRuling is checked UNCONDITIONALLY, not only when - // result.Decision == "": it is also true for an explicit incoming - // "hikiwake" over a stored default win (its own definition allows - // incoming.Decision to be "" OR "hikiwake"), and that write must - // still pad, since preserveWithdrawalRuling reinstates prior.Decision - // downstream regardless of which of the two the correction sent. - padDecision := result.Decision - if KeepsWithdrawalRuling(prior.Status, prior.Decision, result) { - padDecision = prior.Decision - } - if state.NeedsDefaultWinBoutPadding(result.Status, padDecision, prior.SideA, prior.SideB, matchID, - result.SubResults, prior.SubResultsUnreadable, comp.TeamSize) { - result.SubResults = state.PadDefaultWinBoutPositions(result.SubResults, comp.TeamSize) - } + if err := shapeScoreWrite(comp, prior, result, matchID); err != nil { + return nil, err } - noteServerBoutChanges(result, writerSubs, prior.SubResults, comp.IsKachinuki()) // K3 ahead of the write: a withdrawal whose loser a DIFFERENT match has // already made ineligible is refused before anything is written. The @@ -1226,6 +1148,99 @@ func exactDecisionReplay(prior *state.MatchResult, decision, decisionBy string, prior.GroupStamp(state.GroupResult) == stamp } +// shapeScoreWrite makes the server's own changes to a score write before the +// pool or bracket write merges it: the representative bout's winner, the +// kachinuki bout-log merge, the default-win bout padding, and the bout groups +// those changes add to a write that named its groups. One function, shared by +// RecordMatchResultWithIneligibilityTx and the exact-replay probe +// (exactScoreReplayProbe), so the probe reads a write exactly as it would land +// (bc-xrpl). prior is the stored match (never nil here); matchID the match's id. +func shapeScoreWrite(comp *state.Competition, prior, result *state.MatchResult, matchID string) error { + deriveDaihyosenWinner(result) + + // The bout rows as the writer sent them, before the server's own changes + // below (the kachinuki merge, the default-win padding). A writer that + // named its groups did not name those: noteServerBoutChanges adds them, + // or the merge would put the stored rows back over them. + writerSubs := state.CloneSubResults(result.SubResults) + + // Kachinuki bout logs merge BY POSITION rather than replace wholesale + // (ACID: a client whose local log is behind the server must never + // destroy server-appended bouts). Applied here at the entry point, + // BEFORE the pool/bracket write primitives, so the rollback path in + // RecordMatchResultWithIneligibilityTx (which replays `prior` through those primitives) still + // restores the pre-write state exactly. A merge-time rejection (e.g. a + // kachinuki-exhaustion write ending on a tied bout, mp-gmcg review R2) + // returns BEFORE any write primitive, so nothing is persisted. + if merr := applyKachinukiMerge(comp, prior, result); merr != nil { + return merr + } + + // Default-win bout padding (bc-tmfn follow-up): a non-kachinuki team + // match a default-win ruling (any kiken, fusenpai, or fusensho: + // domain.IsDefaultWinDecisionStr) closes before every numbered bout has + // a result of its own -- most commonly a kiken declared before the + // match's first bout was ever scored -- gets an empty SubMatchResult row + // (Position only) for every missing position 1..TeamSize, via + // state.NeedsDefaultWinBoutPadding / state.PadDefaultWinBoutPositions, the + // SAME predicate and function the legacy-load repair uses + // (state.EnsureLegacyUpgraded). Without a row for every position, + // state.DefaultWinCreditSide's readers (TeamResultFrom, + // accrueTeamSubResults, the Excel export) have nothing to range over for + // the positions nobody fought and silently credit them zero. + // + // Gated on PRIOR's sides and unreadable flag, never result's: a client + // score payload commonly omits SideA/SideB and relies on reconcileSides + // (inside applyPoolWrite/applyBracketResultIn, called later via + // writeToPoolOrBracket) to backfill them from the stored row -- reading + // result.SideA/SideB here would skip padding on exactly that common case + // (a bare kiken payload). Likewise a stored SubResults cell that failed + // to parse must never be padded over: NeedsDefaultWinBoutPadding's + // subResultsUnreadable check keeps this branch from turning an empty + // result.SubResults into a non-empty padded one, which would defeat + // applyPoolWrite's own raw-bytes preservation (it only keeps + // SubResultsRaw when the incoming SubResults is still empty) and destroy + // the only copy of the corrupt cell. prior is guaranteed non-nil here: + // the caller's lookupExistingResult error already returned on a miss. + // + // The decision tested is result's own, OR -- covering a CORRECTION that + // KEEPS a stored withdrawal ruling -- prior's: such a write's own + // Decision is either empty (a score sheet states no decision) or an + // explicit "hikiwake" (KeepsWithdrawalRuling's own two accepted shapes), + // and applyPoolWrite/applyBracketResultIn only reinstate the stored + // decision AFTER this point (preserveWithdrawalRuling), so testing + // result.Decision alone would miss it here -- and nothing downstream + // pads again once this function returns. A correction can legitimately + // send fewer SubResults rows than TeamSize (e.g. a bout-level edit that + // only touches the rows it corrects), so this is the one place that + // still needs to catch it up. + // + // Excluded: a bye (either side empty -- a bye never carries a + // default-win decision in practice, but the padding shape assumes two + // real teams either way) and a pool daihyosen/tiebreaker row + // (IsPoolDaihyosenMatchID / IsTiebreakerMatchID), which is scored as a + // single individual representative bout, never the team's own numbered + // positions. Both checked inside NeedsDefaultWinBoutPadding. + if comp != nil && comp.TeamSize >= 2 && !comp.IsKachinuki() { + // KeepsWithdrawalRuling is checked UNCONDITIONALLY, not only when + // result.Decision == "": it is also true for an explicit incoming + // "hikiwake" over a stored default win (its own definition allows + // incoming.Decision to be "" OR "hikiwake"), and that write must + // still pad, since preserveWithdrawalRuling reinstates prior.Decision + // downstream regardless of which of the two the correction sent. + padDecision := result.Decision + if KeepsWithdrawalRuling(prior.Status, prior.Decision, result) { + padDecision = prior.Decision + } + if state.NeedsDefaultWinBoutPadding(result.Status, padDecision, prior.SideA, prior.SideB, matchID, + result.SubResults, prior.SubResultsUnreadable, comp.TeamSize) { + result.SubResults = state.PadDefaultWinBoutPositions(result.SubResults, comp.TeamSize) + } + } + noteServerBoutChanges(result, writerSubs, prior.SubResults, comp.IsKachinuki()) + return nil +} + // ExactScoreReplay reports whether incoming is a completing score write the // stored match has already recorded: the score counterpart of // exactDecisionReplay (bc-xrpl). A Finish whose first send landed but whose @@ -1234,22 +1249,25 @@ func exactDecisionReplay(prior *state.MatchResult, decision, decisionBy string, // with; that resend is the same write, so it answers as recorded, with no // correction reason asked and nothing written. Exact means: both the stored // match and the write are completed, the write is stamped, its stamp is the -// stored result group's, and no group it changes (its own list, or the -// default, effectiveChangedGroups) differs from the stored value once the -// write is read the way the write path would land it (the hansoku award, -// the stored side names and ids, the winner id, the stored correction -// reason). A same-stamp write that differs anywhere it changes is a -// correction and still needs its reason. nilSubsClear is the branch's -// (true for a pool match, false for a knockout one, see mergeCtx). -func ExactScoreReplay(stored, incoming *state.MatchResult, nilSubsClear bool) bool { - _, ok := exactScoreReplayProbe(stored, incoming, nilSubsClear) +// stored result group's, and, with the write shaped as the write path shapes +// it (shapeScoreWrite and the side and id backfill; engiFinishProbe for an +// engi write), the merge owner (mergeMatchWrite) would land its verdict and +// every group it lands equals the stored value, as sent and as landed. A +// group the merge would hold because its stored stamp is newer (a later +// point from another board, R3 or S2) lands nothing and is not compared. A +// same-stamp write that differs where it would land is a correction and +// still needs its reason. comp names a kachinuki or engi competition (nil +// for neither); nilSubsClear is the branch's (true for a pool match, false +// for a knockout one, see mergeCtx). +func ExactScoreReplay(stored, incoming *state.MatchResult, comp *state.Competition, nilSubsClear bool) bool { + _, ok := exactScoreReplayProbe(stored, incoming, comp, nilSubsClear) return ok } // exactScoreReplayProbe is ExactScoreReplay's judgement, handing back the -// write as the write path would land it (a copy: incoming is not changed), -// which the replay's history entry is merged from. -func exactScoreReplayProbe(stored, incoming *state.MatchResult, nilSubsClear bool) (*state.MatchResult, bool) { +// write as the merge would land it (a copy: incoming is not changed), its +// MergeReport on probe.Merge, which the replay's history entry records. +func exactScoreReplayProbe(stored, incoming *state.MatchResult, comp *state.Competition, nilSubsClear bool) (*state.MatchResult, bool) { if stored == nil || incoming == nil || incoming.ModifiedAt <= 0 || incoming.Status != state.MatchStatusCompleted || stored.Status != state.MatchStatusCompleted || @@ -1267,20 +1285,44 @@ func exactScoreReplayProbe(stored, incoming *state.MatchResult, nilSubsClear boo } probe.SubResults = state.CloneSubResults(incoming.SubResults) probe.GroupStamps = state.CloneGroupStamps(incoming.GroupStamps) - if applyHansokuIppons(&probe) != nil { - return nil, false + probe.Changed = slices.Clone(incoming.Changed) + if probe.CorrectionReason == "" { + probe.CorrectionReason = stored.CorrectionReason } - if reconcileSides(&probe, storedSides{A: stored.SideA, B: stored.SideB, AID: stored.SideAID, BID: stored.SideBID}) { - return nil, false + if comp != nil && comp.Engi { + // An engi finish lands as the write the engi recorder builds on the + // stored match from the flags (engiFinishHeld merges this probe). + probe = engiFinishProbe(stored, &probe) + } else { + if applyHansokuIppons(&probe) != nil { + return nil, false + } + if shapeScoreWrite(comp, stored, &probe, stored.ID) != nil { + return nil, false + } + if reconcileSides(&probe, storedSides{A: stored.SideA, B: stored.SideB, AID: stored.SideAID, BID: stored.SideBID}) { + return nil, false + } + if backfillMatchIdentity(&probe, stored, matchWriteForward) != nil { + return nil, false + } } - if backfillMatchIdentity(&probe, stored, matchWriteForward) != nil { + // The merge owner decides which groups of the write would land: a group + // whose stored stamp is newer is held and lands nothing (a later point + // from another board, R3 or S2), and every whole-write rule (R2, finish + // atomicity) applies as it would to the write. Exact means the verdict + // lands, and every group that lands equals the stored value both as the + // write sent it and as it would land (S2 can re-derive a verdict the + // write sent differently, which is still a correction). + sent := probe + sent.IpponsA, sent.IpponsB = slices.Clone(probe.IpponsA), slices.Clone(probe.IpponsB) + sent.SubResults = state.CloneSubResults(probe.SubResults) + rep := mergeMatchWrite(stored, &probe, matchWriteForward, mergeCtx{comp: comp, knockout: !nilSubsClear, nilSubsClear: nilSubsClear}) + if rep == nil || !slices.Contains(rep.Applied, state.GroupResult) || len(rep.Displaced) > 0 { return nil, false } - if probe.CorrectionReason == "" { - probe.CorrectionReason = stored.CorrectionReason - } - for _, g := range effectiveChangedGroups(stored, &probe, nilSubsClear) { - if state.GroupDiffers(stored, &probe, g) { + for _, g := range rep.Applied { + if !slices.Contains(rep.Unchanged, g) || state.GroupDiffers(stored, &sent, g) { return nil, false } } @@ -1291,7 +1333,8 @@ func exactScoreReplayProbe(stored, incoming *state.MatchResult, nilSubsClear boo // (ExactScoreReplay) as recorded: result becomes the stored match, exactly // as a landed write leaves it, and nothing is written to the match file. Its // history entry says the write arrived and changed nothing, as an exact -// decision replay's does. It runs before anything else judges the write, as +// decision replay's does (a group changed since it was made is listed as +// held, as the merge holds it). It runs before anything else judges the write, as // the decision replay runs before the decision's refusals: a downstream or // eligibility refusal would only misreport a result already recorded as one // to resolve and send again. false, with result untouched, for any other @@ -1304,11 +1347,13 @@ func (e *Engine) answerExactScoreReplay(tx state.StoreTx, compID, matchID string if err != nil { return false, err } - probe, ok := exactScoreReplayProbe(prior, result, inPool) + probe, ok := exactScoreReplayProbe(prior, result, comp, inPool) if !ok { return false, nil } - result.Merge = mergeMatchWrite(prior, probe, matchWriteForward, mergeCtx{comp: comp, knockout: !inPool, nilSubsClear: inPool}) + // The probe's merge is the history entry's: what of the resend would + // land (echoes) and what would be held (groups changed since). + result.Merge = probe.Merge e.recordWriteHistory(tx, compID, matchID, result) // A bracket match's prior came through bracketMatchAsResult, which omits // the court and the schedule a landed write echoes (the decision diff --git a/internal/engine/swiss.go b/internal/engine/swiss.go index 6deadcec8..ca27579a6 100644 --- a/internal/engine/swiss.go +++ b/internal/engine/swiss.go @@ -169,6 +169,12 @@ func (e *Engine) GenerateSwissRound(compID string, roundNumber int) ([]state.Mat return nil, validationErrorf("round %d exceeds configured swissRounds %d", roundNumber, comp.SwissRounds) } + // Loaded first: whether check-in applies depends on the tournament's mode. + tournament, err := e.store.LoadTournament() + if err != nil { + return nil, err + } + participants, err := e.store.LoadParticipants(compID, comp.EffectiveWithZekkenName()) if err != nil { return nil, err @@ -197,9 +203,10 @@ func (e *Engine) GenerateSwissRound(compID string, roundNumber int) ([]state.Mat // Determine the Swiss field for this round (mp-w7x; PR #199 review). // // Round 1 (the initial draw): exclude participants who have not checked - // in, but ONLY when check-in tracking is enabled (comp.CheckInEnabled), - // otherwise a stale/imported checked_in marker would shrink the field even - // though the competition doesn't use check-in (PR #199 review). When + // in, but ONLY when check-in applies (comp.CheckInApplies: the setting is on + // and the tournament is not self-run), otherwise a stale/imported + // checked_in marker would shrink the field even though the competition + // doesn't use check-in (PR #199 review). When // enabled, opt-in semantics still apply (see filterCheckedIn). // // Round N > 1: the field is FROZEN to whoever was part of the initial @@ -209,7 +216,7 @@ func (e *Engine) GenerateSwissRound(compID string, roundNumber int) ([]state.Mat // player into a later round nor silently drop one. Withdrawals are handled // separately by the eligibility (kiken/fusenpai) filter below. if roundNumber == 1 { - if comp.CheckInEnabled { + if comp.CheckInApplies(tournament) { participants = filterCheckedIn(participants) } } else { @@ -302,11 +309,8 @@ func (e *Engine) GenerateSwissRound(compID string, roundNumber int) ([]state.Mat matches := buildSwissMatches(pairings, byeKey, roundNumber, comp.Courts, keyToPlayer) // Schedule slot assignment (same per-court cursor logic as pool - // matches). Reload tournament for ceremony / multiplier tuning. - tournament, err := e.store.LoadTournament() - if err != nil { - return nil, err - } + // matches). The tournament loaded above also supplies the ceremony / + // multiplier tuning. // Apply the team-size default (same guard as competition.go StartCompetition). // GenerateSwissRound reloads comp from disk; if TeamSize was 0 in the stored // config, the default must be applied here so assignPoolMatchSlots uses the @@ -936,7 +940,7 @@ func (e *Engine) AdvanceSwissRound(compID string) ([]state.MatchResult, int, err if comp.Format != state.CompFormatSwiss { return nil, 0, validationErrorf("competition %s is not swiss format", compID) } - if comp.SwissRounds > 0 && comp.SwissCurrentRound >= comp.SwissRounds { + if comp.SwissRounds > 0 && SwissRoundsToDraw(comp) == 0 { return nil, 0, validationErrorf("all %d swiss rounds already completed", comp.SwissRounds) } completed, err := e.CurrentSwissRoundCompleted(compID) diff --git a/internal/mobileapp/auth_admin.go b/internal/mobileapp/auth_admin.go deleted file mode 100644 index f485b4974..000000000 --- a/internal/mobileapp/auth_admin.go +++ /dev/null @@ -1,192 +0,0 @@ -package mobileapp - -import ( - "errors" - "fmt" - - "github.com/gitrgoliveira/bracket-creator/internal/state" - "golang.org/x/crypto/bcrypt" -) - -// ElevatedVerifier gates the destructive-operation middleware -// (RequireElevatedPassword) for spec 004 / mp-e21. It is intentionally -// NARROWER than PasswordVerifier: there is no bootstrap branch, no reset -// semantics, and no stored-password redaction policy, those belong to the -// primary credential. The elevated password is a SECOND factor layered on -// top of the main-password AuthMiddleware, so by the time these methods run -// the request has already proven it holds the main password. -// -// Two orthogonal questions drive the middleware's three outcomes: -// GateActive() (should we enforce at all?) and Configured() (is there a -// credential to compare against?): -// -// GateActive=false -> pass through (file mode, no admin pw set; -// back-compat: main password alone suffices) -// GateActive=true, Configured=false -> 503 "admin password not configured" -// (locked mode, env hash unset; fail-closed) -// GateActive=true, Configured=true -> compare X-Admin-Password via Verify() -type ElevatedVerifier interface { - // GateActive reports whether the middleware should enforce at all. - // file mode: true only when an admin password has been set. - // locked mode: always true (fail-closed, a locked deployment that - // forgot the env var must NOT silently allow destructive - // ops with the main password alone). - GateActive() bool - - // Configured reports whether a credential exists to compare against. - // file mode: admin password non-empty (== GateActive there). - // locked mode: TOURNAMENT_ADMIN_PASSWORD_HASH was a valid bcrypt hash. - Configured() bool - - // Verify checks the presented X-Admin-Password value. Only consulted - // when GateActive() && Configured(); implementations may still be called - // defensively and must treat an empty presented value as a non-match. - Verify(presented string) (bool, error) - - // Mode mirrors PasswordVerifier.Mode for /auth-config ("file"|"locked"). - Mode() string -} - -// fileElevatedVerifier implements ElevatedVerifier for file mode. It reads -// Tournament.AdminPassword from the same store the rest of the app uses, so -// a rotation through PUT /api/auth/admin-password takes effect immediately -// on the next request (no process restart, unlike the bcrypt env-var path). -type fileElevatedVerifier struct { - store *state.Store -} - -// NewFileElevatedVerifier constructs the file-mode elevated verifier. -func NewFileElevatedVerifier(store *state.Store) ElevatedVerifier { - return &fileElevatedVerifier{store: store} -} - -// adminPassword loads the current on-disk elevated password, or "" if no -// tournament exists yet. Errors propagate so the middleware can 500 rather -// than fail open. -func (v *fileElevatedVerifier) adminPassword() (string, error) { - t, err := v.store.LoadTournament() - if err != nil { - return "", err - } - if t == nil { - return "", nil - } - return t.AdminPassword, nil -} - -// GateActive: in file mode the gate is opt-in, it activates only once the -// operator has set an admin password. A load error is treated as -// "active" so a transient store failure fails closed (the subsequent -// Verify will surface the error and the middleware 500s) rather than -// silently disabling the gate. -func (v *fileElevatedVerifier) GateActive() bool { - pw, err := v.adminPassword() - if err != nil { - return true - } - return pw != "" -} - -// Configured mirrors GateActive in file mode: the same on-disk field both -// activates the gate and is the credential. A load error reports false so -// the middleware's Configured() branch doesn't 503 on a transient error, -// the error is surfaced by Verify instead. -func (v *fileElevatedVerifier) Configured() bool { - pw, err := v.adminPassword() - if err != nil { - return false - } - return pw != "" -} - -// Verify does an exact-string compare against the stored admin password. -// An empty presented value never matches a non-empty stored password; the -// middleware's GateActive() short-circuit means this is never reached when -// the stored password is empty, so there is no empty-vs-empty vacuous match. -func (v *fileElevatedVerifier) Verify(presented string) (bool, error) { - stored, err := v.adminPassword() - if err != nil { - return false, err - } - if stored == "" { - return false, nil - } - return presented == stored, nil -} - -func (v *fileElevatedVerifier) Mode() string { return "file" } - -// bcryptElevatedVerifier implements ElevatedVerifier for locked mode when a -// valid TOURNAMENT_ADMIN_PASSWORD_HASH is present. It reuses the exact -// hardening proven in bcryptPasswordVerifier (auth_source.go): an empty or -// oversized presented value is a non-match (never a 500), and only genuine -// bcrypt mismatch/too-long errors are folded into "wrong credential". -type bcryptElevatedVerifier struct { - hash []byte -} - -// NewBcryptElevatedVerifier validates `hash` as a bcrypt-encoded password -// hash and returns a configured locked-mode verifier. An empty hash is -// rejected with a distinct error so the caller can fall back to the -// unconfigured verifier (which 503s on use) rather than failing startup, -// a locked deployment that never performs destructive ops shouldn't be -// forced to set this env var just to boot. -func NewBcryptElevatedVerifier(hash string) (ElevatedVerifier, error) { - if hash == "" { - return nil, errors.New("TOURNAMENT_ADMIN_PASSWORD_HASH is empty") - } - b := []byte(hash) - if _, err := bcrypt.Cost(b); err != nil { - return nil, fmt.Errorf("TOURNAMENT_ADMIN_PASSWORD_HASH is not a valid bcrypt hash: %w", err) - } - return &bcryptElevatedVerifier{hash: b}, nil -} - -func (v *bcryptElevatedVerifier) GateActive() bool { return true } -func (v *bcryptElevatedVerifier) Configured() bool { return true } - -func (v *bcryptElevatedVerifier) Verify(presented string) (bool, error) { - if presented == "" { - return false, nil - } - // Pre-check length so an oversized header can't trip a 500 (and thus a - // differential-error oracle), identical rationale to - // bcryptPasswordVerifier.Verify, bcryptMaxInputBytes defined there. - if len(presented) > bcryptMaxInputBytes { - return false, nil - } - err := bcrypt.CompareHashAndPassword(v.hash, []byte(presented)) - if err == nil { - return true, nil - } - if errors.Is(err, bcrypt.ErrMismatchedHashAndPassword) || errors.Is(err, bcrypt.ErrPasswordTooLong) { - return false, nil - } - return false, err -} - -func (v *bcryptElevatedVerifier) Mode() string { return "locked" } - -// lockedUnconfiguredElevatedVerifier represents locked mode with no (or an -// invalid) TOURNAMENT_ADMIN_PASSWORD_HASH. The gate is active (locked mode -// fails closed) but there is no credential, so the middleware returns 503 -// "admin password not configured" on any gated endpoint. Routine -// (non-gated) endpoints are unaffected. -type lockedUnconfiguredElevatedVerifier struct{} - -// NewLockedUnconfiguredElevatedVerifier constructs the fail-closed locked -// verifier used when the elevated env-var hash is absent or malformed. -func NewLockedUnconfiguredElevatedVerifier() ElevatedVerifier { - return &lockedUnconfiguredElevatedVerifier{} -} - -func (v *lockedUnconfiguredElevatedVerifier) GateActive() bool { return true } -func (v *lockedUnconfiguredElevatedVerifier) Configured() bool { return false } - -// Verify should never be reached (the middleware checks Configured() first) -// but returns a non-match defensively so a future caller can't fail open. -func (v *lockedUnconfiguredElevatedVerifier) Verify(string) (bool, error) { - return false, nil -} - -func (v *lockedUnconfiguredElevatedVerifier) Mode() string { return "locked" } diff --git a/internal/mobileapp/auth_admin_test.go b/internal/mobileapp/auth_admin_test.go deleted file mode 100644 index 16bb1db30..000000000 --- a/internal/mobileapp/auth_admin_test.go +++ /dev/null @@ -1,189 +0,0 @@ -package mobileapp - -import ( - "net/http" - "net/http/httptest" - "strings" - "testing" - - "github.com/gin-gonic/gin" - "github.com/gitrgoliveira/bracket-creator/internal/state" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - "golang.org/x/crypto/bcrypt" -) - -// setAdminPassword writes an admin password onto the stored tournament so -// the file-mode elevated verifier has something to compare against. Creates -// the tournament record if needed. -func setAdminPassword(t *testing.T, store *state.Store, admin string) { - t.Helper() - _, err := store.UpdateTournamentChanged(&state.Tournament{}, func(current, desired *state.Tournament) error { - if current != nil { - *desired = *current - } else { - desired.Name = "Test" - desired.Password = "main" - } - desired.AdminPassword = admin - return nil - }) - require.NoError(t, err) -} - -func TestFileElevatedVerifier_GateInactiveWhenUnset(t *testing.T) { - store := setupVerifierTestStore(t) - v := NewFileElevatedVerifier(store) - assert.Equal(t, "file", v.Mode()) - // No tournament / no admin password → gate inactive, not configured. - assert.False(t, v.GateActive()) - assert.False(t, v.Configured()) -} - -func TestFileElevatedVerifier_SetActivatesGate(t *testing.T) { - store := setupVerifierTestStore(t) - v := NewFileElevatedVerifier(store) - setAdminPassword(t, store, "secret-admin") - - assert.True(t, v.GateActive()) - assert.True(t, v.Configured()) - - t.Run("matching → true", func(t *testing.T) { - ok, err := v.Verify("secret-admin") - require.NoError(t, err) - assert.True(t, ok) - }) - t.Run("wrong → false", func(t *testing.T) { - ok, err := v.Verify("nope") - require.NoError(t, err) - assert.False(t, ok) - }) - t.Run("empty presented against non-empty stored → false", func(t *testing.T) { - ok, err := v.Verify("") - require.NoError(t, err) - assert.False(t, ok) - }) -} - -func TestBcryptElevatedVerifier(t *testing.T) { - plain := "elevated-secret" - hash, err := bcrypt.GenerateFromPassword([]byte(plain), bcrypt.MinCost) - require.NoError(t, err) - v, err := NewBcryptElevatedVerifier(string(hash)) - require.NoError(t, err) - - assert.Equal(t, "locked", v.Mode()) - assert.True(t, v.GateActive()) - assert.True(t, v.Configured()) - - t.Run("matching → true", func(t *testing.T) { - ok, err := v.Verify(plain) - require.NoError(t, err) - assert.True(t, ok) - }) - t.Run("wrong → false", func(t *testing.T) { - ok, err := v.Verify("nope") - require.NoError(t, err) - assert.False(t, ok) - }) - t.Run("empty → false (short-circuit)", func(t *testing.T) { - ok, err := v.Verify("") - require.NoError(t, err) - assert.False(t, ok) - }) - // Hardening parity with bcryptPasswordVerifier: oversized input must not - // 500 (which would be a locked-vs-file differential oracle). - t.Run("oversize (>72 bytes) → false (no error)", func(t *testing.T) { - ok, err := v.Verify(strings.Repeat("x", 100)) - require.NoError(t, err) - assert.False(t, ok) - }) -} - -func TestNewBcryptElevatedVerifier_EmptyAndInvalid(t *testing.T) { - _, err := NewBcryptElevatedVerifier("") - require.Error(t, err) - assert.Contains(t, err.Error(), "empty") - - _, err = NewBcryptElevatedVerifier("not-a-bcrypt-hash") - require.Error(t, err) - assert.Contains(t, err.Error(), "valid bcrypt hash") -} - -func TestLockedUnconfiguredElevatedVerifier(t *testing.T) { - v := NewLockedUnconfiguredElevatedVerifier() - assert.Equal(t, "locked", v.Mode()) - // Gate active (fail-closed) but no credential → middleware must 503. - assert.True(t, v.GateActive()) - assert.False(t, v.Configured()) - ok, err := v.Verify("anything") - require.NoError(t, err) - assert.False(t, ok) -} - -// --- middleware behavior --- - -// elevatedTestRouter mounts a single gated GET behind RequireElevatedPassword -// so the three outcome paths can be exercised without the full admin stack. -func elevatedTestRouter(ev ElevatedVerifier) *gin.Engine { - gin.SetMode(gin.TestMode) - r := gin.New() - r.GET("/gated", RequireElevatedPassword(ev), func(c *gin.Context) { - c.JSON(http.StatusOK, gin.H{"ok": true}) - }) - return r -} - -func doGated(r *gin.Engine, adminHeader string, setHeader bool) *httptest.ResponseRecorder { - req := httptest.NewRequest(http.MethodGet, "/gated", nil) - if setHeader { - req.Header.Set("X-Admin-Password", adminHeader) - } - w := httptest.NewRecorder() - r.ServeHTTP(w, req) - return w -} - -func TestRequireElevatedPassword_GateInactivePassesThrough(t *testing.T) { - store := setupVerifierTestStore(t) - // file mode, no admin password → gate inactive → request passes with no header. - r := elevatedTestRouter(NewFileElevatedVerifier(store)) - w := doGated(r, "", false) - assert.Equal(t, http.StatusOK, w.Code) -} - -func TestRequireElevatedPassword_FileMode(t *testing.T) { - store := setupVerifierTestStore(t) - setAdminPassword(t, store, "the-admin-pw") - r := elevatedTestRouter(NewFileElevatedVerifier(store)) - - t.Run("missing header → 401", func(t *testing.T) { - assert.Equal(t, http.StatusUnauthorized, doGated(r, "", false).Code) - }) - t.Run("wrong header → 401", func(t *testing.T) { - assert.Equal(t, http.StatusUnauthorized, doGated(r, "wrong", true).Code) - }) - t.Run("correct header → 200", func(t *testing.T) { - assert.Equal(t, http.StatusOK, doGated(r, "the-admin-pw", true).Code) - }) -} - -func TestRequireElevatedPassword_LockedUnconfigured503(t *testing.T) { - r := elevatedTestRouter(NewLockedUnconfiguredElevatedVerifier()) - // Even a (wrong or right-looking) header can't help, no credential exists. - w := doGated(r, "anything", true) - assert.Equal(t, http.StatusServiceUnavailable, w.Code) - assert.Contains(t, w.Body.String(), "not configured") -} - -func TestRequireElevatedPassword_LockedConfigured(t *testing.T) { - plain := "locked-elevated" - hash, err := bcrypt.GenerateFromPassword([]byte(plain), bcrypt.MinCost) - require.NoError(t, err) - ev, err := NewBcryptElevatedVerifier(string(hash)) - require.NoError(t, err) - r := elevatedTestRouter(ev) - - assert.Equal(t, http.StatusUnauthorized, doGated(r, "wrong", true).Code) - assert.Equal(t, http.StatusOK, doGated(r, plain, true).Code) -} diff --git a/internal/mobileapp/confirm_password.go b/internal/mobileapp/confirm_password.go new file mode 100644 index 000000000..356e536fc --- /dev/null +++ b/internal/mobileapp/confirm_password.go @@ -0,0 +1,65 @@ +package mobileapp + +import ( + "net/http" + + "github.com/gin-gonic/gin" +) + +// ConfirmPasswordHeader carries the password typed in the moment a competition +// is deleted. Deleting a competition is the one action that asks for the +// password again (operator ruling 2026-10-12); every other organiser write is +// authorised by the sign-in alone. +const ConfirmPasswordHeader = "X-Confirm-Password" + +// Codes of the two refusals; both carry confirmPasswordSentence. +const ( + confirmPasswordRequiredCode = "confirm_password_required" + confirmPasswordWrongCode = "confirm_password_wrong" +) + +const confirmPasswordSentence = "Wrong password. The competition was not deleted." + +// isCompetitionDelete reports whether a request is DELETE /api/competitions/:id, +// by the route pattern, so DELETE .../draw and the other sub-routes are not it. +func isCompetitionDelete(method, fullPath string) bool { + return method == http.MethodDelete && fullPath == "/api/competitions/:id" +} + +// RequireConfirmPasswordForDelete refuses a competition delete unless +// X-Confirm-Password matches the admin password, judged by the same +// PasswordVerifier as the sign-in. It runs after AuthMiddleware, so the +// sign-in applies first in both modes, and before the handler deletes anything. +// The refusal is a 401 the SPA reads as a wrong typed password, not as a lost +// session (it names a code, and the sign-in's own 401 body does not). +func RequireConfirmPasswordForDelete(verifier PasswordVerifier) gin.HandlerFunc { + return func(c *gin.Context) { + if !isCompetitionDelete(c.Request.Method, c.FullPath()) { + c.Next() + return + } + confirm := c.GetHeader(ConfirmPasswordHeader) + if confirm == "" { + respondConfirmPassword(c, confirmPasswordRequiredCode) + return + } + ok, err := verifier.Verify(confirm) + if err != nil { + c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"error": "auth verification failed"}) + return + } + if !ok { + respondConfirmPassword(c, confirmPasswordWrongCode) + return + } + c.Next() + } +} + +func respondConfirmPassword(c *gin.Context, code string) { + c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{ + "error": confirmPasswordSentence, + "code": code, + "message": confirmPasswordSentence, + }) +} diff --git a/internal/mobileapp/confirm_password_test.go b/internal/mobileapp/confirm_password_test.go new file mode 100644 index 000000000..4fb5937c9 --- /dev/null +++ b/internal/mobileapp/confirm_password_test.go @@ -0,0 +1,138 @@ +package mobileapp + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "golang.org/x/crypto/bcrypt" + + "github.com/gitrgoliveira/bracket-creator/internal/state" +) + +// Deleting a competition asks for the password again: the sign-in password +// typed in that moment, in X-Confirm-Password, checked by the same +// PasswordVerifier as the sign-in (operator ruling 2026-10-12). + +const confirmWrongSentence = "Wrong password. The competition was not deleted." + +func confirmDeleteRig(t *testing.T, mode string, locked bool) (*gin.Engine, *state.Store) { + t.Helper() + gin.SetMode(gin.TestMode) + store := newTempStore(t) + var v PasswordVerifier + if locked { + hash, err := bcrypt.GenerateFromPassword([]byte("main-pw"), bcrypt.MinCost) + require.NoError(t, err) + v, err = NewBcryptVerifier(string(hash)) + require.NoError(t, err) + } + tour := &state.Tournament{Name: "T", Date: "01-06-2026", Venue: "Dojo", Courts: []string{"A"}, Mode: mode} + if !locked { + tour.Password = "main-pw" + } + require.NoError(t, store.SaveTournament(tour)) + require.NoError(t, store.SaveCompetition(&state.Competition{ID: "doomed", Name: "Doomed"})) + return setupSelfRunRouter(t, store, v), store +} + +func confirmDelete(r *gin.Engine, signIn, confirm string) *httptest.ResponseRecorder { + req := httptest.NewRequest(http.MethodDelete, "/api/competitions/doomed", nil) + if signIn != "" { + req.Header.Set("X-Tournament-Password", signIn) + } + if confirm != "" { + req.Header.Set("X-Confirm-Password", confirm) + } + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + return w +} + +func TestDeleteCompetition_AsksForThePasswordAgain(t *testing.T) { + for _, tc := range []struct { + name string + mode string + locked bool + }{ + {"officiated file mode", state.TournamentModeOfficiated, false}, + {"officiated locked mode", state.TournamentModeOfficiated, true}, + {"self-run file mode", state.TournamentModeSelfRun, false}, + {"self-run locked mode", state.TournamentModeSelfRun, true}, + } { + t.Run(tc.name, func(t *testing.T) { + r, store := confirmDeleteRig(t, tc.mode, tc.locked) + stillThere := func() { + t.Helper() + c, err := store.LoadCompetition("doomed") + require.NoError(t, err) + require.NotNil(t, c, "the competition must still exist") + } + + for label, w := range map[string]*httptest.ResponseRecorder{ + "no confirm header": confirmDelete(r, "main-pw", ""), + "wrong confirm header": confirmDelete(r, "main-pw", "nope"), + "sign-in as the confirm, wrong sign-in": confirmDelete(r, "nope", "main-pw"), + } { + assert.Equal(t, http.StatusUnauthorized, w.Code, label) + stillThere() + } + + // The refusal names the sentence in the house shape. + w := confirmDelete(r, "main-pw", "nope") + var body map[string]any + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body)) + assert.Equal(t, confirmWrongSentence, body["error"]) + assert.Equal(t, confirmWrongSentence, body["message"]) + assert.Equal(t, "confirm_password_wrong", body["code"]) + + w = confirmDelete(r, "main-pw", "") + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body)) + assert.Equal(t, confirmWrongSentence, body["error"]) + assert.Equal(t, "confirm_password_required", body["code"]) + stillThere() + + w = confirmDelete(r, "main-pw", "main-pw") + require.Equal(t, http.StatusNoContent, w.Code, w.Body.String()) + gone, err := store.LoadCompetition("doomed") + require.NoError(t, err) + assert.Nil(t, gone, "the right password deletes the competition") + }) + } +} + +// The gate that already keeps an anonymous caller out of the route in self-run +// answers first, with its own body, whatever confirm header is sent. +func TestDeleteCompetition_SelfRunAnonymousStillRefusedByTheGate(t *testing.T) { + r, store := confirmDeleteRig(t, state.TournamentModeSelfRun, false) + w := confirmDelete(r, "", "main-pw") + assert.Equal(t, http.StatusUnauthorized, w.Code) + assert.Contains(t, w.Body.String(), "invalid tournament password") + c, err := store.LoadCompetition("doomed") + require.NoError(t, err) + require.NotNil(t, c) +} + +// Only the competition delete asks. Discarding a draw and the other +// destructive routes stay one-step for a signed-in organiser. +func TestConfirmPassword_OnlyDeleteCompetitionAsks(t *testing.T) { + r, _ := confirmDeleteRig(t, state.TournamentModeOfficiated, false) + req := httptest.NewRequest(http.MethodDelete, "/api/competitions/doomed/draw", nil) + req.Header.Set("X-Tournament-Password", "main-pw") + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + assert.NotEqual(t, http.StatusUnauthorized, w.Code, w.Body.String()) + assert.NotContains(t, w.Body.String(), confirmWrongSentence) +} + +func TestCORSAllowsTheConfirmPasswordHeader(t *testing.T) { + r, _ := confirmDeleteRig(t, state.TournamentModeOfficiated, false) + req := httptest.NewRequest(http.MethodOptions, "/api/competitions/doomed", nil) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + assert.Contains(t, w.Header().Get("Access-Control-Allow-Headers"), "X-Confirm-Password") +} diff --git a/internal/mobileapp/coverage_gap_test.go b/internal/mobileapp/coverage_gap_test.go index 21fc1f1b7..15dd1f21f 100644 --- a/internal/mobileapp/coverage_gap_test.go +++ b/internal/mobileapp/coverage_gap_test.go @@ -514,46 +514,6 @@ func TestRegistration_POST_BadJSON(t *testing.T) { assert.Equal(t, http.StatusBadRequest, w.Code) } -// --------------------------------------------------------------------------- -// Admin password endpoint, input validation gaps -// --------------------------------------------------------------------------- - -// TestAdminPassword_BadJSON covers the ShouldBindJSON error path -// (line 50-53 in handlers_auth_admin.go). -func TestAdminPassword_BadJSON(t *testing.T) { - store := setupVerifierTestStore(t) - seedTournament(t, store) - r, _ := elevatedHandlerRouter(t, store, NewFileVerifier(store)) - req := httptest.NewRequest(http.MethodPut, "/api/auth/admin-password", bytes.NewBufferString("{invalid")) - req.Header.Set("Content-Type", "application/json") - w := httptest.NewRecorder() - r.ServeHTTP(w, req) - assert.Equal(t, http.StatusBadRequest, w.Code) -} - -// TestAdminPassword_NewPasswordTooLong covers the validateMaxLen("newPassword") -// error path (line 62-65 in handlers_auth_admin.go). -func TestAdminPassword_NewPasswordTooLong(t *testing.T) { - store := setupVerifierTestStore(t) - seedTournament(t, store) - r, _ := elevatedHandlerRouter(t, store, NewFileVerifier(store)) - w := putAdminPassword(r, map[string]string{"newPassword": strings.Repeat("x", MaxLenTournamentPassword+1)}) - assert.Equal(t, http.StatusBadRequest, w.Code) -} - -// TestAdminPassword_CurrentPasswordTooLong covers the validateMaxLen("currentPassword") -// error path (line 66-69 in handlers_auth_admin.go). -func TestAdminPassword_CurrentPasswordTooLong(t *testing.T) { - store := setupVerifierTestStore(t) - seedTournament(t, store) - r, _ := elevatedHandlerRouter(t, store, NewFileVerifier(store)) - w := putAdminPassword(r, map[string]string{ - "newPassword": "valid", - "currentPassword": strings.Repeat("x", MaxLenTournamentPassword+1), - }) - assert.Equal(t, http.StatusBadRequest, w.Code) -} - // --------------------------------------------------------------------------- // Eligibility POST, bad JSON and validate-length error paths // --------------------------------------------------------------------------- diff --git a/internal/mobileapp/handlers_auth_admin.go b/internal/mobileapp/handlers_auth_admin.go deleted file mode 100644 index cfee7e5ba..000000000 --- a/internal/mobileapp/handlers_auth_admin.go +++ /dev/null @@ -1,120 +0,0 @@ -package mobileapp - -import ( - "net/http" - - "github.com/gin-gonic/gin" - "github.com/gitrgoliveira/bracket-creator/internal/state" -) - -// adminPasswordRequest is the body shape for PUT /api/auth/admin-password. -// -// - NewPassword is the elevated password to store (required, non-empty). -// - CurrentPassword is required ONLY when an admin password is already set -// (rotation): it must equal the stored value so a main-password holder -// cannot silently re-set the elevated secret (spec 004 Finding 2). On -// first-time set (TOFU) it is ignored. -// -// Neither value is ever echoed back, the elevated password is write-only at -// the API boundary (Tournament.AdminPassword has json:"-"). -type adminPasswordRequest struct { - NewPassword string `json:"newPassword"` - CurrentPassword string `json:"currentPassword,omitempty"` -} - -// RegisterAdminPasswordHandler wires PUT /api/auth/admin-password, the only -// way to set or rotate the file-mode elevated password (spec 004 §6a). It is -// registered inside the admin group, so AuthMiddleware has already verified -// the MAIN password before this runs. -// -// Gating: -// - locked mode → 404. The elevated credential is the env-var bcrypt hash -// (TOURNAMENT_ADMIN_PASSWORD_HASH); it is not settable via the API. The -// 404 (not 405/403) matches the "route doesn't exist in this mode" shape -// used by the password-reset endpoint. -// - file mode, no admin password set yet → trust-on-first-use: the main -// password (already verified) authorizes the initial set. -// - file mode, admin password already set → rotation requires the correct -// CurrentPassword (verified via the ElevatedVerifier) in addition to the -// main password. -func RegisterAdminPasswordHandler(r *gin.RouterGroup, store *state.Store, ev ElevatedVerifier) { - r.PUT("/auth/admin-password", func(c *gin.Context) { - // Locked mode: not settable via API. ev.Mode() == "locked" for both - // the configured (bcrypt) and unconfigured locked verifiers. - if ev.Mode() == "locked" { - c.JSON(http.StatusNotFound, gin.H{"error": "admin password is controlled by TOURNAMENT_ADMIN_PASSWORD_HASH and is not settable via the API"}) - return - } - - var req adminPasswordRequest - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - - // NewPassword is NOT trimmed (passwords may legitimately contain - // whitespace; the auth comparison is exact-string match, same - // policy as the main password). - if req.NewPassword == "" { - c.JSON(http.StatusBadRequest, gin.H{"error": "newPassword is required"}) - return - } - if err := validateMaxLen("newPassword", req.NewPassword, MaxLenTournamentPassword); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - if err := validateMaxLen("currentPassword", req.CurrentPassword, MaxLenTournamentPassword); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - - // A tournament record must exist before an admin password can be - // attached to it, otherwise the set would land on a non-existent - // record (and the bulk PUT/POST guards require a name anyway). - t, err := store.LoadTournament() - if err != nil { - internalError(c, err) - return - } - if t == nil { - c.JSON(http.StatusConflict, gin.H{"error": "tournament not configured yet"}) - return - } - - // Rotation gate: if an admin password is already set, the caller - // must prove possession of the current one. GateActive() is true in - // file mode exactly when AdminPassword != "". Verify reads the - // current stored value, so this is the canonical "old password" - // check. First-time set (GateActive() == false) skips it (TOFU). - if ev.GateActive() { - ok, verr := ev.Verify(req.CurrentPassword) - if verr != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "admin auth verification failed"}) - return - } - if !ok { - c.JSON(http.StatusUnauthorized, gin.H{"error": "current admin password is incorrect"}) - return - } - } - - // Persist under the store's write lock. The desired struct's other - // fields are irrelevant, the transform copies everything from the - // current record forward and changes only AdminPassword, so a - // concurrent bulk PUT can't be clobbered by a stale desired here. - _, err = store.UpdateTournamentChanged(t, func(current, desired *state.Tournament) error { - if current != nil { - *desired = *current - } - desired.AdminPassword = req.NewPassword - return nil - }) - if err != nil { - internalError(c, err) - return - } - - // Write-only: never echo the value. Report only the resulting state. - c.JSON(http.StatusOK, gin.H{"status": "ok", "elevatedConfigured": true}) - }) -} diff --git a/internal/mobileapp/handlers_auth_admin_test.go b/internal/mobileapp/handlers_auth_admin_test.go deleted file mode 100644 index bcf705e6f..000000000 --- a/internal/mobileapp/handlers_auth_admin_test.go +++ /dev/null @@ -1,247 +0,0 @@ -package mobileapp - -import ( - "bytes" - "encoding/json" - "net/http" - "net/http/httptest" - "os" - "testing" - - "github.com/gin-gonic/gin" - "github.com/gitrgoliveira/bracket-creator/internal/state" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - "golang.org/x/crypto/bcrypt" -) - -// elevatedHandlerRouter mounts the tournament + admin-password + auth-config -// handlers for a given verifier. No AuthMiddleware, the main-password gate -// is orthogonal to the elevated-password logic under test here. -func elevatedHandlerRouter(t *testing.T, store *state.Store, verifier PasswordVerifier) (*gin.Engine, ElevatedVerifier) { - t.Helper() - gin.SetMode(gin.TestMode) - r := gin.New() - hub := NewHub() - ev := defaultElevatedVerifier(verifier, store) - api := r.Group("/api") - RegisterTournamentHandlers(api, store, hub, verifier) - RegisterAdminPasswordHandler(api, store, ev) - RegisterAuthConfigHandlers(api, verifier, ev, false) - return r, ev -} - -func seedTournament(t *testing.T, store *state.Store) { - t.Helper() - _, err := store.SaveTournamentChanged(&state.Tournament{ - Name: "Seeded", Date: "01-01-2026", Venue: "Hall", Courts: []string{"A"}, Password: "main", - }) - require.NoError(t, err) -} - -func putAdminPassword(r *gin.Engine, body map[string]string) *httptest.ResponseRecorder { - b, _ := json.Marshal(body) - req := httptest.NewRequest(http.MethodPut, "/api/auth/admin-password", bytes.NewReader(b)) - req.Header.Set("Content-Type", "application/json") - w := httptest.NewRecorder() - r.ServeHTTP(w, req) - return w -} - -func TestAdminPasswordEndpoint_TOFUThenRotate(t *testing.T) { - store := setupVerifierTestStore(t) - seedTournament(t, store) - r, ev := elevatedHandlerRouter(t, store, NewFileVerifier(store)) - - // First-time set: no current password needed (gate inactive). - require.False(t, ev.GateActive()) - w := putAdminPassword(r, map[string]string{"newPassword": "first-admin"}) - assert.Equal(t, http.StatusOK, w.Code) - assert.True(t, ev.GateActive(), "gate should activate after first set") - - // Rotation without the current password → 401. - w = putAdminPassword(r, map[string]string{"newPassword": "second-admin"}) - assert.Equal(t, http.StatusUnauthorized, w.Code) - - // Rotation with wrong current password → 401. - w = putAdminPassword(r, map[string]string{"newPassword": "second-admin", "currentPassword": "wrong"}) - assert.Equal(t, http.StatusUnauthorized, w.Code) - - // Rotation with correct current password → 200, new value takes effect. - w = putAdminPassword(r, map[string]string{"newPassword": "second-admin", "currentPassword": "first-admin"}) - assert.Equal(t, http.StatusOK, w.Code) - ok, err := ev.Verify("second-admin") - require.NoError(t, err) - assert.True(t, ok) - ok, _ = ev.Verify("first-admin") - assert.False(t, ok, "old admin password must no longer verify") -} - -func TestAdminPasswordEndpoint_EmptyNewPasswordRejected(t *testing.T) { - store := setupVerifierTestStore(t) - seedTournament(t, store) - r, _ := elevatedHandlerRouter(t, store, NewFileVerifier(store)) - w := putAdminPassword(r, map[string]string{"newPassword": ""}) - assert.Equal(t, http.StatusBadRequest, w.Code) -} - -func TestAdminPasswordEndpoint_NoTournament409(t *testing.T) { - store := setupVerifierTestStore(t) - r, _ := elevatedHandlerRouter(t, store, NewFileVerifier(store)) - w := putAdminPassword(r, map[string]string{"newPassword": "x"}) - assert.Equal(t, http.StatusConflict, w.Code) -} - -// Finding 1 regression: GET /tournament must NEVER expose the elevated -// password, even in file mode where the main password IS returned. -func TestTournamentGet_NeverLeaksAdminPassword(t *testing.T) { - store := setupVerifierTestStore(t) - seedTournament(t, store) - setAdminPassword(t, store, "super-secret-admin") - r, _ := elevatedHandlerRouter(t, store, NewFileVerifier(store)) - - req := httptest.NewRequest(http.MethodGet, "/api/tournament", nil) - w := httptest.NewRecorder() - r.ServeHTTP(w, req) - require.Equal(t, http.StatusOK, w.Code) - body := w.Body.String() - assert.NotContains(t, body, "super-secret-admin", "admin password leaked in GET /tournament") - assert.NotContains(t, body, "adminPassword") - assert.NotContains(t, body, "admin_password") -} - -// Finding 2 regression: PUT /tournament (main-password gate) must NOT be able -// to set or overwrite the elevated password, and a routine settings save must -// preserve the existing one. -func TestTournamentPut_CannotSetOrWipeAdminPassword(t *testing.T) { - store := setupVerifierTestStore(t) - seedTournament(t, store) - setAdminPassword(t, store, "original-admin") - r, ev := elevatedHandlerRouter(t, store, NewFileVerifier(store)) - - // Attacker attempts to overwrite the elevated password via the bulk PUT, - // sending both the JSON field name variants. - body := []byte(`{"name":"Renamed","date":"01-01-2026","venue":"Hall","courts":["A"],"password":"main","adminPassword":"attacker","admin_password":"attacker"}`) - req := httptest.NewRequest(http.MethodPut, "/api/tournament", bytes.NewReader(body)) - req.Header.Set("Content-Type", "application/json") - w := httptest.NewRecorder() - r.ServeHTTP(w, req) - require.Equal(t, http.StatusOK, w.Code) - - // The elevated password is unchanged: attacker's value does NOT verify, - // the original still does (preserve-on-write). - ok, err := ev.Verify("attacker") - require.NoError(t, err) - assert.False(t, ok, "PUT /tournament overwrote the admin password, privilege escalation") - ok, err = ev.Verify("original-admin") - require.NoError(t, err) - assert.True(t, ok, "routine PUT /tournament wiped the admin password") - - // And the rename actually took effect (sanity: the PUT did persist). - tn, err := store.LoadTournament() - require.NoError(t, err) - assert.Equal(t, "Renamed", tn.Name) -} - -func TestAuthConfig_ElevatedBits_FileMode(t *testing.T) { - store := setupVerifierTestStore(t) - seedTournament(t, store) - r, _ := elevatedHandlerRouter(t, store, NewFileVerifier(store)) - - get := func() authConfigResponse { - req := httptest.NewRequest(http.MethodGet, "/api/auth-config", nil) - w := httptest.NewRecorder() - r.ServeHTTP(w, req) - require.Equal(t, http.StatusOK, w.Code) - var resp authConfigResponse - require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp)) - return resp - } - - // Before setting: editable (file mode) but not required/configured. - resp := get() - assert.Equal(t, "file", resp.Mode) - assert.True(t, resp.ElevatedEditable) - assert.False(t, resp.ElevatedRequired) - assert.False(t, resp.ElevatedConfigured) - - // After setting: required + configured. - require.Equal(t, http.StatusOK, putAdminPassword(r, map[string]string{"newPassword": "abc"}).Code) - resp = get() - assert.True(t, resp.ElevatedRequired) - assert.True(t, resp.ElevatedConfigured) - assert.True(t, resp.ElevatedEditable) -} - -func TestAdminPasswordEndpoint_LockedMode404(t *testing.T) { - store := setupVerifierTestStore(t) - seedTournament(t, store) - // Build a locked main verifier so defaultElevatedVerifier picks the - // locked branch. The env var is unset → unconfigured elevated verifier, - // which still reports Mode()=="locked" → endpoint 404s. - hash := mustBcrypt(t, "main") - mainV, err := NewBcryptVerifier(hash) - require.NoError(t, err) - r, ev := elevatedHandlerRouter(t, store, mainV) - assert.Equal(t, "locked", ev.Mode()) - - w := putAdminPassword(r, map[string]string{"newPassword": "x"}) - assert.Equal(t, http.StatusNotFound, w.Code) -} - -// Regression for the bypass Copilot caught on PR #193: the elevated gate on -// the dedicated participant endpoints was circumventable via PUT -// /api/competitions/:id, which persists the roster (SaveParticipants/SaveSeeds) -// whenever the body has a non-nil Players field, and that is the SPA's -// primary roster flow. The bulk PUT must now enforce the same gate, but ONLY -// when it carries a roster mutation; settings-only PUTs stay single-factor. -func TestBulkCompetitionPut_RosterMutationIsGated(t *testing.T) { - r, store, _, _, dir := setupTestRouter(t) - defer os.RemoveAll(dir) - - // Activate the gate (file mode) and create a competition. - setAdminPassword(t, store, "destroypw") - require.NoError(t, store.SaveCompetition(&state.Competition{ - ID: "c1", Name: "C1", Format: "knockout", Courts: []string{"A"}, Status: state.CompStatusSetup, - })) - - putComp := func(body string, admin string, withAdmin bool) *httptest.ResponseRecorder { - req := httptest.NewRequest(http.MethodPut, "/api/competitions/c1", bytes.NewReader([]byte(body))) - req.Header.Set("Content-Type", "application/json") - if withAdmin { - req.Header.Set("X-Admin-Password", admin) - } - w := httptest.NewRecorder() - r.ServeHTTP(w, req) - return w - } - - rosterBody := `{"id":"c1","name":"C1","format":"knockout","courts":["A"],"players":[{"name":"Alice","dojo":"D"}]}` - settingsBody := `{"id":"c1","name":"C1 Renamed","format":"knockout","courts":["A"]}` // no players field → nil - - t.Run("roster mutation without admin header → 401", func(t *testing.T) { - assert.Equal(t, http.StatusUnauthorized, putComp(rosterBody, "", false).Code) - }) - t.Run("roster mutation with wrong admin header → 401", func(t *testing.T) { - assert.Equal(t, http.StatusUnauthorized, putComp(rosterBody, "wrong", true).Code) - }) - t.Run("roster mutation with correct admin header → 200 and persists", func(t *testing.T) { - assert.Equal(t, http.StatusOK, putComp(rosterBody, "destroypw", true).Code) - players, err := store.LoadParticipants("c1", false) - require.NoError(t, err) - require.Len(t, players, 1) - assert.Equal(t, "Alice", players[0].Name) - }) - t.Run("settings-only PUT (no players) is NOT gated", func(t *testing.T) { - // No admin header, gate active, must still succeed because the body - // carries no roster mutation. - assert.Equal(t, http.StatusOK, putComp(settingsBody, "", false).Code) - }) -} - -func mustBcrypt(t *testing.T, plain string) string { - t.Helper() - h, err := bcrypt.GenerateFromPassword([]byte(plain), bcrypt.MinCost) - require.NoError(t, err) - return string(h) -} diff --git a/internal/mobileapp/handlers_auth_config.go b/internal/mobileapp/handlers_auth_config.go index 41d7f58f2..10234aca2 100644 --- a/internal/mobileapp/handlers_auth_config.go +++ b/internal/mobileapp/handlers_auth_config.go @@ -21,22 +21,6 @@ type authConfigResponse struct { Mode string `json:"mode"` ResetEnabled bool `json:"resetEnabled"` - // Elevated-password (destructive-ops) bits, spec 004 / mp-e21. The SPA - // reads these on mount to decide whether to prompt for X-Admin-Password - // before a destructive action and whether the Settings field is editable. - // - // ElevatedRequired , is the gate active? (file: admin pw set; - // locked: always true). When false, destructive - // ops need only the main password. - // ElevatedConfigured, does a credential exist? (file: admin pw set; - // locked: env hash present). When required but - // not configured, gated endpoints return 503. - // ElevatedEditable , can the admin password be set via the API? - // file mode true; locked mode false (env-var). - ElevatedRequired bool `json:"elevatedRequired"` - ElevatedConfigured bool `json:"elevatedConfigured"` - ElevatedEditable bool `json:"elevatedEditable"` - // ScheduleEnabled controls whether the SPA renders the admin // "Tournament schedule" card. Sourced from the ENABLE_TOURNAMENT_SCHEDULE // env var (truthy: "1", "true", "yes", "on"). Default OFF, mp-fwce. @@ -57,17 +41,12 @@ type authConfigResponse struct { // scheduleEnabled is sourced from ENABLE_TOURNAMENT_SCHEDULE in // cmd/mobile_app.go and threaded here so the env var is read once at // startup rather than on every request. -func RegisterAuthConfigHandlers(r *gin.RouterGroup, verifier PasswordVerifier, elevated ElevatedVerifier, scheduleEnabled bool) { +func RegisterAuthConfigHandlers(r *gin.RouterGroup, verifier PasswordVerifier, scheduleEnabled bool) { r.GET("/auth-config", func(c *gin.Context) { c.JSON(http.StatusOK, authConfigResponse{ - Mode: verifier.Mode(), - ResetEnabled: verifier.ResetEnabled(), - ElevatedRequired: elevated.GateActive(), - ElevatedConfigured: elevated.Configured(), - // Editable only in file mode, the locked-mode credential is the - // TOURNAMENT_ADMIN_PASSWORD_HASH env var, not settable via API. - ElevatedEditable: elevated.Mode() == "file", - ScheduleEnabled: scheduleEnabled, + Mode: verifier.Mode(), + ResetEnabled: verifier.ResetEnabled(), + ScheduleEnabled: scheduleEnabled, }) }) } diff --git a/internal/mobileapp/handlers_auth_config_test.go b/internal/mobileapp/handlers_auth_config_test.go index 92f7cf080..92f4a68ee 100644 --- a/internal/mobileapp/handlers_auth_config_test.go +++ b/internal/mobileapp/handlers_auth_config_test.go @@ -33,8 +33,7 @@ func TestAuthConfigHandler_ScheduleEnabledFlag(t *testing.T) { r := gin.New() api := r.Group("/api") verifier := NewFileVerifier(store) - elevated := NewFileElevatedVerifier(store) - RegisterAuthConfigHandlers(api, verifier, elevated, tc.scheduleEnabled) + RegisterAuthConfigHandlers(api, verifier, tc.scheduleEnabled) w := httptest.NewRecorder() req, _ := http.NewRequest("GET", "/api/auth-config", nil) @@ -47,6 +46,14 @@ func TestAuthConfigHandler_ScheduleEnabledFlag(t *testing.T) { require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp)) assert.Equal(t, tc.scheduleEnabled, resp.ScheduleEnabled, "scheduleEnabled in response must match the flag passed to RegisterAuthConfigHandlers") + + // The destructive-ops password was removed (operator ruling + // 2026-10-12), so the payload carries none of its bits. + var raw map[string]any + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &raw)) + for _, gone := range []string{"elevatedRequired", "elevatedConfigured", "elevatedEditable"} { + assert.NotContains(t, raw, gone, "auth-config must not report %s", gone) + } }) } } diff --git a/internal/mobileapp/handlers_competition.go b/internal/mobileapp/handlers_competition.go index d7c88c310..5f805c6d9 100644 --- a/internal/mobileapp/handlers_competition.go +++ b/internal/mobileapp/handlers_competition.go @@ -724,7 +724,7 @@ func migrateMissingNumberPrefixes(store *state.Store, eng *engine.Engine, comps return reloaded } -func RegisterCompetitionHandlers(r *gin.RouterGroup, store *state.Store, eng *engine.Engine, hub *Hub, elevated ElevatedVerifier) { +func RegisterCompetitionHandlers(r *gin.RouterGroup, store *state.Store, eng *engine.Engine, hub *Hub) { r.GET("/competitions", func(c *gin.Context) { comps, err := loadAllCompetitions(store) if err != nil { @@ -1154,8 +1154,7 @@ func RegisterCompetitionHandlers(r *gin.RouterGroup, store *state.Store, eng *en // GET /competitions/:id/schedule/estimate, pre-draw schedule estimate // for a specific competition. Read-only; main-password gated (registered - // under adminGroup via RegisterCompetitionHandlers) but does NOT require - // elevated auth. Returns a ScheduleEstimate JSON body; 404 for unknown + // under adminGroup via RegisterCompetitionHandlers). Returns a ScheduleEstimate JSON body; 404 for unknown // competition. mp-zoh Phase 3. r.GET("/competitions/:id/schedule/estimate", func(c *gin.Context) { id, ok := requireValidCompID(c) @@ -1222,18 +1221,11 @@ func RegisterCompetitionHandlers(r *gin.RouterGroup, store *state.Store, eng *en } comp.ID = id // ensure ID matches URL (also for empty-body case) - // Elevated-password gate for the ROSTER-mutation path (spec 004 / - // mp-e21; Copilot PR #193). This handler doubles as a roster writer: - // a non-nil Players field below triggers SaveParticipants/SaveSeeds. - // That is the SPA's PRIMARY roster flow (paste/import, seed edits via - // API.updateCompetition), so gating only the dedicated - // POST/PUT /participants endpoints would leave the gate bypassable. - // Route-level middleware can't see Players (it runs before binding), - // so enforce inline now that the body is decoded. Settings-only PUTs - // (Players == nil) are unaffected and stay single-factor. - if comp.Players != nil && !enforceElevated(c, elevated) { - return - } + // This handler doubles as a roster writer (a non-nil Players field + // below triggers SaveParticipants/SaveSeeds). It needs no second + // password: the admin sign-in is enough (operator ruling 2026-10-12), + // and in self-run the route is organiser-only + // (isSelfRunMainGatedConfigRoute). comp.Name = strings.TrimSpace(comp.Name) // See POST handler comment, same trim is needed here so the @@ -2361,7 +2353,7 @@ func RegisterCompetitionHandlers(r *gin.RouterGroup, store *state.Store, eng *en }{Competition: updated, Warnings: nearDupWarnings}) }) - r.DELETE("/competitions/:id", RequireElevatedPassword(elevated), func(c *gin.Context) { + r.DELETE("/competitions/:id", func(c *gin.Context) { id, ok := requireValidCompID(c) if !ok { return @@ -2385,7 +2377,7 @@ func RegisterCompetitionHandlers(r *gin.RouterGroup, store *state.Store, eng *en c.Status(http.StatusNoContent) }) - r.POST("/competitions/:id/invalidate", RequireElevatedPassword(elevated), func(c *gin.Context) { + r.POST("/competitions/:id/invalidate", func(c *gin.Context) { id, ok := requireValidCompID(c) if !ok { return @@ -2499,7 +2491,7 @@ func RegisterCompetitionHandlers(r *gin.RouterGroup, store *state.Store, eng *en c.JSON(http.StatusOK, comp) }) - r.DELETE("/competitions/:id/draw", RequireElevatedPassword(elevated), func(c *gin.Context) { + r.DELETE("/competitions/:id/draw", func(c *gin.Context) { id, ok := requireValidCompID(c) if !ok { return @@ -2512,11 +2504,12 @@ func RegisterCompetitionHandlers(r *gin.RouterGroup, store *state.Store, eng *en c.Status(http.StatusNoContent) }) - // Elevated-gated for the same reason as DELETE /draw above: it discards - // recorded results. It does NOT delete the operator's file, but the - // knockout results inside it become unreachable through the app, so it is - // as consequential as a discard from the console's point of view. - r.POST("/competitions/:id/bracket/quarantine", RequireElevatedPassword(elevated), func(c *gin.Context) { + // Organiser-only in self-run (isSelfRunMainGatedConfigRoute), like DELETE + // /draw above: it discards recorded results. It does NOT delete the + // operator's file, but the knockout results inside it become unreachable + // through the app, so it is as consequential as a discard from the + // console's point of view. + r.POST("/competitions/:id/bracket/quarantine", func(c *gin.Context) { id, ok := requireValidCompID(c) if !ok { return @@ -2539,10 +2532,14 @@ func RegisterCompetitionHandlers(r *gin.RouterGroup, store *state.Store, eng *en }) }) - // Elevated-gated: completing a competition is IRREVERSIBLE (invalidate - // rejects a completed comp), like DELETE /competitions/:id and - // /invalidate. In file mode without an admin password the gate is a no-op. - r.POST("/competitions/:id/complete", RequireElevatedPassword(elevated), func(c *gin.Context) { + // Completing a competition is IRREVERSIBLE (invalidate rejects a completed + // comp), but it asks for no second password (operator ruling 2026-10-12: + // "Completing a competition must not require an admin password"). It needs + // what starting a competition needs: the sign-in in officiated mode, and + // nothing in self-run, where it is deliberately NOT in + // isSelfRunMainGatedConfigRoute. The bracket guard below is what stops a + // competition being sealed early. + r.POST("/competitions/:id/complete", func(c *gin.Context) { id, ok := requireValidCompID(c) if !ok { return @@ -2564,14 +2561,17 @@ func RegisterCompetitionHandlers(r *gin.RouterGroup, store *state.Store, eng *en // completion. tx.LoadBracket reuses the already-held lock; the public // Store.LoadBracket would deadlock the non-reentrant mutex. // - // len(Rounds) > 0 skips bracketless formats (league/swiss/pools complete - // via their own path -- no bracket.json, which LoadBracket maps to an - // empty bracket + nil error). Completion is IRREVERSIBLE, so a genuine - // I/O/parse fault fails CLOSED (500) rather than sealing with unknown - // bracket state. + // A bracketless format (league/swiss/pools: no bracket.json, which + // LoadBracket maps to an empty bracket + nil error) is held to its own + // matches instead (engine.PoolMatchesSettled, plus the Swiss round + // count). Completion is IRREVERSIBLE, so a genuine I/O/parse fault + // fails CLOSED (500) rather than sealing with unknown state. var compOut *state.Competition - var statusErr, bracketFault error + var statusErr, bracketFault, matchesFault error var notFoundFlag bool + // tx.LoadPoolMatches skips the first-read legacy conversion the + // store's own read runs; run it first, as AdvanceSwissRound does. + store.EnsureLegacyUpgraded(id) txErr := store.WithTransaction(id, func(tx state.StoreTx) error { current, lerr := tx.LoadCompetition(id) if lerr != nil { @@ -2594,6 +2594,27 @@ func RegisterCompetitionHandlers(r *gin.RouterGroup, store *state.Store, eng *en statusErr = fmt.Errorf("all bracket matches must be completed before this competition can be finished") return nil } + if bracket == nil || len(bracket.Rounds) == 0 { + // No knockout rounds to check (a league, a Swiss competition, + // a pools-only one): the competition is finished only once its + // own matches are, by the rule a league completes by itself + // (engine.PoolMatchesSettled), and a Swiss competition only + // once every configured round has been drawn. Every caller is + // held to it, the anonymous self-run caller included. + matches, merr := tx.LoadPoolMatches(id) + if merr != nil { + matchesFault = merr + return nil + } + if !engine.PoolMatchesSettled(matches) { + statusErr = fmt.Errorf("all matches must be completed before this competition can be finished") + return nil + } + if n := engine.SwissRoundsToDraw(current); n > 0 { + statusErr = fmt.Errorf("%d Swiss round(s) remain to be drawn and played before this competition can be finished", n) + return nil + } + } current.Status = state.CompStatusComplete compOut = current return tx.SaveCompetition(current) @@ -2606,6 +2627,10 @@ func RegisterCompetitionHandlers(r *gin.RouterGroup, store *state.Store, eng *en c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load bracket"}) return } + if matchesFault != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load matches"}) + return + } if statusErr != nil { c.JSON(http.StatusBadRequest, gin.H{"error": statusErr.Error()}) return @@ -2857,7 +2882,7 @@ func RegisterCompetitionHandlers(r *gin.RouterGroup, store *state.Store, eng *en c.JSON(http.StatusOK, gin.H{"candidates": groupsJSON(report.Pending), "recorded": groupsJSON(report.Recorded)}) }) - r.DELETE("/competitions/:id/overrides", RequireElevatedPassword(elevated), func(c *gin.Context) { + r.DELETE("/competitions/:id/overrides", func(c *gin.Context) { id, ok := requireValidCompID(c) if !ok { return @@ -2890,7 +2915,7 @@ func RegisterCompetitionHandlers(r *gin.RouterGroup, store *state.Store, eng *en c.Status(http.StatusNoContent) }) - r.PUT("/competitions/:id/awards", RequireElevatedPassword(elevated), func(c *gin.Context) { + r.PUT("/competitions/:id/awards", func(c *gin.Context) { id, ok := requireValidCompID(c) if !ok { return diff --git a/internal/mobileapp/handlers_competition_estimate_test.go b/internal/mobileapp/handlers_competition_estimate_test.go index 600fb974a..292dc70e9 100644 --- a/internal/mobileapp/handlers_competition_estimate_test.go +++ b/internal/mobileapp/handlers_competition_estimate_test.go @@ -99,11 +99,10 @@ func TestGETCompetitionScheduleEstimate(t *testing.T) { "expected non-zero total duration for competition with 8 participants") }) - t.Run("no elevated auth required, main password sufficient", func(t *testing.T) { - // The endpoint is registered under adminGroup (main-password gated) - // but does NOT require elevated (admin) auth. This test confirms - // the handler itself does not check for elevated credentials; - // AuthMiddleware enforcement is tested via the real server wiring, + t.Run("main password sufficient", func(t *testing.T) { + // The endpoint is registered under adminGroup (main-password gated). + // This test confirms the handler itself asks for no other + // credential; AuthMiddleware enforcement is tested via the real server wiring, // not this unit-level router (setupTestRouter omits AuthMiddleware). comp := state.Competition{ ID: "main-auth-estimate", @@ -115,10 +114,10 @@ func TestGETCompetitionScheduleEstimate(t *testing.T) { w := httptest.NewRecorder() req, _ := http.NewRequest("GET", "/api/competitions/main-auth-estimate/schedule/estimate", nil) - // No X-Admin-Password, only main-password auth is needed (enforced - // by AuthMiddleware in production, not by the handler itself). + // Only main-password auth is needed (enforced by AuthMiddleware in + // production, not by the handler itself). r.ServeHTTP(w, req) assert.Equal(t, http.StatusOK, w.Code, - "estimate endpoint must not require elevated auth") + "estimate endpoint must need no other credential") }) } diff --git a/internal/mobileapp/handlers_competition_test.go b/internal/mobileapp/handlers_competition_test.go index 808e8be68..da5fee6b0 100644 --- a/internal/mobileapp/handlers_competition_test.go +++ b/internal/mobileapp/handlers_competition_test.go @@ -3358,43 +3358,6 @@ func TestPUTCompetitionAwards(t *testing.T) { assert.Equal(t, http.StatusBadRequest, w.Code) }) - t.Run("missing elevated password rejected", func(t *testing.T) { - // Build a router with an active elevated gate: store has a tournament - // with a non-empty AdminPassword. The gate becomes active and any - // request without X-Admin-Password must be rejected. - tmpDir := t.TempDir() - s, err := state.NewStore(tmpDir) - require.NoError(t, err) - // SaveTournament with AdminPassword set activates the fileElevatedVerifier gate. - require.NoError(t, s.SaveTournament(&state.Tournament{ - Name: "T", - Password: "mainpw", - Courts: []string{"A"}, - AdminPassword: "secretadminpw", - })) - require.NoError(t, s.SaveCompetition(&state.Competition{ - ID: "awards-elev", Name: "Elev Test", - })) - - gin.SetMode(gin.TestMode) - elev := NewFileElevatedVerifier(s) - hr := gin.New() - api := hr.Group("/api") - RegisterCompetitionHandlers(api, s, nil, NewHub(), elev) - - body2, _ := json.Marshal(map[string]any{ - "fightingSpiritAwards": []map[string]any{ - {"title": "Spirit", "recipientName": "Alice"}, - }, - }) - w := httptest.NewRecorder() - req, _ := http.NewRequest("PUT", "/api/competitions/awards-elev/awards", bytes.NewBuffer(body2)) - req.Header.Set("Content-Type", "application/json") - // Do NOT set X-Admin-Password, should be rejected with 401 (wrong credential). - hr.ServeHTTP(w, req) - assert.Equalf(t, http.StatusUnauthorized, w.Code, "missing elevated password must return 401, got %d: %s", w.Code, w.Body.String()) - }) - t.Run("whitespace trimmed from awards before persisting", func(t *testing.T) { body, _ := json.Marshal(map[string]any{ "fightingSpiritAwards": []map[string]any{ diff --git a/internal/mobileapp/handlers_import.go b/internal/mobileapp/handlers_import.go index e99e82f5b..799412ca2 100644 --- a/internal/mobileapp/handlers_import.go +++ b/internal/mobileapp/handlers_import.go @@ -85,8 +85,8 @@ type ImportResult struct { Warning string `json:"warning,omitempty"` } -func RegisterImportHandlers(r *gin.RouterGroup, store *state.Store, eng *engine.Engine, hub *Hub, elevated ElevatedVerifier) { - r.POST("/tournament/import", RequireElevatedPassword(elevated), func(c *gin.Context) { +func RegisterImportHandlers(r *gin.RouterGroup, store *state.Store, eng *engine.Engine, hub *Hub) { + r.POST("/tournament/import", func(c *gin.Context) { if err := c.Request.ParseMultipartForm(64 << 20); err != nil { // 64 MB limit c.JSON(http.StatusBadRequest, gin.H{"error": "failed to parse multipart form: " + err.Error()}) return diff --git a/internal/mobileapp/handlers_kachinuki_roster_test.go b/internal/mobileapp/handlers_kachinuki_roster_test.go index 6283d903a..f0cd0fba4 100644 --- a/internal/mobileapp/handlers_kachinuki_roster_test.go +++ b/internal/mobileapp/handlers_kachinuki_roster_test.go @@ -207,7 +207,7 @@ func TestKachinukiRosterGET_ReadFailureIs500(t *testing.T) { // not, so it is not in isSelfRunMainGatedConfigRoute. func TestSelfRun_KachinukiRosterIsPublic(t *testing.T) { store := newTempStore(t) - seedSelfRunTournament(t, store, "admin-pw") + seedSelfRunTournament(t, store) seedKachinukiRosterComp(t, store) r := setupSelfRunRouter(t, store, NewFileVerifier(store)) diff --git a/internal/mobileapp/handlers_lineup_changed_test.go b/internal/mobileapp/handlers_lineup_changed_test.go index 7310396f6..19356422b 100644 --- a/internal/mobileapp/handlers_lineup_changed_test.go +++ b/internal/mobileapp/handlers_lineup_changed_test.go @@ -43,7 +43,7 @@ const ( func newChangedLineupFixture(t *testing.T) changedLineupFixture { t.Helper() store := newTempStore(t) - seedSelfRunTournament(t, store, "admin-pw") + seedSelfRunTournament(t, store) require.NoError(t, store.SaveCompetition(&state.Competition{ ID: "c1", Name: "Teams", Kind: "team", TeamSize: 5, Format: state.CompFormatLeague, Status: state.CompStatusPools, })) diff --git a/internal/mobileapp/handlers_match.go b/internal/mobileapp/handlers_match.go index 47133112e..47b6070ad 100644 --- a/internal/mobileapp/handlers_match.go +++ b/internal/mobileapp/handlers_match.go @@ -888,7 +888,7 @@ func RegisterMatchHandlers(r *gin.RouterGroup, eng *engine.Engine, store Competi // calls it over its own snapshot) so the two cannot drift; only // the error SHAPE differs here (partial-success entries carry a // plain message). - check, snap, snapErr := applyCorrectionReasonUnderTx(stx, id, results[i].ID, &results[i].MatchResult) + check, snap, snapErr := applyCorrectionReasonUnderTx(stx, id, results[i].ID, &results[i].MatchResult, comp) if snapErr != nil { return snapErr } @@ -2013,7 +2013,9 @@ var errRepBoutAdded = &selfRunRefusal{ // stored. In order: // // - A match that has finished is the organiser's to correct -// (errResultFinalized). +// (errResultFinalized), except that an exact resend of the finish already +// recorded (engine.ExactScoreReplay) passes: the engine answers it as +// recorded and writes nothing to the match (bc-xrpl). // - An encounter has one representative bout: a write listing two is // refused (errDuplicateRepBout). A participant's write never creates one // (only the add route does, with its tie, pool, kachinuki, engi and @@ -2056,11 +2058,23 @@ var errRepBoutAdded = &selfRunRefusal{ // error rather than allowing the write (matchSnapshotOrErr). A match in neither // store (found false) passes on purpose: the write that follows under the same // lock refuses it (the engine's errMatchNotFound), so nothing can land on it. -func holdSelfReportedWriteUnderTx(compID, matchID string, snap matchSnapshot, found bool, result *state.MatchResult, startOnly bool) error { +func holdSelfReportedWriteUnderTx(compID, matchID string, snap matchSnapshot, found bool, result *state.MatchResult, comp *state.Competition, startOnly bool) error { if !found { return nil } if isMatchFinalized(snap.Status) { + // The participant's own finish sent again because its answer was + // lost (the offline queue replays it with the stamp it was sent + // with) is no change to a finished match: the engine answers it as + // recorded and writes nothing (engine.ExactScoreReplay, the same + // probe over the same stored match, under this same lock, runs first + // in RecordMatchResultWithIneligibilityTx). The rewrites below shape + // a write that will land, so they have nothing to do here. Any + // resend that differs where it would land is no such replay and is + // refused as before. + if engine.ExactScoreReplay(snap.Stored, result, comp, !snap.InBracket) { + return nil + } return errResultFinalized } var subs []state.SubMatchResult @@ -2934,7 +2948,7 @@ type correctionCheck struct { // reads the snapshot ONCE for every guard of the write (the self-run judge or // the pick judge, then this), and the start gate after it reads the returned // status instead. -func applyCorrectionReason(snap matchSnapshot, r *state.MatchResult) correctionCheck { +func applyCorrectionReason(snap matchSnapshot, r *state.MatchResult, comp *state.Competition) correctionCheck { r.ReopenPending = snap.ReopenPending if r.Status == state.MatchStatusCompleted && snap.Status == state.MatchStatusCompleted { // A finish made BEFORE the stored result (a queued Finish replayed @@ -2950,9 +2964,13 @@ func applyCorrectionReason(snap matchSnapshot, r *state.MatchResult) correctionC // An equal stamp with nothing it changes differing from what is // stored is the finish already recorded, sent again because its // answer was lost: the engine answers it as recorded and writes - // nothing (engine.ExactScoreReplay, bc-xrpl). A same-stamp write - // that differs is a correction, and still needs its reason. - if engine.ExactScoreReplay(snap.Stored, r, !snap.InBracket) { + // nothing (engine.ExactScoreReplay, bc-xrpl). A group changed since + // (a later point from another board, R3/S2) is held by the merge and + // lands nothing, so it is not the resend's to differ in. A same-stamp + // write that differs where it would land is a correction, and still + // needs its reason. comp tells the probe a kachinuki or engi write, + // which the write path shapes before the merge. + if engine.ExactScoreReplay(snap.Stored, r, comp, !snap.InBracket) { return correctionCheck{StoredStatus: snap.Status, StoredDecision: snap.Decision} } if r.CorrectionReason == "" { @@ -2990,7 +3008,7 @@ func applyCorrectionReason(snap matchSnapshot, r *state.MatchResult) correctionC // bulk-score, one read per entry, whose later guard (the landed picks' // judge) compares the match against it. The score handler reads the snapshot // once for all its guards and asks applyCorrectionReason directly. -func applyCorrectionReasonUnderTx(stx state.StoreTx, compID, matchID string, r *state.MatchResult) (correctionCheck, matchSnapshot, error) { +func applyCorrectionReasonUnderTx(stx state.StoreTx, compID, matchID string, r *state.MatchResult, comp *state.Competition) (correctionCheck, matchSnapshot, error) { // matchSnapshotOrErr fails CLOSED on a load error: the correction-reason // gate must not pass on an assumed-running status, silently overwriting a // finalized result without the mandatory reason. @@ -2998,7 +3016,7 @@ func applyCorrectionReasonUnderTx(stx state.StoreTx, compID, matchID string, r * if err != nil { return correctionCheck{}, matchSnapshot{}, err } - return applyCorrectionReason(snap, r), snap, nil + return applyCorrectionReason(snap, r, comp), snap, nil } // missingCorrectionReasonError is the refusal for overwriting a completed @@ -3589,12 +3607,19 @@ func registerScoreHandler(r *gin.RouterGroup, eng ScoringEngine, store Competiti if snapErr != nil { return snapErr } + // The competition tells the exact-replay probe (the self-run + // judge's and applyCorrectionReason's) a kachinuki or engi + // write (bc-xrpl). + comp, compErr := stx.LoadCompetition(id) + if compErr != nil { + return compErr + } // mp-ba3, bc-dhas: the self-run judge runs under the per-comp // lock, so concurrent anonymous submissions cannot race it. It // may rewrite result's representative bout before the write. selfReported := resultSource == "self-reported" if selfReported { - if err := holdSelfReportedWriteUnderTx(id, mid, snap, found, result, body.StartOnly); err != nil { + if err := holdSelfReportedWriteUnderTx(id, mid, snap, found, result, comp, body.StartOnly); err != nil { engErr = err return nil } @@ -3615,7 +3640,7 @@ func registerScoreHandler(r *gin.RouterGroup, eng ScoringEngine, store Competiti // runs inside the tx so the is-completed read is race-free (same // lock), over the snapshot read above, and the status it returns // is reused by the start gate below. - check := applyCorrectionReason(snap, result) + check := applyCorrectionReason(snap, result, comp) existingStatus := check.StoredStatus if check.Reject != nil { engErr = check.Reject diff --git a/internal/mobileapp/handlers_match_test.go b/internal/mobileapp/handlers_match_test.go index 51cff67d6..ac00ff5ee 100644 --- a/internal/mobileapp/handlers_match_test.go +++ b/internal/mobileapp/handlers_match_test.go @@ -45,7 +45,7 @@ func TestApplyCorrectionReasonUnderTx_FailsClosedOnLoadError(t *testing.T) { var gotErr error txErr := store.WithTransaction("c1", func(stx state.StoreTx) error { r := &state.MatchResult{ID: "m1", Status: state.MatchStatusCompleted} - _, _, gotErr = applyCorrectionReasonUnderTx(stx, "c1", "m1", r) + _, _, gotErr = applyCorrectionReasonUnderTx(stx, "c1", "m1", r, nil) return nil }) require.NoError(t, txErr) diff --git a/internal/mobileapp/handlers_participants.go b/internal/mobileapp/handlers_participants.go index 0384e043c..5b1cc2d58 100644 --- a/internal/mobileapp/handlers_participants.go +++ b/internal/mobileapp/handlers_participants.go @@ -13,7 +13,7 @@ import ( "github.com/gitrgoliveira/bracket-creator/internal/state" ) -func RegisterParticipantHandlers(r *gin.RouterGroup, store *state.Store, eng *engine.Engine, hub Broadcaster, elevated ElevatedVerifier) { +func RegisterParticipantHandlers(r *gin.RouterGroup, store *state.Store, eng *engine.Engine, hub Broadcaster) { r.GET("/competitions/:id/participants", func(c *gin.Context) { id, ok := requireValidCompID(c) if !ok { @@ -33,7 +33,7 @@ func RegisterParticipantHandlers(r *gin.RouterGroup, store *state.Store, eng *en c.JSON(http.StatusOK, players) }) - r.POST("/competitions/:id/participants", RequireElevatedPassword(elevated), func(c *gin.Context) { + r.POST("/competitions/:id/participants", func(c *gin.Context) { id, ok := requireValidCompID(c) if !ok { return @@ -375,7 +375,7 @@ func RegisterParticipantHandlers(r *gin.RouterGroup, store *state.Store, eng *en c.JSON(http.StatusOK, saved) }) - r.PUT("/competitions/:id/participants/:pid", RequireElevatedPassword(elevated), func(c *gin.Context) { + r.PUT("/competitions/:id/participants/:pid", func(c *gin.Context) { id, ok := requireValidCompID(c) if !ok { return @@ -691,6 +691,9 @@ func RegisterParticipantHandlers(r *gin.RouterGroup, store *state.Store, eng *en if !ok { return } + if refuseCheckInInSelfRun(c, store) { + return + } pid := c.Param("pid") comp, err := store.LoadCompetition(id) @@ -727,6 +730,9 @@ func RegisterParticipantHandlers(r *gin.RouterGroup, store *state.Store, eng *en if !ok { return } + if refuseCheckInInSelfRun(c, store) { + return + } var req struct { ParticipantIDs []string `json:"participantIds"` @@ -780,6 +786,9 @@ func RegisterParticipantHandlers(r *gin.RouterGroup, store *state.Store, eng *en if !ok { return } + if refuseCheckInInSelfRun(c, store) { + return + } pid := c.Param("pid") comp, err := store.LoadCompetition(id) @@ -809,3 +818,29 @@ func RegisterParticipantHandlers(r *gin.RouterGroup, store *state.Store, eng *en c.JSON(http.StatusOK, updatedPlayer) }) } + +// errCheckInNotInSelfRun refuses every check-in write in a self-run +// tournament, whoever sends it (operator ruling 2026-10-13: "Self run mode has +// no check-in"). The organiser is refused too: the draw ignores check-in there +// (Competition.CheckInApplies), so a stored check-in would decide nothing. +var errCheckInNotInSelfRun = &selfRunRefusal{ + status: http.StatusConflict, + code: "check_in_not_in_self_run", + message: "Self-run mode has no check-in.", +} + +// refuseCheckInInSelfRun answers a check-in write with errCheckInNotInSelfRun +// when the tournament is self-run and reports whether it did, so the handler +// stops. A tournament that cannot be loaded is a 500, never a pass. +func refuseCheckInInSelfRun(c *gin.Context, store *state.Store) bool { + t, err := store.LoadTournament() + if err != nil { + internalError(c, err) + return true + } + if !t.IsSelfRun() { + return false + } + c.JSON(errCheckInNotInSelfRun.status, errCheckInNotInSelfRun.body()) + return true +} diff --git a/internal/mobileapp/handlers_participants_test.go b/internal/mobileapp/handlers_participants_test.go index 50595be74..031c8979d 100644 --- a/internal/mobileapp/handlers_participants_test.go +++ b/internal/mobileapp/handlers_participants_test.go @@ -989,7 +989,7 @@ func TestBulkCheckIn_BroadcastBehaviour(t *testing.T) { gin.SetMode(gin.TestMode) r := gin.New() admin := r.Group("/api") - RegisterParticipantHandlers(admin, store, engine.New(store), spy, NewFileElevatedVerifier(store)) + RegisterParticipantHandlers(admin, store, engine.New(store), spy) compID := "comp-broadcast-test" require.NoError(t, store.SaveCompetition(&state.Competition{ diff --git a/internal/mobileapp/handlers_reset_test.go b/internal/mobileapp/handlers_reset_test.go index 76f6d4aeb..f1746c3a6 100644 --- a/internal/mobileapp/handlers_reset_test.go +++ b/internal/mobileapp/handlers_reset_test.go @@ -37,7 +37,7 @@ func setupResetTest(t *testing.T, verifier PasswordVerifier) (*state.Store, *gin r := gin.New() api := r.Group("/api") RegisterResetHandlers(api, store, verifier, hub) - RegisterAuthConfigHandlers(api, verifier, defaultElevatedVerifier(verifier, store), false) + RegisterAuthConfigHandlers(api, verifier, false) return store, r, hub } diff --git a/internal/mobileapp/handlers_sponsors_test.go b/internal/mobileapp/handlers_sponsors_test.go index e45c57bac..44a172866 100644 --- a/internal/mobileapp/handlers_sponsors_test.go +++ b/internal/mobileapp/handlers_sponsors_test.go @@ -450,10 +450,9 @@ func TestPostSponsor_SelfRunMode_RejectsAnonymous(t *testing.T) { store, err := state.NewStore(tempDir) require.NoError(t, err) require.NoError(t, store.SaveTournament(&state.Tournament{ - Name: "Self-Run Cup", - Password: "main-pw", - AdminPassword: "admin-pw", - Mode: state.TournamentModeSelfRun, + Name: "Self-Run Cup", + Password: "main-pw", + Mode: state.TournamentModeSelfRun, })) eng := engine.New(store) mockFS := fstest.MapFS{"web-mobile/index.html": {Data: []byte("")}} diff --git a/internal/mobileapp/handlers_test.go b/internal/mobileapp/handlers_test.go index e1ccfa65f..2eb61a2e0 100644 --- a/internal/mobileapp/handlers_test.go +++ b/internal/mobileapp/handlers_test.go @@ -50,9 +50,9 @@ func setupTestRouter(t testing.TB) (*gin.Engine, *state.Store, *engine.Engine, * // Admin API admin := r.Group("/api") RegisterTournamentHandlers(admin, store, hub, NewFileVerifier(store)) - RegisterImportHandlers(admin, store, eng, hub, NewFileElevatedVerifier(store)) - RegisterCompetitionHandlers(admin, store, eng, hub, NewFileElevatedVerifier(store)) - RegisterParticipantHandlers(admin, store, eng, hub, NewFileElevatedVerifier(store)) + RegisterImportHandlers(admin, store, eng, hub) + RegisterCompetitionHandlers(admin, store, eng, hub) + RegisterParticipantHandlers(admin, store, eng, hub) RegisterMatchHandlers(admin, eng, store, store, hub, NewFileVerifier(store), store) RegisterDecisionHandlers(admin, eng, store, store, hub) RegisterEligibilityHandlers(admin, store, hub) diff --git a/internal/mobileapp/handlers_tournament.go b/internal/mobileapp/handlers_tournament.go index fffda5710..2b6e3ad84 100644 --- a/internal/mobileapp/handlers_tournament.go +++ b/internal/mobileapp/handlers_tournament.go @@ -479,15 +479,6 @@ func validateTournamentDurationDays(durationDays int) error { // participants out mid-scoring (self-run → officiated). var errModeImmutable = errors.New("tournament mode cannot be changed after creation") -// errSelfRunRequiresAdminPassword is the sentinel returned when a caller -// tries to create or save a self-run tournament in file mode without an -// elevated (admin) password configured. In self-run mode the main-gate -// is skipped, so if enforceElevated's GateActive()==false path (file mode, -// no admin password) were reached the destructive routes would be fully -// public, violating the self-run invariant that destructive calls still -// require X-Admin-Password. (mp-7h7 fail-open fix) -var errSelfRunRequiresAdminPassword = errors.New("self-run tournaments require an admin (destructive-ops) password to be set; configure it before switching to self-run mode or set it in the same request") - // trimPublicInfoFields trims all optional public tournament info string fields // and normalises the Contacts slice: each entry's Label/Value is trimmed and // all-empty entries are dropped. Called identically by the PUT and POST @@ -766,15 +757,6 @@ func RegisterTournamentHandlers(r *gin.RouterGroup, store *state.Store, hub *Hub // immediately instead of waiting for their next write to 401. passwordChanged := false changed, err := store.UpdateTournamentChanged(&t, func(current, desired *state.Tournament) error { - // Preserve the write-only elevated password (spec 004). It has - // json:"-", so the bound body always leaves desired.AdminPassword - // == "", without copying it forward, a routine name/venue save - // would silently wipe the elevated credential. It is changed only - // via PUT /api/auth/admin-password, never here. - if current != nil { - desired.AdminPassword = current.AdminPassword - } - // Preserve Theme.LogoPath (mp-scf): LogoPath has json:"-" so the // bound body never carries it. The logo is managed via // POST/DELETE /api/branding/logo; this PUT must not clear it. @@ -790,8 +772,7 @@ func RegisterTournamentHandlers(r *gin.RouterGroup, store *state.Store, hub *Hub } // Immutability of Mode (mp-7h7): Mode is chosen once at creation - // and cannot be changed via PUT. Mirror the write-only AdminPassword - // preservation pattern: when the incoming body sends Mode == "" + // and cannot be changed via PUT. When the incoming body sends Mode == "" // (omitempty), preserve the current stored value. When the body // sends a non-empty Mode that differs from the stored Mode, reject. // @@ -847,27 +828,6 @@ func RegisterTournamentHandlers(r *gin.RouterGroup, store *state.Store, hub *Hub passwordChanged = true } - // Fail-open guard for self-run + file mode (mp-7h7): if the - // tournament is self-run, the main-gate is skipped, so - // destructive routes rely SOLELY on RequireElevatedPassword / - // enforceElevated. enforceElevated allows requests when - // GateActive()==false (file mode, no AdminPassword set). Without - // this guard, a self-run tournament with no admin password would - // leave destructive routes fully public. Locked mode always fails - // closed (GateActive always true → 503 when unconfigured) so we - // must NOT apply this guard in locked mode: there, the on-disk - // AdminPassword is always empty/inert (the env-var bcrypt hash is - // authoritative), so guarding on desired.AdminPassword == "" - // would wrongly reject every PUT to a locked-mode self-run - // tournament (e.g. a routine venue edit). The `locked` flag is - // captured from the enclosing handler scope. - // - // desired.AdminPassword was preserved from current above, so it - // reflects the current on-disk value. - if !locked && desired.Mode == state.TournamentModeSelfRun && desired.AdminPassword == "" { - return errSelfRunRequiresAdminPassword - } - return nil }) if errors.Is(err, errPasswordRequired) { @@ -878,10 +838,6 @@ func RegisterTournamentHandlers(r *gin.RouterGroup, store *state.Store, hub *Hub c.JSON(http.StatusBadRequest, gin.H{"error": errModeImmutable.Error()}) return } - if errors.Is(err, errSelfRunRequiresAdminPassword) { - c.JSON(http.StatusBadRequest, gin.H{"error": errSelfRunRequiresAdminPassword.Error()}) - return - } if err != nil { internalError(c, err) return @@ -921,34 +877,12 @@ func RegisterTournamentHandlers(r *gin.RouterGroup, store *state.Store, hub *Hub r.POST("/tournament", func(c *gin.Context) { var t state.Tournament - // ShouldBindBodyWith caches the raw request body so we can bind it a - // SECOND time below into a separate struct. This lets us read a - // transient `adminPassword` field from the SAME POST body WITHOUT - // weakening the json:"-" invariant on Tournament.AdminPassword (which - // deliberately can never be populated by binding a request body). - // Creation is the one safe place to accept the elevated credential in - // the body: there is no existing credential to protect at bootstrap, - // and it lets self-run creation be atomic (the fail-open guard never - // sees a self-run tournament persisted without an admin password). PUT - // keeps the json:"-" preserve-only behaviour (rotation stays on - // PUT /api/auth/admin-password). + // An `adminPassword` field sent by an older client (the removed + // destructive-ops password) is an unknown key here and is ignored. if err := c.ShouldBindBodyWith(&t, binding.JSON); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } - // Second bind into a local struct to capture the transient admin - // (destructive-ops) password. Tournament.AdminPassword has json:"-" - // so it is never populated by the bind above, this is the only way - // to read it from the body at creation time. A bind failure here is - // impossible (the first bind already validated the JSON), but handle - // it for safety. - var extra struct { - AdminPassword string `json:"adminPassword"` - } - if err := c.ShouldBindBodyWith(&extra, binding.JSON); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } // See PUT handler above. The current CreateTournament UI in // app.jsx trims client-side before submit; this is defense-in-depth // against direct API callers (curl etc.) sending padded values, @@ -1073,15 +1007,6 @@ func RegisterTournamentHandlers(r *gin.RouterGroup, store *state.Store, hub *Hub return } - // Preserve the write-only elevated password (spec 004) across a - // re-bootstrap POST. It has json:"-" so the bound body never carries - // it; without this a re-POST over an existing record would wipe the - // elevated credential. On a true first bootstrap (existingForPost == - // nil) it stays "", the operator sets it later via Settings. - if existingForPost != nil { - t.AdminPassword = existingForPost.AdminPassword - } - // Fix 3329416172: Mode is immutable after creation (mp-7h7). On a // re-bootstrap POST (existingForPost != nil) preserve the stored Mode // so a caller cannot flip an officiated tournament to self-run or vice @@ -1097,59 +1022,6 @@ func RegisterTournamentHandlers(r *gin.RouterGroup, store *state.Store, hub *Hub t.Mode = existingMode } - // Atomic admin-password set at creation (mp-7h7). In file mode the - // admin (destructive-ops) password is stored as PLAINTEXT in - // Tournament.AdminPassword (fileElevatedVerifier.Verify does an - // exact-string compare). When the body carried an `adminPassword` - // (read into `extra` above) and no admin password is already on disk, - // set it now, BEFORE the fail-open guard and before - // SaveTournamentChanged, so a self-run tournament is persisted WITH - // the credential in a single request. This is what makes self-run - // creation possible via the real API (the json:"-" invariant blocks - // the field from being bound directly into Tournament). - // - // Locked mode: the env-var bcrypt hash is the authoritative elevated - // credential; any body adminPassword is inert, so ignore it (do not - // write a plaintext value that would never be consulted). - // - // Fix 3329406566: validate adminPassword length before copy. The field - // is transient (not in Tournament) so validateTournamentLengths never - // sees it; enforce MaxLenTournamentPassword here. - fileMode := verifier == nil || !verifier.RedactStoredPassword() - // Fix 3331061367: validate adminPassword length whenever the field is - // present in the body, regardless of mode or whether an existing - // credential is already stored. This enforces the OpenAPI maxLength:256 - // contract consistently: locked mode and re-bootstrap with an existing - // password would otherwise accept and silently discard an oversized - // value, giving callers no feedback that their request violates the - // contract. Only persist the value when in file mode with no existing - // elevated credential (locked mode uses TOURNAMENT_ADMIN_PASSWORD_HASH; - // re-bootstrap preserves the existing credential). - if extra.AdminPassword != "" { - if err := validateMaxLen("adminPassword", extra.AdminPassword, MaxLenTournamentPassword); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - if fileMode && t.AdminPassword == "" { - t.AdminPassword = extra.AdminPassword - } - } - - // Fail-open guard for self-run + file mode (mp-7h7). - // In self-run mode the main-gate is skipped, so destructive routes - // rely SOLELY on RequireElevatedPassword / enforceElevated. - // enforceElevated GateActive()==false (file mode, no admin password) - // means destructive routes would be fully public, violating the - // invariant. Reject creation of a self-run tournament in file mode - // unless an AdminPassword is set: either already on disk (preserved - // from existingForPost) or supplied in this request's body (set just - // above). Locked mode always fails closed (GateActive always true → - // 503) so no guard needed there. - if fileMode && t.Mode == state.TournamentModeSelfRun && t.AdminPassword == "" { - c.JSON(http.StatusBadRequest, gin.H{"error": errSelfRunRequiresAdminPassword.Error()}) - return - } - if _, err := store.SaveTournamentChanged(&t); err != nil { internalError(c, err) return diff --git a/internal/mobileapp/middleware.go b/internal/mobileapp/middleware.go index 26858fe37..07d35174a 100644 --- a/internal/mobileapp/middleware.go +++ b/internal/mobileapp/middleware.go @@ -186,86 +186,33 @@ func loadExistingCompetition(c *gin.Context, comps CompetitionStore, compID stri return comp, true } -// RequireElevatedPassword gates destructive operations behind a SECOND -// password supplied in the X-Admin-Password header (spec 004 / mp-e21). It -// is layered on top of AuthMiddleware, the route group already verified the -// main password, so this only adds the second factor. Attach it per-route to -// the gated subset (competition delete / invalidate / draw / overrides, -// participant roster mutations, CSV import) rather than to the whole group, -// since most admin routes stay single-factor. -// -// Three outcomes, driven by the ElevatedVerifier contract: -// -// - GateActive() == false → c.Next(). File mode with no admin password -// set: the feature is opt-in, so destructive ops behave exactly as -// before (main password only). This is the back-compat path. -// - Configured() == false → 503. Locked mode with no/invalid -// TOURNAMENT_ADMIN_PASSWORD_HASH: fail closed rather than silently -// allowing destructive ops with the main password alone. -// - otherwise → Verify(X-Admin-Password); 401 on mismatch. -// -// Per the product decision (re-prompt every time) there is no elevation -// token or session, the password travels on each gated request. -func RequireElevatedPassword(ev ElevatedVerifier) gin.HandlerFunc { - return func(c *gin.Context) { - if !enforceElevated(c, ev) { - c.Abort() - return - } - c.Next() - } -} - -// enforceElevated runs the elevated-password gate decision and returns true -// when the request may proceed. On a blocked request it writes the -// appropriate response (503 unconfigured / 401 wrong / 500 verify-error) and -// returns false, the caller must stop (Abort in middleware, or `return` in a -// handler). -// -// It exists as a standalone function, not just the middleware closure, -// because some gated mutations can't be caught by route-level middleware: -// PUT /api/competitions/:id persists the roster (SaveParticipants/SaveSeeds) -// only when the bound body has a non-nil Players field, so its gate must run -// INSIDE the handler after binding. Sharing this one decision keeps the -// inline check and the middleware byte-for-byte identical (same status codes, -// same fail-closed semantics), Copilot PR #193 caught that gating only the -// dedicated participant endpoints left the bulk-PUT roster path open. -func enforceElevated(c *gin.Context, ev ElevatedVerifier) bool { - if !ev.GateActive() { - return true - } - if !ev.Configured() { - c.JSON(http.StatusServiceUnavailable, gin.H{"error": "admin password not configured"}) - return false - } - ok, err := ev.Verify(c.GetHeader("X-Admin-Password")) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "admin auth verification failed"}) - return false - } - if !ok { - c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid admin password"}) - return false - } - return true -} - // isSelfRunMainGatedConfigRoute reports whether the given (method, route) // is an organiser-owned configuration/setup mutation that must remain behind // the main-password gate even in self-run mode (mp-7h7 / Copilot #203). // -// Self-run makes the OPERATIONAL play surface public (scoring, check-in, -// start, draw generation) because there is no table operator. But -// competition COMPLETION is elevated-gated (irreversible, like invalidate). -// configuration mutations are NOT operational play and are NOT elevated-gated, -// so the self-run pass-through would otherwise let an anonymous client tamper -// with setup. The routes below mutate: tournament setup (name/password/courts/ -// check-in windows) and competition setup (creation + config). They are keyed -// by the param-free FullPath so the match is stable across path parameters. +// Self-run makes the OPERATIONAL play surface public (scoring, start, draw +// generation, and completing a competition; check-in does not exist) because there is no +// table operator. Organiser actions are NOT operational play, so the self-run +// pass-through would otherwise let an anonymous client tamper with them: the +// routes below mutate tournament setup (name/password/courts/check-in +// windows), competition setup (creation + config, seeds), make +// corrections, or destroy data (delete or invalidate a competition, discard a +// draw, reset overrides, import a tournament). There is no second password +// for any of them (operator ruling 2026-10-12: the destructive-ops password +// was removed; an admin-level action needs the admin sign-in alone), so this +// list is the ONE thing keeping each of them off the public surface. They are +// keyed by the param-free FullPath so the match is stable across path +// parameters. +// +// Completing a competition (POST /api/competitions/:id/complete) is +// deliberately NOT here: in self-run it is open like starting one (operator +// ruling 2026-10-12, "For self managed, no admin password necessary"). Nor +// are the roster add and edit (POST .../participants, PUT +// .../participants/:pid): in self-run anyone can edit the roster (operator +// ruling 2026-10-12). // -// IMPORTANT: any NEW admin route that mutates organiser-owned setup (rather -// than operational play) and is not already elevated-gated MUST be added here, -// otherwise it becomes anonymously writable in self-run mode. +// IMPORTANT: any NEW admin route that is not operational play MUST be added +// here, otherwise it becomes anonymously writable in self-run mode. // // Three team writes are deliberately public in self-run (operator decision, // bc-dhas): the ones the public score sheet makes when a competitor names a @@ -278,18 +225,27 @@ func isSelfRunMainGatedConfigRoute(method, fullPath string) bool { switch method + " " + fullPath { case http.MethodGet + " /api/tournament", // Fix 3329406556: password field in full Tournament struct; viewer uses /api/viewer/tournament http.MethodPost + " /api/tournament", // Fix 3329416167: re-bootstrap overwrite when tournament already exists - http.MethodPut + " /api/tournament", // tournament name/password/courts/check-in windows + http.MethodPut + " /api/tournament", // tournament name/password/courts http.MethodPost + " /api/competitions", // create a competition (category), setup http.MethodPut + " /api/competitions/:id", // edit competition config, setup http.MethodPost + " /api/tournament/announce", // Fix 3329416176: organiser config, not operational play http.MethodDelete + " /api/announcements/:id", // Fix 3329416176: organiser config, not operational play http.MethodDelete + " /api/announcements", // Fix 3329416176: organiser config, not operational play - http.MethodPut + " /api/auth/admin-password", // Fix 3330063192: relies on AuthMiddleware main-pw verification; not elevated-gated + http.MethodDelete + " /api/competitions/:id", // bc-cmpw: deleting a competition, organiser, destructive + http.MethodPost + " /api/competitions/:id/invalidate", // bc-cmpw: invalidating a competition, organiser, irreversible + http.MethodDelete + " /api/competitions/:id/draw", // bc-cmpw: discarding a draw, organiser, destructive + http.MethodPost + " /api/competitions/:id/bracket/quarantine", // bc-cmpw: setting aside a damaged bracket file, organiser repair + http.MethodDelete + " /api/competitions/:id/overrides", // bc-cmpw: resetting manual ranking overrides, organiser correction + http.MethodPut + " /api/competitions/:id/awards", // bc-cmpw: fighting-spirit awards, organiser + http.MethodPost + " /api/tournament/import", // bc-cmpw: importing a tournament replaces its data, organiser, destructive http.MethodPut + " /api/competitions/:id/matches/:mid/override-winner", // Fix 3330080949: result correction, organiser, not participant play http.MethodPost + " /api/competitions/:id/matches/:mid/reopen", // mp-gmcg: reopen a finalized kachinuki result, organiser correction, same class as override-winner http.MethodPost + " /api/competitions/:id/matches/:mid/requeue-blocker-and-reopen", // mp-gmcg A4: destructive (requeues a blocker) organiser correction; same class as reopen http.MethodPut + " /api/competitions/:id/pools/:poolId/override-rank", // Fix 3330080949: standings correction, organiser, not participant play http.MethodPost + " /api/competitions/:id/competitor-status", // Fix 3331033814: eligibility mutation, organiser decision, not operational play + http.MethodPost + " /api/competitions/:id/league-tiebreak", // a team league's supplementary tie-break bouts, organiser decision, same class as override-rank + http.MethodDelete + " /api/competitions/:id/league-tiebreak", // removing them, organiser decision + http.MethodPost + " /api/competitions/:id/league-tiebreak/finalize", // accepting a league's shared ranks, organiser decision http.MethodGet + " /api/competitions/:id/export", // Fix 3332740291: xlsx export, admin/CPU-heavy, not operational play http.MethodGet + " /api/competitions/:id/export-results", // mp-i96p: results xlsx export, admin/CPU-heavy, keep gated like /export http.MethodPut + " /api/competitions/:id/matches/:mid/court", // court assignment, organiser coordination @@ -299,9 +255,13 @@ func isSelfRunMainGatedConfigRoute(method, fullPath string) bool { http.MethodPut + " /api/competitions/:id/matches/:mid/quick-score", // bc-dhas: same, and it replaces a match's bouts outright, finished or not http.MethodPut + " /api/competitions/:id/seeds", // seeding, organiser pre-draw setup http.MethodPost + " /api/competitions/:id/competitors/:pid/reinstate", // kiken-injury reinstatement, organiser decision - // Forward check-in (single PUT + bulk POST /checkin-bulk) is intentionally ungated, - // participants and desk staff can check in without the main admin password. - // Only reversal (DELETE) requires it. + // Self-run mode has no check-in (operator ruling 2026-10-13): the three + // check-in writes (PUT, POST /checkin-bulk, DELETE) answer 409 + // check_in_not_in_self_run there for every caller + // (refuseCheckInInSelfRun). Forward check-in is ungated here, so that 409 + // is what an anonymous PUT or bulk POST gets; the reversal below stays on + // this list, so an anonymous DELETE gets the gate's 401 first. In an + // officiated tournament the main gate covers all three. http.MethodDelete + " /api/competitions/:id/participants/:pid/checkin", // check-in reversal, organiser correction // The score sheet's own lineup and member writes are NOT here (see the // doc comment above); these are the Lineups page's, which the public @@ -315,6 +275,8 @@ func isSelfRunMainGatedConfigRoute(method, fullPath string) bool { http.MethodDelete + " /api/competitions/:id/matches/:mid/kachinuki-bout", // mp-gmcg: removing a bout is an organiser correction, same class as reopen/override-winner; the participant score path gates itself via enforceSelfRunPolicy, this route does not http.MethodPost + " /api/sponsors", // mp-c38: sponsor logo upload, organiser setup, not operational play http.MethodDelete + " /api/sponsors/:index", // mp-c38: sponsor deletion, organiser setup, not operational play + http.MethodPost + " /api/branding/logo", // mp-scf: tournament logo upload, organiser setup, not operational play + http.MethodDelete + " /api/branding/logo", // mp-scf: tournament logo removal, organiser setup, not operational play http.MethodPost + " /api/print/:type": // mp-w87e: PDF export, admin/CPU-heavy, not operational play return true default: @@ -427,18 +389,15 @@ func AuthMiddleware(verifier PasswordVerifier, store *state.Store) gin.HandlerFu // Self-run mode (mp-7h7): skip the main-password gate for operational // routes (scoring, check-in, start, generate draw, etc.), // there is no dedicated table operator, so participants drive their own - // flow. Destructive routes (delete competition, invalidate, complete, - // draw, overrides, participant roster mutations, import) remain gated by the - // EXISTING RequireElevatedPassword / enforceElevated decorators that - // already fire downstream on those specific routes. No new allowlist - // needed, the elevated-decorator set IS the allowlist (DRY). + // flow. // - // Exception, configuration/setup mutations stay main-gated even in - // self-run mode (see isSelfRunMainGatedConfigRoute). These routes - // mutate organiser-owned setup (tournament name/password/courts/ - // check-in windows; competition creation and competition config) as - // opposed to the operational play surface participants drive. They are - // NOT elevated-gated, so without this carve-out the pass-through would + // Exception, organiser routes stay main-gated even in self-run mode + // (see isSelfRunMainGatedConfigRoute). These routes mutate + // organiser-owned setup (tournament name/password/courts/check-in + // windows; competition creation and config), make corrections, + // or destroy data (delete/invalidate a competition, discard a draw, + // reset overrides, import), as opposed to the operational play surface + // participants drive. Without this carve-out the pass-through would // expose them to anonymous callers in self-run (Copilot #203). The SPA // always sends the main password as X-Tournament-Password on admin // mutations, so for the organiser who created the tournament this is diff --git a/internal/mobileapp/middleware_selfrun_test.go b/internal/mobileapp/middleware_selfrun_test.go index a011c7b59..a4ccd0bde 100644 --- a/internal/mobileapp/middleware_selfrun_test.go +++ b/internal/mobileapp/middleware_selfrun_test.go @@ -2,12 +2,12 @@ package mobileapp // Tests for the self-run tournament mode auth boundary (mp-7h7). // -// Three categories: -// 1. Auth matrix: officiated vs self-run × constructive vs destructive routes -// 2. Fail-open guard: self-run + file mode + no admin pw → 400 at creation +// Categories: +// 1. Auth matrix: officiated vs self-run × public vs organiser routes +// 2. No second password: self-run creation needs none, an old one is ignored // 3. Immutability: POST persists mode; PUT switch → 400; PUT omitting mode → preserved // 4. ValidateTournamentMode + ApplyTournamentDefaults unit tests -// 5. Locked + self-run: elevated gate still fires on destructive routes +// 5. Locked + self-run: organiser routes need the main password import ( "bytes" @@ -17,6 +17,9 @@ import ( "net/http" "net/http/httptest" "os" + "path/filepath" + "slices" + "strings" "testing" "testing/fstest" @@ -47,19 +50,16 @@ func setupSelfRunRouter(t *testing.T, store *state.Store, verifier PasswordVerif return r } -// seedSelfRunTournament saves a self-run tournament with a main password -// AND sets an admin password via the store directly (since we need the -// adminPassword on disk before we can call enforceElevated). -func seedSelfRunTournament(t *testing.T, store *state.Store, adminPw string) { +// seedSelfRunTournament saves a self-run tournament with a main password. +func seedSelfRunTournament(t *testing.T, store *state.Store) { t.Helper() err := store.SaveTournament(&state.Tournament{ - Name: "SelfRun Test", - Date: "01-06-2026", - Venue: "Dojo", - Courts: []string{"A"}, - Password: "main-pw", - AdminPassword: adminPw, - Mode: state.TournamentModeSelfRun, + Name: "SelfRun Test", + Date: "01-06-2026", + Venue: "Dojo", + Courts: []string{"A"}, + Password: "main-pw", + Mode: state.TournamentModeSelfRun, }) require.NoError(t, err) } @@ -68,13 +68,12 @@ func seedSelfRunTournament(t *testing.T, store *state.Store, adminPw string) { func seedOfficiatedTournament(t *testing.T, store *state.Store) { t.Helper() err := store.SaveTournament(&state.Tournament{ - Name: "Officiated Test", - Date: "01-06-2026", - Venue: "Dojo", - Courts: []string{"A"}, - Password: "main-pw", - AdminPassword: "admin-pw", - Mode: state.TournamentModeOfficiated, + Name: "Officiated Test", + Date: "01-06-2026", + Venue: "Dojo", + Courts: []string{"A"}, + Password: "main-pw", + Mode: state.TournamentModeOfficiated, }) require.NoError(t, err) } @@ -148,11 +147,11 @@ func TestSelfRun_OfficiatedMode_RequiresMainPassword(t *testing.T) { // Constructive routes are public (no main password needed) in self-run mode. func TestSelfRun_SelfRunMode_ConstructiveRoutesArePublic(t *testing.T) { store := newTempStore(t) - seedSelfRunTournament(t, store, "admin-pw") + seedSelfRunTournament(t, store) r := setupSelfRunRouter(t, store, NewFileVerifier(store)) - // Routes that are main-gated-only today (no elevated decorator) become - // public in self-run. We test a subset because full competition state + // Routes that are main-gated in officiated mode and are not organiser + // routes (isSelfRunMainGatedConfigRoute) become public in self-run. We test a subset because full competition state // is complex to seed. The key invariant is: no 401 from the main gate. // // Note: GET /api/tournament is intentionally NOT in this list. In file @@ -162,6 +161,10 @@ func TestSelfRun_SelfRunMode_ConstructiveRoutesArePublic(t *testing.T) { // (Copilot #203 finding 3329406556). It is kept in isSelfRunMainGatedConfigRoute. constructiveRoutes := []struct{ method, path string }{ {http.MethodGet, "/api/competitions"}, + // bc-cmpw: completing a competition is open in self-run, like + // starting one (operator ruling 2026-10-12). The handler 404s the + // unknown id; the point is that no gate refused it. + {http.MethodPost, "/api/competitions/nonexistent-id/complete"}, } for _, tc := range constructiveRoutes { @@ -182,7 +185,7 @@ func TestSelfRun_SelfRunMode_ConstructiveRoutesArePublic(t *testing.T) { func TestSelfRun_SelfRunMode_GETTournament_RequiresMainPassword(t *testing.T) { gin.SetMode(gin.TestMode) store := newTempStore(t) - seedSelfRunTournament(t, store, "admin-pw") + seedSelfRunTournament(t, store) r := setupSelfRunRouter(t, store, NewFileVerifier(store)) t.Run("no_pw_returns_401", func(t *testing.T) { @@ -210,7 +213,7 @@ func TestSelfRun_SelfRunMode_GETTournament_RequiresMainPassword(t *testing.T) { // on PUT /api/tournament even in self-run mode, so this is transparent. func TestSelfRun_SelfRunMode_PUTTournament_RequiresMainPassword(t *testing.T) { store := newTempStore(t) - seedSelfRunTournament(t, store, "admin-pw") + seedSelfRunTournament(t, store) r := setupSelfRunRouter(t, store, NewFileVerifier(store)) putBody := map[string]any{ @@ -243,11 +246,11 @@ func TestSelfRun_SelfRunMode_PUTTournament_RequiresMainPassword(t *testing.T) { // config) are organiser setup, NOT operational play, like PUT /api/tournament // they stay main-gated in self-run mode (mp-7h7 / Copilot #203 sibling audit). // Without this an anonymous client could create or reconfigure competitions in -// a self-run tournament. These routes are not elevated-gated, so the main gate +// a self-run tournament. These are organiser routes, so the main gate // is the protection. The organiser's SPA always sends X-Tournament-Password. func TestSelfRun_SelfRunMode_CompetitionConfigRoutes_RequireMainPassword(t *testing.T) { store := newTempStore(t) - seedSelfRunTournament(t, store, "admin-pw") + seedSelfRunTournament(t, store) r := setupSelfRunRouter(t, store, NewFileVerifier(store)) configRoutes := []struct{ method, path string }{ @@ -267,7 +270,18 @@ func TestSelfRun_SelfRunMode_CompetitionConfigRoutes_RequireMainPassword(t *test // bc-mrgc: a match's write history, held values included, is the // score editors' organiser view; the public page never reads it. {http.MethodGet, "/api/competitions/some-id/matches/some-match/history"}, + // A team league's tie-breaker decisions (run supplementary bouts, + // remove them, or accept the shared ranks) are the organiser's, as a + // pool rank override is. + {http.MethodPost, "/api/competitions/some-id/league-tiebreak"}, + {http.MethodDelete, "/api/competitions/some-id/league-tiebreak"}, + {http.MethodPost, "/api/competitions/some-id/league-tiebreak/finalize"}, + } + for _, fullPath := range []string{"/api/competitions/:id/league-tiebreak", "/api/competitions/:id/league-tiebreak/finalize"} { + assert.True(t, isSelfRunMainGatedConfigRoute(http.MethodPost, fullPath), "POST %s must be organiser-only in self-run", fullPath) } + assert.True(t, isSelfRunMainGatedConfigRoute(http.MethodDelete, "/api/competitions/:id/league-tiebreak"), + "DELETE /api/competitions/:id/league-tiebreak must be organiser-only in self-run") for _, tc := range configRoutes { t.Run("no_pw_401_"+tc.method+"_"+tc.path, func(t *testing.T) { @@ -292,81 +306,328 @@ func TestSelfRun_SelfRunMode_CompetitionConfigRoutes_RequireMainPassword(t *test } } -// Destructive routes still require X-Admin-Password in self-run mode. -// The main gate is skipped but RequireElevatedPassword still fires. -func TestSelfRun_SelfRunMode_DestructiveRoutes_Require_AdminPassword(t *testing.T) { +// formerlyElevatedOrganiserRoutes lists every route that needed the separate +// destructive-ops password before it was removed (operator ruling 2026-10-12: +// "not re enter admin password to change something. For self managed, no +// admin password necessary"), plus the bulk competition PUT, whose roster path +// asked for it inline. Each is an ORGANISER route now: the admin sign-in alone +// in officiated mode, and organiser-only in self-run +// (isSelfRunMainGatedConfigRoute), so none became public. POST /complete, the +// one other route that password guarded, is public in self-run and is pinned +// by TestSelfRun_Complete_IsPublicAndKeepsTheBracketGuard. The roster add and +// edit were among them too and are open to anyone in self-run (operator +// ruling 2026-10-12: "anyone can edit the roster"): selfRunOpenRosterRoutes. +var formerlyElevatedOrganiserRoutes = []struct{ method, fullPath, path string }{ + {http.MethodDelete, "/api/competitions/:id", "/api/competitions/nonexistent-id"}, + {http.MethodPut, "/api/competitions/:id", "/api/competitions/nonexistent-id"}, + {http.MethodPost, "/api/competitions/:id/invalidate", "/api/competitions/nonexistent-id/invalidate"}, + {http.MethodDelete, "/api/competitions/:id/draw", "/api/competitions/nonexistent-id/draw"}, + {http.MethodPost, "/api/competitions/:id/bracket/quarantine", "/api/competitions/nonexistent-id/bracket/quarantine"}, + {http.MethodDelete, "/api/competitions/:id/overrides", "/api/competitions/nonexistent-id/overrides"}, + {http.MethodPut, "/api/competitions/:id/awards", "/api/competitions/nonexistent-id/awards"}, + {http.MethodPost, "/api/tournament/import", "/api/tournament/import"}, +} + +// selfRunOpenRosterRoutes are the roster add and edit: the admin sign-in in +// officiated mode, open to an anonymous caller in self-run. +var selfRunOpenRosterRoutes = []struct{ method, fullPath, path string }{ + {http.MethodPost, "/api/competitions/:id/participants", "/api/competitions/nonexistent-id/participants"}, + {http.MethodPut, "/api/competitions/:id/participants/:pid", "/api/competitions/nonexistent-id/participants/some-pid"}, +} + +// In self-run mode anyone may add and edit a competition's participants: an +// anonymous caller (an EMPTY password, as the public page sends) reaches both +// handlers and the change is stored. +func TestSelfRun_SelfRunMode_RosterAddAndEdit_AreOpenToAnyone(t *testing.T) { store := newTempStore(t) - // Need a competition to exercise DELETE /competitions/:id - seedSelfRunTournament(t, store, "admin-pw") + seedSelfRunTournament(t, store) r := setupSelfRunRouter(t, store, NewFileVerifier(store)) - destructiveRoutes := []struct{ method, path string }{ - {http.MethodDelete, "/api/competitions/nonexistent-id"}, - {http.MethodPost, "/api/competitions/nonexistent-id/invalidate"}, - {http.MethodDelete, "/api/competitions/nonexistent-id/draw"}, - {http.MethodDelete, "/api/competitions/nonexistent-id/overrides"}, - {http.MethodPost, "/api/tournament/import"}, + for _, tc := range selfRunOpenRosterRoutes { + assert.False(t, isSelfRunMainGatedConfigRoute(tc.method, tc.fullPath), + "%s %s must stay out of isSelfRunMainGatedConfigRoute", tc.method, tc.fullPath) } - for _, tc := range destructiveRoutes { + const compID = "open-roster" + require.NoError(t, store.SaveCompetition(&state.Competition{ID: compID, Name: "Open Roster", Status: state.CompStatusSetup})) + + add := jsonReq(http.MethodPost, "/api/competitions/"+compID+"/participants", map[string]any{"name": "Alice", "dojo": "Dojo A"}) + add.Header.Set("X-Tournament-Password", "") + w := httptest.NewRecorder() + r.ServeHTTP(w, add) + require.Less(t, w.Code, 300, "an anonymous self-run caller must add a participant: %d %s", w.Code, w.Body.String()) + + players, err := store.LoadParticipants(compID, false) + require.NoError(t, err) + require.Len(t, players, 1) + require.NotEmpty(t, players[0].ID) + + edit := jsonReq(http.MethodPut, "/api/competitions/"+compID+"/participants/"+players[0].ID, map[string]any{"name": "Alice Edited", "dojo": "Dojo A"}) + edit.Header.Set("X-Tournament-Password", "") + w = httptest.NewRecorder() + r.ServeHTTP(w, edit) + require.Less(t, w.Code, 300, "an anonymous self-run caller must edit a participant: %d %s", w.Code, w.Body.String()) + + players, err = store.LoadParticipants(compID, false) + require.NoError(t, err) + require.Len(t, players, 1) + assert.Equal(t, "Alice Edited", players[0].Name) +} + +// In self-run mode every formerly elevated route is organiser-only: refused +// without the main password, and the old admin header alone grants nothing. +func TestSelfRun_SelfRunMode_FormerlyElevatedRoutes_AreOrganiserOnly(t *testing.T) { + store := newTempStore(t) + seedSelfRunTournament(t, store) + r := setupSelfRunRouter(t, store, NewFileVerifier(store)) + + for _, tc := range formerlyElevatedOrganiserRoutes { + assert.True(t, isSelfRunMainGatedConfigRoute(tc.method, tc.fullPath), + "%s %s must be in isSelfRunMainGatedConfigRoute", tc.method, tc.fullPath) + t.Run("selfrun_no_pw_"+tc.method+"_"+tc.path, func(t *testing.T) { + w := httptest.NewRecorder() + r.ServeHTTP(w, httptest.NewRequest(tc.method, tc.path, nil)) + assert.Equal(t, http.StatusUnauthorized, w.Code, + "self-run organiser route %s %s must return 401 without the main password", tc.method, tc.path) + }) + + t.Run("selfrun_old_admin_header_alone_"+tc.method+"_"+tc.path, func(t *testing.T) { req := httptest.NewRequest(tc.method, tc.path, nil) - // No X-Tournament-Password and no X-Admin-Password + req.Header.Set("X-Admin-Password", "main-pw") w := httptest.NewRecorder() r.ServeHTTP(w, req) assert.Equal(t, http.StatusUnauthorized, w.Code, - "self-run destructive route %s %s must return 401 without admin pw", tc.method, tc.path) + "the removed X-Admin-Password header must grant nothing on %s %s", tc.method, tc.path) }) - t.Run("selfrun_wrong_admin_pw_"+tc.method+"_"+tc.path, func(t *testing.T) { + t.Run("selfrun_main_pw_"+tc.method+"_"+tc.path, func(t *testing.T) { req := httptest.NewRequest(tc.method, tc.path, nil) - req.Header.Set("X-Admin-Password", "wrong-admin-pw") + req.Header.Set("X-Tournament-Password", "main-pw") + req.Header.Set("X-Confirm-Password", "main-pw") // only DELETE /competitions/:id reads it w := httptest.NewRecorder() r.ServeHTTP(w, req) - assert.Equal(t, http.StatusUnauthorized, w.Code, - "self-run destructive route %s %s must return 401 with wrong admin pw", tc.method, tc.path) + // Past the gate the handler answers for the unknown id or the + // empty body (400/404); no second password is asked for. + assert.NotContains(t, []int{http.StatusUnauthorized, http.StatusForbidden, http.StatusServiceUnavailable}, w.Code, + "the main password alone must reach %s %s (got %d: %s)", tc.method, tc.path, w.Code, w.Body.String()) }) + } +} + +// In officiated mode the signed-in admin performs every formerly elevated +// action with the main password alone. +func TestOfficiatedMode_FormerlyElevatedRoutes_NeedOnlyTheSignIn(t *testing.T) { + store := newTempStore(t) + seedOfficiatedTournament(t, store) + r := setupSelfRunRouter(t, store, NewFileVerifier(store)) - t.Run("selfrun_correct_admin_pw_"+tc.method+"_"+tc.path, func(t *testing.T) { + for _, tc := range append(slices.Clone(formerlyElevatedOrganiserRoutes), selfRunOpenRosterRoutes...) { + t.Run("officiated_no_pw_"+tc.method+"_"+tc.path, func(t *testing.T) { + w := httptest.NewRecorder() + r.ServeHTTP(w, httptest.NewRequest(tc.method, tc.path, nil)) + assert.Equal(t, http.StatusUnauthorized, w.Code, + "officiated %s %s must require the sign-in", tc.method, tc.path) + }) + t.Run("officiated_main_pw_"+tc.method+"_"+tc.path, func(t *testing.T) { req := httptest.NewRequest(tc.method, tc.path, nil) - req.Header.Set("X-Admin-Password", "admin-pw") + req.Header.Set("X-Tournament-Password", "main-pw") + req.Header.Set("X-Confirm-Password", "main-pw") // only DELETE /competitions/:id reads it w := httptest.NewRecorder() r.ServeHTTP(w, req) - // The gate passed (not 401); the downstream handler may return - // 400/404/422 because we're not providing valid payload / IDs. - // We only care that RequireElevatedPassword didn't reject it. - assert.NotEqual(t, http.StatusUnauthorized, w.Code, - "self-run destructive route %s %s must not 401 with correct admin pw", tc.method, tc.path) + assert.NotContains(t, []int{http.StatusUnauthorized, http.StatusForbidden, http.StatusServiceUnavailable}, w.Code, + "the sign-in alone must reach %s %s (got %d: %s)", tc.method, tc.path, w.Code, w.Body.String()) }) } + + t.Run("delete a competition with the sign-in and the password typed again", func(t *testing.T) { + require.NoError(t, store.SaveCompetition(&state.Competition{ID: "to-delete", Name: "To Delete"})) + req := httptest.NewRequest(http.MethodDelete, "/api/competitions/to-delete", nil) + req.Header.Set("X-Tournament-Password", "main-pw") + req.Header.Set("X-Confirm-Password", "main-pw") + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + require.Less(t, w.Code, 300, "delete must succeed: %d %s", w.Code, w.Body.String()) + gone, err := store.LoadCompetition("to-delete") + require.NoError(t, err) + assert.Nil(t, gone, "the competition must be deleted") + }) + + t.Run("save fighting-spirit awards with the sign-in alone", func(t *testing.T) { + require.NoError(t, store.SaveCompetition(&state.Competition{ID: "awards-comp", Name: "Awards"})) + req := jsonReq(http.MethodPut, "/api/competitions/awards-comp/awards", map[string]any{ + "fightingSpiritAwards": []map[string]any{{"title": "Spirit", "recipientName": "Alice"}}, + }) + req.Header.Set("X-Tournament-Password", "main-pw") + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + require.Equal(t, http.StatusOK, w.Code, w.Body.String()) + saved, err := store.LoadCompetition("awards-comp") + require.NoError(t, err) + require.Len(t, saved.FightingSpiritAwards, 1) + }) +} + +// seedFinishedKnockout saves a knockout competition whose bracket is fully +// played (final and bronze completed) or, with finished false, whose bronze +// match is still to play. +func seedFinishedKnockout(t *testing.T, store *state.Store, compID string, finished bool) { + t.Helper() + require.NoError(t, store.SaveCompetition(&state.Competition{ + ID: compID, Name: compID, Format: state.CompFormatKnockout, Status: state.CompStatusKnockout, + })) + bronze := state.MatchStatusCompleted + if !finished { + bronze = state.MatchStatusScheduled + } + require.NoError(t, store.SaveBracket(compID, &state.Bracket{ + Rounds: [][]state.BracketMatch{{ + {ID: "m-final", SideA: "Alice", SideB: "Bob", Status: state.MatchStatusCompleted, Winner: "Alice"}, + }}, + ThirdPlaceMatch: &state.BracketMatch{ID: "m-bronze", SideA: "Carol", SideB: "Dave", Status: bronze, Winner: "Carol"}, + })) } -// POST /api/competitions/:id/participants is elevated-gated (Path A: mp-7h7 -// does NOT relax this endpoint; that's mp-e5j's job). Verify it still -// requires the admin password in self-run mode. -func TestSelfRun_SelfRunMode_ParticipantPOST_StillElevatedGated(t *testing.T) { +// Completing a competition asks no second password (operator ruling +// 2026-10-12): in self-run it is public, like starting one; the bracket +// guard still refuses a bracket with a match to play. +func TestSelfRun_Complete_IsPublicAndKeepsTheBracketGuard(t *testing.T) { + assert.False(t, isSelfRunMainGatedConfigRoute(http.MethodPost, "/api/competitions/:id/complete"), + "POST /complete must stay public in self-run") + store := newTempStore(t) - seedSelfRunTournament(t, store, "admin-pw") + seedSelfRunTournament(t, store) r := setupSelfRunRouter(t, store, NewFileVerifier(store)) - // No X-Admin-Password → 401 even in self-run. - req := jsonReq(http.MethodPost, "/api/competitions/any-id/participants", map[string]any{}) + seedFinishedKnockout(t, store, "ko-unfinished", false) w := httptest.NewRecorder() + r.ServeHTTP(w, httptest.NewRequest(http.MethodPost, "/api/competitions/ko-unfinished/complete", nil)) + assert.Equal(t, http.StatusBadRequest, w.Code, w.Body.String()) + assert.Contains(t, w.Body.String(), "all bracket matches") + + seedFinishedKnockout(t, store, "ko-finished", true) + w = httptest.NewRecorder() + r.ServeHTTP(w, httptest.NewRequest(http.MethodPost, "/api/competitions/ko-finished/complete", nil)) + require.Equal(t, http.StatusOK, w.Code, w.Body.String()) + done, err := store.LoadCompetition("ko-finished") + require.NoError(t, err) + assert.Equal(t, state.CompStatusComplete, done.Status) +} + +// A competition with no knockout rounds to check (a league, a Swiss +// competition) is finished only once every one of its matches is: an +// anonymous self-run caller, who may complete a competition, cannot seal one +// with a match still to play, since completion is irreversible. +func TestSelfRun_Complete_RefusesALeagueWithAMatchToPlay(t *testing.T) { + store := newTempStore(t) + seedSelfRunTournament(t, store) + r := setupSelfRunRouter(t, store, NewFileVerifier(store)) + + const compID = "league-unfinished" + require.NoError(t, store.SaveCompetition(&state.Competition{ + ID: compID, Name: compID, Format: state.CompFormatLeague, Status: state.CompStatusPools, + })) + matches := []state.MatchResult{ + {ID: "Pool A-0", SideA: "Alice", SideB: "Bob", Status: state.MatchStatusCompleted, Winner: "Alice"}, + {ID: "Pool A-1", SideA: "Alice", SideB: "Carol", Status: state.MatchStatusScheduled}, + } + require.NoError(t, store.SavePoolMatches(compID, matches)) + + complete := func() *httptest.ResponseRecorder { + req := httptest.NewRequest(http.MethodPost, "/api/competitions/"+compID+"/complete", nil) + req.Header.Set("X-Tournament-Password", "") + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + return w + } + + w := complete() + assert.Equal(t, http.StatusBadRequest, w.Code, w.Body.String()) + assert.Contains(t, w.Body.String(), "all matches must be completed") + stored, err := store.LoadCompetition(compID) + require.NoError(t, err) + assert.Equal(t, state.CompStatusPools, stored.Status, "a refused completion must leave the competition unfinished") + + matches[1].Status = state.MatchStatusCompleted + matches[1].Winner = "Carol" + require.NoError(t, store.SavePoolMatches(compID, matches)) + w = complete() + require.Equal(t, http.StatusOK, w.Code, w.Body.String()) + stored, err = store.LoadCompetition(compID) + require.NoError(t, err) + assert.Equal(t, state.CompStatusComplete, stored.Status) +} + +// A Swiss competition is finished only once every configured round has been +// drawn and played: the matches of the rounds drawn so far being complete is +// not enough. +func TestSelfRun_Complete_RefusesASwissCompetitionWithARoundToDraw(t *testing.T) { + store := newTempStore(t) + seedSelfRunTournament(t, store) + r := setupSelfRunRouter(t, store, NewFileVerifier(store)) + + const compID = "swiss-unfinished" + comp := &state.Competition{ + ID: compID, Name: compID, Format: state.CompFormatSwiss, Status: state.CompStatusPools, + SwissRounds: 3, SwissCurrentRound: 2, + } + require.NoError(t, store.SaveCompetition(comp)) + require.NoError(t, store.SavePoolMatches(compID, []state.MatchResult{ + {ID: "Swiss R1-M1", SideA: "Alice", SideB: "Bob", Status: state.MatchStatusCompleted, Winner: "Alice"}, + {ID: "Swiss R2-M1", SideA: "Alice", SideB: "Carol", Status: state.MatchStatusCompleted, Winner: "Carol"}, + })) + + complete := func() *httptest.ResponseRecorder { + req := httptest.NewRequest(http.MethodPost, "/api/competitions/"+compID+"/complete", nil) + req.Header.Set("X-Tournament-Password", "") + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + return w + } + + w := complete() + assert.Equal(t, http.StatusBadRequest, w.Code, w.Body.String()) + assert.Contains(t, w.Body.String(), "round") + stored, err := store.LoadCompetition(compID) + require.NoError(t, err) + assert.Equal(t, state.CompStatusPools, stored.Status) + + comp.SwissCurrentRound = 3 + require.NoError(t, store.SaveCompetition(comp)) + w = complete() + require.Equal(t, http.StatusOK, w.Code, w.Body.String()) +} + +// In officiated mode completing needs the sign-in, and nothing more. +func TestOfficiatedMode_Complete_NeedsOnlyTheSignIn(t *testing.T) { + store := newTempStore(t) + seedOfficiatedTournament(t, store) + r := setupSelfRunRouter(t, store, NewFileVerifier(store)) + seedFinishedKnockout(t, store, "ko-finished", true) + + w := httptest.NewRecorder() + r.ServeHTTP(w, httptest.NewRequest(http.MethodPost, "/api/competitions/ko-finished/complete", nil)) + assert.Equal(t, http.StatusUnauthorized, w.Code, "officiated completion needs the sign-in") + + req := httptest.NewRequest(http.MethodPost, "/api/competitions/ko-finished/complete", nil) + req.Header.Set("X-Tournament-Password", "main-pw") + w = httptest.NewRecorder() r.ServeHTTP(w, req) - assert.Equal(t, http.StatusUnauthorized, w.Code, - "POST /competitions/:id/participants must remain elevated-gated in self-run (Path A)") + require.Equal(t, http.StatusOK, w.Code, w.Body.String()) + done, err := store.LoadCompetition("ko-finished") + require.NoError(t, err) + assert.Equal(t, state.CompStatusComplete, done.Status) } // --------------------------------------------------------------------------- -// §2 Fail-open guard: self-run + file mode without admin password +// §2 No second password // --------------------------------------------------------------------------- -// Creating a self-run tournament in file mode without an admin password -// must return 400. (Without the guard, destructive routes would be public.) -func TestSelfRun_FailOpenGuard_CreationWithoutAdminPw_Rejected(t *testing.T) { +// Creating a self-run tournament needs no admin password: organiser routes +// are protected by the main password (isSelfRunMainGatedConfigRoute). +func TestSelfRun_Creation_NeedsNoAdminPassword(t *testing.T) { gin.SetMode(gin.TestMode) store := newTempStore(t) - // File mode verifier. r := setupSelfRunRouter(t, store, NewFileVerifier(store)) body := map[string]any{ @@ -376,140 +637,82 @@ func TestSelfRun_FailOpenGuard_CreationWithoutAdminPw_Rejected(t *testing.T) { "courts": []string{"A"}, "password": "main-pw", "mode": "self-run", - // NO admin password set at all } - req := jsonReq(http.MethodPost, "/api/tournament", body) w := httptest.NewRecorder() - r.ServeHTTP(w, req) - assert.Equal(t, http.StatusBadRequest, w.Code, - "self-run tournament without admin pw must be rejected in file mode") - assert.Contains(t, w.Body.String(), "admin") + r.ServeHTTP(w, jsonReq(http.MethodPost, "/api/tournament", body)) + require.Equal(t, http.StatusCreated, w.Code, w.Body.String()) + + stored, err := store.LoadTournament() + require.NoError(t, err) + assert.Equal(t, state.TournamentModeSelfRun, stored.Mode) + + wDel := httptest.NewRecorder() + r.ServeHTTP(wDel, httptest.NewRequest(http.MethodDelete, "/api/competitions/some-id", nil)) + assert.Equal(t, http.StatusUnauthorized, wDel.Code, "a destructive route stays organiser-only") } -// Creating a self-run tournament via the REAL API body path: the POST carries -// a transient `adminPassword` field (the only way the credential can reach the -// server, since Tournament.AdminPassword is json:"-"). The handler must set it -// atomically with creation so the fail-open guard passes, the tournament is -// persisted WITH the credential, and the destructive routes are gated by it. -// -// This exercises the path the browser/SPA actually uses, the prior -// store-seeding test bypassed it, which is why the "creation impossible via -// real API" bug slipped through. -func TestSelfRun_FailOpenGuard_CreationWithBodyAdminPw_Atomic(t *testing.T) { +// An older client may still send the removed `adminPassword` field, of any +// length; it is ignored and never stored. +func TestSelfRun_Creation_IgnoresALegacyAdminPasswordField(t *testing.T) { gin.SetMode(gin.TestMode) - store := newTempStore(t) + dir := t.TempDir() + store, err := state.NewStore(dir) + require.NoError(t, err) r := setupSelfRunRouter(t, store, NewFileVerifier(store)) - // Fresh file-mode bootstrap POST: self-run + main password + a transient - // adminPassword in the SAME body. No header (file-mode bootstrap). body := map[string]any{ - "name": "Self-Run Tournament", + "name": "T", "date": "01-06-2026", - "venue": "Dojo", + "venue": "V", "courts": []string{"A"}, "password": "main-pw", "mode": "self-run", - "adminPassword": "destructive-pw", + "adminPassword": string(make([]byte, MaxLenTournamentPassword+1)), } - req := jsonReq(http.MethodPost, "/api/tournament", body) w := httptest.NewRecorder() - r.ServeHTTP(w, req) - require.Equal(t, http.StatusCreated, w.Code, - "self-run creation with adminPassword in body must succeed: %s", w.Body.String()) + r.ServeHTTP(w, jsonReq(http.MethodPost, "/api/tournament", body)) + require.Equal(t, http.StatusCreated, w.Code, w.Body.String()) - // The persisted tournament must have mode=self-run AND a non-empty - // (correct) admin password on disk. - stored, err := store.LoadTournament() + raw, err := os.ReadFile(filepath.Join(dir, "tournament.md")) require.NoError(t, err) - require.NotNil(t, stored) - assert.Equal(t, state.TournamentModeSelfRun, stored.Mode, - "stored mode must be self-run") - assert.Equal(t, "destructive-pw", stored.AdminPassword, - "admin password must be persisted atomically with creation") - - // Verify the destructive-route gate now uses the stored admin password: - // no X-Admin-Password → 401; correct X-Admin-Password → passes the gate. - t.Run("destructive route requires admin pw", func(t *testing.T) { - reqNoPw := httptest.NewRequest(http.MethodDelete, "/api/competitions/some-id", nil) - wNoPw := httptest.NewRecorder() - r.ServeHTTP(wNoPw, reqNoPw) - assert.Equal(t, http.StatusUnauthorized, wNoPw.Code, - "destructive route must 401 without X-Admin-Password") - }) - - t.Run("destructive route passes gate with correct admin pw", func(t *testing.T) { - reqWithPw := httptest.NewRequest(http.MethodDelete, "/api/competitions/some-id", nil) - reqWithPw.Header.Set("X-Admin-Password", "destructive-pw") - wWithPw := httptest.NewRecorder() - r.ServeHTTP(wWithPw, reqWithPw) - // Not 401: the elevated gate passed (downstream handler may 204/404). - assert.NotEqual(t, http.StatusUnauthorized, wWithPw.Code, - "destructive route must pass the elevated gate with correct X-Admin-Password (got %d)", wWithPw.Code) - }) - - t.Run("constructive route is public", func(t *testing.T) { - // Use GET /api/competitions, GET /api/tournament is main-gated in - // self-run (file-mode password leak prevention, fix 3329406556). - reqPub := httptest.NewRequest(http.MethodGet, "/api/competitions", nil) - wPub := httptest.NewRecorder() - r.ServeHTTP(wPub, reqPub) - assert.NotEqual(t, http.StatusUnauthorized, wPub.Code, - "constructive route must be public in self-run (no main password)") - }) + assert.NotContains(t, string(raw), "admin_password", "the legacy field must not be stored") } -// Creating a self-run tournament IS allowed when the existing record already -// has an AdminPassword on disk (from a prior setAdminPassword call or -// direct store write). This exercises the preserveFromExisting path where -// t.AdminPassword != "" after the preserve step. -func TestSelfRun_FailOpenGuard_CreationWithExistingAdminPw_Allowed(t *testing.T) { - gin.SetMode(gin.TestMode) - store := newTempStore(t) - - // Seed a prior record with an admin password already set. - err := store.SaveTournament(&state.Tournament{ - Name: "Previous", - Date: "01-01-2026", - Venue: "Old", - Courts: []string{"A"}, - Password: "old-pw", - AdminPassword: "existing-admin-pw", - }) +// A tournament.md written by an earlier release with admin_password still +// loads and works; the value is ignored and the next save drops it. +func TestLegacyAdminPasswordInTournamentFile_IsIgnored(t *testing.T) { + dir := t.TempDir() + store, err := state.NewStore(dir) require.NoError(t, err) + seedOfficiatedTournament(t, store) + path := filepath.Join(dir, "tournament.md") + raw, err := os.ReadFile(path) + require.NoError(t, err) + withLegacy := strings.Replace(string(raw), "password: main-pw\n", "password: main-pw\nadmin_password: old-admin-pw\n", 1) + require.Contains(t, withLegacy, "admin_password: old-admin-pw") + require.NoError(t, os.WriteFile(path, []byte(withLegacy), 0o600)) - r := setupSelfRunRouter(t, store, NewFileVerifier(store)) + // A fresh store reads the file from disk. + store2, err := state.NewStore(dir) + require.NoError(t, err) + loaded, err := store2.LoadTournament() + require.NoError(t, err) + require.NotNil(t, loaded) + assert.Equal(t, "main-pw", loaded.Password) - // Re-POST (re-bootstrap) with mode:"self-run" in body, but no adminPassword - // in the body. The fail-open guard passes because the handler's preserve step - // copies existingForPost.AdminPassword from the prior record. - // NOTE: Mode is immutable, the preserve step also copies existingForPost.Mode - // (empty → "officiated"), so the stored record remains officiated regardless - // of what mode the body requests. This test exercises the fail-open path, not - // self-run creation; for first-create self-run see TestSelfRun_Immutability_POSTPreservesMode. - body := map[string]any{ - "name": "Self-Run Tournament", - "date": "01-06-2026", - "venue": "Dojo", - "courts": []string{"A"}, - "password": "main-pw", - "mode": "self-run", - // adminPassword omitted from body (json:"-"); preserve step picks it up. - } - req := jsonReq(http.MethodPost, "/api/tournament", body) - req.Header.Set("X-Tournament-Password", "old-pw") + r := setupSelfRunRouter(t, store2, NewFileVerifier(store2)) + require.NoError(t, store2.SaveCompetition(&state.Competition{ID: "c1", Name: "C1"})) + req := httptest.NewRequest(http.MethodDelete, "/api/competitions/c1", nil) + req.Header.Set("X-Tournament-Password", "main-pw") + req.Header.Set("X-Confirm-Password", "main-pw") w := httptest.NewRecorder() r.ServeHTTP(w, req) - // Should succeed (201) because the existing record has an admin password. - assert.Equal(t, http.StatusCreated, w.Code, - "re-bootstrap must succeed when existing record has admin pw") + assert.Less(t, w.Code, 300, "the sign-in and the password typed again delete a competition: %d %s", w.Code, w.Body.String()) - // The stored mode must be "officiated", mode immutability preserves the - // existing record's mode (empty → officiated) and ignores the body's "self-run". - loaded, err := store.LoadTournament() + require.NoError(t, store2.SaveTournament(loaded)) + after, err := os.ReadFile(path) require.NoError(t, err) - require.NotNil(t, loaded) - assert.Equal(t, state.TournamentModeOfficiated, loaded.Mode, - "re-bootstrap must preserve existing mode (officiated), not adopt body mode") + assert.NotContains(t, string(after), "admin_password", "the next save drops the legacy key") } // --------------------------------------------------------------------------- @@ -518,12 +721,9 @@ func TestSelfRun_FailOpenGuard_CreationWithExistingAdminPw_Allowed(t *testing.T) // POST self-run → GET mode returns "self-run". // This test exercises a fresh bootstrap (no prior tournament.md) so the -// AuthMiddleware bootstrap branch fires (anonymous POST allowed). -// The fail-open guard for self-run requires an admin password, so we do a -// two-step: (1) fresh bootstrap as officiated to create the record with an -// admin password, (2) verify the store persisted mode=officiated, then -// (3) verify that a self-run can be started fresh when admin pw is pre-set -// directly via the store. +// AuthMiddleware bootstrap branch fires (anonymous POST allowed): (1) fresh +// bootstrap as officiated, verify the store persisted mode=officiated, then +// (2) a fresh self-run bootstrap, verify mode=self-run. func TestSelfRun_Immutability_POSTPreservesMode(t *testing.T) { gin.SetMode(gin.TestMode) store := newTempStore(t) @@ -563,13 +763,12 @@ func TestSelfRun_Immutability_POSTPreservesMode(t *testing.T) { r2 := setupSelfRunRouter(t, store2, NewFileVerifier(store2)) postSelfRun := map[string]any{ - "name": "My Self-Run", - "date": "01-06-2026", - "venue": "Dojo", - "courts": []string{"A"}, - "password": "main-pw", - "mode": "self-run", - "adminPassword": "admin-pw", // required for self-run in file mode + "name": "My Self-Run", + "date": "01-06-2026", + "venue": "Dojo", + "courts": []string{"A"}, + "password": "main-pw", + "mode": "self-run", } // Fresh bootstrap, no X-Tournament-Password needed (file mode, no record). req3 := jsonReq(http.MethodPost, "/api/tournament", postSelfRun) @@ -619,7 +818,7 @@ func TestSelfRun_Immutability_PUT_SwitchModeRejected(t *testing.T) { func TestSelfRun_Immutability_PUT_OmittingModePreservesIt(t *testing.T) { gin.SetMode(gin.TestMode) store := newTempStore(t) - seedSelfRunTournament(t, store, "admin-pw") + seedSelfRunTournament(t, store) r := setupSelfRunRouter(t, store, NewFileVerifier(store)) // PUT without mode field, should preserve self-run. @@ -648,7 +847,7 @@ func TestSelfRun_Immutability_PUT_OmittingModePreservesIt(t *testing.T) { func TestSelfRun_Immutability_PUT_SameModeIsIdempotent(t *testing.T) { gin.SetMode(gin.TestMode) store := newTempStore(t) - seedSelfRunTournament(t, store, "admin-pw") + seedSelfRunTournament(t, store) r := setupSelfRunRouter(t, store, NewFileVerifier(store)) putBody := map[string]any{ @@ -733,10 +932,12 @@ func TestApplyTournamentDefaults_NormalizesEmptyMode(t *testing.T) { // §5 Locked mode + self-run // --------------------------------------------------------------------------- -// In locked mode the elevated gate is always active (GateActive==true); -// destructive routes in self-run mode must still return 401/503. -func TestSelfRun_LockedMode_DestructiveRoutes_StillRequireAdminPw(t *testing.T) { +// In locked mode + self-run the formerly elevated routes are organiser-only: +// 401 without the main password, reached with it. A deployment that still +// sets TOURNAMENT_ADMIN_PASSWORD_HASH starts and works; the value is ignored. +func TestSelfRun_LockedMode_OrganiserRoutes_NeedOnlyTheMainPassword(t *testing.T) { gin.SetMode(gin.TestMode) + t.Setenv(retiredAdminPasswordHashEnv, "$2a$10$retiredretiredretiredretiredretiredretiredretiredre") store := newTempStore(t) // Build a bcrypt verifier (locked mode). @@ -752,24 +953,32 @@ func TestSelfRun_LockedMode_DestructiveRoutes_StillRequireAdminPw(t *testing.T) Venue: "Dojo", Courts: []string{"A"}, Password: "", // irrelevant in locked mode - // No AdminPassword on disk, locked mode reads from env var only. - Mode: state.TournamentModeSelfRun, + Mode: state.TournamentModeSelfRun, }) require.NoError(t, err) r := setupSelfRunRouter(t, store, lockedV) - // Destructive route without admin header → 503 (lockedUnconfigured elevated - // verifier: GateActive==true, Configured==false → 503). req := httptest.NewRequest(http.MethodDelete, "/api/competitions/any-id", nil) - // Main password provided (required by locked verifier even in self-run?, - // no: self-run skips the main gate, so we send nothing). w := httptest.NewRecorder() r.ServeHTTP(w, req) - // 401 (admin pw required but not provided) or 503 (no hash configured). - // Either is acceptable, both mean "destructive gate is active." - assert.True(t, w.Code == http.StatusUnauthorized || w.Code == http.StatusServiceUnavailable, - "locked+self-run destructive route must return 401 or 503, got %d: %s", w.Code, w.Body.String()) + assert.Equal(t, http.StatusUnauthorized, w.Code, + "locked+self-run organiser route must return 401 without the main password: %s", w.Body.String()) + + reqPw := httptest.NewRequest(http.MethodDelete, "/api/competitions/any-id", nil) + reqPw.Header.Set("X-Tournament-Password", "main-pw") + reqPw.Header.Set("X-Confirm-Password", "main-pw") + wPw := httptest.NewRecorder() + r.ServeHTTP(wPw, reqPw) + // DELETE of an unknown competition is idempotent: 204 from the handler. + assert.Equal(t, http.StatusNoContent, wPw.Code, + "locked+self-run organiser route must reach the handler with the main password typed twice: %s", wPw.Body.String()) + + reqComplete := httptest.NewRequest(http.MethodPost, "/api/competitions/any-id/complete", nil) + wComplete := httptest.NewRecorder() + r.ServeHTTP(wComplete, reqComplete) + assert.Equal(t, http.StatusNotFound, wComplete.Code, + "locked+self-run completion must be public (the handler 404s the unknown id): %s", wComplete.Body.String()) // Constructive route without any header must NOT be 401 in self-run // (main gate is skipped). Use GET /api/competitions, GET /api/tournament @@ -781,14 +990,10 @@ func TestSelfRun_LockedMode_DestructiveRoutes_StillRequireAdminPw(t *testing.T) "locked+self-run constructive route must be public (not 401)") } -// Regression (mp-7h7): a locked-mode self-run tournament has an empty on-disk -// AdminPassword (the env-var bcrypt hash is authoritative). The self-run -// fail-open guard applies to FILE MODE ONLY, in locked mode the elevated -// gate already fails closed via GateActive(). A routine PUT that edits -// venue/name MUST therefore succeed. Before the fix the PUT transform guard -// fired unconditionally on desired.AdminPassword == "", returning 400 -// errSelfRunRequiresAdminPassword and making locked self-run tournaments -// permanently uneditable. +// Regression (mp-7h7): a routine PUT that edits venue/name on a locked-mode +// self-run tournament MUST succeed. An earlier guard (since removed with the +// destructive-ops password) once rejected it and made locked self-run +// tournaments permanently uneditable. func TestSelfRun_LockedMode_PUTEdit_NotRejectedByFailOpenGuard(t *testing.T) { gin.SetMode(gin.TestMode) store := newTempStore(t) @@ -798,8 +1003,8 @@ func TestSelfRun_LockedMode_PUTEdit_NotRejectedByFailOpenGuard(t *testing.T) { lockedV, err := NewBcryptVerifier(string(hashBytes)) require.NoError(t, err) - // Locked-mode self-run tournament: empty on-disk Password AND empty - // on-disk AdminPassword (both inert; credentials come from env vars). + // Locked-mode self-run tournament: empty on-disk Password (inert; the + // credential comes from the env var). err = store.SaveTournament(&state.Tournament{ Name: "LockedSelfRun", Date: "01-06-2026", @@ -816,8 +1021,7 @@ func TestSelfRun_LockedMode_PUTEdit_NotRejectedByFailOpenGuard(t *testing.T) { // require the main password even in self-run mode (mp-7h7: only operational // scoring/check-in routes bypass the main gate). In locked mode the bcrypt // verifier requires the env-var password. Password rotation is omitted - // (disabled in locked mode); mode is omitted (preserved). This must NOT be - // rejected by the file-mode-only fail-open guard. + // (disabled in locked mode); mode is omitted (preserved). putBody := map[string]any{ "name": "LockedSelfRun", "date": "01-06-2026", @@ -829,7 +1033,7 @@ func TestSelfRun_LockedMode_PUTEdit_NotRejectedByFailOpenGuard(t *testing.T) { w := httptest.NewRecorder() r.ServeHTTP(w, req) require.Equal(t, http.StatusOK, w.Code, - "locked self-run PUT venue edit must succeed, not be rejected by the file-mode fail-open guard: %s", w.Body.String()) + "locked self-run PUT venue edit must succeed: %s", w.Body.String()) t2, err := store.LoadTournament() require.NoError(t, err) @@ -863,38 +1067,6 @@ func TestSelfRun_POST_InvalidMode_Rejected(t *testing.T) { "POST with invalid mode must return 400") } -// POST with an oversized adminPassword returns 400 even in locked mode -// (where the field is never persisted). Fix 3331061367: validate the -// transient field whenever present, regardless of server mode. -func TestSelfRun_POST_OversizedAdminPassword_Rejected(t *testing.T) { - gin.SetMode(gin.TestMode) - store := newTempStore(t) - - // Build a bcrypt verifier (locked mode). - hashBytes, err := bcrypt.GenerateFromPassword([]byte("main-pw"), bcrypt.MinCost) - require.NoError(t, err) - lockedV, err := NewBcryptVerifier(string(hashBytes)) - require.NoError(t, err) - - r := setupSelfRunRouter(t, store, lockedV) - - body := map[string]any{ - "name": "T", - "date": "01-06-2026", - "venue": "V", - "courts": []string{"A"}, - "password": "main-pw", - "adminPassword": string(make([]byte, MaxLenTournamentPassword+1)), // 257 chars - } - req := jsonReq(http.MethodPost, "/api/tournament", body) - req.Header.Set("X-Tournament-Password", "main-pw") - w := httptest.NewRecorder() - r.ServeHTTP(w, req) - assert.Equal(t, http.StatusBadRequest, w.Code, - "oversized adminPassword must return 400 even in locked mode") - assert.Contains(t, w.Body.String(), "adminPassword") -} - // POST with no mode → defaults to officiated. func TestSelfRun_POST_NoMode_DefaultsToOfficiated(t *testing.T) { gin.SetMode(gin.TestMode) @@ -934,7 +1106,7 @@ func TestSelfRun_POST_NoMode_DefaultsToOfficiated(t *testing.T) { func TestSelfRun_Sponsors_RequireMainPassword(t *testing.T) { store := newTempStore(t) - seedSelfRunTournament(t, store, "admin-pw") + seedSelfRunTournament(t, store) r := setupSelfRunRouter(t, store, NewFileVerifier(store)) buildMultipart := func() (*bytes.Buffer, string) { @@ -980,6 +1152,78 @@ func TestSelfRun_Sponsors_RequireMainPassword(t *testing.T) { }) } +// --------------------------------------------------------------------------- +// §branding: Self-run auth gate for the tournament logo (mp-scf) +// --------------------------------------------------------------------------- +// +// The logo POST/DELETE are organiser setup, the same class as the sponsor +// routes above, and are listed in isSelfRunMainGatedConfigRoute. Without that +// entry an anonymous caller in self-run mode could replace or remove the +// tournament logo. GET /api/branding/logo stays public: every viewer surface +// shows it. +func TestSelfRun_BrandingLogo_RequiresMainPassword(t *testing.T) { + store := newTempStore(t) + seedSelfRunTournament(t, store) + r := setupSelfRunRouter(t, store, NewFileVerifier(store)) + + buildMultipart := func() (*bytes.Buffer, string) { + buf := &bytes.Buffer{} + mw := multipart.NewWriter(buf) + fw, err := mw.CreateFormFile("file", "logo.png") + require.NoError(t, err) + _, err = fw.Write(tinyPNG) // reuse tinyPNG defined in handlers_sponsors_test.go + require.NoError(t, err) + require.NoError(t, mw.Close()) + return buf, mw.FormDataContentType() + } + + t.Run("POST_without_password_returns_401", func(t *testing.T) { + body, ct := buildMultipart() + req := httptest.NewRequest(http.MethodPost, "/api/branding/logo", body) + req.Header.Set("Content-Type", ct) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + assert.Equal(t, http.StatusUnauthorized, w.Code, + "POST /api/branding/logo must be main-gated in self-run mode") + _, err := os.Stat(filepath.Join(store.GetFolder(), state.BrandingDirName)) + assert.True(t, os.IsNotExist(err), "a refused upload must store no logo") + }) + + t.Run("DELETE_without_password_returns_401", func(t *testing.T) { + req := httptest.NewRequest(http.MethodDelete, "/api/branding/logo", nil) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + assert.Equal(t, http.StatusUnauthorized, w.Code, + "DELETE /api/branding/logo must be main-gated in self-run mode") + }) + + t.Run("POST_with_main_password_reaches_the_handler", func(t *testing.T) { + body, ct := buildMultipart() + req := httptest.NewRequest(http.MethodPost, "/api/branding/logo", body) + req.Header.Set("Content-Type", ct) + req.Header.Set("X-Tournament-Password", "main-pw") + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + assert.Equal(t, http.StatusOK, w.Code, "body: %s", w.Body.String()) + }) + + t.Run("DELETE_with_main_password_reaches_the_handler", func(t *testing.T) { + req := httptest.NewRequest(http.MethodDelete, "/api/branding/logo", nil) + req.Header.Set("X-Tournament-Password", "main-pw") + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + assert.Equal(t, http.StatusOK, w.Code, "body: %s", w.Body.String()) + }) + + t.Run("GET_logo_is_public", func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/api/branding/logo", nil) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + assert.NotEqual(t, http.StatusUnauthorized, w.Code, + "GET /api/branding/logo must be public, every viewer surface shows it") + }) +} + // --------------------------------------------------------------------------- // §reopen: Self-run auth gate for kachinuki match reopen (mp-gmcg) // --------------------------------------------------------------------------- @@ -992,7 +1236,7 @@ func TestSelfRun_Sponsors_RequireMainPassword(t *testing.T) { // deliberately carries no hand-rolled self-run check. func TestSelfRun_ReopenRequiresMainPassword(t *testing.T) { store := newTempStore(t) - seedSelfRunTournament(t, store, "admin-pw") + seedSelfRunTournament(t, store) r := setupSelfRunRouter(t, store, NewFileVerifier(store)) t.Run("without_password_returns_401", func(t *testing.T) { @@ -1027,7 +1271,7 @@ func TestSelfRun_ReopenRequiresMainPassword(t *testing.T) { // a missing allowlist entry would let the self-run pass-through expose it. func TestSelfRun_RemoveKachinukiBoutRequiresMainPassword(t *testing.T) { store := newTempStore(t) - seedSelfRunTournament(t, store, "admin-pw") + seedSelfRunTournament(t, store) r := setupSelfRunRouter(t, store, NewFileVerifier(store)) req := jsonReq(http.MethodDelete, "/api/competitions/some-comp/matches/m-r1-0/kachinuki-bout", nil) @@ -1042,7 +1286,7 @@ func TestSelfRun_RemoveKachinukiBoutRequiresMainPassword(t *testing.T) { // anonymously callable in self-run mode. Same central-allowlist gate as reopen. func TestSelfRun_RequeueBlockerAndReopenRequiresMainPassword(t *testing.T) { store := newTempStore(t) - seedSelfRunTournament(t, store, "admin-pw") + seedSelfRunTournament(t, store) r := setupSelfRunRouter(t, store, NewFileVerifier(store)) req := jsonReq(http.MethodPost, "/api/competitions/some-comp/matches/m-r1-0/requeue-blocker-and-reopen", @@ -1061,7 +1305,7 @@ func TestSelfRun_RequeueBlockerAndReopenRequiresMainPassword(t *testing.T) { // either route; organiser tooling sends the password. func TestSelfRun_BulkAndQuickScoreRequireMainPassword(t *testing.T) { store := newTempStore(t) - seedSelfRunTournament(t, store, "admin-pw") + seedSelfRunTournament(t, store) r := setupSelfRunRouter(t, store, NewFileVerifier(store)) routes := []struct { diff --git a/internal/mobileapp/score_exact_replay_test.go b/internal/mobileapp/score_exact_replay_test.go index 574a192ee..3d0ce65b2 100644 --- a/internal/mobileapp/score_exact_replay_test.go +++ b/internal/mobileapp/score_exact_replay_test.go @@ -9,8 +9,10 @@ package mobileapp // stored is still a correction, and still needs its reason. import ( + "bytes" "encoding/json" "net/http" + "net/http/httptest" "os" "path/filepath" "testing" @@ -142,6 +144,77 @@ func TestScoreHandler_ExactResendOfAFinishIsAnsweredAsRecorded(t *testing.T) { } } +// A self-run participant's finish whose answer was lost is re-sent by the +// offline queue exactly as it was: it is answered as recorded, as the +// organiser's is, and not refused as a result already reported (which the +// page would show as "Not saved" for a result that is stored). A resend that +// differs in anything it would land is still refused: a finished match is +// the organiser's to correct. +func TestScoreHandler_SelfRunExactResendOfAFinishIsAnsweredAsRecorded(t *testing.T) { + for _, knockout := range []bool{false, true} { + name := "pool" + if knockout { + name = "knockout" + } + t.Run(name, func(t *testing.T) { + const compID = "exact-replay-self-run" + r, store, mid, file, dataDir := exactReplayServer(t, compID, knockout) + require.NoError(t, store.SaveTournament(&state.Tournament{ + Name: "T", Password: "main-pw", Courts: []string{"A"}, Mode: state.TournamentModeSelfRun, + })) + url := "/api/competitions/" + compID + "/matches/" + mid + "/score" + at := time.Now().UnixMilli() - 5_000 + anonymous := func(payload map[string]any) *httptest.ResponseRecorder { + body, _ := json.Marshal(payload) + req, _ := http.NewRequest(http.MethodPut, url, bytes.NewBuffer(body)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("X-Tournament-Password", "") + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + return w + } + + w := anonymous(exactReplayFinish(at)) + require.Equal(t, http.StatusOK, w.Code, "the participant's finish lands: %s", w.Body.String()) + + path := filepath.Join(dataDir, "competitions", compID, file) + bytesBefore, err := os.ReadFile(path) + require.NoError(t, err) + versionBefore := store.FileVersion(compID, file) + + out := requireAnsweredAsRecorded(t, anonymous(exactReplayFinish(at)), store, compID, file, versionBefore) + assert.Equal(t, mgAlice, out["winner"]) + assert.Equal(t, []any{"M", "K"}, out["ipponsA"]) + assert.EqualValues(t, at, out["modifiedAt"]) + bytesAfter, err := os.ReadFile(path) + require.NoError(t, err) + assert.Equal(t, string(bytesBefore), string(bytesAfter), "nothing is written to the match file") + + history, err := store.LoadMatchHistory(compID, mid) + require.NoError(t, err) + require.Len(t, history, 2, "the resend has its own history line, as the organiser's does") + for group, outcome := range history[1].Outcomes { + assert.Equal(t, state.HistoryOutcomeUnchanged, outcome, "group %s", group) + } + + // The same stamp with another scoreline is no resend. + differs := exactReplayFinish(at) + differs["ipponsA"] = []string{"M"} + w = anonymous(differs) + assert.Equal(t, http.StatusConflict, w.Code, w.Body.String()) + assert.Contains(t, w.Body.String(), "result_finalized") + // A later stamp with the same scoreline is no resend either. + w = anonymous(exactReplayFinish(at + 1_000)) + assert.Equal(t, http.StatusConflict, w.Code, w.Body.String()) + assert.Contains(t, w.Body.String(), "result_finalized") + assert.Equal(t, versionBefore, store.FileVersion(compID, file), "a refused resend writes nothing") + history, err = store.LoadMatchHistory(compID, mid) + require.NoError(t, err) + assert.Len(t, history, 2, "a refused resend leaves no history line") + }) + } +} + func TestScoreHandler_SameStampFinishThatDiffersStillNeedsAReason(t *testing.T) { for _, tc := range []struct { name string @@ -201,3 +274,179 @@ func TestScoreHandler_UnstampedFinishOverAFinishStillNeedsAReason(t *testing.T) require.Equal(t, http.StatusBadRequest, w.Code, w.Body.String()) assert.Contains(t, w.Body.String(), "correctionReason") } + +// requireAnsweredAsRecorded checks a resend was answered like a landed write +// (no refusal, nothing held) and that the match file was not written. +func requireAnsweredAsRecorded(t *testing.T, w *httptest.ResponseRecorder, store *state.Store, compID, file string, versionBefore uint64) map[string]any { + t.Helper() + require.Equal(t, http.StatusOK, w.Code, "an exact resend is answered as recorded: %s", w.Body.String()) + var out map[string]any + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &out)) + assert.NotContains(t, out, "applied", "answered like a landed write, not a refusal") + assert.NotContains(t, out, "heldGroups") + assert.Equal(t, "completed", out["status"]) + assert.Equal(t, versionBefore, store.FileVersion(compID, file), "nothing is written to the match file") + return out +} + +// exactReplayLaterPoint is another board adding Alice's kote at `at`, after +// (R3) or before (S2) the finish arrived. +func exactReplayLaterPoint(at int64) map[string]any { + return map[string]any{ + "sideA": mgAlice, "sideB": mgBob, "sideAId": mgAliceID, "sideBId": mgBobID, + "ipponsA": []string{"M", "K"}, "ipponsB": []string{}, + "status": "running", "changed": []string{"points"}, "modifiedAt": at, + } +} + +// A finish whose answer was lost, while another board added a point stamped +// after it, is resent with the finish's stamp. The merge would hold its points +// (the stored ones are newer) and apply only its verdict, which equals the +// stored one, so nothing of it would land: it is the finish already recorded +// and answered as such, not refused for a correction reason. Both arrival +// orders of the later point (R3: after the finish; S2: before it) end there. +func TestScoreHandler_ExactResendAfterALaterPointIsAnsweredAsRecorded(t *testing.T) { + for _, tc := range []struct { + name string + knockout bool + pointArrives string // "after" (R3) or "before" (S2) the finish + }{ + {name: "pool, R3", pointArrives: "after"}, + {name: "knockout, R3", knockout: true, pointArrives: "after"}, + {name: "pool, S2", pointArrives: "before"}, + {name: "knockout, S2", knockout: true, pointArrives: "before"}, + } { + t.Run(tc.name, func(t *testing.T) { + const compID = "exact-replay-later-point" + r, store, mid, file, _ := exactReplayServer(t, compID, tc.knockout) + url := "/api/competitions/" + compID + "/matches/" + mid + "/score" + at := time.Now().UnixMilli() - 5_000 + finish := func() map[string]any { + b := exactReplayFinish(at) + b["ipponsA"] = []string{"M"} + return b + } + + if tc.pointArrives == "before" { + w := serveJSON(r, "PUT", url, exactReplayLaterPoint(at+3_000)) + require.Equal(t, http.StatusOK, w.Code, w.Body.String()) + } + w := serveJSON(r, "PUT", url, finish()) + require.Equal(t, http.StatusOK, w.Code, "the finish lands: %s", w.Body.String()) + if tc.pointArrives == "after" { + w = serveJSON(r, "PUT", url, exactReplayLaterPoint(at+3_000)) + require.Equal(t, http.StatusOK, w.Code, "R3: the later point applies to the finished match: %s", w.Body.String()) + } + versionBefore := store.FileVersion(compID, file) + + out := requireAnsweredAsRecorded(t, serveJSON(r, "PUT", url, finish()), store, compID, file, versionBefore) + assert.Equal(t, mgAlice, out["winner"]) + assert.Equal(t, []any{"M", "K"}, out["ipponsA"], "the answer is the match as stored, the later point included") + }) + } +} + +// The other half: a same-stamp resend over that state whose verdict (a group +// the merge would apply) differs from the stored one is a correction, and +// still needs its reason. +func TestScoreHandler_SameStampResendWhoseAppliedGroupDiffersStillNeedsAReason(t *testing.T) { + for _, knockout := range []bool{false, true} { + t.Run(map[bool]string{false: "pool", true: "knockout"}[knockout], func(t *testing.T) { + const compID = "exact-replay-later-point-differs" + r, store, mid, file, _ := exactReplayServer(t, compID, knockout) + url := "/api/competitions/" + compID + "/matches/" + mid + "/score" + at := time.Now().UnixMilli() - 5_000 + finish := exactReplayFinish(at) + finish["ipponsA"] = []string{"M"} + w := serveJSON(r, "PUT", url, finish) + require.Equal(t, http.StatusOK, w.Code, w.Body.String()) + w = serveJSON(r, "PUT", url, exactReplayLaterPoint(at+3_000)) + require.Equal(t, http.StatusOK, w.Code, w.Body.String()) + versionBefore := store.FileVersion(compID, file) + + changed := exactReplayFinish(at) + changed["winner"], changed["winnerId"] = mgBob, mgBobID + changed["ipponsA"], changed["ipponsB"] = []string{}, []string{"M", "K"} + w = serveJSON(r, "PUT", url, changed) + require.Equal(t, http.StatusBadRequest, w.Code, w.Body.String()) + assert.Contains(t, w.Body.String(), "correctionReason") + assert.Equal(t, versionBefore, store.FileVersion(compID, file), "the refused correction changes nothing") + }) + } +} + +// The resend of a finish of every other shape the score sheet sends: each +// lands once, then is sent again identically with the same stamp, and is +// answered as recorded however the write path rewrote the first landing +// (applyKachinukiMerge's strip of a trailing unscored pairing and member ids, +// the representative bout's hantei mark and names, the engi winner the flags +// decide). +func TestScoreHandler_ExactResendOfATeamOrEngiFinishIsAnsweredAsRecorded(t *testing.T) { + t.Run("kachinuki encounter", func(t *testing.T) { + const compID = "exact-replay-kachinuki" + r, store := setupKachinukiScoreServer(t, compID) + require.NoError(t, store.SavePoolMatches(compID, []state.MatchResult{{ + ID: "P1-0", SideA: "Ryu", SideB: "Tora", Status: state.MatchStatusRunning, + SubResults: []state.SubMatchResult{ + {Position: 1, SideA: "R-1", SideB: "W-1", IpponsA: []string{"M", "K"}, Winner: "R-1", Decision: "fought"}, + {Position: 2, SideA: "R-1", SideB: "W-2"}, + }, + }})) + at := time.Now().UnixMilli() - 5_000 + finish := func() map[string]any { + return map[string]any{ + "sideA": "Ryu", "sideB": "Tora", "winner": "Ryu", "status": "completed", "decision": "kachinuki-exhaustion", + "modifiedAt": at, + "subResults": []map[string]any{ + kachinukiSub(1, "R-1", "W-1", []string{"M", "K"}, "R-1", "fought"), + kachinukiSub(2, "R-1", "W-2", []string{}, "", ""), + }, + } + } + w := putScore(t, r, compID, "P1-0", finish()) + require.Equal(t, http.StatusOK, w.Code, w.Body.String()) + require.Len(t, loadPoolMatch(t, store, compID, "P1-0").SubResults, 1, "the first landing strips the unscored pairing") + versionBefore := store.FileVersion(compID, "pool-matches.csv") + + out := requireAnsweredAsRecorded(t, putScore(t, r, compID, "P1-0", finish()), store, compID, "pool-matches.csv", versionBefore) + assert.Equal(t, "Ryu", out["winner"]) + }) + + t.Run("fixed-order team knockout decided by a hantei on the representative bout", func(t *testing.T) { + f := newRepBoutFixture(t, false) + w := f.send(http.MethodPost, repBoutMatchPath+"/daihyosen", "main-pw", map[string]any{"modifiedAt": f.now}) + require.Equal(t, http.StatusOK, w.Code, "adding the representative bout: %s", w.Body.String()) + at := f.now + 100 + w = f.score("main-pw", state.MatchStatusCompleted, "TeamA", at, hanteiRow()) + require.Equal(t, http.StatusOK, w.Code, "the finish lands: %s", w.Body.String()) + require.True(t, f.storedRepBout(t).HanteiDecided()) + versionBefore := f.store.FileVersion("c1", "bracket.json") + + out := requireAnsweredAsRecorded(t, f.score("main-pw", state.MatchStatusCompleted, "TeamA", at, hanteiRow()), f.store, "c1", "bracket.json", versionBefore) + assert.Equal(t, "TeamA", out["winner"]) + assert.True(t, f.storedRepBout(t).HanteiDecided(), "the verdict stands") + }) + + t.Run("engi", func(t *testing.T) { + const compID = "exact-replay-engi" + r, store := mergeServer(t, compID) + comp, err := store.LoadCompetition(compID) + require.NoError(t, err) + comp.Engi = true + require.NoError(t, store.SaveCompetition(comp)) + at := time.Now().UnixMilli() - 5_000 + finish := func() map[string]any { + return map[string]any{ + "sideA": mgAlice, "sideB": mgBob, "status": "completed", "flagsA": 3, "flagsB": 0, + "changed": []string{"result", "flags"}, "modifiedAt": at, + } + } + code, out := mergeReviewPut(t, r, compID, finish()) + require.Equal(t, http.StatusOK, code, out) + require.Equal(t, mgAlice, mergeStored(t, store, compID).Winner) + versionBefore := store.FileVersion(compID, "pool-matches.csv") + + got := requireAnsweredAsRecorded(t, serveJSON(r, "PUT", "/api/competitions/"+compID+"/matches/Pool A-0/score", finish()), store, compID, "pool-matches.csv", versionBefore) + assert.Equal(t, mgAlice, got["winner"]) + }) +} diff --git a/internal/mobileapp/self_run_daihyosen_test.go b/internal/mobileapp/self_run_daihyosen_test.go index fa1790ea5..fec3bd82a 100644 --- a/internal/mobileapp/self_run_daihyosen_test.go +++ b/internal/mobileapp/self_run_daihyosen_test.go @@ -48,7 +48,7 @@ func newRepBoutFixture(t *testing.T, selfRun bool) *repBoutFixture { t.Helper() store := newTempStore(t) if selfRun { - seedSelfRunTournament(t, store, "admin-pw") + seedSelfRunTournament(t, store) } else { seedOfficiatedTournament(t, store) } @@ -819,7 +819,7 @@ func TestHoldSelfReportedWriteUnderTx(t *testing.T) { snap, found, err := matchSnapshotOrErr(store, "c1", "B1", "test") require.NoError(t, err) - err = holdSelfReportedWriteUnderTx("c1", "B1", snap, found, result, tc.startOnly) + err = holdSelfReportedWriteUnderTx("c1", "B1", snap, found, result, nil, tc.startOnly) if tc.want != nil { var refusal *selfRunRefusal require.ErrorAs(t, err, &refusal) diff --git a/internal/mobileapp/self_run_no_checkin_test.go b/internal/mobileapp/self_run_no_checkin_test.go new file mode 100644 index 000000000..451324d15 --- /dev/null +++ b/internal/mobileapp/self_run_no_checkin_test.go @@ -0,0 +1,112 @@ +package mobileapp + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/gitrgoliveira/bracket-creator/internal/domain" + "github.com/gitrgoliveira/bracket-creator/internal/state" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// Operator ruling 2026-10-13: "Self run mode has no check-in." The three +// check-in writes are refused 409 for every caller in a self-run tournament, +// the organiser included, and work as before in an officiated one. + +const noCheckInComp = "no-checkin" + +type checkInCall struct { + name, method, path string + body any +} + +func checkInCalls(pid string) []checkInCall { + base := "/api/competitions/" + noCheckInComp + "/participants" + return []checkInCall{ + {"PUT checkin", http.MethodPut, base + "/" + pid + "/checkin", nil}, + {"POST checkin-bulk", http.MethodPost, base + "/checkin-bulk", map[string]any{"participantIds": []string{pid}}}, + {"DELETE checkin", http.MethodDelete, base + "/" + pid + "/checkin", nil}, + } +} + +func seedCheckInRoster(t *testing.T, store *state.Store) string { + t.Helper() + require.NoError(t, store.SaveCompetition(&state.Competition{ + ID: noCheckInComp, Name: "No Check-in", Status: state.CompStatusSetup, CheckInEnabled: true, + })) + require.NoError(t, store.SaveParticipants(noCheckInComp, []domain.Player{{Name: "Alice", Dojo: "Suigetsu"}})) + players, err := store.LoadParticipants(noCheckInComp, false) + require.NoError(t, err) + require.Len(t, players, 1) + return players[0].ID +} + +func serve(r http.Handler, c checkInCall, password *string) *httptest.ResponseRecorder { + req := jsonReq(c.method, c.path, c.body) + if password != nil { + req.Header.Set("X-Tournament-Password", *password) + } + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + return w +} + +func TestSelfRun_CheckInWrites_RefusedForTheOrganiser(t *testing.T) { + store := newTempStore(t) + seedSelfRunTournament(t, store) + pid := seedCheckInRoster(t, store) + r := setupSelfRunRouter(t, store, NewFileVerifier(store)) + organiser := "main-pw" + + for _, c := range checkInCalls(pid) { + t.Run(c.name, func(t *testing.T) { + w := serve(r, c, &organiser) + require.Equal(t, http.StatusConflict, w.Code, w.Body.String()) + var body map[string]string + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body)) + assert.Equal(t, "check_in_not_in_self_run", body["error"]) + assert.Equal(t, "Self-run mode has no check-in.", body["message"]) + }) + } + + players, err := store.LoadParticipants(noCheckInComp, false) + require.NoError(t, err) + assert.False(t, players[0].CheckedIn, "a refused check-in writes nothing") +} + +func TestSelfRun_CheckInWrites_RefusedForAnAnonymousCaller(t *testing.T) { + store := newTempStore(t) + seedSelfRunTournament(t, store) + pid := seedCheckInRoster(t, store) + r := setupSelfRunRouter(t, store, NewFileVerifier(store)) + anonymous := "" + + for _, c := range checkInCalls(pid)[:2] { // forward check-in: ungated, so the 409 answers + t.Run(c.name, func(t *testing.T) { + w := serve(r, c, &anonymous) + require.Equal(t, http.StatusConflict, w.Code, w.Body.String()) + assert.Contains(t, w.Body.String(), "check_in_not_in_self_run") + }) + } + + // The reversal stays on the organiser-only list: the gate answers first. + w := serve(r, checkInCalls(pid)[2], &anonymous) + assert.Equal(t, http.StatusUnauthorized, w.Code) +} + +func TestOfficiated_CheckInWrites_StillWork(t *testing.T) { + store := newTempStore(t) + seedOfficiatedTournament(t, store) + pid := seedCheckInRoster(t, store) + r := setupSelfRunRouter(t, store, NewFileVerifier(store)) + organiser := "main-pw" + + calls := checkInCalls(pid) + for _, c := range []checkInCall{calls[0], calls[2], calls[1]} { + w := serve(r, c, &organiser) + require.Equal(t, http.StatusOK, w.Code, "%s: %s", c.name, w.Body.String()) + } +} diff --git a/internal/mobileapp/self_run_team_writes_test.go b/internal/mobileapp/self_run_team_writes_test.go index 5fec1d99e..b3f93c4f5 100644 --- a/internal/mobileapp/self_run_team_writes_test.go +++ b/internal/mobileapp/self_run_team_writes_test.go @@ -41,7 +41,7 @@ func newTeamWritesFixture(t *testing.T, selfRun bool) teamWritesFixture { t.Helper() store := newTempStore(t) if selfRun { - seedSelfRunTournament(t, store, "admin-pw") + seedSelfRunTournament(t, store) } else { seedOfficiatedTournament(t, store) } diff --git a/internal/mobileapp/server.go b/internal/mobileapp/server.go index ebc75656c..b18615358 100644 --- a/internal/mobileapp/server.go +++ b/internal/mobileapp/server.go @@ -21,34 +21,26 @@ import ( "github.com/gitrgoliveira/bracket-creator/internal/state" ) -// AdminPasswordHashEnv is the env var holding the bcrypt hash of the -// elevated (destructive-ops) password in locked mode (spec 004 / mp-e21). -// It is the elevated-credential analogue of TOURNAMENT_PASSWORD_HASH. -const AdminPasswordHashEnv = "TOURNAMENT_ADMIN_PASSWORD_HASH" +// retiredAdminPasswordHashEnv held the bcrypt hash of the destructive-ops +// password in locked mode. That password was removed (operator ruling +// 2026-10-12: an admin-level action needs the admin sign-in alone), so the +// variable is read only to say it is ignored: a deployment that still sets +// it starts and works exactly as one that does not. +const retiredAdminPasswordHashEnv = "TOURNAMENT_ADMIN_PASSWORD_HASH" // mobileWebRoot is the embedded-resources subdirectory the mobile-app SPA is // served from. Shared between the fs.Sub call below and FrontendBundleMissing // (frontend_bundle.go) so the two can't drift on which root they check. const mobileWebRoot = "web-mobile" -// defaultElevatedVerifier derives the elevated-password verifier from the -// main verifier's mode (spec 004). File mode reads the write-only -// Tournament.AdminPassword from the store (no env var); locked mode reads -// the bcrypt hash from TOURNAMENT_ADMIN_PASSWORD_HASH, falling back to the -// fail-closed unconfigured verifier (503 on gated endpoints) when the env -// var is absent or malformed. Reading the env here, rather than threading -// an explicit param through NewRouter, keeps the router signature stable -// for the many existing callers; file-mode tests never touch the env. -func defaultElevatedVerifier(verifier PasswordVerifier, store *state.Store) ElevatedVerifier { - if verifier != nil && verifier.Mode() == "locked" { - if v, err := NewBcryptElevatedVerifier(os.Getenv(AdminPasswordHashEnv)); err == nil { - return v - } - slog.Warn("mobile-app: locked mode without a valid " + AdminPasswordHashEnv + - "; destructive operations will return 503 until it is set") - return NewLockedUnconfiguredElevatedVerifier() +// noteRetiredAdminPasswordHash logs, once per router, that +// TOURNAMENT_ADMIN_PASSWORD_HASH is set but no longer used. It never fails +// startup: the value is simply ignored. +func noteRetiredAdminPasswordHash() { + if os.Getenv(retiredAdminPasswordHashEnv) != "" { + slog.Info("mobile-app: " + retiredAdminPasswordHashEnv + + " is set but no longer used; admin actions need only the tournament password") } - return NewFileElevatedVerifier(store) } // NewRouter wires the mobile-app gin engine. The returned *gin.Engine @@ -75,15 +67,13 @@ func NewRouterWithHub(store *state.Store, eng *engine.Engine, res *resources.Res verifier = NewFileVerifier(store) } - // Elevated (destructive-ops) password verifier, spec 004 / mp-e21. - // Derived from the main verifier's mode; see defaultElevatedVerifier. - elevated := defaultElevatedVerifier(verifier, store) + noteRetiredAdminPasswordHash() // Enable CORS r.Use(func(c *gin.Context) { c.Writer.Header().Set("Access-Control-Allow-Origin", "*") c.Writer.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS") - c.Writer.Header().Set("Access-Control-Allow-Headers", "Origin, Content-Type, Content-Length, Accept-Encoding, X-CSRF-Token, Authorization, X-Tournament-Password, X-Admin-Password") + c.Writer.Header().Set("Access-Control-Allow-Headers", "Origin, Content-Type, Content-Length, Accept-Encoding, X-CSRF-Token, Authorization, X-Tournament-Password, X-Confirm-Password") if c.Request.Method == "OPTIONS" { c.AbortWithStatus(204) @@ -180,7 +170,7 @@ func NewRouterWithHub(store *state.Store, eng *engine.Engine, res *resources.Res // inert payloads when locked mode is active, see handlers_reset.go // and handlers_auth_config.go. RegisterResetHandlers(api, store, verifier, hub) - RegisterAuthConfigHandlers(api, verifier, elevated, scheduleEnabled) + RegisterAuthConfigHandlers(api, verifier, scheduleEnabled) // Admin API endpoints (protected). Split into three sub-groups by // expected body size so the body cap fires BEFORE AuthMiddleware at @@ -197,9 +187,11 @@ func NewRouterWithHub(store *state.Store, eng *engine.Engine, res *resources.Res adminSmallBody := adminGroup(r, DefaultMaxBodyBytes, verifier, store) RegisterTournamentHandlers(adminSmallBody, store, hub, verifier) - RegisterAdminPasswordHandler(adminSmallBody, store, elevated) - RegisterCompetitionHandlers(adminSmallBody, store, eng, hub, elevated) - RegisterParticipantHandlers(adminSmallBody, store, eng, hub, elevated) + // Deleting a competition asks for the password again (X-Confirm-Password); + // this middleware judges only that one route, after the sign-in. + adminSmallBody.Use(RequireConfirmPasswordForDelete(verifier)) + RegisterCompetitionHandlers(adminSmallBody, store, eng, hub) + RegisterParticipantHandlers(adminSmallBody, store, eng, hub) RegisterMatchHandlers(adminSmallBody, eng, store, store, hub, verifier, store) RegisterMatchHistoryHandler(adminSmallBody, store) RegisterDecisionHandlers(adminSmallBody, eng, store, store, hub) @@ -217,7 +209,7 @@ func NewRouterWithHub(store *state.Store, eng *engine.Engine, res *resources.Res RegisterExportResultsHandlers(adminSmallBody, store, eng) adminLargeBody := adminGroup(r, MaxImportBodyBytes, verifier, store) - RegisterImportHandlers(adminLargeBody, store, eng, hub, elevated) + RegisterImportHandlers(adminLargeBody, store, eng, hub) // Sponsor uploads (mp-c38), multipart logo upload needs envelope // headroom for the file plus boundary/form-field overhead; so it gets diff --git a/internal/state/check_in_applies_test.go b/internal/state/check_in_applies_test.go new file mode 100644 index 000000000..73b737be8 --- /dev/null +++ b/internal/state/check_in_applies_test.go @@ -0,0 +1,45 @@ +package state + +import "testing" + +// Operator ruling 2026-10-13: "Self run mode has no check-in." +func TestCompetitionCheckInApplies(t *testing.T) { + cases := []struct { + name string + t *Tournament + on bool + want bool + }{ + {"no tournament, on", nil, true, true}, + {"no tournament, off", nil, false, false}, + {"officiated, on", &Tournament{Mode: TournamentModeOfficiated}, true, true}, + {"officiated, off", &Tournament{Mode: TournamentModeOfficiated}, false, false}, + {"unset mode, on", &Tournament{}, true, true}, + {"self-run, on", &Tournament{Mode: TournamentModeSelfRun}, true, false}, + {"self-run, off", &Tournament{Mode: TournamentModeSelfRun}, false, false}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + c := &Competition{CheckInEnabled: tc.on} + if got := c.CheckInApplies(tc.t); got != tc.want { + t.Fatalf("CheckInApplies = %v, want %v", got, tc.want) + } + if c.CheckInEnabled != tc.on { + t.Fatal("the stored setting was changed") + } + }) + } +} + +func TestTournamentIsSelfRun(t *testing.T) { + var nilT *Tournament + if nilT.IsSelfRun() { + t.Fatal("a nil tournament is not self-run") + } + if (&Tournament{Mode: TournamentModeOfficiated}).IsSelfRun() { + t.Fatal("officiated is not self-run") + } + if !(&Tournament{Mode: TournamentModeSelfRun}).IsSelfRun() { + t.Fatal("self-run is self-run") + } +} diff --git a/internal/state/models.go b/internal/state/models.go index ea259af32..c8477b615 100644 --- a/internal/state/models.go +++ b/internal/state/models.go @@ -31,22 +31,11 @@ type Tournament struct { // the load path (and ApplyTournamentDefaults). DurationDays int `yaml:"duration_days,omitempty" json:"durationDays,omitempty"` - // AdminPassword gates destructive operations (spec 004 / mp-e21): - // competition delete, draw/override/invalidate, and participant roster - // mutations. It is a SEPARATE, higher-privilege credential from - // Password (which gates the API as a whole). - // - // CRITICAL: it is WRITE-ONLY at the API boundary; `json:"-"` means it - // is never emitted in any response AND never populated by binding a - // request body into a Tournament. That is the opposite of Password - // (which is a peer of the credential gating /tournament, so returning - // it is harmless). AdminPassword is HIGHER privilege than that gate, so - // leaking it via GET; or letting a main-password holder overwrite it - // via the bulk PUT; would collapse the separation. It is set only via - // the dedicated, elevated-gated PUT /api/auth/admin-password handler. - // File mode only; in locked mode the env-var bcrypt hash is - // authoritative and any on-disk value here is inert. - AdminPassword string `yaml:"admin_password,omitempty" json:"-"` + // No admin_password field: the separate destructive-ops password was + // removed (operator ruling 2026-10-12, "not re enter admin password to + // change something"). A tournament.md written by an earlier release may + // still carry admin_password; front-matter decoding ignores unknown keys, + // so it loads, and the next save drops it. // Ceremony blocks expressed as human duration strings (e.g. "30m", // "1h"). When set, the auto-scheduler reserves a contiguous range @@ -73,9 +62,9 @@ type Tournament struct { // creation and IMMUTABLE thereafter (mp-7h7). "officiated" (default) // gates the full admin surface behind X-Tournament-Password. // "self-run" inverts the boundary: constructive actions (scoring, - // check-in, start/complete) are public; only destructive actions - // (those already gated by RequireElevatedPassword / enforceElevated) - // still require X-Admin-Password. omitempty means older tournament.md + // start/complete) are public and there is no check-in; organiser actions (setup, + // corrections and every destructive action) still require + // X-Tournament-Password (isSelfRunMainGatedConfigRoute). omitempty means older tournament.md // files (Mode == "") normalise to "officiated" via ApplyTournamentDefaults. Mode string `yaml:"mode,omitempty" json:"mode,omitempty"` @@ -214,7 +203,7 @@ func ValidateTheme(theme *Theme) error { // Tournament mode constants (mp-7h7). const ( TournamentModeOfficiated = "officiated" // default: main-gate on all admin routes - TournamentModeSelfRun = "self-run" // main-gate skipped; elevated gate stays + TournamentModeSelfRun = "self-run" // main-gate skipped except on organiser routes ) // ValidateTournamentMode reports whether the given mode value is acceptable. @@ -277,6 +266,14 @@ func ApplyTournamentDefaults(t *Tournament) { // // Consumers should call ApplyTournamentDefaults before Days() to ensure // DurationDays has its correct minimum of 1. +// IsSelfRun reports whether the tournament runs in self-run mode. A nil +// tournament (none created yet) is not self-run. This is the one Go spelling +// of the question for engine code; the HTTP layer compares t.Mode against +// TournamentModeSelfRun directly where it already holds the tournament. +func (t *Tournament) IsSelfRun() bool { + return t != nil && t.Mode == TournamentModeSelfRun +} + func (t *Tournament) Days() []string { if t == nil || t.DurationDays < 1 || t.Date == "" { return nil @@ -631,6 +628,16 @@ func (c *Competition) IsTeam() bool { return c != nil && (c.Kind == "team" || c.TeamSize > 0) } +// CheckInApplies reports whether check-in decides anything for this +// competition in tournament t: its CheckInEnabled setting, except in a +// self-run tournament, which has no check-in (operator ruling 2026-10-13). +// This is the ONE owner of that rule: the draw filter, the Swiss field and the +// schedule estimate ask it, and nothing reads CheckInEnabled to decide +// behaviour. The stored setting is never rewritten. +func (c *Competition) CheckInApplies(t *Tournament) bool { + return c != nil && c.CheckInEnabled && !t.IsSelfRun() +} + // IsKachinuki reports whether c is a kachinuki (winner-stays-on) team // competition. The single spelling of this predicate (mp-gmcg review): before // this method it was reimplemented inline at 7 call sites across diff --git a/internal/state/participants.go b/internal/state/participants.go index 7d3f66350..072f28c2f 100644 --- a/internal/state/participants.go +++ b/internal/state/participants.go @@ -1349,8 +1349,7 @@ func (s *Store) saveParticipantsNoLock(compID string, players []domain.Player, w // persistence path; the bulk PUT /competitions/:id roster import (the // SPA's primary flow), single add/edit, and any future caller; rejects // duplicate (normalizedName, normalizedDojo) pairs uniformly. Enforcing - // only in the handlers would leave the guard bypassable, the same reason - // the elevated-password gate is inline on the roster PUT path. + // only in the handlers would leave the guard bypassable. // Blank-dojo guard runs BEFORE the duplicate scan on purpose: with no // dojo, two same-named competitors key identically, so a blank-dojo // roster would otherwise be reported as a DUPLICATE, which names the diff --git a/scripts/screenshots/lib/api.mjs b/scripts/screenshots/lib/api.mjs index 6a80cb603..65edbd1bd 100644 --- a/scripts/screenshots/lib/api.mjs +++ b/scripts/screenshots/lib/api.mjs @@ -12,10 +12,7 @@ // sends, and fixture.mjs's assertLineupIds re-reads it before a capture. export const PASSWORD = 'testpassword'; -// `headers` lets a caller add one: a self-run tournament gates roster and draw -// mutations behind a second X-Admin-Password header (middleware.go's -// RequireElevatedPassword), and the recipe that needs it configures this -// client rather than re-implementing the call. +// `headers` lets a caller add a request header without re-implementing the call. export function client(base, headers = {}) { const call = async (method, p, body, auth = true) => { const res = await fetch(base + p, { diff --git a/scripts/screenshots/recipes/public.mjs b/scripts/screenshots/recipes/public.mjs index 7238fbe41..6e363db96 100644 --- a/scripts/screenshots/recipes/public.mjs +++ b/scripts/screenshots/recipes/public.mjs @@ -13,8 +13,7 @@ // them in self-run mode. That used to require scheduling care because every // mobile recipe shared one server; run.mjs now gives each FAMILY its own // server and data dir, so the families cannot contaminate one another. -import { settle, PASSWORD, withAdminPage } from '../lib/ui.mjs'; -import { client } from '../lib/api.mjs'; +import { settle, withAdminPage } from '../lib/ui.mjs'; import { EDITOR, sameSideTapPacer, finishMatch, startMatch } from '../lib/editor.mjs'; import { assertIndividualBoutPoints, assertHanteiRecorded } from '../lib/fixture.mjs'; import { SCORE_EDITOR_SOURCES, VIEWER_SOURCES } from '../lib/scope.mjs'; @@ -89,27 +88,22 @@ export const families = { name: 'Riverside Open', venue: 'Riverside Budokan', mode: 'self-run', - adminPassword: PASSWORD, }); const compId = await api.competition(SELFRUN_COMP, 'Open Individual', { courts: ['A'] }); // A SECOND open competition, because selfrun-viewer-home's caption reads // "each open competition carries a Register button" - a plural claim that // one card demonstrates poorly, and that a reader cannot check at all. const secondId = await api.competition('womens-open', "Women's Open", { courts: ['A'] }); - // Setting a self-run tournament's adminPassword above flips - // ElevatedVerifier.GateActive() on (middleware.go's RequireElevatedPassword): - // a roster mutation is one of the gated "destructive ops" (delete - // competition, discard draw, roster add/edit, import), so it needs a - // SECOND header, X-Admin-Password, which the shared client sends when - // asked. A plain roster POST is scaffolding, not a fidelity concern. - const elevated = client(base, { 'X-Admin-Password': PASSWORD }); - await elevated.post(`/api/competitions/${compId}/participants`, { players: [ + // A roster add is an organiser action in self-run, so it goes with the + // tournament password the shared client sends. Scaffolding, not a + // fidelity concern. + await api.post(`/api/competitions/${compId}/participants`, { players: [ { name: 'Emi Kondo', dojo: 'Riverside Dojo' }, { name: 'Taro Fujita', dojo: 'Riverside Dojo' }, { name: 'Nana Ishii', dojo: 'Harbor Dojo' }, { name: 'Sho Matsuda', dojo: 'Harbor Dojo' }, ] }); - await elevated.post(`/api/competitions/${secondId}/participants`, { players: [ + await api.post(`/api/competitions/${secondId}/participants`, { players: [ { name: 'Aiko Tanaka', dojo: 'Riverside Dojo' }, { name: 'Mika Suzuki', dojo: 'Harbor Dojo' }, { name: 'Hana Sato', dojo: 'Northgate Dojo' }, diff --git a/specs/004-elevated-password/spec.md b/specs/004-elevated-password/spec.md index c80c10d3d..2c6aecc80 100644 --- a/specs/004-elevated-password/spec.md +++ b/specs/004-elevated-password/spec.md @@ -1,7 +1,22 @@ # Spec 004: Elevated (second) password for destructive operations +> **Superseded (2026-10-12).** The operator ruled that no action re-prompts +> for a password, with one exception: "Admin password, because it's at the admin level it's ok. But +> not re enter admin password to change something. For self managed, no admin +> password necessary." The exception, ruled the same day: "To delete a +> competition you need to re enter the password." `DELETE /api/competitions/{id}` +> needs the sign-in password typed again in `X-Confirm-Password`, checked by the +> same `PasswordVerifier` as the sign-in (401 `confirm_password_required` or +> `confirm_password_wrong`). Every other admin-level action needs only `X-Tournament-Password`; +> in self-run mode the routes this spec gated are organiser-only +> (`isSelfRunMainGatedConfigRoute`), except completing a competition and adding +> or editing participants, which are public (ruling 2026-10-12); a self-run +> tournament has no check-in, so the check-in toggles below are refused there +> for every caller (ruling 2026-10-13, `check_in_not_in_self_run`). A stored `admin_password` and `TOURNAMENT_ADMIN_PASSWORD_HASH` are +> ignored. The text below records the design this spec describes. + **Issue:** mp-e21 -**Status:** Draft: awaiting approval before implementation +**Status:** Superseded (operator ruling 2026-10-12, bc-cmpw) **Author:** Claude (Opus 4.8) with Ricardo Oliveira **Date:** 2026-05-29 diff --git a/specs/openapi.yaml b/specs/openapi.yaml index 5f6fd38e8..b4f1428b2 100644 --- a/specs/openapi.yaml +++ b/specs/openapi.yaml @@ -26,9 +26,11 @@ info: - **`GET /api/tournament/announcement`**: public read; returns the currently active admin broadcast announcement (204 when none/expired). **Self-run mode.** When the tournament is created with `mode: self-run`, the - main-password gate is bypassed for the operational play surface (scoring, check-in, + main-password gate is bypassed for the operational play surface (scoring, start/complete, generate-draw, etc.) so participants can drive their own flow without - an organiser at a table. Destructive routes (delete competition, invalidate, discard + an organiser at a table. Self-run mode has no check-in: the three check-in operations + answer 409 `check_in_not_in_self_run` for every caller, and the draw includes every + participant whatever a competition's `checkInEnabled` says. Destructive routes (delete competition, invalidate, discard draw, result overrides, roster mutations, import) and organiser-owned setup (tournament/competition config) stay gated. In the default `officiated` mode every `/api/` route keeps requiring `X-Tournament-Password` as described above. @@ -602,25 +604,6 @@ paths: resetEnabled: type: boolean description: Whether POST /api/tournament/reset is honored. - elevatedRequired: - type: boolean - description: | - Whether the destructive-ops password gate is active. File mode: - true once an admin password has been set. Locked mode: always true. - When false, destructive endpoints require only the main password. - elevatedConfigured: - type: boolean - description: | - Whether a destructive-ops credential exists. File mode: an admin - password is set. Locked mode: TOURNAMENT_ADMIN_PASSWORD_HASH is a - valid bcrypt hash. When required but not configured, gated - endpoints return 503. - elevatedEditable: - type: boolean - description: | - Whether the destructive-ops password can be set via - PUT /api/auth/admin-password. True in file mode; false in locked - mode (the credential is the TOURNAMENT_ADMIN_PASSWORD_HASH env var). scheduleEnabled: type: boolean description: | @@ -629,65 +612,6 @@ paths: (default false). When false, the SPA hides the dashboard's "Tournament schedule" entry. - /api/auth/admin-password: - put: - tags: [tournament] - summary: Set or rotate the destructive-ops (elevated) password - description: | - Sets or rotates the file-mode destructive-ops password (spec 004 / - mp-e21). Gated by the MAIN password (`X-Tournament-Password`) via the - admin middleware. - - * First-time set (no admin password yet); trust-on-first-use: the - main password authorizes it; `currentPassword` is ignored. - * Rotation (admin password already set); `currentPassword` must - equal the stored value, else 401. This prevents a main-password - holder from silently re-setting the elevated credential. - - Locked mode returns 404; the credential is the - `TOURNAMENT_ADMIN_PASSWORD_HASH` env var and is not settable via the - API. The response never echoes any password (write-only). - security: - - TournamentPassword: [] - requestBody: - required: true - content: - application/json: - schema: - type: object - required: [newPassword] - properties: - newPassword: - type: string - maxLength: 256 - description: The destructive-ops password to store (non-empty). - currentPassword: - type: string - maxLength: 256 - description: Required only when an admin password is already set (rotation). - responses: - '200': - description: Admin password set or rotated. - content: - application/json: - schema: - type: object - properties: - status: - type: string - example: ok - elevatedConfigured: - type: boolean - example: true - '400': - description: Empty newPassword or over-length field. - '401': - description: Wrong currentPassword on rotation (or missing/invalid main password). - '404': - description: Locked mode; not settable via the API. - '409': - description: No tournament configured yet. - /api/tournament/import: post: tags: [tournament] @@ -698,10 +622,6 @@ paths: One of the uploaded files must be named `manifest.yaml` (or `.yml`); it declares which competitions to create and which participant/seed CSV files to associate with each. - Also requires `X-Admin-Password` when the destructive-ops gate is active - (see the AdminPassword security scheme); returns 503 when locked mode is - active but `TOURNAMENT_ADMIN_PASSWORD_HASH` is unset. - Each competition row is validated against the same rules `POST /api/competitions` applies, and two manifest keys are worth calling out because their absence is not neutral: @@ -717,9 +637,11 @@ paths: `pool_size` and `pool_size_mode`, by contrast, ARE defaulted when omitted (to 4 and `"max"`). + + In self-run mode this is an organiser action: it needs + `X-Tournament-Password`. security: - TournamentPassword: [] - AdminPassword: [] requestBody: required: true content: @@ -1360,18 +1282,13 @@ paths: IGNORED in the body. Use the dedicated transition endpoints (`/start`, `/complete`, `/invalidate`) to change status. - **Elevated password (spec 004 / mp-e21):** the ROSTER-save path - (non-null `players`) additionally requires `X-Admin-Password` - when the destructive-ops gate is active; it rewrites - `participants.csv`, the same protected operation as the dedicated - participant endpoints. Returns 503 if locked mode is active without - `TOURNAMENT_ADMIN_PASSWORD_HASH`. The SETTINGS-only path - (`players` omitted) is NOT gated and needs only the main password. - The gate runs inside the handler (after body binding), so the - security requirement below is conditional on the body shape. + The ROSTER-save path (non-null `players`) rewrites `participants.csv` + and, like every other route here, needs only `X-Tournament-Password`. + + In self-run mode this is an organiser action: it needs + `X-Tournament-Password`. security: - TournamentPassword: [] - AdminPassword: [] requestBody: required: true content: @@ -1435,12 +1352,6 @@ paths: application/json: schema: $ref: '#/components/schemas/Error' - '503': - description: | - The ROSTER-save path (non-null `players`) requires - `X-Admin-Password` when the destructive-ops gate is active (see - the "Elevated password" note above); returned when locked mode - is active without `TOURNAMENT_ADMIN_PASSWORD_HASH` configured. delete: tags: [competitions] summary: Delete competition @@ -1450,17 +1361,48 @@ paths: `invalid`, or unset). In-progress competitions (`pools`/`knockout`) must first be invalidated via `POST /api/competitions/{id}/invalidate`. - Also requires `X-Admin-Password` when the destructive-ops gate is active - (see the AdminPassword security scheme); 503 if locked mode is active - without `TOURNAMENT_ADMIN_PASSWORD_HASH`. + In self-run mode this is an organiser action: it needs + `X-Tournament-Password`. + + This is the one route that asks for the password again. The request + must also carry `X-Confirm-Password`: the admin password typed at the + moment of the delete, verified by the same verifier as the sign-in + (plaintext in file mode, bcrypt in locked mode). The check runs after + `X-Tournament-Password` is accepted and before anything is deleted, + for an unknown competition too. A missing or wrong value is a 401 whose + `error` and `message` read "Wrong password. The competition was not + deleted." and whose `code` is `confirm_password_required` or + `confirm_password_wrong`. That 401 does not mean the sign-in was + refused (the sign-in's own 401 has no `code`). security: - TournamentPassword: [] - AdminPassword: [] + parameters: + - name: X-Confirm-Password + in: header + required: true + description: The admin password, typed again for this delete. + schema: + type: string responses: '204': description: Competition deleted (or did not exist). '400': $ref: '#/components/responses/BadRequest' + '401': + description: >- + `X-Confirm-Password` missing or wrong + (`code: confirm_password_required` or `confirm_password_wrong`), or + the sign-in refused (no `code`). + content: + application/json: + schema: + type: object + properties: + error: {type: string} + message: {type: string} + code: + type: string + enum: [confirm_password_required, confirm_password_wrong] '409': description: Competition is in progress and must be invalidated first. content: @@ -1480,12 +1422,10 @@ paths: competition. Returns 400 if the competition is not in an in-progress state. - Also requires `X-Admin-Password` when the destructive-ops gate is active - (see the AdminPassword security scheme); 503 if locked mode is active - without `TOURNAMENT_ADMIN_PASSWORD_HASH`. + In self-run mode this is an organiser action: it needs + `X-Tournament-Password`. security: - TournamentPassword: [] - AdminPassword: [] parameters: - $ref: '#/components/parameters/CompetitionId' responses: @@ -1614,12 +1554,10 @@ paths: competition to `setup`. Returns 400 if the competition is not in `draw-ready` state. - Also requires `X-Admin-Password` when the destructive-ops gate is active - (see the AdminPassword security scheme); 503 if locked mode is active - without `TOURNAMENT_ADMIN_PASSWORD_HASH`. + In self-run mode this is an organiser action: it needs + `X-Tournament-Password`. security: - TournamentPassword: [] - AdminPassword: [] parameters: - $ref: '#/components/parameters/CompetitionId' responses: @@ -1707,7 +1645,12 @@ paths: summary: Complete competition description: | Transitions a competition from `pools` or `knockout` status to `completed`. - Returns 400 if the competition is not in a completable state. + Irreversible. Returns 400 if the competition is not in a completable state, + or if its bracket still has a match to play (every bracket match, the + 3rd-place match included, must be completed). + + In officiated mode it needs `X-Tournament-Password`, like starting a + competition; in self-run mode it is public, like starting one. security: - TournamentPassword: [] parameters: @@ -1762,12 +1705,10 @@ paths: fresh empty overrides file WITHOUT first parsing the corrupt one, so it always succeeds regardless of what the file currently contains. - Also requires `X-Admin-Password` when the destructive-ops gate is active - (see the AdminPassword security scheme); 503 if locked mode is active - without `TOURNAMENT_ADMIN_PASSWORD_HASH`. + In self-run mode this is an organiser action: it needs + `X-Tournament-Password`. security: - TournamentPassword: [] - AdminPassword: [] parameters: - $ref: '#/components/parameters/CompetitionId' responses: @@ -1784,12 +1725,10 @@ paths: Awards are independent of the auto-derived placement podium and may be set at any competition status. Passing an empty array clears all awards. - Also requires `X-Admin-Password` when the destructive-ops gate is active - (see the AdminPassword security scheme); 503 if locked mode is active - without `TOURNAMENT_ADMIN_PASSWORD_HASH`. + In self-run mode this is an organiser action: it needs + `X-Tournament-Password`. security: - TournamentPassword: [] - AdminPassword: [] parameters: - $ref: '#/components/parameters/CompetitionId' requestBody: @@ -2082,12 +2021,10 @@ paths: description: | Replaces the current participants list for the competition with the provided JSON. - Also requires `X-Admin-Password` when the destructive-ops gate is active - (see the AdminPassword security scheme); 503 if locked mode is active - without `TOURNAMENT_ADMIN_PASSWORD_HASH`. + In self-run mode this is an organiser action: it needs + `X-Tournament-Password`. security: - TournamentPassword: [] - AdminPassword: [] parameters: - $ref: '#/components/parameters/CompetitionId' requestBody: @@ -2171,10 +2108,10 @@ paths: Returns the updated player. When the cascade detects dojo conflicts, a `warnings` array is included alongside the player fields. - Also requires `X-Admin-Password` when the destructive-ops gate is active. + In self-run mode this is an organiser action: it needs + `X-Tournament-Password`. security: - TournamentPassword: [] - AdminPassword: [] requestBody: required: true content: @@ -2267,7 +2204,9 @@ paths: put: tags: [participants] summary: Check in a single participant - description: Sets checked-in status to true for the participant. + description: > + Sets checked-in status to true for the participant. Answers 409 + `check_in_not_in_self_run` in a self-run tournament, for every caller. security: - TournamentPassword: [] responses: @@ -2281,12 +2220,17 @@ paths: $ref: '#/components/responses/BadRequest' '404': $ref: '#/components/responses/NotFound' + '409': + $ref: '#/components/responses/CheckInNotInSelfRun' '500': $ref: '#/components/responses/InternalError' delete: tags: [participants] summary: Undo check-in for a single participant - description: Resets checked-in status to false for the participant. + description: > + Resets checked-in status to false for the participant. Answers 409 + `check_in_not_in_self_run` in a self-run tournament, for the organiser + (an anonymous caller is refused 401 first, as this route stays organiser-only). security: - TournamentPassword: [] responses: @@ -2300,6 +2244,8 @@ paths: $ref: '#/components/responses/BadRequest' '404': $ref: '#/components/responses/NotFound' + '409': + $ref: '#/components/responses/CheckInNotInSelfRun' '500': $ref: '#/components/responses/InternalError' @@ -2309,7 +2255,9 @@ paths: post: tags: [participants] summary: Bulk check-in participants - description: Atomically checks in multiple participants at once. + description: > + Atomically checks in multiple participants at once. Answers 409 + `check_in_not_in_self_run` in a self-run tournament, for every caller. security: - TournamentPassword: [] requestBody: @@ -2344,6 +2292,8 @@ paths: $ref: '#/components/responses/BadRequest' '404': $ref: '#/components/responses/NotFound' + '409': + $ref: '#/components/responses/CheckInNotInSelfRun' '500': $ref: '#/components/responses/InternalError' @@ -2563,12 +2513,14 @@ paths: reopened without any reason, and nothing is gated on that. The completion clears `reopenPending` and keeps a `correctionReason` if one is sent. An exact resend of the finish already recorded - (`completed`, the stored result's own `modifiedAt`, and nothing it - changes differing from what is stored: a queued send whose first - answer was lost) is not a correction either: it is answered as - recorded, with the stored MatchResult, and writes nothing. A - same-stamp write that differs from what is stored is a correction - and needs its reason. + (`completed`, a `modifiedAt` equal to the stored result's own + stamp, i.e. the `result` group's stamp, and nothing it changes + differing from what is stored: a queued send whose first answer + was lost) is not a correction either: it is answered as recorded, + with the stored MatchResult. Nothing is written to the match; the + match's history gains a line saying the write arrived and changed + nothing. A same-stamp write that differs from what is stored is a + correction and needs its reason. A `completed` write on a team match (not kachinuki) is also refused while a numbered bout (1..teamSize) has no result in @@ -5387,38 +5339,6 @@ components: `GET /api/auth-config` endpoint so clients can branch their UI without authenticating. - AdminPassword: - type: apiKey - in: header - name: X-Admin-Password - description: | - Elevated (destructive-ops) password; spec 004 / mp-e21. Required - IN ADDITION to `X-Tournament-Password` on destructive endpoints: - `DELETE /api/competitions/{id}`, `POST /api/competitions/{id}/invalidate`, - `DELETE /api/competitions/{id}/draw`, `DELETE /api/competitions/{id}/overrides`, - `POST /api/competitions/{id}/participants`, - `PUT /api/competitions/{id}/participants/{pid}`, - `POST /api/tournament/import`, and - `PUT /api/competitions/{id}` **when the body carries a non-null - `players` field** (the roster-save path; settings-only PUTs are not - gated). - - Gate behavior: - * File mode; verified exact-string against the write-only - `admin_password` in `tournament.md`. While unset the gate is - INACTIVE (the header is not required; the main password alone - authorizes the action), so existing file deployments are - unaffected until an admin password is set. - * Locked mode; verified via bcrypt against - `TOURNAMENT_ADMIN_PASSWORD_HASH`. The gate is always active - (fail-closed): if the env var is unset/malformed the gated - endpoints return 503 "admin password not configured". - - On a missing/incorrect header (when the gate is active and - configured) the endpoint returns 401. Set or rotate the file-mode - value via `PUT /api/auth/admin-password`. There is no session; the - header is required on each gated request. - parameters: CompetitionId: in: path @@ -5465,6 +5385,22 @@ components: application/json: schema: $ref: '#/components/schemas/Error' + CheckInNotInSelfRun: + description: > + Self-run mode has no check-in. Every check-in write is refused, the + organiser's included. + content: + application/json: + schema: + type: object + required: [error, message] + properties: + error: + type: string + enum: [check_in_not_in_self_run] + message: + type: string + description: The sentence the page shows as it is, "Self-run mode has no check-in." InternalError: description: Server error. content: @@ -6060,33 +5996,18 @@ components: Constructive actions (scoring, check-in, start) and organiser-setup mutations all require the main password. - **self-run**: there is no dedicated table operator. Operational actions - (scoring, check-in, start, complete, swiss round generation) are public; no - password required. Organiser-setup mutations (tournament/competition config, + (scoring, start, complete, swiss round generation) are public; no + password required. There is no check-in. Organiser-setup mutations (tournament/competition config, seeds, override edits (set/change result), announcements, court - assignment, export) remain behind the main password. Destructive actions - (delete competition, discard draw, delete/discard overrides, import, participant - roster mutations) require the separate `X-Admin-Password` elevated credential. + assignment, export, participant roster mutations, + fighting-spirit awards, the tournament logo and sponsor images) and + destructive actions (delete or invalidate a competition, discard a draw, + reset overrides, import) remain behind the main password. Always returned in GET/POST/PUT responses regardless of auth mode (unlike `password`, which is redacted in locked mode). Defaults to `officiated` when omitted on POST (backward-compatible with pre-mp-7h7 tournaments whose `tournament.md` lacks the field). - adminPassword: - type: string - writeOnly: true - maxLength: 256 - description: > - **Creation-only, file mode, self-run tournaments only.** Sets the elevated - (destructive-ops) credential atomically in the same POST that creates the - tournament. Required when `mode` is `"self-run"` in file mode; without it the - main gate is skipped and destructive routes would be unauthenticated. - - This field is **write-only at the API boundary**: it is never returned in any - response (`json:"-"` on the server struct) and is ignored by PUT /tournament - (rotation is done via `PUT /api/auth/admin-password`). In locked mode this - field is accepted but silently ignored; the `TOURNAMENT_ADMIN_PASSWORD_HASH` - env var is the authoritative elevated credential. - TournamentWithCompetitions: description: > The response shape for POST and PUT /api/tournament: every Tournament @@ -6317,6 +6238,9 @@ components: (unchecked no-shows are excluded, and their seed assignments pruned); if nobody has checked in, everyone is included. When this flag is false, check-in markers are ignored and never shrink the field. + A self-run tournament has no check-in: the flag is ignored there (the + draw includes every participant), the apps do not offer it, and the + stored value is kept. fightingSpiritAwards: type: array description: > @@ -6740,14 +6664,11 @@ components: subsequent writes. A correction does NOT exempt the write from the timestamp - last-write-wins guard. It used to: the exemption existed because a - dropped write reported success, so exempting corrections was the - only way to stop a deliberate edit being silently eaten. That is - fixed (see the RefusedWrite schema), and the exemption could not - tell a live correction from one an offline client replayed hours - later, which silently overwrote newer results. A correction stamped - older than the stored result is therefore refused like any other - stale write, and answers with RefusedWrite. + ordering: the server cannot tell a live correction from one an + offline client replays hours later, and an exemption would let that + replay overwrite newer results. A correction stamped older than the + stored result is therefore refused like any other stale write, and + answers with RefusedWrite. reopenPending: type: boolean readOnly: true diff --git a/web-mobile/js/__tests__/admin_elevated.test.jsx b/web-mobile/js/__tests__/admin_elevated.test.jsx deleted file mode 100644 index 1ddc77e85..000000000 --- a/web-mobile/js/__tests__/admin_elevated.test.jsx +++ /dev/null @@ -1,149 +0,0 @@ -// Spec 004 / mp-e21: elevated (destructive-ops) password: client header -// plumbing + the promptAdminPassword UI helper. -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; -import { API } from '../api_client.jsx'; -import { promptAdminPassword, setCachedAuthConfig, getCachedAuthConfig } from '../admin_helpers.jsx'; - -describe('API elevated-password header (X-Admin-Password)', () => { - afterEach(() => { vi.restoreAllMocks(); }); - - // Each gated method must send X-Admin-Password when an adminPassword arg is - // given, alongside the existing X-Tournament-Password. - const gated = [ - ['deleteCompetition', () => API.deleteCompetition('c1', 'main', 'adminpw'), 'DELETE', '/api/competitions/c1'], - ['invalidateCompetition', () => API.invalidateCompetition('c1', 'main', 'adminpw'), 'POST', '/api/competitions/c1/invalidate'], - ['discardDraw', () => API.discardDraw('c1', 'main', 'adminpw'), 'DELETE', '/api/competitions/c1/draw'], - ['resetOverrides', () => API.resetOverrides('c1', 'main', 'adminpw'), 'DELETE', '/api/competitions/c1/overrides'], - ['addParticipant', () => API.addParticipant('c1', { name: 'X', dojo: 'D' }, 'main', 'adminpw'), 'POST', '/api/competitions/c1/participants'], - ['replaceParticipant', () => API.replaceParticipant('c1', 'p1', { name: 'X', dojo: 'D' }, 'main', 'adminpw'), 'PUT', '/api/competitions/c1/participants/p1'], - ['updateCompetitionAwards', () => API.updateCompetitionAwards('c1', [], 'main', 'adminpw'), 'PUT', '/api/competitions/c1/awards'], - ]; - - gated.forEach(([label, call, method, url]) => { - it(`${label} sends X-Admin-Password when provided`, async () => { - global.fetch = vi.fn().mockResolvedValue({ ok: true, status: 200, json: async () => ({}) }); - await call(); - expect(global.fetch).toHaveBeenCalledWith( - url, - expect.objectContaining({ - method, - headers: expect.objectContaining({ - 'X-Tournament-Password': 'main', - 'X-Admin-Password': 'adminpw', - }), - }) - ); - }); - }); - - it('omits X-Admin-Password when no admin password is supplied (gate inactive)', async () => { - global.fetch = vi.fn().mockResolvedValue({ ok: true, status: 200, json: async () => ({}) }); - await API.deleteCompetition('c1', 'main', ''); - const sentHeaders = global.fetch.mock.calls[0][1].headers; - expect(sentHeaders).not.toHaveProperty('X-Admin-Password'); - expect(sentHeaders['X-Tournament-Password']).toBe('main'); - }); - - // Copilot PR #193: the bulk PUT /competitions/:id is the SPA's primary - // roster writer and must carry X-Admin-Password when it bears a roster. - it('updateCompetition forwards X-Admin-Password (roster-bearing)', async () => { - global.fetch = vi.fn().mockResolvedValue({ ok: true, status: 200, json: async () => ({}) }); - await API.updateCompetition('c1', { id: 'c1', players: [{ name: 'A', dojo: 'D' }] }, 'main', 'adminpw'); - const [url, opts] = global.fetch.mock.calls[0]; - expect(url).toBe('/api/competitions/c1'); - expect(opts.method).toBe('PUT'); - expect(opts.headers).toMatchObject({ 'X-Tournament-Password': 'main', 'X-Admin-Password': 'adminpw' }); - }); - - it('updateCompetition omits X-Admin-Password for settings-only saves', async () => { - global.fetch = vi.fn().mockResolvedValue({ ok: true, status: 200, json: async () => ({}) }); - await API.updateCompetition('c1', { id: 'c1', name: 'Renamed' }, 'main', ''); - expect(global.fetch.mock.calls[0][1].headers).not.toHaveProperty('X-Admin-Password'); - }); - - it('importCompetitions forwards X-Admin-Password', async () => { - global.fetch = vi.fn().mockResolvedValue({ ok: true, json: async () => ({}) }); - await API.importCompetitions(new FormData(), 'main', 'adminpw'); - expect(global.fetch.mock.calls[0][1].headers).toMatchObject({ - 'X-Tournament-Password': 'main', - 'X-Admin-Password': 'adminpw', - }); - }); - - describe('setAdminPassword', () => { - it('sends newPassword + currentPassword with the main password header', async () => { - global.fetch = vi.fn().mockResolvedValue({ ok: true, json: async () => ({ status: 'ok' }) }); - await API.setAdminPassword('newpw', 'oldpw', 'main'); - const [url, opts] = global.fetch.mock.calls[0]; - expect(url).toBe('/api/auth/admin-password'); - expect(opts.method).toBe('PUT'); - expect(opts.headers['X-Tournament-Password']).toBe('main'); - expect(JSON.parse(opts.body)).toEqual({ newPassword: 'newpw', currentPassword: 'oldpw' }); - }); - - it('omits currentPassword on first-time set (TOFU)', async () => { - global.fetch = vi.fn().mockResolvedValue({ ok: true, json: async () => ({ status: 'ok' }) }); - await API.setAdminPassword('newpw', '', 'main'); - expect(JSON.parse(global.fetch.mock.calls[0][1].body)).toEqual({ newPassword: 'newpw' }); - }); - - it('throws with status on failure', async () => { - global.fetch = vi.fn().mockResolvedValue({ ok: false, status: 401, json: async () => ({ error: 'current admin password is incorrect' }) }); - await expect(API.setAdminPassword('a', 'b', 'main')).rejects.toThrow('current admin password is incorrect'); - }); - }); -}); - -describe('promptAdminPassword', () => { - // promptAdminPassword is now async and collects the password via the shared - // window.promptDialog primitive (themed, masked input) instead of the native - // window.prompt. ui.jsx isn't imported here, so we install a window.promptDialog - // mock per test and assert against it. - beforeEach(() => { setCachedAuthConfig(null); window.promptDialog = vi.fn(); }); - afterEach(() => { vi.restoreAllMocks(); setCachedAuthConfig(null); delete window.promptDialog; }); - - it('returns "" (no dialog) when the gate is inactive', async () => { - setCachedAuthConfig({ mode: 'file', elevatedRequired: false }); - await expect(promptAdminPassword()).resolves.toBe(''); - expect(window.promptDialog).not.toHaveBeenCalled(); - }); - - it('returns "" when authConfig is unset (loading / fail-open)', async () => { - await expect(promptAdminPassword()).resolves.toBe(''); - expect(window.promptDialog).not.toHaveBeenCalled(); - }); - - it('alerts and returns null when required but not configured (locked, env unset)', async () => { - setCachedAuthConfig({ mode: 'locked', elevatedRequired: true, elevatedConfigured: false }); - const alertSpy = vi.spyOn(window, 'alert').mockImplementation(() => {}); - await expect(promptAdminPassword()).resolves.toBeNull(); - expect(alertSpy).toHaveBeenCalled(); - expect(window.promptDialog).not.toHaveBeenCalled(); - }); - - it('prompts and returns the typed password when required + configured', async () => { - setCachedAuthConfig({ mode: 'file', elevatedRequired: true, elevatedConfigured: true }); - window.promptDialog.mockResolvedValue('typed-secret'); - await expect(promptAdminPassword()).resolves.toBe('typed-secret'); - expect(window.promptDialog).toHaveBeenCalledWith(expect.objectContaining({ password: true })); - }); - - it('returns null when the operator cancels the dialog', async () => { - setCachedAuthConfig({ mode: 'file', elevatedRequired: true, elevatedConfigured: true }); - window.promptDialog.mockResolvedValue(null); - await expect(promptAdminPassword()).resolves.toBeNull(); - }); - - it('returns null on empty submit (empty is never a valid admin password)', async () => { - setCachedAuthConfig({ mode: 'file', elevatedRequired: true, elevatedConfigured: true }); - window.promptDialog.mockResolvedValue(''); - await expect(promptAdminPassword()).resolves.toBeNull(); - }); - - it('cache is shared via window across import sites', () => { - setCachedAuthConfig({ mode: 'file', elevatedRequired: true, elevatedConfigured: true }); - expect(getCachedAuthConfig()).toMatchObject({ elevatedRequired: true }); - // window-backed so a separately-bundled reader sees the same value. - expect(window.__bcAuthConfig).toMatchObject({ elevatedRequired: true }); - }); -}); diff --git a/web-mobile/js/__tests__/create_tournament_validation.test.jsx b/web-mobile/js/__tests__/create_tournament_validation.test.jsx index 03566d9e6..f69a2837c 100644 --- a/web-mobile/js/__tests__/create_tournament_validation.test.jsx +++ b/web-mobile/js/__tests__/create_tournament_validation.test.jsx @@ -16,9 +16,6 @@ describe('validateCreateTournament', () => { pass: 'secret', date: '12-07-2026', courts: 2, - isSelfRun: false, - locked: false, - adminPass: '', maxCourts: MAX_COURTS, }; @@ -42,14 +39,11 @@ describe('validateCreateTournament', () => { expect(msg).not.toBe(''); }); - it('returns error when self-run in file mode and adminPass is missing', () => { - const msg = validateCreateTournament({ ...valid, isSelfRun: true, locked: false, adminPass: '' }); - expect(msg).not.toBe(''); - }); - - it('returns "" when self-run in locked mode without adminPass (locked mode has env-var hash)', () => { - const msg = validateCreateTournament({ ...valid, isSelfRun: true, locked: true, adminPass: '' }); - expect(msg).toBe(''); + // bc-cmpw: a self-run tournament needs only the tournament password; the + // form no longer collects a second one, so nothing else is validated. + it('returns "" for a self-run tournament given only the tournament password', () => { + expect(validateCreateTournament({ ...valid, isSelfRun: true, locked: false })).toBe(''); + expect(validateCreateTournament({ ...valid, isSelfRun: true, locked: true })).toBe(''); }); it('returns error for fractional courts', () => { diff --git a/web-mobile/js/__tests__/delete_competition_confirm.test.jsx b/web-mobile/js/__tests__/delete_competition_confirm.test.jsx new file mode 100644 index 000000000..f76581058 --- /dev/null +++ b/web-mobile/js/__tests__/delete_competition_confirm.test.jsx @@ -0,0 +1,52 @@ +// Deleting a competition asks for the password again (operator ruling +// 2026-10-12): the password typed in that moment travels in X-Confirm-Password, +// beside the sign-in's X-Tournament-Password. A refusal of that typed password +// is a wrong password in a dialog, never a lost session. +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { API } from '../api_client.jsx'; + +const SENTENCE = 'Wrong password. The competition was not deleted.'; + +const refusal = (code) => ({ + ok: false, + status: 401, + headers: { get: () => null }, + json: async () => ({ error: SENTENCE, message: SENTENCE, code }), +}); + +describe('API.deleteCompetition', () => { + afterEach(() => { vi.restoreAllMocks(); }); + + it('sends the sign-in password and the password typed again, each in its own header', async () => { + global.fetch = vi.fn().mockResolvedValue({ ok: true, status: 204, headers: { get: () => null }, json: async () => ({}) }); + await expect(API.deleteCompetition('c1', 'main', 'typed-now')).resolves.toBe(true); + const [url, opts] = global.fetch.mock.calls[0]; + expect(url).toBe('/api/competitions/c1'); + expect(opts.method).toBe('DELETE'); + expect(opts.headers['X-Tournament-Password']).toBe('main'); + expect(opts.headers['X-Confirm-Password']).toBe('typed-now'); + }); + + it('a refused confirm throws the server sentence with its code, once, and signs nobody out', async () => { + global.fetch = vi.fn().mockResolvedValue(refusal('confirm_password_wrong')); + const before = API.unsentWrites().total; + const err = await API.deleteCompetition('c1', 'main', 'nope').catch((e) => e); + expect(err).toBeInstanceOf(Error); + expect(err.message).toBe(SENTENCE); + expect(err.code).toBe('confirm_password_wrong'); + expect(global.fetch).toHaveBeenCalledTimes(1); + // Nothing parked or queued: the 401 is not a refused session. + expect(API.unsentWrites().total).toBe(before); + expect(API.heldWrites()).toEqual([]); + }); + + it('only the other 401 (the sign-in itself refused) has no confirm code', async () => { + global.fetch = vi.fn().mockResolvedValue({ + ok: false, status: 401, headers: { get: () => null }, + json: async () => ({ error: 'invalid tournament password' }), + }); + const err = await API.deleteCompetition('c1', 'stale', 'typed').catch((e) => e); + expect(err.message).toBe('invalid tournament password'); + expect(err.code).toBeUndefined(); + }); +}); diff --git a/web-mobile/js/__tests__/fighting_spirit.test.jsx b/web-mobile/js/__tests__/fighting_spirit.test.jsx index 3bdc6dc60..91dc1bf8d 100644 --- a/web-mobile/js/__tests__/fighting_spirit.test.jsx +++ b/web-mobile/js/__tests__/fighting_spirit.test.jsx @@ -198,7 +198,7 @@ describe('API.updateCompetitionAwards', () => { json: async () => ({ id: 'c1', fightingSpiritAwards: [] }), }); const awards = [{ title: 'Fighting Spirit', recipientName: 'Alice', recipientDojo: 'Tokyo' }]; - await API.updateCompetitionAwards('c1', awards, 'mainpw', 'adminpw'); + await API.updateCompetitionAwards('c1', awards, 'mainpw'); expect(global.fetch).toHaveBeenCalledWith( '/api/competitions/c1/awards', @@ -207,7 +207,6 @@ describe('API.updateCompetitionAwards', () => { headers: expect.objectContaining({ 'Content-Type': 'application/json', 'X-Tournament-Password': 'mainpw', - 'X-Admin-Password': 'adminpw', }), }) ); @@ -215,9 +214,9 @@ describe('API.updateCompetitionAwards', () => { expect(body).toEqual({ fightingSpiritAwards: awards }); }); - it('omits X-Admin-Password when adminPassword is empty', async () => { + it('never sends X-Admin-Password (bc-cmpw: no separate admin password)', async () => { global.fetch = vi.fn().mockResolvedValue({ ok: true, json: async () => ({}) }); - await API.updateCompetitionAwards('c1', [], 'mainpw', ''); + await API.updateCompetitionAwards('c1', [], 'mainpw'); const headers = global.fetch.mock.calls[0][1].headers; expect(headers).not.toHaveProperty('X-Admin-Password'); }); @@ -228,13 +227,13 @@ describe('API.updateCompetitionAwards', () => { status: 400, json: async () => ({ error: 'title is required' }), }); - await expect(API.updateCompetitionAwards('c1', [{}], 'pw', 'adm')).rejects.toThrow('title is required'); + await expect(API.updateCompetitionAwards('c1', [{}], 'pw')).rejects.toThrow('title is required'); }); it('returns parsed competition JSON on success', async () => { const comp = { id: 'c1', fightingSpiritAwards: [{ title: 'Spirit', recipientName: 'Bob' }] }; global.fetch = vi.fn().mockResolvedValue({ ok: true, json: async () => comp }); - const result = await API.updateCompetitionAwards('c1', comp.fightingSpiritAwards, 'pw', 'adm'); + const result = await API.updateCompetitionAwards('c1', comp.fightingSpiritAwards, 'pw'); expect(result.fightingSpiritAwards[0].recipientName).toBe('Bob'); }); }); diff --git a/web-mobile/js/__tests__/mp_turx_knockout.test.jsx b/web-mobile/js/__tests__/mp_turx_knockout.test.jsx index 3944c749e..d6eecfdeb 100644 --- a/web-mobile/js/__tests__/mp_turx_knockout.test.jsx +++ b/web-mobile/js/__tests__/mp_turx_knockout.test.jsx @@ -201,7 +201,7 @@ describe('AdminCompetition: page-head has no Start-knockout affordance (mp-turx) 'StatusBadge', 'formatDate', 'competitionKindLabel', 'Breadcrumbs', 'AdminTopbar', 'AdminParticipants', 'AdminSettings', 'AdminExport', 'AdminScoreEditor', 'AdminPools', 'AdminCompOverview', 'AdminTeamLineupsList', - 'AdminSwissRounds', 'RunningMatchPanel', 'BracketTree', 'promptAdminPassword', + 'AdminSwissRounds', 'RunningMatchPanel', 'BracketTree', 'isValidDate', 'roundLabel', 'bracketRoundLabel', 'hasBothSides', 'AdminCompetition', ]; @@ -257,7 +257,6 @@ describe('AdminCompetition: page-head has no Start-knockout affordance (mp-turx) global.window.AdminSwissRounds = function AdminSwissRounds() { return null; }; global.window.RunningMatchPanel = function RunningMatchPanel() { return null; }; global.window.BracketTree = function BracketTree() { return null; }; - global.window.promptAdminPassword = () => 'admin'; global.window.isValidDate = () => true; global.window.roundLabel = (i) => `Round ${i + 1}`; global.window.bracketRoundLabel = (_m, i, n) => global.window.roundLabel(i, n); @@ -423,7 +422,7 @@ describe('AdminBracket: per-match playability (mp-turx)', () => { 'StatusBadge', 'formatDate', 'competitionKindLabel', 'Breadcrumbs', 'AdminTopbar', 'AdminParticipants', 'AdminSettings', 'AdminExport', 'AdminScoreEditor', 'AdminPools', 'AdminCompOverview', 'AdminTeamLineupsList', - 'AdminSwissRounds', 'RunningMatchPanel', 'BracketTree', 'promptAdminPassword', + 'AdminSwissRounds', 'RunningMatchPanel', 'BracketTree', 'isValidDate', 'roundLabel', 'bracketRoundLabel', 'hasBothSides', 'AdminCompetition', ]; @@ -502,7 +501,6 @@ describe('AdminBracket: per-match playability (mp-turx)', () => { global.window.AdminSwissRounds = function AdminSwissRounds() { return null; }; global.window.RunningMatchPanel = function RunningMatchPanel() { return null; }; global.window.BracketTree = function BracketTree() { return null; }; - global.window.promptAdminPassword = () => 'admin'; global.window.isValidDate = () => true; global.window.roundLabel = (i) => `Round ${i + 1}`; global.window.bracketRoundLabel = (_m, i, n) => global.window.roundLabel(i, n); diff --git a/web-mobile/js/__tests__/no_admin_password_header.test.jsx b/web-mobile/js/__tests__/no_admin_password_header.test.jsx new file mode 100644 index 000000000..c5ef040b3 --- /dev/null +++ b/web-mobile/js/__tests__/no_admin_password_header.test.jsx @@ -0,0 +1,50 @@ +// bc-cmpw: the separate destructive-ops password is gone. Every organiser +// write is authorised by the tournament password alone, so no request may +// carry X-Admin-Password, and the client no longer offers a way to set one. +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { API } from '../api_client.jsx'; +import * as adminHelpers from '../admin_helpers.jsx'; + +describe('organiser writes send the tournament password only', () => { + afterEach(() => { vi.restoreAllMocks(); }); + + const writes = [ + ['deleteCompetition', () => API.deleteCompetition('c1', 'main'), 'DELETE', '/api/competitions/c1'], + ['invalidateCompetition', () => API.invalidateCompetition('c1', 'main'), 'POST', '/api/competitions/c1/invalidate'], + ['discardDraw', () => API.discardDraw('c1', 'main'), 'DELETE', '/api/competitions/c1/draw'], + ['quarantineBracket', () => API.quarantineBracket('c1', 'main'), 'POST', '/api/competitions/c1/bracket/quarantine'], + ['completeCompetition', () => API.completeCompetition('c1', 'main'), 'POST', '/api/competitions/c1/complete'], + ['resetOverrides', () => API.resetOverrides('c1', 'main'), 'DELETE', '/api/competitions/c1/overrides'], + ['addParticipant', () => API.addParticipant('c1', { name: 'X', dojo: 'D' }, 'main'), 'POST', '/api/competitions/c1/participants'], + ['replaceParticipant', () => API.replaceParticipant('c1', 'p1', { name: 'X', dojo: 'D' }, 'main'), 'PUT', '/api/competitions/c1/participants/p1'], + ['updateCompetitionAwards', () => API.updateCompetitionAwards('c1', [], 'main'), 'PUT', '/api/competitions/c1/awards'], + ['updateCompetition (roster-bearing)', () => API.updateCompetition('c1', { id: 'c1', players: [{ name: 'A', dojo: 'D' }] }, 'main'), 'PUT', '/api/competitions/c1'], + ['importCompetitions', () => API.importCompetitions(new FormData(), 'main'), 'POST', '/api/tournament/import'], + ]; + + writes.forEach(([label, call, method, url]) => { + it(`${label} sends X-Tournament-Password and no X-Admin-Password`, async () => { + global.fetch = vi.fn().mockResolvedValue({ ok: true, status: 200, headers: { get: () => null }, json: async () => ({}) }); + await call(); + expect(global.fetch).toHaveBeenCalledTimes(1); + const [sentUrl, opts] = global.fetch.mock.calls[0]; + expect(sentUrl).toBe(url); + expect(opts.method).toBe(method); + expect(opts.headers['X-Tournament-Password']).toBe('main'); + expect(opts.headers).not.toHaveProperty('X-Admin-Password'); + }); + }); + + it('a stale trailing argument from an older caller is never sent as a header', async () => { + global.fetch = vi.fn().mockResolvedValue({ ok: true, status: 200, json: async () => ({}) }); + await API.invalidateCompetition('c1', 'main', 'old-admin-pw'); + expect(global.fetch.mock.calls[0][1].headers).not.toHaveProperty('X-Admin-Password'); + }); + + it('offers no way to set an admin password or prompt for one', () => { + expect(API.setAdminPassword).toBeUndefined(); + expect(adminHelpers.promptAdminPassword).toBeUndefined(); + expect(adminHelpers.setCachedAuthConfig).toBeUndefined(); + expect(adminHelpers.getCachedAuthConfig).toBeUndefined(); + }); +}); diff --git a/web-mobile/js/__tests__/render/admin_competition.render.test.jsx b/web-mobile/js/__tests__/render/admin_competition.render.test.jsx index 3103ce4e4..a4f5ef70b 100644 --- a/web-mobile/js/__tests__/render/admin_competition.render.test.jsx +++ b/web-mobile/js/__tests__/render/admin_competition.render.test.jsx @@ -63,13 +63,13 @@ const STUBBED_GLOBALS = { }), // Dialogs / async: only reached from handlers; safe resolved stubs. confirmDialog: vi.fn().mockResolvedValue(false), - promptAdminPassword: vi.fn().mockResolvedValue(null), promptDialog: vi.fn().mockResolvedValue(null), API: { estimateCompetitionSchedule: vi.fn().mockResolvedValue(null), swissGenerateRound: vi.fn().mockResolvedValue(null), updateCompetitionAwards: vi.fn().mockResolvedValue(null), completeCompetition: vi.fn().mockResolvedValue({ status: 'completed' }), + discardDraw: vi.fn().mockResolvedValue({}), // Advisory fetch made on every competition mount; per-test overrides in the // draw-warning suite below. fetchDrawWarnings: vi.fn().mockResolvedValue([]), @@ -257,11 +257,9 @@ describe('AdminCompetition "Complete competition" action (mp-gy6g)', () => { } }); - it('calls the API and refreshes on confirm', async () => { + it('calls the API and refreshes on confirm, asking for no password', async () => { window.confirmDialog.mockResolvedValueOnce(true); - // Completion is elevated-gated; promptAdminPassword resolves "" (no admin - // password configured) so the handler proceeds. - window.promptAdminPassword.mockResolvedValueOnce(''); + window.promptDialog.mockClear(); window.API.completeCompetition.mockClear(); const onRefreshCompetition = vi.fn(); const showToast = vi.fn(); @@ -299,7 +297,11 @@ describe('AdminCompetition "Complete competition" action (mp-gy6g)', () => { expect(btn).not.toBeUndefined(); await act(async () => { fireEvent.click(btn); }); - await waitFor(() => expect(window.API.completeCompetition).toHaveBeenCalledWith('nagi-1', 'shiaijo2026', '')); + // bc-cmpw: the confirm is the only step; the tournament password the + // operator signed in with authorises it, and nothing else is sent. + await waitFor(() => expect(window.API.completeCompetition).toHaveBeenCalledTimes(1)); + expect(window.API.completeCompetition.mock.calls[0]).toEqual(['nagi-1', 'shiaijo2026']); + expect(window.promptDialog).not.toHaveBeenCalled(); expect(onRefreshCompetition).toHaveBeenCalled(); expect(showToast).toHaveBeenCalledWith(expect.stringContaining('marked complete')); }); @@ -318,6 +320,75 @@ describe('AdminCompetition "Complete competition" action (mp-gy6g)', () => { }); }); +// bc-cmpw: discarding a draw keeps its confirm, and once confirmed it goes +// straight to the API with the signed-in tournament password: there is no +// second, destructive-ops password to prompt for. +describe('AdminCompetition "Discard draw" action (bc-cmpw)', () => { + const findButton = (container, text) => + Array.from(container.querySelectorAll('button')).find((b) => b.textContent.trim() === text); + + async function mountDrawReady() { + const comp = makeCompetition({ id: 'mud-1', status: 'draw-ready' }); + const t = makeTournament(comp); + let container; + await act(async () => { + ({ container } = render( + + )); + }); + return container; + } + + it('calls discardDraw with exactly (id, password) after the confirm, with no password prompt', async () => { + window.confirmDialog.mockClear(); + window.confirmDialog.mockResolvedValueOnce(true); + window.promptDialog.mockClear(); + window.API.discardDraw.mockClear(); + const container = await mountDrawReady(); + + const btn = findButton(container, 'Discard draw'); + expect(btn).not.toBeUndefined(); + await act(async () => { fireEvent.click(btn); }); + + await waitFor(() => expect(window.API.discardDraw).toHaveBeenCalledTimes(1)); + expect(window.API.discardDraw.mock.calls[0]).toEqual(['mud-1', 'shiaijo2026']); + expect(window.confirmDialog).toHaveBeenCalledTimes(1); + expect(window.promptDialog).not.toHaveBeenCalled(); + }); + + it('still asks for the confirm and does nothing when it is cancelled', async () => { + window.confirmDialog.mockClear(); + window.confirmDialog.mockResolvedValueOnce(false); + window.API.discardDraw.mockClear(); + const container = await mountDrawReady(); + + await act(async () => { fireEvent.click(findButton(container, 'Discard draw')); }); + + expect(window.confirmDialog).toHaveBeenCalledTimes(1); + expect(window.API.discardDraw).not.toHaveBeenCalled(); + }); +}); + // bc-draw R9 UAT gap 1: the settings screen renders Save TWICE (header and // foot of a long form) and the two disabled conditions had drifted. The footer // copy omitted both hasDurationError and blockingCourtsErr, so with an @@ -1101,6 +1172,35 @@ describe('AdminSettings Knockout qualifiers (bc-qual LP-5a)', () => { await act(async () => { fireEvent.click(checkbox); }); expect(preview().textContent).toContain('Needs seeded pools'); }); + + // Operator ruling 2026-10-13: "Self run mode has no check-in." The setting + // is hidden there and the draw ignores it, so the preview reads the whole + // roster even though the stored setting is on. + const checkInLabel = (container) => Array.from(container.querySelectorAll('label.checkbox')) + .find((l) => l.textContent.includes('Check-in tracking')); + + it('offers the check-in toggle in an officiated tournament', async () => { + const comp = mixedMinComp({ players: makePlayers(9), checkInEnabled: false }); + const { container } = await mountSection('settings', { comp, tournament: { mode: 'officiated' } }); + expect(checkInLabel(container)).toBeTruthy(); + }); + + it('hides the check-in toggle in a self-run tournament', async () => { + const comp = mixedMinComp({ players: makePlayers(9), checkInEnabled: true }); + const { container } = await mountSection('settings', { comp, tournament: { mode: 'self-run' } }); + expect(checkInLabel(container)).toBeFalsy(); + }); + + it('previews the whole roster in a self-run tournament although the stored setting is on', async () => { + const players = [ + { id: 'noshow', name: 'No Show', seed: 1, checkedIn: false }, + ...makePlayers(9, () => ({ checkedIn: true })), + ]; + const comp = mixedMinComp({ players, checkInEnabled: true, extraQualifiers: 'fill-bracket' }); + const { container } = await mountSection('settings', { comp, tournament: { mode: 'self-run' } }); + const preview = container.querySelector('[data-testid="qualifier-preview-line"]'); + expect(preview.textContent).not.toContain('Needs seeded pools'); + }); }); // bc-pnum ruling 1e follow-up: GET /api/viewer/competitions/:id used to diff --git a/web-mobile/js/__tests__/render/admin_create_competition.render.test.jsx b/web-mobile/js/__tests__/render/admin_create_competition.render.test.jsx index a222f8935..183872d9b 100644 --- a/web-mobile/js/__tests__/render/admin_create_competition.render.test.jsx +++ b/web-mobile/js/__tests__/render/admin_create_competition.render.test.jsx @@ -243,6 +243,23 @@ describe('AdminCreateCompetition standing shiaijo hint (spec 007 R9)', () => { }); }); +// Operator ruling 2026-10-13: "Self run mode has no check-in." The create form +// offers no check-in toggle there; the stored default (off) is still sent. +describe('AdminCreateCompetition check-in toggle', () => { + const checkInLabel = (container) => Array.from(container.querySelectorAll('label.checkbox')) + .find((l) => l.textContent.includes('Check-in tracking')); + + it('is offered in an officiated tournament', async () => { + const { container } = await mountForm({ tournament: makeTournament({ mode: 'officiated' }) }); + expect(checkInLabel(container)).toBeTruthy(); + }); + + it('is not offered in a self-run tournament', async () => { + const { container } = await mountForm({ tournament: makeTournament({ mode: 'self-run' }) }); + expect(checkInLabel(container)).toBeFalsy(); + }); +}); + describe('AdminCreateCompetition server-error surfacing (bc-draw R9 gap 2)', () => { const errorBanner = (container) => container.querySelector('.alert--error'); diff --git a/web-mobile/js/__tests__/render/admin_participants_missing_id_disables_writes.render.test.jsx b/web-mobile/js/__tests__/render/admin_participants_missing_id_disables_writes.render.test.jsx index 7d8fa839d..f1080c0ae 100644 --- a/web-mobile/js/__tests__/render/admin_participants_missing_id_disables_writes.render.test.jsx +++ b/web-mobile/js/__tests__/render/admin_participants_missing_id_disables_writes.render.test.jsx @@ -25,7 +25,6 @@ describe('AdminParticipants disables writes for an id-less row (bc-pnum)', () => it('disables the Edit modal Save button for an id-less row and sends no request on click', async () => { const replaceParticipant = vi.fn().mockResolvedValue({}); window.API = { replaceParticipant }; - window.promptAdminPassword = vi.fn().mockResolvedValue('admin-pw'); const { container, getByText } = await mountParticipants(makeParticipantsCompetition({ players: [{ id: '', name: 'Bob', dojo: 'Dojo Bob', checkedIn: false }], })); diff --git a/web-mobile/js/__tests__/render/admin_registration_desk.render.test.jsx b/web-mobile/js/__tests__/render/admin_registration_desk.render.test.jsx index b2d58e0a7..4ecf4b47b 100644 --- a/web-mobile/js/__tests__/render/admin_registration_desk.render.test.jsx +++ b/web-mobile/js/__tests__/render/admin_registration_desk.render.test.jsx @@ -215,6 +215,25 @@ describe('AdminRegistrationDeskPage scopes to competitions with check-in trackin // identifier at all, and checkPersonEntries now skips it client-side // (reporting the skip via toast) rather than sending a write the server // can only 404. +// Operator ruling 2026-10-13: "Self run mode has no check-in." The desk is +// check-in only, so in a self-run tournament it says so in one plain line +// instead of gathering any roster, whatever the competitions' stored setting. +describe('AdminRegistrationDeskPage in a self-run tournament', () => { + it('says self-run mode has no check-in and lists no competition or roster', async () => { + const { container } = await mount(makeTournament({ mode: 'self-run' })); + expect(container.textContent).toContain('Self-run mode has no check-in.'); + expect(container.querySelector('.rd-workspace')).toBeNull(); + expect(container.querySelector('.rd-search__input')).toBeNull(); + expect(container.textContent).not.toContain('Turn on Check-in tracking'); + }); + + it('still gathers the rosters in an officiated tournament', async () => { + const { container } = await mount(makeTournament({ mode: 'officiated' })); + expect(container.textContent).not.toContain('Self-run mode has no check-in.'); + expect(container.querySelector('.rd-workspace')).not.toBeNull(); + }); +}); + describe('AdminRegistrationDeskPage check-in sends the id-only wire pid (bc-pnum)', () => { let toggleCheckIn; let savedFetchCompetitions; @@ -345,13 +364,11 @@ describe('AdminRegistrationDeskPage disables writes for an id-less row in comp m replaceParticipant = vi.fn().mockResolvedValue({}); window.API.toggleCheckIn = toggleCheckIn; window.API.replaceParticipant = replaceParticipant; - window.promptAdminPassword = vi.fn().mockResolvedValue('admin-pw'); }); afterEach(() => { delete window.API.toggleCheckIn; delete window.API.replaceParticipant; - delete window.promptAdminPassword; }); it('disables the row check-in control for an id-less row and sends no request', async () => { diff --git a/web-mobile/js/__tests__/render/admin_setup_shiaijo.render.test.jsx b/web-mobile/js/__tests__/render/admin_setup_shiaijo.render.test.jsx index f8764102f..1ad206a4d 100644 --- a/web-mobile/js/__tests__/render/admin_setup_shiaijo.render.test.jsx +++ b/web-mobile/js/__tests__/render/admin_setup_shiaijo.render.test.jsx @@ -31,7 +31,6 @@ const STUBBED_GLOBALS = { BrandingManager: Stub('BrandingManager'), isNonPublicOrigin: () => false, confirmDialog: vi.fn().mockResolvedValue(false), - promptAdminPassword: vi.fn().mockResolvedValue(null), API: { importCompetitions: vi.fn().mockResolvedValue({ results: [] }), updateTournament: vi.fn().mockResolvedValue(null), @@ -86,6 +85,27 @@ const venueHint = (container) => { return el && el.textContent; }; +// Operator ruling 2026-10-13: "Self run mode has no check-in." The read-only +// Tournament type line states what self-run makes public and names no check-in. +describe('Tournament type line (self-run has no check-in)', () => { + const typeHint = (container) => Array.from(container.querySelectorAll('.field')) + .find((f) => f.textContent.includes('Tournament type')).textContent; + + it('says scoring, starting and completing are public and that there is no check-in', async () => { + const { container } = await mountEdit(makeTournament({ mode: 'self-run' })); + const text = typeHint(container); + expect(text).toContain('Self-run'); + expect(text).toContain('Scoring and starting and completing a competition are public.'); + expect(text).toContain('Self-run mode has no check-in.'); + expect(text).not.toContain('roster changes'); + }); + + it('says nothing about check-in for an officiated tournament', async () => { + const { container } = await mountEdit(makeTournament({ mode: 'officiated' })); + expect(typeHint(container).toLowerCase()).not.toContain('check-in'); + }); +}); + describe('Tournament venue field teaches the rule up front (U3)', () => { it('says any number is fine and scopes the rule to each competition', async () => { const { container } = await mountEdit(); @@ -225,7 +245,6 @@ describe('Import row errors read as prose, not as a tag (U4)', () => { const runImport = async (container, results) => { window.API.importCompetitions.mockResolvedValueOnce({ results }); window.confirmDialog.mockResolvedValueOnce(true); - window.promptAdminPassword.mockResolvedValueOnce(''); const btn = Array.from(container.querySelectorAll('button')).find((b) => b.textContent.trim() === 'Import'); await act(async () => { fireEvent.click(btn); }); await flushUntil(container, '[data-testid="import-result-row"]'); @@ -283,7 +302,6 @@ describe('Import warning: a reassigned row surfaces without being mistaken for a const runImport = async (container, results) => { window.API.importCompetitions.mockResolvedValueOnce({ results }); window.confirmDialog.mockResolvedValueOnce(true); - window.promptAdminPassword.mockResolvedValueOnce(''); const btn = Array.from(container.querySelectorAll('button')).find((b) => b.textContent.trim() === 'Import'); await act(async () => { fireEvent.click(btn); }); await flushUntil(container, '[data-testid="import-result-row"]'); diff --git a/web-mobile/js/__tests__/render/create_tournament_labels.render.test.jsx b/web-mobile/js/__tests__/render/create_tournament_labels.render.test.jsx index 708431d5e..04844bfbb 100644 --- a/web-mobile/js/__tests__/render/create_tournament_labels.render.test.jsx +++ b/web-mobile/js/__tests__/render/create_tournament_labels.render.test.jsx @@ -49,8 +49,21 @@ describe('the first-run tournament form', () => { expect(within(group).getByRole('button', { name: 'Self-run' }).getAttribute('aria-pressed')).toBe('false'); }); - it('names the self-run destructive-ops password once Self-run is chosen', async () => { + // bc-cmpw: a self-run tournament is created with the tournament password + // alone; choosing Self-run adds no second password field. + it('asks for no second password once Self-run is chosen', async () => { await act(async () => { fireEvent.click(screen.getByRole('button', { name: 'Self-run' })); }); - expect(screen.getByLabelText('Destructive-ops password (required for self-run)').type).toBe('password'); + expect(screen.queryByLabelText(/destructive-ops password/i)).toBeNull(); + expect(screen.getAllByLabelText(/password/i)).toHaveLength(1); + }); + + // Operator ruling 2026-10-13: "Self run mode has no check-in." The form + // says so, and no longer lists check-in among what self-run makes public. + it('says self-run has no check-in and that its scoring is public', () => { + expect(screen.getByText(/In self-run mode, scoring is public so participants don't need this\./)).toBeInTheDocument(); + expect(screen.getByText(/Self-run mode has no check-in\./)).toBeInTheDocument(); + expect(document.body.textContent).not.toContain('scoring, check-in'); + expect(document.body.textContent).not.toContain('scoring and check-in'); + expect(document.body.textContent).not.toContain('roster changes'); }); }); diff --git a/web-mobile/js/__tests__/render/delete_competition_password.render.test.jsx b/web-mobile/js/__tests__/render/delete_competition_password.render.test.jsx new file mode 100644 index 000000000..070e9a93c --- /dev/null +++ b/web-mobile/js/__tests__/render/delete_competition_password.render.test.jsx @@ -0,0 +1,126 @@ +// Delete competition asks for the password again (operator ruling 2026-10-12). +// Driven through the real Settings screen and the real DialogHost: the dialog +// names the competition, takes the password in a password input, and a wrong +// one shows the server's sentence INSIDE the dialog and leaves the operator +// signed in; the right one deletes and leaves the screen. +import React from 'react'; +import { render, act, fireEvent, cleanup } from '@testing-library/react'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { DialogHost, confirmDialog, promptDialog } from '../../ui.jsx'; +import { installSettingsHarness, makeSettingsCompetition } from './settings_mount_harness.jsx'; +import { pointerTap, keyboardClick } from '../helpers/tap_events.js'; + +const stubs = installSettingsHarness({ confirmDialog, promptDialog }); + +const SENTENCE = 'Wrong password. The competition was not deleted.'; +const noop = () => {}; + +const wrongPassword = () => Object.assign(new Error(SENTENCE), { code: 'confirm_password_wrong' }); + +async function mountWithDialogHost({ comp, onBack, showToast }) { + const t = { name: 'Spring Taikai', courts: ['A', 'B'], competitions: [comp] }; + let result; + await act(async () => { + result = render( +
+ + +
+ ); + }); + return result; +} + +const button = (container, text) => + Array.from(container.querySelectorAll('button')).find((b) => b.textContent.trim() === text); +const dialog = () => document.querySelector('[role="dialog"]'); +const type = (value) => act(async () => { + fireEvent.change(dialog().querySelector('input'), { target: { value } }); +}); + +describe('Delete competition asks for the password again', () => { + let onBack; let showToast; let container; + + beforeEach(async () => { + stubs.API.deleteCompetition = vi.fn(); + onBack = vi.fn(); + showToast = vi.fn(); + ({ container } = await mountWithDialogHost({ + comp: makeSettingsCompetition({ id: 'c1', name: 'Autumn Cup' }), onBack, showToast, + })); + }); + afterEach(() => { cleanup(); }); + + it('opens a password dialog that names the competition, with Cancel and Delete', async () => { + await pointerTap(button(container, 'Delete competition')); + const d = dialog(); + expect(d).not.toBeNull(); + expect(d.textContent).toContain('Enter the admin password to delete "Autumn Cup".'); + expect(d.querySelector('input').type).toBe('password'); + expect(button(d, 'Cancel')).toBeDefined(); + expect(button(d, 'Delete')).toBeDefined(); + expect(stubs.API.deleteCompetition).not.toHaveBeenCalled(); + }); + + it('a wrong password shows the sentence in the dialog, keeps it open and deletes nothing', async () => { + stubs.API.deleteCompetition.mockRejectedValueOnce(wrongPassword()); + await pointerTap(button(container, 'Delete competition')); + await type('nope'); + await keyboardClick(button(dialog(), 'Delete')); + + expect(stubs.API.deleteCompetition).toHaveBeenCalledWith('c1', 'sign-in-pw', 'nope'); + expect(dialog()).not.toBeNull(); + expect(dialog().querySelector('[role="alert"]').textContent).toBe(SENTENCE); + expect(onBack).not.toHaveBeenCalled(); + expect(showToast).not.toHaveBeenCalled(); + }); + + it('the right password after a wrong one deletes and leaves the screen', async () => { + stubs.API.deleteCompetition.mockRejectedValueOnce(wrongPassword()); + stubs.API.deleteCompetition.mockResolvedValueOnce(true); + await pointerTap(button(container, 'Delete competition')); + await type('nope'); + await keyboardClick(button(dialog(), 'Delete')); + await type('right'); + await keyboardClick(button(dialog(), 'Delete')); + + expect(stubs.API.deleteCompetition).toHaveBeenLastCalledWith('c1', 'sign-in-pw', 'right'); + expect(dialog()).toBeNull(); + expect(onBack).toHaveBeenCalledTimes(1); + }); + + it('Cancel closes the dialog and sends nothing', async () => { + await pointerTap(button(container, 'Delete competition')); + await type('whatever'); + await keyboardClick(button(dialog(), 'Cancel')); + expect(dialog()).toBeNull(); + expect(stubs.API.deleteCompetition).not.toHaveBeenCalled(); + expect(onBack).not.toHaveBeenCalled(); + }); + + it('any other refusal closes the dialog and is toasted', async () => { + const logged = vi.spyOn(console, 'error').mockImplementation(() => {}); + stubs.API.deleteCompetition.mockRejectedValueOnce(new Error('competition is in progress; mark it invalid before deleting')); + await pointerTap(button(container, 'Delete competition')); + await type('right'); + await keyboardClick(button(dialog(), 'Delete')); + expect(dialog()).toBeNull(); + expect(showToast).toHaveBeenCalledWith('competition is in progress; mark it invalid before deleting', 'error'); + expect(onBack).not.toHaveBeenCalled(); + expect(logged).toHaveBeenCalledTimes(1); + logged.mockRestore(); + }); + + it('an empty password is not sent', async () => { + await pointerTap(button(container, 'Delete competition')); + await keyboardClick(button(dialog(), 'Delete')); + expect(stubs.API.deleteCompetition).not.toHaveBeenCalled(); + expect(dialog()).not.toBeNull(); + expect(dialog().querySelector('[role="alert"]').textContent).toBe('Enter the password.'); + }); +}); diff --git a/web-mobile/js/__tests__/render/individual_editor_tap_back_kept.render.test.jsx b/web-mobile/js/__tests__/render/individual_editor_tap_back_kept.render.test.jsx index c5e01c737..b6fdc783c 100644 --- a/web-mobile/js/__tests__/render/individual_editor_tap_back_kept.render.test.jsx +++ b/web-mobile/js/__tests__/render/individual_editor_tap_back_kept.render.test.jsx @@ -130,4 +130,27 @@ describe('a tap made while the previous save is out is never wiped by that save\ }); expect(marks(r.container).some((l) => l.endsWith('remove K')), 'the later change shows').toBe(true); }); + + it('once the tap\'s own save has landed, a server copy stamped just before the tap is followed', async () => { + // The write's stamp is worked out at send, less the edit's age, so it can + // read a millisecond before the tap that made it. Once that save has + // landed the board holds nothing unsent, and a change the server applied + // under such a stamp (another device's overtime, made before the tap and + // arriving after it) is the match as it stands. + const onSubmit = vi.fn().mockResolvedValue({ id: 'm1', status: 'running' }); + let r; + await act(async () => { r = render(el(bout(), onSubmit)); }); + await tap(menButton(r.container.querySelector('.sb-side--aka'))); + await wait(350); // the tap's save goes out and lands + await act(async () => {}); + const added = onSubmit.mock.calls.at(-1)[0]; + const echo = bout({ ipponsA: added.ipponsA, ipponsB: added.ipponsB, modifiedAt: NOW - 1 }); + await act(async () => { r.rerender(el(echo, onSubmit)); }); + await act(async () => { r.rerender(el({ ...echo, encho: { periodCount: 1 } }, onSubmit)); }); + + vi.setSystemTime(NOW + 1000); + await tap(menButton(r.container.querySelector('.sb-side--shiro'))); + await wait(350); + expect(onSubmit.mock.calls.at(-1)[0].encho, 'the board took the overtime, so the next save keeps it').toEqual({ periodCount: 1 }); + }); }); diff --git a/web-mobile/js/__tests__/render/settings_mount_harness.jsx b/web-mobile/js/__tests__/render/settings_mount_harness.jsx index 3ddab6f44..b61ccb483 100644 --- a/web-mobile/js/__tests__/render/settings_mount_harness.jsx +++ b/web-mobile/js/__tests__/render/settings_mount_harness.jsx @@ -50,7 +50,6 @@ function defaultStubbedGlobals() { addListener: noop, removeListener: noop, }), confirmDialog: vi.fn().mockResolvedValue(false), - promptAdminPassword: vi.fn().mockResolvedValue(null), promptDialog: vi.fn().mockResolvedValue(null), API: { estimateCompetitionSchedule: vi.fn().mockResolvedValue(null), diff --git a/web-mobile/js/__tests__/render/sweep_form_labels.render.test.jsx b/web-mobile/js/__tests__/render/sweep_form_labels.render.test.jsx index 9c9485de3..ab68745a3 100644 --- a/web-mobile/js/__tests__/render/sweep_form_labels.render.test.jsx +++ b/web-mobile/js/__tests__/render/sweep_form_labels.render.test.jsx @@ -22,7 +22,6 @@ const STUBBED_GLOBALS = { isNonPublicOrigin: () => false, buildCompetition: (cfg) => ({ ...cfg }), addMinutes: (t) => t, - setCachedAuthConfig: noop, API: { estimateCompetitionSchedule: vi.fn().mockResolvedValue(null), getNumberPrefixDefault: vi.fn().mockResolvedValue({ numberPrefix: '' }), @@ -61,7 +60,7 @@ describe('the tournament Edit details form', () => { await act(async () => { render( + onViewerMode={noop} authConfig={{ mode: 'file' }} password="pw" showToast={noop} /> ); }); expect(screen.getByLabelText('Name').value).toBe('Spring Taikai'); @@ -84,8 +83,8 @@ describe('the tournament Edit details form', () => { expect(screen.getByLabelText('Contact 1 value').value).toBe('a@b.test'); expect(screen.getByRole('button', { name: 'Remove contact 1' })).toBeInTheDocument(); expect(screen.getByLabelText('Admin password').type).toBe('password'); - expect(screen.getByLabelText('Destructive-ops password (set)').type).toBe('password'); - expect(screen.getByLabelText('Current destructive-ops password').type).toBe('password'); + // bc-cmpw: the separate destructive-ops password is gone from the form. + expect(screen.queryByLabelText(/destructive-ops password/i)).toBeNull(); }); }); diff --git a/web-mobile/js/__tests__/render/team_editor_row_stamp_cleared_on_landed_save.render.test.jsx b/web-mobile/js/__tests__/render/team_editor_row_stamp_cleared_on_landed_save.render.test.jsx new file mode 100644 index 000000000..f14e3d51e --- /dev/null +++ b/web-mobile/js/__tests__/render/team_editor_row_stamp_cleared_on_landed_save.render.test.jsx @@ -0,0 +1,146 @@ +// A bout row's edit stamp (lastRowEditRef, bc-kclr) holds the operator's row against a +// server copy stamped before the tap. It stands only while an edit is unsent: a point +// struck and taken back inside the autosave debounce changes nothing on the server, so +// the write does not name the bout and nothing ever moves the match's stamp past the tap +// (the client stamps a write `now - age`). Another device whose clock runs a little +// behind then scores the same bout, its write applies, the match's modifiedAt stays +// below the tap stamp, and the sheet kept the operator's row forever while every other +// surface showed the other device's. Once a save built AFTER the row's latest edit has +// landed, the sheet holds nothing the server lacks, so the stamp is cleared and the +// server's copy is followed again. The individual editor does the same with its board +// stamp (individual_editor_tap_back_kept.render.test.jsx, case 4). + +import React from 'react'; +import { render, act, fireEvent } from '@testing-library/react'; +import { describe, it, expect, vi, beforeAll, afterAll, beforeEach, afterEach } from 'vitest'; +import { installWindowStubs } from '../helpers/stub_globals.js'; + +const STUBBED_GLOBALS = { + isHikiwake: () => false, + arraysEqual: (a, b) => a.length === b.length && a.every((v, i) => v === b[i]), + isKikenDecision: () => false, + isTextEntry: () => false, + isInteractiveTarget: () => false, + confirmDialog: vi.fn().mockResolvedValue(true), + API: { + fetchCompetitionDetails: vi.fn().mockResolvedValue({ + id: 'comp1', config: { format: 'mixed', teamMatchType: 'kachinuki', naginata: false, players: [] }, + }), + recordScore: vi.fn().mockResolvedValue(undefined), + recordDaihyosen: vi.fn(), + removeDaihyosen: vi.fn(), + putMatchLineup: vi.fn(), + recordDecision: vi.fn(), + }, + AdminLineupHelpers: { rosterFor: vi.fn().mockReturnValue([]) }, + compMatches: () => [], + Term: ({ children }) => {children}, + GlossaryHint: ({ name }) => , +}; + +let restoreGlobals; +let ScoreEditorModal; + +beforeAll(async () => { + restoreGlobals = installWindowStubs(STUBBED_GLOBALS); + await import('../../admin_scoring_modal.jsx'); + ScoreEditorModal = window.ScoreEditorModal; +}); + +afterAll(() => restoreGlobals()); + +beforeEach(() => { vi.useFakeTimers(); }); +afterEach(() => { vi.useRealTimers(); }); + +const kachinukiMatch = (subResults, modifiedAt = 0) => ({ + id: 'k1', compId: 'comp1', status: 'running', phase: 'pool', poolName: 'Pool 1', court: 'A', modifiedAt, + compKind: 'team', teamSize: 3, compFormat: 'mixed', teamMatchType: 'kachinuki', + sideA: { id: 'team-A', name: 'Team A' }, + sideB: { id: 'team-B', name: 'Team B' }, + subResults, +}); + +const strikeAkaMen = async () => { + await act(async () => { fireEvent.keyDown(window, { key: 'M', shiftKey: true }); }); +}; +async function tapMark(side, letter) { + const mark = [...document.querySelectorAll(`.team-sub-match:not(.team-sub-match--readonly) .tsm-center-pts--${side} .editor-side__pt`)] + .find((b) => b.textContent === letter); + expect(mark, `a scored ${letter} on ${side}`).toBeTruthy(); + await act(async () => { fireEvent.click(mark); }); +} +const filled = (side) => document.querySelectorAll(`.team-sub-match:not(.team-sub-match--readonly) .tsm-center-pts--${side} .editor-side__pt--filled`).length; + +// The other device scored Shiro's K on bout 1; its write left the match's stamp at `modifiedAt`. +const otherDeviceScoredShiro = (modifiedAt) => + kachinukiMatch([{ position: 1, sideA: '', sideB: '', ipponsA: [], ipponsB: ['K'] }], modifiedAt); + +describe('a bout row stamp stands only while an edit is unsent', () => { + it('follows a server copy stamped below the tap once the autosave built after it has landed', async () => { + const onSubmit = vi.fn().mockResolvedValue({ applied: true }); + let utils; + const el = (match) => ; + await act(async () => { utils = render(el(kachinukiMatch([]))); }); + // Strike and take back inside the debounce: the sheet returns to what the server shows. + await strikeAkaMen(); + await tapMark('aka', 'M'); + const tappedAt = Date.now(); + await act(async () => { vi.advanceTimersByTime(400); }); + expect(onSubmit, 'the autosave was sent').toHaveBeenCalled(); + + await act(async () => { utils.rerender(el(otherDeviceScoredShiro(tappedAt - 1))); }); + expect(filled('shiro'), "the other device's point shows").toBe(1); + }); + + it('keeps the row while an edit made after the landed save is still owed', async () => { + const onSubmit = vi.fn().mockResolvedValue({ applied: true }); + let utils; + const el = (match) => ; + await act(async () => { utils = render(el(kachinukiMatch([]))); }); + await strikeAkaMen(); + await tapMark('aka', 'M'); + await act(async () => { vi.advanceTimersByTime(400); }); + // A new tap, still inside its debounce: the server cannot know it. + await strikeAkaMen(); + const tappedAt = Date.now(); + + await act(async () => { utils.rerender(el(otherDeviceScoredShiro(tappedAt - 1))); }); + expect(filled('aka'), 'the unsent point stays').toBe(1); + expect(filled('shiro'), 'and the older copy does not replace the row').toBe(0); + }); + + it('keeps the row when the save that landed was built before the latest tap', async () => { + let settle; + const onSubmit = vi.fn().mockImplementationOnce(() => new Promise((r) => { settle = r; })) + .mockResolvedValue({ applied: true }); + let utils; + const el = (match) => ; + await act(async () => { utils = render(el(kachinukiMatch([]))); }); + await strikeAkaMen(); + await tapMark('aka', 'M'); + await act(async () => { vi.advanceTimersByTime(400); }); + expect(onSubmit, 'the first save is out and unanswered').toHaveBeenCalledTimes(1); + // A tap made while that save is out, then the save lands. + await strikeAkaMen(); + const tappedAt = Date.now(); + await act(async () => { settle({ applied: true }); }); + + await act(async () => { utils.rerender(el(otherDeviceScoredShiro(tappedAt - 1))); }); + expect(filled('aka'), 'the tap the landed save never carried stays').toBe(1); + expect(filled('shiro')).toBe(0); + }); + + it('keeps the row when the save was refused', async () => { + const onSubmit = vi.fn().mockResolvedValue({ applied: false, reason: 'superseded' }); + let utils; + const el = (match) => ; + await act(async () => { utils = render(el(kachinukiMatch([]))); }); + await strikeAkaMen(); + await tapMark('aka', 'M'); + const tappedAt = Date.now(); + await act(async () => { vi.advanceTimersByTime(400); }); + + await act(async () => { utils.rerender(el(otherDeviceScoredShiro(tappedAt - 1))); }); + expect(filled('shiro'), 'a refused save landed nothing, so the stamp stands').toBe(0); + }); +}); diff --git a/web-mobile/js/__tests__/self_run_no_checkin.test.jsx b/web-mobile/js/__tests__/self_run_no_checkin.test.jsx new file mode 100644 index 000000000..b8c00f754 --- /dev/null +++ b/web-mobile/js/__tests__/self_run_no_checkin.test.jsx @@ -0,0 +1,75 @@ +// Operator ruling 2026-10-13: "Self run mode has no check-in." checkInApplies +// (competition_shape.jsx) is the ONE client owner of whether a competition's +// check-in setting counts; the watchlist roster and the admin dashboard ask it. +import { describe, it, expect, vi, beforeAll, afterAll } from 'vitest'; +import { installWindowStubs } from './helpers/stub_globals.js'; +import { collectText } from './helpers/vdom.js'; +import { checkInApplies, SELF_RUN_NO_CHECK_IN } from '../competition_shape.jsx'; +import { buildRoster } from '../viewer.jsx'; + +const SELF_RUN = { mode: 'self-run' }; +const OFFICIATED = { mode: 'officiated' }; + +describe('checkInApplies', () => { + it('is the competition setting in an officiated tournament', () => { + expect(checkInApplies(OFFICIATED, true)).toBe(true); + expect(checkInApplies(OFFICIATED, false)).toBe(false); + }); + it('is the competition setting when the tournament has no mode or is unknown', () => { + expect(checkInApplies({}, true)).toBe(true); + expect(checkInApplies(null, true)).toBe(true); + expect(checkInApplies(undefined, false)).toBe(false); + }); + it('is false in a self-run tournament whatever the stored setting is', () => { + expect(checkInApplies(SELF_RUN, true)).toBe(false); + expect(checkInApplies(SELF_RUN, false)).toBe(false); + }); + it('words the refusal as the server does', () => { + expect(SELF_RUN_NO_CHECK_IN).toBe('Self-run mode has no check-in.'); + }); +}); + +describe('buildRoster in a self-run tournament', () => { + const comps = [{ id: 'c1', checkInEnabled: true, players: [{ id: 'p1', name: 'Alice', checkedIn: true }] }]; + it('never marks anyone checked in', () => { + expect(buildRoster(comps, SELF_RUN)[0].checkedIn).toBe(false); + }); + it('keeps the mark in an officiated tournament and when no tournament is given', () => { + expect(buildRoster(comps, OFFICIATED)[0].checkedIn).toBe(true); + expect(buildRoster(comps)[0].checkedIn).toBe(true); + }); +}); + +describe('AdminDashboard Registration desk card', () => { + let AdminDashboard; + let restoreGlobals; + beforeAll(async () => { + restoreGlobals = installWindowStubs({ + pluralize: (n, s, p) => `${n} ${n === 1 ? s : (p || `${s}s`)}`, + formatLabelShort: (f) => f, + formatDate: (d) => d, + formatAdminHeaderSub: () => 'header', + competitionKindLabel: () => 'Individual', + courtCount: () => 1, + StatusBadge: function StatusBadge() { return null; }, + }); + await import('../admin_shell.jsx'); + AdminDashboard = window.AdminDashboard; + }); + afterAll(() => restoreGlobals()); + + const dashboardText = (mode) => collectText(AdminDashboard({ + tournament: { + name: 'Cup', courts: ['A'], mode, + competitions: [{ id: 'c1', name: 'Men', kind: 'individual', status: 'setup', players: [], courts: ['A'] }], + }, + onOpenRegistration: vi.fn(), + })); + + it('is offered in an officiated tournament', () => { + expect(dashboardText('officiated')).toContain('Registration desk'); + }); + it('is not offered in a self-run tournament', () => { + expect(dashboardText('self-run')).not.toContain('Registration desk'); + }); +}); diff --git a/web-mobile/js/admin.jsx b/web-mobile/js/admin.jsx index 83df45ae4..18e2ae1af 100644 --- a/web-mobile/js/admin.jsx +++ b/web-mobile/js/admin.jsx @@ -246,21 +246,9 @@ function AdminApp({ tournament, onUpdate, onLogout, onViewerMode, onPasswordChan // succession (AdminParticipants.apply) or a "Last saved 13:45:22" // stamp on a failed save (AdminSettings.saveNow). const updateCompetition = async (cid, next) => { - // Elevated-password gate (spec 004 / mp-e21). This PUT doubles as the - // SPA's primary roster writer: a non-null `players` payload makes the - // server persist participants/seeds, which is a destructive op. Prompt - // for the admin password here (centrally) only when the payload carries - // a roster mutation. Settings-only saves omit `players` and stay - // single-factor. promptAdminPassword returns "" when the gate is - // inactive (proceed) and null when cancelled/not-configured (abort). - let admin = ""; - if (next && next.players != null) { - admin = await window.promptAdminPassword(); - if (admin === null) return; - } let updated; try { - updated = await window.API.updateCompetition(cid, next, password, admin); + updated = await window.API.updateCompetition(cid, next, password); } catch (e) { showToast(e.message, "error"); throw e; diff --git a/web-mobile/js/admin_competition.jsx b/web-mobile/js/admin_competition.jsx index bafb4cf04..c1b812b95 100644 --- a/web-mobile/js/admin_competition.jsx +++ b/web-mobile/js/admin_competition.jsx @@ -13,6 +13,7 @@ // helper re-exports; overview has no pure helper, so it needs a side-effect import. import './admin_competition_overview.jsx'; import { effectiveDrawPlayers } from './qualifier_preview.jsx'; +import { checkInApplies } from './competition_shape.jsx'; export { formatCompMinutes } from './admin_competition_settings.jsx'; export { buildRunningIpponResult, loadScoreboardPoints } from './admin_competition_bracket.jsx'; export { @@ -107,7 +108,7 @@ function AdminCompetition({ tournament, competition, pools, poolMatches, standin // roster. Empty roster (e.g. a knockout-from-source competition resolved // server-side) yields 0. const drawPlayers = c.players || []; - const excludedFromDraw = drawPlayers.length - effectiveDrawPlayers(drawPlayers, c.checkInEnabled).length; + const excludedFromDraw = drawPlayers.length - effectiveDrawPlayers(drawPlayers, checkInApplies(t, c.checkInEnabled)).length; // Confirm before a draw/start that would drop non-checked-in participants. // Returns false when the operator cancels. @@ -144,11 +145,9 @@ function AdminCompetition({ tournament, competition, pools, poolMatches, standin const discardDraw = async () => { if (!(await window.confirmDialog({ message: `Discard the generated draw for "${c.name}"? The pools/bracket will be removed so you can edit participants and settings, then generate a new draw.`, confirmLabel: "Discard draw", danger: true }))) return; - const admin = await window.promptAdminPassword(); - if (admin === null) return; setDiscarding(true); try { - await window.API.discardDraw(c.id, password, admin); + await window.API.discardDraw(c.id, password); if (!mountedRef.current) return; onRefreshCompetition?.(); showToast(`Draw discarded for ${c.name}`); @@ -183,13 +182,9 @@ function AdminCompetition({ tournament, competition, pools, poolMatches, standin confirmLabel: "Complete competition", danger: true, }))) return; - // Completion is irreversible → elevated-gated on the server; prompt for the - // admin password (a no-op when no admin password is configured). - const admin = await window.promptAdminPassword(); - if (admin === null) return; setCompleting(true); try { - await window.API.completeCompetition(c.id, password, admin); + await window.API.completeCompetition(c.id, password); if (!mountedRef.current) return; onRefreshCompetition?.(); showToast(`${c.name} marked complete`); diff --git a/web-mobile/js/admin_competition_overview.jsx b/web-mobile/js/admin_competition_overview.jsx index eb6ac52ad..742e4ce1e 100644 --- a/web-mobile/js/admin_competition_overview.jsx +++ b/web-mobile/js/admin_competition_overview.jsx @@ -10,6 +10,7 @@ import { EstimateHeadline } from './admin_schedule_utils.jsx'; // settings preview so this stat, the checklist and the preview cannot // disagree about what counts as seeded. import { seededRanks } from './admin_helpers.jsx'; +import { checkInApplies } from './competition_shape.jsx'; // One owner for what the operator is told when a file on disk does not say what // the app expects; shared with the pool surfaces so the wording cannot drift. import { @@ -249,11 +250,9 @@ function AdminCompOverview({ c, tournament, pools, poolMatches, bracket, onSecti danger: true, }); if (!ok) return; - const admin = await window.promptAdminPassword(); - if (admin === null) return; setQuarantining(true); try { - const res = await window.API.quarantineBracket(c.id, password, admin); + const res = await window.API.quarantineBracket(c.id, password); // res.rebuilt is the SERVER's answer, not the kind we predicted above: it // is what actually happened, and a toast that guessed would one day tell // an operator a stage was rebuilt when nothing was. @@ -333,9 +332,9 @@ function AdminCompOverview({ c, tournament, pools, poolMatches, bracket, onSecti // Tournament ceremony/timing fields mirror AdminSettings' estimate effect deps. }, [c.id, c.format, c.kind, c.poolMatchDurationSeconds, c.knockoutMatchDurationSeconds, c.courts, c.teamSize, c.poolSize, c.poolSizeMode, c.poolWinners, c.roundRobin, c.poolFormat, - c.swissRounds, c.checkInEnabled, password, + c.swissRounds, checkInApplies(tournament, c.checkInEnabled), password, (c.players || []).length, - c.checkInEnabled ? (c.players || []).filter(p => p && p.checkedIn).length : 0, + checkInApplies(tournament, c.checkInEnabled) ? (c.players || []).filter(p => p && p.checkedIn).length : 0, tournament?.openingBlock, tournament?.lunchBlock, tournament?.closingBlock, tournament?.clockToElapsedMultiplier, tournament?.slowestCourtBufferPct]); @@ -776,7 +775,7 @@ function AdminCompOverview({ c, tournament, pools, poolMatches, bracket, onSecti // FightingSpiritAwardsEditor: free-text form for adding/removing/saving // optional fighting-spirit (敢闘賞) awards for a competition. Each award has // a title, recipient name, and optional dojo. Save calls -// API.updateCompetitionAwards (elevated-gated PUT /api/competitions/:id/awards). +// API.updateCompetitionAwards (PUT /api/competitions/:id/awards). // v1 = free-text only; no competitor picker (deferred). function FightingSpiritAwardsEditor({ c, password, showToast }) { // Each row carries a stable client-only `_key` so React keys survive @@ -812,13 +811,11 @@ function FightingSpiritAwardsEditor({ c, password, showToast }) { const updateField = (idx, field, val) => { setDirty(true); setAwards(prev => prev.map((a, i) => i === idx ? { ...a, [field]: val } : a)); }; const save = async () => { - const admin = await window.promptAdminPassword(); - if (admin === null) return; setSaving(true); try { // Strip the client-only `_key` before sending to the API. const payload = awards.map(({ _key, ...rest }) => rest); - await window.API.updateCompetitionAwards(c.id, payload, password, admin); + await window.API.updateCompetitionAwards(c.id, payload, password); // Edits are now persisted; clear dirty so the next prop refresh // (the save triggers an SSE refetch) re-syncs to the saved state. if (mountedRef.current) { setDirty(false); showToast("Fighting Spirit awards saved.", "success"); } diff --git a/web-mobile/js/admin_competition_settings.jsx b/web-mobile/js/admin_competition_settings.jsx index cb1fc1925..4ada1658d 100644 --- a/web-mobile/js/admin_competition_settings.jsx +++ b/web-mobile/js/admin_competition_settings.jsx @@ -48,7 +48,7 @@ import { MIN_POOL_SIZE, MIN_POOL_WINNERS, MIN_SWISS_ROUNDS, HINT_ZEKKEN, HINT_ENGI, HINT_KIND_ONLY_INDIVIDUAL, HINT_TEAM_SIZE, HINT_POOL_WINNERS_LOCKED, LABEL_NAGINATA, HINT_NAGINATA, - LABEL_CHECK_IN, HINT_CHECK_IN, + LABEL_CHECK_IN, HINT_CHECK_IN, tournamentHasCheckIn, checkInApplies, LABEL_NUMBER_PREFIX, cutNumberPrefix, numberPrefixHint, LABEL_COURTS, } from './competition_shape.jsx'; import { PillGroup, PillButton, CheckboxField, NumberField, TextField } from './competition_fields.jsx'; @@ -95,7 +95,6 @@ function formatClearedValue(key, value) { function AdminSettings({ c, tournament, onUpdate, onBack, password, showToast, onStatusChange }) { const [lastSaved, setLastSaved] = useStateA(null); const [saveErr, setSaveErr] = useStateA(null); - const [deleting, setDeleting] = useStateA(false); const [invalidating, setInvalidating] = useStateA(false); // twoThirdPlaces (bc-3rdp) is seeded through effectiveTwoThirdPlaces, not a // bare `c.twoThirdPlaces` copy: a competition saved before this field @@ -130,9 +129,9 @@ function AdminSettings({ c, tournament, onUpdate, onBack, password, showToast, o // roster and runs the full formation scan, and AdminSettings re-renders on // every keystroke of any settings field. const qualifierPreview = useMemoA(() => { - const drawPlayers = effectiveDrawPlayers(c.players, local.checkInEnabled); + const drawPlayers = effectiveDrawPlayers(c.players, checkInApplies(tournament, local.checkInEnabled)); return computeQualifierPreview(drawPlayers.length, local.poolSize, local.poolWinners, seededRanks(drawPlayers)); - }, [c.players, local.checkInEnabled, local.poolSize, local.poolWinners]); + }, [c.players, tournament, local.checkInEnabled, local.poolSize, local.poolWinners]); // Schedule estimate (mp-zoh Phase 4): fetch per-competition estimate and // display it inline near the duration inputs. Re-fetches whenever the @@ -1535,7 +1534,7 @@ function AdminSettings({ c, tournament, onUpdate, onBack, password, showToast, o twoThirdPlacesForNaginata picks a sane starting point; the operator can still re-tick "Award two joint 3rd places" after. */} { update("naginata", on); update("twoThirdPlaces", twoThirdPlacesForNaginata(on)); }} hint={`${HINT_NAGINATA}${lockedNote}`} /> - update("checkInEnabled", on)} hint={HINT_CHECK_IN} /> + {tournamentHasCheckIn(tournament) && update("checkInEnabled", on)} hint={HINT_CHECK_IN} />}
{/* Repeat Save at the foot of the long settings form so the operator doesn't have to scroll back to the header after editing. Same @@ -1556,13 +1555,11 @@ function AdminSettings({ c, tournament, onUpdate, onBack, password, showToast, o
{(local.status === "pools" || local.status === "knockout") && (
-
)} -
Deleting a started competition will remove all matches and results.
diff --git a/web-mobile/js/admin_helpers.jsx b/web-mobile/js/admin_helpers.jsx index d1b06a3ba..5cb751e3c 100644 --- a/web-mobile/js/admin_helpers.jsx +++ b/web-mobile/js/admin_helpers.jsx @@ -1024,9 +1024,6 @@ if (typeof window !== "undefined") { window.MAX_RANK = MAX_RANK; window.MAX_TOURNAMENT_DURATION_DAYS = MAX_TOURNAMENT_DURATION_DAYS; window.deriveTournamentDays = deriveTournamentDays; - window.setCachedAuthConfig = setCachedAuthConfig; - window.getCachedAuthConfig = getCachedAuthConfig; - window.promptAdminPassword = promptAdminPassword; window.normalizeCourts = normalizeCourts; window.courtCount = courtCount; window.shiaijoCountHint = shiaijoCountHint; @@ -1044,73 +1041,7 @@ if (typeof window !== "undefined") { window.partitionStartableCompetitions = partitionStartableCompetitions; } -// --- Elevated (destructive-ops) password prompt (spec 004 / mp-e21) --- -// -// authConfig is held in app.jsx state and threaded to only a few components. -// Rather than prop-drill it to every destructive call site, app.jsx pushes -// the latest value here whenever it resolves /api/auth-config, and the -// destructive handlers read it via promptAdminPassword(). Single writer -// (app.jsx), many readers: no React context needed for an imperative prompt. -// -// IMPORTANT: the cache lives on `window`, NOT a module-level variable. -// esbuild bundles this file into BOTH the app and admin entry chunks, so a -// module-scoped `let` would be two independent instances: app.jsx's writes -// would never be visible to the admin components' reads. A single window slot -// is shared across both bundles. Guarded for non-browser (vitest) use. -const _authConfigSlot = () => (typeof window !== "undefined" ? window : globalThis); - -// setCachedAuthConfig is called by app.jsx after every fetchAuthConfig(). -function setCachedAuthConfig(cfg) { - _authConfigSlot().__bcAuthConfig = cfg || null; -} - -function getCachedAuthConfig() { - return _authConfigSlot().__bcAuthConfig || null; -} - -// promptAdminPassword returns the elevated password to send with a -// destructive action, following the "re-prompt every time" model: -// -// - gate inactive (file mode, no admin pw set) → returns "" so the caller -// proceeds; the server ignores the (omitted) X-Admin-Password header. -// - gate active but not configured (locked mode, env var unset) → alerts -// the operator and returns null so the caller ABORTS (the server would -// 503 anyway). -// - gate active and configured → window.prompt for the password. Returns -// the typed value, or null if the operator cancels or submits empty -// (the caller aborts). -// -// Caller contract (now async): `const a = await promptAdminPassword(); if (a === null) return;` -// then pass `a` as the trailing adminPassword arg to the API method. A wrong -// password surfaces as the API's 401 error (caught/toasted by the caller); -// the operator simply retries the action, which re-prompts. -// -// Returns a Promise so the password is collected via the app's themed, -// accessible promptDialog (masked input) instead of window.prompt, which -// renders the password in plaintext in some browsers and can't be styled. -async function promptAdminPassword(message) { - const cfg = getCachedAuthConfig(); - if (!cfg || !cfg.elevatedRequired) return ""; - if (cfg.elevatedConfigured === false) { - window.alert( - "This action requires an admin password, but none is configured on this server. " + - "In locked mode set TOURNAMENT_ADMIN_PASSWORD_HASH; in file mode set one in Settings." - ); - return null; - } - const pw = await window.promptDialog({ - title: "Admin password required", - message: message || "This action requires the admin (destructive-ops) password:", - password: true, - confirmLabel: "Confirm", - }); - return pw ? pw : null; -} - export { - setCachedAuthConfig, - getCachedAuthConfig, - promptAdminPassword, sideName, hasBothSides, hasPoolOriginPlaceholder, diff --git a/web-mobile/js/admin_participants.jsx b/web-mobile/js/admin_participants.jsx index fdfbdaf10..d67a2c499 100644 --- a/web-mobile/js/admin_participants.jsx +++ b/web-mobile/js/admin_participants.jsx @@ -579,8 +579,6 @@ function AdminParticipants({ c, tournament: _tournament, onUpdate, password, sho // in the name field ("Name 1 - Name 2"), see participantFormError. const formErr = participantFormError({ name, dojo, engi: c.engi }); if (formErr) { showToast(formErr, "error"); return; } - const admin = await window.promptAdminPassword(); - if (admin === null) return; setAddLoading(true); try { // displayName is only forwarded for zekken-enabled competitions; for @@ -589,7 +587,7 @@ function AdminParticipants({ c, tournament: _tournament, onUpdate, password, sho // poison the slot with a stale value (see TestReplaceDoesNotInherit…). const payload = { name, dojo, danGrade: danGrade || undefined }; if (withZekken && zekken) payload.displayName = zekken; - await window.API.addParticipant(c.id, payload, password, admin); + await window.API.addParticipant(c.id, payload, password); if (!mountedRef.current) return; setAddName(""); setAddDojo(""); setAddDanGrade(""); setAddZekken(""); setShowAddForm(false); showToast(`${name} added`); @@ -614,8 +612,6 @@ function AdminParticipants({ c, tournament: _tournament, onUpdate, password, sho const oldName = replaceTarget.name; const targetPid = window.checkinApiPid(replaceTarget); const targetSource = replaceTarget.source || ""; - const admin = await window.promptAdminPassword(); - if (admin === null) return; setReplaceLoading(true); try { // Build metadata from danGrade so the edited grade actually persists: @@ -631,7 +627,7 @@ function AdminParticipants({ c, tournament: _tournament, onUpdate, password, sho const metadata = window.buildPlayerMetadata(danGrade, replaceTarget.metadata); const payload = { name, dojo, displayName: withZekken ? zekken : "", source: targetSource }; if (metadata !== undefined) payload.metadata = metadata; - const updated = await window.API.replaceParticipant(c.id, targetPid, payload, password, admin); + const updated = await window.API.replaceParticipant(c.id, targetPid, payload, password); if (!mountedRef.current) return; setReplaceTarget(null); showToast(oldName === updated.name ? `Saved changes for ${updated.name}` : `Renamed ${oldName} → ${updated.name}`); @@ -762,10 +758,9 @@ function AdminParticipants({ c, tournament: _tournament, onUpdate, password, sho // this component's local state. if (!mountedRef.current) return; - // updateCompetition returns undefined when it short-circuited without - // saving (e.g. the elevated-password prompt was cancelled). Don't show - // a "Saved" toast for a PUT that never happened. A real save returns the - // warnings array (possibly empty). + // A real save returns the warnings array (possibly empty), as + // AdminApp's updateCompetition always does. An onUpdate that returns + // nothing reported no save, so don't show a "Saved" toast for it. if (warnings === undefined) return; let msg = `Saved ${pluralize(np.length, kindLabel)}`; diff --git a/web-mobile/js/admin_registration_desk.jsx b/web-mobile/js/admin_registration_desk.jsx index e2814bf8f..301fc78d0 100644 --- a/web-mobile/js/admin_registration_desk.jsx +++ b/web-mobile/js/admin_registration_desk.jsx @@ -20,8 +20,12 @@ // bc-prow: check-in exists only under that setting and this desk is its // only UI), so a competition with the setting off never appears in the // rail, the all-competitions roster, or an "also in" chip. -// - walk-up add (POST single) needs the elevated password and only works -// while a competition is in "setup" status. +// - walk-up add (POST single) needs the tournament password in officiated +// mode (none in self-run, where anyone can edit the roster), and only +// works while a competition is in "setup" status. +// - a self-run tournament has no check-in (operator ruling 2026-10-13): the +// server refuses every check-in write there with 409, the dashboard offers +// no card for this page, and the page itself shows one line saying so. // // SSE: when this view is mounted, AdminApp renders ONLY this page: neither the // dashboard's nor the competition-view's SSE subscription is active. So this @@ -31,6 +35,7 @@ import { checkinPid, checkinApiPid } from './data.jsx'; import { NO_ID_HINT, NoIdHint } from './data_integrity.jsx'; +import { checkInApplies, tournamentHasCheckIn, SELF_RUN_NO_CHECK_IN } from './competition_shape.jsx'; const { useState: useStateRD, useEffect: useEffectRD, useRef: useRefRD, useMemo: useMemoRD, useCallback: useCallbackRD } = React; @@ -150,8 +155,8 @@ function rdPlayerTag(comp, p) { // the page the count it needs to tell "no competitions at all" from "none with // check-in tracking on", which are different empty states with different // remedies. -function rdCheckInComps(list) { - return (list || []).filter((c) => c && c.checkInEnabled); +function rdCheckInComps(list, tournament) { + return (list || []).filter((c) => c && checkInApplies(tournament, c.checkInEnabled)); } // Build the cross-competition people index from the competitions array. @@ -457,14 +462,12 @@ function RdWalkUp({ comp, seedName, password, showToast, onAdded, onCancel }) { e.preventDefault(); const n = name.trim(), d = dojo.trim(); if (!n || !d) { showToast(isTeam ? "Team name and dojo are required" : "Name and dojo are required", "error"); return; } - const admin = await window.promptAdminPassword(); - if (admin === null) return; setBusy(true); try { const payload = { name: n, dojo: d }; if (!isTeam && dan.trim()) payload.danGrade = dan.trim(); if (!isTeam && comp.withZekkenName && zekken.trim()) payload.displayName = zekken.trim(); - const added = await window.API.addParticipant(comp.id, payload, password, admin); + const added = await window.API.addParticipant(comp.id, payload, password); // Check them in straight away: they're standing at the desk. let checkInOk = false; try { @@ -521,7 +524,7 @@ function RdWalkUp({ comp, seedName, password, showToast, onAdded, onCancel }) { // Inline edit: fix a wrong name / dojo / zekken on an existing entry without // leaving the desk. Operates on one (competition, participant); editing here // changes only that competition's record (the per-competition data model). Uses -// the same elevated-password gate and replace endpoint as the Participants tab. +// the same replace endpoint as the Participants tab. // --------------------------------------------------------------------------- function RdEditModal({ comp, player, password, showToast, onSaved, onClose }) { const isTeam = comp.kind === "team"; @@ -535,14 +538,12 @@ function RdEditModal({ comp, player, password, showToast, onSaved, onClose }) { const save = async () => { const n = name.trim(), d = dojo.trim(); if (!n || !d) { showToast(isTeam ? "Team name and dojo are required" : "Name and dojo are required", "error"); return; } - const admin = await window.promptAdminPassword(); - if (admin === null) return; setBusy(true); try { const metadata = window.buildPlayerMetadata(isTeam ? "" : dan.trim(), player.metadata); const payload = { name: n, dojo: d, displayName: !isTeam && comp.withZekkenName ? zekken.trim() : "", source: player.source || "" }; if (metadata !== undefined) payload.metadata = metadata; - const updated = await window.API.replaceParticipant(comp.id, rdApiPid(player), payload, password, admin); + const updated = await window.API.replaceParticipant(comp.id, rdApiPid(player), payload, password); onSaved(comp, updated); } catch (err) { showToast(err.message, "error"); @@ -614,7 +615,7 @@ function AdminRegistrationDeskPage({ tournament, onBack, password, showToast, on // two empty states apart (no competitions yet vs none with check-in tracking // on). const [allComps, setAllComps] = useStateRD(() => tournament.competitions || []); - const comps = useMemoRD(() => rdCheckInComps(allComps), [allComps]); + const comps = useMemoRD(() => rdCheckInComps(allComps, tournament), [allComps, tournament]); const tRef = useRefRD(tournament); useEffectRD(() => { tRef.current = tournament; }, [tournament]); // Number of check-in writes in flight. While > 0 the SSE-driven refresh @@ -932,6 +933,10 @@ function AdminRegistrationDeskPage({ tournament, onBack, password, showToast, on // competitions all have check-in tracking off needs the setting turned on. const noCompsAtAll = allComps.length === 0; const noComps = comps.length === 0; + // A self-run tournament has no check-in (operator ruling 2026-10-13): the + // desk, which is check-in only, says so instead of gathering any roster. The + // dashboard offers no card to reach it; this covers a view opened another way. + const noCheckIn = !tournamentHasCheckIn(tournament); return (
@@ -941,11 +946,16 @@ function AdminRegistrationDeskPage({ tournament, onBack, password, showToast, on

Registration desk

-
Check competitors in as they arrive, across every competition with check-in tracking on.
+ {!noCheckIn &&
Check competitors in as they arrive, across every competition with check-in tracking on.
}
- {noComps ? ( + {noCheckIn ? ( +
+ +

{SELF_RUN_NO_CHECK_IN}

+
+ ) : noComps ? (

{noCompsAtAll ? "No competitions yet" : "No competition has check-in tracking on"}

diff --git a/web-mobile/js/admin_scoring_autosave.jsx b/web-mobile/js/admin_scoring_autosave.jsx index 5390386fa..e43929afd 100644 --- a/web-mobile/js/admin_scoring_autosave.jsx +++ b/web-mobile/js/admin_scoring_autosave.jsx @@ -4,7 +4,7 @@ import { toBackendMatchResult, matchWire } from './api_serializers.jsx'; import { changedGroups, statedGroupsOf, groupMatches, keptInHistoryNote, GROUP_RESULT } from './match_groups.jsx'; -import { writeDidNotLand } from './write_result.jsx'; +import { writeDidNotLand, writeWasRefused } from './write_result.jsx'; const { useState: useStateA, useEffect: useEffectA, useRef: useRefA } = React; @@ -136,6 +136,26 @@ export function useKeptInHistoryNote() { return { note, noteFromWrite: noteFromWriteRef.current }; } +// landedAutosave: an editor's autosave submit, wrapped so `onLanded(seq)` runs when a save +// LANDED (not queued, not refused), with `seq` the value of the editor's edit counter +// (`seqRef`) when the save was handed over. The patch is built in the same tick, so a save +// carries every edit counted up to `seq` and none after it: the edit stamps the editor +// keeps to hold its board against an older server copy (bc-kclr, bc-ltap) clear only for +// edits at or before it, never for a tap made while the save was out. The individual +// editor's board stamp and the team sheet's row and pick stamps are its two callers. +export function landedAutosave(submit, seqRef, onLanded) { + return (patch) => { + const seq = seqRef.current; + const p = submit(patch); + if (p && typeof p.then === "function") { + p.then((res) => { + if (!writeWasRefused(res) && !writeDidNotLand(res)) onLanded(seq); + }, () => {}); + } + return p; + }; +} + export function KeptInHistoryNote({ note }) { if (!note) return null; return
{note}
; diff --git a/web-mobile/js/admin_scoring_individual.jsx b/web-mobile/js/admin_scoring_individual.jsx index 6473d3ad9..c79352c25 100644 --- a/web-mobile/js/admin_scoring_individual.jsx +++ b/web-mobile/js/admin_scoring_individual.jsx @@ -57,7 +57,7 @@ import { // one owner of that question. import { isBarredMatch } from './ineligible_match.jsx'; -import { SyncStatusPill, useDebouncedRunningWrite, useChangedGroups, useKeptInHistoryNote, KeptInHistoryNote } from './admin_scoring_autosave.jsx'; +import { SyncStatusPill, useDebouncedRunningWrite, useChangedGroups, useKeptInHistoryNote, KeptInHistoryNote, landedAutosave } from './admin_scoring_autosave.jsx'; import { MatchHistoryDisclosure } from './match_history_view.jsx'; import { GROUP_POINTS, GROUP_RESULT, GROUP_ENCHO, GROUP_REP } from './match_groups.jsx'; @@ -323,11 +323,24 @@ export function ScoreEditorModal({ match, onClose, onSubmit, onSubmitAndNext, on // a mark off and tapping it back while the removal is still saving returns the // board to the value the server still shows, which reads as untouched, and the // removal's echo then wiped the tap (bc-ltap). + // The stamp stands only while an edit is unsent: the write's own stamp is + // worked out at send, less the edit's age (api_client.jsx), so its echo can + // read a millisecond before the tap, and taps that net to nothing change + // nothing on the server at all. Once an autosave built after the latest + // board edit has landed, the board holds nothing the server lacks, so the + // stamp is cleared (landedBoardSave) and every later server copy is followed. const boardEditedAtRef = useRefA(undefined); + const boardEditSeqRef = useRefA(0); const markScoringDirty = () => { boardEditedAtRef.current = serverNowMs(); + boardEditSeqRef.current += 1; markRunningWrite(); }; + // The autosave's submit: the host's onSubmit, and on a landed answer the + // board stamp cleared when no board edit was made since this save was built. + const landedBoardSave = (submit) => landedAutosave(submit, boardEditSeqRef, (seq) => { + if (boardEditSeqRef.current === seq) boardEditedAtRef.current = undefined; + }); useEffectA(() => { if (!m.compId) return; @@ -555,7 +568,7 @@ export function ScoreEditorModal({ match, onClose, onSubmit, onSubmitAndNext, on _autosaveIsRunningRef.current = m.status === "running"; _autosaveSeenStampRef.current = m.modifiedAt || 0; _autosaveBuildPatchRef.current = buildPatch; - _autosaveOnSubmitRef.current = onSubmit; + _autosaveOnSubmitRef.current = landedBoardSave(onSubmit); const doSubmit = async (fn) => { cancelScoringDebounce(); // C1: cancel any pending autosave before explicit submit diff --git a/web-mobile/js/admin_scoring_team.jsx b/web-mobile/js/admin_scoring_team.jsx index fac401c7a..c117b5d64 100644 --- a/web-mobile/js/admin_scoring_team.jsx +++ b/web-mobile/js/admin_scoring_team.jsx @@ -49,7 +49,7 @@ import { import { isBarredMatch } from './ineligible_match.jsx'; import { isOlderRunningCopy } from './patch.jsx'; -import { useDebouncedRunningWrite, SyncStatusPill, useChangedGroups, useKeptInHistoryNote, KeptInHistoryNote } from './admin_scoring_autosave.jsx'; +import { useDebouncedRunningWrite, SyncStatusPill, useChangedGroups, useKeptInHistoryNote, KeptInHistoryNote, landedAutosave } from './admin_scoring_autosave.jsx'; import { MatchHistoryDisclosure } from './match_history_view.jsx'; import { GROUP_ENCHO, GROUP_RESULT, GROUP_REP_PICK_A, GROUP_REP_PICK_B, boutGroup } from './match_groups.jsx'; import { serverNowMs } from './server_clock.jsx'; @@ -1771,6 +1771,7 @@ export function TeamScoreEditorModal({ match, teamSize, onClose, onSubmit, onSub // pick-part edit stamp goes with it: whatever was edited was about the old team, and // the adopt takes the server's value for the side again. lastRepPickEditRef.current[side] = undefined; + repPickEditSeqRef.current[side] = undefined; if (daihyosenIdx >= 0 && subs[daihyosenIdx]?.[`${side}MemberIdOverride`]) { const memberKey = `${side}MemberIdOverride`; setSubs(prev => reconcileRowsToPositions(prev, serverSubs).map((s, i) => i === daihyosenIdx ? { ...s, [memberKey]: "" } : s)); @@ -2332,6 +2333,29 @@ export function TeamScoreEditorModal({ match, teamSize, onClose, onSubmit, onSub // side's pick alone, so it stamps here and not on the row (updateSub's repPickSide). // The row's own entry in lastRowEditRef is then the score part's. const lastRepPickEditRef = useRefA({}); + // The stamps above stand only while an edit is unsent: a point struck and taken back + // inside the debounce changes nothing on the server, so no write names the bout and the + // match's stamp may never pass the tap (the write is stamped `now - age`); a copy another + // device then writes stamped below it would be held off forever. Each stamp therefore + // carries the edit counter's value when it was made (editSeqRef; rowEditSeqRef per + // position, repPickEditSeqRef per side), and a save that LANDED clears the stamps of the + // edits it was built after (landedAutosave, clearLandedEditStamps), never a later tap's. + const editSeqRef = useRefA(0); + const rowEditSeqRef = useRefA(new Map()); + const repPickEditSeqRef = useRefA({}); + const clearLandedEditStamps = (seq) => { + for (const [pos, at] of rowEditSeqRef.current) { + if (at > seq) continue; + rowEditSeqRef.current.delete(pos); + lastRowEditRef.current.delete(pos); + } + for (const side of ["a", "b"]) { + const at = repPickEditSeqRef.current[side]; + if (at === undefined || at > seq) continue; + repPickEditSeqRef.current[side] = undefined; + lastRepPickEditRef.current[side] = undefined; + } + }; // C1: updateSub is the single choke-point for all sub-bout state // mutations. Calling markScoringDirty() here captures every edit // (pts add/remove, fouls, fusensho, draw) without repetition. @@ -2349,8 +2373,14 @@ export function TeamScoreEditorModal({ match, teamSize, onClose, onSubmit, onSub // representative row: it stamps the pick part, and every other edit of the row the score // part, because the adopt follows the server for each part on its own. const updateSub = (idx, fn, opts) => { - if (idx === daihyosenIdx && opts?.repPickSide) lastRepPickEditRef.current[opts.repPickSide] = serverNowMs(); - else lastRowEditRef.current.set(subs[idx]._pos, serverNowMs()); + editSeqRef.current += 1; + if (idx === daihyosenIdx && opts?.repPickSide) { + lastRepPickEditRef.current[opts.repPickSide] = serverNowMs(); + repPickEditSeqRef.current[opts.repPickSide] = editSeqRef.current; + } else { + lastRowEditRef.current.set(subs[idx]._pos, serverNowMs()); + rowEditSeqRef.current.set(subs[idx]._pos, editSeqRef.current); + } setSubsByOperator(prev => { const rows = reconcileRowsToPositions(prev, serverSubs); return rows.map((s, i) => i === idx ? fn(s) : s); @@ -3721,7 +3751,7 @@ export function TeamScoreEditorModal({ match, teamSize, onClose, onSubmit, onSub // case where a prior [Remove this bout] left one to adopt into. _autosaveSeenStampRef.current = Math.max(m.modifiedAt || 0, _recordBoutAnswerStampRef.current); _autosaveBuildPatchRef.current = buildPatch; - _autosaveOnSubmitRef.current = onSubmit; + _autosaveOnSubmitRef.current = landedAutosave(onSubmit, editSeqRef, clearLandedEditStamps); const doSubmit = async (fn) => { cancelScoringDebounce(); // C1: cancel pending autosave before explicit submit diff --git a/web-mobile/js/admin_setup.jsx b/web-mobile/js/admin_setup.jsx index 7d62a2082..aca8a6c8d 100644 --- a/web-mobile/js/admin_setup.jsx +++ b/web-mobile/js/admin_setup.jsx @@ -145,7 +145,7 @@ import { poolFormatHint, resolvePoolFormat, leagueTiebreakActive, HINT_ZEKKEN, HINT_ENGI, HINT_TEAM_SIZE, HINT_POOL_WINNERS_LOCKED, LABEL_NAGINATA, HINT_NAGINATA, - LABEL_CHECK_IN, HINT_CHECK_IN, + LABEL_CHECK_IN, HINT_CHECK_IN, tournamentHasCheckIn, SELF_RUN_NO_CHECK_IN, LABEL_NUMBER_PREFIX, HINT_NUMBER_PREFIX, cutNumberPrefix, LABEL_COURTS, } from './competition_shape.jsx'; import { PillGroup, PillButton, CheckboxField, NumberField, TextField } from './competition_fields.jsx'; @@ -255,35 +255,6 @@ function AdminEditTournament({ tournament, onCancel, onSave, onLogout, onViewerM publicURL, venueAddress, venueMapURL, openingTime, closingTime, websiteURL, awardsNote, infoNotes, pass, contacts, theme]); - // Elevated (destructive-ops) password: spec 004 / mp-e21. File mode only; - // in locked mode it's the TOURNAMENT_ADMIN_PASSWORD_HASH env var (read-only - // here). `elevatedConfigured` decides whether the current-password field is - // shown (rotation) vs. a first-time set (TOFU). - const elevatedConfigured = authConfig && authConfig.elevatedConfigured === true; - const [adminNew, setAdminNew] = useStateA(""); - const [adminCurrent, setAdminCurrent] = useStateA(""); - const [adminSaving, setAdminSaving] = useStateA(false); - - const handleSetAdminPassword = async () => { - if (!adminNew) { if (showToast) showToast("Enter a new admin password", "error"); return; } - setAdminSaving(true); - try { - await window.API.setAdminPassword(adminNew, adminCurrent, password); - setAdminNew(""); setAdminCurrent(""); - // Refresh auth-config so elevatedConfigured/elevatedRequired reflect the - // new state (the gate is now active) for subsequent destructive actions. - try { - const cfg = await window.API.fetchAuthConfig(); - if (cfg && typeof cfg === "object") window.setCachedAuthConfig(cfg); - } catch (_e) { /* non-fatal */ } - if (showToast) showToast("Admin password updated", "success"); - } catch (e) { - if (showToast) showToast(e.message, "error"); - } finally { - setAdminSaving(false); - } - }; - // mp-sspn: set a field-scoped error, then focus + scroll its input so the // message is visible at its cause. Returns false so callers can `return // failField(...)` in one line. The public-info fields aren't validated @@ -491,14 +462,14 @@ function AdminEditTournament({ tournament, onCancel, onSave, onLogout, onViewerM
{/* Tournament type (mp-7h7): read-only after creation. Displayed for information: it affects the auth boundary (officiated: - main password gates everything; self-run: only destructive - actions require a password). Never submitted on PUT. */} + main password gates everything; self-run: play is public and + organiser actions need the password). Never submitted on PUT. */}
{tournamentMode === "self-run" ? "Self-run" : "Officiated"} {tournamentMode === "self-run" - ? ": Scoring, check-in and other constructive actions are public. Only destructive actions (delete, import, roster changes) require the destructive-ops password." + ? `: Scoring and starting and completing a competition are public. Anyone can add and edit participants. ${SELF_RUN_NO_CHECK_IN} Organiser actions such as setup, deleting and importing still need the tournament password.` : ": All admin actions require the tournament password."} {" "}This setting was fixed at creation and cannot be changed.
@@ -640,46 +611,19 @@ function AdminEditTournament({ tournament, onCancel, onSave, onLogout, onViewerM
Access & security
{locked ? ( -
+
This server is in locked mode. The admin password comes from TOURNAMENT_PASSWORD_HASH and can only be rotated by restarting the server with a new hash.
) : ( -
+
{ setPass(e.target.value); setError(""); }} placeholder="••••••••" autoComplete="new-password" />
Enter a new password to change it. Leave blank to keep the current one.
)} - {/* Elevated (destructive-ops) password: spec 004 / mp-e21. */} - {locked ? ( -
- -
- This server is in locked mode. The destructive-ops password comes from TOURNAMENT_ADMIN_PASSWORD_HASH and can only be changed by restarting the server with a new hash. If unset, destructive actions (delete competition, discard draw, roster changes, import) return 503. -
-
- ) : ( -
- - {/* The label names the new password; the current one, shown - only for a rotation, is named by its own words. */} - {elevatedConfigured && ( - setAdminCurrent(e.target.value)} placeholder="Current destructive-ops password" autoComplete="off" style={{ marginBottom: 8 }} /> - )} -
- setAdminNew(e.target.value)} placeholder={elevatedConfigured ? "New destructive-ops password" : "Set destructive-ops password"} autoComplete="new-password" /> - -
-
- A separate password required for destructive actions (delete competition, discard draw, roster add/edit, import). Leave unset to gate those behind the main password only. {elevatedConfigured && "Changing it requires the current destructive-ops password."} -
-
- )}
{/* mp-scf: branding (colors + logo). Appears before sponsors so the brand-identity section leads the page. onThemeChange propagates @@ -709,14 +653,13 @@ function AdminEditTournament({ tournament, onCancel, onSave, onLogout, onViewerM )} {/* Action bar is the page's final commit, sitting below every card it (partly) persists. Branding colors + main password ride along on - this Save; logo, sponsors and the destructive-ops password - self-save via their own buttons. */} + this Save; logo and sponsors self-save via their own buttons. */}
{/* mp-sspn: make the split persistence model explicit so the single */} {/* Save button's scope isn't a guess. */}
Saves the tournament details, schedule blocks, public information, branding colors, and admin password. - The logo, sponsors, and destructive-ops password save on their own buttons. + The logo and sponsors save on their own buttons.
{dirty && Unsaved changes} @@ -1470,7 +1413,7 @@ function AdminCreateCompetition({ tournament, onCancel, onCreate, onLogout, onVi { setNaginata(on); setTwoThirdPlaces(twoThirdPlacesForNaginata(on)); }} hint={HINT_NAGINATA} /> - + {tournamentHasCheckIn(tournament) && }
@@ -1577,8 +1520,6 @@ function AdminImportPage({ tournament, onBack, onImported, onLogout, onViewerMod const doImport = async () => { if (!files.length) return; if (!(await window.confirmDialog({ message: "Are you sure you want to import these competitions? This will add new competitions to the tournament.", confirmLabel: "Import competitions" }))) return; - const admin = await window.promptAdminPassword(); - if (admin === null) return; setLoading(true); setError(null); try { @@ -1586,7 +1527,7 @@ function AdminImportPage({ tournament, onBack, onImported, onLogout, onViewerMod files.forEach(f => fd.append("files", f, f.webkitRelativePath || f.name)); // Use the centralized API wrapper (api.jsx) so auth + error handling // stay consistent with the rest of the admin UI. - const body = await window.API.importCompetitions(fd, password, admin); + const body = await window.API.importCompetitions(fd, password); // mountedRef gates the post-await setStates so a navigate-back // during the upload doesn't fire setResults / setTimeout on a // torn-down component. importedTimerRef has its own unmount diff --git a/web-mobile/js/admin_shell.jsx b/web-mobile/js/admin_shell.jsx index 947836228..45e26a622 100644 --- a/web-mobile/js/admin_shell.jsx +++ b/web-mobile/js/admin_shell.jsx @@ -8,6 +8,7 @@ import { heldWritesText, heldWriteLine, heldWriteDiscardConfirm, matchLabel, heldLineupLabel, HELD_WRITES_TITLE, HELD_WRITES_EMPTY, HELD_WRITE_DISCARD_ONE_LABEL } from './write_result.jsx'; import { scoreRowMatchLabel } from './pool_ids.jsx'; import { publishHeight } from './published_height.jsx'; +import { tournamentHasCheckIn } from './competition_shape.jsx'; const { useState: useStateA, useMemo: useMemoA, useEffect: useEffectA, useRef: useRefA, useLayoutEffect: useLayoutEffectA } = React; @@ -647,7 +648,7 @@ function AdminDashboard({ tournament, password, onOpenCompetition, onCreateCompe
- {onOpenRegistration && ( + {onOpenRegistration && tournamentHasCheckIn(t) && (
@@ -2014,34 +1974,14 @@ function CreateTournament({ onCreated, authConfig }) { />
{locked && isSelfRun - ? "This server is in locked mode. Enter the password whose bcrypt hash is in TOURNAMENT_PASSWORD_HASH: it authorises this bootstrap and organiser-setup mutations. Destructive actions (delete competition, discard draw, import) require the separate TOURNAMENT_ADMIN_PASSWORD_HASH credential." + ? "This server is in locked mode. Enter the password whose bcrypt hash is in TOURNAMENT_PASSWORD_HASH: it authorises this bootstrap and every organiser action afterwards." : locked ? "This server is running in locked mode. Enter the password whose bcrypt hash is in TOURNAMENT_PASSWORD_HASH: it's used both to authorize this bootstrap and as your admin credential afterwards." : isSelfRun - ? "Used to authorise tournament setup. In self-run mode, scoring and check-in are public so participants don't need this." + ? "Used to authorise tournament setup. In self-run mode, scoring is public so participants don't need this." : "This password will be required to manage the tournament."}
- {/* In self-run + file mode, require a destructive-ops password at - creation so delete/invalidate/import can't be called anonymously. - Locked mode uses TOURNAMENT_ADMIN_PASSWORD_HASH (no UI input). */} - {isSelfRun && !locked && ( -
- - setAdminPass(e.target.value)} - placeholder="Password to protect delete / import actions" - required - /> -
- A separate password required for destructive actions (delete competition, discard draw, roster add/edit, import). Since this tournament is self-run, scoring is public: this password is the only thing protecting irreversible actions. -
-
- )} {error && (
mounted once at the app // root. They replace native window.confirm/window.prompt across the admin -// SPA so destructive/elevated prompts use the app's themed, accessible modal +// SPA so destructive confirms and prompts use the app's themed, accessible modal // (Escape to cancel, focus management, consistent styling) instead of browser // chrome that can't be styled, labelled, or (for prompt) mask a password. // @@ -300,7 +300,7 @@ function confirmDialog(opts = {}) { function promptDialog(opts = {}) { const o = typeof opts === "string" ? { message: opts } : (opts || {}); - return new Promise((resolve) => { + return new Promise((resolve, reject) => { _setDialogReq({ kind: "prompt", title: o.title || "Enter a value", @@ -308,9 +308,16 @@ function promptDialog(opts = {}) { defaultValue: o.defaultValue != null ? String(o.defaultValue) : "", placeholder: o.placeholder || "", password: !!o.password, + danger: !!o.danger, + // submit(value) judges the typed value before the dialog closes. It + // resolves a string to keep the dialog open with that sentence shown (the + // operator types again), or nothing to accept the value; a rejection + // closes the dialog and rejects the promptDialog() promise with it. + submit: typeof o.submit === "function" ? o.submit : null, confirmLabel: o.confirmLabel || "OK", cancelLabel: o.cancelLabel || "Cancel", _resolve: resolve, + _reject: reject, }); }); } @@ -320,6 +327,10 @@ function promptDialog(opts = {}) { function DialogHost() { const [req, setReq] = React.useState(_dialogReq); const [value, setValue] = React.useState(""); + // A prompt with a `submit` judge: the sentence it refused with, and whether + // a judgement is out (the dialog neither closes nor submits twice meanwhile). + const [error, setError] = React.useState(""); + const [busy, setBusy] = React.useState(false); const inputRef = React.useRef(null); const triggerRef = React.useRef(null); const trapRef = React.useRef(null); @@ -327,7 +338,7 @@ function DialogHost() { const { openedRef, onClickCapture } = useOpenedTapGuard(); React.useEffect(() => { - const fn = (r) => { setReq(r); if (r && r.kind === "prompt") setValue(r.defaultValue || ""); }; + const fn = (r) => { setReq(r); setError(""); setBusy(false); if (r && r.kind === "prompt") setValue(r.defaultValue || ""); }; _dialogListeners.add(fn); // Pick up a request created before this host finished mounting. if (_dialogReq) fn(_dialogReq); @@ -387,18 +398,44 @@ function DialogHost() { } }, [openedRef]); - const close = (result) => { + const close = (result, failure) => { const r = req; setReq(null); + setBusy(false); if (_dialogReq && _dialogReq._resolve === (r && r._resolve)) _dialogReq = null; - if (r && r._resolve) r._resolve(result); + if (failure && r && r._reject) r._reject(failure); + else if (r && r._resolve) r._resolve(result); }; if (!req) return null; // req is guaranteed truthy past the guard, so no need to handle the null case. const cancelResult = req.kind === "confirm" ? false : null; - const onCancel = () => close(cancelResult); - const onConfirm = () => close(req.kind === "confirm" ? true : (value || null)); + // A judgement still out (a password being checked) is not cancelled: closing + // then would leave the operator unsure whether the action went through. + const onCancel = () => { if (!busy) close(cancelResult); }; + const onConfirm = async () => { + if (busy) return; + if (req.kind === "prompt" && req.submit) { + setBusy(true); + setError(""); + let refusal; + try { + refusal = await req.submit(value); + } catch (e) { + close(null, e); + return; + } + if (refusal) { + setBusy(false); + setError(String(refusal)); + if (inputRef.current) { inputRef.current.focus(); inputRef.current.select(); } + return; + } + close(value || null); + return; + } + close(req.kind === "confirm" ? true : (value || null)); + }; // Escape-to-cancel is handled ON the dialog container (not via the global // useEscapeToClose window listener). The dialog frequently opens on top of a @@ -434,14 +471,16 @@ function DialogHost() { // fallback): otherwise screen readers announce a bare text field. aria-label={req.message || req.title} autoComplete={req.password ? "current-password" : "off"} - onChange={(e) => setValue(e.target.value)} + aria-invalid={error ? "true" : undefined} + onChange={(e) => { setValue(e.target.value); if (error) setError(""); }} onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); onConfirm(); } }} /> )} + {error &&
{error}
}
- - + +
diff --git a/web-mobile/js/viewer_competition.jsx b/web-mobile/js/viewer_competition.jsx index fd556d5e3..074acbb67 100644 --- a/web-mobile/js/viewer_competition.jsx +++ b/web-mobile/js/viewer_competition.jsx @@ -81,7 +81,7 @@ export function ViewerCompetition({ tournament, competition, pools, poolMatches, // still feeds the "your next match" opponent-side logic. const [watchlist, setWatchlist] = useWatchlist(); const [primaryKey, setPrimaryKey] = usePrimaryWatch(); - const compRoster = useMemo(() => buildRoster([c]), [c]); + const compRoster = useMemo(() => buildRoster([c], tournament), [c, tournament]); const rosterById = useMemo(() => new Map(compRoster.map((p) => [p.id, p])), [compRoster]); // Restrict the filter bar chips to entries that are relevant to THIS competition: // player entries only shown when the player is in compRoster; dojo entries only diff --git a/web-mobile/js/viewer_home.jsx b/web-mobile/js/viewer_home.jsx index c26b47f36..fd8cb62b9 100644 --- a/web-mobile/js/viewer_home.jsx +++ b/web-mobile/js/viewer_home.jsx @@ -127,7 +127,7 @@ export function ViewerHome({ tournament, onSelectCompetition, onAdminClick, onOp // single "followed player" is migrated into the list once (see useWatchlist). const [watchlist, setWatchlist] = useWatchlist(); const [primaryKey, setPrimaryKey] = usePrimaryWatch(); - const roster = useMemo(() => buildRoster(t.competitions), [t.competitions]); + const roster = useMemo(() => buildRoster(t.competitions, t), [t.competitions, t]); // Derived beside the roster because it qualifies it: a non-empty roster does // not mean every competition's participants loaded, and only the panel's // "not in this tournament" claim depends on the difference. diff --git a/web-mobile/js/viewer_schedule.jsx b/web-mobile/js/viewer_schedule.jsx index 1adee8d2a..9f0590795 100644 --- a/web-mobile/js/viewer_schedule.jsx +++ b/web-mobile/js/viewer_schedule.jsx @@ -164,7 +164,7 @@ export function PlayerMultiFilter({ tournament, picked, setPicked, dojoText, set // the app already names for the operator (helper.MissingParticipantIDsMessage // tells them to save the roster once), and one bogus row standing in for // every id-less competitor was not a better answer than none. - const roster = useMemo(() => buildRoster(tournament.competitions), [tournament]); + const roster = useMemo(() => buildRoster(tournament.competitions, tournament), [tournament]); const q = query.trim().toLowerCase(); // Memoised on the two things it reads, which saves the roster scan on the @@ -408,7 +408,7 @@ export function ScheduleViewer({ tournament, tweaks }) { // fight the user's edits). const [watchlist] = useWatchlist(); const [primaryKey, setPrimaryKey] = usePrimaryWatch(); - const schedRoster = useMemo(() => buildRoster(tournament.competitions), [tournament]); + const schedRoster = useMemo(() => buildRoster(tournament.competitions, tournament), [tournament]); const initialPicked = useMemo(() => { return resolveWatchedPlayers(watchlist, schedRoster).map((p) => ({ id: p.id, name: p.name || "", dojo: p.dojo || "" })); }, [watchlist, schedRoster]); diff --git a/web-mobile/js/viewer_watchlist_core.jsx b/web-mobile/js/viewer_watchlist_core.jsx index b55ae1604..d02082582 100644 --- a/web-mobile/js/viewer_watchlist_core.jsx +++ b/web-mobile/js/viewer_watchlist_core.jsx @@ -21,6 +21,7 @@ import { competitorKey } from './competitor_identity.jsx'; import { resultRecencyDesc } from './result_recency.jsx'; import { parseWatchlistTokens, resolveFreshTokens, WATCHLIST_PARAM } from './watchlist_link.jsx'; import { isBarredMatch } from './ineligible_match.jsx'; +import { checkInApplies } from './competition_shape.jsx'; const { useState } = React; @@ -622,12 +623,14 @@ export function rosterFullyLoaded(competitions) { } // checkedIn=true wins if any check-in-enabled competition has the player checked in. -export function buildRoster(competitions) { +// `tournament` (optional) is asked whether check-in applies at all: a self-run +// tournament has none, so nobody in its roster is ever checked in. +export function buildRoster(competitions, tournament) { const map = new Map(); (competitions || []).forEach((c) => { (c.players || []).forEach((p) => { if (!p || !p.id) return; - const checkedIn = !!c.checkInEnabled && !!p.checkedIn; + const checkedIn = checkInApplies(tournament, c.checkInEnabled) && !!p.checkedIn; const existing = map.get(p.id); if (!existing) { // `comps` is the competition names this record covers, for the From ac60a875354ed3e4943b75e01f5475f2782bcf2b Mon Sep 17 00:00:00 2001 From: Ricardo Oliveira Date: Tue, 13 Oct 2026 09:42:48 +0100 Subject: [PATCH 23/30] fix(bc-cmpw): reset only from the server machine; round-3 review fixes Operator ruling 2026-10-13: in file mode POST /api/tournament/reset answers only a loopback peer (judged on the TCP peer address, never on forwarded headers); anyone else gets 403 reset_local_only, "Reset the password on the machine running the server.", which the /reset page shows. Locked mode still answers 404. Review fixes: a self-run participant's exact resend of a finish adds no history line; the file-mode password check is constant-time; the self-run setup text names the admin password and says Delete competition asks for it again; the API spec and docs state that anyone can add and edit participants in self-run and when a competition can be completed; gosec annotation on the confirm header name; a useMemo dependency the linter flagged. Co-Authored-By: Claude Opus 5.5 --- .claude/rules/match-writes.md | 4 +- AGENTS.md | 2 +- CLAUDE.md | 2 +- docs/architecture/network-architecture.md | 3 +- docs/user-guide/commands/mobile-app.md | 6 +- docs/user-guide/install/hosting.md | 2 +- docs/user-guide/organisers/operating-modes.md | 6 +- docs/user-guide/organisers/run-tournament.md | 2 +- internal/engine/scoring.go | 6 + internal/engine/scoring_tx.go | 8 +- internal/mobileapp/auth_source.go | 3 +- internal/mobileapp/confirm_password.go | 2 +- internal/mobileapp/coverage_gap_test.go | 1 + internal/mobileapp/handlers_match.go | 3 + internal/mobileapp/handlers_reset.go | 39 +++++- internal/mobileapp/handlers_reset_test.go | 122 ++++++++++++++++++ internal/mobileapp/score_exact_replay_test.go | 7 +- specs/openapi.yaml | 75 ++++++----- web-mobile/js/__tests__/api.test.jsx | 18 +++ .../admin_setup_shiaijo.render.test.jsx | 5 +- .../create_tournament_labels.render.test.jsx | 7 + web-mobile/js/__tests__/reset.test.jsx | 16 +++ web-mobile/js/admin_setup.jsx | 4 +- web-mobile/js/api_client.jsx | 3 + web-mobile/js/app.jsx | 4 +- web-mobile/js/reset.jsx | 8 +- web-mobile/js/viewer_home.jsx | 2 +- 27 files changed, 300 insertions(+), 60 deletions(-) diff --git a/.claude/rules/match-writes.md b/.claude/rules/match-writes.md index 05bcdd3e4..d7d594620 100644 --- a/.claude/rules/match-writes.md +++ b/.claude/rules/match-writes.md @@ -24,7 +24,7 @@ Operator ruling 2026-10-03: "Nothing should be dropped. All events must be order - **R4** (operator ruling 2026-10-04, replacing the implementer's back-to-running edge: "It needs a winner if the match is finished and is being corrected"): a scoring change that would leave a FINISHED knockout match tied, or an engi match (pool or knockout) with no valid flag count, is NOT applied. `deriveWinnerAfterMerge` reports false and changes nothing, and `mergeHold` (the one place a group is taken back out of a write) holds the scoring groups: the match keeps its recorded finish, winner and advanced winner, never goes back to running, and the change is in the history with `HoldReasonKnockoutNeedsWinner`/`HoldReasonEngiNeedsValidCount`. `MergeReport.NeedsWinner` reaches the answer as `heldReason: "needs_winner"` (`state.HeldReasonNeedsWinner`) beside `heldGroups`, with `NeedsWinnerMessage` on a fully held write, so the operator is told to correct the result with a winner. A pool or league tie becomes a completed draw (`hikiwake`). Nothing on the bracket write retracts a winner for it any more. - **S2, the other arrival order**: a finish whose `result` applies over scoring stored NEWER than it (a point made after the finish on a board that never saw it, arriving first) gets the verdict that scoring gives, through the same `deriveWinnerAfterMerge`, so both arrival orders end in the same state (status, winner, scoreline, stamps); the result is stamped with that newer scoring's stamp when the verdict moved (`verdictMoved`), as R3 stamps it. The finish's own hantei mark is carried onto the newer scoreline first (`carryHantei`, the S3 rule the other way round). A withdrawal is excluded (R2's `withdrawalOutranked` owns it). When the newer scoring would leave the match without the winner it needs (a knockout tie, an engi count that is not valid), stamp order says that scoring came after the finish and could not apply (R4), so the finish is APPLIED on its own scoreline and each newer scoring group is moved out of the match into the history as held (`mergeHold.displaceNewerScoring`, `MergeReport.Displaced`, one entry per stamp with door `displaced` and the R4 reason, written by `recordDisplacedHistory`, also from the engi branch); `ModifiedAt` falls back to the newest stamp left. Both arrival orders then reach the same state (status, winner, scoreline, stamps, next round) and both histories hold the tying change; the answer is applied and carries `displacedGroups` with `heldReason: "needs_winner"`. The same displacement covers a representative pick stamped after a representative-bout removal: when the removal applies, the pick is moved to the history (reason `HoldReasonRepBoutRemoved`, "the representative bout was removed"), and that answer carries `displacedGroups` with NO `heldReason`; the client asks `writeDisplacedForWinner` (write_result.jsx) before saying a displacement left a match without a winner. Only a finish whose payload does not carry the scoring it stood on (it names `result` alone and sends no scoreline) cannot be replayed: it is held (`needs_winner`) and the match stays as stored. Winners worked out again take a side's id from the stored match when the payload omits it (`mergeCtx.storedAID`/`storedBID`), since ids are backfilled only after the merge. - **The history** (internal/state/match_history.go): one append-only file per match, `competitions//history/.jsonl`, one JSON line per write that reached the merge: stamp, server `receivedAt`, door, session, changed groups, per-group outcome (`applied`/`unchanged`/`held`), the held groups' incoming values, any cleared withdrawal, and the `reason` when one rule held the write's groups rather than their stamps (`MergeReport.HoldReason`: `HoldReasonOlderRevision`, `HoldReasonEngiAtomic`, `HoldReasonFinishAtomic`, `HoldReasonKnockoutNeedsWinner`, `HoldReasonEngiNeedsValidCount`; the History view shows it as "Not applied: "). `StoreTx.AppendMatchHistory` stages the whole small file in the SAME transaction as the write (`LoadMatchHistory` reads it, read-your-own-writes), so an aborted transaction leaves no entry. Written at the point the write can no longer be rolled back (`recordWriteHistory`: after K3/requalification in `RecordMatchResultWithIneligibilityTx`, in `writeMatchResult`, and on the superseded paths), so a write refused by validation, a side mismatch or K3 leaves none; every direct writer above appends its own (`recordDirectHistory`) INSIDE the transaction that makes its write: the doors already in one (the reopens, the kachinuki bout remove, the downstream reopen) pass their `tx`, and the kachinuki advance, `OverrideBracketWinner` and `RevertMatchToQueue` each open a `WithTransaction` for the write and the entry together (`withBracketMatch` takes the store handle, as `withPoolMatch` does), so a process dying between the two leaves no match change without its line; a held override writes nothing and appends on its own. `recordDirectHistory` logs a failed append rather than returning it, so a failure of the append alone still lets the write commit without its entry (accepted). The history directory is created only under an existing competition directory (`ensureMatchHistoryDirLocked`), so a write after `DeleteCompetition` fails rather than resurrecting it; `DiscardDraw` deletes it (`DeleteMatchHistory`), since a regenerated draw reuses the match ids. A fully-held write must COMMIT its transaction for its entry to land: every door returns nil from its closure on `ErrMatchSuperseded` and answers after (the score/decision handlers always did; bulk-score, both daihyosen paths and `RecordMatchResult` were changed to). -- **The answers**: a write applied in part answers applied (the merged result) plus `heldGroups` (`scoreResponseWithReopened`, `withHeldGroups`, bulk-score's top-level `heldGroups`); a fully-held one keeps the 200 `{applied:false, reason:"superseded"}` shape the SPA keys on, plus `heldGroups` (`respondSuperseded`, `SupersededMessage`: "kept in the match's history"). Either answer adds `heldReason: "needs_winner"` when R4/S2 held them (`MergeReport.HeldReason`; a fully held one then carries `NeedsWinnerMessage`; bulk-score puts it on the entry), and an applied finish that moved stored newer scoring to the history adds `displacedGroups` (`MergeReport.DisplacedGroups`) with the same `heldReason` (score, daihyosen, bulk-score's `heldGroups[]` items). `ErrMatchSuperseded` is returned as `*SupersededError` carrying the held groups and that code (`errors.Is` still matches; `HeldGroupsOf`/`HeldReasonOf` read them). A completed write stamped OLDER than the stored `result` group over a finished match is not asked for a correction reason (`applyCorrectionReason`, `matchSnapshot.ResultStamp`): a write that completes the match is atomic with everything else it changes (`HoldReasonFinishAtomic`, mirroring `HoldReasonEngiAtomic`), so a stale finish is held WHOLE and kept in the history rather than landing its scoreline beside a verdict it never declared. An equal stamp that changes nothing (every group the write changes, `effectiveChangedGroups`, equal to the stored value once read as the write would land it) is the finish already recorded, sent again because its answer was lost: `engine.ExactScoreReplay` (the score counterpart of `exactDecisionReplay`) lets it past the reason check, and the engine answers it as recorded (`answerExactScoreReplay`, in `RecordMatchResultWithIneligibilityTx` after the older-revision hold and before the engi dispatch, so before every refusal): the stored match, with a bracket match's court and schedule filled in, nothing written, and one history line whose groups all read unchanged. A same-stamp write that differs still needs a reason. The request-only fields (`Changed`, `WriteDoor`, `Merge`, `ClearsWithdrawal`) never ride on a stored copy: `MatchResult.ClearRequestFields` is the one list, called on the pool write's overwrite and on every pool-match copy the store hands out. +- **The answers**: a write applied in part answers applied (the merged result) plus `heldGroups` (`scoreResponseWithReopened`, `withHeldGroups`, bulk-score's top-level `heldGroups`); a fully-held one keeps the 200 `{applied:false, reason:"superseded"}` shape the SPA keys on, plus `heldGroups` (`respondSuperseded`, `SupersededMessage`: "kept in the match's history"). Either answer adds `heldReason: "needs_winner"` when R4/S2 held them (`MergeReport.HeldReason`; a fully held one then carries `NeedsWinnerMessage`; bulk-score puts it on the entry), and an applied finish that moved stored newer scoring to the history adds `displacedGroups` (`MergeReport.DisplacedGroups`) with the same `heldReason` (score, daihyosen, bulk-score's `heldGroups[]` items). `ErrMatchSuperseded` is returned as `*SupersededError` carrying the held groups and that code (`errors.Is` still matches; `HeldGroupsOf`/`HeldReasonOf` read them). A completed write stamped OLDER than the stored `result` group over a finished match is not asked for a correction reason (`applyCorrectionReason`, `matchSnapshot.ResultStamp`): a write that completes the match is atomic with everything else it changes (`HoldReasonFinishAtomic`, mirroring `HoldReasonEngiAtomic`), so a stale finish is held WHOLE and kept in the history rather than landing its scoreline beside a verdict it never declared. An equal stamp that changes nothing (every group the write changes, `effectiveChangedGroups`, equal to the stored value once read as the write would land it) is the finish already recorded, sent again because its answer was lost: `engine.ExactScoreReplay` (the score counterpart of `exactDecisionReplay`) lets it past the reason check, and the engine answers it as recorded (`answerExactScoreReplay`, in `RecordMatchResultWithIneligibilityTx` after the older-revision hold and before the engi dispatch, so before every refusal): the stored match, with a bracket match's court and schedule filled in, nothing written, and one history line whose groups all read unchanged (none for an anonymous self-run participant's resend, `ForceOptions.NoReplayHistory`, so looping it cannot grow the history file). A same-stamp write that differs still needs a reason. The request-only fields (`Changed`, `WriteDoor`, `Merge`, `ClearsWithdrawal`) never ride on a stored copy: `MatchResult.ClearRequestFields` is the one list, called on the pool write's overwrite and on every pool-match copy the store hands out. - **The client (phase 2)**: every score write names its groups. `web-mobile/js/match_groups.jsx` is the ONE client owner of the names (pinned against `state.ScalarGroups`/`BoutGroup` by the shared table `internal/state/testdata/match_groups.json`: `TestMatchGroups_SharedTable` and `match_groups_table.test.jsx`), of the comparison (`changedGroups`, on WIRE shapes only: both sides go through `toBackendMatchResult`, the adopted copy via `matchWire` in api_serializers.jsx, so a placeholder slot, an Ht placement or a winner object never reads as a change; a field the write leaves out, e.g. an omitted correction reason or a silent representative bout's ippons, is not compared, and a bout row the write does not carry is never a change), of the union (`unionChanged`: no list on either side means every group and stays so) and of a group's words (`groupLabel`). `useChangedGroups(m)` (admin_scoring_autosave.jsx) is the one hook the three editors call; every patch goes through its `claim` (the individual and team `buildPatch`, a hantei included since bc-htsd, the engi save and autosave builder). A group counts against TWO baselines: the server value the editor last AGREED with for that group (seeded at mount, moved on at each build where local equals the prop, and by `claim.agree(group)` when an editor's adopt TAKES the server's value for that group, or when the team editor's withdrawal-removal Undo puts the whole board back to the server's (every bout group and both pick groups); an adopt that takes only PART of a group agrees it only when the editor's whole group then equals the server's (the individual editor's hantei adopt skips `points` while a point of its own is unsaved; a representative-side adopt agrees `rep` only when both sides then hold the server's picks); both baselines are kept per group), NOT the latest prop, or an edit held while another device changed a group the editor kept its own value for would put the old value back; and the last write the editor built, or a tap taken back before the first write's echo would name nothing. A reopen is a write the server makes, not a build, so when the match the editor renders leaves `completed` (a reopen, or a send back to the queue) the editor takes the result it now holds as agreed, that group alone (`useChangedGroups`): without it, ending a reopened match again with the verdict it had named nothing, and the server kept the match running with no error. It keys on the status, not on `reopenPending`: a reopen without a reason carries that stamp, but one a correction's reason comes with (a downstream reopen, a requalification, a pool-rank override) leaves the match scheduled with its points and no stamp, and an editor that watched only the stamp named no result for it. `startPatch` names `result` alone. The outbox keeps `changed` verbatim (persist, cross-tab, rehydrate, replay); a score write that takes a queued one's place claims the union (`_claimingQueuedGroups` in `_placeWrite`, and the absorb in `recordScore`, both against the one entry `_coalesceTarget` names); `_broadcastPatch` strips it. Answers: `writeHeldGroups`/`writePartlyHeld` (write_result.jsx) for a partial apply, which the editors answer with a quiet note (`useKeptInHistoryNote`/`KeptInHistoryNote`, worded by `heldGroupsNote`) and `editMatchScore` with a toast when the write closes the editor; a superseded write leads "Not applied", never "Not saved" (`SUPERSEDED_LEAD`, `notSavedText`'s `lead`, `supersededAlertText`). The editors' closed-by-default **History** disclosure (`MatchHistoryDisclosure`, match_history_view.jsx, `data-testid="match-history"`, hidden under `selfReport`) reads `GET /api/competitions/:id/matches/:mid/history` (`RegisterMatchHistoryHandler`, stamp order with an unstamped entry at its `receivedAt`, `[]` when none, 404 for an unknown competition or match, main-gated in self-run), readable values through `formatIpponsScore`/`withdrawalLabel`/`sideWord`. @@ -36,7 +36,7 @@ Operator ruling 2026-10-03: "Nothing should be dropped. All events must be order - **Read-modify-writes are one transaction.** `InjectPoolDaihyosenMatches`, `InjectTiebreakerMatches` (the engi self-heal too), `GenerateLeagueTiebreakMatches` and `AdvanceSwissRound`'s append each read pool-matches.csv, decide, and save under ONE `WithTransaction` (standings through `computeStandingsFrom(tx)`, the tournament read before the tx, `appendWithSlots` for the new slots); a score landing meanwhile waits for the lock and goes on top. `MaybeAdvanceKachinuki` still reads without the lock, and its locked write re-checks (`kachinukiAdvanceStillHolds`: not completed, same status, stamp and bout log) and on `errKachinukiAdvanceStale` reads the match again and works the pairing out from it (`advanceKachinukiOnce`, up to `kachinukiAdvanceAttempts` times, standing down only then: a stand-down answered the operator's Record bout with no next pairing; a finish that landed is final on the re-read, so it is never reopened; the bracket branch uses `UpdateBracket` with `Bracket.MatchByID` so it can abort). `Engine.afterMatchRead` is the test-only seam at each read (`lost_update_window_test.go` lands a real write there). - **A rename is not a side mismatch.** `reconcileSides` accepts a payload side whose id equals the stored id whatever name it carries, keeping the stored (current) name on the side, on a winner named by the old name, and on every bout row field that names the side by it (`adoptCurrentSideName`: the representative-bout row, and a fixed-order or id-less kachinuki row whose winner is the team, which `state.SubBoutWinnerSide` would otherwise credit to nobody); only an id disagreement, or a name disagreement no id can settle, is `side_mismatch`. Only an id the CLIENT sent proves a rename: the handler's hantei/winner-id backfill (`backfillMatchIdentityForHantei`) fills a side id only beside the stored name it belongs to, so a payload naming someone else with no ids is still `side_mismatch`. -Accepted as they are: a replayed queued write applied in part tells nobody (the history has it), unless what was held would have left a finished match without a winner (`writeNeedsWinner`), which the replay reports like a superseded one. An exact decision replay adds a second history line that reads like the first ("Decision recorded", every group unchanged), and an exact finish replay likewise (every group unchanged). A queued write the server keeps refusing with a 5xx is retried as long as the page is open and holds back no other write; the editors' **Discard held result** covers a terminal score write, and the admin topbar's held-writes count opens `HeldWritesPanel` (admin_shell.jsx), which lists every held write (`API.heldWrites()`) and discards any one that keeps failing (`API.discardHeldWrite(key)`; both doors go through `_discardIfFailing`, so a write only waiting for the connection is never discarded); a same-session reopen over a write the browser could not store shows the plain held notice. An answer that both held groups and moved a later change (`heldGroups` and `displacedGroups` with one `heldReason`) is read as the move's: the held part gets the plain newer-change note. `unsentWrites().total` counts entries, so two pending writes for one match count two; `terminal` still counts results. `InjectTiebreakerMatches` and `InjectPoolDaihyosenMatches` compute standings uncached, under the competition's lock, on each call. A partial apply on the self-run public sheet's finishing write closes the editor with no note. Every write re-stages its match's whole history file through the WAL, so a long match's history costs a rewrite per autosave; compaction is for a later phase. An R3 correction on a knockout match whose next match was already played is refused like any other knockout correction (`downstream_knockout_played`/`_running`). History entries recorded before phase 3 may still list an echo as held. A lineup save made while a save of the same lineup is queued reads `{queued: true}` until the flush sends it, a moment later when the device is online. A write whose transaction log committed but whose file write then failed (`state.ErrTxCommitted`: a disk that refuses writes) is answered 500 like any internal error, so the device keeps retrying until a write lands (operator decision 2026-10-04): the change is already kept, written on restart or by the next write the disk accepts, and `internalError` (errors.go) logs it as an ERROR naming the disk; a Discard of it does not stop it applying at restart. A completed correction whose result is held because a still-scoring board's LATER change (R3) moved the result's stamp first is held WHOLE, by `HoldReasonFinishAtomic`, even though in plain stamp order its own bouts would have applied before that later change: finish atomicity holds the correction together with the scoreline it stood on rather than letting its bouts land piecemeal beside a verdict a newer write already superseded. Nothing is lost (the held write is in the match's history as always), and the operator is told to check it rather than having the correction's bouts silently re-order themselves around a verdict that has since moved. Do not report this ordering as a bug to fix: it is finding 4's atomicity rule doing its job. +Accepted as they are: a replayed queued write applied in part tells nobody (the history has it), unless what was held would have left a finished match without a winner (`writeNeedsWinner`), which the replay reports like a superseded one. An exact decision replay adds a second history line that reads like the first ("Decision recorded", every group unchanged), and the organiser's exact finish replay likewise (every group unchanged); an anonymous self-run participant's adds none. A queued write the server keeps refusing with a 5xx is retried as long as the page is open and holds back no other write; the editors' **Discard held result** covers a terminal score write, and the admin topbar's held-writes count opens `HeldWritesPanel` (admin_shell.jsx), which lists every held write (`API.heldWrites()`) and discards any one that keeps failing (`API.discardHeldWrite(key)`; both doors go through `_discardIfFailing`, so a write only waiting for the connection is never discarded); a same-session reopen over a write the browser could not store shows the plain held notice. An answer that both held groups and moved a later change (`heldGroups` and `displacedGroups` with one `heldReason`) is read as the move's: the held part gets the plain newer-change note. `unsentWrites().total` counts entries, so two pending writes for one match count two; `terminal` still counts results. `InjectTiebreakerMatches` and `InjectPoolDaihyosenMatches` compute standings uncached, under the competition's lock, on each call. A partial apply on the self-run public sheet's finishing write closes the editor with no note. Every write re-stages its match's whole history file through the WAL, so a long match's history costs a rewrite per autosave; compaction is for a later phase. An R3 correction on a knockout match whose next match was already played is refused like any other knockout correction (`downstream_knockout_played`/`_running`). History entries recorded before phase 3 may still list an echo as held. A lineup save made while a save of the same lineup is queued reads `{queued: true}` until the flush sends it, a moment later when the device is online. A write whose transaction log committed but whose file write then failed (`state.ErrTxCommitted`: a disk that refuses writes) is answered 500 like any internal error, so the device keeps retrying until a write lands (operator decision 2026-10-04): the change is already kept, written on restart or by the next write the disk accepts, and `internalError` (errors.go) logs it as an ERROR naming the disk; a Discard of it does not stop it applying at restart. A completed correction whose result is held because a still-scoring board's LATER change (R3) moved the result's stamp first is held WHOLE, by `HoldReasonFinishAtomic`, even though in plain stamp order its own bouts would have applied before that later change: finish atomicity holds the correction together with the scoreline it stood on rather than letting its bouts land piecemeal beside a verdict a newer write already superseded. Nothing is lost (the held write is in the match's history as always), and the operator is told to check it rather than having the correction's bouts silently re-order themselves around a verdict that has since moved. Do not report this ordering as a bug to fix: it is finding 4's atomicity rule doing its job. ## Write refusal and the clock frame (bc-lww1 / bc-cse) diff --git a/AGENTS.md b/AGENTS.md index 56cbb0e22..5c904c1ad 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -23,7 +23,7 @@ Detailed domain rulings (match results and marks, match writes and the offline q - **API Documentation:** The OpenAPI specification for the web API is located in `specs/openapi.yaml` and is fully synchronized with the backend implementation. - **Mobile App (`mobile-app` command):** A real-time tournament management server serving a Preact/JSX UI from `web-mobile/`. State is file-backed: `tournament-data/tournament.md` (YAML) and `tournament-data/competitions//config.md` + `participants.csv`. Backend in `internal/mobileapp/` (Gin handlers) and `internal/state/` (store). Real-time updates via SSE. Admin actions require `X-Tournament-Password` header. Run with `make run-mobile`. - **Real-Time Updates:** Real-time updates via SSE. Admin actions require `X-Tournament-Password` header. Run with `make run-mobile`. -- **Admin Auth Modes:** Two startup-selected modes (`internal/mobileapp/auth_source.go`). **File mode** (default) stores the password plaintext in `tournament.md`; a public `POST /api/tournament/reset` resets it without the old password. **Locked mode** (`--lock-password` + `TOURNAMENT_PASSWORD_HASH` env var) compares the header against a bcrypt hash and disables the reset API (`POST /api/tournament/reset` returns 404; the SPA `/reset` page still loads but shows an operator-disabled message). The public `GET /api/auth-config` reports the active mode so the SPA can hide reset UI when locked. Generate hashes with `bracket-creator hash-password`. +- **Admin Auth Modes:** Two startup-selected modes (`internal/mobileapp/auth_source.go`). **File mode** (default) stores the password plaintext in `tournament.md`; `POST /api/tournament/reset` resets it without the old password, for loopback callers only (403 `reset_local_only` otherwise). **Locked mode** (`--lock-password` + `TOURNAMENT_PASSWORD_HASH` env var) compares the header against a bcrypt hash and disables the reset API (`POST /api/tournament/reset` returns 404; the SPA `/reset` page still loads but shows an operator-disabled message). The public `GET /api/auth-config` reports the active mode so the SPA can hide reset UI when locked. Generate hashes with `bracket-creator hash-password`. - **Pool Scoring Rules:** - **Individual:** 1. Fights Won, 2. Fights Lost, 3. Hikiwake, 4. Points Scored, 5. Points Lost. - **Team:** 1. Team W, 2. Team L, 3. Team T, 4. Individual Winners (IV), 5. Individual Losses (IL), 6. Individual Ties (IT), 7. Points Won (PW), 8. Points Lost (PL). diff --git a/CLAUDE.md b/CLAUDE.md index 7e3119071..860f2285c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -116,7 +116,7 @@ go test -cover ./internal/helper/... - **`internal/excel/`**: Excel file lifecycle (`Client`), the from-scratch workbook template (`NewFileFromScratch`), style definitions. - **`internal/service/`**: Service layer abstraction over helper logic. - **`internal/resources/`**: Embedded file management. Resources flow: `main.go` embeds → `resources.NewResources()` → `cmd.ExecuteWithResources()`. -- **`internal/mobileapp/`**: Gin HTTP handlers for the tournament app (`mobile-app` command). Routes: `handlers_competition.go` (including `POST /api/competitions/:id/generate-draw` [status `draw-ready`] and `DELETE /api/competitions/:id/draw` to discard the draw; a draw-ready competition is listed on the shiaijo operator views and its FIRST RECORDED result starts it -- a match left running or completed, NEVER a write that clears a result back to scheduled (the engine's result gate, which also discounts the byes a knockout draw resolves at generation time) -- via `engine.MaybeAutoCompletePools`'s `AutoCompleteStarted` outcome, which `tryAutoCompletePools` broadcasts as `competition_started`, so the desk's `POST .../start` is one of two doors, bc-prow ruling), `handlers_match.go`, `handlers_participants.go` (including single participant PUT updates, individual/bulk check-ins `POST /api/competitions/:id/participants/checkin-bulk`; self-run mode has no check-in, so the three check-in writes answer 409 `check_in_not_in_self_run` for every caller, and `state.Competition.CheckInApplies` is the one owner the draw, the Swiss field and the estimate ask, never `CheckInEnabled` itself), `handlers_tournament.go`, `handlers_swiss.go` (`generate-round`, `standings`), `handlers_decision.go` (kiken/fusenpai/fusensho/daihyosen: `POST /api/competitions/:id/matches/:mid/decision`; `handleBothSidesBarredHikiwake` runs before the endpoint's own `Validate()` -- which otherwise 400s `hikiwake` outright with "use /score for fought/hikiwake" -- as the one carve-out that accepts `decision: "hikiwake"`, no `decisionBy` needed, when both sides of a SCHEDULED pool or league match (`engine.IsPoolMatchID`, league reuses the same `"Pool "`-prefixed ids; Swiss ids never match and are not covered) are already barred; the write records `Status: completed, Decision: "hikiwake"` with no new `CompetitorStatus`. The same both-barred knockout match instead gets `ineligible_competitor` naming both sides and pointing at the earlier withdrawal or the draw), `handlers_eligibility.go` (`/api/competitions/:id/competitor-status`), `handlers_lineup.go` (team lineups), `handlers_schedule.go` (`GET /api/schedule/estimate`, public), `handlers_reset.go` (`POST /api/tournament/reset`, public, for forgotten admin passwords; 404s in locked mode), `handlers_auth_config.go` (`GET /api/auth-config`, public, reports auth mode to the SPA). Real-time push via SSE (`hub.go`) with events: `match_updated`, `competitor_status_updated`, `competition_completed`, `swiss_round_generated`, etc. Auth via `X-Tournament-Password` header (`middleware.go`), with two modes selected at startup by a `PasswordVerifier` (`auth_source.go`): **file mode** (default, plaintext compare against `tournament.md`) or **locked mode** (`--lock-password` flag + `TOURNAMENT_PASSWORD_HASH` env var, bcrypt compare, `POST /api/tournament/reset` returns 404; the SPA `/reset` page still renders an operator-disabled message). Consumer-boundary interfaces live in `deps.go` (NFR-002). +- **`internal/mobileapp/`**: Gin HTTP handlers for the tournament app (`mobile-app` command). Routes: `handlers_competition.go` (including `POST /api/competitions/:id/generate-draw` [status `draw-ready`] and `DELETE /api/competitions/:id/draw` to discard the draw; a draw-ready competition is listed on the shiaijo operator views and its FIRST RECORDED result starts it -- a match left running or completed, NEVER a write that clears a result back to scheduled (the engine's result gate, which also discounts the byes a knockout draw resolves at generation time) -- via `engine.MaybeAutoCompletePools`'s `AutoCompleteStarted` outcome, which `tryAutoCompletePools` broadcasts as `competition_started`, so the desk's `POST .../start` is one of two doors, bc-prow ruling), `handlers_match.go`, `handlers_participants.go` (including single participant PUT updates, individual/bulk check-ins `POST /api/competitions/:id/participants/checkin-bulk`; self-run mode has no check-in, so the three check-in writes answer 409 `check_in_not_in_self_run` for every caller, and `state.Competition.CheckInApplies` is the one owner the draw, the Swiss field and the estimate ask, never `CheckInEnabled` itself), `handlers_tournament.go`, `handlers_swiss.go` (`generate-round`, `standings`), `handlers_decision.go` (kiken/fusenpai/fusensho/daihyosen: `POST /api/competitions/:id/matches/:mid/decision`; `handleBothSidesBarredHikiwake` runs before the endpoint's own `Validate()` -- which otherwise 400s `hikiwake` outright with "use /score for fought/hikiwake" -- as the one carve-out that accepts `decision: "hikiwake"`, no `decisionBy` needed, when both sides of a SCHEDULED pool or league match (`engine.IsPoolMatchID`, league reuses the same `"Pool "`-prefixed ids; Swiss ids never match and are not covered) are already barred; the write records `Status: completed, Decision: "hikiwake"` with no new `CompetitorStatus`. The same both-barred knockout match instead gets `ineligible_competitor` naming both sides and pointing at the earlier withdrawal or the draw), `handlers_eligibility.go` (`/api/competitions/:id/competitor-status`), `handlers_lineup.go` (team lineups), `handlers_schedule.go` (`GET /api/schedule/estimate`, public), `handlers_reset.go` (`POST /api/tournament/reset`, for forgotten admin passwords; in file mode it answers loopback callers only, judged on `RemoteAddr` and never on forwarded headers, 403 `reset_local_only` otherwise; 404s in locked mode), `handlers_auth_config.go` (`GET /api/auth-config`, public, reports auth mode to the SPA). Real-time push via SSE (`hub.go`) with events: `match_updated`, `competitor_status_updated`, `competition_completed`, `swiss_round_generated`, etc. Auth via `X-Tournament-Password` header (`middleware.go`), with two modes selected at startup by a `PasswordVerifier` (`auth_source.go`): **file mode** (default, plaintext compare against `tournament.md`) or **locked mode** (`--lock-password` flag + `TOURNAMENT_PASSWORD_HASH` env var, bcrypt compare, `POST /api/tournament/reset` returns 404; the SPA `/reset` page still renders an operator-disabled message). Consumer-boundary interfaces live in `deps.go` (NFR-002). - **`internal/state/`**: File-backed state store for the mobile app. Tournament and competition config lives in `tournament-data/tournament.md` and `tournament-data/competitions//config.md` (YAML front-matter). Participants are in `participants.csv` alongside each config. - **`internal/engine/`**: Thin adapter that drives `internal/helper` pool/bracket generation from a `state.Competition`. Called by the `POST /api/competitions/:id/start` handler. - **`web-mobile/`**: Preact/JSX frontend for the mobile app, served embedded in the binary. Entry point: `web-mobile/index.html`. JS modules in `web-mobile/js/` are grouped by prefix: `admin_*.jsx` (the operator console: setup, participants, pools, scoring, scheduling, lineups, etc.), `viewer_*.jsx` and `display_*.jsx` (public attendee/spectator surfaces), and shared infrastructure (`app.jsx`, `api_client.jsx`, `api_serializers.jsx`, `bracket.jsx`, `data.jsx`, `patch.jsx`, `router.jsx`, `ui.jsx`, `glossary.jsx`). Run `ls web-mobile/js/` for the current set rather than relying on an enumerated list here. Note `admin_participants.jsx` holds the `LinedTextarea` gutter participant paste box; check-in is NOT there, it is owned by `admin_registration_desk.jsx`. CSS in `web-mobile/css/styles.css`. Pre-compiled to `web-mobile/dist/` by esbuild (run automatically as part of `make go/build`). diff --git a/docs/architecture/network-architecture.md b/docs/architecture/network-architecture.md index aee1e057a..60e64dbf2 100644 --- a/docs/architecture/network-architecture.md +++ b/docs/architecture/network-architecture.md @@ -178,7 +178,8 @@ flowchart TD ``` - **File mode** (default): plaintext compare against `tournament.md`. `POST /api/tournament/reset` - is available (for a forgotten admin password). + (for a forgotten admin password) answers only a caller on the machine running the server, + judged on the TCP peer address; any other caller gets 403 `reset_local_only`. - **Locked mode** (`--lock-password` / `LOCK_PASSWORD=true` + `TOURNAMENT_PASSWORD_HASH`): bcrypt compare; reset endpoint returns 404. `GET /api/auth-config` reports the mode to the SPA. diff --git a/docs/user-guide/commands/mobile-app.md b/docs/user-guide/commands/mobile-app.md index 3ec9da4fa..fa809ae58 100644 --- a/docs/user-guide/commands/mobile-app.md +++ b/docs/user-guide/commands/mobile-app.md @@ -35,7 +35,7 @@ The server has two authentication modes for the admin console; viewer routes are The admin password is stored plaintext in `tournament-data/tournament.md` and compared by exact-string match. Set the password during the in-app **Create tournament** flow, or edit `tournament.md` directly. -- `POST /api/tournament/reset` is enabled and unauthenticated. If you've forgotten the current password, browse to `http:///reset` from any device on the same network to set a new one. +- `POST /api/tournament/reset` needs no password but answers only a request from the machine running the server (a loopback address). Any other machine gets `403` with the code `reset_local_only`. If you've forgotten the current password, browse to `http://localhost:/reset` on the machine running the server to set a new one. In a Docker container run with `-p`, a browser on the host reaches the server through the container network, not loopback, so the reset is refused there too; edit `tournament.md` in the mounted data folder instead. - `GET /api/auth-config` returns `{"mode": "file", "resetEnabled": true}`. ### Locked mode (`--lock-password`) @@ -64,8 +64,8 @@ Recommended for any deployment reachable over the internet. The plaintext-in-fil #### Operational caveats -- **`POST /api/tournament/reset` has no rate limiting.** The endpoint is unauthenticated and the server does not throttle calls. For internet-exposed deployments, run with `--lock-password` (which 404s `POST /api/tournament/reset`) OR front the server with a reverse proxy that rate-limits that path. -- **Locked mode has no bcrypt brute-force protection.** The server runs a full bcrypt comparison (`bcrypt.DefaultCost` approx 50-100 ms) on every `X-Tournament-Password` header, but does not throttle repeated failed attempts. For internet-exposed locked-mode deployments, add a reverse proxy that rate-limits authenticated routes (for example, nginx's `limit_req`) in addition to `POST /api/tournament/reset`. +- **`POST /api/tournament/reset` is limited to the machine running the server, judged on the connection's peer address.** `X-Forwarded-For` and similar headers are not consulted. Behind a reverse proxy on the same machine, every request arrives from the proxy's loopback address, so the reset is reachable by whoever the proxy exposes it to; block that path at the proxy or run with `--lock-password` (which 404s `POST /api/tournament/reset`). +- **Locked mode has no bcrypt brute-force protection.** The server runs a full bcrypt comparison (`bcrypt.DefaultCost` approx 50-100 ms) on every `X-Tournament-Password` header, but does not throttle repeated failed attempts. For internet-exposed locked-mode deployments, add a reverse proxy that rate-limits authenticated routes (for example, nginx's `limit_req`). - **Mode switching preserves the stored password.** Switching from file mode to locked mode does NOT erase `tournament.md`'s `password` field; auth stops consulting it. A later switch back to file mode resurrects the original password. This is a deliberate rollback feature, but anyone with filesystem access can still read the value. To fully retire a file-mode credential before going locked, `POST /api/tournament/reset` it to a one-time throwaway first. ## Competition configuration diff --git a/docs/user-guide/install/hosting.md b/docs/user-guide/install/hosting.md index 15e66eac5..36fcf6a35 100644 --- a/docs/user-guide/install/hosting.md +++ b/docs/user-guide/install/hosting.md @@ -24,7 +24,7 @@ The Docker baseline is the reusable core: the GCP and Oracle Terraform modules r ## Security -Any deployment reachable over the internet should run in locked mode rather than the default file mode. Locked mode reads a bcrypt hash from an environment variable and disables the public password-reset endpoint. Refer to [Admin authentication](../organisers/operating-modes.md#admin-authentication-mode) for the setup steps. +Any deployment reachable over the internet should run in locked mode rather than the default file mode. Locked mode reads a bcrypt hash from an environment variable and turns the password-reset endpoint off (in file mode it answers only the machine running the server). Refer to [Admin authentication](../organisers/operating-modes.md#admin-authentication-mode) for the setup steps. ## What still needs printing diff --git a/docs/user-guide/organisers/operating-modes.md b/docs/user-guide/organisers/operating-modes.md index 33700733e..a03f5cb58 100644 --- a/docs/user-guide/organisers/operating-modes.md +++ b/docs/user-guide/organisers/operating-modes.md @@ -14,7 +14,7 @@ Officiated mode is the default. Every action (scoring, check-in, starting, and c In self-run mode, scoring and starting a competition are open to anyone without a password, so competitors or table helpers can run and score their own matches. Completing a competition needs no password either: in self-run mode the server accepts it from anyone. The **Complete competition** button is on the organiser's competition page, which you reach by signing in. A public self-registration page also becomes available for competitors to sign themselves up, for individual competitions only and only while the competition is still in setup, before the draw is generated. -Organiser actions stay behind the admin password in self-run mode: creating and editing competitions, tournament settings, the tournament logo and sponsor images, seeds, scheduling, the **Lineups** page, match decisions such as kiken, league tie-breaker decisions (adding or removing tie-breaker matches, and accepting shared ranks), exports, fighting-spirit awards, and every action that destroys data (deleting or invalidating a competition, discarding a draw, resetting manual overrides, and importing competitions). +Organiser actions stay behind the admin password in self-run mode: creating and editing competitions, tournament settings, the tournament logo and sponsor images, seeds, scheduling, the **Lineups** page, match decisions such as kiken, result corrections (reopening a match, setting a winner by hand, overriding a pool rank), announcements, league tie-breaker decisions (adding or removing tie-breaker matches, and accepting shared ranks), exports, fighting-spirit awards, and every action that destroys data (deleting or invalidating a competition, discarding a draw, resetting manual overrides, and importing competitions). The roster is open to anyone in self-run mode: anyone can add and edit a competition's participants, without the password, before the competition starts. @@ -36,7 +36,7 @@ Authentication mode controls how the admin password is stored and verified. Ther In file mode, the admin password is stored in plain text in the `tournament.md` data file. You set it during tournament creation, or you can edit the file directly. -If you forget the password, browse to `http:///reset` from the same network and choose a new one. No old password is required. The reset path is intentionally unauthenticated, which is acceptable on a trusted private network but is a security risk on the public internet. +If you forget the password, browse to `http://localhost:/reset` in a browser on the machine running the server and choose a new one. No old password is required. In file mode the password can be reset only from that machine: a request from any other machine is refused with "Reset the password on the machine running the server." When the server runs in a Docker container started with `-p`, a browser on the host reaches it through the container network, not loopback, so the reset is refused there too; edit `tournament.md` in the mounted data folder instead. !!! note Switching from file mode to locked mode does not erase the on-disk password. The app ignores it while locked mode is active. Switching back re-enables the stored value. @@ -78,7 +78,7 @@ For a real security boundary: - Run the server behind TLS. Refer to [Hosting](../install/hosting.md) for guidance. - Use locked mode for any deployment reachable from the internet. -- In file mode, the password reset is unauthenticated: anyone who can reach the server can set a new admin password without knowing the current one, which locks out the operator or takes over the tournament. Locked mode disables reset entirely, so any internet-exposed deployment should use locked mode. +- In file mode, the password reset needs no current password but is limited to the machine running the server, so someone who can open a browser on that machine can set a new admin password. Behind a reverse proxy on the same machine, requests arrive from the proxy's own address, so block `/reset` at the proxy. Locked mode turns reset off, so any internet-exposed deployment should use locked mode. ### Upgrading from an earlier release diff --git a/docs/user-guide/organisers/run-tournament.md b/docs/user-guide/organisers/run-tournament.md index c15bc6c43..6e0b3b4c0 100644 --- a/docs/user-guide/organisers/run-tournament.md +++ b/docs/user-guide/organisers/run-tournament.md @@ -251,7 +251,7 @@ For naginata and Engi-kyogi divisions, refer to [Naginata](naginata.md). The public viewer shows a competition's podium when it finishes, and a provisional ranking while it is still in progress. -A knockout or pools-then-knockout competition finishes when you tap **Complete competition** on its page. The button appears once every bracket match, the bronze match included when there is one, has been played, and completing cannot be undone. A league finishes by itself after its last match and any tie-breakers. In a team league, when a tie left after every ranking criterion is consequential, you first run or decline the tie-breaker; any other tie is left as a shared rank with no decision (refer to [Team standings and tie-breaks](team-tournaments.md#team-standings-and-tie-breaks)). A Swiss competition never finishes in this sense. After its last round, its organiser page shows a **Competition complete** notice and a link to the final standings, which the public viewer also shows, but there is no **Complete competition** button for it and it does not finish by itself, so the public viewer shows no podium for it. +A knockout or pools-then-knockout competition finishes when you tap **Complete competition** on its page. The button appears once every bracket match, the bronze match included when there is one, has been played, and completing cannot be undone. A league finishes by itself after its last match and any tie-breakers. In a team league, when a tie left after every ranking criterion is consequential, you first run or decline the tie-breaker; any other tie is left as a shared rank with no decision (refer to [Team standings and tie-breaks](team-tournaments.md#team-standings-and-tie-breaks)). A Swiss competition has no **Complete competition** button. After its last round, its organiser page shows a **Competition complete** notice and a link to the final standings, which the public viewer also shows. The **Award two joint 3rd places** setting, on the competition's Settings tab, controls whether a competition awards two joint 3rd places or decides a single one with a bronze match. It is on by default (the standard kendo convention) and applies to knockout, pools-then-knockout, and league competitions. It is not shown for Swiss, which has no bracket and no bronze match. Naginata competitions default the setting off (a single 3rd, decided by a bronze match). You can turn it back on for a naginata competition too, and you can turn it off for a kendo competition that needs a single 3rd, for example a selection event with one bronze medal. Refer to [Naginata](naginata.md#third-place-match) for the bronze match itself. diff --git a/internal/engine/scoring.go b/internal/engine/scoring.go index bda032785..7bf936619 100644 --- a/internal/engine/scoring.go +++ b/internal/engine/scoring.go @@ -704,6 +704,12 @@ type ForceOptions struct { // (HoldReasonOlderRevision): the ordering protection the rev guard gives, // without dropping anything. HoldReason string + // NoReplayHistory leaves out the history line an exact resend of a + // recorded finish would otherwise add (answerExactScoreReplay). The score + // handler sets it for an anonymous self-run participant, whose resend + // changes nothing and could otherwise be looped to grow the match's + // history file. The organiser's resend keeps its line. + NoReplayHistory bool } // HoldReasonOlderRevision is the history reason of a running write the same diff --git a/internal/engine/scoring_tx.go b/internal/engine/scoring_tx.go index e32069fb2..276bd061a 100644 --- a/internal/engine/scoring_tx.go +++ b/internal/engine/scoring_tx.go @@ -113,7 +113,7 @@ func (e *Engine) RecordMatchResultWithIneligibilityTx(tx state.StoreTx, compID, // An exact resend of a finish already recorded (a queued write whose // first send landed but whose answer was lost) answers as recorded and // writes nothing, before anything else judges it (bc-xrpl). - if replayed, err := e.answerExactScoreReplay(tx, compID, matchID, result, comp); err != nil || replayed { + if replayed, err := e.answerExactScoreReplay(tx, compID, matchID, result, comp, fo.NoReplayHistory); err != nil || replayed { return nil, err } // Only a COMPLETING write goes through the engi recorder. Engi's flag-total @@ -1339,7 +1339,7 @@ func exactScoreReplayProbe(stored, incoming *state.MatchResult, comp *state.Comp // eligibility refusal would only misreport a result already recorded as one // to resolve and send again. false, with result untouched, for any other // write. -func (e *Engine) answerExactScoreReplay(tx state.StoreTx, compID, matchID string, result *state.MatchResult, comp *state.Competition) (bool, error) { +func (e *Engine) answerExactScoreReplay(tx state.StoreTx, compID, matchID string, result *state.MatchResult, comp *state.Competition, noHistory bool) (bool, error) { if result.Status != state.MatchStatusCompleted || result.ModifiedAt <= 0 { return false, nil } @@ -1354,7 +1354,9 @@ func (e *Engine) answerExactScoreReplay(tx state.StoreTx, compID, matchID string // The probe's merge is the history entry's: what of the resend would // land (echoes) and what would be held (groups changed since). result.Merge = probe.Merge - e.recordWriteHistory(tx, compID, matchID, result) + if !noHistory { + e.recordWriteHistory(tx, compID, matchID, result) + } // A bracket match's prior came through bracketMatchAsResult, which omits // the court and the schedule a landed write echoes (the decision // replay's fill, bc-cse finding 5). diff --git a/internal/mobileapp/auth_source.go b/internal/mobileapp/auth_source.go index f17c6d3eb..87b1bd242 100644 --- a/internal/mobileapp/auth_source.go +++ b/internal/mobileapp/auth_source.go @@ -1,6 +1,7 @@ package mobileapp import ( + "crypto/subtle" "errors" "fmt" @@ -100,7 +101,7 @@ func (v *filePasswordVerifier) Verify(presented string) (bool, error) { if t == nil { return false, nil } - return presented == t.Password, nil + return subtle.ConstantTimeCompare([]byte(presented), []byte(t.Password)) == 1, nil } func (v *filePasswordVerifier) Mode() string { return "file" } diff --git a/internal/mobileapp/confirm_password.go b/internal/mobileapp/confirm_password.go index 356e536fc..cb92a27bc 100644 --- a/internal/mobileapp/confirm_password.go +++ b/internal/mobileapp/confirm_password.go @@ -10,7 +10,7 @@ import ( // is deleted. Deleting a competition is the one action that asks for the // password again (operator ruling 2026-10-12); every other organiser write is // authorised by the sign-in alone. -const ConfirmPasswordHeader = "X-Confirm-Password" +const ConfirmPasswordHeader = "X-Confirm-Password" // #nosec G101 -- a header name, not a credential // Codes of the two refusals; both carry confirmPasswordSentence. const ( diff --git a/internal/mobileapp/coverage_gap_test.go b/internal/mobileapp/coverage_gap_test.go index 15dd1f21f..5968e87d3 100644 --- a/internal/mobileapp/coverage_gap_test.go +++ b/internal/mobileapp/coverage_gap_test.go @@ -618,6 +618,7 @@ func TestReset_BadJSON(t *testing.T) { w := httptest.NewRecorder() req := httptest.NewRequest("POST", "/api/tournament/reset", bytes.NewBufferString("{invalid")) + req.RemoteAddr = loopbackPeer req.Header.Set("Content-Type", "application/json") r.ServeHTTP(w, req) assert.Equal(t, http.StatusBadRequest, w.Code) diff --git a/internal/mobileapp/handlers_match.go b/internal/mobileapp/handlers_match.go index 47b6070ad..84bc645f0 100644 --- a/internal/mobileapp/handlers_match.go +++ b/internal/mobileapp/handlers_match.go @@ -3684,6 +3684,9 @@ func registerScoreHandler(r *gin.RouterGroup, eng ScoringEngine, store Competiti Force: body.ForceDownstreamReopen, Reopened: &reopenedDownstream, StartOnly: body.StartOnly, + // A participant's exact resend of a finish changes nothing, + // so it leaves no history line to loop. + NoReplayHistory: selfReported, }) // engErr is a normal application-level signal (AlreadyIneligible // → 409, validation/not-found → other codes); we surface it diff --git a/internal/mobileapp/handlers_reset.go b/internal/mobileapp/handlers_reset.go index a7552389c..9d1596adc 100644 --- a/internal/mobileapp/handlers_reset.go +++ b/internal/mobileapp/handlers_reset.go @@ -2,6 +2,7 @@ package mobileapp import ( "errors" + "net" "net/http" "net/url" "strings" @@ -18,7 +19,9 @@ import ( // // Operators who want to deny this endpoint should enable locked mode // (--lock-password) which 404s the route. In file mode the endpoint is -// the documented recovery path for a forgotten admin password. +// the documented recovery path for a forgotten admin password, and it +// answers only a caller on the machine running the server (403 +// reset_local_only otherwise). type resetPasswordRequest struct { Password string `json:"password"` // OriginatorID is an opaque per-tab identifier the SPA generates @@ -93,6 +96,29 @@ var errResetPasswordRequired = errors.New("password is required") // must be reachable from any browser tab on the same network so // operators at a scoring table on the far side of the venue can reset // without SSH access to the server. +// resetLocalOnlyCode and resetLocalOnlySentence are the refusal of a reset +// that does not come from the machine running the server. The SPA shows the +// sentence when it sees the code. +const ( + resetLocalOnlyCode = "reset_local_only" + resetLocalOnlySentence = "Reset the password on the machine running the server." +) + +// isLoopbackPeer reports whether the request arrived over TCP from the machine +// running the server: the peer address is a loopback address (127.0.0.0/8, ::1, +// or an IPv4-mapped 127/8). It reads r.RemoteAddr alone. X-Forwarded-For, +// X-Real-IP, Forwarded and gin's c.ClientIP() are client-controlled (ClientIP +// trusts them by default), so a request from another machine could claim to be +// local through any of them. An address that does not parse is not loopback. +func isLoopbackPeer(r *http.Request) bool { + host, _, err := net.SplitHostPort(r.RemoteAddr) + if err != nil { + return false + } + ip := net.ParseIP(host) + return ip != nil && ip.IsLoopback() +} + func isSameOriginReset(c *gin.Context) bool { origin := strings.TrimSpace(c.GetHeader("Origin")) if origin == "" { @@ -141,6 +167,17 @@ func RegisterResetHandlers(r *gin.RouterGroup, store *state.Store, verifier Pass return } + // File mode answers only the machine running the server, judged on + // the TCP peer address (see isLoopbackPeer). + if !isLoopbackPeer(c.Request) { + c.JSON(http.StatusForbidden, gin.H{ + "error": resetLocalOnlySentence, + "code": resetLocalOnlyCode, + "message": resetLocalOnlySentence, + }) + return + } + // Cross-origin POST defense, see isSameOriginReset. Done before // body parsing so a malicious site can't even probe how the // endpoint reacts to a payload. diff --git a/internal/mobileapp/handlers_reset_test.go b/internal/mobileapp/handlers_reset_test.go index f1746c3a6..90888537d 100644 --- a/internal/mobileapp/handlers_reset_test.go +++ b/internal/mobileapp/handlers_reset_test.go @@ -42,6 +42,113 @@ func setupResetTest(t *testing.T, verifier PasswordVerifier) (*state.Store, *gin return store, r, hub } +// loopbackPeer is the TCP peer address of a browser on the machine running +// the server. httptest.NewRequest defaults RemoteAddr to 192.0.2.1:1234, which +// reset treats as another machine, so every test that reaches the reset body +// sets this. +const loopbackPeer = "127.0.0.1:54321" + +// setupFileModeReset is setupResetTest in file mode over a tournament whose +// password is "old". +func setupFileModeReset(t *testing.T) (*state.Store, *gin.Engine) { + t.Helper() + gin.SetMode(gin.TestMode) + store, err := state.NewStore(t.TempDir()) + require.NoError(t, err) + require.NoError(t, store.SaveTournament(&state.Tournament{Name: "T", Password: "old"})) + r := gin.New() + RegisterResetHandlers(r.Group("/api"), store, NewFileVerifier(store), NewHub()) + return store, r +} + +// postReset sends a reset POST from the given TCP peer address and returns the +// recorder, so each local-only case states only the peer and the extra headers. +func postReset(r *gin.Engine, remoteAddr string, headers map[string]string) *httptest.ResponseRecorder { + body, _ := json.Marshal(map[string]string{"password": "new-password"}) + req := httptest.NewRequest(http.MethodPost, "/api/tournament/reset", bytes.NewReader(body)) + req.RemoteAddr = remoteAddr + req.Header.Set("Content-Type", "application/json") + for k, v := range headers { + req.Header.Set(k, v) + } + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + return w +} + +func TestReset_FileMode_LoopbackPeers_Succeed(t *testing.T) { + for _, peer := range []string{ + "127.0.0.1:54321", + "[::1]:54321", + "127.8.9.10:54321", + "[::ffff:127.0.0.1]:54321", + } { + t.Run(peer, func(t *testing.T) { + store, r := setupFileModeReset(t) + + w := postReset(r, peer, nil) + + assert.Equal(t, http.StatusNoContent, w.Code) + loaded, err := store.LoadTournament() + require.NoError(t, err) + assert.Equal(t, "new-password", loaded.Password) + }) + } +} + +func TestReset_FileMode_NonLoopbackPeer_Refused(t *testing.T) { + for _, peer := range []string{ + "192.0.2.1:1234", + "10.0.0.5:4000", + "[2001:db8::1]:4000", + "not-an-address", + } { + t.Run(peer, func(t *testing.T) { + store, r := setupFileModeReset(t) + + w := postReset(r, peer, nil) + + assert.Equal(t, http.StatusForbidden, w.Code) + assert.Contains(t, w.Body.String(), "reset_local_only") + assert.Contains(t, w.Body.String(), "Reset the password on the machine running the server.") + loaded, err := store.LoadTournament() + require.NoError(t, err) + assert.Equal(t, "old", loaded.Password, "a refused reset must leave the password alone") + }) + } +} + +// A forged forwarding header must not turn a remote peer into a local one: +// the judgement is on the TCP peer address alone. +func TestReset_FileMode_ForgedForwardingHeaders_StillRefused(t *testing.T) { + store, r := setupFileModeReset(t) + + w := postReset(r, "192.0.2.1:1234", map[string]string{ + "X-Forwarded-For": "127.0.0.1", + "X-Real-IP": "127.0.0.1", + "Forwarded": "for=127.0.0.1", + }) + + assert.Equal(t, http.StatusForbidden, w.Code) + assert.Contains(t, w.Body.String(), "reset_local_only") + loaded, err := store.LoadTournament() + require.NoError(t, err) + assert.Equal(t, "old", loaded.Password) +} + +func TestReset_LockedMode_NonLoopbackPeer_Still404(t *testing.T) { + hash, err := bcrypt.GenerateFromPassword([]byte("envpass"), bcrypt.MinCost) + require.NoError(t, err) + bcryptV, err := NewBcryptVerifier(string(hash)) + require.NoError(t, err) + _, r, _ := setupResetTest(t, bcryptV) + + w := postReset(r, "192.0.2.1:1234", nil) + + assert.Equal(t, http.StatusNotFound, w.Code) + assert.Contains(t, w.Body.String(), "reset disabled") +} + func TestReset_FileMode_Success(t *testing.T) { dir, err := os.MkdirTemp("", "reset-test-*") require.NoError(t, err) @@ -60,6 +167,7 @@ func TestReset_FileMode_Success(t *testing.T) { body, _ := json.Marshal(map[string]string{"password": "new-password"}) req := httptest.NewRequest(http.MethodPost, "/api/tournament/reset", bytes.NewReader(body)) + req.RemoteAddr = loopbackPeer req.Header.Set("Content-Type", "application/json") w := httptest.NewRecorder() r.ServeHTTP(w, req) @@ -90,6 +198,7 @@ func TestReset_LockedMode_Returns404(t *testing.T) { body, _ := json.Marshal(map[string]string{"password": "new-password"}) req := httptest.NewRequest(http.MethodPost, "/api/tournament/reset", bytes.NewReader(body)) + req.RemoteAddr = loopbackPeer req.Header.Set("Content-Type", "application/json") w := httptest.NewRecorder() r.ServeHTTP(w, req) @@ -112,6 +221,7 @@ func TestReset_NoTournament_Returns409(t *testing.T) { body, _ := json.Marshal(map[string]string{"password": "new-password"}) req := httptest.NewRequest(http.MethodPost, "/api/tournament/reset", bytes.NewReader(body)) + req.RemoteAddr = loopbackPeer req.Header.Set("Content-Type", "application/json") w := httptest.NewRecorder() r.ServeHTTP(w, req) @@ -141,6 +251,7 @@ func TestReset_NamedNewTournamentWithPassword_AllowsReset(t *testing.T) { body, _ := json.Marshal(map[string]string{"password": "fresh"}) req := httptest.NewRequest(http.MethodPost, "/api/tournament/reset", bytes.NewReader(body)) + req.RemoteAddr = loopbackPeer req.Header.Set("Content-Type", "application/json") w := httptest.NewRecorder() r.ServeHTTP(w, req) @@ -169,6 +280,7 @@ func TestReset_CrossOriginPost_Rejected(t *testing.T) { body, _ := json.Marshal(map[string]string{"password": "attacker-set"}) req := httptest.NewRequest(http.MethodPost, "/api/tournament/reset", bytes.NewReader(body)) + req.RemoteAddr = loopbackPeer req.Header.Set("Content-Type", "application/json") req.Header.Set("Origin", "https://evil.example") req.Host = "tournament.local:8080" @@ -199,6 +311,7 @@ func TestReset_SameOriginPost_Accepted(t *testing.T) { body, _ := json.Marshal(map[string]string{"password": "newpw"}) req := httptest.NewRequest(http.MethodPost, "/api/tournament/reset", bytes.NewReader(body)) + req.RemoteAddr = loopbackPeer req.Header.Set("Content-Type", "application/json") req.Header.Set("Origin", "http://tournament.local:8080") req.Host = "tournament.local:8080" @@ -225,6 +338,7 @@ func TestReset_LanIPSameOrigin_Accepted(t *testing.T) { body, _ := json.Marshal(map[string]string{"password": "newpw"}) req := httptest.NewRequest(http.MethodPost, "/api/tournament/reset", bytes.NewReader(body)) + req.RemoteAddr = loopbackPeer req.Header.Set("Content-Type", "application/json") // LAN IP, Origin matches Host, so same-origin check passes. req.Header.Set("Origin", "http://192.168.1.100:8080") @@ -254,6 +368,7 @@ func TestReset_SchemeMismatch_Rejected(t *testing.T) { // Origin says HTTPS; server is HTTP (req.TLS == nil → expectedScheme = "http"). body, _ := json.Marshal(map[string]string{"password": "attacker"}) req := httptest.NewRequest(http.MethodPost, "/api/tournament/reset", bytes.NewReader(body)) + req.RemoteAddr = loopbackPeer req.Header.Set("Content-Type", "application/json") req.Header.Set("Origin", "https://tournament.local:8080") req.Host = "tournament.local:8080" @@ -285,6 +400,7 @@ func TestReset_OriginNull_Rejected(t *testing.T) { body, _ := json.Marshal(map[string]string{"password": "attacker"}) req := httptest.NewRequest(http.MethodPost, "/api/tournament/reset", bytes.NewReader(body)) + req.RemoteAddr = loopbackPeer req.Header.Set("Content-Type", "application/json") req.Header.Set("Origin", "null") w := httptest.NewRecorder() @@ -321,6 +437,7 @@ func TestReset_OriginMalformed_Rejected(t *testing.T) { t.Run(origin, func(t *testing.T) { body, _ := json.Marshal(map[string]string{"password": "attacker"}) req := httptest.NewRequest(http.MethodPost, "/api/tournament/reset", bytes.NewReader(body)) + req.RemoteAddr = loopbackPeer req.Header.Set("Content-Type", "application/json") req.Header.Set("Origin", origin) w := httptest.NewRecorder() @@ -348,6 +465,7 @@ func TestReset_NoOriginHeader_Accepted(t *testing.T) { body, _ := json.Marshal(map[string]string{"password": "newpw"}) req := httptest.NewRequest(http.MethodPost, "/api/tournament/reset", bytes.NewReader(body)) + req.RemoteAddr = loopbackPeer req.Header.Set("Content-Type", "application/json") w := httptest.NewRecorder() r.ServeHTTP(w, req) @@ -383,6 +501,7 @@ func TestReset_BroadcastsPasswordResetEvent(t *testing.T) { "originatorId": "client-abc-123", }) req := httptest.NewRequest(http.MethodPost, "/api/tournament/reset", bytes.NewReader(body)) + req.RemoteAddr = loopbackPeer req.Header.Set("Content-Type", "application/json") w := httptest.NewRecorder() r.ServeHTTP(w, req) @@ -435,6 +554,7 @@ func TestReset_OriginatorIDOversized_Returns400(t *testing.T) { "originatorId": strings.Repeat("x", MaxLenOriginatorID+1), }) req := httptest.NewRequest(http.MethodPost, "/api/tournament/reset", bytes.NewReader(body)) + req.RemoteAddr = loopbackPeer req.Header.Set("Content-Type", "application/json") w := httptest.NewRecorder() r.ServeHTTP(w, req) @@ -456,6 +576,7 @@ func TestReset_EmptyPassword_Returns400(t *testing.T) { body, _ := json.Marshal(map[string]string{"password": ""}) req := httptest.NewRequest(http.MethodPost, "/api/tournament/reset", bytes.NewReader(body)) + req.RemoteAddr = loopbackPeer req.Header.Set("Content-Type", "application/json") w := httptest.NewRecorder() r.ServeHTTP(w, req) @@ -478,6 +599,7 @@ func TestReset_OversizedPassword_Returns400(t *testing.T) { huge := strings.Repeat("x", MaxLenTournamentPassword+1) body, _ := json.Marshal(map[string]string{"password": huge}) req := httptest.NewRequest(http.MethodPost, "/api/tournament/reset", bytes.NewReader(body)) + req.RemoteAddr = loopbackPeer req.Header.Set("Content-Type", "application/json") w := httptest.NewRecorder() r.ServeHTTP(w, req) diff --git a/internal/mobileapp/score_exact_replay_test.go b/internal/mobileapp/score_exact_replay_test.go index 3d0ce65b2..2de9066d6 100644 --- a/internal/mobileapp/score_exact_replay_test.go +++ b/internal/mobileapp/score_exact_replay_test.go @@ -192,10 +192,7 @@ func TestScoreHandler_SelfRunExactResendOfAFinishIsAnsweredAsRecorded(t *testing history, err := store.LoadMatchHistory(compID, mid) require.NoError(t, err) - require.Len(t, history, 2, "the resend has its own history line, as the organiser's does") - for group, outcome := range history[1].Outcomes { - assert.Equal(t, state.HistoryOutcomeUnchanged, outcome, "group %s", group) - } + assert.Len(t, history, 1, "a participant's resend records no history line, so looping it cannot grow the file") // The same stamp with another scoreline is no resend. differs := exactReplayFinish(at) @@ -210,7 +207,7 @@ func TestScoreHandler_SelfRunExactResendOfAFinishIsAnsweredAsRecorded(t *testing assert.Equal(t, versionBefore, store.FileVersion(compID, file), "a refused resend writes nothing") history, err = store.LoadMatchHistory(compID, mid) require.NoError(t, err) - assert.Len(t, history, 2, "a refused resend leaves no history line") + assert.Len(t, history, 1, "a refused resend leaves no history line") }) } } diff --git a/specs/openapi.yaml b/specs/openapi.yaml index b4f1428b2..bad353c36 100644 --- a/specs/openapi.yaml +++ b/specs/openapi.yaml @@ -14,8 +14,10 @@ info: - **`GET /api/events`**: SSE stream; always public so viewers and spectator screens can receive real-time updates without an admin credential. - **`GET /api/auth-config`**: always public; carries no secret material. - - **`POST /api/tournament/reset`**: public in file mode (the recovery path for a - forgotten password); returns `404` in locked mode (`--lock-password`). + - **`POST /api/tournament/reset`**: needs no password in file mode (the recovery path + for a forgotten password) but answers only a caller on the machine running the + server (`403` `reset_local_only` otherwise); returns `404` in locked mode + (`--lock-password`). - **`GET /api/schedule/estimate`**: stateless schedule calculator; always public. - **`GET /api/competitions/{id}/competitor-status`**: public read; the `POST` write is protected. - **`GET /api/competitions/{id}/teams/{tid}/lineups/{round}`**: public read; `PUT`/`DELETE` are protected. @@ -31,8 +33,9 @@ info: an organiser at a table. Self-run mode has no check-in: the three check-in operations answer 409 `check_in_not_in_self_run` for every caller, and the draw includes every participant whatever a competition's `checkInEnabled` says. Destructive routes (delete competition, invalidate, discard - draw, result overrides, roster mutations, import) and organiser-owned setup - (tournament/competition config) stay gated. In the default `officiated` mode every + draw, result overrides, import) and organiser-owned setup + (tournament/competition config) stay gated. Adding and editing participants + (`POST .../participants`, `PUT .../participants/{pid}`) is open to anyone. In the default `officiated` mode every `/api/` route keeps requiring `X-Tournament-Password` as described above. The team writes the public score sheet makes when a competitor names a bout's fighter are open in self-run mode too: `PUT .../teams/{tid}/match-lineups/{matchId}`, @@ -360,7 +363,7 @@ paths: auto-complete check that runs after it). - `password_reset`: the admin password was changed. Emitted from three sources (file mode only; never emitted in locked mode): - 1. `POST /api/tournament/reset`; the public recovery endpoint. + 1. `POST /api/tournament/reset`; the recovery endpoint. Payload includes `originatorId` so the submitting tab can suppress its own broadcast: `data: { originatorId: "" }`. 2. `PUT /api/tournament`; when an admin changes the password @@ -526,9 +529,15 @@ paths: tags: [tournament] summary: Reset the admin password (file mode only) description: | - Replaces the stored admin password with the supplied value. Unauthenticated by - design; this is the documented recovery path for a forgotten admin password - on local / private deployments. + Replaces the stored admin password with the supplied value. Needs no password; + this is the documented recovery path for a forgotten admin password. + + The endpoint answers only a request from the machine running the server: the + TCP peer address must be a loopback address (127.0.0.0/8, `::1`, or an + IPv4-mapped 127/8). `X-Forwarded-For`, `X-Real-IP` and `Forwarded` are never + consulted. Any other peer receives `403` with the code `reset_local_only`. + A server in a Docker container started with `-p` sees a browser on the host + as a non-loopback peer, so the reset is refused there too. The endpoint is disabled in **locked mode** (`--lock-password` CLI flag, with `TOURNAMENT_PASSWORD_HASH` env var supplying a bcrypt hash). In locked mode the @@ -566,11 +575,17 @@ paths: $ref: '#/components/responses/BadRequest' '403': description: | - Cross-origin POST rejected. The handler requires both the Origin scheme and - host:port to match the server's effective scheme (http vs https) and `Host` - header (RFC 6454 §3.2). Rejected cases: scheme mismatch (e.g. HTTPS Origin - to a plain-HTTP server), host or port mismatch, malformed Origin URL, and - `Origin: null` (sandboxed iframes, file://, data: URLs). + Refused, in one of two ways. + + The request did not come from the machine running the server. The body is + `{"error": "Reset the password on the machine running the server.", "code": + "reset_local_only", "message": ""}`. + + Or a cross-origin POST was rejected. The handler requires both the Origin + scheme and host:port to match the server's effective scheme (http vs https) + and `Host` header (RFC 6454 §3.2). Rejected cases: scheme mismatch (e.g. + HTTPS Origin to a plain-HTTP server), host or port mismatch, malformed + Origin URL, and `Origin: null` (sandboxed iframes, file://, data: URLs). '404': description: Reset disabled (locked mode active). '409': @@ -631,8 +646,7 @@ paths: - `team_size` must be stated (and be >= 2) whenever `kind` is `"team"`. An omitted key decodes to 0, which is refused with `teamSize: team competitions require teamSize >= 2` -- the same - answer the create endpoint gives. A row that omitted it was - accepted by earlier versions; it now fails that row while the + answer the create endpoint gives. Such a row fails while the rest of the import proceeds. `pool_size` and `pool_size_mode`, by contrast, ARE defaulted when @@ -1283,7 +1297,7 @@ paths: (`/start`, `/complete`, `/invalidate`) to change status. The ROSTER-save path (non-null `players`) rewrites `participants.csv` - and, like every other route here, needs only `X-Tournament-Password`. + and needs only `X-Tournament-Password`. In self-run mode this is an organiser action: it needs `X-Tournament-Password`. @@ -1647,7 +1661,10 @@ paths: Transitions a competition from `pools` or `knockout` status to `completed`. Irreversible. Returns 400 if the competition is not in a completable state, or if its bracket still has a match to play (every bracket match, the - 3rd-place match included, must be completed). + 3rd-place match included, must be completed). A competition with no + knockout rounds (a league, a pools-only or a Swiss one) is held to its + own matches instead: every match must be completed, and a Swiss one must + have drawn all its configured rounds. In officiated mode it needs `X-Tournament-Password`, like starting a competition; in self-run mode it is public, like starting one. @@ -2021,8 +2038,8 @@ paths: description: | Replaces the current participants list for the competition with the provided JSON. - In self-run mode this is an organiser action: it needs - `X-Tournament-Password`. + In self-run mode this is public, like starting a competition: anyone can + add participants while the competition is in setup. security: - TournamentPassword: [] parameters: @@ -2108,8 +2125,7 @@ paths: Returns the updated player. When the cascade detects dojo conflicts, a `warnings` array is included alongside the player fields. - In self-run mode this is an organiser action: it needs - `X-Tournament-Password`. + In self-run mode this is public: anyone can edit participants. security: - TournamentPassword: [] requestBody: @@ -5287,8 +5303,9 @@ components: name: X-Tournament-Password description: | Tournament admin password. Required for all `/api/` routes - except `/api/viewer/`, `/api/events`, the unauthenticated - recovery endpoints `/api/auth-config` and `/api/tournament/reset`, + except `/api/viewer/`, `/api/events`, the recovery endpoints + `/api/auth-config` and `/api/tournament/reset` (which answers only the + machine running the server), the public read endpoints `GET /api/schedule/estimate`, `GET /api/competitions/{id}/competitor-status`, `GET /api/competitions/{id}/teams/{tid}/lineups/{round}`, @@ -5305,8 +5322,8 @@ components: against the `password` field stored in `tournament.md` (no trimming on either side). Set during the initial bootstrap via `POST /api/tournament`, rotated via `PUT /api/tournament` - with a non-empty `password`, or via the public - `POST /api/tournament/reset` recovery endpoint. + with a non-empty `password`, or via the `POST /api/tournament/reset` + recovery endpoint (from the machine running the server). Bootstrap exception: when no tournament is configured yet, `POST/PUT /api/tournament` is allowed without the header so @@ -5999,10 +6016,10 @@ components: (scoring, start, complete, swiss round generation) are public; no password required. There is no check-in. Organiser-setup mutations (tournament/competition config, seeds, override edits (set/change result), announcements, court - assignment, export, participant roster mutations, - fighting-spirit awards, the tournament logo and sponsor images) and - destructive actions (delete or invalidate a competition, discard a draw, - reset overrides, import) remain behind the main password. + assignment, export, fighting-spirit awards, the tournament logo and + sponsor images) and destructive actions (delete or invalidate a + competition, discard a draw, reset overrides, import) remain behind + the main password. Anyone can add and edit participants. Always returned in GET/POST/PUT responses regardless of auth mode (unlike `password`, which is redacted in locked mode). Defaults to `officiated` when diff --git a/web-mobile/js/__tests__/api.test.jsx b/web-mobile/js/__tests__/api.test.jsx index 703ff679c..983549ca8 100644 --- a/web-mobile/js/__tests__/api.test.jsx +++ b/web-mobile/js/__tests__/api.test.jsx @@ -1538,6 +1538,24 @@ describe('API Utils', () => { } }); + it('carries the server refusal code on the thrown Error', async () => { + global.fetch = vi.fn().mockResolvedValue({ + ok: false, + status: 403, + json: async () => ({ + error: 'Reset the password on the machine running the server.', + code: 'reset_local_only', + }), + }); + try { + await API.resetPassword('whatever'); + throw new Error('should have rejected'); + } catch (e) { + expect(e.status).toBe(403); + expect(e.code).toBe('reset_local_only'); + } + }); + it('throws with default message if response json parse fails', async () => { global.fetch = vi.fn().mockResolvedValue({ ok: false, diff --git a/web-mobile/js/__tests__/render/admin_setup_shiaijo.render.test.jsx b/web-mobile/js/__tests__/render/admin_setup_shiaijo.render.test.jsx index 1ad206a4d..fe7fae4e7 100644 --- a/web-mobile/js/__tests__/render/admin_setup_shiaijo.render.test.jsx +++ b/web-mobile/js/__tests__/render/admin_setup_shiaijo.render.test.jsx @@ -95,8 +95,11 @@ describe('Tournament type line (self-run has no check-in)', () => { const { container } = await mountEdit(makeTournament({ mode: 'self-run' })); const text = typeHint(container); expect(text).toContain('Self-run'); - expect(text).toContain('Scoring and starting and completing a competition are public.'); + expect(text).toContain('Scoring is public, and so are starting and completing a competition.'); expect(text).toContain('Self-run mode has no check-in.'); + expect(text).toContain('need the admin password'); + expect(text).toContain('deleting a competition asks for it again'); + expect(text).not.toContain('tournament password'); expect(text).not.toContain('roster changes'); }); diff --git a/web-mobile/js/__tests__/render/create_tournament_labels.render.test.jsx b/web-mobile/js/__tests__/render/create_tournament_labels.render.test.jsx index 04844bfbb..bccded4fc 100644 --- a/web-mobile/js/__tests__/render/create_tournament_labels.render.test.jsx +++ b/web-mobile/js/__tests__/render/create_tournament_labels.render.test.jsx @@ -66,4 +66,11 @@ describe('the first-run tournament form', () => { expect(document.body.textContent).not.toContain('scoring and check-in'); expect(document.body.textContent).not.toContain('roster changes'); }); + + it('names the admin password, never the tournament password, in the self-run description', () => { + expect(document.body.textContent).toContain('Scoring is public, and so are starting and completing a competition.'); + expect(document.body.textContent).toContain('need the admin password'); + expect(document.body.textContent).toContain('deleting a competition asks for it again'); + expect(document.body.textContent).not.toContain('tournament password'); + }); }); diff --git a/web-mobile/js/__tests__/reset.test.jsx b/web-mobile/js/__tests__/reset.test.jsx index bead99b05..e582df48d 100644 --- a/web-mobile/js/__tests__/reset.test.jsx +++ b/web-mobile/js/__tests__/reset.test.jsx @@ -227,6 +227,22 @@ describe('ResetPasswordForm', () => { expect(mockStorage.setItem).not.toHaveBeenCalledWith('bc_password', expect.anything()); }); + it('shows the local-only sentence when the server refuses a reset from another machine', async () => { + const err403 = new Error('refused'); + err403.status = 403; + err403.code = 'reset_local_only'; + window.API.resetPassword = vi.fn(async () => { throw err403; }); + const onSuccess = vi.fn(); + const { typeInto, submit } = setupForm({ onSuccess }); + typeInto(0, 'newpw'); + typeInto(1, 'newpw'); + await submit(); + + expect(collectText(runtime.currentTree())).toContain('Reset the password on the machine running the server.'); + expect(onSuccess).not.toHaveBeenCalled(); + expect(mockStorage.setItem).not.toHaveBeenCalledWith('bc_password', expect.anything()); + }); + it('surfaces a generic error from the server on non-404 failures', async () => { window.API.resetPassword = vi.fn(async () => { throw new Error('store unavailable'); }); const { typeInto, submit } = setupForm(); diff --git a/web-mobile/js/admin_setup.jsx b/web-mobile/js/admin_setup.jsx index aca8a6c8d..3ca823b0c 100644 --- a/web-mobile/js/admin_setup.jsx +++ b/web-mobile/js/admin_setup.jsx @@ -469,8 +469,8 @@ function AdminEditTournament({ tournament, onCancel, onSave, onLogout, onViewerM
{tournamentMode === "self-run" ? "Self-run" : "Officiated"} {tournamentMode === "self-run" - ? `: Scoring and starting and completing a competition are public. Anyone can add and edit participants. ${SELF_RUN_NO_CHECK_IN} Organiser actions such as setup, deleting and importing still need the tournament password.` - : ": All admin actions require the tournament password."} + ? `: Scoring is public, and so are starting and completing a competition. Anyone can add and edit participants. ${SELF_RUN_NO_CHECK_IN} Organiser setup, corrections and destructive actions such as deleting and importing need the admin password, and deleting a competition asks for it again.` + : ": All admin actions require the admin password."} {" "}This setting was fixed at creation and cannot be changed.
diff --git a/web-mobile/js/api_client.jsx b/web-mobile/js/api_client.jsx index 276a6e32e..123a3426e 100644 --- a/web-mobile/js/api_client.jsx +++ b/web-mobile/js/api_client.jsx @@ -2801,6 +2801,9 @@ const API = { const err = await res.json().catch(() => ({})); const e = new Error(err.error || `Failed to reset password (Status ${res.status})`); e.status = res.status; + // The refusal code (reset_local_only) lets the form word the + // case; absent on a bare error. + if (err.code) e.code = err.code; throw e; } // Backend returns 204 No Content: calling .json() on an empty diff --git a/web-mobile/js/app.jsx b/web-mobile/js/app.jsx index f1720bfc9..a7dd935c3 100644 --- a/web-mobile/js/app.jsx +++ b/web-mobile/js/app.jsx @@ -1957,8 +1957,8 @@ function CreateTournament({ onCreated, authConfig }) {
{mode === "officiated" - ? "An operator manages scoring and bracket progression. All admin actions require the tournament password. This is the standard setup." - : `No dedicated operator. Participants self-report scores; scoring and starting and completing a competition are public. Anyone can add and edit participants. ${SELF_RUN_NO_CHECK_IN} Organiser actions such as setup, deleting and importing still need the tournament password. Cannot be changed after creation.`} + ? "An operator manages scoring and bracket progression. All admin actions require the admin password. This is the standard setup." + : `No dedicated operator. Participants self-report scores. Scoring is public, and so are starting and completing a competition. Anyone can add and edit participants. ${SELF_RUN_NO_CHECK_IN} Organiser setup, corrections and destructive actions such as deleting and importing need the admin password, and deleting a competition asks for it again. Cannot be changed after creation.`}
diff --git a/web-mobile/js/reset.jsx b/web-mobile/js/reset.jsx index 87d4dd3ab..68b52e22e 100644 --- a/web-mobile/js/reset.jsx +++ b/web-mobile/js/reset.jsx @@ -2,7 +2,9 @@ // the admin password set a new one without authenticating against the // old one (since they don't know it). The backing endpoint is // POST /api/tournament/reset (handlers_reset.go); it is unauthenticated -// by design and the operator hardens production deployments by passing +// by design but answers only a browser on the machine running the server +// (403 reset_local_only otherwise), and the operator hardens production +// deployments by passing // --lock-password (mobile-app CLI) which 404s the reset endpoint AND // switches auth to a bcrypt env-var hash. // @@ -102,6 +104,10 @@ function ResetPasswordForm({ authConfig, onBack, onSuccess, originatorId }) { // flipped --lock-password while the SPA was open). if (e2.status === 404) { setErr("Password reset has been disabled by the operator."); + } else if (e2.code === "reset_local_only") { + // File mode answers only a browser on the machine running the + // server (handlers_reset.go); the same sentence the server sends. + setErr("Reset the password on the machine running the server."); } else { setErr(e2.message || "Failed to reset password. Please try again."); } diff --git a/web-mobile/js/viewer_home.jsx b/web-mobile/js/viewer_home.jsx index fd8cb62b9..331e4dbe4 100644 --- a/web-mobile/js/viewer_home.jsx +++ b/web-mobile/js/viewer_home.jsx @@ -127,7 +127,7 @@ export function ViewerHome({ tournament, onSelectCompetition, onAdminClick, onOp // single "followed player" is migrated into the list once (see useWatchlist). const [watchlist, setWatchlist] = useWatchlist(); const [primaryKey, setPrimaryKey] = usePrimaryWatch(); - const roster = useMemo(() => buildRoster(t.competitions, t), [t.competitions, t]); + const roster = useMemo(() => buildRoster(t.competitions, t), [t]); // Derived beside the roster because it qualifies it: a non-empty roster does // not mean every competition's participants loaded, and only the panel's // "not in this tournament" claim depends on the difference. From 512e38f02386c346d297448f1acca7e5de52a556 Mon Sep 17 00:00:00 2001 From: Ricardo Oliveira Date: Tue, 13 Oct 2026 09:50:53 +0100 Subject: [PATCH 24/30] fix(bc-cmpw): reset also requires a loopback Host A page whose name rebinds to 127.0.0.1, opened in a browser on the server machine, passed the loopback-peer and same-origin checks. The reset now also requires the request's Host to be localhost or a loopback address, else the same 403 reset_local_only. Co-Authored-By: Claude Opus 5.5 --- docs/user-guide/commands/mobile-app.md | 4 +- docs/user-guide/organisers/operating-modes.md | 2 +- internal/mobileapp/coverage_gap_test.go | 1 + internal/mobileapp/handlers_reset.go | 43 +++++-- internal/mobileapp/handlers_reset_test.go | 118 ++++++++++++++---- specs/openapi.yaml | 9 +- web-mobile/js/api_client.jsx | 4 +- web-mobile/js/reset.jsx | 3 +- 8 files changed, 137 insertions(+), 47 deletions(-) diff --git a/docs/user-guide/commands/mobile-app.md b/docs/user-guide/commands/mobile-app.md index fa809ae58..2376b69db 100644 --- a/docs/user-guide/commands/mobile-app.md +++ b/docs/user-guide/commands/mobile-app.md @@ -35,7 +35,7 @@ The server has two authentication modes for the admin console; viewer routes are The admin password is stored plaintext in `tournament-data/tournament.md` and compared by exact-string match. Set the password during the in-app **Create tournament** flow, or edit `tournament.md` directly. -- `POST /api/tournament/reset` needs no password but answers only a request from the machine running the server (a loopback address). Any other machine gets `403` with the code `reset_local_only`. If you've forgotten the current password, browse to `http://localhost:/reset` on the machine running the server to set a new one. In a Docker container run with `-p`, a browser on the host reaches the server through the container network, not loopback, so the reset is refused there too; edit `tournament.md` in the mounted data folder instead. +- `POST /api/tournament/reset` needs no password but answers only a request from the machine running the server (a loopback address) that is addressed to a loopback name: `localhost`, `127.0.0.1` or `[::1]`. Any other request, from another machine or addressed by another name, gets `403` with the code `reset_local_only`. If you've forgotten the current password, browse to `http://localhost:/reset` on the machine running the server to set a new one. In a Docker container run with `-p`, a browser on the host reaches the server through the container network, not loopback, so the reset is refused there too; edit `tournament.md` in the mounted data folder instead. - `GET /api/auth-config` returns `{"mode": "file", "resetEnabled": true}`. ### Locked mode (`--lock-password`) @@ -64,7 +64,7 @@ Recommended for any deployment reachable over the internet. The plaintext-in-fil #### Operational caveats -- **`POST /api/tournament/reset` is limited to the machine running the server, judged on the connection's peer address.** `X-Forwarded-For` and similar headers are not consulted. Behind a reverse proxy on the same machine, every request arrives from the proxy's loopback address, so the reset is reachable by whoever the proxy exposes it to; block that path at the proxy or run with `--lock-password` (which 404s `POST /api/tournament/reset`). +- **`POST /api/tournament/reset` is limited to the machine running the server, judged on the connection's peer address and on the request's `Host`, which must be a loopback name.** `X-Forwarded-For` and similar headers are not consulted. The `Host` rule means a web page whose domain was rebound to `127.0.0.1` in a browser on the server machine is refused. Behind a reverse proxy on the same machine, every request arrives from the proxy's loopback address, so the reset is reachable by whoever the proxy exposes it to; block that path at the proxy or run with `--lock-password` (which 404s `POST /api/tournament/reset`). - **Locked mode has no bcrypt brute-force protection.** The server runs a full bcrypt comparison (`bcrypt.DefaultCost` approx 50-100 ms) on every `X-Tournament-Password` header, but does not throttle repeated failed attempts. For internet-exposed locked-mode deployments, add a reverse proxy that rate-limits authenticated routes (for example, nginx's `limit_req`). - **Mode switching preserves the stored password.** Switching from file mode to locked mode does NOT erase `tournament.md`'s `password` field; auth stops consulting it. A later switch back to file mode resurrects the original password. This is a deliberate rollback feature, but anyone with filesystem access can still read the value. To fully retire a file-mode credential before going locked, `POST /api/tournament/reset` it to a one-time throwaway first. diff --git a/docs/user-guide/organisers/operating-modes.md b/docs/user-guide/organisers/operating-modes.md index a03f5cb58..f7157a446 100644 --- a/docs/user-guide/organisers/operating-modes.md +++ b/docs/user-guide/organisers/operating-modes.md @@ -36,7 +36,7 @@ Authentication mode controls how the admin password is stored and verified. Ther In file mode, the admin password is stored in plain text in the `tournament.md` data file. You set it during tournament creation, or you can edit the file directly. -If you forget the password, browse to `http://localhost:/reset` in a browser on the machine running the server and choose a new one. No old password is required. In file mode the password can be reset only from that machine: a request from any other machine is refused with "Reset the password on the machine running the server." When the server runs in a Docker container started with `-p`, a browser on the host reaches it through the container network, not loopback, so the reset is refused there too; edit `tournament.md` in the mounted data folder instead. +If you forget the password, browse to `http://localhost:/reset` in a browser on the machine running the server and choose a new one. No old password is required. In file mode the password can be reset only from that machine: a request from any other machine, or one addressed by a name other than `localhost`, `127.0.0.1` or `[::1]`, is refused with "Reset the password on the machine running the server." When the server runs in a Docker container started with `-p`, a browser on the host reaches it through the container network, not loopback, so the reset is refused there too; edit `tournament.md` in the mounted data folder instead. !!! note Switching from file mode to locked mode does not erase the on-disk password. The app ignores it while locked mode is active. Switching back re-enables the stored value. diff --git a/internal/mobileapp/coverage_gap_test.go b/internal/mobileapp/coverage_gap_test.go index 5968e87d3..147df8bfe 100644 --- a/internal/mobileapp/coverage_gap_test.go +++ b/internal/mobileapp/coverage_gap_test.go @@ -619,6 +619,7 @@ func TestReset_BadJSON(t *testing.T) { w := httptest.NewRecorder() req := httptest.NewRequest("POST", "/api/tournament/reset", bytes.NewBufferString("{invalid")) req.RemoteAddr = loopbackPeer + req.Host = "localhost:8080" req.Header.Set("Content-Type", "application/json") r.ServeHTTP(w, req) assert.Equal(t, http.StatusBadRequest, w.Code) diff --git a/internal/mobileapp/handlers_reset.go b/internal/mobileapp/handlers_reset.go index 9d1596adc..5c70e64ee 100644 --- a/internal/mobileapp/handlers_reset.go +++ b/internal/mobileapp/handlers_reset.go @@ -84,18 +84,12 @@ var errResetPasswordRequired = errors.New("password is required") // /reset page still renders a disabled-state message) and rotate // credentials via env-var hash; the recovery endpoint is designed // for direct same-host operator access. -// - DNS rebinding: a malicious page can rebind its domain to the -// tournament server's IP so that both Origin.Host and c.Request.Host -// equal the attacker's domain, passing the Origin == Host check even -// though the request reaches this server. This endpoint is intended -// for trusted networks (local / private LAN); for any -// internet-exposed deployment use --lock-password instead, which -// disables this endpoint entirely. +// - DNS rebinding: a malicious page opened in a browser on the server +// machine can rebind its domain to 127.0.0.1, so that the peer is +// loopback and both Origin.Host and c.Request.Host equal the +// attacker's domain. isLoopbackHost closes this: the Host must be a +// loopback name, which the attacker's domain is not. // -// We deliberately do NOT restrict to loopback-only: the recovery path -// must be reachable from any browser tab on the same network so -// operators at a scoring table on the far side of the venue can reset -// without SSH access to the server. // resetLocalOnlyCode and resetLocalOnlySentence are the refusal of a reset // that does not come from the machine running the server. The SPA shows the // sentence when it sees the code. @@ -115,10 +109,32 @@ func isLoopbackPeer(r *http.Request) bool { if err != nil { return false } + return isLoopbackIP(host) +} + +// isLoopbackIP reports whether host is a loopback IP literal (127.0.0.0/8, ::1, +// or an IPv4-mapped 127/8). A name or an address that does not parse is not. +func isLoopbackIP(host string) bool { ip := net.ParseIP(host) return ip != nil && ip.IsLoopback() } +// isLoopbackHost reports whether the request was addressed to the machine +// running the server by a loopback name: "localhost" (any case), a loopback IPv4 +// or a bracketed loopback IPv6, each with or without a port. A page whose +// hostname was rebound to 127.0.0.1 (DNS rebinding) reaches this server from a +// loopback peer but carries its own name as Host, so the peer test alone cannot +// tell it from the operator's browser. +func isLoopbackHost(r *http.Request) bool { + host := r.Host + if h, _, err := net.SplitHostPort(host); err == nil { + host = h + } else if strings.HasPrefix(host, "[") && strings.HasSuffix(host, "]") { + host = host[1 : len(host)-1] + } + return strings.EqualFold(host, "localhost") || isLoopbackIP(host) +} + func isSameOriginReset(c *gin.Context) bool { origin := strings.TrimSpace(c.GetHeader("Origin")) if origin == "" { @@ -168,8 +184,9 @@ func RegisterResetHandlers(r *gin.RouterGroup, store *state.Store, verifier Pass } // File mode answers only the machine running the server, judged on - // the TCP peer address (see isLoopbackPeer). - if !isLoopbackPeer(c.Request) { + // the TCP peer address (see isLoopbackPeer) and on a loopback Host + // (see isLoopbackHost), one refusal for both. + if !isLoopbackPeer(c.Request) || !isLoopbackHost(c.Request) { c.JSON(http.StatusForbidden, gin.H{ "error": resetLocalOnlySentence, "code": resetLocalOnlyCode, diff --git a/internal/mobileapp/handlers_reset_test.go b/internal/mobileapp/handlers_reset_test.go index 90888537d..ba4523f27 100644 --- a/internal/mobileapp/handlers_reset_test.go +++ b/internal/mobileapp/handlers_reset_test.go @@ -64,9 +64,16 @@ func setupFileModeReset(t *testing.T) (*state.Store, *gin.Engine) { // postReset sends a reset POST from the given TCP peer address and returns the // recorder, so each local-only case states only the peer and the extra headers. func postReset(r *gin.Engine, remoteAddr string, headers map[string]string) *httptest.ResponseRecorder { + return postResetHost(r, remoteAddr, "localhost:8080", headers) +} + +// postResetHost posts a reset with an explicit Host header. httptest.NewRequest +// defaults it to "example.com", which is not a loopback name. +func postResetHost(r *gin.Engine, remoteAddr, host string, headers map[string]string) *httptest.ResponseRecorder { body, _ := json.Marshal(map[string]string{"password": "new-password"}) req := httptest.NewRequest(http.MethodPost, "/api/tournament/reset", bytes.NewReader(body)) req.RemoteAddr = remoteAddr + req.Host = host req.Header.Set("Content-Type", "application/json") for k, v := range headers { req.Header.Set(k, v) @@ -96,6 +103,62 @@ func TestReset_FileMode_LoopbackPeers_Succeed(t *testing.T) { } } +// A loopback peer is answered only when the Host is a loopback name too: a page +// whose hostname was rebound to 127.0.0.1 arrives from a loopback peer with its +// own name as Host. +func TestReset_FileMode_LoopbackHosts_Succeed(t *testing.T) { + for _, host := range []string{ + "localhost:8080", + "localhost", + "LOCALHOST:8080", + "127.0.0.1:8080", + "127.0.0.1", + "127.8.9.10:8080", + "[::1]:8080", + "[::1]", + "[::ffff:127.0.0.1]:8080", + } { + t.Run(host, func(t *testing.T) { + store, r := setupFileModeReset(t) + + w := postResetHost(r, loopbackPeer, host, nil) + + assert.Equal(t, http.StatusNoContent, w.Code) + loaded, err := store.LoadTournament() + require.NoError(t, err) + assert.Equal(t, "new-password", loaded.Password) + }) + } +} + +func TestReset_FileMode_NonLoopbackHost_Refused(t *testing.T) { + for _, host := range []string{ + "evil.example:8080", + "evil.example", + "example.com", + "192.168.1.100:8080", + "localhost.evil.example:8080", + "127.0.0.1.evil.example", + "[2001:db8::1]:8080", + "", + } { + t.Run(host, func(t *testing.T) { + store, r := setupFileModeReset(t) + + // Origin equals Host: the same-origin check alone would pass. + headers := map[string]string{"Origin": "http://" + host} + w := postResetHost(r, loopbackPeer, host, headers) + + assert.Equal(t, http.StatusForbidden, w.Code) + assert.Contains(t, w.Body.String(), "reset_local_only") + assert.Contains(t, w.Body.String(), "Reset the password on the machine running the server.") + loaded, err := store.LoadTournament() + require.NoError(t, err) + assert.Equal(t, "old", loaded.Password, "a refused reset must leave the password alone") + }) + } +} + func TestReset_FileMode_NonLoopbackPeer_Refused(t *testing.T) { for _, peer := range []string{ "192.0.2.1:1234", @@ -168,6 +231,7 @@ func TestReset_FileMode_Success(t *testing.T) { body, _ := json.Marshal(map[string]string{"password": "new-password"}) req := httptest.NewRequest(http.MethodPost, "/api/tournament/reset", bytes.NewReader(body)) req.RemoteAddr = loopbackPeer + req.Host = "localhost:8080" req.Header.Set("Content-Type", "application/json") w := httptest.NewRecorder() r.ServeHTTP(w, req) @@ -199,6 +263,7 @@ func TestReset_LockedMode_Returns404(t *testing.T) { body, _ := json.Marshal(map[string]string{"password": "new-password"}) req := httptest.NewRequest(http.MethodPost, "/api/tournament/reset", bytes.NewReader(body)) req.RemoteAddr = loopbackPeer + req.Host = "localhost:8080" req.Header.Set("Content-Type", "application/json") w := httptest.NewRecorder() r.ServeHTTP(w, req) @@ -222,6 +287,7 @@ func TestReset_NoTournament_Returns409(t *testing.T) { body, _ := json.Marshal(map[string]string{"password": "new-password"}) req := httptest.NewRequest(http.MethodPost, "/api/tournament/reset", bytes.NewReader(body)) req.RemoteAddr = loopbackPeer + req.Host = "localhost:8080" req.Header.Set("Content-Type", "application/json") w := httptest.NewRecorder() r.ServeHTTP(w, req) @@ -252,6 +318,7 @@ func TestReset_NamedNewTournamentWithPassword_AllowsReset(t *testing.T) { body, _ := json.Marshal(map[string]string{"password": "fresh"}) req := httptest.NewRequest(http.MethodPost, "/api/tournament/reset", bytes.NewReader(body)) req.RemoteAddr = loopbackPeer + req.Host = "localhost:8080" req.Header.Set("Content-Type", "application/json") w := httptest.NewRecorder() r.ServeHTTP(w, req) @@ -281,9 +348,9 @@ func TestReset_CrossOriginPost_Rejected(t *testing.T) { body, _ := json.Marshal(map[string]string{"password": "attacker-set"}) req := httptest.NewRequest(http.MethodPost, "/api/tournament/reset", bytes.NewReader(body)) req.RemoteAddr = loopbackPeer + req.Host = "localhost:8080" req.Header.Set("Content-Type", "application/json") req.Header.Set("Origin", "https://evil.example") - req.Host = "tournament.local:8080" w := httptest.NewRecorder() r.ServeHTTP(w, req) @@ -312,9 +379,9 @@ func TestReset_SameOriginPost_Accepted(t *testing.T) { body, _ := json.Marshal(map[string]string{"password": "newpw"}) req := httptest.NewRequest(http.MethodPost, "/api/tournament/reset", bytes.NewReader(body)) req.RemoteAddr = loopbackPeer + req.Host = "localhost:8080" req.Header.Set("Content-Type", "application/json") - req.Header.Set("Origin", "http://tournament.local:8080") - req.Host = "tournament.local:8080" + req.Header.Set("Origin", "http://localhost:8080") w := httptest.NewRecorder() r.ServeHTTP(w, req) @@ -324,29 +391,20 @@ func TestReset_SameOriginPost_Accepted(t *testing.T) { // A browser on a LAN IP (non-loopback) with matching Origin/Host is accepted. // Operators access the server from other devices on the same network; the // same-origin check (Origin == Host) is sufficient protection here. -func TestReset_LanIPSameOrigin_Accepted(t *testing.T) { - store, err := state.NewStore(t.TempDir()) - require.NoError(t, err) - r := gin.New() - api := r.Group("/api") - RegisterResetHandlers(api, store, NewFileVerifier(store), NewHub()) - - require.NoError(t, store.SaveTournament(&state.Tournament{ - Name: "T", - Password: "old", - })) +func TestReset_LanIPHost_Refused(t *testing.T) { + store, r := setupFileModeReset(t) - body, _ := json.Marshal(map[string]string{"password": "newpw"}) - req := httptest.NewRequest(http.MethodPost, "/api/tournament/reset", bytes.NewReader(body)) - req.RemoteAddr = loopbackPeer - req.Header.Set("Content-Type", "application/json") - // LAN IP, Origin matches Host, so same-origin check passes. - req.Header.Set("Origin", "http://192.168.1.100:8080") - req.Host = "192.168.1.100:8080" - w := httptest.NewRecorder() - r.ServeHTTP(w, req) + // A loopback peer addressed by the machine's LAN IP, Origin matching Host: + // the same-origin check passes, the loopback Host check does not. + w := postResetHost(r, loopbackPeer, "192.168.1.100:8080", map[string]string{ + "Origin": "http://192.168.1.100:8080", + }) - assert.Equal(t, http.StatusNoContent, w.Code) + assert.Equal(t, http.StatusForbidden, w.Code) + assert.Contains(t, w.Body.String(), "reset_local_only") + loaded, err := store.LoadTournament() + require.NoError(t, err) + assert.Equal(t, "old", loaded.Password) } // A browser on an HTTPS origin that POSTs to an HTTP tournament server @@ -369,14 +427,15 @@ func TestReset_SchemeMismatch_Rejected(t *testing.T) { body, _ := json.Marshal(map[string]string{"password": "attacker"}) req := httptest.NewRequest(http.MethodPost, "/api/tournament/reset", bytes.NewReader(body)) req.RemoteAddr = loopbackPeer + req.Host = "localhost:8080" req.Header.Set("Content-Type", "application/json") - req.Header.Set("Origin", "https://tournament.local:8080") - req.Host = "tournament.local:8080" + req.Header.Set("Origin", "https://localhost:8080") // req.TLS is nil by default (http), scheme mismatch with https Origin w := httptest.NewRecorder() r.ServeHTTP(w, req) assert.Equal(t, http.StatusForbidden, w.Code, "https Origin against http server must be rejected") + assert.Contains(t, w.Body.String(), "cross-origin") loaded, _ := store.LoadTournament() assert.Equal(t, "old", loaded.Password) } @@ -401,6 +460,7 @@ func TestReset_OriginNull_Rejected(t *testing.T) { body, _ := json.Marshal(map[string]string{"password": "attacker"}) req := httptest.NewRequest(http.MethodPost, "/api/tournament/reset", bytes.NewReader(body)) req.RemoteAddr = loopbackPeer + req.Host = "localhost:8080" req.Header.Set("Content-Type", "application/json") req.Header.Set("Origin", "null") w := httptest.NewRecorder() @@ -438,6 +498,7 @@ func TestReset_OriginMalformed_Rejected(t *testing.T) { body, _ := json.Marshal(map[string]string{"password": "attacker"}) req := httptest.NewRequest(http.MethodPost, "/api/tournament/reset", bytes.NewReader(body)) req.RemoteAddr = loopbackPeer + req.Host = "localhost:8080" req.Header.Set("Content-Type", "application/json") req.Header.Set("Origin", origin) w := httptest.NewRecorder() @@ -466,6 +527,7 @@ func TestReset_NoOriginHeader_Accepted(t *testing.T) { body, _ := json.Marshal(map[string]string{"password": "newpw"}) req := httptest.NewRequest(http.MethodPost, "/api/tournament/reset", bytes.NewReader(body)) req.RemoteAddr = loopbackPeer + req.Host = "localhost:8080" req.Header.Set("Content-Type", "application/json") w := httptest.NewRecorder() r.ServeHTTP(w, req) @@ -502,6 +564,7 @@ func TestReset_BroadcastsPasswordResetEvent(t *testing.T) { }) req := httptest.NewRequest(http.MethodPost, "/api/tournament/reset", bytes.NewReader(body)) req.RemoteAddr = loopbackPeer + req.Host = "localhost:8080" req.Header.Set("Content-Type", "application/json") w := httptest.NewRecorder() r.ServeHTTP(w, req) @@ -555,6 +618,7 @@ func TestReset_OriginatorIDOversized_Returns400(t *testing.T) { }) req := httptest.NewRequest(http.MethodPost, "/api/tournament/reset", bytes.NewReader(body)) req.RemoteAddr = loopbackPeer + req.Host = "localhost:8080" req.Header.Set("Content-Type", "application/json") w := httptest.NewRecorder() r.ServeHTTP(w, req) @@ -577,6 +641,7 @@ func TestReset_EmptyPassword_Returns400(t *testing.T) { body, _ := json.Marshal(map[string]string{"password": ""}) req := httptest.NewRequest(http.MethodPost, "/api/tournament/reset", bytes.NewReader(body)) req.RemoteAddr = loopbackPeer + req.Host = "localhost:8080" req.Header.Set("Content-Type", "application/json") w := httptest.NewRecorder() r.ServeHTTP(w, req) @@ -600,6 +665,7 @@ func TestReset_OversizedPassword_Returns400(t *testing.T) { body, _ := json.Marshal(map[string]string{"password": huge}) req := httptest.NewRequest(http.MethodPost, "/api/tournament/reset", bytes.NewReader(body)) req.RemoteAddr = loopbackPeer + req.Host = "localhost:8080" req.Header.Set("Content-Type", "application/json") w := httptest.NewRecorder() r.ServeHTTP(w, req) diff --git a/specs/openapi.yaml b/specs/openapi.yaml index bad353c36..f5cbde383 100644 --- a/specs/openapi.yaml +++ b/specs/openapi.yaml @@ -534,8 +534,10 @@ paths: The endpoint answers only a request from the machine running the server: the TCP peer address must be a loopback address (127.0.0.0/8, `::1`, or an - IPv4-mapped 127/8). `X-Forwarded-For`, `X-Real-IP` and `Forwarded` are never - consulted. Any other peer receives `403` with the code `reset_local_only`. + IPv4-mapped 127/8), and the request's `Host` must be a loopback name + (`localhost`, a 127/8 IPv4 or a bracketed `[::1]`, with or without a port). + `X-Forwarded-For`, `X-Real-IP` and `Forwarded` are never consulted. Any + other peer or Host receives `403` with the code `reset_local_only`. A server in a Docker container started with `-p` sees a browser on the host as a non-loopback peer, so the reset is refused there too. @@ -577,7 +579,8 @@ paths: description: | Refused, in one of two ways. - The request did not come from the machine running the server. The body is + The request did not come from the machine running the server, or its `Host` + is not a loopback name. The body is `{"error": "Reset the password on the machine running the server.", "code": "reset_local_only", "message": ""}`. diff --git a/web-mobile/js/api_client.jsx b/web-mobile/js/api_client.jsx index 123a3426e..b347c4c08 100644 --- a/web-mobile/js/api_client.jsx +++ b/web-mobile/js/api_client.jsx @@ -2779,7 +2779,9 @@ const API = { }, // Reset the tournament password. Unauthenticated by design: the // server enforces "is this endpoint enabled" via the verifier's - // ResetEnabled() (locked mode returns 404). Throws on non-2xx so + // ResetEnabled() (locked mode returns 404). In file mode it answers + // only a request made on the server machine (loopback peer and + // loopback Host), else 403 reset_local_only. Throws on non-2xx so // the caller can surface the server's error message (including the // 404 "reset disabled" case if the SPA's cached authConfig was // stale). diff --git a/web-mobile/js/reset.jsx b/web-mobile/js/reset.jsx index 68b52e22e..0604afa94 100644 --- a/web-mobile/js/reset.jsx +++ b/web-mobile/js/reset.jsx @@ -3,7 +3,8 @@ // old one (since they don't know it). The backing endpoint is // POST /api/tournament/reset (handlers_reset.go); it is unauthenticated // by design but answers only a browser on the machine running the server -// (403 reset_local_only otherwise), and the operator hardens production +// (loopback peer and loopback Host, opened as http://localhost:/reset; +// 403 reset_local_only otherwise), and the operator hardens production // deployments by passing // --lock-password (mobile-app CLI) which 404s the reset endpoint AND // switches auth to a bcrypt env-var hash. From 9d03ba20f3025a4bed10bffadc1d2dd9994ff0fb Mon Sep 17 00:00:00 2001 From: Ricardo Oliveira Date: Tue, 13 Oct 2026 10:30:56 +0100 Subject: [PATCH 25/30] test(bc-cmpw): use the house test password in the delete dialog test GitGuardian read the fixture value as a generic password. Co-Authored-By: Claude Opus 5.5 --- .../render/delete_competition_password.render.test.jsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/web-mobile/js/__tests__/render/delete_competition_password.render.test.jsx b/web-mobile/js/__tests__/render/delete_competition_password.render.test.jsx index 070e9a93c..a3dd19776 100644 --- a/web-mobile/js/__tests__/render/delete_competition_password.render.test.jsx +++ b/web-mobile/js/__tests__/render/delete_competition_password.render.test.jsx @@ -27,7 +27,7 @@ async function mountWithDialogHost({ comp, onBack, showToast }) { tournament={t} competition={comp} pools={[]} poolMatches={[]} standings={[]} bracket={null} section="settings" onSection={noop} onBack={onBack} onOpenCompetition={noop} onUpdate={noop} onRefreshCompetition={noop} onMoveCourt={noop} onEditScore={noop} onLogout={noop} - onViewerMode={noop} tweaks={{}} password="sign-in-pw" showToast={showToast} + onViewerMode={noop} tweaks={{}} password="pw" showToast={showToast} />
@@ -73,7 +73,7 @@ describe('Delete competition asks for the password again', () => { await type('nope'); await keyboardClick(button(dialog(), 'Delete')); - expect(stubs.API.deleteCompetition).toHaveBeenCalledWith('c1', 'sign-in-pw', 'nope'); + expect(stubs.API.deleteCompetition).toHaveBeenCalledWith('c1', 'pw', 'nope'); expect(dialog()).not.toBeNull(); expect(dialog().querySelector('[role="alert"]').textContent).toBe(SENTENCE); expect(onBack).not.toHaveBeenCalled(); @@ -89,7 +89,7 @@ describe('Delete competition asks for the password again', () => { await type('right'); await keyboardClick(button(dialog(), 'Delete')); - expect(stubs.API.deleteCompetition).toHaveBeenLastCalledWith('c1', 'sign-in-pw', 'right'); + expect(stubs.API.deleteCompetition).toHaveBeenLastCalledWith('c1', 'pw', 'right'); expect(dialog()).toBeNull(); expect(onBack).toHaveBeenCalledTimes(1); }); From e7ff13d0693d016edc993bd05c485d03a4e6d3b5 Mon Sep 17 00:00:00 2001 From: Ricardo Oliveira Date: Tue, 13 Oct 2026 11:48:15 +0100 Subject: [PATCH 26/30] fix(bc-cmpw): call the one password the admin password everywhere The sign-in dialog, the reset page and confirm, the discarded-writes message, the server's error sentences and startup log, the API spec and the docs said "tournament password" where the setup screens and the operator docs say "admin password". Identifiers, the X-Tournament-Password header and wire codes are unchanged. Co-Authored-By: Claude Opus 5.5 --- .../infrastructure-architecture.md | 2 +- internal/mobileapp/confirm_password_test.go | 2 +- internal/mobileapp/handlers_competition.go | 2 +- internal/mobileapp/handlers_match_test.go | 2 +- internal/mobileapp/handlers_sponsors_test.go | 2 +- internal/mobileapp/handlers_swiss.go | 2 +- internal/mobileapp/handlers_swiss_test.go | 2 +- internal/mobileapp/handlers_test.go | 6 ++-- internal/mobileapp/handlers_tournament.go | 6 ++-- internal/mobileapp/middleware.go | 4 +-- internal/mobileapp/public_projection.go | 2 +- .../mobileapp/self_run_team_writes_test.go | 2 +- internal/mobileapp/server.go | 2 +- scripts/screenshots/recipes/public.mjs | 2 +- .../inputs/gaps_ARCHITECTURE.md | 2 +- specs/004-elevated-password/spec.md | 2 +- specs/openapi.yaml | 32 +++++++++---------- web-mobile/js/__tests__/api.test.jsx | 10 +++--- .../create_tournament_validation.test.jsx | 4 +-- .../delete_competition_confirm.test.jsx | 4 +-- .../no_admin_password_header.test.jsx | 4 +-- .../render/admin_competition.render.test.jsx | 4 +-- .../self_run_team_names.render.test.jsx | 2 +- web-mobile/js/__tests__/reset.test.jsx | 8 ++--- web-mobile/js/__tests__/sync_queue.test.jsx | 4 +-- web-mobile/js/admin_registration_desk.jsx | 2 +- web-mobile/js/api_client.jsx | 6 ++-- web-mobile/js/app.jsx | 8 ++--- web-mobile/js/reset.jsx | 2 +- 29 files changed, 66 insertions(+), 66 deletions(-) diff --git a/docs/architecture/infrastructure-architecture.md b/docs/architecture/infrastructure-architecture.md index 7f8226c7d..ded115cb8 100644 --- a/docs/architecture/infrastructure-architecture.md +++ b/docs/architecture/infrastructure-architecture.md @@ -137,7 +137,7 @@ flowchart TB | Device | What it runs | Notes | |---|---|---| -| Operator console (1 per court) | admin scoring SPA | tablet/desktop surface; authenticates with the tournament password; scores its own shiai-jo | +| Operator console (1 per court) | admin scoring SPA | tablet/desktop surface; authenticates with the admin password; scores its own shiai-jo | | Display screen (1 per court, optional) | public display / scoreboard view | a browser at a display URL; read-only, no auth. **Preferred: drive it from the operator console's own machine** via an HDMI cable to a TV or monitor, so the board survives a Wi-Fi outage (refer to [Keep the court scoreboard alive on the same machine](#keep-the-court-scoreboard-alive-on-the-same-machine-hdmi)). A standalone smart-TV browser or separate mini-PC also works but loses that offline path | | Spectator phones | public viewer (mobile-first) | can be on cellular; they don't need venue Wi-Fi when the app is cloud-hosted | diff --git a/internal/mobileapp/confirm_password_test.go b/internal/mobileapp/confirm_password_test.go index 4fb5937c9..996563b05 100644 --- a/internal/mobileapp/confirm_password_test.go +++ b/internal/mobileapp/confirm_password_test.go @@ -111,7 +111,7 @@ func TestDeleteCompetition_SelfRunAnonymousStillRefusedByTheGate(t *testing.T) { r, store := confirmDeleteRig(t, state.TournamentModeSelfRun, false) w := confirmDelete(r, "", "main-pw") assert.Equal(t, http.StatusUnauthorized, w.Code) - assert.Contains(t, w.Body.String(), "invalid tournament password") + assert.Contains(t, w.Body.String(), "invalid admin password") c, err := store.LoadCompetition("doomed") require.NoError(t, err) require.NotNil(t, c) diff --git a/internal/mobileapp/handlers_competition.go b/internal/mobileapp/handlers_competition.go index 5f805c6d9..7c44c7566 100644 --- a/internal/mobileapp/handlers_competition.go +++ b/internal/mobileapp/handlers_competition.go @@ -2826,7 +2826,7 @@ func RegisterCompetitionHandlers(r *gin.RouterGroup, store *state.Store, eng *en // the override-rank endpoint. "recorded" lists the ties a chusen already // settled, with the recorded ranks, so a wrong order can be changed. Admin-gated: RegisterCompetitionHandlers is // mounted under the authenticated router group, so callers must send the - // tournament password header (unlike the public league-tiebreak candidates). + // admin password header (unlike the public league-tiebreak candidates). r.GET("/competitions/:id/chusen-candidates", func(c *gin.Context) { id, ok := requireValidCompID(c) if !ok { diff --git a/internal/mobileapp/handlers_match_test.go b/internal/mobileapp/handlers_match_test.go index ac00ff5ee..85a43b0ef 100644 --- a/internal/mobileapp/handlers_match_test.go +++ b/internal/mobileapp/handlers_match_test.go @@ -3641,7 +3641,7 @@ func TestScoreHandler_ScheduledWriteCannotRevertCompleted(t *testing.T) { // near-future epoch into runningRevStore and poisoning the guard so that // every subsequent legitimate admin autosave looks "older" and gets dropped. // -// Requires a non-empty tournament password so that a request with no +// Requires a non-empty admin password so that a request with no // X-Tournament-Password header is treated as an anonymous caller (not admin). func TestSelfRunPolicy_NoRevMetadata(t *testing.T) { r, store, _, _, tempDir := setupTestRouter(t) diff --git a/internal/mobileapp/handlers_sponsors_test.go b/internal/mobileapp/handlers_sponsors_test.go index 44a172866..372168d7e 100644 --- a/internal/mobileapp/handlers_sponsors_test.go +++ b/internal/mobileapp/handlers_sponsors_test.go @@ -426,7 +426,7 @@ func TestPostSponsor_NoTournament(t *testing.T) { w := httptest.NewRecorder() req, _ := http.NewRequest(http.MethodPost, "/api/sponsors", body) req.Header.Set("Content-Type", ct) - // No tournament password set (no tournament exists). AuthMiddleware + // No admin password set (no tournament exists). AuthMiddleware // permits unauth POST /api/tournament on a virgin install, but // /api/sponsors is NOT on that allowlist. The middleware should // reject (4xx, not 200 or 404 from the handler), proving the auth diff --git a/internal/mobileapp/handlers_swiss.go b/internal/mobileapp/handlers_swiss.go index e311483e4..74165448b 100644 --- a/internal/mobileapp/handlers_swiss.go +++ b/internal/mobileapp/handlers_swiss.go @@ -88,7 +88,7 @@ func RegisterSwissHandlers(r *gin.RouterGroup, store *state.Store, eng *engine.E // which are themselves public via the viewer endpoint, so spectators, // coaches, and TV displays need them without admin credentials. // Discovered during the post-merge browser UAT pass: the SwissStandings -// viewer tab broke with "invalid tournament password" because the +// viewer tab broke with "invalid admin password" because the // admin-only RegisterSwissHandlers was the only registration. // // FR-050e. diff --git a/internal/mobileapp/handlers_swiss_test.go b/internal/mobileapp/handlers_swiss_test.go index 97a7e2387..ad790f11a 100644 --- a/internal/mobileapp/handlers_swiss_test.go +++ b/internal/mobileapp/handlers_swiss_test.go @@ -17,7 +17,7 @@ import ( ) // adminReqSwiss is a tiny helper that wraps a request through the -// admin-protected swiss endpoints. Tournament password is the empty +// admin-protected swiss endpoints. Admin password is the empty // string by default (set on the tournament in setupTestRouter); we // re-use the existing test plumbing. func adminReqSwiss(method, path string) *http.Request { diff --git a/internal/mobileapp/handlers_test.go b/internal/mobileapp/handlers_test.go index 2eb61a2e0..92d71dc71 100644 --- a/internal/mobileapp/handlers_test.go +++ b/internal/mobileapp/handlers_test.go @@ -269,7 +269,7 @@ func TestTournamentHandlers(t *testing.T) { r.ServeHTTP(w, req) assert.Equal(t, http.StatusBadRequest, w.Code, "POST /api/tournament with empty Password must return 400") - assert.Contains(t, w.Body.String(), "tournament password is required", + assert.Contains(t, w.Body.String(), "admin password is required", "rejection should explain the empty-password reason") // Confirm it didn't land on disk. stored, _ := store.LoadTournament() @@ -346,7 +346,7 @@ func TestTournamentHandlers(t *testing.T) { r.ServeHTTP(w, req) assert.Equal(t, http.StatusBadRequest, w.Code, "PUT with empty Password against legacy empty-password tournament must reject") - assert.Contains(t, w.Body.String(), "tournament password is required") + assert.Contains(t, w.Body.String(), "admin password is required") } // mp-ef3 Copilot round 2: public info field trimming, contacts-count @@ -837,7 +837,7 @@ func TestTournamentHandlers_FileMode_POSTOverwriteWithNewPassword_BroadcastsRese } } assert.True(t, seen["tournament_updated"], "tournament_updated event missing") - assert.True(t, seen["password_reset"], "password_reset event missing, overwriting an existing tournament's password must clear other admins' cached credentials") + assert.True(t, seen["password_reset"], "password_reset event missing, overwriting an existing admin password must clear other admins' cached credentials") } // TestTournamentHandlers_ModeSwitchPreservesStoredPassword walks the diff --git a/internal/mobileapp/handlers_tournament.go b/internal/mobileapp/handlers_tournament.go index 2b6e3ad84..c1c651cc3 100644 --- a/internal/mobileapp/handlers_tournament.go +++ b/internal/mobileapp/handlers_tournament.go @@ -458,7 +458,7 @@ func resolveCompetitionCourts(compCourts []string, tourn *state.Tournament) []st // UpdateTournamentChanged unchanged so the handler can map it to a // 400 response. Using a typed sentinel rather than an inline error // keeps the handler's errors.Is check stable across refactors. -var errPasswordRequired = errors.New("tournament password is required") +var errPasswordRequired = errors.New("admin password is required") // validateTournamentDurationDays returns an error when durationDays is // outside the accepted range. 0 is allowed (treated as "default to 1" by @@ -1003,7 +1003,7 @@ func RegisterTournamentHandlers(r *gin.RouterGroup, store *state.Store, hub *Hub t.Password = "" } } else if t.Password == "" { - c.JSON(http.StatusBadRequest, gin.H{"error": "tournament password is required"}) + c.JSON(http.StatusBadRequest, gin.H{"error": "admin password is required"}) return } @@ -1029,7 +1029,7 @@ func RegisterTournamentHandlers(r *gin.RouterGroup, store *state.Store, hub *Hub hub.Broadcast(EventTournamentUpdated, nil) // In file mode, broadcast EventPasswordReset when an existing - // tournament's password is OVERWRITTEN (re-bootstrap with a new + // admin password is OVERWRITTEN (re-bootstrap with a new // credential) so other logged-in admin sessions clear their // stale cached credentials. Mirrors the PUT handler's behavior. // diff --git a/internal/mobileapp/middleware.go b/internal/mobileapp/middleware.go index 07d35174a..68c2cf8e3 100644 --- a/internal/mobileapp/middleware.go +++ b/internal/mobileapp/middleware.go @@ -324,7 +324,7 @@ func selfRunAnonymous(c *gin.Context, tl TournamentLoader, verifier PasswordVeri // wrong, on a gated route (AuthMiddleware) and on one that is public in // self-run (selfRunAnonymous) alike. func respondInvalidPassword(c *gin.Context) { - c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "invalid tournament password"}) + c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "invalid admin password"}) } // AuthMiddleware gates admin endpoints behind the X-Tournament-Password @@ -428,7 +428,7 @@ func AuthMiddleware(verifier PasswordVerifier, store *state.Store) gin.HandlerFu // tournament with empty Password is a misconfiguration, and // refusing to authorize is the safer fail-closed choice. The // 403 message tells the operator to fix the password rather than - // the misleading 401 ("invalid tournament password", which + // the misleading 401 ("invalid admin password", which // would imply the request is wrong, not the server state). // // In locked mode the stored Password is irrelevant (auth comes diff --git a/internal/mobileapp/public_projection.go b/internal/mobileapp/public_projection.go index 021f4dd17..68f27d638 100644 --- a/internal/mobileapp/public_projection.go +++ b/internal/mobileapp/public_projection.go @@ -17,7 +17,7 @@ import ( // them back over the wire (the SPA only WRITES decisionReason; decisionBy, an // enum, is what drives viewer rendering and is deliberately preserved). They must // never reach spectators. This mirrors the existing redaction at handlers_viewer.go -// where the tournament password is zeroed before public serialization. +// where the admin password is zeroed before public serialization. // // Two flavours, both safe against corrupting stored/cached state: // - COPY helpers take the value by value and return a redacted copy: diff --git a/internal/mobileapp/self_run_team_writes_test.go b/internal/mobileapp/self_run_team_writes_test.go index b3f93c4f5..a64c35ea2 100644 --- a/internal/mobileapp/self_run_team_writes_test.go +++ b/internal/mobileapp/self_run_team_writes_test.go @@ -317,7 +317,7 @@ func TestSelfRun_ScoreSheetTeamWrites_AWrongPasswordIsRefused(t *testing.T) { func requireInvalidPassword(t *testing.T, w *httptest.ResponseRecorder) { t.Helper() require.Equal(t, http.StatusUnauthorized, w.Code, w.Body.String()) - assert.JSONEq(t, `{"error":"invalid tournament password"}`, w.Body.String()) + assert.JSONEq(t, `{"error":"invalid admin password"}`, w.Body.String()) } // An officiated tournament opens nothing: the same writes still need the diff --git a/internal/mobileapp/server.go b/internal/mobileapp/server.go index b18615358..4beb8b1fd 100644 --- a/internal/mobileapp/server.go +++ b/internal/mobileapp/server.go @@ -39,7 +39,7 @@ const mobileWebRoot = "web-mobile" func noteRetiredAdminPasswordHash() { if os.Getenv(retiredAdminPasswordHashEnv) != "" { slog.Info("mobile-app: " + retiredAdminPasswordHashEnv + - " is set but no longer used; admin actions need only the tournament password") + " is set but no longer used; admin actions need only the admin password") } } diff --git a/scripts/screenshots/recipes/public.mjs b/scripts/screenshots/recipes/public.mjs index 6e363db96..83ff00b4a 100644 --- a/scripts/screenshots/recipes/public.mjs +++ b/scripts/screenshots/recipes/public.mjs @@ -95,7 +95,7 @@ export const families = { // one card demonstrates poorly, and that a reader cannot check at all. const secondId = await api.competition('womens-open', "Women's Open", { courts: ['A'] }); // A roster add is an organiser action in self-run, so it goes with the - // tournament password the shared client sends. Scaffolding, not a + // admin password the shared client sends. Scaffolding, not a // fidelity concern. await api.post(`/api/competitions/${compId}/participants`, { players: [ { name: 'Emi Kondo', dojo: 'Riverside Dojo' }, diff --git a/specs/003-tournament-gap-closure/inputs/gaps_ARCHITECTURE.md b/specs/003-tournament-gap-closure/inputs/gaps_ARCHITECTURE.md index d508e7a2c..3609b134a 100644 --- a/specs/003-tournament-gap-closure/inputs/gaps_ARCHITECTURE.md +++ b/specs/003-tournament-gap-closure/inputs/gaps_ARCHITECTURE.md @@ -250,7 +250,7 @@ Add Vitest tests for the validation and time estimator logic; these are pure fun **Problem**: The file-backed `state.Store` uses per-file reads and writes with no atomic transactions. Concurrent requests (e.g., two operators scoring different matches simultaneously) can produce TOCTOU races: read config, modify in-memory, write back; if two requests interleave, the second write overwrites the first's changes. -PR #103/#104 addressed the most critical TOCTOU vectors (tournament password, pool/bracket scoring, competition lifecycle) with targeted mutex guards, but the store's overall design still relies on callers getting locking right. There is no store-level transaction primitive. +PR #103/#104 addressed the most critical TOCTOU vectors (admin password, pool/bracket scoring, competition lifecycle) with targeted mutex guards, but the store's overall design still relies on callers getting locking right. There is no store-level transaction primitive. **Suggestion**: Introduce a `Store.WithTransaction(fn func(tx StoreTx) error) error` method that: - Acquires the relevant lock(s) for the competition/tournament being modified. diff --git a/specs/004-elevated-password/spec.md b/specs/004-elevated-password/spec.md index 2c6aecc80..74a7a373d 100644 --- a/specs/004-elevated-password/spec.md +++ b/specs/004-elevated-password/spec.md @@ -280,7 +280,7 @@ into a handler whose other fields only need the main password. ## 7. UI ([web-mobile/](../../web-mobile/)) - **Settings page**: new "Admin / destructive-ops password" control next to the - existing tournament password input. The current value is **never displayed** + existing admin password input. The current value is **never displayed** (write-only, §6): the field is an empty "set / change" input, like any change-password form. - file mode → editable; saved via the **dedicated** `PUT /api/auth/admin-password` diff --git a/specs/openapi.yaml b/specs/openapi.yaml index f5cbde383..594341d5e 100644 --- a/specs/openapi.yaml +++ b/specs/openapi.yaml @@ -97,7 +97,7 @@ info: the recorded ones. One that would record, move or clear the decision (dropping the row included), or name a match winner other than the side it decided, is refused with 409 `hantei_organiser_only`. On these public writes a password that - is sent but wrong is answered 401 (`invalid tournament password`), as on a gated + is sent but wrong is answered 401 (`invalid admin password`), as on a gated route: only an empty one is a participant. `POST .../matches/bulk-score` and `PUT .../matches/{mid}/quick-score` stay gated: they write results without these checks. @@ -415,7 +415,7 @@ paths: **File mode (default)**: allowed without the `X-Tournament-Password` header when no tournament has been configured yet (bootstrap). `password` must be non-empty - (`tournament password is required`); an empty Password on + (`admin password is required`); an empty Password on disk would let AuthMiddleware's `password != t.Password` check vacuously pass for any client sending an empty header. Once a tournament with a password exists, auth is required. @@ -490,7 +490,7 @@ paths: to keep current"). If after the preserve step the password is STILL empty (operator manually edited `tournament.md`, or a fresh PUT against a never-initialized tournament), the - request is rejected with 400 (`tournament password is + request is rejected with 400 (`admin password is required`). Successful rotations broadcast `EventPasswordReset` over SSE so other logged-in admins clear their stale localStorage credential and re-prompt; name-only @@ -825,7 +825,7 @@ paths: '400': description: Validation error (name missing/too long, link invalid, cap reached). '401': - description: Missing or incorrect tournament password. + description: Missing or incorrect admin password. '413': description: Logo file exceeds 1 MB. '415': @@ -863,7 +863,7 @@ paths: '400': description: Invalid index (negative or non-integer). '401': - description: Missing or incorrect tournament password. + description: Missing or incorrect admin password. '404': description: Index out of range or tournament not initialized. @@ -2655,7 +2655,7 @@ paths: description: The sentence the page shows as it is, for those refusals. '401': description: > - `invalid tournament password`: the password is missing or wrong in an + `invalid admin password`: the password is missing or wrong in an officiated tournament, or, in a self-run one, sent but wrong. An empty password is a participant there, never a 401. content: @@ -3006,7 +3006,7 @@ paths: $ref: '#/components/schemas/Error' '401': description: > - Missing or invalid tournament password. Applies in every mode: + Missing or invalid admin password. Applies in every mode: in self-run mode this route is one of the main-gated organiser corrections, not participant-driven play. content: @@ -3173,7 +3173,7 @@ paths: $ref: '#/components/schemas/Error' '401': description: > - Missing or invalid tournament password. In self-run mode this route + Missing or invalid admin password. In self-run mode this route is a main-gated organiser correction, not participant-driven play. content: application/json: @@ -3362,7 +3362,7 @@ paths: '400': $ref: '#/components/responses/BadRequest' '401': - description: Missing or incorrect tournament password. + description: Missing or incorrect admin password. '404': $ref: '#/components/responses/NotFound' '500': @@ -4036,7 +4036,7 @@ paths: $ref: '#/components/schemas/Error' '401': description: > - `invalid tournament password`: missing or wrong in an officiated + `invalid admin password`: missing or wrong in an officiated tournament, or sent but wrong in a self-run one, where an empty password is a participant. Answered before the body is read, so a wrong password is never told about its stamp. @@ -4162,7 +4162,7 @@ paths: $ref: '#/components/schemas/Error' '401': description: > - `invalid tournament password`: missing or wrong in an officiated + `invalid admin password`: missing or wrong in an officiated tournament, or sent but wrong in a self-run one, where an empty password is a participant. Answered before the body is read, so a wrong password is never told about its stamp. @@ -4485,7 +4485,7 @@ paths: $ref: '#/components/schemas/Error' '401': description: > - `invalid tournament password`: missing or wrong in an officiated + `invalid admin password`: missing or wrong in an officiated tournament, or sent but wrong in a self-run one, where an empty password is a participant. content: @@ -4648,7 +4648,7 @@ paths: $ref: '#/components/schemas/Error' '401': description: > - `invalid tournament password`: missing or wrong in an officiated + `invalid admin password`: missing or wrong in an officiated tournament, or sent but wrong in a self-run one, where an empty password is a participant. content: @@ -4735,7 +4735,7 @@ paths: $ref: '#/components/schemas/Error' '401': description: > - `invalid tournament password`: missing or wrong in an officiated + `invalid admin password`: missing or wrong in an officiated tournament, or sent but wrong in a self-run one, where an empty password is a participant. content: @@ -4787,7 +4787,7 @@ paths: '400': $ref: '#/components/responses/BadRequest' '401': - description: '`invalid tournament password`: missing or wrong.' + description: '`invalid admin password`: missing or wrong.' content: application/json: schema: @@ -5332,7 +5332,7 @@ components: `POST/PUT /api/tournament` is allowed without the header so the SPA's CreateTournament flow can pick the admin password. - Defense-in-depth: if the stored tournament password is somehow + Defense-in-depth: if the stored admin password is somehow empty (e.g. operator manually edited `tournament.md` to land an empty `password:` field), AuthMiddleware returns 403 (`tournament misconfigured: password is not set`) for ALL diff --git a/web-mobile/js/__tests__/api.test.jsx b/web-mobile/js/__tests__/api.test.jsx index 983549ca8..2102e2172 100644 --- a/web-mobile/js/__tests__/api.test.jsx +++ b/web-mobile/js/__tests__/api.test.jsx @@ -1062,7 +1062,7 @@ describe('API Utils', () => { // mp-a7y: pins the invalidate endpoint URL + auth header so a // future refactor can't silently re-route the call. The handler - // is gated on tournament password (internal/mobileapp/handlers_competition.go); + // is gated on admin password (internal/mobileapp/handlers_competition.go); // missing the X-Tournament-Password header would 401 with the // status-flip never reaching disk. describe('invalidateCompetition', () => { @@ -1422,9 +1422,9 @@ describe('API Utils', () => { global.fetch = vi.fn().mockResolvedValue({ ok: false, status: 401, - json: async () => ({ error: 'invalid tournament password' }), + json: async () => ({ error: 'invalid admin password' }), }); - await expect(API.createTournament({ name: 'X' }, 'wrong')).rejects.toThrow('invalid tournament password'); + await expect(API.createTournament({ name: 'X' }, 'wrong')).rejects.toThrow('invalid admin password'); }); }); @@ -1821,9 +1821,9 @@ describe('API Utils', () => { global.fetch = vi.fn().mockResolvedValue({ ok: false, status: 401, - json: async () => ({ error: 'invalid tournament password' }), + json: async () => ({ error: 'invalid admin password' }), }); - await expect(API.chusenCandidates('comp-1', 'bad')).rejects.toThrow('invalid tournament password'); + await expect(API.chusenCandidates('comp-1', 'bad')).rejects.toThrow('invalid admin password'); }); }); diff --git a/web-mobile/js/__tests__/create_tournament_validation.test.jsx b/web-mobile/js/__tests__/create_tournament_validation.test.jsx index f69a2837c..4375ee9d9 100644 --- a/web-mobile/js/__tests__/create_tournament_validation.test.jsx +++ b/web-mobile/js/__tests__/create_tournament_validation.test.jsx @@ -39,9 +39,9 @@ describe('validateCreateTournament', () => { expect(msg).not.toBe(''); }); - // bc-cmpw: a self-run tournament needs only the tournament password; the + // bc-cmpw: a self-run tournament needs only the admin password; the // form no longer collects a second one, so nothing else is validated. - it('returns "" for a self-run tournament given only the tournament password', () => { + it('returns "" for a self-run tournament given only the admin password', () => { expect(validateCreateTournament({ ...valid, isSelfRun: true, locked: false })).toBe(''); expect(validateCreateTournament({ ...valid, isSelfRun: true, locked: true })).toBe(''); }); diff --git a/web-mobile/js/__tests__/delete_competition_confirm.test.jsx b/web-mobile/js/__tests__/delete_competition_confirm.test.jsx index f76581058..4c8ebb55b 100644 --- a/web-mobile/js/__tests__/delete_competition_confirm.test.jsx +++ b/web-mobile/js/__tests__/delete_competition_confirm.test.jsx @@ -43,10 +43,10 @@ describe('API.deleteCompetition', () => { it('only the other 401 (the sign-in itself refused) has no confirm code', async () => { global.fetch = vi.fn().mockResolvedValue({ ok: false, status: 401, headers: { get: () => null }, - json: async () => ({ error: 'invalid tournament password' }), + json: async () => ({ error: 'invalid admin password' }), }); const err = await API.deleteCompetition('c1', 'stale', 'typed').catch((e) => e); - expect(err.message).toBe('invalid tournament password'); + expect(err.message).toBe('invalid admin password'); expect(err.code).toBeUndefined(); }); }); diff --git a/web-mobile/js/__tests__/no_admin_password_header.test.jsx b/web-mobile/js/__tests__/no_admin_password_header.test.jsx index c5ef040b3..9a15339f0 100644 --- a/web-mobile/js/__tests__/no_admin_password_header.test.jsx +++ b/web-mobile/js/__tests__/no_admin_password_header.test.jsx @@ -1,11 +1,11 @@ // bc-cmpw: the separate destructive-ops password is gone. Every organiser -// write is authorised by the tournament password alone, so no request may +// write is authorised by the admin password alone, so no request may // carry X-Admin-Password, and the client no longer offers a way to set one. import { describe, it, expect, vi, afterEach } from 'vitest'; import { API } from '../api_client.jsx'; import * as adminHelpers from '../admin_helpers.jsx'; -describe('organiser writes send the tournament password only', () => { +describe('organiser writes send the admin password only', () => { afterEach(() => { vi.restoreAllMocks(); }); const writes = [ diff --git a/web-mobile/js/__tests__/render/admin_competition.render.test.jsx b/web-mobile/js/__tests__/render/admin_competition.render.test.jsx index a4f5ef70b..168761b5d 100644 --- a/web-mobile/js/__tests__/render/admin_competition.render.test.jsx +++ b/web-mobile/js/__tests__/render/admin_competition.render.test.jsx @@ -297,7 +297,7 @@ describe('AdminCompetition "Complete competition" action (mp-gy6g)', () => { expect(btn).not.toBeUndefined(); await act(async () => { fireEvent.click(btn); }); - // bc-cmpw: the confirm is the only step; the tournament password the + // bc-cmpw: the confirm is the only step; the admin password the // operator signed in with authorises it, and nothing else is sent. await waitFor(() => expect(window.API.completeCompetition).toHaveBeenCalledTimes(1)); expect(window.API.completeCompetition.mock.calls[0]).toEqual(['nagi-1', 'shiaijo2026']); @@ -321,7 +321,7 @@ describe('AdminCompetition "Complete competition" action (mp-gy6g)', () => { }); // bc-cmpw: discarding a draw keeps its confirm, and once confirmed it goes -// straight to the API with the signed-in tournament password: there is no +// straight to the API with the signed-in admin password: there is no // second, destructive-ops password to prompt for. describe('AdminCompetition "Discard draw" action (bc-cmpw)', () => { const findButton = (container, text) => diff --git a/web-mobile/js/__tests__/render/self_run_team_names.render.test.jsx b/web-mobile/js/__tests__/render/self_run_team_names.render.test.jsx index 7b0569407..76bf922e4 100644 --- a/web-mobile/js/__tests__/render/self_run_team_names.render.test.jsx +++ b/web-mobile/js/__tests__/render/self_run_team_names.render.test.jsx @@ -3,7 +3,7 @@ // A typed name, a pick from the list and "+ Add" save the match lineup and // name or add the team member, with no organiser password: the public page // sends an empty one, and the server lets these writes through in self-run. -// Before, each of them was refused with "invalid tournament password". +// Before, each of them was refused with "invalid admin password". // // Mounted through the real door, MatchViewerModal and "Report result", with // the real lineup helpers (admin_lineup.jsx) the public page loads too. diff --git a/web-mobile/js/__tests__/reset.test.jsx b/web-mobile/js/__tests__/reset.test.jsx index e582df48d..11ca160c8 100644 --- a/web-mobile/js/__tests__/reset.test.jsx +++ b/web-mobile/js/__tests__/reset.test.jsx @@ -77,7 +77,7 @@ describe('ResetPasswordForm', () => { const text = collectText(tree); expect(text).toContain('Password reset disabled'); expect(text).toContain('environment variable'); - expect(text).not.toContain('Reset tournament password'); + expect(text).not.toContain('Reset admin password'); }); it('renders a Back button that fires onBack', () => { @@ -102,7 +102,7 @@ describe('ResetPasswordForm', () => { onSuccess: vi.fn(), }); const text = collectText(tree); - expect(text).toContain('Reset tournament password'); + expect(text).toContain('Reset admin password'); const pwInputs = []; findInTree(tree, (n) => { if (n.type === 'input' && n.props?.type === 'password') pwInputs.push(n); @@ -127,7 +127,7 @@ describe('ResetPasswordForm', () => { }); const text = collectText(tree); expect(text).toContain('Loading'); - expect(text).not.toContain('Reset tournament password'); + expect(text).not.toContain('Reset admin password'); expect(text).not.toContain('Password reset disabled'); }); @@ -136,7 +136,7 @@ describe('ResetPasswordForm', () => { const tree = runtime.mount(ResetPasswordForm, { onBack: vi.fn(), onSuccess: vi.fn() }); const text = collectText(tree); expect(text).toContain('Loading'); - expect(text).not.toContain('Reset tournament password'); + expect(text).not.toContain('Reset admin password'); }); }); diff --git a/web-mobile/js/__tests__/sync_queue.test.jsx b/web-mobile/js/__tests__/sync_queue.test.jsx index 99ef41efa..39121cc78 100644 --- a/web-mobile/js/__tests__/sync_queue.test.jsx +++ b/web-mobile/js/__tests__/sync_queue.test.jsx @@ -1701,7 +1701,7 @@ describe('bc-qttl: API.resumeAfterAuth un-parks 401-blocked writes with the fres describe('bc-qttl: 403 retries like 5xx/429 (never parks, never drops)', () => { it('keeps retrying past the notice threshold; authBlocked stays 0; status becomes server-error, not auth-required', async () => { // Correction to an earlier spec: on this server 401 is "invalid - // tournament password" (re-auth fixes it) but 403 means the tournament + // admin password" (re-auth fixes it) but 403 means the tournament // itself is unusable ("not configured yet" / "password not set"), a // server-side state re-authenticating cannot repair. So 403 must NOT be // parked behind a "sign in to save" prompt; it retries like a 5xx. @@ -1928,7 +1928,7 @@ describe('bc-qttl: API.parkQueueForReauth (password_reset no longer destroys que await flushMicrotasks(); expect(API.hasPendingTerminalWrite('c1', 'm1')).toBe(true); - // Another admin resets the tournament password mid-event: this + // Another admin resets the admin password mid-event: this // tablet's queue is PARKED, not cleared. const parked = API.parkQueueForReauth(); expect(parked).toBe(1); diff --git a/web-mobile/js/admin_registration_desk.jsx b/web-mobile/js/admin_registration_desk.jsx index 301fc78d0..f346b4585 100644 --- a/web-mobile/js/admin_registration_desk.jsx +++ b/web-mobile/js/admin_registration_desk.jsx @@ -20,7 +20,7 @@ // bc-prow: check-in exists only under that setting and this desk is its // only UI), so a competition with the setting off never appears in the // rail, the all-competitions roster, or an "also in" chip. -// - walk-up add (POST single) needs the tournament password in officiated +// - walk-up add (POST single) needs the admin password in officiated // mode (none in self-run, where anyone can edit the roster), and only // works while a competition is in "setup" status. // - a self-run tournament has no check-in (operator ruling 2026-10-13): the diff --git a/web-mobile/js/api_client.jsx b/web-mobile/js/api_client.jsx index b347c4c08..105d3e412 100644 --- a/web-mobile/js/api_client.jsx +++ b/web-mobile/js/api_client.jsx @@ -1800,7 +1800,7 @@ async function _flushQueue() { anyFailed = true; } else if (res.status === 401) { // bc-qttl: an auth failure PARKS the write, it does not drop it. - // 401 is precisely "invalid tournament password", the one 4xx + // 401 is precisely "invalid admin password", the one 4xx // where a later retry can genuinely succeed: the tournament // password can be rotated mid-event, or the operator can simply // sign in again. Discarding here would throw away a finished @@ -2777,7 +2777,7 @@ const API = { return null; } }, - // Reset the tournament password. Unauthenticated by design: the + // Reset the admin password. Unauthenticated by design: the // server enforces "is this endpoint enabled" via the verifier's // ResetEnabled() (locked mode returns 404). In file mode it answers // only a request made on the server machine (loopback peer and @@ -4581,7 +4581,7 @@ const API = { // resolves each by chusen (drawing lots), recorded via overridePoolRanks. async chusenCandidates(compID, password) { // Lives in the admin-gated competition router, so it needs the - // tournament password header (unlike the public league candidates GET). + // admin password header (unlike the public league candidates GET). const res = await fetch(`/api/competitions/${encodeURIComponent(compID)}/chusen-candidates`, { headers: { 'X-Tournament-Password': password || '' } }); diff --git a/web-mobile/js/app.jsx b/web-mobile/js/app.jsx index a7dd935c3..4a00d83cc 100644 --- a/web-mobile/js/app.jsx +++ b/web-mobile/js/app.jsx @@ -386,7 +386,7 @@ export function queueAlertMessage(alert) { case "storage_full": return "Browser storage is full, so unsaved results can no longer be kept safely. Let them sync before reloading this tab."; case "discarded": - return `${n} unsaved ${noun} ${one ? "was" : "were"} discarded because the tournament password changed.`; + return `${n} unsaved ${noun} ${one ? "was" : "were"} discarded because the admin password changed.`; case "sent": return `${n} ${noun} sent.`; // Held finishes that landed and moved a later change of their match to @@ -1730,8 +1730,8 @@ function AuthModal({ onClose, onSuccess, onForgotPassword, resetEnabled, reauth
{reauth ? "Sign in to save" : "Admin sign in"}
{reauth - ? "Results saved on this device have not reached the server because the tournament password changed. Enter the current password to save them." - : "Enter the tournament password to manage brackets, schedules and results."} + ? "Results saved on this device have not reached the server because the admin password changed. Enter the current password to save them." + : "Enter the admin password to manage brackets, schedules and results."}
@@ -1761,7 +1761,7 @@ function AuthModal({ onClose, onSuccess, onForgotPassword, resetEnabled, reauth // re-authenticated): see resetPassword's tournament // broadcast. Cheaper to ask twice than to surprise an // operator who clicked the wrong link. - if (await window.confirmDialog({ message: "Reset the tournament password? This will sign out all other admins.", confirmLabel: "Reset password", danger: true })) { + if (await window.confirmDialog({ message: "Reset the admin password? This will sign out all other admins.", confirmLabel: "Reset password", danger: true })) { onForgotPassword(); } }} diff --git a/web-mobile/js/reset.jsx b/web-mobile/js/reset.jsx index 0604afa94..a0b166008 100644 --- a/web-mobile/js/reset.jsx +++ b/web-mobile/js/reset.jsx @@ -120,7 +120,7 @@ function ResetPasswordForm({ authConfig, onBack, onSuccess, originatorId }) { return (
-

Reset tournament password

+

Reset admin password

Set a new admin password. The new password will replace the current one for everyone: any logged-in admins will need to sign in again. From 7f63382a888215b9d545f78103dc2afec4b20aa7 Mon Sep 17 00:00:00 2001 From: Ricardo Oliveira Date: Tue, 13 Oct 2026 12:15:10 +0100 Subject: [PATCH 27/30] test(bc-e2eh): harness review round 1 fixes Infrastructure: retapWhileSaving and doubleTap fail instead of passing silently (the double-tap limit derives from the app's TAP_BOUNCE_MS); the start/reopen 409 allowlist entry matches only a start or a reopen, and console lines pair one-to-one with failed answers; the shared server helper kills its child and removes its data folder on process exit, awaits the child on stop and catches a port race; a failing test gets the server log; afterAll output is attributed to its own file. Journeys: J7 asserts the finished TV board; the team guards no longer hang off one serial chain and the kachinuki guards cannot skip the final; the kachinuki totals, the team kiken and the one-mark double tap are asserted; runningOnCourt waits for the list; J6's second device is its own context; lineup names are read after the source. Docs: the harness README is developer documentation (adding a journey, traces, network needs); the developer guide links to it. Co-Authored-By: Claude Opus 5.5 --- docs/dev-guide/contributing.md | 15 ++ scripts/e2e/README.md | 131 ++++++--- scripts/e2e/fixtures/clumsy.mjs | 54 ++-- scripts/e2e/fixtures/pace.mjs | 15 ++ scripts/e2e/fixtures/shared-court.mjs | 19 +- scripts/e2e/fixtures/team.mjs | 2 +- scripts/e2e/fixtures/test.mjs | 79 ++++-- .../knockout-mixed-individual.spec.mjs | 57 +++- .../knockout-mixed-kachinuki.spec.mjs | 248 ++++++++++-------- .../e2e/journeys/knockout-mixed-team.spec.mjs | 89 +++---- scripts/screenshots/lib/server.mjs | 52 +++- 11 files changed, 501 insertions(+), 260 deletions(-) diff --git a/docs/dev-guide/contributing.md b/docs/dev-guide/contributing.md index b36a9d303..77147457f 100644 --- a/docs/dev-guide/contributing.md +++ b/docs/dev-guide/contributing.md @@ -81,6 +81,21 @@ An explicit `--folder`, `--port`, or `--bind` flag still overrides the env var. Editing these frontend sources and refreshing the browser does **not** pick up changes. The browser still gets the embedded bundle from the last binary build. +### Run the end-to-end journeys + +The end-to-end journeys drive the tournament app in a real browser, the way a court operator and a spectator use it: an iPad-sized touch screen for the operator and a phone for the public pages. Run them before you open a pull request that changes `web-mobile/`; the pull request template asks for it. + +```sh +make e2e # every journey +make e2e E2E_ARGS='journeys/smoke.spec.mjs' # one journey file +make e2e E2E_ARGS='--grep "score on a shiaijo"' # one test by title +make e2e E2E_ARGS='journeys/smoke.spec.mjs --headed' # watch it run +``` + +`make e2e` builds the binary, then runs each journey file against its own server on a free port with an empty data folder. You need Go, `node` with `npm`, and a network connection: the first run installs the harness's packages and its Chromium, and every build downloads the frontend libraries. The journeys are not part of `make go/test` and do not run in CI. + +The harness's own guide, [`scripts/e2e/README.md`](https://github.com/gitrgoliveira/bracket-creator/blob/main/scripts/e2e/README.md), covers the rest: the devices, the console policy that fails a journey on a browser error, where screenshots and traces are written, how to add a journey, and what to do when a journey finds a bug. + ## Regenerate the documentation screenshots Every screenshot and video under `docs/screenshots/` and `docs/videos/` is captured from the running application by a script, so a picture cannot quietly drift from the product it shows. If your change alters anything a user sees, recapture what it reaches with `make docs/media SINCE=main` before you open the pull request. diff --git a/scripts/e2e/README.md b/scripts/e2e/README.md index 9f3d8b75f..ca547b206 100644 --- a/scripts/e2e/README.md +++ b/scripts/e2e/README.md @@ -1,7 +1,8 @@ # E2E journeys Playwright journeys that drive the tournament app (`mobile-app`) in a real -browser, the way an operator and a spectator use it. +browser, the way an operator and a spectator use it. This is developer +documentation, for a contributor who runs, debugs and extends the journeys. make e2e # every journey make e2e E2E_ARGS='journeys/smoke.spec.mjs' # one spec @@ -15,16 +16,23 @@ once per worktree), then runs `playwright test` here with `E2E_ARGS` passed through unchanged. It is not part of `make go/test` and does not run in CI. Run it before opening a PR that changes `web-mobile/`: the PR template asks. -It needs Go (for the build), Node.js with npm, and a network connection on the -first run: `make e2e/deps` (or the first `make e2e`) downloads the npm packages -and Chromium, and the build fetches the vendored frontend libraries. After that -it runs offline. "Green" means no test failed: the `test.fixme` steps of open -beads (refer to [When a journey finds a bug](#when-a-journey-finds-a-bug)) are -reported as skipped. +It needs Go (for the build), Node.js with npm, and a network connection on +every run: the first `make e2e/deps` (or `make e2e`) downloads the npm packages +and Chromium, and each `make e2e` rebuilds the binary, whose `vendor-frontend` +step downloads the vendored frontend libraries from unpkg every time and whose +`esbuild-jsx` step runs esbuild through `npx --yes`. "Green" means no test +failed: the `test.fixme` steps of open beads (see +[When a journey finds a bug](#when-a-journey-finds-a-bug)) are reported as +skipped. -Playwright is pinned to the same release as `scripts/screenshots`, so the two -share one Chromium under `~/.cache/ms-playwright`. If that cache is removed the -stamp outlives it; run `npx playwright install chromium` here. +The Playwright version here and the one `scripts/screenshots` resolves are the +same release, so the two share one Chromium under `~/.cache/ms-playwright`. If +that cache is removed the stamp outlives it; run `npx playwright install +chromium` here. + +To debug, pass Playwright's own flags through `E2E_ARGS`: `--headed` shows the +browser, `--debug` opens the inspector, and a failing test leaves a trace +(see [Output](#output)). ## Layout @@ -67,10 +75,15 @@ Two projects, both Chromium: - `operator`: an iPad Air in landscape (1180x820) with touch. Its page is checked to match `(pointer: coarse)`, so the tap-target rules apply as on - the device. Every journey runs here unless its file is named - `*.public.spec.mjs`. + the device. If it does not, `fixtures/pointer.mjs` turns on CDP touch + emulation and the test carries a `coarse-pointer` annotation saying so. + Every journey runs here unless its file is named `*.public.spec.mjs`. - `public`: a phone in portrait (390x844), for journeys about a public surface - alone (`*.public.spec.mjs`). + alone (`*.public.spec.mjs`). No journey uses this project yet; the public + surfaces are checked from `operator` journeys through a second context. + +Every test has a 240 s timeout (`expect` waits 15 s), `retries` is 0, and +`forbidOnly` is on, so a stray `test.only` fails the run. An operator journey that needs a second device opens another context with one of the two devices from `fixtures/devices.mjs`: `PUBLIC_DEVICE` for a public @@ -96,8 +109,8 @@ with `serverForFile(testInfo.file)`. Journeys navigate by path Every journey builds its tournament the way an operator does: the first-run form, the real sign-in form, the create wizard, the roster paste box, Generate draw and Start. The HTTP API is not a seeding path here, and neither are -hand-written files in the data folder. That is an operator ruling, made knowing -it couples every journey to the wizard's copy: the setup steps are part of what +hand-written files in the data folder. This is a standing rule, and it couples +every journey to the wizard's copy: the setup steps are part of what a journey exercises, and a shortcut would skip the part most likely to break. `scripts/screenshots` seeds over the API for a different reason (a capture is about one screen, not the path to it) and its `api.mjs`, `seed.mjs` and the @@ -123,36 +136,39 @@ variants as real input: | Helper | Variant | |---|---| | `tapNeighbour(locator, direction)` | taps the control next to the intended one, found by layout, and returns which it hit | -| `doubleTap(locator)` | two taps inside 300ms | -| `retapWhileSaving(locator)` | a second tap while the button reads "Saving…" (writes are held briefly so the state is observable) | -| `interrupt(page, kind)` | `reload`, `back` (then forward), `hidden` (tab to background and back), `offline` (network down and back) | +| `doubleTap(locator)` | two taps inside the app's bounce window less 100 ms; throws when the machine was too slow to land them that close, so a caller may assert on the outcome unconditionally | +| `retapWhileSaving(locator)` | a second tap while the button reads "Saving…" (writes are held briefly so the state is observable); throws when "Saving…" is never seen | +| `interrupt(page, kind, { awayMs, during })` | `reload`, `back` (then forward, only if Back moved), `hidden` (tab to background and back), `offline` (network down and back); `awayMs` (600 by default) is the time away and `during(page)` runs while the operator is away | | `hastyConfirm(page)` | answers the open confirm dialog with its most prominent button and returns its label | | `hastyShiaijoConfirm(page)` | the same for the shiaijo page's own confirms (Send back to queue, Move court) | +| `loudestButton(scope)` | the ranking both confirm helpers use, for any other panel | Each returns what happened, and a journey records that as an audit row. Where the docs or an operator ruling say what must happen (a double tap on an ippon -button lands one mark, an interruption leaves the operator on the same court), the journey -also asserts it, so a regression fails the suite; the rest is judged by a person -from the screenshots. `hidden` needs a note: headless Chromium never changes page +button lands one mark, an interruption leaves the operator on the same court), +the journey also asserts it, so a regression fails the suite; the rest is +judged by a person from the screenshots. `hidden` needs a note: headless Chromium never changes page visibility on its own, so the helper overrides `document.hidden` and `document.visibilityState` and dispatches a real `visibilitychange` for each edge, which is what the app's listeners read. ## The pace of a tap -The app ignores a pointer tap that lands within 400ms of the layer it opened (a +The app ignores a pointer tap that lands within 400 ms of the layer it opened (a confirm, the sign-in dialog) or of the tap that armed a two-tap commit (Finish, End match), and a second ippon or foul tap on the same SIDE of a bout inside that window, whichever button it lands on (`web-mobile/js/tap_guard.jsx`; a tap on the other side counts at once). Playwright's second tap is always inside it, so `fixtures/pace.mjs` holds the dwell (the screenshot harness's -`TAP_DWELL_MS`) and every helper that taps into a fresh layer, confirms an arm +`TAP_DWELL_MS`, 500 ms) and every helper that taps into a fresh layer, confirms an arm or strikes for a side waits it out: `login`, `hastyConfirm`, `hastyShiaijoConfirm`, `lineupsNameSlot`, `finishMatch`, `finishTeam`, `endMatch`, `awardIppon`, `boutIppon`, `awardBoutIppon` (`tapOnSide` keeps the time of the last tap on each side). A journey that taps such a thing by hand does the same. `doubleTap` is the one helper that deliberately does not: it is -the bounce, and the app is meant to ignore the second tap. +the bounce, and the app is meant to ignore the second tap. `pace.mjs` reads the +app's window from `web-mobile/js/tap_guard.jsx` (`TAP_BOUNCE_MS`) rather than +copying it, and fails to load if the dwell is not longer than it. ## The console policy @@ -168,33 +184,53 @@ fixture is wrapped so none is missed). When the test ends: `CONSOLE {json}` line otherwise, and is listed on the test as a `console-warning` annotation. -Everything a test's pages logged is attached to it as `console.json`, with the +Everything a test's pages logged is attached to it as `console.json` (kept in a failing test's trace), with the page it came from, whether the device was offline at the time, and, for a failed request, the status and body of the `/api` answer. What a `beforeAll` hook's pages log is checked with the first test of the file and marked as -such; what an `afterAll` hook's log is checked when the worker stops. - -The allowlist holds only noise a journey's scenario makes: requests the -browser could not send while a journey has the device offline, and the 409s of -a start or reopen the app sends through the server's gate and shows refused -(the court busy, a competitor fighting elsewhere or barred by a withdrawal, or -the knockout match it feeds being fought). +such; what an `afterAll` hook's pages log is reported under its own file when +the next file's server starts or the worker stops, and an error there fails +the run. + +The allowlist holds only noise a journey's scenario makes, in three entries: +requests the browser could not send while a journey has the device offline; +the app's own "Failed to fetch court matches" log from the court console +while the device is offline; and the 409s of a start (a `/score` write whose +body carries `status: "running"`) or a reopen the app sends through the +server's gate and shows refused (the court busy, a competitor fighting +elsewhere or barred by a withdrawal, or the knockout match it feeds being +fought). Any other write refused that way is an error. A browser "Failed to +load resource" line is paired with the `/api` answer it is about one to one, +in arrival order per URL. Each entry matches as narrowly as it can (the message, the URL, the refusal code, offline only) and says why in one line. An error that looks like the app's own fault does not go on it: the test stays red until a person decides. ## Output -Each journey has a folder, `output//`. Its per-step screenshots land -there, numbered in the order they were taken, beside its `audit.json` (the +Each journey has a folder, `output//`, named by the journey passed to +`journeyShots` (it may be a subfolder, `knockout-mixed-individual/J1`). Its +per-step screenshots land there, numbered in the order they were taken, beside its `audit.json` (the audit rows, also printed to the run log as `AUDIT {json}` lines), and are replaced on the next run. Playwright's own traces and failure screenshots land in `output/test-results/`. All of it is gitignored. There are no pixel comparisons: a screenshot is for a person to look at. -Every run empties `output/test-results/` when it starts. Two runs at the same -time in one checkout would delete each other's traces mid-test, so give the -second one its own folder: `E2E_RESULTS_DIR=output/test-results-2 make e2e ...`. +Traces are kept for a failing test only (`retain-on-failure`). Open one with + + npx playwright show-trace output/test-results//trace.zip + +from this directory. The trace's Attachments tab holds the test's +`console.json` (see [The console policy](#the-console-policy)); the list reporter +does not write it to a file of its own, and a passing test keeps no trace, so +its console errors are only in the run log as `CONSOLE {json}` lines. A failing +test also carries the server's output as `server.log`. + +Every run empties `output/test-results/` when it starts, and a journey empties +its own `output//` in `beforeAll`. Two runs at the same time in one +checkout would delete each other's traces mid-test, and two runs without +`E2E_RESULTS_DIR` share every `output//`, so give the second one its +own folder: `E2E_RESULTS_DIR=output/test-results-2 make e2e ...`. With `E2E_RESULTS_DIR` set, the journey folders move inside it too (`/journeys//`), so neither run empties the other's. A relative `E2E_RESULTS_DIR` is relative to this directory. @@ -208,3 +244,24 @@ becomes so the suite stays green while the defect is open. The PR that fixes the bug removes the `fixme`, and the step then guards the fix. + +## Adding a journey + +1. Name the file `journeys/.spec.mjs`. A journey about a public surface + alone is `journeys/.public.spec.mjs` and runs on the phone project. +2. Import `test` and `expect` from `../fixtures/test.mjs`, never from + `@playwright/test`: only that `test` starts the file's server and applies + the console policy and the coarse-pointer check. +3. Seed through the interface with the fixtures (`createTournament`, `login`, + `createCompetition`, `pasteRoster`, `generateDraw`, `startCompetition`). +4. Take screenshots and keep an audit: `const shot = journeyShots('')` + in `beforeAll` (it empties the folder), `const audit = auditLog('')` + at describe level with `audit.flush()` in `afterAll`, so a journey that fails + part way still leaves its rows. `record` from the audit prints each row as an + `AUDIT` line. +5. When tests share the seeded server, declare + `test.describe.configure({ mode: 'serial' })`. +6. A defect the journey finds becomes a `test.fixme(': ...')` step + (see below). + +`smoke.spec.mjs` is the smallest complete example. diff --git a/scripts/e2e/fixtures/clumsy.mjs b/scripts/e2e/fixtures/clumsy.mjs index 55f97ee77..1e9edf174 100644 --- a/scripts/e2e/fixtures/clumsy.mjs +++ b/scripts/e2e/fixtures/clumsy.mjs @@ -2,7 +2,7 @@ // helpers a journey calls around its intended actions: // // V1 tapNeighbour the tap lands on the control next to the intended one -// V2 doubleTap two taps inside 300ms +// V2 doubleTap two taps inside the app's bounce window // retapWhileSaving a second tap while the button still reads "Saving…" // V3 interrupt reload / back / tab hidden / offline, then return // V4 hastyConfirm a confirm dialog answered by its loudest button @@ -18,7 +18,14 @@ // Taps go through page.touchscreen at the element's centre: the browser's own // hit-testing decides what is tapped, as it does for a thumb. Every project in // playwright.config.mjs has a touchscreen. -import { dwell } from './pace.mjs'; +import { errors } from '@playwright/test'; +import { dwell, TAP_BOUNCE_MS } from './pace.mjs'; + +// The longest gap that still counts as a double tap: the app's bounce window +// less a margin for the time between the first tap's start and the click the +// app times from. +const DOUBLE_TAP_MARGIN_MS = 100; +export const DOUBLE_TAP_LIMIT_MS = TAP_BOUNCE_MS - DOUBLE_TAP_MARGIN_MS; // Controls a thumb can land on. const INTERACTIVE = 'button, [role="button"], a[href], input:not([type="hidden"]), select, textarea, [role="checkbox"], [role="tab"], [role="switch"]'; @@ -86,10 +93,14 @@ export async function tapNeighbour(locator, direction) { return row; } -// V2. Two taps on the same spot inside 300ms, the impatient thumb. Returns -// { gapMs }, from the start of the first tap to the start of the second; -// `withinWindow` is false if this machine was too slow to land them inside -// 300ms, in which case the row did not test what it says. +// V2. Two taps on the same spot inside the app's bounce window +// (DOUBLE_TAP_LIMIT_MS, derived from tap_guard.jsx's TAP_BOUNCE_MS), the +// impatient thumb. Returns { gapMs, withinWindow }, gapMs being the time from +// the start of the first tap to the start of the second, and withinWindow +// always true: a machine too slow to land the taps inside the window THROWS, +// because the row would not have tested what it says. The first tap's effects +// cannot be taken back, so the taps are not retried; the caller's test fails +// and is run again. Callers may assert on the outcome unconditionally. export async function doubleTap(locator) { const page = locator.page(); const { x, y } = await centreOf(locator); @@ -97,15 +108,20 @@ export async function doubleTap(locator) { await page.touchscreen.tap(x, y); const gapMs = Date.now() - t0; await page.touchscreen.tap(x, y); - return { gapMs, withinWindow: gapMs < 300 }; + if (gapMs >= DOUBLE_TAP_LIMIT_MS) { + throw new Error(`clumsy.doubleTap: the two taps were ${gapMs} ms apart (limit ${DOUBLE_TAP_LIMIT_MS} ms, the app's bounce window is ${TAP_BOUNCE_MS} ms): this machine was too slow, so the row did not test the bounce guard`); + } + return { gapMs, withinWindow: true }; } // V2 (second form). Tap `locator`, then tap the same spot again while it reads // "Saving…". A local server answers in milliseconds, far too fast for any // thumb, so the write is held for `holdWritesMs` first (every non-GET /api // request), which is what a venue's wifi does anyway. Returns -// { savingSeen, retapped, labelAfter }: savingSeen false means the button -// never showed "Saving…" and the second tap was not made. +// { savingSeen, retapped, labelAfter }, both flags always true: a button that +// never shows "Saving…" within the hold THROWS (the second tap was not made, so +// a check built on this helper would pass without testing anything). Any other +// failure propagates as it is. export async function retapWhileSaving(locator, { holdWritesMs = 1200, savingText = /Saving…/ } = {}) { const page = locator.page(); const { x, y } = await centreOf(locator); @@ -122,12 +138,20 @@ export async function retapWhileSaving(locator, { holdWritesMs = 1200, savingTex await page.route('**/api/**', hold); try { await page.touchscreen.tap(x, y); - const savingSeen = await page.waitForFunction( - ([el, src, flags]) => new RegExp(src, flags).test(el.textContent || ''), - [handle, savingText.source, savingText.flags], - { timeout: holdWritesMs, polling: 'raf' }, - ).then(() => true, () => false); - if (savingSeen) await page.touchscreen.tap(x, y); + try { + await page.waitForFunction( + ([el, src, flags]) => new RegExp(src, flags).test(el.textContent || ''), + [handle, savingText.source, savingText.flags], + { timeout: holdWritesMs, polling: 'raf' }, + ); + } catch (err) { + if (err instanceof errors.TimeoutError) { + throw new Error(`clumsy.retapWhileSaving: ${locator} never read ${savingText} within ${holdWritesMs} ms of the tap (writes were held for that long), so there was no second tap to make`); + } + throw err; + } + const savingSeen = true; + await page.touchscreen.tap(x, y); // Let the held write go through before the route is removed. await page.waitForFunction( ([el, src, flags]) => !el.isConnected || !new RegExp(src, flags).test(el.textContent || ''), diff --git a/scripts/e2e/fixtures/pace.mjs b/scripts/e2e/fixtures/pace.mjs index 455e95290..d64241dab 100644 --- a/scripts/e2e/fixtures/pace.mjs +++ b/scripts/e2e/fixtures/pace.mjs @@ -17,8 +17,23 @@ // moment the journey saw the layer or the arm, or made the tap. TAP_DWELL_MS // is the screenshot harness's value, kept above the app's window, so both // harnesses pace alike. +import fs from 'node:fs'; +import { fileURLToPath } from 'node:url'; import { TAP_DWELL_MS } from '../../screenshots/lib/editor.mjs'; +// The app's own window, read from its one source (tap_guard.jsx is JSX, so it +// cannot be imported here). Failing to find it is an error: a harness that +// guessed would drift from the app unnoticed. +export const TAP_BOUNCE_MS = (() => { + const file = fileURLToPath(new URL('../../../web-mobile/js/tap_guard.jsx', import.meta.url)); + const m = /export const TAP_BOUNCE_MS = (\d+);/.exec(fs.readFileSync(file, 'utf8')); + if (!m) throw new Error(`pace: no TAP_BOUNCE_MS found in ${file}`); + return Number(m[1]); +})(); +if (TAP_DWELL_MS <= TAP_BOUNCE_MS) { + throw new Error(`pace: TAP_DWELL_MS (${TAP_DWELL_MS}) must exceed the app's TAP_BOUNCE_MS (${TAP_BOUNCE_MS})`); +} + // Wait out what is left of the dwell since `since` (a Date.now() reading taken // when the layer or the arm was seen, or the tap made). export async function dwell(page, since) { diff --git a/scripts/e2e/fixtures/shared-court.mjs b/scripts/e2e/fixtures/shared-court.mjs index 22b162c6a..7015fae91 100644 --- a/scripts/e2e/fixtures/shared-court.mjs +++ b/scripts/e2e/fixtures/shared-court.mjs @@ -253,9 +253,14 @@ export async function chooseCourt(row, court) { export async function runningOnCourt(page, court) { await page.goto('/admin/score-editor'); await expect(page.getByRole('heading', { level: 1, name: 'Score editor' })).toBeVisible(); - await page.getByRole('group', { name: 'Match status' }).getByRole('button', { name: 'Now' }).tap(); - await page.waitForTimeout(500); - const rows = await page.locator('.score-edit-row').evaluateAll((els) => els.map((r) => { + const now = page.getByRole('group', { name: 'Match status' }).getByRole('button', { name: 'Now' }); + await now.tap(); + await expect(now).toHaveAttribute('aria-pressed', 'true'); + // Wait for the list to have settled on a row or on its empty state, then for two + // consecutive reads to agree: a read straight after the tap could see the list + // before the tournament loaded and make a zero-length assertion pass vacuously. + await expect(page.locator('.score-edit-row').first().or(page.getByText('No matches', { exact: true }))).toBeVisible(); + const read = () => page.locator('.score-edit-row').evaluateAll((els) => els.map((r) => { const names = [...r.querySelectorAll('.numbered-name__text')].map((n) => n.textContent.trim()); const ct = r.querySelector('.score-edit-row__court'); const comp = r.querySelector(':scope > div:first-child > div:last-child'); @@ -267,6 +272,14 @@ export async function runningOnCourt(page, court) { status: r.classList.contains('score-edit-row--running') ? 'running' : 'other', }; })); + let rows = await read(); + for (let i = 0; i < 20; i++) { + await page.waitForTimeout(250); + const again = await read(); + const same = JSON.stringify(again) === JSON.stringify(rows); + rows = again; + if (same) break; + } return rows.filter((r) => r.court === court && r.status === 'running'); } diff --git a/scripts/e2e/fixtures/team.mjs b/scripts/e2e/fixtures/team.mjs index 2aef96de9..4918a1077 100644 --- a/scripts/e2e/fixtures/team.mjs +++ b/scripts/e2e/fixtures/team.mjs @@ -28,7 +28,7 @@ export const FIK5 = ['Senpo', 'Jiho', 'Chuken', 'Fukusho', 'Taisho']; export const positionLabels = (teamSize) => (teamSize === 5 ? FIK5 : Array.from({ length: teamSize }, (_, i) => String(i + 1))); // Sign in, creating the tournament first if this server has none yet. Every -// test in a serial file can call it, whichever runs first. +// test in a file can call it, whichever runs first or in which worker. export async function enterAdmin(page, { courts = 16 } = {}) { await page.goto('/'); // The first-run form, or the public home of a tournament that exists: diff --git a/scripts/e2e/fixtures/test.mjs b/scripts/e2e/fixtures/test.mjs index 339d826f3..301564494 100644 --- a/scripts/e2e/fixtures/test.mjs +++ b/scripts/e2e/fixtures/test.mjs @@ -2,7 +2,8 @@ // file, an operator page whose pointer is verified coarse, and the console // policy every page of the test is held to. import { test as base, expect } from '@playwright/test'; -import { start } from '../../screenshots/lib/server.mjs'; +import fs from 'node:fs'; +import { start, BIN } from '../../screenshots/lib/server.mjs'; import { auditsFor } from './audit.mjs'; import { isCoarse, restoreCoarse } from './pointer.mjs'; @@ -28,6 +29,8 @@ export { isCoarse }; // text the message, as the browser or the app logs it // url where it came from (a failed request's URL, or the script) // code the app's refusal code in the failed /api answer's body +// request which request was refused (method and body), for an entry that +// is about one kind of write only // whileOffline only while the page's context is offline (or just back) const CONSOLE_ALLOWLIST = [ { @@ -43,19 +46,29 @@ const CONSOLE_ALLOWLIST = [ whileOffline: true, }, { - reason: 'A start or reopen the app sends through the server\'s gate and shows refused (toast, row notice, court-busy panel, reopen error): the shiaijo is running another bout, a competitor is fighting elsewhere or barred by a withdrawal, or the knockout match it feeds is being fought.', + reason: 'A start or reopen the app sends through the server\'s gate and shows refused (toast, row notice, court-busy panel, reopen error): the shiaijo is running another bout, a competitor is fighting elsewhere or barred by a withdrawal, or the knockout match it feeds is being fought. A start is a /score write with status "running"; any other /score write refused this way is a real error.', text: /^Failed to load resource: the server responded with a status of 409 \(Conflict\)$/, url: /\/api\/competitions\/[^/]+\/matches\/[^/]+\/(score|reopen)$/, code: /^(court_busy|ineligible_competitor|downstream_knockout_running)$/, + request: (r) => !!r && (/\/reopen$/.test(r.url) || writeStatus(r.postData) === 'running'), }, ]; // The `error` field of a refusal body; read with a pattern, since the body is // kept only in part. const refusalCode = (body) => (/"error"\s*:\s*"([^"]+)"/.exec(body || '') || [])[1] || null; +// The `status` a score write carries (its postData is JSON), or null. +const writeStatus = (postData) => { + try { + return JSON.parse(postData || '').status ?? null; + } catch { + return null; + } +}; const allowed = (e) => e.kind === 'console' && CONSOLE_ALLOWLIST.find((r) => r.text.test(e.text) && (!r.url || r.url.test(e.url || '')) && (!r.code || r.code.test(refusalCode(e.body) || '')) + && (!r.request || r.request(e.request)) && (!r.whileOffline || e.offline)); // One collector per worker. Pages report into `pending`, and each test's @@ -96,7 +109,7 @@ function consoleCollector() { if (res.status() < 400 || !/\/api\//.test(res.url())) return; const at = Date.now(); const body = await res.text().then((t) => t.slice(0, 1000), () => null); - failed.push({ url: res.url(), status: res.status(), body, at }); + failed.push({ url: res.url(), status: res.status(), method: res.request().method(), postData: res.request().postData(), body, at }); }); }; const attachContext = (ctx) => { @@ -112,25 +125,36 @@ function consoleCollector() { for (const p of ctx.pages()) attachPage(p); }; // The answer a "Failed to load resource" line is about: the same URL, - // answered within a few seconds of it. - const answerFor = (e) => { - if (e.kind !== 'console' || !e.url || !/^Failed to load resource/.test(e.text)) return null; - const near = failed.filter((f) => f.url === e.url && Math.abs(f.at - e.at) < 5_000); - return near.length ? near[near.length - 1] : null; - }; + // answered within a few seconds of it. Lines and answers pair one to one in + // arrival order (per URL, first line takes the first answer), so a second + // answer of another code can never be read as the first line's. const drain = () => { + const unclaimed = failed.splice(0, failed.length); const out = pending.splice(0, pending.length).map((e) => { - const answer = answerFor(e); - return answer ? { ...e, status: answer.status, body: answer.body } : e; + if (e.kind !== 'console' || !e.url || !/^Failed to load resource/.test(e.text)) return e; + const i = unclaimed.findIndex((f) => f.url === e.url && Math.abs(f.at - e.at) < 5_000); + if (i < 0) return e; + const [answer] = unclaimed.splice(i, 1); + return { ...e, status: answer.status, body: answer.body, request: { url: answer.url, method: answer.method, postData: answer.postData } }; }); - failed.splice(0, failed.length); return out; }; - return { attachPage, attachContext, drain }; + return { attachPage, attachContext, drain, late: [], file: null }; } const describeEntry = (e) => `${e.kind === 'pageerror' ? 'uncaught page error' : `console ${e.type}`} on ${e.page || '(no page)'}: ${e.text}${e.url ? ` (from ${e.url}${e.line != null ? `:${e.line}` : ''})` : ''}`; +// Report what file `c.file` logged after its last test (its afterAll hooks). +// Errors not on the allowlist are kept in c.late, which fails the run when the +// worker ends. +function settleLate(c) { + for (const e of c.drain()) { + const phase = `after the last test of ${c.file} (an afterAll hook)`; + console.log(`CONSOLE ${JSON.stringify({ phase, ...e })}`); + if (e.type === 'error' && !allowed(e)) c.late.push({ ...e, file: c.file }); + } +} + export const test = base.extend({ // One mobile-app server per SPEC FILE, on a free port and a throwaway // TOURNAMENT_DATA_DIR, started and stopped by scripts/screenshots' @@ -141,7 +165,7 @@ export const test = base.extend({ // changes. Tests in a file share theirs, so a spec that seeds once and walks // several tests declares test.describe.configure({ mode: 'serial' }). A // beforeAll hook (worker scope) asks for it with its own testInfo.file. - serverForFile: [async ({}, use) => { + serverForFile: [async ({ consoleCollector: c }, use) => { let current = null; // { file, server } const forFile = async (file) => { if (current && current.file === file) return current.server; @@ -149,12 +173,16 @@ export const test = base.extend({ const old = current; current = null; await old.server.stop(); + // Whatever the previous file's afterAll hooks logged is still pending: + // settle it as theirs, not as the first test of this file's. + settleLate(c); } + c.file = file; let server; try { server = await start('mobile'); } catch (err) { - throw new Error(`${err.message}\n(make e2e builds the binary before it runs the suite)`); + throw new Error(`${err.message}${fs.existsSync(BIN) ? '' : '\n(make e2e builds the binary before it runs the suite)'}`); } current = { file, server }; return server; @@ -166,16 +194,17 @@ export const test = base.extend({ } }, { scope: 'worker' }], - // The console policy's collector (above). Whatever is still pending when the - // worker ends came from an afterAll hook, after the last test's check: it is - // reported there, and an error fails the run. + // The console policy's collector (above). What is still pending when a file + // ends (its server is replaced) or the worker ends came from that file's + // afterAll hooks, after its last test's check: settleLate reports it under + // that file, and an error fails the run when the worker ends. consoleCollector: [async ({}, use) => { const c = consoleCollector(); await use(c); - const late = c.drain(); - for (const e of late) console.log(`CONSOLE ${JSON.stringify({ phase: 'after the last test (an afterAll hook)', ...e })}`); - const bad = late.filter((e) => e.type === 'error' && !allowed(e)); - if (bad.length) throw new Error(`console policy: ${bad.length} error(s) after the last test:\n${bad.map(describeEntry).join('\n')}`); + settleLate(c); + if (c.late.length) { + throw new Error(`console policy: ${c.late.length} error(s) after the last test of a file (an afterAll hook):\n${c.late.map((e) => `[${e.file}] ${describeEntry(e)}`).join('\n')}`); + } }, { scope: 'worker' }], // Every context made through this browser, a spec's own browser.newContext @@ -229,7 +258,11 @@ export const test = base.extend({ // The server of the file this test is in. server: async ({ serverForFile }, use, testInfo) => { - await use(await serverForFile(testInfo.file)); + const server = await serverForFile(testInfo.file); + await use(server); + if (testInfo.status !== testInfo.expectedStatus) { + await testInfo.attach('server.log', { body: server.logTail(), contentType: 'text/plain' }); + } }, // Every journey navigates by path; the host is the file's server. diff --git a/scripts/e2e/journeys/knockout-mixed-individual.spec.mjs b/scripts/e2e/journeys/knockout-mixed-individual.spec.mjs index c75a09a97..f0876560f 100644 --- a/scripts/e2e/journeys/knockout-mixed-individual.spec.mjs +++ b/scripts/e2e/journeys/knockout-mixed-individual.spec.mjs @@ -162,6 +162,10 @@ test.describe('knockout-mixed-individual', () => { const afterDbl = await slotMarks(page, w1); row({ step: 'award ippon', action: `${w1} M`, variant: 'V2 doubleTap', ...dblIppon, marks: afterDbl, shot: await shot(page, 'ippon-double-tapped') }); + // The side was empty (the wrong mark was cleared above): a bounce would land + // two marks, so exactly one is the app ignoring it. + expect(dblIppon.withinWindow).toBe(true); + expect(afterDbl).toEqual(['M']); // V2 on the undo: a double tap on the outer mark. const outer = inlineEditor(page).locator(`[aria-label^="${w1 === 'shiro' ? 'Shiro' : 'Aka'} slot "][aria-label*=": remove "]`).first(); @@ -430,11 +434,21 @@ test.describe('knockout-mixed-individual', () => { seen.push(await editorIdentity(page, EDITOR)); } expect(new Set(seen.map((s) => s.key)).size, 'Next showed at least two matches').toBeGreaterThanOrEqual(2); + // Docs (scoring-a-match.md): Left and Right move to the previous or next + // match on the court, so each key must land on the neighbour the key names. + const atEnd = seen[seen.length - 1].key; + const keyed = []; for (const key of ['ArrowLeft', 'ArrowRight', 'ArrowLeft']) { + const was = (await editorIdentity(page, EDITOR)).key; await page.keyboard.press(key); - await page.waitForTimeout(200); - seen.push(await editorIdentity(page, EDITOR)); + await expect.poll(async () => (await editorIdentity(page, EDITOR)).key, { message: `${key} moves the editor` }).not.toBe(was); + const now = await editorIdentity(page, EDITOR); + keyed.push({ key, from: was, to: now.key }); + seen.push(now); } + expect(keyed[0].to, 'Left goes to the previous match').not.toBe(atEnd); + expect(keyed[1].to, 'Right comes back').toBe(atEnd); + expect(keyed[2].to, 'Left again goes to the same previous match').toBe(keyed[0].to); row({ step: 'scores tab nav', action: 'Next x3, ArrowLeft/Right', variant: 'correct path', courts: seen.map((s) => s.court), eyebrows: seen.map((s) => s.eyebrow), shot: await shot(page, 'scores-overlay-after-nav') }); for (const s of seen) expect(s.court).toBe('B'); @@ -693,6 +707,7 @@ test.describe('knockout-mixed-individual', () => { finishEnabled: !koTieDisabled, label: koTieLabel, title: koTieTitle, completed: tieCompleted, stillRunning: await inlineRunning(page), shot: await shot(page, 'finish-on-knockout-tie') }); expect(koTieDisabled).toBe(true); + expect(tieCompleted, 'the tied bout is not completed').toBe(false); expect(koTieLabel).toBe('Needs a winner'); expect(koTieTitle).toBe('Needs a winner: fight encho, then record hantei if still tied.'); expect(tieCompleted).toBe(false); @@ -744,7 +759,9 @@ test.describe('knockout-mixed-individual', () => { labels: [(await shiroWins.innerText()).trim(), (await akaWins.innerText()).trim()] }); await shot(page, 'hantei-armed'); const nbHantei = await tapNeighbour(shiroWins, 'right'); - await page.waitForTimeout(1000); + // The tap picked a side (a positive signal) before it is read that nothing committed. + await expect.poll(async () => (await shiroWins.getAttribute('aria-pressed')) === 'true' + || (await akaWins.getAttribute('aria-pressed')) === 'true', { message: 'the neighbour tap picked a side' }).toBe(true); const semiRow = completedRowFor(page, semi); const committedByPick = await semiRow.isVisible().catch(() => false); const picked = { shiro: await shiroWins.getAttribute('aria-pressed'), aka: await akaWins.getAttribute('aria-pressed') }; @@ -798,7 +815,7 @@ test.describe('knockout-mixed-individual', () => { // J6. Confirm + advance OFFLINE (F2, court C): the second semi-final is // finished with the network down. The write is queued, the local bracket // advances; back online, the server must agree. - test('J6 finish a knockout bout offline, then reconnect', async ({ page, context }) => { + test('J6 finish a knockout bout offline, then reconnect', async ({ page, context, browser, baseURL }) => { test.setTimeout(180_000); const J = 'J6'; const shot = journeyShots('knockout-mixed-individual/J6'); @@ -906,16 +923,28 @@ test.describe('knockout-mixed-individual', () => { expect(landed).toBe(true); expect(semiResult).toMatch(/M/); - // A second device (fresh page) reads the server's bracket. - const other = await context.newPage(); - await other.goto(`/admin/competition/${comps.F2}/bracket`); - await expect(other.getByText('3RD PLACE MATCH')).toBeVisible(); - const bracketText = (await other.locator('.page').first().innerText()).replace(/\s+/g, ' '); - row({ step: 'reconnect', action: 'second page reads the bracket', variant: 'correct path', - finalHasWinner: bracketText.includes(winnerName), bronzeHasLoser: (bracketText.split('3RD PLACE MATCH')[1] || '').includes(loserName), - shot: await shot(other, 'server-bracket-after-reconnect', { fullPage: true }) }); - expect((bracketText.split('3RD PLACE MATCH')[1] || '')).toContain(loserName); - await other.close(); + // The first device's held write has gone out: its topbar shows no held-writes + // count once the queue is empty. + await expect(page.getByTestId('topbar-held')).toHaveCount(0, { timeout: 20_000 }); + + // A second device (its own browser context, so its own storage and no + // outbox shared with the first) reads the server's bracket. + const otherCtx = await browser.newContext({ ...OPERATOR_DEVICE, baseURL }); + try { + const other = await otherCtx.newPage(); + await login(other); + await other.goto(`/admin/competition/${comps.F2}/bracket`); + await expect(other.getByText('3RD PLACE MATCH')).toBeVisible(); + // The server's bracket seats the semi-final's loser in the 3rd-place match. + const bracketText = (await other.locator('.page').first().innerText()).replace(/\s+/g, ' '); + await expect.poll(async () => ((await other.locator('.page').first().innerText()).replace(/\s+/g, ' ').split('3RD PLACE MATCH')[1] || ''), + { timeout: 15_000 }).toContain(loserName); + row({ step: 'reconnect', action: 'second device reads the bracket', variant: 'correct path', + finalHasWinner: bracketText.includes(winnerName), bronzeHasLoser: (bracketText.split('3RD PLACE MATCH')[1] || '').includes(loserName), + shot: await shot(other, 'server-bracket-after-reconnect', { fullPage: true }) }); + } finally { + await otherCtx.close(); + } }); // J8. The same scoring from the competition's own Pools tab (F1c, overlay diff --git a/scripts/e2e/journeys/knockout-mixed-kachinuki.spec.mjs b/scripts/e2e/journeys/knockout-mixed-kachinuki.spec.mjs index eb9d6bac8..55b551aac 100644 --- a/scripts/e2e/journeys/knockout-mixed-kachinuki.spec.mjs +++ b/scripts/e2e/journeys/knockout-mixed-kachinuki.spec.mjs @@ -96,6 +96,128 @@ test.describe('J5 kachinuki from the court console', () => { test.afterAll(() => audit.flush()); + // The four regression guards below (bc-crpn, bc-emsl, bc-kheb, bc-sync) need + // the final's live console, so they run as steps INSIDE the final's tests. A + // failure is deferred, not thrown: it is recorded, the console is put back, + // and the final's own checks (played out, completed, the public viewer) still + // run; the played-out test fails at its very end naming every guard that did. + // (In a serial file a failing test skips the rest, and a restarted worker + // loses the shared state, so a guard of its own could not stand before them.) + const guardFailures = []; + async function guarded(page, title, fn) { + try { + await test.step(title, () => fn(page)); + } catch (e) { + guardFailures.push(`${title}: ${String(e.message).split('\n')[0]}`); + record({ step: title, action: 'regression guard', variant: 'FAILED', error: String(e.message).split('\n')[0] }); + await openShiaijo(page, 'A').catch(() => {}); + if (await recordBoutButton(page).isVisible().catch(() => false)) { + // Leave the live bout unmarked and untied, as the final's play-out expects. + const live = currentBout(page); + for (const side of ['shiro', 'aka']) { + while ((await boutFilled(live, side).count().catch(() => 0)) > 0) await boutFilled(live, side).last().tap(); + } + if (/✓ Tie/.test(await tieButton(live).innerText().catch(() => ''))) await tieButton(live).tap(); + } + } + } + + // bc-crpn (fixed): the court console never holds a running match and an + // open correction at once. While a finished match is open for correction, + // Start match on the Up next card is refused with a notice at the card, the + // correction stays open and the queued match is not started. + async function guardCrpn(page) { + const next = await sides(upNextCard(page)); + await completedRowFor(page, m1).locator('.shiaijo-row__correct').tap(); + await expect(reopenButton(page)).toBeVisible(); + const start = upNextCard(page).getByRole('button', { name: 'Start match' }); + await start.tap(); + const notice = upNextCard(page).locator('.shiaijo-upnext__error'); + await expect(notice).toHaveText(new RegExp( + `^Save the correction of .*${m1.shiro} vs ${m1.aka}, or leave it with Back to court, then start this match\\.$`)); + record({ step: 'bc-crpn', action: 'Start match (Up next) while M1 is open for correction', variant: 'refused', + notice: (await notice.innerText()).trim(), + toast: (await page.locator('.toast').allInnerTexts().catch(() => [])).join(' | '), + screenshot: await shot(page, 'bc-crpn-start-refused-during-correction') }); + // The correction stays open, and the final is still waiting in the queue. + await expect(reopenButton(page)).toBeVisible(); + await expect(recordBoutButton(page)).toHaveCount(0); + await expect(start).toBeVisible(); + expect(await sides(upNextCard(page))).toEqual(next); + // Leave the correction; the court is as it was, with the final up next. + await page.getByRole('button', { name: /Back to court/ }).tap(); + await expect(reopenButton(page)).toHaveCount(0); + await expect(notice).toHaveCount(0); + expect(await sides(upNextCard(page))).toEqual(next); + } + + // bc-emsl (fixed): a tap on an EMPTY mark slot (the "·" beside the scored + // marks) used to clear the bout's Tie. It is a no-op now; this guards it. + async function guardEmsl(page) { + const row = currentBout(page); + // The final's live bout carries one Shiro point; clear it, mark the tie. + while ((await boutFilled(row, 'shiro').count()) > 0) await boutFilled(row, 'shiro').last().tap(); + await tieButton(row).tap(); + await expect(tieButton(row)).toHaveText(/✓ Tie/); + await row.locator('.tsm-center-pts--aka .editor-side__pt:not(.editor-side__pt--filled)').first().tap(); + await expect(tieButton(row)).toHaveText(/✓ Tie/); + await expect(boutMiddle(row)).resolves.toBe('X'); + // Restore: untie, one Shiro point. + await tieButton(row).tap(); + await awardBoutIppon(page, 'shiro', 'M'); + } + + // bc-kheb (fixed): Encho on ONE kachinuki bout used to set the encounter's + // overtime count, so the header kept reading "(E) OVERTIME" on every later + // bout. The (E) now lives on that bout's row only; this guards it. + async function guardKheb(page) { + const row = currentBout(page); + while ((await boutFilled(row, 'shiro').count()) > 0) await boutFilled(row, 'shiro').last().tap(); + await tieButton(row).tap(); + await enchoButton(page).tap(); + await awardBoutIppon(page, 'shiro', 'M'); + await recordBout(page); + // Bout 2 is a regulation bout. + await expect(editor(page).locator('.editor-modal__eyebrow').first()).not.toContainText(/overtime/i); + } + + // bc-sync (fixed): the sync pill used to read "Synced" through the + // autosave's debounce window after a tap, although nothing had been sent, + // so a reload there lost the point with no warning (audit row "final bout + // 4"). It reads "Syncing…" from the tap until the write lands; this guards + // it. The score write is held, so the pill is read while the point is + // provably still unsaved, whatever this machine's timing. + async function guardSync(page) { + const live = currentBout(page); + const isScoreWrite = (url) => /\/api\/competitions\/[^/]+\/matches\/[^/]+\/score$/.test(url.pathname); + let release; + const released = new Promise((r) => { release = r; }); + const hold = async (route) => { + if (route.request().method() !== 'PUT') return route.continue(); + await released; + return route.continue().catch(() => {}); + }; + await page.route(isScoreWrite, hold); + try { + const sent = page.waitForRequest((r) => r.method() === 'PUT' && isScoreWrite(new URL(r.url()))); + // A test that fails before the write goes out must not leave this rejecting unheard. + sent.catch(() => {}); + await boutIpponButton(live, 'aka', 'D').tap(); + // From the tap: the edit waits out the debounce (nothing sent yet)... + await expect(syncPill(page)).toHaveText('Syncing…'); + // ...then its write is out, and held: still unsaved. + await sent; + await expect(syncPill(page)).toHaveText('Syncing…'); + } finally { + release(); + await page.unroute(isScoreWrite, hold); + } + await expect(syncPill(page)).toHaveText('Synced'); + // Leave the bout as it was: take the point back. + await boutFilled(currentBout(page), 'aka').last().tap(); + await expect(boutFilled(currentBout(page), 'aka')).toHaveCount(0); + } + test('F5: seed a kachinuki knockout and name every team member at the court', async ({ page }) => { await createTournament(page, { name: 'Kachinuki Cup', courts: 2 }); compId = await createCompetition(page, { @@ -322,7 +444,8 @@ test.describe('J5 kachinuki from the court console', () => { const tappedAt = Date.now(); const dbl = await doubleTap(boutIpponButton(currentBout(page), 'shiro', 'M')); // The app ignores the bounce of a tap: two taps inside its window land one M. - if (dbl.withinWindow) await expect(boutFilled(currentBout(page), 'shiro')).toHaveCount(1); + expect(dbl.withinWindow).toBe(true); + await expect(boutFilled(currentBout(page), 'shiro')).toHaveCount(1); record({ step: 'M1 bout 5', action: 'Shiro M', variant: 'V2 doubleTap', ...dbl, marksAfter: await boutFilled(currentBout(page), 'shiro').allInnerTexts() }); // The mistake the later correction is about: the operator, meaning M K, @@ -563,15 +686,18 @@ test.describe('J5 kachinuki from the court console', () => { // it the bounce added a second). Does the court's own Completed record say so? const want = /IV 4–2\s+PW 7–3/; const result = row.locator('.shiaijo-qrow__result'); - const settled = await expect(result).toHaveText(want, { timeout: 6000 }).then(() => true, () => false); + const settledWithoutRefresh = await expect(result).toHaveText(want, { timeout: 6000 }).then(() => true, () => false); const shownAtFirst = (await result.innerText()).replace(/\s+/g, ' '); const staleShot = await shot(page, 'm1-completed'); await page.getByRole('button', { name: 'Refresh' }).tap(); await page.waitForTimeout(1500); record({ step: 'M1 end', action: 'Completed row after End match', variant: 'result', expected: 'IV 4–2 PW 7–3', - shownFor6s: shownAtFirst, settledWithoutRefresh: settled, + shownFor6s: shownAtFirst, settledWithoutRefresh, afterRefresh: (await completedRowFor(page, m1).locator('.shiaijo-qrow__result').innerText()).replace(/\s+/g, ' '), screenshot: staleShot }); + // Whatever the row showed before the refresh (recorded above), the totals + // the bouts add up to must be what the Completed record settles on. + await expect(completedRowFor(page, m1).locator('.shiaijo-qrow__result')).toHaveText(want, { timeout: 8000 }); // The bracket advanced: the final on A now names M1's winner (Shiro). await expect(page.locator('.shiaijo-pending, .shiaijo-upnext').first()).toContainText(m1.shiro); }); @@ -707,10 +833,9 @@ test.describe('J5 kachinuki from the court console', () => { const immediately = (await row.locator('.shiaijo-qrow__result').innerText()).replace(/\s+/g, ' '); const immediateShot = await shot(page, 'm1-ended-again'); // Bout 8 is now a men (1 point) instead of the walkover (2 maru). - const settled = await expect(row.locator('.shiaijo-qrow__result')).toHaveText(/IV 4–2\s+PW 6–3/, { timeout: 8000 }) - .then(() => true, () => false); + await expect(row.locator('.shiaijo-qrow__result')).toHaveText(/IV 4–2\s+PW 6–3/, { timeout: 8000 }); record({ step: 'M1 reopen', action: 'ended again: Completed row', variant: 'result', expected: 'IV 4–2 PW 6–3', - immediately, settledWithin8s: settled, after: (await row.locator('.shiaijo-qrow__result').innerText()).replace(/\s+/g, ' '), + immediately, settledWithin8s: true, after: (await row.locator('.shiaijo-qrow__result').innerText()).replace(/\s+/g, ' '), screenshot: immediateShot }); // Still Kawasemi's (Shiro's) win: the final keeps its side. await expect(page.locator('.shiaijo-pending, .shiaijo-upnext').first()).toContainText(m1.shiro); @@ -1047,37 +1172,6 @@ test.describe('J5 kachinuki from the court console', () => { }); }); - // bc-crpn (fixed): the court console never holds a running match and an - // open correction at once. While a finished match is open for correction, - // Start match on the Up next card is refused with a notice at the card, the - // correction stays open and the queued match is not started. - test('bc-crpn: Start match while a correction is open is refused', async ({ page }) => { - await login(page); - await openShiaijo(page, 'A'); - const next = await sides(upNextCard(page)); - await completedRowFor(page, m1).locator('.shiaijo-row__correct').tap(); - await expect(reopenButton(page)).toBeVisible(); - const start = upNextCard(page).getByRole('button', { name: 'Start match' }); - await start.tap(); - const notice = upNextCard(page).locator('.shiaijo-upnext__error'); - await expect(notice).toHaveText(new RegExp( - `^Save the correction of .*${m1.shiro} vs ${m1.aka}, or leave it with Back to court, then start this match\\.$`)); - record({ step: 'bc-crpn', action: 'Start match (Up next) while M1 is open for correction', variant: 'refused', - notice: (await notice.innerText()).trim(), - toast: (await page.locator('.toast').allInnerTexts().catch(() => [])).join(' | '), - screenshot: await shot(page, 'bc-crpn-start-refused-during-correction') }); - // The correction stays open, and the final is still waiting in the queue. - await expect(reopenButton(page)).toBeVisible(); - await expect(recordBoutButton(page)).toHaveCount(0); - await expect(start).toBeVisible(); - expect(await sides(upNextCard(page))).toEqual(next); - // Leave the correction; the court is as it was, with the final up next. - await page.getByRole('button', { name: /Back to court/ }).tap(); - await expect(reopenButton(page)).toHaveCount(0); - await expect(notice).toHaveCount(0); - expect(await sides(upNextCard(page))).toEqual(next); - }); - test('J5 final: lineups carried over, the final starts on A', async ({ page }) => { await login(page); await openShiaijo(page, 'A'); @@ -1085,6 +1179,7 @@ test.describe('J5 kachinuki from the court console', () => { // The final pairs the two semifinal winners: M1's Shiro and M2's Aka. expect([final.shiro, final.aka].sort()).toEqual([m1.shiro, m2.aka].sort()); finalPair = final; + await guarded(page, 'bc-crpn: Start match while a correction is open is refused', guardCrpn); await test.step('lineups for the final: each team carries its previous match\'s lineup', async () => { await openLineupFromUpNext(page); @@ -1138,83 +1233,13 @@ test.describe('J5 kachinuki from the court console', () => { }); }); - // bc-emsl (fixed): a tap on an EMPTY mark slot (the "·" beside the scored - // marks) used to clear the bout's Tie. It is a no-op now; this guards it. - test('bc-emsl: tapping an empty mark slot leaves the bout\'s Tie on', async ({ page }) => { - await login(page); - await openShiaijo(page, 'A'); - const row = currentBout(page); - // The final's live bout carries one Shiro point; clear it, mark the tie. - while ((await boutFilled(row, 'shiro').count()) > 0) await boutFilled(row, 'shiro').last().tap(); - await tieButton(row).tap(); - await expect(tieButton(row)).toHaveText(/✓ Tie/); - await row.locator('.tsm-center-pts--aka .editor-side__pt:not(.editor-side__pt--filled)').first().tap(); - await expect(tieButton(row)).toHaveText(/✓ Tie/); - await expect(boutMiddle(row)).resolves.toBe('X'); - // Restore: untie, one Shiro point. - await tieButton(row).tap(); - await awardBoutIppon(page, 'shiro', 'M'); - }); - - // bc-kheb (fixed): Encho on ONE kachinuki bout used to set the encounter's - // overtime count, so the header kept reading "(E) OVERTIME" on every later - // bout. The (E) now lives on that bout's row only; this guards it. - test('bc-kheb: after one bout goes to encho, the next bout\'s header does not claim overtime', async ({ page }) => { - await login(page); - await openShiaijo(page, 'A'); - const row = currentBout(page); - while ((await boutFilled(row, 'shiro').count()) > 0) await boutFilled(row, 'shiro').last().tap(); - await tieButton(row).tap(); - await enchoButton(page).tap(); - await awardBoutIppon(page, 'shiro', 'M'); - await recordBout(page); - // Bout 2 is a regulation bout. - await expect(editor(page).locator('.editor-modal__eyebrow').first()).not.toContainText(/overtime/i); - }); - - // bc-sync (fixed): the sync pill used to read "Synced" through the - // autosave's debounce window after a tap, although nothing had been sent, - // so a reload there lost the point with no warning (audit row "final bout - // 4"). It reads "Syncing…" from the tap until the write lands; this guards - // it. The score write is held, so the pill is read while the point is - // provably still unsaved, whatever this machine's timing. - test('bc-sync: the sync pill reads "Syncing…" while a tapped point is still unsaved', async ({ page }) => { - await login(page); - await openShiaijo(page, 'A'); - const live = currentBout(page); - const isScoreWrite = (url) => /\/api\/competitions\/[^/]+\/matches\/[^/]+\/score$/.test(url.pathname); - let release; - const released = new Promise((r) => { release = r; }); - const hold = async (route) => { - if (route.request().method() !== 'PUT') return route.continue(); - await released; - return route.continue().catch(() => {}); - }; - await page.route(isScoreWrite, hold); - try { - const sent = page.waitForRequest((r) => r.method() === 'PUT' && isScoreWrite(new URL(r.url()))); - // A test that fails before the write goes out must not leave this rejecting unheard. - sent.catch(() => {}); - await boutIpponButton(live, 'aka', 'D').tap(); - // From the tap: the edit waits out the debounce (nothing sent yet)... - await expect(syncPill(page)).toHaveText('Syncing…'); - // ...then its write is out, and held: still unsaved. - await sent; - await expect(syncPill(page)).toHaveText('Syncing…'); - } finally { - release(); - await page.unroute(isScoreWrite, hold); - } - await expect(syncPill(page)).toHaveText('Synced'); - // Leave the bout as it was: take the point back. - await boutFilled(currentBout(page), 'aka').last().tap(); - await expect(boutFilled(currentBout(page), 'aka')).toHaveCount(0); - }); - test('J5 final: played out, the knockout completes', async ({ page, browser, baseURL }) => { await login(page); await openShiaijo(page, 'A'); await expect(recordBoutButton(page)).toBeVisible(); + await guarded(page, 'bc-emsl: tapping an empty mark slot leaves the bout\'s Tie on', guardEmsl); + await guarded(page, 'bc-kheb: after one bout goes to encho, the next bout\'s header does not claim overtime', guardKheb); + await guarded(page, 'bc-sync: the sync pill reads "Syncing…" while a tapped point is still unsaved', guardSync); const S = MEMBERS[finalPair.shiro]; // Shiro keeps winning with whoever is on until End match; the operator // ends it when Aka is out of fighters. The Aka fighter changes each bout. @@ -1285,6 +1310,9 @@ test.describe('J5 kachinuki from the court console', () => { } finally { await spectator.close(); } + // Last: the deferred guards (above) fail the test only now, after the + // final and its viewer check ran. + expect(guardFailures, 'regression guards that failed').toEqual([]); }); }); diff --git a/scripts/e2e/journeys/knockout-mixed-team.spec.mjs b/scripts/e2e/journeys/knockout-mixed-team.spec.mjs index 66b64b64d..21a60af37 100644 --- a/scripts/e2e/journeys/knockout-mixed-team.spec.mjs +++ b/scripts/e2e/journeys/knockout-mixed-team.spec.mjs @@ -80,8 +80,11 @@ async function nameStartingLineup(page, id, team, names, teamSize) { await T.lineupsSave(page); } -test.describe.configure({ mode: 'serial' }); - +// NOT serial: every test here seeds its own competitions on its own shiaijo +// (T.enterAdmin creates the tournament when the worker's server has none), so +// a failing journey skips none of the others. After a failure Playwright runs +// the rest in a fresh worker, which gets a fresh server (fixtures/test.mjs +// serverForFile) and enterAdmin creates the tournament there again. test.describe('knockout-mixed-team', () => { test.afterAll(() => { for (const log of audits) log.flush(); @@ -357,9 +360,9 @@ test.describe('knockout-mixed-team', () => { expect(options[0]).toBe(T.STARTING_LINEUP); expect(options.length).toBeGreaterThan(1); await T.openLineups(page, f3, { team: pair3.aka, lineupFor: options[1] }); - const names = [await T.lineupsSelected(page, '1'), await T.lineupsSelected(page, '2'), await T.lineupsSelected(page, '3')]; await expect(T.lineupsForm(page).getByTestId('lineup-source')).toHaveText('Lineup for this match'); await expect(T.lineupsForm(page).getByRole('button', { name: 'Save lineup' })).toBeDisabled(); + const names = [await T.lineupsSelected(page, '1'), await T.lineupsSelected(page, '2'), await T.lineupsSelected(page, '3')]; await shot(page, 'f3-lineups-for-a-match', { fullPage: true }); record({ step: 'Lineup for', action: 'choose the first match in "Lineup for"', variant: 'correct', options, names }); expect(names[0]).toContain('Dai'); @@ -429,49 +432,26 @@ test.describe('knockout-mixed-team', () => { await prompt.locator('input[value="shiro"]').check(); await prompt.locator('button.btn--primary').tap(); // The post-kiken Remaining matches panel was removed by the operator's - // "do not cascade" ruling (2026-09-26; bc-kpnl closed with it): `shown` - // records false and the award loop below taps nothing. Each remaining - // match is closed from its own notice when it comes up (the Start below). - const panel = ed.locator('.remaining-matches'); - const shown = await panel.waitFor({ state: 'visible', timeout: 8000 }).then(() => true, () => false); - const panelText = shown ? (await panel.innerText({ timeout: 2000 }).catch(() => '')).replace(/\s+/g, ' ') : ''; - await shot(page, 'f3-kiken-remaining-matches'); - // Does it stay long enough to act on? The operator reads it first. - await page.waitForTimeout(1500); - const stillThere = await panel.isVisible().catch(() => false); - const editorNow = await sides(ed).catch(() => null); - await shot(page, 'f3-kiken-1500ms-later'); - record({ step: 'pool kiken chain', action: 'the remaining-matches panel, 1.5 s later', variant: 'V5 lost place', stillThere, editorNow, - upNext: await sides(upNextCard(page)).catch(() => null) }); - const award = panel.getByRole('button', { name: /opponent/ }); - const awards = await award.count(); - let nb = null; - if (awards) { - nb = await tapNeighbour(award.first(), 'up').catch((e) => ({ error: String(e.message).slice(0, 120) })); - await page.waitForTimeout(800); - } - const panelAfterSlip = await panel.isVisible().catch(() => false); - await shot(page, 'f3-kiken-after-neighbour-tap'); - record({ step: 'pool kiken chain', action: `Kiken – Voluntary, Shiro (${pair.shiro}) withdraws`, variant: 'correct', panelShown: shown, - panelText: panelText.slice(0, 300), awardButtons: awards, awardTapBox: awards ? await tapSize(award.first()).catch(() => null) : null }); - record({ step: 'pool kiken chain', action: 'Award the remaining match to the opponent', variant: 'V1 tapNeighbour up', ...(nb || {}), panelStillOpen: panelAfterSlip }); - let awarded = 0; - if (panelAfterSlip) { - while (await award.count()) { - const dbl = awarded === 0 ? await doubleTap(award.first()) : (await award.first().tap(), null); - if (dbl) record({ step: 'pool kiken chain', action: 'Award the remaining match to the opponent', variant: 'V2 doubleTap', ...dbl }); - awarded += 1; - await page.waitForTimeout(1200); - if (awarded > 4) break; - } - } - await shot(page, 'f3-kiken-after-awards'); + // "do not cascade" ruling (2026-09-26; bc-kpnl closed with it): the + // withdrawal changes only this match, and each remaining match is closed + // from its own notice when it comes up (the Start below). + const kikenRow = completedRowFor(page, pair); + await expect(kikenRow).toBeVisible(); + // A team encounter's Completed row reads its IV/PW summary: Shiro withdrew, + // so every bout (3 bouts, 2 points each) is credited to Aka and none to Shiro. + await expect(kikenRow.locator('.shiaijo-qrow__result')).toHaveText(/IV 0–3\s+PW 0–6/); + const kikenResult = (await kikenRow.locator('.shiaijo-qrow__result').innerText()).trim(); + await expect(ed.locator('.remaining-matches')).toHaveCount(0); + await shot(page, 'f3-kiken-recorded'); + record({ step: 'pool kiken chain', action: `Kiken – Voluntary, Shiro (${pair.shiro}) withdraws`, variant: 'correct', kikenResult, + editorNow: await sides(ed).catch(() => null), upNext: await sides(upNextCard(page)).catch(() => null) }); await page.goto(`/admin/competition/${f3}/pools`); await page.waitForTimeout(1500); await shot(page, 'f3-pools-after-kiken-chain', { fullPage: true }); const matchesText = (await page.locator('body').innerText()).replace(/\s+/g, ' '); + expect(matchesText).toContain('Kiken'); const i = matchesText.indexOf('Pool A'); - record({ step: 'pool kiken chain', action: 'Pools tab after the chain', variant: 'consistency', awardsTapped: awarded, + record({ step: 'pool kiken chain', action: 'Pools tab after the withdrawal', variant: 'consistency', poolA: matchesText.slice(i, i + 700), note: 'the remaining match stays scheduled: the Remaining matches panel is gone ("do not cascade"), and the match is closed from its own notice' }); // The operator carries on at the court: Up next now holds the // withdrawn team's remaining match. @@ -567,23 +547,27 @@ test.describe('knockout-mixed-team', () => { expect(await sides(ed)).toEqual(sf1); }); - await test.step('bout 1: Shiro men; a double tap on kote; clear the extra mark', async () => { + await test.step('bout 1: a double tap on kote lands one mark; clear it, then Shiro men', async () => { const row = T.boutRow(ed, 1); - await T.boutIppon(ed, 1, 'shiro', 'M'); + // The double tap goes on an EMPTY side, so one mark (not the two a bounce + // would add) is the only outcome that tells the tap guard held: on a side + // that already held a mark the cap of two would hide a bounce. const k = T.rowIppon(row, 'shiro', 'K'); const ipponBox = await tapSize(k); const dbl = await doubleTap(k); await expect(T.syncPill(ed)).toHaveText('Synced'); const slotsAfter = await T.rowSlots(row, 'shiro').allInnerTexts(); await shot(page, 'bout1-double-tap-kote'); - // Recover: tap the extra mark in the centre (the sheet's own hint). + expect(dbl.withinWindow).toBe(true); + expect(slotsAfter).toEqual(['K']); + // Take the mark back (the sheet's own hint), then record Shiro men. const hint = (await ed.getByTestId('team-scoring-clear-hint').innerText().catch(() => '')).trim(); - const extra = T.rowSlots(row, 'shiro').filter({ hasText: /^K$/ }); - const extraCount = await extra.count(); - for (let i = 0; i < extraCount; i += 1) await T.rowSlots(row, 'shiro').filter({ hasText: /^K$/ }).first().tap(); - await expect(T.rowSlots(row, 'shiro')).toHaveCount(1); + await T.rowSlots(row, 'shiro').first().tap(); + await expect(T.rowSlots(row, 'shiro')).toHaveCount(0); + await expect(T.syncPill(ed)).toHaveText('Synced'); + await T.boutIppon(ed, 1, 'shiro', 'M'); await expect(T.syncPill(ed)).toHaveText('Synced'); - record({ step: 'bout 1', action: 'Shiro kote', variant: 'V2 doubleTap', ...dbl, slotsAfter, hint, tapsToRecover: extraCount, ipponTapBox: ipponBox, + record({ step: 'bout 1', action: 'Shiro kote', variant: 'V2 doubleTap', ...dbl, slotsAfter, hint, tapsToRecover: 1, ipponTapBox: ipponBox, slotTapBox: await tapSize(T.rowSlots(row, 'shiro').first()) }); expect(await T.rowSlots(row, 'shiro').allInnerTexts()).toEqual(['M']); }); @@ -1077,7 +1061,12 @@ test.describe('knockout-mixed-team', () => { await shot(phone, 'viewer-final', { fullPage: true }); const viewerText = (await phone.locator('.viewer__body').innerText()).replace(/\s+/g, ' '); await tv.reload(); - await tv.waitForTimeout(1500); + // A finished court's TV board carries no scoreline or winner by design + // (display_scoreboard.jsx: "All matches completed on Shiaijo E", with the + // QR code to the results), so what it can show is that the court is done, + // and not the encounter any more (no live "(DH)" row, no running score). + await expect(tvBoard(tv)).toContainText('All matches completed'); + await expect(tvBoard(tv)).not.toContainText('(DH)'); await shot(tv, 'tv-final'); const tvText = (await tvBoard(tv).innerText()).replace(/\s+/g, ' '); record({ step: 'every surface', action: 'viewer and TV after the correction', variant: 'correct', diff --git a/scripts/screenshots/lib/server.mjs b/scripts/screenshots/lib/server.mjs index c0fbd38aa..68fa59096 100644 --- a/scripts/screenshots/lib/server.mjs +++ b/scripts/screenshots/lib/server.mjs @@ -74,14 +74,40 @@ export async function start(kind) { child.stdout.on('data', keep); child.stderr.on('data', keep); + // A worker or runner that exits without reaching stop() (an uncaught error, + // process.exit) must not leave the server running or its data folder behind. + // A synchronous 'exit' listener is the only thing that still runs then. A + // SIGKILL runs nothing, and no hook can cover it. + const removeData = () => fs.rmSync(dataDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); + const onExit = () => { + if (!exited(child)) child.kill('SIGKILL'); + try { + removeData(); + } catch { + // the process is going away; nothing left to report to + } + }; + process.on('exit', onExit); + const base = `http://localhost:${port}`; try { await waitForHealth(base, child, readyPath); + // A health answer is only ours if our child is still alive: when another + // process took the port between freePort() and the bind, this child exits + // with a bind error and the answer came from the other server. A short + // settle, then the check. + await new Promise((r) => setTimeout(r, 250)); + if (exited(child)) { + throw new Error(`server exited right after answering ${readyPath} ` + + `(${child.signalCode ? `signal ${child.signalCode}` : `code ${child.exitCode}`}); ` + + `the answer may have come from another process on port ${port}`); + } } catch (err) { + process.off('exit', onExit); child.kill('SIGKILL'); // stop() is never handed out on this path, so the data dir it would have // removed is removed here. - fs.rmSync(dataDir, { recursive: true, force: true }); + removeData(); throw new Error(`${err.message}\n--- server output ---\n${log.join('')}`); } @@ -89,14 +115,26 @@ export async function start(kind) { const stop = async () => { if (stopped) return; stopped = true; - child.kill('SIGTERM'); - // The binary shuts SSE down gracefully; give it a moment before insisting. - await new Promise((r) => setTimeout(r, 400)); - if (!exited(child)) child.kill('SIGKILL'); - fs.rmSync(dataDir, { recursive: true, force: true }); + process.off('exit', onExit); + if (!exited(child)) { + const gone = new Promise((resolve) => child.once('exit', resolve)); + child.kill('SIGTERM'); + // The binary shuts SSE down gracefully; give it a moment before insisting. + const timer = { id: null }; + const graceful = await Promise.race([ + gone.then(() => true), + new Promise((r) => { timer.id = setTimeout(() => r(false), 3000); }), + ]); + clearTimeout(timer.id); + if (!graceful) { + child.kill('SIGKILL'); + await gone; + } + } + removeData(); }; - return { base, dataDir, stop }; + return { base, dataDir, stop, logTail: () => log.join('') }; } export { REPO, BIN }; From 61a488c1eef6819d0081375ba80500f5392434ba Mon Sep 17 00:00:00 2001 From: Ricardo Oliveira Date: Tue, 13 Oct 2026 12:32:23 +0100 Subject: [PATCH 28/30] test(bc-e2eh): harness review round 2 fixes; keyboard docs Kachinuki guard cleanup is capped and cannot replace a guard's own failure, and each guard failure is annotated when it happens; the overtime guard checks Encho landed before its negative check; dead withinWindow assertions and a vacuous viewer assertion are gone; J7 says what the TV board can show; the shared server helper also cleans up on SIGINT/SIGTERM when nothing else handles them; README states the default timeout, parallel workers, when a server stops and that the results folder is emptied. Docs: the laptop shortcuts sentence states which editors take Left, Right and Esc, as the code does. Co-Authored-By: Claude Opus 5.5 --- .../court-operators/scoring-a-match.md | 2 +- scripts/e2e/README.md | 25 ++++++---- scripts/e2e/fixtures/clumsy.mjs | 2 +- scripts/e2e/fixtures/test.mjs | 2 +- .../knockout-mixed-individual.spec.mjs | 1 - .../knockout-mixed-kachinuki.spec.mjs | 48 ++++++++++++++----- .../e2e/journeys/knockout-mixed-team.spec.mjs | 6 +-- scripts/screenshots/lib/server.mjs | 36 +++++++++++++- 8 files changed, 93 insertions(+), 29 deletions(-) diff --git a/docs/user-guide/court-operators/scoring-a-match.md b/docs/user-guide/court-operators/scoring-a-match.md index 53f28cbbc..7284dd6b5 100644 --- a/docs/user-guide/court-operators/scoring-a-match.md +++ b/docs/user-guide/court-operators/scoring-a-match.md @@ -11,7 +11,7 @@ Open a match from the Upcoming list to start scoring it; the score editor opens !!! tip In self-run events, competitors or table helpers can record their own scores without the admin password. Refer to the [Competitor self-run guide](../competitors/self-run.md) and [Operating modes and access control](../organisers/operating-modes.md) for how that works. -On a laptop you can score without the mouse. When scoring an individual match or a kachinuki bout, press **M**, **K**, **D**, **T**, or **H** to award that strike to Shiro (White). Hold **Shift** with the same key to award it to Aka (Red). **Left** and **Right** move to the previous or next match on the court, and **Esc** closes the editor. On a tablet, use the on-screen buttons. +On a laptop you can score without the mouse. When scoring an individual match or a kachinuki bout, press **M**, **K**, **D**, **T**, or **H** to award that strike to Shiro (White). Hold **Shift** with the same key to award it to Aka (Red). In the score editor opened from the **Scores** tab, **Left** and **Right** move to the previous or next match on the court. **Esc** closes a score editor that opens over the page; the editor on the court console has no **Esc**, **Left** or **Right** shortcut. On a tablet, use the on-screen buttons. Everything you enter on a running match is saved as you go, so closing the editor or moving to another match keeps it, even straight after a tap. Two things are only kept when you press **Finish** or save a correction: a **Mark draw** on a running match, and changes to a finished match. If you close the editor or move to another match with one of those not yet saved, the app asks first: **Discard changes** drops it, and **Cancel** keeps you on the match. diff --git a/scripts/e2e/README.md b/scripts/e2e/README.md index ca547b206..bedbe587a 100644 --- a/scripts/e2e/README.md +++ b/scripts/e2e/README.md @@ -82,8 +82,8 @@ Two projects, both Chromium: alone (`*.public.spec.mjs`). No journey uses this project yet; the public surfaces are checked from `operator` journeys through a second context. -Every test has a 240 s timeout (`expect` waits 15 s), `retries` is 0, and -`forbidOnly` is on, so a stray `test.only` fails the run. +240 s is the default test timeout (`expect` waits 15 s); some journeys raise it +for a long test with `test.setTimeout`. `retries` is 0 and `forbidOnly` is on, so a stray `test.only` fails the run. An operator journey that needs a second device opens another context with one of the two devices from `fixtures/devices.mjs`: `PUBLIC_DEVICE` for a public @@ -95,10 +95,13 @@ another court). `fixtures/test.mjs` starts one `mobile-app` per spec file, on a free port and a throwaway data folder, through `scripts/screenshots/lib/server.mjs`, and -stops it by PID when the file is done. Playwright runs several files one after -another in one worker when there are more files than workers (`--workers=1` -runs them all in one), and each file starts with a fresh server, never the -previous file's tournament. Tests in one file share its server, so a spec file +stops it by PID. Spec files run in parallel across workers (Playwright's +default, about half the cores), each worker with its own browser and its own +server; on a slow or loaded machine pass `E2E_ARGS='--workers=1'`. A worker +runs its files one after another (all of them with `--workers=1`), and each +file starts with a fresh server, never the previous file's tournament. A +worker stops a file's server when it starts the next file's server, or when +the worker ends. Tests in one file share its server, so a spec file that seeds once and then walks several tests declares `test.describe.configure({ mode: 'serial' })`. A `beforeAll` hook asks for it with `serverForFile(testInfo.file)`. Journeys navigate by path @@ -187,8 +190,9 @@ fixture is wrapped so none is missed). When the test ends: Everything a test's pages logged is attached to it as `console.json` (kept in a failing test's trace), with the page it came from, whether the device was offline at the time, and, for a failed request, the status and body of the `/api` answer. What a `beforeAll` -hook's pages log is checked with the first test of the file and marked as -such; what an `afterAll` hook's pages log is reported under its own file when +hook's pages log, or what the previous test's pages still had open, is checked +with the first test of the file and marked "before this test (a beforeAll hook or +the previous test's open pages)"; what an `afterAll` hook's pages log is reported under its own file when the next file's server starts or the worker stops, and an error there fails the run. @@ -226,7 +230,10 @@ does not write it to a file of its own, and a passing test keeps no trace, so its console errors are only in the run log as `CONSOLE {json}` lines. A failing test also carries the server's output as `server.log`. -Every run empties `output/test-results/` when it starts, and a journey empties +Every run empties its whole results folder (`output/test-results/`, or +`E2E_RESULTS_DIR` when set) when it starts, and with `E2E_RESULTS_DIR` set the +journey folders live inside it, so pointing it at a folder that holds anything +else deletes that content. A journey empties its own `output//` in `beforeAll`. Two runs at the same time in one checkout would delete each other's traces mid-test, and two runs without `E2E_RESULTS_DIR` share every `output//`, so give the second one its diff --git a/scripts/e2e/fixtures/clumsy.mjs b/scripts/e2e/fixtures/clumsy.mjs index 1e9edf174..495c142c3 100644 --- a/scripts/e2e/fixtures/clumsy.mjs +++ b/scripts/e2e/fixtures/clumsy.mjs @@ -100,7 +100,7 @@ export async function tapNeighbour(locator, direction) { // always true: a machine too slow to land the taps inside the window THROWS, // because the row would not have tested what it says. The first tap's effects // cannot be taken back, so the taps are not retried; the caller's test fails -// and is run again. Callers may assert on the outcome unconditionally. +// with the message above (playwright.config.mjs sets retries to 0). Callers may assert on the outcome unconditionally. export async function doubleTap(locator) { const page = locator.page(); const { x, y } = await centreOf(locator); diff --git a/scripts/e2e/fixtures/test.mjs b/scripts/e2e/fixtures/test.mjs index 301564494..5954bd8dd 100644 --- a/scripts/e2e/fixtures/test.mjs +++ b/scripts/e2e/fixtures/test.mjs @@ -235,7 +235,7 @@ export const test = base.extend({ consolePolicy: [async ({ consoleCollector: c }, use, testInfo) => { const startedAt = Date.now(); await use(); - const entries = c.drain().map((e) => (e.at < startedAt ? { ...e, phase: 'before this test (a beforeAll hook)' } : e)); + const entries = c.drain().map((e) => (e.at < startedAt ? { ...e, phase: 'before this test (a beforeAll hook or the previous test\'s open pages)' } : e)); if (!entries.length) return; await testInfo.attach('console.json', { body: JSON.stringify(entries, null, 2), contentType: 'application/json' }); const audits = auditsFor(testInfo.file); diff --git a/scripts/e2e/journeys/knockout-mixed-individual.spec.mjs b/scripts/e2e/journeys/knockout-mixed-individual.spec.mjs index f0876560f..1fad431c7 100644 --- a/scripts/e2e/journeys/knockout-mixed-individual.spec.mjs +++ b/scripts/e2e/journeys/knockout-mixed-individual.spec.mjs @@ -164,7 +164,6 @@ test.describe('knockout-mixed-individual', () => { shot: await shot(page, 'ippon-double-tapped') }); // The side was empty (the wrong mark was cleared above): a bounce would land // two marks, so exactly one is the app ignoring it. - expect(dblIppon.withinWindow).toBe(true); expect(afterDbl).toEqual(['M']); // V2 on the undo: a double tap on the outer mark. diff --git a/scripts/e2e/journeys/knockout-mixed-kachinuki.spec.mjs b/scripts/e2e/journeys/knockout-mixed-kachinuki.spec.mjs index 55b551aac..dc72eb446 100644 --- a/scripts/e2e/journeys/knockout-mixed-kachinuki.spec.mjs +++ b/scripts/e2e/journeys/knockout-mixed-kachinuki.spec.mjs @@ -104,20 +104,39 @@ test.describe('J5 kachinuki from the court console', () => { // (In a serial file a failing test skips the rest, and a restarted worker // loses the shared state, so a guard of its own could not stand before them.) const guardFailures = []; + const MAX_CLEAR_TAPS = 6; + // Taps a side's filled marks off, at most MAX_CLEAR_TAPS times: a mark that + // does not go away fails with a message instead of looping until the timeout. + async function clearBoutMarks(live, side) { + for (let i = 0; i < MAX_CLEAR_TAPS; i += 1) { + if ((await boutFilled(live, side).count()) === 0) return; + await boutFilled(live, side).last().tap(); + } + if ((await boutFilled(live, side).count()) > 0) { + throw new Error(`the ${side} marks did not clear after ${MAX_CLEAR_TAPS} taps`); + } + } async function guarded(page, title, fn) { try { await test.step(title, () => fn(page)); } catch (e) { - guardFailures.push(`${title}: ${String(e.message).split('\n')[0]}`); - record({ step: title, action: 'regression guard', variant: 'FAILED', error: String(e.message).split('\n')[0] }); - await openShiaijo(page, 'A').catch(() => {}); - if (await recordBoutButton(page).isVisible().catch(() => false)) { - // Leave the live bout unmarked and untied, as the final's play-out expects. - const live = currentBout(page); - for (const side of ['shiro', 'aka']) { - while ((await boutFilled(live, side).count().catch(() => 0)) > 0) await boutFilled(live, side).last().tap(); + const message = String(e.message).split('\n')[0]; + // Recorded at the moment it happens: the guard's name stays visible even + // when a later step fails first. + test.info().annotations.push({ type: 'guard-failure', description: `${title}: ${message}` }); + guardFailures.push(`${title}: ${message}`); + record({ step: title, action: 'regression guard', variant: 'FAILED', error: message }); + // Cleanup is best effort: its own error must not replace the guard's failure. + try { + await openShiaijo(page, 'A').catch(() => {}); + if (await recordBoutButton(page).isVisible().catch(() => false)) { + // Leave the live bout unmarked and untied, as the final's play-out expects. + const live = currentBout(page); + for (const side of ['shiro', 'aka']) await clearBoutMarks(live, side); + if (/✓ Tie/.test(await tieButton(live).innerText().catch(() => ''))) await tieButton(live).tap(); } - if (/✓ Tie/.test(await tieButton(live).innerText().catch(() => ''))) await tieButton(live).tap(); + } catch (cleanupError) { + record({ step: title, action: 'regression guard cleanup', variant: 'FAILED', error: String(cleanupError.message).split('\n')[0] }); } } } @@ -156,7 +175,7 @@ test.describe('J5 kachinuki from the court console', () => { async function guardEmsl(page) { const row = currentBout(page); // The final's live bout carries one Shiro point; clear it, mark the tie. - while ((await boutFilled(row, 'shiro').count()) > 0) await boutFilled(row, 'shiro').last().tap(); + await clearBoutMarks(row, 'shiro'); await tieButton(row).tap(); await expect(tieButton(row)).toHaveText(/✓ Tie/); await row.locator('.tsm-center-pts--aka .editor-side__pt:not(.editor-side__pt--filled)').first().tap(); @@ -172,11 +191,17 @@ test.describe('J5 kachinuki from the court console', () => { // bout. The (E) now lives on that bout's row only; this guards it. async function guardKheb(page) { const row = currentBout(page); - while ((await boutFilled(row, 'shiro').count()) > 0) await boutFilled(row, 'shiro').last().tap(); + await clearBoutMarks(row, 'shiro'); await tieButton(row).tap(); await enchoButton(page).tap(); await awardBoutIppon(page, 'shiro', 'M'); + const bout1 = await doneRows(page).count(); await recordBout(page); + // Positive check first: Encho was recorded on bout 1 (its row's centre reads (E)). + expect(await boutMiddle(doneRows(page).nth(bout1))).toBe('(E)'); + // Bout 2 has rendered (the live row is a fresh, unmarked one) before the negative check. + await expect(currentBout(page)).toBeVisible(); + await expect(boutFilled(currentBout(page), 'shiro')).toHaveCount(0); // Bout 2 is a regulation bout. await expect(editor(page).locator('.editor-modal__eyebrow').first()).not.toContainText(/overtime/i); } @@ -444,7 +469,6 @@ test.describe('J5 kachinuki from the court console', () => { const tappedAt = Date.now(); const dbl = await doubleTap(boutIpponButton(currentBout(page), 'shiro', 'M')); // The app ignores the bounce of a tap: two taps inside its window land one M. - expect(dbl.withinWindow).toBe(true); await expect(boutFilled(currentBout(page), 'shiro')).toHaveCount(1); record({ step: 'M1 bout 5', action: 'Shiro M', variant: 'V2 doubleTap', ...dbl, marksAfter: await boutFilled(currentBout(page), 'shiro').allInnerTexts() }); diff --git a/scripts/e2e/journeys/knockout-mixed-team.spec.mjs b/scripts/e2e/journeys/knockout-mixed-team.spec.mjs index 21a60af37..637298f31 100644 --- a/scripts/e2e/journeys/knockout-mixed-team.spec.mjs +++ b/scripts/e2e/journeys/knockout-mixed-team.spec.mjs @@ -558,7 +558,6 @@ test.describe('knockout-mixed-team', () => { await expect(T.syncPill(ed)).toHaveText('Synced'); const slotsAfter = await T.rowSlots(row, 'shiro').allInnerTexts(); await shot(page, 'bout1-double-tap-kote'); - expect(dbl.withinWindow).toBe(true); expect(slotsAfter).toEqual(['K']); // Take the mark back (the sheet's own hint), then record Shiro men. const hint = (await ed.getByTestId('team-scoring-clear-hint').innerText().catch(() => '')).trim(); @@ -1055,12 +1054,14 @@ test.describe('knockout-mixed-team', () => { await expect(ed.getByTestId('team-daihyosen-ht-shiro')).toHaveCount(0); }); - await test.step('the viewer and the TV board show the same final result', async () => { + await test.step('the TV board shows the court is done; the viewer and the bracket show the corrected winner', async () => { await phone.reload(); await expect(phone.locator('.viewer__body')).toBeVisible(); await shot(phone, 'viewer-final', { fullPage: true }); const viewerText = (await phone.locator('.viewer__body').innerText()).replace(/\s+/g, ' '); await tv.reload(); + // This step checks that the court is done on the TV board; the corrected + // winner is checked on the viewer's bracket below. // A finished court's TV board carries no scoreline or winner by design // (display_scoreboard.jsx: "All matches completed on Shiaijo E", with the // QR code to the results), so what it can show is that the court is done, @@ -1071,7 +1072,6 @@ test.describe('knockout-mixed-team', () => { const tvText = (await tvBoard(tv).innerText()).replace(/\s+/g, ' '); record({ step: 'every surface', action: 'viewer and TV after the correction', variant: 'correct', viewer: viewerText.slice(0, 600), tv: tvText.slice(0, 400), expectedWinner: pair.aka }); - await expect(phone.locator('.viewer__body')).toContainText(pair.aka); // For a team encounter the marks appear on the bracket // (recording-decisions.md): the viewer's Bracket tab. await phone.getByRole('tab', { name: 'Bracket' }).or(phone.getByRole('button', { name: 'Bracket' })).first().tap(); diff --git a/scripts/screenshots/lib/server.mjs b/scripts/screenshots/lib/server.mjs index 68fa59096..5cc518076 100644 --- a/scripts/screenshots/lib/server.mjs +++ b/scripts/screenshots/lib/server.mjs @@ -44,6 +44,33 @@ async function waitForHealth(base, child, readyPath, timeoutMs = 30000) { throw new Error(`server did not answer ${readyPath} within ${timeoutMs}ms at ${base}`); } +// SIGINT and SIGTERM do not run an 'exit' listener when the process has no +// handler for them: the default action ends it at once. While a server is +// running, one module-level handler per signal runs every live server's +// cleanup and exits with the conventional code (128 + signal number). It is +// installed only when nothing else listens for that signal, so a runner that +// handles its own signals (a Playwright worker) is not disturbed, and it is +// removed when the last server stops. +const SIGNAL_EXIT = { SIGINT: 130, SIGTERM: 143 }; +const liveCleanups = new Set(); +const signalHandlers = new Map(); +function armSignals() { + for (const [signal, code] of Object.entries(SIGNAL_EXIT)) { + if (signalHandlers.has(signal) || process.listenerCount(signal) > 0) continue; + const handler = () => { + for (const cleanup of [...liveCleanups]) cleanup(); + process.exit(code); + }; + signalHandlers.set(signal, handler); + process.on(signal, handler); + } +} +function disarmSignals() { + if (liveCleanups.size > 0) return; + for (const [signal, handler] of signalHandlers) process.off(signal, handler); + signalHandlers.clear(); +} + // start("mobile" | "web") -> { base, dataDir, stop() } export async function start(kind) { if (!fs.existsSync(BIN)) { @@ -77,7 +104,8 @@ export async function start(kind) { // A worker or runner that exits without reaching stop() (an uncaught error, // process.exit) must not leave the server running or its data folder behind. // A synchronous 'exit' listener is the only thing that still runs then. A - // SIGKILL runs nothing, and no hook can cover it. + // SIGINT or SIGTERM reaches it through armSignals() above. A SIGKILL runs + // nothing, and no hook can cover it. const removeData = () => fs.rmSync(dataDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); const onExit = () => { if (!exited(child)) child.kill('SIGKILL'); @@ -88,6 +116,8 @@ export async function start(kind) { } }; process.on('exit', onExit); + liveCleanups.add(onExit); + armSignals(); const base = `http://localhost:${port}`; try { @@ -104,6 +134,8 @@ export async function start(kind) { } } catch (err) { process.off('exit', onExit); + liveCleanups.delete(onExit); + disarmSignals(); child.kill('SIGKILL'); // stop() is never handed out on this path, so the data dir it would have // removed is removed here. @@ -116,6 +148,8 @@ export async function start(kind) { if (stopped) return; stopped = true; process.off('exit', onExit); + liveCleanups.delete(onExit); + disarmSignals(); if (!exited(child)) { const gone = new Promise((resolve) => child.once('exit', resolve)); child.kill('SIGTERM'); From 83b6dbef606b4b69edde91b4baa187b4551c25e4 Mon Sep 17 00:00:00 2001 From: Ricardo Oliveira Date: Tue, 13 Oct 2026 13:49:38 +0100 Subject: [PATCH 29/30] test(bc-e2eh): independent review fixes for the harness A failed answer is recorded before its body arrives and the console check awaits pending bodies, so an allowed refusal cannot fail a test; wizardDefaults waits for the schedule estimate it expects instead of fixed sleeps; a failing test gets its file's server log even when it never asked for the server; the bc-ltap hold is released after the tap-back rather than after a fixed 150 ms. Co-Authored-By: Claude Opus 5.5 --- scripts/e2e/fixtures/shared-court.mjs | 64 ++++++++++++++----- scripts/e2e/fixtures/test.mjs | 54 ++++++++++++---- .../knockout-mixed-individual.spec.mjs | 25 ++++++-- 3 files changed, 106 insertions(+), 37 deletions(-) diff --git a/scripts/e2e/fixtures/shared-court.mjs b/scripts/e2e/fixtures/shared-court.mjs index 7015fae91..8acc762fc 100644 --- a/scripts/e2e/fixtures/shared-court.mjs +++ b/scripts/e2e/fixtures/shared-court.mjs @@ -60,22 +60,54 @@ export async function enterTournament(page, { name = 'Shared Court Cup', courts // What the create wizard offers before a competition is created: its default // Start time (stacked after the latest same-day competition, admin_setup.jsx) // and the hint under the shiaijo choice. Read on the wizard page itself, the -// one createCompetition then fills; the stacked default arrives from an -// estimate request, so the value is read once it has stopped changing. -export async function wizardDefaults(page) { - await page.goto('/admin/create-competition'); - await expect(page.getByRole('heading', { level: 1, name: 'Add competition' })).toBeVisible(); - const input = page.locator('#create-start-time'); - let last = await input.inputValue(); - for (let i = 0; i < 6; i += 1) { - await page.waitForTimeout(300); - const now = await input.inputValue(); - if (now === last && i >= 2) break; - last = now; +// one createCompetition then fills. +// +// The stacked default comes from ONE request, GET /api/competitions// +// schedule/estimate, which the wizard sends exactly when the tournament already +// holds a same-day competition with a start time (pickStackPredecessor); with +// none it keeps the 09:00 default and sends nothing. The caller knows which +// case it is in (`expectEstimate`: a competition was created before this one, +// all on the tournament's date), so the read does not guess from timing: +// - expected: the response is awaited (registered before the page loads, so +// it cannot be missed), the page is given two animation frames to render +// the value the answer set, and the input is then read once two reads +// agree (a competitions refresh can re-send the estimate; the earlier +// request is aborted and the later one wins). +// - not expected: the input is read as soon as the form shows, and an +// estimate request seen meanwhile is an error, never a stale default. +export async function wizardDefaults(page, { expectEstimate = false } = {}) { + const isEstimate = (r) => /\/api\/competitions\/[^/]+\/schedule\/estimate$/.test(new URL(r.url()).pathname); + let sent = 0; + const onRequest = (r) => { if (isEstimate(r)) sent += 1; }; + page.on('request', onRequest); + try { + const answered = expectEstimate + ? page.waitForResponse((r) => isEstimate(r.request()) && r.ok(), { timeout: 15_000 }) + : null; + await page.goto('/admin/create-competition'); + await expect(page.getByRole('heading', { level: 1, name: 'Add competition' })).toBeVisible(); + const input = page.locator('#create-start-time'); + await expect(input).toBeVisible(); + if (answered) { + const res = await answered; + await res.finished(); + await page.evaluate(() => new Promise((done) => requestAnimationFrame(() => requestAnimationFrame(done)))); + let prev = null; + await expect.poll(async () => { + const v = await input.inputValue(); + const settled = v === prev; + prev = v; + return settled; + }, { intervals: [100], timeout: 5_000 }).toBe(true); + } else if (sent) { + throw new Error('wizardDefaults: the wizard sent a schedule estimate request, but the caller said no same-day competition exists (pass expectEstimate: true)'); + } + const hint = await page.locator('.field__hint', { hasText: 'Different competitions can share' }).first() + .innerText().then((t) => t.trim(), () => null); + return { startTime: await input.inputValue(), sharedCourtHint: hint }; + } finally { + page.off('request', onRequest); } - const hint = await page.locator('.field__hint', { hasText: 'Different competitions can share' }).first() - .innerText().then((t) => t.trim(), () => null); - return { startTime: last, sharedCourtHint: hint }; } // Create every competition first, then paste each roster, generate its draw and @@ -85,7 +117,7 @@ export async function seedCompetitions(page, specs) { const ids = {}; const defaults = {}; for (const s of specs) { - defaults[s.key] = await wizardDefaults(page); + defaults[s.key] = await wizardDefaults(page, { expectEstimate: Object.keys(ids).length > 0 }); ids[s.key] = await createCompetition(page, s.opts); } for (const s of specs) { diff --git a/scripts/e2e/fixtures/test.mjs b/scripts/e2e/fixtures/test.mjs index 5954bd8dd..c3fb69223 100644 --- a/scripts/e2e/fixtures/test.mjs +++ b/scripts/e2e/fixtures/test.mjs @@ -80,6 +80,9 @@ const allowed = (e) => e.kind === 'console' && CONSOLE_ALLOWLIST.find((r) => r.t // for the browser's "Failed to load resource" line, the /api answer it refers // to (status and the start of its body, where the app's refusal code is). const OFFLINE_GRACE_MS = 3_000; +// The longest drain() waits for a refused answer's body (a page that closed +// meanwhile never delivers it; the entry then carries no body). +const BODY_WAIT_MS = 3_000; function consoleCollector() { const pending = []; const failed = []; // { url, status, body, at } @@ -105,10 +108,16 @@ function consoleCollector() { pending.push({ kind: 'pageerror', type: 'error', text: err.message, stack: err.stack || null, page: pageUrl(), offline: wasOffline(ctx), at: Date.now() }); }); - page.on('response', async (res) => { + page.on('response', (res) => { if (res.status() < 400 || !/\/api\//.test(res.url())) return; + // Recorded at once, with the body still pending: the console line for + // this answer can reach drain() before the body has been read, and an + // answer that is not in the list yet would read as "no answer". const at = Date.now(); - const body = await res.text().then((t) => t.slice(0, 1000), () => null); + const body = Promise.race([ + res.text().then((t) => t.slice(0, 1000), () => null), + new Promise((done) => setTimeout(done, BODY_WAIT_MS, null)), + ]); failed.push({ url: res.url(), status: res.status(), method: res.request().method(), postData: res.request().postData(), body, at }); }); }; @@ -128,9 +137,15 @@ function consoleCollector() { // answered within a few seconds of it. Lines and answers pair one to one in // arrival order (per URL, first line takes the first answer), so a second // answer of another code can never be read as the first line's. - const drain = () => { + const drain = async () => { + // Both lists are taken at once, so a line and the answer it is about are + // always in the same drain. const unclaimed = failed.splice(0, failed.length); - const out = pending.splice(0, pending.length).map((e) => { + const lines = pending.splice(0, pending.length); + // Every pending body is awaited before any line is paired, so pairing + // never depends on which of the line and the body arrived first. + await Promise.all(unclaimed.map(async (f) => { f.body = await f.body; })); + const out = lines.map((e) => { if (e.kind !== 'console' || !e.url || !/^Failed to load resource/.test(e.text)) return e; const i = unclaimed.findIndex((f) => f.url === e.url && Math.abs(f.at - e.at) < 5_000); if (i < 0) return e; @@ -147,8 +162,8 @@ const describeEntry = (e) => `${e.kind === 'pageerror' ? 'uncaught page error' : // Report what file `c.file` logged after its last test (its afterAll hooks). // Errors not on the allowlist are kept in c.late, which fails the run when the // worker ends. -function settleLate(c) { - for (const e of c.drain()) { +async function settleLate(c) { + for (const e of await c.drain()) { const phase = `after the last test of ${c.file} (an afterAll hook)`; console.log(`CONSOLE ${JSON.stringify({ phase, ...e })}`); if (e.type === 'error' && !allowed(e)) c.late.push({ ...e, file: c.file }); @@ -175,7 +190,7 @@ export const test = base.extend({ await old.server.stop(); // Whatever the previous file's afterAll hooks logged is still pending: // settle it as theirs, not as the first test of this file's. - settleLate(c); + await settleLate(c); } c.file = file; let server; @@ -187,6 +202,8 @@ export const test = base.extend({ current = { file, server }; return server; }; + // The server of `file` when one is running, else null: asks, never starts. + forFile.runningFor = (file) => (current && current.file === file ? current.server : null); try { await use(forFile); } finally { @@ -201,7 +218,7 @@ export const test = base.extend({ consoleCollector: [async ({}, use) => { const c = consoleCollector(); await use(c); - settleLate(c); + await settleLate(c); if (c.late.length) { throw new Error(`console policy: ${c.late.length} error(s) after the last test of a file (an afterAll hook):\n${c.late.map((e) => `[${e.file}] ${describeEntry(e)}`).join('\n')}`); } @@ -235,7 +252,7 @@ export const test = base.extend({ consolePolicy: [async ({ consoleCollector: c }, use, testInfo) => { const startedAt = Date.now(); await use(); - const entries = c.drain().map((e) => (e.at < startedAt ? { ...e, phase: 'before this test (a beforeAll hook or the previous test\'s open pages)' } : e)); + const entries = (await c.drain()).map((e) => (e.at < startedAt ? { ...e, phase: 'before this test (a beforeAll hook or the previous test\'s open pages)' } : e)); if (!entries.length) return; await testInfo.attach('console.json', { body: JSON.stringify(entries, null, 2), contentType: 'application/json' }); const audits = auditsFor(testInfo.file); @@ -256,13 +273,22 @@ export const test = base.extend({ } }, { auto: true }], - // The server of the file this test is in. - server: async ({ serverForFile }, use, testInfo) => { - const server = await serverForFile(testInfo.file); - await use(server); - if (testInfo.status !== testInfo.expectedStatus) { + // A failing test carries its file's server output as server.log, whether or + // not the test asked for `server` (a spec whose beforeAll starts the server + // and whose tests drive their own devices never does). Auto, but it only + // looks: it starts nothing, and a test in a file with no server running + // attaches nothing. + serverLog: [async ({ serverForFile }, use, testInfo) => { + await use(); + const server = serverForFile.runningFor(testInfo.file); + if (server && testInfo.status !== testInfo.expectedStatus) { await testInfo.attach('server.log', { body: server.logTail(), contentType: 'text/plain' }); } + }, { auto: true }], + + // The server of the file this test is in. + server: async ({ serverForFile }, use, testInfo) => { + await use(await serverForFile(testInfo.file)); }, // Every journey navigates by path; the host is the file's server. diff --git a/scripts/e2e/journeys/knockout-mixed-individual.spec.mjs b/scripts/e2e/journeys/knockout-mixed-individual.spec.mjs index 1fad431c7..3cc1878c6 100644 --- a/scripts/e2e/journeys/knockout-mixed-individual.spec.mjs +++ b/scripts/e2e/journeys/knockout-mixed-individual.spec.mjs @@ -1257,18 +1257,27 @@ test.describe('knockout-mixed-individual bead guards', () => { // two events"): a mark taken off and tapped back while the removal's save is // still out stays on the board and is sent, and so does the reverse. On // localhost the removal's echo arrives within milliseconds of the save going - // out, so the save is held 150 ms, as a slow uplink would hold it. + // out, so the save is held until the test releases it, and it does so only + // after the tap-back has been dispatched, as a slow uplink would hold it + // (a safety timeout lets go of a hold nobody released). test('bc-ltap: a mark tapped back while its removal is saving stays, and is saved', async ({ page }) => { const shot = journeyShots('knockout-mixed-individual/bc-ltap'); await login(page); await seedOwn(page, { name: 'Finding A9', format: 'knockout', courts: ['O'], numberPrefix: 'FI' }, rosterOf('Anine', 4)); await openShiaijo(page, 'O'); expect(await ensureRunning(page), 'a semi-final starts on O').not.toBeNull(); - let holdNext = false; + let hold = null; // { release } while the next score write is to be held + const holdNextWrite = () => { + const h = {}; + h.promise = new Promise((resolve) => { h.release = resolve; }); + hold = h; + return h; + }; await page.route('**/api/competitions/*/matches/*/score', async (route) => { - if (route.request().method() === 'PUT' && holdNext) { - holdNext = false; - await new Promise((r) => setTimeout(r, 150)); + if (route.request().method() === 'PUT' && hold) { + const { promise } = hold; + hold = null; + await Promise.race([promise, new Promise((r) => setTimeout(r, 10_000))]); } await route.continue(); }); @@ -1283,11 +1292,12 @@ test.describe('knockout-mixed-individual bead guards', () => { await ipponButton(page, 'aka', 'M').tap(); await settle(page); // Remove, then the same mark back while the removal is out. - holdNext = true; + let held = holdNextWrite(); let sent = scoreSent(); await akaMark().tap(); await sent; await ipponButton(page, 'aka', 'M').tap(); + held.release(); await settle(page); await page.waitForTimeout(1000); await expect(filledSlots(page, 'aka'), 'the tapped-back mark stays').toHaveCount(1); @@ -1298,11 +1308,12 @@ test.describe('knockout-mixed-individual bead guards', () => { // The reverse: take it off, add it, and take it off again while the add is out. await akaMark().tap(); await settle(page); - holdNext = true; + held = holdNextWrite(); sent = scoreSent(); await ipponButton(page, 'aka', 'M').tap(); await sent; await akaMark().tap(); + held.release(); await settle(page); await page.waitForTimeout(1000); await expect(filledSlots(page, 'aka'), 'the removal stays').toHaveCount(0); From 303daf665f5e09db79d87adf382bd8e16be497b1 Mon Sep 17 00:00:00 2001 From: Ricardo Oliveira Date: Tue, 13 Oct 2026 14:08:00 +0100 Subject: [PATCH 30/30] test(bc-e2eh): independent review round 2 fixes for the harness An answer no console line claimed yet is kept for the pairing window, so a refusal whose console line arrives in the next test still pairs; a refusal body is awaited up to the expect timeout (one shared constant) and its timer cleared; consolePolicy is torn down before serverLog, so a test failed by the console policy carries server.log. Co-Authored-By: Claude Opus 5.5 --- scripts/e2e/fixtures/devices.mjs | 5 +++++ scripts/e2e/fixtures/test.mjs | 26 ++++++++++++++++++++------ scripts/e2e/playwright.config.mjs | 4 ++-- 3 files changed, 27 insertions(+), 8 deletions(-) diff --git a/scripts/e2e/fixtures/devices.mjs b/scripts/e2e/fixtures/devices.mjs index dfd5bc9b3..630097b5c 100644 --- a/scripts/e2e/fixtures/devices.mjs +++ b/scripts/e2e/fixtures/devices.mjs @@ -3,6 +3,11 @@ // viewer beside the operator's iPad) spreads the same constant into // browser.newContext(), so a project and a hand-made context cannot drift. +// How long an expect() waits (playwright.config.mjs's expect.timeout), and so +// how long the console policy waits for a refused answer's body +// (fixtures/test.mjs): one constant, so the two cannot drift. +export const EXPECT_TIMEOUT_MS = 15_000; + // iPad Air, landscape: the court table's device. hasTouch + isMobile is what // makes `(pointer: coarse)` match, measured, so the 44px tap-floor rules in // styles.css apply as they do on the device. fixtures/test.mjs re-checks it diff --git a/scripts/e2e/fixtures/test.mjs b/scripts/e2e/fixtures/test.mjs index c3fb69223..46c498303 100644 --- a/scripts/e2e/fixtures/test.mjs +++ b/scripts/e2e/fixtures/test.mjs @@ -5,6 +5,7 @@ import { test as base, expect } from '@playwright/test'; import fs from 'node:fs'; import { start, BIN } from '../../screenshots/lib/server.mjs'; import { auditsFor } from './audit.mjs'; +import { EXPECT_TIMEOUT_MS } from './devices.mjs'; import { isCoarse, restoreCoarse } from './pointer.mjs'; export { isCoarse }; @@ -81,8 +82,11 @@ const allowed = (e) => e.kind === 'console' && CONSOLE_ALLOWLIST.find((r) => r.t // to (status and the start of its body, where the app's refusal code is). const OFFLINE_GRACE_MS = 3_000; // The longest drain() waits for a refused answer's body (a page that closed -// meanwhile never delivers it; the entry then carries no body). -const BODY_WAIT_MS = 3_000; +// meanwhile never delivers it; the entry then carries no body). The expect +// timeout, so a body slow under load is waited for as long as an assertion is. +const BODY_WAIT_MS = EXPECT_TIMEOUT_MS; +// How far apart a "Failed to load resource" line and its answer may be. +const PAIR_WINDOW_MS = 5_000; function consoleCollector() { const pending = []; const failed = []; // { url, status, body, at } @@ -114,10 +118,11 @@ function consoleCollector() { // this answer can reach drain() before the body has been read, and an // answer that is not in the list yet would read as "no answer". const at = Date.now(); + let timer; const body = Promise.race([ res.text().then((t) => t.slice(0, 1000), () => null), - new Promise((done) => setTimeout(done, BODY_WAIT_MS, null)), - ]); + new Promise((done) => { timer = setTimeout(done, BODY_WAIT_MS, null); }), + ]).finally(() => clearTimeout(timer)); failed.push({ url: res.url(), status: res.status(), method: res.request().method(), postData: res.request().postData(), body, at }); }); }; @@ -147,11 +152,17 @@ function consoleCollector() { await Promise.all(unclaimed.map(async (f) => { f.body = await f.body; })); const out = lines.map((e) => { if (e.kind !== 'console' || !e.url || !/^Failed to load resource/.test(e.text)) return e; - const i = unclaimed.findIndex((f) => f.url === e.url && Math.abs(f.at - e.at) < 5_000); + const i = unclaimed.findIndex((f) => f.url === e.url && Math.abs(f.at - e.at) < PAIR_WINDOW_MS); if (i < 0) return e; const [answer] = unclaimed.splice(i, 1); return { ...e, status: answer.status, body: answer.body, request: { url: answer.url, method: answer.method, postData: answer.postData } }; }); + // An answer no line claimed may still be claimed by one that has not + // arrived: the 409 landing at the end of one test, its console line in the + // next test's drain. Keep the ones young enough to pair, in order and + // ahead of anything recorded meanwhile; drop the older ones. + const now = Date.now(); + failed.unshift(...unclaimed.filter((f) => now - f.at < PAIR_WINDOW_MS)); return out; }; return { attachPage, attachContext, drain, late: [], file: null }; @@ -249,7 +260,10 @@ export const test = base.extend({ }, // The check, at the end of every test (auto, so no journey has to ask). - consolePolicy: [async ({ consoleCollector: c }, use, testInfo) => { + // It asks for serverLog only for ORDER: a fixture is torn down before the + // ones it depends on, so this check throws first (failing the test) and + // serverLog then looks at a failed status and attaches server.log. + consolePolicy: [async ({ consoleCollector: c, serverLog }, use, testInfo) => { const startedAt = Date.now(); await use(); const entries = (await c.drain()).map((e) => (e.at < startedAt ? { ...e, phase: 'before this test (a beforeAll hook or the previous test\'s open pages)' } : e)); diff --git a/scripts/e2e/playwright.config.mjs b/scripts/e2e/playwright.config.mjs index 3a5b92238..6fd82a6d9 100644 --- a/scripts/e2e/playwright.config.mjs +++ b/scripts/e2e/playwright.config.mjs @@ -1,7 +1,7 @@ // Local e2e journeys for the tournament app. Run through `make e2e`, which // builds bin/bracket-creator first; see README.md. import { defineConfig } from '@playwright/test'; -import { OPERATOR_DEVICE, PUBLIC_DEVICE } from './fixtures/devices.mjs'; +import { EXPECT_TIMEOUT_MS, OPERATOR_DEVICE, PUBLIC_DEVICE } from './fixtures/devices.mjs'; // Chromium refuses to run its sandbox as root, so the sandbox is dropped there // and only there (the same rule as scripts/screenshots/run.mjs). @@ -26,7 +26,7 @@ export default defineConfig({ // can run in parallel, and a worker that runs several files starts a fresh // one for each; tests inside a file run in order. timeout: 240_000, - expect: { timeout: 15_000 }, + expect: { timeout: EXPECT_TIMEOUT_MS }, fullyParallel: false, forbidOnly: true, retries: 0,