From 53b19277dcd64b085a1513854d1610594347a80d Mon Sep 17 00:00:00 2001 From: Ricardo Oliveira Date: Tue, 13 Oct 2026 16:52:04 +0100 Subject: [PATCH 01/31] fix(bc-evts): engi editor keeps a flag tap made while its previous save is out The engi editor compared flag counts by value only, so taking a flag back to the count the server still showed read as untouched, and the earlier save's echo then wiped the tap (the race bc-ltap fixed on the individual editor). It now holds its counts against a server copy stamped before the operator's last unsent edit, cleared by landedAutosave once a save built after that edit has landed. The rule and its three owners are recorded in the match results rules. Co-Authored-By: Claude Opus 5.5 --- .claude/rules/match-results-and-marks.md | 2 + .../engi_editor_tap_back_kept.render.test.jsx | 110 ++++++++++++++++++ web-mobile/js/admin_scoring_autosave.jsx | 3 +- web-mobile/js/admin_scoring_engi.jsx | 33 +++++- 4 files changed, 143 insertions(+), 5 deletions(-) create mode 100644 web-mobile/js/__tests__/render/engi_editor_tap_back_kept.render.test.jsx diff --git a/.claude/rules/match-results-and-marks.md b/.claude/rules/match-results-and-marks.md index 4f262d1d3..c2c903cfd 100644 --- a/.claude/rules/match-results-and-marks.md +++ b/.claude/rules/match-results-and-marks.md @@ -93,6 +93,8 @@ It projects `ModifiedAt` and `GroupStamps` too (bc-lww1, bc-mrgc). `mergeMatchWr Because an editor can now never rule on something it did not display, an explicit `false` is always the operator's decision and always reaches the wire as what it now IS, a markless scoreline: `daihyosenEnchoFields` still states the local boolean unconditionally, and the serializer chokepoint converts it (true → mark placed on the winner's side, false → arrays stay markless; `realIppons` strips echoed marks first so placement never doubles). Two earlier designs are recorded in `web-mobile/js/__tests__/render/individual_editor_live_hantei.render.test.jsx` and must not be reintroduced: recomputing the baseline from the live prop while freezing the local armed state (which erased the verdict on the next write), and freezing at mount with a `hanteiKnown` silence guard (which stopped the erase by accepting the divergence; the guard was deleted with it). +**The edit-time adopt rule.** A score editor holds its board against a server copy stamped before the operator's last unsent edit, because comparing values alone reads a tap taken back during a save as untouched, and that save's echo then wipes it. Its three owners are the team sheet's `rowTakenFromServer`/`repRowTakes` (bc-kclr), the individual editor's `adoptServerScore` (bc-ltap) and the engi editor's adopt (`flagsEditedAtRef`, bc-evts). `landedAutosave` (admin_scoring_autosave.jsx) clears a stamp once a save built after the last edit has landed, so every later server copy is followed. + ## Silence on the wire Silence still means "no opinion" on the wire. It is now OMITTED IPPONS (nil, not `[]`) rather than an omitted flag, and the distinction is load-bearing: an explicit `[]` is the operator saying ZERO (a 0-0 withdrawal), a missing key is a writer with nothing to say, whose stored verdict `preserveSubHantei` restores. The team editor IS one of those writers: `buildPatch` (admin_scoring_team.jsx) has a `daihyosenSilent` branch that OMITS the daihyosen row's ippon arrays when the operator has not touched that row this session AND nothing about it is known locally. That branch is the only reason the nil case is reachable from a real client at all, so do NOT "fix" it into an unconditional write. Any touch, or any locally-known verdict or scoreline, falls through to the explicit-array path, so a deliberate withdrawal still withdraws. Scope note: the restore protects a stored HANTEI verdict only; `preserveSubHantei` bails when the stored row carries no mark, so a rep bout decided by a strike is not restored by this mechanism. diff --git a/web-mobile/js/__tests__/render/engi_editor_tap_back_kept.render.test.jsx b/web-mobile/js/__tests__/render/engi_editor_tap_back_kept.render.test.jsx new file mode 100644 index 000000000..0559886a2 --- /dev/null +++ b/web-mobile/js/__tests__/render/engi_editor_tap_back_kept.render.test.jsx @@ -0,0 +1,110 @@ +// bc-evts: on the engi editor, taking a flag off and tapping it back (or the +// reverse) while the earlier tap is still saving returned the count to the +// value the server still showed, so the editor read it as untouched, and the +// earlier save's echo then wiped the second tap; its own save named no change. +// The counts now keep every server copy stamped before the operator's last +// edit, the rule the individual editor (bc-ltap) and the team sheet (bc-kclr) +// follow. + +import React from 'react'; +import { render, act, fireEvent } from '@testing-library/react'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { EngiScoreEditorModal } from '../../admin_scoring_engi.jsx'; + +const NOW = 1_800_000_000_000; +beforeEach(() => { vi.useFakeTimers(); vi.setSystemTime(NOW); }); +afterEach(() => { vi.useRealTimers(); }); + +// A running engi pool match. No compId: the history fetch stays out. +const engi = (overrides = {}) => ({ + id: 'm1', status: 'running', phase: 'pool', poolName: 'Pool 1', court: 'A', + sideA: { id: 'p-aka', name: 'Aka Pair 1 - Aka Pair 2', dojo: 'Aka Dojo' }, + sideB: { id: 'p-shiro', name: 'Shiro Pair 1 - Shiro Pair 2', dojo: 'Shiro Dojo' }, + flagsA: 0, flagsB: 0, modifiedAt: NOW - 5000, + ...overrides, +}); + +const el = (match, onSubmit) => ( + +); + +const count = (r, side) => r.getByTestId(`engi-${side}-count`).textContent; +const tap = async (r, id) => { await act(async () => { fireEvent.click(r.getByTestId(id)); }); }; +const wait = async (ms) => { await act(async () => { vi.advanceTimersByTime(ms); }); }; + +describe('a flag tap made while the previous save is out is never wiped by that save\'s echo (bc-evts)', () => { + it('add a flag, take it off before the add\'s echo: the count stays at the server\'s and the removal is saved', async () => { + const onSubmit = vi.fn().mockResolvedValue(undefined); + let r; + await act(async () => { r = render(el(engi(), onSubmit)); }); + await tap(r, 'engi-aka-inc'); + await wait(350); // the add's autosave goes out + expect(onSubmit.mock.calls.at(-1)?.[0]?.flagsA, 'precondition: the add was saved').toBe(1); + + vi.setSystemTime(NOW + 400); + await tap(r, 'engi-aka-dec'); // back to the value the server still shows + expect(count(r, 'aka'), 'the tap shows').toBe('0'); + + // The add's echo: the server now holds the flag, stamped at the add. + await act(async () => { r.rerender(el(engi({ flagsA: 1, modifiedAt: NOW + 50 }), onSubmit)); }); + expect(count(r, 'aka'), 'the echo of the earlier add does not bring the flag back').toBe('0'); + + await wait(350); // the removal's own autosave + const save = onSubmit.mock.calls.at(-1)?.[0]; + expect(save?.flagsA, 'the removal is sent').toBe(0); + expect(save?.changed, 'and named as a change').toContain('flags'); + }); + + it('take a flag off, tap it back before the removal\'s echo: the flag stays and is saved', async () => { + const onSubmit = vi.fn().mockResolvedValue(undefined); + let r; + await act(async () => { r = render(el(engi({ flagsA: 1 }), onSubmit)); }); + await tap(r, 'engi-aka-dec'); + await wait(350); // the removal's autosave goes out + expect(onSubmit.mock.calls.at(-1)?.[0]?.flagsA, 'precondition: the removal was saved').toBe(0); + + vi.setSystemTime(NOW + 400); + await tap(r, 'engi-aka-inc'); // back to the value the server still shows + expect(count(r, 'aka'), 'the tap shows').toBe('1'); + + // The removal's echo: the server now holds no flag, stamped at the removal. + await act(async () => { r.rerender(el(engi({ flagsA: 0, modifiedAt: NOW + 50 }), onSubmit)); }); + expect(count(r, 'aka'), 'the echo of the earlier removal does not wipe the newer tap').toBe('1'); + + await wait(350); // the tap's own autosave + const save = onSubmit.mock.calls.at(-1)?.[0]; + expect(save?.flagsA, 'the tap is sent').toBe(1); + expect(save?.changed, 'and named as a change').toContain('flags'); + }); + + 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(engi(), onSubmit)); }); + await tap(r, 'engi-aka-inc'); + await wait(350); + // The server copy carries this tap, then another device's Shiro flag, + // stamped after the tap. + await act(async () => { r.rerender(el(engi({ flagsA: 1, modifiedAt: NOW + 50 }), onSubmit)); }); + await act(async () => { r.rerender(el(engi({ flagsA: 1, flagsB: 1, modifiedAt: NOW + 5000 }), onSubmit)); }); + expect(count(r, 'shiro'), 'the later change shows').toBe('1'); + expect(count(r, 'aka')).toBe('1'); + }); + + 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 is the match as it stands. + const onSubmit = vi.fn().mockResolvedValue({ id: 'm1', status: 'running' }); + let r; + await act(async () => { r = render(el(engi(), onSubmit)); }); + await tap(r, 'engi-aka-inc'); + await wait(350); // the tap's save goes out and lands + await act(async () => {}); + const echo = engi({ flagsA: 1, modifiedAt: NOW - 1 }); + await act(async () => { r.rerender(el(echo, onSubmit)); }); + await act(async () => { r.rerender(el({ ...echo, flagsB: 1 }, onSubmit)); }); + expect(count(r, 'shiro'), 'the board took the Shiro flag stamped just before the tap').toBe('1'); + }); +}); diff --git a/web-mobile/js/admin_scoring_autosave.jsx b/web-mobile/js/admin_scoring_autosave.jsx index e43929afd..4b2538d4b 100644 --- a/web-mobile/js/admin_scoring_autosave.jsx +++ b/web-mobile/js/admin_scoring_autosave.jsx @@ -142,7 +142,8 @@ export function useKeptInHistoryNote() { // 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. +// editor's board stamp, the team sheet's row and pick stamps and the engi editor's flags +// stamp are its three callers. export function landedAutosave(submit, seqRef, onLanded) { return (patch) => { const seq = seqRef.current; diff --git a/web-mobile/js/admin_scoring_engi.jsx b/web-mobile/js/admin_scoring_engi.jsx index b6c6d87e1..fa78b2c84 100644 --- a/web-mobile/js/admin_scoring_engi.jsx +++ b/web-mobile/js/admin_scoring_engi.jsx @@ -22,7 +22,8 @@ const { useState: useStateE, useEffect: useEffectE, useRef: useRefE } = React; import { ReasonPrompt, CORRECTION_PRESETS, useAdoptFromServer, HeldWriteDiscard, HeldWriteNotice, useClearPendingWhenNothingHeld } from './admin_scoring_shared.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 { serverNowMs } from './server_clock.jsx'; import { MatchHistoryDisclosure } from './match_history_view.jsx'; import { GROUP_FLAGS } from './match_groups.jsx'; import { useEscapeToClose, confirmDialog } from './ui.jsx'; @@ -143,7 +144,19 @@ export function EngiScoreEditorModal({ match, onClose, onSubmit, onSubmitAndNext autosaveIsRunningRef.current = m.status === "running"; autosaveSeenStampRef.current = m.modifiedAt || 0; autosaveBuildPatchRef.current = (status) => claimChanged({ flagsA, flagsB, status }); - autosaveOnSubmitRef.current = onSubmit; + // When the operator last changed a count here, 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 + // counts: comparing values alone reads a tap taken back during a save as + // untouched, and that save's echo then wipes it (bc-ltap, bc-kclr; the + // individual editor's boardEditedAtRef explains the rule at length). The seq + // counts edits so landedAutosave clears the stamp only for a save built after + // the latest one. + const flagsEditedAtRef = useRefE(undefined); + const flagsEditSeqRef = useRefE(0); + autosaveOnSubmitRef.current = landedAutosave(onSubmit, flagsEditSeqRef, (seq) => { + if (flagsEditSeqRef.current === seq) flagsEditedAtRef.current = undefined; + }); // An operator change to either count: the value, then the save it schedules. // A count adopted from the server does not come through here. A key pressed // at a bound (a/s at MAX_FLAGS, Backspace at 0; the buttons are disabled @@ -153,6 +166,8 @@ export function EngiScoreEditorModal({ match, onClose, onSubmit, onSubmitAndNext const next = clamp(n); if (next === (side === "a" ? flagsA : flagsB)) return; (side === "a" ? setFlagsA : setFlagsB)(next); + flagsEditedAtRef.current = serverNowMs(); + flagsEditSeqRef.current += 1; markDirty(); }; @@ -173,14 +188,24 @@ export function EngiScoreEditorModal({ match, onClose, onSubmit, onSubmitAndNext // keepLocalEdits, like the individual editor's scoreline and unlike the team // board: a pair of flag counts is ONE reading of ONE kata performance, so // there is no per-row seam to merge along. An operator with unsaved flags - // keeps all of them. + // keeps all of them. isDirty is value-only, so it cannot see a tap taken + // back to the server's value while a save is out; the apply holds the + // counts against a server copy stamped before the last edit (flagsEditedAtRef) + // and, once it does take one, clears the stamp. // // Until the match is running nothing this editor holds reaches the server // before the operator submits, so a stale reading can sit in front of them // for as long as the match is open. useAdoptFromServer({ signature: `${initialFlagsA}:${initialFlagsB}`, - apply: () => { setFlagsA(initialFlagsA); setFlagsB(initialFlagsB); claimChanged.agree(GROUP_FLAGS); }, + apply: () => { + const editedAt = flagsEditedAtRef.current; + if (editedAt !== undefined && (m.modifiedAt || 0) < editedAt) return; + flagsEditedAtRef.current = undefined; + setFlagsA(initialFlagsA); + setFlagsB(initialFlagsB); + claimChanged.agree(GROUP_FLAGS); + }, keepLocalEdits: true, isDirty, }); From 65fe321730f95f0beb6bbca80d74b97a53d9005d Mon Sep 17 00:00:00 2001 From: Ricardo Oliveira Date: Tue, 13 Oct 2026 17:00:21 +0100 Subject: [PATCH 02/31] test(bc-e2rv): engi journey, and the bc-evts tap-back guard end to end A new journey seeds an engi (kata pairs) competition through the wizard, scores and finishes a match on the court console and sees it on a public phone. Its second test holds a flag's save in flight, takes the flag back, and checks the echo does not restore it, in both directions; with the engi fix reverted it fails. The held-save route moves to a fixture shared with the bc-ltap test, and the wizard fixture learns the Engi option. Co-Authored-By: Claude Opus 5.5 --- scripts/e2e/README.md | 24 ++- scripts/e2e/fixtures/engi.mjs | 68 ++++++++ scripts/e2e/fixtures/held-write.mjs | 42 +++++ scripts/e2e/fixtures/wizard.mjs | 5 +- scripts/e2e/journeys/engi.spec.mjs | 154 ++++++++++++++++++ .../knockout-mixed-individual.spec.mjs | 30 +--- 6 files changed, 298 insertions(+), 25 deletions(-) create mode 100644 scripts/e2e/fixtures/engi.mjs create mode 100644 scripts/e2e/fixtures/held-write.mjs create mode 100644 scripts/e2e/journeys/engi.spec.mjs diff --git a/scripts/e2e/README.md b/scripts/e2e/README.md index bedbe587a..9020f1993 100644 --- a/scripts/e2e/README.md +++ b/scripts/e2e/README.md @@ -56,6 +56,10 @@ browser, `--debug` opens the inspector, and a failing test leaves a trace 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/engi.mjs the engi (kata pairs) flag editor: flag counters, entering + flags, the two-tap Save result + fixtures/held-write.mjs holding a running match's score write in flight, to + observe a race with its echo (below) 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 @@ -155,6 +159,19 @@ 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. +## Holding a save in flight + +On localhost the answer to an autosave comes back within milliseconds of the save +going out, so a tap made "while the save is out" never lands in that window by +itself. `fixtures/held-write.mjs` (`holdScoreWrites(page)`) intercepts the match +score PUTs: `holdNext()` holds the next one, its `sent` promise resolves once that +PUT is out (so the autosave debounce has run), and `release()` lets it through +after the journey has made the next tap, as a slow uplink would. `bodies` lists +every score PUT the page sent. The bc-ltap step of +`knockout-mixed-individual.spec.mjs` (individual editor) and the bc-evts step of +`engi.spec.mjs` (engi editor) use it to guard a flag or mark taken back while its +add is still saving. + ## The pace of a tap The app ignores a pointer tap that lands within 400 ms of the layer it opened (a @@ -271,4 +288,9 @@ removes the `fixme`, and the step then guards the fix. 6. A defect the journey finds becomes a `test.fixme(': ...')` step (see below). -`smoke.spec.mjs` is the smallest complete example. +`smoke.spec.mjs` is the smallest complete example. `engi.spec.mjs` is the one +journey on an engi (kata pairs) competition: it seeds four pairs through the +wizard's "Engi (kata competition)" box (`engi: true` in `createCompetition`) and +the roster paste box (`Name 1 - Name 2, Dojo`), scores a match in flags on the +court console and checks the result on a spectator's phone, and guards the engi +editor's tap-back race. diff --git a/scripts/e2e/fixtures/engi.mjs b/scripts/e2e/fixtures/engi.mjs new file mode 100644 index 000000000..4d518dc72 --- /dev/null +++ b/scripts/e2e/fixtures/engi.mjs @@ -0,0 +1,68 @@ +// Scoring an ENGI (kata pairs) match on the flag editor +// (admin_scoring_engi.jsx). An engi competitor is a pair, rostered as one +// participant ("Name 1 - Name 2, Dojo"), and a match is scored in flags, not +// ippon: each side has a counter, and the total must be 1, 3 or 5. The editor +// is the same whether the shiaijo page mounts it inline or the Scores tab +// opens it as an overlay; the helpers default to the inline one. +import { expect } from '@playwright/test'; +import { INLINE_EDITOR } from '../../screenshots/lib/editor.mjs'; +import { beforeFinish, finishLanded } from './scoring.mjs'; +import { settled } from './pace.mjs'; +import { sides, upNextCard } from './shiaijo.mjs'; + +// The editor panel itself (inline) or its dialog (overlay). +export const engiEditor = (page, root = INLINE_EDITOR) => page.locator(root).filter({ has: page.getByTestId('engi-submit') }).first(); + +const SIDE_KEY = { aka: 'aka', shiro: 'shiro' }; + +// A side's flag count as the board shows it, and its + / - buttons. +export const flagCount = (page, side, root = INLINE_EDITOR) => engiEditor(page, root).getByTestId(`engi-${SIDE_KEY[side]}-count`); +export const flagButton = (page, side, change, root = INLINE_EDITOR) => engiEditor(page, root) + .getByTestId(`engi-${SIDE_KEY[side]}-${change === '+' ? 'inc' : 'dec'}`); +export const engiTotal = (page, root = INLINE_EDITOR) => engiEditor(page, root).getByTestId('engi-total'); +export const engiSubmit = (page, root = INLINE_EDITOR) => engiEditor(page, root).getByTestId('engi-submit'); + +// The autosave pill of the open engi editor ("Synced", "Syncing…", ...). +export const engiSync = (page, root = INLINE_EDITOR) => engiEditor(page, root).locator('.sync-pill__label').first(); + +// Wait for the autosave to land. +export const engiSettle = (page, root = INLINE_EDITOR) => expect(engiSync(page, root)).toHaveText('Synced'); + +// The pair each side of the open editor holds, by the first member's line. +export async function engiPair(page, root = INLINE_EDITOR) { + const ed = engiEditor(page, root); + const first = async (side) => (await ed.getByTestId(`engi-side-${side}`).locator('.numbered-name__text').first().textContent()).trim(); + return { shiro: await first('shiro'), aka: await first('aka') }; +} + +// Start the Up next match from its card and return its pair once the engi +// editor holds it. +export async function startUpNextEngi(page) { + const card = upNextCard(page); + const pair = await sides(card); + await card.getByRole('button', { name: 'Start match' }).tap(); + await expect(engiEditor(page)).toBeVisible(); + const held = await engiPair(page); + expect(`${held.shiro} ${held.aka}`, 'the editor holds the match started').toContain(pair.shiro.split(' - ')[0]); + return held; +} + +// Enter flags: tap + the given number of times on each side. +export async function enterFlags(page, { aka = 0, shiro = 0 }, root = INLINE_EDITOR) { + for (let i = 0; i < aka; i += 1) await flagButton(page, 'aka', '+', root).tap(); + for (let i = 0; i < shiro; i += 1) await flagButton(page, 'shiro', '+', root).tap(); + await expect(flagCount(page, 'aka', root)).toHaveText(String(aka)); + await expect(flagCount(page, 'shiro', root)).toHaveText(String(shiro)); +} + +// Save the result in two taps ("Save result" / "Finish + Start Next →", then +// "Tap again to ..."), and return once the write has landed (finishLanded). +export async function saveEngiResult(page, root = INLINE_EDITOR) { + const ed = page.locator(root).first(); + await engiSubmit(page, root).tap(); + const armed = engiSubmit(page, root).filter({ hasText: /^Tap again to/ }); + await settled(armed); + const before = await beforeFinish(ed); + await armed.tap(); + return finishLanded(ed, before); +} diff --git a/scripts/e2e/fixtures/held-write.mjs b/scripts/e2e/fixtures/held-write.mjs new file mode 100644 index 000000000..186184f3b --- /dev/null +++ b/scripts/e2e/fixtures/held-write.mjs @@ -0,0 +1,42 @@ +// Hold a running match's score write in flight, so a race with its echo can be +// observed. On localhost the answer to an autosave arrives within milliseconds +// of the save going out, so a tap made "while the save is out" never lands in +// that window by itself; the write is held until the test releases it, and it +// does so only after the next tap has been dispatched, as a slow uplink would +// hold it. A safety timeout lets go of a hold nobody released. +const SCORE_ROUTE = '**/api/competitions/*/matches/*/score'; +const isScorePut = (r) => r.method() === 'PUT' && /\/score$/.test(r.url()); + +// Start holding on `page`. Returns: +// holdNext() the next score PUT is held; returns { release, sent } where +// `sent` resolves once that PUT has been sent (so the autosave +// debounce has run) and `release()` lets it through +// bodies the JSON body of every score PUT the page sent, in order +// stop() remove the route (call it in a finally) +export async function holdScoreWrites(page, { safetyMs = 10_000 } = {}) { + let hold = null; + const bodies = []; + const handler = async (route) => { + const req = route.request(); + if (isScorePut(req)) { + try { bodies.push(req.postDataJSON()); } catch { bodies.push(null); } + if (hold) { + const { promise } = hold; + hold = null; + await Promise.race([promise, new Promise((r) => setTimeout(r, safetyMs))]); + } + } + await route.continue(); + }; + await page.route(SCORE_ROUTE, handler); + return { + bodies, + holdNext() { + const h = {}; + h.promise = new Promise((resolve) => { h.release = resolve; }); + hold = h; + return { release: h.release, sent: page.waitForRequest(isScorePut) }; + }, + stop: () => page.unroute(SCORE_ROUTE, handler), + }; +} diff --git a/scripts/e2e/fixtures/wizard.mjs b/scripts/e2e/fixtures/wizard.mjs index 52b7847a1..19f8c139f 100644 --- a/scripts/e2e/fixtures/wizard.mjs +++ b/scripts/e2e/fixtures/wizard.mjs @@ -46,6 +46,8 @@ async function setNumber(page, label, value) { // // kind 'individual' | 'team' // format 'knockout' | 'mixed' | 'league' | 'swiss' +// engi boolean: "Engi (kata competition)" (individual only): the +// roster is pairs and a match is scored in flags // teamSize number (team only) // teamMatchType 'fixed' | 'kachinuki' (team only) // poolSizeMode 'maximum' | 'minimum' (mixed only) @@ -57,7 +59,7 @@ async function setNumber(page, label, value) { export async function createCompetition(page, opts = {}) { const { name, kind, format, teamSize, teamMatchType, poolSizeMode, poolSize, poolWinners, - courts, numberPrefix, twoThirdPlaces, + courts, numberPrefix, twoThirdPlaces, engi, } = opts; await page.goto('/admin/create-competition'); await expect(page.getByRole('heading', { level: 1, name: 'Add competition' })).toBeVisible(); @@ -65,6 +67,7 @@ export async function createCompetition(page, opts = {}) { 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 (engi !== undefined) await page.getByRole('checkbox', { name: /^Engi \(kata competition\)/ }).setChecked(engi); 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]); diff --git a/scripts/e2e/journeys/engi.spec.mjs b/scripts/e2e/journeys/engi.spec.mjs new file mode 100644 index 000000000..dadc9354c --- /dev/null +++ b/scripts/e2e/journeys/engi.spec.mjs @@ -0,0 +1,154 @@ +// Engi (kata pairs) journey: a knockout of four pairs, scored in flags. +// +// The tournament, the engi competition (the wizard's "Engi (kata competition)" +// box), the roster of pairs ("Name 1 - Name 2, Dojo"), the draw and the start +// are all seeded through the interface, once, in beforeAll. The tests then +// walk the court console: one scores a match and checks the result on a +// spectator's phone, the other guards bc-evts, the engi editor's counterpart +// of bc-ltap (a flag taken back while the save of the one before is still out +// must stay taken back). +import { test, expect } from '../fixtures/test.mjs'; +import { OPERATOR_DEVICE, PUBLIC_DEVICE } from '../fixtures/devices.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, completedRows } from '../fixtures/shiaijo.mjs'; +import { openViewer } from '../fixtures/public.mjs'; +import { holdScoreWrites } from '../fixtures/held-write.mjs'; +import { + engiEditor, engiSettle, engiTotal, enterFlags, flagCount, flagButton, saveEngiResult, startUpNextEngi, +} from '../fixtures/engi.mjs'; + +// Four pairs, one dojo each: (name, dojo) is unique per pair. +const PAIRS = [ + 'Akira Tanaka - Ben Carter, Gyokusen', + 'Chie Mori - Dan Evans, Musashi', + 'Eri Kato - Finn Hale, Seishinkan', + 'Goro Abe - Hana Ito, Thames', +]; + +let compId; +const audit = auditLog('engi'); +const { record } = audit; +let shot; + +test.describe.configure({ mode: 'serial' }); + +test.describe('engi', () => { + test.beforeAll(async ({ browser, serverForFile }, testInfo) => { + shot = journeyShots('engi'); + const server = await serverForFile(testInfo.file); + const ctx = await browser.newContext({ ...OPERATOR_DEVICE, baseURL: server.base }); + try { + const page = await ctx.newPage(); + await createTournament(page, { name: 'Engi Cup', courts: 2 }); + compId = await createCompetition(page, { + name: 'Engi Pairs', kind: 'individual', format: 'knockout', engi: true, courts: ['A'], numberPrefix: 'EN', + }); + await pasteRoster(page, compId, PAIRS); + await generateDraw(page, compId); + await startCompetition(page, compId); + } finally { + await ctx.close(); + } + }); + + test.afterAll(() => audit.flush()); + + test('score an engi match on the court console', async ({ page, browser, baseURL }) => { + await login(page); + await openShiaijo(page, 'A'); + await shot(page, 'court-before-start'); + + const pair = await startUpNextEngi(page); + await expect(engiTotal(page)).toContainText('Enter flags'); + await shot(page, 'engi-editor-started'); + + await enterFlags(page, { aka: 2, shiro: 1 }); + await expect(engiTotal(page)).toHaveText('Total: 3, Aka wins'); + await engiSettle(page); + await shot(page, 'flags-entered'); + + const landed = await saveEngiResult(page); + record({ journey: 'engi', action: 'Save result', pair, landed: landed.detail }); + await expect(completedRows(page)).toHaveCount(1); + const row = completedRows(page).first(); + await expect(row).toContainText(pair.aka); + // Aka's side carries the winner mark (the row's text names the side it is on). + const rowText = (await row.innerText()).replace(/\s+/g, ' '); + expect(rowText, 'Aka, who held the two flags, is marked the winner').toContain(`Aka: ✓ Winner: ${pair.aka}`); + expect(rowText.match(/✓/g), 'and only Aka').toHaveLength(1); + const result = (await row.locator('.shiaijo-qrow__result').innerText()).trim(); + record({ journey: 'engi', action: 'console result row', result }); + expect(result, 'the result reads in flags, Shiro 1 and Aka 2').toBe('Flags1–2'); + await shot(page, 'console-after-save'); + + const spectator = await browser.newContext({ ...PUBLIC_DEVICE, baseURL }); + try { + const phone = await spectator.newPage(); + await openViewer(phone, compId); + const resultCard = phone.locator('button.vsched-item').filter({ hasText: pair.aka }).filter({ hasText: pair.shiro }).first(); + await expect(resultCard).toBeVisible(); + const cardText = (await resultCard.innerText()).replace(/\s+/g, ' '); + record({ journey: 'engi', action: 'public viewer row', text: cardText }); + expect(cardText, 'the phone shows the flags, Shiro 1 and Aka 2').toContain('1–2'); + expect(cardText, 'Aka is marked the winner').toMatch(new RegExp(`Aka: ${pair.aka}[^✓]*✓`)); + expect(cardText.match(/✓/g), 'and only Aka').toHaveLength(1); + await shot(phone, 'viewer-engi-result', { fullPage: true }); + } finally { + await spectator.close(); + } + }); + + test('bc-evts: a flag taken back while its add is saving stays taken back, and is saved', async ({ page }) => { + await login(page); + await openShiaijo(page, 'A'); + // The previous test's Finish + Start Next left the second semi-final running. + if (!(await engiEditor(page).isVisible())) await startUpNextEngi(page); + await expect(engiEditor(page)).toBeVisible(); + await expect(flagCount(page, 'aka')).toHaveText('0'); + const writes = await holdScoreWrites(page); + const lastBody = () => writes.bodies[writes.bodies.length - 1]; + const reloaded = async () => { + await page.reload(); + await expect(engiEditor(page)).toBeVisible({ timeout: 15_000 }); + return Number(await flagCount(page, 'aka').innerText()); + }; + try { + // Add one, and take it back while the add's save is still out. + let held = writes.holdNext(); + await flagButton(page, 'aka', '+').tap(); + await held.sent; // past the autosave debounce: the add's PUT is out, held + await flagButton(page, 'aka', '-').tap(); + held.release(); + await engiSettle(page); + await page.waitForTimeout(1000); + await expect(flagCount(page, 'aka'), 'the taken-back flag stays taken back').toHaveText('0'); + await shot(page, 'flag-taken-back-kept'); + expect(await reloaded(), 'and the server holds no flag').toBe(0); + expect(lastBody(), 'the last save sent was the removal').toMatchObject({ flagsA: 0 }); + await shot(page, 'flag-taken-back-after-reload'); + + // The mirror: from one flag saved, take it off, and put it back while + // the removal's save is out. + await flagButton(page, 'aka', '+').tap(); + await engiSettle(page); + await expect(flagCount(page, 'aka')).toHaveText('1'); + held = writes.holdNext(); + await flagButton(page, 'aka', '-').tap(); + await held.sent; + await flagButton(page, 'aka', '+').tap(); + held.release(); + await engiSettle(page); + await page.waitForTimeout(1000); + await expect(flagCount(page, 'aka'), 'the flag put back stays').toHaveText('1'); + await shot(page, 'flag-put-back-kept'); + expect(await reloaded(), 'and the server holds it').toBe(1); + expect(lastBody(), 'the last save sent was the add').toMatchObject({ flagsA: 1 }); + } finally { + await writes.stop(); + } + }); +}); diff --git a/scripts/e2e/journeys/knockout-mixed-individual.spec.mjs b/scripts/e2e/journeys/knockout-mixed-individual.spec.mjs index 3cc1878c6..05e127ee5 100644 --- a/scripts/e2e/journeys/knockout-mixed-individual.spec.mjs +++ b/scripts/e2e/journeys/knockout-mixed-individual.spec.mjs @@ -39,6 +39,7 @@ import { doubleTap, hastyConfirm, hastyShiaijoConfirm, interrupt, retapWhileSaving, tapNeighbour, } from '../fixtures/clumsy.mjs'; import { auditLog } from '../fixtures/audit.mjs'; +import { holdScoreWrites } from '../fixtures/held-write.mjs'; import { tapSize } from '../fixtures/pointer.mjs'; import { ROSTER_12, ROSTER_6, ROSTER_4_BRONZE, ROSTER_4_JOINT, ROSTER_6B, rosterOf, @@ -1266,22 +1267,7 @@ test.describe('knockout-mixed-individual bead guards', () => { 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 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' && hold) { - const { promise } = hold; - hold = null; - await Promise.race([promise, new Promise((r) => setTimeout(r, 10_000))]); - } - await route.continue(); - }); - const scoreSent = () => page.waitForRequest((r) => r.method() === 'PUT' && /\/score$/.test(r.url())); + const writes = await holdScoreWrites(page); const akaMark = () => inlineEditor(page).locator('[aria-label^="Aka slot "][aria-label*=": remove "]').first(); const marksAfterReload = async () => { await page.reload(); @@ -1292,10 +1278,9 @@ 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. - let held = holdNextWrite(); - let sent = scoreSent(); + let held = writes.holdNext(); await akaMark().tap(); - await sent; + await held.sent; await ipponButton(page, 'aka', 'M').tap(); held.release(); await settle(page); @@ -1308,16 +1293,15 @@ 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); - held = holdNextWrite(); - sent = scoreSent(); + held = writes.holdNext(); await ipponButton(page, 'aka', 'M').tap(); - await sent; + await held.sent; await akaMark().tap(); held.release(); 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'); + await writes.stop(); }); }); From ca4f90d814f4482c76568d8af1e83fb3c36c4360 Mon Sep 17 00:00:00 2001 From: Ricardo Oliveira Date: Tue, 13 Oct 2026 17:20:44 +0100 Subject: [PATCH 03/31] test(bc-e2rv): harness review fixes, README checked against the code From the re-verification review of the e2e harness after PR #442: - audit rows find their spec from a path with spaces or parentheses; - mark-clearing loops are bounded (clearMarks) and fail with a message; - lineup saves wait for the save's response, not a button or a toast; - an allowlisted 409 whose body could not be read is not an error; - the fighter list closes with Escape, not a tap at a fixed point; - a harness-owned startMatch waits for Start, taps it and fails if absent; - journeyShots refuses a name that would empty the output root; - one enterTournament owner; one expect timeout constant; rosterOf bounds. The README and the Makefile comment now say what the code does. Co-Authored-By: Claude Opus 5.5 --- Makefile | 7 +- scripts/e2e/README.md | 183 ++++++++++++------ scripts/e2e/fixtures/audit.mjs | 11 +- scripts/e2e/fixtures/clumsy.mjs | 11 +- scripts/e2e/fixtures/individual.mjs | 14 +- scripts/e2e/fixtures/kachinuki.mjs | 8 +- scripts/e2e/fixtures/scoring.mjs | 62 +++++- scripts/e2e/fixtures/setup.mjs | 23 ++- scripts/e2e/fixtures/shared-court.mjs | 21 +- scripts/e2e/fixtures/shots.mjs | 11 +- scripts/e2e/fixtures/team.mjs | 77 +++++--- scripts/e2e/fixtures/test.mjs | 7 +- .../knockout-mixed-individual.spec.mjs | 11 +- .../knockout-mixed-kachinuki.spec.mjs | 43 +--- .../e2e/journeys/knockout-mixed-team.spec.mjs | 46 ++--- scripts/e2e/journeys/moved-match.spec.mjs | 4 +- scripts/e2e/journeys/shared-shiaijo.spec.mjs | 4 +- 17 files changed, 340 insertions(+), 203 deletions(-) diff --git a/Makefile b/Makefile index f372d102a..12795d66b 100644 --- a/Makefile +++ b/Makefile @@ -378,9 +378,10 @@ docs/media: go/build $(SHOTS_BROWSER_STAMP) ## Regenerate every captured screens @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 +# above, Playwright lives in its own package.json rather than web-mobile's. Both +# lockfiles currently resolve the same Playwright release (1.63.0; the capture +# harness's package.json only asks for ^1.56.0), 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 diff --git a/scripts/e2e/README.md b/scripts/e2e/README.md index 9020f1993..b46b1f7c3 100644 --- a/scripts/e2e/README.md +++ b/scripts/e2e/README.md @@ -25,10 +25,13 @@ 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. -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. +This directory pins `@playwright/test` at 1.63.0, and `scripts/screenshots` +resolves `playwright-core` 1.63.0 from its lockfile (its `package.json` only +asks for `^1.56.0`, so the match holds because both lockfiles are current, not +because anything enforces it). While the two agree they share one Chromium under +`~/.cache/ms-playwright`; if either lockfile moves to another release, each +downloads its own. 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 @@ -36,19 +39,24 @@ browser, `--debug` opens the inspector, and a failing test leaves a trace ## Layout + package.json Playwright, pinned; package-lock.json is its lockfile playwright.config.mjs projects, timeouts, output 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/devices.mjs the operator iPad and the spectator phone, and + EXPECT_TIMEOUT_MS, the one expect timeout + fixtures/setup.mjs first-run tournament form, sign in, sign out, and + enterTournament (sign in, or create the tournament + when the file's server has none): the one owner 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, 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, and - the Finish check every editor shares + fixtures/scoring.mjs scoring an individual bout on either editor, Start + match (startMatch), clearMarks, 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 @@ -67,11 +75,33 @@ browser, `--debug` opens the inspector, and a failing test leaves a trace 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 -one file. The wizard is the one most likely to move; `fixtures/wizard.mjs` is -the only file that knows its copy. + journeys/*.spec.mjs the journeys, one line each: + smoke the harness end to end: seeding through the + interface, scoring on both editors, every + clumsy helper once, a spectator's phone + knockout-mixed-individual the court operator's review of individual + competitions (pools and knockout), and the + bead guards (bc-ltap and others) + knockout-mixed-team team lineups from the three entry points, + scoring a team encounter, one result on every + surface + knockout-mixed-kachinuki kachinuki (winner stays on) played eight bouts + deep, every correction door, the final + moved-match a waiting match moved to another shiaijo + reaches that court's console without a reload + shared-shiaijo two competitions on one shiaijo: the picker, + the nudge lines, correcting across them + engi an engi (kata pairs) knockout scored in flags, + and the bc-evts tap-back guard + output/ screenshots, audit.json and traces (below); + gitignored + +Each fixture module owns the selectors for one surface, so a UI change +usually breaks one fixture file. Not only one: `fixtures/shared-court.mjs` +reads the create wizard page itself (`/admin/create-competition`, its "Add +competition" heading, `#create-start-time`) beside `fixtures/wizard.mjs`, and +a spec holds raw locators for what it checks once. The wizard is the surface +most likely to move; look in both files when it does. ## Projects and devices @@ -86,8 +116,14 @@ 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. -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. +240 s is the default test timeout; some journeys raise it for a long test with +`test.setTimeout`. `expect` waits 15 s (`EXPECT_TIMEOUT_MS` in +`fixtures/devices.mjs`, which the fixtures use for their own waits), an action +such as a tap waits 15 s (`actionTimeout`) and a navigation 30 s +(`navigationTimeout`). `retries` is 0 and `forbidOnly` is on, so a stray +`test.only` fails the run. Chromium runs with `--no-sandbox` only when the +process is root, which it refuses to sandbox; anywhere else the sandbox stays +on (the same rule as `scripts/screenshots/run.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 @@ -105,12 +141,28 @@ 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 +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 (`page.goto('/admin')`); the host is the file's server. +When a test fails, Playwright discards its worker. In a serial file the rest of +that file is skipped, because the later tests depend on what the earlier ones +seeded. A file that is not serial (`knockout-mixed-team.spec.mjs`, and the bead +guards at the end of `knockout-mixed-individual.spec.mjs`) goes on in a fresh +worker, with a fresh server and an empty data folder, so each of its tests must +seed or sign in again itself (`enterTournament` creates the tournament when the +server has none). + +`knockout-mixed-kachinuki.spec.mjs` is serial but cannot let a guard skip the +rest. Its four regression guards (bc-crpn, bc-emsl, bc-kheb, bc-sync) need the +final's live console, so they run as steps inside the final's tests, wrapped in +`guarded()`. A guard that fails is not thrown. It is recorded as a +`guard-failure` annotation on the test and as an audit row, the console is put +back, and the final's own checks still run; the played-out test then fails at +its end naming every guard that failed. + ## Seeding goes through the interface Every journey builds its tournament the way an operator does: the first-run @@ -124,9 +176,12 @@ 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 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 +owns the editor selectors, which `fixtures/scoring.mjs` re-exports beside the +ippon helpers. Start and Finish are `fixtures/scoring.mjs`'s own, not that +library's, which clicks and can return without having acted. `startMatch` taps +the overlay editor's Start match, fails when the button never appears, and +returns once the button has left. A journey taps Finish, 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` @@ -145,7 +200,7 @@ variants as real input: | `tapNeighbour(locator, direction)` | taps the control next to the intended one, found by layout, and returns which it hit | | `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 | +| `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, for every kind but `reload`, which reloads at once and uses neither | | `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 | @@ -154,10 +209,17 @@ 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. +judged by a person from the screenshots. + +A helper asserts nothing about the app, but it throws when it could not perform +its variant: `tapNeighbour` finds no neighbour, `doubleTap` lands its taps too +far apart, `retapWhileSaving` never sees "Saving…", and the confirm helpers find +no dialog. + +`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. ## Holding a save in flight @@ -181,12 +243,15 @@ 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`, 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. `pace.mjs` reads the +or strikes for a side waits it out. Into a fresh layer: `login`, +`hastyConfirm`, `hastyShiaijoConfirm`, `lineupsNameSlot`. Onto an armed button, +through `settled`: `armFinish` (so `finishMatch`), `finishTeam`, `endMatch`, +`saveEngiResult`. Per side of a bout, through `tapOnSide`, which keeps the time +of the last tap on each side: `awardIppon` and `boutIppon`. `awardBoutIppon` +taps by itself and dwells after its tap instead, so the next strike is ready. +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. `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. @@ -204,14 +269,17 @@ 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` (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, 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. +Everything a test's pages logged is attached to it as `console.json`, but only +when there is something to attach. A failing test keeps it in its trace. Each +entry carries the page it came from and whether the device was offline at the +time. For a failed request it also carries the status and body of the `/api` +answer. + +What a `beforeAll` 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. 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; @@ -222,7 +290,9 @@ 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. +in arrival order per URL. When the page closed before the 409's body was read, +the refusal code cannot be checked, and the entry then stands on the answer's +status, URL and request alone. 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. @@ -245,19 +315,22 @@ 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`. +test also carries the server's output as `server.log`, when a server is running +for its file. 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 -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. +`E2E_RESULTS_DIR` when set) when it starts, so pointing `E2E_RESULTS_DIR` at a +folder that holds anything else deletes that content. A journey empties its own +folder when it calls `journeyShots`, which is once per spec file or once per +test (see below). + +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 live inside it, as `/journeys//`, +so neither run empties the other's. A relative `E2E_RESULTS_DIR` is relative to +this directory. ## When a journey finds a bug @@ -279,9 +352,11 @@ removes the `fixme`, and the step then guards the fix. 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 + (it empties the folder) either in `beforeAll`, for a file whose tests share + one folder, or inside a test, for a file that gives each test a folder of its + own (`knockout-mixed-individual` and `knockout-mixed-team` do); + `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' })`. diff --git a/scripts/e2e/fixtures/audit.mjs b/scripts/e2e/fixtures/audit.mjs index 18e703457..dd2d4e7e9 100644 --- a/scripts/e2e/fixtures/audit.mjs +++ b/scripts/e2e/fixtures/audit.mjs @@ -13,11 +13,16 @@ 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. +// can add its warning rows to the audit of the test's own file, looked up by +// the testInfo.file Playwright reports. auditLog runs while a spec file loads, +// before any testInfo exists, so the file is read off the call stack: the first +// frame in a `.spec.mjs` followed by its `:line:column`, taken to that suffix +// (not to the next space, parenthesis or colon), so a checkout path holding any +// of those still resolves. An ESM frame is a file URL (percent-encoded), which +// fileURLToPath decodes to the path testInfo.file carries. const bySpec = new Map(); function callingSpec() { - const m = (new Error().stack || '').match(/((?:file:\/\/)?\/[^\s():]+\.spec\.mjs)/); + const m = /(file:\/\/\/[^\n]*?\.spec\.mjs|\/[^\n]*?\.spec\.mjs)(?=:\d+:\d+)/.exec(new Error().stack || ''); if (!m) return null; return m[1].startsWith('file://') ? fileURLToPath(m[1]) : m[1]; } diff --git a/scripts/e2e/fixtures/clumsy.mjs b/scripts/e2e/fixtures/clumsy.mjs index 495c142c3..6ab3ba409 100644 --- a/scripts/e2e/fixtures/clumsy.mjs +++ b/scripts/e2e/fixtures/clumsy.mjs @@ -13,12 +13,17 @@ // 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. +// call, and judgement columns are formed by eye, never asserted. They throw +// only when the variant itself could not be performed, so the row would not +// have tested what it says: tapNeighbour finds no neighbour in reach, +// doubleTap's taps land too far apart, retapWhileSaving never sees "Saving…", +// and a helper that needs a dialog or a button finds none. // // 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 { errors } from '@playwright/test'; +import { EXPECT_TIMEOUT_MS } from './devices.mjs'; import { dwell, TAP_BOUNCE_MS } from './pace.mjs'; // The longest gap that still counts as a double tap: the app's bounce window @@ -156,7 +161,7 @@ export async function retapWhileSaving(locator, { holdWritesMs = 1200, savingTex await page.waitForFunction( ([el, src, flags]) => !el.isConnected || !new RegExp(src, flags).test(el.textContent || ''), [handle, savingText.source, savingText.flags], - { timeout: holdWritesMs + 15000 }, + { timeout: holdWritesMs + EXPECT_TIMEOUT_MS }, ); const labelAfter = await handle.evaluate((el) => (el.isConnected ? el.textContent.trim() : null)); return { savingSeen, retapped: savingSeen, labelAfter }; @@ -280,7 +285,7 @@ export async function loudestButton(scope) { // 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 }); + await handle.waitForElementState('hidden', { timeout: EXPECT_TIMEOUT_MS }); } finally { await handle.dispose(); } diff --git a/scripts/e2e/fixtures/individual.mjs b/scripts/e2e/fixtures/individual.mjs index fe2b785f5..d076dee83 100644 --- a/scripts/e2e/fixtures/individual.mjs +++ b/scripts/e2e/fixtures/individual.mjs @@ -11,6 +11,7 @@ import { UP_NEXT, WAITING_ROWS, completedRowFor, courtDone, courtIdle, inlineEditor, upNextCard, } from './shiaijo.mjs'; import { hastyShiaijoConfirm } from './clumsy.mjs'; +import { EXPECT_TIMEOUT_MS } from './devices.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 @@ -34,9 +35,14 @@ export const ROSTER_6B = [ ]; // 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], -]); +const ROSTER_FIRST_NAMES = ['Aki', 'Bea', 'Cal', 'Dee', 'Eli', 'Fay']; +const ROSTER_DOJOS = ['Gyokusen', 'Thames', 'Musashi', 'Seishinkan', 'Mumeishi', 'Hizen']; +export const rosterOf = (prefix, n) => { + if (n > ROSTER_FIRST_NAMES.length) { + throw new Error(`rosterOf: ${n} entrants asked for, only ${ROSTER_FIRST_NAMES.length} names to give them`); + } + return Array.from({ length: n }, (_, i) => [`${prefix} ${ROSTER_FIRST_NAMES[i]}`, ROSTER_DOJOS[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 = [ @@ -116,7 +122,7 @@ export async function inlineRunning(page) { // 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 } = {}) { +export async function ensureRunning(page, { timeout = EXPECT_TIMEOUT_MS } = {}) { const start = upNextCard(page).getByRole('button', { name: 'Start match' }); let tappedAt = null; let nothingSince = null; diff --git a/scripts/e2e/fixtures/kachinuki.mjs b/scripts/e2e/fixtures/kachinuki.mjs index ccc596841..a928fc23e 100644 --- a/scripts/e2e/fixtures/kachinuki.mjs +++ b/scripts/e2e/fixtures/kachinuki.mjs @@ -14,6 +14,8 @@ // does for a thumb. import { expect } from '@playwright/test'; import { dwell, settled } from './pace.mjs'; +import { EXPECT_TIMEOUT_MS } from './devices.mjs'; +import { clearMarks } from './scoring.mjs'; import { isCoarse, restoreCoarse } from './pointer.mjs'; import { inlineEditor } from './shiaijo.mjs'; import * as T from './team.mjs'; @@ -126,6 +128,10 @@ export async function doneRowWinner(row) { // The centre mark of a row (vs / X / (E)). export const boutMiddle = async (row) => (await T.rowMiddle(row).first().innerText()).trim(); +// Tap one side's filled marks off a bout row until `keep` are left (the +// bounded clearMarks, fixtures/scoring.mjs); returns the taps made. +export const clearBoutMarks = (row, side, opts) => clearMarks(boutFilled(row, side), opts); + // 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 // passed (fixtures/pace.mjs), so the helper returns after it. @@ -143,7 +149,7 @@ export async function awardBoutIppon(page, side, waza = 'M') { 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(doneRows(page)).toHaveCount(before + 1, { timeout: EXPECT_TIMEOUT_MS }); await expect(recordBoutButton(page)).not.toHaveText('Saving…'); } diff --git a/scripts/e2e/fixtures/scoring.mjs b/scripts/e2e/fixtures/scoring.mjs index 2c4246df3..3c8f4097a 100644 --- a/scripts/e2e/fixtures/scoring.mjs +++ b/scripts/e2e/fixtures/scoring.mjs @@ -6,18 +6,62 @@ // (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 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). +// The editor selectors come from scripts/screenshots/lib/editor.mjs, +// re-exported here so a journey imports one scoring module. Start and Finish +// are this file's own, not that library's: a journey TAPS, as the operator does +// on the iPad, and must know the action LANDED (the library's startMatch clicks +// and returns silently when the button has not rendered yet; its finishMatch +// clicks, on the overlay only, and returns once the button stops saving). +// Finish also takes the inline editor as well as the overlay. import { expect } from '@playwright/test'; -import { EDITOR, INLINE_EDITOR, startMatch } from '../../screenshots/lib/editor.mjs'; +import { EDITOR, INLINE_EDITOR, TAP_DWELL_MS } from '../../screenshots/lib/editor.mjs'; +import { EXPECT_TIMEOUT_MS } from './devices.mjs'; import { settled, tapOnSide } from './pace.mjs'; import { courtIdle, editorIdentity } from './shiaijo.mjs'; -export { EDITOR, INLINE_EDITOR, startMatch }; +export { EDITOR, INLINE_EDITOR }; + +// Start the match the OVERLAY editor holds and return once the start has +// landed: "Start match" is offered only while the match is scheduled, so the +// button leaving is the server having it running. Fails when the button never +// appears (the editor did not open on a scheduled match) or never leaves. +export async function startMatch(page) { + const btn = page.locator(EDITOR).locator('button').filter({ hasText: /^Start match$/ }).first(); + await expect(btn, 'the overlay editor offers Start match').toBeVisible(); + await btn.tap(); + await expect(btn, 'Start match left the editor once the start landed').toBeHidden(); +} + +// The most taps clearMarks makes on one side before it gives up. A side holds a +// couple of marks at most, so six leaves room for a tap or two that did not count. +export const MAX_CLEAR_TAPS = 6; + +// Tap filled mark slots off until `slots` (a Locator of the FILLED slots, e.g. +// filledSlots or fixtures/team.mjs rowSlots) holds `keep` of them. Bounded: a +// mark that does not go away fails with a message instead of looping until the +// test timeout. A tap the app does not count (one inside the bounce window of +// the layer it just opened, such as a correction row, tap_guard.jsx) removes +// nothing, so after each tap the count is given TAP_DWELL_MS to drop, which +// outlasts that window, before the next tap is made; a tap that lands is not +// waited on. `from` is the end of the list the next tap takes ('last' or +// 'first'); `pauseMs` is the operator's beat between two removals. Returns the +// taps made. +export async function clearMarks(slots, { keep = 0, from = 'last', pauseMs = 0 } = {}) { + let taps = 0; + for (let before = await slots.count(); before > keep; before = await slots.count()) { + if (taps >= MAX_CLEAR_TAPS) { + throw new Error(`clearMarks: ${slots} still holds ${before} mark(s) (wanted ${keep}) after ${MAX_CLEAR_TAPS} taps`); + } + await (from === 'first' ? slots.first() : slots.last()).tap(); + taps += 1; + const tappedAt = Date.now(); + while ((await slots.count()) >= before && Date.now() - tappedAt < TAP_DWELL_MS) { + await slots.page().waitForTimeout(50); + } + if (pauseMs) await slots.page().waitForTimeout(pauseMs); + } + return taps; +} const SIDE_NAME = { shiro: 'Shiro', aka: 'Aka' }; @@ -81,7 +125,7 @@ const visibleTexts = (loc) => loc.evaluateAll((els) => els 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); + && /"status":"completed"/.test(r.request().postData() || ''), { timeout: EXPECT_TIMEOUT_MS }).then((r) => r, () => null); return { ...(await editorIdentity(page, ed)), notSaved: await visibleTexts(ed.locator(NOT_SAVED)), answer }; } diff --git a/scripts/e2e/fixtures/setup.mjs b/scripts/e2e/fixtures/setup.mjs index c45d66ea8..c2d452a27 100644 --- a/scripts/e2e/fixtures/setup.mjs +++ b/scripts/e2e/fixtures/setup.mjs @@ -1,5 +1,6 @@ -// The first-run tournament form, the admin sign-in form and Sign out. -// Every selector for those three lives here. +// The first-run tournament form, the admin sign-in form, Sign out, and the +// sign-in-or-create step the journeys start from. Every selector for those +// lives here. import { expect } from '@playwright/test'; import { dwell } from './pace.mjs'; @@ -28,6 +29,24 @@ export async function createTournament(page, { await expect(page.getByRole('heading', { level: 1, name })).toBeVisible(); } +// Sign in, creating the tournament first when this file's server has none yet. +// Every test in a file can call it, whichever runs first or in which worker. +// Returns { created } so a caller can tell the first run from a later one. +export async function enterTournament(page, { name = 'E2E 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 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 }; + } + await login(page); + return { created: false }; +} + export async function signOut(page) { await page.getByRole('button', { name: 'Sign out' }).tap(); await expect(page.getByRole('button', { name: 'Sign out' })).toHaveCount(0); diff --git a/scripts/e2e/fixtures/shared-court.mjs b/scripts/e2e/fixtures/shared-court.mjs index 8acc762fc..b0ae14dd3 100644 --- a/scripts/e2e/fixtures/shared-court.mjs +++ b/scripts/e2e/fixtures/shared-court.mjs @@ -10,11 +10,10 @@ // 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 { EXPECT_TIMEOUT_MS, 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'; @@ -41,22 +40,6 @@ export async function publicDevice(browser, base) { 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('/'); - // 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 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 }; - } - 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 @@ -82,7 +65,7 @@ export async function wizardDefaults(page, { expectEstimate = false } = {}) { page.on('request', onRequest); try { const answered = expectEstimate - ? page.waitForResponse((r) => isEstimate(r.request()) && r.ok(), { timeout: 15_000 }) + ? page.waitForResponse((r) => isEstimate(r.request()) && r.ok(), { timeout: EXPECT_TIMEOUT_MS }) : null; await page.goto('/admin/create-competition'); await expect(page.getByRole('heading', { level: 1, name: 'Add competition' })).toBeVisible(); diff --git a/scripts/e2e/fixtures/shots.mjs b/scripts/e2e/fixtures/shots.mjs index 1e2eb8537..8e43beca3 100644 --- a/scripts/e2e/fixtures/shots.mjs +++ b/scripts/e2e/fixtures/shots.mjs @@ -22,7 +22,16 @@ const JOURNEYS_ROOT = process.env.E2E_RESULTS_DIR // 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); +// A name that is empty, absolute or climbs out of the root is refused: this +// path is what journeyShots empties. +export function journeyDir(journey) { + const dir = path.resolve(JOURNEYS_ROOT, String(journey)); + const inside = path.relative(JOURNEYS_ROOT, dir); + if (!journey || path.isAbsolute(journey) || !inside || inside.startsWith('..') || path.isAbsolute(inside)) { + throw new Error(`journeyDir: "${journey}" is not a folder inside ${JOURNEYS_ROOT}`); + } + return dir; +} // Empty the journey's folder and return its camera. Call once per spec file // (in beforeAll), so a folder never mixes two runs. diff --git a/scripts/e2e/fixtures/team.mjs b/scripts/e2e/fixtures/team.mjs index 4918a1077..c0958e977 100644 --- a/scripts/e2e/fixtures/team.mjs +++ b/scripts/e2e/fixtures/team.mjs @@ -17,7 +17,7 @@ // 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 { EXPECT_TIMEOUT_MS } from './devices.mjs'; import { settled, tapOnSide } from './pace.mjs'; import { beforeFinish, finishLanded } from './scoring.mjs'; import { inlineEditor, upNextCard } from './shiaijo.mjs'; @@ -27,22 +27,6 @@ import { inlineEditor, upNextCard } from './shiaijo.mjs'; 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 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: - // whichever the first paint shows, however long it takes. - const welcome = page.getByRole('heading', { name: 'Welcome to Bracket Creator' }); - 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; - } - await login(page); -} - // ---------------------------------------------------------------- Lineups page const overlineField = (page, text) => page.locator('label') @@ -116,7 +100,7 @@ async function answerDialog(page, label) { const handle = await dialog.elementHandle(); try { await dialog.getByRole('button', { name: label }).tap(); - await handle.waitForElementState('hidden', { timeout: 15_000 }); + await handle.waitForElementState('hidden', { timeout: EXPECT_TIMEOUT_MS }); } finally { await handle.dispose(); } @@ -130,9 +114,25 @@ export async function lineupsNameSlot(page, label, name) { await expect(lineupsSelect(page, label).locator('option:checked')).toContainText(name); } +// The PUT every lineup save sends: a team's lineup of a round (the Lineups +// page) or of one match (the lineup panel, the score sheet row), api_client.jsx +// putTeamLineup / putMatchLineup. Armed BEFORE the tap that sends it. A timeout +// the caller never reached (the tap threw first) is not an unhandled rejection. +function lineupSaveAnswer(page) { + const answered = page.waitForResponse((r) => r.request().method() === 'PUT' + && /\/teams\/[^/]+\/(lineups|match-lineups)\/[^/?]+(\?|$)/.test(r.url()), { timeout: EXPECT_TIMEOUT_MS }); + answered.catch(() => {}); + return answered; +} + +// Tap Save lineup on the Lineups page and return once the server has answered +// the save with a success status. The "Lineup saved" toast is not the signal: it +// also opens "Lineup saved, but ..." warnings, and an older toast outlives it. export async function lineupsSave(page) { + const answered = lineupSaveAnswer(page); await lineupsForm(page).getByRole('button', { name: 'Save lineup' }).tap(); - await expect(page.getByText('Lineup saved').first()).toBeVisible(); + const res = await answered; + expect(res.ok(), `the lineup save was answered ${res.status()} ${res.url()}`).toBe(true); } // The option text currently selected at a position (e.g. "T1.2 Ito"). @@ -175,18 +175,20 @@ 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. 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. +// box). Close it with Escape on the box that owns the list: an open list takes +// the key and keeps it from the layer behind (LineupNameInput stops it there), +// and pressing on the owning input puts the key where the list is, whatever +// the focus was. A fixed tap on the page margin is not used: where it lands +// depends on the surface (on the shiaijo page it can be a queue row). Escape +// is pressed only while a list is drawn, since one that arrives with none +// closes the layer behind 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; if (wasOpen) { - await page.touchscreen.tap(6, 500); + await page.locator('.lineup-name').filter({ has: options }).first().locator('input.pmf__input').press('Escape'); await expect(options).toHaveCount(0); } return wasOpen; @@ -199,12 +201,23 @@ 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) { +// Tap one side's Save lineup and return once the save has been answered, then +// once the button is back from "Saving…" (disabled when the side was saved: +// nothing is left to save). By default the server must answer the save PUT with +// a success status. `refused: true` is for a save the panel refuses: it waits +// for the side's inline error instead (a refusal the panel makes itself sends +// no request), and the caller asserts its text. +export async function panelSave(page, i, { refused = false } = {}) { + const side = panelSide(page, i); + const answered = refused ? null : lineupSaveAnswer(page); await panelSaveButton(page, i).tap(); - await expect(panelSide(page, i).getByRole('button', { name: 'Save lineup' })).toBeVisible(); + if (refused) { + await expect(side.locator('.alert--error')).toBeVisible(); + } else { + const res = await answered; + expect(res.ok(), `the lineup save was answered ${res.status()} ${res.url()}`).toBe(true); + } + await expect(side.getByRole('button', { name: 'Save lineup' })).toBeVisible(); } export async function closePanel(page) { diff --git a/scripts/e2e/fixtures/test.mjs b/scripts/e2e/fixtures/test.mjs index 46c498303..597194ef1 100644 --- a/scripts/e2e/fixtures/test.mjs +++ b/scripts/e2e/fixtures/test.mjs @@ -29,7 +29,9 @@ 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 +// code the app's refusal code in the failed /api answer's body; with +// codeUnreadable, an answer whose body could not be read (its page +// closed first) stands in for the code, the rest still matching // 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) @@ -51,6 +53,7 @@ const CONSOLE_ALLOWLIST = [ 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)$/, + codeUnreadable: true, request: (r) => !!r && (/\/reopen$/.test(r.url) || writeStatus(r.postData) === 'running'), }, ]; @@ -68,7 +71,7 @@ const writeStatus = (postData) => { }; 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.code || r.code.test(refusalCode(e.body) || '') || (r.codeUnreadable && e.body === null && !!e.request)) && (!r.request || r.request(e.request)) && (!r.whileOffline || e.offline)); diff --git a/scripts/e2e/journeys/knockout-mixed-individual.spec.mjs b/scripts/e2e/journeys/knockout-mixed-individual.spec.mjs index 05e127ee5..b3f60b6ab 100644 --- a/scripts/e2e/journeys/knockout-mixed-individual.spec.mjs +++ b/scripts/e2e/journeys/knockout-mixed-individual.spec.mjs @@ -33,7 +33,7 @@ import { editorIdentity, UP_NEXT, } from '../fixtures/shiaijo.mjs'; import { - INLINE_EDITOR, EDITOR, ipponButton, syncState, filledSlots, finishButton, armedFinishButton, armFinish, finishMatch, + INLINE_EDITOR, EDITOR, clearMarks, ipponButton, syncState, filledSlots, finishButton, armedFinishButton, armFinish, finishMatch, } from '../fixtures/scoring.mjs'; import { doubleTap, hastyConfirm, hastyShiaijoConfirm, interrupt, retapWhileSaving, tapNeighbour, @@ -175,7 +175,7 @@ test.describe('knockout-mixed-individual', () => { row({ step: 'clear mark', action: 'tap a scored mark', variant: 'V2 doubleTap', ...dblClear, 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(); + await clearMarks(filledSlots(page, w1), { keep: 1, from: 'first' }); 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(); @@ -195,12 +195,7 @@ test.describe('knockout-mixed-individual', () => { row({ step: 'running bout', action: `${other} K then reload within 300ms`, variant: 'V3 interrupt reload (inside autosave debounce)', pillAtTap, marksAfterReload: otherAfterFastReload, shot: await shot(page, 'fast-reload-after-tap') }); // Whatever survived, go back to one men for the winner and nothing else. - for (const sd of [other, w1]) { - while ((await filledSlots(page, sd).count()) > 0) { - await filledSlots(page, sd).first().tap(); - await page.waitForTimeout(150); - } - } + for (const sd of [other, w1]) await clearMarks(filledSlots(page, sd), { from: 'first', pauseMs: 150 }); // 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. diff --git a/scripts/e2e/journeys/knockout-mixed-kachinuki.spec.mjs b/scripts/e2e/journeys/knockout-mixed-kachinuki.spec.mjs index dc72eb446..2e1aa16f0 100644 --- a/scripts/e2e/journeys/knockout-mixed-kachinuki.spec.mjs +++ b/scripts/e2e/journeys/knockout-mixed-kachinuki.spec.mjs @@ -52,7 +52,7 @@ import { } from '../fixtures/clumsy.mjs'; import { closeNameList } from '../fixtures/team.mjs'; import { - awardBoutIppon, boutFilled, ensureCoarse, boutIpponButton, boutMiddle, boutNames, closeLineupPanel, correctingBout, + awardBoutIppon, boutFilled, clearBoutMarks, ensureCoarse, boutIpponButton, boutMiddle, boutNames, closeLineupPanel, correctingBout, currentBout, doneRowWinner, doneRows, editor, enchoButton, enchoUndoButton, endMatch, endMatchButton, lineupSide, lineupSource, openLineupFromUpNext, recordBout, recordBoutButton, removeBoutButton, reopenButton, syncPill, tieButton, typeLineup, winBout, @@ -104,18 +104,6 @@ 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)); @@ -346,11 +334,7 @@ test.describe('J5 kachinuki from the court console', () => { // 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 closeNameList(page); - let recoverTaps = 0; - while ((await boutFilled(currentBout(page), 'shiro').count()) > 1) { - await boutFilled(currentBout(page), 'shiro').last().tap(); - recoverTaps += 1; - } + const recoverTaps = await clearBoutMarks(currentBout(page), 'shiro', { keep: 1 }); 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 }); @@ -361,7 +345,7 @@ test.describe('J5 kachinuki from the court console', () => { record({ step: 'M1 bout 1', action: 'Shiro M (second point)', variant: 'V1 tapNeighbour right', ...v1, marksAfter: after, screenshot: await shot(page, 'v1-ippon-neighbour') }); // Put the board where the operator meant it: M K for Shiro. - while ((await boutFilled(currentBout(page), 'shiro').count()) > 1) await boutFilled(currentBout(page), 'shiro').last().tap(); + await clearBoutMarks(currentBout(page), 'shiro', { keep: 1 }); 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. The wait @@ -639,7 +623,7 @@ test.describe('J5 kachinuki from the court console', () => { const corr = correctingBout(page); await expect(corr).toBeVisible(); // Clear Shiro's two marks, give Aka two: bout 1 now reads as Aka's. - while ((await boutFilled(corr, 'shiro').count()) > 0) await boutFilled(corr, 'shiro').last().tap(); + await clearBoutMarks(corr, 'shiro'); await boutIpponButton(corr, 'aka', 'M').tap(); await boutIpponButton(corr, 'aka', 'M').tap(); const warning = editor(page).getByTestId('kachinuki-done-edit-warn'); @@ -648,7 +632,7 @@ test.describe('J5 kachinuki from the court console', () => { const flipShot = await shot(page, 'bout-1-winner-flipped-warning'); const laterUnchanged = await boutNames(currentBout(page)); // Put it back as it was fought. - while ((await boutFilled(corr, 'aka').count()) > 0) await boutFilled(corr, 'aka').last().tap(); + await clearBoutMarks(corr, 'aka'); await boutIpponButton(corr, 'shiro', 'M').tap(); await boutIpponButton(corr, 'shiro', 'K').tap(); await editor(page).getByTestId('kachinuki-done-collapse-0').tap(); @@ -675,11 +659,7 @@ test.describe('J5 kachinuki from the court console', () => { await page.waitForTimeout(300); const marks = await boutFilled(currentBout(page), 'shiro').allInnerTexts(); const v1Shot = await shot(page, 'v1-fusensho-neighbour'); - let recoverTaps = 0; - while ((await boutFilled(currentBout(page), 'shiro').count()) > 0) { - await boutFilled(currentBout(page), 'shiro').last().tap(); - recoverTaps += 1; - } + const recoverTaps = await clearBoutMarks(currentBout(page), 'shiro'); record({ step: 'M1 bout 8', action: 'Fusensho (Shiro)', variant: 'V1 tapNeighbour left', ...v1, shiroMarks: marks, recoverTaps, screenshot: v1Shot }); // V2: Fusensho double-tapped (it is a toggle). @@ -791,7 +771,7 @@ test.describe('J5 kachinuki from the court console', () => { const wasFusensho = /✓/.test(await fus.innerText()); if (wasFusensho) await fus.tap(); // re-tap undoes the walkover await expect(fus).toHaveText(/^Fusensho$/); - while ((await boutFilled(currentBout(page), 'shiro').count()) > 0) await boutFilled(currentBout(page), 'shiro').last().tap(); + await clearBoutMarks(currentBout(page), 'shiro'); await awardBoutIppon(page, 'shiro', 'M'); return wasFusensho; }; @@ -1022,14 +1002,7 @@ test.describe('J5 kachinuki from the court console', () => { await expect(option(S[1])).toBeVisible(); return taps; }; - const clearGhostMarks = async () => { - let taps = 0; - while ((await boutFilled(currentBout(page), 'shiro').count()) > 0) { - await boutFilled(currentBout(page), 'shiro').last().tap(); - taps += 1; - } - return taps; - }; + const clearGhostMarks = () => clearBoutMarks(currentBout(page), 'shiro'); // Correct path: one tap on the right fighter. await openList(); await shot(page, 'm2-fighter-pick-open'); diff --git a/scripts/e2e/journeys/knockout-mixed-team.spec.mjs b/scripts/e2e/journeys/knockout-mixed-team.spec.mjs index 637298f31..50a942f0b 100644 --- a/scripts/e2e/journeys/knockout-mixed-team.spec.mjs +++ b/scripts/e2e/journeys/knockout-mixed-team.spec.mjs @@ -17,11 +17,11 @@ 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 { enterTournament, 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, completedRowFor } from '../fixtures/shiaijo.mjs'; -import { EDITOR } from '../fixtures/scoring.mjs'; +import { EDITOR, clearMarks } 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'; @@ -72,6 +72,10 @@ async function seedF4(page, { name, court, prefix, teams }) { return id; } +// The tournament every test here signs in to, or creates when its server has +// none: sixteen shiaijo, so each test seeds its own competition on a court of its own. +const TEAM_CUP = { name: 'Team Cup', courts: 16 }; + // 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 }); @@ -81,10 +85,10 @@ async function nameStartingLineup(page, id, team, names, teamSize) { } // 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 +// (enterTournament 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. +// serverForFile) and enterTournament creates the tournament there again. test.describe('knockout-mixed-team', () => { test.afterAll(() => { for (const log of audits) log.flush(); @@ -95,7 +99,7 @@ test.describe('knockout-mixed-team', () => { test.setTimeout(600_000); const shot = journeyShots('J3-lineups'); const { record } = recorder('J3', 'J3-lineups'); - await T.enterAdmin(page); + await enterTournament(page, TEAM_CUP); let f3; let pair3; @@ -236,7 +240,7 @@ test.describe('knockout-mixed-team', () => { // One member at two positions: type Dai (at 1) into 2 and save. await T.typeIntoNameBox(T.panelInput(page, 1, '2'), 'Dai'); - await T.panelSave(page, 1); + await T.panelSave(page, 1, { refused: true }); const err = T.panelSide(page, 1).locator('.alert--error'); await expect(err).toBeVisible(); const copy = (await err.innerText()).trim(); @@ -394,9 +398,7 @@ test.describe('knockout-mixed-team', () => { await expect(T.boutRow(ed, 1)).toBeVisible(); const pairNow = await sides(ed); for (const n of [1, 2, 3]) { - for (const side of ['shiro', 'aka']) { - while (await T.rowSlots(T.boutRow(ed, n), side).count()) await T.rowSlots(T.boutRow(ed, n), side).first().tap(); - } + for (const side of ['shiro', 'aka']) await clearMarks(T.rowSlots(T.boutRow(ed, n), side), { from: 'first' }); } await T.boutIppon(ed, 1, 'shiro', 'M'); await T.ensureTie(ed, 2); @@ -526,7 +528,7 @@ test.describe('knockout-mixed-team', () => { test.setTimeout(600_000); const shot = journeyShots('J4-team-scoring'); const { record } = recorder('J4', 'J4-team-scoring'); - await T.enterAdmin(page); + await enterTournament(page, TEAM_CUP); const id = await seedF4(page, { name: 'Team KO J4', court: 'D', prefix: 'S', teams: FOUR_TEAMS('J4') }); await openShiaijo(page, 'D'); const ed = inlineEditor(page); @@ -646,9 +648,7 @@ test.describe('knockout-mixed-team', () => { record({ step: 'bout 3', action: 'Fusensho Aka, reload, untoggle', variant: 'V3 interrupt reload', ...r, afterReload, afterUntoggle, sheet: await T.sheetState(ed), expected: 'Shiro K restored (the control title promises the previous score)', note: 'bc-fsnp when Shiro is empty' }); // Make bout 3 a clean hikiwake for the tie that follows. - for (const side of ['shiro', 'aka']) { - while (await T.rowSlots(T.boutRow(ed, 3), side).count()) await T.rowSlots(T.boutRow(ed, 3), side).first().tap(); - } + for (const side of ['shiro', 'aka']) await clearMarks(T.rowSlots(T.boutRow(ed, 3), side), { from: 'first' }); if (/✓/.test(await T.rowFusensho(T.boutRow(ed, 3), 'aka').innerText())) await T.rowFusensho(T.boutRow(ed, 3), 'aka').tap(); await T.ensureTie(ed, 3); }); @@ -692,7 +692,7 @@ test.describe('knockout-mixed-team', () => { sameMatch: edBack && (await ed.locator('.editor-modal__eyebrow').first().innerText()).trim() === identity, akaKept: edBack ? await T.rowSlots(T.boutRow(ed, 4), 'aka').allInnerTexts() : null, sheet: edBack ? await T.sheetState(ed) : null }); await expect(T.boutRow(ed, 4)).toBeVisible(); - while (await T.rowSlots(T.boutRow(ed, 4), 'aka').count()) await T.rowSlots(T.boutRow(ed, 4), 'aka').first().tap(); + await clearMarks(T.rowSlots(T.boutRow(ed, 4), 'aka'), { from: 'first' }); await T.ensureTie(ed, 4); // Bout 5, not yet fought: the operator taps its Tie for a real draw. const row5 = T.boutRow(ed, 5); @@ -959,7 +959,7 @@ test.describe('knockout-mixed-team', () => { test.setTimeout(600_000); const shot = journeyShots('J7-one-result'); const { record } = recorder('J7', 'J7-one-result'); - await T.enterAdmin(page); + await enterTournament(page, TEAM_CUP); 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'); const pair = await sides(upNextCard(page)); @@ -1101,7 +1101,7 @@ test.describe('knockout-mixed-team', () => { // 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); + await enterTournament(page, TEAM_CUP); const id = await createCompetition(page, { name: 'B1 Pools', kind: 'team', format: 'mixed', teamSize: 3, teamMatchType: 'fixed', courts: ['G', 'H'], numberPrefix: 'G', }); @@ -1128,7 +1128,7 @@ test.describe('knockout-mixed-team', () => { // 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 enterTournament(page, TEAM_CUP); await seedF4(page, { name: 'B2 KO', court: 'I', prefix: 'I', teams: FOUR_TEAMS('B2') }); await openShiaijo(page, 'I'); await T.openPanelFromUpNext(page); @@ -1139,14 +1139,14 @@ test.describe('knockout-mixed-team', () => { // 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'); - await T.panelSave(page, 1); + await T.panelSave(page, 1, { refused: true }); await expect(T.panelSide(page, 1).locator('.alert--error')).toHaveText('Dai is already at Senpo.'); }); // 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 enterTournament(page, TEAM_CUP); await seedF4(page, { name: 'B3 KO', court: 'J', prefix: 'J', teams: [['B3 Ume', 'Kita Dojo'], ['B3 Sakura', 'Minami Dojo']] }); await openShiaijo(page, 'J'); const ed = await T.startUpNextTeam(page); @@ -1173,7 +1173,7 @@ test.describe('knockout-mixed-team', () => { // 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 enterTournament(page, TEAM_CUP); 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); @@ -1199,7 +1199,7 @@ test.describe('knockout-mixed-team', () => { // are no unfinished team matches. So Finish must refuse while a numbered // bout has no result, and an unfought bout never reaches the standings. test('bc-tmfn: a team encounter cannot be finished while a bout has no result', async ({ page }) => { - await T.enterAdmin(page); + await enterTournament(page, TEAM_CUP); const id = await createCompetition(page, { name: 'B3 Pools', kind: 'team', format: 'mixed', teamSize: 3, teamMatchType: 'fixed', courts: ['M', 'N'], numberPrefix: 'Q', }); @@ -1231,7 +1231,7 @@ test.describe('knockout-mixed-team', () => { }); test('bc-dhrp: the daihyosen row lets each team pick its representative', async ({ page }) => { - await T.enterAdmin(page); + await enterTournament(page, TEAM_CUP); await seedF4(page, { name: 'B6 KO', court: 'F', prefix: 'R', teams: [['B6 Ume', 'Kita Dojo'], ['B6 Sakura', 'Minami Dojo']] }); await openShiaijo(page, 'F'); const ed = await T.startUpNextTeam(page); @@ -1248,7 +1248,7 @@ test.describe('knockout-mixed-team', () => { // 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 enterTournament(page, TEAM_CUP); 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); diff --git a/scripts/e2e/journeys/moved-match.spec.mjs b/scripts/e2e/journeys/moved-match.spec.mjs index 7e644535f..f1de02f28 100644 --- a/scripts/e2e/journeys/moved-match.spec.mjs +++ b/scripts/e2e/journeys/moved-match.spec.mjs @@ -27,7 +27,7 @@ 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 { enterTournament, login } from '../fixtures/setup.mjs'; import { openShiaijo, upNextCard, sides, startUpNext } from '../fixtures/shiaijo.mjs'; import { EDITOR, awardIppon, finishButton, finishMatch } from '../fixtures/scoring.mjs'; import { hastyShiaijoConfirm } from '../fixtures/clumsy.mjs'; @@ -36,7 +36,7 @@ import { tapSize } from '../fixtures/pointer.mjs'; import { ROSTER_6, recordDecision, slotMarks } from '../fixtures/individual.mjs'; import * as T from '../fixtures/team.mjs'; import { - enterTournament, seedCompetitions, readScoresTab, operatorDevice, markPage, pageMark, + seedCompetitions, readScoresTab, operatorDevice, markPage, pageMark, competitionPicker, officiatingName, pickCompetition, nudges, nudgeLines, courtState, upcomingRow, chooseCourt, runningOnCourt, playTeamMatch, liveBout, } from '../fixtures/shared-court.mjs'; diff --git a/scripts/e2e/journeys/shared-shiaijo.spec.mjs b/scripts/e2e/journeys/shared-shiaijo.spec.mjs index aedd005e9..ad92436ca 100644 --- a/scripts/e2e/journeys/shared-shiaijo.spec.mjs +++ b/scripts/e2e/journeys/shared-shiaijo.spec.mjs @@ -31,7 +31,7 @@ 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 { enterTournament, login } from '../fixtures/setup.mjs'; import { openShiaijo, upNextCard, sides, startUpNext, completedRowFor, inlineEditor, editorIdentity, } from '../fixtures/shiaijo.mjs'; @@ -50,7 +50,7 @@ import { } from '../fixtures/individual.mjs'; import * as T from '../fixtures/team.mjs'; import { - enterTournament, seedCompetitions, readScoresTab, bracketSlots, operatorDevice, publicDevice, + seedCompetitions, readScoresTab, bracketSlots, operatorDevice, publicDevice, competitionPicker, officiatingName, pickCompetition, nudges, nudgeLines, courtState, runningOnCourt, playTeamMatch, liveBout, correctAcross, chooseCourt, } from '../fixtures/shared-court.mjs'; From 9abfdf713e841aae993f4757b6f45fc005a5c624 Mon Sep 17 00:00:00 2001 From: Ricardo Oliveira Date: Tue, 13 Oct 2026 18:09:29 +0100 Subject: [PATCH 04/31] fix(bc-evts): stamp a board edit only while the match is running; review round 1 The edit-time stamp holds a score editor's board against the echo of its own autosave, and only a landed autosave clears it. On a match that is not running nothing is autosaved, so a net-zero fiddle left a stamp nothing cleared and the editor refused another device's earlier copy. The engi and individual editors now take the stamp only while the match is running. Tests now model a landed save, and pin the edit counter (a save built before the latest tap clears nothing), a queued or refused save keeping the stamp, the scheduled match, and the stamp boundary. Harness: an unreadable 409 body is allowed only when the page had closed, under its own reason; the held-save guard asserts its answer reached the page before the next write was sent, and fails on an unreleased hold; lineup saves are matched to their team and lineup; a refused panel save sends nothing; clearMarks fails when a removed mark comes back. Co-Authored-By: Claude Opus 5.5 --- .claude/rules/match-results-and-marks.md | 2 +- scripts/e2e/README.md | 10 ++- scripts/e2e/fixtures/engi.mjs | 6 +- scripts/e2e/fixtures/held-write.mjs | 78 ++++++++++++++---- scripts/e2e/fixtures/scoring.mjs | 16 +++- scripts/e2e/fixtures/team.mjs | 46 +++++++++-- scripts/e2e/fixtures/test.mjs | 36 ++++++--- scripts/e2e/journeys/engi.spec.mjs | 16 +++- .../knockout-mixed-individual.spec.mjs | 72 +++++++++-------- .../engi_editor_tap_back_kept.render.test.jsx | 80 ++++++++++++++++++- ...idual_editor_tap_back_kept.render.test.jsx | 76 +++++++++++++++++- web-mobile/js/admin_scoring_engi.jsx | 9 ++- web-mobile/js/admin_scoring_individual.jsx | 8 +- 13 files changed, 365 insertions(+), 90 deletions(-) diff --git a/.claude/rules/match-results-and-marks.md b/.claude/rules/match-results-and-marks.md index c2c903cfd..d0e31aa38 100644 --- a/.claude/rules/match-results-and-marks.md +++ b/.claude/rules/match-results-and-marks.md @@ -93,7 +93,7 @@ It projects `ModifiedAt` and `GroupStamps` too (bc-lww1, bc-mrgc). `mergeMatchWr Because an editor can now never rule on something it did not display, an explicit `false` is always the operator's decision and always reaches the wire as what it now IS, a markless scoreline: `daihyosenEnchoFields` still states the local boolean unconditionally, and the serializer chokepoint converts it (true → mark placed on the winner's side, false → arrays stay markless; `realIppons` strips echoed marks first so placement never doubles). Two earlier designs are recorded in `web-mobile/js/__tests__/render/individual_editor_live_hantei.render.test.jsx` and must not be reintroduced: recomputing the baseline from the live prop while freezing the local armed state (which erased the verdict on the next write), and freezing at mount with a `hanteiKnown` silence guard (which stopped the erase by accepting the divergence; the guard was deleted with it). -**The edit-time adopt rule.** A score editor holds its board against a server copy stamped before the operator's last unsent edit, because comparing values alone reads a tap taken back during a save as untouched, and that save's echo then wipes it. Its three owners are the team sheet's `rowTakenFromServer`/`repRowTakes` (bc-kclr), the individual editor's `adoptServerScore` (bc-ltap) and the engi editor's adopt (`flagsEditedAtRef`, bc-evts). `landedAutosave` (admin_scoring_autosave.jsx) clears a stamp once a save built after the last edit has landed, so every later server copy is followed. +**The edit-time adopt rule.** A score editor holds its board against a server copy stamped before the operator's last unsent edit, because comparing values alone reads a tap taken back during a save as untouched, and that save's echo then wipes it. Its three owners are the team sheet's `rowTakenFromServer`/`repRowTakes` (bc-kclr), the individual editor's `adoptServerScore` (bc-ltap) and the engi editor's adopt (`flagsEditedAtRef`, bc-evts). `landedAutosave` (admin_scoring_autosave.jsx) clears a stamp once a save built after the last edit has landed, so every later server copy is followed. The stamp is taken only while the match is running, because only then is the edit autosaved: on a scheduled match or a correction of a completed one nothing is sent, no landed save would ever clear the stamp, and a net-zero fiddle would make the editor refuse another device's copy it would otherwise have adopted. ## Silence on the wire diff --git a/scripts/e2e/README.md b/scripts/e2e/README.md index b46b1f7c3..b2c193aad 100644 --- a/scripts/e2e/README.md +++ b/scripts/e2e/README.md @@ -290,9 +290,13 @@ 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. When the page closed before the 409's body was read, -the refusal code cannot be checked, and the entry then stands on the answer's -status, URL and request alone. +in arrival order per URL. Only when the page was +closed while the 409's body was being read is the refusal code unreadable, and +the entry then stands on the answer's status, URL and request alone; the +`CONSOLE` line and a `console-allowed-unread` annotation say "allowed: page +closed before the refusal's code could be read". A body that is missing for any +other reason (the read timed out, or failed on a page still open, as a reload +does) is not allowed: the test fails. 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/engi.mjs b/scripts/e2e/fixtures/engi.mjs index 4d518dc72..6360a9cf0 100644 --- a/scripts/e2e/fixtures/engi.mjs +++ b/scripts/e2e/fixtures/engi.mjs @@ -43,7 +43,11 @@ export async function startUpNextEngi(page) { await card.getByRole('button', { name: 'Start match' }).tap(); await expect(engiEditor(page)).toBeVisible(); const held = await engiPair(page); - expect(`${held.shiro} ${held.aka}`, 'the editor holds the match started').toContain(pair.shiro.split(' - ')[0]); + // Each card holds its own pair: Shiro's the one the Up next card showed on + // Shiro, Aka's the one it showed on Aka. + const firstMember = (name) => name.split(' - ')[0].trim(); + expect(held.shiro, 'the Shiro card holds the Shiro pair').toBe(firstMember(pair.shiro)); + expect(held.aka, 'the Aka card holds the Aka pair').toBe(firstMember(pair.aka)); return held; } diff --git a/scripts/e2e/fixtures/held-write.mjs b/scripts/e2e/fixtures/held-write.mjs index 186184f3b..f6c5bf50f 100644 --- a/scripts/e2e/fixtures/held-write.mjs +++ b/scripts/e2e/fixtures/held-write.mjs @@ -3,40 +3,90 @@ // of the save going out, so a tap made "while the save is out" never lands in // that window by itself; the write is held until the test releases it, and it // does so only after the next tap has been dispatched, as a slow uplink would -// hold it. A safety timeout lets go of a hold nobody released. +// hold it. A safety timeout lets go of a hold nobody released, and stop() +// throws when one had to. const SCORE_ROUTE = '**/api/competitions/*/matches/*/score'; const isScorePut = (r) => r.method() === 'PUT' && /\/score$/.test(r.url()); // Start holding on `page`. Returns: -// holdNext() the next score PUT is held; returns { release, sent } where -// `sent` resolves once that PUT has been sent (so the autosave -// debounce has run) and `release()` lets it through -// bodies the JSON body of every score PUT the page sent, in order -// stop() remove the route (call it in a finally) +// holdNext() the next score PUT is held; returns { release, sent, answered } +// where `sent` resolves once that PUT has been sent (so the +// autosave debounce has run), `release()` lets it through and +// `answered` resolves once its response has reached the page +// answeredBeforeNextWrite(held) +// waits for the held write's response, then throws unless it +// reached the page BEFORE the write that followed it was sent +// (the echo then met a board whose next edit was still unsent, +// which is the race a journey holds the write for) +// bodies the JSON body of every score PUT the page sent, in send order +// stop() release any hold still pending, remove the route and the +// listener (call it in a finally), and throw when the safety +// timeout had to release a hold export async function holdScoreWrites(page, { safetyMs = 10_000 } = {}) { - let hold = null; + let hold = null; // armed by holdNext(), taken by the next PUT + let safetyFired = 0; const bodies = []; + const sends = []; // { body, at } in send order, parallel to `bodies` + const holds = []; // every hold made, taken or not const handler = async (route) => { const req = route.request(); if (isScorePut(req)) { - try { bodies.push(req.postDataJSON()); } catch { bodies.push(null); } + let body = null; + try { body = req.postDataJSON(); } catch { /* not JSON */ } + bodies.push(body); + sends.push({ body, at: Date.now() }); if (hold) { - const { promise } = hold; + const h = hold; hold = null; - await Promise.race([promise, new Promise((r) => setTimeout(r, safetyMs))]); + h.request = req; + h.sendIndex = sends.length - 1; + let timer; + const timedOut = new Promise((resolve) => { + timer = setTimeout(() => { if (!h.released) safetyFired += 1; resolve(); }, safetyMs); + }); + await Promise.race([h.promise, timedOut]); + clearTimeout(timer); } } await route.continue(); }; + const onResponse = (res) => { + const h = holds.find((x) => x.request && x.request === res.request()); + if (h && h.answeredAt === undefined) { + h.answeredAt = Date.now(); + h.resolveAnswered(); + } + }; await page.route(SCORE_ROUTE, handler); + page.on('response', onResponse); return { bodies, holdNext() { - const h = {}; - h.promise = new Promise((resolve) => { h.release = resolve; }); + const h = { released: false, request: null, sendIndex: -1, answeredAt: undefined }; + h.promise = new Promise((resolve) => { h.release = () => { h.released = true; resolve(); }; }); + h.answered = new Promise((resolve) => { h.resolveAnswered = resolve; }); + holds.push(h); hold = h; - return { release: h.release, sent: page.waitForRequest(isScorePut) }; + return { release: h.release, sent: page.waitForRequest(isScorePut), answered: h.answered, hold: h }; + }, + async answeredBeforeNextWrite(held) { + const h = held.hold; + await Promise.race([ + h.answered, + new Promise((_, reject) => setTimeout(() => reject(new Error('held score write: its response never reached the page')), safetyMs)), + ]); + const next = sends[h.sendIndex + 1]; + if (next && next.at <= h.answeredAt) { + throw new Error(`held score write: the next write was sent ${h.answeredAt - next.at} ms before the held one's response reached the page, so the race was not exercised`); + } + }, + async stop() { + // Release first: a PUT still blocked in the route handler would make the + // unroute wait for it. + for (const h of holds) h.release(); + page.off('response', onResponse); + await page.unroute(SCORE_ROUTE, handler); + if (safetyFired) throw new Error(`held score write: the ${safetyMs} ms safety timeout released ${safetyFired} hold(s) nobody released`); }, - stop: () => page.unroute(SCORE_ROUTE, handler), }; } diff --git a/scripts/e2e/fixtures/scoring.mjs b/scripts/e2e/fixtures/scoring.mjs index 3c8f4097a..9e503da01 100644 --- a/scripts/e2e/fixtures/scoring.mjs +++ b/scripts/e2e/fixtures/scoring.mjs @@ -43,21 +43,29 @@ export const MAX_CLEAR_TAPS = 6; // the layer it just opened, such as a correction row, tap_guard.jsx) removes // nothing, so after each tap the count is given TAP_DWELL_MS to drop, which // outlasts that window, before the next tap is made; a tap that lands is not -// waited on. `from` is the end of the list the next tap takes ('last' or -// 'first'); `pauseMs` is the operator's beat between two removals. Returns the -// taps made. +// waited on. A count that goes UP between taps is a failure, never tapped +// away: a removed mark coming back is the bc-ltap class of defect. `from` is +// the end of the list the next tap takes ('last' or 'first'); `pauseMs` is the +// operator's beat between two removals. Returns the taps made. export async function clearMarks(slots, { keep = 0, from = 'last', pauseMs = 0 } = {}) { let taps = 0; + let last = null; // the count after the previous tap's wait + const cameBack = (was, now) => new Error(`clearMarks: ${slots} went UP from ${was} to ${now} mark(s) after ${taps} tap(s) (wanted ${keep}): a removed mark came back`); for (let before = await slots.count(); before > keep; before = await slots.count()) { + if (last !== null && before > last) throw cameBack(last, before); if (taps >= MAX_CLEAR_TAPS) { throw new Error(`clearMarks: ${slots} still holds ${before} mark(s) (wanted ${keep}) after ${MAX_CLEAR_TAPS} taps`); } await (from === 'first' ? slots.first() : slots.last()).tap(); taps += 1; const tappedAt = Date.now(); - while ((await slots.count()) >= before && Date.now() - tappedAt < TAP_DWELL_MS) { + let now = await slots.count(); + while (now === before && Date.now() - tappedAt < TAP_DWELL_MS) { await slots.page().waitForTimeout(50); + now = await slots.count(); } + if (now > before) throw cameBack(before, now); + last = now; if (pauseMs) await slots.page().waitForTimeout(pauseMs); } return taps; diff --git a/scripts/e2e/fixtures/team.mjs b/scripts/e2e/fixtures/team.mjs index c0958e977..81a064586 100644 --- a/scripts/e2e/fixtures/team.mjs +++ b/scripts/e2e/fixtures/team.mjs @@ -118,9 +118,21 @@ export async function lineupsNameSlot(page, label, name) { // page) or of one match (the lineup panel, the score sheet row), api_client.jsx // putTeamLineup / putMatchLineup. Armed BEFORE the tap that sends it. A timeout // the caller never reached (the tap threw first) is not an unhandled rejection. -function lineupSaveAnswer(page) { - const answered = page.waitForResponse((r) => r.request().method() === 'PUT' - && /\/teams\/[^/]+\/(lineups|match-lineups)\/[^/?]+(\?|$)/.test(r.url()), { timeout: EXPECT_TIMEOUT_MS }); +// `teamId` names the team whose lineup is being saved, and `matchId` the match +// whose own lineup it is (`null`: the team's starting lineup, round 0); a PUT +// for another team or lineup is not this save's answer. `matchId` undefined +// matches any match lineup of the team (the lineup panel does not show the id). +const LINEUP_PUT = /\/teams\/([^/]+)\/(lineups|match-lineups)\/([^/?]+)(\?|$)/; +function lineupPut(r, { teamId, matchId }) { + if (r.method() !== 'PUT') return false; + const m = LINEUP_PUT.exec(r.url()); + if (!m || decodeURIComponent(m[1]) !== teamId) return false; + if (matchId === undefined) return m[2] === 'match-lineups'; + if (matchId === null) return m[2] === 'lineups' && decodeURIComponent(m[3]) === '0'; + return m[2] === 'match-lineups' && decodeURIComponent(m[3]) === matchId; +} +function lineupSaveAnswer(page, target) { + const answered = page.waitForResponse((r) => lineupPut(r.request(), target), { timeout: EXPECT_TIMEOUT_MS }); answered.catch(() => {}); return answered; } @@ -129,7 +141,10 @@ function lineupSaveAnswer(page) { // the save with a success status. The "Lineup saved" toast is not the signal: it // also opens "Lineup saved, but ..." warnings, and an older toast outlives it. export async function lineupsSave(page) { - const answered = lineupSaveAnswer(page); + // The save is of the team and lineup the two selects show right now. + const teamId = await overlineField(page, 'Team').locator('select').inputValue(); + const matchId = (await overlineField(page, 'Lineup for').locator('select').inputValue()) || null; + const answered = lineupSaveAnswer(page, { teamId, matchId }); await lineupsForm(page).getByRole('button', { name: 'Save lineup' }).tap(); const res = await answered; expect(res.ok(), `the lineup save was answered ${res.status()} ${res.url()}`).toBe(true); @@ -207,13 +222,28 @@ export async function typeIntoNameBox(input, name) { // a success status. `refused: true` is for a save the panel refuses: it waits // for the side's inline error instead (a refusal the panel makes itself sends // no request), and the caller asserts its text. -export async function panelSave(page, i, { refused = false } = {}) { +export async function panelSave(page, i, { refused = false, matchId } = {}) { const side = panelSide(page, i); - const answered = refused ? null : lineupSaveAnswer(page); - await panelSaveButton(page, i).tap(); + // The side's testid ends in its team's id (admin_schedule_lineup.jsx). + const teamId = (await side.getAttribute('data-testid')).replace(/^match-lineup-side-/, ''); + const answered = refused ? null : lineupSaveAnswer(page, { teamId, matchId }); if (refused) { - await expect(side.locator('.alert--error')).toBeVisible(); + // An alert from an earlier save would satisfy the wait below on its own. + await expect(side.locator('.alert--error'), 'no refusal is showing before the tap').toHaveCount(0); + const sent = []; + const onRequest = (r) => { if (lineupPut(r, { teamId, matchId })) sent.push(r.url()); }; + page.on('request', onRequest); + try { + await panelSaveButton(page, i).tap(); + await expect(side.locator('.alert--error')).toBeVisible(); + // A refusal the panel makes itself sends nothing; give a stray write time to go out. + await page.waitForTimeout(500); + expect(sent, 'the refused save sent no lineup write').toEqual([]); + } finally { + page.off('request', onRequest); + } } else { + await panelSaveButton(page, i).tap(); const res = await answered; expect(res.ok(), `the lineup save was answered ${res.status()} ${res.url()}`).toBe(true); } diff --git a/scripts/e2e/fixtures/test.mjs b/scripts/e2e/fixtures/test.mjs index 597194ef1..ebbbf4ca6 100644 --- a/scripts/e2e/fixtures/test.mjs +++ b/scripts/e2e/fixtures/test.mjs @@ -30,8 +30,10 @@ 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; with -// codeUnreadable, an answer whose body could not be read (its page -// closed first) stands in for the code, the rest still matching +// codeUnreadable, an answer whose body could not be read because +// its page closed first stands in for the code, the rest still +// matching (a body missing for any other reason, a timeout or a +// failed read on a page still open, never does) // 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) @@ -69,9 +71,13 @@ const writeStatus = (postData) => { return null; } }; +// The page was closed when the answer's body was read: the one reason a body may +// be missing and an entry with codeUnreadable still stand on the answer's status, +// URL and request. +const closedBeforeBody = (e) => e.body === null && e.bodyMissing === 'page-closed' && !!e.request; 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.codeUnreadable && e.body === null && !!e.request)) + && (!r.code || r.code.test(refusalCode(e.body) || '') || (r.codeUnreadable && closedBeforeBody(e))) && (!r.request || r.request(e.request)) && (!r.whileOffline || e.offline)); @@ -88,6 +94,7 @@ const OFFLINE_GRACE_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; +const ALLOWED_UNREAD_REASON = 'allowed: page closed before the refusal\'s code could be read'; // How far apart a "Failed to load resource" line and its answer may be. const PAIR_WINDOW_MS = 5_000; function consoleCollector() { @@ -121,12 +128,19 @@ 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(); + // Why the body is missing, when it is: the page was closed when the read + // failed, the read failed on a page still open (a reload, a navigation), + // or it did not arrive in BODY_WAIT_MS. + const entry = { url: res.url(), status: res.status(), method: res.request().method(), postData: res.request().postData(), bodyMissing: null, at }; let timer; - const body = Promise.race([ - res.text().then((t) => t.slice(0, 1000), () => null), - new Promise((done) => { timer = setTimeout(done, BODY_WAIT_MS, null); }), + entry.body = Promise.race([ + res.text().then((t) => t.slice(0, 1000), () => { + entry.bodyMissing = page.isClosed() ? 'page-closed' : 'read-failed'; + return null; + }), + new Promise((done) => { timer = setTimeout(() => { entry.bodyMissing = 'timeout'; done(null); }, BODY_WAIT_MS); }), ]).finally(() => clearTimeout(timer)); - failed.push({ url: res.url(), status: res.status(), method: res.request().method(), postData: res.request().postData(), body, at }); + failed.push(entry); }); }; const attachContext = (ctx) => { @@ -158,7 +172,7 @@ function consoleCollector() { 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 } }; + return { ...e, status: answer.status, body: answer.body, bodyMissing: answer.bodyMissing, 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 @@ -282,7 +296,11 @@ export const test = base.extend({ 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 })}`); + // An entry let through for want of a body says so, in place of the + // matched-code reason: nobody checked the code. + const unread = rule && rule.code && !rule.code.test(refusalCode(e.body) || ''); + if (unread) testInfo.annotations.push({ type: 'console-allowed-unread', description: describeEntry(e) }); + console.log(`CONSOLE ${JSON.stringify({ test: testInfo.title, allowed: rule ? (unread ? ALLOWED_UNREAD_REASON : rule.reason) : null, ...e })}`); } const bad = errors.filter((e) => !allowed(e)); if (bad.length) { diff --git a/scripts/e2e/journeys/engi.spec.mjs b/scripts/e2e/journeys/engi.spec.mjs index dadc9354c..5974fbeda 100644 --- a/scripts/e2e/journeys/engi.spec.mjs +++ b/scripts/e2e/journeys/engi.spec.mjs @@ -8,7 +8,7 @@ // of bc-ltap (a flag taken back while the save of the one before is still out // must stay taken back). import { test, expect } from '../fixtures/test.mjs'; -import { OPERATOR_DEVICE, PUBLIC_DEVICE } from '../fixtures/devices.mjs'; +import { OPERATOR_DEVICE, PUBLIC_DEVICE, EXPECT_TIMEOUT_MS } from '../fixtures/devices.mjs'; import { journeyShots } from '../fixtures/shots.mjs'; import { auditLog } from '../fixtures/audit.mjs'; import { createTournament, login } from '../fixtures/setup.mjs'; @@ -105,15 +105,18 @@ test.describe('engi', () => { test('bc-evts: a flag taken back while its add is saving stays taken back, and is saved', async ({ page }) => { await login(page); await openShiaijo(page, 'A'); - // The previous test's Finish + Start Next left the second semi-final running. - if (!(await engiEditor(page).isVisible())) await startUpNextEngi(page); + // The previous test's Finish + Start Next left the second semi-final running; + // wait for the editor to render, and start the Up next match only when it + // never does. + const running = await engiEditor(page).waitFor({ timeout: EXPECT_TIMEOUT_MS }).then(() => true, () => false); + if (!running) await startUpNextEngi(page); await expect(engiEditor(page)).toBeVisible(); await expect(flagCount(page, 'aka')).toHaveText('0'); const writes = await holdScoreWrites(page); const lastBody = () => writes.bodies[writes.bodies.length - 1]; const reloaded = async () => { await page.reload(); - await expect(engiEditor(page)).toBeVisible({ timeout: 15_000 }); + await expect(engiEditor(page)).toBeVisible({ timeout: EXPECT_TIMEOUT_MS }); return Number(await flagCount(page, 'aka').innerText()); }; try { @@ -123,12 +126,15 @@ test.describe('engi', () => { await held.sent; // past the autosave debounce: the add's PUT is out, held await flagButton(page, 'aka', '-').tap(); held.release(); + // The add's response reached the page before the removal's own save went out. + await writes.answeredBeforeNextWrite(held); await engiSettle(page); await page.waitForTimeout(1000); await expect(flagCount(page, 'aka'), 'the taken-back flag stays taken back').toHaveText('0'); await shot(page, 'flag-taken-back-kept'); expect(await reloaded(), 'and the server holds no flag').toBe(0); expect(lastBody(), 'the last save sent was the removal').toMatchObject({ flagsA: 0 }); + expect(lastBody().changed, 'and named the flags as changed').toContain('flags'); await shot(page, 'flag-taken-back-after-reload'); // The mirror: from one flag saved, take it off, and put it back while @@ -141,12 +147,14 @@ test.describe('engi', () => { await held.sent; await flagButton(page, 'aka', '+').tap(); held.release(); + await writes.answeredBeforeNextWrite(held); await engiSettle(page); await page.waitForTimeout(1000); await expect(flagCount(page, 'aka'), 'the flag put back stays').toHaveText('1'); await shot(page, 'flag-put-back-kept'); expect(await reloaded(), 'and the server holds it').toBe(1); expect(lastBody(), 'the last save sent was the add').toMatchObject({ flagsA: 1 }); + expect(lastBody().changed, 'and named the flags as changed').toContain('flags'); } finally { await writes.stop(); } diff --git a/scripts/e2e/journeys/knockout-mixed-individual.spec.mjs b/scripts/e2e/journeys/knockout-mixed-individual.spec.mjs index b3f60b6ab..d8e8bbb25 100644 --- a/scripts/e2e/journeys/knockout-mixed-individual.spec.mjs +++ b/scripts/e2e/journeys/knockout-mixed-individual.spec.mjs @@ -1263,40 +1263,46 @@ test.describe('knockout-mixed-individual bead guards', () => { await openShiaijo(page, 'O'); expect(await ensureRunning(page), 'a semi-final starts on O').not.toBeNull(); const writes = await holdScoreWrites(page); - 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(); - }; + try { + 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. - let held = writes.holdNext(); - await akaMark().tap(); - await held.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); - 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'); + await ipponButton(page, 'aka', 'M').tap(); + await settle(page); + // Remove, then the same mark back while the removal is out. + let held = writes.holdNext(); + await akaMark().tap(); + await held.sent; + await ipponButton(page, 'aka', 'M').tap(); + held.release(); + // The removal's response reached the page before the tap-back's own save went out. + await writes.answeredBeforeNextWrite(held); + 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); - held = writes.holdNext(); - await ipponButton(page, 'aka', 'M').tap(); - await held.sent; - await akaMark().tap(); - held.release(); - 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 writes.stop(); + // The reverse: take it off, add it, and take it off again while the add is out. + await akaMark().tap(); + await settle(page); + held = writes.holdNext(); + await ipponButton(page, 'aka', 'M').tap(); + await held.sent; + await akaMark().tap(); + held.release(); + await writes.answeredBeforeNextWrite(held); + 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); + } finally { + await writes.stop(); + } }); }); diff --git a/web-mobile/js/__tests__/render/engi_editor_tap_back_kept.render.test.jsx b/web-mobile/js/__tests__/render/engi_editor_tap_back_kept.render.test.jsx index 0559886a2..c90f7ab03 100644 --- a/web-mobile/js/__tests__/render/engi_editor_tap_back_kept.render.test.jsx +++ b/web-mobile/js/__tests__/render/engi_editor_tap_back_kept.render.test.jsx @@ -24,6 +24,9 @@ const engi = (overrides = {}) => ({ ...overrides, }); +// A save the server took: the answer a landed write comes back with. +const LANDED = { id: 'm1', status: 'running' }; + const el = (match, onSubmit) => ( ); @@ -34,7 +37,7 @@ const wait = async (ms) => { await act(async () => { vi.advanceTimersByTime(ms); describe('a flag tap made while the previous save is out is never wiped by that save\'s echo (bc-evts)', () => { it('add a flag, take it off before the add\'s echo: the count stays at the server\'s and the removal is saved', async () => { - const onSubmit = vi.fn().mockResolvedValue(undefined); + const onSubmit = vi.fn().mockResolvedValue(LANDED); let r; await act(async () => { r = render(el(engi(), onSubmit)); }); await tap(r, 'engi-aka-inc'); @@ -56,7 +59,7 @@ describe('a flag tap made while the previous save is out is never wiped by that }); it('take a flag off, tap it back before the removal\'s echo: the flag stays and is saved', async () => { - const onSubmit = vi.fn().mockResolvedValue(undefined); + const onSubmit = vi.fn().mockResolvedValue(LANDED); let r; await act(async () => { r = render(el(engi({ flagsA: 1 }), onSubmit)); }); await tap(r, 'engi-aka-dec'); @@ -78,7 +81,7 @@ describe('a flag tap made while the previous save is out is never wiped by that }); it('a change made elsewhere after the last tap is still followed', async () => { - const onSubmit = vi.fn().mockResolvedValue(undefined); + const onSubmit = vi.fn().mockResolvedValue(LANDED); let r; await act(async () => { r = render(el(engi(), onSubmit)); }); await tap(r, 'engi-aka-inc'); @@ -96,7 +99,7 @@ describe('a flag tap made while the previous save is out is never wiped by that // 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 is the match as it stands. - const onSubmit = vi.fn().mockResolvedValue({ id: 'm1', status: 'running' }); + const onSubmit = vi.fn().mockResolvedValue(LANDED); let r; await act(async () => { r = render(el(engi(), onSubmit)); }); await tap(r, 'engi-aka-inc'); @@ -107,4 +110,73 @@ describe('a flag tap made while the previous save is out is never wiped by that await act(async () => { r.rerender(el({ ...echo, flagsB: 1 }, onSubmit)); }); expect(count(r, 'shiro'), 'the board took the Shiro flag stamped just before the tap').toBe('1'); }); + + it('a save built before the latest tap clears nothing when it lands', async () => { + let landFirst; + const first = new Promise((resolve) => { landFirst = resolve; }); + const onSubmit = vi.fn().mockReturnValueOnce(first).mockResolvedValue(LANDED); + let r; + await act(async () => { r = render(el(engi(), onSubmit)); }); + await tap(r, 'engi-aka-inc'); + await wait(350); // the add's save is out and unanswered + expect(onSubmit.mock.calls.at(-1)?.[0]?.flagsA, 'precondition: the add was saved').toBe(1); + + vi.setSystemTime(NOW + 400); + await tap(r, 'engi-aka-dec'); // a tap the save in flight was not built with + await act(async () => { landFirst(LANDED); }); + + // The add's echo, stamped before the removal. + await act(async () => { r.rerender(el(engi({ flagsA: 1, modifiedAt: NOW + 50 }), onSubmit)); }); + expect(count(r, 'aka'), 'the add landing does not release the board from the removal it never carried').toBe('0'); + + await wait(350); // the removal's own autosave + expect(onSubmit.mock.calls.at(-1)?.[0]?.flagsA, 'the removal is sent').toBe(0); + }); + + it.each([ + ['queued', { queued: true }], + ['refused', { applied: false, reason: 'superseded' }], + ])('a save the server did not take (%s) keeps the board against an older copy', async (_name, answer) => { + const onSubmit = vi.fn().mockResolvedValue(answer); + let r; + await act(async () => { r = render(el(engi(), onSubmit)); }); + await tap(r, 'engi-aka-inc'); + await wait(350); + vi.setSystemTime(NOW + 400); + await tap(r, 'engi-aka-dec'); + await wait(350); + expect(onSubmit.mock.calls.length, 'precondition: both taps were saved').toBe(2); + + await act(async () => { r.rerender(el(engi({ flagsA: 1, modifiedAt: NOW + 50 }), onSubmit)); }); + expect(count(r, 'aka'), 'a write that did not land released nothing').toBe('0'); + }); + + it('a scheduled match is not autosaved, so a net-zero fiddle holds nothing against another device', async () => { + const onSubmit = vi.fn().mockResolvedValue(LANDED); + let r; + await act(async () => { r = render(el(engi({ status: 'scheduled' }), onSubmit)); }); + await tap(r, 'engi-aka-inc'); + await tap(r, 'engi-aka-dec'); + await wait(350); + expect(onSubmit, 'precondition: nothing was autosaved').not.toHaveBeenCalled(); + + // Another device's flag, stamped before the taps. + await act(async () => { r.rerender(el(engi({ status: 'scheduled', flagsB: 1, modifiedAt: NOW - 1000 }), onSubmit)); }); + expect(count(r, 'shiro'), 'the copy from the other device is followed').toBe('1'); + }); + + it.each([ + ['one millisecond before the tap\'s stamp is held', -1, '0'], + ['at the tap\'s stamp is followed', 0, '1'], + ])('a copy stamped %s', async (_name, offset, shiro) => { + const onSubmit = vi.fn().mockResolvedValue({ queued: true }); + let r; + await act(async () => { r = render(el(engi(), onSubmit)); }); + await tap(r, 'engi-aka-inc'); + await wait(350); + vi.setSystemTime(NOW + 400); + await tap(r, 'engi-aka-dec'); // the board is back at the server's value; the stamp is NOW + 400 + await act(async () => { r.rerender(el(engi({ flagsB: 1, modifiedAt: NOW + 400 + offset }), onSubmit)); }); + expect(count(r, 'shiro')).toBe(shiro); + }); }); 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 b6fdc783c..9bb27a039 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 @@ -54,6 +54,9 @@ const bout = (overrides = {}) => ({ ...overrides, }); +// A save the server took: the answer a landed write comes back with. +const LANDED = { id: 'm1', status: 'running' }; + const el = (match, onSubmit) => ( ); @@ -67,7 +70,7 @@ 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 onSubmit = vi.fn().mockResolvedValue(LANDED); const stored = bout({ ipponsA: ['M'] }); let r; await act(async () => { r = render(el(stored, onSubmit)); }); @@ -95,7 +98,7 @@ describe('a tap made while the previous save is out is never wiped by that save\ }); it('add a mark, take it off before the add\'s echo: the board stays empty', async () => { - const onSubmit = vi.fn().mockResolvedValue(undefined); + const onSubmit = vi.fn().mockResolvedValue(LANDED); let r; await act(async () => { r = render(el(bout(), onSubmit)); }); const aka = r.container.querySelector('.sb-side--aka'); @@ -114,7 +117,7 @@ describe('a tap made while the previous save is out is never wiped by that save\ }); it('a change made elsewhere after the last tap is still followed', async () => { - const onSubmit = vi.fn().mockResolvedValue(undefined); + const onSubmit = vi.fn().mockResolvedValue(LANDED); let r; await act(async () => { r = render(el(bout(), onSubmit)); }); await tap(menButton(r.container.querySelector('.sb-side--aka'))); @@ -137,7 +140,7 @@ describe('a tap made while the previous save is out is never wiped by that save\ // 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' }); + const onSubmit = vi.fn().mockResolvedValue(LANDED); let r; await act(async () => { r = render(el(bout(), onSubmit)); }); await tap(menButton(r.container.querySelector('.sb-side--aka'))); @@ -153,4 +156,69 @@ describe('a tap made while the previous save is out is never wiped by that save\ 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 }); }); + + it('a save built before the latest tap clears nothing when it lands', async () => { + let landFirst; + const first = new Promise((resolve) => { landFirst = resolve; }); + const onSubmit = vi.fn().mockReturnValueOnce(first).mockResolvedValue(LANDED); + let r; + await act(async () => { r = render(el(bout(), onSubmit)); }); + await tap(menButton(r.container.querySelector('.sb-side--aka'))); + await wait(350); // the add's save is out and unanswered + const added = onSubmit.mock.calls.at(-1)[0]; + expect(added.ipponsA, 'precondition: the add was saved').toEqual(['M']); + + vi.setSystemTime(NOW + 400); + const [label] = marks(r.container); + await tap(r.container.querySelector(`[aria-label="${label}"]`)); // a tap the save in flight was not built with + expect(marks(r.container)).toHaveLength(0); + await act(async () => { landFirst(LANDED); }); + + await act(async () => { + r.rerender(el(bout({ ipponsA: added.ipponsA, ipponsB: added.ipponsB, modifiedAt: NOW + 50 }), onSubmit)); + }); + expect(marks(r.container), 'the add landing does not release the board from the removal it never carried').toHaveLength(0); + + await wait(350); // the removal's own autosave + expect(onSubmit.mock.calls.at(-1)[0].ipponsA, 'the removal is sent').toEqual([]); + }); + + it.each([ + ['queued', { queued: true }], + ['refused', { applied: false, reason: 'superseded' }], + ])('a save the server did not take (%s) keeps the board against an older copy', async (_name, answer) => { + const onSubmit = vi.fn().mockResolvedValue(answer); + 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]; + vi.setSystemTime(NOW + 400); + const [label] = marks(r.container); + await tap(r.container.querySelector(`[aria-label="${label}"]`)); + await wait(350); + expect(onSubmit.mock.calls.length, 'precondition: both taps were saved').toBe(2); + + await act(async () => { + r.rerender(el(bout({ ipponsA: added.ipponsA, ipponsB: added.ipponsB, modifiedAt: NOW + 50 }), onSubmit)); + }); + expect(marks(r.container), 'a write that did not land released nothing').toHaveLength(0); + }); + + it('a scheduled match is not autosaved, so a net-zero fiddle holds nothing against another device', async () => { + const onSubmit = vi.fn().mockResolvedValue(LANDED); + let r; + await act(async () => { r = render(el(bout({ status: 'scheduled' }), onSubmit)); }); + await tap(menButton(r.container.querySelector('.sb-side--aka'))); + const [label] = marks(r.container); + expect(label, 'precondition: the scheduled board takes the tap').toBeTruthy(); + await tap(r.container.querySelector(`[aria-label="${label}"]`)); + expect(marks(r.container)).toHaveLength(0); + await wait(350); + expect(onSubmit, 'precondition: nothing was autosaved').not.toHaveBeenCalled(); + + // Another device's K, stamped before the taps. + await act(async () => { r.rerender(el(bout({ status: 'scheduled', ipponsB: ['K'], modifiedAt: NOW - 1000 }), onSubmit)); }); + expect(marks(r.container).some((l) => l.endsWith('remove K')), 'the copy from the other device is followed').toBe(true); + }); }); diff --git a/web-mobile/js/admin_scoring_engi.jsx b/web-mobile/js/admin_scoring_engi.jsx index fa78b2c84..2899ad53c 100644 --- a/web-mobile/js/admin_scoring_engi.jsx +++ b/web-mobile/js/admin_scoring_engi.jsx @@ -151,7 +151,8 @@ export function EngiScoreEditorModal({ match, onClose, onSubmit, onSubmitAndNext // untouched, and that save's echo then wipes it (bc-ltap, bc-kclr; the // individual editor's boardEditedAtRef explains the rule at length). The seq // counts edits so landedAutosave clears the stamp only for a save built after - // the latest one. + // the latest one. The stamp is taken only while the match is running: only then + // is the edit autosaved, and only a landed autosave clears the stamp. const flagsEditedAtRef = useRefE(undefined); const flagsEditSeqRef = useRefE(0); autosaveOnSubmitRef.current = landedAutosave(onSubmit, flagsEditSeqRef, (seq) => { @@ -166,8 +167,10 @@ export function EngiScoreEditorModal({ match, onClose, onSubmit, onSubmitAndNext const next = clamp(n); if (next === (side === "a" ? flagsA : flagsB)) return; (side === "a" ? setFlagsA : setFlagsB)(next); - flagsEditedAtRef.current = serverNowMs(); - flagsEditSeqRef.current += 1; + if (m.status === "running") { + flagsEditedAtRef.current = serverNowMs(); + flagsEditSeqRef.current += 1; + } markDirty(); }; diff --git a/web-mobile/js/admin_scoring_individual.jsx b/web-mobile/js/admin_scoring_individual.jsx index c79352c25..60fd44c27 100644 --- a/web-mobile/js/admin_scoring_individual.jsx +++ b/web-mobile/js/admin_scoring_individual.jsx @@ -329,11 +329,15 @@ export function ScoreEditorModal({ match, onClose, onSubmit, onSubmitAndNext, on // 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. + // It is taken only while the match is running: only then is the edit autosaved, + // and a stamp no save can clear would hold the board against other devices. const boardEditedAtRef = useRefA(undefined); const boardEditSeqRef = useRefA(0); const markScoringDirty = () => { - boardEditedAtRef.current = serverNowMs(); - boardEditSeqRef.current += 1; + if (m.status === "running") { + boardEditedAtRef.current = serverNowMs(); + boardEditSeqRef.current += 1; + } markRunningWrite(); }; // The autosave's submit: the host's onSubmit, and on a landed answer the From b886399021ca5c36e29e9cd3a140a72eb835507e Mon Sep 17 00:00:00 2001 From: Ricardo Oliveira Date: Tue, 13 Oct 2026 18:31:53 +0100 Subject: [PATCH 05/31] fix(bc-evts): clear the edit stamp when the match leaves running; review round 2 A finish or send back made elsewhere inside the autosave window drops the pending write with no answer, so no landed save cleared the stamp; both editors now clear it once the match is no longer running. The rules note scopes the running-only stamp to the individual and engi editors and names the team sheet's stamps as the open question. Harness: a page close is detected from its close event as well; held-write's stop() only cleans up and assertNoSafetyRelease() runs after a passing body, so it never hides the body's own failure. Co-Authored-By: Claude Opus 5.5 --- .claude/rules/match-results-and-marks.md | 2 +- scripts/e2e/README.md | 9 ++++++++- scripts/e2e/fixtures/held-write.mjs | 20 ++++++++++++------- scripts/e2e/fixtures/test.mjs | 9 +++++++-- scripts/e2e/journeys/engi.spec.mjs | 1 + .../knockout-mixed-individual.spec.mjs | 1 + .../engi_editor_tap_back_kept.render.test.jsx | 18 +++++++++++++++++ ...idual_editor_tap_back_kept.render.test.jsx | 19 ++++++++++++++++++ web-mobile/js/admin_scoring_engi.jsx | 3 +++ web-mobile/js/admin_scoring_individual.jsx | 3 +++ 10 files changed, 74 insertions(+), 11 deletions(-) diff --git a/.claude/rules/match-results-and-marks.md b/.claude/rules/match-results-and-marks.md index d0e31aa38..fec35a561 100644 --- a/.claude/rules/match-results-and-marks.md +++ b/.claude/rules/match-results-and-marks.md @@ -93,7 +93,7 @@ It projects `ModifiedAt` and `GroupStamps` too (bc-lww1, bc-mrgc). `mergeMatchWr Because an editor can now never rule on something it did not display, an explicit `false` is always the operator's decision and always reaches the wire as what it now IS, a markless scoreline: `daihyosenEnchoFields` still states the local boolean unconditionally, and the serializer chokepoint converts it (true → mark placed on the winner's side, false → arrays stay markless; `realIppons` strips echoed marks first so placement never doubles). Two earlier designs are recorded in `web-mobile/js/__tests__/render/individual_editor_live_hantei.render.test.jsx` and must not be reintroduced: recomputing the baseline from the live prop while freezing the local armed state (which erased the verdict on the next write), and freezing at mount with a `hanteiKnown` silence guard (which stopped the erase by accepting the divergence; the guard was deleted with it). -**The edit-time adopt rule.** A score editor holds its board against a server copy stamped before the operator's last unsent edit, because comparing values alone reads a tap taken back during a save as untouched, and that save's echo then wipes it. Its three owners are the team sheet's `rowTakenFromServer`/`repRowTakes` (bc-kclr), the individual editor's `adoptServerScore` (bc-ltap) and the engi editor's adopt (`flagsEditedAtRef`, bc-evts). `landedAutosave` (admin_scoring_autosave.jsx) clears a stamp once a save built after the last edit has landed, so every later server copy is followed. The stamp is taken only while the match is running, because only then is the edit autosaved: on a scheduled match or a correction of a completed one nothing is sent, no landed save would ever clear the stamp, and a net-zero fiddle would make the editor refuse another device's copy it would otherwise have adopted. +**The edit-time adopt rule.** A score editor holds its board against a server copy stamped before the operator's last unsent edit, because comparing values alone reads a tap taken back during a save as untouched, and that save's echo then wipes it. Its three owners are the team sheet's `rowTakenFromServer`/`repRowTakes` (bc-kclr), the individual editor's `adoptServerScore` (bc-ltap) and the engi editor's adopt (`flagsEditedAtRef`, bc-evts). `landedAutosave` (admin_scoring_autosave.jsx) clears a stamp once a save built after the last edit has landed, so every later server copy is followed. In the individual and engi editors the stamp is taken only while the match is running, because only then is the edit autosaved: on a scheduled match or a correction of a completed one nothing is sent, no landed save would ever clear the stamp, and a net-zero fiddle would make the editor refuse another device's copy it would otherwise have adopted. Both also clear it when the match leaves running (an effect keyed on the status): another device finishing the match or sending it back to the queue inside the debounce makes `fireRunningWrite` drop the write with no callback, so no landed save would clear it. The team sheet's row and pick stamps (`updateSub`, admin_scoring_team.jsx) are still taken whatever the status, an open question for the operator. ## Silence on the wire diff --git a/scripts/e2e/README.md b/scripts/e2e/README.md index b2c193aad..55d604842 100644 --- a/scripts/e2e/README.md +++ b/scripts/e2e/README.md @@ -229,7 +229,14 @@ itself. `fixtures/held-write.mjs` (`holdScoreWrites(page)`) intercepts the match score PUTs: `holdNext()` holds the next one, its `sent` promise resolves once that PUT is out (so the autosave debounce has run), and `release()` lets it through after the journey has made the next tap, as a slow uplink would. `bodies` lists -every score PUT the page sent. The bc-ltap step of +every score PUT the page sent. `answeredBeforeNextWrite(held)` waits for the held +write's response and throws unless it reached the page before the write that +followed it was sent, so a journey cannot pass without exercising the race. A hold +nobody releases is let go after a safety timeout (`safetyMs`, 10 s by default); +`stop()` only cleans up (release, unroute, remove the listener) and is called in a +`finally`, and `assertNoSafetyRelease()` throws when the safety timeout had to +fire, so a journey calls it after its body succeeded, outside the `finally`, where +a throw would hide the body's own assertion error. The bc-ltap step of `knockout-mixed-individual.spec.mjs` (individual editor) and the bc-evts step of `engi.spec.mjs` (engi editor) use it to guard a flag or mark taken back while its add is still saving. diff --git a/scripts/e2e/fixtures/held-write.mjs b/scripts/e2e/fixtures/held-write.mjs index f6c5bf50f..21d136cec 100644 --- a/scripts/e2e/fixtures/held-write.mjs +++ b/scripts/e2e/fixtures/held-write.mjs @@ -3,8 +3,8 @@ // of the save going out, so a tap made "while the save is out" never lands in // that window by itself; the write is held until the test releases it, and it // does so only after the next tap has been dispatched, as a slow uplink would -// hold it. A safety timeout lets go of a hold nobody released, and stop() -// throws when one had to. +// hold it. A safety timeout lets go of a hold nobody released, and +// assertNoSafetyRelease() throws when one had to. const SCORE_ROUTE = '**/api/competitions/*/matches/*/score'; const isScorePut = (r) => r.method() === 'PUT' && /\/score$/.test(r.url()); @@ -19,9 +19,12 @@ const isScorePut = (r) => r.method() === 'PUT' && /\/score$/.test(r.url()); // (the echo then met a board whose next edit was still unsent, // which is the race a journey holds the write for) // bodies the JSON body of every score PUT the page sent, in send order -// stop() release any hold still pending, remove the route and the -// listener (call it in a finally), and throw when the safety -// timeout had to release a hold +// stop() release any hold still pending and remove the route and the +// listener; cleanup only, never throws (call it in a finally, +// where a throw would replace the body's real assertion error) +// assertNoSafetyRelease() +// throws when the safety timeout had to release a hold; call it +// AFTER the body succeeded, outside the finally export async function holdScoreWrites(page, { safetyMs = 10_000 } = {}) { let hold = null; // armed by holdNext(), taken by the next PUT let safetyFired = 0; @@ -81,11 +84,14 @@ export async function holdScoreWrites(page, { safetyMs = 10_000 } = {}) { } }, async stop() { - // Release first: a PUT still blocked in the route handler would make the - // unroute wait for it. + // Release first: unrouting does not wait for a handler (Playwright + // 1.63.0), so a PUT still blocked in it would stay held until the safety + // timer instead of going through now. for (const h of holds) h.release(); page.off('response', onResponse); await page.unroute(SCORE_ROUTE, handler); + }, + assertNoSafetyRelease() { if (safetyFired) throw new Error(`held score write: the ${safetyMs} ms safety timeout released ${safetyFired} hold(s) nobody released`); }, }; diff --git a/scripts/e2e/fixtures/test.mjs b/scripts/e2e/fixtures/test.mjs index ebbbf4ca6..c565330d0 100644 --- a/scripts/e2e/fixtures/test.mjs +++ b/scripts/e2e/fixtures/test.mjs @@ -111,6 +111,10 @@ function consoleCollector() { seen.add(page); const pageUrl = () => { try { return page.url(); } catch { return null; } }; const ctx = page.context(); + // page.isClosed() can still read false while the close is in progress, so + // the close event is recorded too. + let closed = false; + page.on('close', () => { closed = true; }); page.on('console', (msg) => { const type = msg.type(); if (type !== 'error' && type !== 'warning') return; @@ -134,8 +138,9 @@ function consoleCollector() { const entry = { url: res.url(), status: res.status(), method: res.request().method(), postData: res.request().postData(), bodyMissing: null, at }; let timer; entry.body = Promise.race([ - res.text().then((t) => t.slice(0, 1000), () => { - entry.bodyMissing = page.isClosed() ? 'page-closed' : 'read-failed'; + res.text().then((t) => t.slice(0, 1000), (err) => { + const gone = closed || page.isClosed() || /(?:target|page|context|browser).*closed/i.test(String(err && err.message)); + entry.bodyMissing = gone ? 'page-closed' : 'read-failed'; return null; }), new Promise((done) => { timer = setTimeout(() => { entry.bodyMissing = 'timeout'; done(null); }, BODY_WAIT_MS); }), diff --git a/scripts/e2e/journeys/engi.spec.mjs b/scripts/e2e/journeys/engi.spec.mjs index 5974fbeda..2872706e9 100644 --- a/scripts/e2e/journeys/engi.spec.mjs +++ b/scripts/e2e/journeys/engi.spec.mjs @@ -158,5 +158,6 @@ test.describe('engi', () => { } finally { await writes.stop(); } + writes.assertNoSafetyRelease(); // after the body succeeded: a throw in the finally would hide its error }); }); diff --git a/scripts/e2e/journeys/knockout-mixed-individual.spec.mjs b/scripts/e2e/journeys/knockout-mixed-individual.spec.mjs index d8e8bbb25..1c78d14ef 100644 --- a/scripts/e2e/journeys/knockout-mixed-individual.spec.mjs +++ b/scripts/e2e/journeys/knockout-mixed-individual.spec.mjs @@ -1304,5 +1304,6 @@ test.describe('knockout-mixed-individual bead guards', () => { } finally { await writes.stop(); } + writes.assertNoSafetyRelease(); // after the body succeeded: a throw in the finally would hide its error }); }); diff --git a/web-mobile/js/__tests__/render/engi_editor_tap_back_kept.render.test.jsx b/web-mobile/js/__tests__/render/engi_editor_tap_back_kept.render.test.jsx index c90f7ab03..f156480dd 100644 --- a/web-mobile/js/__tests__/render/engi_editor_tap_back_kept.render.test.jsx +++ b/web-mobile/js/__tests__/render/engi_editor_tap_back_kept.render.test.jsx @@ -180,3 +180,21 @@ describe('a flag tap made while the previous save is out is never wiped by that expect(count(r, 'shiro')).toBe(shiro); }); }); + +describe('a stamp left by a write the match\'s leaving running dropped is cleared (bc-evts)', () => { + it('sent back to the queue inside the debounce: a later scheduled copy stamped before the tap is adopted', async () => { + const onSubmit = vi.fn().mockResolvedValue(LANDED); + let r; + await act(async () => { r = render(el(engi(), onSubmit)); }); + await tap(r, 'engi-aka-inc'); + await tap(r, 'engi-aka-dec'); // net zero: the board reads as untouched again + // Another device sends the match back to the queue, same score, inside the + // debounce: the autosave is dropped at fire time, so no landed save clears the stamp. + await act(async () => { r.rerender(el(engi({ status: 'scheduled' }), onSubmit)); }); + await wait(350); + expect(onSubmit, 'the autosave was dropped').not.toHaveBeenCalled(); + // A later copy, stamped before the tap but after the match left running. + await act(async () => { r.rerender(el(engi({ status: 'scheduled', flagsB: 1, modifiedAt: NOW - 4000 }), onSubmit)); }); + expect(count(r, 'shiro'), 'the copy is adopted, not held against a stamp nothing can clear').toBe('1'); + }); +}); 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 9bb27a039..46984fc18 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 @@ -222,3 +222,22 @@ 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 copy from the other device is followed').toBe(true); }); }); + +describe('a stamp left by a write the match\'s leaving running dropped is cleared (bc-ltap)', () => { + it('sent back to the queue inside the debounce: a later scheduled copy stamped before the tap is adopted', async () => { + const onSubmit = vi.fn().mockResolvedValue(LANDED); + let r; + await act(async () => { r = render(el(bout(), onSubmit)); }); + await tap(menButton(r.container.querySelector('.sb-side--aka'))); + await tap(r.container.querySelector(`[aria-label="${marks(r.container)[0]}"]`)); // net zero + expect(marks(r.container), 'precondition: the board is empty again').toHaveLength(0); + // Another device sends the match back to the queue, same score, inside the + // debounce: the autosave is dropped at fire time, so no landed save clears the stamp. + await act(async () => { r.rerender(el(bout({ status: 'scheduled' }), onSubmit)); }); + await wait(350); + expect(onSubmit, 'the autosave was dropped').not.toHaveBeenCalled(); + // A later copy, stamped before the tap but after the match left running. + await act(async () => { r.rerender(el(bout({ status: 'scheduled', ipponsB: ['M'], modifiedAt: NOW - 4000 }), onSubmit)); }); + expect(marks(r.container), 'the copy is adopted, not held against a stamp nothing can clear').toHaveLength(1); + }); +}); diff --git a/web-mobile/js/admin_scoring_engi.jsx b/web-mobile/js/admin_scoring_engi.jsx index 2899ad53c..e7b3cd6ff 100644 --- a/web-mobile/js/admin_scoring_engi.jsx +++ b/web-mobile/js/admin_scoring_engi.jsx @@ -158,6 +158,9 @@ export function EngiScoreEditorModal({ match, onClose, onSubmit, onSubmitAndNext autosaveOnSubmitRef.current = landedAutosave(onSubmit, flagsEditSeqRef, (seq) => { if (flagsEditSeqRef.current === seq) flagsEditedAtRef.current = undefined; }); + // The match left running (finished, or sent back to the queue, elsewhere) inside the + // debounce: the write is dropped with no callback, so no landed save clears the stamp. + useEffectE(() => { if (m.status !== "running") flagsEditedAtRef.current = undefined; }, [m.status]); // An operator change to either count: the value, then the save it schedules. // A count adopted from the server does not come through here. A key pressed // at a bound (a/s at MAX_FLAGS, Backspace at 0; the buttons are disabled diff --git a/web-mobile/js/admin_scoring_individual.jsx b/web-mobile/js/admin_scoring_individual.jsx index 60fd44c27..ec2f5ca93 100644 --- a/web-mobile/js/admin_scoring_individual.jsx +++ b/web-mobile/js/admin_scoring_individual.jsx @@ -345,6 +345,9 @@ export function ScoreEditorModal({ match, onClose, onSubmit, onSubmitAndNext, on const landedBoardSave = (submit) => landedAutosave(submit, boardEditSeqRef, (seq) => { if (boardEditSeqRef.current === seq) boardEditedAtRef.current = undefined; }); + // The match left running (finished, or sent back to the queue, elsewhere) inside the + // debounce: the write is dropped with no callback, so no landed save clears the stamp. + useEffectA(() => { if (m.status !== "running") boardEditedAtRef.current = undefined; }, [m.status]); useEffectA(() => { if (!m.compId) return; From 4691ceace2ff783d519f11dbf5545393c3780ce1 Mon Sep 17 00:00:00 2001 From: Ricardo Oliveira Date: Tue, 13 Oct 2026 18:46:02 +0100 Subject: [PATCH 06/31] test(bc-e2rv): independent review fixes for the held-write guard and engi journey The held-write guard now waits for the follow-up write and fails when it is never sent, clears its timers, and forgets a pending hold on stop. The engi journey waits for the running editor or Up next's Start, whichever shows, and checks every flag tap landed. Comment and rules wording corrected. Co-Authored-By: Claude Opus 5.5 --- .claude/rules/match-results-and-marks.md | 2 +- scripts/e2e/README.md | 5 ++-- scripts/e2e/fixtures/engi.mjs | 14 +++++++-- scripts/e2e/fixtures/held-write.mjs | 30 +++++++++++++------ scripts/e2e/journeys/engi.spec.mjs | 11 +++---- .../engi_editor_tap_back_kept.render.test.jsx | 2 +- ...idual_editor_tap_back_kept.render.test.jsx | 2 +- 7 files changed, 44 insertions(+), 22 deletions(-) diff --git a/.claude/rules/match-results-and-marks.md b/.claude/rules/match-results-and-marks.md index fec35a561..c20e9c99b 100644 --- a/.claude/rules/match-results-and-marks.md +++ b/.claude/rules/match-results-and-marks.md @@ -93,7 +93,7 @@ It projects `ModifiedAt` and `GroupStamps` too (bc-lww1, bc-mrgc). `mergeMatchWr Because an editor can now never rule on something it did not display, an explicit `false` is always the operator's decision and always reaches the wire as what it now IS, a markless scoreline: `daihyosenEnchoFields` still states the local boolean unconditionally, and the serializer chokepoint converts it (true → mark placed on the winner's side, false → arrays stay markless; `realIppons` strips echoed marks first so placement never doubles). Two earlier designs are recorded in `web-mobile/js/__tests__/render/individual_editor_live_hantei.render.test.jsx` and must not be reintroduced: recomputing the baseline from the live prop while freezing the local armed state (which erased the verdict on the next write), and freezing at mount with a `hanteiKnown` silence guard (which stopped the erase by accepting the divergence; the guard was deleted with it). -**The edit-time adopt rule.** A score editor holds its board against a server copy stamped before the operator's last unsent edit, because comparing values alone reads a tap taken back during a save as untouched, and that save's echo then wipes it. Its three owners are the team sheet's `rowTakenFromServer`/`repRowTakes` (bc-kclr), the individual editor's `adoptServerScore` (bc-ltap) and the engi editor's adopt (`flagsEditedAtRef`, bc-evts). `landedAutosave` (admin_scoring_autosave.jsx) clears a stamp once a save built after the last edit has landed, so every later server copy is followed. In the individual and engi editors the stamp is taken only while the match is running, because only then is the edit autosaved: on a scheduled match or a correction of a completed one nothing is sent, no landed save would ever clear the stamp, and a net-zero fiddle would make the editor refuse another device's copy it would otherwise have adopted. Both also clear it when the match leaves running (an effect keyed on the status): another device finishing the match or sending it back to the queue inside the debounce makes `fireRunningWrite` drop the write with no callback, so no landed save would clear it. The team sheet's row and pick stamps (`updateSub`, admin_scoring_team.jsx) are still taken whatever the status, an open question for the operator. +**The edit-time adopt rule.** A score editor holds its board against a server copy stamped before the operator's last unsent edit, because comparing values alone reads a tap taken back during a save as untouched, and that save's echo then wipes it. Its three owners are the team sheet's `rowTakenFromServer`/`repRowTakes` (bc-kclr), the individual editor's `adoptServerScore` (bc-ltap) and the engi editor's adopt (`flagsEditedAtRef`, bc-evts). `landedAutosave` (admin_scoring_autosave.jsx) clears a stamp once a save carrying the last edit has landed, so every later server copy is followed. In the individual and engi editors the stamp is taken only while the match is running, because only then is the edit autosaved: on a scheduled match or a correction of a completed one nothing is sent, no landed save would ever clear the stamp, and a net-zero fiddle would make the editor refuse another device's copy it would otherwise have adopted. Both also clear it when the match leaves running (an effect keyed on the status): another device finishing the match or sending it back to the queue inside the debounce makes `fireRunningWrite` drop the write with no callback, so no landed save would clear it. The team sheet's row and pick stamps (`updateSub`, admin_scoring_team.jsx) are still taken whatever the status, an open question for the operator. ## Silence on the wire diff --git a/scripts/e2e/README.md b/scripts/e2e/README.md index 55d604842..542dac502 100644 --- a/scripts/e2e/README.md +++ b/scripts/e2e/README.md @@ -230,8 +230,9 @@ score PUTs: `holdNext()` holds the next one, its `sent` promise resolves once th PUT is out (so the autosave debounce has run), and `release()` lets it through after the journey has made the next tap, as a slow uplink would. `bodies` lists every score PUT the page sent. `answeredBeforeNextWrite(held)` waits for the held -write's response and throws unless it reached the page before the write that -followed it was sent, so a journey cannot pass without exercising the race. A hold +write's response, then for the write that follows it (each up to `safetyMs`), and +throws if no follow-up write was ever sent or if it was sent before the response +reached the page, so a journey cannot pass without exercising the race. A hold nobody releases is let go after a safety timeout (`safetyMs`, 10 s by default); `stop()` only cleans up (release, unroute, remove the listener) and is called in a `finally`, and `assertNoSafetyRelease()` throws when the safety timeout had to diff --git a/scripts/e2e/fixtures/engi.mjs b/scripts/e2e/fixtures/engi.mjs index 6360a9cf0..99c6ac399 100644 --- a/scripts/e2e/fixtures/engi.mjs +++ b/scripts/e2e/fixtures/engi.mjs @@ -51,10 +51,18 @@ export async function startUpNextEngi(page) { return held; } -// Enter flags: tap + the given number of times on each side. +// Enter flags: tap + the given number of times on each side. Each tap is +// checked against the count it should show next, so a dropped tap fails at +// once. No dwell between taps: engi counters are deliberately not tap-guarded. export async function enterFlags(page, { aka = 0, shiro = 0 }, root = INLINE_EDITOR) { - for (let i = 0; i < aka; i += 1) await flagButton(page, 'aka', '+', root).tap(); - for (let i = 0; i < shiro; i += 1) await flagButton(page, 'shiro', '+', root).tap(); + for (const [side, taps] of [['aka', aka], ['shiro', shiro]]) { + const count = flagCount(page, side, root); + const start = taps > 0 ? Number(await count.innerText()) : 0; + for (let i = 0; i < taps; i += 1) { + await flagButton(page, side, '+', root).tap(); + await expect(count, `the ${side} count shows the tap`).toHaveText(String(start + i + 1)); + } + } await expect(flagCount(page, 'aka', root)).toHaveText(String(aka)); await expect(flagCount(page, 'shiro', root)).toHaveText(String(shiro)); } diff --git a/scripts/e2e/fixtures/held-write.mjs b/scripts/e2e/fixtures/held-write.mjs index 21d136cec..bf1ac1fd0 100644 --- a/scripts/e2e/fixtures/held-write.mjs +++ b/scripts/e2e/fixtures/held-write.mjs @@ -14,8 +14,9 @@ const isScorePut = (r) => r.method() === 'PUT' && /\/score$/.test(r.url()); // autosave debounce has run), `release()` lets it through and // `answered` resolves once its response has reached the page // answeredBeforeNextWrite(held) -// waits for the held write's response, then throws unless it -// reached the page BEFORE the write that followed it was sent +// waits for the held write's response and then for the write that +// follows it (up to safetyMs each), and throws unless that write +// was sent AFTER the response reached the page // (the echo then met a board whose next edit was still unsent, // which is the race a journey holds the write for) // bodies the JSON body of every score PUT the page sent, in send order @@ -74,19 +75,30 @@ export async function holdScoreWrites(page, { safetyMs = 10_000 } = {}) { }, async answeredBeforeNextWrite(held) { const h = held.hold; - await Promise.race([ - h.answered, - new Promise((_, reject) => setTimeout(() => reject(new Error('held score write: its response never reached the page')), safetyMs)), - ]); - const next = sends[h.sendIndex + 1]; - if (next && next.at <= h.answeredAt) { - throw new Error(`held score write: the next write was sent ${h.answeredAt - next.at} ms before the held one's response reached the page, so the race was not exercised`); + const deadline = Date.now() + safetyMs; + const timers = new Set(); + const wait = (ms) => new Promise((resolve) => { const t = setTimeout(resolve, ms); timers.add(t); }); + try { + let timedOut = false; + const expire = wait(safetyMs).then(() => { timedOut = true; }); + await Promise.race([h.answered, expire]); + if (timedOut) throw new Error('held score write: its response never reached the page'); + // The answer is in; the write that follows it may not have been sent yet. + while (!sends[h.sendIndex + 1] && Date.now() < deadline) await wait(25); + const next = sends[h.sendIndex + 1]; + if (!next) throw new Error('held score write: the follow-up write was never sent'); + if (next.at <= h.answeredAt) { + throw new Error(`held score write: the next write was sent ${h.answeredAt - next.at} ms before the held one's response reached the page, so the race was not exercised`); + } + } finally { + for (const t of timers) clearTimeout(t); } }, async stop() { // Release first: unrouting does not wait for a handler (Playwright // 1.63.0), so a PUT still blocked in it would stay held until the safety // timer instead of going through now. + hold = null; for (const h of holds) h.release(); page.off('response', onResponse); await page.unroute(SCORE_ROUTE, handler); diff --git a/scripts/e2e/journeys/engi.spec.mjs b/scripts/e2e/journeys/engi.spec.mjs index 2872706e9..5a30ebd4b 100644 --- a/scripts/e2e/journeys/engi.spec.mjs +++ b/scripts/e2e/journeys/engi.spec.mjs @@ -14,7 +14,7 @@ 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, completedRows } from '../fixtures/shiaijo.mjs'; +import { openShiaijo, completedRows, upNextCard } from '../fixtures/shiaijo.mjs'; import { openViewer } from '../fixtures/public.mjs'; import { holdScoreWrites } from '../fixtures/held-write.mjs'; import { @@ -106,10 +106,11 @@ test.describe('engi', () => { await login(page); await openShiaijo(page, 'A'); // The previous test's Finish + Start Next left the second semi-final running; - // wait for the editor to render, and start the Up next match only when it - // never does. - const running = await engiEditor(page).waitFor({ timeout: EXPECT_TIMEOUT_MS }).then(() => true, () => false); - if (!running) await startUpNextEngi(page); + // when this test runs alone the Up next card shows instead. Wait for either, + // and start the Up next match only when the editor is not the one showing. + const upNextStart = upNextCard(page).getByRole('button', { name: 'Start match' }); + await expect(engiEditor(page).or(upNextStart)).toBeVisible({ timeout: EXPECT_TIMEOUT_MS }); + if (!(await engiEditor(page).isVisible())) await startUpNextEngi(page); await expect(engiEditor(page)).toBeVisible(); await expect(flagCount(page, 'aka')).toHaveText('0'); const writes = await holdScoreWrites(page); diff --git a/web-mobile/js/__tests__/render/engi_editor_tap_back_kept.render.test.jsx b/web-mobile/js/__tests__/render/engi_editor_tap_back_kept.render.test.jsx index f156480dd..a2a69695a 100644 --- a/web-mobile/js/__tests__/render/engi_editor_tap_back_kept.render.test.jsx +++ b/web-mobile/js/__tests__/render/engi_editor_tap_back_kept.render.test.jsx @@ -193,7 +193,7 @@ describe('a stamp left by a write the match\'s leaving running dropped is cleare await act(async () => { r.rerender(el(engi({ status: 'scheduled' }), onSubmit)); }); await wait(350); expect(onSubmit, 'the autosave was dropped').not.toHaveBeenCalled(); - // A later copy, stamped before the tap but after the match left running. + // A later-arriving copy, stamped before the tap (and before the match left running). await act(async () => { r.rerender(el(engi({ status: 'scheduled', flagsB: 1, modifiedAt: NOW - 4000 }), onSubmit)); }); expect(count(r, 'shiro'), 'the copy is adopted, not held against a stamp nothing can clear').toBe('1'); }); 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 46984fc18..9cbe411b4 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 @@ -236,7 +236,7 @@ describe('a stamp left by a write the match\'s leaving running dropped is cleare await act(async () => { r.rerender(el(bout({ status: 'scheduled' }), onSubmit)); }); await wait(350); expect(onSubmit, 'the autosave was dropped').not.toHaveBeenCalled(); - // A later copy, stamped before the tap but after the match left running. + // A later-arriving copy, stamped before the tap (and before the match left running). await act(async () => { r.rerender(el(bout({ status: 'scheduled', ipponsB: ['M'], modifiedAt: NOW - 4000 }), onSubmit)); }); expect(marks(r.container), 'the copy is adopted, not held against a stamp nothing can clear').toHaveLength(1); }); From 30e0efe082d72867947ca67b0160e663b220b013 Mon Sep 17 00:00:00 2001 From: Ricardo Oliveira Date: Tue, 13 Oct 2026 18:49:06 +0100 Subject: [PATCH 07/31] test(bc-e2rv): follow-up write gets its own wait; enterFlags checks from the starting count Co-Authored-By: Claude Opus 5.5 --- scripts/e2e/fixtures/engi.mjs | 8 +++++--- scripts/e2e/fixtures/held-write.mjs | 2 +- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/scripts/e2e/fixtures/engi.mjs b/scripts/e2e/fixtures/engi.mjs index 99c6ac399..2083fccd2 100644 --- a/scripts/e2e/fixtures/engi.mjs +++ b/scripts/e2e/fixtures/engi.mjs @@ -55,16 +55,18 @@ export async function startUpNextEngi(page) { // checked against the count it should show next, so a dropped tap fails at // once. No dwell between taps: engi counters are deliberately not tap-guarded. export async function enterFlags(page, { aka = 0, shiro = 0 }, root = INLINE_EDITOR) { + const expected = {}; for (const [side, taps] of [['aka', aka], ['shiro', shiro]]) { const count = flagCount(page, side, root); - const start = taps > 0 ? Number(await count.innerText()) : 0; + const start = Number(await count.innerText()); + expected[side] = start + taps; for (let i = 0; i < taps; i += 1) { await flagButton(page, side, '+', root).tap(); await expect(count, `the ${side} count shows the tap`).toHaveText(String(start + i + 1)); } } - await expect(flagCount(page, 'aka', root)).toHaveText(String(aka)); - await expect(flagCount(page, 'shiro', root)).toHaveText(String(shiro)); + await expect(flagCount(page, 'aka', root)).toHaveText(String(expected.aka)); + await expect(flagCount(page, 'shiro', root)).toHaveText(String(expected.shiro)); } // Save the result in two taps ("Save result" / "Finish + Start Next →", then diff --git a/scripts/e2e/fixtures/held-write.mjs b/scripts/e2e/fixtures/held-write.mjs index bf1ac1fd0..4b7aafa38 100644 --- a/scripts/e2e/fixtures/held-write.mjs +++ b/scripts/e2e/fixtures/held-write.mjs @@ -75,7 +75,6 @@ export async function holdScoreWrites(page, { safetyMs = 10_000 } = {}) { }, async answeredBeforeNextWrite(held) { const h = held.hold; - const deadline = Date.now() + safetyMs; const timers = new Set(); const wait = (ms) => new Promise((resolve) => { const t = setTimeout(resolve, ms); timers.add(t); }); try { @@ -83,6 +82,7 @@ export async function holdScoreWrites(page, { safetyMs = 10_000 } = {}) { const expire = wait(safetyMs).then(() => { timedOut = true; }); await Promise.race([h.answered, expire]); if (timedOut) throw new Error('held score write: its response never reached the page'); + const deadline = Date.now() + safetyMs; // The answer is in; the write that follows it may not have been sent yet. while (!sends[h.sendIndex + 1] && Date.now() < deadline) await wait(25); const next = sends[h.sendIndex + 1]; From d67cb6450016347f8d763678980d4ad80d41c31b Mon Sep 17 00:00:00 2001 From: Ricardo Oliveira Date: Tue, 13 Oct 2026 23:32:34 +0100 Subject: [PATCH 08/31] fix(bc-evts): team sheet stamps only while running; a refused autosave takes the server's copy The team sheet now takes its row and pick stamps only while the match is running and clears them when it leaves running, as the individual and engi editors do. In all three editors a refused autosave (landedAutosave's new onRefused) clears the stamp and takes the server's current copy, since no broadcast follows a refusal to bring the board back; a queued answer keeps the stamp. Tests that answered an autosave with nothing now answer a landed one, since nothing handed back reads as a refusal. Co-Authored-By: Claude Opus 5.5 --- .claude/rules/match-results-and-marks.md | 2 +- ...utosave_every_scoring_edit.render.test.jsx | 2 +- ...hanged_groups_every_editor.render.test.jsx | 7 +- .../engi_editor_tap_back_kept.render.test.jsx | 9 +- ...idual_editor_tap_back_kept.render.test.jsx | 9 +- ...used_autosave_takes_server.render.test.jsx | 179 ++++++++++++++++++ .../render/ippon_double_tap.render.test.jsx | 2 +- ...leared_bout_reaches_server.render.test.jsx | 10 +- ...amp_cleared_on_landed_save.render.test.jsx | 4 +- ..._stamps_only_while_running.render.test.jsx | 168 ++++++++++++++++ ...team_fusensho_keeps_points.render.test.jsx | 2 +- web-mobile/js/admin_scoring_autosave.jsx | 14 +- web-mobile/js/admin_scoring_engi.jsx | 18 +- web-mobile/js/admin_scoring_individual.jsx | 7 + web-mobile/js/admin_scoring_team.jsx | 56 +++++- 15 files changed, 454 insertions(+), 35 deletions(-) create mode 100644 web-mobile/js/__tests__/render/individual_engi_refused_autosave_takes_server.render.test.jsx create mode 100644 web-mobile/js/__tests__/render/team_editor_stamps_only_while_running.render.test.jsx diff --git a/.claude/rules/match-results-and-marks.md b/.claude/rules/match-results-and-marks.md index c20e9c99b..054617d29 100644 --- a/.claude/rules/match-results-and-marks.md +++ b/.claude/rules/match-results-and-marks.md @@ -93,7 +93,7 @@ It projects `ModifiedAt` and `GroupStamps` too (bc-lww1, bc-mrgc). `mergeMatchWr Because an editor can now never rule on something it did not display, an explicit `false` is always the operator's decision and always reaches the wire as what it now IS, a markless scoreline: `daihyosenEnchoFields` still states the local boolean unconditionally, and the serializer chokepoint converts it (true → mark placed on the winner's side, false → arrays stay markless; `realIppons` strips echoed marks first so placement never doubles). Two earlier designs are recorded in `web-mobile/js/__tests__/render/individual_editor_live_hantei.render.test.jsx` and must not be reintroduced: recomputing the baseline from the live prop while freezing the local armed state (which erased the verdict on the next write), and freezing at mount with a `hanteiKnown` silence guard (which stopped the erase by accepting the divergence; the guard was deleted with it). -**The edit-time adopt rule.** A score editor holds its board against a server copy stamped before the operator's last unsent edit, because comparing values alone reads a tap taken back during a save as untouched, and that save's echo then wipes it. Its three owners are the team sheet's `rowTakenFromServer`/`repRowTakes` (bc-kclr), the individual editor's `adoptServerScore` (bc-ltap) and the engi editor's adopt (`flagsEditedAtRef`, bc-evts). `landedAutosave` (admin_scoring_autosave.jsx) clears a stamp once a save carrying the last edit has landed, so every later server copy is followed. In the individual and engi editors the stamp is taken only while the match is running, because only then is the edit autosaved: on a scheduled match or a correction of a completed one nothing is sent, no landed save would ever clear the stamp, and a net-zero fiddle would make the editor refuse another device's copy it would otherwise have adopted. Both also clear it when the match leaves running (an effect keyed on the status): another device finishing the match or sending it back to the queue inside the debounce makes `fireRunningWrite` drop the write with no callback, so no landed save would clear it. The team sheet's row and pick stamps (`updateSub`, admin_scoring_team.jsx) are still taken whatever the status, an open question for the operator. +**The edit-time adopt rule.** A score editor holds its board against a server copy stamped before the operator's last unsent edit, because comparing values alone reads a tap taken back during a save as untouched, and that save's echo then wipes it. Its three owners are the team sheet's `rowTakenFromServer`/`repRowTakes` (bc-kclr), the individual editor's `adoptServerScore` (bc-ltap) and the engi editor's adopt (`flagsEditedAtRef`, bc-evts). `landedAutosave` (admin_scoring_autosave.jsx) clears a stamp once a save carrying the last edit has landed, so every later server copy is followed. All three editors take the stamp only while the match is running, because only then is the edit autosaved: on a scheduled match or a correction of a completed one nothing is sent, no landed save would ever clear the stamp, and a net-zero fiddle would make the editor refuse another device's copy it would otherwise have adopted. All three also clear it when the match leaves running (an effect keyed on the status): another device finishing the match or sending it back to the queue inside the debounce makes `fireRunningWrite` drop the write with no callback, so no landed save would clear it. A REFUSED autosave (`writeWasRefused`: nothing handed back, or `applied:false`) stores and broadcasts nothing, so no later copy would re-run the adopt and the stamp would hold the operator's value against the server's for good. `landedAutosave` therefore takes an optional `onRefused(seq)`; each editor, when no edit was made since that save was built (the `seq` still matches), clears the stamp and takes the server's CURRENT copy through its own adopt path (the engi counts, the individual `applyServerScore`, the team sheet's rows and pick parts the save carried, composed by `composeRepRow`), reading the latest render's closure through a ref. An edit made since keeps the stamp and the save that carries it decides. A QUEUED answer calls neither callback and keeps the stamp: that write will land. Known limit: a save applied IN PART (`heldGroups`) counts as landed and clears the stamp, and no new broadcast follows when the server's value for a held group equals the copy the adopt already skipped; that stays the genuine disagreement `keepLocalEdits` leaves to the operator, with the held values in the match's history. ## Silence on the wire diff --git a/web-mobile/js/__tests__/render/autosave_every_scoring_edit.render.test.jsx b/web-mobile/js/__tests__/render/autosave_every_scoring_edit.render.test.jsx index e7486426d..02818c074 100644 --- a/web-mobile/js/__tests__/render/autosave_every_scoring_edit.render.test.jsx +++ b/web-mobile/js/__tests__/render/autosave_every_scoring_edit.render.test.jsx @@ -62,7 +62,7 @@ beforeEach(() => { window.API.recordScore.mockReset(); window.API.recordScore.mockImplementation((_c, _m, patch) => { writes.push(patch); - return Promise.resolve(undefined); + return Promise.resolve({ applied: true }); }); window.API.notePendingEdit.mockReset(); vi.useFakeTimers(); 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 f4dc3ba67..5381cf8a1 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 @@ -65,7 +65,7 @@ beforeEach(() => { window.API.recordScore.mockReset(); window.API.recordScore.mockImplementation((_c, _m, patch) => { writes.push(patch); - return Promise.resolve(undefined); + return Promise.resolve({ applied: true }); }); window.API.notePendingEdit.mockReset(); vi.useFakeTimers(); @@ -203,6 +203,9 @@ describe('individual editor', () => { // The agreement follows the server leaving the finished result every time, not once. const done = () => individual({ status: 'completed', ipponsA: ['M', 'K'], winner: { id: 'p1', name: 'Yamada' } }); const reopened = () => individual({ status: 'scheduled', ipponsA: ['M', 'K'] }); + // The host hands nothing back for a finish (a landed one closes the editor), so the + // editor stays armed for the second finish. + window.API.recordScore.mockImplementation((_c, _m, patch) => { writes.push(patch); return Promise.resolve(undefined); }); const { rerender } = await mount(done()); await act(async () => { rerender(editorFor(reopened())); }); await finish(); @@ -610,6 +613,8 @@ describe('an adopted value is agreed, never re-sent', () => { }); it('a bout whose point was struck and taken back here stays the other device\'s (held, not agreed)', async () => { + // The saves are queued (offline): none has landed, so the row's edit stamp stands. + window.API.recordScore.mockImplementation((_c, _m, patch) => { writes.push(patch); return Promise.resolve({ queued: true }); }); const rerenderWith = await mountTeam(team()); await pointerTap(boutBtn(0, 'shiro', 'K')); await settle(); diff --git a/web-mobile/js/__tests__/render/engi_editor_tap_back_kept.render.test.jsx b/web-mobile/js/__tests__/render/engi_editor_tap_back_kept.render.test.jsx index a2a69695a..a7aad6aa4 100644 --- a/web-mobile/js/__tests__/render/engi_editor_tap_back_kept.render.test.jsx +++ b/web-mobile/js/__tests__/render/engi_editor_tap_back_kept.render.test.jsx @@ -133,11 +133,10 @@ describe('a flag tap made while the previous save is out is never wiped by that expect(onSubmit.mock.calls.at(-1)?.[0]?.flagsA, 'the removal is sent').toBe(0); }); - it.each([ - ['queued', { queued: true }], - ['refused', { applied: false, reason: 'superseded' }], - ])('a save the server did not take (%s) keeps the board against an older copy', async (_name, answer) => { - const onSubmit = vi.fn().mockResolvedValue(answer); + // A REFUSED save is not here: it takes the server's copy instead + // (individual_engi_refused_autosave_takes_server.render.test.jsx). + it('a queued save keeps the board against an older copy', async () => { + const onSubmit = vi.fn().mockResolvedValue({ queued: true }); let r; await act(async () => { r = render(el(engi(), onSubmit)); }); await tap(r, 'engi-aka-inc'); 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 9cbe411b4..14240e1ac 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 @@ -183,11 +183,10 @@ describe('a tap made while the previous save is out is never wiped by that save\ expect(onSubmit.mock.calls.at(-1)[0].ipponsA, 'the removal is sent').toEqual([]); }); - it.each([ - ['queued', { queued: true }], - ['refused', { applied: false, reason: 'superseded' }], - ])('a save the server did not take (%s) keeps the board against an older copy', async (_name, answer) => { - const onSubmit = vi.fn().mockResolvedValue(answer); + // A REFUSED save is not here: it takes the server's copy instead + // (individual_engi_refused_autosave_takes_server.render.test.jsx). + it('a queued save keeps the board against an older copy', async () => { + const onSubmit = vi.fn().mockResolvedValue({ queued: true }); let r; await act(async () => { r = render(el(bout(), onSubmit)); }); await tap(menButton(r.container.querySelector('.sb-side--aka'))); diff --git a/web-mobile/js/__tests__/render/individual_engi_refused_autosave_takes_server.render.test.jsx b/web-mobile/js/__tests__/render/individual_engi_refused_autosave_takes_server.render.test.jsx new file mode 100644 index 000000000..9c633806a --- /dev/null +++ b/web-mobile/js/__tests__/render/individual_engi_refused_autosave_takes_server.render.test.jsx @@ -0,0 +1,179 @@ +// A REFUSED autosave leaves the editor's edit stamp set (individual board stamp, engi flags +// stamp), so the board kept the operator's value while the server and every other surface +// showed the server's, and nothing re-adopted: a refusal broadcasts nothing. The editor now +// takes the server's CURRENT copy when the refusal arrives (landedAutosave's onRefused) and +// clears the stamp; a QUEUED answer keeps the board and the stamp, since that write will +// land later. See "The edit-time adopt rule" in .claude/rules/match-results-and-marks.md. + +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'; +import { EngiScoreEditorModal } from '../../admin_scoring_engi.jsx'; + +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(); }); + +const REFUSED = { applied: false, reason: 'superseded' }; +const QUEUED = { queued: true }; + +const tap = async (node) => { await act(async () => { fireEvent.click(node); }); }; +const wait = async (ms) => { await act(async () => { vi.advanceTimersByTime(ms); }); }; +// The save's answer is read in a microtask after the timer fires. +const settle = async () => { for (let i = 0; i < 6; i++) await act(async () => { await Promise.resolve(); }); }; + +describe('individual editor: a refused autosave takes the server\'s copy', () => { + 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'); + + // Aka taps M; the server's copy (another device's Shiro M, stamped before the tap) is held + // by the stamp; the autosave then comes back with `answer`. + async function tapThenAnswer(answer) { + const onSubmit = vi.fn().mockResolvedValue(answer); + let r; + await act(async () => { r = render(el(bout(), onSubmit)); }); + await tap(menButton(r.container.querySelector('.sb-side--aka'))); + expect(marks(r.container), 'precondition: the tap shows').toHaveLength(1); + await act(async () => { r.rerender(el(bout({ ipponsB: ['M'], modifiedAt: NOW - 1 }), onSubmit)); }); + expect(marks(r.container).map((l) => l.split(' ')[0]), 'precondition: the older copy is held').toEqual(['Aka']); + await wait(350); + await settle(); + expect(onSubmit, 'precondition: the autosave went out').toHaveBeenCalled(); + return r; + } + + it('a superseded answer puts the server\'s mark on the board and drops the operator\'s', async () => { + const r = await tapThenAnswer(REFUSED); + expect(marks(r.container).map((l) => l.split(' ')[0])).toEqual(['Shiro']); + }); + + it('a nothing-handed-back answer (a thrown refusal the host reported) does the same', async () => { + const r = await tapThenAnswer(undefined); + expect(marks(r.container).map((l) => l.split(' ')[0])).toEqual(['Shiro']); + }); + + it('a queued answer keeps the board: that write will land', async () => { + const r = await tapThenAnswer(QUEUED); + expect(marks(r.container).map((l) => l.split(' ')[0])).toEqual(['Aka']); + }); + + it('a tap made after the refused save was built is kept', async () => { + let settleFirst; + const onSubmit = vi.fn().mockImplementationOnce(() => new Promise((res) => { settleFirst = res; })) + .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 act(async () => { r.rerender(el(bout({ ipponsB: ['M'], modifiedAt: NOW - 1 }), onSubmit)); }); + await wait(350); // the first save is out and unanswered + vi.setSystemTime(NOW + 400); + await tap(menButton(r.container.querySelector('.sb-side--aka'))); // a second Aka mark, after it was built + await act(async () => { settleFirst(REFUSED); }); + await settle(); + expect(marks(r.container).filter((l) => l.startsWith('Aka')), 'the newer edit is still owed').not.toHaveLength(0); + }); +}); + +describe('engi editor: a refused autosave takes the server\'s copy', () => { + const engi = (overrides = {}) => ({ + id: 'm1', status: 'running', phase: 'pool', poolName: 'Pool 1', court: 'A', + sideA: { id: 'p-aka', name: 'Aka Pair 1 - Aka Pair 2', dojo: 'Aka Dojo' }, + sideB: { id: 'p-shiro', name: 'Shiro Pair 1 - Shiro Pair 2', dojo: 'Shiro Dojo' }, + flagsA: 0, flagsB: 0, modifiedAt: NOW - 5000, + ...overrides, + }); + const el = (match, onSubmit) => ( + + ); + const count = (r, side) => r.getByTestId(`engi-${side}-count`).textContent; + + async function tapThenAnswer(answer) { + const onSubmit = vi.fn().mockResolvedValue(answer); + let r; + await act(async () => { r = render(el(engi(), onSubmit)); }); + await tap(r.getByTestId('engi-aka-inc')); + expect(count(r, 'aka'), 'precondition: the tap shows').toBe('1'); + // Another device's count, stamped before the tap: held by the stamp. + await act(async () => { r.rerender(el(engi({ flagsB: 1, modifiedAt: NOW - 1 }), onSubmit)); }); + expect([count(r, 'aka'), count(r, 'shiro')], 'precondition: the older copy is held').toEqual(['1', '0']); + await wait(350); + await settle(); + expect(onSubmit, 'precondition: the autosave went out').toHaveBeenCalled(); + return r; + } + + it('a superseded answer puts the server\'s counts on the board', async () => { + const r = await tapThenAnswer(REFUSED); + expect([count(r, 'aka'), count(r, 'shiro')]).toEqual(['0', '1']); + }); + + it('a nothing-handed-back answer does the same', async () => { + const r = await tapThenAnswer(undefined); + expect([count(r, 'aka'), count(r, 'shiro')]).toEqual(['0', '1']); + }); + + it('a queued answer keeps the board', async () => { + const r = await tapThenAnswer(QUEUED); + expect([count(r, 'aka'), count(r, 'shiro')]).toEqual(['1', '0']); + }); + + it('a tap made after the refused save was built is kept', async () => { + let settleFirst; + const onSubmit = vi.fn().mockImplementationOnce(() => new Promise((res) => { settleFirst = res; })) + .mockResolvedValue({ id: 'm1', status: 'running' }); + let r; + await act(async () => { r = render(el(engi(), onSubmit)); }); + await tap(r.getByTestId('engi-aka-inc')); + await act(async () => { r.rerender(el(engi({ flagsB: 1, modifiedAt: NOW - 1 }), onSubmit)); }); + await wait(350); + vi.setSystemTime(NOW + 400); + await tap(r.getByTestId('engi-aka-inc')); // after the first save was built + await act(async () => { settleFirst(REFUSED); }); + await settle(); + expect(count(r, 'aka'), 'the newer edit is still owed').toBe('2'); + }); +}); diff --git a/web-mobile/js/__tests__/render/ippon_double_tap.render.test.jsx b/web-mobile/js/__tests__/render/ippon_double_tap.render.test.jsx index b329107e6..988a5a449 100644 --- a/web-mobile/js/__tests__/render/ippon_double_tap.render.test.jsx +++ b/web-mobile/js/__tests__/render/ippon_double_tap.render.test.jsx @@ -26,7 +26,7 @@ const STUBBED_GLOBALS = { confirmDialog: vi.fn().mockResolvedValue(true), API: { fetchCompetitionDetails: vi.fn().mockResolvedValue(null), - recordScore: vi.fn().mockResolvedValue(undefined), + recordScore: vi.fn().mockResolvedValue({ applied: true }), recordDaihyosen: vi.fn(), removeDaihyosen: vi.fn(), putMatchLineup: vi.fn(), diff --git a/web-mobile/js/__tests__/render/kachinuki_cleared_bout_reaches_server.render.test.jsx b/web-mobile/js/__tests__/render/kachinuki_cleared_bout_reaches_server.render.test.jsx index ff1c9cc4e..aae9df9c4 100644 --- a/web-mobile/js/__tests__/render/kachinuki_cleared_bout_reaches_server.render.test.jsx +++ b/web-mobile/js/__tests__/render/kachinuki_cleared_bout_reaches_server.render.test.jsx @@ -23,7 +23,7 @@ const STUBBED_GLOBALS = { fetchCompetitionDetails: vi.fn().mockResolvedValue({ id: 'comp1', config: { format: 'mixed', teamMatchType: 'kachinuki', naginata: false, players: [] }, }), - recordScore: vi.fn().mockResolvedValue(undefined), + recordScore: vi.fn().mockResolvedValue({ applied: true }), recordDaihyosen: vi.fn(), removeDaihyosen: vi.fn(), putMatchLineup: vi.fn(), @@ -58,7 +58,7 @@ const kachinukiMatch = (subResults, modifiedAt = 0) => ({ }); async function mount(match) { - const onSubmit = vi.fn().mockResolvedValue(undefined); + const onSubmit = vi.fn().mockResolvedValue({ applied: true }); await act(async () => { render(); }); @@ -121,7 +121,7 @@ describe('a mark taken back on a kachinuki bout reaches the server (bc-kclr)', ( // Each write is stamped when it is sent (server_clock.jsx's frame; the // offset is 0 here), and a snapshot of it carries that stamp. const sentAt = []; - const onSubmit = vi.fn().mockImplementation(() => { sentAt.push(Date.now()); return Promise.resolve(undefined); }); + const onSubmit = vi.fn().mockImplementation(() => { sentAt.push(Date.now()); return Promise.resolve({ applied: true }); }); let utils; const el = (match) => ; await act(async () => { utils = render(el(kachinukiMatch([]))); }); @@ -145,7 +145,7 @@ describe('a mark taken back on a kachinuki bout reaches the server (bc-kclr)', ( // adopted. It is keyed on the snapshot's stamp now: written after the edit, // it is news, and a row the operator has put back where it was follows it. it('a change saved on another device after the edit is adopted', async () => { - const onSubmit = vi.fn().mockResolvedValue(undefined); + const onSubmit = vi.fn().mockResolvedValue({ applied: true }); let utils; const el = (match) => ; await act(async () => { utils = render(el(kachinukiMatch([]))); }); @@ -166,7 +166,7 @@ describe('a mark taken back on a kachinuki bout reaches the server (bc-kclr)', ( // nothing. it('closing right after the clear flushes it with no discard prompt', async () => { window.confirmDialog = vi.fn().mockResolvedValue(false); - const onSubmit = vi.fn().mockResolvedValue(undefined); + const onSubmit = vi.fn().mockResolvedValue({ applied: true }); // Closing unmounts the editor, as every host does; the unmount writes it. let view; const onClose = vi.fn(() => view.unmount()); 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 index f14e3d51e..1cf6b6248 100644 --- 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 @@ -130,7 +130,7 @@ describe('a bout row stamp stands only while an edit is unsent', () => { expect(filled('shiro')).toBe(0); }); - it('keeps the row when the save was refused', async () => { + it('follows the server once the save was refused: the refusal clears the stamp', async () => { const onSubmit = vi.fn().mockResolvedValue({ applied: false, reason: 'superseded' }); let utils; const el = (match) => ; @@ -141,6 +141,6 @@ describe('a bout row stamp stands only while an edit is unsent', () => { 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); + expect(filled('shiro'), "a refused save is never followed by a broadcast, so the other device's point shows").toBe(1); }); }); diff --git a/web-mobile/js/__tests__/render/team_editor_stamps_only_while_running.render.test.jsx b/web-mobile/js/__tests__/render/team_editor_stamps_only_while_running.render.test.jsx new file mode 100644 index 000000000..167553ab5 --- /dev/null +++ b/web-mobile/js/__tests__/render/team_editor_stamps_only_while_running.render.test.jsx @@ -0,0 +1,168 @@ +// The team sheet's bout-row and representative-pick edit stamps (lastRowEditRef, +// lastRepPickEditRef, bc-kclr) hold the operator's rows against a server copy stamped before +// the edit. Like the individual and engi editors' stamps they are taken only while the match +// is RUNNING, because only then is the edit autosaved and a landed save able to clear them, +// and they are cleared when the match leaves running. And a REFUSED autosave (nothing handed +// back, or applied:false) takes the server's current copy for the rows it carried: it stores +// and broadcasts nothing, so nothing else would bring the sheet back to what every other +// surface shows. A QUEUED answer keeps the sheet and the stamps: that write will land. See +// "The edit-time adopt rule" in .claude/rules/match-results-and-marks.md. + +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 REFUSED = { applied: false, reason: 'superseded' }; +const QUEUED = { queued: true }; + +// A completed match is a CORRECTION: its rows are fixed-position, tappable from the start. +const kachinukiMatch = (subResults, modifiedAt = 0, status = 'running') => ({ + id: 'k1', compId: 'comp1', status, phase: 'pool', poolName: 'Pool 1', court: 'A', modifiedAt, + compKind: 'team', teamSize: 3, compFormat: 'mixed', teamMatchType: status === 'completed' ? 'fixed' : 'kachinuki', + ...(status === 'completed' ? { winner: { id: 'team-A', name: 'Team A' } } : {}), + sideA: { id: 'team-A', name: 'Team A' }, + sideB: { id: 'team-B', name: 'Team B' }, + subResults, +}); +// The other device scored Shiro's K on bout 1; its write left the match's stamp at `modifiedAt`. +const otherDeviceScoredShiro = (modifiedAt, status = 'running') => + kachinukiMatch([{ position: 1, sideA: '', sideB: '', ipponsA: [], ipponsB: ['K'] }], modifiedAt, status); + +const strikeAkaMen = async () => { + await act(async () => { fireEvent.keyDown(window, { key: 'M', shiftKey: true }); }); +}; +// A correction has no live bout for the keyboard to strike: tap Aka's M input on bout 1. +const tapAkaMenInput = async () => { + const row = document.querySelector('.team-sub-match:not(.team-sub-match--readonly)'); + const men = [...row.querySelectorAll('.ipt-btn')].filter((b) => b.textContent === 'M'); + await act(async () => { fireEvent.click(men[men.length - 1]); }); +}; +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; +const settle = async () => { for (let i = 0; i < 6; i++) await act(async () => { await Promise.resolve(); }); }; +const el = (match, onSubmit) => ; + +describe('the stamps are taken only while the match is running', () => { + it.each(['scheduled', 'completed'])('a net-zero fiddle on a %s match holds nothing against another device', async (status) => { + const onSubmit = vi.fn().mockResolvedValue({ applied: true }); + let utils; + await act(async () => { utils = render(el(kachinukiMatch([], 0, status), onSubmit)); }); + await (status === 'completed' ? tapAkaMenInput() : strikeAkaMen()); + await tapMark('aka', 'M'); + const tappedAt = Date.now(); + await act(async () => { vi.advanceTimersByTime(400); }); + expect(onSubmit, 'a match that is not running is not autosaved').not.toHaveBeenCalled(); + + await act(async () => { utils.rerender(el(otherDeviceScoredShiro(tappedAt - 1, status), onSubmit)); }); + expect(filled('shiro'), "the other device's point shows").toBe(1); + }); + + it('a stamp taken while running goes when the match leaves running inside the debounce', async () => { + const onSubmit = vi.fn().mockResolvedValue({ applied: true }); + let utils; + await act(async () => { utils = render(el(kachinukiMatch([]), onSubmit)); }); + await strikeAkaMen(); + await tapMark('aka', 'M'); + const tappedAt = Date.now(); + // Sent back to the queue elsewhere before the debounce fired: the write is dropped. + await act(async () => { utils.rerender(el(kachinukiMatch([], 5, 'scheduled'), onSubmit)); }); + await act(async () => { vi.advanceTimersByTime(400); }); + expect(onSubmit).not.toHaveBeenCalled(); + + await act(async () => { utils.rerender(el(otherDeviceScoredShiro(tappedAt - 1, 'scheduled'), onSubmit)); }); + expect(filled('shiro'), "the other device's point shows").toBe(1); + }); +}); + +describe('a refused autosave takes the server\'s copy of the rows it carried', () => { + // Aka strikes M; another device's copy (Shiro K, stamped before the tap) is held by the + // stamp; the autosave then comes back with `answer`. + async function strikeThenAnswer(answer) { + const onSubmit = vi.fn().mockResolvedValue(answer); + let utils; + await act(async () => { utils = render(el(kachinukiMatch([]), onSubmit)); }); + await strikeAkaMen(); + const tappedAt = Date.now(); + expect(filled('aka'), 'precondition: the strike shows').toBe(1); + await act(async () => { utils.rerender(el(otherDeviceScoredShiro(tappedAt - 1), onSubmit)); }); + expect([filled('aka'), filled('shiro')], 'precondition: the older copy is held').toEqual([1, 0]); + await act(async () => { vi.advanceTimersByTime(400); }); + await settle(); + expect(onSubmit, 'precondition: the autosave went out').toHaveBeenCalled(); + return utils; + } + + it('a superseded answer puts the server\'s point on the row and drops the operator\'s', async () => { + await strikeThenAnswer(REFUSED); + expect([filled('aka'), filled('shiro')]).toEqual([0, 1]); + }); + + it('a nothing-handed-back answer does the same', async () => { + await strikeThenAnswer(undefined); + expect([filled('aka'), filled('shiro')]).toEqual([0, 1]); + }); + + it('a queued answer keeps the sheet: that write will land', async () => { + await strikeThenAnswer(QUEUED); + expect([filled('aka'), filled('shiro')]).toEqual([1, 0]); + }); + + it('a strike made after the refused save was built is kept', async () => { + let settleFirst; + const onSubmit = vi.fn().mockImplementationOnce(() => new Promise((res) => { settleFirst = res; })) + .mockResolvedValue({ applied: true }); + let utils; + await act(async () => { utils = render(el(kachinukiMatch([]), onSubmit)); }); + await strikeAkaMen(); + const tappedAt = Date.now(); + await act(async () => { utils.rerender(el(otherDeviceScoredShiro(tappedAt - 1), onSubmit)); }); + await act(async () => { vi.advanceTimersByTime(400); }); // the first save is out and unanswered + await strikeAkaMen(); // after it was built + await act(async () => { settleFirst(REFUSED); }); + await settle(); + expect(filled('aka'), 'the newer edit is still owed').toBeGreaterThan(0); + }); +}); diff --git a/web-mobile/js/__tests__/render/team_fusensho_keeps_points.render.test.jsx b/web-mobile/js/__tests__/render/team_fusensho_keeps_points.render.test.jsx index 164d0ca3f..d0f6dbed1 100644 --- a/web-mobile/js/__tests__/render/team_fusensho_keeps_points.render.test.jsx +++ b/web-mobile/js/__tests__/render/team_fusensho_keeps_points.render.test.jsx @@ -23,7 +23,7 @@ const STUBBED_GLOBALS = { confirmDialog: vi.fn().mockResolvedValue(true), API: { fetchCompetitionDetails: vi.fn().mockResolvedValue(null), - recordScore: vi.fn().mockResolvedValue(undefined), + recordScore: vi.fn().mockResolvedValue({ applied: true }), recordDaihyosen: vi.fn(), removeDaihyosen: vi.fn(), putMatchLineup: vi.fn(), diff --git a/web-mobile/js/admin_scoring_autosave.jsx b/web-mobile/js/admin_scoring_autosave.jsx index 4b2538d4b..8a029aa7d 100644 --- a/web-mobile/js/admin_scoring_autosave.jsx +++ b/web-mobile/js/admin_scoring_autosave.jsx @@ -144,13 +144,23 @@ export function useKeptInHistoryNote() { // edits at or before it, never for a tap made while the save was out. The individual // editor's board stamp, the team sheet's row and pick stamps and the engi editor's flags // stamp are its three callers. -export function landedAutosave(submit, seqRef, onLanded) { +// +// `onRefused(seq)`, optional, runs instead when the save came back REFUSED +// (writeWasRefused: nothing handed back, or applied:false), with the same `seq`. A refused +// save stores nothing and broadcasts nothing, so no later server copy would ever bring +// the board back: the stamp it left set would hold the operator's value for good while the +// server and every other surface show the server's. The callback is for the editor to +// compare `seq` with its counter (an edit made since is still owed and keeps its stamp), +// then drop the stamp and take the server's CURRENT copy. A QUEUED answer calls neither: +// that write will land, so the stamp stays. +export function landedAutosave(submit, seqRef, onLanded, onRefused) { 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); + if (writeWasRefused(res)) { if (onRefused) onRefused(seq); } + else if (!writeDidNotLand(res)) onLanded(seq); }, () => {}); } return p; diff --git a/web-mobile/js/admin_scoring_engi.jsx b/web-mobile/js/admin_scoring_engi.jsx index e7b3cd6ff..6c52b24b5 100644 --- a/web-mobile/js/admin_scoring_engi.jsx +++ b/web-mobile/js/admin_scoring_engi.jsx @@ -155,8 +155,14 @@ export function EngiScoreEditorModal({ match, onClose, onSubmit, onSubmitAndNext // is the edit autosaved, and only a landed autosave clears the stamp. const flagsEditedAtRef = useRefE(undefined); const flagsEditSeqRef = useRefE(0); + // A REFUSED autosave stores and broadcasts nothing, so no later copy would re-run the + // adopt: with no edit since the save was built, the counts take the server's current + // copy now (takeServerFlagsRef holds the latest render's adopt, not this one's). + const takeServerFlagsRef = useRefE(null); autosaveOnSubmitRef.current = landedAutosave(onSubmit, flagsEditSeqRef, (seq) => { if (flagsEditSeqRef.current === seq) flagsEditedAtRef.current = undefined; + }, (seq) => { + if (flagsEditSeqRef.current === seq && takeServerFlagsRef.current) takeServerFlagsRef.current(); }); // The match left running (finished, or sent back to the queue, elsewhere) inside the // debounce: the write is dropped with no callback, so no landed save clears the stamp. @@ -202,15 +208,19 @@ export function EngiScoreEditorModal({ match, onClose, onSubmit, onSubmitAndNext // Until the match is running nothing this editor holds reaches the server // before the operator submits, so a stale reading can sit in front of them // for as long as the match is open. + const takeServerFlags = () => { + flagsEditedAtRef.current = undefined; + setFlagsA(initialFlagsA); + setFlagsB(initialFlagsB); + claimChanged.agree(GROUP_FLAGS); + }; + takeServerFlagsRef.current = takeServerFlags; useAdoptFromServer({ signature: `${initialFlagsA}:${initialFlagsB}`, apply: () => { const editedAt = flagsEditedAtRef.current; if (editedAt !== undefined && (m.modifiedAt || 0) < editedAt) return; - flagsEditedAtRef.current = undefined; - setFlagsA(initialFlagsA); - setFlagsB(initialFlagsB); - claimChanged.agree(GROUP_FLAGS); + takeServerFlags(); }, keepLocalEdits: true, isDirty, diff --git a/web-mobile/js/admin_scoring_individual.jsx b/web-mobile/js/admin_scoring_individual.jsx index ec2f5ca93..f3d093b0e 100644 --- a/web-mobile/js/admin_scoring_individual.jsx +++ b/web-mobile/js/admin_scoring_individual.jsx @@ -342,8 +342,14 @@ export function ScoreEditorModal({ match, onClose, onSubmit, onSubmitAndNext, on }; // 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. + // A REFUSED autosave stores and broadcasts nothing, so no later copy would re-run the + // adopt: with no board edit since the save was built, the board takes the server's + // current copy now (applyServerScoreRef holds the latest render's, not this one's). + const applyServerScoreRef = useRefA(null); const landedBoardSave = (submit) => landedAutosave(submit, boardEditSeqRef, (seq) => { if (boardEditSeqRef.current === seq) boardEditedAtRef.current = undefined; + }, (seq) => { + if (boardEditSeqRef.current === seq && applyServerScoreRef.current) applyServerScoreRef.current(); }); // The match left running (finished, or sent back to the queue, elsewhere) inside the // debounce: the write is dropped with no callback, so no landed save clears the stamp. @@ -711,6 +717,7 @@ export function ScoreEditorModal({ match, onClose, onSubmit, onSubmitAndNext, on claimChanged.agree(GROUP_ENCHO); claimChanged.agree(GROUP_RESULT); }; + applyServerScoreRef.current = applyServerScore; // Arranged as [left, right]: left is always SHIRO (White), right is always AKA (Red). // onIncrement applies the FIK 2-foul auto-award rule via applyFoulIncrement: diff --git a/web-mobile/js/admin_scoring_team.jsx b/web-mobile/js/admin_scoring_team.jsx index c117b5d64..45a3ac967 100644 --- a/web-mobile/js/admin_scoring_team.jsx +++ b/web-mobile/js/admin_scoring_team.jsx @@ -2356,6 +2356,19 @@ export function TeamScoreEditorModal({ match, teamSize, onClose, onSubmit, onSub lastRepPickEditRef.current[side] = undefined; } }; + // The stamps are taken only while the match is RUNNING, because only then is the edit + // autosaved: on a scheduled match, or a correction of a completed one, nothing is sent, + // no landed save would ever clear a stamp, and a net-zero fiddle would make the sheet + // refuse a copy from another device it would otherwise follow. When the match leaves + // running (another device finished it or sent it back to the queue inside the debounce) + // the write is dropped with no callback, so no landed save clears them either: they go + // here. The individual and engi editors do the same. + useEffectA(() => { if (m.status !== "running") clearLandedEditStamps(Infinity); }, [m.status]); + // A REFUSED autosave stores and broadcasts nothing, so no later copy would re-run the + // adopt, and the stamps it left would hold the operator's rows against the server for + // good. With no edit since the save was built, every row and pick it carried takes the + // server's current copy (assigned beside the adopt below; the ref is the latest render's). + const takeServerRowsRef = useRefA(null); // 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. @@ -2374,12 +2387,15 @@ export function TeamScoreEditorModal({ match, teamSize, onClose, onSubmit, onSub // part, because the adopt follows the server for each part on its own. const updateSub = (idx, fn, opts) => { 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); + // Stamped only while running: nothing else is autosaved to clear one (the effect above). + if (m.status === "running") { + 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); @@ -3751,7 +3767,9 @@ 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 = landedAutosave(onSubmit, editSeqRef, clearLandedEditStamps); + _autosaveOnSubmitRef.current = landedAutosave(onSubmit, editSeqRef, clearLandedEditStamps, (seq) => { + if (takeServerRowsRef.current) takeServerRowsRef.current(seq); + }); const doSubmit = async (fn) => { cancelScoringDebounce(); // C1: cancel pending autosave before explicit submit @@ -3984,6 +4002,30 @@ export function TeamScoreEditorModal({ match, teamSize, onClose, onSubmit, onSub // per-MATCH, not per-bout, so it cannot merge two people's rows. That case is // theirs to resolve. const prevServerSubsRef = useRefA(serverSubs); + // A refused autosave (takeServerRowsRef above): the rows and picks stamped up to `seq` + // take the server's current copy, by the adopt's own composition (composeRepRow) and + // agreement (boutGroup, the pick groups). An edit since `seq` is still owed, so + // nothing is taken; the save that carries it decides. A row the server does not hold + // stays the operator's. + takeServerRowsRef.current = (seq) => { + if (editSeqRef.current !== seq) return; + const rows = new Set(rowEditSeqRef.current.keys()); + const picks = { a: repPickEditSeqRef.current.a !== undefined, b: repPickEditSeqRef.current.b !== undefined }; + clearLandedEditStamps(seq); + const servedByPos = new Map(serverSubs.map(s => [s._pos, s])); + const repServed = servedByPos.has(DAIHYOSEN_POSITION); + setSubs(prev => prev.map(local => { + const ss = servedByPos.get(local._pos); + if (!ss) return local; + if (local._pos === DAIHYOSEN_POSITION) { + return composeRepRow(local, ss, { score: rows.has(local._pos), a: picks.a, b: picks.b }); + } + return rows.has(local._pos) ? ss : local; + })); + for (const pos of rows) if (servedByPos.has(pos)) claimChanged.agree(boutGroup(pos)); + if (repServed && picks.a) claimChanged.agree(GROUP_REP_PICK_A); + if (repServed && picks.b) claimChanged.agree(GROUP_REP_PICK_B); + }; useAdoptFromServer({ signature: serverSubsSig, apply: () => { From c36252fc6ba05aad3490300ebd4f1aab200305b3 Mon Sep 17 00:00:00 2001 From: Ricardo Oliveira Date: Tue, 13 Oct 2026 19:32:28 +0100 Subject: [PATCH 09/31] test(bc-e2rv): kachinuki in pools journey Five teams, kachinuki, pools of 3 and 2 feeding a final: winner stays on, a hikiwake retiring both, a team exhausted, a level pool encounter recorded as a draw with no daihyosen offered, the pool tables' W/L/T, IV/IL/IT and PW/PL on admin and the public viewer, and the pool winners seated in the final. Co-Authored-By: Claude Opus 5.5 --- scripts/e2e/fixtures/kachinuki.mjs | 26 +- scripts/e2e/fixtures/pools.mjs | 52 +++ scripts/e2e/journeys/kachinuki-pools.spec.mjs | 346 ++++++++++++++++++ 3 files changed, 423 insertions(+), 1 deletion(-) create mode 100644 scripts/e2e/fixtures/pools.mjs create mode 100644 scripts/e2e/journeys/kachinuki-pools.spec.mjs diff --git a/scripts/e2e/fixtures/kachinuki.mjs b/scripts/e2e/fixtures/kachinuki.mjs index a928fc23e..c8995657c 100644 --- a/scripts/e2e/fixtures/kachinuki.mjs +++ b/scripts/e2e/fixtures/kachinuki.mjs @@ -17,7 +17,7 @@ import { dwell, settled } from './pace.mjs'; import { EXPECT_TIMEOUT_MS } from './devices.mjs'; import { clearMarks } from './scoring.mjs'; import { isCoarse, restoreCoarse } from './pointer.mjs'; -import { inlineEditor } from './shiaijo.mjs'; +import { inlineEditor, sides, upNextCard } from './shiaijo.mjs'; import * as T from './team.mjs'; // Does the page still match (pointer: coarse)? If an interruption or a @@ -171,3 +171,27 @@ export async function endMatch(page) { await btn.tap(); return armed; } + +// ------------------------------------------------------------ pool play -- + +// Start the Up next kachinuki encounter from its card and return the pairing +// (Shiro, Aka team names) once the sheet is running. The individual board's +// startUpNext (fixtures/shiaijo.mjs) waits for `.sb-side--*`, which this sheet +// does not have. +export async function startUpNextKachinuki(page) { + const pair = await sides(upNextCard(page)); + await upNextCard(page).getByRole('button', { name: 'Start match' }).tap(); + await expect(recordBoutButton(page)).toBeVisible(); + return pair; +} + +// The line that stands where the current bout was once Record bout took a +// team's last fighter off ("Aka has no fighters left. End match finishes the +// encounter."). +export const noPairingLine = (page) => editor(page).getByTestId('kachinuki-no-pairing'); + +// The representative bout (daihyosen) offer. A kachinuki sheet never carries +// it (admin_scoring_team.jsx hides the add affordance under isKachinuki), so a +// journey asserts both are absent. +export const daihyosenOffer = (page) => editor(page).getByTestId('scoring-modal-daihyosen-button'); +export const daihyosenControls = (page) => editor(page).locator('.daihyosen-controls'); diff --git a/scripts/e2e/fixtures/pools.mjs b/scripts/e2e/fixtures/pools.mjs new file mode 100644 index 000000000..591cbcc6d --- /dev/null +++ b/scripts/e2e/fixtures/pools.mjs @@ -0,0 +1,52 @@ +// The pool standings tables: the admin Pools tab (/admin/competition/:id/pools, +// admin_pools.jsx) and the public viewer's Pools tab (/competition/:id, +// viewer_competition.jsx) both render PoolsViewer (viewer_standings.jsx), so +// one set of selectors reads either page. A team pool's row lists the team in +// draw order with a rank badge, then eight figures: W L T (team matches won, +// lost, tied), IV IL IT (individual victories, losses, ties) and PW PL +// (points won, lost). +import { expect } from '@playwright/test'; +import { openViewer } from './public.mjs'; + +export const FIGURES = ['W', 'L', 'T', 'IV', 'IL', 'IT', 'PW', 'PL']; + +// Pool blocks on the page; a standings table sits inside each. +export const poolBlocks = (page) => page.locator('.pool').filter({ has: page.locator('table.pool__table') }); + +// The admin Pools tab, loaded once at least one pool is drawn. +export async function openAdminPools(page, id) { + await page.goto(`/admin/competition/${encodeURIComponent(id)}/pools`); + await expect(poolBlocks(page).first()).toBeVisible(); +} + +// The public viewer's Pools tab. +export async function openViewerPools(page, id) { + await openViewer(page, id); + await page.locator('.viewer__tab', { hasText: /^Pools$/ }).tap(); + await expect(poolBlocks(page).first()).toBeVisible(); +} + +// A team's standings row, found by the team name its cell shows. +export const teamRow = (page, team) => page.locator('table.pool__table tbody tr') + .filter({ has: page.locator('.pool__player-name', { hasText: team }) }); + +// Everything a row says: its eight figures by name, the rank badge ("1st"), +// (lower-cased: the page upper-cases it with CSS), and whether the row is marked as advancing to the knockout. +export async function rowFigures(page, team) { + const row = teamRow(page, team); + await expect(row).toHaveCount(1); + const cells = (await row.locator('td.num').allInnerTexts()).map((t) => t.trim()); + const figures = Object.fromEntries(FIGURES.map((f, i) => [f, cells[i] === undefined ? NaN : Number(cells[i])])); + const badge = row.locator('.rank-badge'); + return { + ...figures, + rank: (await badge.count()) ? (await badge.first().innerText()).trim().toLowerCase() : '', + advancing: ((await row.getAttribute('class')) || '').split(/\s+/).includes('advancing'), + }; +} + +// The names of the teams in a pool, in the table's (draw) order. +export async function poolTeams(block) { + return (await block.locator('table.pool__table tbody .pool__player-name').allInnerTexts()) + .map((t) => t.replace(/\s+/g, ' ').trim()); +} diff --git a/scripts/e2e/journeys/kachinuki-pools.spec.mjs b/scripts/e2e/journeys/kachinuki-pools.spec.mjs new file mode 100644 index 000000000..a120d666e --- /dev/null +++ b/scripts/e2e/journeys/kachinuki-pools.spec.mjs @@ -0,0 +1,346 @@ +// Kachinuki (winner stays on) in POOLS. +// +// Fixture: a team competition, kachinuki, team size 3, format "Pools + +// Knockout", five teams in pools of at most three, one qualifier per pool. The +// draw makes a pool of three and a pool of two; the knockout is the final +// between the two pool winners. Everything is seeded through the interface +// (wizard, roster paste, Generate draw, Start). The members of the teams of the +// three-team pool are named at the court through the lineup panel before their +// first encounter. +// +// The draw decides who meets whom and in which order, so the three-team pool +// is scripted by the ROLE a team plays, not by its name: +// 1 its first encounter: Shiro's team (A) beats B, a hikiwake retiring both +// on the way, B's last fighter beaten: winner stays on, a tie removes both, +// a team exhausted (Record bout takes the last fighter off and no pairing +// follows). +// 2 one of A/B against the third team (C): ends LEVEL on individual victories +// and points, both lineups used up on a double hikiwake. A pool may end in +// a draw and a pool has no representative bout, so it is recorded as a +// draw (T) and no daihyosen is offered. +// 3 the remaining pair: a clean win (End match straight after the win). +// Encounter 3's winner is picked so the pool has a strict order on W, L and T +// alone (no tie-break flow). The two-team pool's only encounter is one bout won +// by Shiro's team, ended with End match. Expected standings are tallied from the +// scripted bouts, never read back from the app. +// +// Asserted: the pairing the winner-stays-on rule brings up after each bout, the +// recorded result of each encounter, the pool tables' eight figures and rank +// badges for every team on the admin Pools tab and on a spectator's phone, and +// the two pool winners seated in the final. +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 { completedRowFor, openShiaijo, sides, upNextCard } from '../fixtures/shiaijo.mjs'; +import { PUBLIC_DEVICE } from '../fixtures/devices.mjs'; +import { FIGURES, openAdminPools, openViewerPools, poolBlocks, poolTeams, rowFigures } from '../fixtures/pools.mjs'; +import { + awardBoutIppon, boutNames, closeLineupPanel, currentBout, daihyosenControls, daihyosenOffer, doneRows, endMatch, + endMatchButton, noPairingLine, openLineupFromUpNext, recordBout, recordBoutButton, startUpNextKachinuki, tieButton, + typeLineup, +} from '../fixtures/kachinuki.mjs'; + +const JOURNEY = 'kachinuki-pools'; + +const TEAMS = [ + ['Team Hayabusa', 'Kita Dojo'], ['Team Kawasemi', 'Minami Dojo'], ['Team Tsubame', 'Higashi Dojo'], + ['Team Washi', 'Nishi Dojo'], ['Team Taka', 'Chuo Dojo'], +]; +const NAMES = TEAMS.map(([name]) => name); +// Senpo to Taisho. +const MEMBERS = { + 'Team Hayabusa': ['Aoki', 'Baba', 'Chiba'], + 'Team Kawasemi': ['Fujii', 'Goto', 'Hara'], + 'Team Tsubame': ['Kudo', 'Maeda', 'Noda'], + 'Team Washi': ['Taki', 'Ueno', 'Wada'], + 'Team Taka': ['Yano', 'Zaitsu', 'Ando'], +}; +const TEAM_SIZE = 3; +const ORDINAL = ['1st', '2nd', '3rd']; + +const zero = () => ({ W: 0, L: 0, T: 0, IV: 0, IL: 0, IT: 0, PW: 0, PL: 0 }); + +test.describe.configure({ mode: 'serial' }); + +test.describe('kachinuki in pools', () => { + let shot; + let compId; + const audit = auditLog(JOURNEY); + const { record } = audit; + + // What the scripted bouts add up to, per team (the pool tables' figures). + const tally = Object.fromEntries(NAMES.map((n) => [n, zero()])); + // The pools as the draw made them (team names), the three-team one first. + let pools; + // Teams whose members have been named at the court (a lineup carries to the + // team's later matches). + const lineupDone = new Set(); + + test.beforeAll(() => { + shot = journeyShots(JOURNEY); + }); + + test.afterAll(() => audit.flush()); + + // Name the members of every team on the Up next card that has none yet. + async function ensureLineups(page, pair) { + const needing = [pair.shiro, pair.aka].filter((t) => !lineupDone.has(t)); + if (!needing.length) return; + await openLineupFromUpNext(page); + for (const team of needing) { + await typeLineup(page, team, MEMBERS[team]); + lineupDone.add(team); + } + await shot(page, `lineups-${needing.length}-teams-named`); + await closeLineupPanel(page); + } + + // Play `bouts` on the running sheet. Each entry is the winning team's name or + // null for a hikiwake. The winner-stays-on pairing is tracked here and checked + // against the sheet before every bout. A bout is scored M then K (2 points), + // and recorded with Record bout unless it is the last one and `recordLast` is + // false. Returns the encounter's per-team IV and PW for the Completed row. + async function playBouts(page, pair, bouts, { recordLast, label }) { + const next = { [pair.shiro]: 0, [pair.aka]: 0 }; + const other = (team) => (team === pair.shiro ? pair.aka : pair.shiro); + const line = { [pair.shiro]: { IV: 0, PW: 0 }, [pair.aka]: { IV: 0, PW: 0 } }; + for (let n = 0; n < bouts.length; n += 1) { + const winner = bouts[n]; + const last = n === bouts.length - 1; + await expect.poll(() => boutNames(currentBout(page)), { message: `${label}: bout ${n + 1} pairing` }).toEqual({ + shiro: MEMBERS[pair.shiro][next[pair.shiro]], aka: MEMBERS[pair.aka][next[pair.aka]], + }); + expect(await doneRows(page).count(), `${label}: fought bouts above the live one`).toBe(n); + if (winner) { + const side = winner === pair.shiro ? 'shiro' : 'aka'; + await awardBoutIppon(page, side, 'M'); + await awardBoutIppon(page, side, 'K'); + const loser = other(winner); + tally[winner].IV += 1; + tally[winner].PW += 2; + tally[loser].IL += 1; + tally[loser].PL += 2; + line[winner].IV += 1; + line[winner].PW += 2; + next[loser] += 1; + } else { + const tie = tieButton(currentBout(page)); + await tie.tap(); + await expect(tie).toHaveText(/✓ Tie/); + tally[pair.shiro].IT += 1; + tally[pair.aka].IT += 1; + next[pair.shiro] += 1; + next[pair.aka] += 1; + } + await shot(page, `${label}-bout-${n + 1}-scored`); + if (!last || recordLast) await recordBout(page); + } + return line; + } + + // The Completed row's result string for a finished encounter, by side. + const resultText = (pair, line) => new RegExp( + `IV ${line[pair.shiro].IV}–${line[pair.aka].IV}\\s+PW ${line[pair.shiro].PW}–${line[pair.aka].PW}`); + + async function expectCompleted(page, pair, line) { + const row = completedRowFor(page, pair); + await expect(row).toBeVisible(); + await expect(row.locator('.shiaijo-qrow__result')).toHaveText(resultText(pair, line)); + return (await row.innerText()).replace(/\s+/g, ' '); + } + + // The ranking the scripted results give within one pool: W high, L low, T high. + const rankedIn = (teams) => [...teams].sort((a, b) => (tally[b].W - tally[a].W) || (tally[a].L - tally[b].L) + || (tally[b].T - tally[a].T)); + + // Roles of the three-team pool, fixed by its first two encounters. + const role = {}; + + // Encounter 1: winner stays on, a hikiwake retires both, a team is exhausted. + async function encounter1(page, pair) { + role.A = pair.shiro; + role.B = pair.aka; + await ensureLineups(page, pair); + await startUpNextKachinuki(page); + await shot(page, 'enc1-started'); + // A beats B's first, A stays on and ties B's second (both retire), A's + // second beats B's third: B has nobody left. + const line = await playBouts(page, pair, [role.A, null, role.A], { recordLast: true, label: 'enc1' }); + // Record bout took B's last fighter off: no pairing follows, and the sheet says so. + await expect(noPairingLine(page)).toContainText('has no fighters left'); + await expect(recordBoutButton(page)).toBeDisabled(); + await expect(daihyosenOffer(page)).toHaveCount(0); + await shot(page, 'enc1-b-exhausted'); + await endMatch(page); + const rowText = await expectCompleted(page, pair, line); + tally[role.A].W += 1; + tally[role.B].L += 1; + record({ journey: JOURNEY, step: 'enc1', action: 'End match', pair, rowText, screenshot: await shot(page, 'enc1-completed') }); + } + + // Encounter 2: level on victories and points, so a draw; no daihyosen. + async function encounter2(page, pair) { + role.C = pools[0].find((n) => n !== role.A && n !== role.B); + const p = pair.shiro === role.C ? pair.aka : pair.shiro; + expect([role.A, role.B], 'one of the first encounter\'s teams meets the third').toContain(p); + await ensureLineups(page, pair); + await startUpNextKachinuki(page); + await shot(page, 'enc2-started'); + // P beats C's first; C's second beats P's first; P's second ties C's second + // (both retire); P's third ties C's third. Both lineups are used up: the last + // bout stays the current bout, Record bout is off, and the encounter is level + // at 1 victory and 2 points each. + const line = await playBouts(page, pair, [p, role.C, null, null], { recordLast: false, label: 'enc2' }); + // Record bout records the tie and finds nobody to bring up: the bout stays the + // current one and Record bout is greyed out (team-tournaments.md, "A tie + // leaves both teams with nobody"). + await expect(recordBoutButton(page)).toBeEnabled(); + await recordBoutButton(page).tap(); + await expect(recordBoutButton(page)).toBeDisabled(); + expect(await doneRows(page).count(), 'the tied last bout stays the current bout').toBe(3); + // A knockout would offer a representative bout on this tie; a pool + // encounter never does, and kachinuki has none at all. + await expect(daihyosenOffer(page)).toHaveCount(0); + await expect(daihyosenControls(page)).toHaveCount(0); + await expect(endMatchButton(page)).toBeEnabled(); + await shot(page, 'enc2-level-no-daihyosen'); + await endMatch(page); + const rowText = await expectCompleted(page, pair, line); + // A draw names no winner. + expect(rowText.match(/✓/g) || [], `a drawn encounter marks no winner: ${rowText}`).toHaveLength(0); + tally[p].T += 1; + tally[role.C].T += 1; + record({ journey: JOURNEY, step: 'enc2', action: 'End match', pair, rowText, screenshot: await shot(page, 'enc2-completed') }); + } + + // Encounter 3: the remaining pair, a clean win ended with End match. + async function encounter3(page, pair) { + const [x, y] = [pair.shiro, pair.aka]; + // The winner that leaves the pool with a strict order on W, L, T. + const order = (a, b) => (b.W - a.W) || (a.L - b.L) || (b.T - a.T); + const strict = (winner) => { + const after = Object.fromEntries(pools[0].map((n) => [n, { ...tally[n] }])); + after[winner].W += 1; + after[winner === x ? y : x].L += 1; + const sorted = [...pools[0]].sort((a, b) => order(after[a], after[b])); + return sorted.every((n, i) => i === 0 || order(after[sorted[i - 1]], after[n]) !== 0); + }; + const winner = [x, y].find(strict); + expect(winner, 'one winner leaves a strict order').toBeTruthy(); + await ensureLineups(page, pair); + await startUpNextKachinuki(page); + // Three wins in a row: the winner stays on against all three. The last win + // is not recorded; End match finishes on the last scored bout. + const line = await playBouts(page, pair, [winner, winner, winner], { recordLast: false, label: 'enc3' }); + await shot(page, 'enc3-last-bout-scored'); + await endMatch(page); + const rowText = await expectCompleted(page, pair, line); + tally[winner].W += 1; + tally[winner === x ? y : x].L += 1; + record({ journey: JOURNEY, step: 'enc3', action: 'End match', pair, winner, rowText, screenshot: await shot(page, 'enc3-completed') }); + } + + // The two-team pool's only encounter: one bout, Shiro's team wins it. + async function quickEncounter(page, pair) { + await startUpNextKachinuki(page); + const line = { [pair.shiro]: { IV: 1, PW: 2 }, [pair.aka]: { IV: 0, PW: 0 } }; + await awardBoutIppon(page, 'shiro', 'M'); + await awardBoutIppon(page, 'shiro', 'K'); + await endMatch(page); + const rowText = await expectCompleted(page, pair, line); + Object.assign(tally[pair.shiro], { W: 1, IV: 1, PW: 2 }); + Object.assign(tally[pair.aka], { L: 1, IL: 1, PL: 2 }); + record({ journey: JOURNEY, step: 'pair pool', action: 'End match', pair, rowText, screenshot: await shot(page, 'pair-pool-completed') }); + } + + test('seed: five teams, a pool of three and a pool of two, one qualifier each', async ({ page }) => { + await createTournament(page, { name: 'Kachinuki Pools Cup', courts: 2 }); + compId = await createCompetition(page, { + name: 'Team Kachinuki Pools', kind: 'team', format: 'mixed', teamSize: TEAM_SIZE, teamMatchType: 'kachinuki', + poolSizeMode: 'maximum', poolSize: 3, poolWinners: 1, courts: ['A'], + }); + await pasteRoster(page, compId, TEAMS); + await generateDraw(page, compId); + await startCompetition(page, compId); + + // The pools as the draw made them, read back. + await openAdminPools(page, compId); + await expect(poolBlocks(page)).toHaveCount(2); + const read = []; + for (let i = 0; i < 2; i += 1) { + const names = await poolTeams(poolBlocks(page).nth(i)); + read.push(NAMES.filter((n) => names.some((t) => t.includes(n)))); + } + pools = read.sort((a, b) => b.length - a.length); + record({ journey: JOURNEY, step: 'draw', action: 'pool composition', pools, screenshot: await shot(page, 'pools-drawn') }); + expect(pools.map((t) => t.length), 'a pool of three and a pool of two').toEqual([3, 2]); + }); + + test('play the pools: winner stays on, hikiwake, exhaustion, a level draw', async ({ page }) => { + await login(page); + const poolOf = (team) => pools.findIndex((t) => t.includes(team)); + let threeTeamPlayed = 0; + for (let n = 1; n <= 4; n += 1) { + await openShiaijo(page, 'A'); + await expect(upNextCard(page)).toBeVisible(); + const pair = await sides(upNextCard(page)); + expect(poolOf(pair.shiro), 'an encounter is within one pool').toBe(poolOf(pair.aka)); + const isThree = poolOf(pair.shiro) === 0; + const title = !isThree ? `encounter ${n}: the pool of two` + : ['winner stays on, a hikiwake retires both, a team is exhausted', 'level on victories and points is a draw, no daihyosen offered', + 'a clean win ended with End match'][threeTeamPlayed]; + await test.step(`encounter ${n} (${pair.shiro} v ${pair.aka}): ${title}`, async () => { + if (!isThree) await quickEncounter(page, pair); + else await [encounter1, encounter2, encounter3][threeTeamPlayed](page, pair); + }); + if (isThree) threeTeamPlayed += 1; + } + expect(threeTeamPlayed).toBe(3); + }); + + async function expectTables(page, where) { + for (const team of pools) { + for (const [i, name] of rankedIn(team).entries()) { + const want = { ...tally[name], rank: ORDINAL[i], advancing: i < 1 }; + // The table reads the server's standings: wait for the last write to show. + await expect.poll(() => rowFigures(page, name), { message: `${where}: ${name}` }).toEqual(want); + } + } + } + + test('the pool standings: team and individual figures, on the admin Pools tab and a spectator\'s phone', async ({ page, browser, baseURL }) => { + await login(page); + await openAdminPools(page, compId); + await expectTables(page, 'admin Pools tab'); + record({ journey: JOURNEY, step: 'standings', action: 'admin Pools tab', columns: FIGURES, tally, + screenshot: await shot(page, 'admin-pools-standings', { fullPage: true }) }); + + const spectator = await browser.newContext({ ...PUBLIC_DEVICE, baseURL }); + try { + const phone = await spectator.newPage(); + await openViewerPools(phone, compId); + await expectTables(phone, 'public viewer Pools tab'); + await shot(phone, 'viewer-pools-standings', { fullPage: true }); + } finally { + await spectator.close(); + } + }); + + test('the winner of each pool is seated in the final', async ({ page }) => { + await login(page); + const winners = pools.map((t) => rankedIn(t)[0]).sort(); + await expect.poll(async () => { + await openShiaijo(page, 'A'); + const card = upNextCard(page); + if (!(await card.count())) return null; + const pair = await sides(card); + return [pair.shiro, pair.aka].sort(); + }, { message: 'the final seats the pool winners', timeout: 15_000 }).toEqual(winners); + const text = (await upNextCard(page).innerText()).replace(/\s+/g, ' '); + record({ journey: JOURNEY, step: 'knockout', action: 'Up next after the pools', winners, text, + screenshot: await shot(page, 'final-seated') }); + }); +}); From 053bc4a90ea577a21a5967781965c31af5565448 Mon Sep 17 00:00:00 2001 From: Ricardo Oliveira Date: Tue, 13 Oct 2026 19:32:16 +0100 Subject: [PATCH 10/31] test(bc-e2rv): organiser roster journey (check-in, seeds, participant edit, replace) Co-Authored-By: Claude Opus 5.5 --- scripts/e2e/fixtures/roster.mjs | 180 ++++++++++++ scripts/e2e/fixtures/test.mjs | 7 + scripts/e2e/fixtures/wizard.mjs | 4 +- .../e2e/journeys/organiser-roster.spec.mjs | 274 ++++++++++++++++++ 4 files changed, 464 insertions(+), 1 deletion(-) create mode 100644 scripts/e2e/fixtures/roster.mjs create mode 100644 scripts/e2e/journeys/organiser-roster.spec.mjs diff --git a/scripts/e2e/fixtures/roster.mjs b/scripts/e2e/fixtures/roster.mjs new file mode 100644 index 000000000..14c3fa7b6 --- /dev/null +++ b/scripts/e2e/fixtures/roster.mjs @@ -0,0 +1,180 @@ +// The roster surfaces an organiser works before and just after the draw: the +// Registration desk (check-in, its search, Group by dojo), the seed rank boxes +// and the participant Edit dialog on the Participants & seeds page, and a read +// of the draw as it stands (pool membership, bracket slots) on the admin +// preview or on the public viewer. Every selector for those lives here. +import { expect } from '@playwright/test'; +import { dwell } from './pace.mjs'; +import { EXPECT_TIMEOUT_MS } from './devices.mjs'; + +// ---- the Registration desk (admin_registration_desk.jsx) ------------------- + +// Open the desk from the dashboard card (it is a view of /admin, not a URL) +// and pick one competition on its rail, so each row carries that competition's +// own check-in box, number tag and Edit pencil. +export async function openDesk(page, compName) { + await page.goto('/admin'); + await page.getByRole('button', { name: /^Registration desk/ }).tap(); + await expect(page.getByRole('heading', { level: 1, name: 'Registration desk' })).toBeVisible(); + const rail = page.getByRole('navigation', { name: 'Competitions' }); + const item = rail.getByRole('button').filter({ hasText: compName }); + await item.tap(); + await expect(item).toHaveAttribute('aria-current', 'true'); +} + +export const deskSearch = (page) => page.getByRole('textbox', { name: 'Search competitors' }); +export const deskRows = (page) => page.locator('.rd-roster .rd-row'); +export const deskRow = (page, name) => deskRows(page).filter({ has: page.locator('.rd-row__name', { hasText: new RegExp(`^${name}$`) }) }); +export const deskCheck = (page, name) => deskRow(page, name).getByRole('checkbox'); +// " of " and the noun beside it, as one line. +export const deskHeadline = (page) => page.locator('.rd-headline'); + +// Tap one row's check-in box and wait for the box to read the new state. +export async function toggleCheckIn(page, name, on = true) { + const box = deskCheck(page, name); + await box.tap(); + await expect(box).toHaveAttribute('aria-checked', String(on)); +} + +// Type into the desk's search box and wait for the list to settle on it. +export async function searchDesk(page, text) { + const box = deskSearch(page); + await box.fill(text); + await expect(box).toHaveValue(text); +} + +export async function groupByDojo(page, on = true) { + const toggle = page.getByRole('button', { name: 'Group by dojo' }); + if ((await toggle.getAttribute('aria-pressed')) !== String(on)) await toggle.tap(); + await expect(toggle).toHaveAttribute('aria-pressed', String(on)); +} + +// "Check in all N" on one dojo's group header (Group by dojo must be on). +export async function bulkCheckInDojo(page, dojo) { + const group = page.locator('.rd-group').filter({ has: page.locator('.rd-group__name', { hasText: new RegExp(`^${dojo}$`) }) }); + await group.getByRole('button', { name: /^Check in all \d+$/ }).tap(); + await expect(group.getByRole('button', { name: /^Check in all/ })).toHaveCount(0); +} + +// ---- seed rank boxes (admin_participants.jsx) ------------------------------ + +export async function openParticipants(page, id) { + await page.goto(`/admin/competition/${id}/participants`); + await expect(page.getByRole('button', { name: 'Clear seeds' })).toBeVisible(); +} + +const seedBox = (page, name) => page.getByLabel(`Seed rank for ${name}`); + +// Type a rank into a competitor's seed box. Each rank is saved as it is typed +// (the docs say so), so the box is read back after the page is reloaded. +export async function setSeed(page, name, rank) { + const box = seedBox(page, name); + // The box saves 200 ms after the last keystroke (or at once when it loses + // focus), and a person has gone on to the next box long after that. + // Playwright goes on at once, and a reload then drops the save still + // pending: wait for the save that carries this rank to be answered. + const saved = page.waitForResponse((r) => { + const req = r.request(); + if (req.method() !== 'PUT' || !r.ok()) return false; + let body; + try { body = req.postDataJSON(); } catch { return false; } + return Array.isArray(body && body.players) && body.players.some((p) => p.name === name && p.seed === Number(rank)); + }, { timeout: EXPECT_TIMEOUT_MS }); + await box.fill(String(rank)); + await expect(box).toHaveValue(String(rank)); + await saved; +} + +export const seedRankOf = (page, name) => seedBox(page, name).inputValue(); + +// ---- the participant Edit dialog ------------------------------------------- + +export const editDialog = (page) => page.locator('[role="dialog"][aria-labelledby="edit-modal-title"]'); + +export async function openEdit(page, name) { + await page.getByRole('button', { name: `Edit ${name}` }).tap(); + const dlg = editDialog(page); + await expect(dlg).toBeVisible(); + await expect(dlg.locator('#edit-modal-title')).toHaveText(`Edit ${name}`); + return dlg; +} + +// Fill the dialog and tap Save once the dialog's own bounce window has passed. +// Returns the dialog so a caller can see whether it closed (saved) or stayed +// (refused, with a toast). +export async function saveEdit(page, { name, dojo }) { + const dlg = editDialog(page); + const shown = Date.now(); + if (name !== undefined) await dlg.getByLabel(/^Name/).fill(name); + if (dojo !== undefined) await dlg.getByLabel(/^Dojo/).fill(dojo); + await dwell(page, shown); + await dlg.getByRole('button', { name: 'Save', exact: true }).tap(); + return dlg; +} + +// The toast's text, once it shows a message matching `re`. +export async function toastText(page, re) { + const msg = page.locator('.toast.is-visible .toast__msg').filter({ hasText: re }); + await expect(msg).toBeVisible({ timeout: EXPECT_TIMEOUT_MS }); + return (await msg.innerText()).trim(); +} + +// ---- the draw as it stands ------------------------------------------------- + +// Pool name -> the names in it, read from the pools preview (admin or public +// viewer: both draw `.pool` cards). +export async function poolMembers(page) { + await expect(page.locator('.pool').first()).toBeVisible(); + return page.locator('.pool').evaluateAll((pools) => Object.fromEntries(pools.map((p) => [ + p.querySelector('.pool__name').textContent.trim(), + [...p.querySelectorAll('.pool__player-name')].map((e) => (e.querySelector('.numbered-name__text') || e).textContent.trim()), + ]))); +} + +// Bracket slot -> { name, num, dojo }, read from the bracket (admin preview or +// public viewer). A slot is ":a|b"; the match id (m-r1-3) is the +// place in the tree, which a replaced competitor must keep. +export async function bracketSlots(page) { + await expect(page.locator('.bc-match').first()).toBeVisible(); + return page.locator('.bc-match').evaluateAll((matches) => { + const out = {}; + matches.forEach((m) => { + ['a', 'b'].forEach((side) => { + const el = m.querySelector(`.bc-side--${side}`); + const name = el && el.querySelector('.numbered-name__text'); + // A later round shows "Winner of M1" until that match is played. + if (!name || /^(Winner|Loser) of /.test(name.textContent.trim())) return; + const num = el.querySelector('.num-prefix'); + const dojo = el.querySelector('.bc-dojo'); + out[`${m.getAttribute('data-match-id')}:${side}`] = { + name: name.textContent.trim(), + num: num ? num.textContent.trim() : '', + dojo: dojo ? dojo.textContent.trim() : '', + }; + }); + }); + return out; + }); +} + +export const bracketNames = async (page) => Object.values(await bracketSlots(page)).map((s) => s.name); + +export async function openCompetitionTab(page, id, tab) { + await page.goto(`/admin/competition/${id}/${tab}`); +} + +// Generate the draw of a competition whose check-in leaves some competitors +// out: the app asks first, naming how many are excluded. Answer Confirm after +// the dialog's own bounce window and return the sentence it asked. +export async function generateDrawExcluding(page, id, excluded) { + if (!(await page.getByRole('button', { name: 'Generate draw' }).isVisible())) await page.goto(`/admin/competition/${id}`); + await page.getByRole('button', { name: 'Generate draw' }).tap(); + const dlg = page.locator('.modal[role="dialog"]'); + const shown = Date.now(); + await expect(dlg).toContainText(`${excluded} participants are not checked in and will be excluded from the draw`); + const asked = (await dlg.innerText()).replace(/\s+/g, ' ').trim(); + await dwell(page, shown); + await dlg.getByRole('button', { name: 'Confirm' }).tap(); + await expect(page.getByRole('button', { name: 'Discard draw' })).toBeVisible(); + return asked; +} diff --git a/scripts/e2e/fixtures/test.mjs b/scripts/e2e/fixtures/test.mjs index c565330d0..498d43f13 100644 --- a/scripts/e2e/fixtures/test.mjs +++ b/scripts/e2e/fixtures/test.mjs @@ -58,6 +58,13 @@ const CONSOLE_ALLOWLIST = [ codeUnreadable: true, request: (r) => !!r && (/\/reopen$/.test(r.url) || writeStatus(r.postData) === 'running'), }, + { + reason: 'The organiser edits a competitor to the same name AND dojo as another one on the roster (organiser-roster journey): the server refuses the participant PUT and the Edit dialog shows its message. Same name in another dojo is not refused.', + text: /^Failed to load resource: the server responded with a status of 409 \(Conflict\)$/, + url: /\/api\/competitions\/[^/]+\/participants\/[^/]+$/, + code: /^a participant with the same name and dojo already exists$/, + request: (r) => !!r && r.method === 'PUT', + }, ]; // The `error` field of a refusal body; read with a pattern, since the body is diff --git a/scripts/e2e/fixtures/wizard.mjs b/scripts/e2e/fixtures/wizard.mjs index 19f8c139f..3d560ad04 100644 --- a/scripts/e2e/fixtures/wizard.mjs +++ b/scripts/e2e/fixtures/wizard.mjs @@ -56,10 +56,11 @@ async function setNumber(page, label, value) { // numberPrefix up to 3 characters // twoThirdPlaces boolean: "Award two joint 3rd places" (on by default; // untick it for a bronze match) +// checkIn boolean: "Check-in tracking" (officiated tournaments only) export async function createCompetition(page, opts = {}) { const { name, kind, format, teamSize, teamMatchType, poolSizeMode, poolSize, poolWinners, - courts, numberPrefix, twoThirdPlaces, engi, + courts, numberPrefix, twoThirdPlaces, engi, checkIn, } = opts; await page.goto('/admin/create-competition'); await expect(page.getByRole('heading', { level: 1, name: 'Add competition' })).toBeVisible(); @@ -76,6 +77,7 @@ export async function createCompetition(page, opts = {}) { if (twoThirdPlaces !== undefined) { await page.getByRole('checkbox', { name: 'Award two joint 3rd places' }).setChecked(twoThirdPlaces); } + if (checkIn !== undefined) await page.getByRole('checkbox', { name: /^Check-in tracking/ }).setChecked(checkIn); if (courts) { const grp = group(page, 'Assigned shiaijo'); const pills = grp.getByRole('button'); diff --git a/scripts/e2e/journeys/organiser-roster.spec.mjs b/scripts/e2e/journeys/organiser-roster.spec.mjs new file mode 100644 index 000000000..f50bef458 --- /dev/null +++ b/scripts/e2e/journeys/organiser-roster.spec.mjs @@ -0,0 +1,274 @@ +// Organiser roster journey: what an organiser does with the roster around the +// draw, in an officiated tournament, all through the interface. +// +// 1. Check-in on the Registration desk (one by one, by search, by dojo) and +// the draw that follows: only checked-in competitors are drawn, and the +// desk's search finds a competitor by number. +// 2. Seeds typed before the draw: two seeds never share a pool. +// 3. The participant Edit dialog after the draw: a new name and dojo reach +// the court console, the bracket and the public viewer; the same name and +// dojo as another competitor is refused, the same name in another dojo is +// accepted. +// 4. Replacing a drawn competitor who has not fought with someone new: the +// newcomer takes the same slot, the old name is nowhere. +// +// One server for the file (serial); the tournament is made once, in beforeAll, +// and every test seeds its own competition so it reads as a story of its own. +import { test, expect } from '../fixtures/test.mjs'; +import { OPERATOR_DEVICE, PUBLIC_DEVICE } from '../fixtures/devices.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 } from '../fixtures/competition.mjs'; +import { openShiaijo } from '../fixtures/shiaijo.mjs'; +import { openViewer } from '../fixtures/public.mjs'; +import { + openDesk, deskRows, deskRow, deskCheck, deskHeadline, deskSearch, toggleCheckIn, searchDesk, + groupByDojo, bulkCheckInDojo, openParticipants, setSeed, seedRankOf, openEdit, saveEdit, + editDialog, toastText, poolMembers, bracketSlots, bracketNames, openCompetitionTab, generateDrawExcluding, +} from '../fixtures/roster.mjs'; + +// Twelve entrants, four dojos of three, (name, dojo) unique. +const ROSTER_12 = [ + ['Aoi Sato', 'Gyokusen'], ['Gen Ota', 'Gyokusen'], ['Goro Abe', 'Gyokusen'], + ['Ben Carter', 'Thames'], ['Tom Hill', 'Thames'], ['Tia Moss', 'Thames'], + ['Chie Mori', 'Musashi'], ['Mei Ono', 'Musashi'], ['Max Reed', 'Musashi'], + ['Dan Evans', 'Seishinkan'], ['Sho Ito', 'Seishinkan'], ['Sia Lowe', 'Seishinkan'], +]; +// Eight entrants, four dojos of two. +const ROSTER_8 = [ + ['Akira Tanaka', 'Gyokusen'], ['Ben Carter', 'Thames'], ['Chie Mori', 'Musashi'], + ['Dan Evans', 'Seishinkan'], ['Eri Kato', 'Gyokusen'], ['Finn Hale', 'Thames'], + ['Goro Abe', 'Musashi'], ['Hana Ito', 'Seishinkan'], +]; + +const audit = auditLog('organiser-roster'); +const { record } = audit; +let shot; +const ids = {}; + +test.describe.configure({ mode: 'serial' }); + +test.describe('organiser roster', () => { + test.beforeAll(async ({ browser, serverForFile }, testInfo) => { + shot = journeyShots('organiser-roster'); + const server = await serverForFile(testInfo.file); + const ctx = await browser.newContext({ ...OPERATOR_DEVICE, baseURL: server.base }); + try { + const page = await ctx.newPage(); + await createTournament(page, { name: 'Roster Cup', courts: 2 }); + } finally { + await ctx.close(); + } + }); + + test.afterAll(() => audit.flush()); + + test('check-in decides who is drawn, and the desk finds a number', async ({ page }) => { + await login(page); + ids.checkIn = await createCompetition(page, { + name: 'Check-in Cup', kind: 'individual', format: 'knockout', courts: ['A'], numberPrefix: 'K', checkIn: true, + }); + await pasteRoster(page, ids.checkIn, ROSTER_12); + + await openDesk(page, 'Check-in Cup'); + await expect(deskRows(page)).toHaveCount(12); + await expect(deskHeadline(page)).toContainText('0 of 12'); + await shot(page, 'desk-nobody-in'); + + // One at a time, from the row's own box. + await toggleCheckIn(page, 'Aoi Sato'); + // By search: Enter checks in the top match. + await searchDesk(page, 'Tom Hill'); + await expect(deskRows(page)).toHaveCount(1); + await deskSearch(page).press('Enter'); + await expect(deskSearch(page)).toHaveValue(''); + await expect(deskCheck(page, 'Tom Hill')).toHaveAttribute('aria-checked', 'true'); + await expect(deskHeadline(page)).toContainText('2 of 12'); + + // In bulk: a whole dojo from its group header. Thames is left as it is, so + // two Thames competitors (Ben Carter, Tia Moss) never arrive. + await groupByDojo(page, true); + for (const dojo of ['Gyokusen', 'Musashi', 'Seishinkan']) await bulkCheckInDojo(page, dojo); + await expect(deskHeadline(page)).toContainText('10 of 12'); + await shot(page, 'desk-ten-in'); + + // "Unchecked only" leaves exactly the two who have not arrived. + await page.getByRole('button', { name: 'Unchecked only' }).tap(); + await expect(deskRows(page)).toHaveCount(2); + await expect(deskRow(page, 'Ben Carter')).toHaveCount(1); + await expect(deskRow(page, 'Tia Moss')).toHaveCount(1); + record({ journey: 'organiser-roster', action: 'check-in', checkedIn: 10, absent: ['Ben Carter', 'Tia Moss'] }); + + // The draw takes only the ten who are checked in. + const asked = await generateDrawExcluding(page, ids.checkIn, 2); + record({ journey: 'organiser-roster', action: 'generate draw confirm', asked }); + await openCompetitionTab(page, ids.checkIn, 'bracket'); + const names = await bracketNames(page); + const absent = ['Ben Carter', 'Tia Moss']; + const drawn = ROSTER_12.map(([n]) => n).filter((n) => !absent.includes(n)); + expect([...names].sort(), 'the bracket holds exactly the checked-in competitors').toEqual([...drawn].sort()); + await shot(page, 'bracket-checked-in-only'); + + // The desk's number search: bare digits find the competitor whose number + // contains them. Ten were drawn, so K1..K10; "10" is K10 alone. + await openDesk(page, 'Check-in Cup'); + await searchDesk(page, '10'); + await expect(deskRows(page)).toHaveCount(1); + await expect(deskRows(page).first().locator('.rd-tag--number .rd-tag__value')).toHaveText('K10'); + const found = (await deskRows(page).first().locator('.rd-row__name').innerText()).trim(); + expect(drawn, 'the K10 row is one of the drawn').toContain(found); + record({ journey: 'organiser-roster', action: 'desk number search', query: '10', found }); + await shot(page, 'desk-search-number'); + // The two who were left out carry no number. + await searchDesk(page, ''); + await expect(deskRow(page, 'Ben Carter').locator('.rd-tag--number')).toHaveCount(0); + await expect(deskRows(page).locator('.rd-tag--number')).toHaveCount(10); + }); + + test('seeds typed before the draw never share a pool', async ({ page }) => { + await login(page); + ids.seeds = await createCompetition(page, { + name: 'Seed Cup', kind: 'individual', format: 'mixed', poolSize: 4, poolWinners: 2, courts: ['A'], numberPrefix: 'S', + }); + await pasteRoster(page, ids.seeds, ROSTER_8); + + // The first two names on the roster: the order a plain draw would put + // together, which is what makes the rule visible. + await openParticipants(page, ids.seeds); + await setSeed(page, 'Akira Tanaka', 1); + await setSeed(page, 'Ben Carter', 2); + // Each rank is saved as it is typed: they survive a reload. + await page.reload(); + await expect(page.getByRole('button', { name: 'Clear seeds' })).toBeVisible(); + expect(await seedRankOf(page, 'Akira Tanaka')).toBe('1'); + expect(await seedRankOf(page, 'Ben Carter')).toBe('2'); + await shot(page, 'seeds-typed'); + + await generateDraw(page, ids.seeds); + await openCompetitionTab(page, ids.seeds, 'pools'); + const pools = await poolMembers(page); + const poolOf = (n) => Object.keys(pools).find((p) => pools[p].includes(n)); + record({ journey: 'organiser-roster', action: 'seeds placed', pools }); + expect(Object.keys(pools), 'two pools of four').toHaveLength(2); + expect(poolOf('Akira Tanaka'), 'seed 1 is drawn').toBeTruthy(); + expect(poolOf('Ben Carter'), 'seed 2 is drawn').toBeTruthy(); + expect(poolOf('Akira Tanaka'), 'two seeds never share a pool').not.toBe(poolOf('Ben Carter')); + await shot(page, 'pools-seeds-apart'); + }); + + test('an edited name and dojo reach the court, the bracket and the public viewer; duplicates are refused', async ({ page, browser, baseURL }) => { + await login(page); + ids.edit = await createCompetition(page, { + name: 'Edit Cup', kind: 'individual', format: 'knockout', courts: ['B'], numberPrefix: 'E', + }); + await pasteRoster(page, ids.edit, ROSTER_8); + await generateDraw(page, ids.edit); + await openCompetitionTab(page, ids.edit, 'bracket'); + const before = await bracketSlots(page); + const slotOf = (slots, n) => Object.keys(slots).find((k) => slots[k].name === n); + const danSlot = slotOf(before, 'Dan Evans'); + expect(danSlot, 'Dan Evans is drawn').toBeTruthy(); + + // A new name and a new dojo, after the draw. + await openParticipants(page, ids.edit); + await openEdit(page, 'Dan Evans'); + await saveEdit(page, { name: 'Daniel Evans', dojo: 'Seishinkan UK' }); + await expect(editDialog(page)).toHaveCount(0); + await shot(page, 'participants-after-edit'); + + const expectEdited = (slots, where) => { + expect(slots[danSlot], `${where}: the same slot now holds Daniel Evans`).toMatchObject({ + name: 'Daniel Evans', dojo: 'Seishinkan UK', num: before[danSlot].num, + }); + expect(Object.values(slots).map((s) => s.name), `${where}: Dan Evans is nowhere`).not.toContain('Dan Evans'); + }; + await openCompetitionTab(page, ids.edit, 'bracket'); + expectEdited(await bracketSlots(page), 'admin bracket'); + await shot(page, 'bracket-after-edit'); + + await openShiaijo(page, 'B'); + const queue = page.locator('.shiaijo__queue'); + await expect(queue.locator('.numbered-name__text', { hasText: 'Daniel Evans' }).first()).toBeVisible(); + const consoleNames = await queue.locator('.numbered-name__text').allInnerTexts(); + expect(consoleNames.map((n) => n.trim()), 'court console: Dan Evans is nowhere').not.toContain('Dan Evans'); + await shot(page, 'court-after-edit'); + + const spectator = await browser.newContext({ ...PUBLIC_DEVICE, baseURL }); + try { + const phone = await spectator.newPage(); + await openViewer(phone, ids.edit); + await phone.getByRole('button', { name: 'Bracket' }).tap(); + expectEdited(await bracketSlots(phone), 'public viewer'); + await shot(phone, 'viewer-after-edit', { fullPage: true }); + } finally { + await spectator.close(); + } + + // Same name AND same dojo as another competitor: refused, with its reason. + await openParticipants(page, ids.edit); + await openEdit(page, 'Finn Hale'); + await saveEdit(page, { name: 'Akira Tanaka', dojo: 'Gyokusen' }); + const refusal = await toastText(page, /same name and dojo/i); + record({ journey: 'organiser-roster', action: 'duplicate refused', message: refusal }); + await expect(editDialog(page), 'the dialog stays open on a refusal').toBeVisible(); + await shot(page, 'edit-duplicate-refused'); + + // The same name in a different dojo is two different people: accepted. + await saveEdit(page, { dojo: 'Thames' }); + await expect(editDialog(page)).toHaveCount(0); + await openCompetitionTab(page, ids.edit, 'bracket'); + const after = await bracketSlots(page); + const akiras = Object.values(after).filter((s) => s.name === 'Akira Tanaka').map((s) => s.dojo).sort(); + expect(akiras, 'two Akira Tanaka, told apart by dojo').toEqual(['Gyokusen', 'Thames']); + expect(Object.values(after).map((s) => s.name), 'Finn Hale was renamed, not duplicated').not.toContain('Finn Hale'); + await shot(page, 'bracket-namesakes'); + }); + + test('replacing a drawn competitor who has not fought puts the newcomer in the same slot', async ({ page, browser, baseURL }) => { + await login(page); + await openCompetitionTab(page, ids.edit, 'bracket'); + const before = await bracketSlots(page); + const slot = Object.keys(before).find((k) => before[k].name === 'Hana Ito'); + expect(slot, 'Hana Ito is drawn and has not fought').toBeTruthy(); + + await openParticipants(page, ids.edit); + await openEdit(page, 'Hana Ito'); + await saveEdit(page, { name: 'Zed Newman', dojo: 'Shudokan' }); + await expect(editDialog(page)).toHaveCount(0); + await expect(page.getByRole('button', { name: 'Edit Zed Newman' })).toBeVisible(); + await expect(page.getByRole('button', { name: 'Edit Hana Ito' })).toHaveCount(0); + + const expectReplaced = (slots, where) => { + expect(slots[slot], `${where}: the newcomer holds the old slot, with its number`).toMatchObject({ + name: 'Zed Newman', dojo: 'Shudokan', num: before[slot].num, + }); + expect(Object.values(slots).map((s) => s.name), `${where}: Hana Ito is nowhere`).not.toContain('Hana Ito'); + // Nobody else moved. + Object.keys(before).filter((k) => k !== slot && before[k].name !== 'Finn Hale').forEach((k) => { + expect(slots[k]?.num, `${where}: ${k} keeps its number`).toBe(before[k].num); + }); + }; + await openCompetitionTab(page, ids.edit, 'bracket'); + expectReplaced(await bracketSlots(page), 'admin bracket'); + await shot(page, 'bracket-after-replace'); + + await openShiaijo(page, 'B'); + const consoleNames = (await page.locator('.shiaijo__queue .numbered-name__text').allInnerTexts()).map((n) => n.trim()); + expect(consoleNames, 'court console shows Zed Newman').toContain('Zed Newman'); + expect(consoleNames, 'court console: Hana Ito is nowhere').not.toContain('Hana Ito'); + await shot(page, 'court-after-replace'); + + const spectator = await browser.newContext({ ...PUBLIC_DEVICE, baseURL }); + try { + const phone = await spectator.newPage(); + await openViewer(phone, ids.edit); + await phone.getByRole('button', { name: 'Bracket' }).tap(); + expectReplaced(await bracketSlots(phone), 'public viewer'); + } finally { + await spectator.close(); + } + record({ journey: 'organiser-roster', action: 'replace', slot, replaced: before[slot] }); + }); +}); From 398799ed9de29512fff5489d7cab2c2af1ea57f7 Mon Sep 17 00:00:00 2001 From: Ricardo Oliveira Date: Tue, 13 Oct 2026 19:51:18 +0100 Subject: [PATCH 11/31] test(bc-e2rv): organiser admin journeys (export, import, branding, reset, locked mode) Adds organiser-admin.spec.mjs (file mode: results workbook download, import from a folder, logo and sponsor, forgotten-password reset) and organiser-locked.spec.mjs (--lock-password with a hash from the app's own hash-password command), with fixtures/organiser.mjs. A spec file may declare its server's extra args and env with serverOptionsFor; start() takes optional { args, env }, unchanged when absent. One narrow console allowlist entry for the Edit details HEAD probe of /api/branding/logo. Co-Authored-By: Claude Opus 5.5 --- scripts/e2e/fixtures/organiser.mjs | 193 ++++++++++++ scripts/e2e/fixtures/test.mjs | 18 +- scripts/e2e/journeys/organiser-admin.spec.mjs | 280 ++++++++++++++++++ .../e2e/journeys/organiser-locked.spec.mjs | 83 ++++++ scripts/screenshots/lib/server.mjs | 13 +- 5 files changed, 582 insertions(+), 5 deletions(-) create mode 100644 scripts/e2e/fixtures/organiser.mjs create mode 100644 scripts/e2e/journeys/organiser-admin.spec.mjs create mode 100644 scripts/e2e/journeys/organiser-locked.spec.mjs diff --git a/scripts/e2e/fixtures/organiser.mjs b/scripts/e2e/fixtures/organiser.mjs new file mode 100644 index 000000000..d5c864024 --- /dev/null +++ b/scripts/e2e/fixtures/organiser.mjs @@ -0,0 +1,193 @@ +// The organiser's own pages: the Excel download on a competition's Export +// section, the folder import, the forgotten-password reset page, and the +// branding and sponsor uploads on Edit tournament details. Every selector for +// those lives here, with the runtime file makers they need (a PNG, an +// import bundle) and the small zip reader that looks inside a workbook. +import { expect } from '@playwright/test'; +import fs from 'node:fs'; +import path from 'node:path'; +import zlib from 'node:zlib'; +import { dwell } from './pace.mjs'; +import { EXPECT_TIMEOUT_MS } from './devices.mjs'; + +// ------------------------------------------------------------- run files -- + +// A valid PNG of w x h in one flat colour, built here at run time: the +// journeys commit no binaries. A size nothing else in the app has (3 x 2) is +// what lets a journey tell an uploaded image from the bundled default. +export function pngBytes(w, h, [r, g, b] = [200, 30, 30]) { + const chunk = (type, data) => { + const body = Buffer.concat([Buffer.from(type, 'ascii'), data]); + const len = Buffer.alloc(4); + len.writeUInt32BE(data.length); + const crc = Buffer.alloc(4); + crc.writeUInt32BE(zlib.crc32(body)); + return Buffer.concat([len, body, crc]); + }; + const head = Buffer.alloc(13); + head.writeUInt32BE(w, 0); + head.writeUInt32BE(h, 4); + head.writeUInt8(8, 8); // bit depth + head.writeUInt8(2, 9); // colour type: RGB + const row = Buffer.concat([Buffer.from([0]), Buffer.from(Array.from({ length: w }, () => [r, g, b]).flat())]); + const raw = Buffer.concat(Array.from({ length: h }, () => row)); + return Buffer.concat([ + Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]), + chunk('IHDR', head), + chunk('IDAT', zlib.deflateSync(raw)), + chunk('IEND', Buffer.alloc(0)), + ]); +} + +// ---------------------------------------------------------------- workbook -- + +// Every entry of a zip, by name, read from its central directory and inflated +// with node:zlib. Enough for an .xlsx, which is a plain deflated zip. +export function readZip(buf) { + let eocd = -1; + for (let i = buf.length - 22; i >= 0; i -= 1) { + if (buf.readUInt32LE(i) === 0x06054b50) { eocd = i; break; } + } + if (eocd < 0) throw new Error('readZip: no end-of-central-directory record: not a zip'); + const count = buf.readUInt16LE(eocd + 10); + let p = buf.readUInt32LE(eocd + 16); + const entries = new Map(); + for (let n = 0; n < count; n += 1) { + if (buf.readUInt32LE(p) !== 0x02014b50) throw new Error('readZip: bad central directory entry'); + const method = buf.readUInt16LE(p + 10); + const size = buf.readUInt32LE(p + 20); + const nameLen = buf.readUInt16LE(p + 28); + const extraLen = buf.readUInt16LE(p + 30); + const commentLen = buf.readUInt16LE(p + 32); + const local = buf.readUInt32LE(p + 42); + const name = buf.toString('utf8', p + 46, p + 46 + nameLen); + const dataAt = local + 30 + buf.readUInt16LE(local + 26) + buf.readUInt16LE(local + 28); + const packed = buf.subarray(dataAt, dataAt + size); + entries.set(name, method === 0 ? packed : zlib.inflateRawSync(packed)); + p += 46 + nameLen + extraLen + commentLen; + } + return entries; +} + +const xmlText = (s) => s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>') + .replace(/"/g, '"').replace(/'/g, "'"); + +// What a downloaded workbook holds: its sheet names, in order, and all of its +// cell text (shared strings and inline strings, joined), unescaped. +export function readWorkbook(buf) { + const zip = readZip(buf); + const xml = (name) => (zip.get(name) ? zip.get(name).toString('utf8') : ''); + if (!zip.has('xl/workbook.xml')) throw new Error(`not a workbook: parts are ${[...zip.keys()].join(', ')}`); + const sheets = [...xml('xl/workbook.xml').matchAll(/]*\bname="([^"]*)"/g)].map((m) => xmlText(m[1])); + const parts = [...zip.keys()].filter((k) => k === 'xl/sharedStrings.xml' || /^xl\/worksheets\/[^/]+\.xml$/.test(k)); + const text = xmlText(parts.map((k) => xml(k).replace(/<[^>]+>/g, ' ')).join(' ')); + return { sheets, text, parts: [...zip.keys()] }; +} + +// Open a competition's Export section and download its results workbook. +// Resolves to { file, name, bytes } once Playwright's download event has fired. +export async function downloadResultsWorkbook(page, id) { + await page.goto(`/admin/competition/${id}/export`); + const button = page.getByRole('button', { name: 'Download results (.xlsx)' }); + await expect(button).toBeVisible(); + const [download] = await Promise.all([page.waitForEvent('download'), button.tap()]); + const file = await download.path(); + if (!file) throw new Error(`the download failed: ${await download.failure()}`); + return { file, name: download.suggestedFilename(), bytes: fs.readFileSync(file) }; +} + +// ------------------------------------------------------------------ import -- + +// Write an import bundle to `dir`: manifest.yaml plus one participants CSV per +// competition. `comps` is [{ id, name, kind, format, date, roster: [[name, dojo]] }] +// (`date` is DD-MM-YYYY and may be left out). +// The app has no exporter that writes a manifest, so a bundle is assembled by +// hand, as the import page describes ("a folder containing manifest.yaml and +// participant CSV files"). Returns the file paths, manifest first. +export function writeImportBundle(dir, comps) { + fs.mkdirSync(dir, { recursive: true }); + const files = []; + const lines = ['competitions:']; + for (const c of comps) { + const csv = `${c.id}.csv`; + fs.writeFileSync(path.join(dir, csv), c.roster.map((r) => r.join(', ')).join('\n') + '\n'); + lines.push(` - id: ${c.id}`, ` name: ${c.name}`, ` kind: ${c.kind || 'individual'}`, + ` format: ${c.format || 'mixed'}`, ` participants: ${csv}`); + if (c.date) lines.push(` date: ${c.date}`); + files.push(path.join(dir, csv)); + } + const manifest = path.join(dir, 'manifest.yaml'); + fs.writeFileSync(manifest, lines.join('\n') + '\n'); + return [manifest, ...files]; +} + +// Pick `files` on the import page ("Select files individually" opens the +// browser's file chooser), tap Import, answer the confirm, and return once the +// results list is up. +export async function importFiles(page, files) { + await page.goto('/admin/import'); + await expect(page.getByRole('heading', { level: 1, name: 'Import competitions' })).toBeVisible(); + const [chooser] = await Promise.all([ + page.waitForEvent('filechooser'), + page.getByRole('button', { name: 'Select files individually' }).tap(), + ]); + await chooser.setFiles(files); + await expect(page.getByText('manifest found: manifest.yaml')).toBeVisible(); + await page.getByRole('button', { name: 'Import', exact: true }).tap(); + const dialog = page.locator('.modal[role="dialog"]').filter({ has: page.locator('.modal__foot') }).last(); + await dialog.waitFor({ state: 'visible' }); + await dwell(page, Date.now()); + await dialog.getByRole('button', { name: 'Import competitions' }).tap(); + await expect(page.getByText('Import results')).toBeVisible(); +} + +export const importResultRows = (page) => page.getByTestId('import-result-row'); + +// ------------------------------------------------------------------- reset -- + +// Choose a new admin password on the /reset page (file mode, from the machine +// running the server). The page signs the browser straight in on success. +export async function resetPassword(page, password) { + await page.getByLabel('New password', { exact: true }).fill(password); + await page.getByLabel('Confirm new password').fill(password); + await page.getByRole('button', { name: 'Reset password', exact: true }).tap(); +} + +// ------------------------------------------------------ branding, sponsors -- + +export async function openEditTournament(page) { + await page.goto('/admin/edit-tournament'); + await expect(page.getByRole('heading', { level: 1, name: 'Edit tournament details' })).toBeVisible(); +} + +// Upload the tournament logo from `file` (a path). Returns once the page shows +// the logo as the current one (its Remove button is there). +export async function uploadLogo(page, file) { + await page.locator('#branding-logo-file').setInputFiles(file); + await page.getByRole('button', { name: 'Upload logo', exact: true }).tap(); + await expect(page.getByRole('button', { name: 'Remove' }).first()).toBeVisible(); +} + +// Add a sponsor by name and image. Returns once the sponsor is listed. +export async function addSponsor(page, { name, file }) { + await page.locator('#sponsor-name').fill(name); + await page.locator('#sponsor-logo').setInputFiles(file); + await page.getByRole('button', { name: 'Add sponsor' }).tap(); + await expect(page.locator(`img[alt="${name}"]`)).toBeVisible(); +} + +// What an really holds once loaded: its size in pixels and the request +// URL it was fetched from. The tournament logo falls back to the bundled +// /logo.jpeg when none is uploaded, so "an image is on screen" proves nothing; +// the uploaded image's own size does. +export async function loadedImage(locator) { + await expect.poll(() => locator.evaluate((i) => i.complete && i.naturalWidth > 0), { timeout: EXPECT_TIMEOUT_MS }).toBe(true); + return locator.evaluate((i) => ({ w: i.naturalWidth, h: i.naturalHeight, src: i.getAttribute('src') })); +} + +// Today as the manifest's DD-MM-YYYY date (the machine's own day, which is the +// day the first-run form offers the tournament). +export function todayDMY(now = new Date()) { + const pad = (n) => String(n).padStart(2, '0'); + return `${pad(now.getDate())}-${pad(now.getMonth() + 1)}-${now.getFullYear()}`; +} diff --git a/scripts/e2e/fixtures/test.mjs b/scripts/e2e/fixtures/test.mjs index 498d43f13..c8bb7ec0f 100644 --- a/scripts/e2e/fixtures/test.mjs +++ b/scripts/e2e/fixtures/test.mjs @@ -65,6 +65,12 @@ const CONSOLE_ALLOWLIST = [ code: /^a participant with the same name and dojo already exists$/, request: (r) => !!r && r.method === 'PUT', }, + { + reason: 'Edit tournament details probes for a custom logo with HEAD /api/branding/logo, which answers 404 on purpose while none is uploaded (handlers_branding.go: "must keep returning 404 when none is set"); the browser logs it once per page load of a tournament without a logo.', + text: /^Failed to load resource: the server responded with a status of 404 \(Not Found\)$/, + url: /\/api\/branding\/logo$/, + request: (r) => !!r && r.method === 'HEAD', + }, ]; // The `error` field of a refusal body; read with a pattern, since the body is @@ -210,6 +216,15 @@ async function settleLate(c) { } } +// A spec file whose server needs extra command-line arguments or environment +// (the locked-mode spec) declares them once, at load, for its own file: +// `serverOptionsFor(fileURLToPath(import.meta.url), () => ({ args, env }))`. +// A function is called when the file's server is started, so what it needs (a +// hash made by the built binary) is made then and not while the suite loads. +// A file that declares nothing starts the server it always did. +const fileServerOptions = new Map(); +export const serverOptionsFor = (file, options) => { fileServerOptions.set(file, options); }; + 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' @@ -235,7 +250,8 @@ export const test = base.extend({ c.file = file; let server; try { - server = await start('mobile'); + const declared = fileServerOptions.get(file); + server = await start('mobile', typeof declared === 'function' ? declared() : declared); } catch (err) { throw new Error(`${err.message}${fs.existsSync(BIN) ? '' : '\n(make e2e builds the binary before it runs the suite)'}`); } diff --git a/scripts/e2e/journeys/organiser-admin.spec.mjs b/scripts/e2e/journeys/organiser-admin.spec.mjs new file mode 100644 index 000000000..3c3daef4e --- /dev/null +++ b/scripts/e2e/journeys/organiser-admin.spec.mjs @@ -0,0 +1,280 @@ +// The organiser's side jobs, in file mode: the Excel download of a competition +// with a result, importing competitions from a folder, the tournament logo and +// a sponsor, and the forgotten-password reset. Locked mode is its own spec +// file (organiser-locked.spec.mjs), because it is a different server. +// +// One server for the file; the tests run in order and each leaves the server +// for the next. The reset goes LAST: it changes the admin password every +// earlier test signs in with. +// +// Everything is seeded through the interface. The files the journey feeds to +// the app's file inputs (an import bundle, two PNGs) are written to a +// throwaway folder at run time; nothing binary is committed. +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { test, expect } from '../fixtures/test.mjs'; +import { OPERATOR_DEVICE, PUBLIC_DEVICE, EXPECT_TIMEOUT_MS } from '../fixtures/devices.mjs'; +import { journeyShots } from '../fixtures/shots.mjs'; +import { auditLog } from '../fixtures/audit.mjs'; +import { enterTournament, login, signOut } from '../fixtures/setup.mjs'; +import { createCompetition } from '../fixtures/wizard.mjs'; +import { generateDraw, pasteRoster, startCompetition } from '../fixtures/competition.mjs'; +import { openShiaijo } from '../fixtures/shiaijo.mjs'; +import { playRunningBout } from '../fixtures/individual.mjs'; +import { openViewer, openTvBoard, tvBoard } from '../fixtures/public.mjs'; +import { dwell } from '../fixtures/pace.mjs'; +import { + addSponsor, downloadResultsWorkbook, importFiles, importResultRows, loadedImage, openEditTournament, + pngBytes, readWorkbook, resetPassword, todayDMY, uploadLogo, writeImportBundle, +} from '../fixtures/organiser.mjs'; + +const ROSTER = [ + ['Akira Tanaka', 'Gyokusen'], ['Ben Carter', 'Thames'], ['Chie Mori', 'Musashi'], ['Dan Evans', 'Seishinkan'], +]; +const IMPORTED = [ + ['Emi Hoshino', 'Kenshikan'], ['Fumi Yamada', 'Shudokan'], ['Gen Ota', 'Kenshikan'], +]; +const IMPORTED_B = [ + ['Hiro Sasaki', 'Seiwakan'], ['Ino Mizuno', 'Tenshin'], +]; +const NEW_PASSWORD = 'e2e-after-reset'; + +test.describe.configure({ mode: 'serial' }); + +test.describe('organiser admin (file mode)', () => { + let shot; + let work; // throwaway folder for the files fed to file inputs + let compId; // the competition with a result + const audit = auditLog('organiser-admin'); + const { record } = audit; + + test.beforeAll(() => { + shot = journeyShots('organiser-admin'); + work = fs.mkdtempSync(path.join(os.tmpdir(), 'bc-e2e-organiser-')); + }); + test.afterAll(() => { + audit.flush(); + if (work) fs.rmSync(work, { recursive: true, force: true }); + }); + + test('Excel export: a competition with a result downloads as a workbook', async ({ page }) => { + let pair; + await test.step('seed a knockout of four and play one bout', async () => { + await enterTournament(page, { name: 'Organiser Cup', courts: 2 }); + compId = await createCompetition(page, { + name: 'Export Men', kind: 'individual', format: 'knockout', courts: ['A'], numberPrefix: 'X', + }); + await pasteRoster(page, compId, ROSTER); + await generateDraw(page, compId); + await startCompetition(page, compId); + await openShiaijo(page, 'A'); + pair = await playRunningBout(page); + await shot(page, 'bout-played'); + }); + + await test.step('download the results workbook from the Export section', async () => { + const dl = await downloadResultsWorkbook(page, compId); + await shot(page, 'export-section'); + const book = readWorkbook(dl.bytes); + record({ journey: 'organiser-admin', step: 'excel export', file: dl.name, bytes: dl.bytes.length, sheets: book.sheets }); + expect(dl.name).toBe(`results-${compId}.xlsx`); + expect(book.sheets.length).toBeGreaterThan(0); + expect(book.sheets).toContain('Elimination Matches'); + for (const [name] of ROSTER) expect(book.text, `${name} is in the workbook`).toContain(name); + // The played bout's two names sit in the bracket, and its winner is told + // apart from the others by a mark: the workbook is not the blank draw. + expect(book.text).toContain(pair.shiro); + expect(book.text).toContain(pair.aka); + }); + }); + + test('Import competitions: a folder of CSVs lands on the dashboard and the public page', async ({ page, browser, baseURL }) => { + // Two rows: one with a date, and one without (the manifest's date is optional). + const files = writeImportBundle(path.join(work, 'bundle'), [ + { id: 'imp-kyu', name: 'Imported Kyu', kind: 'individual', format: 'knockout', date: todayDMY(), roster: IMPORTED }, + { id: 'imp-nodate', name: 'Imported No Date', kind: 'individual', format: 'mixed', roster: IMPORTED_B }, + ]); + await login(page, 'e2e-operator'); + await test.step('import through the page', async () => { + await importFiles(page, files); + await shot(page, 'import-results'); + const rows = importResultRows(page); + await expect(rows).toHaveCount(2); + for (const name of ['Imported Kyu', 'Imported No Date']) { + const row = rows.filter({ hasText: name }); + await expect(row).toContainText('imported'); + await expect(row).not.toContainText('not imported'); + } + }); + + await test.step('the dashboard lists it and its roster is there', async () => { + await expect(page).toHaveURL(/\/admin$/, { timeout: EXPECT_TIMEOUT_MS }); + await expect(page.getByText('Imported Kyu').first()).toBeVisible(); + await expect(page.getByText('Export Men').first()).toBeVisible(); + await page.goto('/admin/competition/imp-kyu/participants'); + for (const [name] of IMPORTED) await expect(page.getByText(name).first()).toBeVisible(); + await shot(page, 'imported-roster'); + }); + + await test.step('the row with no date is imported but cannot be drawn until a date is set', async () => { + await page.goto('/admin/competition/imp-nodate'); + const generate = page.getByRole('button', { name: 'Generate draw' }); + await expect(generate).toBeVisible(); + const note = await page.getByText(/Cannot start: invalid date/).innerText().catch(() => null); + record({ journey: 'organiser-admin', step: 'import without a date', generateDrawEnabled: await generate.isEnabled(), note }); + await shot(page, 'imported-no-date'); + }); + + await test.step('a spectator sees it listed, and its roster once the imported roster is drawn and started', async () => { + const spectator = await browser.newContext({ ...PUBLIC_DEVICE, baseURL }); + try { + const phone = await spectator.newPage(); + await phone.goto('/'); + await expect(phone.locator('.viewer__body')).toBeVisible(); + await expect(phone.getByText('Imported Kyu').first()).toBeVisible(); + await openViewer(phone, 'imp-kyu'); + await expect(phone.locator('.viewer__body')).toContainText('Not started yet'); + + // The imported roster is a real roster: it draws and starts. + await page.goto('/admin/competition/imp-kyu'); + await generateDraw(page, 'imp-kyu'); + await startCompetition(page, 'imp-kyu'); + await shot(page, 'imported-started'); + await openViewer(phone, 'imp-kyu'); + await phone.getByRole('tab', { name: 'Bracket' }).or(phone.getByRole('button', { name: 'Bracket', exact: true })).first().tap(); + for (const [name] of IMPORTED) await expect(phone.locator('.viewer__body')).toContainText(name); + await shot(phone, 'viewer-imported', { fullPage: true }); + } finally { + await spectator.close(); + } + }); + }); + + test.fixme('bead-pending: a competition imported with no date cannot be drawn or started (invalid date in Settings tab, e.g. empty)', async ({ page }) => { + // The import accepts a manifest row with no date (and the server accepts an + // empty date everywhere), yet the competition page then disables Generate + // draw and Start competition with a note that quotes an empty date. A + // competition the import reported as imported must be drawable. + await login(page, 'e2e-operator'); + await page.goto('/admin/competition/imp-nodate'); + await expect(page.getByRole('button', { name: 'Generate draw' })).toBeEnabled(); + await expect(page.getByText(/Cannot start: invalid date/)).toHaveCount(0); + }); + + test('Branding and sponsors: the logo and a sponsor show on the public page', async ({ page, browser, baseURL }) => { + const logo = path.join(work, 'logo.png'); + const sponsor = path.join(work, 'sponsor.png'); + fs.writeFileSync(logo, pngBytes(3, 2)); + fs.writeFileSync(sponsor, pngBytes(5, 4, [20, 90, 200])); + await login(page, 'e2e-operator'); + + await test.step('upload a logo and add a sponsor on Edit details', async () => { + await openEditTournament(page); + await uploadLogo(page, logo); + await addSponsor(page, { name: 'Acme Dojo Supplies', file: sponsor }); + await shot(page, 'edit-details-uploaded', { fullPage: true }); + }); + + await test.step('the admin top bar shows the uploaded logo', async () => { + await page.goto('/admin'); + const img = page.locator('.topbar__logo').first(); + const size = await loadedImage(img); + expect(size).toMatchObject({ w: 3, h: 2 }); + }); + + await test.step('a spectator phone shows the logo and the sponsor strip', async () => { + const spectator = await browser.newContext({ ...PUBLIC_DEVICE, baseURL }); + try { + const phone = await spectator.newPage(); + await phone.goto('/'); + await expect(phone.locator('.viewer__body')).toBeVisible(); + expect(await loadedImage(phone.locator('.viewer__logo').first())).toMatchObject({ w: 3, h: 2 }); + const strip = phone.getByRole('complementary', { name: 'Sponsors' }); + await strip.scrollIntoViewIfNeeded(); + expect(await loadedImage(strip.getByRole('img', { name: 'Acme Dojo Supplies' }))).toMatchObject({ w: 5, h: 4 }); + await shot(phone, 'viewer-logo-and-sponsor', { fullPage: true }); + + // The docs: sponsor images appear on the public viewer only, never on + // the TV board. + await openTvBoard(phone, 'A'); + await expect(tvBoard(phone).getByRole('img', { name: 'Acme Dojo Supplies' })).toHaveCount(0); + await expect(phone.getByRole('complementary', { name: 'Sponsors' })).toHaveCount(0); + await shot(phone, 'tv-board-no-sponsor'); + + // The docs also list the lobby display for the logo. Recorded, and held + // by the fixme below. + await phone.goto('/display?court=all'); + await expect(phone.getByTestId('lobby-root')).toBeVisible(); + record({ journey: 'organiser-admin', step: 'logo on the lobby display', logoImages: await phone.locator('img[alt="Tournament logo"]').count() }); + await shot(phone, 'lobby-display'); + } finally { + await spectator.close(); + } + }); + }); + + test.fixme('bead-pending: the docs list the lobby display among the surfaces that show the tournament logo, and it shows none', async ({ browser, baseURL }) => { + // run-tournament.md (Branding and sponsors): "Logo: ... shown on the viewer, + // the lobby displays, and the admin screens." The viewer and the admin top + // bar show it (asserted above); /display?court=all, the lobby grid, has no + // logo image at all (display_lobby.jsx). Runs after the upload test. + const spectator = await browser.newContext({ ...PUBLIC_DEVICE, baseURL }); + try { + const phone = await spectator.newPage(); + await phone.goto('/display?court=all'); + await expect(phone.getByTestId('lobby-root')).toBeVisible(); + await expect(phone.getByRole('img', { name: 'Tournament logo' })).toBeVisible(); + } finally { + await spectator.close(); + } + }); + + test('Reset in file mode: Forgot password sets a new one from the loopback browser', async ({ page, browser, baseURL }) => { + // A second organiser signed in on another device: the reset signs it out. + const other = await browser.newContext({ ...OPERATOR_DEVICE, baseURL }); + try { + const otherPage = await other.newPage(); + await login(otherPage, 'e2e-operator'); + + await test.step('from the sign-in dialog, Forgot password? leads to the reset page', async () => { + await page.goto('/admin'); + const dialog = page.locator('.modal.auth'); + await expect(dialog.getByText('Admin sign in')).toBeVisible(); + // The sign-in dialog ignores a tap inside the bounce window of its opening. + await dwell(page, Date.now()); + const forgot = dialog.getByRole('button', { name: 'Forgot password?' }); + await expect(forgot).toBeVisible(); + await forgot.tap(); + const confirm = page.locator('.modal[role="dialog"]').filter({ has: page.locator('.modal__foot') }).last(); + await confirm.waitFor({ state: 'visible' }); + const confirmShown = Date.now(); + await expect(confirm).toContainText('sign out all other admins'); + await dwell(page, confirmShown); + await confirm.getByRole('button', { name: 'Reset password' }).tap(); + await expect(page).toHaveURL(/\/reset$/); + await expect(page.getByRole('heading', { name: 'Reset admin password' })).toBeVisible(); + await shot(page, 'reset-page'); + }); + + await test.step('choose a new password: no old one is asked for, and the browser is signed in', async () => { + await resetPassword(page, NEW_PASSWORD); + await expect(page.getByRole('button', { name: 'Sign out' })).toBeVisible(); + await shot(page, 'after-reset'); + // The tournament and its competitions are untouched: only the password changed. + await page.goto('/admin'); + await expect(page.getByText('Export Men').first()).toBeVisible(); + await expect(page.getByText('Imported Kyu').first()).toBeVisible(); + }); + + await test.step('the new password signs in; the other organiser is asked to sign in again', async () => { + await signOut(page); + await login(page, NEW_PASSWORD); + await expect(otherPage.locator('.modal.auth')).toBeVisible({ timeout: EXPECT_TIMEOUT_MS }); + await shot(otherPage, 'other-admin-signed-out'); + }); + } finally { + await other.close(); + } + }); +}); diff --git a/scripts/e2e/journeys/organiser-locked.spec.mjs b/scripts/e2e/journeys/organiser-locked.spec.mjs new file mode 100644 index 000000000..f8df5d8c0 --- /dev/null +++ b/scripts/e2e/journeys/organiser-locked.spec.mjs @@ -0,0 +1,83 @@ +// Locked mode: the server is started with --lock-password and a bcrypt hash in +// TOURNAMENT_PASSWORD_HASH (operating-modes.md, "Locked mode"), so this is its +// own spec file: a file's server is started once, and the file-mode journeys +// (organiser-admin.spec.mjs) need a different one. +// +// The hash is made by the app's own `hash-password` command, which reads the +// password from standard input, exactly as the docs tell an organiser to. +// The server is started with these options through serverOptionsFor +// (fixtures/test.mjs). +// +// What is asserted: the first-run form says the password is the one behind the +// hash, the sign-in dialog accepts it, offers no "Forgot password?", and the +// /reset page shows the operator-disabled message instead of a form. A wrong +// password is not tried: the refusal is a 401 the browser logs as a console +// error, and the console policy lets none through. +import { execFileSync } from 'node:child_process'; +import { BIN } from '../../screenshots/lib/server.mjs'; +import { fileURLToPath } from 'node:url'; +import { test, expect, serverOptionsFor } from '../fixtures/test.mjs'; +import { journeyShots } from '../fixtures/shots.mjs'; +import { auditLog } from '../fixtures/audit.mjs'; +import { createTournament, login } from '../fixtures/setup.mjs'; +import { dwell } from '../fixtures/pace.mjs'; + +const PASSWORD = 'e2e-locked-secret'; + +// Started with --lock-password and the hash of PASSWORD, made when the file's +// server starts (see serverOptionsFor in fixtures/test.mjs). +serverOptionsFor(fileURLToPath(import.meta.url), () => { + const hash = execFileSync(BIN, ['hash-password'], { input: PASSWORD, encoding: 'utf8' }).trim(); + if (!/^\$2[aby]\$/.test(hash)) throw new Error('hash-password did not print a bcrypt hash'); + return { args: ['--lock-password'], env: { TOURNAMENT_PASSWORD_HASH: hash } }; +}); + +test.describe.configure({ mode: 'serial' }); + +test.describe('organiser locked mode', () => { + let shot; + const audit = auditLog('organiser-locked'); + const { record } = audit; + + test.beforeAll(() => { + shot = journeyShots('organiser-locked'); + }); + test.afterAll(() => audit.flush()); + + test('sign-in works with the hashed password and offers no Forgot password', async ({ page }) => { + await test.step('the first-run form names the password behind the hash', async () => { + await page.goto('/'); + await expect(page.getByRole('heading', { name: 'Welcome to Bracket Creator' })).toBeVisible(); + await expect(page.getByLabel('Admin Password (from TOURNAMENT_PASSWORD_HASH)')).toBeVisible(); + await expect(page.getByText('This server is running in locked mode.')).toBeVisible(); + await shot(page, 'first-run-locked'); + }); + + await test.step('create the tournament with that password', async () => { + await createTournament(page, { name: 'Locked Cup', courts: 1, password: PASSWORD }); + await shot(page, 'created'); + }); + + await test.step('the sign-in dialog has no Forgot password?, and the password signs in', async () => { + await page.getByRole('button', { name: 'Sign out' }).tap(); + await page.goto('/admin'); + const dialog = page.locator('.modal.auth'); + await expect(dialog.getByText('Admin sign in')).toBeVisible(); + await dwell(page, Date.now()); + await expect(dialog.getByRole('button', { name: 'Forgot password?' })).toHaveCount(0); + await shot(page, 'sign-in-no-forgot'); + await login(page, PASSWORD); + await expect(page.getByRole('heading', { level: 1, name: 'Locked Cup' })).toBeVisible(); + record({ journey: 'organiser-locked', step: 'sign in', signedIn: true }); + }); + }); + + test('the /reset page says the operator disabled it', async ({ page }) => { + await page.goto('/reset'); + await expect(page.getByRole('heading', { name: 'Password reset disabled' })).toBeVisible(); + await expect(page.getByText('password reset disabled by the operator', { exact: false })).toBeVisible(); + await expect(page.locator('#reset-password')).toHaveCount(0); + await expect(page.getByRole('button', { name: 'Reset password' })).toHaveCount(0); + await shot(page, 'reset-disabled'); + }); +}); diff --git a/scripts/screenshots/lib/server.mjs b/scripts/screenshots/lib/server.mjs index 5cc518076..46ab3222d 100644 --- a/scripts/screenshots/lib/server.mjs +++ b/scripts/screenshots/lib/server.mjs @@ -71,8 +71,13 @@ function disarmSignals() { signalHandlers.clear(); } -// start("mobile" | "web") -> { base, dataDir, stop() } -export async function start(kind) { +// start("mobile" | "web", { args, env }?) -> { base, dataDir, stop() } +// `args` (extra command-line arguments after the command) and `env` (extra +// environment, applied last) are optional and default to none, so a caller +// that passes neither starts exactly the server it always did. The e2e harness +// uses them to start a locked-mode server (--lock-password and +// TOURNAMENT_PASSWORD_HASH). +export async function start(kind, { args = [], env = {} } = {}) { if (!fs.existsSync(BIN)) { throw new Error(`missing ${BIN} - run "make go/build" first`); } @@ -86,8 +91,8 @@ export async function start(kind) { // var alone is enough and works for both commands. `serve` keeps no state. const command = kind === 'web' ? 'serve' : 'mobile-app'; - const child = spawn(BIN, [command], { - env: { ...process.env, PORT: String(port), TOURNAMENT_DATA_DIR: dataDir }, + const child = spawn(BIN, [command, ...args], { + env: { ...process.env, PORT: String(port), TOURNAMENT_DATA_DIR: dataDir, ...env }, stdio: ['ignore', 'pipe', 'pipe'], }); // Only the startup failure path reads this, so keep a bounded tail rather From 51d4544c413a61684bc842ff443533b124a8903b Mon Sep 17 00:00:00 2001 From: Ricardo Oliveira Date: Tue, 13 Oct 2026 23:04:12 +0100 Subject: [PATCH 12/31] fix(bc-imdt): a folder-import row with no date takes the tournament date A manifest row without a date imported as a success, yet the competition could never be drawn ("Cannot start: invalid date in Settings tab"). The import now gives it the tournament's Day 1 through defaultCompetitionDate, the rule POST /competitions applies (extracted, not respelled), and refuses a row whose date still cannot be resolved with a per-row message. The organiser-admin journey's fixme becomes a step asserting Generate draw is enabled and the date it took; the other fixme is renamed to its bead, bc-lblg. Co-Authored-By: Claude Opus 5.5 --- internal/mobileapp/handlers_competition.go | 17 ++++- internal/mobileapp/handlers_import.go | 11 +++ .../mobileapp/handlers_import_date_test.go | 68 +++++++++++++++++++ .../handlers_import_extra_qualifiers_test.go | 5 ++ .../mobileapp/handlers_import_format_test.go | 1 + .../mobileapp/handlers_import_kind_test.go | 4 ++ internal/mobileapp/handlers_import_test.go | 1 + scripts/e2e/journeys/organiser-admin.spec.mjs | 27 +++----- specs/openapi.yaml | 7 ++ 9 files changed, 120 insertions(+), 21 deletions(-) create mode 100644 internal/mobileapp/handlers_import_date_test.go diff --git a/internal/mobileapp/handlers_competition.go b/internal/mobileapp/handlers_competition.go index 7c44c7566..3b9169b06 100644 --- a/internal/mobileapp/handlers_competition.go +++ b/internal/mobileapp/handlers_competition.go @@ -146,6 +146,19 @@ func extractSeeds(players []domain.Player) []domain.SeedAssignment { // - comp.Date is NOT in the derived day list → return a descriptive error. // // errcheck: no bare ignored returns, always propagated by callers. +// defaultCompetitionDate gives a competition that states no date the +// tournament's Day 1, the one rule every door that creates a competition +// shares (POST /competitions and the folder import). It leaves an explicit +// date alone and reports whether the date is set afterwards, so a door that +// cannot accept a dateless competition can refuse it (a tournament with no +// date gives it nothing to take). +func defaultCompetitionDate(comp *state.Competition, tourn *state.Tournament) bool { + if comp.Date == "" && tourn != nil && tourn.Date != "" { + comp.Date = tourn.Date + } + return comp.Date != "" +} + func validateCompetitionDateInTournament(comp *state.Competition, tourn *state.Tournament) error { if comp.Date == "" || tourn == nil || tourn.Date == "" { return nil @@ -866,9 +879,7 @@ func RegisterCompetitionHandlers(r *gin.RouterGroup, store *state.Store, eng *en // from tournament.date directly (JS), but the backend defaulted to // today when it was empty, using Day 1 is the correct multi-day // behaviour and also keeps server and client in sync. - if comp.Date == "" && createTourn != nil && createTourn.Date != "" { - comp.Date = createTourn.Date - } + defaultCompetitionDate(&comp, createTourn) // Reject a competition date that falls outside the tournament's day // range. Skipped when tournament has no date configured. diff --git a/internal/mobileapp/handlers_import.go b/internal/mobileapp/handlers_import.go index 799412ca2..bd1370039 100644 --- a/internal/mobileapp/handlers_import.go +++ b/internal/mobileapp/handlers_import.go @@ -265,6 +265,17 @@ func importCompetition(store *state.Store, eng *engine.Engine, entry ImportManif return res } + // A row with no date takes the tournament's Day 1, the rule POST + // /competitions applies (defaultCompetitionDate). It has to be settled + // here: a competition with no date can never be drawn ("Cannot start: + // invalid date in Settings tab"), so a row that still has none (the + // tournament has no date to give it) is refused rather than imported + // as a competition the organiser cannot use (bc-imdt). + if !defaultCompetitionDate(comp, importTourn) { + res.Error = "date: the row has no date and the tournament has no date to give it; set the tournament date or add a date to the row" + return res + } + // Cross-file guard symmetry with POST/PUT /competitions: reject a // competition date that falls outside the tournament's day range. if err := validateCompetitionDateInTournament(comp, importTourn); err != nil { diff --git a/internal/mobileapp/handlers_import_date_test.go b/internal/mobileapp/handlers_import_date_test.go new file mode 100644 index 000000000..96c8c9dae --- /dev/null +++ b/internal/mobileapp/handlers_import_date_test.go @@ -0,0 +1,68 @@ +package mobileapp + +import ( + "testing" + + "github.com/gitrgoliveira/bracket-creator/internal/engine" + "github.com/gitrgoliveira/bracket-creator/internal/state" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// bc-imdt: a manifest row with no date used to import as a success, yet the +// competition could never be drawn ("Cannot start: invalid date in Settings +// tab"). The importer now gives it the tournament's Day 1, the rule POST +// /competitions applies (defaultCompetitionDate), and refuses a row whose date +// still cannot be resolved. +func TestImportCompetition_DateDefaulting(t *testing.T) { + newStore := func(t *testing.T, tournDate string) *state.Store { + t.Helper() + store, err := state.NewStore(t.TempDir()) + require.NoError(t, err) + require.NoError(t, store.SaveTournament(&state.Tournament{ + Name: "T", Date: tournDate, DurationDays: 2, Courts: []string{"A"}, + })) + return store + } + + t.Run("a row with no date takes the tournament date", func(t *testing.T) { + store := newStore(t, "11-06-2026") + res := importCompetition(store, engine.New(store), ImportManifestComp{ID: "no-date", Name: "No Date"}, map[string][]byte{}) + require.Emptyf(t, res.Error, "import should succeed: %s", res.Error) + comp, err := store.LoadCompetition("no-date") + require.NoError(t, err) + assert.Equal(t, "11-06-2026", comp.Date) + }) + + t.Run("an explicit date is left unchanged", func(t *testing.T) { + store := newStore(t, "11-06-2026") + res := importCompetition(store, engine.New(store), ImportManifestComp{ID: "day-two", Name: "Day Two", Date: "12-06-2026"}, map[string][]byte{}) + require.Emptyf(t, res.Error, "import should succeed: %s", res.Error) + comp, err := store.LoadCompetition("day-two") + require.NoError(t, err) + assert.Equal(t, "12-06-2026", comp.Date) + }) + + t.Run("a row with no date under a tournament with no date is refused", func(t *testing.T) { + store := newStore(t, "") + res := importCompetition(store, engine.New(store), ImportManifestComp{ID: "stuck", Name: "Stuck"}, map[string][]byte{}) + assert.Contains(t, res.Error, "date:") + assert.Contains(t, res.Error, "tournament has no date") + stored, _ := store.LoadCompetition("stuck") + assert.Nil(t, stored, "a refused row must not be persisted") + }) + + t.Run("a dated row under a tournament with no date still imports", func(t *testing.T) { + store := newStore(t, "") + res := importCompetition(store, engine.New(store), ImportManifestComp{ID: "dated", Name: "Dated", Date: "11-06-2026"}, map[string][]byte{}) + require.Emptyf(t, res.Error, "import should succeed: %s", res.Error) + }) +} + +// seedImportTournamentDate gives a test's tournament a date, which a manifest +// row that states none takes (bc-imdt). The date is the one the importer tests +// that state a date use, so those rows stay inside the tournament's day range. +func seedImportTournamentDate(t testing.TB, store *state.Store) { + t.Helper() + require.NoError(t, store.SaveTournament(&state.Tournament{Name: "T", Date: "12-05-2026", Courts: []string{"A", "B", "C", "D"}})) +} diff --git a/internal/mobileapp/handlers_import_extra_qualifiers_test.go b/internal/mobileapp/handlers_import_extra_qualifiers_test.go index fa72b9889..9e163fe49 100644 --- a/internal/mobileapp/handlers_import_extra_qualifiers_test.go +++ b/internal/mobileapp/handlers_import_extra_qualifiers_test.go @@ -63,6 +63,7 @@ func importOneComp(t *testing.T, r http.Handler, manifest string) ImportResult { func TestImport_ExtraQualifiers_RoundTripsAValidSetting(t *testing.T) { r, store, _, _, tempDir := setupTestRouter(t) + seedImportTournamentDate(t, store) defer os.RemoveAll(tempDir) res := importOneComp(t, r, ` @@ -89,6 +90,7 @@ competitions: func TestImport_ExtraQualifiers_RejectsAnUnknownValue(t *testing.T) { r, store, _, _, tempDir := setupTestRouter(t) + seedImportTournamentDate(t, store) defer os.RemoveAll(tempDir) res := importOneComp(t, r, ` @@ -114,6 +116,7 @@ competitions: func TestImport_ExtraQualifiers_RejectsMaximumSizingPairing(t *testing.T) { r, store, _, _, tempDir := setupTestRouter(t) + seedImportTournamentDate(t, store) defer os.RemoveAll(tempDir) res := importOneComp(t, r, ` @@ -143,6 +146,7 @@ competitions: // the mode, and store the pair the rule forbids. func TestImport_ExtraQualifiers_ValidatedAgainstTheDefaultedSizingMode(t *testing.T) { r, store, _, _, tempDir := setupTestRouter(t) + seedImportTournamentDate(t, store) defer os.RemoveAll(tempDir) res := importOneComp(t, r, ` @@ -171,6 +175,7 @@ competitions: // two cannot answer differently for the same record. func TestImport_ExtraQualifiers_ZeroedForAFormatWithNoPoolFedKnockout(t *testing.T) { r, store, _, _, tempDir := setupTestRouter(t) + seedImportTournamentDate(t, store) defer os.RemoveAll(tempDir) res := importOneComp(t, r, ` diff --git a/internal/mobileapp/handlers_import_format_test.go b/internal/mobileapp/handlers_import_format_test.go index 303aa42e5..4b181e190 100644 --- a/internal/mobileapp/handlers_import_format_test.go +++ b/internal/mobileapp/handlers_import_format_test.go @@ -51,6 +51,7 @@ func TestImport_FormatNormalizesToKnockout(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { r, store, _, _, tempDir := setupTestRouter(t) + seedImportTournamentDate(t, store) defer os.RemoveAll(tempDir) res := importOneComp(t, r, "competitions:\n"+ diff --git a/internal/mobileapp/handlers_import_kind_test.go b/internal/mobileapp/handlers_import_kind_test.go index 32795e321..d09b0f169 100644 --- a/internal/mobileapp/handlers_import_kind_test.go +++ b/internal/mobileapp/handlers_import_kind_test.go @@ -34,6 +34,7 @@ import ( func TestImport_RejectsAnUnrecognisedKind(t *testing.T) { r, store, _, _, tempDir := setupTestRouter(t) + seedImportTournamentDate(t, store) defer os.RemoveAll(tempDir) res := importOneComp(t, r, ` @@ -68,6 +69,7 @@ func TestImport_AcceptsTheKindsTheServerRecognises(t *testing.T) { } { t.Run(tc.name, func(t *testing.T) { r, store, _, _, tempDir := setupTestRouter(t) + seedImportTournamentDate(t, store) defer os.RemoveAll(tempDir) res := importOneComp(t, r, ` @@ -106,6 +108,7 @@ func TestImport_RejectsATeamSizeItsKindForbids(t *testing.T) { } { t.Run(tc.name, func(t *testing.T) { r, store, _, _, tempDir := setupTestRouter(t) + seedImportTournamentDate(t, store) defer os.RemoveAll(tempDir) res := importOneComp(t, r, ` @@ -152,6 +155,7 @@ competitions: // specs/openapi.yaml rather than by weakening the guard. func TestImport_RequiresATeamCompetitionToStateItsSize(t *testing.T) { r, store, _, _, tempDir := setupTestRouter(t) + seedImportTournamentDate(t, store) defer os.RemoveAll(tempDir) // No `team_size:` key at all -- the shape the default was proposed for. diff --git a/internal/mobileapp/handlers_import_test.go b/internal/mobileapp/handlers_import_test.go index 0b8a7e8db..0b678750d 100644 --- a/internal/mobileapp/handlers_import_test.go +++ b/internal/mobileapp/handlers_import_test.go @@ -21,6 +21,7 @@ import ( func TestRegisterImportHandlers(t *testing.T) { r, store, _, _, tempDir := setupTestRouter(t) + seedImportTournamentDate(t, store) defer os.RemoveAll(tempDir) t.Run("Import Successful", func(t *testing.T) { diff --git a/scripts/e2e/journeys/organiser-admin.spec.mjs b/scripts/e2e/journeys/organiser-admin.spec.mjs index 3c3daef4e..6dc5e81f8 100644 --- a/scripts/e2e/journeys/organiser-admin.spec.mjs +++ b/scripts/e2e/journeys/organiser-admin.spec.mjs @@ -90,7 +90,8 @@ test.describe('organiser admin (file mode)', () => { }); test('Import competitions: a folder of CSVs lands on the dashboard and the public page', async ({ page, browser, baseURL }) => { - // Two rows: one with a date, and one without (the manifest's date is optional). + // Two rows: one with a date, and one without (the manifest's date is optional + // and a row without one takes the tournament's). const files = writeImportBundle(path.join(work, 'bundle'), [ { id: 'imp-kyu', name: 'Imported Kyu', kind: 'individual', format: 'knockout', date: todayDMY(), roster: IMPORTED }, { id: 'imp-nodate', name: 'Imported No Date', kind: 'individual', format: 'mixed', roster: IMPORTED_B }, @@ -117,12 +118,13 @@ test.describe('organiser admin (file mode)', () => { await shot(page, 'imported-roster'); }); - await test.step('the row with no date is imported but cannot be drawn until a date is set', async () => { + await test.step('the row with no date took the tournament date and can be drawn (bc-imdt)', async () => { await page.goto('/admin/competition/imp-nodate'); - const generate = page.getByRole('button', { name: 'Generate draw' }); - await expect(generate).toBeVisible(); - const note = await page.getByText(/Cannot start: invalid date/).innerText().catch(() => null); - record({ journey: 'organiser-admin', step: 'import without a date', generateDrawEnabled: await generate.isEnabled(), note }); + await expect(page.getByRole('button', { name: 'Generate draw' })).toBeEnabled(); + await expect(page.getByText(/Cannot start: invalid date/)).toHaveCount(0); + // The tournament's date, as the page head writes it ("13 Oct 2026"). + const day = new Date().toLocaleDateString('en-GB', { day: 'numeric', month: 'short', year: 'numeric' }); + await expect(page.locator('.page-head__sub')).toContainText(day); await shot(page, 'imported-no-date'); }); @@ -151,17 +153,6 @@ test.describe('organiser admin (file mode)', () => { }); }); - test.fixme('bead-pending: a competition imported with no date cannot be drawn or started (invalid date in Settings tab, e.g. empty)', async ({ page }) => { - // The import accepts a manifest row with no date (and the server accepts an - // empty date everywhere), yet the competition page then disables Generate - // draw and Start competition with a note that quotes an empty date. A - // competition the import reported as imported must be drawable. - await login(page, 'e2e-operator'); - await page.goto('/admin/competition/imp-nodate'); - await expect(page.getByRole('button', { name: 'Generate draw' })).toBeEnabled(); - await expect(page.getByText(/Cannot start: invalid date/)).toHaveCount(0); - }); - test('Branding and sponsors: the logo and a sponsor show on the public page', async ({ page, browser, baseURL }) => { const logo = path.join(work, 'logo.png'); const sponsor = path.join(work, 'sponsor.png'); @@ -214,7 +205,7 @@ test.describe('organiser admin (file mode)', () => { }); }); - test.fixme('bead-pending: the docs list the lobby display among the surfaces that show the tournament logo, and it shows none', async ({ browser, baseURL }) => { + test.fixme('bc-lblg: the docs list the lobby display among the surfaces that show the tournament logo, and it shows none', async ({ browser, baseURL }) => { // run-tournament.md (Branding and sponsors): "Logo: ... shown on the viewer, // the lobby displays, and the admin screens." The viewer and the admin top // bar show it (asserted above); /display?court=all, the lobby grid, has no diff --git a/specs/openapi.yaml b/specs/openapi.yaml index 594341d5e..e258db584 100644 --- a/specs/openapi.yaml +++ b/specs/openapi.yaml @@ -655,6 +655,13 @@ paths: `pool_size` and `pool_size_mode`, by contrast, ARE defaulted when omitted (to 4 and `"max"`). + `date` is defaulted too: a row without one takes the tournament's + date (Day 1), as `POST /api/competitions` does, because a + competition with no date can never be drawn. If the tournament + has no date either, the row fails with + `date: the row has no date and the tournament has no date to give it; ...` + while the rest of the import proceeds. + In self-run mode this is an organiser action: it needs `X-Tournament-Password`. security: From 83a5d0a91d236c9f06cced8cc5b10faffd27d534 Mon Sep 17 00:00:00 2001 From: Ricardo Oliveira Date: Tue, 13 Oct 2026 23:29:14 +0100 Subject: [PATCH 13/31] fix(bc-lgnf): Edit details no longer probes a logo that may answer 404 BrandingManager asked HEAD /api/branding/logo whether a logo was set, and the server answers 404 on purpose while none is, so the browser logged a console error on every load. The tournament's theme now carries a derived, read-only hasLogo (omitted when false; the disk filename stays off the wire) and the page reads it, following the tournament when it changes. The HEAD and GET routes are untouched for other callers. The e2e console allowlist entry for the 404 is removed. Co-Authored-By: Claude Opus 5.5 --- internal/mobileapp/handlers_branding.go | 14 +-- .../handlers_branding_haslogo_test.go | 88 ++++++++++++++++++ internal/state/models.go | 14 +++ scripts/e2e/fixtures/test.mjs | 6 -- specs/openapi.yaml | 24 +++++ ...min_branding_no_logo_probe.render.test.jsx | 89 +++++++++++++++++++ web-mobile/js/admin_branding.jsx | 14 +-- 7 files changed, 230 insertions(+), 19 deletions(-) create mode 100644 internal/mobileapp/handlers_branding_haslogo_test.go create mode 100644 web-mobile/js/__tests__/render/admin_branding_no_logo_probe.render.test.jsx diff --git a/internal/mobileapp/handlers_branding.go b/internal/mobileapp/handlers_branding.go index 07f669f0f..021c44804 100644 --- a/internal/mobileapp/handlers_branding.go +++ b/internal/mobileapp/handlers_branding.go @@ -27,9 +27,10 @@ var validBrandingContentTypes = map[string]string{ // RegisterPublicBrandingHandlers wires the unauthenticated GET and HEAD routes // that serve the tournament logo bytes. When no custom logo is configured, GET // redirects to the bundled default (/logo.jpeg) so image consumers never see a -// 404, while HEAD returns 404 so BrandingManager can probe whether a custom -// logo is set. HEAD is registered explicitly because Gin does not auto-route -// HEAD to GET. +// 404. HEAD answers 404 while none is set, as it always has, but the app no +// longer asks: BrandingManager reads theme.hasLogo off the tournament instead +// (bc-lgnf), so a console never logs this 404. HEAD is registered explicitly +// because Gin does not auto-route HEAD to GET. func RegisterPublicBrandingHandlers(r *gin.RouterGroup, store *state.Store) { // serveLogo serves the custom logo when one is configured; otherwise it // calls onMissing, which differs by method (GET redirects to the default, @@ -77,9 +78,10 @@ func RegisterPublicBrandingHandlers(r *gin.RouterGroup, store *state.Store) { r.GET("/branding/logo", func(c *gin.Context) { serveLogo(c, func(c *gin.Context) { c.Redirect(http.StatusFound, "/logo.jpeg") }) }) - // HEAD is used by BrandingManager to probe custom-logo existence without - // fetching the payload, so it must keep returning 404 when none is set. - // Gin does not auto-route HEAD to GET, so register it explicitly. + // HEAD reports custom-logo existence without the payload: 404 when none is + // set. The app reads theme.hasLogo instead of probing, so this is for + // outside callers only; keep the answer. Gin does not auto-route HEAD to + // GET, so register it explicitly. r.HEAD("/branding/logo", func(c *gin.Context) { serveLogo(c, func(c *gin.Context) { c.JSON(http.StatusNotFound, gin.H{"error": "no logo configured"}) }) }) diff --git a/internal/mobileapp/handlers_branding_haslogo_test.go b/internal/mobileapp/handlers_branding_haslogo_test.go new file mode 100644 index 000000000..28ed07b83 --- /dev/null +++ b/internal/mobileapp/handlers_branding_haslogo_test.go @@ -0,0 +1,88 @@ +package mobileapp + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// bc-lgnf: the admin page learns whether a custom logo exists from the +// tournament it already reads (theme.hasLogo), instead of probing +// HEAD /api/branding/logo, which answers 404 while none is set and made the +// browser log a console error. +func TestTournament_ThemeHasLogoFollowsTheUploadedLogo(t *testing.T) { + h, _, cleanup := brandingTestSetup(t) + defer cleanup() + + getTheme := func() map[string]any { + t.Helper() + req := httptest.NewRequest(http.MethodGet, "/api/tournament", nil) + req.Header.Set("X-Tournament-Password", "secret") + w := httptest.NewRecorder() + h.ServeHTTP(w, req) + require.Equal(t, http.StatusOK, w.Code, w.Body.String()) + var body map[string]any + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body)) + theme, _ := body["theme"].(map[string]any) + return theme + } + write := func(method string) { + t.Helper() + var req *http.Request + if method == http.MethodPost { + body, ct := buildBrandingUpload(t, "logo.png", tinyPNG) + req = httptest.NewRequest(method, "/api/branding/logo", body) + req.Header.Set("Content-Type", ct) + } else { + req = httptest.NewRequest(method, "/api/branding/logo", nil) + } + req.Header.Set("X-Tournament-Password", "secret") + w := httptest.NewRecorder() + h.ServeHTTP(w, req) + require.Equal(t, http.StatusOK, w.Code, w.Body.String()) + } + + assert.NotContains(t, getTheme(), "hasLogo", "no logo: the field is absent") + + write(http.MethodPost) + theme := getTheme() + assert.Equal(t, true, theme["hasLogo"], "an uploaded logo is reported") + assert.NotContains(t, theme, "logoPath", "the disk filename stays off the wire") + + write(http.MethodDelete) + assert.NotContains(t, getTheme(), "hasLogo", "a removed logo is no longer reported") +} + +// A client that sends the theme back with hasLogo (it read it from GET) must +// neither lose the logo nor be able to set one this way. +func TestPutTournament_HasLogoOnTheWireIsIgnored(t *testing.T) { + h, _, cleanup := brandingTestSetup(t) + defer cleanup() + + put := func(body string) { + t.Helper() + req := httptest.NewRequest(http.MethodPut, "/api/tournament", strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("X-Tournament-Password", "secret") + w := httptest.NewRecorder() + h.ServeHTTP(w, req) + require.Equal(t, http.StatusOK, w.Code, w.Body.String()) + } + put(`{"name":"Branding Test Cup","password":"secret","courts":["A"],"theme":{"primaryColor":"#112233","hasLogo":true}}`) + + req := httptest.NewRequest(http.MethodGet, "/api/tournament", nil) + req.Header.Set("X-Tournament-Password", "secret") + w := httptest.NewRecorder() + h.ServeHTTP(w, req) + require.Equal(t, http.StatusOK, w.Code) + var body map[string]any + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body)) + theme, _ := body["theme"].(map[string]any) + assert.Equal(t, "#112233", theme["primaryColor"]) + assert.NotContains(t, theme, "hasLogo", "hasLogo is derived from the stored logo, never set by a client") +} diff --git a/internal/state/models.go b/internal/state/models.go index c8477b615..e643ae175 100644 --- a/internal/state/models.go +++ b/internal/state/models.go @@ -1,6 +1,7 @@ package state import ( + "encoding/json" "errors" "fmt" "net/url" @@ -177,6 +178,19 @@ type Theme struct { LogoPath string `yaml:"logo_path,omitempty" json:"-"` // disk filename; served via /api/branding/logo } +// MarshalJSON adds hasLogo (true when a custom logo is uploaded) to the wire +// shape, derived from LogoPath, which stays off the wire. A client learns +// whether a logo exists from the tournament it already holds, so it never +// probes /api/branding/logo and meets a 404 while none is set (bc-lgnf). +// Absent when false, so a theme without a logo serialises exactly as before. +func (t Theme) MarshalJSON() ([]byte, error) { + type plain Theme + return json.Marshal(struct { + plain + HasLogo bool `json:"hasLogo,omitempty"` + }{plain(t), t.LogoPath != ""}) +} + const maxWindowTitleLen = 100 var hexColorRE = regexp.MustCompile(`^#[0-9a-fA-F]{6}$`) diff --git a/scripts/e2e/fixtures/test.mjs b/scripts/e2e/fixtures/test.mjs index c8bb7ec0f..db200fcc7 100644 --- a/scripts/e2e/fixtures/test.mjs +++ b/scripts/e2e/fixtures/test.mjs @@ -65,12 +65,6 @@ const CONSOLE_ALLOWLIST = [ code: /^a participant with the same name and dojo already exists$/, request: (r) => !!r && r.method === 'PUT', }, - { - reason: 'Edit tournament details probes for a custom logo with HEAD /api/branding/logo, which answers 404 on purpose while none is uploaded (handlers_branding.go: "must keep returning 404 when none is set"); the browser logs it once per page load of a tournament without a logo.', - text: /^Failed to load resource: the server responded with a status of 404 \(Not Found\)$/, - url: /\/api\/branding\/logo$/, - request: (r) => !!r && r.method === 'HEAD', - }, ]; // The `error` field of a refusal body; read with a pattern, since the body is diff --git a/specs/openapi.yaml b/specs/openapi.yaml index e258db584..09aa79729 100644 --- a/specs/openapi.yaml +++ b/specs/openapi.yaml @@ -6035,6 +6035,30 @@ components: `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). + theme: + type: object + description: > + Optional branding. Absent when nothing is set (a theme holding + only a logo is returned as `{"hasLogo": true}`). + properties: + primaryColor: + type: string + description: Accent colour as `#rrggbb`. + accentSoftColor: + type: string + description: Soft accent (background tint) as `#rrggbb`. + windowTitle: + type: string + maxLength: 100 + description: Browser tab / window title. + hasLogo: + type: boolean + readOnly: true + description: > + True when a custom logo has been uploaded; absent otherwise. + Derived by the server, so a value sent on PUT is ignored. A + client reads it instead of probing `HEAD /api/branding/logo`, + which answers 404 while no logo is set. TournamentWithCompetitions: description: > The response shape for POST and PUT /api/tournament: every Tournament diff --git a/web-mobile/js/__tests__/render/admin_branding_no_logo_probe.render.test.jsx b/web-mobile/js/__tests__/render/admin_branding_no_logo_probe.render.test.jsx new file mode 100644 index 000000000..12a50ec79 --- /dev/null +++ b/web-mobile/js/__tests__/render/admin_branding_no_logo_probe.render.test.jsx @@ -0,0 +1,89 @@ +// bc-lgnf: Edit tournament details learns whether a custom logo is set from the +// tournament it already holds (theme.hasLogo), so it sends no request that can +// answer 404 for "no logo". It used to probe HEAD /api/branding/logo, which the +// server answers 404 on purpose while none is uploaded, and the browser logged +// a console error on every load. +import React from 'react'; +import { render, screen, 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 API = { + uploadBrandingLogo: vi.fn().mockResolvedValue({ logoPath: 'logo.png' }), + deleteBrandingLogo: vi.fn().mockResolvedValue({}), +}; + +let restoreGlobals; +let BrandingManager; + +beforeAll(async () => { + restoreGlobals = installWindowStubs({ API, confirmDialog: vi.fn().mockResolvedValue(true) }); + ({ BrandingManager } = await import('../../admin_branding.jsx')); +}); +afterAll(() => restoreGlobals()); + +let fetchSpy; +beforeEach(() => { + fetchSpy = vi.fn().mockResolvedValue({ ok: false, status: 404 }); + vi.stubGlobal('fetch', fetchSpy); +}); +afterEach(() => vi.unstubAllGlobals()); + +const logoRequests = () => fetchSpy.mock.calls.filter(([url]) => String(url).includes('/api/branding/logo')); + +const mount = async (theme) => { + await act(async () => { + render( {}} onThemeChange={() => {}} />); + }); +}; + +describe('BrandingManager and the custom logo', () => { + it('with no logo: sends no logo request, offers Upload logo', async () => { + await mount({}); + expect(logoRequests()).toHaveLength(0); + expect(fetchSpy).not.toHaveBeenCalled(); + expect(screen.getByRole('button', { name: 'Upload logo' })).toBeTruthy(); + expect(screen.queryByText('Current logo')).toBeNull(); + }); + + it('with no theme at all: sends no request either', async () => { + await mount(undefined); + expect(fetchSpy).not.toHaveBeenCalled(); + expect(screen.queryByText('Current logo')).toBeNull(); + }); + + it('with a logo set: shows it without asking the server', async () => { + await mount({ hasLogo: true }); + expect(fetchSpy).not.toHaveBeenCalled(); + expect(screen.getByText('Current logo')).toBeTruthy(); + expect(screen.getByRole('button', { name: 'Replace logo' })).toBeTruthy(); + }); + + it('uploading then removing a logo flips the preview with no probe', async () => { + await mount({}); + const input = screen.getByLabelText(/Upload logo/); + const file = new File([new Uint8Array([1, 2, 3])], 'logo.png', { type: 'image/png' }); + await act(async () => { fireEvent.change(input, { target: { files: [file] } }); }); + await act(async () => { fireEvent.click(screen.getByRole('button', { name: 'Upload logo' })); }); + expect(API.uploadBrandingLogo).toHaveBeenCalledTimes(1); + expect(screen.getByText('Current logo')).toBeTruthy(); + + await act(async () => { fireEvent.click(screen.getByRole('button', { name: 'Remove' })); }); + expect(API.deleteBrandingLogo).toHaveBeenCalledWith('pw'); + expect(screen.queryByText('Current logo')).toBeNull(); + expect(fetchSpy).not.toHaveBeenCalled(); + }); + + it('follows the tournament when another organiser changes the logo', async () => { + let view; + await act(async () => { + view = render( {}} onThemeChange={() => {}} />); + }); + expect(screen.queryByText('Current logo')).toBeNull(); + await act(async () => { + view.rerender( {}} onThemeChange={() => {}} />); + }); + expect(screen.getByText('Current logo')).toBeTruthy(); + expect(fetchSpy).not.toHaveBeenCalled(); + }); +}); diff --git a/web-mobile/js/admin_branding.jsx b/web-mobile/js/admin_branding.jsx index 59c13dfe7..a6208c9e2 100644 --- a/web-mobile/js/admin_branding.jsx +++ b/web-mobile/js/admin_branding.jsx @@ -39,17 +39,17 @@ function BrandingManager({ tournament, password, showToast, onThemeChange }) { const [primaryColor, setPrimaryColor] = useStateBr(theme.primaryColor || "#1d3557"); const [accentSoftColor, setAccentSoftColor] = useStateBr(theme.accentSoftColor || "#e7eaf3"); const [windowTitle, setWindowTitle] = useStateBr(theme.windowTitle || ""); - const [hasLogo, setHasLogo] = useStateBr(false); + // Whether a custom logo is set comes with the tournament (theme.hasLogo), so + // the page never probes /api/branding/logo and meets a 404 while none is set + // (bc-lgnf). Upload and Remove below set it at once; the effect follows the + // tournament when it changes (an SSE reload, another organiser's upload). + const tournamentHasLogo = !!theme.hasLogo; + const [hasLogo, setHasLogo] = useStateBr(tournamentHasLogo); const [logoKey, setLogoKey] = useStateBr(0); // bump to force img reload const [busy, setBusy] = useStateBr(false); const fileRef = useRefBr(null); - // Probe whether a logo exists on mount. - useEffectBr(() => { - fetch('/api/branding/logo', { method: 'HEAD' }) - .then(r => setHasLogo(r.ok)) - .catch(() => setHasLogo(false)); - }, [logoKey]); + useEffectBr(() => { setHasLogo(tournamentHasLogo); }, [tournamentHasLogo]); // Sync incoming theme changes (e.g. after a tournament SSE reload). // Always update pickers/inputs, but only propagate to the parent when From bdc6fb0c7c82e10a91e81c0cc82c93bb4ddd3575 Mon Sep 17 00:00:00 2001 From: Ricardo Oliveira Date: Wed, 14 Oct 2026 00:00:34 +0100 Subject: [PATCH 14/31] test(bc-e2rv): e2e polish: condition waits, one owner per helper, tighter fixmes Settle-before-read sleeps become condition waits: a state, a write's answer (nextMatchWrite/writeLanded), or, where the outcome is the thing under audit, the new rendered/untilStable settle helpers (pace.mjs). The sleeps that stay (scenario time, and negatives where an absence is the evidence) are marked "Deliberate:". The swallowed-assert probes in smoke and the team journey are real assertions. One owner each: armTwoTap (finishTeam, endMatch, armFinish, saveEngiResult), ipponOnRow (boutIppon and awardBoutIppon), correctCompleted with a live-bout reader (correctAcross), ensureCoarse (pointer.mjs). 15 s literals use EXPECT_TIMEOUT_MS. The bc-adna, bc-seqc and bc-pubq fixmes are tightened to their beads' acceptance (still fixme). The kachinuki correction step paces its strikes past each side's bounce window, which makes bout 1 M K again and the encounter total PW 8-3 (7-3 after the reopened bout 8), where an unpaced pair left it at M and the expectations had followed. Co-Authored-By: Claude Opus 5.5 --- scripts/e2e/README.md | 60 ++++++-- scripts/e2e/fixtures/engi.mjs | 6 +- scripts/e2e/fixtures/individual.mjs | 24 +++- scripts/e2e/fixtures/kachinuki.mjs | 41 ++---- scripts/e2e/fixtures/pace.mjs | 42 ++++++ scripts/e2e/fixtures/pointer.mjs | 13 ++ scripts/e2e/fixtures/scoring.mjs | 37 ++++- scripts/e2e/fixtures/shared-court.mjs | 37 ++--- scripts/e2e/fixtures/shots.mjs | 4 +- scripts/e2e/fixtures/team.mjs | 41 +++--- scripts/e2e/fixtures/test.mjs | 10 +- scripts/e2e/journeys/engi.spec.mjs | 2 + .../knockout-mixed-individual.spec.mjs | 109 +++++++------- .../knockout-mixed-kachinuki.spec.mjs | 134 ++++++++++-------- .../e2e/journeys/knockout-mixed-team.spec.mjs | 89 +++++++----- scripts/e2e/journeys/moved-match.spec.mjs | 48 +++++-- scripts/e2e/journeys/shared-shiaijo.spec.mjs | 120 ++++++++++------ scripts/e2e/journeys/smoke.spec.mjs | 12 +- 18 files changed, 505 insertions(+), 324 deletions(-) diff --git a/scripts/e2e/README.md b/scripts/e2e/README.md index 542dac502..3e8a34d45 100644 --- a/scripts/e2e/README.md +++ b/scripts/e2e/README.md @@ -48,20 +48,26 @@ browser, `--debug` opens the inspector, and a failing test leaves a trace fixtures/setup.mjs first-run tournament form, sign in, sign out, and enterTournament (sign in, or create the tournament when the file's server has none): the one owner - fixtures/pace.mjs the dwell the app's tap-bounce guard needs (below) + fixtures/pace.mjs the dwell the app's tap-bounce guard needs (below), + and the two settle helpers, rendered and + untilStable (Waiting for the app) fixtures/wizard.mjs the create-competition wizard fixtures/competition.mjs roster paste box, Generate / Discard draw, Start 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, Start - match (startMatch), clearMarks, and the Finish - check every editor shares + match (startMatch), clearMarks, the Finish check + every editor shares (finishMatch, which the team + sheet's finishTeam is), and the wait for a match + write's answer (nextMatchWrite, writeLanded) 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 + row) and the team score sheet's bout rows (the + strike on a row, ipponOnRow, is shared with the + kachinuki sheet) fixtures/kachinuki.mjs the kachinuki sheet: bouts, Record bout, Encho, End match, the correction doors fixtures/engi.mjs the engi (kata pairs) flag editor: flag counters, entering @@ -72,7 +78,8 @@ browser, `--debug` opens the inspector, and a failing test leaves a trace 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 + fixtures/pointer.mjs the coarse pointer: check, restore, ensureCoarse + (the one "if not coarse, 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, one line each: @@ -181,8 +188,9 @@ ippon helpers. Start and Finish are `fixtures/scoring.mjs`'s own, not that library's, which clicks and can return without having acted. `startMatch` taps the overlay editor's Start match, fails when the button never appears, and returns once the button has left. A journey taps Finish, on the inline editor as -well as the overlay, and `finishMatch` (and -`finishTeam`, through the same check) returns only once the write landed. It +well as the overlay, and `finishMatch` (the team sheet's `finishTeam` is the +same function, given the sheet as its root) 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 @@ -253,16 +261,44 @@ inside it, so `fixtures/pace.mjs` holds the dwell (the screenshot harness's `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. Into a fresh layer: `login`, `hastyConfirm`, `hastyShiaijoConfirm`, `lineupsNameSlot`. Onto an armed button, -through `settled`: `armFinish` (so `finishMatch`), `finishTeam`, `endMatch`, -`saveEngiResult`. Per side of a bout, through `tapOnSide`, which keeps the time -of the last tap on each side: `awardIppon` and `boutIppon`. `awardBoutIppon` -taps by itself and dwells after its tap instead, so the next strike is ready. -A journey that taps such a thing by hand does the same. `doubleTap` is the one +through `armTwoTap` (tap, wait for the armed state, out the dwell): `armFinish` +(so `finishMatch` and `finishTeam`), `endMatch`, `saveEngiResult`. Per side of +a bout, through `tapOnSide`, which keeps the time of the last tap on each side: +`awardIppon` and `ipponOnRow` (the strike `boutIppon` makes on the team sheet +and `awardBoutIppon` on the kachinuki sheet, which both wait for the board and +the autosave). 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. `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. +## Waiting for the app + +A journey waits for what it is about to read, and never for a guessed time. +In order of preference: a state (`expect(...).toHave...`, `expect.poll`); the +server's answer to a write (`nextMatchWrite(page)` before the tap that causes +it, `writeLanded(answered)` after: the sync pill reads "Synced" until the +autosave debounce has run, so it cannot say an edit was saved); and, when the +outcome is the thing under audit and no single state names it, one of two +settle helpers in `fixtures/pace.mjs`: + +- `rendered(page)` waits two animation frames, for the read after a tap whose + effect varies by design and is local to the page (the control next to the + intended one, a double tap). +- `untilStable(read)` polls `read()` until two reads 400 ms apart agree and + returns that value, for a screen that refetches after the action (a reload, + Back and Forward, a write's echo) and ends in a state the journey records + rather than expects. + +`page.waitForTimeout` stays in four places, each marked `Deliberate:` or +named for what it is: the dwell of a tap's pace (above); the time away of an +`interrupt` (`awayMs`) and the operator's own delay a journey names ("3 s +later", "300 ms after Finish"); and a NEGATIVE, where the journey asserts that +nothing happens (no second bout appended by a bounced tap, no start after a +decision, a taken-back mark staying taken back after a late echo). An absence +has no state to wait for, so the time is the evidence, and a single read +straight after the action would pass whatever came later. + ## The console policy `fixtures/test.mjs` listens to every page of every browser context a test diff --git a/scripts/e2e/fixtures/engi.mjs b/scripts/e2e/fixtures/engi.mjs index 2083fccd2..18b011325 100644 --- a/scripts/e2e/fixtures/engi.mjs +++ b/scripts/e2e/fixtures/engi.mjs @@ -7,7 +7,7 @@ import { expect } from '@playwright/test'; import { INLINE_EDITOR } from '../../screenshots/lib/editor.mjs'; import { beforeFinish, finishLanded } from './scoring.mjs'; -import { settled } from './pace.mjs'; +import { armTwoTap } from './pace.mjs'; import { sides, upNextCard } from './shiaijo.mjs'; // The editor panel itself (inline) or its dialog (overlay). @@ -73,9 +73,7 @@ export async function enterFlags(page, { aka = 0, shiro = 0 }, root = INLINE_EDI // "Tap again to ..."), and return once the write has landed (finishLanded). export async function saveEngiResult(page, root = INLINE_EDITOR) { const ed = page.locator(root).first(); - await engiSubmit(page, root).tap(); - const armed = engiSubmit(page, root).filter({ hasText: /^Tap again to/ }); - await settled(armed); + const armed = await armTwoTap(engiSubmit(page, root), engiSubmit(page, root).filter({ hasText: /^Tap again to/ })); const before = await beforeFinish(ed); await armed.tap(); return finishLanded(ed, before); diff --git a/scripts/e2e/fixtures/individual.mjs b/scripts/e2e/fixtures/individual.mjs index d076dee83..1db66a5f0 100644 --- a/scripts/e2e/fixtures/individual.mjs +++ b/scripts/e2e/fixtures/individual.mjs @@ -57,10 +57,10 @@ export const ROSTER_4_JOINT = [ export const winnerOf = ({ shiro, aka }) => (shiro.localeCompare(aka) <= 0 ? 'shiro' : 'aka'); // The two names an editor shows, Shiro first. -export async function editorSides(page, root = INLINE_EDITOR) { +export async function editorSides(page, root = INLINE_EDITOR, { timeout } = {}) { 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(); + const shiro = (await ed.locator('.sb-side--shiro .numbered-name__text').first().innerText({ timeout })).trim(); + const aka = (await ed.locator('.sb-side--aka .numbered-name__text').first().innerText({ timeout })).trim(); return { shiro, aka }; } @@ -165,15 +165,25 @@ export async function playRunningBout(page, { waza = 'M' } = {}) { return { ...pair, winner: side }; } +// The bout the console's inline editor is running on the individual board, as +// its pairing, or null when no bout runs there. The editor can leave between +// the two reads (a send back lands), which is a bout no longer running: the +// pairing read then gives up quickly rather than waiting for a gone editor. +const liveIndividualBout = async (page) => ( + (await inlineRunning(page)) ? editorSides(page, INLINE_EDITOR, { timeout: 1_000 }).catch(() => null) : null); + // 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) { +// `readLive(page)` reads the bout the console's editor is running (null when +// none): the individual board by default; fixtures/shared-court.mjs passes +// liveBout, which reads a team sheet as well (correctAcross). +export async function correctCompleted(page, pair, { readLive = liveIndividualBout } = {}) { const done = completedRowFor(page, pair); - const live = (await inlineRunning(page)) ? await editorSides(page) : null; + const live = await readLive(page); await done.getByRole('button', { name: 'Correct' }).tap(); let refusal = null; if (live) { @@ -182,12 +192,12 @@ export async function correctCompleted(page, pair) { 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), + editorStillOnLiveBout: JSON.stringify(await readLive(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 expect.poll(() => readLive(page), { timeout: 10_000 }).toBeNull(); await done.getByRole('button', { name: 'Correct' }).tap(); } await expect(inlineEditor(page).first().locator('.editor-head-pill')).toHaveText('CORRECTION'); diff --git a/scripts/e2e/fixtures/kachinuki.mjs b/scripts/e2e/fixtures/kachinuki.mjs index c8995657c..85c29c58f 100644 --- a/scripts/e2e/fixtures/kachinuki.mjs +++ b/scripts/e2e/fixtures/kachinuki.mjs @@ -13,24 +13,12 @@ // Locator.tap(), so the coarse-pointer hit test decides what is hit, as it // does for a thumb. import { expect } from '@playwright/test'; -import { dwell, settled } from './pace.mjs'; +import { armTwoTap } from './pace.mjs'; import { EXPECT_TIMEOUT_MS } from './devices.mjs'; import { clearMarks } from './scoring.mjs'; -import { isCoarse, restoreCoarse } from './pointer.mjs'; import { inlineEditor, sides, upNextCard } 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 -// 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) { - if (await isCoarse(page)) return { coarseLost: false }; - await restoreCoarse(page); - return { coarseLost: true, restoredBy: 'CDP Emulation.setTouchEmulationEnabled (in place)' }; -} - export const editor = (page) => inlineEditor(page).filter({ has: page.locator('.team-bouts-scroll') }).first(); // ---------------------------------------------------------------- lineup -- @@ -132,17 +120,11 @@ export const boutMiddle = async (row) => (await T.rowMiddle(row).first().innerTe // bounded clearMarks, fixtures/scoring.mjs); returns the taps made. export const clearBoutMarks = (row, side, opts) => clearMarks(boutFilled(row, side), opts); -// 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 -// passed (fixtures/pace.mjs), so the helper returns after it. -export async function awardBoutIppon(page, side, waza = 'M') { - const row = currentBout(page); - const before = await boutFilled(row, side).count(); - const tappedAt = Date.now(); - await boutIpponButton(row, side, waza).tap(); - await expect(boutFilled(currentBout(page), side)).toHaveCount(before + 1); - await dwell(page, tappedAt); -} +// Award one ippon on the current bout and return once the board shows it and +// the autosave has landed (fixtures/team.mjs ipponOnRow, the one strike the +// team sheet shares). A second ippon on the same side waits out the bounce +// window of the first (fixtures/pace.mjs). +export const awardBoutIppon = (page, side, waza = 'M') => T.ipponOnRow(editor(page), () => currentBout(page), 'current bout', side, waza); // 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). @@ -163,13 +145,10 @@ export async function winBout(page, side) { // 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(); - // The second tap counts once the arming tap's bounce window has passed. - await settled(btn); - await btn.tap(); - return armed; + const armed = await armTwoTap(btn, btn.filter({ hasText: /^Tap again/ })); + const label = (await armed.innerText()).trim(); + await armed.tap(); + return label; } // ------------------------------------------------------------ pool play -- diff --git a/scripts/e2e/fixtures/pace.mjs b/scripts/e2e/fixtures/pace.mjs index d64241dab..e7bc84090 100644 --- a/scripts/e2e/fixtures/pace.mjs +++ b/scripts/e2e/fixtures/pace.mjs @@ -20,6 +20,7 @@ import fs from 'node:fs'; import { fileURLToPath } from 'node:url'; import { TAP_DWELL_MS } from '../../screenshots/lib/editor.mjs'; +import { EXPECT_TIMEOUT_MS } from './devices.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 @@ -48,6 +49,47 @@ export async function settled(locator) { await dwell(locator.page(), Date.now()); } +// The first tap of a two-tap commit (Finish, End match, Save result): tap +// `button`, wait for `armed` (the same control in its armed state) to show, and +// out the dwell, so the second tap, which the caller makes on the returned +// `armed`, is a deliberate one. The one owner of that arm-settle-tap sequence. +export async function armTwoTap(button, armed) { + await button.tap(); + await settled(armed); + return armed; +} + +// Settle before a read that no single state names. A journey that taps the +// control NEXT to the intended one (or taps twice) cannot say what it will see: +// the outcome varies by design and is what the audit row records, so there is +// no state to wait for. Two real frames later the page has drawn whatever the +// tap changed locally (a mark, a panel, a dialog, a list), which is what the +// read that follows is of. A change that needs the server's answer is not +// covered; wait for that answer (scoring.mjs nextMatchWrite) or use +// untilStable. +export function rendered(page) { + return page.evaluate(() => new Promise((done) => { + requestAnimationFrame(() => requestAnimationFrame(() => done())); + })); +} + +// Settle before a read that follows the server's data: poll `read()` until two +// reads `quietMs` apart agree, then return that value. For a screen that +// refetches after an action (a reload, Back and Forward, a write's echo) whose +// end state the journey cannot name, such as a probe of what the app does. The +// answer is the last read, so the caller need not read again. +export async function untilStable(read, { quietMs = 400, timeout = EXPECT_TIMEOUT_MS } = {}) { + const deadline = Date.now() + timeout; + let last = JSON.stringify(await read()); + for (;;) { + await new Promise((done) => setTimeout(done, quietMs)); + const now = JSON.stringify(await read()); + if (now === last) return JSON.parse(now === undefined ? 'null' : now); + if (Date.now() > deadline) throw new Error(`untilStable: the read kept changing for ${timeout} ms (last: ${now})`); + last = now; + } +} + // page -> Map(side key -> when the last tap on that side finished). const sideTaps = new WeakMap(); diff --git a/scripts/e2e/fixtures/pointer.mjs b/scripts/e2e/fixtures/pointer.mjs index 07c033ab9..8776a2b2f 100644 --- a/scripts/e2e/fixtures/pointer.mjs +++ b/scripts/e2e/fixtures/pointer.mjs @@ -30,6 +30,19 @@ export async function restoreCoarse(page) { } } +// Is the page still (pointer: coarse)? If an interruption or a fullPage +// screenshot lost it, every tap-floor rule in styles.css is off for the rest of +// the test, so put it back in place (restoreCoarse, no reload, so the bout is +// not interrupted a second time) and say whether it had been lost, so an audit +// row can name the pointer a measurement was made at. The one owner of "if not +// coarse, restore": the page fixture, the second devices, the screenshot +// camera and the kachinuki interruptions all ask it. +export async function ensureCoarse(page) { + if (await isCoarse(page)) return { coarseLost: false }; + await restoreCoarse(page); + return { coarseLost: true, restoredBy: 'CDP Emulation.setTouchEmulationEnabled (in place)' }; +} + // 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. diff --git a/scripts/e2e/fixtures/scoring.mjs b/scripts/e2e/fixtures/scoring.mjs index 9e503da01..3510e3142 100644 --- a/scripts/e2e/fixtures/scoring.mjs +++ b/scripts/e2e/fixtures/scoring.mjs @@ -16,7 +16,7 @@ import { expect } from '@playwright/test'; import { EDITOR, INLINE_EDITOR, TAP_DWELL_MS } from '../../screenshots/lib/editor.mjs'; import { EXPECT_TIMEOUT_MS } from './devices.mjs'; -import { settled, tapOnSide } from './pace.mjs'; +import { armTwoTap, tapOnSide } from './pace.mjs'; import { courtIdle, editorIdentity } from './shiaijo.mjs'; export { EDITOR, INLINE_EDITOR }; @@ -101,19 +101,42 @@ export async function awardIppon(page, side, waza = 'M', root = INLINE_EDITOR) { await expect(syncState(page, root)).toHaveText('Synced'); } +// `root` is an editor's selector or its Locator (the team sheet's callers hold one). +const within = (page, root) => (typeof root === 'string' ? page.locator(root) : root); + // 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) +export const finishButton = (page, root = INLINE_EDITOR) => within(page, root) .locator('button').filter({ hasText: /^Finish( \+ Start Next|$)/ }).first(); -export const armedFinishButton = (page, root = INLINE_EDITOR) => page.locator(root) +export const armedFinishButton = (page, root = INLINE_EDITOR) => within(page, root) .locator('button').filter({ hasText: /^Tap again to finish/ }).first(); +// The server's answer to the next write a match gets, for a journey that must +// not move on (reload, read the court) before an edit has been saved. The +// autosave is debounced and the sync pill still reads "Synced" until it runs, +// so the pill alone cannot say the edit landed. Call this BEFORE the tap that +// causes the write, then `await writeLanded(...)` after it. `tail` is the +// write's route below /matches/:id: 'score' (PUT, the default: marks, fusensho, +// Tie, Start, Finish) or 'daihyosen' (POST, the representative bout's add). +// A timeout the caller never reached is not an unhandled rejection. +export function nextMatchWrite(page, { method = 'PUT', tail = 'score' } = {}) { + const answered = page.waitForResponse((r) => r.request().method() === method + && new RegExp(`/matches/[^/]+/${tail}$`).test(new URL(r.url()).pathname), { timeout: EXPECT_TIMEOUT_MS }); + answered.catch(() => {}); + return answered; +} + +// Return once that write has been answered with a success status. +export async function writeLanded(answered, what = 'the write') { + const res = await answered; + expect(res.ok(), `${what} was answered ${res.status()} ${res.url()}`).toBe(true); + return res; +} + // Tap Finish once: the two-tap guard arms and nothing is submitted yet. Returns // the armed button once the arm is old enough for a second tap to count. export async function armFinish(page, root = INLINE_EDITOR) { - await finishButton(page, root).tap(); - await settled(armedFinishButton(page, root)); - return armedFinishButton(page, root); + return armTwoTap(finishButton(page, root), armedFinishButton(page, root)); } // What an editor shows when a write did not save: its not-saved banner @@ -189,7 +212,7 @@ export async function finishLanded(ed, before) { // 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 ed = within(page, root).first(); const armed = await armFinish(page, root); const before = await beforeFinish(ed); await armed.tap(); diff --git a/scripts/e2e/fixtures/shared-court.mjs b/scripts/e2e/fixtures/shared-court.mjs index b0ae14dd3..955dbe05b 100644 --- a/scripts/e2e/fixtures/shared-court.mjs +++ b/scripts/e2e/fixtures/shared-court.mjs @@ -12,11 +12,12 @@ import { expect } from '@playwright/test'; import { createCompetition } from './wizard.mjs'; import { generateDraw, pasteRoster, startCompetition } from './competition.mjs'; -import { hastyShiaijoConfirm } from './clumsy.mjs'; +import { correctCompleted } from './individual.mjs'; import { EXPECT_TIMEOUT_MS, OPERATOR_DEVICE, PUBLIC_DEVICE } from './devices.mjs'; -import { isCoarse, restoreCoarse } from './pointer.mjs'; +import { rendered } from './pace.mjs'; +import { ensureCoarse } from './pointer.mjs'; import { INLINE_EDITOR } from './scoring.mjs'; -import { UP_NEXT, WAITING_ROWS, completedRowFor, inlineEditor, waitingRows } from './shiaijo.mjs'; +import { UP_NEXT, WAITING_ROWS, inlineEditor, waitingRows } from './shiaijo.mjs'; import * as T from './team.mjs'; // A device of its own: an operator iPad (pointer verified coarse, as the @@ -27,7 +28,7 @@ import * as T from './team.mjs'; 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); + await ensureCoarse(page); return page; } export async function publicDevice(browser, base) { @@ -74,7 +75,7 @@ export async function wizardDefaults(page, { expectEstimate = false } = {}) { if (answered) { const res = await answered; await res.finished(); - await page.evaluate(() => new Promise((done) => requestAnimationFrame(() => requestAnimationFrame(done)))); + await rendered(page); let prev = null; await expect.poll(async () => { const v = await input.inputValue(); @@ -223,29 +224,9 @@ export async function liveBout(page) { } // 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 }; -} +// competition is live there: individual.mjs correctCompleted, reading the live +// bout with liveBout, since the default reader sees the individual board only. +export const correctAcross = (page, pair) => correctCompleted(page, pair, { readLive: liveBout }); // A waiting row of the queue (Upcoming, not the Up next card) naming both // competitors of a pairing. diff --git a/scripts/e2e/fixtures/shots.mjs b/scripts/e2e/fixtures/shots.mjs index 8e43beca3..817181487 100644 --- a/scripts/e2e/fixtures/shots.mjs +++ b/scripts/e2e/fixtures/shots.mjs @@ -4,7 +4,7 @@ import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; -import { isCoarse, restoreCoarse } from './pointer.mjs'; +import { ensureCoarse, isCoarse } from './pointer.mjs'; // scripts/e2e, the directory playwright.config.mjs sits in. const E2E_DIR = path.resolve(fileURLToPath(new URL('..', import.meta.url))); @@ -49,7 +49,7 @@ export function journeyShots(journey) { // emulation: measured, (pointer: coarse) reads false afterwards, so every // tap-floor rule switches off for the rest of the test. Put it back in // place (fixtures/pointer.mjs). - if (coarse) await restoreCoarse(page); + if (coarse) await ensureCoarse(page); return file; }; } diff --git a/scripts/e2e/fixtures/team.mjs b/scripts/e2e/fixtures/team.mjs index 81a064586..bca97f6df 100644 --- a/scripts/e2e/fixtures/team.mjs +++ b/scripts/e2e/fixtures/team.mjs @@ -19,7 +19,7 @@ import { expect } from '@playwright/test'; import { EXPECT_TIMEOUT_MS } from './devices.mjs'; import { settled, tapOnSide } from './pace.mjs'; -import { beforeFinish, finishLanded } from './scoring.mjs'; +import { finishMatch } from './scoring.mjs'; import { inlineEditor, upNextCard } from './shiaijo.mjs'; // FIK position names for a five-person team, in order; any other size uses @@ -318,32 +318,27 @@ export async function startUpNextTeam(page) { return ed; } -// 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 tapOnSide(root.page(), `bout ${n} ${side}`, rowIppon(row, side, waza)); - await expect(rowSlots(row, side)).toHaveCount(before + 1); +// Award one strike on a bout row and return once the board shows it and the +// autosave has landed. `getRow` returns the row (a function, because the +// kachinuki sheet's live row is a different element after each Record bout); +// `key` names the bout for pacing, so a second strike for the same side of +// the same bout waits out the bounce window of the first (fixtures/pace.mjs). +// The one strike, for the team sheet (boutIppon) and the kachinuki sheet +// (fixtures/kachinuki.mjs awardBoutIppon) alike. +export async function ipponOnRow(root, getRow, key, side, waza = 'M') { + const before = await rowSlots(getRow(), side).count(); + await tapOnSide(root.page(), `${key} ${side}`, rowIppon(getRow(), side, waza)); + await expect(rowSlots(getRow(), side)).toHaveCount(before + 1); await expect(syncPill(root)).toHaveText('Synced'); } +// One strike on bout `n` of the team sheet. +export const boutIppon = (root, n, side, waza = 'M') => ipponOnRow(root, () => boutRow(root, n), `bout ${n}`, side, waza); + // 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 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(); - return finishLanded(ed, before); -} +// not: fixtures/scoring.mjs finishMatch, which reads the same signs on the +// team sheet as on the individual board. +export const finishTeam = (root) => finishMatch(root.page(), root); diff --git a/scripts/e2e/fixtures/test.mjs b/scripts/e2e/fixtures/test.mjs index db200fcc7..91e1964e8 100644 --- a/scripts/e2e/fixtures/test.mjs +++ b/scripts/e2e/fixtures/test.mjs @@ -6,7 +6,7 @@ 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'; +import { ensureCoarse, isCoarse } from './pointer.mjs'; export { isCoarse }; @@ -356,12 +356,12 @@ export const test = base.extend({ // 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 in place - // (restoreCoarse, which fails loudly if even that does not do it). + // (ensureCoarse, which fails loudly if even that does not do it). 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' }); + if (testInfo.project.name === 'operator') { + const { coarseLost, restoredBy } = await ensureCoarse(page); + if (coarseLost) testInfo.annotations.push({ type: 'coarse-pointer', description: restoredBy }); } await use(page); }, diff --git a/scripts/e2e/journeys/engi.spec.mjs b/scripts/e2e/journeys/engi.spec.mjs index 5a30ebd4b..5f0395a20 100644 --- a/scripts/e2e/journeys/engi.spec.mjs +++ b/scripts/e2e/journeys/engi.spec.mjs @@ -130,6 +130,7 @@ test.describe('engi', () => { // The add's response reached the page before the removal's own save went out. await writes.answeredBeforeNextWrite(held); await engiSettle(page); + // Deliberate: a late echo that undoes the tap-back must be given the time to land, so the time is the evidence. await page.waitForTimeout(1000); await expect(flagCount(page, 'aka'), 'the taken-back flag stays taken back').toHaveText('0'); await shot(page, 'flag-taken-back-kept'); @@ -150,6 +151,7 @@ test.describe('engi', () => { held.release(); await writes.answeredBeforeNextWrite(held); await engiSettle(page); + // Deliberate: a late echo that undoes the tap-back must be given the time to land, so the time is the evidence. await page.waitForTimeout(1000); await expect(flagCount(page, 'aka'), 'the flag put back stays').toHaveText('1'); await shot(page, 'flag-put-back-kept'); diff --git a/scripts/e2e/journeys/knockout-mixed-individual.spec.mjs b/scripts/e2e/journeys/knockout-mixed-individual.spec.mjs index 1c78d14ef..07fda0210 100644 --- a/scripts/e2e/journeys/knockout-mixed-individual.spec.mjs +++ b/scripts/e2e/journeys/knockout-mixed-individual.spec.mjs @@ -23,7 +23,7 @@ // 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 { OPERATOR_DEVICE, EXPECT_TIMEOUT_MS } from '../fixtures/devices.mjs'; import { journeyShots } from '../fixtures/shots.mjs'; import { createTournament, login } from '../fixtures/setup.mjs'; import { createCompetition } from '../fixtures/wizard.mjs'; @@ -34,7 +34,9 @@ import { } from '../fixtures/shiaijo.mjs'; import { INLINE_EDITOR, EDITOR, clearMarks, ipponButton, syncState, filledSlots, finishButton, armedFinishButton, armFinish, finishMatch, + startMatch, awardIppon, nextMatchWrite, writeLanded, } from '../fixtures/scoring.mjs'; +import { dwell, rendered, untilStable } from '../fixtures/pace.mjs'; import { doubleTap, hastyConfirm, hastyShiaijoConfirm, interrupt, retapWhileSaving, tapNeighbour, } from '../fixtures/clumsy.mjs'; @@ -126,10 +128,10 @@ test.describe('knockout-mixed-individual', () => { const startBtn = card.getByRole('button', { name: /Start match|Starting…/ }); row({ step: 'start bout', action: 'Start match (Up next card)', variant: 'size', ...(await tapSize(startBtn)) }); const dbl = await doubleTap(startBtn); - await expect.poll(() => inlineRunning(page), { timeout: 15_000 }).toBe(true); + await expect.poll(() => inlineRunning(page), { timeout: EXPECT_TIMEOUT_MS }).toBe(true); const running1 = await editorSides(page); const inProgress = (await page.locator('.admin-live-strip, .live-strip').allInnerTexts()).join(' '); - await page.waitForTimeout(800); + await expect.poll(async () => JSON.stringify(await sides(upNextCard(page)).catch(() => null))).not.toBe(JSON.stringify(running1)); const upNextAfter = await sides(upNextCard(page)).catch(() => null); row({ step: 'start bout', action: 'Start match (Up next card)', variant: 'V2 doubleTap', ...dbl, intended: firstPair, editorHolds: running1, upNextAfter, liveStrip: inProgress, @@ -189,8 +191,8 @@ test.describe('knockout-mixed-individual', () => { await ipponButton(page, other, 'K').tap(); const pillAtTap = (await syncState(page).innerText()).trim(); await interrupt(page, 'reload'); - await expect.poll(() => inlineRunning(page), { timeout: 15_000 }).toBe(true); - await page.waitForTimeout(1000); + await expect.poll(() => inlineRunning(page), { timeout: EXPECT_TIMEOUT_MS }).toBe(true); + await expect(syncState(page)).toHaveText('Synced'); const otherAfterFastReload = await slotMarks(page, other); row({ step: 'running bout', action: `${other} K then reload within 300ms`, variant: 'V3 interrupt reload (inside autosave debounce)', pillAtTap, marksAfterReload: otherAfterFastReload, shot: await shot(page, 'fast-reload-after-tap') }); @@ -208,7 +210,7 @@ test.describe('knockout-mixed-individual', () => { // V3 interruptions mid-bout, and V5: same court, same match, marks kept? 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 back = await expect.poll(() => inlineRunning(page), { timeout: EXPECT_TIMEOUT_MS }).toBe(true).then(() => true, () => false); 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, @@ -221,7 +223,9 @@ test.describe('knockout-mixed-individual', () => { const finish = finishButton(page); row({ step: 'finish', action: 'Finish + Start Next', variant: 'size', label: (await finish.innerText()).trim(), ...(await tapSize(finish)) }); const dblFinish = await doubleTap(finish); - await page.waitForTimeout(1500); + await expect(completedRowFor(page, running1).or(armedFinishButton(page))).toBeVisible(); + // A tap inside the arm's bounce window is ignored: out it, so the tap below counts. + await dwell(page, Date.now()); const finishedByDouble = await completedRowFor(page, running1).isVisible().catch(() => false); const armedLeft = await armedFinishButton(page).isVisible().catch(() => false); row({ step: 'finish', action: 'Finish + Start Next', variant: 'V2 doubleTap', ...dblFinish, @@ -235,7 +239,7 @@ test.describe('knockout-mixed-individual', () => { expect(r1.result).toMatch(/M/); // Finish + Start Next started the next bout on THIS court. - await expect.poll(() => inlineRunning(page), { timeout: 15_000 }).toBe(true); + await expect.poll(() => inlineRunning(page), { timeout: EXPECT_TIMEOUT_MS }).toBe(true); const id2 = await editorIdentity(page); const running2 = await editorSides(page); expect(id2.court).toBe('A'); @@ -252,14 +256,14 @@ test.describe('knockout-mixed-individual', () => { } catch (e) { nbFinish = { hit: null, note: String(e.message).slice(0, 120) }; } - await page.waitForTimeout(500); + await rendered(page); 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 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(); - await expect.poll(() => inlineRunning(page), { timeout: 15_000 }).toBe(true); + await expect.poll(() => inlineRunning(page), { timeout: EXPECT_TIMEOUT_MS }).toBe(true); const running3 = await editorSides(page); const completedCount = await completedRows(page).count(); row({ step: 'finish', action: 'armed Finish', variant: 'V2 retapWhileSaving', ...retap, @@ -277,7 +281,7 @@ test.describe('knockout-mixed-individual', () => { await page.locator('.shiaijo-move-confirm[role="dialog"]').waitFor({ state: 'visible' }); const sendBackDialogShot = await shot(page, 'send-back-dialog'); const hasty = await hastyShiaijoConfirm(page); - await page.waitForTimeout(800); + await expect.poll(() => inlineRunning(page)).toBe(false); const idleAfter = await page.locator('.shiaijo__placeholder').isVisible().catch(() => false); const upNextAfterRevert = await sides(upNextCard(page)).catch(() => null); row({ step: 'send back', action: 'Send back to queue confirm', variant: 'V4 hastyConfirm', ...hasty, @@ -292,7 +296,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); + await expect.poll(() => inlineRunning(page), { timeout: EXPECT_TIMEOUT_MS }).toBe(true); expect((await editorIdentity(page)).eyebrow).toMatch(/POOL/); await shot(page, 'court-A-last-pool-bout-running'); @@ -317,7 +321,7 @@ test.describe('knockout-mixed-individual', () => { await expect(reason).toBeVisible(); await shot(page, 'correction-reason'); await reason.locator('button.btn--primary').tap(); - await page.waitForTimeout(800); + await expect(reason).toBeHidden(); const backToCourt = page.getByRole('button', { name: /Back to court/ }); const stillCorrecting = await backToCourt.isVisible().catch(() => false); row({ step: 'correct', action: 'Save correction (reason prompt, loud button)', variant: 'V4 hasty (inline prompt)', @@ -344,8 +348,9 @@ test.describe('knockout-mixed-individual', () => { const played = await playRunningBout(page); bOrder.push({ ...played, eyebrow: eyebrow || '(started from Up next)' }); // Did Finish + Start Next leave the court idle with pool bouts queued? - await page.waitForTimeout(1500); - if (!(await inlineRunning(page)) && (await queuedPoolBouts(page)) > 0) { + // Give it the time a start takes; an idle court is what the row records. + const restarted = await expect.poll(() => inlineRunning(page), { timeout: 1500 }).toBe(true).then(() => true, () => false); + if (!restarted && (await queuedPoolBouts(page)) > 0) { const toasts = (await page.locator('.toast, [role="status"]').allInnerTexts()).join(' | '); row({ step: 'court B pools', action: `Finish + Start Next after ${eyebrow || 'a bout'}`, variant: 'correct path', happened: 'court left idle although pool bouts wait in Up next', upNext: await sides(upNextCard(page)).catch(() => null), @@ -358,7 +363,7 @@ test.describe('knockout-mixed-individual', () => { // Back on A: the last pool bout, finished with Finish + Start Next. await openShiaijo(page, 'A'); - await expect.poll(() => inlineRunning(page), { timeout: 15_000 }).toBe(true); + await expect.poll(() => inlineRunning(page), { timeout: EXPECT_TIMEOUT_MS }).toBe(true); expect(await editorSides(page)).toEqual(lastPoolA); const labelAtBoundary = (await finishButton(page).innerText()).trim(); await ipponButton(page, winnerOf(lastPoolA), 'M').tap(); @@ -400,7 +405,7 @@ test.describe('knockout-mixed-individual', () => { const seenK = []; for (const key of ['ArrowRight', 'ArrowLeft', 'Escape']) { await page.keyboard.press(key); - await page.waitForTimeout(300); + await rendered(page); seenK.push({ key, eyebrow: (await editorIdentity(page).catch(() => ({ eyebrow: null }))).eyebrow, editor: await inlineEditor(page).isVisible().catch(() => false) }); } @@ -477,7 +482,7 @@ test.describe('knockout-mixed-individual', () => { // V1: the thumb meant Kiken - Voluntary and landed on its neighbour. const nbKiken = await tapNeighbour(decisionButton(page, 'kiken-voluntary'), 'right'); - await page.waitForTimeout(400); + await rendered(page); const promptOpen = await decisionPrompt(page).isVisible().catch(() => false); const popover = await page.locator('[role="tooltip"], .glossary-pop, .term-pop').first().isVisible().catch(() => false); row({ step: 'kiken', action: 'Kiken - Voluntary', variant: 'V1 tapNeighbour right', hit: nbKiken.hit.label, gapPx: nbKiken.gapPx, @@ -500,6 +505,8 @@ test.describe('knockout-mixed-individual', () => { const recordLabelBefore = (await record.innerText()).trim(); const hint = (await decisionPrompt(page).getByTestId('decision-prompt-hint').innerText()).trim(); await record.tap({ force: true }); + // Deliberate: the prompt must STAY open and nothing may be recorded, and + // an absence has no state to wait for, so the time is the evidence. await page.waitForTimeout(500); const stillOpen = await decisionPrompt(page).isVisible(); const recordedByHastyTap = await completedRowFor(page, b1).isVisible().catch(() => false); @@ -600,8 +607,10 @@ test.describe('knockout-mixed-individual', () => { ...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); + if (await backToCourt.isVisible().catch(() => false)) { + await backToCourt.tap(); + await expect(backToCourt).toBeHidden(); + } const liveAfter = await inlineRunning(page) ? await editorSides(page) : null; 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), @@ -620,7 +629,7 @@ test.describe('knockout-mixed-individual', () => { await ipponButton(page, opp, 'M').tap(); await settle(page); await (await armFinish(page)).tap(); - await page.waitForTimeout(1500); + await expect(completedRowFor(page, live).or(page.locator('.toast, .pending-write-banner').first())).toBeVisible(); const msg = (await page.locator('.toast, .pending-write-banner').allInnerTexts().catch(() => [])).join(' | '); const done = await completedRowFor(page, live).isVisible().catch(() => false); row({ step: 'kiken undo', action: 'Finish the running bout of the now-withdrawn competitor (M for the opponent)', variant: 'recovery attempt', @@ -634,15 +643,17 @@ test.describe('knockout-mixed-individual', () => { .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); + await expect.poll(() => inlineRunning(page), { timeout: EXPECT_TIMEOUT_MS }).toBe(true); expect(await editorSides(page)).toEqual(b2); 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(); const dblRecord = await doubleTap(decisionPrompt(page).getByRole('button', { name: 'Record' })); - await page.waitForTimeout(2000); - const dialogAfterDbl = await page.locator('.modal[role="dialog"]').isVisible().catch(() => false); + // Either a confirm asks first, or the decision is recorded. + const confirmDialog = page.locator('.modal[role="dialog"]'); + await expect(confirmDialog.first().or(completedRowFor(page, b2))).toBeVisible(); + const dialogAfterDbl = await confirmDialog.first().isVisible().catch(() => false); let dblDialog = null; if (dialogAfterDbl) { await shot(page, 'fusenpai-double-record-dialog'); @@ -712,8 +723,8 @@ test.describe('knockout-mixed-individual', () => { // Docs: "open the Overtime control and tick Encho started". The pill is // tapped where a thumb lands, its centre. await pill.tap(); - await page.waitForTimeout(500); const encho = inlineEditor(page).getByTestId('scoring-modal-encho-checkbox'); + await expect(encho).toBeVisible(); const openedByCentre = await encho.isVisible().catch(() => false); 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, @@ -818,7 +829,7 @@ test.describe('knockout-mixed-individual', () => { await login(page); await openShiaijo(page, 'C'); // 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); + await expect.poll(async () => (await inlineRunning(page)) || (await upNextCard(page).isVisible().catch(() => false)), { timeout: EXPECT_TIMEOUT_MS }).toBe(true); let played = null; const liveNow = (await inlineRunning(page)) ? (await editorIdentity(page)).eyebrow : ''; const upNow = ((await page.locator('.shiaijo-upnext__time').allInnerTexts())[0] || ''); @@ -832,11 +843,11 @@ test.describe('knockout-mixed-individual', () => { } if (await page.locator('.shiaijo__placeholder').isVisible().catch(() => false)) return 'idle'; return 'waiting'; - }, { timeout: 15_000 }).not.toBe('waiting'); + }, { timeout: EXPECT_TIMEOUT_MS }).not.toBe('waiting'); if (!semi) { semi = await sides(upNextCard(page)); await upNextCard(page).getByRole('button', { name: 'Start match' }).tap(); - await expect.poll(async () => ((await inlineRunning(page)) ? JSON.stringify(await editorSides(page)) : ''), { timeout: 15_000 }) + await expect.poll(async () => ((await inlineRunning(page)) ? JSON.stringify(await editorSides(page)) : ''), { timeout: EXPECT_TIMEOUT_MS }) .toBe(JSON.stringify(semi)); } row({ step: 'finish offline', action: 'the last semi-final is running', variant: 'correct path', semi }); @@ -859,6 +870,9 @@ test.describe('knockout-mixed-individual', () => { }; row({ step: 'finish offline', action: 'the screen 300ms after Finish, offline', variant: 'V5 lost place', ...at300, shot: await shot(page, 'offline-finish-300ms') }); + // The operator watches the screen for the rest of two seconds with the + // network down: what it shows by then (a banner, or the court moved on) is + // what the row records, so the time is the scenario, not a wait for the app. await page.waitForTimeout(1700); const banner = inlineEditor(page).locator('.pending-write-banner'); const bannerText = (await banner.allInnerTexts()).join(' | '); @@ -933,7 +947,7 @@ test.describe('knockout-mixed-individual', () => { // 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); + { timeout: EXPECT_TIMEOUT_MS }).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 }) }); @@ -965,8 +979,7 @@ test.describe('knockout-mixed-individual', () => { await shot(page, 'pools-tab-editor-open'); const start = page.locator(EDITOR).getByRole('button', { name: 'Start match' }); const startOffered = await start.isVisible().catch(() => false); - if (startOffered) await start.tap(); - await page.waitForTimeout(1500); + if (startOffered) await startMatch(page); const openAfterStart = await page.locator(EDITOR).isVisible().catch(() => false); row({ step: 'pools tab', action: 'Start match in the Pools-tab editor', variant: 'correct path', row: rowText.slice(0, 80), startOffered, editorStillOpen: openAfterStart, shot: await shot(page, 'pools-tab-after-start') }); @@ -975,8 +988,9 @@ test.describe('knockout-mixed-individual', () => { await expect(page.locator(EDITOR)).toBeVisible(); } const w = winnerOf(pair); + const ipponSaved = nextMatchWrite(page); await ipponButton(page, w, 'M', EDITOR).tap(); - await page.waitForTimeout(1500); + await writeLanded(ipponSaved, 'the ippon save'); const openAfterIppon = await page.locator(EDITOR).isVisible().catch(() => false); row({ step: 'pools tab', action: 'award one ippon in the Pools-tab editor', variant: 'correct path', editorStillOpen: openAfterIppon, shot: await shot(page, 'pools-tab-after-ippon') }); @@ -988,13 +1002,13 @@ test.describe('knockout-mixed-individual', () => { } const finishLabel = (await finishButton(page, EDITOR).innerText()).trim(); await finishMatch(page, EDITOR); - await page.waitForTimeout(800); - const after = { + // Where the screen ends up once the list has refetched after the finish. + const after = await untilStable(async () => ({ editorOpen: await page.locator(EDITOR).isVisible().catch(() => false), url: new URL(page.url()).pathname, rowNow: (await firstRow.innerText()).replace(/\s+/g, ' ').slice(0, 120), toast: (await page.locator('.toast').allInnerTexts().catch(() => [])).join(' | '), - }; + })); row({ step: 'pools tab', action: 'Finish (Pools tab: no Start Next)', variant: 'V5 lost place', finishLabel, court: idP.court, ...after, shot: await shot(page, 'pools-tab-after-finish') }); expect(after.editorOpen).toBe(false); @@ -1006,8 +1020,7 @@ test.describe('knockout-mixed-individual', () => { await expect(page.locator(EDITOR)).toBeVisible(); const pair2 = await editorSides(page, EDITOR); if (await page.locator(EDITOR).getByRole('button', { name: 'Start match' }).isVisible().catch(() => false)) { - await page.locator(EDITOR).getByRole('button', { name: 'Start match' }).tap(); - await page.waitForTimeout(1000); + await startMatch(page); if (!(await page.locator(EDITOR).isVisible().catch(() => false))) await secondRow.tap(); } await ipponButton(page, winnerOf(pair2), 'M', EDITOR).tap(); @@ -1015,7 +1028,8 @@ test.describe('knockout-mixed-individual', () => { const closedByTap = await page.locator(EDITOR).getByRole('button', { name: /✕ Close/ }).tap({ timeout: 2000 }).then(() => true, () => false); const discardAsked = await page.locator('.modal[role="dialog"] .modal__foot').isVisible().catch(() => false); const hasty = discardAsked ? await hastyConfirm(page) : null; - await page.waitForTimeout(800); + // Whether the editor closed (and the unsaved ippon was kept) settles after the answer. + await untilStable(() => page.locator(EDITOR).isVisible().catch(() => false)); await secondRow.tap(); await expect(page.locator(EDITOR)).toBeVisible(); const kept = await slotMarks(page, winnerOf(pair2), EDITOR); @@ -1038,7 +1052,7 @@ test.describe('knockout-mixed-individual', () => { await ipponButton(page, winnerOf(bpair), 'M').tap(); await settle(page); await finishMatch(page, INLINE_EDITOR); - await page.waitForTimeout(1000); + await expect(panel.locator('.running-panel__result')).toBeVisible(); const bAfter = { resultCard: await panel.locator('.running-panel__result').isVisible().catch(() => false), editRes: await panel.getByRole('button', { name: 'Edit result' }).isVisible().catch(() => false), @@ -1052,7 +1066,7 @@ test.describe('knockout-mixed-individual', () => { row({ step: 'bracket tab', action: 'Edit result', variant: 'size', ...(await tapSize(panel.getByRole('button', { name: 'Edit result' }))) }); await panel.getByRole('button', { name: 'Edit result' }).tap(); const editDialog = await hastyConfirm(page); - await page.waitForTimeout(500); + await rendered(page); row({ step: 'bracket tab', action: 'Edit result confirm', variant: 'V4 hastyConfirm', ...editDialog, editorReopened: await panel.locator(INLINE_EDITOR).isVisible().catch(() => false), shot: await shot(page, 'bracket-tab-edit-result') }); @@ -1072,8 +1086,8 @@ test.describe('knockout-mixed-individual', () => { await ipponButton(page, winnerOf(bpair2), 'M').tap(); await settle(page); await finishMatch(page, INLINE_EDITOR); - await page.waitForTimeout(1000); - const bracketText = (await page.locator('.page').first().innerText()).replace(/\s+/g, ' '); + // The bracket redraws once the finish has refetched: the text it settles on. + const bracketText = await untilStable(async () => (await page.locator('.page').first().innerText()).replace(/\s+/g, ' ')); row({ step: 'bracket tab', action: 'second semi-final, default joint 3rd places', variant: 'correct path', pair: bpair2, 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); @@ -1182,11 +1196,9 @@ test.describe('knockout-mixed-individual bead guards', () => { await first.tap(); await expect(page.locator(EDITOR)).toBeVisible(); const pair = await editorSides(page, EDITOR); - await page.locator(EDITOR).getByRole('button', { name: 'Start match' }).tap(); - await page.waitForTimeout(1500); + await startMatch(page); await expect(page.locator(EDITOR)).toBeVisible(); - await ipponButton(page, winnerOf(pair), 'M', EDITOR).tap(); - await page.waitForTimeout(1500); + await awardIppon(page, winnerOf(pair), 'M', EDITOR); await expect(page.locator(EDITOR)).toBeVisible(); }); test('bc-sbq: Send back to queue says the score is kept, and starting the bout again carries it on', async ({ page }) => { @@ -1242,7 +1254,6 @@ test.describe('knockout-mixed-individual bead guards', () => { await context.setOffline(true); try { await (await armFinish(page)).tap(); - await page.waitForTimeout(1000); await expect(page.getByText('Offline: 1 result not sent')).toBeVisible(); } finally { await context.setOffline(false); @@ -1267,7 +1278,7 @@ test.describe('knockout-mixed-individual bead guards', () => { 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); + await expect.poll(() => inlineRunning(page), { timeout: EXPECT_TIMEOUT_MS }).toBe(true); return filledSlots(page, 'aka').count(); }; @@ -1282,6 +1293,7 @@ test.describe('knockout-mixed-individual bead guards', () => { // The removal's response reached the page before the tap-back's own save went out. await writes.answeredBeforeNextWrite(held); await settle(page); + // Deliberate: a late echo that undoes the tap-back must be given the time to land, so the time is the evidence. await page.waitForTimeout(1000); await expect(filledSlots(page, 'aka'), 'the tapped-back mark stays').toHaveCount(1); await shot(page, 'tapped-back-mark-kept'); @@ -1298,6 +1310,7 @@ test.describe('knockout-mixed-individual bead guards', () => { held.release(); await writes.answeredBeforeNextWrite(held); await settle(page); + // Deliberate: a late echo that undoes the tap-back must be given the time to land, so the time is the evidence. 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); diff --git a/scripts/e2e/journeys/knockout-mixed-kachinuki.spec.mjs b/scripts/e2e/journeys/knockout-mixed-kachinuki.spec.mjs index 2e1aa16f0..4d73f0416 100644 --- a/scripts/e2e/journeys/knockout-mixed-kachinuki.spec.mjs +++ b/scripts/e2e/journeys/knockout-mixed-kachinuki.spec.mjs @@ -38,11 +38,12 @@ import { createTournament, login } from '../fixtures/setup.mjs'; 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 { PUBLIC_DEVICE, EXPECT_TIMEOUT_MS } from '../fixtures/devices.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'; +import { dwell, rendered, settled, tapOnSide, untilStable } from '../fixtures/pace.mjs'; +import { ensureCoarse } from '../fixtures/pointer.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 @@ -52,7 +53,7 @@ import { } from '../fixtures/clumsy.mjs'; import { closeNameList } from '../fixtures/team.mjs'; import { - awardBoutIppon, boutFilled, clearBoutMarks, ensureCoarse, boutIpponButton, boutMiddle, boutNames, closeLineupPanel, correctingBout, + awardBoutIppon, boutFilled, clearBoutMarks, boutIpponButton, boutMiddle, boutNames, closeLineupPanel, correctingBout, currentBout, doneRowWinner, doneRows, editor, enchoButton, enchoUndoButton, endMatch, endMatchButton, lineupSide, lineupSource, openLineupFromUpNext, recordBout, recordBoutButton, removeBoutButton, reopenButton, syncPill, tieButton, typeLineup, winBout, @@ -267,7 +268,7 @@ test.describe('J5 kachinuki from the court console', () => { // 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); + await rendered(page); record({ step: 'M2 lineup', action: 'Save lineup', variant: 'V2 doubleTap', ...v2, toasts: await page.locator('.toast, [role="status"]').allInnerTexts().catch(() => []), saved: await lineupSide(page, pair.shiro).locator('input.pmf__input').evaluateAll((els) => els.map((e) => e.value)), @@ -327,7 +328,7 @@ test.describe('J5 kachinuki from the court console', () => { // 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); + await expect(boutFilled(currentBout(page), 'shiro')).toHaveCount(1); 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'); @@ -340,7 +341,7 @@ test.describe('J5 kachinuki from the court console', () => { 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'); - await page.waitForTimeout(300); + await rendered(page); const after = await boutFilled(currentBout(page), 'shiro').allInnerTexts(); record({ step: 'M1 bout 1', action: 'Shiro M (second point)', variant: 'V1 tapNeighbour right', ...v1, marksAfter: after, screenshot: await shot(page, 'v1-ippon-neighbour') }); @@ -362,7 +363,8 @@ test.describe('J5 kachinuki from the court console', () => { screenshot: await shot(page, 'v3-reload-bout-1') }); // V2 on Record bout: a re-tap while it reads "Saving…". const retap = await retapWhileSaving(recordBoutButton(page)); - await page.waitForTimeout(800); + await expect(doneRows(page)).not.toHaveCount(0); + await expect(recordBoutButton(page)).not.toHaveText('Saving…'); record({ step: 'M1 bout 1', action: 'Record bout', variant: 'V2 retapWhileSaving', ...retap, doneRows: await doneRows(page).count(), current: await boutNames(currentBout(page)), screenshot: await shot(page, 'v2-record-retap') }); @@ -374,7 +376,7 @@ test.describe('J5 kachinuki from the court console', () => { await awardBoutIppon(page, 'aka', 'M'); await awardBoutIppon(page, 'aka', 'D'); const v1 = await tapNeighbour(recordBoutButton(page), 'right'); - await page.waitForTimeout(300); + await rendered(page); const endLabel = (await endMatchButton(page).innerText()).trim(); record({ step: 'M1 bout 2', action: 'Record bout', variant: 'V1 tapNeighbour right', ...v1, endLabel, screenshot: await shot(page, 'v1-record-neighbour-end-armed') }); @@ -390,7 +392,8 @@ test.describe('J5 kachinuki from the court console', () => { await expect(remove).toBeVisible(); const removeBox = await remove.boundingBox(); const v2 = await doubleTap(remove); - await page.waitForTimeout(1200); + // The appended pairing is gone once the removal has been answered. + await expect(remove).toBeHidden(); const afterRemove = { doneRows: await doneRows(page).count(), current: await boutNames(currentBout(page)), currentShiroMarks: await boutFilled(currentBout(page), 'shiro').count(), currentAkaMarks: await boutFilled(currentBout(page), 'aka').count() }; const removeShot = await shot(page, 'v2-remove-bout-double-tap'); @@ -402,14 +405,13 @@ test.describe('J5 kachinuki from the court console', () => { const tie = tieButton(currentBout(page)); const v2tie = await doubleTap(tie); - await page.waitForTimeout(300); - const tieLabel = (await tie.innerText()).trim(); + const tieLabel = (await untilStable(() => tie.innerText())).trim(); record({ step: 'M1 bout 3', action: 'Tie (hikiwake)', variant: 'V2 doubleTap', ...v2tie, tieLabel, middle: await boutMiddle(currentBout(page)), screenshot: await shot(page, 'v2-tie-double-tap') }); if (!/✓/.test(tieLabel)) await tie.tap(); await expect(tie).toHaveText(/✓ Tie/); const v1tie = await tapNeighbour(tie, 'up'); - await page.waitForTimeout(300); + await rendered(page); record({ step: 'M1 bout 3', action: 'Tie (hikiwake)', variant: 'V1 tapNeighbour up', ...v1tie, tieStill: (await tie.innerText()).trim(), middle: await boutMiddle(currentBout(page)), screenshot: await shot(page, 'v1-tie-neighbour') }); @@ -426,11 +428,10 @@ test.describe('J5 kachinuki from the court console', () => { await expect(enchoButton(page)).toBeVisible(); await shot(page, 'bout-4-tied-encho-offered'); const v2 = await doubleTap(enchoButton(page)); - await page.waitForTimeout(300); const eyebrow = (await editor(page).locator('.editor-modal__eyebrow').first().innerText()).trim(); // 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(); + const inOvertime = await untilStable(() => 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') }); @@ -472,7 +473,7 @@ test.describe('J5 kachinuki from the court console', () => { const offline = await interruptC(page, 'offline', { during: async (p) => { await boutIpponButton(currentBout(p), 'shiro', 'M').tap(); - await p.waitForTimeout(1500); + await expect(syncPill(p)).not.toHaveText('Synced'); whileOffline = (await syncPill(p).innerText().catch(() => '?')).trim(); }, }); @@ -547,7 +548,9 @@ test.describe('J5 kachinuki from the court console', () => { }); // V1: the thumb meant bout 5 and landed on the row below it. const v1 = await tapNeighbour(row5, 'down'); - await page.waitForTimeout(400); + // Opening or closing a correction flushes the live bout's save first: wait + // for the rows to stop moving. + await untilStable(() => correctingBout(page).count()); const openedWrong = await correctingBout(page).count() ? await boutNames(correctingBout(page)) : null; const v1Shot = await shot(page, 'v1-fought-row-neighbour'); let v1Recover = 0; @@ -561,7 +564,7 @@ test.describe('J5 kachinuki from the court console', () => { // does the second land? const before = await marksOf(4); const v2 = await doubleTap(doneRows(page).nth(4)); - await page.waitForTimeout(600); + await untilStable(() => correctingBout(page).count()); // Which bout did the double tap leave open? const openCaret = editor(page).locator('[data-testid^="kachinuki-done-collapse-"]'); const openIdx = await openCaret.count() @@ -597,7 +600,9 @@ test.describe('J5 kachinuki from the court console', () => { await expect(syncPill(page)).toHaveText('Synced'); const back = await interruptC(page, 'back'); await expect(editor(page)).toBeVisible(); - await page.waitForTimeout(500); + // Back reloads the court's data: whether the correction survives it is + // what the row records, so wait for the sheet to stop changing. + await untilStable(() => doneRows(page).count()); record({ step: 'M1 correction', action: 'bout 5 open for correction', variant: 'V3 interrupt back', ...back, stillOpen: await correctingBout(page).count(), bout5: await boutNames(doneRows(page).nth(4)).catch(() => null), bout5Marks: await marksOf(4), live: await boutNames(currentBout(page)), screenshot: await shot(page, 'v3-back-mid-correction') }); @@ -605,8 +610,7 @@ test.describe('J5 kachinuki from the court console', () => { // V2 on the collapse triangle. const caretBtn = editor(page).getByTestId('kachinuki-done-collapse-4'); const v2c = await doubleTap(caretBtn); - await page.waitForTimeout(400); - const reopened = await correctingBout(page).count(); + const reopened = await untilStable(() => correctingBout(page).count()); record({ step: 'M1 correction', action: 'collapse triangle', variant: 'V2 doubleTap', ...v2c, openAfter: reopened, screenshot: await shot(page, 'v2-collapse-double-tap') }); if (reopened) await editor(page).getByTestId('kachinuki-done-collapse-4').tap(); @@ -624,8 +628,9 @@ test.describe('J5 kachinuki from the court console', () => { await expect(corr).toBeVisible(); // Clear Shiro's two marks, give Aka two: bout 1 now reads as Aka's. await clearBoutMarks(corr, 'shiro'); - await boutIpponButton(corr, 'aka', 'M').tap(); - await boutIpponButton(corr, 'aka', 'M').tap(); + // Two strikes for one side: the second waits out the first's bounce window. + await tapOnSide(page, 'correction bout 1 aka', boutIpponButton(corr, 'aka', 'M')); + await tapOnSide(page, 'correction bout 1 aka', boutIpponButton(corr, 'aka', 'M')); const warning = editor(page).getByTestId('kachinuki-done-edit-warn'); const warned = await warning.isVisible(); const warningText = warned ? (await warning.innerText()).trim() : ''; @@ -633,8 +638,8 @@ test.describe('J5 kachinuki from the court console', () => { const laterUnchanged = await boutNames(currentBout(page)); // Put it back as it was fought. await clearBoutMarks(corr, 'aka'); - await boutIpponButton(corr, 'shiro', 'M').tap(); - await boutIpponButton(corr, 'shiro', 'K').tap(); + await tapOnSide(page, 'correction bout 1 shiro', boutIpponButton(corr, 'shiro', 'M')); + await tapOnSide(page, 'correction bout 1 shiro', boutIpponButton(corr, 'shiro', 'K')); await editor(page).getByTestId('kachinuki-done-collapse-0').tap(); await expect(syncPill(page)).toHaveText('Synced'); record({ step: 'M1 correction', action: 'flip bout 1 winner by correction, then restore', variant: 'correct path', @@ -656,7 +661,7 @@ test.describe('J5 kachinuki from the court console', () => { const fus = currentBout(page).locator('.team-sub-match__side--shiro').getByTestId('scoring-modal-fusensho-button'); // V1: the thumb meant Fusensho and landed on the button beside it. const v1 = await tapNeighbour(fus, 'left'); - await page.waitForTimeout(300); + await rendered(page); const marks = await boutFilled(currentBout(page), 'shiro').allInnerTexts(); const v1Shot = await shot(page, 'v1-fusensho-neighbour'); const recoverTaps = await clearBoutMarks(currentBout(page), 'shiro'); @@ -664,8 +669,7 @@ test.describe('J5 kachinuki from the court console', () => { recoverTaps, screenshot: v1Shot }); // V2: Fusensho double-tapped (it is a toggle). const v2 = await doubleTap(fus); - await page.waitForTimeout(300); - const label = (await fus.innerText()).trim(); + const label = (await untilStable(() => fus.innerText())).trim(); record({ step: 'M1 bout 8', action: 'Fusensho (Shiro)', variant: 'V2 doubleTap', ...v2, labelAfter: label, shiroMarks: await boutFilled(currentBout(page), 'shiro').allInnerTexts(), screenshot: await shot(page, 'v2-fusensho-double-tap') }); if (!/✓/.test(label)) await fus.tap(); @@ -686,16 +690,21 @@ test.describe('J5 kachinuki from the court console', () => { 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 - // it the bounce added a second). Does the court's own Completed record say so? - const want = /IV 4–2\s+PW 7–3/; + // IV 4-2, PW 8-3: bout 1 M K, bout 5 M K (corrected from M M), bout 6 M K + // and the walkover's two maru for Shiro, bout 2 D M and bout 4 K for Aka + // (bout 5's double tap scores one M since bc-dtip; before it the bounce added + // a second). Bout 1 is put back to M K by the correction step above, each + // strike paced past its side's bounce window; an unpaced pair left it at M + // (the second strike swallowed) and this line read 7-3. Does the court's + // own Completed record say so? + const want = /IV 4–2\s+PW 8–3/; const result = row.locator('.shiaijo-qrow__result'); 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', + await expect(completedRowFor(page, m1).locator('.shiaijo-qrow__result')).toHaveText(want); + record({ step: 'M1 end', action: 'Completed row after End match', variant: 'result', expected: 'IV 4–2 PW 8–3', shownFor6s: shownAtFirst, settledWithoutRefresh, afterRefresh: (await completedRowFor(page, m1).locator('.shiaijo-qrow__result').innerText()).replace(/\s+/g, ' '), screenshot: staleShot }); @@ -718,7 +727,7 @@ test.describe('J5 kachinuki from the court console', () => { // V1: is there anything beside Correct for the thumb to land on? const v1 = await tapNeighbour(completedRowFor(page, m1).locator('.shiaijo-row__correct'), 'left') .catch((e) => ({ none: e.message.split('\n')[0] })); - await page.waitForTimeout(300); + await rendered(page); 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') }); @@ -736,7 +745,7 @@ test.describe('J5 kachinuki from the court console', () => { const btn = boutIpponButton(firstRow, 'aka', 'M'); const enabled = await btn.isEnabled().catch(() => false); if (enabled) await btn.tap(); - await page.waitForTimeout(400); + await rendered(page); record({ step: 'M1 reopen', action: 'tap an ippon on the completed sheet', variant: 'V1 stray tap', buttonEnabled: enabled, akaMarksBefore: before, akaMarksAfter: await boutFilled(firstRow, 'aka').count(), saveButton: await editor(page).getByRole('button', { name: /Save correction/ }).count(), @@ -747,8 +756,8 @@ test.describe('J5 kachinuki from the court console', () => { const reopen = reopenButton(page); const box = await reopen.boundingBox(); const v2 = await doubleTap(reopen); - await expect(recordBoutButton(page)).toBeVisible({ timeout: 15_000 }); - await page.waitForTimeout(500); + await expect(recordBoutButton(page)).toBeVisible({ timeout: EXPECT_TIMEOUT_MS }); + await rendered(page); record({ step: 'M1 reopen', action: 'Reopen match', variant: 'V2 doubleTap', ...v2, reopenBox: box, error: (await page.getByTestId('kachinuki-reopen-error').innerText().catch(() => '')).trim(), doneRows: await doneRows(page).count(), current: await boutNames(currentBout(page)), @@ -757,7 +766,7 @@ test.describe('J5 kachinuki from the court console', () => { await test.step('reload while reopened: back on the same encounter?', async () => { const reload = await interruptC(page, 'reload'); - const back = await editor(page).waitFor({ state: 'visible', timeout: 15_000 }).then(() => true, () => false); + const back = await editor(page).waitFor({ state: 'visible', timeout: EXPECT_TIMEOUT_MS }).then(() => true, () => false); record({ step: 'M1 reopen', action: 'reopened encounter', variant: 'V3 interrupt reload', ...reload, editorBack: back, eyebrow: back ? (await editor(page).locator('.editor-modal__eyebrow').first().innerText()).trim() : null, doneRows: back ? await doneRows(page).count() : null, current: back ? await boutNames(currentBout(page)) : null, @@ -784,8 +793,7 @@ test.describe('J5 kachinuki from the court console', () => { await expect(syncPill(page)).toHaveText('Synced'); record({ step: 'M1 reopen', action: 'undo fusensho, award M on bout 8', variant: 'correct path', wasFusensho, pill: (await syncPill(page).innerText()).trim(), screenshot: await shot(page, 'm1-bout-8-rescored') }); - // V3: two seconds later (well past the autosave's 300ms), the tab reloads. - await page.waitForTimeout(2000); + // V3: the edit has landed (Synced above), then the tab reloads. const reload = await interruptC(page, 'reload'); await expect(recordBoutButton(page)).toBeVisible(); const kept = await bout8Marks(); @@ -837,8 +845,8 @@ 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). - 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', + await expect(row.locator('.shiaijo-qrow__result')).toHaveText(/IV 4–2\s+PW 7–3/, { timeout: 8000 }); + record({ step: 'M1 reopen', action: 'ended again: Completed row', variant: 'result', expected: 'IV 4–2 PW 7–3', 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. @@ -899,7 +907,7 @@ test.describe('J5 kachinuki from the court console', () => { await test.step('V1: the thumb meant the danger button and hit Leave it running', async () => { const v1 = await tapNeighbour(page.getByTestId('kachinuki-reopen-requeue-button'), 'right'); - await page.waitForTimeout(300); + await rendered(page); const panelStill = await conflict.isVisible(); 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') }); @@ -911,14 +919,14 @@ 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 }); + await expect(conflict).toHaveCount(0, { timeout: EXPECT_TIMEOUT_MS }); // The overlay follows the reopened match into bout mode. - await expect(page.locator(EDITOR).getByRole('button', { name: 'Record bout' })).toBeVisible({ timeout: 15_000 }); + await expect(page.locator(EDITOR).getByRole('button', { name: 'Record bout' })).toBeVisible({ timeout: EXPECT_TIMEOUT_MS }); 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 }); + await expect(recordBoutButton(page)).toBeVisible({ timeout: EXPECT_TIMEOUT_MS }); const upNext = await upNextCard(page).isVisible() ? await sides(upNextCard(page)) : null; 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, @@ -939,7 +947,7 @@ test.describe('J5 kachinuki from the court console', () => { await expect(upNextCard(page)).toBeVisible(); expect(await sides(upNextCard(page))).toEqual(m2); await upNextCard(page).getByRole('button', { name: 'Start match' }).tap(); - await page.waitForTimeout(1500); + await expect(recordBoutButton(page)).toBeVisible(); // 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). @@ -1008,7 +1016,9 @@ test.describe('J5 kachinuki from the court console', () => { await shot(page, 'm2-fighter-pick-open'); const optBox = await option(S[1]).boundingBox(); await option(S[1]).tap(); - await page.waitForTimeout(600); + await expect(option(S[1])).toBeHidden(); + // The pick is saved and the row redrawn: a tap made meanwhile is lost with the old row. + await untilStable(() => boutNames(currentBout(page))); const afterPick = await shiroMarks(); const pickShot = await shot(page, 'm2-fighter-picked-one-tap'); record({ step: 'M2 bout 2', action: 'pick the Shiro fighter (one tap)', variant: 'correct path', @@ -1019,7 +1029,9 @@ test.describe('J5 kachinuki from the court console', () => { // V2: the option double-tapped. const reopenTaps = await openList(); const v2 = await doubleTap(option(S[1])); - await page.waitForTimeout(600); + await expect(option(S[1])).toBeHidden(); + // The pick is saved and the row redrawn: a tap made meanwhile is lost with the old row. + await untilStable(() => boutNames(currentBout(page))); const v2Marks = await shiroMarks(); const v2Shot = await shot(page, 'm2-v2-fighter-pick-double-tap'); record({ step: 'M2 bout 2', action: 'pick the Shiro fighter', variant: 'V2 doubleTap', ...v2, tapsToReopenList: reopenTaps, @@ -1028,14 +1040,14 @@ test.describe('J5 kachinuki from the court console', () => { // own shortcut line says "Esc close"). await openList(); await page.keyboard.press('Escape'); - await page.waitForTimeout(400); + await rendered(page); const discard = page.locator('.modal[role="dialog"]').filter({ hasText: /Discard unsaved/ }); const dialogShown = await discard.isVisible(); const escShot = await shot(page, 'm2-esc-discard-dialog'); let hasty = {}; if (dialogShown) { hasty = await hastyConfirm(page); - await page.waitForTimeout(800); + await expect(discard).toBeHidden(); } record({ step: 'M2 bout 2', action: 'Esc to close the fighter list', variant: 'V4 hastyConfirm', dialogShown, ...hasty, editorStill: await recordBoutButton(page).isVisible(), pairing: await boutNames(currentBout(page)).catch(() => null), @@ -1045,7 +1057,7 @@ test.describe('J5 kachinuki from the court console', () => { // V1: the option below is hit instead. await openList(); const v1 = await tapNeighbour(option(S[1]), 'down'); - await page.waitForTimeout(600); + await rendered(page); const wrong = await boutNames(currentBout(page)); const v1Marks = await shiroMarks(); const v1Shot = await shot(page, 'm2-v1-fighter-pick-neighbour'); @@ -1055,7 +1067,9 @@ test.describe('J5 kachinuki from the court console', () => { recoverTaps += await openList(); await option(S[1]).tap(); recoverTaps += 1; - await page.waitForTimeout(600); + await expect(option(S[1])).toBeHidden(); + // The pick is saved and the row redrawn: a tap made meanwhile is lost with the old row. + await untilStable(() => boutNames(currentBout(page))); recoverTaps += await clearGhostMarks(); } await expect.poll(() => boutNames(currentBout(page))).toEqual({ shiro: S[1], aka: A[0] }); @@ -1079,6 +1093,8 @@ test.describe('J5 kachinuki from the court console', () => { const before = await doneRows(page).count(); const v2 = await doubleTap(recordBoutButton(page)); await expect(doneRows(page)).toHaveCount(before + 1); + // Deliberate: the bounced second tap must NOT append another bout, and + // an absence has no state to wait for, so the time is the evidence. await page.waitForTimeout(1200); record({ step: 'M2 bout 4', action: 'Record bout', variant: 'V2 doubleTap', ...v2, doneRows: await doneRows(page).count(), current: await boutNames(currentBout(page)), @@ -1123,22 +1139,22 @@ test.describe('J5 kachinuki from the court console', () => { await test.step('the hurried operator reopens M2 and taps Send back to queue to get out', async () => { await completedRowFor(page, m2).locator('.shiaijo-row__correct').tap(); await reopenButton(page).tap(); - await expect(recordBoutButton(page)).toBeVisible({ timeout: 15_000 }); + await expect(recordBoutButton(page)).toBeVisible({ timeout: EXPECT_TIMEOUT_MS }); const boutsBefore = await doneRows(page).count(); const revert = page.getByRole('button', { name: 'Send back to queue' }); await expect(revert).toBeVisible(); await revert.tap(); await shot(page, 'm2-send-back-confirm'); const hasty = await hastyShiaijoConfirm(page); - await page.waitForTimeout(1500); + await expect(recordBoutButton(page)).toBeHidden(); // Where did the encounter go, and did its bout log survive? const upNext = await upNextCard(page).isVisible() ? await sides(upNextCard(page)).catch(() => null) : null; let boutsAfter = null; if (upNext) { await upNextCard(page).getByRole('button', { name: 'Start match' }).tap(); await expect(recordBoutButton(page)).toBeVisible(); - await page.waitForTimeout(800); - boutsAfter = await doneRows(page).count(); + // Did the bout log survive the send back: the count once the sheet has loaded it. + boutsAfter = await untilStable(() => doneRows(page).count()); } record({ step: 'M2 reopened', action: 'Send back to queue (reopened encounter)', variant: 'V4 hasty confirm', ...hasty, boutsBefore, upNext, boutsAfterRestart: boutsAfter, current: upNext ? await boutNames(currentBout(page)) : null, @@ -1275,7 +1291,7 @@ test.describe('J5 kachinuki from the court console', () => { await shot(p, 'final-v3-record-offline'); }, }); - const appended = await expect(doneRows(page)).toHaveCount(before + 1, { timeout: 15_000 }).then(() => true, () => false); + const appended = await expect(doneRows(page)).toHaveCount(before + 1, { timeout: EXPECT_TIMEOUT_MS }).then(() => true, () => false); record({ step: 'final bout 2', action: 'Record bout', variant: 'V3 interrupt offline', ...offline, whileOffline, appendedAfterReconnect: appended, doneRows: await doneRows(page).count(), live: await boutNames(currentBout(page)), screenshot: await shot(page, 'final-after-reconnect') }); @@ -1327,7 +1343,7 @@ async function completedOnServer(page, pair, court = 'A') { await expect.poll(async () => { await openShiaijo(fresh, court); 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); + }, { timeout: EXPECT_TIMEOUT_MS, message: `${pair.shiro} v ${pair.aka} is completed on the server` }).toBeGreaterThan(0); } finally { await fresh.close(); } @@ -1366,7 +1382,7 @@ async function hastyReasonPrompt(page) { const preset = await prompt.locator('select').first().inputValue().catch(() => ''); const { button, ...loud } = await loudestButton(prompt); await button.tap(); - await prompt.waitFor({ state: 'hidden', timeout: 15_000 }); + await prompt.waitFor({ state: 'hidden', timeout: EXPECT_TIMEOUT_MS }); return { ...loud, defaultReason: preset }; } diff --git a/scripts/e2e/journeys/knockout-mixed-team.spec.mjs b/scripts/e2e/journeys/knockout-mixed-team.spec.mjs index 50a942f0b..04cd93665 100644 --- a/scripts/e2e/journeys/knockout-mixed-team.spec.mjs +++ b/scripts/e2e/journeys/knockout-mixed-team.spec.mjs @@ -15,17 +15,17 @@ // 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 { OPERATOR_DEVICE, PUBLIC_DEVICE, EXPECT_TIMEOUT_MS } from '../fixtures/devices.mjs'; import { journeyShots } from '../fixtures/shots.mjs'; import { enterTournament, 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, completedRowFor } from '../fixtures/shiaijo.mjs'; -import { EDITOR, clearMarks } from '../fixtures/scoring.mjs'; +import { EDITOR, clearMarks, nextMatchWrite, writeLanded } 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 { dwell, rendered, settled, untilStable } from '../fixtures/pace.mjs'; import { auditLog } from '../fixtures/audit.mjs'; import { tapSize } from '../fixtures/pointer.mjs'; import * as T from '../fixtures/team.mjs'; @@ -123,7 +123,7 @@ test.describe('knockout-mixed-team', () => { const addBtn = T.lineupsForm(page).getByRole('button', { name: /^Add$/ }); const addBox = await tapSize(addBtn); const dbl = await doubleTap(addBtn); - await page.waitForTimeout(400); + await rendered(page); const dialogs = await page.locator('.modal[role="dialog"]').count(); await shot(page, 'f3-lineups-add-doubletap-dialog'); // The second tap may have landed on the dialog itself; if it closed it, @@ -224,7 +224,7 @@ test.describe('knockout-mixed-team', () => { if (!/no control within/.test(e.message)) throw e; return { hazardGone: true }; }); - await page.waitForTimeout(800); + await rendered(page); 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), @@ -320,7 +320,7 @@ test.describe('knockout-mixed-team', () => { const wrongOpt = opts.nth(Math.min(aokiIdx + 1, texts.length - 1)); const wrongText = (await wrongOpt.innerText()).trim(); await wrongOpt.tap(); - await page.waitForTimeout(600); + await rendered(page); const afterWrong = await box.inputValue(); const labelAfterWrong = (await T.rowMemberLabel(row1, 'shiro').innerText().catch(() => '')).trim(); await shot(page, 'f3-sheet-wrong-fighter'); @@ -336,8 +336,8 @@ test.describe('knockout-mixed-team', () => { 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'); - await page.waitForTimeout(800); const err = ed.getByTestId('team-editor-lineup-warning'); + await expect(err).toBeVisible(); const errText = (await err.count()) ? (await err.innerText()).trim() : ''; await shot(page, 'f3-sheet-duplicate'); record({ step: 'EP3 duplicate', action: 'type Baba into row 1 (Baba at row 2)', variant: 'refusal copy', copy: errText, @@ -385,8 +385,8 @@ test.describe('knockout-mixed-team', () => { await shot(page, 'f3-lineups-add-member-confirm'); hasty = await hastyConfirm(page); } - await page.waitForTimeout(800); - const after = await T.lineupsForm(page).locator('[data-testid^="squad-member-"]').count(); + // The add is a write and the list refetches: the count the answer left. + const after = await untilStable(() => T.lineupsForm(page).locator('[data-testid^="squad-member-"]').count()); await shot(page, 'f3-lineups-after-add-member', { fullPage: true }); record({ step: 'EP1 add a substitute', action: '+ Add new member… at 3 (reserves .4/.5 blank)', variant: 'V4 hastyConfirm', ...(hasty || {}), membersBefore: before, membersAfter: after, alert: r.alert }); @@ -406,12 +406,11 @@ test.describe('knockout-mixed-team', () => { // a result; neither team fields bout 3, so it is recorded as a Tie // rather than left unfought, and Finish now refuses until it is. await T.ensureTie(ed, 3); - await page.waitForTimeout(1200); const sheet = await T.sheetState(ed, 3); await shot(page, 'f3-pool-encounter-bout3-tied'); await T.finishTeam(ed); await page.goto(`/admin/competition/${f3}/pools`); - await page.waitForTimeout(1500); + await expect(page.locator('tr').filter({ hasText: pairNow.shiro }).first()).toBeVisible(); await shot(page, 'f3-pools-standings', { fullPage: true }); const standings = await page.locator('tr').filter({ hasText: pairNow.shiro }).first().innerText().catch(() => ''); const standingsAka = await page.locator('tr').filter({ hasText: pairNow.aka }).first().innerText().catch(() => ''); @@ -448,7 +447,7 @@ test.describe('knockout-mixed-team', () => { 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 expect(page.locator('body')).toContainText('Kiken'); await shot(page, 'f3-pools-after-kiken-chain', { fullPage: true }); const matchesText = (await page.locator('body').innerText()).replace(/\s+/g, ' '); expect(matchesText).toContain('Kiken'); @@ -460,8 +459,9 @@ test.describe('knockout-mixed-team', () => { await openShiaijo(page, 'A'); const upNext = await sides(upNextCard(page)).catch(() => null); await upNextCard(page).getByRole('button', { name: 'Start match' }).tap().catch(() => {}); - await page.waitForTimeout(1500); - const msgs = (await page.locator('.alert, [role="alert"], .toast, [class*="toast"]').allInnerTexts().catch(() => [])).join(' | ').replace(/\s+/g, ' '); + const messages = page.locator('.alert, [role="alert"], .toast, [class*="toast"]'); + await expect(T.boutRow(inlineEditor(page), 1).or(messages.first())).toBeVisible(); + const msgs = (await messages.allInnerTexts().catch(() => [])).join(' | ').replace(/\s+/g, ' '); await shot(page, 'f3-start-withdrawn-team-match'); record({ step: 'pool kiken chain', action: 'Start match on the withdrawn team\'s remaining match', variant: 'V5 lost place', upNext, messages: msgs.slice(0, 300), editorOpen: await T.boutRow(inlineEditor(page), 1).isVisible().catch(() => false) }); @@ -510,7 +510,7 @@ test.describe('knockout-mixed-team', () => { await expect(row1.getByRole('textbox', { name: 'Senpo AKA player' })).toBeVisible(); const shiroRow1 = await T.rowNameBox(row1, 'shiro').inputValue(); await T.typeIntoNameBox(T.rowNameBox(row1, 'aka'), 'Nakamura'); - await page.waitForTimeout(800); + await expect(T.rowMemberLabel(row1, 'aka')).toHaveText(/\.1$/); const akaLabel = (await T.rowMemberLabel(row1, 'aka').innerText()).trim(); await shot(page, 'f4-sheet-row1'); record({ step: 'F4 EP3 sheet', action: 'type Nakamura into Aka Senpo (picked slot .1)', variant: 'correct', shiroRow1, akaLabel, @@ -540,9 +540,10 @@ test.describe('knockout-mixed-team', () => { const startBox = await tapSize(start); const dbl = await doubleTap(start); await expect(T.boutRow(ed, 1)).toBeVisible(); - await page.waitForTimeout(800); + await expect(T.syncPill(ed)).toBeVisible(); const identity = (await ed.locator('.editor-modal__eyebrow').first().innerText()).trim(); - const upNextNow = await sides(upNextCard(page)).catch(() => null); + // Up next moves on once the list has refetched after the start. + const upNextNow = await untilStable(() => sides(upNextCard(page)).catch(() => null)); await shot(page, 'sf1-started'); record({ step: 'start SF1', action: 'Start match (Up next)', variant: 'V2 doubleTap', ...dbl, startTapBox: startBox, editorHolds: identity, upNextAfter: upNextNow, errorsShown: await page.locator('.alert--error, [role="alert"]').allInnerTexts() }); @@ -577,7 +578,7 @@ test.describe('knockout-mixed-team', () => { const row = T.boutRow(ed, 2); const intended = T.rowIppon(row, 'aka', 'M'); const nb = await tapNeighbour(intended, 'left'); - await page.waitForTimeout(600); + await rendered(page); const tieOn = /✓/.test((await T.rowTie(row).innerText().catch(() => '')) || ''); const middle = (await T.rowMiddle(row).innerText()).trim(); await shot(page, 'bout2-neighbour-tap'); @@ -627,12 +628,14 @@ test.describe('knockout-mixed-team', () => { // V3: fusensho, the operator looks away (the write lands), the page // reloads, then the operator untoggles "from memory". + const fusenshoSaved = nextMatchWrite(page); await T.rowFusensho(row, 'aka').tap(); - await page.waitForTimeout(1500); + await expect(T.rowSlots(row, 'aka')).toHaveCount(2); + await writeLanded(fusenshoSaved, 'the fusensho save'); await expect(T.syncPill(ed)).toHaveText('Synced'); const r = await interrupt(page, 'reload'); await expect(T.boutRow(ed, 3)).toBeVisible(); - await page.waitForTimeout(1500); + await expect(T.rowFusensho(T.boutRow(ed, 3), 'aka')).toHaveText('✓ Fusensho'); const afterReload = { shiro: await T.rowSlots(T.boutRow(ed, 3), 'shiro').allInnerTexts(), aka: await T.rowSlots(T.boutRow(ed, 3), 'aka').allInnerTexts(), @@ -642,7 +645,8 @@ test.describe('knockout-mixed-team', () => { await T.boutRow(ed, 3).scrollIntoViewIfNeeded(); await shot(page, 'bout3-after-reload-before-untoggle'); await T.rowFusensho(T.boutRow(ed, 3), 'aka').tap(); - await page.waitForTimeout(1200); + await expect(T.rowSlots(T.boutRow(ed, 3), 'aka')).toHaveCount(0); + await expect(T.syncPill(ed)).toHaveText('Synced'); const afterUntoggle = { shiro: await T.rowSlots(T.boutRow(ed, 3), 'shiro').allInnerTexts(), aka: await T.rowSlots(T.boutRow(ed, 3), 'aka').allInnerTexts() }; await shot(page, 'bout3-untoggle-after-reload'); record({ step: 'bout 3', action: 'Fusensho Aka, reload, untoggle', variant: 'V3 interrupt reload', ...r, afterReload, afterUntoggle, sheet: await T.sheetState(ed), @@ -665,7 +669,8 @@ test.describe('knockout-mixed-team', () => { const off = await interrupt(page, 'offline', { during: async (p) => { await T.rowIppon(T.boutRow(inlineEditor(p), 4), 'aka', 'D').tap(); - await expect(T.syncPill(inlineEditor(p))).not.toHaveText('Synced', { timeout: 3000 }).catch(() => {}); + // The pill must stop claiming "Synced" while the point cannot be saved. + await expect(T.syncPill(inlineEditor(p))).not.toHaveText('Synced'); offlinePill = (await T.syncPill(inlineEditor(p)).innerText()).trim(); await shot(p, 'bout4-offline'); }, @@ -678,7 +683,7 @@ test.describe('knockout-mixed-team', () => { const pillAtReload = (await T.syncPill(ed).innerText()).trim(); const quick = await interrupt(page, 'reload'); await expect(T.boutRow(ed, 4)).toBeVisible(); - await page.waitForTimeout(1500); + await expect(T.syncPill(ed)).toHaveText('Synced'); record({ step: 'bout 4', action: 'Aka kote, reload within 300ms', variant: 'V3 interrupt reload (instant)', ...quick, pillAtReload, akaAfter: await T.rowSlots(T.boutRow(ed, 4), 'aka').allInnerTexts(), expected: ['D', 'K'], note: 'bc-sync when K is gone' }); const hidden = await interrupt(page, 'hidden'); @@ -686,7 +691,7 @@ test.describe('knockout-mixed-team', () => { sameMatch: (await ed.locator('.editor-modal__eyebrow').first().innerText()).trim() === identity, akaKept: await T.rowSlots(T.boutRow(ed, 4), 'aka').allInnerTexts() }); const back = await interrupt(page, 'back'); - const edBack = await T.boutRow(ed, 4).waitFor({ state: 'visible', timeout: 15000 }).then(() => true, () => false); + const edBack = await T.boutRow(ed, 4).waitFor({ state: 'visible', timeout: EXPECT_TIMEOUT_MS }).then(() => true, () => false); await shot(page, 'bout4-after-back'); record({ step: 'bout 4', action: 'Back then Forward', variant: 'V3 interrupt back + V5 lost place', ...back, editorBack: edBack, sameMatch: edBack && (await ed.locator('.editor-modal__eyebrow').first().innerText()).trim() === identity, @@ -698,8 +703,9 @@ test.describe('knockout-mixed-team', () => { const row5 = T.boutRow(ed, 5); await row5.scrollIntoViewIfNeeded(); const before5 = { middle: (await T.rowMiddle(row5).innerText()).trim(), tieLabel: (await T.rowTie(row5).innerText()).trim() }; + const tieSaved = nextMatchWrite(page); await T.rowTie(row5).tap(); - await page.waitForTimeout(1500); + await writeLanded(tieSaved, 'the Tie save'); const after5 = { middle: (await T.rowMiddle(row5).innerText()).trim(), tieLabel: (await T.rowTie(row5).innerText()).trim() }; await shot(page, 'bout5-tie-tapped'); record({ step: 'bout 5', action: 'Tie (hikiwake) on the unfought bout 5', variant: 'correct', before5, after5, @@ -755,7 +761,7 @@ test.describe('knockout-mixed-team', () => { await ed.getByTestId('team-daihyosen-hantei-arm').tap(); const shiroWins = ed.getByTestId('team-daihyosen-hantei-shiro'); const nb = await tapNeighbour(shiroWins, 'right'); - await page.waitForTimeout(300); + await rendered(page); const akaHt = await ed.getByTestId('team-daihyosen-ht-aka').count(); await shot(page, 'hantei-wrong-side'); await shiroWins.tap(); @@ -776,7 +782,10 @@ test.describe('knockout-mixed-team', () => { await finish.scrollIntoViewIfNeeded(); const labelBefore = (await finish.innerText()).trim(); const dbl = await doubleTap(finish); - await page.waitForTimeout(1500); + await expect(completedRows(page).first().or(T.finishBtn(ed).filter({ hasText: /^Tap again/ }))).toBeVisible(); + // The app ignores a tap inside the arm's bounce window; out it, so the + // operator's own tap below (when the double tap only armed) counts. + await dwell(page, Date.now()); const done = await completedRows(page).count(); let extraTaps = 0; if (!done) { @@ -833,6 +842,8 @@ test.describe('knockout-mixed-team', () => { 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); + // Deliberate: the prompt must STAY open and nothing may be recorded, and + // an absence has no state to wait for, so the time is the evidence. await page.waitForTimeout(800); const promptStillOpen = await prompt.isVisible(); const completedAfterHasty = await completedRows(page).count(); @@ -850,10 +861,9 @@ test.describe('knockout-mixed-team', () => { await shot(page, 'sf2-kiken-side-picked'); // Not reading the button, the operator taps Record. await loud.tap(); - await page.waitForTimeout(1500); - 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(); + await shot(page, 'sf2-kiken-recorded'); 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(), @@ -909,6 +919,8 @@ test.describe('knockout-mixed-team', () => { await prompt.locator('button.btn--primary').tap(); // bc-dlck: no "A subsequent match ... Proceed anyway?" confirm follows; the // final it feeds was sent back unscored, so nothing else asks either. + // Deliberate: an absence (no confirm) has no state to wait for, so the + // time is the evidence. 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(' | '); @@ -917,7 +929,7 @@ test.describe('knockout-mixed-team', () => { 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 expect(page.locator('.shiaijo__queue')).toBeVisible(); await shot(page, 'court-after-kiken-recovery'); const court = (await page.locator('body').innerText()).replace(/\s+/g, ' '); record({ step: 'SF2 kiken recovery', action: 'court after the recovery', variant: 'V5 lost place', @@ -942,7 +954,7 @@ test.describe('knockout-mixed-team', () => { await prompt.locator('input[value="aka"]').check(); await shot(page, 'final-fusenpai-prompt'); await prompt.locator('button.btn--primary').tap(); - await page.waitForTimeout(1500); + await expect(prompt).toHaveCount(0); await shot(page, 'final-fusenpai-recorded'); record({ step: 'final fusenpai', action: 'Fusenpai (Aka did not show)', variant: 'correct', pair, errors: (await ed.locator('[style*="danger"], .alert--error').allInnerTexts().catch(() => [])).join(' | '), @@ -1012,7 +1024,7 @@ test.describe('knockout-mixed-team', () => { await shot(hp, 'head-table-hantei-shiro'); await T.finishTeam(hed); // The court console, left open, follows the result. - await expect(completedRows(page)).toHaveCount(1, { timeout: 15000 }); + await expect(completedRows(page)).toHaveCount(1, { timeout: EXPECT_TIMEOUT_MS }); await shot(page, 'court-after-head-table-finish'); const courtRow = (await completedRows(page).first().innerText()).replace(/\s+/g, ' '); record({ step: 'finish elsewhere', action: 'head table Finish with hantei Shiro', variant: 'two devices', courtCompletedRow: courtRow, @@ -1042,7 +1054,7 @@ test.describe('knockout-mixed-team', () => { await shot(hp, 'head-table-correction-reason'); await reason.locator('input, textarea').first().fill('Hantei was for Aka'); await reason.getByRole('button', { name: /Save|Confirm|Record/ }).last().tap(); - await expect(hp.locator(EDITOR)).toHaveCount(0, { timeout: 15000 }).catch(() => {}); + await expect(hp.locator(EDITOR)).toHaveCount(0); await shot(hp, 'head-table-after-correction'); // The court's open correction editor adopts the new verdict, untouched. const adopted = await ed.getByTestId('team-daihyosen-ht-aka').waitFor({ state: 'visible', timeout: 10000 }).then(() => true, () => false); @@ -1075,7 +1087,7 @@ test.describe('knockout-mixed-team', () => { // 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(); - await phone.waitForTimeout(800); + await expect(phone.locator('.viewer__tab.is-active')).toHaveText('Bracket'); await shot(phone, 'viewer-bracket-final', { fullPage: true }); 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), @@ -1150,8 +1162,9 @@ test.describe('knockout-mixed-team', () => { await seedF4(page, { name: 'B3 KO', court: 'J', prefix: 'J', teams: [['B3 Ume', 'Kita Dojo'], ['B3 Sakura', 'Minami Dojo']] }); await openShiaijo(page, 'J'); const ed = await T.startUpNextTeam(page); + const ipponSaved = nextMatchWrite(page); await T.boutIppon(ed, 1, 'shiro', 'M'); - await page.waitForTimeout(1500); + await writeLanded(ipponSaved, 'the ippon save'); await page.reload(); await expect(T.boutRow(ed, 2)).toBeVisible(); // Bouts 2-5 have not been fought: no draw mark, and Tie is not pressed. @@ -1181,12 +1194,13 @@ test.describe('knockout-mixed-team', () => { // The kote is saved before the fusensho. await expect(T.syncPill(ed)).toHaveText('Synced'); // Shiro's fighter cannot continue; the bout goes to Aka as a fusensho. + const fusenshoSaved = nextMatchWrite(page); 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 // scored stays valid and is kept on the sheet." await expect(T.rowSlots(T.boutRow(ed, 1), 'shiro')).toHaveText(['K']); - await page.waitForTimeout(1500); + await writeLanded(fusenshoSaved, 'the fusensho save'); await page.reload(); await expect(T.rowFusensho(T.boutRow(ed, 1), 'aka')).toHaveText('✓ Fusensho'); // "Click to undo fusensho: restores the previous score". @@ -1255,9 +1269,10 @@ test.describe('knockout-mixed-team', () => { for (const n of [1, 2, 3, 4, 5]) await T.ensureTie(ed, n); // ensureTie saw the last Tie's write land (the pill back at Synced); the // operator goes straight on. + const daihyosenAdded = nextMatchWrite(page, { method: 'POST', tail: 'daihyosen' }); await ed.getByTestId('scoring-modal-daihyosen-button').tap(); await expect(T.boutRow(ed, 'DH')).toBeVisible(); - await page.waitForTimeout(1500); + await writeLanded(daihyosenAdded, 'the daihyosen add'); await expect(T.boutRow(ed, 'DH')).toBeVisible(); await page.reload(); await expect(T.boutRow(ed, 'DH')).toBeVisible(); diff --git a/scripts/e2e/journeys/moved-match.spec.mjs b/scripts/e2e/journeys/moved-match.spec.mjs index f1de02f28..46389728d 100644 --- a/scripts/e2e/journeys/moved-match.spec.mjs +++ b/scripts/e2e/journeys/moved-match.spec.mjs @@ -31,7 +31,8 @@ import { enterTournament, login } from '../fixtures/setup.mjs'; import { openShiaijo, upNextCard, sides, startUpNext } from '../fixtures/shiaijo.mjs'; import { EDITOR, awardIppon, finishButton, finishMatch } from '../fixtures/scoring.mjs'; import { hastyShiaijoConfirm } from '../fixtures/clumsy.mjs'; -import { settled } from '../fixtures/pace.mjs'; +import { rendered, settled, untilStable } from '../fixtures/pace.mjs'; +import { EXPECT_TIMEOUT_MS } from '../fixtures/devices.mjs'; import { tapSize } from '../fixtures/pointer.mjs'; import { ROSTER_6, recordDecision, slotMarks } from '../fixtures/individual.mjs'; import * as T from '../fixtures/team.mjs'; @@ -191,7 +192,7 @@ test.describe('moved-match', () => { // 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]); + await expect.poll(async () => ((await courtState(B)).editor || {}).names, { timeout: EXPECT_TIMEOUT_MS }).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)', @@ -224,7 +225,7 @@ test.describe('moved-match', () => { await T.finishTeam(hed); // 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); + await expect.poll(async () => (await courtState(B)).completed, { timeout: EXPECT_TIMEOUT_MS }).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') }); @@ -238,7 +239,7 @@ test.describe('moved-match', () => { await expect.poll(async () => { const s = await courtState(B); return !!(s.upNext || (s.editor && !s.editor.pill)); - }, { timeout: 15_000 }).toBe(true); + }, { timeout: EXPECT_TIMEOUT_MS }).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 }); @@ -261,7 +262,8 @@ test.describe('moved-match', () => { // 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 expect.poll(async () => (await nudgeLines(B)).map((l) => l.amber), { timeout: EXPECT_TIMEOUT_MS }).toEqual([true]); + // Deliberate: the court must NOT switch by itself, so the time is the evidence. await B.waitForTimeout(3000); const bDone = await courtState(B); const amber = await nudgeLines(B); @@ -288,7 +290,7 @@ test.describe('moved-match', () => { 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); + await rendered(H); 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); @@ -309,11 +311,13 @@ test.describe('moved-match', () => { 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]); + await expect.poll(async () => ((await courtState(B)).editor || {}).names, { timeout: EXPECT_TIMEOUT_MS }).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(' | '); + // A start the server refuses shows its notice once answered; one it accepts + // shows the bout running: the row records whichever, so wait for it to settle. + await untilStable(async () => [await notice(), await runningOnCourt(H, 'B')]); 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') }); @@ -325,9 +329,9 @@ test.describe('moved-match', () => { 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 expect.poll(async () => ((await courtState(B)).upNext || {}).names, { timeout: EXPECT_TIMEOUT_MS }).toEqual([secondPair.shiro, secondPair.aka]); await (await startOf(secondPair)).tap(); - await B.waitForTimeout(2000); + await untilStable(async () => [await notice(), await runningOnCourt(H, 'B')]); 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') }); @@ -353,7 +357,7 @@ test.describe('moved-match', () => { 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 expect.poll(() => runs(moved), { timeout: EXPECT_TIMEOUT_MS }).toBe(true); } await awardIppon(B, 'shiro', 'M'); const marks = await slotMarks(B, 'shiro'); @@ -364,10 +368,12 @@ test.describe('moved-match', () => { 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); + }, { timeout: EXPECT_TIMEOUT_MS }).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 }); + // Deliberate: the running bout must STAY running, and a late send back would + // pass a single read, so the time is the evidence. 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); @@ -380,7 +386,7 @@ test.describe('moved-match', () => { 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); + await expect.poll(() => runs(blocked), { timeout: EXPECT_TIMEOUT_MS }).toBe(true); const onBafter = await runningOnCourt(H, 'B'); expect(onBafter).toHaveLength(1); expect(onBafter[0]).toMatchObject({ shiro: blocked.shiro, aka: blocked.aka }); @@ -400,8 +406,18 @@ test.describe('moved-match', () => { // 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. + // fighting on B. The network log is read too: the Scores tab sends no /score + // start the server refuses with court_busy (the refusal the toast of the bead's + // report came from), which the console policy would otherwise let pass. test.fixme('bc-adna: the Scores tab never starts a match by itself', async () => { + const courtBusyStarts = []; + const onResponse = async (res) => { + const req = res.request(); + if (req.method() !== 'PUT' || res.status() !== 409 || !/\/matches\/[^/]+\/score$/.test(new URL(res.url()).pathname)) return; + if (!/"status"\s*:\s*"running"/.test(req.postData() || '')) return; + if (/"error"\s*:\s*"court_busy"/.test(await res.text().catch(() => ''))) courtBusyStarts.push(res.url()); + }; + H.on('response', onResponse); 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)); @@ -427,6 +443,7 @@ test.describe('moved-match', () => { await expect(finishButton(H, EDITOR)).toHaveText('Finish'); await awardIppon(H, 'shiro', 'M', EDITOR); await finishMatch(H, EDITOR); + // Deliberate: nothing may start by itself, so the time is the evidence. 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. @@ -434,9 +451,12 @@ test.describe('moved-match', () => { await open(second); await start(); await recordDecision(H, 'fusenpai', { side: 'aka', root: EDITOR }); + // Deliberate: no start may follow (no toast, nothing running), so the time is the evidence. 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); + H.off('response', onResponse); + expect(courtBusyStarts, 'no start sent from the Scores tab was refused for the court').toEqual([]); }); }); diff --git a/scripts/e2e/journeys/shared-shiaijo.spec.mjs b/scripts/e2e/journeys/shared-shiaijo.spec.mjs index ad92436ca..a445e1180 100644 --- a/scripts/e2e/journeys/shared-shiaijo.spec.mjs +++ b/scripts/e2e/journeys/shared-shiaijo.spec.mjs @@ -41,7 +41,8 @@ import { import { doubleTap, hastyShiaijoConfirm, interrupt, loudestButton, retapWhileSaving, tapNeighbour, } from '../fixtures/clumsy.mjs'; -import { dwell, settled } from '../fixtures/pace.mjs'; +import { dwell, rendered, settled, untilStable } from '../fixtures/pace.mjs'; +import { EXPECT_TIMEOUT_MS } from '../fixtures/devices.mjs'; import { tapSize } from '../fixtures/pointer.mjs'; import { openTvBoard, tvBoard } from '../fixtures/public.mjs'; import { @@ -127,7 +128,7 @@ async function nextLive(prev) { await expect.poll(async () => { live = await liveOnConsole(); return !!live && !same(live, prev); - }, { timeout: 15_000 }).toBe(true); + }, { timeout: EXPECT_TIMEOUT_MS }).toBe(true); return live; } @@ -182,10 +183,12 @@ test.describe('shared-shiaijo', () => { // 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. + // match there, and ends. 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 and the wizard's form (nothing is created), so + // S1 starts from the same state. The wizard still offers its start time to the + // operator, who can change it as the day goes. The hint's new wording is the + // fix's to write: it is recorded here, and only its presence is 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 = [ @@ -197,8 +200,21 @@ test.describe('shared-shiaijo', () => { 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); + // After SI's last match there ENDS, not merely starts: its slot is as long as + // the gap SI's own slots keep (an SI match of a single slot has no gap to read). + const minutes = (t) => Number(t.slice(0, 2)) * 60 + Number(t.slice(3)); + const siStarts = [...new Set(siOnA.map(minutes))]; + const slot = Math.min(...siStarts.slice(1).map((m, i) => m - siStarts[i]).filter((g) => g > 0), Infinity); + const siEnds = siStarts[siStarts.length - 1] + (Number.isFinite(slot) ? slot : 1); + expect(minutes(stOnA[0]), `${ST}'s first match on A (${stOnA[0]}) comes after ${SI}'s last there ends (${siEnds} min)`).toBeGreaterThanOrEqual(siEnds); + // The wizard still tells the operator something about sharing a shiaijo, and + // still lets them set the start time themselves. + expect(draw.defaults.ST.sharedCourtHint, 'the wizard hint about sharing a shiaijo').toBeTruthy(); + await H.goto('/admin/create-competition'); + const startInput = H.locator('#create-start-time'); + await expect(startInput).toBeEditable(); + await startInput.fill('13:45'); + await expect(startInput).toHaveValue('13:45'); }); // S1 the picker, S8 the slot times (P6), S7 the public queue before play. @@ -243,7 +259,7 @@ test.describe('shared-shiaijo', () => { nb.note.push(String(e.message).slice(0, 120)); } } - await C.waitForTimeout(600); + await rendered(C); 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))) }); @@ -261,8 +277,10 @@ test.describe('shared-shiaijo', () => { 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); + await expect(H.getByRole('button', { name: 'Auto-schedule competition' })).toBeEnabled({ timeout: EXPECT_TIMEOUT_MS }); + // The button is enabled again before it has visibly been busy, so the saved + // times are what says the re-timing is done: ST's earliest slot is the restart. + await expect.poll(async () => (await readScoresTab(H, ids.ST)).map((m) => m.time).filter(Boolean).sort()[0]).toBe(ST_RESTART); const siAfter = await readScoresTab(H, ids.SI); draw.st = await readScoresTab(H, ids.ST); draw.stKnockout = await bracketSlots(H, ids.ST); @@ -273,9 +291,9 @@ test.describe('shared-shiaijo', () => { // 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); + // Both screens fill from the server after they load: the text they settle on. + await untilStable(async () => [await tvBoard(TV).innerText(), await P.locator('body').innerText()]); 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), @@ -299,8 +317,19 @@ test.describe('shared-shiaijo', () => { 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); + // The board's next-match slots (display_scoreboard.jsx), each on its own. The + // promoted slot is the match the board puts first before play (its NOW row on an + // individual board, the headline on a team board) and must be the console's Up + // next; the NEXT line, drawn when a queued match is not already on the board, + // must not be the other competition's. The UP NEXT strip lists the next POOL and + // is not the next match, so it is not read here. + const promoted = s.officiating === SI ? TV.getByTestId('tvd-indiv-row-now').first() : tvBoard(TV); + await expect(promoted, 'the TV board promotes a match').toBeVisible(); + await expect(promoted).toContainText(shiro); + await expect(promoted).toContainText(aka); + const nextLine = TV.locator('div:has(> span:text-is("NEXT"))'); + if (await nextLine.count()) for (const name of otherNames) await expect(nextLine).not.toContainText(name); + // And the other competition appears nowhere else on the board either. for (const name of otherNames) await expect(tvBoard(TV)).not.toContainText(name); await P.goto('/schedule'); const nextUp = scheduleColumn(P, 'A').locator('.tw-match') @@ -329,7 +358,10 @@ test.describe('shared-shiaijo', () => { const fin = finishButton(C); const finLabel = (await fin.innerText()).trim(); const dbl = await doubleTap(fin); - await C.waitForTimeout(1000); + await expect(completedRowFor(C, bouts.b1).or(armedFinishButton(C))).toBeVisible(); + // Out the arm's bounce window before reading, so a second tap that was + // wrongly counted would have committed by now, and the tap below counts. + await dwell(C, Date.now()); 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, @@ -338,7 +370,6 @@ test.describe('shared-shiaijo', () => { 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); @@ -360,7 +391,7 @@ test.describe('shared-shiaijo', () => { 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); + await expect(C.getByRole('button', { name: /Back to court/ })).toBeHidden(); 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') }); @@ -391,9 +422,8 @@ test.describe('shared-shiaijo', () => { 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); + await untilStable(async () => [await tvBoard(TV).innerText(), await P.locator('body').innerText()]); 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), @@ -417,7 +447,7 @@ test.describe('shared-shiaijo', () => { 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); + }, { timeout: EXPECT_TIMEOUT_MS }).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, @@ -431,8 +461,9 @@ test.describe('shared-shiaijo', () => { 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; + const heldBefore = await C.locator(EDITOR).innerText(); await nav(cls).tap(); - await C.waitForTimeout(300); + await expect.poll(() => C.locator(EDITOR).innerText()).not.toBe(heldBefore); 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, @@ -445,7 +476,7 @@ test.describe('shared-shiaijo', () => { 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); + }, { timeout: EXPECT_TIMEOUT_MS }).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') }); @@ -464,7 +495,7 @@ test.describe('shared-shiaijo', () => { // 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); + await expect.poll(async () => (await liveOnConsole()) && same(await liveOnConsole(), bouts.b5), { timeout: EXPECT_TIMEOUT_MS }).toBe(true); const marksB5 = await slotMarks(C, winnerOf(bouts.b5)); await pickCompetition(C, ST); const s3 = await courtState(C); @@ -474,7 +505,7 @@ test.describe('shared-shiaijo', () => { // 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); + await expect(C.locator('.shiaijo-officiating__name').first()).toBeVisible(); 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); @@ -487,8 +518,11 @@ test.describe('shared-shiaijo', () => { } 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); + const lineupPanel = C.locator('[data-testid^="match-lineup-side-"]').first(); + // A neighbour that is the lineup button opens the panel; any other control + // opens nothing, which the read below takes as it finds it. + if (/lineup/i.test((nbStart.hit && nbStart.hit.label) || '')) await expect(lineupPanel).toBeVisible(); + const panelOpen = await lineupPanel.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); @@ -509,8 +543,8 @@ test.describe('shared-shiaijo', () => { await expect.poll(async () => { const l = await liveOnConsole(); return !!l && l.comp === ST; - }, { timeout: 15_000 }).toBe(true); - await C.waitForTimeout(1500); + }, { timeout: EXPECT_TIMEOUT_MS }).toBe(true); + await expect.poll(() => writes.length).toBeGreaterThan(0); C.off('response', onRes); const onAafterSwitch = await runningOnCourt(H, 'A'); const s3b = await courtState(C); @@ -534,7 +568,7 @@ test.describe('shared-shiaijo', () => { 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); + await expect.poll(async () => same(await liveOnConsole(), bouts.b5), { timeout: EXPECT_TIMEOUT_MS }).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)', @@ -601,9 +635,9 @@ test.describe('shared-shiaijo', () => { await startBtn.waitFor({ state: 'visible' }); await dwell(H, Date.now()); await startBtn.tap(); - await expect(T.syncPill(hed)).toBeVisible({ timeout: 15_000 }); + await expect(T.syncPill(hed)).toBeVisible({ timeout: EXPECT_TIMEOUT_MS }); const alert = C.locator('.shiaijo-also-running'); - await expect(alert).toBeVisible({ timeout: 15_000 }); + await expect(alert).toBeVisible({ timeout: EXPECT_TIMEOUT_MS }); 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(() => {}); @@ -617,7 +651,7 @@ test.describe('shared-shiaijo', () => { // 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); + await expect.poll(async () => same(await liveOnConsole(), bouts.b2), { timeout: EXPECT_TIMEOUT_MS }).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, @@ -628,7 +662,7 @@ test.describe('shared-shiaijo', () => { // 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); + await expect(C.locator('.shiaijo-officiating__name').first()).toBeVisible(); 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); @@ -645,6 +679,7 @@ test.describe('shared-shiaijo', () => { // 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. + // Deliberate: nothing may start, so the time is the evidence. 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, @@ -653,7 +688,7 @@ test.describe('shared-shiaijo', () => { // 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); + await expect(C.locator('.shiaijo-officiating__name').first()).toBeVisible(); 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); @@ -694,6 +729,7 @@ test.describe('shared-shiaijo', () => { // 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. + // Deliberate: the console must NOT switch by itself, so the time is the evidence. await C.waitForTimeout(3000); const i5 = await courtState(C); const lines = await nudgeLines(C); @@ -768,7 +804,7 @@ test.describe('shared-shiaijo', () => { // 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); + await expect.poll(async () => same(await liveBout(Bc), bouts.m6), { timeout: EXPECT_TIMEOUT_MS }).toBe(true); const ed = inlineEditor(Bc); await expect(ed.getByText(SYNC_PILL)).toHaveCount(0); const conn = Bc.locator('.topbar').getByRole('status').filter({ hasText: 'Connected' }); @@ -812,7 +848,7 @@ test.describe('shared-shiaijo', () => { 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); + await rendered(H); 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); @@ -824,6 +860,8 @@ test.describe('shared-shiaijo', () => { const held = await liveOnConsole(); const ed = inlineEditor(C); let pointTried = null; + // The waits below are the time a refusal (or none) takes to show: whether + // the point is refused is what the row records, so there is no state to wait for. 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(); @@ -841,7 +879,7 @@ test.describe('shared-shiaijo', () => { // 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); + await expect.poll(async () => !same(await liveOnConsole(), held)).toBe(true); 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') }); @@ -878,14 +916,14 @@ test.describe('shared-shiaijo', () => { // 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); + await expect.poll(async () => ((await liveOnConsole()) || {}).comp, { timeout: EXPECT_TIMEOUT_MS }).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); + await expect.poll(async () => same(await sides(upNextCard(Bc)).catch(() => null), bouts.m6), { timeout: EXPECT_TIMEOUT_MS }).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. @@ -908,7 +946,7 @@ test.describe('shared-shiaijo', () => { 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 expect.poll(async () => same(await sides(upNextCard(C)).catch(() => null), bouts.m6), { timeout: EXPECT_TIMEOUT_MS }).toBe(true); await H.goto(`/admin/competition/${ids.SI}/scores`); await m6ScoresRow().getByRole('button', { name: /^Score$/ }).tap(); await expect(H.locator(EDITOR)).toBeVisible(); diff --git a/scripts/e2e/journeys/smoke.spec.mjs b/scripts/e2e/journeys/smoke.spec.mjs index 2908233cb..abdf4d9fb 100644 --- a/scripts/e2e/journeys/smoke.spec.mjs +++ b/scripts/e2e/journeys/smoke.spec.mjs @@ -11,7 +11,7 @@ // 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 { PUBLIC_DEVICE, EXPECT_TIMEOUT_MS } from '../fixtures/devices.mjs'; import { journeyShots } from '../fixtures/shots.mjs'; import { auditLog } from '../fixtures/audit.mjs'; import { createTournament, login, signOut } from '../fixtures/setup.mjs'; @@ -101,7 +101,7 @@ test.describe('smoke', () => { const identity = await editorIdentity(page); const reload = await interrupt(page, 'reload'); - const backAfterReload = await inlineEditor(page).waitFor({ state: 'visible', timeout: 15_000 }) + const backAfterReload = await inlineEditor(page).waitFor({ state: 'visible', timeout: EXPECT_TIMEOUT_MS }) .then(() => true, () => false); record({ variant: 'V3 interrupt', action: 'running bout', ...reload, editorBack: backAfterReload, sameMatch: backAfterReload && (await editorIdentity(page)).key === identity.key }); @@ -113,7 +113,7 @@ test.describe('smoke', () => { sameMatch: (await editorIdentity(page)).key === identity.key }); const back = await interrupt(page, 'back'); - const backAfterBack = await inlineEditor(page).waitFor({ state: 'visible', timeout: 15_000 }) + const backAfterBack = await inlineEditor(page).waitFor({ state: 'visible', timeout: EXPECT_TIMEOUT_MS }) .then(() => true, () => false); const shiroPoints = backAfterBack ? await inlineEditor(page).locator('[aria-label^="Shiro slot "][aria-label*=": remove "]').count() : null; @@ -142,9 +142,9 @@ test.describe('smoke', () => { 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(() => {}); + // The pill must stop claiming "Synced" while the point cannot be + // saved; what it settles on instead is recorded, not asserted. + await expect(syncState(p)).not.toHaveText('Synced'); offlineState = (await syncState(p).innerText()).trim(); }, }); From be3b86fff6d26adf28e790d8ff3cb1bbc820f9dc Mon Sep 17 00:00:00 2001 From: Ricardo Oliveira Date: Wed, 14 Oct 2026 00:08:38 +0100 Subject: [PATCH 15/31] docs(bc-e2rv): e2e README lists the new journeys, fixtures, server options and allowlist entry Co-Authored-By: Claude Opus 5.5 --- scripts/e2e/README.md | 42 ++++++++++++++++++++++++++++++++++++------ 1 file changed, 36 insertions(+), 6 deletions(-) diff --git a/scripts/e2e/README.md b/scripts/e2e/README.md index 3e8a34d45..89a9d9ab7 100644 --- a/scripts/e2e/README.md +++ b/scripts/e2e/README.md @@ -53,6 +53,12 @@ browser, `--debug` opens the inspector, and a failing test leaves a trace untilStable (Waiting for the app) fixtures/wizard.mjs the create-competition wizard fixtures/competition.mjs roster paste box, Generate / Discard draw, Start + fixtures/roster.mjs the Registration desk: check-in, search, + Group by dojo; the seed boxes; the participant Edit + dialog; reads of pool membership and bracket slots. + fixtures/pools.mjs the pool standings tables, admin Pools tab and the + public viewer's Pools tab: rows, figures, rank + badge, advancing. fixtures/shiaijo.mjs /admin/shiaijo/:court (queue, inline editor, which match an editor holds) fixtures/scores.mjs the Scores tab and its overlay editor @@ -77,6 +83,9 @@ browser, `--debug` opens the inspector, and a failing test leaves a trace 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/organiser.mjs the Excel download, folder import, /reset, logo and + sponsor uploads; runtime PNG and import-bundle + makers; a zip reader for workbooks. fixtures/clumsy.mjs the clumsy operator (below) fixtures/pointer.mjs the coarse pointer: check, restore, ensureCoarse (the one "if not coarse, restore"), tap-target size @@ -94,12 +103,27 @@ browser, `--debug` opens the inspector, and a failing test leaves a trace surface knockout-mixed-kachinuki kachinuki (winner stays on) played eight bouts deep, every correction door, the final + kachinuki-pools kachinuki in pools: winner stays on, a + hikiwake, a team exhausted, a level pool draw + with no representative bout, the pool tables + on admin and phone, the winners in the final moved-match a waiting match moved to another shiaijo reaches that court's console without a reload shared-shiaijo two competitions on one shiaijo: the picker, the nudge lines, correcting across them engi an engi (kata pairs) knockout scored in flags, and the bc-evts tap-back guard + organiser-roster check-in decides who is drawn, and the desk + finds a number; seeds keep two seeds out of + one pool; an edited or replaced competitor + reaches the bracket, court console and public + viewer; a duplicate name and dojo is refused. + organiser-admin file-mode organiser jobs: the results workbook, + folder import, including a row with no date, + logo and sponsor, forgotten-password reset. + organiser-locked locked mode: sign-in with the hashed password, + no Forgot password, and /reset shows it is + disabled. output/ screenshots, audit.json and traces (below); gitignored @@ -152,7 +176,11 @@ 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 -(`page.goto('/admin')`); the host is the file's server. +(`page.goto('/admin')`); the host is the file's server. A spec file that +needs its server started with extra arguments or environment declares them with +`serverOptionsFor(fileURLToPath(import.meta.url), () => ({ args, env }))` from +`fixtures/test.mjs`, as `organiser-locked.spec.mjs` does for `--lock-password` +and its hash in `TOURNAMENT_PASSWORD_HASH`. When a test fails, Playwright discards its worker. In a serial file the rest of that file is skipped, because the later tests depend on what the earlier ones @@ -325,16 +353,18 @@ 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. -The allowlist holds only noise a journey's scenario makes, in three entries: +The allowlist holds only noise a journey's scenario makes, in four 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 +while the device is offline; 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. Only when the page was +fought). Any other write refused that way is an error. The fourth is the 409 +refusing an organiser's participant edit to the same name and dojo as another +competitor (organiser-roster journey); the same name in another dojo is +accepted. A browser "Failed to load resource" line is paired with the `/api` +answer it is about one to one, in arrival order per URL. Only when the page was closed while the 409's body was being read is the refusal code unreadable, and the entry then stands on the answer's status, URL and request alone; the `CONSOLE` line and a `console-allowed-unread` annotation say "allowed: page From 06d6264809693d78e53a3df3c19e8a84a11ecac9 Mon Sep 17 00:00:00 2001 From: Ricardo Oliveira Date: Wed, 14 Oct 2026 00:35:09 +0100 Subject: [PATCH 16/31] fix(bc-evts): a clock_skew autosave keeps the tap; review round 3 landedAutosave read a clock_skew answer as a refusal and took the server's copy, wiping a tap that recordScore deliberately leaves on the board for the next save to carry with a corrected stamp; it now keeps the stamp and the board, as for a queued answer. The individual and engi refusal adopt also requires the stamp still to be set, so a correction tapped after the match finished elsewhere is not wiped by a late refusal. The rules note states the team sheet's scope and the queued-then-refused limit. Smaller: a Go doc comment back on its function, the hasLogo test proves a PUT cannot lose a logo, and writeWasRefused names its callers. Co-Authored-By: Claude Opus 5.5 --- .claude/rules/match-results-and-marks.md | 2 +- .../handlers_branding_haslogo_test.go | 36 ++++++++--- internal/mobileapp/handlers_competition.go | 16 ++--- ...used_autosave_takes_server.render.test.jsx | 61 +++++++++++++++++++ ..._stamps_only_while_running.render.test.jsx | 6 ++ web-mobile/js/admin_scoring_autosave.jsx | 8 ++- web-mobile/js/admin_scoring_engi.jsx | 5 +- web-mobile/js/admin_scoring_individual.jsx | 5 +- web-mobile/js/write_result.jsx | 6 ++ 9 files changed, 125 insertions(+), 20 deletions(-) diff --git a/.claude/rules/match-results-and-marks.md b/.claude/rules/match-results-and-marks.md index 054617d29..aac218657 100644 --- a/.claude/rules/match-results-and-marks.md +++ b/.claude/rules/match-results-and-marks.md @@ -93,7 +93,7 @@ It projects `ModifiedAt` and `GroupStamps` too (bc-lww1, bc-mrgc). `mergeMatchWr Because an editor can now never rule on something it did not display, an explicit `false` is always the operator's decision and always reaches the wire as what it now IS, a markless scoreline: `daihyosenEnchoFields` still states the local boolean unconditionally, and the serializer chokepoint converts it (true → mark placed on the winner's side, false → arrays stay markless; `realIppons` strips echoed marks first so placement never doubles). Two earlier designs are recorded in `web-mobile/js/__tests__/render/individual_editor_live_hantei.render.test.jsx` and must not be reintroduced: recomputing the baseline from the live prop while freezing the local armed state (which erased the verdict on the next write), and freezing at mount with a `hanteiKnown` silence guard (which stopped the erase by accepting the divergence; the guard was deleted with it). -**The edit-time adopt rule.** A score editor holds its board against a server copy stamped before the operator's last unsent edit, because comparing values alone reads a tap taken back during a save as untouched, and that save's echo then wipes it. Its three owners are the team sheet's `rowTakenFromServer`/`repRowTakes` (bc-kclr), the individual editor's `adoptServerScore` (bc-ltap) and the engi editor's adopt (`flagsEditedAtRef`, bc-evts). `landedAutosave` (admin_scoring_autosave.jsx) clears a stamp once a save carrying the last edit has landed, so every later server copy is followed. All three editors take the stamp only while the match is running, because only then is the edit autosaved: on a scheduled match or a correction of a completed one nothing is sent, no landed save would ever clear the stamp, and a net-zero fiddle would make the editor refuse another device's copy it would otherwise have adopted. All three also clear it when the match leaves running (an effect keyed on the status): another device finishing the match or sending it back to the queue inside the debounce makes `fireRunningWrite` drop the write with no callback, so no landed save would clear it. A REFUSED autosave (`writeWasRefused`: nothing handed back, or `applied:false`) stores and broadcasts nothing, so no later copy would re-run the adopt and the stamp would hold the operator's value against the server's for good. `landedAutosave` therefore takes an optional `onRefused(seq)`; each editor, when no edit was made since that save was built (the `seq` still matches), clears the stamp and takes the server's CURRENT copy through its own adopt path (the engi counts, the individual `applyServerScore`, the team sheet's rows and pick parts the save carried, composed by `composeRepRow`), reading the latest render's closure through a ref. An edit made since keeps the stamp and the save that carries it decides. A QUEUED answer calls neither callback and keeps the stamp: that write will land. Known limit: a save applied IN PART (`heldGroups`) counts as landed and clears the stamp, and no new broadcast follows when the server's value for a held group equals the copy the adopt already skipped; that stays the genuine disagreement `keepLocalEdits` leaves to the operator, with the held values in the match's history. +**The edit-time adopt rule.** A score editor holds its board against a server copy stamped before the operator's last unsent edit, because comparing values alone reads a tap taken back during a save as untouched, and that save's echo then wipes it. Its three owners are the team sheet's `rowTakenFromServer`/`repRowTakes` (bc-kclr), the individual editor's `adoptServerScore` (bc-ltap) and the engi editor's adopt (`flagsEditedAtRef`, bc-evts). `landedAutosave` (admin_scoring_autosave.jsx) clears a stamp once a save carrying the last edit has landed, so every later server copy is followed. All three editors take the stamp only while the match is running, because only then is the edit autosaved: on a scheduled match or a correction of a completed one nothing is sent, no landed save would ever clear the stamp, and a net-zero fiddle would make the editor refuse another device's copy it would otherwise have adopted. All three also clear it when the match leaves running (an effect keyed on the status): another device finishing the match or sending it back to the queue inside the debounce makes `fireRunningWrite` drop the write with no callback, so no landed save would clear it. A REFUSED autosave (`writeWasRefused`: nothing handed back, or `applied:false`) stores and broadcasts nothing, so no later copy would re-run the adopt and the stamp would hold the operator's value against the server's for good. `landedAutosave` therefore takes an optional `onRefused(seq)`; each editor, when no edit was made since that save was built (the `seq` still matches), clears the stamp and takes the server's CURRENT copy through its own adopt path (the engi counts, the individual `applyServerScore`, the team sheet's rows and pick parts the save carried, composed by `composeRepRow`), reading the latest render's closure through a ref. An edit made since keeps the stamp and the save that carries it decides; the individual and engi editors also require the stamp still to be set, because their counter counts only running edits, so a correction tapped on a match that finished elsewhere while the save was out is not wiped by a late refusal. A QUEUED answer calls neither callback and keeps the stamp: that write will land. A queued write that the server later refuses on replay is not seen by the editor (no promise reaches it), so its stamp stays until the match leaves running or a later save lands. A `clock_skew` answer (`writeWasRefusedForClock`) calls neither callback either and keeps the stamp: `recordScore` keeps a running one silent, relearns the clock and leaves the tap on the board for the NEXT save to carry with a corrected stamp (nothing re-sends it by itself; `_handleOkBody` only relearns). Known limits: the team sheet's refusal adopt (`takeServerRowsRef`) restores bout rows and representative picks only, so the representative-bout verdict and the encounter overtime stay local after a refused save; and a save applied IN PART (`heldGroups`) counts as landed and clears the stamp, and no new broadcast follows when the server's value for a held group equals the copy the adopt already skipped; that stays the genuine disagreement `keepLocalEdits` leaves to the operator, with the held values in the match's history. ## Silence on the wire diff --git a/internal/mobileapp/handlers_branding_haslogo_test.go b/internal/mobileapp/handlers_branding_haslogo_test.go index 28ed07b83..3f4be07dc 100644 --- a/internal/mobileapp/handlers_branding_haslogo_test.go +++ b/internal/mobileapp/handlers_branding_haslogo_test.go @@ -73,16 +73,38 @@ func TestPutTournament_HasLogoOnTheWireIsIgnored(t *testing.T) { h.ServeHTTP(w, req) require.Equal(t, http.StatusOK, w.Code, w.Body.String()) } + getTheme := func() map[string]any { + t.Helper() + req := httptest.NewRequest(http.MethodGet, "/api/tournament", nil) + req.Header.Set("X-Tournament-Password", "secret") + w := httptest.NewRecorder() + h.ServeHTTP(w, req) + require.Equal(t, http.StatusOK, w.Code) + var body map[string]any + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body)) + theme, _ := body["theme"].(map[string]any) + return theme + } + + // No logo stored: a client cannot set one by sending hasLogo. put(`{"name":"Branding Test Cup","password":"secret","courts":["A"],"theme":{"primaryColor":"#112233","hasLogo":true}}`) + theme := getTheme() + assert.Equal(t, "#112233", theme["primaryColor"]) + assert.NotContains(t, theme, "hasLogo", "hasLogo is derived from the stored logo, never set by a client") - req := httptest.NewRequest(http.MethodGet, "/api/tournament", nil) + // A logo stored: a client cannot lose it by sending hasLogo false, or by omitting it. + body, ct := buildBrandingUpload(t, "logo.png", tinyPNG) + req := httptest.NewRequest(http.MethodPost, "/api/branding/logo", body) + req.Header.Set("Content-Type", ct) req.Header.Set("X-Tournament-Password", "secret") w := httptest.NewRecorder() h.ServeHTTP(w, req) - require.Equal(t, http.StatusOK, w.Code) - var body map[string]any - require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body)) - theme, _ := body["theme"].(map[string]any) - assert.Equal(t, "#112233", theme["primaryColor"]) - assert.NotContains(t, theme, "hasLogo", "hasLogo is derived from the stored logo, never set by a client") + require.Equal(t, http.StatusOK, w.Code, w.Body.String()) + require.Equal(t, true, getTheme()["hasLogo"], "precondition: the uploaded logo is reported") + + put(`{"name":"Branding Test Cup","password":"secret","courts":["A"],"theme":{"primaryColor":"#112233","hasLogo":false}}`) + assert.Equal(t, true, getTheme()["hasLogo"], "a PUT carrying hasLogo false does not lose the logo") + + put(`{"name":"Branding Test Cup","password":"secret","courts":["A"],"theme":{"primaryColor":"#112233"}}`) + assert.Equal(t, true, getTheme()["hasLogo"], "a PUT that omits hasLogo does not lose the logo") } diff --git a/internal/mobileapp/handlers_competition.go b/internal/mobileapp/handlers_competition.go index 3b9169b06..cfef763b4 100644 --- a/internal/mobileapp/handlers_competition.go +++ b/internal/mobileapp/handlers_competition.go @@ -138,14 +138,6 @@ func extractSeeds(players []domain.Player) []domain.SeedAssignment { return out } -// validateCompetitionDateInTournament checks that the competition's Date -// falls within the tournament's day range [Day 1 .. Day N]. When: -// - comp.Date is empty (optional field) → skip, return nil. -// - tourn is nil or tourn.Date is empty → skip (can't derive day list yet). -// - comp.Date is in the derived day list → return nil. -// - comp.Date is NOT in the derived day list → return a descriptive error. -// -// errcheck: no bare ignored returns, always propagated by callers. // defaultCompetitionDate gives a competition that states no date the // tournament's Day 1, the one rule every door that creates a competition // shares (POST /competitions and the folder import). It leaves an explicit @@ -159,6 +151,14 @@ func defaultCompetitionDate(comp *state.Competition, tourn *state.Tournament) bo return comp.Date != "" } +// validateCompetitionDateInTournament checks that the competition's Date +// falls within the tournament's day range [Day 1 .. Day N]. When: +// - comp.Date is empty (optional field) → skip, return nil. +// - tourn is nil or tourn.Date is empty → skip (can't derive day list yet). +// - comp.Date is in the derived day list → return nil. +// - comp.Date is NOT in the derived day list → return a descriptive error. +// +// errcheck: no bare ignored returns, always propagated by callers. func validateCompetitionDateInTournament(comp *state.Competition, tourn *state.Tournament) error { if comp.Date == "" || tourn == nil || tourn.Date == "" { return nil diff --git a/web-mobile/js/__tests__/render/individual_engi_refused_autosave_takes_server.render.test.jsx b/web-mobile/js/__tests__/render/individual_engi_refused_autosave_takes_server.render.test.jsx index 9c633806a..cd66412e8 100644 --- a/web-mobile/js/__tests__/render/individual_engi_refused_autosave_takes_server.render.test.jsx +++ b/web-mobile/js/__tests__/render/individual_engi_refused_autosave_takes_server.render.test.jsx @@ -49,6 +49,9 @@ afterEach(() => { vi.useRealTimers(); }); const REFUSED = { applied: false, reason: 'superseded' }; const QUEUED = { queued: true }; +// recordScore answers a running clock_skew refusal silently: it relearns the clock and the +// tap stays on the board for the NEXT save to carry with a corrected stamp. +const CLOCK_SKEW = { applied: false, reason: 'clock_skew' }; const tap = async (node) => { await act(async () => { fireEvent.click(node); }); }; const wait = async (ms) => { await act(async () => { vi.advanceTimersByTime(ms); }); }; @@ -116,6 +119,35 @@ describe('individual editor: a refused autosave takes the server\'s copy', () => await settle(); expect(marks(r.container).filter((l) => l.startsWith('Aka')), 'the newer edit is still owed').not.toHaveLength(0); }); + + it('a clock_skew answer keeps the board: the tap is owed to the next save, not wiped', async () => { + const r = await tapThenAnswer(CLOCK_SKEW); + expect(marks(r.container).map((l) => l.split(' ')[0])).toEqual(['Aka']); + }); + + it('a late refusal does not wipe a correction tapped after the match finished elsewhere', async () => { + let settleFirst; + const onSubmit = vi.fn().mockImplementationOnce(() => new Promise((res) => { settleFirst = res; })) + .mockResolvedValue({ id: 'm1', status: 'completed' }); + let r; + await act(async () => { r = render(el(bout(), onSubmit)); }); + await tap(menButton(r.container.querySelector('.sb-side--aka'))); + await wait(350); // the autosave is out and unanswered + expect(onSubmit, 'precondition: the autosave went out').toHaveBeenCalledTimes(1); + // Another device finishes the match: Shiro won by M, stamped after the tap. + await act(async () => { + r.rerender(el(bout({ + status: 'completed', ipponsB: ['M'], winner: { id: 'p2', name: 'Tanaka' }, modifiedAt: NOW + 100, + }), onSubmit)); + }); + vi.setSystemTime(NOW + 600); + await tap(menButton(r.container.querySelector('.sb-side--aka'))); // a correction on the finished match + const before = marks(r.container).map((l) => l.split(' ')[0]); + expect(before, 'precondition: the correction shows').toContain('Aka'); + await act(async () => { settleFirst(REFUSED); }); // the autosave is refused late + await settle(); + expect(marks(r.container).map((l) => l.split(' ')[0]), 'the correction stays').toEqual(before); + }); }); describe('engi editor: a refused autosave takes the server\'s copy', () => { @@ -176,4 +208,33 @@ describe('engi editor: a refused autosave takes the server\'s copy', () => { await settle(); expect(count(r, 'aka'), 'the newer edit is still owed').toBe('2'); }); + + it('a clock_skew answer keeps the board: the tap is owed to the next save, not wiped', async () => { + const r = await tapThenAnswer(CLOCK_SKEW); + expect([count(r, 'aka'), count(r, 'shiro')]).toEqual(['1', '0']); + }); + + it('a late refusal does not wipe a correction tapped after the match finished elsewhere', async () => { + let settleFirst; + const onSubmit = vi.fn().mockImplementationOnce(() => new Promise((res) => { settleFirst = res; })) + .mockResolvedValue({ id: 'm1', status: 'completed' }); + let r; + await act(async () => { r = render(el(engi(), onSubmit)); }); + await tap(r.getByTestId('engi-aka-inc')); + await wait(350); // the autosave is out and unanswered + expect(onSubmit, 'precondition: the autosave went out').toHaveBeenCalledTimes(1); + // Another device finishes the match: Shiro 1 flag, stamped after the tap. + await act(async () => { + r.rerender(el(engi({ + status: 'completed', flagsB: 1, winner: { id: 'p-shiro', name: 'Shiro Pair 1 - Shiro Pair 2' }, modifiedAt: NOW + 100, + }), onSubmit)); + }); + vi.setSystemTime(NOW + 600); + await tap(r.getByTestId('engi-aka-inc')); // a correction on the finished match + const before = [count(r, 'aka'), count(r, 'shiro')]; + await act(async () => { settleFirst(REFUSED); }); // the autosave is refused late + await settle(); + expect([count(r, 'aka'), count(r, 'shiro')], 'the correction stays').toEqual(before); + expect(Number(before[0]), 'precondition: the correction was taken').toBeGreaterThan(0); + }); }); diff --git a/web-mobile/js/__tests__/render/team_editor_stamps_only_while_running.render.test.jsx b/web-mobile/js/__tests__/render/team_editor_stamps_only_while_running.render.test.jsx index 167553ab5..50a5f6324 100644 --- a/web-mobile/js/__tests__/render/team_editor_stamps_only_while_running.render.test.jsx +++ b/web-mobile/js/__tests__/render/team_editor_stamps_only_while_running.render.test.jsx @@ -150,6 +150,12 @@ describe('a refused autosave takes the server\'s copy of the rows it carried', ( expect([filled('aka'), filled('shiro')]).toEqual([1, 0]); }); + it('a clock_skew answer keeps the sheet: the tap is owed to the next save, not wiped', async () => { + // recordScore keeps a running clock_skew refusal silent and relearns the clock. + await strikeThenAnswer({ applied: false, reason: 'clock_skew' }); + expect([filled('aka'), filled('shiro')]).toEqual([1, 0]); + }); + it('a strike made after the refused save was built is kept', async () => { let settleFirst; const onSubmit = vi.fn().mockImplementationOnce(() => new Promise((res) => { settleFirst = res; })) diff --git a/web-mobile/js/admin_scoring_autosave.jsx b/web-mobile/js/admin_scoring_autosave.jsx index 8a029aa7d..26f0a7d73 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, writeWasRefused } from './write_result.jsx'; +import { writeDidNotLand, writeWasRefused, writeWasRefusedForClock } from './write_result.jsx'; const { useState: useStateA, useEffect: useEffectA, useRef: useRefA } = React; @@ -152,13 +152,17 @@ export function useKeptInHistoryNote() { // server and every other surface show the server's. The callback is for the editor to // compare `seq` with its counter (an edit made since is still owed and keeps its stamp), // then drop the stamp and take the server's CURRENT copy. A QUEUED answer calls neither: -// that write will land, so the stamp stays. +// that write will land, so the stamp stays. So does a clock_skew refusal +// (writeWasRefusedForClock): recordScore keeps a running one silent on purpose, relearns the +// clock and leaves the tap on the board for the NEXT save to carry with a corrected stamp, so +// taking the server's copy here would wipe a tap that is still owed. export function landedAutosave(submit, seqRef, onLanded, onRefused) { return (patch) => { const seq = seqRef.current; const p = submit(patch); if (p && typeof p.then === "function") { p.then((res) => { + if (writeWasRefusedForClock(res)) return; if (writeWasRefused(res)) { if (onRefused) onRefused(seq); } else if (!writeDidNotLand(res)) onLanded(seq); }, () => {}); diff --git a/web-mobile/js/admin_scoring_engi.jsx b/web-mobile/js/admin_scoring_engi.jsx index 6c52b24b5..523c39bd9 100644 --- a/web-mobile/js/admin_scoring_engi.jsx +++ b/web-mobile/js/admin_scoring_engi.jsx @@ -162,7 +162,10 @@ export function EngiScoreEditorModal({ match, onClose, onSubmit, onSubmitAndNext autosaveOnSubmitRef.current = landedAutosave(onSubmit, flagsEditSeqRef, (seq) => { if (flagsEditSeqRef.current === seq) flagsEditedAtRef.current = undefined; }, (seq) => { - if (flagsEditSeqRef.current === seq && takeServerFlagsRef.current) takeServerFlagsRef.current(); + // The stamp must still be set: the seq counts only RUNNING edits, so a correction tapped + // after the match finished elsewhere (the status effect cleared the stamp, the seq did + // not move) would otherwise be wiped by a refusal that arrives late. + if (flagsEditSeqRef.current === seq && flagsEditedAtRef.current !== undefined && takeServerFlagsRef.current) takeServerFlagsRef.current(); }); // The match left running (finished, or sent back to the queue, elsewhere) inside the // debounce: the write is dropped with no callback, so no landed save clears the stamp. diff --git a/web-mobile/js/admin_scoring_individual.jsx b/web-mobile/js/admin_scoring_individual.jsx index f3d093b0e..58792c180 100644 --- a/web-mobile/js/admin_scoring_individual.jsx +++ b/web-mobile/js/admin_scoring_individual.jsx @@ -349,7 +349,10 @@ export function ScoreEditorModal({ match, onClose, onSubmit, onSubmitAndNext, on const landedBoardSave = (submit) => landedAutosave(submit, boardEditSeqRef, (seq) => { if (boardEditSeqRef.current === seq) boardEditedAtRef.current = undefined; }, (seq) => { - if (boardEditSeqRef.current === seq && applyServerScoreRef.current) applyServerScoreRef.current(); + // The stamp must still be set: the seq counts only RUNNING edits, so a correction tapped + // after the match finished elsewhere (the status effect cleared the stamp, the seq did + // not move) would otherwise be wiped by a refusal that arrives late. + if (boardEditSeqRef.current === seq && boardEditedAtRef.current !== undefined && applyServerScoreRef.current) applyServerScoreRef.current(); }); // The match left running (finished, or sent back to the queue, elsewhere) inside the // debounce: the write is dropped with no callback, so no landed save clears the stamp. diff --git a/web-mobile/js/write_result.jsx b/web-mobile/js/write_result.jsx index 5644ed683..a497464e2 100644 --- a/web-mobile/js/write_result.jsx +++ b/web-mobile/js/write_result.jsx @@ -305,6 +305,12 @@ export function writeWasSuperseded(res) { // - applied:false: superseded or clock_skew (writeWasSuperseded). // A QUEUED write is not refused: it lands on reconnect. // +// Callers that must tell the two refusals apart ask writeWasRefusedForClock first: +// landedAutosave (admin_scoring_autosave.jsx) treats a clock_skew answer as neither +// landed nor refused, because recordScore keeps a running one silent and leaves the +// tap on the board for the next save to carry with a corrected stamp, while a +// superseded one is final and the editor takes the server's copy. +// // The editors ask it to DISARM a two-tap commit (Finish, Finish + Start Next, // End match, the engi Save) whose write was refused: left armed, one more tap // re-sent the very write just refused, and for a superseded one that tap From f45c791aebaa85815ab11878515c30564570d13f Mon Sep 17 00:00:00 2001 From: Ricardo Oliveira Date: Wed, 14 Oct 2026 00:52:27 +0100 Subject: [PATCH 17/31] test(bc-e2rv): e2e round 3 review fixes: seeds that can fail, a workbook that must hold the result, settled reads - organiser-roster: the seeds test reads the unseeded draw, discards it, seeds two competitors it put in one pool, and asserts the redraw separates them (red when the seeding step is skipped). - organiser-admin: the Excel export is compared with one taken before the bout (winner carried into the next round, waza letter in a score cell); the bc-imdt step uses a tournament day that is not today and the page's own formatDate; the TV board's no-sponsor negative waits for the tournament payload that carries the sponsor; the bc-lblg fixme is written against an optional setting, on and off. The dated import row takes a tournament day. - untilStable takes `from` (the settled read before the tap); the probes that read after a tap use it, wait for a known target, or wait for the write. - retapWhileSaving waits for every write the taps caused before returning. - organiser-locked waits for auth-config; kachinuki-pools claims only what it tests; liveIndividualBout's 1 s read is only in the toBeNull poll; the Registration desk confirm's dwell starts after it rendered; a stale toast cannot satisfy the withdrawn competitor's Finish; the sync-pill reason in scoring.mjs and the README is corrected; small nits. Co-Authored-By: Claude Opus 5.5 --- scripts/e2e/README.md | 13 +++- scripts/e2e/fixtures/clumsy.mjs | 27 ++++++- scripts/e2e/fixtures/competition.mjs | 12 +++ scripts/e2e/fixtures/individual.mjs | 18 +++-- scripts/e2e/fixtures/organiser.mjs | 40 ++++++++++ scripts/e2e/fixtures/pace.mjs | 16 +++- scripts/e2e/fixtures/roster.mjs | 2 +- scripts/e2e/fixtures/scoring.mjs | 7 +- scripts/e2e/fixtures/setup.mjs | 8 +- scripts/e2e/journeys/kachinuki-pools.spec.mjs | 19 ++--- .../knockout-mixed-individual.spec.mjs | 7 +- .../knockout-mixed-kachinuki.spec.mjs | 29 ++++++-- .../e2e/journeys/knockout-mixed-team.spec.mjs | 12 ++- scripts/e2e/journeys/moved-match.spec.mjs | 13 +++- scripts/e2e/journeys/organiser-admin.spec.mjs | 74 ++++++++++++++----- .../e2e/journeys/organiser-locked.spec.mjs | 4 + .../e2e/journeys/organiser-roster.spec.mjs | 41 ++++++---- 17 files changed, 265 insertions(+), 77 deletions(-) diff --git a/scripts/e2e/README.md b/scripts/e2e/README.md index 89a9d9ab7..3d8ddad86 100644 --- a/scripts/e2e/README.md +++ b/scripts/e2e/README.md @@ -235,7 +235,7 @@ variants as real input: |---|---| | `tapNeighbour(locator, direction)` | taps the control next to the intended one, found by layout, and returns which it hit | | `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 | +| `retapWhileSaving(locator)` | a second tap while the button reads "Saving…" (writes are held briefly so the state is observable, and every write the taps caused is waited for before it returns, so a duplicate one is on the page when the caller reads it); 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, for every kind but `reload`, which reloads at once and uses neither | | `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) | @@ -305,8 +305,8 @@ copying it, and fails to load if the dwell is not longer than it. A journey waits for what it is about to read, and never for a guessed time. In order of preference: a state (`expect(...).toHave...`, `expect.poll`); the server's answer to a write (`nextMatchWrite(page)` before the tap that causes -it, `writeLanded(answered)` after: the sync pill reads "Synced" until the -autosave debounce has run, so it cannot say an edit was saved); and, when the +it, `writeLanded(answered)` after: the sync pill is process-wide, not per +match, so it cannot say that one match's edit was saved); and, when the outcome is the thing under audit and no single state names it, one of two settle helpers in `fixtures/pace.mjs`: @@ -316,7 +316,12 @@ settle helpers in `fixtures/pace.mjs`: - `untilStable(read)` polls `read()` until two reads 400 ms apart agree and returns that value, for a screen that refetches after the action (a reload, Back and Forward, a write's echo) and ends in a state the journey records - rather than expects. + rather than expects. A read taken after a tap can agree with itself on the + state from BEFORE the tap, so a probe passes `from` (the settled read taken + before the tap): the helper then waits up to 3 s for the read to leave it, + and settles on it only if nothing ever changed. Where the end state IS known, + wait for that instead (`expect.poll(...)`); where the tap causes one match + write, wait for its answer (`nextMatchWrite`). `page.waitForTimeout` stays in four places, each marked `Deliberate:` or named for what it is: the dwell of a tap's pace (above); the time away of an diff --git a/scripts/e2e/fixtures/clumsy.mjs b/scripts/e2e/fixtures/clumsy.mjs index 6ab3ba409..26a161df3 100644 --- a/scripts/e2e/fixtures/clumsy.mjs +++ b/scripts/e2e/fixtures/clumsy.mjs @@ -123,7 +123,7 @@ export async function doubleTap(locator) { // "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 }, both flags always true: a button that +// { savingSeen, retapped, labelAfter, writesSent }, 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. @@ -141,6 +141,21 @@ export async function retapWhileSaving(locator, { holdWritesMs = 1200, savingTex return route.continue().catch(() => {}); }; await page.route('**/api/**', hold); + // Every write the two taps cause is counted and waited for: a second write + // from the bounced tap is held on its own clock and can land after the first + // one's answer has cleared "Saving…", so the caller reads the page only once + // none is in flight, whatever it then finds. + const inFlight = new Set(); + let writesSent = 0; + const started = (req) => { + if (req.method() === 'GET' || !new URL(req.url()).pathname.startsWith('/api/')) return; + inFlight.add(req); + writesSent += 1; + }; + const ended = (req) => inFlight.delete(req); + page.on('request', started); + page.on('requestfinished', ended); + page.on('requestfailed', ended); try { await page.touchscreen.tap(x, y); try { @@ -163,9 +178,17 @@ export async function retapWhileSaving(locator, { holdWritesMs = 1200, savingTex [handle, savingText.source, savingText.flags], { timeout: holdWritesMs + EXPECT_TIMEOUT_MS }, ); + const drainBy = Date.now() + holdWritesMs + EXPECT_TIMEOUT_MS; + while (inFlight.size > 0) { + if (Date.now() > drainBy) throw new Error(`clumsy.retapWhileSaving: ${inFlight.size} write(s) still unanswered after the retap`); + await new Promise((r) => setTimeout(r, 50)); + } const labelAfter = await handle.evaluate((el) => (el.isConnected ? el.textContent.trim() : null)); - return { savingSeen, retapped: savingSeen, labelAfter }; + return { savingSeen, retapped: savingSeen, labelAfter, writesSent }; } finally { + page.off('request', started); + page.off('requestfinished', ended); + page.off('requestfailed', ended); await page.unroute('**/api/**', hold); await handle.dispose(); } diff --git a/scripts/e2e/fixtures/competition.mjs b/scripts/e2e/fixtures/competition.mjs index 9894db26a..3ab471a6e 100644 --- a/scripts/e2e/fixtures/competition.mjs +++ b/scripts/e2e/fixtures/competition.mjs @@ -1,6 +1,7 @@ // A competition's own admin pages: the roster paste box (Participants & // seeds), Generate draw, Discard draw and Start competition. import { expect } from '@playwright/test'; +import { dwell } from './pace.mjs'; const compPath = (id, section = '') => `/admin/competition/${id}${section ? `/${section}` : ''}`; @@ -39,6 +40,17 @@ export async function requestDiscardDraw(page) { await expect(page.locator('.modal[role="dialog"] .modal__foot')).toBeVisible(); } +// Discard the draw and answer its confirm, after the confirm's own bounce +// window, with the button that discards. Returns once the header offers +// Generate draw again. +export async function discardDraw(page) { + await requestDiscardDraw(page); + const dlg = page.locator('.modal[role="dialog"]').filter({ has: page.locator('.modal__foot') }).last(); + await dwell(page, Date.now()); + await dlg.getByRole('button', { name: 'Discard draw' }).tap(); + await expect(page.getByRole('button', { name: 'Generate draw' })).toBeVisible(); +} + // Start the competition. There is no confirm: the tap starts it and lands on // the Scores tab. export async function startCompetition(page, id) { diff --git a/scripts/e2e/fixtures/individual.mjs b/scripts/e2e/fixtures/individual.mjs index 1db66a5f0..f68703eb0 100644 --- a/scripts/e2e/fixtures/individual.mjs +++ b/scripts/e2e/fixtures/individual.mjs @@ -166,11 +166,15 @@ export async function playRunningBout(page, { waza = 'M' } = {}) { } // The bout the console's inline editor is running on the individual board, as -// its pairing, or null when no bout runs there. The editor can leave between -// the two reads (a send back lands), which is a bout no longer running: the -// pairing read then gives up quickly rather than waiting for a gone editor. -const liveIndividualBout = async (page) => ( - (await inlineRunning(page)) ? editorSides(page, INLINE_EDITOR, { timeout: 1_000 }).catch(() => null) : null); +// its pairing, or null when no bout runs there. With `quick` (the poll for the +// bout to be gone) the editor can leave between the two reads, which is a bout +// no longer running: the pairing read then gives up in a second rather than +// waiting for a gone editor. A read that expects a bout waits as long as any +// other. +const liveIndividualBout = async (page, { quick = false } = {}) => ( + (await inlineRunning(page)) + ? editorSides(page, INLINE_EDITOR, quick ? { timeout: 1_000 } : {}).catch(() => null) + : null); // 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 @@ -178,7 +182,7 @@ const liveIndividualBout = async (page) => ( // 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. -// `readLive(page)` reads the bout the console's editor is running (null when +// `readLive(page, { quick })` reads the bout the console's editor is running (null when // none): the individual board by default; fixtures/shared-court.mjs passes // liveBout, which reads a team sheet as well (correctAcross). export async function correctCompleted(page, pair, { readLive = liveIndividualBout } = {}) { @@ -197,7 +201,7 @@ export async function correctCompleted(page, pair, { readLive = liveIndividualBo 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(() => readLive(page), { timeout: 10_000 }).toBeNull(); + await expect.poll(() => readLive(page, { quick: true }), { timeout: 10_000 }).toBeNull(); await done.getByRole('button', { name: 'Correct' }).tap(); } await expect(inlineEditor(page).first().locator('.editor-head-pill')).toHaveText('CORRECTION'); diff --git a/scripts/e2e/fixtures/organiser.mjs b/scripts/e2e/fixtures/organiser.mjs index d5c864024..83d09162b 100644 --- a/scripts/e2e/fixtures/organiser.mjs +++ b/scripts/e2e/fixtures/organiser.mjs @@ -84,6 +84,37 @@ export function readWorkbook(buf) { return { sheets, text, parts: [...zip.keys()] }; } +// Every non-empty cell of one sheet, as { "F4": "M", ... }: shared and inline +// strings resolved, numbers as written. A cell's place is what tells a played +// bout's mark from the same letter in a heading, which the flat text of +// readWorkbook cannot. +export function sheetCells(buf, sheetName) { + const zip = readZip(buf); + const xml = (name) => (zip.get(name) ? zip.get(name).toString('utf8') : ''); + const shared = [...xml('xl/sharedStrings.xml').matchAll(/([\s\S]*?)<\/si>/g)] + .map((m) => xmlText(m[1].replace(/<[^>]+>/g, ''))); + const entry = [...xml('xl/workbook.xml').matchAll(/]*\bname="([^"]*)"[^>]*\br:id="([^"]*)"/g)] + .find((m) => xmlText(m[1]) === sheetName); + if (!entry) throw new Error(`sheetCells: no sheet named ${sheetName}`); + const rel = [...xml('xl/_rels/workbook.xml.rels').matchAll(/Id="([^"]*)"[^>]*Target="([^"]*)"|Target="([^"]*)"[^>]*Id="([^"]*)"/g)] + .map((m) => ({ id: m[1] || m[4], target: m[2] || m[3] })).find((r) => r.id === entry[2]); + if (!rel) throw new Error(`sheetCells: no part for ${sheetName}`); + const part = xml(`xl/${rel.target.replace(/^\/?(xl\/)?/, '')}`); + const out = {}; + for (const m of part.matchAll(/]*?)(?:\/>|>([\s\S]*?)<\/c>)/g)) { + const ref = /\br="([A-Z]+\d+)"/.exec(m[1]); + if (!ref || !m[2]) continue; + const type = /\bt="(\w+)"/.exec(m[1]); + const v = /([\s\S]*?)<\/v>/.exec(m[2]); + const inline = /([\s\S]*?)<\/is>/.exec(m[2]); + let text = null; + if (inline) text = xmlText(inline[1].replace(/<[^>]+>/g, '')); + else if (v) text = type && type[1] === 's' ? shared[Number(v[1])] : xmlText(v[1]); + if (text !== null && text !== '') out[ref[1]] = text; + } + return out; +} + // Open a competition's Export section and download its results workbook. // Resolves to { file, name, bytes } once Playwright's download event has fired. export async function downloadResultsWorkbook(page, id) { @@ -191,3 +222,12 @@ export function todayDMY(now = new Date()) { const pad = (n) => String(n).padStart(2, '0'); return `${pad(now.getDate())}-${pad(now.getMonth() + 1)}-${now.getFullYear()}`; } + +// The day `days` from now, as the date input's YYYY-MM-DD and as the +// manifest's DD-MM-YYYY: a tournament day that is not today. +export function dayFromNow(days, now = new Date()) { + const d = new Date(now.getFullYear(), now.getMonth(), now.getDate() + days); + const pad = (n) => String(n).padStart(2, '0'); + const [y, m, dd] = [d.getFullYear(), pad(d.getMonth() + 1), pad(d.getDate())]; + return { iso: `${y}-${m}-${dd}`, dmy: `${dd}-${m}-${y}` }; +} diff --git a/scripts/e2e/fixtures/pace.mjs b/scripts/e2e/fixtures/pace.mjs index e7bc84090..6bdcc2021 100644 --- a/scripts/e2e/fixtures/pace.mjs +++ b/scripts/e2e/fixtures/pace.mjs @@ -78,7 +78,21 @@ export function rendered(page) { // refetches after an action (a reload, Back and Forward, a write's echo) whose // end state the journey cannot name, such as a probe of what the app does. The // answer is the last read, so the caller need not read again. -export async function untilStable(read, { quietMs = 400, timeout = EXPECT_TIMEOUT_MS } = {}) { +// +// A probe that taps and then reads can settle on the state from BEFORE the tap +// when the tap's effect (a refetch, a flushed save) has not arrived yet, and +// two reads of an unchanged page agree. Pass `from`, the read taken (settled) +// before the tap: the helper first waits up to `changeWithinMs` for the read to +// leave that value. A tap that really changes nothing costs that wait, a +// Deliberate: negative one, and settles on the unchanged value. +export async function untilStable(read, { quietMs = 400, timeout = EXPECT_TIMEOUT_MS, changeWithinMs = 3_000, ...rest } = {}) { + if ('from' in rest) { + const was = JSON.stringify(rest.from); + const changeDeadline = Date.now() + changeWithinMs; + while (JSON.stringify(await read()) === was && Date.now() < changeDeadline) { + await new Promise((done) => setTimeout(done, 100)); + } + } const deadline = Date.now() + timeout; let last = JSON.stringify(await read()); for (;;) { diff --git a/scripts/e2e/fixtures/roster.mjs b/scripts/e2e/fixtures/roster.mjs index 14c3fa7b6..3c3f96fbd 100644 --- a/scripts/e2e/fixtures/roster.mjs +++ b/scripts/e2e/fixtures/roster.mjs @@ -170,8 +170,8 @@ export async function generateDrawExcluding(page, id, excluded) { if (!(await page.getByRole('button', { name: 'Generate draw' }).isVisible())) await page.goto(`/admin/competition/${id}`); await page.getByRole('button', { name: 'Generate draw' }).tap(); const dlg = page.locator('.modal[role="dialog"]'); - const shown = Date.now(); await expect(dlg).toContainText(`${excluded} participants are not checked in and will be excluded from the draw`); + const shown = Date.now(); const asked = (await dlg.innerText()).replace(/\s+/g, ' ').trim(); await dwell(page, shown); await dlg.getByRole('button', { name: 'Confirm' }).tap(); diff --git a/scripts/e2e/fixtures/scoring.mjs b/scripts/e2e/fixtures/scoring.mjs index 3510e3142..3b9bd6edc 100644 --- a/scripts/e2e/fixtures/scoring.mjs +++ b/scripts/e2e/fixtures/scoring.mjs @@ -112,9 +112,10 @@ export const armedFinishButton = (page, root = INLINE_EDITOR) => within(page, ro .locator('button').filter({ hasText: /^Tap again to finish/ }).first(); // The server's answer to the next write a match gets, for a journey that must -// not move on (reload, read the court) before an edit has been saved. The -// autosave is debounced and the sync pill still reads "Synced" until it runs, -// so the pill alone cannot say the edit landed. Call this BEFORE the tap that +// not move on (reload, read the court) before an edit has been saved. The sync +// pill is process-wide, not per match: it says whether anything is still being +// written, not that THIS match's edit was, so it cannot stand in for the +// answer to the write itself. Call this BEFORE the tap that // causes the write, then `await writeLanded(...)` after it. `tail` is the // write's route below /matches/:id: 'score' (PUT, the default: marks, fusensho, // Tie, Start, Finish) or 'daihyosen' (POST, the representative bout's add). diff --git a/scripts/e2e/fixtures/setup.mjs b/scripts/e2e/fixtures/setup.mjs index c2d452a27..252d0a399 100644 --- a/scripts/e2e/fixtures/setup.mjs +++ b/scripts/e2e/fixtures/setup.mjs @@ -15,12 +15,14 @@ const field = (page, label) => page.getByLabel(new RegExp(`^${label}`)); // 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, + name = 'E2E Cup', venue = 'Test Hall', courts = 2, mode = 'Officiated', password = PASSWORD, date, } = {}) { await page.goto('/'); await expect(page.getByRole('heading', { name: 'Welcome to Bracket Creator' })).toBeVisible(); await field(page, 'Tournament Name').fill(name); await field(page, 'Venue').fill(venue); + // The form offers today; `date` (YYYY-MM-DD) sets another day. + if (date) await page.locator('#first-run-date').fill(date); 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').fill(password); @@ -32,7 +34,7 @@ export async function createTournament(page, { // Sign in, creating the tournament first when this file's server has none yet. // Every test in a file can call it, whichever runs first or in which worker. // Returns { created } so a caller can tell the first run from a later one. -export async function enterTournament(page, { name = 'E2E Cup', courts = 2 } = {}) { +export async function enterTournament(page, { name = 'E2E Cup', courts = 2, date } = {}) { 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. @@ -40,7 +42,7 @@ export async function enterTournament(page, { name = 'E2E Cup', courts = 2 } = { const home = page.locator('.viewer__body'); await welcome.or(home).first().waitFor({ state: 'visible' }); if (await welcome.isVisible()) { - await createTournament(page, { name, courts }); + await createTournament(page, { name, courts, date }); return { created: true }; } await login(page); diff --git a/scripts/e2e/journeys/kachinuki-pools.spec.mjs b/scripts/e2e/journeys/kachinuki-pools.spec.mjs index a120d666e..1c9df8f50 100644 --- a/scripts/e2e/journeys/kachinuki-pools.spec.mjs +++ b/scripts/e2e/journeys/kachinuki-pools.spec.mjs @@ -15,9 +15,10 @@ // a team exhausted (Record bout takes the last fighter off and no pairing // follows). // 2 one of A/B against the third team (C): ends LEVEL on individual victories -// and points, both lineups used up on a double hikiwake. A pool may end in -// a draw and a pool has no representative bout, so it is recorded as a -// draw (T) and no daihyosen is offered. +// and points, both lineups used up on a double hikiwake. A pool encounter +// may end in a draw, so it is recorded as a draw (T) with no winner +// marked. (Kachinuki has no representative bout anywhere, in a pool or a +// knockout, so the sheet offering none here says nothing about pools.) // 3 the remaining pair: a clean win (End match straight after the win). // Encounter 3's winner is picked so the pool has a strict order on W, L and T // alone (no tie-break flow). The two-team pool's only encounter is one bout won @@ -35,7 +36,7 @@ import { createTournament, login } from '../fixtures/setup.mjs'; 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 { EXPECT_TIMEOUT_MS, PUBLIC_DEVICE } from '../fixtures/devices.mjs'; import { FIGURES, openAdminPools, openViewerPools, poolBlocks, poolTeams, rowFigures } from '../fixtures/pools.mjs'; import { awardBoutIppon, boutNames, closeLineupPanel, currentBout, daihyosenControls, daihyosenOffer, doneRows, endMatch, @@ -181,7 +182,7 @@ test.describe('kachinuki in pools', () => { record({ journey: JOURNEY, step: 'enc1', action: 'End match', pair, rowText, screenshot: await shot(page, 'enc1-completed') }); } - // Encounter 2: level on victories and points, so a draw; no daihyosen. + // Encounter 2: level on victories and points, so a draw (T) with no winner marked. async function encounter2(page, pair) { role.C = pools[0].find((n) => n !== role.A && n !== role.B); const p = pair.shiro === role.C ? pair.aka : pair.shiro; @@ -201,8 +202,8 @@ test.describe('kachinuki in pools', () => { await recordBoutButton(page).tap(); await expect(recordBoutButton(page)).toBeDisabled(); expect(await doneRows(page).count(), 'the tied last bout stays the current bout').toBe(3); - // A knockout would offer a representative bout on this tie; a pool - // encounter never does, and kachinuki has none at all. + // Kachinuki has no representative bout in any round, so none is offered on + // this tie. That holds in a knockout too; it is not a claim about pools. await expect(daihyosenOffer(page)).toHaveCount(0); await expect(daihyosenControls(page)).toHaveCount(0); await expect(endMatchButton(page)).toBeEnabled(); @@ -290,7 +291,7 @@ test.describe('kachinuki in pools', () => { expect(poolOf(pair.shiro), 'an encounter is within one pool').toBe(poolOf(pair.aka)); const isThree = poolOf(pair.shiro) === 0; const title = !isThree ? `encounter ${n}: the pool of two` - : ['winner stays on, a hikiwake retires both, a team is exhausted', 'level on victories and points is a draw, no daihyosen offered', + : ['winner stays on, a hikiwake retires both, a team is exhausted', 'level on victories and points is a draw (T)', 'a clean win ended with End match'][threeTeamPlayed]; await test.step(`encounter ${n} (${pair.shiro} v ${pair.aka}): ${title}`, async () => { if (!isThree) await quickEncounter(page, pair); @@ -338,7 +339,7 @@ test.describe('kachinuki in pools', () => { if (!(await card.count())) return null; const pair = await sides(card); return [pair.shiro, pair.aka].sort(); - }, { message: 'the final seats the pool winners', timeout: 15_000 }).toEqual(winners); + }, { message: 'the final seats the pool winners', timeout: EXPECT_TIMEOUT_MS }).toEqual(winners); const text = (await upNextCard(page).innerText()).replace(/\s+/g, ' '); record({ journey: JOURNEY, step: 'knockout', action: 'Up next after the pools', winners, text, screenshot: await shot(page, 'final-seated') }); diff --git a/scripts/e2e/journeys/knockout-mixed-individual.spec.mjs b/scripts/e2e/journeys/knockout-mixed-individual.spec.mjs index 07fda0210..8a6dd4f0f 100644 --- a/scripts/e2e/journeys/knockout-mixed-individual.spec.mjs +++ b/scripts/e2e/journeys/knockout-mixed-individual.spec.mjs @@ -626,11 +626,14 @@ test.describe('knockout-mixed-individual', () => { // operator scores it for the other side and Finishes. const live = await editorSides(page); const opp = live.aka === b1.aka ? 'shiro' : 'aka'; + // A toast left by an earlier step must not stand for this Finish's answer: + // let it go, so what shows next is this tap's. + await expect(page.locator('.toast.is-visible')).toHaveCount(0); await ipponButton(page, opp, 'M').tap(); await settle(page); await (await armFinish(page)).tap(); - await expect(completedRowFor(page, live).or(page.locator('.toast, .pending-write-banner').first())).toBeVisible(); - const msg = (await page.locator('.toast, .pending-write-banner').allInnerTexts().catch(() => [])).join(' | '); + await expect(completedRowFor(page, live).or(page.locator('.toast.is-visible, .pending-write-banner').first())).toBeVisible(); + const msg = (await page.locator('.toast.is-visible, .pending-write-banner').allInnerTexts().catch(() => [])).join(' | '); const done = await completedRowFor(page, live).isVisible().catch(() => false); row({ step: 'kiken undo', action: 'Finish the running bout of the now-withdrawn competitor (M for the opponent)', variant: 'recovery attempt', pair: live, completed: done, message: msg.replace(/\s+/g, ' ').slice(0, 240), shot: await shot(page, 'finish-bout-of-withdrawn') }); diff --git a/scripts/e2e/journeys/knockout-mixed-kachinuki.spec.mjs b/scripts/e2e/journeys/knockout-mixed-kachinuki.spec.mjs index 4d73f0416..19401a515 100644 --- a/scripts/e2e/journeys/knockout-mixed-kachinuki.spec.mjs +++ b/scripts/e2e/journeys/knockout-mixed-kachinuki.spec.mjs @@ -41,6 +41,7 @@ import { completedRowFor, openShiaijo, sides, upNextCard } from '../fixtures/shi import { PUBLIC_DEVICE, EXPECT_TIMEOUT_MS } from '../fixtures/devices.mjs'; import { openViewer, recentResult } from '../fixtures/public.mjs'; import { scoreRows } from '../fixtures/scores.mjs'; +import { nextMatchWrite, writeLanded } from '../fixtures/scoring.mjs'; import { EDITOR } from '../../screenshots/lib/editor.mjs'; import { dwell, rendered, settled, tapOnSide, untilStable } from '../fixtures/pace.mjs'; import { ensureCoarse } from '../fixtures/pointer.mjs'; @@ -546,11 +547,13 @@ test.describe('J5 kachinuki from the court console', () => { shiro: await editor(page).locator('.team-sub-match').nth(i).locator('.tsm-center-pts--shiro .editor-side__pt--filled').allInnerTexts(), aka: await editor(page).locator('.team-sub-match').nth(i).locator('.tsm-center-pts--aka .editor-side__pt--filled').allInnerTexts(), }); + // Opening or closing a correction flushes the live bout's save first, so + // each tap below is read against the settled state before it (`from`): a + // read straight after the tap can still be the pre-tap page. + const openBeforeV1 = await untilStable(() => correctingBout(page).count()); // V1: the thumb meant bout 5 and landed on the row below it. const v1 = await tapNeighbour(row5, 'down'); - // Opening or closing a correction flushes the live bout's save first: wait - // for the rows to stop moving. - await untilStable(() => correctingBout(page).count()); + await untilStable(() => correctingBout(page).count(), { from: openBeforeV1 }); const openedWrong = await correctingBout(page).count() ? await boutNames(correctingBout(page)) : null; const v1Shot = await shot(page, 'v1-fought-row-neighbour'); let v1Recover = 0; @@ -563,8 +566,9 @@ test.describe('J5 kachinuki from the court console', () => { // V2: two taps inside 300ms on the fought row. The first expands it; where // does the second land? const before = await marksOf(4); + const openBeforeV2 = await untilStable(() => correctingBout(page).count()); const v2 = await doubleTap(doneRows(page).nth(4)); - await untilStable(() => correctingBout(page).count()); + await untilStable(() => correctingBout(page).count(), { from: openBeforeV2 }); // Which bout did the double tap leave open? const openCaret = editor(page).locator('[data-testid^="kachinuki-done-collapse-"]'); const openIdx = await openCaret.count() @@ -609,11 +613,16 @@ test.describe('J5 kachinuki from the court console', () => { if (!(await correctingBout(page).count())) await doneRows(page).nth(4).tap(); // V2 on the collapse triangle. const caretBtn = editor(page).getByTestId('kachinuki-done-collapse-4'); + const openBeforeV2c = await untilStable(() => correctingBout(page).count()); const v2c = await doubleTap(caretBtn); - const reopened = await untilStable(() => correctingBout(page).count()); + const reopened = await untilStable(() => correctingBout(page).count(), { from: openBeforeV2c }); + const openedByV2c = reopened + ? await editor(page).locator('[data-testid^="kachinuki-done-collapse-"]').first().getAttribute('data-testid') : null; record({ step: 'M1 correction', action: 'collapse triangle', variant: 'V2 doubleTap', ...v2c, - openAfter: reopened, screenshot: await shot(page, 'v2-collapse-double-tap') }); - if (reopened) await editor(page).getByTestId('kachinuki-done-collapse-4').tap(); + openAfter: reopened, openedByV2c, screenshot: await shot(page, 'v2-collapse-double-tap') }); + // Collapse whatever the double tap left open: the second tap may have + // landed on another bout's row once the first had collapsed bout 5. + if (reopened) await editor(page).locator('[data-testid^="kachinuki-done-collapse-"]').first().tap(); await expect(correctingBout(page)).toHaveCount(0); await expect(syncPill(page)).toHaveText('Synced'); expect(await boutFilled(doneRows(page).nth(4), 'shiro').allInnerTexts()).toEqual(['M', 'K']); @@ -1018,6 +1027,7 @@ test.describe('J5 kachinuki from the court console', () => { await option(S[1]).tap(); await expect(option(S[1])).toBeHidden(); // The pick is saved and the row redrawn: a tap made meanwhile is lost with the old row. + await expect.poll(() => boutNames(currentBout(page))).toEqual({ shiro: S[1], aka: A[0] }); await untilStable(() => boutNames(currentBout(page))); const afterPick = await shiroMarks(); const pickShot = await shot(page, 'm2-fighter-picked-one-tap'); @@ -1069,6 +1079,7 @@ test.describe('J5 kachinuki from the court console', () => { recoverTaps += 1; await expect(option(S[1])).toBeHidden(); // The pick is saved and the row redrawn: a tap made meanwhile is lost with the old row. + await expect.poll(() => boutNames(currentBout(page))).toEqual({ shiro: S[1], aka: A[0] }); await untilStable(() => boutNames(currentBout(page))); recoverTaps += await clearGhostMarks(); } @@ -1151,7 +1162,11 @@ test.describe('J5 kachinuki from the court console', () => { const upNext = await upNextCard(page).isVisible() ? await sides(upNextCard(page)).catch(() => null) : null; let boutsAfter = null; if (upNext) { + // Start is one match write; the sheet shows the bout log only once that + // answer is in, so a count read before it is the empty sheet. + const startAnswered = nextMatchWrite(page); await upNextCard(page).getByRole('button', { name: 'Start match' }).tap(); + await writeLanded(startAnswered, 'Start match'); await expect(recordBoutButton(page)).toBeVisible(); // Did the bout log survive the send back: the count once the sheet has loaded it. boutsAfter = await untilStable(() => doneRows(page).count()); diff --git a/scripts/e2e/journeys/knockout-mixed-team.spec.mjs b/scripts/e2e/journeys/knockout-mixed-team.spec.mjs index 04cd93665..7d694a188 100644 --- a/scripts/e2e/journeys/knockout-mixed-team.spec.mjs +++ b/scripts/e2e/journeys/knockout-mixed-team.spec.mjs @@ -385,8 +385,9 @@ test.describe('knockout-mixed-team', () => { await shot(page, 'f3-lineups-add-member-confirm'); hasty = await hastyConfirm(page); } - // The add is a write and the list refetches: the count the answer left. - const after = await untilStable(() => T.lineupsForm(page).locator('[data-testid^="squad-member-"]').count()); + // The add is a write and the list refetches: the count the answer left, + // read against the count before the add (`from`). + const after = await untilStable(() => T.lineupsForm(page).locator('[data-testid^="squad-member-"]').count(), { from: before }); await shot(page, 'f3-lineups-after-add-member', { fullPage: true }); record({ step: 'EP1 add a substitute', action: '+ Add new member… at 3 (reserves .4/.5 blank)', variant: 'V4 hastyConfirm', ...(hasty || {}), membersBefore: before, membersAfter: after, alert: r.alert }); @@ -542,7 +543,12 @@ test.describe('knockout-mixed-team', () => { await expect(T.boutRow(ed, 1)).toBeVisible(); await expect(T.syncPill(ed)).toBeVisible(); const identity = (await ed.locator('.editor-modal__eyebrow').first().innerText()).trim(); - // Up next moves on once the list has refetched after the start. + // Up next moves on once the list has refetched after the start: wait for + // it to leave the pairing just started before reading where it settled. + await expect.poll(async () => { + const now = await sides(upNextCard(page)).catch(() => null); + return now !== null && JSON.stringify(now) !== JSON.stringify(sf1); + }).toBe(true); const upNextNow = await untilStable(() => sides(upNextCard(page)).catch(() => null)); await shot(page, 'sf1-started'); record({ step: 'start SF1', action: 'Start match (Up next)', variant: 'V2 doubleTap', ...dbl, startTapBox: startBox, editorHolds: identity, diff --git a/scripts/e2e/journeys/moved-match.spec.mjs b/scripts/e2e/journeys/moved-match.spec.mjs index 46389728d..7a05fde89 100644 --- a/scripts/e2e/journeys/moved-match.spec.mjs +++ b/scripts/e2e/journeys/moved-match.spec.mjs @@ -313,11 +313,15 @@ test.describe('moved-match', () => { await (await startOf(moved)).tap(); await expect.poll(async () => ((await courtState(B)).editor || {}).names, { timeout: EXPECT_TIMEOUT_MS }).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(); const notice = async () => (await B.locator('.shiaijo-upnext__error[role="alert"]').allInnerTexts()).join(' | '); + const courtB = async () => [await notice(), await runningOnCourt(H, 'B')]; + // One plain read, not a settled one: the tap must still come at once. + const beforeSecondStart = await courtB(); + await (await startOf(secondPair)).tap(); // A start the server refuses shows its notice once answered; one it accepts - // shows the bout running: the row records whichever, so wait for it to settle. - await untilStable(async () => [await notice(), await runningOnCourt(H, 'B')]); + // shows the bout running: the row records whichever, so wait for the court + // to leave the state before the tap, and then to settle. + await untilStable(courtB, { from: beforeSecondStart }); 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') }); @@ -330,8 +334,9 @@ test.describe('moved-match', () => { return; } await expect.poll(async () => ((await courtState(B)).upNext || {}).names, { timeout: EXPECT_TIMEOUT_MS }).toEqual([secondPair.shiro, secondPair.aka]); + const beforeBusyStart = await untilStable(courtB); await (await startOf(secondPair)).tap(); - await untilStable(async () => [await notice(), await runningOnCourt(H, 'B')]); + await untilStable(courtB, { from: beforeBusyStart }); 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/organiser-admin.spec.mjs b/scripts/e2e/journeys/organiser-admin.spec.mjs index 6dc5e81f8..7b03e686a 100644 --- a/scripts/e2e/journeys/organiser-admin.spec.mjs +++ b/scripts/e2e/journeys/organiser-admin.spec.mjs @@ -25,8 +25,8 @@ import { playRunningBout } from '../fixtures/individual.mjs'; import { openViewer, openTvBoard, tvBoard } from '../fixtures/public.mjs'; import { dwell } from '../fixtures/pace.mjs'; import { - addSponsor, downloadResultsWorkbook, importFiles, importResultRows, loadedImage, openEditTournament, - pngBytes, readWorkbook, resetPassword, todayDMY, uploadLogo, writeImportBundle, + addSponsor, dayFromNow, downloadResultsWorkbook, importFiles, importResultRows, loadedImage, openEditTournament, + pngBytes, readWorkbook, resetPassword, sheetCells, todayDMY, uploadLogo, writeImportBundle, } from '../fixtures/organiser.mjs'; const ROSTER = [ @@ -39,6 +39,7 @@ const IMPORTED_B = [ ['Hiro Sasaki', 'Seiwakan'], ['Ino Mizuno', 'Tenshin'], ]; const NEW_PASSWORD = 'e2e-after-reset'; +const tournamentDay = dayFromNow(45); test.describe.configure({ mode: 'serial' }); @@ -60,14 +61,19 @@ test.describe('organiser admin (file mode)', () => { test('Excel export: a competition with a result downloads as a workbook', async ({ page }) => { let pair; + let unplayed; await test.step('seed a knockout of four and play one bout', async () => { - await enterTournament(page, { name: 'Organiser Cup', courts: 2 }); + // Not today: a row the import gives no date must take THIS day. + await enterTournament(page, { name: 'Organiser Cup', courts: 2, date: tournamentDay.iso }); compId = await createCompetition(page, { name: 'Export Men', kind: 'individual', format: 'knockout', courts: ['A'], numberPrefix: 'X', }); await pasteRoster(page, compId, ROSTER); await generateDraw(page, compId); await startCompetition(page, compId); + // The export before any bout is played: the control the played one is + // measured against. + unplayed = (await downloadResultsWorkbook(page, compId)).bytes; await openShiaijo(page, 'A'); pair = await playRunningBout(page); await shot(page, 'bout-played'); @@ -82,18 +88,25 @@ test.describe('organiser admin (file mode)', () => { expect(book.sheets.length).toBeGreaterThan(0); expect(book.sheets).toContain('Elimination Matches'); for (const [name] of ROSTER) expect(book.text, `${name} is in the workbook`).toContain(name); - // The played bout's two names sit in the bracket, and its winner is told - // apart from the others by a mark: the workbook is not the blank draw. expect(book.text).toContain(pair.shiro); expect(book.text).toContain(pair.aka); + // What only a recorded result writes (the names alone are on the blank + // draw too): the winner's name carried into the next round's slot, and + // the waza letter the bout was won with in a score cell. Both are + // counted against the export taken before the bout was played. + const winner = pair[pair.winner]; + const count = (bytes, text) => Object.values(sheetCells(bytes, 'Elimination Matches')).filter((v) => v === text).length; + expect(count(dl.bytes, winner), `${winner} is carried into the next round`).toBeGreaterThan(count(unplayed, winner)); + expect(count(dl.bytes, 'M'), 'the men ippon is written in a score cell').toBeGreaterThan(count(unplayed, 'M')); }); }); test('Import competitions: a folder of CSVs lands on the dashboard and the public page', async ({ page, browser, baseURL }) => { - // Two rows: one with a date, and one without (the manifest's date is optional - // and a row without one takes the tournament's). + // Two rows: one with a date (it must be a tournament day: the import refuses + // any other), and one without (the manifest's date is optional and a row + // without one takes the tournament's). const files = writeImportBundle(path.join(work, 'bundle'), [ - { id: 'imp-kyu', name: 'Imported Kyu', kind: 'individual', format: 'knockout', date: todayDMY(), roster: IMPORTED }, + { id: 'imp-kyu', name: 'Imported Kyu', kind: 'individual', format: 'knockout', date: tournamentDay.dmy, roster: IMPORTED }, { id: 'imp-nodate', name: 'Imported No Date', kind: 'individual', format: 'mixed', roster: IMPORTED_B }, ]); await login(page, 'e2e-operator'); @@ -122,8 +135,10 @@ test.describe('organiser admin (file mode)', () => { await page.goto('/admin/competition/imp-nodate'); await expect(page.getByRole('button', { name: 'Generate draw' })).toBeEnabled(); await expect(page.getByText(/Cannot start: invalid date/)).toHaveCount(0); - // The tournament's date, as the page head writes it ("13 Oct 2026"). - const day = new Date().toLocaleDateString('en-GB', { day: 'numeric', month: 'short', year: 'numeric' }); + // The tournament's day, written by the page's own formatter, which is + // not today's: only taking the tournament's date can put it there. + const [day, today] = await page.evaluate(([a, b]) => [window.formatDate(a), window.formatDate(b)], [tournamentDay.dmy, todayDMY()]); + expect(day).not.toBe(today); await expect(page.locator('.page-head__sub')).toContainText(day); await shot(page, 'imported-no-date'); }); @@ -188,7 +203,11 @@ test.describe('organiser admin (file mode)', () => { // The docs: sponsor images appear on the public viewer only, never on // the TV board. + // The board gets the sponsors with the tournament it loads: wait for that + // answer, and see the sponsor in it, before its absence means anything. + const tournamentLoaded = phone.waitForResponse((r) => new URL(r.url()).pathname === '/api/viewer/tournament' && r.ok()); await openTvBoard(phone, 'A'); + expect(JSON.stringify(await (await tournamentLoaded).json()), 'the board was sent the sponsor').toContain('Acme Dojo Supplies'); await expect(tvBoard(phone).getByRole('img', { name: 'Acme Dojo Supplies' })).toHaveCount(0); await expect(phone.getByRole('complementary', { name: 'Sponsors' })).toHaveCount(0); await shot(phone, 'tv-board-no-sponsor'); @@ -205,17 +224,36 @@ test.describe('organiser admin (file mode)', () => { }); }); - test.fixme('bc-lblg: the docs list the lobby display among the surfaces that show the tournament logo, and it shows none', async ({ browser, baseURL }) => { - // run-tournament.md (Branding and sponsors): "Logo: ... shown on the viewer, - // the lobby displays, and the admin screens." The viewer and the admin top - // bar show it (asserted above); /display?court=all, the lobby grid, has no - // logo image at all (display_lobby.jsx). Runs after the upload test. + test.fixme('bc-lblg: the lobby display shows the tournament logo only when the organiser turns that setting on', async ({ page, browser, baseURL }) => { + // bc-lblg (operator decision 2026-10-13): the logo on the lobby display is an + // OPTIONAL organiser setting. run-tournament.md (Branding and sponsors) says + // the logo is "shown on the viewer, the lobby displays, and the admin + // screens"; today /display?court=all (display_lobby.jsx) draws no logo at + // all. The setting does not exist yet, so the step is written against the + // most likely control: a checkbox on Edit tournament details whose label + // contains "logo on the lobby display", saved by Save changes. Change the + // label here to the real one when bc-lblg lands. Runs after the upload test, + // so a logo is uploaded. + const setLogoOnLobby = async (show) => { + await login(page, 'e2e-operator'); + await openEditTournament(page); + const toggle = page.getByLabel(/logo on the lobby display/i); + if (show) await toggle.check(); else await toggle.uncheck(); + await page.getByRole('button', { name: 'Save changes' }).tap(); + await expect(toggle).toBeChecked({ checked: show }); + }; const spectator = await browser.newContext({ ...PUBLIC_DEVICE, baseURL }); try { const phone = await spectator.newPage(); - await phone.goto('/display?court=all'); - await expect(phone.getByTestId('lobby-root')).toBeVisible(); - await expect(phone.getByRole('img', { name: 'Tournament logo' })).toBeVisible(); + const lobbyLogo = async () => { + await phone.goto('/display?court=all'); + await expect(phone.getByTestId('lobby-root')).toBeVisible(); + return phone.getByRole('img', { name: 'Tournament logo' }); + }; + await setLogoOnLobby(true); + await expect(await lobbyLogo()).toBeVisible(); + await setLogoOnLobby(false); + await expect(await lobbyLogo()).toHaveCount(0); } finally { await spectator.close(); } diff --git a/scripts/e2e/journeys/organiser-locked.spec.mjs b/scripts/e2e/journeys/organiser-locked.spec.mjs index f8df5d8c0..39eff12df 100644 --- a/scripts/e2e/journeys/organiser-locked.spec.mjs +++ b/scripts/e2e/journeys/organiser-locked.spec.mjs @@ -60,9 +60,13 @@ test.describe('organiser locked mode', () => { await test.step('the sign-in dialog has no Forgot password?, and the password signs in', async () => { await page.getByRole('button', { name: 'Sign out' }).tap(); + // The dialog decides on Forgot password? from the auth config: wait for + // that answer, so its absence is not just the config not having come yet. + const authConfig = page.waitForResponse((r) => new URL(r.url()).pathname === '/api/auth-config' && r.ok()); await page.goto('/admin'); const dialog = page.locator('.modal.auth'); await expect(dialog.getByText('Admin sign in')).toBeVisible(); + await authConfig; await dwell(page, Date.now()); await expect(dialog.getByRole('button', { name: 'Forgot password?' })).toHaveCount(0); await shot(page, 'sign-in-no-forgot'); diff --git a/scripts/e2e/journeys/organiser-roster.spec.mjs b/scripts/e2e/journeys/organiser-roster.spec.mjs index f50bef458..451ca21cf 100644 --- a/scripts/e2e/journeys/organiser-roster.spec.mjs +++ b/scripts/e2e/journeys/organiser-roster.spec.mjs @@ -4,7 +4,8 @@ // 1. Check-in on the Registration desk (one by one, by search, by dojo) and // the draw that follows: only checked-in competitors are drawn, and the // desk's search finds a competitor by number. -// 2. Seeds typed before the draw: two seeds never share a pool. +// 2. Seeds typed before the draw: two competitors the unseeded draw puts in +// one pool are seeded, and the draw then keeps them in different pools. // 3. The participant Edit dialog after the draw: a new name and dojo reach // the court console, the bracket and the public viewer; the same name and // dojo as another competitor is refused, the same name in another dojo is @@ -20,7 +21,7 @@ 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 } from '../fixtures/competition.mjs'; +import { discardDraw, generateDraw, pasteRoster } from '../fixtures/competition.mjs'; import { openShiaijo } from '../fixtures/shiaijo.mjs'; import { openViewer } from '../fixtures/public.mjs'; import { @@ -134,27 +135,38 @@ test.describe('organiser roster', () => { }); await pasteRoster(page, ids.seeds, ROSTER_8); - // The first two names on the roster: the order a plain draw would put - // together, which is what makes the rule visible. + // The draw with no seeds typed is read first. It is what the seeds must + // change: two competitors it puts in the SAME pool are the pair to seed, + // so a rule that held anyway cannot pass for the seeds working. + await generateDraw(page, ids.seeds); + await openCompetitionTab(page, ids.seeds, 'pools'); + const unseeded = await poolMembers(page); + record({ journey: 'organiser-roster', action: 'unseeded draw', pools: unseeded }); + expect(Object.keys(unseeded), 'two pools of four').toHaveLength(2); + const [firstSeed, secondSeed] = Object.values(unseeded).find((members) => members.length >= 2); + await shot(page, 'pools-unseeded'); + await page.goto(`/admin/competition/${ids.seeds}`); + await discardDraw(page); + await openParticipants(page, ids.seeds); - await setSeed(page, 'Akira Tanaka', 1); - await setSeed(page, 'Ben Carter', 2); + await setSeed(page, firstSeed, 1); + await setSeed(page, secondSeed, 2); // Each rank is saved as it is typed: they survive a reload. await page.reload(); await expect(page.getByRole('button', { name: 'Clear seeds' })).toBeVisible(); - expect(await seedRankOf(page, 'Akira Tanaka')).toBe('1'); - expect(await seedRankOf(page, 'Ben Carter')).toBe('2'); + expect(await seedRankOf(page, firstSeed)).toBe('1'); + expect(await seedRankOf(page, secondSeed)).toBe('2'); await shot(page, 'seeds-typed'); await generateDraw(page, ids.seeds); await openCompetitionTab(page, ids.seeds, 'pools'); const pools = await poolMembers(page); const poolOf = (n) => Object.keys(pools).find((p) => pools[p].includes(n)); - record({ journey: 'organiser-roster', action: 'seeds placed', pools }); + record({ journey: 'organiser-roster', action: 'seeds placed', seeds: [firstSeed, secondSeed], pools }); expect(Object.keys(pools), 'two pools of four').toHaveLength(2); - expect(poolOf('Akira Tanaka'), 'seed 1 is drawn').toBeTruthy(); - expect(poolOf('Ben Carter'), 'seed 2 is drawn').toBeTruthy(); - expect(poolOf('Akira Tanaka'), 'two seeds never share a pool').not.toBe(poolOf('Ben Carter')); + expect(poolOf(firstSeed), 'seed 1 is drawn').toBeTruthy(); + expect(poolOf(secondSeed), 'seed 2 is drawn').toBeTruthy(); + expect(poolOf(firstSeed), 'two seeds never share a pool, though the unseeded draw put them together').not.toBe(poolOf(secondSeed)); await shot(page, 'pools-seeds-apart'); }); @@ -170,6 +182,7 @@ test.describe('organiser roster', () => { const slotOf = (slots, n) => Object.keys(slots).find((k) => slots[k].name === n); const danSlot = slotOf(before, 'Dan Evans'); expect(danSlot, 'Dan Evans is drawn').toBeTruthy(); + expect(before[danSlot].num, 'Dan Evans carries a number').not.toBe(''); // A new name and a new dojo, after the draw. await openParticipants(page, ids.edit); @@ -232,6 +245,7 @@ test.describe('organiser roster', () => { const before = await bracketSlots(page); const slot = Object.keys(before).find((k) => before[k].name === 'Hana Ito'); expect(slot, 'Hana Ito is drawn and has not fought').toBeTruthy(); + expect(before[slot].num, 'Hana Ito carries a number').not.toBe(''); await openParticipants(page, ids.edit); await openEdit(page, 'Hana Ito'); @@ -246,7 +260,8 @@ test.describe('organiser roster', () => { }); expect(Object.values(slots).map((s) => s.name), `${where}: Hana Ito is nowhere`).not.toContain('Hana Ito'); // Nobody else moved. - Object.keys(before).filter((k) => k !== slot && before[k].name !== 'Finn Hale').forEach((k) => { + Object.keys(before).filter((k) => k !== slot).forEach((k) => { + expect(before[k].num, `${k} carries a number`).not.toBe(''); expect(slots[k]?.num, `${where}: ${k} keeps its number`).toBe(before[k].num); }); }; From 0eaac2c828a072810284584d5990432e519af861 Mon Sep 17 00:00:00 2001 From: Ricardo Oliveira Date: Wed, 14 Oct 2026 01:25:04 +0100 Subject: [PATCH 18/31] test(bc-e2rv): e2e review round 4: settled reads, one navigation per poll, listeners released in finally Co-Authored-By: Claude Opus 5.5 --- scripts/e2e/fixtures/organiser.mjs | 8 +- scripts/e2e/fixtures/pace.mjs | 4 +- scripts/e2e/fixtures/roster.mjs | 28 ++++++- scripts/e2e/fixtures/shared-court.mjs | 6 ++ scripts/e2e/journeys/kachinuki-pools.spec.mjs | 4 +- .../knockout-mixed-individual.spec.mjs | 21 +++-- .../knockout-mixed-kachinuki.spec.mjs | 29 +++++-- .../e2e/journeys/knockout-mixed-team.spec.mjs | 9 ++- scripts/e2e/journeys/moved-match.spec.mjs | 81 ++++++++++--------- .../e2e/journeys/organiser-roster.spec.mjs | 18 +++-- scripts/e2e/journeys/shared-shiaijo.spec.mjs | 46 +++++++---- 11 files changed, 174 insertions(+), 80 deletions(-) diff --git a/scripts/e2e/fixtures/organiser.mjs b/scripts/e2e/fixtures/organiser.mjs index 83d09162b..77efb7ddd 100644 --- a/scripts/e2e/fixtures/organiser.mjs +++ b/scripts/e2e/fixtures/organiser.mjs @@ -93,11 +93,13 @@ export function sheetCells(buf, sheetName) { const xml = (name) => (zip.get(name) ? zip.get(name).toString('utf8') : ''); const shared = [...xml('xl/sharedStrings.xml').matchAll(/([\s\S]*?)<\/si>/g)] .map((m) => xmlText(m[1].replace(/<[^>]+>/g, ''))); - const entry = [...xml('xl/workbook.xml').matchAll(/]*\bname="([^"]*)"[^>]*\br:id="([^"]*)"/g)] - .find((m) => xmlText(m[1]) === sheetName); + // Each tag's attributes are read one by one, so their order does not matter. + const entry = [...xml('xl/workbook.xml').matchAll(/]*>/g)] + .map((m) => ({ name: /\bname="([^"]*)"/.exec(m[0]), rid: /\br:id="([^"]*)"/.exec(m[0]) })) + .find((s) => s.name && s.rid && xmlText(s.name[1]) === sheetName); if (!entry) throw new Error(`sheetCells: no sheet named ${sheetName}`); const rel = [...xml('xl/_rels/workbook.xml.rels').matchAll(/Id="([^"]*)"[^>]*Target="([^"]*)"|Target="([^"]*)"[^>]*Id="([^"]*)"/g)] - .map((m) => ({ id: m[1] || m[4], target: m[2] || m[3] })).find((r) => r.id === entry[2]); + .map((m) => ({ id: m[1] || m[4], target: m[2] || m[3] })).find((r) => r.id === entry.rid[1]); if (!rel) throw new Error(`sheetCells: no part for ${sheetName}`); const part = xml(`xl/${rel.target.replace(/^\/?(xl\/)?/, '')}`); const out = {}; diff --git a/scripts/e2e/fixtures/pace.mjs b/scripts/e2e/fixtures/pace.mjs index 6bdcc2021..2861cd9ce 100644 --- a/scripts/e2e/fixtures/pace.mjs +++ b/scripts/e2e/fixtures/pace.mjs @@ -98,7 +98,9 @@ export async function untilStable(read, { quietMs = 400, timeout = EXPECT_TIMEOU for (;;) { await new Promise((done) => setTimeout(done, quietMs)); const now = JSON.stringify(await read()); - if (now === last) return JSON.parse(now === undefined ? 'null' : now); + // JSON.stringify(undefined) is undefined, which JSON.parse cannot take: + // a read of undefined settles as undefined, not as null. + if (now === last) return now === undefined ? undefined : JSON.parse(now); if (Date.now() > deadline) throw new Error(`untilStable: the read kept changing for ${timeout} ms (last: ${now})`); last = now; } diff --git a/scripts/e2e/fixtures/roster.mjs b/scripts/e2e/fixtures/roster.mjs index 3c3f96fbd..71fdd5829 100644 --- a/scripts/e2e/fixtures/roster.mjs +++ b/scripts/e2e/fixtures/roster.mjs @@ -4,7 +4,7 @@ // of the draw as it stands (pool membership, bracket slots) on the admin // preview or on the public viewer. Every selector for those lives here. import { expect } from '@playwright/test'; -import { dwell } from './pace.mjs'; +import { dwell, rendered } from './pace.mjs'; import { EXPECT_TIMEOUT_MS } from './devices.mjs'; // ---- the Registration desk (admin_registration_desk.jsx) ------------------- @@ -36,11 +36,15 @@ export async function toggleCheckIn(page, name, on = true) { await expect(box).toHaveAttribute('aria-checked', String(on)); } -// Type into the desk's search box and wait for the list to settle on it. +// Type into the desk's search box and wait for the list to draw the filter. +// The filter is local React state (no debounce, no request), so the box +// holding the text and two frames later is the list drawn from it; a caller +// that knows the result still asserts it (a row count, a row visible). export async function searchDesk(page, text) { const box = deskSearch(page); await box.fill(text); await expect(box).toHaveValue(text); + await rendered(page); } export async function groupByDojo(page, on = true) { @@ -134,8 +138,26 @@ export async function poolMembers(page) { // Bracket slot -> { name, num, dojo }, read from the bracket (admin preview or // public viewer). A slot is ":a|b"; the match id (m-r1-3) is the // place in the tree, which a replaced competitor must keep. -export async function bracketSlots(page) { +// +// `until(slots)`, when given, is the state the caller is about to assert: the +// bracket is re-read until it holds (up to the expect timeout) instead of once +// after the first match shows, which a late refetch could still change. On a +// timeout the last read is returned, so the caller's own assertion names what +// is missing. +export async function bracketSlots(page, until) { await expect(page.locator('.bc-match').first()).toBeVisible(); + if (!until) return readBracketSlots(page); + let slots = await readBracketSlots(page); + try { + await expect.poll(async () => { + slots = await readBracketSlots(page); + return until(slots); + }, { timeout: EXPECT_TIMEOUT_MS }).toBe(true); + } catch { /* the caller's assertion on `slots` reports the difference */ } + return slots; +} + +function readBracketSlots(page) { return page.locator('.bc-match').evaluateAll((matches) => { const out = {}; matches.forEach((m) => { diff --git a/scripts/e2e/fixtures/shared-court.mjs b/scripts/e2e/fixtures/shared-court.mjs index 955dbe05b..82b0cff34 100644 --- a/scripts/e2e/fixtures/shared-court.mjs +++ b/scripts/e2e/fixtures/shared-court.mjs @@ -117,6 +117,12 @@ export async function seedCompetitions(page, specs) { // courts the draw produced come from here. export async function readScoresTab(page, id) { await page.goto(`/admin/competition/${encodeURIComponent(id)}/scores`); + return readScoresRows(page); +} + +// The same rows of a Scores tab the page is already on (no navigation), for a +// poll that waits on what the page shows as the app follows the server. +export async function readScoresRows(page) { await expect(page.locator('.score-edit-row').first()).toBeVisible(); return page.locator('.score-edit-row').evaluateAll((rows) => rows.map((r) => { const txt = (sel) => { diff --git a/scripts/e2e/journeys/kachinuki-pools.spec.mjs b/scripts/e2e/journeys/kachinuki-pools.spec.mjs index 1c9df8f50..bd5847a2a 100644 --- a/scripts/e2e/journeys/kachinuki-pools.spec.mjs +++ b/scripts/e2e/journeys/kachinuki-pools.spec.mjs @@ -333,8 +333,10 @@ test.describe('kachinuki in pools', () => { test('the winner of each pool is seated in the final', async ({ page }) => { await login(page); const winners = pools.map((t) => rankedIn(t)[0]).sort(); + // One navigation; the poll then reads the console as it follows the data + // (a goto per poll interval would re-load the page under every read). + await openShiaijo(page, 'A'); await expect.poll(async () => { - await openShiaijo(page, 'A'); const card = upNextCard(page); if (!(await card.count())) return null; const pair = await sides(card); diff --git a/scripts/e2e/journeys/knockout-mixed-individual.spec.mjs b/scripts/e2e/journeys/knockout-mixed-individual.spec.mjs index 8a6dd4f0f..fcf6015b8 100644 --- a/scripts/e2e/journeys/knockout-mixed-individual.spec.mjs +++ b/scripts/e2e/journeys/knockout-mixed-individual.spec.mjs @@ -1030,9 +1030,12 @@ test.describe('knockout-mixed-individual', () => { // Close straight away (inside the autosave's 300ms), as a hasty operator would. const closedByTap = await page.locator(EDITOR).getByRole('button', { name: /✕ Close/ }).tap({ timeout: 2000 }).then(() => true, () => false); const discardAsked = await page.locator('.modal[role="dialog"] .modal__foot').isVisible().catch(() => false); + // `from`: the editor as it stands before the answer (still open behind the + // confirm), so the settle cannot agree with the page as it was before. + const editorBeforeAnswer = await page.locator(EDITOR).isVisible().catch(() => false); const hasty = discardAsked ? await hastyConfirm(page) : null; // Whether the editor closed (and the unsaved ippon was kept) settles after the answer. - await untilStable(() => page.locator(EDITOR).isVisible().catch(() => false)); + await untilStable(() => page.locator(EDITOR).isVisible().catch(() => false), { from: editorBeforeAnswer }); await secondRow.tap(); await expect(page.locator(EDITOR)).toBeVisible(); const kept = await slotMarks(page, winnerOf(pair2), EDITOR); @@ -1069,9 +1072,11 @@ test.describe('knockout-mixed-individual', () => { row({ step: 'bracket tab', action: 'Edit result', variant: 'size', ...(await tapSize(panel.getByRole('button', { name: 'Edit result' }))) }); await panel.getByRole('button', { name: 'Edit result' }).tap(); const editDialog = await hastyConfirm(page); - await rendered(page); + // The reopen is a server write: the editor is read once it has left the + // result card's state (not shown), or settled on not shown if it never did. + const editorReopened = await untilStable(() => panel.locator(INLINE_EDITOR).isVisible().catch(() => false), { from: false }); row({ step: 'bracket tab', action: 'Edit result confirm', variant: 'V4 hastyConfirm', ...editDialog, - editorReopened: await panel.locator(INLINE_EDITOR).isVisible().catch(() => false), + editorReopened, shot: await shot(page, 'bracket-tab-edit-result') }); const closeEdit = panel.getByRole('button', { name: /✕ Close|^Cancel$/ }).first(); if (await closeEdit.isVisible().catch(() => false)) await closeEdit.tap(); @@ -1088,9 +1093,15 @@ test.describe('knockout-mixed-individual', () => { const bpair2 = await editorSides(page); await ipponButton(page, winnerOf(bpair2), 'M').tap(); await settle(page); + const readBracket = async () => (await page.locator('.page').first().innerText()).replace(/\s+/g, ' '); await finishMatch(page, INLINE_EDITOR); - // The bracket redraws once the finish has refetched: the text it settles on. - const bracketText = await untilStable(async () => (await page.locator('.page').first().innerText()).replace(/\s+/g, ' ')); + // The bracket redraws once the finish has refetched. Both semi-finals are + // played now, so the final is seated (no "Winner of ..." left): that is + // the state the absence below is read against, not the tree from before + // the finish, which has no 3rd place match either. Then the text it + // settles on. + await expect.poll(readBracket, { message: 'the final is seated once both semi-finals are played' }).not.toMatch(/Winner of /); + const bracketText = await untilStable(readBracket); row({ step: 'bracket tab', action: 'second semi-final, default joint 3rd places', variant: 'correct path', pair: bpair2, 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); diff --git a/scripts/e2e/journeys/knockout-mixed-kachinuki.spec.mjs b/scripts/e2e/journeys/knockout-mixed-kachinuki.spec.mjs index 19401a515..894f6d21b 100644 --- a/scripts/e2e/journeys/knockout-mixed-kachinuki.spec.mjs +++ b/scripts/e2e/journeys/knockout-mixed-kachinuki.spec.mjs @@ -405,8 +405,11 @@ test.describe('J5 kachinuki from the court console', () => { await expectPairing(3, S[1], A[1]); const tie = tieButton(currentBout(page)); + // `from`: the label before the taps, so the settle cannot agree with the + // page as it was before they were handled. + const tieBefore = await tie.innerText(); const v2tie = await doubleTap(tie); - const tieLabel = (await untilStable(() => tie.innerText())).trim(); + const tieLabel = (await untilStable(() => tie.innerText(), { from: tieBefore })).trim(); record({ step: 'M1 bout 3', action: 'Tie (hikiwake)', variant: 'V2 doubleTap', ...v2tie, tieLabel, middle: await boutMiddle(currentBout(page)), screenshot: await shot(page, 'v2-tie-double-tap') }); if (!/✓/.test(tieLabel)) await tie.tap(); @@ -428,11 +431,12 @@ test.describe('J5 kachinuki from the court console', () => { await tieButton(currentBout(page)).tap(); await expect(enchoButton(page)).toBeVisible(); await shot(page, 'bout-4-tied-encho-offered'); + const overtimeBefore = await enchoUndoButton(page).isVisible(); const v2 = await doubleTap(enchoButton(page)); const eyebrow = (await editor(page).locator('.editor-modal__eyebrow').first().innerText()).trim(); // 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 untilStable(() => enchoUndoButton(page).isVisible()); + const inOvertime = await untilStable(() => enchoUndoButton(page).isVisible(), { from: overtimeBefore }); 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') }); @@ -602,11 +606,19 @@ test.describe('J5 kachinuki from the court console', () => { screenshot: await shot(page, 'bout-5-and-live-bout-both-editable', { fullPage: true }) }); // V3: the Back button while bout 5 is open for correction. await expect(syncPill(page)).toHaveText('Synced'); + // The sheet as the correction leaves it: whether the correction is open, + // the bouts fought and bout 5's marks. A Back that reloads the court's + // data changes one of them unless the correction survives it. + const sheetNow = async () => ({ + open: await correctingBout(page).count(), done: await doneRows(page).count(), marks: await marksOf(4).catch(() => null), + }); + const sheetBeforeBack = await sheetNow(); const back = await interruptC(page, 'back'); await expect(editor(page)).toBeVisible(); - // Back reloads the court's data: whether the correction survives it is - // what the row records, so wait for the sheet to stop changing. - await untilStable(() => doneRows(page).count()); + // Whether the correction survives is what the row records: wait for the + // sheet to leave its pre-Back state and stop changing (a correction that + // survives changes nothing, which costs the helper's wait for a change). + await untilStable(sheetNow, { from: sheetBeforeBack }); record({ step: 'M1 correction', action: 'bout 5 open for correction', variant: 'V3 interrupt back', ...back, stillOpen: await correctingBout(page).count(), bout5: await boutNames(doneRows(page).nth(4)).catch(() => null), bout5Marks: await marksOf(4), live: await boutNames(currentBout(page)), screenshot: await shot(page, 'v3-back-mid-correction') }); @@ -677,8 +689,9 @@ test.describe('J5 kachinuki from the court console', () => { record({ step: 'M1 bout 8', action: 'Fusensho (Shiro)', variant: 'V1 tapNeighbour left', ...v1, shiroMarks: marks, recoverTaps, screenshot: v1Shot }); // V2: Fusensho double-tapped (it is a toggle). + const fusBefore = await fus.innerText(); const v2 = await doubleTap(fus); - const label = (await untilStable(() => fus.innerText())).trim(); + const label = (await untilStable(() => fus.innerText(), { from: fusBefore })).trim(); record({ step: 'M1 bout 8', action: 'Fusensho (Shiro)', variant: 'V2 doubleTap', ...v2, labelAfter: label, shiroMarks: await boutFilled(currentBout(page), 'shiro').allInnerTexts(), screenshot: await shot(page, 'v2-fusensho-double-tap') }); if (!/✓/.test(label)) await fus.tap(); @@ -1169,7 +1182,9 @@ test.describe('J5 kachinuki from the court console', () => { await writeLanded(startAnswered, 'Start match'); await expect(recordBoutButton(page)).toBeVisible(); // Did the bout log survive the send back: the count once the sheet has loaded it. - boutsAfter = await untilStable(() => doneRows(page).count()); + // The empty sheet (0) is the read before the log loads; `from` makes + // the settle wait for it to leave that. + boutsAfter = await untilStable(() => doneRows(page).count(), { from: 0 }); } record({ step: 'M2 reopened', action: 'Send back to queue (reopened encounter)', variant: 'V4 hasty confirm', ...hasty, boutsBefore, upNext, boutsAfterRestart: boutsAfter, current: upNext ? await boutNames(currentBout(page)) : null, diff --git a/scripts/e2e/journeys/knockout-mixed-team.spec.mjs b/scripts/e2e/journeys/knockout-mixed-team.spec.mjs index 7d694a188..796ea3643 100644 --- a/scripts/e2e/journeys/knockout-mixed-team.spec.mjs +++ b/scripts/e2e/journeys/knockout-mixed-team.spec.mjs @@ -411,7 +411,14 @@ test.describe('knockout-mixed-team', () => { await shot(page, 'f3-pool-encounter-bout3-tied'); await T.finishTeam(ed); await page.goto(`/admin/competition/${f3}/pools`); - await expect(page.locator('tr').filter({ hasText: pairNow.shiro }).first()).toBeVisible(); + // The row is there before the standings have counted the encounter: wait + // for its team matches (W, L, T, the first three figures) to add up to the + // one just played, for both teams, before the rows are read. + const teamMatches = async (team) => (await page.locator('tr').filter({ hasText: team }).first().locator('td.num').allInnerTexts()) + .slice(0, 3).reduce((n, t) => n + (Number(t.trim()) || 0), 0); + for (const team of [pairNow.shiro, pairNow.aka]) { + await expect.poll(() => teamMatches(team), { message: `${team}'s standings row counts the finished encounter` }).toBeGreaterThan(0); + } await shot(page, 'f3-pools-standings', { fullPage: true }); const standings = await page.locator('tr').filter({ hasText: pairNow.shiro }).first().innerText().catch(() => ''); const standingsAka = await page.locator('tr').filter({ hasText: pairNow.aka }).first().innerText().catch(() => ''); diff --git a/scripts/e2e/journeys/moved-match.spec.mjs b/scripts/e2e/journeys/moved-match.spec.mjs index 7a05fde89..13aaf7fcf 100644 --- a/scripts/e2e/journeys/moved-match.spec.mjs +++ b/scripts/e2e/journeys/moved-match.spec.mjs @@ -423,45 +423,48 @@ test.describe('moved-match', () => { if (/"error"\s*:\s*"court_busy"/.test(await res.text().catch(() => ''))) courtBusyStarts.push(res.url()); }; H.on('response', onResponse); - 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); - // Deliberate: nothing may start by itself, so the time is the evidence. - 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 }); - // Deliberate: no start may follow (no toast, nothing running), so the time is the evidence. - 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); - H.off('response', onResponse); + try { + 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); + // Deliberate: nothing may start by itself, so the time is the evidence. + 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 }); + // Deliberate: no start may follow (no toast, nothing running), so the time is the evidence. + 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); + } finally { + H.off('response', onResponse); + } expect(courtBusyStarts, 'no start sent from the Scores tab was refused for the court').toEqual([]); }); }); diff --git a/scripts/e2e/journeys/organiser-roster.spec.mjs b/scripts/e2e/journeys/organiser-roster.spec.mjs index 451ca21cf..f9aac8ec5 100644 --- a/scripts/e2e/journeys/organiser-roster.spec.mjs +++ b/scripts/e2e/journeys/organiser-roster.spec.mjs @@ -124,6 +124,9 @@ test.describe('organiser roster', () => { await shot(page, 'desk-search-number'); // The two who were left out carry no number. await searchDesk(page, ''); + // The row is drawn before its tag is counted absent: a row not drawn yet + // has no tag either. + await expect(deskRow(page, 'Ben Carter')).toBeVisible(); await expect(deskRow(page, 'Ben Carter').locator('.rd-tag--number')).toHaveCount(0); await expect(deskRows(page).locator('.rd-tag--number')).toHaveCount(10); }); @@ -191,6 +194,8 @@ test.describe('organiser roster', () => { await expect(editDialog(page)).toHaveCount(0); await shot(page, 'participants-after-edit'); + // The state expectEdited asserts, for bracketSlots to wait for. + const editedSlot = (slots) => slots[danSlot]?.name === 'Daniel Evans'; const expectEdited = (slots, where) => { expect(slots[danSlot], `${where}: the same slot now holds Daniel Evans`).toMatchObject({ name: 'Daniel Evans', dojo: 'Seishinkan UK', num: before[danSlot].num, @@ -198,7 +203,7 @@ test.describe('organiser roster', () => { expect(Object.values(slots).map((s) => s.name), `${where}: Dan Evans is nowhere`).not.toContain('Dan Evans'); }; await openCompetitionTab(page, ids.edit, 'bracket'); - expectEdited(await bracketSlots(page), 'admin bracket'); + expectEdited(await bracketSlots(page, editedSlot), 'admin bracket'); await shot(page, 'bracket-after-edit'); await openShiaijo(page, 'B'); @@ -213,7 +218,7 @@ test.describe('organiser roster', () => { const phone = await spectator.newPage(); await openViewer(phone, ids.edit); await phone.getByRole('button', { name: 'Bracket' }).tap(); - expectEdited(await bracketSlots(phone), 'public viewer'); + expectEdited(await bracketSlots(phone, editedSlot), 'public viewer'); await shot(phone, 'viewer-after-edit', { fullPage: true }); } finally { await spectator.close(); @@ -232,7 +237,7 @@ test.describe('organiser roster', () => { await saveEdit(page, { dojo: 'Thames' }); await expect(editDialog(page)).toHaveCount(0); await openCompetitionTab(page, ids.edit, 'bracket'); - const after = await bracketSlots(page); + const after = await bracketSlots(page, (slots) => Object.values(slots).filter((s) => s.name === 'Akira Tanaka').length === 2); const akiras = Object.values(after).filter((s) => s.name === 'Akira Tanaka').map((s) => s.dojo).sort(); expect(akiras, 'two Akira Tanaka, told apart by dojo').toEqual(['Gyokusen', 'Thames']); expect(Object.values(after).map((s) => s.name), 'Finn Hale was renamed, not duplicated').not.toContain('Finn Hale'); @@ -254,6 +259,7 @@ test.describe('organiser roster', () => { await expect(page.getByRole('button', { name: 'Edit Zed Newman' })).toBeVisible(); await expect(page.getByRole('button', { name: 'Edit Hana Ito' })).toHaveCount(0); + const replacedSlot = (slots) => slots[slot]?.name === 'Zed Newman'; const expectReplaced = (slots, where) => { expect(slots[slot], `${where}: the newcomer holds the old slot, with its number`).toMatchObject({ name: 'Zed Newman', dojo: 'Shudokan', num: before[slot].num, @@ -266,10 +272,12 @@ test.describe('organiser roster', () => { }); }; await openCompetitionTab(page, ids.edit, 'bracket'); - expectReplaced(await bracketSlots(page), 'admin bracket'); + expectReplaced(await bracketSlots(page, replacedSlot), 'admin bracket'); await shot(page, 'bracket-after-replace'); await openShiaijo(page, 'B'); + // The queue is drawn (the newcomer is in it) before its names are read. + await expect(page.locator('.shiaijo__queue .numbered-name__text', { hasText: 'Zed Newman' }).first()).toBeVisible(); const consoleNames = (await page.locator('.shiaijo__queue .numbered-name__text').allInnerTexts()).map((n) => n.trim()); expect(consoleNames, 'court console shows Zed Newman').toContain('Zed Newman'); expect(consoleNames, 'court console: Hana Ito is nowhere').not.toContain('Hana Ito'); @@ -280,7 +288,7 @@ test.describe('organiser roster', () => { const phone = await spectator.newPage(); await openViewer(phone, ids.edit); await phone.getByRole('button', { name: 'Bracket' }).tap(); - expectReplaced(await bracketSlots(phone), 'public viewer'); + expectReplaced(await bracketSlots(phone, replacedSlot), 'public viewer'); } finally { await spectator.close(); } diff --git a/scripts/e2e/journeys/shared-shiaijo.spec.mjs b/scripts/e2e/journeys/shared-shiaijo.spec.mjs index a445e1180..23bcd47f8 100644 --- a/scripts/e2e/journeys/shared-shiaijo.spec.mjs +++ b/scripts/e2e/journeys/shared-shiaijo.spec.mjs @@ -51,7 +51,7 @@ import { } from '../fixtures/individual.mjs'; import * as T from '../fixtures/team.mjs'; import { - seedCompetitions, readScoresTab, bracketSlots, operatorDevice, publicDevice, + seedCompetitions, readScoresTab, readScoresRows, bracketSlots, operatorDevice, publicDevice, competitionPicker, officiatingName, pickCompetition, nudges, nudgeLines, courtState, runningOnCourt, playTeamMatch, liveBout, correctAcross, chooseCourt, } from '../fixtures/shared-court.mjs'; @@ -210,11 +210,17 @@ test.describe('shared-shiaijo', () => { // The wizard still tells the operator something about sharing a shiaijo, and // still lets them set the start time themselves. expect(draw.defaults.ST.sharedCourtHint, 'the wizard hint about sharing a shiaijo').toBeTruthy(); - await H.goto('/admin/create-competition'); - const startInput = H.locator('#create-start-time'); - await expect(startInput).toBeEditable(); - await startInput.fill('13:45'); - await expect(startInput).toHaveValue('13:45'); + // H is shared with the tests after this one: the wizard it opens (a typed + // start time in it) is left again whatever the checks find. + try { + await H.goto('/admin/create-competition'); + const startInput = H.locator('#create-start-time'); + await expect(startInput).toBeEditable(); + await startInput.fill('13:45'); + await expect(startInput).toHaveValue('13:45'); + } finally { + await H.goto('/admin'); + } }); // S1 the picker, S8 the slot times (P6), S7 the public queue before play. @@ -280,7 +286,9 @@ test.describe('shared-shiaijo', () => { await expect(H.getByRole('button', { name: 'Auto-schedule competition' })).toBeEnabled({ timeout: EXPECT_TIMEOUT_MS }); // The button is enabled again before it has visibly been busy, so the saved // times are what says the re-timing is done: ST's earliest slot is the restart. - await expect.poll(async () => (await readScoresTab(H, ids.ST)).map((m) => m.time).filter(Boolean).sort()[0]).toBe(ST_RESTART); + // One navigation; the poll reads the tab as the app follows the saved times. + await H.goto(`/admin/competition/${encodeURIComponent(ids.ST)}/scores`); + await expect.poll(async () => (await readScoresRows(H)).map((m) => m.time).filter(Boolean).sort()[0]).toBe(ST_RESTART); const siAfter = await readScoresTab(H, ids.SI); draw.st = await readScoresTab(H, ids.ST); draw.stKnockout = await bracketSlots(H, ids.ST); @@ -328,7 +336,10 @@ test.describe('shared-shiaijo', () => { await expect(promoted).toContainText(shiro); await expect(promoted).toContainText(aka); const nextLine = TV.locator('div:has(> span:text-is("NEXT"))'); - if (await nextLine.count()) for (const name of otherNames) await expect(nextLine).not.toContainText(name); + // The NEXT line is drawn only when a queued match is not already on the + // board, so its absence is allowed; the board has to have stopped drawing + // before it is taken to be absent. + if (await untilStable(() => nextLine.count())) for (const name of otherNames) await expect(nextLine).not.toContainText(name); // And the other competition appears nowhere else on the board either. for (const name of otherNames) await expect(tvBoard(TV)).not.toContainText(name); await P.goto('/schedule'); @@ -539,13 +550,18 @@ test.describe('shared-shiaijo', () => { } }; 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: EXPECT_TIMEOUT_MS }).toBe(true); - await expect.poll(() => writes.length).toBeGreaterThan(0); - C.off('response', onRes); + let dblStart; + try { + dblStart = await doubleTap(stCard.getByRole('button', { name: 'Start match' })); + await expect.poll(async () => { + const l = await liveOnConsole(); + return !!l && l.comp === ST; + }, { timeout: EXPECT_TIMEOUT_MS }).toBe(true); + await expect.poll(() => writes.length).toBeGreaterThan(0); + } finally { + // A failed poll must not leave the listener on the shared page. + 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, From 9fc0a9059ea0c2e485e20c64e4061eb1439a55bc Mon Sep 17 00:00:00 2001 From: Ricardo Oliveira Date: Wed, 14 Oct 2026 01:30:54 +0100 Subject: [PATCH 19/31] test(bc-e2rv): the bc-adna fixme navigates once and polls the Scores rows Co-Authored-By: Claude Opus 5.5 --- scripts/e2e/journeys/moved-match.spec.mjs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/scripts/e2e/journeys/moved-match.spec.mjs b/scripts/e2e/journeys/moved-match.spec.mjs index 13aaf7fcf..ac51b54e9 100644 --- a/scripts/e2e/journeys/moved-match.spec.mjs +++ b/scripts/e2e/journeys/moved-match.spec.mjs @@ -37,7 +37,7 @@ import { tapSize } from '../fixtures/pointer.mjs'; import { ROSTER_6, recordDecision, slotMarks } from '../fixtures/individual.mjs'; import * as T from '../fixtures/team.mjs'; import { - seedCompetitions, readScoresTab, operatorDevice, markPage, pageMark, + seedCompetitions, readScoresTab, readScoresRows, operatorDevice, markPage, pageMark, competitionPicker, officiatingName, pickCompetition, nudges, nudgeLines, courtState, upcomingRow, chooseCourt, runningOnCourt, playTeamMatch, liveBout, } from '../fixtures/shared-court.mjs'; @@ -460,7 +460,9 @@ test.describe('moved-match', () => { // Deliberate: no start may follow (no toast, nothing running), so the time is the evidence. 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'); + // One navigation; the poll reads the tab as the app follows the fusenpai. + await H.goto(`/admin/competition/${encodeURIComponent(ids.IND)}/scores`); + await expect.poll(async () => ((await readScoresRows(H)).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); } finally { H.off('response', onResponse); From ff0484be2e14692d5ea43980c52161a1e78f60d3 Mon Sep 17 00:00:00 2001 From: Ricardo Oliveira Date: Wed, 14 Oct 2026 02:47:23 +0100 Subject: [PATCH 20/31] test(bc-e2rv): name-list option taps wait out the bout list's bounce window A pick from a fighter name list stamps the team sheet's bout list, which ignores every click inside it for TAP_BOUNCE_MS; the J3 repair pick landed on that boundary and was dropped about one run in five with --workers=1, and the step's deliberate wrong pick was always dropped. tapNameOption paces option taps from the previous one, so the wrong pick now really lands and the repair always does. Co-Authored-By: Claude Opus 5.5 --- scripts/e2e/README.md | 6 ++++- scripts/e2e/fixtures/team.mjs | 24 +++++++++++++++++-- .../e2e/journeys/knockout-mixed-team.spec.mjs | 2 +- 3 files changed, 28 insertions(+), 4 deletions(-) diff --git a/scripts/e2e/README.md b/scripts/e2e/README.md index 3d8ddad86..833b154e1 100644 --- a/scripts/e2e/README.md +++ b/scripts/e2e/README.md @@ -294,7 +294,11 @@ through `armTwoTap` (tap, wait for the armed state, out the dwell): `armFinish` a bout, through `tapOnSide`, which keeps the time of the last tap on each side: `awardIppon` and `ipponOnRow` (the strike `boutIppon` makes on the team sheet and `awardBoutIppon` on the kachinuki sheet, which both wait for the board and -the autosave). A journey that taps such a thing by hand does the same. `doubleTap` is the one +the autosave). In a fighter name list, through `tapNameOption`, which keeps the +time of the last option tap: a pick stamps the team sheet's bout list, which +ignores every click inside it for that window, so `pickFromNameBox` and any +option tapped by hand go through it. 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. `pace.mjs` reads the app's window from `web-mobile/js/tap_guard.jsx` (`TAP_BOUNCE_MS`) rather than diff --git a/scripts/e2e/fixtures/team.mjs b/scripts/e2e/fixtures/team.mjs index bca97f6df..19d3e689f 100644 --- a/scripts/e2e/fixtures/team.mjs +++ b/scripts/e2e/fixtures/team.mjs @@ -18,7 +18,7 @@ // checks the write landed (fixtures/scoring.mjs finishLanded). import { expect } from '@playwright/test'; import { EXPECT_TIMEOUT_MS } from './devices.mjs'; -import { settled, tapOnSide } from './pace.mjs'; +import { dwell, settled, tapOnSide } from './pace.mjs'; import { finishMatch } from './scoring.mjs'; import { inlineEditor, upNextCard } from './shiaijo.mjs'; @@ -179,6 +179,26 @@ export async function openPanelFromUpNext(page) { await expect(panelSide(page, 1)).toBeVisible(); } +// page -> when the last name-list option was tapped. A pick from the list stamps +// the score sheet's bout list (stampTap(boutListTapRef), admin_scoring_team.jsx), +// and that list swallows every click inside it for TAP_BOUNCE_MS afterwards: the +// next option tap is a bounce of the pick's tap to the app, and a pick made that +// soon does nothing (the list stays open, no write is sent). A person's next pick +// is never that close, Playwright's is, so every option tap waits out the dwell +// since the previous one, timed from that tap (nothing in the page says the +// window has passed). +const optionTaps = new WeakMap(); + +// Tap one option of an open name list, at the operator's pace. The one owner of +// "tap a name-list option"; a journey that taps an option itself calls this. +export async function tapNameOption(option) { + const page = option.page(); + const last = optionTaps.get(page); + if (last !== undefined) await dwell(page, last); + await option.tap(); + optionTaps.set(page, Date.now()); +} + // Tap a LineupNameInput and choose the option containing `text`. Works on the // panel and on the score sheet row alike (both render LineupNameInput). export async function pickFromNameBox(input, text) { @@ -186,7 +206,7 @@ export async function pickFromNameBox(input, text) { const box = input.locator('xpath=ancestor::div[contains(concat(" ", normalize-space(@class), " "), " lineup-name ")][1]'); const option = box.locator('.pmf__option').filter({ hasText: text }).first(); await expect(option).toBeVisible(); - await option.tap(); + await tapNameOption(option); } // A name list left open (by the operator, or by a tap that landed on a name diff --git a/scripts/e2e/journeys/knockout-mixed-team.spec.mjs b/scripts/e2e/journeys/knockout-mixed-team.spec.mjs index 796ea3643..aff3d659f 100644 --- a/scripts/e2e/journeys/knockout-mixed-team.spec.mjs +++ b/scripts/e2e/journeys/knockout-mixed-team.spec.mjs @@ -319,7 +319,7 @@ test.describe('knockout-mixed-team', () => { const aokiIdx = texts.findIndex((t) => t.includes('Aoki')); const wrongOpt = opts.nth(Math.min(aokiIdx + 1, texts.length - 1)); const wrongText = (await wrongOpt.innerText()).trim(); - await wrongOpt.tap(); + await T.tapNameOption(wrongOpt); await rendered(page); const afterWrong = await box.inputValue(); const labelAfterWrong = (await T.rowMemberLabel(row1, 'shiro').innerText().catch(() => '')).trim(); From 8c543bed3fda0cfd39dea6c26f6f22260e69310a Mon Sep 17 00:00:00 2001 From: Ricardo Oliveira Date: Wed, 14 Oct 2026 08:22:03 +0100 Subject: [PATCH 21/31] test(bc-e2rv): the bc-adna fixme asserts its bead in full; README reflow The fixme now checks each part of bc-adna on its own (soft assertions, the network ones read in finally): Finish reads plain Finish; the Scores tab sends no start by itself (a start is a /score write carrying startOnly), after Finish or after a fusenpai; and no start is refused court_busy. Three README listing lines reflowed to 80 columns. Co-Authored-By: Claude Opus 5.5 --- scripts/e2e/README.md | 14 +++-- scripts/e2e/journeys/moved-match.spec.mjs | 74 +++++++++++++++-------- 2 files changed, 57 insertions(+), 31 deletions(-) diff --git a/scripts/e2e/README.md b/scripts/e2e/README.md index 833b154e1..61de30e05 100644 --- a/scripts/e2e/README.md +++ b/scripts/e2e/README.md @@ -39,7 +39,8 @@ browser, `--debug` opens the inspector, and a failing test leaves a trace ## Layout - package.json Playwright, pinned; package-lock.json is its lockfile + package.json Playwright, pinned; package-lock.json is its + lockfile playwright.config.mjs projects, timeouts, output fixtures/test.mjs the `test` every journey imports (server, baseURL, page) and the console policy (below) @@ -76,8 +77,8 @@ browser, `--debug` opens the inspector, and a failing test leaves a trace kachinuki sheet) fixtures/kachinuki.mjs the kachinuki sheet: bouts, Record bout, Encho, End match, the correction doors - fixtures/engi.mjs the engi (kata pairs) flag editor: flag counters, entering - flags, the two-tap Save result + fixtures/engi.mjs the engi (kata pairs) flag editor: flag counters, + entering flags, the two-tap Save result fixtures/held-write.mjs holding a running match's score write in flight, to observe a race with its echo (below) fixtures/shared-court.mjs two competitions on one shiaijo and a moved @@ -118,9 +119,10 @@ browser, `--debug` opens the inspector, and a failing test leaves a trace one pool; an edited or replaced competitor reaches the bracket, court console and public viewer; a duplicate name and dojo is refused. - organiser-admin file-mode organiser jobs: the results workbook, - folder import, including a row with no date, - logo and sponsor, forgotten-password reset. + organiser-admin file-mode organiser jobs: the results + workbook, folder import, including a row with + no date, logo and sponsor, forgotten-password + reset. organiser-locked locked mode: sign-in with the hashed password, no Forgot password, and /reset shows it is disabled. diff --git a/scripts/e2e/journeys/moved-match.spec.mjs b/scripts/e2e/journeys/moved-match.spec.mjs index ac51b54e9..1adab5361 100644 --- a/scripts/e2e/journeys/moved-match.spec.mjs +++ b/scripts/e2e/journeys/moved-match.spec.mjs @@ -400,29 +400,45 @@ test.describe('moved-match', () => { // 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 + // Scores tab, with other Individual matches waiting on A, the bead's fixed + // behaviour is three things, each asserted on its own (soft, so one failing + // does not hide the others, and the network ones sit in the finally so they + // are read whatever happened before): Finish reads plain Finish; the tab + // sends no /score start by itself (a start is a /score write whose request + // carries startOnly), after Finish or after a fusenpai, so nothing starts on + // A; and no start it sends is refused for the court's status (court_busy), + // whoever asked for it. The refusal half is read from + // the network log, because the console policy would otherwise let the toast + // of the bead's report pass. An operator's own Start match is logged apart + // from a start the tab made by itself. 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. The network log is read too: the Scores tab sends no /score - // start the server refuses with court_busy (the refusal the toast of the bead's - // report came from), which the console policy would otherwise let pass. + // fighting on B. test.fixme('bc-adna: the Scores tab never starts a match by itself', async () => { - const courtBusyStarts = []; + const scoreWrites = []; + const pending = []; + let operatorStarting = false; const onResponse = async (res) => { const req = res.request(); - if (req.method() !== 'PUT' || res.status() !== 409 || !/\/matches\/[^/]+\/score$/.test(new URL(res.url()).pathname)) return; - if (!/"status"\s*:\s*"running"/.test(req.postData() || '')) return; - if (/"error"\s*:\s*"court_busy"/.test(await res.text().catch(() => ''))) courtBusyStarts.push(res.url()); + if (req.method() !== 'PUT' || !/\/matches\/[^/]+\/score$/.test(new URL(res.url()).pathname)) return; + const sent = req.postData() || ''; + if (!/"status"\s*:\s*"running"/.test(sent)) return; + // A start is the write whose request carries startOnly (startPatch). + let isStart = false; + try { isStart = JSON.parse(sent).startOnly === true; } catch { /* not JSON: not a start */ } + const body = res.status() === 409 ? await res.text().catch(() => '') : ''; + scoreWrites.push({ + url: res.url(), + status: res.status(), + courtBusy: /"error"\s*:\s*"court_busy"/.test(body), + byOperator: operatorStarting, + isStart, + }); }; - H.on('response', onResponse); + const listener = (res) => { pending.push(onResponse(res)); }; + H.on('response', listener); try { const onB = (await runningOnCourt(H, 'B')).flatMap((m) => [m.shiro, m.aka]); const rows = await readScoresTab(H, ids.IND); @@ -438,20 +454,26 @@ test.describe('moved-match', () => { await H.locator('.score-edit-row').filter({ hasText: m.shiro }).filter({ hasText: m.aka }).first().getByRole('button', { name: /^Score$/ }).tap(); await expect(ed).toBeVisible(); }; + // The operator's own Start match, logged apart from a start by itself. const start = async () => { const btn = ed.getByRole('button', { name: 'Start match' }); await settled(btn); - await btn.tap(); - await expect(finishButton(H, EDITOR)).toBeVisible(); + operatorStarting = true; + try { + await btn.tap(); + await expect(finishButton(H, EDITOR)).toBeVisible(); + } finally { + operatorStarting = false; + } }; await open(first); if (startsHere) await start(); - await expect(finishButton(H, EDITOR)).toHaveText('Finish'); + await expect.soft(finishButton(H, EDITOR), 'the Scores tab editor offers plain Finish').toHaveText('Finish'); await awardIppon(H, 'shiro', 'M', EDITOR); await finishMatch(H, EDITOR); // Deliberate: nothing may start by itself, so the time is the evidence. await H.waitForTimeout(1500); - expect(await runningOnCourt(H, 'A'), 'nothing started on A after Finish').toHaveLength(0); + expect.soft(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); @@ -459,14 +481,16 @@ test.describe('moved-match', () => { await recordDecision(H, 'fusenpai', { side: 'aka', root: EDITOR }); // Deliberate: no start may follow (no toast, nothing running), so the time is the evidence. await H.waitForTimeout(2000); - await expect(H.locator('.toast').filter({ hasText: /is running/ })).toHaveCount(0); + await expect.soft(H.locator('.toast').filter({ hasText: /is running/ })).toHaveCount(0); // One navigation; the poll reads the tab as the app follows the fusenpai. await H.goto(`/admin/competition/${encodeURIComponent(ids.IND)}/scores`); await expect.poll(async () => ((await readScoresRows(H)).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); + expect.soft(await runningOnCourt(H, 'A'), 'nothing started on A after the fusenpai').toHaveLength(0); } finally { - H.off('response', onResponse); + await Promise.all(pending); + H.off('response', listener); + expect.soft(scoreWrites.filter((w) => w.courtBusy), 'no start sent from the Scores tab was refused for the court').toEqual([]); + expect.soft(scoreWrites.filter((w) => w.isStart && !w.byOperator), 'the Scores tab sent no start by itself').toEqual([]); } - expect(courtBusyStarts, 'no start sent from the Scores tab was refused for the court').toEqual([]); }); }); From eddedb01fc8af9ad3939dd7ea96eb4ed4f43fb73 Mon Sep 17 00:00:00 2001 From: Ricardo Oliveira Date: Wed, 14 Oct 2026 08:34:01 +0100 Subject: [PATCH 22/31] test(bc-e2tb): pools-tiebreak journey: minimum pool size and a tie-break bout Individual Pools + Knockout, seven entrants, pool size a minimum of 3: the draw makes pools of 4 and 3 (a maximum of 3 would make 3, 2, 2). One pool is scripted so two competitors end level on W, L, D, PW and PL for the advancing 2nd place: the app adds the tie-break bout (Pool X-TB-N), shown as the only bout left to score on the court console and Scores tab, the table marks both tied until it is fought, the knockout holds both places, then the winner (picked from the one the table badged lower) ranks above the loser and is seated, on admin and public viewer. The other pool is split on points lost alone, with no tie-break bout. New helpers: playerRowFigures (pools.mjs), markDraw (scoring.mjs), playScriptedBout and ROSTER_7 (individual.mjs). Co-Authored-By: Claude Opus 5.5 --- scripts/e2e/fixtures/individual.mjs | 31 +- scripts/e2e/fixtures/pools.mjs | 21 ++ scripts/e2e/fixtures/scoring.mjs | 9 + scripts/e2e/journeys/pools-tiebreak.spec.mjs | 330 +++++++++++++++++++ 4 files changed, 390 insertions(+), 1 deletion(-) create mode 100644 scripts/e2e/journeys/pools-tiebreak.spec.mjs diff --git a/scripts/e2e/fixtures/individual.mjs b/scripts/e2e/fixtures/individual.mjs index f68703eb0..05b2a234d 100644 --- a/scripts/e2e/fixtures/individual.mjs +++ b/scripts/e2e/fixtures/individual.mjs @@ -6,7 +6,7 @@ // 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 { INLINE_EDITOR, awardIppon, finishMatch } from './scoring.mjs'; +import { INLINE_EDITOR, awardIppon, finishMatch, markDraw } from './scoring.mjs'; import { UP_NEXT, WAITING_ROWS, completedRowFor, courtDone, courtIdle, inlineEditor, upNextCard, } from './shiaijo.mjs'; @@ -33,6 +33,15 @@ export const ROSTER_6B = [ ['Bo Grant', 'Gyokusen'], ['Cy Novak', 'Thames'], ['Di Weller', 'Musashi'], ['Ed Frost', 'Seishinkan'], ['Flo Adler', 'Mumeishi'], ['Gil Varga', 'Hizen'], ]; +// Seven for the minimum-pool-size journey (pools-tiebreak): three pools of 3 at +// a maximum of 3, but two pools (4 and 3) at a minimum of 3. Seven dojos, so the +// draw has no dojo to keep apart and the pools are whatever the minimum rule cuts. +export const ROSTER_7 = [ + ['Hiro Sasaki', 'Kyoto Kai'], ['Ines Ferrer', 'Osaka Kan'], ['Joan Mercer', 'Nagoya Dojo'], + ['Kei Takagi', 'Sendai Kan'], ['Lars Nilsen', 'Sapporo Kai'], ['Mina Abara', 'Fukuoka Dojo'], + ['Noel Garnier', 'Kobe Kan'], +]; + // Small rosters for the ': ...' tests, which seed their own // competitions so each runs alone. const ROSTER_FIRST_NAMES = ['Aki', 'Bea', 'Cal', 'Dee', 'Eli', 'Fay']; @@ -165,6 +174,26 @@ export async function playRunningBout(page, { waza = 'M' } = {}) { return { ...pair, winner: side }; } +// Play the running bout (or start the Up next one) as a script says, and Finish. +// `script({ shiro, aka })` returns what to record for that pairing, or throws for +// a pairing the journey did not plan: +// { draw: true } Mark draw (hikiwake, no points) +// { shiro: ['M'], aka: ['K', 'D'] } the waza each side is awarded, in order +// Marks level on both sides finish as hikiwake too (the board reads equal totals +// as a draw). Returns the pairing and what was recorded. +export async function playScriptedBout(page, script) { + const pair = await ensureRunning(page); + if (!pair) throw new Error('playScriptedBout: nothing running or startable on this court'); + const plan = script(pair); + if (plan.draw) await markDraw(page); + for (const side of ['shiro', 'aka']) { + for (const waza of plan[side] || []) await awardIppon(page, side, waza); + } + await finishMatch(page); + await expect(completedRowFor(page, pair)).toBeVisible(); + return { ...pair, plan }; +} + // The bout the console's inline editor is running on the individual board, as // its pairing, or null when no bout runs there. With `quick` (the poll for the // bout to be gone) the editor can leave between the two reads, which is a bout diff --git a/scripts/e2e/fixtures/pools.mjs b/scripts/e2e/fixtures/pools.mjs index 591cbcc6d..37d7e57eb 100644 --- a/scripts/e2e/fixtures/pools.mjs +++ b/scripts/e2e/fixtures/pools.mjs @@ -45,6 +45,27 @@ export async function rowFigures(page, team) { }; } +// An INDIVIDUAL pool's row carries five figures, not the team table's eight: +// W L D (fights won, lost, drawn: hikiwake) and PW PL (points won, lost: ippons +// scored and conceded). Beside them it reads the rank badge, whether the row is +// marked as advancing, and whether it is marked tied (the amber row: a tie at a +// place still to be broken; a group a tie-break bout already ordered is not). +export const INDIVIDUAL_FIGURES = ['W', 'L', 'D', 'PW', 'PL']; + +export async function playerRowFigures(page, name) { + const row = teamRow(page, name); + await expect(row).toHaveCount(1); + const cells = (await row.locator('td.num').allInnerTexts()).map((t) => t.trim()); + const badge = row.locator('.rank-badge'); + const classes = ((await row.getAttribute('class')) || '').split(/\s+/); + return { + ...Object.fromEntries(INDIVIDUAL_FIGURES.map((f, i) => [f, cells[i] === undefined ? NaN : Number(cells[i])])), + rank: (await badge.count()) ? (await badge.first().innerText()).trim().toLowerCase() : '', + advancing: classes.includes('advancing'), + tied: classes.includes('pool__row--tied'), + }; +} + // The names of the teams in a pool, in the table's (draw) order. export async function poolTeams(block) { return (await block.locator('table.pool__table tbody .pool__player-name').allInnerTexts()) diff --git a/scripts/e2e/fixtures/scoring.mjs b/scripts/e2e/fixtures/scoring.mjs index 3b9bd6edc..6d16aa839 100644 --- a/scripts/e2e/fixtures/scoring.mjs +++ b/scripts/e2e/fixtures/scoring.mjs @@ -101,6 +101,15 @@ export async function awardIppon(page, side, waza = 'M', root = INLINE_EDITOR) { await expect(syncState(page, root)).toHaveText('Synced'); } +// The hikiwake toggle of the individual board ("Mark draw", "Cancel draw" while +// on). It is offered only while neither side holds a point. Returns once the +// board shows the draw. +export async function markDraw(page, root = INLINE_EDITOR) { + const toggle = page.locator(root).first().getByTestId('scoring-modal-mark-draw'); + await toggle.tap(); + await expect(toggle).toHaveText('Cancel draw'); +} + // `root` is an editor's selector or its Locator (the team sheet's callers hold one). const within = (page, root) => (typeof root === 'string' ? page.locator(root) : root); diff --git a/scripts/e2e/journeys/pools-tiebreak.spec.mjs b/scripts/e2e/journeys/pools-tiebreak.spec.mjs new file mode 100644 index 000000000..b46211eef --- /dev/null +++ b/scripts/e2e/journeys/pools-tiebreak.spec.mjs @@ -0,0 +1,330 @@ +// Pools + knockout with a MINIMUM pool size, and a pool tie that needs a +// tie-break bout (bc-e2tb). +// +// Fixture: an individual "Pools + Knockout" competition, seven entrants, pool +// size a MINIMUM of 3, two qualifiers per pool, one shiaijo. The minimum rule +// divides the entrant count down (docs/user-guide/organisers/pool-draw.md: +// "every pool reaches the minimum and no extra pool opens for the remainder; the +// players left over join existing pools, one each"), so seven entrants make two +// pools, of 4 and of 3. A MAXIMUM of 3 would open three pools (3, 2, 2): seven +// is the smallest roster on which the two modes differ. +// +// The draw decides who is in which pool and who meets whom, so the bouts are +// scripted by the ROLE a competitor plays (their place in the pool's draw +// order), never by name, and a bout's script does not depend on which side the +// draw gives it: +// pool of 3, players A B C A beats B and C 1-0 (A is first, W2). +// B and C fight a 1-1 hikiwake. B and C end level on +// EVERY criterion (W0 L1 D1, PW1, PL2) for the 2nd +// place, which advances: the engine adds a +// tie-break bout between them (internal/engine/ +// tiebreaker.go, id "Pool X-TB-N"). +// pool of 4, players P Q R S P beats Q, R and S (R by two points), Q and R beat +// S, and Q and R draw (Mark draw, no points). Q and R +// end level on W1 L1 D1 and PW1 and differ only on +// points lost (Q 1, R 2): the LATER criterion +// orders them, Q above R, so NO tie-break bout is +// added (CLAUDE.md, Key Algorithms: wins, losses, +// hikiwake, points scored, points lost). +// Expected figures and orders are tallied from these scripts, never read back +// from the app. +// +// Asserted: the pool sizes the minimum rule makes; the tie-break bout appearing +// on the court console and the Scores tab as the only bout left to score, with +// no "Match n of m" label, while the pool table shows B and C level on every +// figure and marked tied; no tie-break bout for the pool settled on points +// lost; after the tie-break bout, the winner ranked above the loser and seated +// in the knockout (the loser out), on the admin pages and the public viewer. +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 { completedRowFor, openShiaijo, sides, upNextCard } from '../fixtures/shiaijo.mjs'; +import { EXPECT_TIMEOUT_MS, PUBLIC_DEVICE } from '../fixtures/devices.mjs'; +import { INDIVIDUAL_FIGURES, openAdminPools, openViewerPools, playerRowFigures, poolBlocks, poolTeams } from '../fixtures/pools.mjs'; +import { ROSTER_7, editorSides, ensureRunning, inlineRunning, playScriptedBout } from '../fixtures/individual.mjs'; +import { awardIppon, finishMatch, nextMatchWrite, writeLanded } from '../fixtures/scoring.mjs'; +import { scoreRows } from '../fixtures/scores.mjs'; +import { bracketSlots, openCompetitionTab } from '../fixtures/roster.mjs'; +import { openViewer } from '../fixtures/public.mjs'; + +const JOURNEY = 'pools-tiebreak'; +const MIN_POOL = 3; +const POOL_WINNERS = 2; +const NAMES = ROSTER_7.map(([name]) => name); +const ORDINAL = ['1st', '2nd', '3rd', '4th']; +// A pool bout reads "Match n of m"; a tie-break bout is the bare pairing. +const POOL_MATCH_LABEL = /Match \d+ of \d+/i; + +// The pool sizes the minimum rule makes: floor(n / min) pools, the remainder +// joining existing pools one each. +function minimumRuleSizes(entrants, min) { + const pools = Math.floor(entrants / min); + const leftover = entrants - pools * min; + return Array.from({ length: pools }, (_, i) => min + (i < leftover ? 1 : 0)).sort((a, b) => b - a); +} + +const zero = () => ({ W: 0, L: 0, D: 0, PW: 0, PL: 0 }); +// The pool ranking: wins, then fewer losses, then more hikiwake, then more +// points scored, then fewer points lost. 0 means level on every criterion. +const order = (a, b) => (b.W - a.W) || (a.L - b.L) || (b.D - a.D) || (b.PW - a.PW) || (a.PL - b.PL); + +test.describe.configure({ mode: 'serial' }); + +test.describe('pools: minimum pool size and a tie-break bout', () => { + let shot; + let compId; + const audit = auditLog(JOURNEY); + const { record } = audit; + + // Draw order of each pool as the draw made it, the pool of 4 first. + let big; + let small; + // The scripted bouts: sorted-pair key -> { marks: { name: [waza] } } | { draw }. + const plan = new Map(); + const tally = Object.fromEntries(NAMES.map((n) => [n, zero()])); + // Set once the tie-break bout is on the console. + let tb; + // Who won the tie-break bout, who lost it. + let tbWinner; + let tbLoser; + // The rank badge each of the level pair carried before the bout. + let rankBefore; + + test.beforeAll(() => { + shot = journeyShots(JOURNEY); + }); + + test.afterAll(() => audit.flush()); + + const key = (a, b) => [a, b].sort().join(' | '); + + // Script one bout and add it to the expected tally. + function script(a, b, entry) { + plan.set(key(a, b), entry); + if (entry.draw) { + tally[a].D += 1; + tally[b].D += 1; + return; + } + const pa = (entry.marks[a] || []).length; + const pb = (entry.marks[b] || []).length; + tally[a].PW += pa; + tally[a].PL += pb; + tally[b].PW += pb; + tally[b].PL += pa; + if (pa === pb) { + tally[a].D += 1; + tally[b].D += 1; + } else { + const [win, lose] = pa > pb ? [a, b] : [b, a]; + tally[win].W += 1; + tally[lose].L += 1; + } + } + + const planFor = ({ shiro, aka }) => { + const entry = plan.get(key(shiro, aka)); + if (!entry) throw new Error(`no scripted result for ${shiro} v ${aka}`); + return entry.draw ? { draw: true } : { shiro: entry.marks[shiro] || [], aka: entry.marks[aka] || [] }; + }; + + // The competitor the tie-break bout is between: the pairing on the console, + // whether the bout waits as Up next or already runs in the editor. + async function tieBreakPairing(page) { + await openShiaijo(page, 'A'); + let pair = null; + await expect.poll(async () => { + pair = (await inlineRunning(page)) + ? await editorSides(page).catch(() => null) + : await sides(upNextCard(page)).catch(() => null); + return pair && [pair.shiro, pair.aka].sort(); + }, { message: 'the court holds the tie-break bout', timeout: EXPECT_TIMEOUT_MS }).toEqual([tb.b, tb.c].sort()); + return pair; + } + + async function expectTable(page, where, names, want) { + for (const name of names) { + await expect.poll(() => playerRowFigures(page, name), { message: `${where}: ${name}` }) + .toMatchObject(want(name)); + } + } + + test('seed: seven entrants, pool size a minimum of 3, the draw makes a pool of 4 and a pool of 3', async ({ page }) => { + await createTournament(page, { name: 'Pools Tiebreak Cup', courts: 1 }); + compId = await createCompetition(page, { + name: 'Minimum Pools', kind: 'individual', format: 'mixed', poolSizeMode: 'minimum', poolSize: MIN_POOL, + poolWinners: POOL_WINNERS, courts: ['A'], + }); + await pasteRoster(page, compId, ROSTER_7); + await generateDraw(page, compId); + await startCompetition(page, compId); + + await openAdminPools(page, compId); + const want = minimumRuleSizes(NAMES.length, MIN_POOL); + expect(want, 'the rule makes two pools, 4 and 3 (a maximum of 3 would make 3, 2 and 2)').toEqual([4, 3]); + await expect(poolBlocks(page)).toHaveCount(want.length); + const read = []; + for (let i = 0; i < want.length; i += 1) { + const names = await poolTeams(poolBlocks(page).nth(i)); + read.push(names.map((t) => NAMES.find((n) => t.includes(n)))); + } + const pools = read.sort((a, b) => b.length - a.length); + expect(pools.map((p) => p.length), 'the draw cut the pools the minimum rule describes').toEqual(want); + expect(pools.flat().sort(), 'every entrant is in exactly one pool').toEqual([...NAMES].sort()); + [big, small] = pools; + record({ journey: JOURNEY, step: 'draw', action: 'pool sizes', rule: `floor(7 / ${MIN_POOL}) pools, leftover joins one each`, pools, + screenshot: await shot(page, 'pools-drawn') }); + + // The scripts, by role (draw order within the pool). + const [A, B, C] = small; + const [P, Q, R, S] = big; + script(A, B, { marks: { [A]: ['M'] } }); + script(A, C, { marks: { [A]: ['M'] } }); + script(B, C, { marks: { [B]: ['M'], [C]: ['M'] } }); + script(P, Q, { marks: { [P]: ['M'] } }); + script(P, R, { marks: { [P]: ['M', 'K'] } }); + script(P, S, { marks: { [P]: ['M'] } }); + script(Q, S, { marks: { [Q]: ['M'] } }); + script(R, S, { marks: { [R]: ['M'] } }); + script(Q, R, { draw: true }); + tb = { a: A, b: B, c: C }; + // Level on every criterion, by construction. + expect(order(tally[B], tally[C]), 'B and C are level on every criterion').toBe(0); + // Q and R are level on W, L, D, PW and apart on PL only. + expect(['W', 'L', 'D', 'PW'].map((f) => tally[Q][f]), 'Q and R agree up to points scored') + .toEqual(['W', 'L', 'D', 'PW'].map((f) => tally[R][f])); + expect(tally[Q].PL < tally[R].PL, 'points lost alone orders Q above R').toBe(true); + }); + + test('play the nine regular bouts on the court console', async ({ page }) => { + await login(page); + await openShiaijo(page, 'A'); + for (let n = 1; n <= 9; n += 1) { + const played = await test.step(`regular bout ${n}`, () => playScriptedBout(page, planFor)); + record({ journey: JOURNEY, step: `bout ${n}`, action: 'Finish', shiro: played.shiro, aka: played.aka, plan: played.plan }); + } + await shot(page, 'regular-bouts-done'); + }); + + test('the level pair: a tie-break bout is added, and the table shows them level until it is fought', async ({ page }) => { + await login(page); + const pair = await tieBreakPairing(page); + record({ journey: JOURNEY, step: 'tie-break', action: 'on the court console', pair, screenshot: await shot(page, 'tie-break-on-console') }); + const card = (await inlineRunning(page)) ? page.locator('.editor-modal__eyebrow').first() : upNextCard(page); + expect((await card.innerText()), 'a tie-break bout carries no "Match n of m"').not.toMatch(POOL_MATCH_LABEL); + + // The Scores tab: it is the one bout left to score. + await openCompetitionTab(page, compId, 'scores'); + await expect(scoreRows(page).first()).toBeVisible(); + const toScore = scoreRows(page).filter({ has: page.getByRole('button', { name: /^Score$/ }) }); + await expect(toScore, 'the tie-break bout is the only bout left to score').toHaveCount(1); + await expect(toScore.first()).toContainText(tb.b); + await expect(toScore.first()).toContainText(tb.c); + expect(await toScore.first().innerText()).not.toMatch(POOL_MATCH_LABEL); + await shot(page, 'scores-tab-tie-break'); + + // The pool table: B and C level on every figure and marked tied; A first. + const level = (name) => ({ ...tally[name], tied: true }); + await openAdminPools(page, compId); + await expectTable(page, 'admin Pools tab', [tb.b, tb.c], level); + const rows = { b: await playerRowFigures(page, tb.b), c: await playerRowFigures(page, tb.c) }; + // The badges the level pair carries until the bout: the table lists them in + // draw order and badges the one drawn first higher, since the figures say + // nothing. The tie-break winner is picked from the one badged LOWER, so the + // bout visibly changes the order. + rankBefore = { [tb.b]: rows.b.rank, [tb.c]: rows.c.rank }; + record({ journey: JOURNEY, step: 'tie-break', action: 'table before the bout', columns: INDIVIDUAL_FIGURES, rows, screenshot: await shot(page, 'admin-pools-level', { fullPage: true }) }); + await expectTable(page, 'admin Pools tab', [tb.a], (n) => ({ ...tally[n], rank: '1st', advancing: true, tied: false })); + + // The pool of 4: Q above R on points lost alone, no tie, no bout for them. + const [P, Q, R, S] = big; + const ranked = [P, Q, R, S]; + expect([...big].sort((x, y) => order(tally[x], tally[y])), 'the tally orders P, Q, R, S').toEqual(ranked); + await expectTable(page, 'admin Pools tab', big, (n) => ({ + ...tally[n], rank: ORDINAL[ranked.indexOf(n)], advancing: ranked.indexOf(n) < POOL_WINNERS, tied: false, + })); + await shot(page, 'admin-pools-points-lost-order', { fullPage: true }); + + // The bracket: the tied place is not seated yet (it waits for the tie-break). + await openCompetitionTab(page, compId, 'bracket'); + const seated = Object.values(await bracketSlots(page)).map((s) => s.name); + record({ journey: JOURNEY, step: 'tie-break', action: 'bracket before the bout', seated, screenshot: await shot(page, 'admin-bracket-before') }); + expect(seated, 'the pool of 4 is settled: its two qualifiers are seated').toEqual(expect.arrayContaining([ranked[0], ranked[1]])); + for (const name of [tb.b, tb.c]) { + expect(seated, `${name} is not seated while the tie is open`).not.toContain(name); + } + }); + + test('score the tie-break bout: the winner ranks above the loser', async ({ page }) => { + await login(page); + const pair = await tieBreakPairing(page); + // The bout starts from its Up next card unless a Finish + Start Next already did. + const running = await ensureRunning(page); + expect(running, 'the tie-break bout runs on the court').toEqual(pair); + // The one the table badged lower wins, one men (aka when it badged both alike). + const rankIndex = (name) => ORDINAL.indexOf(rankBefore[name]); + const side = rankIndex(pair.shiro) > rankIndex(pair.aka) ? 'shiro' : 'aka'; + tbWinner = pair[side]; + tbLoser = pair[side === 'shiro' ? 'aka' : 'shiro']; + const wrote = nextMatchWrite(page); + await awardIppon(page, side, 'M'); + const res = await writeLanded(wrote, 'the tie-break bout\'s first mark'); + const id = decodeURIComponent(new URL(res.url()).pathname.split('/matches/')[1].replace(/\/score$/, '')); + expect(id, 'the bout is the pool\'s supplementary bout').toMatch(/^Pool [A-Z]-TB-\d+$/); + await shot(page, 'tie-break-scored'); + await finishMatch(page); + await expect(completedRowFor(page, pair)).toBeVisible(); + record({ journey: JOURNEY, step: 'tie-break', action: 'Finish', id, winner: tbWinner, loser: tbLoser, + screenshot: await shot(page, 'tie-break-finished') }); + + await openAdminPools(page, compId); + await expectOrdered(page, 'admin Pools tab'); + await shot(page, 'admin-pools-after', { fullPage: true }); + }); + + // After the tie-break bout: the pool of 3 is A, the winner, the loser; the + // level pair is no longer marked tied; the pool of 4 is unchanged. + async function expectOrdered(page, where) { + const [A] = [tb.a]; + const first = [A, tbWinner, tbLoser]; + await expectTable(page, where, first, (n) => ({ + rank: ORDINAL[first.indexOf(n)], advancing: first.indexOf(n) < POOL_WINNERS, tied: false, + })); + const [P, Q, R, S] = big; + const ranked = [P, Q, R, S]; + await expectTable(page, where, ranked, (n) => ({ + ...tally[n], rank: ORDINAL[ranked.indexOf(n)], advancing: ranked.indexOf(n) < POOL_WINNERS, tied: false, + })); + record({ journey: JOURNEY, step: 'tie-break', action: `table after the bout (${where})`, + rows: Object.fromEntries(await Promise.all(first.map(async (n) => [n, await playerRowFigures(page, n)]))) }); + } + + test('the winner, not the loser, is seated in the knockout: admin and public viewer', async ({ page, browser, baseURL }) => { + await login(page); + const qualifiers = [big[0], big[1], tb.a, tbWinner].sort(); + const seatedIn = async (p) => Object.values(await bracketSlots(p, (slots) => Object.values(slots).length === 4)) + .map((s) => s.name).sort(); + await openCompetitionTab(page, compId, 'bracket'); + expect(await seatedIn(page), 'the admin bracket seats the four qualifiers').toEqual(qualifiers); + await shot(page, 'admin-bracket-after'); + + const spectator = await browser.newContext({ ...PUBLIC_DEVICE, baseURL }); + try { + const phone = await spectator.newPage(); + await openViewerPools(phone, compId); + await expectOrdered(phone, 'public viewer Pools tab'); + await shot(phone, 'viewer-pools-after', { fullPage: true }); + await openViewer(phone, compId); + await phone.getByRole('button', { name: 'Bracket' }).tap(); + expect(await seatedIn(phone), 'the public bracket seats the same four').toEqual(qualifiers); + await shot(phone, 'viewer-bracket-after'); + } finally { + await spectator.close(); + } + expect(qualifiers, 'the tie-break loser is out').not.toContain(tbLoser); + record({ journey: JOURNEY, step: 'knockout', action: 'seated', qualifiers, out: [tbLoser, big[2], big[3]] }); + }); +}); From 7d403605af8101285aec9a5c11acfc714e3bfed3 Mon Sep 17 00:00:00 2001 From: Ricardo Oliveira Date: Wed, 14 Oct 2026 08:37:38 +0100 Subject: [PATCH 23/31] test(bc-e2pb): spectator journey on the public (phone) project The first journey to run on the public project: an officiated tournament seeded and scored from a second (operator) context through the interface, two individual competitions with prefixes K (12 competitors) and M (6). The phone walks the home, a competition page's tabs and the schedule; checks the bc-nsrc number rule on the watch picker, the schedule's filter and its text filter ('k' lists the K draw, 'k1' is K1 alone, bare digits find nobody, names and dojos by substring); follows a competitor and sees the watchlist hero and the match card follow the court without a reload; and opens the match card from the Home NOW list, the schedule, the Pools tab and the Overview while the operator keeps scoring. Co-Authored-By: Claude Opus 5.5 --- scripts/e2e/fixtures/public.mjs | 105 +++++ .../e2e/journeys/spectator.public.spec.mjs | 410 ++++++++++++++++++ 2 files changed, 515 insertions(+) create mode 100644 scripts/e2e/journeys/spectator.public.spec.mjs diff --git a/scripts/e2e/fixtures/public.mjs b/scripts/e2e/fixtures/public.mjs index 955d1a41f..dc1516a01 100644 --- a/scripts/e2e/fixtures/public.mjs +++ b/scripts/e2e/fixtures/public.mjs @@ -24,3 +24,108 @@ export async function openTvBoard(page, court) { await page.goto(`/display?court=${encodeURIComponent(court)}`); await expect(tvBoard(page)).toContainText(`SHIAIJO ${court}`); } + +// ------------------------------------------------- search, watchlist, cards -- +// +// The home's watchlist picker (viewer_watchlist.jsx WatchPicker) and the +// schedule page's filter (viewer_schedule.jsx PlayerMultiFilter, with the text +// chip that filters the rows) both apply the competitor-number rule of +// web-mobile/js/competitor_search.jsx (bc-nsrc): the whole number, or the +// competition's prefix. They share the `.pmf` markup. + +// The home's picker box, whether it reads "Add a name, dojo or number to +// watch…" (empty watchlist) or "Add another name, dojo or number…". +export const watchInput = (page) => page.locator('.pmf__input[aria-label^="Add a"]'); +// The schedule page's filter box. +export const scheduleFilterInput = (page) => page.getByLabel('Filter by player, team, dojo or number', { exact: true }); + +// A competitor option of an open picker: not a dojo row (WatchPicker) and not +// the "Match "x" in any name, dojo or number" row (PlayerMultiFilter). +const PLAYER_OPTION = '.pmf__dropdown .pmf__option:not(.pmf__option--dojo):not(.pmf__option--text)'; +export const NATURAL = (a, b) => a.localeCompare(b, undefined, { numeric: true }); + +// What the open picker lists, competitors only: { numbers, names }, each sorted +// (numbers naturally, so K2 precedes K10). WatchPicker draws a number chip +// (`.num-prefix`) and the name in `.numbered-name__text`; PlayerMultiFilter +// writes "K1 · Akira Tanaka" in one string. +export async function pickerEntries(page) { + const rows = await pickerPairs(page); + return { + numbers: rows.map((r) => r.number).filter(Boolean).sort(NATURAL), + names: rows.map((r) => r.name).sort(NATURAL), + }; +} + +// The same options as [{ number, name }], in the order they are listed. +export async function pickerPairs(page) { + return page.locator(PLAYER_OPTION).evaluateAll((els) => els.map((el) => { + const chip = el.querySelector('.num-prefix'); + const text = el.querySelector('.numbered-name__text'); + if (text) return { number: chip ? chip.textContent.trim() : '', name: text.textContent.trim() }; + const joined = el.querySelector('.pmf__opt-name').textContent.trim(); + const m = /^(\S+) · (.+)$/.exec(joined); + return m ? { number: m[1], name: m[2] } : { number: '', name: joined }; + })); +} + +// The competitor options of an open picker, to tap one. +export const pickerOption = (page, name) => page.locator(PLAYER_OPTION).filter({ hasText: name }); + +// K1..K: the numbers a draw with this prefix and `n` competitors hands out. +export const numbersOf = (prefix, n) => Array.from({ length: n }, (_, i) => `${prefix}${i + 1}`).sort(NATURAL); + +// The schedule page's own count line ("18 matches of 18") and its rows. +export const scheduleCount = (page) => page.getByText(/^\d+ match(es)? of \d+$/); +export const scheduleRows = (page) => page.locator('button.tw-match'); +// A schedule row naming both competitors. +export const scheduleRow = (page, { shiro, aka }) => scheduleRows(page).filter({ hasText: shiro }).filter({ hasText: aka }); + +// Type a query in the schedule's filter and press Enter: it becomes a text chip +// that filters the match rows by name, dojo or number (matchMentions). Returns +// once the chip shows; clearScheduleText takes it off again. +export async function applyScheduleText(page, text) { + const box = scheduleFilterInput(page); + await box.fill(text); + await box.press('Enter'); + await expect(page.locator('.pmf__chip--text')).toContainText(`"${text}"`); +} +export async function clearScheduleText(page) { + await page.locator('.pmf__chip--text').getByRole('button', { name: 'Remove' }).tap(); + await expect(page.locator('.pmf__chip--text')).toHaveCount(0); +} + +// The watchlist hero (WatchHeroCard) and the button inside it that opens the +// match card. +export const watchHero = (page) => page.getByTestId('watch-hero'); +export const watchHeroMatch = (page) => watchHero(page).locator('.wl-hero__sides--btn'); + +// A competition page's tab (Overview, Pools, Bracket). +export const competitionTab = (page, label) => page.locator('.viewer__tab', { hasText: new RegExp(`^${label}$`) }); + +// The doors a spectator opens a match card through, besides the schedule's own +// rows: Home's NOW list, a pool's match list, and an Up next row (Home's or a +// competition's Overview). Where one list holds several matches the row is +// named by both competitors. +export const homeNowRow = (page) => page.locator('.hero-running .vsched-item'); +export const poolRow = (page, { shiro, aka }) => page.locator('button.pool-match-numbered-row').filter({ hasText: shiro }).filter({ hasText: aka }); +export const upNextRow = (page, { shiro, aka }) => page.locator('button.vsched-item').filter({ hasText: shiro }).filter({ hasText: aka }); + +// The Overview's inline cards (officiated tournaments, where no dialog opens +// there): the one an Up next row expanded (it has a Close button) and the ON NOW +// card of the running match (it has none). +export const expandedCard = (page) => page.locator('.match-detail-card').filter({ has: page.getByRole('button', { name: 'Close' }) }); +export const nowCard = (page) => page.locator('.match-detail-card').filter({ hasNot: page.getByRole('button', { name: 'Close' }) }); + +// The match card (MatchViewerModal): a dialog named "Match: vs ". +export const matchCard = (page) => page.getByRole('dialog', { name: /^Match:/ }); + +// The ippon letters the card shows per side, sorted ("DM", "K"): a side has two +// slots, each holding a letter once scored and nothing before. +export async function cardMarks(card) { + const marks = card.getByTestId('sub-marks'); + const letters = async (loc) => (await loc.allTextContents()).map((t) => t.trim()).filter(Boolean).sort().join(''); + return { + shiro: await letters(marks.locator('.msb-slots:not(.msb-slots--aka) .msb-slot')), + aka: await letters(marks.locator('.msb-slots--aka .msb-slot')), + }; +} diff --git a/scripts/e2e/journeys/spectator.public.spec.mjs b/scripts/e2e/journeys/spectator.public.spec.mjs new file mode 100644 index 000000000..548828261 --- /dev/null +++ b/scripts/e2e/journeys/spectator.public.spec.mjs @@ -0,0 +1,410 @@ +// The spectator's journey, on the PUBLIC project (a 390x844 phone, no sign-in): +// the public home, a competition page and its tabs, the schedule, competitor +// search by number, name and dojo (the bc-nsrc rule), the watchlist, and the +// match card opened from every door while an operator scores. +// +// The test's own `page` is the phone. The tournament is seeded and scored from +// a SECOND context opened with the operator's iPad (OPERATOR_DEVICE), through +// the interface, so the tournament is OFFICIATED: a spectator reads, never +// scores (self-run public scoring is a different journey, mp-yqxn.5). +// +// Two individual competitions with number prefixes chosen so the search rule +// can be seen: +// K Kendo Open 12 competitors, K1..K12 (so K1 and K12 both exist), court A +// M Women Open 6 competitors, M1..M6, court B; no name or dojo of the M +// draw holds a "k" or a digit, so "k" and "1" can only find a K competitor +// by number, or nobody at all. +// Pools of 3 (two through), so every match has both sides at once. Court A +// plays Pool A first, three bouts among the same three competitors. The tests +// read each pairing off the operator's court console and each number off the +// public pages, and assume neither: only the prefixes and the counts are fixed. +import { test, expect } from '../fixtures/test.mjs'; +import { OPERATOR_DEVICE } from '../fixtures/devices.mjs'; +import { journeyShots } from '../fixtures/shots.mjs'; +import { auditLog } from '../fixtures/audit.mjs'; +import { dwell } from '../fixtures/pace.mjs'; +import { createTournament } from '../fixtures/setup.mjs'; +import { createCompetition } from '../fixtures/wizard.mjs'; +import { generateDraw, pasteRoster, startCompetition } from '../fixtures/competition.mjs'; +import { openShiaijo, sides, startUpNext, upNextCard } from '../fixtures/shiaijo.mjs'; +import { awardIppon, clearMarks, filledSlots, finishMatch, syncState } from '../fixtures/scoring.mjs'; +import { editorSides } from '../fixtures/individual.mjs'; +import { + NATURAL, applyScheduleText, cardMarks, clearScheduleText, competitionTab, homeNowRow, expandedCard, matchCard, nowCard, numbersOf, pickerEntries, + pickerOption, pickerPairs, poolRow, scheduleCount, scheduleFilterInput, scheduleRow, scheduleRows, upNextRow, watchHero, + watchHeroMatch, watchInput, +} from '../fixtures/public.mjs'; + +// Twelve K competitors, six dojos plus one with a single member (Shudokan). +const KENDO = [ + ['Akira Tanaka', 'Gyokusen'], ['Ben Carter', 'Thames'], ['Chie Mori', 'Musashi'], ['Dan Evans', 'Seishinkan'], + ['Eri Kato', 'Gyokusen'], ['Finn Hale', 'Thames'], ['Goro Abe', 'Musashi'], ['Hana Ito', 'Seishinkan'], + ['Ivo Nunes', 'Mumeishi'], ['Jun Oda', 'Hizen'], ['Lea Roth', 'Mumeishi'], ['Sora Endo', 'Shudokan'], +]; +// Six M competitors: no "k" and no digit in any name or dojo. +const WOMEN = [ + ['Ana Rossi', 'Thames'], ['Bea Lopez', 'Musashi'], ['Cleo Dane', 'Hizen'], + ['Dora Pine', 'Mumeishi'], ['Ella Quinn', 'Bristol'], ['Fay Stone', 'Nottingham'], +]; + +test.describe.configure({ mode: 'serial' }); + +test.describe('spectator on a phone', () => { + const audit = auditLog('spectator'); + const { record } = audit; + let shot; + let opCtx; + let op; // the operator's page, signed in, kept for the whole file + const ids = {}; + + test.beforeAll(async ({ browser, serverForFile }, testInfo) => { + shot = journeyShots('spectator'); + const server = await serverForFile(testInfo.file); + opCtx = await browser.newContext({ ...OPERATOR_DEVICE, baseURL: server.base }); + op = await opCtx.newPage(); + await createTournament(op, { name: 'Spectator Cup', courts: 2 }); + ids.kendo = await createCompetition(op, { + name: 'Kendo Open', kind: 'individual', format: 'mixed', poolSize: 3, poolWinners: 2, courts: ['A'], numberPrefix: 'K', + }); + await pasteRoster(op, ids.kendo, KENDO); + await generateDraw(op, ids.kendo); + await startCompetition(op, ids.kendo); + ids.women = await createCompetition(op, { + name: 'Women Open', kind: 'individual', format: 'mixed', poolSize: 3, poolWinners: 2, courts: ['B'], numberPrefix: 'M', + }); + await pasteRoster(op, ids.women, WOMEN); + await generateDraw(op, ids.women); + await startCompetition(op, ids.women); + // The bout court A plays first, read from the operator's console. + await openShiaijo(op, 'A'); + ids.first = await sides(upNextCard(op)); + }); + + test.afterAll(async () => { + audit.flush(); + await opCtx?.close(); + }); + + test('the public home, a competition page and its tabs, and the schedule show the competitions and matches', async ({ page }) => { + await test.step('the home lists both competitions, the schedule link and what plays next', async () => { + await page.goto('/'); + await expect(page.locator('.viewer__title')).toHaveText('Spectator Cup'); + const kendo = page.locator('.vlist-item--comp', { hasText: 'Kendo Open' }); + const women = page.locator('.vlist-item--comp', { hasText: 'Women Open' }); + await expect(kendo).toContainText('12 players'); + await expect(women).toContainText('6 players'); + await expect(page.locator('.viewer-nav-card')).toContainText('18 matches · 2 courts'); + // Up next: the first three bouts of court A, K numbers beside the names. + await expect(page.locator('.viewer__upnext-title')).toHaveText(/^Up next · 3$/); + await expect(page.locator('button.vsched-item')).toHaveCount(3); + await expect(page.locator('button.vsched-item').first()).toContainText(ids.first.shiro); + await expect(page.locator('button.vsched-item').first()).toContainText(ids.first.aka); + await expect(page.locator('button.vsched-item').first()).toContainText(/K\d+/); + await shot(page, 'home', { fullPage: true }); + }); + + await test.step('a competition card opens its page: Overview, Pools and Bracket tabs', async () => { + await page.locator('.vlist-item--comp', { hasText: 'Kendo Open' }).tap(); + await expect(page).toHaveURL(new RegExp(`/competition/${ids.kendo}$`)); + for (const tab of ['Overview', 'Pools', 'Bracket']) await expect(competitionTab(page, tab)).toBeVisible(); + // Overview: the next matches. + await expect(page.locator('.section-title', { hasText: /^Up next/ })).toBeVisible(); + await expect(upNextRow(page, ids.first)).toBeVisible(); + await shot(page, 'competition-overview'); + + // Pools: four pools of three, every match listed once. + await competitionTab(page, 'Pools').tap(); + await expect(page.locator('.pool-match-numbered-row')).toHaveCount(12); + await expect(page.getByText(/^Pool [A-D]$/)).toHaveCount(4); + await expect(poolRow(page, ids.first)).toBeVisible(); + await shot(page, 'competition-pools'); + + // Bracket: the knockout tree, drawn before the pools are played. + await competitionTab(page, 'Bracket').tap(); + await expect(competitionTab(page, 'Bracket')).toHaveClass(/is-active/); + await expect(page.getByText('Pool A-1st').first()).toBeVisible(); + await shot(page, 'competition-bracket'); + }); + + await test.step('the second competition has its own page, with its pools', async () => { + await page.goto(`/competition/${ids.women}`); + await competitionTab(page, 'Pools').tap(); + await expect(page.locator('.pool-match-numbered-row')).toHaveCount(6); + await expect(page.getByText(/^Pool [A-B]$/)).toHaveCount(2); + }); + + await test.step('the schedule lists every match on both courts', async () => { + await page.goto('/'); + await page.locator('.viewer-nav-card').tap(); + await expect(page).toHaveURL(/\/schedule$/); + await expect(scheduleRows(page)).toHaveCount(18); + await expect(scheduleCount(page)).toHaveText('18 matches of 18'); + await expect(page.locator('.tw-court', { hasText: 'SHIAIJO A' }).locator('.tw-court__sub')).toHaveText('12 matches'); + await expect(page.locator('.tw-court', { hasText: 'SHIAIJO B' }).locator('.tw-court__sub')).toHaveText('6 matches'); + await expect(scheduleRow(page, ids.first)).toContainText('Kendo Open'); + await shot(page, 'schedule', { fullPage: true }); + }); + }); + + test('competitor search: the number rule (bc-nsrc), names and dojos, on the watch picker and the schedule', async ({ page }) => { + const KENDO_NUMBERS = numbersOf('K', 12); + const WOMEN_NUMBERS = numbersOf('M', 6); + // name -> number, learned from the lists themselves (the draw hands the + // numbers out; only the prefixes and the counts are fixed by the seeding). + const numberOf = {}; + const learn = async (page_) => { for (const p of await pickerPairs(page_)) numberOf[p.name] = p.number; }; + const numbersFor = (...names) => names.map((n) => numberOf[n]).sort(NATURAL); + + // One surface's expectations, run against both pickers: the same rule, so + // the same answers. `type` puts a query in the box; `empty` checks the + // surface's own words for "nobody". + const checkRule = async (surface, type, empty) => { + // The prefix on its own lists the whole draw, and no other prefix. + await type('k'); + await expect.poll(() => pickerEntries(page), { message: `${surface}: "k" lists every K and no M` }) + .toMatchObject({ numbers: KENDO_NUMBERS }); + await learn(page); + expect(Object.values(numberOf).filter((n) => n.startsWith('K')), `${surface}: twelve K competitors`).toHaveLength(12); + expect((await pickerEntries(page)).numbers.filter((n) => !n.startsWith('K')), `${surface}: "k" lists no other prefix`).toEqual([]); + await shot(page, `${surface}-k`); + record({ journey: 'spectator', surface, query: 'k', ...(await pickerEntries(page)) }); + + // The prefix and the digits find one competitor: K1 is not K12 (nor K10, K11). + const k1 = Object.keys(numberOf).find((n) => numberOf[n] === 'K1'); + const k12 = Object.keys(numberOf).find((n) => numberOf[n] === 'K12'); + await type('k1'); + await expect.poll(() => pickerEntries(page), { message: `${surface}: "k1" lists K1 alone` }) + .toEqual({ numbers: ['K1'], names: [k1] }); + await shot(page, `${surface}-k1`); + record({ journey: 'spectator', surface, query: 'k1', ...(await pickerEntries(page)) }); + await type('K12'); + await expect.poll(() => pickerEntries(page), { message: `${surface}: "K12" lists K12 alone` }) + .toEqual({ numbers: ['K12'], names: [k12] }); + + // The second prefix, the same way: the whole M draw is listed (with any K + // competitor whose name or dojo holds an "m", as Musashi does), and M3 alone. + await type('m'); + await expect.poll(async () => (await pickerEntries(page)).numbers, { message: `${surface}: "m" lists the whole M draw` }) + .toEqual(expect.arrayContaining(WOMEN_NUMBERS)); + await learn(page); + await type('m3'); + await expect.poll(async () => (await pickerEntries(page)).numbers, { message: `${surface}: "m3" lists M3 alone` }) + .toEqual(['M3']); + + // Digits on their own are not a competitor number: nobody, there being no + // digit in any name or dojo. + await type('1'); + await expect.poll(() => pickerEntries(page), { message: `${surface}: "1" finds nobody` }) + .toEqual({ numbers: [], names: [] }); + await empty(); + await shot(page, `${surface}-1`); + record({ journey: 'spectator', surface, query: '1', ...(await pickerEntries(page)) }); + + // Names and dojos match on any part of the text. + await type('tanak'); + await expect.poll(() => pickerEntries(page), { message: `${surface}: a name fragment` }) + .toEqual({ numbers: numbersFor('Akira Tanaka'), names: ['Akira Tanaka'] }); + await type('bristol'); + await expect.poll(() => pickerEntries(page), { message: `${surface}: a dojo fragment` }) + .toEqual({ numbers: numbersFor('Ella Quinn'), names: ['Ella Quinn'] }); + await type('thames'); + await expect.poll(() => pickerEntries(page), { message: `${surface}: a dojo shared by two draws` }) + .toEqual({ numbers: numbersFor('Ana Rossi', 'Ben Carter', 'Finn Hale'), names: ['Ana Rossi', 'Ben Carter', 'Finn Hale'] }); + record({ journey: 'spectator', surface, query: 'thames', ...(await pickerEntries(page)) }); + }; + + await test.step('the home\'s watch picker', async () => { + await page.goto('/'); + const box = watchInput(page); + await box.tap(); + await checkRule('watch-picker', (q) => box.fill(q), () => expect(page.getByTestId('watchpicker-empty')).toContainText('No one here matches')); + }); + + await test.step('the schedule\'s filter picker', async () => { + await page.goto('/schedule'); + const box = scheduleFilterInput(page); + await box.tap(); + await checkRule('schedule-picker', (q) => box.fill(q), () => expect(page.locator('.pmf__dropdown-head')).toContainText('0 participants')); + }); + + await test.step('the schedule\'s text filter keeps the matches that mention the query', async () => { + await page.goto('/schedule'); + await expect(scheduleRows(page)).toHaveCount(18); + const k1 = Object.keys(numberOf).find((n) => numberOf[n] === 'K1'); + + // "k": the whole K draw, so every Kendo Open match and not one of Women Open. + await applyScheduleText(page, 'k'); + await expect(scheduleRows(page)).toHaveCount(12); + await expect(scheduleCount(page)).toHaveText('12 matches of 18'); + for (const row of await scheduleRows(page).all()) await expect(row).toContainText('Kendo Open'); + record({ journey: 'spectator', surface: 'schedule-rows', query: 'k', rows: await scheduleRows(page).count() }); + await shot(page, 'schedule-rows-k'); + await clearScheduleText(page); + + // "k1": K1's two matches, and none of K10, K11 or K12's. + await applyScheduleText(page, 'k1'); + await expect(scheduleRows(page)).toHaveCount(2); + for (const row of await scheduleRows(page).all()) { + await expect(row).toContainText(`K1 ${k1}`); + await expect(row).not.toContainText(/\bK1[0-2]\b/); + } + record({ journey: 'spectator', surface: 'schedule-rows', query: 'k1', rows: await scheduleRows(page).count() }); + await shot(page, 'schedule-rows-k1'); + await clearScheduleText(page); + + // "1": no match mentions a competitor by a bare digit. + await applyScheduleText(page, '1'); + await expect(scheduleRows(page)).toHaveCount(0); + record({ journey: 'spectator', surface: 'schedule-rows', query: '1', rows: await scheduleRows(page).count() }); + await clearScheduleText(page); + + // A dojo fragment: the two matches Ella Quinn (Bristol) fights. + await applyScheduleText(page, 'bristol'); + await expect(scheduleRows(page)).toHaveCount(2); + for (const row of await scheduleRows(page).all()) await expect(row).toContainText('Ella Quinn'); + await clearScheduleText(page); + await expect(scheduleRows(page)).toHaveCount(18); + }); + }); + + test('watchlist: follow a competitor, and the hero and the match card follow the court live', async ({ page }) => { + await page.goto('/'); + // The app is never reloaded in this test: this marker is lost if it is. + await page.evaluate(() => { window.__spectatorNoReload = 'kept'; }); + // Court A's first bout, and the one after it (read off the operator's + // console); the followed competitor is in both (pool of three). + await openShiaijo(op, 'A'); + const first = await sides(upNextCard(op)); + expect(first).toEqual(ids.first); + const followed = first.aka; + + await test.step('follow the Aka side of court A\'s first bout: the hero shows the match waiting', async () => { + await watchInput(page).fill(followed.toLowerCase().slice(0, 5)); + await pickerOption(page, followed).tap(); + await expect(watchHero(page)).toContainText(followed); + await expect(watchHero(page)).toContainText(first.shiro); + await expect(watchHero(page)).not.toHaveClass(/wl-hero--running/); + await shot(page, 'watching-waiting'); + }); + + await test.step('the operator starts the match: the hero turns to "On court now"', async () => { + expect(await startUpNext(op)).toEqual(first); + await expect(watchHero(page)).toHaveClass(/wl-hero--running/); + await expect(watchHero(page)).toContainText('On court now'); + await shot(page, 'watching-on-court'); + }); + + const card = matchCard(page); + await test.step('the match card opened from the hero follows the operator\'s marks', async () => { + await watchHeroMatch(page).tap(); + await expect(card).toBeVisible(); + await expect(card).toContainText('NOW'); + expect(await cardMarks(card)).toEqual({ shiro: '', aka: '' }); + await awardIppon(op, 'shiro', 'M'); + await expect.poll(() => cardMarks(card)).toEqual({ shiro: 'M', aka: '' }); + await shot(page, 'watching-card-live'); + record({ journey: 'spectator', step: 'watchlist card live', marks: await cardMarks(card) }); + }); + + await test.step('the operator finishes the match: the open card reads FINAL, the hero moves to the next bout', async () => { + const next = await sides(upNextCard(op)); + const opponent = next.shiro === followed ? next.aka : next.aka === followed ? next.shiro : null; + expect(opponent, 'the next bout on court A also has the followed competitor (a pool of three)').not.toBeNull(); + await finishMatch(op); + await expect(card).toContainText('FINAL'); + expect(await cardMarks(card)).toEqual({ shiro: 'M', aka: '' }); + await shot(page, 'watching-card-final'); + // Close the card (the tap that opened it was a while ago). + await card.getByRole('button', { name: 'Close' }).tap(); + await expect(card).toHaveCount(0); + // Finish + Start Next started the next bout, also the followed one's: the hero follows. + await expect(watchHero(page)).toContainText(opponent); + await expect(watchHero(page)).toHaveClass(/wl-hero--running/); + await shot(page, 'watching-next-bout'); + }); + + expect(await page.evaluate(() => window.__spectatorNoReload), 'the page was never reloaded').toBe('kept'); + }); + + test('the match card reads the live row from every door: Home, Schedule, a competition\'s Pools tab and its Overview', async ({ page }) => { + // The second bout is running on court A since the last test's Finish + + // Start Next; the third waits as Up next. + const match2 = await editorSides(op); + const match3 = await sides(upNextCard(op)); + expect(match3, 'the third bout is another pairing').not.toEqual(match2); + const card = matchCard(page); + + // Open the card with `open()`, check what it shows now, let the operator + // change the score, and check the OPEN card shows the change at once. + const door = async (name, open, before, change, after) => { + await test.step(name, async () => { + const opened = Date.now(); + await open(); + await expect(card).toBeVisible(); + await expect.poll(() => cardMarks(card), { message: `${name}: the card opens on the live score` }).toEqual(before); + await change(); + await expect.poll(() => cardMarks(card), { message: `${name}: the open card follows the operator` }).toEqual(after); + await shot(page, `door-${name.split(' ')[0].toLowerCase().replace(':', '')}`); + record({ journey: 'spectator', door: name, before, after }); + await dwell(page, opened); + await card.getByRole('button', { name: 'Close' }).tap(); + await expect(card).toHaveCount(0); + }); + }; + + await door('Home: the NOW list', + async () => { await page.goto('/'); await homeNowRow(page).tap(); }, + { shiro: '', aka: '' }, + () => awardIppon(op, 'shiro', 'M'), + { shiro: 'M', aka: '' }); + + await door('Schedule: a row', + async () => { await page.goto('/schedule'); await scheduleRow(page, match2).tap(); }, + { shiro: 'M', aka: '' }, + () => awardIppon(op, 'aka', 'K'), + { shiro: 'M', aka: 'K' }); + + await door('Pools: a match row on the competition page', + async () => { + await page.goto(`/competition/${ids.kendo}`); + await competitionTab(page, 'Pools').tap(); + await poolRow(page, match2).tap(); + }, + { shiro: 'M', aka: 'K' }, + () => awardIppon(op, 'shiro', 'D'), + { shiro: 'DM', aka: 'K' }); + + // The Overview is the one door that is no dialog while the tournament is + // officiated: an Up next row expands a card in place (the dialog is the + // self-run page's), and the running match is its own inline ON NOW card. + // Both read the page's live rows. The operator scores on, then finishes the + // running bout so the waiting one (the expanded row) starts. + await test.step('Overview: an Up next row expands a card; the ON NOW card follows the operator', async () => { + await page.goto(`/competition/${ids.kendo}`); + await upNextRow(page, match3).tap(); + const expanded = expandedCard(page); + await expect(expanded).toBeVisible(); + await expect(expanded).not.toContainText('NOW'); + expect(await cardMarks(expanded)).toEqual({ shiro: '', aka: '' }); + await expect.poll(() => cardMarks(nowCard(page)), { message: 'Overview: the ON NOW card opens on the live score' }) + .toEqual({ shiro: 'DM', aka: 'K' }); + // The operator takes Aka's mark back (Shiro's two ippon already decide the bout). + await clearMarks(filledSlots(op, 'aka')); + await expect(syncState(op)).toHaveText('Synced'); + await expect.poll(() => cardMarks(nowCard(page)), { message: 'Overview: the ON NOW card follows the operator' }) + .toEqual({ shiro: 'DM', aka: '' }); + await shot(page, 'door-overview-now'); + + await finishMatch(op); + expect(await editorSides(op)).toEqual(match3); + await expect(nowCard(page), 'the ON NOW card moves to the started bout').toContainText(match3.aka); + await expect(expanded, 'the waiting bout is no longer in Up next').toHaveCount(0); + expect(await cardMarks(nowCard(page))).toEqual({ shiro: '', aka: '' }); + await awardIppon(op, 'shiro', 'M'); + await expect.poll(() => cardMarks(nowCard(page)), { message: 'Overview: the ON NOW card follows the next bout' }) + .toEqual({ shiro: 'M', aka: '' }); + await shot(page, 'door-overview-next-bout'); + record({ journey: 'spectator', door: 'Overview: Up next row, ON NOW card', after: await cardMarks(nowCard(page)) }); + }); + }); +}); From 9cdaed53c649af0b64f0baf1d053ab92d53946c6 Mon Sep 17 00:00:00 2001 From: Ricardo Oliveira Date: Wed, 14 Oct 2026 08:55:38 +0100 Subject: [PATCH 24/31] docs(bc-e2rv): e2e README lists the pools-tiebreak and spectator.public journeys Co-Authored-By: Claude Opus 5.5 --- scripts/e2e/README.md | 39 ++++++++++++++++++++++++++++++--------- 1 file changed, 30 insertions(+), 9 deletions(-) diff --git a/scripts/e2e/README.md b/scripts/e2e/README.md index 61de30e05..1a83a7249 100644 --- a/scripts/e2e/README.md +++ b/scripts/e2e/README.md @@ -59,17 +59,21 @@ browser, `--debug` opens the inspector, and a failing test leaves a trace dialog; reads of pool membership and bracket slots. fixtures/pools.mjs the pool standings tables, admin Pools tab and the public viewer's Pools tab: rows, figures, rank - badge, advancing. + badge, advancing; the individual pool row + (playerRowFigures). 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, Start - match (startMatch), clearMarks, the Finish check - every editor shares (finishMatch, which the team - sheet's finishTeam is), and the wait for a match - write's answer (nextMatchWrite, writeLanded) + match (startMatch), clearMarks, Mark draw + (markDraw), the Finish check every editor shares + (finishMatch, which the team sheet's finishTeam + is), and the wait for a match write's answer + (nextMatchWrite, writeLanded) fixtures/individual.mjs individual rosters, the decision panel, playing and - correcting a bout on the court console + correcting a bout on the court console, and a bout + played from a script (playScriptedBout), such as a + pool bout 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 (the @@ -83,7 +87,11 @@ browser, `--debug` opens the inspector, and a failing test leaves a trace observe a race with its echo (below) 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/public.mjs the public viewer and the TV board; the readers of + the watch picker and the schedule filter's options + (pickerEntries); schedule rows; the watchlist hero; + the doors to a match card (homeNowRow, poolRow, + upNextRow); the card's marks (cardMarks) fixtures/organiser.mjs the Excel download, folder import, /reset, logo and sponsor uploads; runtime PNG and import-bundle makers; a zip reader for workbooks. @@ -108,6 +116,12 @@ browser, `--debug` opens the inspector, and a failing test leaves a trace hikiwake, a team exhausted, a level pool draw with no representative bout, the pool tables on admin and phone, the winners in the final + pools-tiebreak pools and knockout at a minimum pool size (7 + entrants make pools of 4 and 3); a pool tie + level on every criterion adds a tie-break + bout, whose winner is ranked and seated in the + knockout on admin and phone; a tie split on + points lost adds none moved-match a waiting match moved to another shiaijo reaches that court's console without a reload shared-shiaijo two competitions on one shiaijo: the picker, @@ -126,6 +140,11 @@ browser, `--debug` opens the inspector, and a failing test leaves a trace organiser-locked locked mode: sign-in with the hashed password, no Forgot password, and /reset shows it is disabled. + spectator.public a spectator's phone (public project): the + home, competition tabs, schedule; competitor + search by number, name and dojo; the watchlist + hero and the match card following an operator + live; the match card from each door output/ screenshots, audit.json and traces (below); gitignored @@ -146,8 +165,10 @@ Two projects, both Chromium: 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`). No journey uses this project yet; the public - surfaces are checked from `operator` journeys through a second context. + alone (`*.public.spec.mjs`). `spectator.public.spec.mjs` is the one journey + on this project: it seeds and scores from a second context opened with + `OPERATOR_DEVICE`. Other public checks stay on the `operator` project, + through a second context. 240 s is the default test timeout; some journeys raise it for a long test with `test.setTimeout`. `expect` waits 15 s (`EXPECT_TIMEOUT_MS` in From 483e3fc623dad7feb06baa23717f525afaae5d9c Mon Sep 17 00:00:00 2001 From: Ricardo Oliveira Date: Wed, 14 Oct 2026 09:05:57 +0100 Subject: [PATCH 25/31] fix(bc-tbdg): a pool tie awaiting its tie-break bout shows both competitors as tied While two pool competitors are level on every criterion and the tie decides who advances, the pool table badged one 2nd in the advancing style and the other 3rd, by draw order. tiedPlaces (viewer_standings.jsx, the one owner for the admin Pools tab and the public viewer) gives the group one shared place, shown as =2nd, with no advancing mark on any member until the tie-break bout is scored; a tie inside the advancing places keeps the mark, since it cannot change who goes through. The pools-tiebreak journey asserts it on admin and phone, and the organiser guide says it. Co-Authored-By: Claude Opus 5.5 --- docs/user-guide/organisers/run-tournament.md | 2 + scripts/e2e/journeys/pools-tiebreak.spec.mjs | 41 ++++--- .../render/pools_tied_place.render.test.jsx | 107 ++++++++++++++++++ web-mobile/js/viewer_standings.jsx | 52 ++++++++- 4 files changed, 182 insertions(+), 20 deletions(-) create mode 100644 web-mobile/js/__tests__/render/pools_tied_place.render.test.jsx diff --git a/docs/user-guide/organisers/run-tournament.md b/docs/user-guide/organisers/run-tournament.md index 6e0b3b4c0..1aead5806 100644 --- a/docs/user-guide/organisers/run-tournament.md +++ b/docs/user-guide/organisers/run-tournament.md @@ -231,6 +231,8 @@ A draw-ready competition is already listed on the shiai-jo operator views, since The **Pools** tab shows standings for every pool. Ranks are computed automatically from match results. Operators do not edit them by hand, with one exception: chusen (drawing lots), the last-resort tie-break for a consequential team-pool tie that a daihyosen cannot settle. Refer to [Recording decisions](../court-operators/recording-decisions.md). When a daihyosen settles a tie that determines pool advancement, the winning side carries a **DH** badge in the standings. +When two or more competitors are level on every ranking criterion and the tie decides who advances, the pool waits for a tie-break bout between them. Until it is scored, the tied competitors share one place, shown as **=2nd** for a tie over second place, and none of them carries the advancing mark. Once the bout is scored, each takes their own place and the winner is marked as advancing. + Pool finishers move into the knockout bracket on their own: as soon as a pool's last match is scored, its qualifiers are seeded into their bracket slots. Those knockout matches can be scored without waiting for the other pools. Once the last pool is seeded, the competition moves to the knockout phase. The bracket updates in real time as results come in. A pool result corrected later still moves its qualifiers in the bracket; refer to [Correct a pool result after the knockout has started](../court-operators/scoring-a-match.md#correct-a-pool-result-after-the-knockout-has-started). ![The Pools tab: each pool as a card with its standings in draw order, every competitor carrying their number and a rank badge, the pool's matches with their results, and the head-to-head grid beneath.](../../screenshots/mobile-pool-standings.png) diff --git a/scripts/e2e/journeys/pools-tiebreak.spec.mjs b/scripts/e2e/journeys/pools-tiebreak.spec.mjs index b46211eef..55913543b 100644 --- a/scripts/e2e/journeys/pools-tiebreak.spec.mjs +++ b/scripts/e2e/journeys/pools-tiebreak.spec.mjs @@ -31,10 +31,11 @@ // // Asserted: the pool sizes the minimum rule makes; the tie-break bout appearing // on the court console and the Scores tab as the only bout left to score, with -// no "Match n of m" label, while the pool table shows B and C level on every -// figure and marked tied; no tie-break bout for the pool settled on points -// lost; after the tie-break bout, the winner ranked above the loser and seated -// in the knockout (the loser out), on the admin pages and the public viewer. +// no "Match n of m" label, while the pool table (admin and public viewer) shows +// B and C level on every figure, marked tied, both at "=2nd" with no advancing +// mark; no tie-break bout for the pool settled on points lost; after the +// tie-break bout, the winner ranked above the loser and seated in the knockout +// (the loser out), on the admin pages and the public viewer. import { test, expect } from '../fixtures/test.mjs'; import { journeyShots } from '../fixtures/shots.mjs'; import { auditLog } from '../fixtures/audit.mjs'; @@ -90,8 +91,6 @@ test.describe('pools: minimum pool size and a tie-break bout', () => { // Who won the tie-break bout, who lost it. let tbWinner; let tbLoser; - // The rank badge each of the level pair carried before the bout. - let rankBefore; test.beforeAll(() => { shot = journeyShots(JOURNEY); @@ -209,7 +208,7 @@ test.describe('pools: minimum pool size and a tie-break bout', () => { await shot(page, 'regular-bouts-done'); }); - test('the level pair: a tie-break bout is added, and the table shows them level until it is fought', async ({ page }) => { + test('the level pair: a tie-break bout is added, and the table shows them level until it is fought', async ({ page, browser, baseURL }) => { await login(page); const pair = await tieBreakPairing(page); record({ journey: JOURNEY, step: 'tie-break', action: 'on the court console', pair, screenshot: await shot(page, 'tie-break-on-console') }); @@ -226,18 +225,25 @@ test.describe('pools: minimum pool size and a tie-break bout', () => { expect(await toScore.first().innerText()).not.toMatch(POOL_MATCH_LABEL); await shot(page, 'scores-tab-tie-break'); - // The pool table: B and C level on every figure and marked tied; A first. - const level = (name) => ({ ...tally[name], tied: true }); + // The pool table: B and C level on every figure, marked tied, sharing ONE + // place (=2nd) with no advancing mark on either, since the tie-break bout + // decides which of them goes through; A first and advancing. The public + // viewer's table reads the same. + const level = (name) => ({ ...tally[name], rank: '=2nd', advancing: false, tied: true }); await openAdminPools(page, compId); await expectTable(page, 'admin Pools tab', [tb.b, tb.c], level); const rows = { b: await playerRowFigures(page, tb.b), c: await playerRowFigures(page, tb.c) }; - // The badges the level pair carries until the bout: the table lists them in - // draw order and badges the one drawn first higher, since the figures say - // nothing. The tie-break winner is picked from the one badged LOWER, so the - // bout visibly changes the order. - rankBefore = { [tb.b]: rows.b.rank, [tb.c]: rows.c.rank }; record({ journey: JOURNEY, step: 'tie-break', action: 'table before the bout', columns: INDIVIDUAL_FIGURES, rows, screenshot: await shot(page, 'admin-pools-level', { fullPage: true }) }); await expectTable(page, 'admin Pools tab', [tb.a], (n) => ({ ...tally[n], rank: '1st', advancing: true, tied: false })); + const spectator = await browser.newContext({ ...PUBLIC_DEVICE, baseURL }); + try { + const phone = await spectator.newPage(); + await openViewerPools(phone, compId); + await expectTable(phone, 'public viewer Pools tab', [tb.b, tb.c], level); + await expectTable(phone, 'public viewer Pools tab', [tb.a], (n) => ({ ...tally[n], rank: '1st', advancing: true, tied: false })); + } finally { + await spectator.close(); + } // The pool of 4: Q above R on points lost alone, no tie, no bout for them. const [P, Q, R, S] = big; @@ -264,11 +270,12 @@ test.describe('pools: minimum pool size and a tie-break bout', () => { // The bout starts from its Up next card unless a Finish + Start Next already did. const running = await ensureRunning(page); expect(running, 'the tie-break bout runs on the court').toEqual(pair); - // The one the table badged lower wins, one men (aka when it badged both alike). - const rankIndex = (name) => ORDINAL.indexOf(rankBefore[name]); - const side = rankIndex(pair.shiro) > rankIndex(pair.aka) ? 'shiro' : 'aka'; + // The last of the pair in the draw (role C) wins, one men: the table + // read them level, so the bout, not the draw order, decides the place. + const side = pair.shiro === tb.c ? 'shiro' : 'aka'; tbWinner = pair[side]; tbLoser = pair[side === 'shiro' ? 'aka' : 'shiro']; + expect(tbWinner, 'role C wins the tie-break bout').toBe(tb.c); const wrote = nextMatchWrite(page); await awardIppon(page, side, 'M'); const res = await writeLanded(wrote, 'the tie-break bout\'s first mark'); diff --git a/web-mobile/js/__tests__/render/pools_tied_place.render.test.jsx b/web-mobile/js/__tests__/render/pools_tied_place.render.test.jsx new file mode 100644 index 000000000..81fe6f568 --- /dev/null +++ b/web-mobile/js/__tests__/render/pools_tied_place.render.test.jsx @@ -0,0 +1,107 @@ +import React from 'react'; +import { render } from '@testing-library/react'; +import { describe, it, expect, beforeAll } from 'vitest'; +import { PoolsViewer, tiedPlaces } from '../../viewer_standings.jsx'; + +// A pool whose second place is level on EVERY criterion between two +// competitors, with the tie-break bout still to be fought. The admin Pools tab +// and the public viewer render this same component, so one test covers both. +// The server sorts the level pair by draw order (ranks 2 and 3) and marks both +// `tied`; the table must not badge one of them 2nd and advancing. + +beforeAll(async () => { + await import('../../bracket.jsx'); + // api_serializers publishes it for the round-robin matrix under the table. + await import('../../api_serializers.jsx'); +}); + +const player = (id, name) => ({ id, name, dojo: `${name} Dojo` }); +const A = player('a', 'Aoki'); +const B = player('b', 'Baba'); +const C = player('c', 'Cho'); +const pools = [{ poolName: 'Pool A', players: [A, B, C] }]; +const competition = { id: 'c', name: 'C', format: 'mixed', kind: 'individual', poolWinners: 2 }; +const tweaks = { showDojo: false }; + +const row = (p, rank, f) => ({ + player: p, rank, wins: 0, losses: 1, draws: 1, ipponsGiven: 1, ipponsTaken: 2, points: 1, ...f, +}); +const winner = row(A, 1, { wins: 2, losses: 0, draws: 0, ipponsGiven: 2, ipponsTaken: 0, points: 9 }); + +const bout = (id, sideA, sideB, status = 'completed') => ({ id, sideA, sideB, status }); +const regular = [bout('Pool A-0', A, B), bout('Pool A-1', A, C), bout('Pool A-2', B, C)]; + +function renderPool(poolStandings, poolMatches) { + const { container } = render( + , + ); + const byName = {}; + container.querySelectorAll('table.pool__table tbody tr').forEach((tr) => { + const badge = tr.querySelector('.rank-badge'); + const text = tr.querySelector('.pool__player-name').textContent; + byName[[A, B, C].find((p) => text.includes(p.name)).name] = { + badge: badge ? badge.textContent : '', + badgeAdv: !!badge && badge.classList.contains('rank-badge--adv'), + advancing: tr.classList.contains('advancing'), + tied: tr.classList.contains('pool__row--tied'), + }; + }); + return byName; +} + +describe('a tie awaiting its tie-break bout', () => { + it('shows both level rows at the same tied place, with no advancing mark', () => { + const tbPending = [...regular, bout('Pool A-TB-1', B, C, 'scheduled')]; + const rows = renderPool([ + winner, + row(B, 2, { tied: true }), + row(C, 3, { tied: true }), + ], tbPending); + + expect(rows.Aoki).toMatchObject({ badge: '1st', badgeAdv: true, advancing: true, tied: false }); + expect(rows.Baba).toMatchObject({ badge: '=2nd', badgeAdv: false, advancing: false, tied: true }); + expect(rows.Cho).toMatchObject({ badge: '=2nd', badgeAdv: false, advancing: false, tied: true }); + }); + + it('brings the ordinary badges and the advancing mark back once the bout is scored', () => { + const tbDone = [...regular, bout('Pool A-TB-1', B, C, 'completed')]; + const rows = renderPool([ + winner, + row(C, 2, { points: 2 }), + row(B, 3, { points: 1 }), + ], tbDone); + + expect(rows.Aoki).toMatchObject({ badge: '1st', advancing: true, tied: false }); + expect(rows.Cho).toMatchObject({ badge: '2nd', badgeAdv: true, advancing: true, tied: false }); + expect(rows.Baba).toMatchObject({ badge: '3rd', badgeAdv: false, advancing: false, tied: false }); + }); +}); + +describe('tiedPlaces', () => { + const rows = (...r) => r; + it('shares the best place across a group and says whether it straddles the cut', () => { + const m = tiedPlaces(rows(winner, row(B, 2, { tied: true }), row(C, 3, { tied: true })), 2); + expect(m.get('b')).toEqual({ place: 2, decidesAdvancement: true }); + expect(m.get('c')).toEqual({ place: 2, decidesAdvancement: true }); + expect(m.has('a')).toBe(false); + }); + + it('keeps the advancing mark for a tie that sits wholly inside the qualifying places', () => { + const level = { points: 5, tied: true }; + const m = tiedPlaces(rows(row(A, 1, level), row(B, 2, level), row(C, 3, { points: 1 })), 2); + expect(m.get('a')).toEqual({ place: 1, decidesAdvancement: false }); + expect(m.get('b')).toEqual({ place: 1, decidesAdvancement: false }); + }); + + it('keeps two separate tied groups apart', () => { + const D = player('d', 'Dan'); + const m = tiedPlaces(rows( + row(A, 1, { points: 9, tied: true }), row(B, 2, { points: 9, tied: true }), + row(C, 3, { points: 4, tied: true }), row(D, 4, { points: 4, tied: true }), + ), 2); + expect(m.get('a').place).toBe(1); + expect(m.get('c').place).toBe(3); + expect(m.get('d')).toEqual({ place: 3, decidesAdvancement: false }); + }); +}); diff --git a/web-mobile/js/viewer_standings.jsx b/web-mobile/js/viewer_standings.jsx index eff20f53d..bba11cdc8 100644 --- a/web-mobile/js/viewer_standings.jsx +++ b/web-mobile/js/viewer_standings.jsx @@ -735,6 +735,42 @@ PoolNumberedMatchRow.displayName = "PoolNumberedMatchRow"; // residue is a deliberate accepted gap, not a bug: a correct highlight needs // the server to say WHICH pools were drafted, which is future work if this // becomes worth wiring (bc-qual LP-5a report). +// tiedPlaces: the place each still-open tie holds, keyed by standings row. +// +// The server marks a row `tied` while the competitors level with it on EVERY +// criterion await a tie-break bout, but each row still carries its own sorted +// rank (draw order breaks the level pair), so reading `rank` alone badges one +// of them 2nd and the other 3rd as though the order were decided. A tied group +// shares its best member's place, and sits wholly inside the qualifying places +// or not: `decidesAdvancement` is true only when the group straddles the cut, +// so a pair level for 1st that both advance anyway still reads as advancing. +// `points` is the server's packed ranking key (engine individual/teamStandingPoints), +// equal exactly when two rows are level on every criterion, so rows that share +// it among the tied ones form one group. Pool standings only: a league has no +// advancing mark to withhold. +export function tiedPlaces(poolStandings, qualifiers) { + const groups = new Map(); + (poolStandings || []).forEach((s, i) => { + if (!s || !s.tied) return; + const rank = s.rank || i + 1; + const g = groups.get(s.points); + if (g) { + g.place = Math.min(g.place, rank); + g.last = Math.max(g.last, rank); + } else { + groups.set(s.points, { place: rank, last: rank }); + } + }); + const byRow = new Map(); + (poolStandings || []).forEach((s) => { + if (!s || !s.tied) return; + const g = groups.get(s.points); + const key = s.player.id || `${s.player.name}||${s.player.dojo || ""}`; + byRow.set(key, { place: g.place, decidesAdvancement: g.place <= qualifiers && g.last > qualifiers }); + }); + return byRow; +} + export function qualifiersForPool(competition, pool) { const base = (competition && competition.poolWinners) || 2; if ( @@ -810,6 +846,11 @@ export function PoolsViewer({ pools, standings, poolMatches, tweaks, competition }); } + // A tie still waiting for its tie-break bout reads as tied (see + // tiedPlaces): the level rows share one place, and none of them is + // marked advancing when the tie decides who goes through. + const tiedByRow = isLeague ? new Map() : tiedPlaces(poolStandings, poolQualifiers); + // A pool has nothing to rank until at least one bout is decided: every // competitor is tied at 0 and the backend rank is merely the seed/draw // fallback. Suppress rank badges + the "advancing" qualification @@ -865,7 +906,9 @@ export function PoolsViewer({ pools, standings, poolMatches, tweaks, competition const lookup = rankByPlayerKey.get(p.id || `${p.name}||${p.dojo || ""}`); const s = lookup ? lookup.standing : null; const rank = lookup ? lookup.rank : null; - const isAdvancing = !isLeague && poolHasResults && rank !== null && rank <= poolQualifiers; + const tie = tiedByRow.get(p.id || `${p.name}||${p.dojo || ""}`); + const isAdvancing = !isLeague && poolHasResults && rank !== null && rank <= poolQualifiers + && !(tie && tie.decidesAdvancement); const rowClasses = [ isPlayerWatched(p, highlightPlayers) ? "pool__row--me" : "", @@ -886,8 +929,11 @@ export function PoolsViewer({ pools, standings, poolMatches, tweaks, competition )} {poolHasResults && rank !== null ? ( - - {rankOrdinal(rank)}{s && s.isOverridden ? "*" : ""} + + {tie ? `=${rankOrdinal(tie.place)}` : rankOrdinal(rank)}{s && s.isOverridden ? "*" : ""} ) : null} From ddaf26cde1af8b5730bb8f76b68431c9d801e395 Mon Sep 17 00:00:00 2001 From: Ricardo Oliveira Date: Wed, 14 Oct 2026 09:35:33 +0100 Subject: [PATCH 26/31] fix(bc-tbdg): tie display follows the server's tie-break cut; review round 5 tiedPlaces now takes the cut the server holds a tie-break bout against (effectivePoolWinners, mirroring EffectivePoolWinners) beside the advancing cut, so a larger-pools tie over the extra place no longer reads as waiting for a bout that is never scheduled; the tooltip names the bout (or the representative bout for teams) only when one is owed. New render cases: the larger-pools tie and a 3-way tie across the cut. The organiser guide's tie paragraph now says what tiebreaker.go does. Harness: the spectator journey follows the competitor actually in both queued bouts and polls its reads; the bc-adna fixme drops dead bookkeeping; markDraw takes a Locator; the pools-tiebreak bracket read waits for the seated qualifiers. Co-Authored-By: Claude Opus 5.5 --- docs/user-guide/organisers/run-tournament.md | 2 +- scripts/e2e/fixtures/individual.mjs | 4 +- scripts/e2e/fixtures/scoring.mjs | 2 +- scripts/e2e/journeys/moved-match.spec.mjs | 20 ++-- scripts/e2e/journeys/pools-tiebreak.spec.mjs | 8 +- .../e2e/journeys/spectator.public.spec.mjs | 21 ++-- .../render/pools_tied_place.render.test.jsx | 75 ++++++++++--- web-mobile/js/viewer_standings.jsx | 101 +++++++++++------- 8 files changed, 158 insertions(+), 75 deletions(-) diff --git a/docs/user-guide/organisers/run-tournament.md b/docs/user-guide/organisers/run-tournament.md index 1aead5806..9c50dfd87 100644 --- a/docs/user-guide/organisers/run-tournament.md +++ b/docs/user-guide/organisers/run-tournament.md @@ -231,7 +231,7 @@ A draw-ready competition is already listed on the shiai-jo operator views, since The **Pools** tab shows standings for every pool. Ranks are computed automatically from match results. Operators do not edit them by hand, with one exception: chusen (drawing lots), the last-resort tie-break for a consequential team-pool tie that a daihyosen cannot settle. Refer to [Recording decisions](../court-operators/recording-decisions.md). When a daihyosen settles a tie that determines pool advancement, the winning side carries a **DH** badge in the standings. -When two or more competitors are level on every ranking criterion and the tie decides who advances, the pool waits for a tie-break bout between them. Until it is scored, the tied competitors share one place, shown as **=2nd** for a tie over second place, and none of them carries the advancing mark. Once the bout is scored, each takes their own place and the winner is marked as advancing. +When two or more competitors are level on every ranking criterion, the app schedules tie-break bouts between them if the best-placed of them finishes within the **Winners per pool** places, because finishing place decides where a qualifier lands in the bracket. A tie wholly below those places gets no bout and shares its place. Until the bouts are scored, the tied competitors share one place, shown as **=2nd** for a tie over second place. A tie that runs across the advancing line leaves none of them with the advancing mark, while a tie inside the advancing places, such as two competitors level for first, reads **=1st** and keeps it. Once the bouts are scored, each takes their own place. A team pool settles a tie with a daihyosen instead, and if that round still leaves teams level, the pool waits for chusen. In an individual pool, each tied pair fights once, and no second round is scheduled if that leaves them level. Pool finishers move into the knockout bracket on their own: as soon as a pool's last match is scored, its qualifiers are seeded into their bracket slots. Those knockout matches can be scored without waiting for the other pools. Once the last pool is seeded, the competition moves to the knockout phase. The bracket updates in real time as results come in. A pool result corrected later still moves its qualifiers in the bracket; refer to [Correct a pool result after the knockout has started](../court-operators/scoring-a-match.md#correct-a-pool-result-after-the-knockout-has-started). diff --git a/scripts/e2e/fixtures/individual.mjs b/scripts/e2e/fixtures/individual.mjs index 05b2a234d..1e15de7fa 100644 --- a/scripts/e2e/fixtures/individual.mjs +++ b/scripts/e2e/fixtures/individual.mjs @@ -33,8 +33,8 @@ export const ROSTER_6B = [ ['Bo Grant', 'Gyokusen'], ['Cy Novak', 'Thames'], ['Di Weller', 'Musashi'], ['Ed Frost', 'Seishinkan'], ['Flo Adler', 'Mumeishi'], ['Gil Varga', 'Hizen'], ]; -// Seven for the minimum-pool-size journey (pools-tiebreak): three pools of 3 at -// a maximum of 3, but two pools (4 and 3) at a minimum of 3. Seven dojos, so the +// Seven for the minimum-pool-size journey (pools-tiebreak): three pools (3, 2 +// and 2) at a maximum of 3, but two pools (4 and 3) at a minimum of 3. Seven dojos, so the // draw has no dojo to keep apart and the pools are whatever the minimum rule cuts. export const ROSTER_7 = [ ['Hiro Sasaki', 'Kyoto Kai'], ['Ines Ferrer', 'Osaka Kan'], ['Joan Mercer', 'Nagoya Dojo'], diff --git a/scripts/e2e/fixtures/scoring.mjs b/scripts/e2e/fixtures/scoring.mjs index 6d16aa839..60aae76c2 100644 --- a/scripts/e2e/fixtures/scoring.mjs +++ b/scripts/e2e/fixtures/scoring.mjs @@ -105,7 +105,7 @@ export async function awardIppon(page, side, waza = 'M', root = INLINE_EDITOR) { // on). It is offered only while neither side holds a point. Returns once the // board shows the draw. export async function markDraw(page, root = INLINE_EDITOR) { - const toggle = page.locator(root).first().getByTestId('scoring-modal-mark-draw'); + const toggle = within(page, root).first().getByTestId('scoring-modal-mark-draw'); await toggle.tap(); await expect(toggle).toHaveText('Cancel draw'); } diff --git a/scripts/e2e/journeys/moved-match.spec.mjs b/scripts/e2e/journeys/moved-match.spec.mjs index 1adab5361..bc4792774 100644 --- a/scripts/e2e/journeys/moved-match.spec.mjs +++ b/scripts/e2e/journeys/moved-match.spec.mjs @@ -409,8 +409,9 @@ test.describe('moved-match', () => { // A; and no start it sends is refused for the court's status (court_busy), // whoever asked for it. The refusal half is read from // the network log, because the console policy would otherwise let the toast - // of the bead's report pass. An operator's own Start match is logged apart - // from a start the tab made by itself. The same fix retires shared-shiaijo's + // of the bead's report pass. The operator's own Start match in the editor + // sends no startOnly (it writes the editor's own board), so it never counts + // as a start by itself. 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 @@ -419,7 +420,6 @@ test.describe('moved-match', () => { test.fixme('bc-adna: the Scores tab never starts a match by itself', async () => { const scoreWrites = []; const pending = []; - let operatorStarting = false; const onResponse = async (res) => { const req = res.request(); if (req.method() !== 'PUT' || !/\/matches\/[^/]+\/score$/.test(new URL(res.url()).pathname)) return; @@ -433,7 +433,6 @@ test.describe('moved-match', () => { url: res.url(), status: res.status(), courtBusy: /"error"\s*:\s*"court_busy"/.test(body), - byOperator: operatorStarting, isStart, }); }; @@ -454,17 +453,12 @@ test.describe('moved-match', () => { await H.locator('.score-edit-row').filter({ hasText: m.shiro }).filter({ hasText: m.aka }).first().getByRole('button', { name: /^Score$/ }).tap(); await expect(ed).toBeVisible(); }; - // The operator's own Start match, logged apart from a start by itself. + // The operator's own Start match: the editor's write carries no startOnly. const start = async () => { const btn = ed.getByRole('button', { name: 'Start match' }); await settled(btn); - operatorStarting = true; - try { - await btn.tap(); - await expect(finishButton(H, EDITOR)).toBeVisible(); - } finally { - operatorStarting = false; - } + await btn.tap(); + await expect(finishButton(H, EDITOR)).toBeVisible(); }; await open(first); if (startsHere) await start(); @@ -490,7 +484,7 @@ test.describe('moved-match', () => { await Promise.all(pending); H.off('response', listener); expect.soft(scoreWrites.filter((w) => w.courtBusy), 'no start sent from the Scores tab was refused for the court').toEqual([]); - expect.soft(scoreWrites.filter((w) => w.isStart && !w.byOperator), 'the Scores tab sent no start by itself').toEqual([]); + expect.soft(scoreWrites.filter((w) => w.isStart), 'the Scores tab sent no start by itself').toEqual([]); } }); }); diff --git a/scripts/e2e/journeys/pools-tiebreak.spec.mjs b/scripts/e2e/journeys/pools-tiebreak.spec.mjs index 55913543b..2cdc2c00a 100644 --- a/scripts/e2e/journeys/pools-tiebreak.spec.mjs +++ b/scripts/e2e/journeys/pools-tiebreak.spec.mjs @@ -256,7 +256,13 @@ test.describe('pools: minimum pool size and a tie-break bout', () => { // The bracket: the tied place is not seated yet (it waits for the tie-break). await openCompetitionTab(page, compId, 'bracket'); - const seated = Object.values(await bracketSlots(page)).map((s) => s.name); + // Read until the settled pool's two qualifiers are seated, so a refetch + // still to land cannot make the tied pool's absence a false pass. + const settled = (slots) => { + const names = Object.values(slots).map((s) => s.name); + return names.includes(ranked[0]) && names.includes(ranked[1]); + }; + const seated = Object.values(await bracketSlots(page, settled)).map((s) => s.name); record({ journey: JOURNEY, step: 'tie-break', action: 'bracket before the bout', seated, screenshot: await shot(page, 'admin-bracket-before') }); expect(seated, 'the pool of 4 is settled: its two qualifiers are seated').toEqual(expect.arrayContaining([ranked[0], ranked[1]])); for (const name of [tb.b, tb.c]) { diff --git a/scripts/e2e/journeys/spectator.public.spec.mjs b/scripts/e2e/journeys/spectator.public.spec.mjs index 548828261..4b8aef32c 100644 --- a/scripts/e2e/journeys/spectator.public.spec.mjs +++ b/scripts/e2e/journeys/spectator.public.spec.mjs @@ -26,7 +26,7 @@ import { dwell } from '../fixtures/pace.mjs'; import { createTournament } from '../fixtures/setup.mjs'; import { createCompetition } from '../fixtures/wizard.mjs'; import { generateDraw, pasteRoster, startCompetition } from '../fixtures/competition.mjs'; -import { openShiaijo, sides, startUpNext, upNextCard } from '../fixtures/shiaijo.mjs'; +import { openShiaijo, sides, startUpNext, upNextCard, waitingRows } from '../fixtures/shiaijo.mjs'; import { awardIppon, clearMarks, filledSlots, finishMatch, syncState } from '../fixtures/scoring.mjs'; import { editorSides } from '../fixtures/individual.mjs'; import { @@ -272,11 +272,17 @@ test.describe('spectator on a phone', () => { // The app is never reloaded in this test: this marker is lost if it is. await page.evaluate(() => { window.__spectatorNoReload = 'kept'; }); // Court A's first bout, and the one after it (read off the operator's - // console); the followed competitor is in both (pool of three). + // console). The followed competitor is the one in BOTH: named from the + // queue, not assumed to be a given side of the first bout. await openShiaijo(op, 'A'); const first = await sides(upNextCard(op)); expect(first).toEqual(ids.first); - const followed = first.aka; + const queued = []; + for (const row of await waitingRows(op).all()) queued.push(await sides(row)); + const next = queued.find((q) => q.shiro !== first.shiro || q.aka !== first.aka); + expect(next, 'a second bout is queued on court A behind the first').toBeTruthy(); + const followed = [first.shiro, first.aka].find((n) => n === next.shiro || n === next.aka); + expect(followed, `a competitor is in both court A's first bout (${first.shiro} v ${first.aka}) and its next (${next.shiro} v ${next.aka})`).toBeTruthy(); await test.step('follow the Aka side of court A\'s first bout: the hero shows the match waiting', async () => { await watchInput(page).fill(followed.toLowerCase().slice(0, 5)); @@ -307,9 +313,9 @@ test.describe('spectator on a phone', () => { }); await test.step('the operator finishes the match: the open card reads FINAL, the hero moves to the next bout', async () => { - const next = await sides(upNextCard(op)); - const opponent = next.shiro === followed ? next.aka : next.aka === followed ? next.shiro : null; - expect(opponent, 'the next bout on court A also has the followed competitor (a pool of three)').not.toBeNull(); + const started = await sides(upNextCard(op)); + expect(started, 'the next bout is the one read off the queue').toEqual(next); + const opponent = started.shiro === followed ? started.aka : started.shiro; await finishMatch(op); await expect(card).toContainText('FINAL'); expect(await cardMarks(card)).toEqual({ shiro: 'M', aka: '' }); @@ -396,8 +402,9 @@ test.describe('spectator on a phone', () => { await shot(page, 'door-overview-now'); await finishMatch(op); - expect(await editorSides(op)).toEqual(match3); + await expect.poll(() => editorSides(op), { message: 'the operator\'s editor holds the third bout' }).toEqual(match3); await expect(nowCard(page), 'the ON NOW card moves to the started bout').toContainText(match3.aka); + await expect(nowCard(page), 'and shows both of its sides, not the finished bout\'s').toContainText(match3.shiro); await expect(expanded, 'the waiting bout is no longer in Up next').toHaveCount(0); expect(await cardMarks(nowCard(page))).toEqual({ shiro: '', aka: '' }); await awardIppon(op, 'shiro', 'M'); diff --git a/web-mobile/js/__tests__/render/pools_tied_place.render.test.jsx b/web-mobile/js/__tests__/render/pools_tied_place.render.test.jsx index 81fe6f568..b100d4c8d 100644 --- a/web-mobile/js/__tests__/render/pools_tied_place.render.test.jsx +++ b/web-mobile/js/__tests__/render/pools_tied_place.render.test.jsx @@ -19,6 +19,7 @@ const player = (id, name) => ({ id, name, dojo: `${name} Dojo` }); const A = player('a', 'Aoki'); const B = player('b', 'Baba'); const C = player('c', 'Cho'); +const D = player('d', 'Dan'); const pools = [{ poolName: 'Pool A', players: [A, B, C] }]; const competition = { id: 'c', name: 'C', format: 'mixed', kind: 'individual', poolWinners: 2 }; const tweaks = { showDojo: false }; @@ -31,16 +32,16 @@ const winner = row(A, 1, { wins: 2, losses: 0, draws: 0, ipponsGiven: 2, ipponsT const bout = (id, sideA, sideB, status = 'completed') => ({ id, sideA, sideB, status }); const regular = [bout('Pool A-0', A, B), bout('Pool A-1', A, C), bout('Pool A-2', B, C)]; -function renderPool(poolStandings, poolMatches) { +function renderPool(poolStandings, poolMatches, { comp = competition, poolList = pools } = {}) { const { container } = render( - , + , ); const byName = {}; container.querySelectorAll('table.pool__table tbody tr').forEach((tr) => { const badge = tr.querySelector('.rank-badge'); const text = tr.querySelector('.pool__player-name').textContent; - byName[[A, B, C].find((p) => text.includes(p.name)).name] = { + byName[[A, B, C, D].find((p) => text.includes(p.name)).name] = { badge: badge ? badge.textContent : '', badgeAdv: !!badge && badge.classList.contains('rank-badge--adv'), advancing: tr.classList.contains('advancing'), @@ -78,30 +79,78 @@ describe('a tie awaiting its tie-break bout', () => { }); }); +describe('a tie over the larger-pools extra qualifier place', () => { + // poolWinners 2 plus one extra place for an oversized pool (4 players over a + // minimum size of 3) sends three. The server owes a tie-break bout only for a + // tie whose best place is within poolWinners (engine.pendingTieBreaks), so a + // tie for 3rd and 4th gets none and must not be left reading as unresolved. + const oversized = [{ poolName: 'Pool A', players: [A, B, C, D] }]; + const extra = { ...competition, poolSize: 3, extraQualifiers: 'larger-pools' }; + const all4 = [ + bout('Pool A-0', A, B), bout('Pool A-1', A, C), bout('Pool A-2', A, D), + bout('Pool A-3', B, C), bout('Pool A-4', B, D), bout('Pool A-5', C, D), + ]; + + it('keeps the advancing mark on the place the draw order gave, since no bout is coming', () => { + const rows = renderPool([ + winner, + row(B, 2, { points: 6 }), + row(C, 3, { tied: true }), + row(D, 4, { tied: true }), + ], all4, { comp: extra, poolList: oversized }); + + expect(rows.Baba).toMatchObject({ badge: '2nd', badgeAdv: true, advancing: true }); + expect(rows.Cho).toMatchObject({ badge: '=3rd', badgeAdv: true, advancing: true, tied: true }); + expect(rows.Dan).toMatchObject({ badge: '=3rd', badgeAdv: false, advancing: false, tied: true }); + }); +}); + +describe('a three-way tie straddling the cut', () => { + it('shows all three at the same place, none advancing', () => { + const four = [{ poolName: 'Pool A', players: [A, B, C, D] }]; + const level = { tied: true }; + const rows = renderPool([ + winner, + row(B, 2, level), + row(C, 3, level), + row(D, 4, level), + ], [], { poolList: four }); + + expect(rows.Aoki).toMatchObject({ badge: '1st', advancing: true, tied: false }); + for (const name of ['Baba', 'Cho', 'Dan']) { + expect(rows[name]).toMatchObject({ badge: '=2nd', badgeAdv: false, advancing: false, tied: true }); + } + }); +}); + describe('tiedPlaces', () => { const rows = (...r) => r; it('shares the best place across a group and says whether it straddles the cut', () => { - const m = tiedPlaces(rows(winner, row(B, 2, { tied: true }), row(C, 3, { tied: true })), 2); - expect(m.get('b')).toEqual({ place: 2, decidesAdvancement: true }); - expect(m.get('c')).toEqual({ place: 2, decidesAdvancement: true }); + const m = tiedPlaces(rows(winner, row(B, 2, { tied: true }), row(C, 3, { tied: true })), 2, 2); + expect(m.get('b')).toEqual({ place: 2, boutOwed: true, decidesAdvancement: true }); + expect(m.get('c')).toEqual({ place: 2, boutOwed: true, decidesAdvancement: true }); expect(m.has('a')).toBe(false); }); it('keeps the advancing mark for a tie that sits wholly inside the qualifying places', () => { const level = { points: 5, tied: true }; - const m = tiedPlaces(rows(row(A, 1, level), row(B, 2, level), row(C, 3, { points: 1 })), 2); - expect(m.get('a')).toEqual({ place: 1, decidesAdvancement: false }); - expect(m.get('b')).toEqual({ place: 1, decidesAdvancement: false }); + const m = tiedPlaces(rows(row(A, 1, level), row(B, 2, level), row(C, 3, { points: 1 })), 2, 2); + expect(m.get('a')).toEqual({ place: 1, boutOwed: true, decidesAdvancement: false }); + expect(m.get('b')).toEqual({ place: 1, boutOwed: true, decidesAdvancement: false }); + }); + + it('holds no bout for a tie over the extra qualifier place, which poolWinners does not cover', () => { + const m = tiedPlaces(rows(winner, row(B, 2, { points: 6 }), row(C, 3, { tied: true }), row(D, 4, { tied: true })), 3, 2); + expect(m.get('c')).toEqual({ place: 3, boutOwed: false, decidesAdvancement: false }); }); it('keeps two separate tied groups apart', () => { - const D = player('d', 'Dan'); const m = tiedPlaces(rows( row(A, 1, { points: 9, tied: true }), row(B, 2, { points: 9, tied: true }), row(C, 3, { points: 4, tied: true }), row(D, 4, { points: 4, tied: true }), - ), 2); + ), 2, 2); expect(m.get('a').place).toBe(1); expect(m.get('c').place).toBe(3); - expect(m.get('d')).toEqual({ place: 3, decidesAdvancement: false }); + expect(m.get('d')).toEqual({ place: 3, boutOwed: false, decidesAdvancement: false }); }); }); diff --git a/web-mobile/js/viewer_standings.jsx b/web-mobile/js/viewer_standings.jsx index bba11cdc8..97c5b01b7 100644 --- a/web-mobile/js/viewer_standings.jsx +++ b/web-mobile/js/viewer_standings.jsx @@ -706,49 +706,37 @@ export const PoolNumberedMatchRow = React.memo(({ m, num, onMatchClick, isEngi } }); PoolNumberedMatchRow.displayName = "PoolNumberedMatchRow"; -// qualifiersForPool: how many top-ranked finishers in `pool` advance to the -// knockout (bc-qual LP-5a). PoolsViewer uses this PER POOL instead of a -// single count across every pool, so an oversized pool's extra qualifier -// (state.ExtraQualifiersLargerPools) is highlighted correctly, e.g. a -// 4-person pool at minimum size 3 with poolWinners=1 highlights its top 2, -// not just its winner. -// -// Mirrors state.Competition.QualifiersForPool's OVERSIZED test exactly (the -// "trivial larger-pools size test" this client-side module is allowed to -// duplicate, per the mp-3abe-adjacent rule against re-deriving the engine's -// own oversized/draft logic in JS): a pool is oversized when it holds more -// players than the competition's configured minimum PoolSize. -// competition.poolWinners is the RAW field (may be 0/unset); default to 2 -// exactly like EffectivePoolWinners() does, since this payload doesn't -// expose that method. -// -// Deliberately does NOT attempt the fill-bracket drafted-2nd calculation -// (helper.SelectFillBracketDrafts): drafts go to the SEEDED pools in seed -// order (oversized as fallback), stopping once the bracket's shortfall is -// met and skipping a candidate whose opposite half is full -- a WHOLE- -// competition scan over the pool set plus the court layout, which no -// per-pool test can reproduce. The per-pool seed ranks ARE on this wire -// (pools[].players[].seed comes through pools.csv), so it is the cross-pool -// stop-and-skip state that is missing, not the seed data. Under fill-bracket -// this therefore returns the uniform EffectivePoolWinners() count, so only -// rank 1 (never a drafted rank 2) is ever highlighted as advancing here; the -// residue is a deliberate accepted gap, not a bug: a correct highlight needs -// the server to say WHICH pools were drafted, which is future work if this -// becomes worth wiring (bc-qual LP-5a report). +// effectivePoolWinners: the number of finishers per pool that the SERVER counts +// as the pool's places that matter. Mirrors state.Competition.EffectivePoolWinners +// (an unset or non-positive poolWinners is 2), which is the cut +// engine.pendingTieBreaks / tieAffectsAdvancement hold a tie-break bout against: +// a bout is owed only for a tie whose best-placed member finishes within it. It +// does NOT include the larger-pools extra qualifier (qualifiersForPool adds that). +export function effectivePoolWinners(competition) { + return (competition && competition.poolWinners) > 0 ? competition.poolWinners : 2; +} + // tiedPlaces: the place each still-open tie holds, keyed by standings row. // // The server marks a row `tied` while the competitors level with it on EVERY // criterion await a tie-break bout, but each row still carries its own sorted // rank (draw order breaks the level pair), so reading `rank` alone badges one // of them 2nd and the other 3rd as though the order were decided. A tied group -// shares its best member's place, and sits wholly inside the qualifying places -// or not: `decidesAdvancement` is true only when the group straddles the cut, -// so a pair level for 1st that both advance anyway still reads as advancing. +// shares its best member's place. +// +// Two cuts, because the server uses two. `tieBreakCut` is where it owes a bout +// (effectivePoolWinners): `boutOwed` is true when the group's best place is +// within it. `qualifiers` is how many advance (qualifiersForPool, with the +// larger-pools extra place): `decidesAdvancement` is true only when a bout is +// owed AND the group straddles that line, so a pair level for 1st that both +// advance anyway still reads as advancing, and a tie over the extra +// larger-pools place, for which the server holds no bout, is never left +// waiting for one that will not come. // `points` is the server's packed ranking key (engine individual/teamStandingPoints), // equal exactly when two rows are level on every criterion, so rows that share // it among the tied ones form one group. Pool standings only: a league has no // advancing mark to withhold. -export function tiedPlaces(poolStandings, qualifiers) { +export function tiedPlaces(poolStandings, qualifiers, tieBreakCut) { const groups = new Map(); (poolStandings || []).forEach((s, i) => { if (!s || !s.tied) return; @@ -766,13 +754,52 @@ export function tiedPlaces(poolStandings, qualifiers) { if (!s || !s.tied) return; const g = groups.get(s.points); const key = s.player.id || `${s.player.name}||${s.player.dojo || ""}`; - byRow.set(key, { place: g.place, decidesAdvancement: g.place <= qualifiers && g.last > qualifiers }); + const boutOwed = g.place <= tieBreakCut; + byRow.set(key, { place: g.place, boutOwed, decidesAdvancement: boutOwed && g.last > qualifiers }); }); return byRow; } +// The tooltip on a tied row's badge. It promises a deciding bout only where the +// server owes one (`boutOwed`); a tie it holds none for just reads as level. A +// team pool settles with a representative bout, an individual one with a +// tie-break bout (engine.pendingTieBreaks). +function tieBadgeTitle(tie, isTeam) { + const level = "Level on every criterion"; + if (!tie.boutOwed) return level; + return `${level}: ${isTeam ? "a representative bout" : "a tie-break bout"} decides the order`; +} + +// qualifiersForPool: how many top-ranked finishers in `pool` advance to the +// knockout (bc-qual LP-5a). PoolsViewer uses this PER POOL instead of a +// single count across every pool, so an oversized pool's extra qualifier +// (state.ExtraQualifiersLargerPools) is highlighted correctly, e.g. a +// 4-person pool at minimum size 3 with poolWinners=1 highlights its top 2, +// not just its winner. +// +// Mirrors state.Competition.QualifiersForPool's OVERSIZED test exactly (the +// "trivial larger-pools size test" this client-side module is allowed to +// duplicate, per the mp-3abe-adjacent rule against re-deriving the engine's +// own oversized/draft logic in JS): a pool is oversized when it holds more +// players than the competition's configured minimum PoolSize. +// The base count is effectivePoolWinners (the payload doesn't expose +// EffectivePoolWinners() itself, so it is mirrored above). +// +// Deliberately does NOT attempt the fill-bracket drafted-2nd calculation +// (helper.SelectFillBracketDrafts): drafts go to the SEEDED pools in seed +// order (oversized as fallback), stopping once the bracket's shortfall is +// met and skipping a candidate whose opposite half is full -- a WHOLE- +// competition scan over the pool set plus the court layout, which no +// per-pool test can reproduce. The per-pool seed ranks ARE on this wire +// (pools[].players[].seed comes through pools.csv), so it is the cross-pool +// stop-and-skip state that is missing, not the seed data. Under fill-bracket +// this therefore returns the uniform EffectivePoolWinners() count, so only +// rank 1 (never a drafted rank 2) is ever highlighted as advancing here; the +// residue is a deliberate accepted gap, not a bug: a correct highlight needs +// the server to say WHICH pools were drafted, which is future work if this +// becomes worth wiring (bc-qual LP-5a report). export function qualifiersForPool(competition, pool) { - const base = (competition && competition.poolWinners) || 2; + const base = effectivePoolWinners(competition); if ( competition && competition.extraQualifiers === "larger-pools" && @@ -849,7 +876,7 @@ export function PoolsViewer({ pools, standings, poolMatches, tweaks, competition // A tie still waiting for its tie-break bout reads as tied (see // tiedPlaces): the level rows share one place, and none of them is // marked advancing when the tie decides who goes through. - const tiedByRow = isLeague ? new Map() : tiedPlaces(poolStandings, poolQualifiers); + const tiedByRow = isLeague ? new Map() : tiedPlaces(poolStandings, poolQualifiers, effectivePoolWinners(competition)); // A pool has nothing to rank until at least one bout is decided: every // competitor is tied at 0 and the backend rank is merely the seed/draw @@ -931,7 +958,7 @@ export function PoolsViewer({ pools, standings, poolMatches, tweaks, competition {poolHasResults && rank !== null ? ( {tie ? `=${rankOrdinal(tie.place)}` : rankOrdinal(rank)}{s && s.isOverridden ? "*" : ""} From 8f60a981d6dcecdfd23bbf22040e1f80bf5eb8c3 Mon Sep 17 00:00:00 2001 From: Ricardo Oliveira Date: Wed, 14 Oct 2026 10:32:32 +0100 Subject: [PATCH 27/31] fix(bc-xqtb): a tie over a larger-pools extra qualifier place gets its tie-break bout The engine held tie-break bouts (and team daihyosen, chusen candidates and the requalification wait) against EffectivePoolWinners, so a tie over the extra place an oversized pool sends under Oversized send +1 got no bout and its qualifier was seated on the standings' arbitrary order. poolTieBreakCut is now the one cut, the pool's own QualifiersForPool, read by every tie path. The standings badge follows the same single cut, the docs paragraph names the extra place, and the pools-tiebreak journey plays a larger-pools tie over the 2nd place on court B. Co-Authored-By: Claude Opus 5.5 --- docs/user-guide/organisers/run-tournament.md | 2 +- internal/engine/chusen.go | 8 +- internal/engine/daihyosen.go | 6 +- .../engine/pool_daihyosen_bandaware_test.go | 14 +- internal/engine/pool_requalify.go | 6 +- .../engine/tiebreak_extra_qualifier_test.go | 273 +++++++++++++++ internal/engine/tiebreaker.go | 53 ++- scripts/e2e/README.md | 4 +- scripts/e2e/fixtures/wizard.mjs | 17 +- scripts/e2e/journeys/pools-tiebreak.spec.mjs | 331 +++++++++++++++--- .../render/pools_tied_place.render.test.jsx | 70 +++- web-mobile/js/viewer_standings.jsx | 39 +-- 12 files changed, 700 insertions(+), 123 deletions(-) create mode 100644 internal/engine/tiebreak_extra_qualifier_test.go diff --git a/docs/user-guide/organisers/run-tournament.md b/docs/user-guide/organisers/run-tournament.md index 9c50dfd87..a59735fde 100644 --- a/docs/user-guide/organisers/run-tournament.md +++ b/docs/user-guide/organisers/run-tournament.md @@ -231,7 +231,7 @@ A draw-ready competition is already listed on the shiai-jo operator views, since The **Pools** tab shows standings for every pool. Ranks are computed automatically from match results. Operators do not edit them by hand, with one exception: chusen (drawing lots), the last-resort tie-break for a consequential team-pool tie that a daihyosen cannot settle. Refer to [Recording decisions](../court-operators/recording-decisions.md). When a daihyosen settles a tie that determines pool advancement, the winning side carries a **DH** badge in the standings. -When two or more competitors are level on every ranking criterion, the app schedules tie-break bouts between them if the best-placed of them finishes within the **Winners per pool** places, because finishing place decides where a qualifier lands in the bracket. A tie wholly below those places gets no bout and shares its place. Until the bouts are scored, the tied competitors share one place, shown as **=2nd** for a tie over second place. A tie that runs across the advancing line leaves none of them with the advancing mark, while a tie inside the advancing places, such as two competitors level for first, reads **=1st** and keeps it. Once the bouts are scored, each takes their own place. A team pool settles a tie with a daihyosen instead, and if that round still leaves teams level, the pool waits for chusen. In an individual pool, each tied pair fights once, and no second round is scheduled if that leaves them level. +When two or more competitors are level on every ranking criterion, the app schedules tie-break bouts between them if the best-placed of them finishes within the pool's qualifying places, because finishing place decides where a qualifier lands in the bracket. Those places are **Winners per pool**, plus the extra place an oversized pool sends under **Oversized send +1**, so a tie over that extra place gets a bout too. A tie wholly below the qualifying places gets no bout and shares its place. The pool's qualifiers are seated in the bracket only once the bouts are scored. Until the bouts are scored, the tied competitors share one place, shown as **=2nd** for a tie over second place. A tie that runs across the advancing line leaves none of them with the advancing mark, while a tie inside the advancing places, such as two competitors level for first, reads **=1st** and keeps it. Once the bouts are scored, each takes their own place. A team pool settles a tie with a daihyosen instead, and if that round still leaves teams level, the pool waits for chusen. In an individual pool, each tied pair fights once, and no second round is scheduled if that leaves them level. Pool finishers move into the knockout bracket on their own: as soon as a pool's last match is scored, its qualifiers are seeded into their bracket slots. Those knockout matches can be scored without waiting for the other pools. Once the last pool is seeded, the competition moves to the knockout phase. The bracket updates in real time as results come in. A pool result corrected later still moves its qualifiers in the bracket; refer to [Correct a pool result after the knockout has started](../court-operators/scoring-a-match.md#correct-a-pool-result-after-the-knockout-has-started). diff --git a/internal/engine/chusen.go b/internal/engine/chusen.go index 6543efdcb..9105aae8b 100644 --- a/internal/engine/chusen.go +++ b/internal/engine/chusen.go @@ -198,7 +198,10 @@ func (e *Engine) ChusenStatus(compID string) (ChusenReport, error) { if overridesObj != nil { poolRanks = overridesObj.PoolRanks } - poolWinners := comp.EffectivePoolWinners() + pools, err := e.store.LoadPools(compID) + if err != nil { + return report, err + } poolNames := make([]string, 0, len(standings)) for name := range standings { @@ -209,9 +212,10 @@ func (e *Engine) ChusenStatus(compID string) (ChusenReport, error) { for _, poolName := range poolNames { poolStandings := standings[poolName] groupOverrides := poolRanks[poolName] + cut := poolTieBreakCut(comp, pools, poolName) for _, positions := range detectPoolTies(poolStandings) { // Only a tie that affects advancement/seed warrants a decider at all. - if !tieAffectsAdvancement(positions, poolWinners) { + if !tieAffectsAdvancement(positions, cut) { continue } group := standingsAt(poolStandings, positions) diff --git a/internal/engine/daihyosen.go b/internal/engine/daihyosen.go index 1e74bd58b..599f46154 100644 --- a/internal/engine/daihyosen.go +++ b/internal/engine/daihyosen.go @@ -238,6 +238,10 @@ func (e *Engine) InjectPoolDaihyosenMatches(compID string) ([]state.MatchResult, return err } e.noteMatchRead(compID) + pools, err := tx.LoadPools(compID) + if err != nil { + return err + } // Scan existing DH matches per pool for idempotency and ID sequencing. // poolNameFromMatchID strips the trailing suffix deterministically so @@ -281,7 +285,7 @@ func (e *Engine) InjectPoolDaihyosenMatches(compID string) ([]state.MatchResult, if regularIncomplete[poolName] { continue } - for _, p := range pendingTieBreaks(comp, poolName, poolStandings, poolDH[poolName], poolCourt[poolName], true) { + for _, p := range pendingTieBreaks(comp, poolName, poolTieBreakCut(comp, pools, poolName), poolStandings, poolDH[poolName], poolCourt[poolName], true) { injected = append(injected, p.bouts...) } } diff --git a/internal/engine/pool_daihyosen_bandaware_test.go b/internal/engine/pool_daihyosen_bandaware_test.go index 29c9d0885..fb41df20d 100644 --- a/internal/engine/pool_daihyosen_bandaware_test.go +++ b/internal/engine/pool_daihyosen_bandaware_test.go @@ -13,7 +13,9 @@ import ( // These tests pin the band-aware rule for the pool daihyosen: a supplementary // bout is played only where the tie affects who advances (or their seed). The -// top `poolWinners` of each pool advance and 1st place gets a bracket bye, so a +// top `poolWinners` of each pool advance (the pool's own qualifier count, which +// is larger for an oversized pool under larger-pools; see +// tiebreak_extra_qualifier_test.go) and 1st place gets a bracket bye, so a // tie whose best position is within [1..poolWinners] is consequential, while a // tie sitting entirely below the cut shares its rank with no bout // (running_a_kendo_tournament.md:441 - a daihyosen is played "to determine @@ -21,15 +23,19 @@ import ( // setupTeamPoolWinners is setupTeamPool with an explicit advancement cutoff so a // tie can be placed above, across, or below the qualifying band. -func setupTeamPoolWinners(t *testing.T, compID string, teams []string, poolWinners int, matches []state.MatchResult) (*Engine, *state.Store) { +func setupTeamPoolWinners(t *testing.T, compID string, teams []string, poolWinners int, matches []state.MatchResult, opts ...func(*state.Competition)) (*Engine, *state.Store) { t.Helper() eng, store, _ := setupTestEngine(t) - require.NoError(t, store.SaveCompetition(&state.Competition{ + comp := &state.Competition{ ID: compID, Name: "Band-aware DH Test", Format: state.CompFormatLeague, Status: state.CompStatusPools, Courts: []string{"A"}, Kind: "team", TeamSize: 2, PoolWinners: poolWinners, - })) + } + for _, o := range opts { + o(comp) + } + require.NoError(t, store.SaveCompetition(comp)) players := make([]helper.Player, len(teams)) for i, n := range teams { players[i] = helper.Player{Name: n} diff --git a/internal/engine/pool_requalify.go b/internal/engine/pool_requalify.go index 2e720117a..daeee80e0 100644 --- a/internal/engine/pool_requalify.go +++ b/internal/engine/pool_requalify.go @@ -149,7 +149,7 @@ func (e *Engine) planRequalification(tx state.StoreTx, compID string, comp *stat // label. Engi places are decided by the standings' own order. tied := map[string]bool{} if !comp.Engi { - tied, err = e.tiedQualifyingLabels(tx, compID, comp, poolName, standings[poolName], ranksNeeded) + tied, err = e.tiedQualifyingLabels(tx, compID, comp, poolName, comp.QualifiersForPool(*pool), standings[poolName], ranksNeeded) if err != nil { return nil, err } @@ -253,7 +253,7 @@ func rankOfLabel(poolName, label string, ranksNeeded int) int { // tiedQualifyingLabels returns the pool's qualifying labels whose rank sits in // a tied group that still owes a supplementary bout (pendingTieBreaks, the // injectors' own rule), read through the transaction. -func (e *Engine) tiedQualifyingLabels(tx state.StoreTx, compID string, comp *state.Competition, poolName string, poolStandings []state.PlayerStanding, ranksNeeded int) (map[string]bool, error) { +func (e *Engine) tiedQualifyingLabels(tx state.StoreTx, compID string, comp *state.Competition, poolName string, cut int, poolStandings []state.PlayerStanding, ranksNeeded int) (map[string]bool, error) { rows, err := tx.LoadPoolMatches(compID) if err != nil { return nil, fmt.Errorf("requalification: load pool matches for %s: %w", compID, err) @@ -273,7 +273,7 @@ func (e *Engine) tiedQualifyingLabels(tx state.StoreTx, compID string, comp *sta } } tied := map[string]bool{} - for _, p := range pendingTieBreaks(comp, poolName, poolStandings, existing, court, daihyosen) { + for _, p := range pendingTieBreaks(comp, poolName, cut, poolStandings, existing, court, daihyosen) { for _, pos := range p.positions { if pos+1 <= ranksNeeded { tied[qualifierLabel(poolName, pos+1)] = true diff --git a/internal/engine/tiebreak_extra_qualifier_test.go b/internal/engine/tiebreak_extra_qualifier_test.go new file mode 100644 index 000000000..8c17d9289 --- /dev/null +++ b/internal/engine/tiebreak_extra_qualifier_test.go @@ -0,0 +1,273 @@ +package engine + +import ( + "fmt" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/gitrgoliveira/bracket-creator/internal/domain" + "github.com/gitrgoliveira/bracket-creator/internal/helper" + "github.com/gitrgoliveira/bracket-creator/internal/state" + bctest "github.com/gitrgoliveira/bracket-creator/internal/test/idstamp" +) + +// bc-xqtb: with extraQualifiers larger-pools an oversized pool sends one more +// qualifier than Winners per pool, so a tie over THAT place decides who goes +// through exactly as a tie over any other qualifying place does. The cut a tie +// is judged against is the pool's own qualifier count (poolTieBreakCut, which is +// Competition.QualifiersForPool), never the uniform Winners per pool. + +// largerPools makes c a minimum-size competition whose pools above poolSize +// players send one extra qualifier. +func largerPools(poolSize int) func(*state.Competition) { + return func(c *state.Competition) { + c.PoolSize = poolSize + c.ExtraQualifiers = state.ExtraQualifiersLargerPools + } +} + +// poolBouts numbers a pool's completed bouts in the order given; a winner of "" +// is a hikiwake. +func poolBouts(pool string, pairs ...[3]string) []state.MatchResult { + out := make([]state.MatchResult, len(pairs)) + for i, p := range pairs { + out[i] = state.MatchResult{ + ID: fmt.Sprintf("%s-%d", pool, i), SideA: p[0], SideB: p[1], + Status: state.MatchStatusCompleted, Winner: p[2], Court: "A", + } + if p[2] == "" { + out[i].Decision = string(domain.DecisionHikiwake) + } + } + return out +} + +// Four players: Alice unbeaten, Bob and Carol level for 2nd (each beat Dave and +// drew each other), Dave last. +func fourPlayerTieForSecond() []state.MatchResult { + return poolBouts("Pool A", + [3]string{"Alice", "Bob", "Alice"}, [3]string{"Alice", "Carol", "Alice"}, + [3]string{"Alice", "Dave", "Alice"}, [3]string{"Bob", "Carol", ""}, + [3]string{"Bob", "Dave", "Bob"}, [3]string{"Carol", "Dave", "Carol"}) +} + +// Five players, Alice and Bob on top in that order. Carol and Dave are level +// for 3rd (one win over Erin each, a hikiwake between them); Erin is last. +func fivePlayerTieForThird() []state.MatchResult { + return poolBouts("Pool A", + [3]string{"Alice", "Bob", "Alice"}, [3]string{"Alice", "Carol", "Alice"}, + [3]string{"Alice", "Dave", "Alice"}, [3]string{"Alice", "Erin", "Alice"}, + [3]string{"Bob", "Carol", "Bob"}, [3]string{"Bob", "Dave", "Bob"}, + [3]string{"Bob", "Erin", "Bob"}, [3]string{"Carol", "Dave", ""}, + [3]string{"Carol", "Erin", "Carol"}, [3]string{"Dave", "Erin", "Dave"}) +} + +// Five players with a strict top three; Dave and Erin are level for 4th. +func fivePlayerTieForFourth() []state.MatchResult { + return poolBouts("Pool A", + [3]string{"Alice", "Bob", "Alice"}, [3]string{"Alice", "Carol", "Alice"}, + [3]string{"Alice", "Dave", "Alice"}, [3]string{"Alice", "Erin", "Alice"}, + [3]string{"Bob", "Carol", "Bob"}, [3]string{"Bob", "Dave", "Bob"}, + [3]string{"Bob", "Erin", "Bob"}, [3]string{"Carol", "Dave", "Carol"}, + [3]string{"Carol", "Erin", "Carol"}, [3]string{"Dave", "Erin", ""}) +} + +func TestInjectTiebreaker_CutIsThePoolsOwnQualifierCount(t *testing.T) { + four := []string{"Alice", "Bob", "Carol", "Dave"} + five := []string{"Alice", "Bob", "Carol", "Dave", "Erin"} + tests := []struct { + name string + players []string + matches []state.MatchResult + opts []func(*state.Competition) + want [][2]string // the injected bouts' sides + }{ + { + // The shape the wizard allows: larger-pools locks Winners per pool at + // 1, so the oversized pool's extra place is its 2nd. + name: "larger-pools, 1 winner, 4 players, 2nd and 3rd level: the extra 2nd place is tie-broken", + players: four, matches: fourPlayerTieForSecond(), + opts: []func(*state.Competition){largerPools(3), func(c *state.Competition) { c.PoolWinners = 1 }}, + want: [][2]string{{"Bob", "Carol"}}, + }, + { + name: "1 winner, no extra qualifiers: a tie for 2nd shares its place with no bout", + players: four, matches: fourPlayerTieForSecond(), + opts: []func(*state.Competition){func(c *state.Competition) { c.PoolWinners = 1 }}, + }, + { + name: "larger-pools, 4 players, 3rd and 4th level: the extra place is tie-broken", + players: four, matches: fourPlayerOneTiedPairTB(), + opts: []func(*state.Competition){largerPools(3)}, + want: [][2]string{{"Carol", "Dave"}}, + }, + { + name: "no extra qualifiers: the same tie shares its place with no bout", + players: four, matches: fourPlayerOneTiedPairTB(), + }, + { + name: "larger-pools but the pool is not oversized: no extra place to break", + players: four, matches: fourPlayerOneTiedPairTB(), + opts: []func(*state.Competition){largerPools(4)}, + }, + { + name: "larger-pools, 5 players, 3rd and 4th level: the extra place is tie-broken", + players: five, matches: fivePlayerTieForThird(), + opts: []func(*state.Competition){largerPools(3)}, + want: [][2]string{{"Carol", "Dave"}}, + }, + { + name: "larger-pools, a tie wholly below the extra place gets no bout", + players: five, matches: fivePlayerTieForFourth(), + opts: []func(*state.Competition){largerPools(3)}, + }, + { + name: "no extra qualifiers: a tie for 3rd shares its place with no bout", + players: five, matches: fivePlayerTieForThird(), + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + eng, store, _ := setupTestEngine(t) + comp := &state.Competition{ + ID: "xqtb", Name: "xqtb", Format: state.CompFormatMixed, + Status: state.CompStatusPools, Courts: []string{"A"}, PoolWinners: 2, + } + for _, o := range tc.opts { + o(comp) + } + require.NoError(t, store.SaveCompetition(comp)) + players := make([]helper.Player, len(tc.players)) + for i, n := range tc.players { + players[i] = helper.Player{Name: n, Dojo: "Dojo " + n} + } + bctest.StampIDs(players, tc.matches) + require.NoError(t, store.SavePools("xqtb", []helper.Pool{{PoolName: "Pool A", Players: players}})) + require.NoError(t, store.SavePoolMatches("xqtb", tc.matches)) + + injected, err := eng.InjectTiebreakerMatches("xqtb") + require.NoError(t, err) + got := make([][2]string, len(injected)) + for i, m := range injected { + got[i] = [2]string{m.SideA, m.SideB} + } + if len(tc.want) == 0 { + assert.Empty(t, got) + return + } + assert.Equal(t, tc.want, got) + }) + } +} + +func TestInjectPoolDaihyosen_CutIsThePoolsOwnQualifierCount(t *testing.T) { + tests := []struct { + name string + opts []func(*state.Competition) + want int + }{ + {"larger-pools, 4 teams, 3rd and 4th level: the extra place gets a representative bout", []func(*state.Competition){largerPools(3)}, 1}, + {"no extra qualifiers: the same tie shares its place", nil, 0}, + {"larger-pools but the pool is not oversized", []func(*state.Competition){largerPools(4)}, 0}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + eng, _ := setupTeamPoolWinners(t, "dh-xqtb", []string{"Alpha", "Beta", "Gamma", "Delta"}, 2, fourTeamOneTiedPair(), tc.opts...) + injected, err := eng.InjectPoolDaihyosenMatches("dh-xqtb") + require.NoError(t, err) + require.Len(t, injected, tc.want) + if tc.want == 1 { + got := map[string]bool{injected[0].SideA: true, injected[0].SideB: true} + assert.True(t, got["Gamma"] && got["Delta"], "the representative bout is between the level teams") + } + }) + } +} + +// A representative bout that leaves the extra-place tie level is a chusen, so the +// operator is offered the lots-draw for it. +func TestChusenStatus_CutIsThePoolsOwnQualifierCount(t *testing.T) { + for _, tc := range []struct { + name string + opts []func(*state.Competition) + want int + }{ + {"larger-pools: a level extra place needs a chusen", []func(*state.Competition){largerPools(3)}, 1}, + {"no extra qualifiers: nothing to draw", nil, 0}, + } { + t.Run(tc.name, func(t *testing.T) { + compID := "chusen-xqtb" + eng, store := setupTeamPoolWinners(t, compID, []string{"Alpha", "Beta", "Gamma", "Delta"}, 2, fourTeamOneTiedPair(), tc.opts...) + _, err := eng.InjectPoolDaihyosenMatches(compID) + require.NoError(t, err) + all, err := store.LoadPoolMatches(compID) + require.NoError(t, err) + for i := range all { + if IsPoolDaihyosenMatchID(all[i].ID) { + all[i].Status = state.MatchStatusCompleted + all[i].Winner = "" // hikiwake: the round leaves them level + } + } + require.NoError(t, store.SavePoolMatches(compID, all)) + eng.standingsCache.Delete(compID) + eng.standingsFlight.Delete(compID) + + report, err := eng.ChusenStatus(compID) + require.NoError(t, err) + assert.Len(t, report.Pending, tc.want) + }) + } +} + +// The whole path: the extra place's tie holds the pool open, the bout is +// scored, and its winner is seated in the knockout slot of that place. +func TestLargerPoolsExtraPlaceTie_QualificationWaitsForTheBout(t *testing.T) { + f := newRQFixture(t, "rq-xqtb", 2, [][]string{{"A1", "A2", "A3", "A4"}, {"B1", "B2", "B3"}}, largerPools(3)) + require.NoError(t, f.store.SaveBracket(f.compID, &state.Bracket{Rounds: [][]state.BracketMatch{{ + {ID: "m-r1-0", MatchNumber: 1, SideA: "Pool A-1st", SideB: "Pool B-2nd", PlaceholderA: "Pool A-1st", PlaceholderB: "Pool B-2nd"}, + {ID: "m-r1-1", MatchNumber: 2, SideA: "Pool B-1st", SideB: "Pool A-2nd", PlaceholderA: "Pool B-1st", PlaceholderB: "Pool A-2nd"}, + {ID: "m-r1-2", MatchNumber: 3, SideA: "Pool A-3rd", SideB: "Pool B-3rd", PlaceholderA: "Pool A-3rd", PlaceholderB: "Pool B-3rd"}, + }}})) + // Pool A: A1 beats all, A2 beats A3 and A4, A3 and A4 draw: level for 3rd, + // the place only an oversized pool sends. + for _, w := range [][2]string{{"Pool A-0", "A1"}, {"Pool A-1", "A1"}, {"Pool A-2", "A1"}, {"Pool A-3", "A2"}, {"Pool A-4", "A2"}} { + f.scorePool(w[0], w[1]) + } + require.NoError(t, f.write("Pool A-5", &state.MatchResult{ + SideA: "A3", SideB: "A4", SideAID: rqID("A3"), SideBID: rqID("A4"), + Status: state.MatchStatusCompleted, Decision: string(domain.DecisionHikiwake), + })) + for _, w := range [][2]string{{"Pool B-0", "B1"}, {"Pool B-1", "B1"}, {"Pool B-2", "B2"}} { + f.scorePool(w[0], w[1]) + } + + _, err := f.eng.MaybeAutoCompletePools(f.compID) + require.NoError(t, err) + var tbs []state.MatchResult + matches, err := f.store.LoadPoolMatches(f.compID) + require.NoError(t, err) + for _, m := range matches { + if IsTiebreakerMatchID(m.ID) { + tbs = append(tbs, m) + } + } + require.Len(t, tbs, 1, "the tie over the extra qualifier place is owed a tie-break bout") + assert.ElementsMatch(t, []string{"A3", "A4"}, []string{tbs[0].SideA, tbs[0].SideB}) + + m, side := f.slot("Pool A-3rd") + name, _ := sideOf(m, side) + assert.Equal(t, "Pool A-3rd", name, "the place stays open while the bout is unplayed") + + f.scorePool(tbs[0].ID, "A4") + _, err = f.eng.MaybeAutoCompletePools(f.compID) + require.NoError(t, err) + m, side = f.slot("Pool A-3rd") + name, id := sideOf(m, side) + assert.Equal(t, "A4", name, "the bout's winner takes the extra place") + assert.Equal(t, rqID("A4"), id) + m, side = f.slot("Pool A-2nd") + name, _ = sideOf(m, side) + assert.Equal(t, "A2", name, "the rest of the pool is seated once the bout is played") +} diff --git a/internal/engine/tiebreaker.go b/internal/engine/tiebreaker.go index 2d2174720..8ce0ea33e 100644 --- a/internal/engine/tiebreaker.go +++ b/internal/engine/tiebreaker.go @@ -4,6 +4,7 @@ import ( "fmt" "sort" + "github.com/gitrgoliveira/bracket-creator/internal/helper" "github.com/gitrgoliveira/bracket-creator/internal/state" ) @@ -217,28 +218,44 @@ func tieSettled(group []state.PlayerStanding, matches []state.MatchResult, isTea // positions from detectPoolTies over the sorted standings) can change the pool // outcome and therefore warrants a supplementary bout. // -// A pool seeds its knockout from the top EffectivePoolWinners of each pool, and +// A pool seeds its knockout from its top qualifying places (poolTieBreakCut), and // finishing position decides where in the bracket a qualifier lands: the 1st // stays in its own block while lower finishers cross to a partner block half the // bracket away (R4), and a block's structural bye goes to its highest-precedence // occupant (R6, helper.BuildKnockoutDraw). That occupant is NOT necessarily a // pool winner -- a block may hold only crossed-in qualifiers, and R5's separation // can take a bye off a winner -- but the ordering always favours the better -// finish, so every position in [1..poolWinners] is a distinct, consequential +// finish, so every position in [1..cut] is a distinct, consequential // seed. That is all this predicate needs; it does not depend on who byes. A tied -// group whose BEST position is already past the cutoff (positions[0]+1 > poolWinners) +// group whose BEST position is already past the cutoff (positions[0]+1 > cut) // sits entirely among eliminated ranks: those teams share that rank and no // supplementary ippon-shobu / daihyosen is played. This mirrors the rule that a // supplementary bout is held only "to determine their relative ranking" where // that ranking matters (running_a_kendo_tournament.md:405/441), and the // band-aware LeagueTiebreakCandidates gate used for team leagues. -func tieAffectsAdvancement(positions []int, poolWinners int) bool { +func tieAffectsAdvancement(positions []int, cut int) bool { if len(positions) == 0 { return false } // positions[0] is 0-based into the descending-sorted standings; +1 makes it // the 1-based finishing rank of the best-placed member of the tied group. - return positions[0]+1 <= poolWinners + return positions[0]+1 <= cut +} + +// poolTieBreakCut is the ONE statement of the cut a pool's ties are judged +// against: how many of its places send a qualifier, which is +// Competition.QualifiersForPool for that pool. Under larger-pools an oversized +// pool sends one more than Winners per pool, so a tie over that extra place is +// owed a bout like any other qualifying place. A pool absent from pools (not +// yet saved, or hand-edited data) falls back to EffectivePoolWinners, the count +// every pool sends without extra qualifiers. +func poolTieBreakCut(comp *state.Competition, pools []helper.Pool, poolName string) int { + for _, p := range pools { + if p.PoolName == poolName { + return comp.QualifiersForPool(p) + } + } + return comp.EffectivePoolWinners() } // tieNeedsIndividualBreak decides whether InjectTiebreakerMatches should hold an @@ -254,7 +271,7 @@ func tieAffectsAdvancement(positions []int, poolWinners int) bool { // - Non-league (mixed pools feeding a knockout): only ties that change who // advances or their seed warrant a bout; a tie entirely below the qualifying // cut shares its rank with no bout. -func tieNeedsIndividualBreak(comp *state.Competition, positions []int, group []state.PlayerStanding, poolWinners int) bool { +func tieNeedsIndividualBreak(comp *state.Competition, positions []int, group []state.PlayerStanding, cut int) bool { if len(positions) == 0 { return false } @@ -266,7 +283,7 @@ func tieNeedsIndividualBreak(comp *state.Competition, positions []int, group []s } return isConsequentialTie(g, comp) } - return tieAffectsAdvancement(positions, poolWinners) + return tieAffectsAdvancement(positions, cut) } // pendingTieBreak is one tied group of a pool that still owes supplementary @@ -293,26 +310,28 @@ type pendingTieBreak struct { // // The band each kind breaks is the injector's own: individual bouts follow // tieNeedsIndividualBreak, team daihyosen follows tieAffectsAdvancement, both -// against EffectivePoolWinners (not MatchWinnerRanksNeeded: an -// extra-qualifier "-2nd" slot below that band is never given a bout, so -// treating it as owing one would leave it unresolved forever). +// against the pool's OWN qualifier count, cut (poolTieBreakCut, which is +// Competition.QualifiersForPool), so a larger-pools extra qualifier's place +// is tie-broken like any other qualifying place. Not MatchWinnerRanksNeeded: +// that is the one global bound for the whole competition, so a pool that +// sends no extra qualifier would owe a bout for a place that qualifies +// nobody and leave it unresolved forever. // // existingRows are the pool's rows of the matching kind (TB rows for // individual, DH rows for team); their count seeds the new bouts' ids. -func pendingTieBreaks(comp *state.Competition, poolName string, poolStandings []state.PlayerStanding, existingRows []state.MatchResult, court string, daihyosen bool) []pendingTieBreak { - poolWinners := comp.EffectivePoolWinners() +func pendingTieBreaks(comp *state.Competition, poolName string, cut int, poolStandings []state.PlayerStanding, existingRows []state.MatchResult, court string, daihyosen bool) []pendingTieBreak { existingCount := len(existingRows) var out []pendingTieBreak for _, positions := range detectPoolTies(poolStandings) { group := standingsAt(poolStandings, positions) var bouts []state.MatchResult if daihyosen { - if !tieAffectsAdvancement(positions, poolWinners) { + if !tieAffectsAdvancement(positions, cut) { continue } bouts = generatePoolDaihyosenMatches(poolName, group, existingCount, court, existingRows) } else { - if !tieNeedsIndividualBreak(comp, positions, group, poolWinners) { + if !tieNeedsIndividualBreak(comp, positions, group, cut) { continue } bouts = generateTiebreakerMatches(poolName, group, existingCount, court, existingRows) @@ -496,6 +515,10 @@ func (e *Engine) InjectTiebreakerMatches(compID string) ([]state.MatchResult, er return err } e.noteMatchRead(compID) + pools, err := tx.LoadPools(compID) + if err != nil { + return err + } // Scan existing TB matches per pool for idempotency and ID sequencing. // existingRows are handed to generateTiebreakerMatches raw (not reduced to @@ -536,7 +559,7 @@ func (e *Engine) InjectTiebreakerMatches(compID string) ([]state.MatchResult, er if regularIncomplete[poolName] { continue } - for _, p := range pendingTieBreaks(comp, poolName, poolStandings, poolTB[poolName], poolCourt[poolName], false) { + for _, p := range pendingTieBreaks(comp, poolName, poolTieBreakCut(comp, pools, poolName), poolStandings, poolTB[poolName], poolCourt[poolName], false) { injected = append(injected, p.bouts...) } } diff --git a/scripts/e2e/README.md b/scripts/e2e/README.md index 1a83a7249..17dce57ef 100644 --- a/scripts/e2e/README.md +++ b/scripts/e2e/README.md @@ -121,7 +121,9 @@ browser, `--debug` opens the inspector, and a failing test leaves a trace level on every criterion adds a tie-break bout, whose winner is ranked and seated in the knockout on admin and phone; a tie split on - points lost adds none + points lost adds none; a second competition + with Oversized send +1 where the tie is over + the extra qualifier place, owed a bout too moved-match a waiting match moved to another shiaijo reaches that court's console without a reload shared-shiaijo two competitions on one shiaijo: the picker, diff --git a/scripts/e2e/fixtures/wizard.mjs b/scripts/e2e/fixtures/wizard.mjs index 3d560ad04..cb685d645 100644 --- a/scripts/e2e/fixtures/wizard.mjs +++ b/scripts/e2e/fixtures/wizard.mjs @@ -15,6 +15,9 @@ const FORMAT = { }; const TEAM_MATCH_TYPE = { fixed: 'Regular', kachinuki: 'Kachinuki (winner stays on)' }; const POOL_SIZE_MODE = { maximum: 'maximum', minimum: 'minimum' }; +// The "Knockout qualifiers" pills (competition_shape.jsx extraQualifiersLabel), +// which only a minimum pool size offers. +const EXTRA_QUALIFIERS = { standard: 'Standard', 'larger-pools': 'Oversized send +1', 'fill-bracket': 'Fit the knockout' }; // 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 @@ -52,6 +55,10 @@ async function setNumber(page, label, value) { // teamMatchType 'fixed' | 'kachinuki' (team only) // poolSizeMode 'maximum' | 'minimum' (mixed only) // poolSize, poolWinners numbers (mixed only) +// extraQualifiers 'standard' | 'larger-pools' | 'fill-bracket': the "Knockout +// qualifiers" pills (mixed with a minimum pool size only). +// Either non-standard choice sets Winners per pool to 1 and +// locks it, so give poolWinners with 'standard' 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; @@ -59,7 +66,7 @@ async function setNumber(page, label, value) { // checkIn boolean: "Check-in tracking" (officiated tournaments only) export async function createCompetition(page, opts = {}) { const { - name, kind, format, teamSize, teamMatchType, poolSizeMode, poolSize, poolWinners, + name, kind, format, teamSize, teamMatchType, poolSizeMode, poolSize, poolWinners, extraQualifiers, courts, numberPrefix, twoThirdPlaces, engi, checkIn, } = opts; await page.goto('/admin/create-competition'); @@ -74,6 +81,14 @@ export async function createCompetition(page, opts = {}) { 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 (extraQualifiers) { + await choose(page, 'Knockout qualifiers', EXTRA_QUALIFIERS[extraQualifiers]); + if (extraQualifiers !== 'standard') { + // The wizard locks the field at 1: oversized pools send one more than that. + await expect(input(page, 'Winners per pool')).toHaveValue('1'); + await expect(input(page, 'Winners per pool')).toBeDisabled(); + } + } if (twoThirdPlaces !== undefined) { await page.getByRole('checkbox', { name: 'Award two joint 3rd places' }).setChecked(twoThirdPlaces); } diff --git a/scripts/e2e/journeys/pools-tiebreak.spec.mjs b/scripts/e2e/journeys/pools-tiebreak.spec.mjs index 2cdc2c00a..60a4148c0 100644 --- a/scripts/e2e/journeys/pools-tiebreak.spec.mjs +++ b/scripts/e2e/journeys/pools-tiebreak.spec.mjs @@ -1,8 +1,10 @@ // Pools + knockout with a MINIMUM pool size, and a pool tie that needs a -// tie-break bout (bc-e2tb). +// tie-break bout (bc-e2tb). A second competition below (bc-xqtb) does the same +// with a tie over the extra place an oversized pool sends. // // Fixture: an individual "Pools + Knockout" competition, seven entrants, pool -// size a MINIMUM of 3, two qualifiers per pool, one shiaijo. The minimum rule +// size a MINIMUM of 3, two qualifiers per pool, court A (the tournament has a +// second court, B, for the extra-place competition below). The minimum rule // divides the entrant count down (docs/user-guide/organisers/pool-draw.md: // "every pool reaches the minimum and no extra pool opens for the remainder; the // players left over join existing pools, one each"), so seven entrants make two @@ -39,7 +41,7 @@ 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 { createTournament, enterTournament, login } from '../fixtures/setup.mjs'; import { createCompetition } from '../fixtures/wizard.mjs'; import { generateDraw, pasteRoster, startCompetition } from '../fixtures/competition.mjs'; import { completedRowFor, openShiaijo, sides, upNextCard } from '../fixtures/shiaijo.mjs'; @@ -72,32 +74,12 @@ const zero = () => ({ W: 0, L: 0, D: 0, PW: 0, PL: 0 }); // points scored, then fewer points lost. 0 means level on every criterion. const order = (a, b) => (b.W - a.W) || (a.L - b.L) || (b.D - a.D) || (b.PW - a.PW) || (a.PL - b.PL); -test.describe.configure({ mode: 'serial' }); - -test.describe('pools: minimum pool size and a tie-break bout', () => { - let shot; - let compId; - const audit = auditLog(JOURNEY); - const { record } = audit; - - // Draw order of each pool as the draw made it, the pool of 4 first. - let big; - let small; - // The scripted bouts: sorted-pair key -> { marks: { name: [waza] } } | { draw }. +// The scripted bouts of one competition and the figures they must produce: +// plan maps a sorted-pair key to { marks: { name: [waza] } } | { draw }, and +// tally is each competitor's expected W, L, D, PW, PL, tallied from the scripts. +function scripted() { const plan = new Map(); const tally = Object.fromEntries(NAMES.map((n) => [n, zero()])); - // Set once the tie-break bout is on the console. - let tb; - // Who won the tie-break bout, who lost it. - let tbWinner; - let tbLoser; - - test.beforeAll(() => { - shot = journeyShots(JOURNEY); - }); - - test.afterAll(() => audit.flush()); - const key = (a, b) => [a, b].sort().join(' | '); // Script one bout and add it to the expected tally. @@ -129,30 +111,74 @@ test.describe('pools: minimum pool size and a tie-break bout', () => { if (!entry) throw new Error(`no scripted result for ${shiro} v ${aka}`); return entry.draw ? { draw: true } : { shiro: entry.marks[shiro] || [], aka: entry.marks[aka] || [] }; }; + return { tally, script, planFor }; +} - // The competitor the tie-break bout is between: the pairing on the console, - // whether the bout waits as Up next or already runs in the editor. - async function tieBreakPairing(page) { - await openShiaijo(page, 'A'); - let pair = null; - await expect.poll(async () => { - pair = (await inlineRunning(page)) - ? await editorSides(page).catch(() => null) - : await sides(upNextCard(page)).catch(() => null); - return pair && [pair.shiro, pair.aka].sort(); - }, { message: 'the court holds the tie-break bout', timeout: EXPECT_TIMEOUT_MS }).toEqual([tb.b, tb.c].sort()); - return pair; +// The pools the draw made, as the competitor names in each pool's draw order, +// the largest pool first; asserts the sizes the draw was meant to make. +async function readDrawnPools(page, compId, want) { + await openAdminPools(page, compId); + await expect(poolBlocks(page)).toHaveCount(want.length); + const read = []; + for (let i = 0; i < want.length; i += 1) { + const names = await poolTeams(poolBlocks(page).nth(i)); + read.push(names.map((t) => NAMES.find((n) => t.includes(n)))); } + const pools = read.sort((a, b) => b.length - a.length); + expect(pools.map((p) => p.length), 'the draw cut the pools the minimum rule describes').toEqual(want); + expect(pools.flat().sort(), 'every entrant is in exactly one pool').toEqual([...NAMES].sort()); + return pools; +} - async function expectTable(page, where, names, want) { - for (const name of names) { - await expect.poll(() => playerRowFigures(page, name), { message: `${where}: ${name}` }) - .toMatchObject(want(name)); - } +// The competitor the tie-break bout is between: the pairing on court `court`'s +// console, whether the bout waits as Up next or already runs in the editor. +async function tieBreakPairingOn(page, court, between) { + await openShiaijo(page, court); + let pair = null; + await expect.poll(async () => { + pair = (await inlineRunning(page)) + ? await editorSides(page).catch(() => null) + : await sides(upNextCard(page)).catch(() => null); + return pair && [pair.shiro, pair.aka].sort(); + }, { message: 'the court holds the tie-break bout', timeout: EXPECT_TIMEOUT_MS }).toEqual([...between].sort()); + return pair; +} + +async function expectTable(page, where, names, want) { + for (const name of names) { + await expect.poll(() => playerRowFigures(page, name), { message: `${where}: ${name}` }) + .toMatchObject(want(name)); } +} + +test.describe.configure({ mode: 'serial' }); + +test.describe('pools: minimum pool size and a tie-break bout', () => { + let shot; + let compId; + const audit = auditLog(JOURNEY); + const { record } = audit; + + // Draw order of each pool as the draw made it, the pool of 4 first. + let big; + let small; + const { tally, script, planFor } = scripted(); + // Set once the tie-break bout is on the console. + let tb; + // Who won the tie-break bout, who lost it. + let tbWinner; + let tbLoser; + + test.beforeAll(() => { + shot = journeyShots(JOURNEY); + }); + + test.afterAll(() => audit.flush()); + + const tieBreakPairing = (page) => tieBreakPairingOn(page, 'A', [tb.b, tb.c]); test('seed: seven entrants, pool size a minimum of 3, the draw makes a pool of 4 and a pool of 3', async ({ page }) => { - await createTournament(page, { name: 'Pools Tiebreak Cup', courts: 1 }); + await createTournament(page, { name: 'Pools Tiebreak Cup', courts: 2 }); compId = await createCompetition(page, { name: 'Minimum Pools', kind: 'individual', format: 'mixed', poolSizeMode: 'minimum', poolSize: MIN_POOL, poolWinners: POOL_WINNERS, courts: ['A'], @@ -161,18 +187,9 @@ test.describe('pools: minimum pool size and a tie-break bout', () => { await generateDraw(page, compId); await startCompetition(page, compId); - await openAdminPools(page, compId); const want = minimumRuleSizes(NAMES.length, MIN_POOL); expect(want, 'the rule makes two pools, 4 and 3 (a maximum of 3 would make 3, 2 and 2)').toEqual([4, 3]); - await expect(poolBlocks(page)).toHaveCount(want.length); - const read = []; - for (let i = 0; i < want.length; i += 1) { - const names = await poolTeams(poolBlocks(page).nth(i)); - read.push(names.map((t) => NAMES.find((n) => t.includes(n)))); - } - const pools = read.sort((a, b) => b.length - a.length); - expect(pools.map((p) => p.length), 'the draw cut the pools the minimum rule describes').toEqual(want); - expect(pools.flat().sort(), 'every entrant is in exactly one pool').toEqual([...NAMES].sort()); + const pools = await readDrawnPools(page, compId, want); [big, small] = pools; record({ journey: JOURNEY, step: 'draw', action: 'pool sizes', rule: `floor(7 / ${MIN_POOL}) pools, leftover joins one each`, pools, screenshot: await shot(page, 'pools-drawn') }); @@ -341,3 +358,207 @@ test.describe('pools: minimum pool size and a tie-break bout', () => { record({ journey: JOURNEY, step: 'knockout', action: 'seated', qualifiers, out: [tbLoser, big[2], big[3]] }); }); }); + +// bc-xqtb: Knockout qualifiers "Oversized send +1" (larger-pools). The pool of 4 +// is over the minimum of 3, so it sends one more than the 1 Winners per pool +// (the wizard locks that field at 1): its 1st and its 2nd. The 2nd is the extra +// place, and a tie over it decides who goes through exactly as any other +// qualifying place does, so it is owed a tie-break bout. It runs on court B, +// beside the competition above, in the same tournament. +// +// pool of 3, players A B C A beats B and C, B beats C: no tie. A is first +// and the pool's one qualifier. +// pool of 4, players P Q R S P beats Q, R and S, Q and R beat S 1-0, and Q and +// R draw. Q and R end level on every criterion +// (W1 L1 D1, PW1, PL1) for 2nd, the extra place. +// Asserted: the tie-break bout appearing as the only bout left to score; Q and +// R both at "=2nd" with no advancing mark on admin and phone, and neither +// seated in the knockout meanwhile; after the bout, the winner ranked 2nd, +// marked advancing and seated, the loser out. +const JOURNEY_EXTRA = 'pools-tiebreak-extra-place'; +const EXTRA_COURT = 'B'; + +test.describe('pools: a tie over the extra qualifier place of an oversized pool', () => { + let shot; + let compId; + const audit = auditLog(JOURNEY_EXTRA); + const { record } = audit; + let big; + let small; + const { tally, script, planFor } = scripted(); + let tb; + let tbWinner; + let tbLoser; + + test.beforeAll(() => { + shot = journeyShots(JOURNEY_EXTRA); + }); + + test.afterAll(() => audit.flush()); + + const tieBreakPairing = (page) => tieBreakPairingOn(page, EXTRA_COURT, [tb.q, tb.r]); + + test('seed: Oversized send +1, seven entrants, the draw makes a pool of 4 and a pool of 3', async ({ page }) => { + await enterTournament(page, { name: 'Pools Tiebreak Cup', courts: 2 }); + compId = await createCompetition(page, { + name: 'Extra Place Pools', kind: 'individual', format: 'mixed', poolSizeMode: 'minimum', poolSize: MIN_POOL, + extraQualifiers: 'larger-pools', courts: [EXTRA_COURT], + }); + await pasteRoster(page, compId, ROSTER_7); + await generateDraw(page, compId); + await startCompetition(page, compId); + + const pools = await readDrawnPools(page, compId, minimumRuleSizes(NAMES.length, MIN_POOL)); + [big, small] = pools; + record({ journey: JOURNEY_EXTRA, step: 'draw', action: 'pool sizes', pools, screenshot: await shot(page, 'pools-drawn') }); + + const [A, B, C] = small; + const [P, Q, R, S] = big; + script(A, B, { marks: { [A]: ['M'] } }); + script(A, C, { marks: { [A]: ['M'] } }); + script(B, C, { marks: { [B]: ['M'] } }); + script(P, Q, { marks: { [P]: ['M'] } }); + script(P, R, { marks: { [P]: ['M'] } }); + script(P, S, { marks: { [P]: ['M'] } }); + script(Q, S, { marks: { [Q]: ['M'] } }); + script(R, S, { marks: { [R]: ['M'] } }); + script(Q, R, { draw: true }); + tb = { a: A, p: P, q: Q, r: R, s: S }; + expect(order(tally[Q], tally[R]), 'Q and R are level on every criterion').toBe(0); + expect(order(tally[P], tally[Q]) < 0 && order(tally[Q], tally[S]) < 0, 'P is above them and S below').toBe(true); + }); + + test('play the nine regular bouts on court B', async ({ page }) => { + await login(page); + await openShiaijo(page, EXTRA_COURT); + for (let n = 1; n <= 9; n += 1) { + const played = await test.step(`regular bout ${n}`, () => playScriptedBout(page, planFor)); + record({ journey: JOURNEY_EXTRA, step: `bout ${n}`, action: 'Finish', shiro: played.shiro, aka: played.aka, plan: played.plan }); + } + await shot(page, 'regular-bouts-done'); + }); + + test('the level pair over the extra place: a tie-break bout is added, and both read =2nd until it is fought', async ({ page, browser, baseURL }) => { + await login(page); + const pair = await tieBreakPairing(page); + record({ journey: JOURNEY_EXTRA, step: 'tie-break', action: 'on the court console', pair, screenshot: await shot(page, 'tie-break-on-console') }); + const card = (await inlineRunning(page)) ? page.locator('.editor-modal__eyebrow').first() : upNextCard(page); + expect((await card.innerText()), 'a tie-break bout carries no "Match n of m"').not.toMatch(POOL_MATCH_LABEL); + + await openCompetitionTab(page, compId, 'scores'); + await expect(scoreRows(page).first()).toBeVisible(); + const toScore = scoreRows(page).filter({ has: page.getByRole('button', { name: /^Score$/ }) }); + await expect(toScore, 'the tie-break bout is the only bout left to score').toHaveCount(1); + await expect(toScore.first()).toContainText(tb.q); + await expect(toScore.first()).toContainText(tb.r); + expect(await toScore.first().innerText()).not.toMatch(POOL_MATCH_LABEL); + + // Q and R share 2nd, the extra place, with no advancing mark on either; P + // first and advancing, S last. The pool of 3 is settled: one qualifier. + const level = (name) => ({ ...tally[name], rank: '=2nd', advancing: false, tied: true }); + const settled = (name) => ({ ...tally[name], rank: '4th', advancing: false, tied: false }); + const first = (name) => ({ ...tally[name], rank: '1st', advancing: true, tied: false }); + const smallRanked = small; + const smallRank = (name) => ({ + ...tally[name], rank: ORDINAL[smallRanked.indexOf(name)], advancing: smallRanked.indexOf(name) < 1, tied: false, + }); + const expectPools = async (p, where) => { + await expectTable(p, where, [tb.q, tb.r], level); + await expectTable(p, where, [tb.p], first); + await expectTable(p, where, [tb.s], settled); + await expectTable(p, where, small, smallRank); + }; + await openAdminPools(page, compId); + await expectPools(page, 'admin Pools tab'); + record({ journey: JOURNEY_EXTRA, step: 'tie-break', action: 'table before the bout', columns: INDIVIDUAL_FIGURES, + rows: { q: await playerRowFigures(page, tb.q), r: await playerRowFigures(page, tb.r) }, + screenshot: await shot(page, 'admin-pools-level', { fullPage: true }) }); + const spectator = await browser.newContext({ ...PUBLIC_DEVICE, baseURL }); + try { + const phone = await spectator.newPage(); + await openViewerPools(phone, compId); + await expectPools(phone, 'public viewer Pools tab'); + await shot(phone, 'viewer-pools-level', { fullPage: true }); + } finally { + await spectator.close(); + } + + // The bracket: the pool of 3 is seated, the pool of 4 waits for the bout. + await openCompetitionTab(page, compId, 'bracket'); + const seatedNames = async (until) => Object.values(await bracketSlots(page, until)).map((s) => s.name); + const seated = await seatedNames((slots) => Object.values(slots).some((s) => s.name === small[0])); + record({ journey: JOURNEY_EXTRA, step: 'tie-break', action: 'bracket before the bout', seated, screenshot: await shot(page, 'admin-bracket-before') }); + expect(seated, 'the pool of 3 is settled: its qualifier is seated').toContain(small[0]); + for (const name of [tb.q, tb.r]) { + expect(seated, `${name} is not seated while the tie over the extra place is open`).not.toContain(name); + } + }); + + test('score the tie-break bout: the winner takes the extra place', async ({ page }) => { + await login(page); + const pair = await tieBreakPairing(page); + const running = await ensureRunning(page); + expect(running, 'the tie-break bout runs on the court').toEqual(pair); + // Role R wins, one men: the table read them level, so the bout, not the + // draw order, decides the place. + const side = pair.shiro === tb.r ? 'shiro' : 'aka'; + tbWinner = pair[side]; + tbLoser = pair[side === 'shiro' ? 'aka' : 'shiro']; + expect(tbWinner, 'role R wins the tie-break bout').toBe(tb.r); + const wrote = nextMatchWrite(page); + await awardIppon(page, side, 'M'); + const res = await writeLanded(wrote, 'the tie-break bout\'s first mark'); + const id = decodeURIComponent(new URL(res.url()).pathname.split('/matches/')[1].replace(/\/score$/, '')); + expect(id, 'the bout is the pool\'s supplementary bout').toMatch(/^Pool [A-Z]-TB-\d+$/); + await finishMatch(page); + await expect(completedRowFor(page, pair)).toBeVisible(); + record({ journey: JOURNEY_EXTRA, step: 'tie-break', action: 'Finish', id, winner: tbWinner, loser: tbLoser, + screenshot: await shot(page, 'tie-break-finished') }); + + await openAdminPools(page, compId); + await expectOrdered(page, 'admin Pools tab'); + await shot(page, 'admin-pools-after', { fullPage: true }); + }); + + // After the bout: P, the winner, the loser, S; only P and the winner advance + // from the pool of 4, and the pool of 3 sends its first alone. + async function expectOrdered(page, where) { + const ranked = [tb.p, tbWinner, tbLoser, tb.s]; + await expectTable(page, where, ranked, (n) => ({ + rank: ORDINAL[ranked.indexOf(n)], advancing: ranked.indexOf(n) < 2, tied: false, + })); + await expectTable(page, where, small, (n) => ({ + ...tally[n], rank: ORDINAL[small.indexOf(n)], advancing: small.indexOf(n) < 1, tied: false, + })); + } + + test('the winner, not the loser, is seated in the knockout: admin and public viewer', async ({ page, browser, baseURL }) => { + await login(page); + const qualifiers = [tb.p, tbWinner, small[0]].sort(); + const seatedIn = async (p) => { + const slots = await bracketSlots(p, (read) => { + const names = Object.values(read).map((s) => s.name); + return qualifiers.every((q) => names.includes(q)); + }); + return Object.values(slots).map((s) => s.name).filter((n) => NAMES.includes(n)).sort(); + }; + await openCompetitionTab(page, compId, 'bracket'); + expect(await seatedIn(page), 'the admin bracket seats the three qualifiers').toEqual(qualifiers); + await shot(page, 'admin-bracket-after'); + + const spectator = await browser.newContext({ ...PUBLIC_DEVICE, baseURL }); + try { + const phone = await spectator.newPage(); + await openViewerPools(phone, compId); + await expectOrdered(phone, 'public viewer Pools tab'); + await openViewer(phone, compId); + await phone.getByRole('button', { name: 'Bracket' }).tap(); + expect(await seatedIn(phone), 'the public bracket seats the same three').toEqual(qualifiers); + await shot(phone, 'viewer-bracket-after'); + } finally { + await spectator.close(); + } + expect(qualifiers, 'the tie-break loser is out').not.toContain(tbLoser); + record({ journey: JOURNEY_EXTRA, step: 'knockout', action: 'seated', qualifiers, out: [tbLoser, tb.s, small[1], small[2]] }); + }); +}); diff --git a/web-mobile/js/__tests__/render/pools_tied_place.render.test.jsx b/web-mobile/js/__tests__/render/pools_tied_place.render.test.jsx index b100d4c8d..584e088c1 100644 --- a/web-mobile/js/__tests__/render/pools_tied_place.render.test.jsx +++ b/web-mobile/js/__tests__/render/pools_tied_place.render.test.jsx @@ -44,6 +44,7 @@ function renderPool(poolStandings, poolMatches, { comp = competition, poolList = byName[[A, B, C, D].find((p) => text.includes(p.name)).name] = { badge: badge ? badge.textContent : '', badgeAdv: !!badge && badge.classList.contains('rank-badge--adv'), + title: badge ? badge.getAttribute('title') : null, advancing: tr.classList.contains('advancing'), tied: tr.classList.contains('pool__row--tied'), }; @@ -81,9 +82,10 @@ describe('a tie awaiting its tie-break bout', () => { describe('a tie over the larger-pools extra qualifier place', () => { // poolWinners 2 plus one extra place for an oversized pool (4 players over a - // minimum size of 3) sends three. The server owes a tie-break bout only for a - // tie whose best place is within poolWinners (engine.pendingTieBreaks), so a - // tie for 3rd and 4th gets none and must not be left reading as unresolved. + // minimum size of 3) sends three. The server owes a tie-break bout for a tie + // over any of those places (engine.poolTieBreakCut is the pool's own qualifier + // count), the extra one included, so 3rd and 4th level read as tied and neither + // is marked advancing until the bout is scored. const oversized = [{ poolName: 'Pool A', players: [A, B, C, D] }]; const extra = { ...competition, poolSize: 3, extraQualifiers: 'larger-pools' }; const all4 = [ @@ -91,17 +93,47 @@ describe('a tie over the larger-pools extra qualifier place', () => { bout('Pool A-3', B, C), bout('Pool A-4', B, D), bout('Pool A-5', C, D), ]; - it('keeps the advancing mark on the place the draw order gave, since no bout is coming', () => { + const level = [ + winner, + row(B, 2, { points: 6 }), + row(C, 3, { tied: true }), + row(D, 4, { tied: true }), + ]; + + it('shows both at the same place with no advancing mark, naming the bout that decides it', () => { + const rows = renderPool(level, [...all4, bout('Pool A-TB-1', C, D, 'scheduled')], { comp: extra, poolList: oversized }); + + expect(rows.Baba).toMatchObject({ badge: '2nd', badgeAdv: true, advancing: true }); + expect(rows.Cho).toMatchObject({ badge: '=3rd', badgeAdv: false, advancing: false, tied: true }); + expect(rows.Dan).toMatchObject({ badge: '=3rd', badgeAdv: false, advancing: false, tied: true }); + expect(rows.Cho.title).toMatch(/tie-break bout decides the order/); + }); + + it('brings the ordinary badges and the advancing mark back once the bout is scored', () => { const rows = renderPool([ winner, row(B, 2, { points: 6 }), - row(C, 3, { tied: true }), - row(D, 4, { tied: true }), - ], all4, { comp: extra, poolList: oversized }); + row(D, 3, { points: 2 }), + row(C, 4, { points: 1 }), + ], [...all4, bout('Pool A-TB-1', C, D, 'completed')], { comp: extra, poolList: oversized }); - expect(rows.Baba).toMatchObject({ badge: '2nd', badgeAdv: true, advancing: true }); - expect(rows.Cho).toMatchObject({ badge: '=3rd', badgeAdv: true, advancing: true, tied: true }); - expect(rows.Dan).toMatchObject({ badge: '=3rd', badgeAdv: false, advancing: false, tied: true }); + expect(rows.Dan).toMatchObject({ badge: '3rd', badgeAdv: true, advancing: true, tied: false }); + expect(rows.Cho).toMatchObject({ badge: '4th', badgeAdv: false, advancing: false, tied: false }); + }); + + it('reads 4th and 5th level as an ordinary shared place, since no bout is owed there', () => { + const E = player('e', 'Eri'); + const five = [{ poolName: 'Pool A', players: [A, B, C, D, E] }]; + const { container } = render( + , + ); + const badges = [...container.querySelectorAll('table.pool__table tbody tr .rank-badge')]; + const dan = badges[3]; + expect(dan.textContent).toBe('=4th'); + expect(dan.getAttribute('title')).toBe('Level on every criterion'); }); }); @@ -126,7 +158,7 @@ describe('a three-way tie straddling the cut', () => { describe('tiedPlaces', () => { const rows = (...r) => r; it('shares the best place across a group and says whether it straddles the cut', () => { - const m = tiedPlaces(rows(winner, row(B, 2, { tied: true }), row(C, 3, { tied: true })), 2, 2); + const m = tiedPlaces(rows(winner, row(B, 2, { tied: true }), row(C, 3, { tied: true })), 2); expect(m.get('b')).toEqual({ place: 2, boutOwed: true, decidesAdvancement: true }); expect(m.get('c')).toEqual({ place: 2, boutOwed: true, decidesAdvancement: true }); expect(m.has('a')).toBe(false); @@ -134,21 +166,27 @@ describe('tiedPlaces', () => { it('keeps the advancing mark for a tie that sits wholly inside the qualifying places', () => { const level = { points: 5, tied: true }; - const m = tiedPlaces(rows(row(A, 1, level), row(B, 2, level), row(C, 3, { points: 1 })), 2, 2); + const m = tiedPlaces(rows(row(A, 1, level), row(B, 2, level), row(C, 3, { points: 1 })), 2); expect(m.get('a')).toEqual({ place: 1, boutOwed: true, decidesAdvancement: false }); expect(m.get('b')).toEqual({ place: 1, boutOwed: true, decidesAdvancement: false }); }); - it('holds no bout for a tie over the extra qualifier place, which poolWinners does not cover', () => { - const m = tiedPlaces(rows(winner, row(B, 2, { points: 6 }), row(C, 3, { tied: true }), row(D, 4, { tied: true })), 3, 2); - expect(m.get('c')).toEqual({ place: 3, boutOwed: false, decidesAdvancement: false }); + it('owes a bout for a tie over the extra qualifier place, since the cut is the pool\'s qualifier count', () => { + const m = tiedPlaces(rows(winner, row(B, 2, { points: 6 }), row(C, 3, { tied: true }), row(D, 4, { tied: true })), 3); + expect(m.get('c')).toEqual({ place: 3, boutOwed: true, decidesAdvancement: true }); + expect(m.get('d')).toEqual({ place: 3, boutOwed: true, decidesAdvancement: true }); + }); + + it('owes no bout for a tie wholly below the qualifying places', () => { + const m = tiedPlaces(rows(winner, row(B, 2, { points: 6 }), row(C, 3, { points: 4 }), row(D, 4, { tied: true }), row(player('e', 'Eri'), 5, { tied: true })), 3); + expect(m.get('d')).toEqual({ place: 4, boutOwed: false, decidesAdvancement: false }); }); it('keeps two separate tied groups apart', () => { const m = tiedPlaces(rows( row(A, 1, { points: 9, tied: true }), row(B, 2, { points: 9, tied: true }), row(C, 3, { points: 4, tied: true }), row(D, 4, { points: 4, tied: true }), - ), 2, 2); + ), 2); expect(m.get('a').place).toBe(1); expect(m.get('c').place).toBe(3); expect(m.get('d')).toEqual({ place: 3, boutOwed: false, decidesAdvancement: false }); diff --git a/web-mobile/js/viewer_standings.jsx b/web-mobile/js/viewer_standings.jsx index 97c5b01b7..1f553e40d 100644 --- a/web-mobile/js/viewer_standings.jsx +++ b/web-mobile/js/viewer_standings.jsx @@ -706,16 +706,6 @@ export const PoolNumberedMatchRow = React.memo(({ m, num, onMatchClick, isEngi } }); PoolNumberedMatchRow.displayName = "PoolNumberedMatchRow"; -// effectivePoolWinners: the number of finishers per pool that the SERVER counts -// as the pool's places that matter. Mirrors state.Competition.EffectivePoolWinners -// (an unset or non-positive poolWinners is 2), which is the cut -// engine.pendingTieBreaks / tieAffectsAdvancement hold a tie-break bout against: -// a bout is owed only for a tie whose best-placed member finishes within it. It -// does NOT include the larger-pools extra qualifier (qualifiersForPool adds that). -export function effectivePoolWinners(competition) { - return (competition && competition.poolWinners) > 0 ? competition.poolWinners : 2; -} - // tiedPlaces: the place each still-open tie holds, keyed by standings row. // // The server marks a row `tied` while the competitors level with it on EVERY @@ -724,19 +714,19 @@ export function effectivePoolWinners(competition) { // of them 2nd and the other 3rd as though the order were decided. A tied group // shares its best member's place. // -// Two cuts, because the server uses two. `tieBreakCut` is where it owes a bout -// (effectivePoolWinners): `boutOwed` is true when the group's best place is -// within it. `qualifiers` is how many advance (qualifiersForPool, with the -// larger-pools extra place): `decidesAdvancement` is true only when a bout is -// owed AND the group straddles that line, so a pair level for 1st that both -// advance anyway still reads as advancing, and a tie over the extra -// larger-pools place, for which the server holds no bout, is never left -// waiting for one that will not come. +// One cut: `qualifiers`, how many of the pool's places advance +// (qualifiersForPool, with the larger-pools extra place). The server owes a +// bout for a tie whose best place is within it (engine.poolTieBreakCut), so +// `boutOwed` is true when the group's best place is, and a tie wholly below it +// just shares its place. `decidesAdvancement` is true only when a bout is owed +// AND the group straddles the line, so a pair level for 1st that both advance +// anyway still reads as advancing, while a tie over the extra larger-pools +// place leaves none of them marked until its bout is scored. // `points` is the server's packed ranking key (engine individual/teamStandingPoints), // equal exactly when two rows are level on every criterion, so rows that share // it among the tied ones form one group. Pool standings only: a league has no // advancing mark to withhold. -export function tiedPlaces(poolStandings, qualifiers, tieBreakCut) { +export function tiedPlaces(poolStandings, qualifiers) { const groups = new Map(); (poolStandings || []).forEach((s, i) => { if (!s || !s.tied) return; @@ -754,7 +744,7 @@ export function tiedPlaces(poolStandings, qualifiers, tieBreakCut) { if (!s || !s.tied) return; const g = groups.get(s.points); const key = s.player.id || `${s.player.name}||${s.player.dojo || ""}`; - const boutOwed = g.place <= tieBreakCut; + const boutOwed = g.place <= qualifiers; byRow.set(key, { place: g.place, boutOwed, decidesAdvancement: boutOwed && g.last > qualifiers }); }); return byRow; @@ -782,8 +772,9 @@ function tieBadgeTitle(tie, isTeam) { // duplicate, per the mp-3abe-adjacent rule against re-deriving the engine's // own oversized/draft logic in JS): a pool is oversized when it holds more // players than the competition's configured minimum PoolSize. -// The base count is effectivePoolWinners (the payload doesn't expose -// EffectivePoolWinners() itself, so it is mirrored above). +// The base count mirrors EffectivePoolWinners() (the payload doesn't expose +// it): an unset or non-positive poolWinners is 2. The same count is the cut the +// server holds a tie-break bout against, per pool (engine.poolTieBreakCut). // // Deliberately does NOT attempt the fill-bracket drafted-2nd calculation // (helper.SelectFillBracketDrafts): drafts go to the SEEDED pools in seed @@ -799,7 +790,7 @@ function tieBadgeTitle(tie, isTeam) { // the server to say WHICH pools were drafted, which is future work if this // becomes worth wiring (bc-qual LP-5a report). export function qualifiersForPool(competition, pool) { - const base = effectivePoolWinners(competition); + const base = (competition && competition.poolWinners) > 0 ? competition.poolWinners : 2; if ( competition && competition.extraQualifiers === "larger-pools" && @@ -876,7 +867,7 @@ export function PoolsViewer({ pools, standings, poolMatches, tweaks, competition // A tie still waiting for its tie-break bout reads as tied (see // tiedPlaces): the level rows share one place, and none of them is // marked advancing when the tie decides who goes through. - const tiedByRow = isLeague ? new Map() : tiedPlaces(poolStandings, poolQualifiers, effectivePoolWinners(competition)); + const tiedByRow = isLeague ? new Map() : tiedPlaces(poolStandings, poolQualifiers); // A pool has nothing to rank until at least one bout is decided: every // competitor is tied at 0 and the backend rank is merely the seed/draw From d4f643cb95f65011c3481d92bb55d4f641f8b652 Mon Sep 17 00:00:00 2001 From: Ricardo Oliveira Date: Wed, 14 Oct 2026 11:13:05 +0100 Subject: [PATCH 28/31] fix(bc-fbtb): a tie over a fill-bracket drafted 2nd place gets its tie-break bout Fill-bracket drafts some pools to send their 2nd too, but the tie cut (poolTieBreakCut) read Competition.QualifiersForPool, which cannot say which pools were drafted, so a tie for a drafted 2nd got no bout and whoever sorted 2nd took the knockout place. The cut is now read off the stored draw (drawnQualifierPlaces: the highest place of the pool the bracket's draw-time placeholders seat), the same labels the qualifier seating fills, so it covers standard, larger-pools and fill-bracket by one rule and cannot drift from the bracket when courts or seeds change after the draw. Requalification asks the same cut. The viewer detail payload carries it per pool (poolQualifiers), and the standings page reads it instead of re-deriving the larger-pools count. The pools-tiebreak journey adds a fill-bracket competition on court C. Co-Authored-By: Claude Opus 5.5 --- docs/user-guide/organisers/run-tournament.md | 2 +- internal/engine/chusen.go | 6 +- internal/engine/daihyosen.go | 6 +- internal/engine/pool_requalify.go | 2 +- .../engine/tiebreak_extra_qualifier_test.go | 263 ++++++++++++++++++ internal/engine/tiebreaker.go | 89 +++++- internal/mobileapp/handlers_viewer.go | 7 + internal/mobileapp/handlers_viewer_test.go | 45 +++ scripts/e2e/README.md | 5 +- scripts/e2e/journeys/pools-tiebreak.spec.mjs | 249 ++++++++++++++++- specs/openapi.yaml | 13 + web-mobile/js/__tests__/api.test.jsx | 17 ++ .../render/pools_tied_place.render.test.jsx | 42 ++- .../js/__tests__/viewer_pools_viewer.test.jsx | 22 +- web-mobile/js/api_serializers.jsx | 21 +- web-mobile/js/viewer_standings.jsx | 56 ++-- 16 files changed, 761 insertions(+), 84 deletions(-) diff --git a/docs/user-guide/organisers/run-tournament.md b/docs/user-guide/organisers/run-tournament.md index a59735fde..0fdc3e066 100644 --- a/docs/user-guide/organisers/run-tournament.md +++ b/docs/user-guide/organisers/run-tournament.md @@ -231,7 +231,7 @@ A draw-ready competition is already listed on the shiai-jo operator views, since The **Pools** tab shows standings for every pool. Ranks are computed automatically from match results. Operators do not edit them by hand, with one exception: chusen (drawing lots), the last-resort tie-break for a consequential team-pool tie that a daihyosen cannot settle. Refer to [Recording decisions](../court-operators/recording-decisions.md). When a daihyosen settles a tie that determines pool advancement, the winning side carries a **DH** badge in the standings. -When two or more competitors are level on every ranking criterion, the app schedules tie-break bouts between them if the best-placed of them finishes within the pool's qualifying places, because finishing place decides where a qualifier lands in the bracket. Those places are **Winners per pool**, plus the extra place an oversized pool sends under **Oversized send +1**, so a tie over that extra place gets a bout too. A tie wholly below the qualifying places gets no bout and shares its place. The pool's qualifiers are seated in the bracket only once the bouts are scored. Until the bouts are scored, the tied competitors share one place, shown as **=2nd** for a tie over second place. A tie that runs across the advancing line leaves none of them with the advancing mark, while a tie inside the advancing places, such as two competitors level for first, reads **=1st** and keeps it. Once the bouts are scored, each takes their own place. A team pool settles a tie with a daihyosen instead, and if that round still leaves teams level, the pool waits for chusen. In an individual pool, each tied pair fights once, and no second round is scheduled if that leaves them level. +When two or more competitors are level on every ranking criterion, the app schedules tie-break bouts between them if the best-placed of them finishes within the pool's qualifying places, because finishing place decides where a qualifier lands in the bracket. Those places are **Winners per pool**, plus the extra place an oversized pool sends under **Oversized send +1**, plus the second place of each pool that **Fit the knockout** drafts to fill the bracket, so a tie over such an extra place gets a bout too. A tie wholly below the qualifying places gets no bout and shares its place. The pool's qualifiers are seated in the bracket only once the bouts are scored. Until the bouts are scored, the tied competitors share one place, shown as **=2nd** for a tie over second place. A tie that runs across the advancing line leaves none of them with the advancing mark, while a tie inside the advancing places, such as two competitors level for first, reads **=1st** and keeps it. Once the bouts are scored, each takes their own place. A team pool settles a tie with a daihyosen instead, and if that round still leaves teams level, the pool waits for chusen. In an individual pool, each tied pair fights once, and no second round is scheduled if that leaves them level. Pool finishers move into the knockout bracket on their own: as soon as a pool's last match is scored, its qualifiers are seeded into their bracket slots. Those knockout matches can be scored without waiting for the other pools. Once the last pool is seeded, the competition moves to the knockout phase. The bracket updates in real time as results come in. A pool result corrected later still moves its qualifiers in the bracket; refer to [Correct a pool result after the knockout has started](../court-operators/scoring-a-match.md#correct-a-pool-result-after-the-knockout-has-started). diff --git a/internal/engine/chusen.go b/internal/engine/chusen.go index 9105aae8b..7e92f1bc4 100644 --- a/internal/engine/chusen.go +++ b/internal/engine/chusen.go @@ -202,6 +202,10 @@ func (e *Engine) ChusenStatus(compID string) (ChusenReport, error) { if err != nil { return report, err } + bracket, err := e.store.LoadBracket(compID) + if err != nil { + return report, err + } poolNames := make([]string, 0, len(standings)) for name := range standings { @@ -212,7 +216,7 @@ func (e *Engine) ChusenStatus(compID string) (ChusenReport, error) { for _, poolName := range poolNames { poolStandings := standings[poolName] groupOverrides := poolRanks[poolName] - cut := poolTieBreakCut(comp, pools, poolName) + cut := poolTieBreakCut(comp, pools, bracket, poolName) for _, positions := range detectPoolTies(poolStandings) { // Only a tie that affects advancement/seed warrants a decider at all. if !tieAffectsAdvancement(positions, cut) { diff --git a/internal/engine/daihyosen.go b/internal/engine/daihyosen.go index 599f46154..c5afd3095 100644 --- a/internal/engine/daihyosen.go +++ b/internal/engine/daihyosen.go @@ -242,6 +242,10 @@ func (e *Engine) InjectPoolDaihyosenMatches(compID string) ([]state.MatchResult, if err != nil { return err } + bracket, err := tx.LoadBracket(compID) + if err != nil { + return err + } // Scan existing DH matches per pool for idempotency and ID sequencing. // poolNameFromMatchID strips the trailing suffix deterministically so @@ -285,7 +289,7 @@ func (e *Engine) InjectPoolDaihyosenMatches(compID string) ([]state.MatchResult, if regularIncomplete[poolName] { continue } - for _, p := range pendingTieBreaks(comp, poolName, poolTieBreakCut(comp, pools, poolName), poolStandings, poolDH[poolName], poolCourt[poolName], true) { + for _, p := range pendingTieBreaks(comp, poolName, poolTieBreakCut(comp, pools, bracket, poolName), poolStandings, poolDH[poolName], poolCourt[poolName], true) { injected = append(injected, p.bouts...) } } diff --git a/internal/engine/pool_requalify.go b/internal/engine/pool_requalify.go index daeee80e0..1426656e3 100644 --- a/internal/engine/pool_requalify.go +++ b/internal/engine/pool_requalify.go @@ -149,7 +149,7 @@ func (e *Engine) planRequalification(tx state.StoreTx, compID string, comp *stat // label. Engi places are decided by the standings' own order. tied := map[string]bool{} if !comp.Engi { - tied, err = e.tiedQualifyingLabels(tx, compID, comp, poolName, comp.QualifiersForPool(*pool), standings[poolName], ranksNeeded) + tied, err = e.tiedQualifyingLabels(tx, compID, comp, poolName, poolTieBreakCut(comp, pools, bracket, poolName), standings[poolName], ranksNeeded) if err != nil { return nil, err } diff --git a/internal/engine/tiebreak_extra_qualifier_test.go b/internal/engine/tiebreak_extra_qualifier_test.go index 8c17d9289..b4d745ccf 100644 --- a/internal/engine/tiebreak_extra_qualifier_test.go +++ b/internal/engine/tiebreak_extra_qualifier_test.go @@ -2,6 +2,7 @@ package engine import ( "fmt" + "strings" "testing" "github.com/stretchr/testify/assert" @@ -271,3 +272,265 @@ func TestLargerPoolsExtraPlaceTie_QualificationWaitsForTheBout(t *testing.T) { name, _ = sideOf(m, side) assert.Equal(t, "A2", name, "the rest of the pool is seated once the bout is played") } + +// bc-fbtb: under fill-bracket some pools are DRAFTED to send their 2nd as well +// (helper.SelectFillBracketDraftIndices), and Competition.QualifiersForPool +// deliberately cannot say which. A tie over a drafted pool's 2nd decides who +// takes the "Pool X-2nd" knockout place, so it is owed a bout exactly like a +// tie over any other qualifying place; the same tie in a pool that was not +// drafted shares its place. The tests draw through the real pipeline and read +// the drafted pool off the stored bracket, so they fail if the cut stops +// following the draw. + +// startFillBracket draws an 11-entrant fill-bracket competition (Winners per +// pool 1, min 3: pools of 3, 4 and 4, one 2nd drafted from an oversized pool) +// and returns the drafted pool and the oversized pool left undrafted. +func startFillBracket(t *testing.T, compID string, team bool) (eng *Engine, store *state.Store, drafted, undrafted string) { + t.Helper() + eng, store, _ = setupTestEngine(t) + createTestCompetition(t, store, compID, state.CompFormatMixed, 3, func(c *state.Competition) { + c.PoolWinners = 1 + c.ExtraQualifiers = state.ExtraQualifiersFillBracket + if team { + c.Kind = "team" + c.TeamSize = 2 + } + }) + names := make([]string, 11) + for i := range names { + names[i] = fmt.Sprintf("Player%02d", i) + } + saveTestParticipants(t, store, compID, names) + require.NoError(t, eng.StartCompetition(compID)) + + bracket, err := store.LoadBracket(compID) + require.NoError(t, err) + for _, m := range bracket.Rounds[0] { + for _, label := range []string{m.PlaceholderA, m.PlaceholderB} { + if strings.HasSuffix(label, "-2nd") { + require.Empty(t, drafted, "exactly one 2nd is drafted") + drafted = qualifierLabelPool(label) + } + } + } + require.NotEmpty(t, drafted, "the draw drafts one pool's 2nd") + pools, err := store.LoadPools(compID) + require.NoError(t, err) + for _, p := range pools { + if p.PoolName != drafted && len(p.Players) == 4 { + undrafted = p.PoolName + } + } + require.NotEmpty(t, undrafted, "an oversized pool is left undrafted") + return eng, store, drafted, undrafted +} + +// scoreFillBracketPools completes the pool matches: the lower-seated player of +// each pair wins, except that in tiePool the 2nd and 3rd players draw each +// other (level for 2nd). Pools other than tiePool are scored only when all is +// set, so a test can leave them incomplete. +func scoreFillBracketPools(t *testing.T, eng *Engine, store *state.Store, compID, tiePool string, team, all bool) { + t.Helper() + pools, err := store.LoadPools(compID) + require.NoError(t, err) + order, poolOf := map[string]int{}, map[string]string{} + for _, p := range pools { + for i, pl := range p.Players { + order[pl.Name], poolOf[pl.Name] = i, p.PoolName + } + } + matches, err := store.LoadPoolMatches(compID) + require.NoError(t, err) + for i := range matches { + m := &matches[i] + pn := poolOf[m.SideA] + if pn != tiePool && !all { + continue + } + a, b := order[m.SideA], order[m.SideB] + winner, winnerID := m.SideA, m.SideAID + if b < a { + winner, winnerID = m.SideB, m.SideBID + } + if pn == tiePool && a*b == 2 && a+b == 3 { + winner, winnerID = "", "" + } + m.Status = state.MatchStatusCompleted + m.Winner, m.WinnerID = winner, winnerID + if winner == "" { + m.Decision = string(domain.DecisionHikiwake) + } + if team { + m.SubResults = []state.SubMatchResult{ + {Position: 1, SideA: m.SideA, SideB: m.SideB, Winner: winner, Decision: m.Decision}, + {Position: 2, SideA: m.SideA, SideB: m.SideB, Winner: winner, Decision: m.Decision}, + } + } + } + require.NoError(t, store.SavePoolMatches(compID, matches)) + eng.standingsCache.Delete(compID) + eng.standingsFlight.Delete(compID) +} + +// drawnPoolNames returns pool's player names in draw order. +func drawnPoolNames(t *testing.T, store *state.Store, compID, pool string) []string { + t.Helper() + pools, err := store.LoadPools(compID) + require.NoError(t, err) + for _, p := range pools { + if p.PoolName == pool { + names := make([]string, len(p.Players)) + for i, pl := range p.Players { + names[i] = pl.Name + } + return names + } + } + require.Failf(t, "pool not found", "%s", pool) + return nil +} + +func TestFillBracketDraftedSecondTie_OwesATieBreakBout(t *testing.T) { + for _, tc := range []struct { + name string + drafted bool + }{ + {"a tie for 2nd in the drafted pool is tie-broken", true}, + {"the same tie in an oversized pool that was not drafted shares its place", false}, + } { + t.Run(tc.name, func(t *testing.T) { + eng, store, drafted, undrafted := startFillBracket(t, "fbtb-ind", false) + pool := undrafted + if tc.drafted { + pool = drafted + } + scoreFillBracketPools(t, eng, store, "fbtb-ind", pool, false, false) + + injected, err := eng.InjectTiebreakerMatches("fbtb-ind") + require.NoError(t, err) + if !tc.drafted { + assert.Empty(t, injected) + return + } + require.Len(t, injected, 1) + names := drawnPoolNames(t, store, "fbtb-ind", pool) + assert.ElementsMatch(t, []string{names[1], names[2]}, []string{injected[0].SideA, injected[0].SideB}) + assert.True(t, IsTiebreakerMatchID(injected[0].ID) && strings.HasPrefix(injected[0].ID, pool+"-TB-"), "got %q", injected[0].ID) + }) + } +} + +func TestFillBracketDraftedSecondTie_TeamPoolGetsADaihyosen(t *testing.T) { + for _, tc := range []struct { + name string + drafted bool + want int + }{ + {"drafted pool: the level teams play a daihyosen", true, 1}, + {"undrafted pool: the level teams share the place", false, 0}, + } { + t.Run(tc.name, func(t *testing.T) { + eng, store, drafted, undrafted := startFillBracket(t, "fbtb-team", true) + pool := undrafted + if tc.drafted { + pool = drafted + } + scoreFillBracketPools(t, eng, store, "fbtb-team", pool, true, false) + + injected, err := eng.InjectPoolDaihyosenMatches("fbtb-team") + require.NoError(t, err) + require.Len(t, injected, tc.want) + }) + } +} + +func TestFillBracketDraftedSecondTie_ChusenWaitsOnlyInTheDraftedPool(t *testing.T) { + for _, tc := range []struct { + name string + drafted bool + want int + }{ + {"drafted pool: a daihyosen that leaves them level needs a chusen", true, 1}, + {"undrafted pool: nothing to draw", false, 0}, + } { + t.Run(tc.name, func(t *testing.T) { + eng, store, drafted, undrafted := startFillBracket(t, "fbtb-chusen", true) + pool := undrafted + if tc.drafted { + pool = drafted + } + scoreFillBracketPools(t, eng, store, "fbtb-chusen", pool, true, false) + _, err := eng.InjectPoolDaihyosenMatches("fbtb-chusen") + require.NoError(t, err) + all, err := store.LoadPoolMatches("fbtb-chusen") + require.NoError(t, err) + for i := range all { + if IsPoolDaihyosenMatchID(all[i].ID) { + all[i].Status = state.MatchStatusCompleted + all[i].Winner = "" // hikiwake: the round leaves them level + } + } + require.NoError(t, store.SavePoolMatches("fbtb-chusen", all)) + eng.standingsCache.Delete("fbtb-chusen") + eng.standingsFlight.Delete("fbtb-chusen") + + report, err := eng.ChusenStatus("fbtb-chusen") + require.NoError(t, err) + assert.Len(t, report.Pending, tc.want) + }) + } +} + +func TestFillBracketDraftedSecondTie_QualificationWaitsForTheBout(t *testing.T) { + eng, store, drafted, _ := startFillBracket(t, "fbtb-wait", false) + scoreFillBracketPools(t, eng, store, "fbtb-wait", drafted, false, true) + names := drawnPoolNames(t, store, "fbtb-wait", drafted) + label := drafted + "-2nd" + + // slot reads who sits in the knockout place the drafted pool's 2nd fills. + slot := func() string { + bracket, err := store.LoadBracket("fbtb-wait") + require.NoError(t, err) + for _, m := range bracket.Rounds[0] { + if m.PlaceholderA == label { + return m.SideA + } + if m.PlaceholderB == label { + return m.SideB + } + } + require.Failf(t, "slot not found", "%s", label) + return "" + } + + _, err := eng.MaybeAutoCompletePools("fbtb-wait") + require.NoError(t, err) + matches, err := store.LoadPoolMatches("fbtb-wait") + require.NoError(t, err) + var tbs []state.MatchResult + for _, m := range matches { + if IsTiebreakerMatchID(m.ID) { + tbs = append(tbs, m) + } + } + require.Len(t, tbs, 1, "the tie over the drafted 2nd is owed a tie-break bout") + assert.ElementsMatch(t, []string{names[1], names[2]}, []string{tbs[0].SideA, tbs[0].SideB}) + assert.Equal(t, label, slot(), "the place stays open while the bout is unplayed") + + tbs[0].Status = state.MatchStatusCompleted + tbs[0].Winner, tbs[0].WinnerID = names[2], tbs[0].SideAID + if tbs[0].SideB == names[2] { + tbs[0].WinnerID = tbs[0].SideBID + } + for i := range matches { + if matches[i].ID == tbs[0].ID { + matches[i] = tbs[0] + } + } + require.NoError(t, store.SavePoolMatches("fbtb-wait", matches)) + eng.standingsCache.Delete("fbtb-wait") + eng.standingsFlight.Delete("fbtb-wait") + _, err = eng.MaybeAutoCompletePools("fbtb-wait") + require.NoError(t, err) + assert.Equal(t, names[2], slot(), "the bout's winner takes the drafted 2nd place") +} diff --git a/internal/engine/tiebreaker.go b/internal/engine/tiebreaker.go index 8ce0ea33e..c36f715cf 100644 --- a/internal/engine/tiebreaker.go +++ b/internal/engine/tiebreaker.go @@ -243,21 +243,86 @@ func tieAffectsAdvancement(positions []int, cut int) bool { } // poolTieBreakCut is the ONE statement of the cut a pool's ties are judged -// against: how many of its places send a qualifier, which is -// Competition.QualifiersForPool for that pool. Under larger-pools an oversized -// pool sends one more than Winners per pool, so a tie over that extra place is -// owed a bout like any other qualifying place. A pool absent from pools (not -// yet saved, or hand-edited data) falls back to EffectivePoolWinners, the count -// every pool sends without extra qualifiers. -func poolTieBreakCut(comp *state.Competition, pools []helper.Pool, poolName string) int { +// against: how many of its places send a qualifier. Under larger-pools an +// oversized pool sends one more than Winners per pool, and under fill-bracket a +// DRAFTED pool sends its 2nd too, so a tie over such a place is owed a bout like +// any other qualifying place. +// +// The count is read off the stored draw (drawnQualifierPlaces), the truth the +// qualifier seating uses and the only one that knows which pools fill-bracket +// drafted. Where the bracket names none of the pool's places (a v1 bracket +// predating the draw placeholders, which carries only the uniform Winners per +// pool layout, or no bracket yet) the pool's own Competition.QualifiersForPool +// answers. A pool absent from pools (not yet saved, or hand-edited data) falls +// back to EffectivePoolWinners, the count every pool sends without extra +// qualifiers. +func poolTieBreakCut(comp *state.Competition, pools []helper.Pool, bracket *state.Bracket, poolName string) int { for _, p := range pools { - if p.PoolName == poolName { - return comp.QualifiersForPool(p) + if p.PoolName != poolName { + continue + } + if n := drawnQualifierPlaces(bracket, p); n > 0 { + return n } + return comp.QualifiersForPool(p) } return comp.EffectivePoolWinners() } +// drawnQualifierPlaces is the highest place of pool that the STORED draw seats a +// qualifier from: the largest rank r for which the bracket's draw-time +// placeholders (recordDrawPlaceholders) name qualifierLabel(pool, r). It is 0 +// when the bracket names none of the pool's places (no bracket, a knockout +// seeded without pool placeholders, or a v1 bracket whose placeholders +// ResolveQualifiedPools has not backfilled yet). +// +// The stored draw, not the competition's config, is the truth the qualifier +// seating reads: qualifierResolver fills exactly the labels the bracket holds. +// Which pools a fill-bracket draw DRAFTED to send their 2nd is a whole-field +// selection (helper.SelectFillBracketDraftIndices, which depends on the court +// count and on every pool's seeds) that Competition.QualifiersForPool +// deliberately cannot express, and re-running it here would drift from the +// bracket on disk the moment courts, seeds or pool size changed after the draw. +// Reading the placeholders cannot disagree with the seating, and covers +// larger-pools and standard mode by the same rule. +func drawnQualifierPlaces(bracket *state.Bracket, pool helper.Pool) int { + if bracket == nil { + return 0 + } + labels := map[string]bool{} + note := func(m *state.BracketMatch) { + labels[m.PlaceholderA], labels[m.PlaceholderB], labels[m.PlaceholderWinner] = true, true, true + } + for ri := range bracket.Rounds { + for mi := range bracket.Rounds[ri] { + note(&bracket.Rounds[ri][mi]) + } + } + if bracket.ThirdPlaceMatch != nil { + note(bracket.ThirdPlaceMatch) + } + for rank := len(pool.Players); rank >= 1; rank-- { + if labels[qualifierLabel(pool.PoolName, rank)] { + return rank + } + } + return 0 +} + +// PoolQualifierCuts maps each pool's name to how many of its places send a +// qualifier: poolTieBreakCut for every pool. The viewer payload carries it so a +// standings table marks the advancing places and the tie badge reads a tie over +// the last of them against the SAME count the server holds a tie-break bout to, +// instead of re-deriving it (the client cannot: which fill-bracket pools were +// drafted is a property of the stored draw). +func PoolQualifierCuts(comp *state.Competition, pools []helper.Pool, bracket *state.Bracket) map[string]int { + out := make(map[string]int, len(pools)) + for _, p := range pools { + out[p.PoolName] = poolTieBreakCut(comp, pools, bracket, p.PoolName) + } + return out +} + // tieNeedsIndividualBreak decides whether InjectTiebreakerMatches should hold an // ippon-shobu bout for a tied group in an INDIVIDUAL competition. // @@ -519,6 +584,10 @@ func (e *Engine) InjectTiebreakerMatches(compID string) ([]state.MatchResult, er if err != nil { return err } + bracket, err := tx.LoadBracket(compID) + if err != nil { + return err + } // Scan existing TB matches per pool for idempotency and ID sequencing. // existingRows are handed to generateTiebreakerMatches raw (not reduced to @@ -559,7 +628,7 @@ func (e *Engine) InjectTiebreakerMatches(compID string) ([]state.MatchResult, er if regularIncomplete[poolName] { continue } - for _, p := range pendingTieBreaks(comp, poolName, poolTieBreakCut(comp, pools, poolName), poolStandings, poolTB[poolName], poolCourt[poolName], false) { + for _, p := range pendingTieBreaks(comp, poolName, poolTieBreakCut(comp, pools, bracket, poolName), poolStandings, poolTB[poolName], poolCourt[poolName], false) { injected = append(injected, p.bouts...) } } diff --git a/internal/mobileapp/handlers_viewer.go b/internal/mobileapp/handlers_viewer.go index e29fd6f64..4288a48cd 100644 --- a/internal/mobileapp/handlers_viewer.go +++ b/internal/mobileapp/handlers_viewer.go @@ -842,6 +842,13 @@ func RegisterViewerHandlers(r *gin.RouterGroup, store *state.Store, eng *engine. "standings": standings, "bracket": bracket, } + // How many of each pool's places send a qualifier, by pool name, + // from the same owner the tie-break bouts are held to + // (engine.PoolQualifierCuts). Absent before a draw, when there are + // no pools to count. + if len(pools) > 0 { + payload["poolQualifiers"] = engine.PoolQualifierCuts(comp, pools, bracket) + } // "teamMembers" is present only for a team competition (isTeamComp, // above) and omitted entirely for an individual one, rather than // carrying an always-empty {} -- a client can treat the key's diff --git a/internal/mobileapp/handlers_viewer_test.go b/internal/mobileapp/handlers_viewer_test.go index 1c961a252..e342d61c9 100644 --- a/internal/mobileapp/handlers_viewer_test.go +++ b/internal/mobileapp/handlers_viewer_test.go @@ -421,6 +421,51 @@ func TestViewerCompetitionDetail_NumbersBeforeAndAfterTheDraw(t *testing.T) { assert.Equal(t, []string{"K2", "K1"}, numbers, "post-draw: pool-order numbers from pools.csv") } +// bc-fbtb: the standings page cannot tell which pools a fill-bracket draw +// drafted to send their 2nd, so the payload says how many places each pool +// sends, from the stored draw (engine.PoolQualifierCuts). +func TestViewerCompetitionDetail_CarriesPoolQualifierCuts(t *testing.T) { + r, store, _, _, tempDir := setupTestRouter(t) + defer os.RemoveAll(tempDir) + + const cid = "viewer-cuts" + require.NoError(t, store.SaveCompetition(&state.Competition{ + ID: cid, Name: "Viewer Cuts", Format: state.CompFormatMixed, Kind: "individual", + Courts: []string{"A"}, PoolSize: 3, PoolWinners: 1, PoolSizeMode: "min", + ExtraQualifiers: state.ExtraQualifiersFillBracket, Status: state.CompStatusPools, + })) + get := func(t *testing.T) map[string]any { + t.Helper() + w := httptest.NewRecorder() + req, _ := http.NewRequest("GET", "/api/viewer/competitions/"+cid, nil) + r.ServeHTTP(w, req) + require.Equalf(t, http.StatusOK, w.Code, "response: %s", w.Body.String()) + var body map[string]any + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body)) + return body + } + _, drawn := get(t)["poolQualifiers"] + assert.False(t, drawn, "before a draw there are no pools to count") + + pool := func(name string, n int) helper.Pool { + p := helper.Pool{PoolName: name} + for i := 0; i < n; i++ { + p.Players = append(p.Players, helper.Player{Name: fmt.Sprintf("%s-%d", name, i), Dojo: "Dojo"}) + } + return p + } + require.NoError(t, store.SavePools(cid, []helper.Pool{pool("Pool A", 3), pool("Pool B", 4), pool("Pool C", 4)})) + // Pool B was drafted: the stored draw seats its 2nd. + require.NoError(t, store.SaveBracket(cid, &state.Bracket{Rounds: [][]state.BracketMatch{{ + {ID: "m1", SideA: "Pool A-1st", SideB: "Pool B-2nd", PlaceholderA: "Pool A-1st", PlaceholderB: "Pool B-2nd"}, + {ID: "m2", SideA: "Pool B-1st", SideB: "Pool C-1st", PlaceholderA: "Pool B-1st", PlaceholderB: "Pool C-1st"}, + }}})) + + cuts, ok := get(t)["poolQualifiers"].(map[string]any) + require.True(t, ok, "the payload carries poolQualifiers") + assert.Equal(t, map[string]any{"Pool A": float64(1), "Pool B": float64(2), "Pool C": float64(1)}, cuts) +} + // TestViewerCompetitionDetail_KnockoutNumbersFollowBracketPosition pins // bc-pnum ruling 2 end to end, through the REAL draw pipeline // (eng.GenerateDraw / eng.DiscardDraw) and the real HTTP viewer detail diff --git a/scripts/e2e/README.md b/scripts/e2e/README.md index 17dce57ef..8cdab19bc 100644 --- a/scripts/e2e/README.md +++ b/scripts/e2e/README.md @@ -123,7 +123,10 @@ browser, `--debug` opens the inspector, and a failing test leaves a trace knockout on admin and phone; a tie split on points lost adds none; a second competition with Oversized send +1 where the tie is over - the extra qualifier place, owed a bout too + the extra qualifier place, owed a bout too; a + third with Fit the knockout where the tie is + over the 2nd the draw drafted, owed a bout + while the same tie in an undrafted pool is not moved-match a waiting match moved to another shiaijo reaches that court's console without a reload shared-shiaijo two competitions on one shiaijo: the picker, diff --git a/scripts/e2e/journeys/pools-tiebreak.spec.mjs b/scripts/e2e/journeys/pools-tiebreak.spec.mjs index 60a4148c0..7502381dd 100644 --- a/scripts/e2e/journeys/pools-tiebreak.spec.mjs +++ b/scripts/e2e/journeys/pools-tiebreak.spec.mjs @@ -1,6 +1,7 @@ // Pools + knockout with a MINIMUM pool size, and a pool tie that needs a // tie-break bout (bc-e2tb). A second competition below (bc-xqtb) does the same -// with a tie over the extra place an oversized pool sends. +// with a tie over the extra place an oversized pool sends, and a third (bc-fbtb) +// with a tie over the 2nd place that "Fit the knockout" drafts one pool to send. // // Fixture: an individual "Pools + Knockout" competition, seven entrants, pool // size a MINIMUM of 3, two qualifiers per pool, court A (the tournament has a @@ -46,11 +47,11 @@ import { createCompetition } from '../fixtures/wizard.mjs'; import { generateDraw, pasteRoster, startCompetition } from '../fixtures/competition.mjs'; import { completedRowFor, openShiaijo, sides, upNextCard } from '../fixtures/shiaijo.mjs'; import { EXPECT_TIMEOUT_MS, PUBLIC_DEVICE } from '../fixtures/devices.mjs'; -import { INDIVIDUAL_FIGURES, openAdminPools, openViewerPools, playerRowFigures, poolBlocks, poolTeams } from '../fixtures/pools.mjs'; +import { INDIVIDUAL_FIGURES, openAdminPools, openViewerPools, playerRowFigures, poolBlocks, poolTeams, teamRow } from '../fixtures/pools.mjs'; import { ROSTER_7, editorSides, ensureRunning, inlineRunning, playScriptedBout } from '../fixtures/individual.mjs'; import { awardIppon, finishMatch, nextMatchWrite, writeLanded } from '../fixtures/scoring.mjs'; import { scoreRows } from '../fixtures/scores.mjs'; -import { bracketSlots, openCompetitionTab } from '../fixtures/roster.mjs'; +import { bracketSlots, openCompetitionTab, openParticipants, setSeed } from '../fixtures/roster.mjs'; import { openViewer } from '../fixtures/public.mjs'; const JOURNEY = 'pools-tiebreak'; @@ -77,9 +78,9 @@ const order = (a, b) => (b.W - a.W) || (a.L - b.L) || (b.D - a.D) || (b.PW - a.P // The scripted bouts of one competition and the figures they must produce: // plan maps a sorted-pair key to { marks: { name: [waza] } } | { draw }, and // tally is each competitor's expected W, L, D, PW, PL, tallied from the scripts. -function scripted() { +function scripted(names = NAMES) { const plan = new Map(); - const tally = Object.fromEntries(NAMES.map((n) => [n, zero()])); + const tally = Object.fromEntries(names.map((n) => [n, zero()])); const key = (a, b) => [a, b].sort().join(' | '); // Script one bout and add it to the expected tally. @@ -116,17 +117,17 @@ function scripted() { // The pools the draw made, as the competitor names in each pool's draw order, // the largest pool first; asserts the sizes the draw was meant to make. -async function readDrawnPools(page, compId, want) { +async function readDrawnPools(page, compId, want, names = NAMES) { await openAdminPools(page, compId); await expect(poolBlocks(page)).toHaveCount(want.length); const read = []; for (let i = 0; i < want.length; i += 1) { - const names = await poolTeams(poolBlocks(page).nth(i)); - read.push(names.map((t) => NAMES.find((n) => t.includes(n)))); + const drawn = await poolTeams(poolBlocks(page).nth(i)); + read.push(drawn.map((t) => names.find((n) => t.includes(n)))); } const pools = read.sort((a, b) => b.length - a.length); expect(pools.map((p) => p.length), 'the draw cut the pools the minimum rule describes').toEqual(want); - expect(pools.flat().sort(), 'every entrant is in exactly one pool').toEqual([...NAMES].sort()); + expect(pools.flat().sort(), 'every entrant is in exactly one pool').toEqual([...names].sort()); return pools; } @@ -178,7 +179,7 @@ test.describe('pools: minimum pool size and a tie-break bout', () => { const tieBreakPairing = (page) => tieBreakPairingOn(page, 'A', [tb.b, tb.c]); test('seed: seven entrants, pool size a minimum of 3, the draw makes a pool of 4 and a pool of 3', async ({ page }) => { - await createTournament(page, { name: 'Pools Tiebreak Cup', courts: 2 }); + await createTournament(page, { name: 'Pools Tiebreak Cup', courts: 3 }); compId = await createCompetition(page, { name: 'Minimum Pools', kind: 'individual', format: 'mixed', poolSizeMode: 'minimum', poolSize: MIN_POOL, poolWinners: POOL_WINNERS, courts: ['A'], @@ -399,7 +400,7 @@ test.describe('pools: a tie over the extra qualifier place of an oversized pool' const tieBreakPairing = (page) => tieBreakPairingOn(page, EXTRA_COURT, [tb.q, tb.r]); test('seed: Oversized send +1, seven entrants, the draw makes a pool of 4 and a pool of 3', async ({ page }) => { - await enterTournament(page, { name: 'Pools Tiebreak Cup', courts: 2 }); + await enterTournament(page, { name: 'Pools Tiebreak Cup', courts: 3 }); compId = await createCompetition(page, { name: 'Extra Place Pools', kind: 'individual', format: 'mixed', poolSizeMode: 'minimum', poolSize: MIN_POOL, extraQualifiers: 'larger-pools', courts: [EXTRA_COURT], @@ -562,3 +563,229 @@ test.describe('pools: a tie over the extra qualifier place of an oversized pool' record({ journey: JOURNEY_EXTRA, step: 'knockout', action: 'seated', qualifiers, out: [tbLoser, tb.s, small[1], small[2]] }); }); }); + +// bc-fbtb: Knockout qualifiers "Fit the knockout" (fill-bracket). Nine entrants +// at a minimum of 3 make three pools of 3, which would send three qualifiers +// into a four-slot bracket with a bye. The draw fills the gap by DRAFTING one +// pool to send its 2nd as well, and it drafts a SEEDED pool first, so seeding +// one entrant says which pool: the one holding them. A tie over that pool's 2nd +// decides who takes the drafted place, so it is owed a tie-break bout; the same +// tie in a pool that was not drafted shares its place with no bout. It runs on +// court C, beside the competitions above, in the same tournament. +// +// drafted pool, A B C A beats B and C; B and C draw 1-1: level on every +// criterion for 2nd, the drafted place. +// undrafted pool, D E F D beats E and F; E and F draw 1-1: the same tie, +// but 2nd sends nobody here. +// undrafted pool, G H I G beats H and I, H beats I: no tie. +// Asserted: the drafted pool's tie-break bout as the only bout left to score (so +// the undrafted pool's identical tie got none); B and C at "=2nd" with a title +// naming the bout, E and F at "=2nd" with a title naming no bout, none of the +// four marked advancing; neither B nor C seated in the knockout while the bout +// is open; after it, the winner ranked 2nd, marked advancing and seated beside +// the three pool winners in the four-slot bracket, the loser out. +const JOURNEY_FILL = 'pools-tiebreak-fit-the-knockout'; +const FILL_COURT = 'C'; +const ROSTER_9 = [ + ['Akio Tanabe', 'Tokyo Kai'], ['Bruno Lopes', 'Lisbon Kan'], ['Chika Moriya', 'Nara Dojo'], + ['Daigo Hirano', 'Chiba Kai'], ['Emi Kashima', 'Himeji Kan'], ['Farid Nasser', 'Cairo Dojo'], + ['Gen Yamada', 'Akita Kai'], ['Hugo Brandt', 'Bonn Kan'], ['Iris Okada', 'Gifu Dojo'], +]; +const NAMES_9 = ROSTER_9.map(([name]) => name); +const SEEDED = NAMES_9[0]; + +test.describe('pools: a tie over the 2nd place a Fit the knockout draw drafted', () => { + let shot; + let compId; + const audit = auditLog(JOURNEY_FILL); + const { record } = audit; + let drafted; + let tied; + let clean; + const { tally, script, planFor } = scripted(NAMES_9); + let tb; + let tbWinner; + let tbLoser; + + test.beforeAll(() => { + shot = journeyShots(JOURNEY_FILL); + }); + + test.afterAll(() => audit.flush()); + + const tieBreakPairing = (page) => tieBreakPairingOn(page, FILL_COURT, [tb.b, tb.c]); + const badgeTitle = (page, name) => teamRow(page, name).locator('.rank-badge').getAttribute('title'); + + test('seed: Fit the knockout, nine entrants with one seed, the draw makes three pools of 3', async ({ page }) => { + await enterTournament(page, { name: 'Pools Tiebreak Cup', courts: 3 }); + compId = await createCompetition(page, { + name: 'Fit Knockout Pools', kind: 'individual', format: 'mixed', poolSizeMode: 'minimum', poolSize: MIN_POOL, + extraQualifiers: 'fill-bracket', courts: [FILL_COURT], + }); + await pasteRoster(page, compId, ROSTER_9); + await openParticipants(page, compId); + await setSeed(page, SEEDED, 1); + await generateDraw(page, compId); + await startCompetition(page, compId); + + const pools = await readDrawnPools(page, compId, [3, 3, 3], NAMES_9); + drafted = pools.find((p) => p.includes(SEEDED)); + expect(drafted, 'the seeded entrant is in one of the pools').toBeTruthy(); + [tied, clean] = pools.filter((p) => p !== drafted); + record({ journey: JOURNEY_FILL, step: 'draw', action: 'pools', pools, seeded: SEEDED, drafted, + screenshot: await shot(page, 'pools-drawn') }); + + const [A, B, C] = drafted; + const [D, E, F] = tied; + const [G, H, I] = clean; + script(A, B, { marks: { [A]: ['M'] } }); + script(A, C, { marks: { [A]: ['M'] } }); + script(B, C, { marks: { [B]: ['M'], [C]: ['M'] } }); + script(D, E, { marks: { [D]: ['M'] } }); + script(D, F, { marks: { [D]: ['M'] } }); + script(E, F, { marks: { [E]: ['M'], [F]: ['M'] } }); + script(G, H, { marks: { [G]: ['M'] } }); + script(G, I, { marks: { [G]: ['M'] } }); + script(H, I, { marks: { [H]: ['M'] } }); + tb = { a: A, b: B, c: C, d: D, e: E, f: F, g: G, h: H, i: I }; + expect(order(tally[B], tally[C]), 'B and C are level on every criterion').toBe(0); + expect(order(tally[E], tally[F]), 'E and F are level on every criterion').toBe(0); + }); + + test('play the nine regular bouts on court C', async ({ page }) => { + await login(page); + await openShiaijo(page, FILL_COURT); + for (let n = 1; n <= 9; n += 1) { + const played = await test.step(`regular bout ${n}`, () => playScriptedBout(page, planFor)); + record({ journey: JOURNEY_FILL, step: `bout ${n}`, action: 'Finish', shiro: played.shiro, aka: played.aka, plan: played.plan }); + } + await shot(page, 'regular-bouts-done'); + }); + + test('the drafted pool\'s level pair is owed a bout; the same tie in an undrafted pool is not', async ({ page, browser, baseURL }) => { + await login(page); + const pair = await tieBreakPairing(page); + record({ journey: JOURNEY_FILL, step: 'tie-break', action: 'on the console of court C', pair, screenshot: await shot(page, 'tie-break-on-console') }); + + // The Scores tab: the drafted pool's bout is the one bout left to score, + // which also says the undrafted pool's level pair was given none. + await openCompetitionTab(page, compId, 'scores'); + await expect(scoreRows(page).first()).toBeVisible(); + const toScore = scoreRows(page).filter({ has: page.getByRole('button', { name: /^Score$/ }) }); + await expect(toScore, 'the drafted pool\'s tie-break bout is the only bout left to score').toHaveCount(1); + await expect(toScore.first()).toContainText(tb.b); + await expect(toScore.first()).toContainText(tb.c); + + const level = (name) => ({ ...tally[name], rank: '=2nd', advancing: false, tied: true }); + const ranked = (list) => (name) => ({ + ...tally[name], rank: ORDINAL[list.indexOf(name)], advancing: list.indexOf(name) < 1, tied: false, + }); + const expectPools = async (p, where) => { + await expectTable(p, where, [tb.b, tb.c], level); + await expectTable(p, where, [tb.e, tb.f], level); + await expectTable(p, where, [tb.a], ranked([tb.a])); + await expectTable(p, where, [tb.d], ranked([tb.d])); + await expectTable(p, where, clean, ranked(clean)); + // The title says whether a bout decides it: only the drafted pool's tie. + expect(await badgeTitle(p, tb.b), `${where}: the drafted pool's level pair names the bout`).toMatch(/tie-break bout decides the order/); + expect(await badgeTitle(p, tb.e), `${where}: the undrafted pool's level pair names no bout`).toBe('Level on every criterion'); + }; + await openAdminPools(page, compId); + await expectPools(page, 'admin Pools tab'); + record({ journey: JOURNEY_FILL, step: 'tie-break', action: 'table before the bout', columns: INDIVIDUAL_FIGURES, + rows: { b: await playerRowFigures(page, tb.b), e: await playerRowFigures(page, tb.e) }, + screenshot: await shot(page, 'admin-pools-level', { fullPage: true }) }); + const spectator = await browser.newContext({ ...PUBLIC_DEVICE, baseURL }); + try { + const phone = await spectator.newPage(); + await openViewerPools(phone, compId); + await expectPools(phone, 'public viewer Pools tab'); + await shot(phone, 'viewer-pools-level', { fullPage: true }); + } finally { + await spectator.close(); + } + + // The bracket: the two undrafted pools' winners are seated; the drafted + // pool waits for the bout, so neither of its level pair is seated. + await openCompetitionTab(page, compId, 'bracket'); + const seated = Object.values(await bracketSlots(page, (slots) => { + const names = Object.values(slots).map((x) => x.name); + return names.includes(tb.d) && names.includes(tb.g); + })).map((x) => x.name); + record({ journey: JOURNEY_FILL, step: 'tie-break', action: 'bracket before the bout', seated, screenshot: await shot(page, 'admin-bracket-before') }); + expect(seated, 'the undrafted pools are settled: their winners are seated').toEqual(expect.arrayContaining([tb.d, tb.g])); + for (const name of [tb.b, tb.c]) { + expect(seated, `${name} is not seated while the tie over the drafted 2nd is open`).not.toContain(name); + } + }); + + test('score the tie-break bout: the winner takes the drafted place', async ({ page }) => { + await login(page); + const pair = await tieBreakPairing(page); + const running = await ensureRunning(page); + expect(running, 'the tie-break bout runs on the court').toEqual(pair); + // Role C wins, one men: the table read them level, so the bout, not the + // draw order, decides the place. + const side = pair.shiro === tb.c ? 'shiro' : 'aka'; + tbWinner = pair[side]; + tbLoser = pair[side === 'shiro' ? 'aka' : 'shiro']; + expect(tbWinner, 'role C wins the tie-break bout').toBe(tb.c); + const wrote = nextMatchWrite(page); + await awardIppon(page, side, 'M'); + const res = await writeLanded(wrote, 'the tie-break bout\'s first mark'); + const id = decodeURIComponent(new URL(res.url()).pathname.split('/matches/')[1].replace(/\/score$/, '')); + expect(id, 'the bout is the pool\'s supplementary bout').toMatch(/^Pool [A-Z]-TB-\d+$/); + await finishMatch(page); + await expect(completedRowFor(page, pair)).toBeVisible(); + record({ journey: JOURNEY_FILL, step: 'tie-break', action: 'Finish', id, winner: tbWinner, loser: tbLoser, + screenshot: await shot(page, 'tie-break-finished') }); + + await openAdminPools(page, compId); + await expectOrdered(page, 'admin Pools tab'); + await shot(page, 'admin-pools-after', { fullPage: true }); + }); + + // After the bout: the drafted pool is A, the winner, the loser, with the + // first two advancing; the undrafted pools are as they were. + async function expectOrdered(page, where) { + const first = [tb.a, tbWinner, tbLoser]; + await expectTable(page, where, first, (n) => ({ + rank: ORDINAL[first.indexOf(n)], advancing: first.indexOf(n) < 2, tied: false, + })); + await expectTable(page, where, [tb.d], (n) => ({ ...tally[n], rank: '1st', advancing: true, tied: false })); + await expectTable(page, where, [tb.e, tb.f], (n) => ({ ...tally[n], rank: '=2nd', advancing: false, tied: true })); + await expectTable(page, where, clean, (n) => ({ + ...tally[n], rank: ORDINAL[clean.indexOf(n)], advancing: clean.indexOf(n) < 1, tied: false, + })); + } + + test('the winner, not the loser, is seated in the knockout: admin and public viewer', async ({ page, browser, baseURL }) => { + await login(page); + const qualifiers = [tb.a, tbWinner, tb.d, tb.g].sort(); + const seatedIn = async (p) => { + const slots = await bracketSlots(p, (read) => { + const names = Object.values(read).map((x) => x.name); + return qualifiers.every((q) => names.includes(q)); + }); + return Object.values(slots).map((x) => x.name).filter((n) => NAMES_9.includes(n)).sort(); + }; + await openCompetitionTab(page, compId, 'bracket'); + expect(await seatedIn(page), 'the admin bracket seats the four qualifiers').toEqual(qualifiers); + await shot(page, 'admin-bracket-after'); + + const spectator = await browser.newContext({ ...PUBLIC_DEVICE, baseURL }); + try { + const phone = await spectator.newPage(); + await openViewerPools(phone, compId); + await expectOrdered(phone, 'public viewer Pools tab'); + await openViewer(phone, compId); + await phone.getByRole('button', { name: 'Bracket' }).tap(); + expect(await seatedIn(phone), 'the public bracket seats the same four').toEqual(qualifiers); + await shot(phone, 'viewer-bracket-after'); + } finally { + await spectator.close(); + } + expect(qualifiers, 'the tie-break loser is out').not.toContain(tbLoser); + record({ journey: JOURNEY_FILL, step: 'knockout', action: 'seated', qualifiers, out: [tbLoser, tb.e, tb.f, tb.h, tb.i] }); + }); +}); diff --git a/specs/openapi.yaml b/specs/openapi.yaml index 09aa79729..449e6bad1 100644 --- a/specs/openapi.yaml +++ b/specs/openapi.yaml @@ -7298,6 +7298,19 @@ components: items: $ref: '#/components/schemas/PlayerStanding' description: Map of pool name to ordered standings. + poolQualifiers: + type: object + additionalProperties: + type: integer + minimum: 1 + description: > + Map of pool name to how many of that pool's places send a qualifier + to the knockout. Present once pools are drawn. It is read off the + stored draw, so a pool whose 2nd is drafted under extra qualifiers + "fill-bracket", or an oversized pool under "larger-pools", counts one + more than `config.poolWinners`. A tie over any of these places is + owed a tie-break bout (a daihyosen for a team pool), and the + standings mark exactly these places as advancing. bracket: $ref: '#/components/schemas/Bracket' diff --git a/web-mobile/js/__tests__/api.test.jsx b/web-mobile/js/__tests__/api.test.jsx index 2102e2172..613ae3066 100644 --- a/web-mobile/js/__tests__/api.test.jsx +++ b/web-mobile/js/__tests__/api.test.jsx @@ -931,6 +931,23 @@ describe('API Utils', () => { expect(out.config.extraQualifiers).toBe('fill-bracket'); }); + // bc-fbtb: the payload's poolQualifiers map (pool name -> places that send a + // qualifier) lands on each pool as `qualifiers`, which is what + // qualifiersForPool reads. + it('puts each pool\'s server-counted qualifiers on the pool', () => { + const out = normalizeCompetitionDetail({ + config: { id: 'x', name: 'X' }, + pools: [{ poolName: 'Pool A', players: [] }, { poolName: 'Pool B', players: [] }], + poolQualifiers: { 'Pool A': 1, 'Pool B': 2 }, + }); + expect(out.pools.map(p => p.qualifiers)).toEqual([1, 2]); + }); + + it('leaves qualifiers unset when the payload carries no poolQualifiers', () => { + const out = normalizeCompetitionDetail({ config: { id: 'x', name: 'X' }, pools: [{ poolName: 'Pool A', players: [] }] }); + expect(out.pools[0].qualifiers).toBeUndefined(); + }); + it('returns falsy input unchanged (no crash)', () => { expect(normalizeCompetitionDetail(null)).toBeNull(); expect(normalizeCompetitionDetail(undefined)).toBeUndefined(); diff --git a/web-mobile/js/__tests__/render/pools_tied_place.render.test.jsx b/web-mobile/js/__tests__/render/pools_tied_place.render.test.jsx index 584e088c1..e76a91a6b 100644 --- a/web-mobile/js/__tests__/render/pools_tied_place.render.test.jsx +++ b/web-mobile/js/__tests__/render/pools_tied_place.render.test.jsx @@ -82,11 +82,11 @@ describe('a tie awaiting its tie-break bout', () => { describe('a tie over the larger-pools extra qualifier place', () => { // poolWinners 2 plus one extra place for an oversized pool (4 players over a - // minimum size of 3) sends three. The server owes a tie-break bout for a tie + // minimum size of 3) sends three (`qualifiers`, as the server counts it). The server owes a tie-break bout for a tie // over any of those places (engine.poolTieBreakCut is the pool's own qualifier // count), the extra one included, so 3rd and 4th level read as tied and neither // is marked advancing until the bout is scored. - const oversized = [{ poolName: 'Pool A', players: [A, B, C, D] }]; + const oversized = [{ poolName: 'Pool A', players: [A, B, C, D], qualifiers: 3 }]; const extra = { ...competition, poolSize: 3, extraQualifiers: 'larger-pools' }; const all4 = [ bout('Pool A-0', A, B), bout('Pool A-1', A, C), bout('Pool A-2', A, D), @@ -123,7 +123,7 @@ describe('a tie over the larger-pools extra qualifier place', () => { it('reads 4th and 5th level as an ordinary shared place, since no bout is owed there', () => { const E = player('e', 'Eri'); - const five = [{ poolName: 'Pool A', players: [A, B, C, D, E] }]; + const five = [{ poolName: 'Pool A', players: [A, B, C, D, E], qualifiers: 3 }]; const { container } = render( { }); }); +describe('a tie over a fill-bracket drafted 2nd', () => { + // Winners per pool 1, and the draw drafted this pool to send its 2nd as well: + // the server's count (`qualifiers`, engine.PoolQualifierCuts, read off the + // stored draw) is 2 for a drafted pool and 1 for one that was not. The page + // cannot tell them apart without it. + const fill = { ...competition, poolWinners: 1, poolSize: 3, extraQualifiers: 'fill-bracket' }; + const poolsOf = (qualifiers) => [{ poolName: 'Pool A', players: [A, B, C], qualifiers }]; + const level = [winner, row(B, 2, { tied: true }), row(C, 3, { tied: true })]; + const tbPending = [...regular, bout('Pool A-TB-1', B, C, 'scheduled')]; + + it('reads both as =2nd with no advancing mark, naming the bout that decides it', () => { + const rows = renderPool(level, tbPending, { comp: fill, poolList: poolsOf(2) }); + + expect(rows.Aoki).toMatchObject({ badge: '1st', badgeAdv: true, advancing: true }); + expect(rows.Baba).toMatchObject({ badge: '=2nd', badgeAdv: false, advancing: false, tied: true }); + expect(rows.Cho).toMatchObject({ badge: '=2nd', badgeAdv: false, advancing: false, tied: true }); + expect(rows.Baba.title).toMatch(/tie-break bout decides the order/); + }); + + it('marks the winner of the bout as advancing once it is scored', () => { + const rows = renderPool([ + winner, row(C, 2, { points: 2 }), row(B, 3, { points: 1 }), + ], [...regular, bout('Pool A-TB-1', B, C, 'completed')], { comp: fill, poolList: poolsOf(2) }); + + expect(rows.Cho).toMatchObject({ badge: '2nd', badgeAdv: true, advancing: true, tied: false }); + expect(rows.Baba).toMatchObject({ badge: '3rd', badgeAdv: false, advancing: false }); + }); + + it('shares the place with no bout owed when the pool was not drafted', () => { + const rows = renderPool(level, regular, { comp: fill, poolList: poolsOf(1) }); + + expect(rows.Baba).toMatchObject({ badge: '=2nd', badgeAdv: false, advancing: false, tied: true }); + expect(rows.Baba.title).toBe('Level on every criterion'); + }); +}); + describe('a three-way tie straddling the cut', () => { it('shows all three at the same place, none advancing', () => { const four = [{ poolName: 'Pool A', players: [A, B, C, D] }]; diff --git a/web-mobile/js/__tests__/viewer_pools_viewer.test.jsx b/web-mobile/js/__tests__/viewer_pools_viewer.test.jsx index 67714f886..73e059a99 100644 --- a/web-mobile/js/__tests__/viewer_pools_viewer.test.jsx +++ b/web-mobile/js/__tests__/viewer_pools_viewer.test.jsx @@ -299,10 +299,10 @@ describe('PoolsViewer draw-order standings (mp-938b)', () => { // ------------------------------------------------------------------ // bc-qual LP-5a: per-pool qualifier highlighting. PoolsViewer used to // highlight a single UNIFORM top-N (competition.poolWinners) across every -// pool; qualifiersForPool (viewer_standings.jsx) now computes the count -// PER POOL so an oversized pool under ExtraQualifiersLargerPools highlights -// one extra row, mirroring state.Competition.QualifiersForPool's oversized -// test (pool.players.length > competition.poolSize). +// pool; qualifiersForPool (viewer_standings.jsx) now reads the SERVER's count +// per pool (`pool.qualifiers`, the payload's poolQualifiers map) so an +// oversized pool under ExtraQualifiersLargerPools, or a pool whose 2nd a +// fill-bracket draw drafted, highlights one extra row (bc-fbtb). // ------------------------------------------------------------------ describe('PoolsViewer per-pool qualifier highlighting (bc-qual LP-5a)', () => { const realReact = global.React; @@ -390,16 +390,16 @@ describe('PoolsViewer per-pool qualifier highlighting (bc-qual LP-5a)', () => { it('standard mode: poolWinners=1 highlights only rank 1 in every pool, regardless of size', () => { const comp = { kind: 'individual', teamSize: 0, format: 'mixed', poolWinners: 1, poolSize: 3, extraQualifiers: '' }; - const tree = runtime.mount(PoolsViewer, { pools: [poolA, poolB], standings, poolMatches: [], tweaks, competition: comp }); + const tree = runtime.mount(PoolsViewer, { pools: [{ ...poolA, qualifiers: 1 }, { ...poolB, qualifiers: 1 }], standings, poolMatches: [], tweaks, competition: comp }); const rows = advancingRowTexts(tree); expect(rows).toHaveLength(2); expect(rows.some(t => t.includes('A1'))).toBe(true); expect(rows.some(t => t.includes('B1'))).toBe(true); }); - it('larger-pools mode: an oversized pool (4 players, min size 3) highlights top 2; a minimum-size pool highlights only 1', () => { + it('larger-pools mode: an oversized pool (server count 2) highlights top 2; a minimum-size pool (count 1) highlights only 1', () => { const comp = { kind: 'individual', teamSize: 0, format: 'mixed', poolWinners: 1, poolSize: 3, extraQualifiers: 'larger-pools' }; - const tree = runtime.mount(PoolsViewer, { pools: [poolA, poolB], standings, poolMatches: [], tweaks, competition: comp }); + const tree = runtime.mount(PoolsViewer, { pools: [{ ...poolA, qualifiers: 2 }, { ...poolB, qualifiers: 1 }], standings, poolMatches: [], tweaks, competition: comp }); const rows = advancingRowTexts(tree); // Pool A (oversized): top 2 (A1, A2). Pool B (exactly minimum): top 1 (B1) only. expect(rows).toHaveLength(3); @@ -410,14 +410,16 @@ describe('PoolsViewer per-pool qualifier highlighting (bc-qual LP-5a)', () => { expect(rows.some(t => t.includes('B2'))).toBe(false); }); - it('fill-bracket mode: only rank 1 highlights even in an oversized pool (drafted-2nd is a documented residue: the payload carries no draft-index list)', () => { + it('fill-bracket mode: the pool the draw drafted highlights its 2nd, wherever it sits and whatever its size', () => { const comp = { kind: 'individual', teamSize: 0, format: 'mixed', poolWinners: 1, poolSize: 3, extraQualifiers: 'fill-bracket' }; - const tree = runtime.mount(PoolsViewer, { pools: [poolA, poolB], standings, poolMatches: [], tweaks, competition: comp }); + // Pool B (the minimum size) was drafted; oversized Pool A was not. + const tree = runtime.mount(PoolsViewer, { pools: [{ ...poolA, qualifiers: 1 }, { ...poolB, qualifiers: 2 }], standings, poolMatches: [], tweaks, competition: comp }); const rows = advancingRowTexts(tree); - expect(rows).toHaveLength(2); + expect(rows).toHaveLength(3); expect(rows.some(t => t.includes('A1'))).toBe(true); expect(rows.some(t => t.includes('A2'))).toBe(false); expect(rows.some(t => t.includes('B1'))).toBe(true); + expect(rows.some(t => t.includes('B2'))).toBe(true); }); }); diff --git a/web-mobile/js/api_serializers.jsx b/web-mobile/js/api_serializers.jsx index 11a8e0bce..6d222fff1 100644 --- a/web-mobile/js/api_serializers.jsx +++ b/web-mobile/js/api_serializers.jsx @@ -521,13 +521,22 @@ function normalizeCompetitionDetail(data) { result.config = config; } - // Normalize pools (Go: PoolName, Players → poolName, players) + // Normalize pools (Go: PoolName, Players → poolName, players). `qualifiers` + // is how many of the pool's places send a qualifier, from the payload's + // sibling `poolQualifiers` map (engine.PoolQualifierCuts, keyed by pool + // name): the server owns it because which fill-bracket pools were drafted + // is a property of the stored draw. qualifiersForPool reads it back. if (result.pools) { - result.pools = result.pools.map(p => ({ - poolName: p.PoolName || p.poolName || "", - players: (p.Players || p.players || []).map(normalizePlayer), - matches: p.Matches || p.matches || [], - })); + const cuts = result.poolQualifiers || {}; + result.pools = result.pools.map(p => { + const poolName = p.PoolName || p.poolName || ""; + return { + poolName, + players: (p.Players || p.players || []).map(normalizePlayer), + matches: p.Matches || p.matches || [], + qualifiers: cuts[poolName], + }; + }); } // Normalize standings player field (carry flags for engi standings) diff --git a/web-mobile/js/viewer_standings.jsx b/web-mobile/js/viewer_standings.jsx index 1f553e40d..1a6ac1058 100644 --- a/web-mobile/js/viewer_standings.jsx +++ b/web-mobile/js/viewer_standings.jsx @@ -715,7 +715,8 @@ PoolNumberedMatchRow.displayName = "PoolNumberedMatchRow"; // shares its best member's place. // // One cut: `qualifiers`, how many of the pool's places advance -// (qualifiersForPool, with the larger-pools extra place). The server owes a +// (qualifiersForPool: the server's count, with a larger-pools extra place or a +// fill-bracket drafted 2nd). The server owes a // bout for a tie whose best place is within it (engine.poolTieBreakCut), so // `boutOwed` is true when the group's best place is, and a tie wholly below it // just shares its place. `decidesAdvancement` is true only when a bout is owed @@ -761,46 +762,23 @@ function tieBadgeTitle(tie, isTeam) { } // qualifiersForPool: how many top-ranked finishers in `pool` advance to the -// knockout (bc-qual LP-5a). PoolsViewer uses this PER POOL instead of a -// single count across every pool, so an oversized pool's extra qualifier -// (state.ExtraQualifiersLargerPools) is highlighted correctly, e.g. a -// 4-person pool at minimum size 3 with poolWinners=1 highlights its top 2, -// not just its winner. +// knockout. PoolsViewer uses this PER POOL instead of a single count across +// every pool, so an oversized pool's extra qualifier (larger-pools) and a +// drafted pool's 2nd (fill-bracket) are both marked advancing. // -// Mirrors state.Competition.QualifiersForPool's OVERSIZED test exactly (the -// "trivial larger-pools size test" this client-side module is allowed to -// duplicate, per the mp-3abe-adjacent rule against re-deriving the engine's -// own oversized/draft logic in JS): a pool is oversized when it holds more -// players than the competition's configured minimum PoolSize. -// The base count mirrors EffectivePoolWinners() (the payload doesn't expose -// it): an unset or non-positive poolWinners is 2. The same count is the cut the -// server holds a tie-break bout against, per pool (engine.poolTieBreakCut). -// -// Deliberately does NOT attempt the fill-bracket drafted-2nd calculation -// (helper.SelectFillBracketDrafts): drafts go to the SEEDED pools in seed -// order (oversized as fallback), stopping once the bracket's shortfall is -// met and skipping a candidate whose opposite half is full -- a WHOLE- -// competition scan over the pool set plus the court layout, which no -// per-pool test can reproduce. The per-pool seed ranks ARE on this wire -// (pools[].players[].seed comes through pools.csv), so it is the cross-pool -// stop-and-skip state that is missing, not the seed data. Under fill-bracket -// this therefore returns the uniform EffectivePoolWinners() count, so only -// rank 1 (never a drafted rank 2) is ever highlighted as advancing here; the -// residue is a deliberate accepted gap, not a bug: a correct highlight needs -// the server to say WHICH pools were drafted, which is future work if this -// becomes worth wiring (bc-qual LP-5a report). +// The count is the SERVER's, `pool.qualifiers` (the payload's `poolQualifiers` +// map, normalized onto each pool in api_serializers.jsx; engine.PoolQualifierCuts +// reads it off the stored draw): the same count the server holds a tie-break +// bout against. It cannot be derived here, because which fill-bracket pools +// were drafted is a whole-field selection over every pool's seeds and the court +// layout, and a larger-pools pool's extra place follows the draw too. Without +// it (a payload from before a draw, or a demo pool) the count is the base +// Winners per pool, mirroring EffectivePoolWinners() (unset or non-positive is +// 2). export function qualifiersForPool(competition, pool) { - const base = (competition && competition.poolWinners) > 0 ? competition.poolWinners : 2; - if ( - competition && - competition.extraQualifiers === "larger-pools" && - competition.poolSize > 0 && - pool && Array.isArray(pool.players) && - pool.players.length > competition.poolSize - ) { - return base + 1; - } - return base; + const served = pool && pool.qualifiers; + if (Number.isInteger(served) && served > 0) return served; + return (competition && competition.poolWinners) > 0 ? competition.poolWinners : 2; } // showDataIssues: opt-in for the operator-only data-integrity notices (see From de5d22a2aa4a0c3ebf749201395d44bede83b9c3 Mon Sep 17 00:00:00 2001 From: Ricardo Oliveira Date: Wed, 14 Oct 2026 11:21:56 +0100 Subject: [PATCH 29/31] docs(bc-fbtb): poolQualifiers counts only the places a small pool's draw seats Co-Authored-By: Claude Opus 5.5 --- specs/openapi.yaml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/specs/openapi.yaml b/specs/openapi.yaml index 449e6bad1..b6eed13f9 100644 --- a/specs/openapi.yaml +++ b/specs/openapi.yaml @@ -7308,7 +7308,8 @@ components: to the knockout. Present once pools are drawn. It is read off the stored draw, so a pool whose 2nd is drafted under extra qualifiers "fill-bracket", or an oversized pool under "larger-pools", counts one - more than `config.poolWinners`. A tie over any of these places is + more than `config.poolWinners`, and a pool too small to fill every + place counts only the places the draw seats. A tie over any of these places is owed a tie-break bout (a daihyosen for a team pool), and the standings mark exactly these places as advancing. bracket: From a2b74448a5ae6deda08bc525779ffa97e48f86fd Mon Sep 17 00:00:00 2001 From: Ricardo Oliveira Date: Wed, 14 Oct 2026 11:56:57 +0100 Subject: [PATCH 30/31] test(bc-e2rv): the workbook reader decodes entities in one pass and reads runs CodeQL flagged the e2e fixture's XML reading: entities were decoded one after another, so "&lt;" became "<", and string text was what was left after a regex stripped tags. Decode in one pass, and read a shared or inline string's runs. Co-Authored-By: Claude Opus 5.5 --- scripts/e2e/fixtures/organiser.mjs | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/scripts/e2e/fixtures/organiser.mjs b/scripts/e2e/fixtures/organiser.mjs index 77efb7ddd..fade9597d 100644 --- a/scripts/e2e/fixtures/organiser.mjs +++ b/scripts/e2e/fixtures/organiser.mjs @@ -69,8 +69,13 @@ export function readZip(buf) { return entries; } -const xmlText = (s) => s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>') - .replace(/"/g, '"').replace(/'/g, "'"); +// One pass, so a decoded '&' is never decoded again ("&lt;" reads "<"). +const XML_ENTITIES = { amp: '&', lt: '<', gt: '>', quot: '"', apos: "'" }; +const xmlText = (s) => s.replace(/&(amp|lt|gt|quot|apos);/g, (_, e) => XML_ENTITIES[e]); + +// A shared or inline string's text: its runs joined (a rich-text string +// splits it across several), read rather than left over from stripping tags. +const runText = (s) => [...s.matchAll(/]*>([\s\S]*?)<\/t>/g)].map((m) => xmlText(m[1])).join(''); // What a downloaded workbook holds: its sheet names, in order, and all of its // cell text (shared strings and inline strings, joined), unescaped. @@ -92,7 +97,7 @@ export function sheetCells(buf, sheetName) { const zip = readZip(buf); const xml = (name) => (zip.get(name) ? zip.get(name).toString('utf8') : ''); const shared = [...xml('xl/sharedStrings.xml').matchAll(/([\s\S]*?)<\/si>/g)] - .map((m) => xmlText(m[1].replace(/<[^>]+>/g, ''))); + .map((m) => runText(m[1])); // Each tag's attributes are read one by one, so their order does not matter. const entry = [...xml('xl/workbook.xml').matchAll(/]*>/g)] .map((m) => ({ name: /\bname="([^"]*)"/.exec(m[0]), rid: /\br:id="([^"]*)"/.exec(m[0]) })) @@ -110,7 +115,7 @@ export function sheetCells(buf, sheetName) { const v = /([\s\S]*?)<\/v>/.exec(m[2]); const inline = /([\s\S]*?)<\/is>/.exec(m[2]); let text = null; - if (inline) text = xmlText(inline[1].replace(/<[^>]+>/g, '')); + if (inline) text = runText(inline[1]); else if (v) text = type && type[1] === 's' ? shared[Number(v[1])] : xmlText(v[1]); if (text !== null && text !== '') out[ref[1]] = text; } From f7cb4169c82f95262572948456cac40b3b3ee21f Mon Sep 17 00:00:00 2001 From: Ricardo Oliveira Date: Thu, 15 Oct 2026 10:08:39 +0100 Subject: [PATCH 31/31] test(bc-e2rv): review round 6: requalification and representative-bout tests; harness polish A corrected pool result after the knockout started is now pinned to wait for the tie-break bout over a larger-pools extra place and a fill-bracket drafted 2nd (both red with the requalification cut reverted to Winners per pool). The team sheet's refused-autosave adopt of the representative bout's score and pick is pinned (red with that branch returning the local row). Harness: a single empty-marks read waits for the card's four slots, the spectator step title names the followed competitor's side correctly, the first pools-tiebreak bracket read counts roster names only, and three audit settles in knockout-mixed-individual are marked Deliberate. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Wyy8S51EvuQx3shdiTPwkp --- .../engine/tiebreak_extra_qualifier_test.go | 124 ++++++++++++++++++ scripts/e2e/fixtures/public.mjs | 8 +- .../knockout-mixed-individual.spec.mjs | 3 + scripts/e2e/journeys/pools-tiebreak.spec.mjs | 5 +- .../e2e/journeys/spectator.public.spec.mjs | 6 +- ..._stamps_only_while_running.render.test.jsx | 82 +++++++++++- 6 files changed, 221 insertions(+), 7 deletions(-) diff --git a/internal/engine/tiebreak_extra_qualifier_test.go b/internal/engine/tiebreak_extra_qualifier_test.go index b4d745ccf..e179bcc29 100644 --- a/internal/engine/tiebreak_extra_qualifier_test.go +++ b/internal/engine/tiebreak_extra_qualifier_test.go @@ -534,3 +534,127 @@ func TestFillBracketDraftedSecondTie_QualificationWaitsForTheBout(t *testing.T) require.NoError(t, err) assert.Equal(t, names[2], slot(), "the bout's winner takes the drafted 2nd place") } + +// tiebreakBoutsOf lists the tie-break bouts a competition's pool matches hold. +func tiebreakBoutsOf(t *testing.T, store *state.Store, compID string) []state.MatchResult { + t.Helper() + matches, err := store.LoadPoolMatches(compID) + require.NoError(t, err) + var tbs []state.MatchResult + for _, m := range matches { + if IsTiebreakerMatchID(m.ID) { + tbs = append(tbs, m) + } + } + return tbs +} + +// bothPlayed finds the stored pool match whose sides are a and b. +func bothPlayed(t *testing.T, store *state.Store, compID, a, b string) string { + t.Helper() + matches, err := store.LoadPoolMatches(compID) + require.NoError(t, err) + for _, m := range matches { + if (m.SideA == a && m.SideB == b) || (m.SideA == b && m.SideB == a) { + return m.ID + } + } + require.Failf(t, "match not found", "%s v %s", a, b) + return "" +} + +// bc-fbtb / bc-xqtb review: a CORRECTION that leaves two players level on the +// pool's extra place goes through the requalification, which judges the tie +// against the pool's own cut (poolTieBreakCut), not the uniform Winners per +// pool. A pool whose extra place is cut by Winners per pool alone would seat +// one of the two by sort order. +func TestRequalify_CorrectionTieOnLargerPoolsExtraPlace_WaitsForTheBout(t *testing.T) { + f := newRQFixture(t, "rq-xqtb-fix", 1, [][]string{ + {"A1", "A2", "A3", "A4"}, {"B1", "B2", "B3"}, {"C1", "C2", "C3"}, + }, largerPools(3)) + require.NoError(t, f.store.SaveBracket(f.compID, &state.Bracket{Rounds: [][]state.BracketMatch{{ + {ID: "m-r1-0", MatchNumber: 1, SideA: "Pool A-1st", SideB: "Pool C-1st", PlaceholderA: "Pool A-1st", PlaceholderB: "Pool C-1st"}, + {ID: "m-r1-1", MatchNumber: 2, SideA: "Pool B-1st", SideB: "Pool A-2nd", PlaceholderA: "Pool B-1st", PlaceholderB: "Pool A-2nd"}, + }}})) + for _, w := range [][2]string{ + {"Pool A-0", "A1"}, {"Pool A-1", "A1"}, {"Pool A-2", "A1"}, + {"Pool A-3", "A2"}, {"Pool A-4", "A2"}, {"Pool A-5", "A3"}, + {"Pool B-0", "B1"}, {"Pool B-1", "B1"}, {"Pool B-2", "B2"}, + {"Pool C-0", "C1"}, {"Pool C-1", "C1"}, {"Pool C-2", "C2"}, + } { + f.scorePool(w[0], w[1]) + } + f.resolve() + setCompStatus(t, f.store, f.compID, state.CompStatusKnockout) + m, side := f.slot("Pool A-2nd") + name, _ := sideOf(m, side) + require.Equal(t, "A2", name, "before the correction A2 is the clear 2nd") + + // Correct A2 v A3 to a hikiwake: A2 and A3 are level for the extra place. + require.NoError(t, f.write("Pool A-3", &state.MatchResult{ + SideA: "A2", SideB: "A3", SideAID: rqID("A2"), SideBID: rqID("A3"), + Status: state.MatchStatusCompleted, Decision: string(domain.DecisionHikiwake), + })) + m, side = f.slot("Pool A-2nd") + name, id := sideOf(m, side) + assert.Equal(t, "Pool A-2nd", name, "a tied extra place is not seated by sort order") + assert.Empty(t, id) + + _, err := f.eng.MaybeAutoCompletePools(f.compID) + require.NoError(t, err) + tbs := tiebreakBoutsOf(t, f.store, f.compID) + require.Len(t, tbs, 1, "the tie over the extra place is owed a tie-break bout") + assert.ElementsMatch(t, []string{"A2", "A3"}, []string{tbs[0].SideA, tbs[0].SideB}) + + f.scorePool(tbs[0].ID, "A3") + _, err = f.eng.MaybeAutoCompletePools(f.compID) + require.NoError(t, err) + m, side = f.slot("Pool A-2nd") + name, id = sideOf(m, side) + assert.Equal(t, "A3", name, "the bout's winner takes the extra place") + assert.Equal(t, rqID("A3"), id) +} + +// The same correction over a fill-bracket drafted pool's 2nd, drawn through the +// real pipeline. +func TestRequalify_CorrectionTieOnFillBracketDraftedSecond_WaitsForTheBout(t *testing.T) { + const compID = "fbtb-fix" + eng, store, drafted, _ := startFillBracket(t, compID, false) + // A clean result everywhere: the drafted pool's order is the draw order. + scoreFillBracketPools(t, eng, store, compID, "", false, true) + names := drawnPoolNames(t, store, compID, drafted) + f := &rqFix{t: t, eng: eng, store: store, compID: compID} + label := drafted + "-2nd" + + _, err := eng.MaybeAutoCompletePools(compID) + require.NoError(t, err) + m, side := f.slot(label) + name, _ := sideOf(m, side) + require.Equal(t, names[1], name, "before the correction the draw's second seed holds the drafted 2nd") + + // Correct the 2nd v 3rd result to a hikiwake: they are level for 2nd. + matchID := bothPlayed(t, store, compID, names[1], names[2]) + prior := loadPoolMatchByID(t, store, compID, matchID) + require.NotNil(t, prior) + require.NoError(t, f.write(matchID, &state.MatchResult{ + SideA: prior.SideA, SideB: prior.SideB, SideAID: prior.SideAID, SideBID: prior.SideBID, + Status: state.MatchStatusCompleted, Decision: string(domain.DecisionHikiwake), + })) + m, side = f.slot(label) + name, id := sideOf(m, side) + assert.Equal(t, label, name, "a tied drafted 2nd is not seated by sort order") + assert.Empty(t, id) + + _, err = eng.MaybeAutoCompletePools(compID) + require.NoError(t, err) + tbs := tiebreakBoutsOf(t, store, compID) + require.Len(t, tbs, 1, "the tie over the drafted 2nd is owed a tie-break bout") + assert.ElementsMatch(t, []string{names[1], names[2]}, []string{tbs[0].SideA, tbs[0].SideB}) + + f.scorePool(tbs[0].ID, names[2]) + _, err = eng.MaybeAutoCompletePools(compID) + require.NoError(t, err) + m, side = f.slot(label) + name, _ = sideOf(m, side) + assert.Equal(t, names[2], name, "the bout's winner takes the drafted 2nd place") +} diff --git a/scripts/e2e/fixtures/public.mjs b/scripts/e2e/fixtures/public.mjs index dc1516a01..b885bede6 100644 --- a/scripts/e2e/fixtures/public.mjs +++ b/scripts/e2e/fixtures/public.mjs @@ -119,8 +119,12 @@ export const nowCard = (page) => page.locator('.match-detail-card').filter({ has // The match card (MatchViewerModal): a dialog named "Match: vs ". export const matchCard = (page) => page.getByRole('dialog', { name: /^Match:/ }); -// The ippon letters the card shows per side, sorted ("DM", "K"): a side has two -// slots, each holding a letter once scored and nothing before. +// A card's ippon slots: two per side, each holding a letter once scored and +// nothing before. Wait for all four before a single read that expects none, +// since a slot not yet rendered also reads as nothing. +export const cardSlots = (card) => card.getByTestId('sub-marks').locator('.msb-slot'); + +// The ippon letters the card shows per side, sorted ("DM", "K"). export async function cardMarks(card) { const marks = card.getByTestId('sub-marks'); const letters = async (loc) => (await loc.allTextContents()).map((t) => t.trim()).filter(Boolean).sort().join(''); diff --git a/scripts/e2e/journeys/knockout-mixed-individual.spec.mjs b/scripts/e2e/journeys/knockout-mixed-individual.spec.mjs index fcf6015b8..39e0f3c4a 100644 --- a/scripts/e2e/journeys/knockout-mixed-individual.spec.mjs +++ b/scripts/e2e/journeys/knockout-mixed-individual.spec.mjs @@ -370,6 +370,7 @@ test.describe('knockout-mixed-individual', () => { await expect(syncState(page)).toHaveText('Synced'); await finishMatch(page, INLINE_EDITOR); await expect(completedRowFor(page, lastPoolA)).toBeVisible(); + // Deliberate: a settle before an audit row only; nothing is asserted on it. await page.waitForTimeout(2500); const afterBoundary = { editorRunning: await inlineRunning(page), @@ -897,6 +898,7 @@ test.describe('knockout-mixed-individual', () => { let retryRow = { present: await retry.isVisible().catch(() => false) }; if (retryRow.present) { retryRow = { ...retryRow, size: await tapSize(retry), ...(await doubleTap(retry)) }; + // Deliberate: a settle before an audit row only; nothing is asserted on it. await page.waitForTimeout(1500); retryRow.bannerAfter = (await banner.allInnerTexts()).join(' | '); } @@ -909,6 +911,7 @@ test.describe('knockout-mixed-individual', () => { let restart = null; if (upAgain && upAgain.shiro === semi.shiro && upAgain.aka === semi.aka) { await upNextCard(page).getByRole('button', { name: 'Start match' }).tap(); + // Deliberate: a settle before an audit row only; nothing is asserted on it. await page.waitForTimeout(1500); restart = { editor: await inlineEditor(page).isVisible().catch(() => false), running: await inlineRunning(page) ? await editorSides(page) : null, diff --git a/scripts/e2e/journeys/pools-tiebreak.spec.mjs b/scripts/e2e/journeys/pools-tiebreak.spec.mjs index 7502381dd..4ff2f404a 100644 --- a/scripts/e2e/journeys/pools-tiebreak.spec.mjs +++ b/scripts/e2e/journeys/pools-tiebreak.spec.mjs @@ -336,8 +336,9 @@ test.describe('pools: minimum pool size and a tie-break bout', () => { test('the winner, not the loser, is seated in the knockout: admin and public viewer', async ({ page, browser, baseURL }) => { await login(page); const qualifiers = [big[0], big[1], tb.a, tbWinner].sort(); - const seatedIn = async (p) => Object.values(await bracketSlots(p, (slots) => Object.values(slots).length === 4)) - .map((s) => s.name).sort(); + // Roster names only, so an unresolved placeholder never counts as seated. + const seatedIn = async (p) => Object.values(await bracketSlots(p, (slots) => Object.values(slots).filter((s) => NAMES.includes(s.name)).length === 4)) + .map((s) => s.name).filter((n) => NAMES.includes(n)).sort(); await openCompetitionTab(page, compId, 'bracket'); expect(await seatedIn(page), 'the admin bracket seats the four qualifiers').toEqual(qualifiers); await shot(page, 'admin-bracket-after'); diff --git a/scripts/e2e/journeys/spectator.public.spec.mjs b/scripts/e2e/journeys/spectator.public.spec.mjs index 4b8aef32c..5330fdf98 100644 --- a/scripts/e2e/journeys/spectator.public.spec.mjs +++ b/scripts/e2e/journeys/spectator.public.spec.mjs @@ -30,7 +30,7 @@ import { openShiaijo, sides, startUpNext, upNextCard, waitingRows } from '../fix import { awardIppon, clearMarks, filledSlots, finishMatch, syncState } from '../fixtures/scoring.mjs'; import { editorSides } from '../fixtures/individual.mjs'; import { - NATURAL, applyScheduleText, cardMarks, clearScheduleText, competitionTab, homeNowRow, expandedCard, matchCard, nowCard, numbersOf, pickerEntries, + NATURAL, applyScheduleText, cardMarks, cardSlots, clearScheduleText, competitionTab, homeNowRow, expandedCard, matchCard, nowCard, numbersOf, pickerEntries, pickerOption, pickerPairs, poolRow, scheduleCount, scheduleFilterInput, scheduleRow, scheduleRows, upNextRow, watchHero, watchHeroMatch, watchInput, } from '../fixtures/public.mjs'; @@ -284,7 +284,7 @@ test.describe('spectator on a phone', () => { const followed = [first.shiro, first.aka].find((n) => n === next.shiro || n === next.aka); expect(followed, `a competitor is in both court A's first bout (${first.shiro} v ${first.aka}) and its next (${next.shiro} v ${next.aka})`).toBeTruthy(); - await test.step('follow the Aka side of court A\'s first bout: the hero shows the match waiting', async () => { + await test.step('follow the competitor who fights both of court A\'s first two bouts: the hero shows the match waiting', async () => { await watchInput(page).fill(followed.toLowerCase().slice(0, 5)); await pickerOption(page, followed).tap(); await expect(watchHero(page)).toContainText(followed); @@ -305,6 +305,7 @@ test.describe('spectator on a phone', () => { await watchHeroMatch(page).tap(); await expect(card).toBeVisible(); await expect(card).toContainText('NOW'); + await expect(cardSlots(card)).toHaveCount(4); expect(await cardMarks(card)).toEqual({ shiro: '', aka: '' }); await awardIppon(op, 'shiro', 'M'); await expect.poll(() => cardMarks(card)).toEqual({ shiro: 'M', aka: '' }); @@ -391,6 +392,7 @@ test.describe('spectator on a phone', () => { const expanded = expandedCard(page); await expect(expanded).toBeVisible(); await expect(expanded).not.toContainText('NOW'); + await expect(cardSlots(expanded)).toHaveCount(4); expect(await cardMarks(expanded)).toEqual({ shiro: '', aka: '' }); await expect.poll(() => cardMarks(nowCard(page)), { message: 'Overview: the ON NOW card opens on the live score' }) .toEqual({ shiro: 'DM', aka: 'K' }); diff --git a/web-mobile/js/__tests__/render/team_editor_stamps_only_while_running.render.test.jsx b/web-mobile/js/__tests__/render/team_editor_stamps_only_while_running.render.test.jsx index 50a5f6324..eec4bef70 100644 --- a/web-mobile/js/__tests__/render/team_editor_stamps_only_while_running.render.test.jsx +++ b/web-mobile/js/__tests__/render/team_editor_stamps_only_while_running.render.test.jsx @@ -9,10 +9,15 @@ // "The edit-time adopt rule" in .claude/rules/match-results-and-marks.md. import React from 'react'; -import { render, act, fireEvent } from '@testing-library/react'; +import { render, act, fireEvent, screen } from '@testing-library/react'; import { describe, it, expect, vi, beforeAll, afterAll, beforeEach, afterEach } from 'vitest'; import { installWindowStubs } from '../helpers/stub_globals.js'; +const SQUADS = { + t1: [{ id: 'm1a', name: 'Alice', index: 1 }, { id: 'm2a', name: 'Brenda', index: 2 }], + t2: [{ id: 'm1b', name: 'Carol', index: 1 }, { id: 'm2b', name: 'Dana', index: 2 }], +}; + const STUBBED_GLOBALS = { isHikiwake: () => false, arraysEqual: (a, b) => a.length === b.length && a.every((v, i) => v === b[i]), @@ -24,6 +29,10 @@ const STUBBED_GLOBALS = { fetchCompetitionDetails: vi.fn().mockResolvedValue({ id: 'comp1', config: { format: 'mixed', teamMatchType: 'kachinuki', naginata: false, players: [] }, }), + fetchSquads: vi.fn().mockResolvedValue(SQUADS), + fetchTeamLineup: vi.fn().mockResolvedValue({ saved: false, positions: {} }), + fetchLineupInForce: vi.fn().mockResolvedValue({ saved: false, positions: {} }), + fetchMatchHistory: vi.fn().mockResolvedValue([]), recordScore: vi.fn().mockResolvedValue(undefined), recordDaihyosen: vi.fn(), removeDaihyosen: vi.fn(), @@ -42,6 +51,7 @@ let ScoreEditorModal; beforeAll(async () => { restoreGlobals = installWindowStubs(STUBBED_GLOBALS); await import('../../admin_scoring_modal.jsx'); + await import('../../admin_lineup.jsx'); ScoreEditorModal = window.ScoreEditorModal; }); @@ -171,4 +181,74 @@ describe('a refused autosave takes the server\'s copy of the rows it carried', ( await settle(); expect(filled('aka'), 'the newer edit is still owed').toBeGreaterThan(0); }); + + // The representative bout (position -1) is taken PART by part: the server's row score for the + // bout the save carried, and the pick of each side whose pick the save carried. The other + // side's pick was not carried, so it stays the operator's (composeRepRow). + describe('the representative bout', () => { + const FOUGHT = [1, 2, 3].map((position) => ({ + position, sideA: '', sideB: '', ipponsA: [], ipponsB: [], winner: '', decision: 'hikiwake', + })); + const repRow = (extra) => ({ + position: -1, sideA: 'Team A', sideB: 'Team B', ipponsA: [], ipponsB: [], winner: '', decision: 'daihyosen', ...extra, + }); + const repMatch = (row, modifiedAt = 0) => ({ + id: 'k-rep', compId: 'comp1', status: 'running', phase: 'knockout', court: 'A', modifiedAt, + compKind: 'team', teamSize: 3, compFormat: 'mixed', teamMatchType: 'fixed', + sideA: { id: 't1', name: 'Team A' }, sideB: { id: 't2', name: 'Team B' }, + subResults: [...FOUGHT, row], + }); + const dhInput = (side) => screen.queryAllByLabelText(/^Daihyosen (SHIRO|AKA) player$/) + .find((e) => e.getAttribute('aria-label').includes(side)); + const repRowEl = () => dhInput('AKA').closest('.team-sub-match'); + const repFilled = (side) => repRowEl().querySelectorAll(`.tsm-center-pts--${side} .editor-side__pt--filled`).length; + + async function pickShiro(name) { + await act(async () => { fireEvent.focus(dhInput('SHIRO')); fireEvent.click(dhInput('SHIRO')); }); + await settle(); + const opt = screen.getByText(name, { selector: '.pmf__opt-name' }); + await act(async () => { fireEvent.click(opt.closest('button')); }); + await settle(); + } + + async function repEditThenAnswer(answer) { + const onSubmit = vi.fn().mockResolvedValue(answer); + let utils; + // Aka (t1) already holds Alice; the sheet starts level. + const start = repMatch(repRow({ sideAMemberId: 'm1a' })); + await act(async () => { utils = render(el(start, onSubmit)); }); + await settle(); + // Aka strikes M on the representative bout and Shiro's representative is picked: two edits. + const men = [...repRowEl().querySelectorAll('.ipt-btn')].filter((b) => b.textContent === 'M'); + await act(async () => { fireEvent.click(men[men.length - 1]); }); + await pickShiro('Carol'); + const tappedAt = Date.now(); + expect([repFilled('aka'), dhInput('SHIRO').value, dhInput('AKA').value], 'precondition: both edits show') + .toEqual([1, 'Carol', 'Alice']); + // Another device's copy, stamped before the tap, so the stamps hold the operator's row: it + // scored Shiro and picked Dana for Shiro; Aka's pick stays Alice (the side the save did not + // carry: an unedited pick is followed by the ordinary adopt, so only a take that wrongly + // reached it, or dropped the sheet's, would show here). + const served = repMatch(repRow({ ipponsB: ['K'], sideAMemberId: 'm1a', sideBMemberId: 'm2b' }), tappedAt - 1); + await act(async () => { utils.rerender(el(served, onSubmit)); }); + expect([repFilled('aka'), repFilled('shiro')], 'precondition: the older copy is held').toEqual([1, 0]); + await act(async () => { vi.advanceTimersByTime(400); }); + await settle(); + expect(onSubmit, 'precondition: the autosave went out').toHaveBeenCalled(); + return onSubmit; + } + + it.each([['a superseded answer', REFUSED], ['a nothing-handed-back answer', undefined]])( + '%s takes the server\'s score and the picked side\'s pick, and keeps the other side\'s pick', + async (_name, answer) => { + const onSubmit = await repEditThenAnswer(answer); + const sent = onSubmit.mock.calls[0][0].subResults.find((s) => s.position === -1); + // The save carried Aka's point and Shiro's pick, never Aka's pick. + expect(sent.ipponsA).toEqual(['M']); + expect(sent.sideBMemberId).toBe('m1b'); + expect([repFilled('aka'), repFilled('shiro')], 'the server\'s score on the row').toEqual([0, 1]); + expect(dhInput('SHIRO').value, 'the picked side shows the server\'s pick').toBe('Dana'); + expect(dhInput('AKA').value, 'the side the save did not carry keeps the operator\'s pick').toBe('Alice'); + }); + }); });