From c956f601efdc8a195face27e4b00c0a614cda9a8 Mon Sep 17 00:00:00 2001 From: Ricardo Oliveira Date: Wed, 7 Oct 2026 18:38:43 +0100 Subject: [PATCH 01/16] fix(bc-dlck): remove the decision-locked confirm on a withdrawal correction Recording a withdrawal on the other side after a later match had started showed a generic "Proceed anyway?" confirm that named neither the match nor what confirming did. Under the 2026-09-27 ruling a withdrawal correction changes only that match, and the matches it can reach in a knockout already carry their own refusal or confirm, so the confirm guarded nothing. It is removed with its engine lock (ErrDecisionLocked, hasDownstreamMatchStarted), the force flag on the decision write, and the client's retry. Knockout behaviour is unchanged (operator decision 2026-10-07): a running later match is still refused, a played one is still named and confirmed before it is reopened. Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 6 +- .../court-operators/recording-decisions.md | 3 +- internal/engine/bc_idfx_test.go | 10 +- internal/engine/decision_no_cascade_test.go | 79 +++++++++ internal/engine/decision_stamp_test.go | 4 +- .../engine/default_win_bout_credit_test.go | 12 +- ...efault_win_bout_padding_unreadable_test.go | 4 +- .../downstream_correction_guard_test.go | 54 ++---- .../engine/downstream_running_refusal_test.go | 22 ++- internal/engine/eligibility.go | 104 ++--------- internal/engine/eligibility_test.go | 166 +++++++----------- internal/engine/errors.go | 7 - .../kachinuki_reopen_withdrawal_test.go | 6 +- .../engine/load_error_propagation_test.go | 6 - internal/engine/match_merge_phase3_test.go | 18 +- internal/engine/match_merge_review_test.go | 2 +- internal/engine/match_merge_test.go | 47 +++-- internal/engine/pool_requalify_test.go | 4 +- internal/engine/reopen_bye_unwind_test.go | 2 +- .../engine/reopen_fusensho_status_test.go | 18 +- .../engine/reopen_scheduled_court_test.go | 4 +- internal/engine/reopen_withdrawal_test.go | 12 +- .../restore_touches_no_eligibility_test.go | 2 +- internal/engine/scoring.go | 5 +- internal/engine/scoring_test.go | 2 +- internal/engine/scoring_tx.go | 119 ++++--------- internal/engine/scoring_tx_internal_test.go | 93 +--------- internal/engine/scoring_tx_test.go | 78 ++++---- internal/engine/winnerid_invariant_test.go | 2 +- .../withdrawal_cleared_by_correction_test.go | 4 +- .../withdrawal_removed_restores_test.go | 6 +- .../engine/withdrawal_ruling_kept_test.go | 8 +- .../mobileapp/decision_no_confirm_test.go | 73 ++++++++ internal/mobileapp/deps.go | 16 +- internal/mobileapp/deps_test.go | 6 +- .../downstream_running_handler_test.go | 11 +- internal/mobileapp/handlers_decision.go | 36 ++-- .../ineligible_sides_annotation_test.go | 6 +- .../reopen_downstream_labels_test.go | 2 +- specs/openapi.yaml | 3 +- .../js/__tests__/admin_scoring_modal.test.jsx | 92 +++------- .../api_client_downstream_knockout.test.jsx | 6 +- .../admin_scoring_modal.render.test.jsx | 3 +- web-mobile/js/__tests__/sync_queue.test.jsx | 34 +--- web-mobile/js/admin_scoring_individual.jsx | 10 +- web-mobile/js/admin_scoring_shared.jsx | 36 +--- web-mobile/js/admin_scoring_team.jsx | 5 +- web-mobile/js/api_client.jsx | 10 +- web-mobile/js/write_result.jsx | 2 +- 49 files changed, 513 insertions(+), 747 deletions(-) create mode 100644 internal/engine/decision_no_cascade_test.go create mode 100644 internal/mobileapp/decision_no_confirm_test.go diff --git a/CLAUDE.md b/CLAUDE.md index c6f77d3e3..c6ad9b069 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -299,7 +299,7 @@ Operator ruling 2026-10-03: "Nothing should be dropped. All events must be order - **Phase 3, the remaining drop sites, each with one owner:** - **The running rev guard holds, never drops.** A running write with a lower `rev` than the same `revSession` already sent is answered by `holdOlderRevision` (handlers_match.go): one bare transaction, no court lock, no start gate, no correction check, and the high-water mark left alone. It calls `RecordMatchResultWithIneligibilityTx` with `engine.ForceOptions{HoldReason: engine.HoldReasonOlderRevision}`, whose `holdWriteTx` asks the merge owner on a copy with `mergeCtx.holdAll` (every changed group held, echoes excepted), records the history entry with the reason, and returns the superseded error; the answer is `respondSuperseded` with `heldGroups`. `{stale:true}` no longer exists anywhere (server, openapi, client: `_handleOkBody` and the admin toast that read it are gone). - - **The offline queue keeps, per match, a list of pending writes, each sent on its own** (api_client.jsx). Each pending write is its own entry, keyed `${base}${QUEUE_ENTRY_SEP}${id}` (`_newEntryKey`; the separator is `\u001f`), where the BASE is what the write is about (`_revKey` for a score or a decision, `override:…`, `lineup:…`; `_baseOf`). A key persisted by the previous build has no separator, so it is its own base: an old queue loads and replays unchanged, which is the migration. The ONE coalescing rule is `_coalesceTarget`: a score write takes the place of its match's LAST entry only when that entry is a score write not newer than it and not the page-hide copy of a write still being sent, and a RUNNING write only of a running one (`_placeWrite` then claims the union and carries a pending `kachinukiBoutFinal`); a LINEUP save joins a queued save of the same lineup (`joinQueuedLineupSave`, lineup_save.jsx: the union of `changed`, the later value per position): a lineup PUT carries no stamp the server could order it by, so a direct save could land before the queued one's replay and lose to it on a position both changed; the caller reads `{queued: true}` for the moment until the flush sends it. Nothing else replaces anything, so a running write never replaces a queued Finish or a decision, and a decision and a score are two entries. A pass sends entries in the ONE replay order, `_byEntryOrder` (the payload's `modifiedAt`, else `enqueuedAt`, then the key), but each entry on its own: one that does not settle (offline, 5xx, parked, re-stamped for skew, held by an open fetch) holds back nothing after it, and a new score, decision or override goes straight to the server whatever is queued, because the server merges by stamp, not arrival (bc-mrgc review C2: the old wait-behind rule let one write failing with a 5xx stop every later write for its match). The editors' "Retry now" on a queued Finish therefore sends it at once: landing, it drains the queued copy it claimed; failing, it takes that copy's place. A landed direct score write drains only the entry it claimed (`claimedEntry`), never a queued Finish or decision. The page-hide copy (`_keepInflightRunning`) is an entry of its own (`w.keptKey`); recordScore settles its OWN copy through the record it created (`inflightRecord`), even after a later running write took the map slot, and when it had one and its fetch answered it schedules a flush a microtask later, after the answer has drained its claimed entry. A queued answer says whether the write reached browser storage (`_queuedAnswer`: `persisted: false` only when `_persistQueue` failed, read before the enqueue kicks a flush). A write the server keeps refusing (past `SERVER_REJECTION_NOTICE_THRESHOLD`) can be discarded alone from its match's editor (`HeldWriteDiscard`, admin_scoring_shared.jsx; `API.heldWriteKeepsFailing`/`discardFailingHeldWrites`, that match's failing entries only, after a confirm). `resumeAfterAuth` gives the new password to every parked write AND every queued write still carrying a password the server refused, never to one queued with none (a self-run participant's). Every refusal on replay is reported: a decision's 409 (`decision_locked`, `already_ineligible` included) goes to the terminal-fail and `rejected` alert channels like any other, because the server answers an EXACT replay of a recorded decision (same decision, side and stamp: a send whose answer was lost) as recorded before any of its refusals (`exactDecisionReplay`, scoring_tx.go). A held finish that lands and moves a later change to the history (`displacedGroups`) counts as sent and raises one `displaced` information alert per pass. Nothing is dropped for its age: `QUEUE_TTL_MS` and the `expired` alert are gone, and an old entry is sent; the server orders it and keeps what loses in the history. Pinned by `queue_ordered_writes.test.jsx` and `sync_queue.test.jsx`. + - **The offline queue keeps, per match, a list of pending writes, each sent on its own** (api_client.jsx). Each pending write is its own entry, keyed `${base}${QUEUE_ENTRY_SEP}${id}` (`_newEntryKey`; the separator is `\u001f`), where the BASE is what the write is about (`_revKey` for a score or a decision, `override:…`, `lineup:…`; `_baseOf`). A key persisted by the previous build has no separator, so it is its own base: an old queue loads and replays unchanged, which is the migration. The ONE coalescing rule is `_coalesceTarget`: a score write takes the place of its match's LAST entry only when that entry is a score write not newer than it and not the page-hide copy of a write still being sent, and a RUNNING write only of a running one (`_placeWrite` then claims the union and carries a pending `kachinukiBoutFinal`); a LINEUP save joins a queued save of the same lineup (`joinQueuedLineupSave`, lineup_save.jsx: the union of `changed`, the later value per position): a lineup PUT carries no stamp the server could order it by, so a direct save could land before the queued one's replay and lose to it on a position both changed; the caller reads `{queued: true}` for the moment until the flush sends it. Nothing else replaces anything, so a running write never replaces a queued Finish or a decision, and a decision and a score are two entries. A pass sends entries in the ONE replay order, `_byEntryOrder` (the payload's `modifiedAt`, else `enqueuedAt`, then the key), but each entry on its own: one that does not settle (offline, 5xx, parked, re-stamped for skew, held by an open fetch) holds back nothing after it, and a new score, decision or override goes straight to the server whatever is queued, because the server merges by stamp, not arrival (bc-mrgc review C2: the old wait-behind rule let one write failing with a 5xx stop every later write for its match). The editors' "Retry now" on a queued Finish therefore sends it at once: landing, it drains the queued copy it claimed; failing, it takes that copy's place. A landed direct score write drains only the entry it claimed (`claimedEntry`), never a queued Finish or decision. The page-hide copy (`_keepInflightRunning`) is an entry of its own (`w.keptKey`); recordScore settles its OWN copy through the record it created (`inflightRecord`), even after a later running write took the map slot, and when it had one and its fetch answered it schedules a flush a microtask later, after the answer has drained its claimed entry. A queued answer says whether the write reached browser storage (`_queuedAnswer`: `persisted: false` only when `_persistQueue` failed, read before the enqueue kicks a flush). A write the server keeps refusing (past `SERVER_REJECTION_NOTICE_THRESHOLD`) can be discarded alone from its match's editor (`HeldWriteDiscard`, admin_scoring_shared.jsx; `API.heldWriteKeepsFailing`/`discardFailingHeldWrites`, that match's failing entries only, after a confirm). `resumeAfterAuth` gives the new password to every parked write AND every queued write still carrying a password the server refused, never to one queued with none (a self-run participant's). Every refusal on replay is reported: a decision's 409 (`already_ineligible` included) goes to the terminal-fail and `rejected` alert channels like any other, because the server answers an EXACT replay of a recorded decision (same decision, side and stamp: a send whose answer was lost) as recorded before any of its refusals (`exactDecisionReplay`, scoring_tx.go). A held finish that lands and moves a later change to the history (`displacedGroups`) counts as sent and raises one `displaced` information alert per pass. Nothing is dropped for its age: `QUEUE_TTL_MS` and the `expired` alert are gone, and an old entry is sent; the server orders it and keeps what loses in the history. Pinned by `queue_ordered_writes.test.jsx` and `sync_queue.test.jsx`. - **Override-winner** stamps and orders the `result` group (phase 1); a held assertion is answered `respondSuperseded(["result"])` (reason `superseded`, `heldGroups`), the client returns `heldGroups`, the Resolve feeders toast says it was kept in the history (`OVERRIDE_HELD_NOTICE`, write_result.jsx), and a queued one the server held counts into the pass's superseded alert as well as asking for a bracket resync. - **The engi finish** is stamped by `recordScore` like every write, and the server orders it: `engiFinishHeld` (engi.go) asks the merge on a copy built from the stored match (`engiFinishProbe`), BEFORE the downstream guard; the flags and the winner they decide are atomic (either held → both held, `HoldReasonEngiAtomic`, reached when the flags are newer than the stored count but the result is older than the stored one, e.g. after a later reopen; a stale finish over a COMPLETED match is held whole one step earlier, by `HoldReasonFinishAtomic`) -- except that the NEWEST COUNT DECIDES (operator ruling 2026-10-04): when a finish's result applies but a newer stored VALID count holds its flags, the finish closes the match with the winner that count gives, the stored count stays, its stamp stamps the result, and the finish's own count is kept in the history as held, so both arrival orders end alike (`TestMerge_EngiNewestValidCountDecidesInBothOrders`); a newer INVALID count is still moved to the history by the displacement above -- and an ECHO of the stored flags (same count, under an older stamp) is not a hold at all, so the finish still applies; held → history and superseded; applied → `recordEngiMatch` writes `result`+`flags` with the stamps the merge decided (`engiFinishHeld` carries the probe's `GroupStamps` down, `stampEngiChanges`/`ApplyMergedGroupStamps`), so both arrival orders reach the same stamps after a displacement; an unstamped writer still gets the server's time. - **Read-modify-writes are one transaction.** `InjectPoolDaihyosenMatches`, `InjectTiebreakerMatches` (the engi self-heal too), `GenerateLeagueTiebreakMatches` and `AdvanceSwissRound`'s append each read pool-matches.csv, decide, and save under ONE `WithTransaction` (standings through `computeStandingsFrom(tx)`, the tournament read before the tx, `appendWithSlots` for the new slots); a score landing meanwhile waits for the lock and goes on top. `MaybeAdvanceKachinuki` still reads without the lock, and its locked write re-checks (`kachinukiAdvanceStillHolds`: not completed, same status, stamp and bout log) and on `errKachinukiAdvanceStale` reads the match again and works the pairing out from it (`advanceKachinukiOnce`, up to `kachinukiAdvanceAttempts` times, standing down only then: a stand-down answered the operator's Record bout with no next pairing; a finish that landed is final on the re-read, so it is never reopened; the bracket branch uses `UpdateBracket` with `Bracket.MatchByID` so it can abort). `Engine.afterMatchRead` is the test-only seam at each read (`lost_update_window_test.go` lands a real write there). @@ -314,7 +314,7 @@ A match write can be REFUSED by the server, and a refusal is always **HTTP 200** - `superseded`: the merge held every group the write changes (bc-mrgc): a NEWER change to each is already stored. Nothing of it was applied, and it is NOT lost: its values are in the match's history and the answer names `heldGroups`. The operator must **not** blindly re-enter: a fresh entry is stamped now and would overwrite the newer change. - `clock_skew`: the write's `modifiedAt` was more than 5s in the server's future (`modifiedAtRefuseSkewMs`, handlers_match.go), so it is provably wrong. Nothing was stored, the client resyncs its `/api/time` offset, and re-entering IS the remedy. The copy for the two cases is deliberately opposite; do not merge them. -**Which endpoints compete on timestamps**: `/score`, `/override-winner`, `/decision` (since mp-jnvl) and both daihyosen paths (since bc-dhas) each carry the client's stamp, so any of them can be refused for either reason. Quick-score alone still builds its result with NO stamp; the merge gives it the server's time, never older than the stored stamp (`writeStamp`, bc-mrgc review), so it still always applies and now leaves a fence a stale replay is ordered against; it maps the refusal shape defensively anyway. The daihyosen add and remove are stamped so the match they write is newer than a copy read before them, which is what lets `keepNewerMatches` keep the post-add copy over a stale refetch. Both take an optional JSON body `{"modifiedAt": N}` (the DELETE too; an empty body is unstamped and always applies), judged by `daihyosenWriteStamp` through the same `clientClockSkew`/`clampClientModifiedAt` pair as `/decision`; the stamp rides on the match the handler writes, so the ordinary write guard decides. `recordDaihyosen`/`removeDaihyosen` hand a refusal back as the refusal body (`_daihyosenOutcome`), and the team editor reports it through `notLandedBanner` and adopts nothing. A decision is stamped because it is the write that CLOSES a match, and a match closed while still `scheduled` (a queue row's Record fusensho) has no earlier write to inherit a stamp from, so the result carried no time at all and nothing downstream could order it. A stale decision is answered `superseded` before any of the decision's refusals judges it (`recordDecisionTx` asks the merge on a copy, `decisionHeldByMerge`: already barred, a running later match, the T103 lock), since each of those would otherwise read as something to resolve and send again; an EXACT replay of the recorded decision is answered as recorded, before them too (`exactDecisionReplay`), from the prior and without a write; for a bracket match that prior is `bracketMatchAsResult`'s projection, which carries no court or schedule, so the replay branch fills them from the bracket (`bracketMatchCourtAndSchedule`, bracket_result.go) as a fresh decision's write echoes them, and the answer and its broadcast keep the court. A held override-winner answers `superseded` with `heldGroups: ["result"]`. The stamp reaches the engine through a VARIADIC parameter on `RecordDecision`/`RecordDecisionTx`: the engine-internal caller and the suite's decision tests omit it, and the handler is the one caller that must always pass it. +**Which endpoints compete on timestamps**: `/score`, `/override-winner`, `/decision` (since mp-jnvl) and both daihyosen paths (since bc-dhas) each carry the client's stamp, so any of them can be refused for either reason. Quick-score alone still builds its result with NO stamp; the merge gives it the server's time, never older than the stored stamp (`writeStamp`, bc-mrgc review), so it still always applies and now leaves a fence a stale replay is ordered against; it maps the refusal shape defensively anyway. The daihyosen add and remove are stamped so the match they write is newer than a copy read before them, which is what lets `keepNewerMatches` keep the post-add copy over a stale refetch. Both take an optional JSON body `{"modifiedAt": N}` (the DELETE too; an empty body is unstamped and always applies), judged by `daihyosenWriteStamp` through the same `clientClockSkew`/`clampClientModifiedAt` pair as `/decision`; the stamp rides on the match the handler writes, so the ordinary write guard decides. `recordDaihyosen`/`removeDaihyosen` hand a refusal back as the refusal body (`_daihyosenOutcome`), and the team editor reports it through `notLandedBanner` and adopts nothing. A decision is stamped because it is the write that CLOSES a match, and a match closed while still `scheduled` (a queue row's Record fusensho) has no earlier write to inherit a stamp from, so the result carried no time at all and nothing downstream could order it. A stale decision is answered `superseded` before any of the decision's refusals judges it (`recordDecisionTx` asks the merge on a copy, `decisionHeldByMerge`: already barred, a running later match), since each of those would otherwise read as something to resolve and send again; an EXACT replay of the recorded decision is answered as recorded, before them too (`exactDecisionReplay`), from the prior and without a write; for a bracket match that prior is `bracketMatchAsResult`'s projection, which carries no court or schedule, so the replay branch fills them from the bracket (`bracketMatchCourtAndSchedule`, bracket_result.go) as a fresh decision's write echoes them, and the answer and its broadcast keep the court. A held override-winner answers `superseded` with `heldGroups: ["result"]`. The stamp reaches the engine through a VARIADIC parameter on `RecordDecision`/`RecordDecisionTx`: the engine-internal caller and the suite's decision tests omit it, and the handler is the one caller that must always pass it. **`web-mobile/js/write_result.jsx` owns that whole rule**: `writeDidNotLand` (queued OR refused), `writeWasSuperseded`, `writeWasRefusedForClock`, `notLandedBanner(res)`, `writeKeepsEditorOpen(patch, res)` (bc-plcl: the one rule every score-editor host asks before closing its editor: a write that did not land, or a running write with no winner, keeps it open), `writeWasRefused(res)` (nothing handed back, which is how every host reports a refusal, or `applied: false`: the two-tap commits Finish, Finish + Start Next, End match and the engi Save disarm, because tapping again would re-send the refused write, and a superseded one would win; a held write the operator DISCARDS disarms the same way, bc-cse operator ruling 2026-10-05, via each editor's own one `dropHeldWrite`-style function, passed as `HeldWriteDiscard`'s `onDiscarded` prop for the editor's own Discard held result AND as `useClearPendingWhenNothingHeld`'s callback for a discard made elsewhere, e.g. the admin topbar's held-writes list) and `writeRetryable(res)` (a QUEUED write only: it gates the pending banner's Retry and the kept submit closure, while the not-saved banner never carries a Retry), `queuedNotice(res, { keepsFailing })` (the held-write line: `QUEUED_NOTICE`, or `QUEUED_UNSAVED_NOTICE` "Not sent yet: keep this page open until the connection returns." when the queue answered `persisted: false` because browser storage would not take it, or `QUEUED_REFUSED_NOTICE` once the server keeps refusing it; the editors keep the queued answer in `pendingWrite` and render the line through `HeldWriteNotice`, admin_scoring_shared.jsx, which with the Discard beside it and the banner's own clearing reads ONE hook, `useMatchHeldWrite`: what this device still holds for the match, `held` and `stuck`, re-read on every sync-status change and every held-count change, the counts carrying `failing` so a SECOND write crossing the threshold is published too; the banner goes once the match holds nothing, landed or discarded from anywhere, `useClearPendingWhenNothingHeld`, never "when the whole queue drains"), the two readings of `heldReason: "needs_winner"`: `writeNeedsWinner(res)` (this write's OWN change was held because it would leave a finished match without a winner, superseded or applied in part; `supersededBanner(res)`, the partial-apply note and the superseded queue alert then say to correct the result with a winner) versus `writeDisplacedGroups(res)` (the write was RECORDED and moved a later change of the match to the history, an applied answer with `displacedGroups` and no `heldGroups`: the note says "Saved" and what was moved, `displacedAlertText` for a replay); `keptInHistoryNote(res)` (match_groups.jsx) is the one note built from them, for the editors' `useKeptInHistoryNote` and admin.jsx's `closingHistoryToast`, and every operator-facing string. `notSavedText` is the one "Not saved" line every such banner shows (the three score editors, fed by `terminalFailureBanner`, and the barred-match notice): "Not saved: . ", or the refusal as it is when it is a whole sentence that says what to do (`sentence`, set by `_replayRefusal` in api_client.jsx for a server `message` or `reasonHuman` and for the busy-shiaijo copy, never for a bare code or HTTP status), and `queueAlertMessage` ends its `rejected` alert on that sentence instead of "Re-enter it." for the same reason. Every host also hands the editor back what a write came back with (a refused write throws and returns nothing), which is how the editors' own Start match tells a landed start from a refused one. It is a leaf with no imports, and **every consumer ES-imports it directly**; there are no `window` mirrors and re-adding one fails `make js/check-imports` (see `web-mobile/check-write-result.mjs`, which also forbids hand-rolled `.applied`/`.persisted` comparisons outside the owner and `api_client.jsx`; its walk, comment stripper and line scan are `web-mobile/check-helpers.mjs`, shared with the competitor-number gate below, and its `FORBIDDEN` rules are exported so `rule_gates.test.jsx` can pin them). This exists because the question was originally spelled `res.queued` inline at ten call sites, and when a second not-landed shape appeared the conversion reached five of them. @@ -411,7 +411,7 @@ In-progress migrations and tech debt to keep in mind (re-derive package sizes wi - **`helper/` mixes concerns**: tree algorithms, CSV parsing, Excel rendering, seeding, utilities. The `helper/{bracket,csv,seeding}/` subpackages are an in-progress extraction; `helper/` proper has not shrunk yet. - **`excel/` has minimal direct test coverage** (roughly 0.3x source) despite Excel being the primary CLI deliverable; most Excel coverage lives in `helper/*_test.go`. - **`domain/` adoption is partial**: much business logic still uses `helper.Player` directly rather than domain types; the migration is incomplete. -- **No top-level interfaces** for `state.Store` or `engine.Engine`: interface adoption is incremental via `mobileapp/deps.go`. One exception: the engine's match-write and decision primitives take `state.StoreTx` as a store handle (`*state.Store` satisfies it), so `writeToPoolOrBracket`, `recordBracketMatchResult`, `RecordDecisionTx`, `lookupMatchSides`, `checkConcurrentIneligibility` and `hasDownstreamMatchStarted` each exist ONCE, with `RecordMatchResultWithIneligibility` and `RecordDecision` as `WithTransaction` shims (the eligibility restore on a rescore is keyed on the competitor-status record's own `MatchID`, never on a re-derived identity). Other engine-to-state and helper-to-engine calls still use concrete types. Extend the handle pattern; never add tx/non-tx twin bodies. +- **No top-level interfaces** for `state.Store` or `engine.Engine`: interface adoption is incremental via `mobileapp/deps.go`. One exception: the engine's match-write and decision primitives take `state.StoreTx` as a store handle (`*state.Store` satisfies it), so `writeToPoolOrBracket`, `recordBracketMatchResult`, `RecordDecisionTx`, `lookupMatchSides` and `checkConcurrentIneligibility` each exist ONCE, with `RecordMatchResultWithIneligibility` and `RecordDecision` as `WithTransaction` shims (the eligibility restore on a rescore is keyed on the competitor-status record's own `MatchID`, never on a re-derived identity). Other engine-to-state and helper-to-engine calls still use concrete types. Extend the handle pattern; never add tx/non-tx twin bodies. ## Testing Conventions diff --git a/docs/user-guide/court-operators/recording-decisions.md b/docs/user-guide/court-operators/recording-decisions.md index 040e5276d..a7b7950e0 100644 --- a/docs/user-guide/court-operators/recording-decisions.md +++ b/docs/user-guide/court-operators/recording-decisions.md @@ -101,7 +101,7 @@ If both competitors due to meet are already barred, neither can fight. In a pool A kiken or fusenpai entered in error can always be fixed. Open the match in the score editor. It shows what is recorded, for example "Recorded: Kiken – Voluntary, Kyoto withdrew", and marks the side that withdrew with **Kiken** or **Fus.** beside its name. In a team match the result under the team names, at the top of the score sheet, names the recorded winner and the decision, and the bouts after the withdrawal are credited to the other team, even though nobody actually fought them. Pick the fix that matches what happened: -- **The wrong competitor or team was marked.** Record the withdrawal again for the other side. In an individual match, use the **Kiken – Voluntary**, **Kiken – Injury** or **Fusenpai** button in the editor's **Decision** row. In a team match, open **Withdrawal or no-show** and use the same buttons there. The side you first marked is eligible again, and the other side becomes the one that withdrew. +- **The wrong competitor or team was marked.** Record the withdrawal again for the other side. In an individual match, use the **Kiken – Voluntary**, **Kiken – Injury** or **Fusenpai** button in the editor's **Decision** row. In a team match, open **Withdrawal or no-show** and use the same buttons there. The side you first marked is eligible again, and the other side becomes the one that withdrew. Nothing else changes: a competitor with a **Fusenpai** recorded on another match stays withdrawn because of that match, and matches already closed with a fusensho because of the first withdrawal keep it, so correct each of them one by one. - **Nobody withdrew, and the match was fought to a result.** Choose **Remove kiken**, **Remove fusenpai**, or **Remove fusensho**: the button names the decision that was recorded. The editor takes that decision off the score board: its circles go, the points the withdrawn competitor struck stay, and you enter the result as it was fought, or a draw in a pool or league. In a team match, every bout needs a result, as when you finish one. Then use **Save correction**, which asks for a short reason like any correction. The match stays finished the whole time, so the fix never needs the court and a match being fought there carries on undisturbed. **Undo** puts the recorded decision back before you save; once the correction is saved on the device and waiting to be sent, it can no longer be undone this way. In a knockout match whose bouts were tied, a representative bout decided it: use **Clear kiken and reopen** (or **Clear fusenpai and reopen**) instead, which puts the match back on the court to fight it. - **Nobody withdrew, and there is more of the match to fight.** Choose **Clear kiken and reopen** or **Clear fusenpai and reopen**, whichever was recorded. It takes one tap and needs no reason; the editor says what it will do beside the button. A kiken or fusenpai gives the opponent the match win, so without it the match was never decided. The match goes back to in progress and the editor stays open on it. The withdrawn competitor or team can compete again. Score the rest of the match and finish it as usual. @@ -118,7 +118,6 @@ Without removing the decision first, **Save correction** never removes a kiken o A fix can change who goes through to later matches, so the app warns you first and you can cancel or go ahead: -- Recording the withdrawal for the other side warns you when either side has already started a later match. - Clearing a decision in a knockout match, or removing one so that the other competitor wins, warns you when the next match has already been played. If you go ahead, that next match is reopened with its winner cleared and its points kept, so check them and take back any that no longer apply, and the editor names it once the reopen is done. A round after it that nobody has played yet stops showing its winner. If a withdrawal decided that next match, its withdrawn competitor is eligible again too. In a knockout, a next match that is still in progress must be finished or sent back to the queue before a decision can be cleared, removed in favour of the other competitor, or recorded for the other side. Clearing and reopening also needs this match's court: if another match is running there, the app names it and offers to send it back to the queue, which keeps any score entered for it. Removing the decision does not need the court. diff --git a/internal/engine/bc_idfx_test.go b/internal/engine/bc_idfx_test.go index 13a015873..c1a84b60a 100644 --- a/internal/engine/bc_idfx_test.go +++ b/internal/engine/bc_idfx_test.go @@ -162,7 +162,7 @@ func TestRecordDecision_KikenResolvesLoserByID_NotFirstRegisteredNamesake(t *tes })) // decisionBy "aka" => SideA (Tanaka@A) is the withdrawing/losing side. - _, status, err := eng.RecordDecision(compID, "Pool A-0", "kiken", "aka", "injury", nil, false) + _, status, err := eng.RecordDecision(compID, "Pool A-0", "kiken", "aka", "injury", nil) require.NoError(t, err) require.NotNil(t, status, "a CompetitorStatus must be written for the kiken") assert.Equal(t, tanakaA, status.PlayerID, "the ineligibility must land on Tanaka@A, the competitor who actually withdrew") @@ -210,7 +210,7 @@ func TestRecordDecision_PartiallyStampedRow_StillRecordsIneligibility(t *testing })) // decisionBy "aka" => SideA (Tanaka) is the withdrawing/losing side. - _, status, err := eng.RecordDecision(compID, "Pool A-0", "kiken", "aka", "injury", nil, false) + _, status, err := eng.RecordDecision(compID, "Pool A-0", "kiken", "aka", "injury", nil) require.NoError(t, err) require.NotNil(t, status, "a CompetitorStatus must be written -- the losing SIDE is known even though her id field on this row is empty") assert.Equal(t, tanakaID, status.PlayerID, "the ineligibility must land on Tanaka, resolved by name fallback for the known losing side") @@ -251,7 +251,7 @@ func TestRecordDecisionTx_SameNamePairing_RescoreAsFoughtRestoresEligibility(t * })) // decisionBy "aka" => SideA (Tanaka@DojoA) withdraws. - _, status, err := eng.RecordDecision(compID, "Pool A-0", "kiken", "aka", "injury", nil, false) + _, status, err := eng.RecordDecision(compID, "Pool A-0", "kiken", "aka", "injury", nil) require.NoError(t, err) require.NotNil(t, status) assert.Equal(t, tanakaAID, status.PlayerID) @@ -266,7 +266,7 @@ func TestRecordDecisionTx_SameNamePairing_RescoreAsFoughtRestoresEligibility(t * // for this match -- the stale Pool A-0 ineligibility entry must be // restored by the record itself, not re-derived from the row's (still // same-name, still id-less-on-WinnerID) sides. - _, restoredStatus, err := eng.RecordDecision(compID, "Pool A-0", "fought", "aka", "", nil, false) + _, restoredStatus, err := eng.RecordDecision(compID, "Pool A-0", "fought", "aka", "", nil) require.NoError(t, err) require.NotNil(t, restoredStatus, "the stale ineligibility for THIS match must be restored, not silently skipped as ambiguous") assert.Equal(t, tanakaAID, restoredStatus.PlayerID) @@ -572,7 +572,7 @@ func TestRecordDecisionTx_SameNamePairing_AttributesWinnerBySide(t *testing.T) { })) // decisionBy "aka" => SideA (Tokyo) withdraws; SideB (Osaka) survives. - result, status, err := eng.RecordDecision(compID, "Pool A-0", "kiken", "aka", "injury", &state.EnchoMetadata{PeriodCount: 1}, false) + result, status, err := eng.RecordDecision(compID, "Pool A-0", "kiken", "aka", "injury", &state.EnchoMetadata{PeriodCount: 1}) require.NoError(t, err) require.NotNil(t, result) diff --git a/internal/engine/decision_no_cascade_test.go b/internal/engine/decision_no_cascade_test.go new file mode 100644 index 000000000..8004d57d9 --- /dev/null +++ b/internal/engine/decision_no_cascade_test.go @@ -0,0 +1,79 @@ +package engine + +import ( + "testing" + + "github.com/gitrgoliveira/bracket-creator/internal/domain" + "github.com/gitrgoliveira/bracket-creator/internal/helper" + "github.com/gitrgoliveira/bracket-creator/internal/state" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func noCascadeSetup(t *testing.T, compID string) (*Engine, *state.Store, string, string) { + t.Helper() + eng, store, _ := setupTestEngine(t) + createTestCompetition(t, store, compID, "league", 3) + aliceID, bobID, carolID := helper.NewUUID4(), helper.NewUUID4(), helper.NewUUID4() + require.NoError(t, store.SaveParticipants(compID, []domain.Player{ + {ID: aliceID, Name: "Alice", Dojo: "A"}, + {ID: bobID, Name: "Bob", Dojo: "B"}, + {ID: carolID, Name: "Carol", Dojo: "C"}, + })) + require.NoError(t, store.SavePoolMatches(compID, []state.MatchResult{ + {ID: "Pool A-0", SideA: "Alice", SideAID: aliceID, SideB: "Bob", SideBID: bobID, Status: state.MatchStatusScheduled}, + {ID: "Pool A-1", SideA: "Alice", SideAID: aliceID, SideB: "Carol", SideBID: carolID, Status: state.MatchStatusScheduled}, + })) + return eng, store, aliceID, bobID +} + +// A match already closed with a fusensho because of the first withdrawal keeps +// it when the withdrawal moves to the other side: nothing cascades. +func TestRecordDecision_MovingAWithdrawalDoesNotReopenMatchesClosedByIt(t *testing.T) { + eng, store, aliceID, bobID := noCascadeSetup(t, "dlck-pool-no-cascade") + compID := "dlck-pool-no-cascade" + + _, _, err := eng.RecordDecision(compID, "Pool A-0", "kiken-voluntary", "aka", "", nil) + require.NoError(t, err) + // Carol's match against the withdrawn Alice is closed by a fusensho. + _, _, err = eng.RecordDecision(compID, "Pool A-1", "fusensho", "aka", "", nil) + require.NoError(t, err) + + _, _, err = eng.RecordDecision(compID, "Pool A-0", "kiken-voluntary", "shiro", "", nil) + require.NoError(t, err, "no confirm: a later match is already closed") + + ms, err := store.LoadPoolMatches(compID) + require.NoError(t, err) + for _, m := range ms { + if m.ID == "Pool A-1" { + assert.Equal(t, state.MatchStatusCompleted, m.Status) + assert.Equal(t, "fusensho", string(m.Decision), "closed by the first withdrawal, it keeps its decision") + assert.Equal(t, "Carol", m.Winner) + } + } + statuses, err := store.LoadCompetitorStatus(compID) + require.NoError(t, err) + assert.True(t, statuses[aliceID].Eligible) + assert.False(t, statuses[bobID].Eligible) +} + +// A competitor with a fusenpai recorded on another match stays withdrawn +// because of that match when the first withdrawal moves to the other side. +func TestRecordDecision_ChainedFusenpaiKeepsTheBarWhenTheWithdrawalMoves(t *testing.T) { + eng, store, aliceID, bobID := noCascadeSetup(t, "dlck-chained") + compID := "dlck-chained" + + _, _, err := eng.RecordDecision(compID, "Pool A-0", "kiken-voluntary", "aka", "", nil) + require.NoError(t, err) + _, _, err = eng.RecordDecision(compID, "Pool A-1", "fusenpai", "aka", "", nil) + require.NoError(t, err) + + _, _, err = eng.RecordDecision(compID, "Pool A-0", "kiken-voluntary", "shiro", "", nil) + require.NoError(t, err) + + statuses, err := store.LoadCompetitorStatus(compID) + require.NoError(t, err) + assert.False(t, statuses[aliceID].Eligible, "the fusenpai on Pool A-1 still bars Alice") + assert.Equal(t, "Pool A-1", statuses[aliceID].MatchID) + assert.False(t, statuses[bobID].Eligible) +} diff --git a/internal/engine/decision_stamp_test.go b/internal/engine/decision_stamp_test.go index c85f26e68..efb1b0160 100644 --- a/internal/engine/decision_stamp_test.go +++ b/internal/engine/decision_stamp_test.go @@ -48,7 +48,7 @@ func TestRecordDecision_StoresTheClientWriteStamp(t *testing.T) { t.Run("a stamped decision persists the stamp", func(t *testing.T) { eng, store, compID := setup(t) const stamp int64 = 1_700_000_000_000 - _, _, err := eng.RecordDecision(compID, "Pool A-0", "kiken", "aka", "knee", nil, false, stamp) + _, _, err := eng.RecordDecision(compID, "Pool A-0", "kiken", "aka", "knee", nil, stamp) require.NoError(t, err) require.Equal(t, stamp, storedStamp(t, store, compID)) }) @@ -60,7 +60,7 @@ func TestRecordDecision_StoresTheClientWriteStamp(t *testing.T) { t.Run("an unstamped decision takes the server's time", func(t *testing.T) { eng, store, compID := setup(t) before := time.Now().UnixMilli() - _, _, err := eng.RecordDecision(compID, "Pool A-0", "kiken", "aka", "knee", nil, false) + _, _, err := eng.RecordDecision(compID, "Pool A-0", "kiken", "aka", "knee", nil) require.NoError(t, err) got := storedStamp(t, store, compID) require.GreaterOrEqual(t, got, before) diff --git a/internal/engine/default_win_bout_credit_test.go b/internal/engine/default_win_bout_credit_test.go index 2f14f6922..c48411d09 100644 --- a/internal/engine/default_win_bout_credit_test.go +++ b/internal/engine/default_win_bout_credit_test.go @@ -65,7 +65,7 @@ func TestDefaultWinBoutCredit_PoolStandings(t *testing.T) { require.NoError(t, store.SavePoolMatches(compID, matches)) // Yama C (aka = SideA) withdraws; Tora A (shiro = SideB) is credited. - _, _, err := eng.RecordDecision(compID, "Pool A-0", "kiken-voluntary", "aka", "no-show", nil, false) + _, _, err := eng.RecordDecision(compID, "Pool A-0", "kiken-voluntary", "aka", "no-show", nil) require.NoError(t, err) // The kiken match's OWN contribution, isolated: bouts 1-3 padded and @@ -145,7 +145,7 @@ func TestDefaultWinBoutCredit_BracketTeamResult(t *testing.T) { require.NoError(t, store.SaveBracket(compID, b)) // Ryu (shiro = SideB) withdraws; Tora (aka = SideA) is credited. - _, _, err := eng.RecordDecision(compID, "m-r1-0", "fusenpai", "shiro", "no-show", nil, false) + _, _, err := eng.RecordDecision(compID, "m-r1-0", "fusenpai", "shiro", "no-show", nil) require.NoError(t, err) loaded, err := store.LoadBracket(compID) @@ -174,7 +174,7 @@ func TestDefaultWinBoutCredit_PoolWritePads(t *testing.T) { require.NoError(t, store.SavePoolMatches(compID, []state.MatchResult{{ ID: "Pool A-0", SideA: "Red", SideB: "White", Status: state.MatchStatusRunning, }})) - result, _, err := eng.RecordDecision(compID, "Pool A-0", "fusenpai", "aka", "no-show", nil, false) + result, _, err := eng.RecordDecision(compID, "Pool A-0", "fusenpai", "aka", "no-show", nil) require.NoError(t, err) // Assert on the WRITE's own returned result first, in memory, before any @@ -210,7 +210,7 @@ func TestDefaultWinBoutCredit_BracketWritePads(t *testing.T) { require.NoError(t, store.SaveBracket(compID, &state.Bracket{Rounds: [][]state.BracketMatch{{ {ID: "m-r1-0", SideA: "Red", SideB: "White", Status: state.MatchStatusRunning}, }}})) - result, _, err := eng.RecordDecision(compID, "m-r1-0", "kiken-injury", "shiro", "injured", nil, false) + result, _, err := eng.RecordDecision(compID, "m-r1-0", "kiken-injury", "shiro", "injured", nil) require.NoError(t, err) // Assert on the WRITE's own returned result first, in memory -- see @@ -244,7 +244,7 @@ func TestDefaultWinBoutCredit_KachinukiNotPadded(t *testing.T) { {Position: 1, SideAMemberID: "r1", SideBMemberID: "w1", WinnerMemberID: "r1", Winner: "r1", IpponsA: []string{"M"}}, }, }})) - _, _, err := eng.RecordDecision("dwb-kachinuki", "Pool A-0", "kiken-voluntary", "shiro", "withdrew", nil, false) + _, _, err := eng.RecordDecision("dwb-kachinuki", "Pool A-0", "kiken-voluntary", "shiro", "withdrew", nil) require.NoError(t, err) ms, err := store.LoadPoolMatches("dwb-kachinuki") @@ -318,7 +318,7 @@ func TestDefaultWinBoutCredit_PoolDaihyosenRowNeverPadded(t *testing.T) { require.NoError(t, store.SavePoolMatches(compID, []state.MatchResult{{ ID: matchID, SideA: "Red", SideB: "White", Status: state.MatchStatusRunning, }})) - result, _, err := eng.RecordDecision(compID, matchID, "kiken-voluntary", "aka", "no-show", nil, false) + result, _, err := eng.RecordDecision(compID, matchID, "kiken-voluntary", "aka", "no-show", nil) require.NoError(t, err) assert.Empty(t, result.SubResults, "the write path never pads a pool DH/TB row") diff --git a/internal/engine/default_win_bout_padding_unreadable_test.go b/internal/engine/default_win_bout_padding_unreadable_test.go index 61907c6f9..9571589c7 100644 --- a/internal/engine/default_win_bout_padding_unreadable_test.go +++ b/internal/engine/default_win_bout_padding_unreadable_test.go @@ -66,7 +66,7 @@ func TestWriteTimeDefaultWinPaddingNeverOverwritesAnUnreadableCell(t *testing.T) // Kenshikan (aka = SideA) withdraws. RecordDecision fills SideA/SideB // from the stored row itself, so this exercises the SubResultsUnreadable // guard in isolation from the sides-backfill-ordering fix below. - _, _, err = eng.RecordDecision(compID, "Pool A-0", "kiken-voluntary", "aka", "no-show", nil, false) + _, _, err = eng.RecordDecision(compID, "Pool A-0", "kiken-voluntary", "aka", "no-show", nil) require.NoError(t, err) loaded, err := store.LoadPoolMatches(compID) @@ -163,7 +163,7 @@ func TestWriteTimeDefaultWinPaddingPadsAnExplicitHikiwakeCorrectionOverAWithdraw // Kenshikan (aka = SideA) withdraws before any bout is fought; padded to // 3 placeholder positions credited to Sanshukan. - _, _, err = eng.RecordDecision(compID, "Pool A-0", "kiken-voluntary", "aka", "no-show", nil, false) + _, _, err = eng.RecordDecision(compID, "Pool A-0", "kiken-voluntary", "aka", "no-show", nil) require.NoError(t, err) loaded, err := store.LoadPoolMatches(compID) diff --git a/internal/engine/downstream_correction_guard_test.go b/internal/engine/downstream_correction_guard_test.go index 6df19ef32..42ec1800e 100644 --- a/internal/engine/downstream_correction_guard_test.go +++ b/internal/engine/downstream_correction_guard_test.go @@ -769,36 +769,28 @@ func TestDownstreamKnockoutCorrection_GuardResolvesWinnerFromStoredSidesWhenOmit assert.Equal(t, "alice", b.Rounds[0][0].WinnerID, "a refused correction must not corrupt the stored winner id either") } -// TestRecordDecisionTxWithOptions_DecouplesForceFromBcKcdg pins bc-cse -// finding 5: RecordDecisionTx's T103 `force` (the kiken/fusenpai -// decision-lock override) and the bc-kcdg downstream-knockout-correction -// guard are different operator confirmations. RecordDecisionTxWithOptions -// must evaluate them independently -- a decision write with the T103 force -// flag set must still be refused by the bc-kcdg guard when kcdgOpts.Force is -// false -- and must surface the reopened match ids to the caller once -// kcdgOpts.Force authorizes the write, the same contract +// TestRecordDecisionTxWithOptions_BcKcdgForce pins that +// RecordDecisionTxWithOptions applies the bc-kcdg downstream-knockout-correction +// guard by default and, once kcdgOpts.Force authorizes the write, surfaces the +// reopened match ids to the caller, the same contract // RecordMatchResultWithIneligibility(Tx) and OverrideBracketWinner already // give theirs. -func TestRecordDecisionTxWithOptions_DecouplesForceFromBcKcdg(t *testing.T) { +func TestRecordDecisionTxWithOptions_BcKcdgForce(t *testing.T) { eng, store, _ := setupTestEngine(t) compID := "kcdg-decision-decouple" seedThreeRoundBracket(t, store, compID) - t.Run("T103 force does not imply bc-kcdg force", func(t *testing.T) { + t.Run("without kcdgOpts.Force the guard refuses", func(t *testing.T) { // decisionBy="aka" makes m-r1-0's SideA (Alice, the current winner) // the WITHDRAWER, so Bob (SideB) becomes the new winner by default -- - // exactly the winner change the bc-kcdg guard exists to catch. The - // prior result carries no withdrawal decision, so hadPriorLoser is - // false and T103's own lock check never even runs regardless of - // `force`: passing force=true here exercises ONLY whether it leaks - // into the unrelated bc-kcdg guard. + // exactly the winner change the bc-kcdg guard exists to catch. txErr := inTx(t, store, compID, func(tx state.StoreTx) error { _, _, err := eng.RecordDecisionTxWithOptions(tx, compID, "m-r1-0", "kiken-voluntary", "aka", "reason", - nil, true, ForceOptions{}) + nil, ForceOptions{}) return err }) var dkErr *DownstreamKnockoutPlayedError - require.ErrorAs(t, txErr, &dkErr, "the bc-kcdg guard must run regardless of the unrelated T103 force flag") + require.ErrorAs(t, txErr, &dkErr, "the bc-kcdg guard must run") assert.Equal(t, "m-r2-0", dkErr.BlockingMatchID) b, err := store.LoadBracket(compID) @@ -810,7 +802,7 @@ func TestRecordDecisionTxWithOptions_DecouplesForceFromBcKcdg(t *testing.T) { var reopened []ReopenedMatch txErr := inTx(t, store, compID, func(tx state.StoreTx) error { _, _, err := eng.RecordDecisionTxWithOptions(tx, compID, "m-r1-0", "kiken-voluntary", "aka", "reason", - nil, false, ForceOptions{Force: true, Reopened: &reopened}) + nil, ForceOptions{Force: true, Reopened: &reopened}) return err }) require.NoError(t, txErr) @@ -999,32 +991,6 @@ func TestDownstreamKnockoutCorrection_DaihyosenSilentRescoreIsNotAWinnerChange(t assert.Equal(t, state.MatchStatusCompleted, b.Rounds[1][0].Status, "and the next round is untouched") } -// TestRecordDecisionTx_T103ForceDoesNotAuthorizeDownstreamClear pins that the -// two confirmations stay separate. T103's `force` answers "undo this kiken even -// though its loser has a later match"; the bc-kcdg override answers "clear the -// already-played next round". Feeding the first into the second meant an -// operator confirming a decision-lock override silently authorized a played -// match being sent back to the queue, with no dialog ever naming it. -func TestRecordDecisionTx_T103ForceDoesNotAuthorizeDownstreamClear(t *testing.T) { - eng, store, _ := setupTestEngine(t) - compID := "kcdg-t103-separate" - seedThreeRoundBracket(t, store, compID) - - err := inTx(t, store, compID, func(tx state.StoreTx) error { - _, _, e := eng.RecordDecisionTx(tx, compID, "m-r1-0", "kiken-voluntary", "aka", "withdrew", - nil, true /* T103 force */) - return e - }) - var dkErr *DownstreamKnockoutPlayedError - require.ErrorAs(t, err, &dkErr, - "T103's force must not stand in for the bc-kcdg confirmation: the refusal must still be raised") - - b, lerr := store.LoadBracket(compID) - require.NoError(t, lerr) - assert.Equal(t, state.MatchStatusCompleted, b.Rounds[1][0].Status, "and nothing downstream may be cleared") - assert.Equal(t, "Alice", b.Rounds[1][0].Winner) -} - // TestDownstreamKnockoutCorrection_ReopenedCarriesTheMatchNumber pins the // identity the OPERATOR is shown. "m-r2-0" is an internal id that appears on no // operator screen: the scores list, the bracket and the Excel tree all say diff --git a/internal/engine/downstream_running_refusal_test.go b/internal/engine/downstream_running_refusal_test.go index 05751accd..28ad1cc17 100644 --- a/internal/engine/downstream_running_refusal_test.go +++ b/internal/engine/downstream_running_refusal_test.go @@ -6,7 +6,7 @@ package engine // (downstream_correction_guard_test.go) pins the score door; these pin the // rest: the bronze a semifinal feeds, a running match past a bye, the // override and engi doors, a feeder assertion overtaken by the real result, -// and /decision, where the refusal must come before the T103 lock. +// and /decision. import ( "encoding/json" @@ -295,13 +295,12 @@ func TestFeederAssertion_RealResultMustAgreeWhileTheFinalRuns(t *testing.T) { }) } -// TestRecordDecisionTx_RunningDownstreamRefusesBeforeTheLock: a wrong-side +// TestRecordDecisionTx_RunningDownstreamRefuses: a wrong-side // kiken on a semifinal, the final started, then the kiken re-recorded on the // other side. The write is refused because the final is being fought, and -// that refusal comes FIRST: the operator is never asked to confirm the T103 -// decision lock for a write that would then be refused anyway. -func TestRecordDecisionTx_RunningDownstreamRefusesBeforeTheLock(t *testing.T) { - for _, t103Force := range []bool{false, true} { +// the operator is never asked to confirm it. +func TestRecordDecisionTx_RunningDownstreamRefuses(t *testing.T) { + { eng, store, _ := setupTestEngine(t) compID := "kcdg-decision-running" require.NoError(t, store.SaveCompetition(&state.Competition{ @@ -324,7 +323,7 @@ func TestRecordDecisionTx_RunningDownstreamRefusesBeforeTheLock(t *testing.T) { })) // The hasty kiken: Alice (aka) recorded as withdrawing, so Bob goes on. require.NoError(t, inTx(t, store, compID, func(tx state.StoreTx) error { - _, _, e := eng.RecordDecisionTx(tx, compID, "m-r1-0", "kiken-voluntary", "aka", "", nil, false) + _, _, e := eng.RecordDecisionTx(tx, compID, "m-r1-0", "kiken-voluntary", "aka", "", nil) return e })) b, err := store.LoadBracket(compID) @@ -335,19 +334,18 @@ func TestRecordDecisionTx_RunningDownstreamRefusesBeforeTheLock(t *testing.T) { // The correction: it was Bob (shiro) who withdrew. err = inTx(t, store, compID, func(tx state.StoreTx) error { - _, _, e := eng.RecordDecisionTx(tx, compID, "m-r1-0", "kiken-voluntary", "shiro", "", nil, t103Force) + _, _, e := eng.RecordDecisionTx(tx, compID, "m-r1-0", "kiken-voluntary", "shiro", "", nil) return e }) var runErr *DownstreamKnockoutRunningError - require.ErrorAs(t, err, &runErr, "t103Force=%v", t103Force) - assert.NotErrorIs(t, err, ErrDecisionLocked, "the refusal comes before the lock (t103Force=%v)", t103Force) + require.ErrorAs(t, err, &runErr) assert.Equal(t, []string{"m-r2-0"}, reopenedIDs(runErr.Running)) assert.Equal(t, "Match 3 (Final) is being fought now on Shiaijo A. Finish it or send it back to the queue, then save this correction again.", runErr.Error()) b, err = store.LoadBracket(compID) require.NoError(t, err) - assert.Equal(t, "Bob", b.Rounds[0][0].Winner, "nothing saved (t103Force=%v)", t103Force) + assert.Equal(t, "Bob", b.Rounds[0][0].Winner, "nothing saved") assert.Equal(t, "Bob", b.Rounds[1][0].SideA) assert.Equal(t, state.MatchStatusRunning, b.Rounds[1][0].Status) } @@ -394,7 +392,7 @@ func TestRecordDecisionTx_StaleDecisionOverRunningNextIsSuperseded(t *testing.T) // The stale kiken: Bob (shiro) withdrawing, which would make Alice the winner. err := inTx(t, store, compID, func(tx state.StoreTx) error { - _, _, e := eng.RecordDecisionTx(tx, compID, "m-r1-0", "kiken-voluntary", "shiro", "", nil, false, 5_000) + _, _, e := eng.RecordDecisionTx(tx, compID, "m-r1-0", "kiken-voluntary", "shiro", "", nil, 5_000) return e }) require.ErrorIs(t, err, ErrMatchSuperseded, "a stale decision is reported as superseded, not as the running refusal") diff --git a/internal/engine/eligibility.go b/internal/engine/eligibility.go index cf353f0e0..e459fca71 100644 --- a/internal/engine/eligibility.go +++ b/internal/engine/eligibility.go @@ -312,7 +312,7 @@ func (e *Engine) checkConcurrentIneligibility(h state.StoreTx, compID, matchID, // matches, the operator's intended exit once StartMatchTx refuses to // start that match. The caller records the loss and leaves the existing // CompetitorStatus untouched, so its MatchID still names the match that -// barred them, which the T103 undo keys on. Reopening such a match +// barred them, which the undo keys on. Reopening such a match // restores nobody (the status is not this match's), so // reopenTargetStatus sends it back to the queue while the bar holds. func alreadyBarredRefusal(decision, playerID string, barred *domain.CompetitorStatus) error { @@ -481,7 +481,7 @@ func (e *Engine) checkSimultaneousMatch(compID, matchID string) error { // checkEligibilityExcludingMatch is like CheckEligibility but skips // CompetitorStatus entries whose source MatchID equals excludeMatchID. -// This lets a match be re-scored (the T103 undo path) even when its +// This lets a match be re-scored (the undo path) even when its // own prior kiken/fusenpai created the ineligibility, the status was // recorded BY that match, so it should not block writing back to it. func (e *Engine) checkEligibilityExcludingMatch(compID string, playerIDs []string, excludeMatchID string) error { @@ -500,14 +500,11 @@ func (e *Engine) checkEligibilityExcludingMatch(compID string, playerIDs []strin // points it had already struck (FIK Art. 32, via preserveLoserScore). // // When the match already has a kiken/fusenpai decision recorded (the -// "undo" path, T103/CHK024) the engine enforces the -// contracts/match-decisions.md §Decision lock & undo rule: if any -// subsequent match involving either prior participant has started -// since the original decision was recorded, the engine returns -// ErrDecisionLocked unless force is true. On a successful overwrite -// where the prior loser is no longer the new loser, the prior loser's -// CompetitorStatus is restored to Eligible: true and surfaced as the -// returned status so the handler can broadcast the change. +// "undo" path) the write is never refused for what the competitors have +// done since: it applies with no confirm and changes no other match. On a +// successful overwrite where the prior loser is no longer the new loser, +// the prior loser's CompetitorStatus is restored to Eligible: true and +// surfaced as the returned status so the handler can broadcast the change. // // Returns the persisted MatchResult and the most-recent // CompetitorStatus change (new ineligibility OR restored eligibility), @@ -517,7 +514,7 @@ func (e *Engine) checkEligibilityExcludingMatch(compID string, playerIDs []strin // (scoring_tx.go) — ONE body, whichever door a caller enters by. The // full behavioural contract above now describes RecordDecisionTx; this // wrapper's only job is acquiring the per-comp lock once for the whole -// sides-lookup + T105 check + T103 lock check + write + eligibility- +// sides-lookup + T105 check + write + eligibility- // restore sequence, mirroring RecordMatchResultWithIneligibility. // // NEVER call this from inside a transaction: it takes the per-competition @@ -526,18 +523,17 @@ func (e *Engine) checkEligibilityExcludingMatch(compID string, playerIDs []strin // the full reasoning. Call RecordDecisionTx directly when already inside // a WithTransaction closure. // -// T090, T103, contracts/match-decisions.md §POST /decision, bc-twin. -func (e *Engine) RecordDecision(compID, matchID, decision, decisionBy, decisionReason string, encho *state.EnchoMetadata, force bool, modifiedAt ...int64) (*state.MatchResult, *domain.CompetitorStatus, error) { +// T090, contracts/match-decisions.md §POST /decision, bc-twin. +func (e *Engine) RecordDecision(compID, matchID, decision, decisionBy, decisionReason string, encho *state.EnchoMetadata, modifiedAt ...int64) (*state.MatchResult, *domain.CompetitorStatus, error) { var ( result *state.MatchResult status *domain.CompetitorStatus engErr error ) txErr := e.store.WithTransaction(compID, func(tx state.StoreTx) error { - result, status, engErr = e.RecordDecisionTx(tx, compID, matchID, decision, decisionBy, decisionReason, encho, force, modifiedAt...) + result, status, engErr = e.RecordDecisionTx(tx, compID, matchID, decision, decisionBy, decisionReason, encho, modifiedAt...) // Return nil regardless: engErr is an application-level signal - // (validation → 400, AlreadyIneligible → 409, ErrDecisionLocked → - // 409) surfaced after the tx, and any K3 rollback has already + // (validation → 400, AlreadyIneligible → 409) surfaced after the tx, and any K3 rollback has already // replayed the prior state INSIDE the tx, so committing persists // exactly what the engine settled on. Mirrors the commit contract // in RecordMatchResultWithIneligibility. @@ -553,21 +549,21 @@ func (e *Engine) RecordDecision(compID, matchID, decision, decisionBy, decisionR // (bc-cse finding 5), mirroring RecordDecisionTxWithOptions the same way // RecordDecision mirrors RecordDecisionTx: it acquires the per-comp lock // once for the whole sequence, then delegates to RecordDecisionTxWithOptions -// so `force` (T103) and `kcdgOpts` (bc-kcdg) stay the two separate -// operator confirmations described there, with kcdgOpts.Reopened populated +// so `kcdgOpts` (bc-kcdg) carries the operator confirmation described +// there, with kcdgOpts.Reopened populated // for the caller exactly as OverrideBracketWinner's does. // // NEVER call this from inside a transaction, for the same non-reentrant-lock // reason documented on RecordDecision; call RecordDecisionTxWithOptions // directly when already inside a WithTransaction closure. -func (e *Engine) RecordDecisionWithOptions(compID, matchID, decision, decisionBy, decisionReason string, encho *state.EnchoMetadata, force bool, kcdgOpts ForceOptions, modifiedAt ...int64) (*state.MatchResult, *domain.CompetitorStatus, error) { +func (e *Engine) RecordDecisionWithOptions(compID, matchID, decision, decisionBy, decisionReason string, encho *state.EnchoMetadata, kcdgOpts ForceOptions, modifiedAt ...int64) (*state.MatchResult, *domain.CompetitorStatus, error) { var ( result *state.MatchResult status *domain.CompetitorStatus engErr error ) txErr := e.store.WithTransaction(compID, func(tx state.StoreTx) error { - result, status, engErr = e.RecordDecisionTxWithOptions(tx, compID, matchID, decision, decisionBy, decisionReason, encho, force, kcdgOpts, modifiedAt...) + result, status, engErr = e.RecordDecisionTxWithOptions(tx, compID, matchID, decision, decisionBy, decisionReason, encho, kcdgOpts, modifiedAt...) // Same commit contract as RecordDecision: engErr is surfaced after // the tx regardless, so the closure always returns nil. return nil @@ -630,72 +626,6 @@ func (e *Engine) lookupExistingResultIn(h state.StoreTx, compID, matchID string) return nil, false, notFoundErrorf("match %q not found in competition %q", matchID, compID) } -// hasDownstreamMatchStarted reports whether any pool or bracket match -// other than excludeMatchID has either SideA or SideB matching one of -// playerNames AND has status running or completed. Used by the -// kiken-undo flow (T103) to enforce the decision-lock rule. -// -// Takes h state.StoreTx so both the transactional (RecordDecisionTx) and -// non-transactional (test) callers share one body (bc-twin follow-up). -func (e *Engine) hasDownstreamMatchStarted(h state.StoreTx, compID string, playerNames []string, excludeMatchID string) (bool, error) { - wantSet := make(map[string]struct{}, len(playerNames)) - for _, n := range playerNames { - if n != "" { - wantSet[n] = struct{}{} - } - } - if len(wantSet) == 0 { - return false, nil - } - involvesAny := func(a, b string) bool { - if _, ok := wantSet[a]; ok { - return true - } - _, ok := wantSet[b] - return ok - } - isStarted := func(s state.MatchStatus) bool { - return s == state.MatchStatusRunning || s == state.MatchStatusCompleted - } - // A load error is returned, not read as "nothing started" (see - // lookupExistingResult): this answer unlocks a kiken undo, so a file - // that cannot be read must not unlock it by default. - poolMatches, err := h.LoadPoolMatches(compID) - if err != nil { - return false, err - } - for _, m := range poolMatches { - if m.ID == excludeMatchID { - continue - } - if isStarted(m.Status) && involvesAny(m.SideA, m.SideB) { - return true, nil - } - } - bracket, err := h.LoadBracket(compID) - if err != nil { - return false, err - } - if bracket != nil { - for _, round := range bracket.Rounds { - for _, bm := range round { - if bm.ID == excludeMatchID { - continue - } - if isStarted(bm.Status) && involvesAny(bm.SideA, bm.SideB) { - return true, nil - } - } - } - if bm := bracket.ThirdPlaceMatch; bm != nil && bm.ID != excludeMatchID { - if isStarted(bm.Status) && involvesAny(bm.SideA, bm.SideB) { - return true, nil - } - } - } - return false, nil -} - // matchSideParticipantIDs resolves matchID's two participant ids the way every // other identity-critical reader in this package does: from the match row's OWN // SideAID/SideBID, with the roster name scan (resolvePlayerIDs) filling only a @@ -1013,7 +943,7 @@ func (e *Engine) recordIneligibilityFromDecision(h state.StoreTx, compID, matchI // The tier 3/4 id gate has one known, narrow consequence: a PRIOR row that // carries side ids but was hand-edited to a kiken/fusenpai Decision with // neither WinnerSide nor WinnerID set resolves to ok=false, which makes -// RecordDecisionTx's T103 downstream-match lock fail OPEN for that row -- +// callers that need the loser fail to attribute it for that row -- // unreachable through any write path this app itself takes (see // TestLosingSide's "id-carrying prior with no WinnerSide/WinnerID" case). func losingSide(result *state.MatchResult) (id, name string, ok bool) { diff --git a/internal/engine/eligibility_test.go b/internal/engine/eligibility_test.go index 53720728e..feedf655c 100644 --- a/internal/engine/eligibility_test.go +++ b/internal/engine/eligibility_test.go @@ -59,12 +59,10 @@ func TestStartMatchBlockedByIneligibleCompetitor(t *testing.T) { assert.Equal(t, "kiken at m_prev", ineligErr.Reason) } -// TestRecordDecision_KikenUndo exercises the T103/CHK024 contract: -// once a kiken has been recorded, a follow-up POST /decision that -// overwrites it is allowed only if no subsequent match involving -// either side has started. The override is gated by an explicit -// `force` flag; on a successful undo the prior loser's -// CompetitorStatus is restored to Eligible=true. +// TestRecordDecision_KikenUndo exercises the undo contract: once a kiken +// has been recorded, a follow-up POST /decision that overwrites it applies +// with no confirm and changes no other match; on a successful undo the +// prior loser's CompetitorStatus is restored to Eligible=true. func TestRecordDecision_KikenUndo(t *testing.T) { setup := func(t *testing.T) (*Engine, *state.Store, string, string, string) { t.Helper() @@ -89,7 +87,7 @@ func TestRecordDecision_KikenUndo(t *testing.T) { })) // Record kiken on Alice at Pool A-0 first so the test starts // from the "prior decision" state. - _, _, err := eng.RecordDecision(compID, "Pool A-0", "kiken", "aka", "knee injury", nil, false) + _, _, err := eng.RecordDecision(compID, "Pool A-0", "kiken", "aka", "knee injury", nil) require.NoError(t, err) return eng, store, compID, aliceID, bobID } @@ -98,7 +96,7 @@ func TestRecordDecision_KikenUndo(t *testing.T) { eng, store, compID, aliceID, _ := setup(t) // Flip the kiken: Bob (shiro) withdrew instead. Same match, // different decisionBy. - result, status, err := eng.RecordDecision(compID, "Pool A-0", "kiken", "shiro", "scoring fix", nil, false) + result, status, err := eng.RecordDecision(compID, "Pool A-0", "kiken", "shiro", "scoring fix", nil) require.NoError(t, err) require.NotNil(t, result) assert.Equal(t, "Alice", result.Winner) @@ -113,48 +111,52 @@ func TestRecordDecision_KikenUndo(t *testing.T) { assert.True(t, statuses[aliceID].Eligible) }) - t.Run("undo locked when a subsequent match has started for either side", func(t *testing.T) { - eng, store, compID, _, _ := setup(t) + t.Run("undo applies although a subsequent match has started, and changes no other match", func(t *testing.T) { + eng, store, compID, aliceID, bobID := setup(t) // Mark Pool A-1 (Alice vs Carol) as running, that's a - // subsequent match involving Alice. + // subsequent match involving Alice, and Pool A-2 completed. matches, err := store.LoadPoolMatches(compID) require.NoError(t, err) for i := range matches { - if matches[i].ID == "Pool A-1" { + switch matches[i].ID { + case "Pool A-1": matches[i].Status = state.MatchStatusRunning - } - } - require.NoError(t, store.SavePoolMatches(compID, matches)) - - _, _, err = eng.RecordDecision(compID, "Pool A-0", "kiken", "shiro", "scoring fix", nil, false) - require.Error(t, err) - assert.Truef(t, errors.Is(err, ErrDecisionLocked), "want ErrDecisionLocked, got %v", err) - }) - - t.Run("force=true bypasses the decision lock", func(t *testing.T) { - eng, store, compID, aliceID, _ := setup(t) - matches, err := store.LoadPoolMatches(compID) - require.NoError(t, err) - for i := range matches { - if matches[i].ID == "Pool A-2" { + case "Pool A-2": matches[i].Status = state.MatchStatusCompleted } } require.NoError(t, store.SavePoolMatches(compID, matches)) - - result, status, err := eng.RecordDecision(compID, "Pool A-0", "kiken", "shiro", "scoring fix", nil, true) + before, err := store.LoadPoolMatches(compID) require.NoError(t, err) + + result, status, err := eng.RecordDecision(compID, "Pool A-0", "kiken", "shiro", "scoring fix", nil) + require.NoError(t, err, "no confirm: the withdrawal on the other side applies") assert.Equal(t, "Alice", result.Winner) require.NotNil(t, status) assert.Equal(t, aliceID, status.PlayerID) assert.True(t, status.Eligible) + + statuses, err := store.LoadCompetitorStatus(compID) + require.NoError(t, err) + assert.True(t, statuses[aliceID].Eligible) + assert.False(t, statuses[bobID].Eligible, "the other side is the one that withdrew") + + after, err := store.LoadPoolMatches(compID) + require.NoError(t, err) + for i := range before { + if before[i].ID == "Pool A-0" { + continue + } + assert.Equal(t, before[i].Status, after[i].Status, "%s is untouched", before[i].ID) + assert.Equal(t, before[i].Decision, after[i].Decision, "%s is untouched", before[i].ID) + } }) t.Run("idempotent overwrite (same decisionBy) does not restore eligibility", func(t *testing.T) { eng, store, compID, aliceID, _ := setup(t) // Re-record kiken with the SAME decisionBy. Prior loser == // new loser, so eligibility should stay false. - _, _, err := eng.RecordDecision(compID, "Pool A-0", "kiken", "aka", "knee injury (repeated)", nil, false) + _, _, err := eng.RecordDecision(compID, "Pool A-0", "kiken", "aka", "knee injury (repeated)", nil) require.NoError(t, err) statuses, err := store.LoadCompetitorStatus(compID) require.NoError(t, err) @@ -165,7 +167,7 @@ func TestRecordDecision_KikenUndo(t *testing.T) { t.Run("restored CompetitorStatus has no Reason and a fresh RecordedAt", func(t *testing.T) { eng, _, compID, _, _ := setup(t) - _, status, err := eng.RecordDecision(compID, "Pool A-0", "kiken", "shiro", "scoring fix", nil, false) + _, status, err := eng.RecordDecision(compID, "Pool A-0", "kiken", "shiro", "scoring fix", nil) require.NoError(t, err) require.NotNil(t, status) assert.True(t, status.Eligible) @@ -195,7 +197,7 @@ func TestRecordDecision_DefaultWinIpponMarkers(t *testing.T) { // decisionBy "shiro" → Bob (shiro/SideB) is the no-show, so Alice // (aka/SideA) is the survivor and her IpponsA carries the fill. - result, _, err := eng.RecordDecision(compID, "Pool A-0", "fusensho", "shiro", "no-show", nil, false) + result, _, err := eng.RecordDecision(compID, "Pool A-0", "fusensho", "shiro", "no-show", nil) require.NoError(t, err) require.NotNil(t, result) assert.Equal(t, "Alice", result.Winner) @@ -207,7 +209,7 @@ func TestRecordDecision_DefaultWinIpponMarkers(t *testing.T) { require.NoError(t, store.SavePoolMatches(compID, []state.MatchResult{ {ID: "Pool A-0", SideA: "Alice", SideB: "Bob", Status: state.MatchStatusScheduled}, })) - result, _, err = eng.RecordDecision(compID, "Pool A-0", "kiken-injury", "shiro", "injury in encho", &state.EnchoMetadata{PeriodCount: 1}, true) + result, _, err = eng.RecordDecision(compID, "Pool A-0", "kiken-injury", "shiro", "injury in encho", &state.EnchoMetadata{PeriodCount: 1}) require.NoError(t, err) require.NotNil(t, result) assert.Equal(t, "Alice", result.Winner) @@ -238,12 +240,12 @@ func TestRecordDecision_ConcurrentKiken(t *testing.T) { })) // First operator records kiken on Alice in Pool A-0 (decisionBy=aka → Alice is loser). - _, _, err := eng.RecordDecision(compID, "Pool A-0", "kiken", "aka", "injury", nil, false) + _, _, err := eng.RecordDecision(compID, "Pool A-0", "kiken", "aka", "injury", nil) require.NoError(t, err) // Second operator attempts kiken on Alice (decisionBy=shiro → Alice is loser) in Pool A-1. // Alice is already ineligible from Pool A-0, different match. - _, _, err = eng.RecordDecision(compID, "Pool A-1", "kiken", "shiro", "concurrent", nil, false) + _, _, err = eng.RecordDecision(compID, "Pool A-1", "kiken", "shiro", "concurrent", nil) require.Error(t, err) var alreadyErr *AlreadyIneligibleError @@ -251,8 +253,8 @@ func TestRecordDecision_ConcurrentKiken(t *testing.T) { assert.Equal(t, aliceID, alreadyErr.PlayerID) assert.Equal(t, "Pool A-0", alreadyErr.MatchID) - // Same match should NOT be blocked, that's the undo path (T103). - _, _, err = eng.RecordDecision(compID, "Pool A-0", "kiken", "aka", "re-record same match", nil, false) + // Same match should NOT be blocked, that's the undo path. + _, _, err = eng.RecordDecision(compID, "Pool A-0", "kiken", "aka", "re-record same match", nil) assert.NoError(t, err, "re-recording the same match must not trigger AlreadyIneligibleError") // Verify that the failed match (Pool A-1) was rolled back (K3 partial-write fix). @@ -298,11 +300,11 @@ func TestRecordDecision_ConcurrentKikenRace(t *testing.T) { } results := make(chan result, 2) go func() { - _, _, err := eng.RecordDecision(compID, "Pool A-0", "kiken", "aka", "race A", nil, false) + _, _, err := eng.RecordDecision(compID, "Pool A-0", "kiken", "aka", "race A", nil) results <- result{err: err, matchID: "Pool A-0"} }() go func() { - _, _, err := eng.RecordDecision(compID, "Pool A-1", "kiken", "shiro", "race B", nil, false) + _, _, err := eng.RecordDecision(compID, "Pool A-1", "kiken", "shiro", "race B", nil) results <- result{err: err, matchID: "Pool A-1"} }() @@ -356,12 +358,12 @@ func TestRecordDecision_FusenshoSkipsConcurrentCheck(t *testing.T) { })) // Mark Alice ineligible via a prior kiken in Pool A-0. - _, _, err := eng.RecordDecision(compID, "Pool A-0", "kiken", "aka", "injury", nil, false) + _, _, err := eng.RecordDecision(compID, "Pool A-0", "kiken", "aka", "injury", nil) require.NoError(t, err) // Now record fusensho on Alice in Pool A-1. This should NOT trip the // concurrent-kiken guard, fusensho doesn't write ineligibility. - _, _, err = eng.RecordDecision(compID, "Pool A-1", "fusensho", "shiro", "default win", nil, false) + _, _, err = eng.RecordDecision(compID, "Pool A-1", "fusensho", "shiro", "default win", nil) assert.NoErrorf(t, err, "fusensho on an already-ineligible player must not trigger AlreadyIneligibleError; got %v", err) } @@ -370,7 +372,7 @@ func TestRecordDecision_FusenshoSkipsConcurrentCheck(t *testing.T) { // operator's exit (StartMatchTx refuses to start those matches), so it must // be recorded as their default loss rather than refused as already_ineligible. // The competitor-status record is left exactly as the withdrawal wrote it, so -// its MatchID still names the match that barred them (the T103 undo keys on +// its MatchID still names the match that barred them (the undo keys on // it) and an injury kiken stays reinstateable. Both doors are covered: the // /decision door (RecordDecision, which runs the T105 pre-write guard) and // the /score door (RecordMatchResultWithIneligibility, whose K3 pre-write @@ -397,7 +399,7 @@ func TestRecordDecision_FusenpaiChainsOntoExistingBar(t *testing.T) { })) // Alice withdraws injured in Pool A-0 (decisionBy=aka: Alice is sideA). - _, _, err := eng.RecordDecision(compID, "Pool A-0", "kiken-injury", "aka", "injury", nil, false) + _, _, err := eng.RecordDecision(compID, "Pool A-0", "kiken-injury", "aka", "injury", nil) require.NoError(t, err) barred := func() domain.CompetitorStatus { t.Helper() @@ -411,7 +413,7 @@ func TestRecordDecision_FusenpaiChainsOntoExistingBar(t *testing.T) { require.Equal(t, "Pool A-0", origin.MatchID) // /decision door: fusenpai on Alice (shiro, sideB) in Pool A-1. - res, status, err := eng.RecordDecision(compID, "Pool A-1", "fusenpai", "shiro", "did not appear", nil, false) + res, status, err := eng.RecordDecision(compID, "Pool A-1", "fusenpai", "shiro", "did not appear", nil) require.NoError(t, err, "a fusenpai against an already-withdrawn competitor is their default loss, not a second withdrawal") require.NotNil(t, status, "the chained fusenpai returns the loser's status in force") assert.Equal(t, "Pool A-0", status.MatchID, "... unchanged: it still names the match where Alice withdrew") @@ -449,7 +451,7 @@ func TestRecordDecision_FusenpaiChainsOntoExistingBar(t *testing.T) { require.NoError(t, store.SavePoolMatches(compID, append(matches, state.MatchResult{ ID: "Pool A-3", SideA: "Bob", SideAID: bobID, SideB: "Alice", SideBID: aliceID, Status: state.MatchStatusScheduled, }))) - _, _, err = eng.RecordDecision(compID, "Pool A-3", "kiken-voluntary", "shiro", "second withdrawal", nil, false) + _, _, err = eng.RecordDecision(compID, "Pool A-3", "kiken-voluntary", "shiro", "second withdrawal", nil) var alreadyErr *AlreadyIneligibleError require.ErrorAs(t, err, &alreadyErr, "a kiken against a competitor already barred elsewhere is still refused") assert.Equal(t, "Pool A-0", alreadyErr.MatchID) @@ -482,19 +484,19 @@ func TestRecordDecision_FusenpaiChainCorrectsAWrongKiken(t *testing.T) { {ID: "Pool A-1", SideA: "Carol", SideAID: carolID, SideB: "Alice", SideBID: aliceID, Status: state.MatchStatusScheduled}, })) - _, _, err := eng.RecordDecision(compID, "Pool A-0", "kiken-voluntary", "aka", "withdrew", nil, false) + _, _, err := eng.RecordDecision(compID, "Pool A-0", "kiken-voluntary", "aka", "withdrew", nil) require.NoError(t, err) // The wrong side: Carol (aka) marked as withdrawing in Pool A-1. - _, _, err = eng.RecordDecision(compID, "Pool A-1", "kiken-voluntary", "aka", "wrong side", nil, false) + _, _, err = eng.RecordDecision(compID, "Pool A-1", "kiken-voluntary", "aka", "wrong side", nil) require.NoError(t, err) statuses, err := store.LoadCompetitorStatus(compID) require.NoError(t, err) require.False(t, statuses[carolID].Eligible, "precondition: the wrong kiken barred Carol") - // The correction: Alice (shiro) did not appear. force: replacing a - // recorded withdrawal while Alice's Pool A-0 is already decided is the - // T103 lock, which the editor confirms ("Proceed anyway") and retries. - _, status, err := eng.RecordDecision(compID, "Pool A-1", "fusenpai", "shiro", "corrected", nil, true) + // The correction: Alice (shiro) did not appear. Replacing a + // recorded withdrawal while Alice's Pool A-0 is already decided needs no + // confirm. + _, status, err := eng.RecordDecision(compID, "Pool A-1", "fusenpai", "shiro", "corrected", nil) require.NoError(t, err) require.NotNil(t, status) assert.Equal(t, carolID, status.PlayerID, "the restored competitor is the one the write reports") @@ -533,11 +535,11 @@ func TestRecordDecision_FusenpaiChainClosesARunningMatch(t *testing.T) { })) // The fought result is corrected to a kiken by Alice (aka). - _, _, err := eng.RecordDecision(compID, "Pool A-0", "kiken-voluntary", "aka", "withdrew after all", nil, true) + _, _, err := eng.RecordDecision(compID, "Pool A-0", "kiken-voluntary", "aka", "withdrew after all", nil) require.NoError(t, err) // Her running match is closed as her default loss (shiro: Alice is sideB). - res, _, err := eng.RecordDecision(compID, "Pool A-1", "fusenpai", "shiro", "did not continue", nil, false) + res, _, err := eng.RecordDecision(compID, "Pool A-1", "fusenpai", "shiro", "did not continue", nil) require.NoError(t, err, "a fusenpai closes a running match of an already-withdrawn competitor") assert.Equal(t, state.MatchStatusCompleted, res.Status) assert.Equal(t, carolID, res.WinnerID) @@ -603,7 +605,7 @@ func TestRecordDecision_OnBracketMatch(t *testing.T) { matchID := bracket.Rounds[0][0].ID // Record kiken on the bracket match (Bob withdraws, decisionBy=shiro → Alice wins). - result, status, err := eng.RecordDecision(compID, matchID, "kiken", "shiro", "injury", nil, false) + result, status, err := eng.RecordDecision(compID, matchID, "kiken", "shiro", "injury", nil) require.NoError(t, err) require.NotNil(t, result) assert.Equal(t, "Alice", result.Winner) @@ -808,9 +810,8 @@ func TestLosingSide(t *testing.T) { // struck, IpponsB empty) is sitting right there. This is // DELIBERATE, not a gap to fix: see losingSide's own doc // comment for the known (and, through the app itself, - // unreachable) consequence -- RecordDecisionTx's - // hadPriorLoser check treats this ok=false as "no prior loser - // to protect" and skips the T103 downstream-match lock. + // unreachable) consequence -- callers that need the + // loser treat this ok=false as "no prior loser". name: "id-carrying prior with no WinnerSide/WinnerID resolves nothing, even with a scoreline (deliberate, see losingSide doc comment)", result: state.MatchResult{ SideA: "Alice", SideAID: "idA", SideB: "Bob", SideBID: "idB", @@ -971,37 +972,6 @@ func TestResolveMatchParticipantIDs_UnknownMatch(t *testing.T) { assert.Error(t, err) } -// TestHasDownstreamMatchStarted_BracketMatch verifies that a started bracket -// match involving one of the named players is detected. -func TestHasDownstreamMatchStarted_BracketMatch(t *testing.T) { - eng, store, _ := setupTestEngine(t) - compID := "downstream-bracket" - require.NoError(t, store.SaveCompetition(&state.Competition{ID: compID})) - require.NoError(t, store.SavePoolMatches(compID, []state.MatchResult{})) - require.NoError(t, store.SaveBracket(compID, &state.Bracket{ - Rounds: [][]state.BracketMatch{ - { - {ID: "B1", SideA: "Alice", SideB: "Bob", Status: state.MatchStatusRunning}, - }, - }, - })) - - started, err := eng.hasDownstreamMatchStarted(eng.store, compID, []string{"Alice"}, "other-match") - require.NoError(t, err) - assert.True(t, started, "started bracket match involving Alice must be detected") -} - -// TestHasDownstreamMatchStarted_EmptyPlayerNames covers the early-return -// when all player names are empty. -func TestHasDownstreamMatchStarted_EmptyPlayerNames(t *testing.T) { - eng, store, _ := setupTestEngine(t) - compID := "downstream-empty" - require.NoError(t, store.SaveCompetition(&state.Competition{ID: compID})) - started, err := eng.hasDownstreamMatchStarted(eng.store, compID, []string{"", ""}, "M1") - require.NoError(t, err) - assert.False(t, started) -} - // TestCheckConcurrentIneligibility_AlreadyIneligible covers the // AlreadyIneligibleError path: loserName is registered as a participant // and already has an ineligibility status from a DIFFERENT match. @@ -1054,7 +1024,7 @@ func TestCheckConcurrentIneligibility_SameMatchNotBlocked(t *testing.T) { // TestCheckEligibilityExcludingMatch_ExcludedMatch covers the // st.MatchID == excludeMatchID path: player is ineligible from the exact -// match being re-scored → not blocked (T103 undo path). +// match being re-scored → not blocked (undo path). func TestCheckEligibilityExcludingMatch_ExcludedMatch(t *testing.T) { eng, store, _ := setupTestEngine(t) compID := "excl-match" @@ -1143,7 +1113,7 @@ func TestRecordDecision_KikenReinstateable(t *testing.T) { {ID: "Pool A-0", SideA: "Alice", SideB: "Bob", Status: state.MatchStatusScheduled}, })) - _, status, err := eng.RecordDecision(compID, "Pool A-0", tc.decision, "aka", "reason", nil, false) + _, status, err := eng.RecordDecision(compID, "Pool A-0", tc.decision, "aka", "reason", nil) require.NoError(t, err) require.NotNil(t, status) assert.False(t, status.Eligible) @@ -1839,7 +1809,7 @@ func TestRecordDecision_LoserKeepsStruckPoints(t *testing.T) { })) // Bob (shiro) withdraws -> Alice wins by default: Alice gets the maru // pair; Bob KEEPS his struck K. - result, _, err := eng.RecordDecision(compID, "Pool A-0", "kiken-voluntary", "shiro", "withdrew", nil, false) + result, _, err := eng.RecordDecision(compID, "Pool A-0", "kiken-voluntary", "shiro", "withdrew", nil) require.NoError(t, err) assert.Equal(t, "Alice", result.Winner) assert.Equal(t, []string{"○", "○"}, result.IpponsA, "winner gets the maru default-win fill") @@ -1871,7 +1841,7 @@ func TestRecordDecision_TeamWithdrawalKeepsSubResults(t *testing.T) { Status: state.MatchStatusRunning, SubResults: subs}, })) // aka (SideA = Team Red) withdraws -> shiro (SideB = Team White) wins. - result, _, err := eng.RecordDecision(compID, "Pool A-0", "kiken-voluntary", "aka", "withdrew", nil, false) + result, _, err := eng.RecordDecision(compID, "Pool A-0", "kiken-voluntary", "aka", "withdrew", nil) require.NoError(t, err) assert.Equal(t, "Team White", result.Winner) // bc-tmfn follow-up: bout 3 (TeamSize 3, never fought) is padded with an @@ -1914,7 +1884,7 @@ func TestRecordDecision_BracketLoserKeepsStruckPoints(t *testing.T) { })) // Bob (shiro) withdraws -> Alice wins by default: Alice gets the maru // pair; Bob KEEPS his struck K. - result, _, err := eng.RecordDecision(compID, "m-r1-0", "kiken-voluntary", "shiro", "withdrew", nil, false) + result, _, err := eng.RecordDecision(compID, "m-r1-0", "kiken-voluntary", "shiro", "withdrew", nil) require.NoError(t, err) assert.Equal(t, "Alice", result.Winner) assert.Equal(t, []string{"○", "○"}, result.IpponsA, "winner gets the maru default-win fill") @@ -1965,7 +1935,7 @@ func TestRecordDecision_BracketTeamWithdrawalKeepsSubResults(t *testing.T) { IpponsA: []string{"M"}, SubResults: subs, })) // aka (SideA = Team Red) withdraws -> shiro (SideB = Team White) wins. - result, _, err := eng.RecordDecision(compID, "m-r1-0", "kiken-voluntary", "aka", "withdrew", nil, false) + result, _, err := eng.RecordDecision(compID, "m-r1-0", "kiken-voluntary", "aka", "withdrew", nil) require.NoError(t, err) assert.Equal(t, "Team White", result.Winner) assert.Equal(t, []string{"M"}, result.IpponsA, @@ -1979,7 +1949,7 @@ func TestRecordDecision_BracketTeamWithdrawalKeepsSubResults(t *testing.T) { assert.False(t, result.SubResults[2].HasResult(), "bout 3 was never fought; padded empty") } -// TestRecordDecision_ReDecisionFlipNoPhantomMaru guards the T103 correction +// TestRecordDecision_ReDecisionFlipNoPhantomMaru guards the correction // path: re-recording a default win with a FLIPPED decisionBy must not carry // the first decision's ○○ maru fill onto the new loser as phantom struck // points. preserveLoserScore keeps only real struck ippons (FIK Art. 32 "any @@ -1997,11 +1967,11 @@ func TestRecordDecision_ReDecisionFlipNoPhantomMaru(t *testing.T) { {ID: "Pool A-0", SideA: "Alice", SideB: "Bob", Status: state.MatchStatusScheduled}, })) // Decision 1: Bob (shiro) withdraws -> Alice wins ○○. - _, _, err := eng.RecordDecision(compID, "Pool A-0", "kiken-voluntary", "shiro", "withdrew", nil, false) + _, _, err := eng.RecordDecision(compID, "Pool A-0", "kiken-voluntary", "shiro", "withdrew", nil) require.NoError(t, err) // Correction: actually Alice (aka) withdrew -> flip decisionBy. Bob wins ○○; // Alice must NOT inherit decision 1's maru as points scored. - result, _, err := eng.RecordDecision(compID, "Pool A-0", "kiken-voluntary", "aka", "correction", nil, false) + result, _, err := eng.RecordDecision(compID, "Pool A-0", "kiken-voluntary", "aka", "correction", nil) require.NoError(t, err) assert.Equal(t, "Bob", result.Winner) assert.Equal(t, []string{"○", "○"}, result.IpponsB, "new winner Bob gets the maru fill") @@ -2046,7 +2016,7 @@ func TestK2ChecksItsHandleIsTransactional(t *testing.T) { t.Run("the production path runs K2 on a transactional handle", func(t *testing.T) { eng, _, compID, _ := setup(t) out := captureLog(t, func() { - _, _, err := eng.RecordDecision(compID, "Pool A-0", "kiken", "aka", "injury", nil, false) + _, _, err := eng.RecordDecision(compID, "Pool A-0", "kiken", "aka", "injury", nil) require.NoError(t, err) }) assert.NotContains(t, out, "NON-transactional store handle", diff --git a/internal/engine/errors.go b/internal/engine/errors.go index c81099065..2f51ea252 100644 --- a/internal/engine/errors.go +++ b/internal/engine/errors.go @@ -52,13 +52,6 @@ func notFoundErrorf(format string, args ...any) *NotFoundError { return &NotFoundError{Msg: fmt.Sprintf(format, args...)} } -// ErrDecisionLocked is returned when a decision-overwrite (kiken-undo -// or similar) is attempted on a match whose participants have started -// a subsequent match. Handlers should return HTTP 409. -// -// T103, CHK024. -var ErrDecisionLocked = errors.New("decision locked: a subsequent match has started") - // ErrDownstreamKnockoutPlayed is the sentinel matched by errors.Is for // DownstreamKnockoutPlayedError. Handlers should return HTTP 409. // diff --git a/internal/engine/kachinuki_reopen_withdrawal_test.go b/internal/engine/kachinuki_reopen_withdrawal_test.go index b123564a4..a0ca94ee1 100644 --- a/internal/engine/kachinuki_reopen_withdrawal_test.go +++ b/internal/engine/kachinuki_reopen_withdrawal_test.go @@ -28,7 +28,7 @@ func seedKachinukiPoolWithdrawal(t *testing.T, compID, decision string) (*Engine ID: "Pool A-0", SideA: wrTeamA, SideAID: wrTeamAID, SideB: wrTeamB, SideBID: wrTeamBID, Court: "A", Status: state.MatchStatusRunning, SubResults: []state.SubMatchResult{wrBout1("M")}, }})) - _, _, err := eng.RecordDecision(compID, "Pool A-0", decision, "aka", "knee", nil, false) + _, _, err := eng.RecordDecision(compID, "Pool A-0", decision, "aka", "knee", nil) require.NoError(t, err) require.False(t, wrEligible(t, store, compID, wrTeamAID), "precondition: the withdrawal barred Ryu") return eng, store @@ -83,7 +83,7 @@ func TestReopenKachinuki_BracketWithdrawalRemovedRestoresEligibility(t *testing. ID: "m-r1-0", SideA: wrTeamA, SideAID: wrTeamAID, SideB: wrTeamB, SideBID: wrTeamBID, Status: state.MatchStatusRunning, }}}})) - _, _, err := eng.RecordDecision(compID, "m-r1-0", "fusenpai", "shiro", "", nil, false) + _, _, err := eng.RecordDecision(compID, "m-r1-0", "fusenpai", "shiro", "", nil) require.NoError(t, err) require.False(t, wrEligible(t, store, compID, wrTeamBID), "precondition: the no-show barred Tora") @@ -113,7 +113,7 @@ func TestReopenKachinuki_NoWithdrawalRestoresNothing(t *testing.T) { Status: state.MatchStatusRunning, }, })) - _, _, err := eng.RecordDecision(compID, "Pool A-1", "kiken-voluntary", "aka", "", nil, false) + _, _, err := eng.RecordDecision(compID, "Pool A-1", "kiken-voluntary", "aka", "", nil) require.NoError(t, err) require.False(t, wrEligible(t, store, compID, wrTeamCID)) diff --git a/internal/engine/load_error_propagation_test.go b/internal/engine/load_error_propagation_test.go index 2ec4bc452..4460b1640 100644 --- a/internal/engine/load_error_propagation_test.go +++ b/internal/engine/load_error_propagation_test.go @@ -80,12 +80,6 @@ func TestMatchLookupsPropagateLoadErrors(t *testing.T) { return err }) }}, - {"hasDownstreamMatchStarted", func() error { - return inTxErr(func(tx state.StoreTx) error { - _, err := eng.hasDownstreamMatchStarted(tx, compID, []string{"Alice"}, matchID) - return err - }) - }}, {"matchSideParticipantIDs", func() error { return inTxErr(func(tx state.StoreTx) error { _, err := eng.matchSideParticipantIDs(tx, compID, matchID) diff --git a/internal/engine/match_merge_phase3_test.go b/internal/engine/match_merge_phase3_test.go index ca23a2122..eaf806094 100644 --- a/internal/engine/match_merge_phase3_test.go +++ b/internal/engine/match_merge_phase3_test.go @@ -542,9 +542,7 @@ func TestMerge_WriteAfterARenameIsAcceptedByItsIDs(t *testing.T) { // A queued decision whose first send landed but whose answer was lost is sent // again by the offline queue with the same stamp. That replay is the same -// write landing twice: it answers as recorded, never with the T103 lock's -// decision_locked (which the queue now reports to the operator as a refusal -// instead of swallowing it as success). +// write landing twice: it answers as recorded, and changes nothing. func TestMerge_ExactDecisionReplayAnswersAsRecorded(t *testing.T) { h := mmIndividual(t, false) ms, err := h.store.LoadPoolMatches(h.compID) @@ -555,10 +553,10 @@ func TestMerge_ExactDecisionReplayAnswersAsRecorded(t *testing.T) { }) require.NoError(t, h.store.SavePoolMatches(h.compID, ms)) - _, _, err = h.eng.RecordDecision(h.compID, h.matchID, "kiken-voluntary", "aka", "knee", nil, false, mmT2) + _, _, err = h.eng.RecordDecision(h.compID, h.matchID, "kiken-voluntary", "aka", "knee", nil, mmT2) require.NoError(t, err) // The withdrawer's later match is put under way (a fusenpai, say, was - // not recorded yet), which arms the T103 lock against an UNDO. + // not recorded yet). ms, err = h.store.LoadPoolMatches(h.compID) require.NoError(t, err) for i := range ms { @@ -568,13 +566,15 @@ func TestMerge_ExactDecisionReplayAnswersAsRecorded(t *testing.T) { } require.NoError(t, h.store.SavePoolMatches(h.compID, ms)) - got, _, err := h.eng.RecordDecision(h.compID, h.matchID, "kiken-voluntary", "aka", "knee", nil, false, mmT2) + got, _, err := h.eng.RecordDecision(h.compID, h.matchID, "kiken-voluntary", "aka", "knee", nil, mmT2) require.NoError(t, err, "an exact replay is the recorded write, not an undo") require.NotNil(t, got) assert.Equal(t, "kiken-voluntary", got.Decision) - t.Run("a different decision is still locked", func(t *testing.T) { - _, _, err := h.eng.RecordDecision(h.compID, h.matchID, "fusenpai", "aka", "", nil, false, mmT3) - require.ErrorIs(t, err, ErrDecisionLocked) + t.Run("a different decision at a newer stamp is recorded, with no confirm", func(t *testing.T) { + res, _, err := h.eng.RecordDecision(h.compID, h.matchID, "fusenpai", "aka", "", nil, mmT3) + require.NoError(t, err) + require.NotNil(t, res) + assert.Equal(t, "fusenpai", res.Decision) }) } diff --git a/internal/engine/match_merge_review_test.go b/internal/engine/match_merge_review_test.go index 1572cae0e..470aafc89 100644 --- a/internal/engine/match_merge_review_test.go +++ b/internal/engine/match_merge_review_test.go @@ -448,7 +448,7 @@ func TestMergeReview_KachinukiPoolRepBoutKeepsItsEncho(t *testing.T) { }) t.Run("a withdrawal in encho gives one circle", func(t *testing.T) { h := seed(t) - _, _, err := h.eng.RecordDecision(h.compID, h.matchID, "kiken-voluntary", "aka", "knee", &state.EnchoMetadata{PeriodCount: 1}, false, mmT1) + _, _, err := h.eng.RecordDecision(h.compID, h.matchID, "kiken-voluntary", "aka", "knee", &state.EnchoMetadata{PeriodCount: 1}, mmT1) require.NoError(t, err) m := h.load(t) assert.Equal(t, wrTeamB, m.Winner) diff --git a/internal/engine/match_merge_test.go b/internal/engine/match_merge_test.go index a7441bb68..2a188e561 100644 --- a/internal/engine/match_merge_test.go +++ b/internal/engine/match_merge_test.go @@ -231,7 +231,7 @@ func TestMerge_EchoOfAHeldGroupIsNotListedAsHeld(t *testing.T) { point.IpponsA = []string{"M"} require.NoError(t, h.write(point)) - _, _, err := h.eng.RecordDecision(h.compID, h.matchID, "kiken-voluntary", "aka", "knee", nil, false, mmT1) + _, _, err := h.eng.RecordDecision(h.compID, h.matchID, "kiken-voluntary", "aka", "knee", nil, mmT1) require.ErrorIs(t, err, ErrMatchSuperseded) assert.NotContains(t, HeldGroupsOf(err), state.GroupEncho, "an echo of the stored overtime is not a held change") assert.Contains(t, HeldGroupsOf(err), state.GroupResult) @@ -258,7 +258,7 @@ func TestMerge_LateKikenOlderThanAPointIsHeld(t *testing.T) { point.IpponsA = []string{"M"} require.NoError(t, h.write(point)) - _, _, err := h.eng.RecordDecision(h.compID, h.matchID, "kiken-voluntary", "aka", "knee", nil, false, mmT1) + _, _, err := h.eng.RecordDecision(h.compID, h.matchID, "kiken-voluntary", "aka", "knee", nil, mmT1) require.ErrorIs(t, err, ErrMatchSuperseded) // The decision changes result, points and encho; it carries no // overtime over a match with none, so encho echoes the stored @@ -284,7 +284,7 @@ func TestMerge_LateKikenOlderThanAPointIsHeld(t *testing.T) { point.IpponsA = []string{"M"} require.NoError(t, h.write(point)) - _, _, err := h.eng.RecordDecision(h.compID, h.matchID, "kiken-voluntary", "aka", "knee", nil, false, mmT1) + _, _, err := h.eng.RecordDecision(h.compID, h.matchID, "kiken-voluntary", "aka", "knee", nil, mmT1) require.ErrorIs(t, err, ErrMatchSuperseded) m := h.load(t) assert.Equal(t, state.MatchStatusRunning, m.Status) @@ -299,7 +299,7 @@ func TestMerge_LateKikenOlderThanAPointIsHeld(t *testing.T) { bout.SubResults = []state.SubMatchResult{{Position: 2, SideA: "r2", SideB: "t2", Winner: "r2", IpponsA: []string{"K"}}} require.NoError(t, h.write(bout)) - _, _, err := h.eng.RecordDecision(h.compID, h.matchID, "kiken-voluntary", "aka", "knee", nil, false, mmT1) + _, _, err := h.eng.RecordDecision(h.compID, h.matchID, "kiken-voluntary", "aka", "knee", nil, mmT1) require.ErrorIs(t, err, ErrMatchSuperseded, "a withdrawal declared before a bout was scored no longer describes the match") m := h.load(t) assert.Equal(t, state.MatchStatusRunning, m.Status) @@ -321,7 +321,7 @@ func TestMerge_LateKikenOlderThanAPointIsHeld(t *testing.T) { status := mmRunning(h, mmT2, state.GroupResult) require.NoError(t, h.write(status)) - _, _, err := h.eng.RecordDecision(h.compID, h.matchID, "kiken-voluntary", "aka", "knee", nil, false, mmT1) + _, _, err := h.eng.RecordDecision(h.compID, h.matchID, "kiken-voluntary", "aka", "knee", nil, mmT1) require.ErrorIs(t, err, ErrMatchSuperseded) m := h.load(t) assert.Empty(t, m.IpponsB, "no default-win circles without the withdrawal they belong to") @@ -334,7 +334,7 @@ func TestMerge_LateKikenOlderThanAPointIsHeld(t *testing.T) { point.IpponsA = []string{"M"} require.NoError(t, h.write(point)) - _, _, err := h.eng.RecordDecision(h.compID, h.matchID, "kiken-voluntary", "aka", "knee", nil, false, mmT2) + _, _, err := h.eng.RecordDecision(h.compID, h.matchID, "kiken-voluntary", "aka", "knee", nil, mmT2) require.NoError(t, err) m := h.load(t) assert.Equal(t, state.MatchStatusCompleted, m.Status) @@ -353,7 +353,7 @@ func TestMerge_LateKikenOlderThanAPointIsHeld(t *testing.T) { func TestMerge_ScoringAfterAWithdrawalClearsIt(t *testing.T) { bothBranches(t, func(t *testing.T, knockout bool) { h := mmIndividual(t, knockout) - _, _, err := h.eng.RecordDecision(h.compID, h.matchID, "kiken-voluntary", "aka", "knee", nil, false, mmT1) + _, _, err := h.eng.RecordDecision(h.compID, h.matchID, "kiken-voluntary", "aka", "knee", nil, mmT1) require.NoError(t, err) require.False(t, wrEligible(t, h.store, h.compID, wrTeamAID), "precondition: Ryu is barred") @@ -383,7 +383,7 @@ func TestMerge_ScoringAfterADefaultWinForABarElsewhereIsHeld(t *testing.T) { bothBranches(t, func(t *testing.T, knockout bool) { h := mmIndividual(t, knockout) // Ryu (aka) cannot fight: the default win is Tora's. - _, _, err := h.eng.RecordDecision(h.compID, h.matchID, "fusensho", "aka", "", nil, false, mmT1) + _, _, err := h.eng.RecordDecision(h.compID, h.matchID, "fusensho", "aka", "", nil, mmT1) require.NoError(t, err) before := h.load(t) require.Equal(t, wrTeamB, before.Winner, "precondition: Tora has the default win") @@ -452,7 +452,7 @@ func TestMerge_ScoringAfterATeamDefaultWinForABarElsewhereIsHeld(t *testing.T) { func TestMerge_ScoringAndOvertimeAfterADefaultWinAreBothHeld(t *testing.T) { bothBranches(t, func(t *testing.T, knockout bool) { h := mmIndividual(t, knockout) - _, _, err := h.eng.RecordDecision(h.compID, h.matchID, "fusensho", "aka", "", nil, false, mmT1) + _, _, err := h.eng.RecordDecision(h.compID, h.matchID, "fusensho", "aka", "", nil, mmT1) require.NoError(t, err) before := h.load(t) @@ -478,7 +478,7 @@ func TestMerge_ScoringAndOvertimeAfterADefaultWinAreBothHeld(t *testing.T) { func TestMerge_OvertimeAloneAfterAWithdrawalIsHeld(t *testing.T) { bothBranches(t, func(t *testing.T, knockout bool) { h := mmIndividual(t, knockout) - _, _, err := h.eng.RecordDecision(h.compID, h.matchID, "kiken-voluntary", "aka", "knee", nil, false, mmT1) + _, _, err := h.eng.RecordDecision(h.compID, h.matchID, "kiken-voluntary", "aka", "knee", nil, mmT1) require.NoError(t, err) board := mmRunning(h, mmT2, state.GroupEncho) @@ -503,7 +503,7 @@ func TestMerge_OvertimeAloneAfterAWithdrawalIsHeld(t *testing.T) { func TestMerge_EchoedOvertimeOverADefaultWinAppliesAsANoOp(t *testing.T) { bothBranches(t, func(t *testing.T, knockout bool) { h := mmIndividual(t, knockout) - _, _, err := h.eng.RecordDecision(h.compID, h.matchID, "fusensho", "aka", "", nil, false, mmT1) + _, _, err := h.eng.RecordDecision(h.compID, h.matchID, "fusensho", "aka", "", nil, mmT1) require.NoError(t, err) before := h.load(t) require.False(t, before.Encho.On(), "precondition: no overtime on a default win") @@ -548,7 +548,7 @@ func TestMerge_OvertimeOverAHandSetWinnerAppliesWithNoReason(t *testing.T) { func TestMerge_ScoringAndOvertimeTogetherClearAWithdrawal(t *testing.T) { bothBranches(t, func(t *testing.T, knockout bool) { h := mmIndividual(t, knockout) - _, _, err := h.eng.RecordDecision(h.compID, h.matchID, "kiken-voluntary", "aka", "knee", nil, false, mmT1) + _, _, err := h.eng.RecordDecision(h.compID, h.matchID, "kiken-voluntary", "aka", "knee", nil, mmT1) require.NoError(t, err) require.False(t, wrEligible(t, h.store, h.compID, wrTeamAID), "precondition: Ryu is barred") @@ -577,7 +577,7 @@ func TestMerge_ScoringAndOvertimeTogetherClearAWithdrawal(t *testing.T) { // holding the scoring AND the overtime together. func TestMerge_ClearingAWithdrawalThatWouldTieAKnockoutLeavesItStanding(t *testing.T) { h := mmIndividual(t, true) - _, _, err := h.eng.RecordDecision(h.compID, h.matchID, "kiken-voluntary", "aka", "knee", nil, false, mmT1) + _, _, err := h.eng.RecordDecision(h.compID, h.matchID, "kiken-voluntary", "aka", "knee", nil, mmT1) require.NoError(t, err) before := h.load(t) @@ -607,7 +607,7 @@ func TestMerge_ClearingAWithdrawalThatWouldTieAKnockoutLeavesItStanding(t *testi // behaviour, unaffected by the knockout fix above. func TestMerge_ClearingAWithdrawalThatTiesAPoolMatchBecomesADraw(t *testing.T) { h := mmIndividual(t, false) - _, _, err := h.eng.RecordDecision(h.compID, h.matchID, "kiken-voluntary", "aka", "knee", nil, false, mmT1) + _, _, err := h.eng.RecordDecision(h.compID, h.matchID, "kiken-voluntary", "aka", "knee", nil, mmT1) require.NoError(t, err) board := mmRunning(h, mmT2, state.GroupPoints, state.GroupEncho) @@ -630,14 +630,14 @@ func TestMerge_ClearingAWithdrawalThatTiesAPoolMatchBecomesADraw(t *testing.T) { func TestMerge_SameDecisionSentAgainLeavesAReinstatement(t *testing.T) { bothBranches(t, func(t *testing.T, knockout bool) { h := mmIndividual(t, knockout) - _, _, err := h.eng.RecordDecision(h.compID, h.matchID, "kiken-injury", "aka", "knee", nil, false, mmT1) + _, _, err := h.eng.RecordDecision(h.compID, h.matchID, "kiken-injury", "aka", "knee", nil, mmT1) require.NoError(t, err) require.False(t, wrEligible(t, h.store, h.compID, wrTeamAID), "precondition: Ryu is barred") _, err = h.eng.ReinstateCompetitor(h.compID, wrTeamAID) require.NoError(t, err) require.True(t, wrEligible(t, h.store, h.compID, wrTeamAID), "precondition: Ryu is reinstated") - _, status, err := h.eng.RecordDecision(h.compID, h.matchID, "kiken-injury", "aka", "knee", nil, false, mmT2) + _, status, err := h.eng.RecordDecision(h.compID, h.matchID, "kiken-injury", "aka", "knee", nil, mmT2) require.NoError(t, err) assert.Nil(t, status, "a verdict that did not move records no status") assert.True(t, wrEligible(t, h.store, h.compID, wrTeamAID), "the reinstatement stands") @@ -692,8 +692,8 @@ func TestMerge_NamedEchoTakesItsOwnStamp(t *testing.T) { // the stored result group's stamp to the replay's own stamp, so a decision // echoed at T2 must leave the result group stamped T2 -- not reverted to // T1, the stamp of the ORIGINAL send -- or a later exact replay of the T2 -// send can no longer recognise itself and falls through to the T103 -// downstream lock instead of being answered as already recorded. +// send can no longer recognise itself and falls through to +// the ordinary write instead of being answered as already recorded. func TestMerge_ExactReplayOfANamedEchoedDecisionStillMatches(t *testing.T) { h := mmIndividual(t, false) ms, err := h.store.LoadPoolMatches(h.compID) @@ -704,20 +704,19 @@ func TestMerge_ExactReplayOfANamedEchoedDecisionStillMatches(t *testing.T) { }) require.NoError(t, h.store.SavePoolMatches(h.compID, ms)) - _, _, err = h.eng.RecordDecision(h.compID, h.matchID, "kiken-voluntary", "aka", "knee", nil, false, mmT1) + _, _, err = h.eng.RecordDecision(h.compID, h.matchID, "kiken-voluntary", "aka", "knee", nil, mmT1) require.NoError(t, err) // The same decision sent again: it moves nothing (an echo), but a // decision always names its groups (decisionChangedGroups), so under // the fix it still takes T2 as the result group's own stamp. - _, status, err := h.eng.RecordDecision(h.compID, h.matchID, "kiken-voluntary", "aka", "knee", nil, false, mmT2) + _, status, err := h.eng.RecordDecision(h.compID, h.matchID, "kiken-voluntary", "aka", "knee", nil, mmT2) require.NoError(t, err) assert.Nil(t, status, "the echo moved nothing, so it has no eligibility consequence") afterEcho := h.load(t) assert.Equal(t, mmT2, afterEcho.GroupStamp(state.GroupResult), "the named echo took its own stamp") - // The withdrawer's later match is put under way, which arms the T103 - // lock against an UNDO. + // The withdrawer's later match is put under way. ms, err = h.store.LoadPoolMatches(h.compID) require.NoError(t, err) for i := range ms { @@ -728,8 +727,8 @@ func TestMerge_ExactReplayOfANamedEchoedDecisionStillMatches(t *testing.T) { require.NoError(t, h.store.SavePoolMatches(h.compID, ms)) // An exact replay of the T2 send (the operator's lost-answer retry) is - // answered as recorded, not refused as an undo the lock would catch. - got, _, err := h.eng.RecordDecision(h.compID, h.matchID, "kiken-voluntary", "aka", "knee", nil, false, mmT2) + // answered as recorded, as a replay, not a new decision. + got, _, err := h.eng.RecordDecision(h.compID, h.matchID, "kiken-voluntary", "aka", "knee", nil, mmT2) require.NoError(t, err, "an exact replay of the T2 write is the recorded write, not an undo") require.NotNil(t, got) assert.Equal(t, "kiken-voluntary", got.Decision) diff --git a/internal/engine/pool_requalify_test.go b/internal/engine/pool_requalify_test.go index c2c7162c4..bff493af4 100644 --- a/internal/engine/pool_requalify_test.go +++ b/internal/engine/pool_requalify_test.go @@ -286,7 +286,7 @@ func TestRequalify_ForcedReopenRestoresWithdrawalEligibility(t *testing.T) { if ko.SideA == "B1" { decisionBy = "aka" } - _, st, err := f.eng.RecordDecision(f.compID, ko.ID, "kiken-voluntary", decisionBy, "", nil, false) + _, st, err := f.eng.RecordDecision(f.compID, ko.ID, "kiken-voluntary", decisionBy, "", nil) require.NoError(t, err) require.NotNil(t, st) require.Equal(t, rqID("B1"), st.PlayerID) @@ -400,7 +400,7 @@ func TestRequalify_TieRevertsToLabelThenTieBreakSeats(t *testing.T) { func TestRequalify_ReopenThenFinish(t *testing.T) { f := newRQFixture(t, "rq-reopen", 1, [][]string{{"A1", "A2"}, {"B1", "B2"}}) // A2 (shiro, SideB) withdraws from Pool A-0, so A1 qualifies. - _, _, err := f.eng.RecordDecision(f.compID, "Pool A-0", "kiken-voluntary", "shiro", "", nil, false) + _, _, err := f.eng.RecordDecision(f.compID, "Pool A-0", "kiken-voluntary", "shiro", "", nil) require.NoError(t, err) f.scorePool("Pool B-0", "B1") f.resolve() diff --git a/internal/engine/reopen_bye_unwind_test.go b/internal/engine/reopen_bye_unwind_test.go index 3f0263c0d..dff04372c 100644 --- a/internal/engine/reopen_bye_unwind_test.go +++ b/internal/engine/reopen_bye_unwind_test.go @@ -71,7 +71,7 @@ func scoreBracketMatch(t *testing.T, eng *Engine, store *state.Store, compID, ma // and the bye passes P01 into the final. func recordMistakenKiken(t *testing.T, eng *Engine, store *state.Store, compID string) { t.Helper() - _, st, err := eng.RecordDecision(compID, "m-r1-0", "kiken-voluntary", "aka", "", nil, false) + _, st, err := eng.RecordDecision(compID, "m-r1-0", "kiken-voluntary", "aka", "", nil) require.NoError(t, err) require.NotNil(t, st) b := loadBracket(t, store, compID) diff --git a/internal/engine/reopen_fusensho_status_test.go b/internal/engine/reopen_fusensho_status_test.go index 7d3c64438..a020c8871 100644 --- a/internal/engine/reopen_fusensho_status_test.go +++ b/internal/engine/reopen_fusensho_status_test.go @@ -45,13 +45,13 @@ func fusenshoBarredFixture(t *testing.T, compID string) (*Engine, *state.Store) {ID: "Pool A-1", SideA: "Alice", SideB: "Carol", SideAID: aliceID, SideBID: carolID, Status: state.MatchStatusScheduled}, })) // Alice (aka/SideA) withdraws from Pool A-0; Bob credited. - _, _, err := eng.RecordDecision(compID, "Pool A-0", "kiken-voluntary", "aka", "no-show", nil, false) + _, _, err := eng.RecordDecision(compID, "Pool A-0", "kiken-voluntary", "aka", "no-show", nil) require.NoError(t, err) // Alice (aka/SideA) cannot fight Pool A-1 either; Carol credited by // match-level fusensho. Not a withdrawal decision, so this write bars // nobody itself (domain.IsWithdrawalDecisionStr excludes fusensho) -- // Alice's only bar is still the one Pool A-0 recorded. - _, _, err = eng.RecordDecision(compID, "Pool A-1", "fusensho", "aka", "already ineligible", nil, false) + _, _, err = eng.RecordDecision(compID, "Pool A-1", "fusensho", "aka", "already ineligible", nil) require.NoError(t, err) return eng, store } @@ -138,9 +138,9 @@ func TestReopenMatch_ChainedFusenpai_StillBarred_GoesToScheduled(t *testing.T) { {ID: "Pool A-0", SideA: "Alice", SideB: "Bob", SideAID: aliceID, SideBID: bobID, Status: state.MatchStatusScheduled}, {ID: "Pool A-1", SideA: "Alice", SideB: "Carol", SideAID: aliceID, SideBID: carolID, Status: state.MatchStatusScheduled}, })) - _, _, err := eng.RecordDecision(compID, "Pool A-0", "kiken-voluntary", "aka", "withdrew", nil, false) + _, _, err := eng.RecordDecision(compID, "Pool A-0", "kiken-voluntary", "aka", "withdrew", nil) require.NoError(t, err) - _, _, err = eng.RecordDecision(compID, "Pool A-1", "fusenpai", "aka", "did not appear", nil, false) + _, _, err = eng.RecordDecision(compID, "Pool A-1", "fusenpai", "aka", "did not appear", nil) require.NoError(t, err) _, err = eng.ReopenMatch(compID, "Pool A-1", "") @@ -176,7 +176,7 @@ func TestReopenMatch_OrdinaryFusenpai_GoesToRunning(t *testing.T) { require.NoError(t, store.SavePoolMatches(compID, []state.MatchResult{ {ID: "Pool A-0", SideA: "Alice", SideB: "Bob", SideAID: aliceID, SideBID: bobID, Status: state.MatchStatusScheduled}, })) - _, _, err := eng.RecordDecision(compID, "Pool A-0", "fusenpai", "aka", "did not appear", nil, false) + _, _, err := eng.RecordDecision(compID, "Pool A-0", "fusenpai", "aka", "did not appear", nil) require.NoError(t, err) _, err = eng.ReopenMatch(compID, "Pool A-0", "") @@ -211,9 +211,9 @@ func TestReopenMatch_ClearingTheOriginMovesTheBarToAChainedFusenpai(t *testing.T {ID: "Pool A-0", SideA: "Alice", SideB: "Bob", SideAID: aliceID, SideBID: bobID, Status: state.MatchStatusScheduled}, {ID: "Pool A-1", SideA: "Alice", SideB: "Carol", SideAID: aliceID, SideBID: carolID, Status: state.MatchStatusScheduled}, })) - _, _, err := eng.RecordDecision(compID, "Pool A-0", "kiken-voluntary", "aka", "withdrew", nil, false) + _, _, err := eng.RecordDecision(compID, "Pool A-0", "kiken-voluntary", "aka", "withdrew", nil) require.NoError(t, err) - _, _, err = eng.RecordDecision(compID, "Pool A-1", "fusenpai", "aka", "did not appear", nil, false) + _, _, err = eng.RecordDecision(compID, "Pool A-1", "fusenpai", "aka", "did not appear", nil) require.NoError(t, err) restored, err := eng.ReopenMatch(compID, "Pool A-0", "") @@ -262,11 +262,11 @@ func TestReopenMatch_ClearingAWithdrawalAfterAReinstatementLeavesThemEligible(t {ID: "Pool A-0", SideA: "Alice", SideB: "Bob", SideAID: aliceID, SideBID: bobID, Status: state.MatchStatusScheduled}, {ID: "Pool A-1", SideA: "Alice", SideB: "Carol", SideAID: aliceID, SideBID: carolID, Status: state.MatchStatusScheduled}, })) - _, _, err := eng.RecordDecision(compID, "Pool A-0", "kiken-injury", "aka", "injured", nil, false) + _, _, err := eng.RecordDecision(compID, "Pool A-0", "kiken-injury", "aka", "injured", nil) require.NoError(t, err) _, err = eng.ReinstateCompetitor(compID, aliceID) require.NoError(t, err) - _, _, err = eng.RecordDecision(compID, "Pool A-1", "kiken-voluntary", "aka", "wrong match", nil, false) + _, _, err = eng.RecordDecision(compID, "Pool A-1", "kiken-voluntary", "aka", "wrong match", nil) require.NoError(t, err) _, err = eng.ReopenMatch(compID, "Pool A-1", "") diff --git a/internal/engine/reopen_scheduled_court_test.go b/internal/engine/reopen_scheduled_court_test.go index 2382fd9c5..2e6810030 100644 --- a/internal/engine/reopen_scheduled_court_test.go +++ b/internal/engine/reopen_scheduled_court_test.go @@ -43,10 +43,10 @@ func busyCourtFusenshoFixture(t *testing.T, compID string, reinstate, crossComp {ID: "Pool A-1", SideA: "Alice", SideB: "Carol", SideAID: aliceID, SideBID: carolID, Court: "A", Status: state.MatchStatusScheduled}, {ID: "Pool A-2", SideA: "Dave", SideB: "Erin", SideAID: daveID, SideBID: erinID, Court: "A", Status: state.MatchStatusScheduled}, })) - _, st, err := eng.RecordDecision(compID, "Pool A-0", "kiken-injury", "aka", "injured", nil, false) + _, st, err := eng.RecordDecision(compID, "Pool A-0", "kiken-injury", "aka", "injured", nil) require.NoError(t, err) require.Equal(t, aliceID, st.PlayerID) - _, _, err = eng.RecordDecision(compID, "Pool A-1", "fusensho", "aka", "already ineligible", nil, false) + _, _, err = eng.RecordDecision(compID, "Pool A-1", "fusensho", "aka", "already ineligible", nil) require.NoError(t, err) if reinstate { _, err = eng.ReinstateCompetitor(compID, aliceID) diff --git a/internal/engine/reopen_withdrawal_test.go b/internal/engine/reopen_withdrawal_test.go index ae3e73731..5e4c856ff 100644 --- a/internal/engine/reopen_withdrawal_test.go +++ b/internal/engine/reopen_withdrawal_test.go @@ -77,7 +77,7 @@ func seedIndividualWithdrawal(t *testing.T, decision string, encho *state.EnchoM IpponsA: []string{"M"}, IpponsB: []string{"K"}, HansokuB: 1, Status: state.MatchStatusRunning, }})) - _, _, err := eng.RecordDecision(compID, "Pool A-0", decision, "aka", "", encho, false) + _, _, err := eng.RecordDecision(compID, "Pool A-0", decision, "aka", "", encho) require.NoError(t, err) require.False(t, wrEligible(t, store, compID, wrTeamAID), "precondition: the withdrawal barred Ryu") return eng, store, compID @@ -165,7 +165,7 @@ func TestReopenWithdrawal_RunningDownstreamIsRefused(t *testing.T) { {{ID: "m-r1-0", SideA: wrTeamA, SideAID: wrTeamAID, SideB: wrTeamB, SideBID: wrTeamBID, Status: state.MatchStatusRunning}}, {{ID: "m-r2-0", SideB: wrTeamC, SideBID: wrTeamCID}}, }})) - _, _, err := eng.RecordDecision(compID, "m-r1-0", "fusenpai", "shiro", "", nil, false) + _, _, err := eng.RecordDecision(compID, "m-r1-0", "fusenpai", "shiro", "", nil) require.NoError(t, err) require.NoError(t, store.UpdateBracket(compID, func(b *state.Bracket) error { b.Rounds[1][0].Status = state.MatchStatusRunning @@ -328,7 +328,7 @@ func TestReopenWithdrawal_TeamRepBoutKeepsWhoFought(t *testing.T) { RepPlayerA: "r2", RepPlayerB: "t2", IpponsA: []string{"M"}, Status: state.MatchStatusRunning, }})) - _, _, err := eng.RecordDecision(compID, "Pool A-DH-0", "kiken-voluntary", "aka", "knee", nil, false) + _, _, err := eng.RecordDecision(compID, "Pool A-DH-0", "kiken-voluntary", "aka", "knee", nil) require.NoError(t, err) ms, err := store.LoadPoolMatches(compID) require.NoError(t, err) @@ -364,7 +364,7 @@ func seedKnockoutFinalWithdrawal(t *testing.T, first func(eng *Engine, compID st b, err := store.LoadBracket(compID) require.NoError(t, err) require.Equal(t, wrTeamB, b.Rounds[1][0].SideA, "precondition: Tora went through to the final") - _, _, err = eng.RecordDecision(compID, "m-r2-0", "kiken-voluntary", "shiro", "knee", nil, false) + _, _, err = eng.RecordDecision(compID, "m-r2-0", "kiken-voluntary", "shiro", "knee", nil) require.NoError(t, err) require.False(t, wrEligible(t, store, compID, wrTeamCID), "precondition: the final's withdrawal barred Kuma") return eng, store, compID @@ -392,7 +392,7 @@ func TestForceReopenedDownstreamWithdrawalRestoresEligibility(t *testing.T) { { name: "clear withdrawal and reopen", first: func(eng *Engine, compID string) { - _, _, err := eng.RecordDecision(compID, "m-r1-0", "fusenpai", "aka", "", nil, false) + _, _, err := eng.RecordDecision(compID, "m-r1-0", "fusenpai", "aka", "", nil) require.NoError(t, err) }, reopen: func(t *testing.T, eng *Engine, compID string) []ReopenedMatch { @@ -456,7 +456,7 @@ func TestForceReopenedDownstreamWithoutWithdrawalRestoresNothing(t *testing.T) { {{ID: "m-r1-0", SideA: wrTeamA, SideAID: wrTeamAID, SideB: wrTeamB, SideBID: wrTeamBID, Status: state.MatchStatusRunning, MatchNumber: 1}}, {{ID: "m-r2-0", SideB: wrTeamC, SideBID: wrTeamCID, MatchNumber: 2}}, }})) - _, _, err := eng.RecordDecision(compID, "m-r1-0", "fusenpai", "aka", "", nil, false) + _, _, err := eng.RecordDecision(compID, "m-r1-0", "fusenpai", "aka", "", nil) require.NoError(t, err) _, err = eng.RecordMatchResultWithIneligibility(compID, "m-r2-0", &state.MatchResult{ ID: "m-r2-0", SideA: wrTeamB, SideB: wrTeamC, Winner: wrTeamB, diff --git a/internal/engine/restore_touches_no_eligibility_test.go b/internal/engine/restore_touches_no_eligibility_test.go index 2924994b3..868fe0a07 100644 --- a/internal/engine/restore_touches_no_eligibility_test.go +++ b/internal/engine/restore_touches_no_eligibility_test.go @@ -22,7 +22,7 @@ func TestRefusedCorrectionRollbackKeepsAReinstatedCompetitorEligible(t *testing. // A2 withdraws injured from Pool A-0 (A1 is aka, A2 shiro), so A1 wins // the pool; B1 wins Pool B; A1 then beats B1 in the knockout. - _, st, err := f.eng.RecordDecision(f.compID, "Pool A-0", "kiken-injury", "shiro", "", nil, false) + _, st, err := f.eng.RecordDecision(f.compID, "Pool A-0", "kiken-injury", "shiro", "", nil) require.NoError(t, err) require.NotNil(t, st) require.Equal(t, rqID("A2"), st.PlayerID) diff --git a/internal/engine/scoring.go b/internal/engine/scoring.go index 8f87fb90f..3a9b29d38 100644 --- a/internal/engine/scoring.go +++ b/internal/engine/scoring.go @@ -1343,7 +1343,7 @@ func preserveLoserScore(result, prior *state.MatchResult, decisionBy string) { // Preserve only points the loser actually STRUCK (Art. 32 says "any point // scored"): strip the maru marker so a prior default-win decision's ○○ // fill is never carried forward as if it were struck points. This matters - // on the T103 re-decision path when decisionBy flips — the side that was + // on the re-decision path when decisionBy flips — the side that was // the prior winner holds maru, not real points, and must not inherit it as // the new loser. if decisionBy == "shiro" { @@ -2684,8 +2684,7 @@ func (e *Engine) applyBracketResultIn(bracket *state.Bracket, compID, matchID st // correction that actually changes the winner from one that does // not. force skips the guard's played refusal, and an unconditional requeue there // cleared the next round for a write that stored the same winner -- - // including one confirmed for an unrelated reason, since the - // decision path's own T103 force used to arrive as this flag. + // including one confirmed for an unrelated reason. priorWinner, priorWinnerID := propagatedWinnerOf(bracket, rIdx, mIdx, bm) if _, err := applyBracketMatchResult(bm, result, policy); err != nil { return nil, false, err diff --git a/internal/engine/scoring_test.go b/internal/engine/scoring_test.go index 804b3d140..9fea23350 100644 --- a/internal/engine/scoring_test.go +++ b/internal/engine/scoring_test.go @@ -2459,7 +2459,7 @@ func TestHansokuFoldGuardIsScopedToThisWrite(t *testing.T) { SubResults: storedOverCapRow(), }})) - _, _, err := eng.RecordDecision(compID, "P1-1", "kiken-voluntary", "shiro", "withdrew", nil, false) + _, _, err := eng.RecordDecision(compID, "P1-1", "kiken-voluntary", "shiro", "withdrew", nil) require.NoError(t, err, "the kiken payload carries no sub rows and no hansoku; the stored row is not this write's error to report") }) diff --git a/internal/engine/scoring_tx.go b/internal/engine/scoring_tx.go index 16cfa23eb..7d4f74ddc 100644 --- a/internal/engine/scoring_tx.go +++ b/internal/engine/scoring_tx.go @@ -26,14 +26,14 @@ // tx-shaped orchestration, not duplicated persistence. // // A follow-up pass collapsed four more twins the same way: lookupMatchSides, -// checkConcurrentIneligibility, hasDownstreamMatchStarted, and (at the time) +// checkConcurrentIneligibility, and (at the time) // restoreCompetitorEligibility, all taking `h`. restoreCompetitorEligibility // itself was later removed outright (second-Opus-pass item 3): // RecordDecisionTx's restore-on-rescore no longer re-derives the prior // loser's identity from the match's side names/ids at all -- it restores // whichever competitor-status entry carries this exact MatchID and is still // Eligible:false, which is exact by construction and needs no roster lookup. -// lookupMatchSides, checkConcurrentIneligibility and hasDownstreamMatchStarted +// lookupMatchSides and checkConcurrentIneligibility // remain, living in eligibility.go, taking `h`. // RecordDecisionTx below is the last of the original hand-copied pairs to be // resolved — unlike the others it keeps ITS name (mobileapp's ScoringEngine @@ -933,48 +933,36 @@ func clientWriteStamp(stamp []int64) int64 { // points it had already struck (FIK Art. 32, via preserveLoserScore). // // When the match already has a kiken/fusenpai decision recorded (the -// "undo" path, T103/CHK024) the engine enforces the -// contracts/match-decisions.md §Decision lock & undo rule: if any -// subsequent match involving either prior participant has started -// since the original decision was recorded, the engine returns -// ErrDecisionLocked unless force is true. On a successful overwrite -// where the prior loser is no longer the new loser, the prior loser's -// CompetitorStatus is restored to Eligible: true and surfaced as the -// returned status so the handler can broadcast the change. +// "undo" path) the write is never refused for what the competitors have +// done since: recording the withdrawal on the other side applies with no +// confirm and changes no other match (operator ruling 2026-09-27, "do not +// cascade"). On a successful overwrite where the prior loser is no longer +// the new loser, the prior loser's CompetitorStatus is restored to +// Eligible: true and surfaced as the returned status so the handler can +// broadcast the change. // -// Runs the sides lookup, the T105 concurrent-kiken check, the T103 -// downstream-match lock check, the match write, and the prior-loser -// eligibility restore on undo, ALL through the supplied tx, so the whole -// sequence commits under ONE per-comp lock acquire (T156). +// Runs the sides lookup, the T105 concurrent-kiken check, the match write, +// and the prior-loser eligibility restore on undo, ALL through the supplied +// tx, so the whole sequence commits under ONE per-comp lock acquire (T156). // // This is the canonical body: RecordDecision (eligibility.go) is a thin -// WithTransaction shim over this function — bc-twin, mirroring the +// WithTransaction shim over this function (bc-twin, mirroring the // RecordMatchResultWithIneligibility / RecordMatchResultWithIneligibilityTx -// pair in scoring.go / scoring_tx.go. Call this directly when already +// pair in scoring.go / scoring_tx.go). Call this directly when already // inside a WithTransaction closure (e.g. the decision HTTP handler); call // RecordDecision otherwise. // -// T090, T103, T156, contracts/match-decisions.md §POST /decision, bc-twin. +// T090, T156, contracts/match-decisions.md §POST /decision, bc-twin. // -// bc-cse finding 5: force here governs ONLY the T103 downstream-match lock -// above -- a different operator confirmation from the bc-kcdg -// downstream-knockout-correction guard the underlying write applies (see -// applyBracketResultIn / guardDownstreamKnockoutCorrection). This entry -// point has always reused the SAME value for both, and its own -// RecordMatchResultWithIneligibilityTx call below still does, purely -// because every existing caller (the /decision HTTP handler, deps.go's -// ScoringEngine interface, this function's own tests) has exactly ONE -// force flag to give it and no channel to receive the reopened ids back -- -// see RecordDecisionTxWithOptions for the twin that decouples the two and -// surfaces Reopened. RecordDecisionTx stays the pre-existing behaviour so -// none of those callers need to change. -func (e *Engine) RecordDecisionTx(tx state.StoreTx, compID, matchID, decision, decisionBy, decisionReason string, encho *state.EnchoMetadata, force bool, modifiedAt ...int64) (*state.MatchResult, *domain.CompetitorStatus, error) { - return e.recordDecisionTx(tx, compID, matchID, decision, decisionBy, decisionReason, encho, force, ForceOptions{}, clientWriteStamp(modifiedAt)) +// The underlying write applies the bc-kcdg downstream-knockout-correction +// guard with an EMPTY ForceOptions here; RecordDecisionTxWithOptions is the +// twin that carries the operator's bc-kcdg answer and surfaces Reopened. +func (e *Engine) RecordDecisionTx(tx state.StoreTx, compID, matchID, decision, decisionBy, decisionReason string, encho *state.EnchoMetadata, modifiedAt ...int64) (*state.MatchResult, *domain.CompetitorStatus, error) { + return e.recordDecisionTx(tx, compID, matchID, decision, decisionBy, decisionReason, encho, ForceOptions{}, clientWriteStamp(modifiedAt)) } // RecordDecisionTxWithOptions is RecordDecisionTx's bc-kcdg-aware twin -// (bc-cse finding 5). `force` still governs ONLY the T103 downstream-match -// lock (the "undo" override); `kcdgOpts` is the SEPARATE authorization for +// (bc-cse finding 5). `kcdgOpts` is the authorization for // the bc-kcdg downstream-knockout-correction guard the underlying bracket // write applies, and its Reopened field, when non-nil, is populated with // the ids of every bracket match the write forced open -- the same contract @@ -985,8 +973,8 @@ func (e *Engine) RecordDecisionTx(tx state.StoreTx, compID, matchID, decision, d // confirmations, or has only one flag to give, should keep calling // RecordDecisionTx instead (source-compatible with every caller that // predates this split). -func (e *Engine) RecordDecisionTxWithOptions(tx state.StoreTx, compID, matchID, decision, decisionBy, decisionReason string, encho *state.EnchoMetadata, force bool, kcdgOpts ForceOptions, modifiedAt ...int64) (*state.MatchResult, *domain.CompetitorStatus, error) { - return e.recordDecisionTx(tx, compID, matchID, decision, decisionBy, decisionReason, encho, force, kcdgOpts, clientWriteStamp(modifiedAt)) +func (e *Engine) RecordDecisionTxWithOptions(tx state.StoreTx, compID, matchID, decision, decisionBy, decisionReason string, encho *state.EnchoMetadata, kcdgOpts ForceOptions, modifiedAt ...int64) (*state.MatchResult, *domain.CompetitorStatus, error) { + return e.recordDecisionTx(tx, compID, matchID, decision, decisionBy, decisionReason, encho, kcdgOpts, clientWriteStamp(modifiedAt)) } // recordDecisionTx is the canonical body RecordDecisionTx and @@ -997,11 +985,11 @@ func (e *Engine) RecordDecisionTxWithOptions(tx state.StoreTx, compID, matchID, // the resolved clientWriteStamp value (a plain int64, since a private // function need not preserve the exported variadic ergonomics its two // public callers offer for their own source compatibility). -func (e *Engine) recordDecisionTx(tx state.StoreTx, compID, matchID, decision, decisionBy, decisionReason string, encho *state.EnchoMetadata, force bool, kcdgOpts ForceOptions, modifiedAtStamp int64) (*state.MatchResult, *domain.CompetitorStatus, error) { +func (e *Engine) recordDecisionTx(tx state.StoreTx, compID, matchID, decision, decisionBy, decisionReason string, encho *state.EnchoMetadata, kcdgOpts ForceOptions, modifiedAtStamp int64) (*state.MatchResult, *domain.CompetitorStatus, error) { if decisionBy != "shiro" && decisionBy != "aka" { return nil, nil, validationErrorf("decisionBy must be 'shiro' or 'aka', got %q", decisionBy) } - // T103: look up the prior result FIRST (ahead of the T105 concurrent + // Look up the prior result FIRST (ahead of the T105 concurrent // check below, which needs it): for a pool match this already carries // the generation-time SideAID/SideBID (stamped once at draw time, // present even before the match is ever scored, mirrors pools.go), which @@ -1035,7 +1023,7 @@ func (e *Engine) recordDecisionTx(tx state.StoreTx, compID, matchID, decision, d // A stale decision (an offline replay older than the stored match) is // superseded before anything judges it: the write below would hold it by // this same rule (the merge, mergeMatchWrite), so the refusals that follow - // (already barred, a running later match, the T103 lock) would only + // (already barred, a running later match) would only // misreport it as something to resolve and send again, when a newer // change is already stored. It is kept in the match's history (bc-mrgc). if decisionHeldByMerge(prior, result, comp) { @@ -1046,9 +1034,9 @@ func (e *Engine) recordDecisionTx(tx state.StoreTx, compID, matchID, decision, d // An exact replay of the decision already recorded (the same decision, // side and stamp: a queued write whose first send landed but whose answer // was lost) is that same write landing again, so it answers as recorded, - // before any refusal below (the T103 lock would otherwise refuse the - // replay of a withdrawal whose competitor has a later match under way, - // and the client would report a recorded result as refused). It changes + // before any refusal below (the running-match refusal would otherwise + // refuse the replay of a withdrawal whose competitor has a later match + // under way, and the client would report a recorded result as refused). It changes // nothing; its history entry says so (bc-mrgc phase 3). if exactDecisionReplay(prior, decision, decisionBy, modifiedAtStamp) { probe := *result @@ -1108,52 +1096,21 @@ func (e *Engine) recordDecisionTx(tx state.StoreTx, compID, matchID, decision, d return nil, nil, cerr } } - hadPriorLoser := false - if domain.IsWithdrawalDecisionStr(prior.Decision) { - // losingSide, so a prior decision that itself came through - // RecordDecisionTx -- and so already carries WinnerSide -- is - // attributed by that authoritative hint rather than an ambiguous - // name/ippon guess. hadPriorLoser=false (skipping the T103 lock - // below, i.e. failing OPEN) is losingSide's answer whenever it - // cannot attribute the loss at all; see its own doc comment for the - // one known, narrow case that reaches. - _, name, ok := losingSide(prior) - hadPriorLoser = ok && name != "" - } - // bc-rfsw: refused BEFORE the T103 lock below, so the operator is never - // asked to confirm a write the bracket write would then refuse. + // bc-rfsw: a decision reaching a later match already being fought is + // refused outright (terminal, not confirmable). if err := refuseDecisionReachingRunningMatch(tx, compID, matchID, decisionBy, prior); err != nil { return nil, nil, err } - // T103: downstream-match check. The contract scope is "either - // participant", if any subsequent match for either side has been - // started or completed since the kiken/fusenpai, refuse the undo - // unless force is set. - if hadPriorLoser && !force { - started, err := e.hasDownstreamMatchStarted(tx, compID, []string{sideA, sideB}, matchID) - if err != nil { - return nil, nil, err - } - if started { - return nil, nil, ErrDecisionLocked - } - } - // kcdgOpts is the caller's own bc-kcdg authorization and is NOT derived - // from the T103 `force` above. They answer different questions: T103's - // force confirms undoing a kiken or fusenpai whose loser has since been - // scheduled, while this one confirms clearing an already-played later - // match. Feeding T103's flag in here (as the first cut did) meant an - // operator confirming an unrelated decision-lock override silently - // authorized a round being requeued, with no dialog naming it. - // RecordDecisionTx therefore passes an EMPTY ForceOptions; only - // RecordDecisionTxWithOptions callers, which have the operator's actual - // answer, can set it, and they read back the reopened ids via - // kcdgOpts.Reopened. + // kcdgOpts is the caller's own bc-kcdg authorization (confirming the + // clearing of an already-played later match). RecordDecisionTx passes an + // EMPTY ForceOptions; only RecordDecisionTxWithOptions callers, which have + // the operator's actual answer, can set it, and they read back the + // reopened ids via kcdgOpts.Reopened. status, err := e.RecordMatchResultWithIneligibilityTx(tx, compID, matchID, result, kcdgOpts) if err != nil { return nil, nil, err } - // T103 undo: the write above already restored whoever the withdrawal it + // Undo: the write above already restored whoever the withdrawal it // replaced had barred (restoreIfWithdrawalRemoved, called by // RecordMatchResultWithIneligibilityTx for every door), keeping the new // withdrawer, and returned the restored status in priority. A second @@ -1267,7 +1224,7 @@ func (e *Engine) holdWriteTx(tx state.StoreTx, compID, matchID string, result *s // whose winner (the side decisionBy does not name, exactly as // recordDecisionTx assigns it) differs from the one already propagated, while // a later match it fed is being fought, is a *DownstreamKnockoutRunningError. -// Asked before the T103 decision lock so no confirm precedes a write that +// Asked before the write so no confirm precedes a write that // would be refused (operator decision 2026-09-27). It asks the score door's // own rules (propagatedWinnerOf, winnerDiffers, downstreamCorrectionRefusal // with force, so only the running half applies) rather than a copy of them. diff --git a/internal/engine/scoring_tx_internal_test.go b/internal/engine/scoring_tx_internal_test.go index dde886f8b..2eab08dbc 100644 --- a/internal/engine/scoring_tx_internal_test.go +++ b/internal/engine/scoring_tx_internal_test.go @@ -378,97 +378,6 @@ func TestRecordMatchResultWithIneligibilityTx_BracketPath(t *testing.T) { assert.Equal(t, state.MatchStatusCompleted, b.Rounds[0][0].Status) } -// TestHasDownstreamMatchStartedTx_PoolPath exercises the pool-match -// branch in hasDownstreamMatchStarted (through a tx handle). Two cases: the Running match -// is excluded (returns false) and a Running match is NOT excluded -// (returns true). -func TestHasDownstreamMatchStartedTx_PoolPath(t *testing.T) { - eng, store, _ := setupTestEngine(t) - compID := "hdmstx-pool" - require.NoError(t, store.SaveCompetition(&state.Competition{ID: compID})) - // Use proper "PoolName-MatchIdx" ID format so the CSV round-trip - // preserves the IDs. - require.NoError(t, store.SavePoolMatches(compID, []state.MatchResult{ - {ID: "Pool A-0", SideA: "Alice", SideB: "Bob", Status: state.MatchStatusRunning}, - {ID: "Pool A-1", SideA: "Alice", SideB: "Carol", Status: state.MatchStatusScheduled}, - })) - - t.Run("excluded running match → false", func(t *testing.T) { - var ( - started bool - txErr error - ) - _ = store.WithTransaction(compID, func(tx state.StoreTx) error { - // Exclude "Pool A-0" (Alice Running) → only P A-1 (Scheduled) left → false. - started, txErr = eng.hasDownstreamMatchStarted(tx, compID, []string{"Alice"}, "Pool A-0") - return nil - }) - require.NoError(t, txErr) - assert.False(t, started, "excluded running match must not count") - }) - - t.Run("non-excluded running match → true", func(t *testing.T) { - var ( - started bool - txErr error - ) - _ = store.WithTransaction(compID, func(tx state.StoreTx) error { - // Exclude "Pool A-1" (Scheduled) → "Pool A-0" (Running+Alice) detected. - started, txErr = eng.hasDownstreamMatchStarted(tx, compID, []string{"Alice"}, "Pool A-1") - return nil - }) - require.NoError(t, txErr) - assert.True(t, started, "running match involving Alice must be detected") - }) -} - -// TestHasDownstreamMatchStartedTx_BracketPath exercises the bracket -// branch and asserts a running bracket match involving the player is -// detected. -func TestHasDownstreamMatchStartedTx_BracketPath(t *testing.T) { - eng, store, _ := setupTestEngine(t) - compID := "hdmstx-bracket" - require.NoError(t, store.SaveCompetition(&state.Competition{ID: compID})) - require.NoError(t, store.SavePoolMatches(compID, []state.MatchResult{})) - require.NoError(t, store.SaveBracket(compID, &state.Bracket{ - Rounds: [][]state.BracketMatch{ - { - {ID: "B1", SideA: "Alice", SideB: "Bob", Status: state.MatchStatusRunning}, - {ID: "B2", SideA: "Carol", SideB: "Dave", Status: state.MatchStatusScheduled}, - }, - }, - })) - - var ( - started bool - txErr error - ) - _ = store.WithTransaction(compID, func(tx state.StoreTx) error { - // Check from a hypothetical other match; B1 (Alice+Bob) is running. - started, txErr = eng.hasDownstreamMatchStarted(tx, compID, []string{"Alice"}, "OTHER") - return nil - }) - require.NoError(t, txErr) - assert.True(t, started, "B1 is running and involves Alice → should be detected") -} - -// TestStartMatchTx_EmptyWantSet verifies that empty player names list -// returns false immediately. -func TestHasDownstreamMatchStartedTx_EmptyNames(t *testing.T) { - eng, store, _ := setupTestEngine(t) - compID := "hdmstx-empty" - require.NoError(t, store.SaveCompetition(&state.Competition{ID: compID})) - - var started bool - _ = store.WithTransaction(compID, func(tx state.StoreTx) error { - var txErr error - started, txErr = eng.hasDownstreamMatchStarted(tx, compID, []string{}, "M1") - require.NoError(t, txErr) - return nil - }) - assert.False(t, started) -} - // TestRecordIneligibilityFromDecisionTx_NilResult confirms nil result // is a no-op. func TestRecordIneligibilityFromDecisionTx_NilResult(t *testing.T) { @@ -764,7 +673,7 @@ func TestRecordDecisionTx_ValidationError(t *testing.T) { var txErr error _ = store.WithTransaction(compID, func(tx state.StoreTx) error { - _, _, txErr = eng.RecordDecisionTx(tx, compID, "Pool A-0", "kiken", "invalid-side", "", nil, false) + _, _, txErr = eng.RecordDecisionTx(tx, compID, "Pool A-0", "kiken", "invalid-side", "", nil) return nil }) require.Error(t, txErr) diff --git a/internal/engine/scoring_tx_test.go b/internal/engine/scoring_tx_test.go index 6721a1149..531ab7947 100644 --- a/internal/engine/scoring_tx_test.go +++ b/internal/engine/scoring_tx_test.go @@ -40,7 +40,7 @@ func TestRecordDecisionTx_BasicEquivalence(t *testing.T) { engErr error ) txErr := store.WithTransaction(compID, func(tx state.StoreTx) error { - result, status, engErr = eng.RecordDecisionTx(tx, compID, "Pool A-0", "kiken", "aka", "knee injury", nil, false) + result, status, engErr = eng.RecordDecisionTx(tx, compID, "Pool A-0", "kiken", "aka", "knee injury", nil) return nil }) require.NoError(t, txErr) @@ -105,7 +105,7 @@ func TestRecordDecisionTx_ConcurrentDoesNotDeadlock(t *testing.T) { defer wg.Done() var engErr error _ = store.WithTransaction(compID, func(tx state.StoreTx) error { - _, _, engErr = eng.RecordDecisionTx(tx, compID, "Pool A-0", "kiken", "aka", "race A", nil, false) + _, _, engErr = eng.RecordDecisionTx(tx, compID, "Pool A-0", "kiken", "aka", "race A", nil) return nil }) results <- res{err: engErr, matchID: "Pool A-0"} @@ -114,7 +114,7 @@ func TestRecordDecisionTx_ConcurrentDoesNotDeadlock(t *testing.T) { defer wg.Done() var engErr error _ = store.WithTransaction(compID, func(tx state.StoreTx) error { - _, _, engErr = eng.RecordDecisionTx(tx, compID, "Pool A-1", "kiken", "shiro", "race B", nil, false) + _, _, engErr = eng.RecordDecisionTx(tx, compID, "Pool A-1", "kiken", "shiro", "race B", nil) return nil }) results <- res{err: engErr, matchID: "Pool A-1"} @@ -168,10 +168,9 @@ func TestRecordDecisionTx_ConcurrentDoesNotDeadlock(t *testing.T) { } } -// TestRecordDecisionTx_KikenUndoSucceeds verifies that the T103 kiken- -// undo path works correctly under the tx-aware variant, both the -// downstream-match lock check and the prior-loser eligibility restore -// dispatch through the supplied StoreTx without re-acquiring the lock. +// TestRecordDecisionTx_KikenUndoSucceeds verifies that the kiken- +// undo path works correctly under the tx-aware variant: the +// prior-loser eligibility restore dispatches through the supplied StoreTx without re-acquiring the lock. func TestRecordDecisionTx_KikenUndoSucceeds(t *testing.T) { eng, store, _ := setupTestEngine(t) compID := "tx-undo" @@ -192,7 +191,7 @@ func TestRecordDecisionTx_KikenUndoSucceeds(t *testing.T) { // Record kiken (Alice loser) via the tx variant. _ = store.WithTransaction(compID, func(tx state.StoreTx) error { - _, _, err := eng.RecordDecisionTx(tx, compID, "Pool A-0", "kiken", "aka", "knee injury", nil, false) + _, _, err := eng.RecordDecisionTx(tx, compID, "Pool A-0", "kiken", "aka", "knee injury", nil) return err }) @@ -203,7 +202,7 @@ func TestRecordDecisionTx_KikenUndoSucceeds(t *testing.T) { engErr error ) _ = store.WithTransaction(compID, func(tx state.StoreTx) error { - result, status, engErr = eng.RecordDecisionTx(tx, compID, "Pool A-0", "kiken", "shiro", "scoring fix", nil, false) + result, status, engErr = eng.RecordDecisionTx(tx, compID, "Pool A-0", "kiken", "shiro", "scoring fix", nil) return nil }) require.NoError(t, engErr) @@ -252,7 +251,7 @@ func TestRecordDecisionTx_RenamedLoser_RescoreStillResolvesByID(t *testing.T) { require.Equal(t, bobID, bracket.Rounds[0][0].SideBID, "precondition: generation stamps Bob's id onto the round-0 row") // First kiken: decisionBy=shiro -> Bob (SideB) withdraws. - _, status, err := eng.RecordDecision(compID, matchID, "kiken-voluntary", "shiro", "injury", nil, false) + _, status, err := eng.RecordDecision(compID, matchID, "kiken-voluntary", "shiro", "injury", nil) require.NoError(t, err) require.NotNil(t, status) assert.Equal(t, bobID, status.PlayerID) @@ -271,7 +270,7 @@ func TestRecordDecisionTx_RenamedLoser_RescoreStillResolvesByID(t *testing.T) { // kiken, so the verdict moves and the eligibility is recorded again (an // identical one is an echo and records no status, bc-mrgc). The stored // id survives the rename, so this still resolves to Bob. - _, status2, err := eng.RecordDecision(compID, matchID, "kiken-injury", "shiro", "injury", nil, false) + _, status2, err := eng.RecordDecision(compID, matchID, "kiken-injury", "shiro", "injury", nil) require.NoError(t, err) require.NotNil(t, status2, "the id survives the rename, so the write still resolves the SAME loser") assert.Equal(t, bobID, status2.PlayerID) @@ -332,7 +331,7 @@ func TestRecordDecisionTx_RenamedLoser_UnstampedBracketRescoreDoesNotRestore(t * // First kiken: decisionBy=shiro -> Bob (SideB) withdraws, resolved by // name (this row carries no ids at all). - _, status, err := eng.RecordDecision(compID, matchID, "kiken-voluntary", "shiro", "injury", nil, false) + _, status, err := eng.RecordDecision(compID, matchID, "kiken-voluntary", "shiro", "injury", nil) require.NoError(t, err) require.NotNil(t, status) assert.Equal(t, bobID, status.PlayerID) @@ -349,7 +348,7 @@ func TestRecordDecisionTx_RenamedLoser_UnstampedBracketRescoreDoesNotRestore(t * // The name-based roster scan can no longer find "Bob" at all, so this // write resolves no CompetitorStatus -- it neither confirms nor changes // anyone's eligibility. - _, status2, err := eng.RecordDecision(compID, matchID, "kiken-voluntary", "shiro", "injury", nil, false) + _, status2, err := eng.RecordDecision(compID, matchID, "kiken-voluntary", "shiro", "injury", nil) require.NoError(t, err) assert.Nil(t, status2, "the write did not resolve/write a loser after the rename, so it must not report a restore") @@ -359,9 +358,12 @@ func TestRecordDecisionTx_RenamedLoser_UnstampedBracketRescoreDoesNotRestore(t * assert.False(t, statuses[bobID].Eligible, "Bob is STILL withdrawn; a re-score the engine could not attribute must never silently restore him") } -// TestRecordDecisionTx_DownstreamLockReturnsErr asserts the T103 -// decision-lock check fires correctly in the tx-aware path. -func TestRecordDecisionTx_DownstreamLockReturnsErr(t *testing.T) { +// TestRecordDecisionTx_WithdrawalCorrectionChangesNoOtherMatch asserts that +// recording the withdrawal on the other side applies in the tx-aware path with +// no confirm, although the first withdrawer has a later match under way, and +// that it changes no other match (operator ruling 2026-09-27, "do not +// cascade"). +func TestRecordDecisionTx_WithdrawalCorrectionChangesNoOtherMatch(t *testing.T) { eng, store, _ := setupTestEngine(t) compID := "tx-lock" createTestCompetition(t, store, compID, "league", 3) @@ -380,22 +382,37 @@ func TestRecordDecisionTx_DownstreamLockReturnsErr(t *testing.T) { })) // Pre-record a kiken so the next call is the "undo" path. - _, _, err := eng.RecordDecision(compID, "Pool A-0", "kiken", "aka", "first", nil, false) + _, _, err := eng.RecordDecision(compID, "Pool A-0", "kiken", "aka", "first", nil) require.NoError(t, err) - // Now try the undo via the tx variant, should hit ErrDecisionLocked. - var engErr error + before, err := store.LoadPoolMatches(compID) + require.NoError(t, err) + + // Now correct it via the tx variant: Bob (shiro) is the one who withdrew. + var ( + engErr error + result *state.MatchResult + ) _ = store.WithTransaction(compID, func(tx state.StoreTx) error { - _, _, engErr = eng.RecordDecisionTx(tx, compID, "Pool A-0", "kiken", "shiro", "undo", nil, false) + result, _, engErr = eng.RecordDecisionTx(tx, compID, "Pool A-0", "kiken", "shiro", "undo", nil) return nil }) - require.Error(t, engErr) - assert.Truef(t, errors.Is(engErr, ErrDecisionLocked), "expected ErrDecisionLocked, got %v", engErr) + require.NoError(t, engErr) + assert.Equal(t, "Alice", result.Winner) + + after, err := store.LoadPoolMatches(compID) + require.NoError(t, err) + for i := range before { + if before[i].ID == "Pool A-0" { + continue + } + assert.Equal(t, before[i], after[i], "%s is untouched", before[i].ID) + } + assert.Equal(t, state.MatchStatusRunning, after[1].Status, "the match under way stays under way") } // A stale decision (an offline replay older than the stored match) is -// superseded before any refusal judges it: the T103 lock and the -// already-barred check would otherwise answer it as something to resolve and +// superseded before any refusal judges it: the already-barred check would otherwise answer it as something to resolve and // send again, when a newer result is already stored. A newer stamp still // meets each refusal, which is what makes these the controls. func TestRecordDecisionTx_StaleReplayIsSupersededBeforeTheRefusals(t *testing.T) { @@ -414,28 +431,27 @@ func TestRecordDecisionTx_StaleReplayIsSupersededBeforeTheRefusals(t *testing.T) {ID: "Pool A-1", SideA: "Carol", SideB: "Alice", Status: state.MatchStatusRunning, ModifiedAt: 10_000}, })) // Alice withdraws in Pool A-0, stamped 10_000. - _, _, err := eng.RecordDecision(compID, "Pool A-0", "kiken", "aka", "first", nil, false, 10_000) + _, _, err := eng.RecordDecision(compID, "Pool A-0", "kiken", "aka", "first", nil, 10_000) require.NoError(t, err) return eng, store, compID } decide := func(eng *Engine, store *state.Store, compID, matchID, decisionBy string, stamp int64) error { var engErr error _ = store.WithTransaction(compID, func(tx state.StoreTx) error { - _, _, engErr = eng.RecordDecisionTx(tx, compID, matchID, "kiken", decisionBy, "replay", nil, false, stamp) + _, _, engErr = eng.RecordDecisionTx(tx, compID, matchID, "kiken", decisionBy, "replay", nil, stamp) return nil }) return engErr } - t.Run("the T103 lock", func(t *testing.T) { - // Undoing Pool A-0's kiken while Alice's Pool A-1 is running. + t.Run("a withdrawal moved to the other side", func(t *testing.T) { + // Moving Pool A-0's kiken to the other side while Alice's Pool A-1 is running. eng, store, compID := setup(t) err := decide(eng, store, compID, "Pool A-0", "shiro", 5_000) require.ErrorIs(t, err, ErrMatchSuperseded) - assert.NotErrorIs(t, err, ErrDecisionLocked) err = decide(eng, store, compID, "Pool A-0", "shiro", 20_000) - assert.ErrorIs(t, err, ErrDecisionLocked, "a newer stamp still meets the lock") + assert.NoError(t, err, "a newer stamp applies") }) t.Run("the already-barred check", func(t *testing.T) { @@ -624,7 +640,7 @@ func TestStartMatchTx_BlocksIneligibleParticipant(t *testing.T) { {ID: "Pool A-1", SideA: "Alice", SideB: "Carol", Status: state.MatchStatusScheduled}, })) // Kiken Alice from Pool A-0. - _, _, err := eng.RecordDecision(compID, "Pool A-0", "kiken", "aka", "knee", nil, false) + _, _, err := eng.RecordDecision(compID, "Pool A-0", "kiken", "aka", "knee", nil) require.NoError(t, err) // StartMatchTx for Pool A-1 (different match, Alice still a diff --git a/internal/engine/winnerid_invariant_test.go b/internal/engine/winnerid_invariant_test.go index 98409a486..44ea785ee 100644 --- a/internal/engine/winnerid_invariant_test.go +++ b/internal/engine/winnerid_invariant_test.go @@ -90,7 +90,7 @@ func TestWinnerIDInvariant_EveryWritePathStampsASideID(t *testing.T) { m := matches[0] // decisionBy "aka" withdraws SideA; SideB survives as the winner. - _, _, err = eng.RecordDecision(compID, m.ID, "kiken", "aka", "voluntary", nil, false) + _, _, err = eng.RecordDecision(compID, m.ID, "kiken", "aka", "voluntary", nil) require.NoError(t, err) assertWinnerIDMatchesASide(t, loadPoolMatch(t, store, compID, m.ID)) diff --git a/internal/engine/withdrawal_cleared_by_correction_test.go b/internal/engine/withdrawal_cleared_by_correction_test.go index d30c6e0f4..97ab2c190 100644 --- a/internal/engine/withdrawal_cleared_by_correction_test.go +++ b/internal/engine/withdrawal_cleared_by_correction_test.go @@ -154,9 +154,9 @@ func TestWithdrawalClearedByCorrection_DefaultWinKeepsTheBarFromElsewhere(t *tes {ID: "Pool A-1", SideA: wrTeamA, SideAID: wrTeamAID, SideB: wrTeamC, SideBID: wrTeamCID, Status: state.MatchStatusRunning}, })) // Ryu withdraws in Pool A-1, then Tora is given the default win in Pool A-0. - _, _, err := eng.RecordDecision(compID, "Pool A-1", "kiken-voluntary", "aka", "", nil, false) + _, _, err := eng.RecordDecision(compID, "Pool A-1", "kiken-voluntary", "aka", "", nil) require.NoError(t, err) - _, _, err = eng.RecordDecision(compID, "Pool A-0", "fusensho", "aka", "", nil, false) + _, _, err = eng.RecordDecision(compID, "Pool A-0", "fusensho", "aka", "", nil) require.NoError(t, err) require.Equal(t, "fusensho", wrPoolMatch(t, store, compID).Decision) diff --git a/internal/engine/withdrawal_removed_restores_test.go b/internal/engine/withdrawal_removed_restores_test.go index ea530b0b3..6342598bd 100644 --- a/internal/engine/withdrawal_removed_restores_test.go +++ b/internal/engine/withdrawal_removed_restores_test.go @@ -45,7 +45,7 @@ func TestWithdrawalRemoved_DecisionReplacingATeamWithdrawalRestores(t *testing.T require.False(t, wrEligible(t, store, compID, wrTeamAID), "precondition: the kiken barred Ryu") require.NotEmpty(t, wrPoolMatch(t, store, compID).SubResults, "precondition: the kiken kept the fought bout") - _, status, err := eng.RecordDecision(compID, "Pool A-0", decision, "aka", "", nil, false) + _, status, err := eng.RecordDecision(compID, "Pool A-0", decision, "aka", "", nil) require.NoError(t, err) m := wrPoolMatch(t, store, compID) @@ -78,7 +78,7 @@ func TestWithdrawalRemoved_BracketTxDoorRestores(t *testing.T) { ID: "m-r1-0", SideA: wrTeamA, SideAID: wrTeamAID, SideB: wrTeamB, SideBID: wrTeamBID, Status: state.MatchStatusRunning, }}}})) - _, _, err := eng.RecordDecision(compID, "m-r1-0", "fusenpai", "shiro", "", nil, false) + _, _, err := eng.RecordDecision(compID, "m-r1-0", "fusenpai", "shiro", "", nil) require.NoError(t, err) require.False(t, wrEligible(t, store, compID, wrTeamBID)) @@ -113,7 +113,7 @@ func wrFoughtBouts() []state.SubMatchResult { func TestWithdrawalRemoved_SupersededWriteRestoresNothing(t *testing.T) { eng, store, compID, dir := seedPoolWithdrawal(t, "kiken-voluntary") // Re-record the same withdrawal with a stamp, so there is a time to lose to. - _, _, err := eng.RecordDecision(compID, "Pool A-0", "kiken-voluntary", "aka", "knee", nil, false, 1_900_000_000_000) + _, _, err := eng.RecordDecision(compID, "Pool A-0", "kiken-voluntary", "aka", "knee", nil, 1_900_000_000_000) require.NoError(t, err) before := string(readStatusFile(t, dir, compID)) stored := wrPoolMatch(t, store, compID) diff --git a/internal/engine/withdrawal_ruling_kept_test.go b/internal/engine/withdrawal_ruling_kept_test.go index 67a51dec1..75af81d32 100644 --- a/internal/engine/withdrawal_ruling_kept_test.go +++ b/internal/engine/withdrawal_ruling_kept_test.go @@ -70,7 +70,7 @@ func seedPoolWithdrawal(t *testing.T, decision string) (*Engine, *state.Store, s ID: "Pool A-0", SideA: wrTeamA, SideAID: wrTeamAID, SideB: wrTeamB, SideBID: wrTeamBID, Status: state.MatchStatusRunning, SubResults: []state.SubMatchResult{wrBout1("M")}, }})) - _, _, err := eng.RecordDecision(compID, "Pool A-0", decision, "aka", "knee", nil, false) + _, _, err := eng.RecordDecision(compID, "Pool A-0", decision, "aka", "knee", nil) require.NoError(t, err) return eng, store, compID, dir } @@ -197,7 +197,7 @@ func TestWithdrawalRulingKept_SingleBoutCorrectionKeepsTheRuling(t *testing.T) { IpponsA: []string{"M"}, IpponsB: []string{"K"}, Status: state.MatchStatusRunning, }})) - _, _, err := eng.RecordDecision(compID, tc.matchID, "kiken-voluntary", "aka", "knee", nil, false) + _, _, err := eng.RecordDecision(compID, tc.matchID, "kiken-voluntary", "aka", "knee", nil) require.NoError(t, err) ms, err := store.LoadPoolMatches(compID) require.NoError(t, err) @@ -259,7 +259,7 @@ func seedBracketWithdrawal(t *testing.T, bronze bool) (*Engine, *state.Store, st } } require.NoError(t, store.SaveBracket(compID, b)) - _, _, err := eng.RecordDecision(compID, matchID, "kiken-voluntary", "aka", "knee", nil, false) + _, _, err := eng.RecordDecision(compID, matchID, "kiken-voluntary", "aka", "knee", nil) require.NoError(t, err) if !bronze { _, err = eng.RecordMatchResultWithIneligibility(compID, "m-r2-0", &state.MatchResult{ @@ -439,7 +439,7 @@ func TestWithdrawalRulingKept_OtherSideStillFixesAWrongWithdrawal(t *testing.T) _, err := eng.RecordMatchResultWithIneligibility(compID, "Pool A-0", wrCorrection("Pool A-0")) require.NoError(t, err) - _, status, err := eng.RecordDecision(compID, "Pool A-0", "kiken-voluntary", "shiro", "wrong team", nil, false) + _, status, err := eng.RecordDecision(compID, "Pool A-0", "kiken-voluntary", "shiro", "wrong team", nil) require.NoError(t, err) require.NotNil(t, status) assert.Equal(t, wrTeamAID, status.PlayerID) diff --git a/internal/mobileapp/decision_no_confirm_test.go b/internal/mobileapp/decision_no_confirm_test.go new file mode 100644 index 000000000..67cc48bdb --- /dev/null +++ b/internal/mobileapp/decision_no_confirm_test.go @@ -0,0 +1,73 @@ +package mobileapp + +import ( + "net/http" + "os" + "testing" + + "github.com/gitrgoliveira/bracket-creator/internal/domain" + "github.com/gitrgoliveira/bracket-creator/internal/helper" + "github.com/gitrgoliveira/bracket-creator/internal/state" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// Recording the withdrawal on the other side applies with no confirm and +// changes no other match, even when the first withdrawer has a later match +// under way (operator ruling 2026-09-27, "do not cascade"). A body that still +// carries the retired `force` key binds and applies the same way. +func TestDecisionHandler_ReRecordingTheWithdrawalAppliesWithNoConfirm(t *testing.T) { + r, store, _, _, tempDir := setupTestRouter(t) + defer os.RemoveAll(tempDir) + compID := "dlck-no-confirm" + require.NoError(t, store.SaveCompetition(&state.Competition{ + ID: compID, Name: "dlck", Status: state.CompStatusPools, + })) + aliceID, bobID := helper.NewUUID4(), helper.NewUUID4() + require.NoError(t, store.SaveParticipants(compID, []domain.Player{ + {ID: aliceID, Name: "Alice", Dojo: "A"}, + {ID: bobID, Name: "Bob", Dojo: "B"}, + {ID: helper.NewUUID4(), Name: "Carol", Dojo: "C"}, + })) + require.NoError(t, store.SavePoolMatches(compID, []state.MatchResult{ + {ID: "Pool A-0", SideA: "Alice", SideB: "Bob", Status: state.MatchStatusScheduled}, + {ID: "Pool A-1", SideA: "Alice", SideB: "Carol", Status: state.MatchStatusScheduled}, + })) + url := "/api/competitions/" + compID + "/matches/Pool A-0/decision" + + w := serveJSON(r, "POST", url, map[string]any{"decision": "kiken-voluntary", "decisionBy": "aka"}) + require.Equal(t, http.StatusOK, w.Code, w.Body.String()) + + // The first withdrawer's later match is put under way. + ms, err := store.LoadPoolMatches(compID) + require.NoError(t, err) + for i := range ms { + if ms[i].ID == "Pool A-1" { + ms[i].Status = state.MatchStatusRunning + } + } + require.NoError(t, store.SavePoolMatches(compID, ms)) + + // The other side withdrew; the retired force key still binds. + w = serveJSON(r, "POST", url, map[string]any{"decision": "kiken-voluntary", "decisionBy": "shiro", "force": true}) + require.Equal(t, http.StatusOK, w.Code, w.Body.String()) + assert.NotContains(t, w.Body.String(), "locked") + + statuses, err := store.LoadCompetitorStatus(compID) + require.NoError(t, err) + assert.True(t, statuses[aliceID].Eligible, "the side first marked is eligible again") + assert.False(t, statuses[bobID].Eligible, "the other side is the one that withdrew") + + ms, err = store.LoadPoolMatches(compID) + require.NoError(t, err) + for _, m := range ms { + if m.ID == "Pool A-1" { + assert.Equal(t, state.MatchStatusRunning, m.Status, "no other match is changed") + assert.Empty(t, m.Decision) + } + } + + // And with no force key at all. + w = serveJSON(r, "POST", url, map[string]any{"decision": "kiken-voluntary", "decisionBy": "aka"}) + require.Equal(t, http.StatusOK, w.Code, w.Body.String()) +} diff --git a/internal/mobileapp/deps.go b/internal/mobileapp/deps.go index d9ba4cab4..673a1a6c3 100644 --- a/internal/mobileapp/deps.go +++ b/internal/mobileapp/deps.go @@ -125,30 +125,28 @@ type ScoringEngine interface { // RecordDecision auto-fills the scoreline + winner from the // decision/decisionBy/encho triple and persists the result. When the // prior result on the match already carried a kiken/fusenpai decision - // the engine enforces the downstream-match lock (T103/CHK024); - // force=true bypasses the lock so an operator can confirm an override. + // the write replaces it with no confirm and changes no other match. // Since bc-twin this is a thin WithTransaction shim over // RecordDecisionTx (the canonical body); NEVER call it from inside an // existing WithTransaction, the per-comp lock is not reentrant. - RecordDecision(compID, matchID, decision, decisionBy, decisionReason string, encho *state.EnchoMetadata, force bool, modifiedAt ...int64) (*state.MatchResult, *domain.CompetitorStatus, error) + RecordDecision(compID, matchID, decision, decisionBy, decisionReason string, encho *state.EnchoMetadata, modifiedAt ...int64) (*state.MatchResult, *domain.CompetitorStatus, error) // RecordDecisionTx is the canonical implementation RecordDecision // delegates to. Used directly by the decision handler under - // WithTransaction (T156) so the sides lookup, T103/T105 checks, match + // WithTransaction (T156) so the sides lookup, T105 check, match // write, and eligibility-restore-on-undo all commit under ONE per-comp // lock acquire. Same contract as RecordDecision; calls flow through the // supplied StoreTx. - RecordDecisionTx(tx state.StoreTx, compID, matchID, decision, decisionBy, decisionReason string, encho *state.EnchoMetadata, force bool, modifiedAt ...int64) (*state.MatchResult, *domain.CompetitorStatus, error) + RecordDecisionTx(tx state.StoreTx, compID, matchID, decision, decisionBy, decisionReason string, encho *state.EnchoMetadata, modifiedAt ...int64) (*state.MatchResult, *domain.CompetitorStatus, error) // RecordDecisionTxWithOptions is RecordDecisionTx's bc-kcdg-aware twin - // (bc-cse finding 5): force still governs ONLY the T103 downstream-match - // lock above, while kcdgOpts is the SEPARATE authorization for the + // (bc-cse finding 5): kcdgOpts is the authorization for the // bc-kcdg downstream-knockout-correction guard the underlying bracket // write applies (engine.DownstreamKnockoutPlayedError, HTTP 409 // downstream_knockout_played) -- the decision handler's - // forceDownstreamReopen field maps to kcdgOpts.Force, never to force. + // forceDownstreamReopen field maps to kcdgOpts.Force. // kcdgOpts.Reopened, when non-nil, is populated with the ids of every // bracket match the write forced open, mirroring // RecordMatchResultWithIneligibility(Tx) and OverrideBracketWinner. - RecordDecisionTxWithOptions(tx state.StoreTx, compID, matchID, decision, decisionBy, decisionReason string, encho *state.EnchoMetadata, force bool, kcdgOpts engine.ForceOptions, modifiedAt ...int64) (*state.MatchResult, *domain.CompetitorStatus, error) + RecordDecisionTxWithOptions(tx state.StoreTx, compID, matchID, decision, decisionBy, decisionReason string, encho *state.EnchoMetadata, kcdgOpts engine.ForceOptions, modifiedAt ...int64) (*state.MatchResult, *domain.CompetitorStatus, error) // MaybeAutoCompletePools transitions the competition's status to // "complete" when every pool match is done, or injects supplementary // ippon-shobu tiebreaker matches when ties are detected. It runs one diff --git a/internal/mobileapp/deps_test.go b/internal/mobileapp/deps_test.go index 964c22040..17427582f 100644 --- a/internal/mobileapp/deps_test.go +++ b/internal/mobileapp/deps_test.go @@ -70,15 +70,15 @@ func (stubScoringEngine) CheckCrossCompCourtBusy(string, string) error { return nil } -func (stubScoringEngine) RecordDecision(string, string, string, string, string, *state.EnchoMetadata, bool, ...int64) (*state.MatchResult, *domain.CompetitorStatus, error) { +func (stubScoringEngine) RecordDecision(string, string, string, string, string, *state.EnchoMetadata, ...int64) (*state.MatchResult, *domain.CompetitorStatus, error) { return nil, nil, nil } -func (stubScoringEngine) RecordDecisionTx(state.StoreTx, string, string, string, string, string, *state.EnchoMetadata, bool, ...int64) (*state.MatchResult, *domain.CompetitorStatus, error) { +func (stubScoringEngine) RecordDecisionTx(state.StoreTx, string, string, string, string, string, *state.EnchoMetadata, ...int64) (*state.MatchResult, *domain.CompetitorStatus, error) { return nil, nil, nil } -func (stubScoringEngine) RecordDecisionTxWithOptions(state.StoreTx, string, string, string, string, string, *state.EnchoMetadata, bool, engine.ForceOptions, ...int64) (*state.MatchResult, *domain.CompetitorStatus, error) { +func (stubScoringEngine) RecordDecisionTxWithOptions(state.StoreTx, string, string, string, string, string, *state.EnchoMetadata, engine.ForceOptions, ...int64) (*state.MatchResult, *domain.CompetitorStatus, error) { return nil, nil, nil } diff --git a/internal/mobileapp/downstream_running_handler_test.go b/internal/mobileapp/downstream_running_handler_test.go index 7a3f178af..b554bdef4 100644 --- a/internal/mobileapp/downstream_running_handler_test.go +++ b/internal/mobileapp/downstream_running_handler_test.go @@ -156,12 +156,11 @@ func TestRunningDownstream_BulkScoreReason(t *testing.T) { } } -// TestRunningDownstream_DecisionRefusesBeforeTheLock is the J4 repro on the +// TestRunningDownstream_DecisionRefuses is the J4 repro on the // wire: a wrong-side kiken on a semifinal, the final started, the kiken -// re-recorded on the other side. The answer is the running refusal, never -// decision_locked, so the editor shows the sentence and no "Proceed -// anyway?" confirm. -func TestRunningDownstream_DecisionRefusesBeforeTheLock(t *testing.T) { +// re-recorded on the other side. The answer is the running refusal, so the +// editor shows the sentence and no "Proceed anyway?" confirm. +func TestRunningDownstream_DecisionRefuses(t *testing.T) { r, store, _, _, tempDir := setupTestRouter(t) defer os.RemoveAll(tempDir) compID := "rfsw-decision-lock" @@ -197,7 +196,7 @@ func TestRunningDownstream_DecisionRefusesBeforeTheLock(t *testing.T) { require.Equal(t, http.StatusConflict, w.Code, w.Body.String()) var resp map[string]any require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp)) - assert.Equal(t, "downstream_knockout_running", resp["error"], "not decision_locked") + assert.Equal(t, "downstream_knockout_running", resp["error"]) assert.Equal(t, "Match 3 (Final) is being fought now on Shiaijo A. Finish it or send it back to the queue, then save this correction again.", resp["message"]) b, err = store.LoadBracket(compID) diff --git a/internal/mobileapp/handlers_decision.go b/internal/mobileapp/handlers_decision.go index 106a91fa3..08b62f705 100644 --- a/internal/mobileapp/handlers_decision.go +++ b/internal/mobileapp/handlers_decision.go @@ -25,17 +25,11 @@ import ( // Per contracts/match-decisions.md §POST /decision the operator // supplies only the decision-type metadata; the server auto-fills the // scoreline and Winner based on decisionBy + encho. -// -// Force bypasses the decision-lock check (T103/CHK024) that prevents -// overwriting a prior kiken/fusenpai when a subsequent match for -// either participant has already started. The admin UI sets it after -// the operator confirms the override. type DecisionRequest struct { Decision string `json:"decision"` DecisionBy string `json:"decisionBy"` DecisionReason string `json:"decisionReason,omitempty"` Encho *state.EnchoMetadata `json:"encho,omitempty"` - Force bool `json:"force,omitempty"` // ModifiedAt is the client's server-relative write stamp, the same one // /score carries (mp-y3nk). Sending it puts decision writes under the // timestamp last-write-wins guard instead of its unstamped bypass, and @@ -51,10 +45,9 @@ type DecisionRequest struct { // competition's POOL match that moves a qualifier the knockout already // played. Same field name and contract as every // other knockout-correction write (see scoreRequestBody.ForceDownstreamReopen - // in handlers_match.go); a SEPARATE field from Force above, which answers - // a different question (T103's decision-lock override): this maps to + // in handlers_match.go); this maps to // engine.ForceOptions.Force on the RecordDecisionTxWithOptions call - // below, never to the force parameter Force itself feeds. + // below. ForceDownstreamReopen bool `json:"forceDownstreamReopen"` } @@ -167,8 +160,8 @@ func RegisterDecisionHandlers(r *gin.RouterGroup, eng ScoringEngine, store Compe return } // T156: run the entire RecordDecision flow inside one - // WithTransaction. The engine call chain, sides lookup, T103 - // downstream-match check, T105 concurrent-kiken pre-check, + // WithTransaction. The engine call chain, sides lookup, + // T105 concurrent-kiken pre-check, // pool/bracket match-write, ineligibility check-and-set, prior- // loser eligibility restore on undo, all use the same StoreTx // handle, so the per-comp lock is acquired exactly once for the @@ -205,13 +198,11 @@ func RegisterDecisionHandlers(r *gin.RouterGroup, eng ScoringEngine, store Compe // here left the two audit fields on one record disagreeing byte-for- // byte on padding. Mirrors the score path's up-front TrimSpace of // CorrectionReason (mp-gmcg review). - // bc-kcdg/bc-cse finding 5: RecordDecisionTxWithOptions keeps - // req.Force (T103 decision-lock override) and + // bc-kcdg/bc-cse finding 5: RecordDecisionTxWithOptions carries // req.ForceDownstreamReopen (the bc-kcdg downstream-knockout- - // correction guard) as two independent confirmations -- setting - // one does not silently grant the other -- and surfaces the - // reopened downstream match ids so they can be broadcast below. - result, status, engErr = eng.RecordDecisionTxWithOptions(stx, id, mid, req.Decision, req.DecisionBy, reason, req.Encho, req.Force, + // correction guard) and surfaces the reopened downstream match + // ids so they can be broadcast below. + result, status, engErr = eng.RecordDecisionTxWithOptions(stx, id, mid, req.Decision, req.DecisionBy, reason, req.Encho, engine.ForceOptions{Force: req.ForceDownstreamReopen, Reopened: &reopenedDownstream}, req.ModifiedAt) if result != nil && result.ResultSource == "" { result.ResultSource = "admin" @@ -284,8 +275,7 @@ func RegisterDecisionHandlers(r *gin.RouterGroup, eng ScoringEngine, store Compe // operator-actionable conflicts that must never look like a server fault. func respondDecisionEngineError(c *gin.Context, store CompetitionStore, compID, matchID string, engErr error) { // Map engine.ValidationError → 400, NotFoundError → 404, - // IneligibleCompetitorError → 409 (FR-035), - // ErrDecisionLocked → 409 (T103/CHK024). + // IneligibleCompetitorError → 409 (FR-035). var alreadyIneligErr *engine.AlreadyIneligibleError var ineligErr *engine.IneligibleCompetitorError var engNotFoundErr *engine.NotFoundError @@ -345,17 +335,11 @@ func respondDecisionEngineError(c *gin.Context, store CompetitionStore, compID, "reason": ineligErr.Reason, "reasonHuman": reasonHuman, }) - case errors.Is(engErr, engine.ErrDecisionLocked): - c.JSON(http.StatusConflict, gin.H{ - "error": "decision_locked", - "reason": engErr.Error(), - }) case respondIfDownstreamKnockoutRunning(c, engErr): // A decision on a mixed competition's POOL match that would // move a qualifier out of a knockout match being fought now, or // on a knockout match whose new winner would change a side of a - // later match being fought now (bc-rfsw, raised before the T103 - // decision_locked check): terminal, not confirmable (see + // later match being fought now (bc-rfsw): terminal, not confirmable (see // respondIfDownstreamKnockoutRunning's doc comment). case respondIfDownstreamKnockoutPlayed(c, engErr): // bc-kcdg: this decision would change an already-propagated diff --git a/internal/mobileapp/ineligible_sides_annotation_test.go b/internal/mobileapp/ineligible_sides_annotation_test.go index f4d7bb751..40237e503 100644 --- a/internal/mobileapp/ineligible_sides_annotation_test.go +++ b/internal/mobileapp/ineligible_sides_annotation_test.go @@ -216,7 +216,7 @@ func TestViewerCompetitionDetail_IneligibleSides_RemovedAfterReinstate(t *testin // The kiken-injury status itself (RecordDecision's normal write path, // exercised directly here for a smaller fixture than a full /decision // HTTP round trip). - _, _, err = eng.RecordDecision(compID, "Pool A-0", "kiken-injury", "aka", "test", nil, false) + _, _, err = eng.RecordDecision(compID, "Pool A-0", "kiken-injury", "aka", "test", nil) require.NoError(t, err) gin.SetMode(gin.TestMode) @@ -293,9 +293,9 @@ func TestViewerCompetitionDetail_WithdrawnStatus_AfterEveryMatchIsFinished(t *te })) // Alice withdraws in Pool A-0, then a no-show is recorded against her on // Pool A-1, which chains onto that bar (bc-kfup). Every match is now over. - _, _, err = eng.RecordDecision(compID, "Pool A-0", "kiken-voluntary", "aka", "test", nil, false) + _, _, err = eng.RecordDecision(compID, "Pool A-0", "kiken-voluntary", "aka", "test", nil) require.NoError(t, err) - _, _, err = eng.RecordDecision(compID, "Pool A-1", "fusenpai", "aka", "", nil, false) + _, _, err = eng.RecordDecision(compID, "Pool A-1", "fusenpai", "aka", "", nil) require.NoError(t, err) gin.SetMode(gin.TestMode) diff --git a/internal/mobileapp/reopen_downstream_labels_test.go b/internal/mobileapp/reopen_downstream_labels_test.go index bcba37489..c2c219957 100644 --- a/internal/mobileapp/reopen_downstream_labels_test.go +++ b/internal/mobileapp/reopen_downstream_labels_test.go @@ -49,7 +49,7 @@ func TestReopenHandler_DownstreamRunning_RespondsWithLabel(t *testing.T) { Status: state.MatchStatusRunning, DisplayRound: 2, MatchNumber: 1}}, {{ID: "m-r2-0", SideB: "Carol", SideBID: carolID, DisplayRound: 1, MatchNumber: 2, Court: "A"}}, }})) - _, _, err = eng.RecordDecision(compID, "m-r1-0", "fusenpai", "shiro", "", nil, false) + _, _, err = eng.RecordDecision(compID, "m-r1-0", "fusenpai", "shiro", "", nil) require.NoError(t, err) require.NoError(t, store.UpdateBracket(compID, func(b *state.Bracket) error { b.Rounds[1][0].Status = state.MatchStatusRunning diff --git a/specs/openapi.yaml b/specs/openapi.yaml index 80c771c24..b1adc581d 100644 --- a/specs/openapi.yaml +++ b/specs/openapi.yaml @@ -3660,8 +3660,7 @@ paths: places a decision on a mixed competition's pool match moves), or one that would move a qualifier out of, or change a side of, a knockout match being fought now (`downstream_knockout_running`, - not confirmable, and answered before `decision_locked` so no - confirm precedes it). Same bodies as PUT .../score. + not confirmable, and answered before any confirm). Same bodies as PUT .../score. content: application/json: schema: diff --git a/web-mobile/js/__tests__/admin_scoring_modal.test.jsx b/web-mobile/js/__tests__/admin_scoring_modal.test.jsx index 7391f71a3..0c4e2d0f5 100644 --- a/web-mobile/js/__tests__/admin_scoring_modal.test.jsx +++ b/web-mobile/js/__tests__/admin_scoring_modal.test.jsx @@ -114,37 +114,9 @@ describe('buildDecisionBody', () => { expect(body.decision).toBe('daihyosen'); }); - describe('force flag (T103 override loop)', () => { - // T103 (decision_locked) uses a confirm-and-retry-with-force flow. - // The helper accepts `opts.force` so the parent's retry path can - // call back into it without re-implementing the body shape. - - it('attaches force=true when opts.force is set', () => { - const body = buildDecisionBody('kiken-voluntary', { decisionBy: 'shiro' }, 0, { force: true }); - expect(body.force).toBe(true); - }); - - it('omits force when opts.force is missing or false', () => { - expect(buildDecisionBody('kiken-voluntary', { decisionBy: 'shiro' }, 0)).not.toHaveProperty('force'); - expect(buildDecisionBody('kiken-voluntary', { decisionBy: 'shiro' }, 0, {})).not.toHaveProperty('force'); - expect(buildDecisionBody('kiken-voluntary', { decisionBy: 'shiro' }, 0, { force: false })).not.toHaveProperty('force'); - }); - - it('force combines with reason + encho cleanly', () => { - const body = buildDecisionBody( - 'kiken-voluntary', - { decisionBy: 'aka', decisionReason: 'no-show' }, - 3, - { force: true }, - ); - expect(body).toEqual({ - decision: 'kiken-voluntary', - decisionBy: 'aka', - decisionReason: 'no-show', - encho: { periodCount: 3 }, - force: true, - }); - }); + it('never emits a force flag (the decision lock was removed)', () => { + expect(buildDecisionBody('kiken-voluntary', { decisionBy: 'shiro' }, 0)).not.toHaveProperty('force'); + expect(buildDecisionBody('kiken-voluntary', { decisionBy: 'shiro' }, 0, { force: true })).not.toHaveProperty('force'); }); }); @@ -443,33 +415,28 @@ describe('DecisionPrompt → /decision POST integration', () => { ); }); - it('parent flow attaches force=true on the retry-after-409 path', async () => { - // T103: when the server replies decision_locked the parent's - // submitDecision recurses with { force: true } after the operator - // confirms. The body must carry that flag through to the server so - // the second attempt isn't also rejected. - const onSubmit = vi.fn((payload) => { - const body = buildDecisionBody('kiken-voluntary', payload, 0, { force: true }); - const password = resolveDecisionPassword('pw'); - return window.API.recordDecision('comp-1', 'match-1', body, password); - }); - const tree = DecisionPrompt({ - kind: 'kiken', - sideA: { name: 'A' }, - sideB: { name: 'B' }, - defaultSide: 'shiro', - askReason: false, - onCancel: vi.fn(), - onSubmit, - submitting: false, + it("an unknown 409 on a decision shows the server's message and never retries with force", async () => { + window.API = { + recordDecision: vi.fn().mockRejectedValue(new Error('some_other_conflict')), + }; + window.confirmDialog = vi.fn(); + const setDecisionErr = vi.fn(); + const submit = makeSubmitDecision({ + match: { compId: 'c1', id: 'm1', sideA: { id: 'pa', name: 'A' }, sideB: { id: 'pb', name: 'B' } }, + enchoPeriodCount: 0, + password: 'pw', + mountedRef: { current: true }, + setDecisionSubmitting: vi.fn(), + setDecisionErr, + setDecisionPromptKind: vi.fn(), + onClose: vi.fn(), + isComplete: true, }); - await tree.props.onSubmit({ preventDefault: () => {} }); - expect(window.API.recordDecision).toHaveBeenCalledWith( - 'comp-1', - 'match-1', - { decision: 'kiken-voluntary', decisionBy: 'shiro', force: true }, - 'pw', - ); + await submit('kiken-voluntary', { decisionBy: 'shiro', decisionReason: '' }); + expect(window.confirmDialog).not.toHaveBeenCalled(); + expect(window.API.recordDecision).toHaveBeenCalledTimes(1); + expect(window.API.recordDecision.mock.calls[0][2]).not.toHaveProperty('force'); + expect(setDecisionErr).toHaveBeenLastCalledWith('some_other_conflict'); }); it('fusenpai: decisionBy is the ABSENT/LOSING side, not the winning side', () => { @@ -1372,7 +1339,6 @@ describe('item 7: non-points decisions advance to next match', () => { const submit = makeSubmitDecision({ match: makeMatch('m1'), enchoPeriodCount: 0, password: 'pw', ...makeSetters(), onClose, onAfterDecision, isComplete: false, - entityLabel: 'competitors', }); await submit('fusenpai', { decisionBy: 'aka', decisionReason: '' }); expect(onAfterDecision).toHaveBeenCalled(); @@ -1388,7 +1354,6 @@ describe('item 7: non-points decisions advance to next match', () => { const submit = makeSubmitDecision({ match: makeMatch('m1b'), enchoPeriodCount: 0, password: 'pw', ...makeSetters(), onClose: vi.fn(), onAfterDecision, isComplete: false, - entityLabel: 'competitors', }); await submit('fusenpai', { decisionBy: 'aka', decisionReason: '' }); expect(onAfterDecision).toHaveBeenCalledWith( @@ -1400,7 +1365,7 @@ describe('item 7: non-points decisions advance to next match', () => { const onClose = vi.fn(); const submit = makeSubmitDecision({ match: makeMatch('m2'), enchoPeriodCount: 0, password: 'pw', - ...makeSetters(), onClose, isComplete: false, entityLabel: 'competitors', + ...makeSetters(), onClose, isComplete: false, }); await submit('fusenpai', { decisionBy: 'aka', decisionReason: '' }); expect(onClose).toHaveBeenCalled(); @@ -1412,7 +1377,6 @@ describe('item 7: non-points decisions advance to next match', () => { const submit = makeSubmitDecision({ match: makeMatch('m3'), enchoPeriodCount: 0, password: 'pw', ...makeSetters(), onClose, onAfterDecision, isComplete: true, - entityLabel: 'competitors', }); await submit('fusenpai', { decisionBy: 'aka', decisionReason: '' }); // Correction: must close rather than advance @@ -1430,7 +1394,6 @@ describe('item 7: non-points decisions advance to next match', () => { const submit = makeSubmitDecision({ match: makeMatch('m4'), enchoPeriodCount: 0, password: 'pw', ...makeSetters(), onClose, onAfterDecision, isComplete: false, - entityLabel: 'competitors', }); await submit('kiken-voluntary', { decisionBy: 'aka', decisionReason: '' }); expect(onAfterDecision).toHaveBeenCalled(); @@ -1443,7 +1406,6 @@ describe('item 7: non-points decisions advance to next match', () => { const submit = makeSubmitDecision({ match: makeMatch('m4b'), enchoPeriodCount: 0, password: 'pw', ...makeSetters(), onClose, onAfterDecision, isComplete: true, - entityLabel: 'competitors', }); await submit('kiken-voluntary', { decisionBy: 'aka', decisionReason: '' }); expect(onClose).toHaveBeenCalled(); @@ -1536,7 +1498,6 @@ describe('submitDecisionRequest / makeSubmitDecision: downstream_knockout_played setDecisionPromptKind: vi.fn(), onClose: vi.fn(), isComplete: true, // correcting an already-completed match - entityLabel: 'competitors', }); await submit('kiken-voluntary', { decisionBy: 'aka', decisionReason: '' }); @@ -1546,7 +1507,7 @@ describe('submitDecisionRequest / makeSubmitDecision: downstream_knockout_played expect(window.API.recordDecision).toHaveBeenCalledTimes(1); expect(setDecisionErr).toHaveBeenCalledWith(DOWNSTREAM_KNOCKOUT_PLAYED_CANCELLED); // Must not fall through to the generic error text (the raw refusal - // message, or the decision_locked confirm copy). + // message, or a confirm copy). expect(setDecisionErr).not.toHaveBeenCalledWith( expect.stringContaining('already played match'), ); @@ -1574,7 +1535,6 @@ describe('submitDecisionRequest / makeSubmitDecision: downstream_knockout_played setDecisionPromptKind: vi.fn(), onClose, isComplete: true, - entityLabel: 'competitors', }); // fusenpai here, but kiken takes the identical isComplete gate now diff --git a/web-mobile/js/__tests__/api_client_downstream_knockout.test.jsx b/web-mobile/js/__tests__/api_client_downstream_knockout.test.jsx index 688dec3f7..f87efbc15 100644 --- a/web-mobile/js/__tests__/api_client_downstream_knockout.test.jsx +++ b/web-mobile/js/__tests__/api_client_downstream_knockout.test.jsx @@ -240,19 +240,19 @@ describe('API.recordDecision: downstream_knockout_played (bc-cse)', () => { }); }); - it('does not decorate an unrelated 409 (e.g. decision_locked)', async () => { + it('does not decorate an unrelated 409 (e.g. already_ineligible)', async () => { originalFetch = global.fetch; global.fetch = vi.fn().mockResolvedValue({ ok: false, status: 409, - json: async () => ({ error: 'decision_locked' }), + json: async () => ({ error: 'already_ineligible' }), }); const err = await API.recordDecision('c1', 'm1', { decision: 'kiken-voluntary', decisionBy: 'aka' }, 'pw').then( () => { throw new Error('expected a rejection'); }, (e) => e, ); - expect(err.message).toBe('decision_locked'); + expect(err.message).toBe('already_ineligible'); expect(downstreamKnockoutPlayedRefusal(err)).toBeNull(); }); diff --git a/web-mobile/js/__tests__/render/admin_scoring_modal.render.test.jsx b/web-mobile/js/__tests__/render/admin_scoring_modal.render.test.jsx index 46aabda9b..bb1ba7431 100644 --- a/web-mobile/js/__tests__/render/admin_scoring_modal.render.test.jsx +++ b/web-mobile/js/__tests__/render/admin_scoring_modal.render.test.jsx @@ -150,8 +150,7 @@ describe('ScoreEditorModal side labelling', () => { // bc-rfsw: a withdrawal re-recorded on a knockout match whose later match is // being fought now is refused (409 downstream_knockout_running, operator // decision 2026-09-27). The editor shows the operator's sentence on the -// decision error line, asks nothing first (it is not decision_locked, so no -// "Proceed anyway?"), and does not move the court on. +// decision error line, asks nothing first (no "Proceed anyway?"), and does not move the court on. describe('ScoreEditorModal: a decision refused because a later match is being fought', () => { for (const [label, make] of [ ['individual', () => makeIndividualMatch({ id: 'm-r1-0', compId: 'c1', status: 'running', phase: 'knockout', ipponsA: [], ipponsB: [] })], diff --git a/web-mobile/js/__tests__/sync_queue.test.jsx b/web-mobile/js/__tests__/sync_queue.test.jsx index 59a1dd015..99ef41efa 100644 --- a/web-mobile/js/__tests__/sync_queue.test.jsx +++ b/web-mobile/js/__tests__/sync_queue.test.jsx @@ -1351,7 +1351,7 @@ describe('_flushQueue: downstream_knockout_played 409 on a queued correction (bc it('drops a queued DECISION correction (kiken/fusenpai/daihyosen) and reports the human reason, not the raw token', async () => { // Pre-fix this landed in the decision-specific 409 branch // (terminal && kind === 'decision' && res.status === 409), which reads - // any body.error other than decision_locked/already_ineligible as an + // any body.error other than already_ineligible as an // "unexpected" 409 and reported body.reasonHuman || body.error -- // i.e. the literal string "downstream_knockout_played" -- with no // advice at all. @@ -1397,38 +1397,6 @@ describe('_flushQueue: downstream_knockout_played 409 on a queued correction (bc expect(rejected[0].detail).not.toBe('downstream_knockout_played'); }); - it('reports decision_locked on a queued replay instead of taking it as success (bc-mrgc phase 3)', async () => { - // A queued decision the server refuses on replay is the operator's - // result not landing, whatever the 409's code. It used to be swallowed - // on the theory that an earlier send had landed; the server now answers - // that exact replay (same decision, side and stamp) as recorded, so a - // decision_locked here is a real refusal and must be reported. - const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); - mockFetch(() => Promise.reject(new TypeError('offline'))); - await API.recordDecision('c1', 'mlocked', { decision: 'fusenpai', decisionBy: 'shiro' }, 'pw'); - - const failures = []; - const unsubFail = mod.subscribeTerminalWriteFailed((info) => failures.push(info)); - const alerts = []; - const unsubAlert = mod.subscribeQueueAlert((a) => alerts.push(a)); - - mockFetch(() => Promise.resolve({ - ok: false, - status: 409, - json: () => Promise.resolve({ error: 'decision_locked' }), - })); - window.dispatchEvent(new Event('online')); - await tick(50); - unsubFail(); - unsubAlert(); - warnSpy.mockRestore(); - - expect(API.hasPendingTerminalWrite('c1', 'mlocked')).toBe(false); - expect(failures.length).toBe(1); - expect(failures[0]).toMatchObject({ compID: 'c1', matchID: 'mlocked', kind: 'decision', status: 409 }); - expect(alerts.filter((a) => a.kind === 'rejected')).toHaveLength(1); - }); - it('reports already_ineligible on a queued replay in the server\'s own words', async () => { const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); mockFetch(() => Promise.reject(new TypeError('offline'))); diff --git a/web-mobile/js/admin_scoring_individual.jsx b/web-mobile/js/admin_scoring_individual.jsx index 0d1ddf241..9c910a443 100644 --- a/web-mobile/js/admin_scoring_individual.jsx +++ b/web-mobile/js/admin_scoring_individual.jsx @@ -295,12 +295,10 @@ export function ScoreEditorModal({ match, onClose, onSubmit, onSubmitAndNext, on // in which case we advance to the next match instead (item 7). Kiken // follows this exact rule too now (operator ruling 2026-09-26): // recording a withdrawal changes only the match it was recorded on. - // - T103/CHK024: when the server replies 409 decision_locked (the - // prior kiken on this match can't be safely overwritten because a - // subsequent match for either side has started), prompt the - // operator to confirm and re-send with force=true. + // - Recording the withdrawal on the other side of a recorded one applies + // with no confirm: it changes only this match. // Shared factory (admin_scoring_shared.jsx): the individual + team modals had - // byte-identical copies; "competitors" is the only per-modal wording. + // byte-identical copies. // Item 7: a decision (fusenpai, kiken, or any future non-points decision) // routes through onAfterDecision when the host page provides it (and this // isn't a correction) so the court advances to the next match: mirroring @@ -309,7 +307,7 @@ export function ScoreEditorModal({ match, onClose, onSubmit, onSubmitAndNext, on const submitDecision = makeSubmitDecision({ match: m, enchoPeriodCount, password, mountedRef, setDecisionSubmitting, setDecisionErr, setDecisionPromptKind, - onClose, onAfterDecision, isComplete, entityLabel: "competitors", + onClose, onAfterDecision, isComplete, // F5: thread pending-write handles so the factory can show the sticky banner // when the decision write is only queued (offline / transient failure). setPendingWrite, pendingFnRef, diff --git a/web-mobile/js/admin_scoring_shared.jsx b/web-mobile/js/admin_scoring_shared.jsx index 9e7e9402c..d11a53681 100644 --- a/web-mobile/js/admin_scoring_shared.jsx +++ b/web-mobile/js/admin_scoring_shared.jsx @@ -465,14 +465,12 @@ function resolveDecisionPassword(propPassword) { } // T093/T094: build the /decision POST body. Pure helper so we can pin the -// wire shape (decision/decisionBy/decisionReason/encho/force) against a -// moving server contract. `force` is the T103 override flag used when the -// server replies decision_locked and the operator confirms the override. +// wire shape (decision/decisionBy/decisionReason/encho) against a +// moving server contract. function buildDecisionBody(kind, { decisionBy, decisionReason }, enchoPeriodCount, opts = {}) { const body = { decision: kind, decisionBy }; if (decisionReason) body.decisionReason = decisionReason; if (enchoPeriodCount > 0) body.encho = { periodCount: enchoPeriodCount }; - if (opts.force) body.force = true; // The match the decision was recorded on (bc-hlck): recordDecision floors // the stamp by it and never sends it. if (opts.seenModifiedAt > 0) body.seenModifiedAt = opts.seenModifiedAt; @@ -484,8 +482,7 @@ function buildDecisionBody(kind, { decisionBy, decisionReason }, enchoPeriodCoun // resolves the password from the explicit prop. Extracted so the regression // test pins the production call site (rather than re-implementing the chain // inside the test, which was how the original gap slipped through). Returns -// the promise from window.API.recordDecision so callers can await + handle -// the 409 decision_locked retry-with-force loop. +// the promise from window.API.recordDecision so callers can await it. // // bc-cse: routed through attemptScoreWrite (write_result.jsx), the SAME // confirm-and-retry loop admin.jsx's editMatchScore gives the score path, so @@ -511,15 +508,12 @@ function submitDecisionRequest(compId, matchId, kind, decisionPayload, enchoPeri // makeSubmitDecision builds the kiken/fusenpai decision submit handler shared by // ScoreEditorModal and TeamScoreEditorModal. The two modals had byte-identical -// copies of this (the only difference was the "competitors"/"teams" wording in -// the decision_locked confirm), so it lives here once. The returned closure: +// copies of this, so it lives here once. The returned closure: // - POSTs the decision, then for every decision alike (kiken included: // operator ruling 2026-09-26, recording a withdrawal changes only the // match it is recorded on) calls onAfterDecision when provided and the // match is not a correction (item 7: starts next match), else falls back -// to onClose; -// - on 409 decision_locked, confirms then retries with force (recursing -// into itself). +// to onClose. // Call it fresh each render so it captures the current enchoPeriodCount/password. function makeSubmitDecision({ match, @@ -540,7 +534,6 @@ function makeSubmitDecision({ // from its own existing notice, when it comes up. onAfterDecision, isComplete, // item 7: corrections (isComplete=true) must not auto-advance - entityLabel = 'competitors', // F5: optional pending-write handles threaded in from ScoreEditorModal so // a queued (offline) decision write shows the sticky "Not sent yet" banner // (handed the queued answer, which queuedNotice words). @@ -614,22 +607,9 @@ function makeSubmitDecision({ if (mountedRef.current) setDecisionErr(DOWNSTREAM_KNOCKOUT_PLAYED_CANCELLED); return; } - // T103: server returns "decision_locked" when a kiken-undo would - // invalidate a downstream match: confirm and retry with force. - if (!opts.force && /decision_locked/i.test(msg)) { - const ok = mountedRef.current && await window.confirmDialog({ - message: - `A subsequent match for one of these ${entityLabel} has already started.\n\n` + - 'Overwriting the prior decision now may make those downstream results inconsistent. Proceed anyway?', - confirmLabel: 'Proceed anyway', - danger: true, - }); - if (!mountedRef.current) return; - if (ok) { await submit(kind, { decisionBy, decisionReason }, { force: true }); return; } - setDecisionErr('Override cancelled.'); - } else if (mountedRef.current) { - setDecisionErr(msg); - } + // Any other refusal is shown as the server worded it; nothing is + // retried with a stronger flag. + if (mountedRef.current) setDecisionErr(msg); } finally { if (mountedRef.current) setDecisionSubmitting(false); } diff --git a/web-mobile/js/admin_scoring_team.jsx b/web-mobile/js/admin_scoring_team.jsx index b4a847700..897e6eeb2 100644 --- a/web-mobile/js/admin_scoring_team.jsx +++ b/web-mobile/js/admin_scoring_team.jsx @@ -1944,8 +1944,7 @@ export function TeamScoreEditorModal({ match, teamSize, onClose, onSubmit, onSub } }; - // Shared factory (admin_scoring_shared.jsx): same handler as ScoreEditorModal; - // "teams" is the only per-modal wording (in the decision_locked confirm). + // Shared factory (admin_scoring_shared.jsx): same handler as ScoreEditorModal. // Item 7: fusenpai routes through onAfterDecision (host-supplied) to advance // the court, same as ScoreEditorModal. Kiken follows the same rule now too // (operator ruling 2026-09-26): recording a withdrawal changes only the @@ -1953,7 +1952,7 @@ export function TeamScoreEditorModal({ match, teamSize, onClose, onSubmit, onSub const submitDecision = makeSubmitDecision({ match: m, enchoPeriodCount: encounterEnchoCount, password, mountedRef, setDecisionSubmitting, setDecisionErr, setDecisionPromptKind, - onClose, onAfterDecision, isComplete, entityLabel: "teams", + onClose, onAfterDecision, isComplete, }); const existingSub = m.subResults || []; diff --git a/web-mobile/js/api_client.jsx b/web-mobile/js/api_client.jsx index d69954882..44042b46e 100644 --- a/web-mobile/js/api_client.jsx +++ b/web-mobile/js/api_client.jsx @@ -1807,7 +1807,7 @@ async function _flushQueue() { } else if (terminal && kind === 'decision' && res.status === 409) { // A queued decision the server refused on replay. Every 409 is // REPORTED, never taken as success (bc-mrgc phase 3, "Nothing - // should be dropped"). decision_locked and already_ineligible + // should be dropped"). A 409 such as already_ineligible // used to be swallowed on the theory that an earlier send whose // answer was lost had landed; the server now answers that exact // replay (same decision, side and stamp) as recorded @@ -1819,7 +1819,7 @@ async function _flushQueue() { // forceDownstreamReopen automatically. // // The direct recordDecision call path always throws on 409 so the - // score editor's force-retry prompt still fires. + // score editor shows the refusal. console.warn(`[sync] queued decision write rejected (409):`, body); const refusal = _replayRefusal(body, 'conflict (409)'); _notifyTerminalWriteFailed({ compID, matchID, kind, status: 409, ...refusal }); @@ -3548,9 +3548,9 @@ const API = { // // F1: bounded (12 s abort on stalled wifi), the body read included. // F5: on network failure / abort / 5xx / 429 the decision is enqueued as a - // terminal write for durable re-delivery. 4xx (including 409 decision_locked + // terminal write for durable re-delivery. 4xx (including a 409 // on the DIRECT call) always throw: the score editor relies on a thrown 409 - // to show its force-retry prompt. A queued replay's 409 is reported too + // to show the refusal. A queued replay's 409 is reported too // (_flushQueue), never taken as success (bc-mrgc phase 3). async recordDecision(compID, matchID, body, password) { const decisionUrl = `/api/competitions/${compID}/matches/${matchID}/decision`; @@ -3598,7 +3598,7 @@ const API = { ); return _queuedAnswer(); } - // 4xx (including 409 decision_locked): throw immediately so the UI + // 4xx (including a 409): throw immediately so the UI // can surface the error (a queued replay's refusal is reported by // _flushQueue the same way). // bc-rawm: the eligibility gate (mp-dc52 Phase 3) refuses a diff --git a/web-mobile/js/write_result.jsx b/web-mobile/js/write_result.jsx index 746a476fd..f631e1195 100644 --- a/web-mobile/js/write_result.jsx +++ b/web-mobile/js/write_result.jsx @@ -639,7 +639,7 @@ function matchLabelList(ms) { // api_client.jsx is the parser, exactly as it is for the 200 shapes: it // attaches the four fields to the thrown Error as `.downstreamKnockoutPlayed` // so a catcher never re-derives the shape from a raw response body or a -// message-string regex (the T103 decision_locked precedent this mirrors). +// message-string regex (the precedent the earlier 409 parsers set). // Ask downstreamKnockoutPlayedRefusal(err) rather than testing // `err.downstreamKnockoutPlayed` by hand -- the same reason every other // predicate in this file exists. From db2cb5b91e5d3ed6b7df5c9c84cc6185d8abd87b Mon Sep 17 00:00:00 2001 From: Ricardo Oliveira Date: Wed, 7 Oct 2026 19:03:53 +0100 Subject: [PATCH 02/16] fix(bc-otpl, bc-dsid): plain-text controls and a withdrawal prompt with no default side bc-otpl: tapping the word "Overtime" on the overtime pill opened the glossary instead of the encho counter, because a glossary term (itself a control that stops the tap) sat inside the button. The pill and the "Encho started" label now hold plain text with the glossary chip beside them, and the rule (a term is never inside a button or label) is stated once above TermAS. bc-dsid: the withdrawal and no-show prompt preselected Shiro and recorded on a bare Record, so a hasty tap withdrew the wrong competitor. No side is preselected now; Record stays off until a side is picked and then names it ("Record: AKA withdrew"), and a line says what recording does: " cannot fight again in this competition. wins this match." (operator decision 2026-10-07). The side options are 44px pills under a coarse pointer. Co-Authored-By: Claude Opus 5.5 --- .../court-operators/recording-decisions.md | 4 + scripts/screenshots/recipes/editors.mjs | 6 +- scripts/screenshots/recipes/public.mjs | 8 +- web-mobile/css/styles.css | 32 ++++ .../js/__tests__/admin_scoring_modal.test.jsx | 136 +--------------- .../decision_prompt_side.render.test.jsx | 152 ++++++++++++++++++ .../render/kachinuki_reopen.render.test.jsx | 16 +- .../term_not_inside_control.render.test.jsx | 81 ++++++++++ web-mobile/js/admin_scoring_individual.jsx | 1 - web-mobile/js/admin_scoring_shared.jsx | 71 ++++++-- web-mobile/js/admin_scoring_team.jsx | 8 +- 11 files changed, 346 insertions(+), 169 deletions(-) create mode 100644 web-mobile/js/__tests__/render/decision_prompt_side.render.test.jsx create mode 100644 web-mobile/js/__tests__/render/term_not_inside_control.render.test.jsx diff --git a/docs/user-guide/court-operators/recording-decisions.md b/docs/user-guide/court-operators/recording-decisions.md index a7b7950e0..868b7c321 100644 --- a/docs/user-guide/court-operators/recording-decisions.md +++ b/docs/user-guide/court-operators/recording-decisions.md @@ -8,6 +8,8 @@ Not every bout is decided on points. The score editor records the kendo outcomes Kiken covers two distinct situations with different consequences. +Pick the side that withdrew: no side is chosen for you, and **Record** stays unavailable until you pick one. Once you do, the button names it (for example **Record: AKA withdrew**) and a line beneath says who cannot fight again and who wins the match. + ### Voluntary withdrawal (FIK Article 31) A voluntary kiken is permanent. The competitor takes no further matches in this competition and cannot be reinstated. @@ -20,6 +22,8 @@ An injury kiken can be reinstated later by the operator if the competitor recove **Fusenpai** is the no-show decision: the competitor who did not appear forfeits the match. +Pick the side that did not show up: no side is chosen for you, and **Record** stays unavailable until you pick one. Once you do, the button names it (for example **Record: SHIRO did not show up**). + **Fusensho** is used in team matches when the opposing team fields a vacant position, awarding that bout to the other side. It is also the decision the app records for the match itself when you record the win for the opponent of an already-barred competitor; refer to [Matches after a competitor is barred](#matches-after-a-competitor-is-barred). When neither team has a fighter for a position, record that bout as a **Tie** instead. A kiken, fusenpai, or fusensho awards the winner the match points without a technique, and each awarded point is recorded as a circle. This follows the FIK Regulations of Kendo Shiai and Shinpan (Article 32 and the score board appendix). The winner's score shows two circles, or a single circle during overtime, when only the one deciding point is awarded. Any point the withdrawing side had already scored stays valid and is kept on the sheet. In a team match, the sub-bouts already fought are preserved and still count toward the standings. diff --git a/scripts/screenshots/recipes/editors.mjs b/scripts/screenshots/recipes/editors.mjs index 0e6c97b77..c46ee2ce0 100644 --- a/scripts/screenshots/recipes/editors.mjs +++ b/scripts/screenshots/recipes/editors.mjs @@ -284,10 +284,8 @@ async function ensureOneIpponEachSide(page) { // Open the overtime counter and wind it to `periods`. Collapsed to a pill // until asked for (EnchoControl, admin_scoring_shared.jsx:573-622). async function setEncho(page, periods) { - // Click the pill's ICON, never its label: the label is a glossary - // (TermAS name="encho", admin_scoring_shared.jsx:585) which takes the tap to - // open its tooltip and never lets the pill's own handler run - so a click on - // the button's centre only flashes the gloss. + // The pill's text is plain (bc-otpl): a tap anywhere on it opens the counter. + // The icon is just the stable handle. const pill = page.locator('.encho-pill__icon'); if (await pill.count()) { await pill.first().click(); diff --git a/scripts/screenshots/recipes/public.mjs b/scripts/screenshots/recipes/public.mjs index f40314688..35a5a95bd 100644 --- a/scripts/screenshots/recipes/public.mjs +++ b/scripts/screenshots/recipes/public.mjs @@ -51,12 +51,8 @@ async function armEncho(page) { // EnchoControl (admin_scoring_shared.jsx): collapsed pill -> expand -> // check the "Encho started" box, which arms periodCount=1. // - // Click the icon span, NOT [data-testid="scoring-modal-encho-pill"] itself - // (confirmed by hand): the pill's own text is wrapped in a nested TermAS - // glossary-term trigger, so a plain .click() on the pill lands on that - // inner element's center and opens the "Overtime" glossary tooltip instead - // of the setShowCounter(true) the outer button owns. The icon span is - // outside the glossary term and reaches the outer button reliably. + // The pill's text is plain (bc-otpl), so a click anywhere on it opens the + // counter; the icon span is simply the stable handle. await page.locator('.encho-pill__icon').click(); await page.locator('[data-testid="scoring-modal-encho-checkbox"]').click(); } diff --git a/web-mobile/css/styles.css b/web-mobile/css/styles.css index 5e5669e57..bacac3468 100644 --- a/web-mobile/css/styles.css +++ b/web-mobile/css/styles.css @@ -8079,6 +8079,7 @@ abbr.msb-lab { text-decoration: none; cursor: help; } .encho-row--collapsed { display: flex; align-items: center; + gap: 4px; margin-bottom: 8px; } .encho-pill { @@ -8603,6 +8604,37 @@ abbr.msb-lab { text-decoration: none; cursor: help; } box-shadow: var(--focus-ring); } +/* --- Decision prompt side picker (bc-dsid) ------------------------------ */ +.decision-prompt__sides { + display: flex; + flex-wrap: wrap; + gap: 8px; +} +/* The native radio stays in the tab order and the accessibility tree; the + pill is what the eye and the thumb see. */ +.decision-prompt__side { + position: relative; + gap: 4px; +} +.decision-prompt__side input[type="radio"] { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + margin: 0; + opacity: 0; + cursor: pointer; +} +.decision-prompt__side:has(input:focus-visible) { + box-shadow: var(--focus-ring); +} +.decision-prompt__hint { + color: var(--ink-3); +} +.decision-prompt__consequence { + font-weight: 600; +} + /* --- Decision prompt motion (compact + roomy) -------------------------- */ .decision-prompt { animation: decision-prompt-in 160ms var(--ease-out) both; diff --git a/web-mobile/js/__tests__/admin_scoring_modal.test.jsx b/web-mobile/js/__tests__/admin_scoring_modal.test.jsx index 0c4e2d0f5..92462accc 100644 --- a/web-mobile/js/__tests__/admin_scoring_modal.test.jsx +++ b/web-mobile/js/__tests__/admin_scoring_modal.test.jsx @@ -277,32 +277,10 @@ describe('DecisionPrompt → /decision POST integration', () => { window.API = originalAPI; }); - it('DecisionPrompt onSubmit fires the form-submit handler with default side', () => { - // The React mock returns `[initial, vi.fn()]` from useState, so - // calling DecisionPrompt as a function produces the initial-state - // virtual tree; the form's onSubmit is what we exercise here. - const onSubmit = vi.fn(); - const tree = DecisionPrompt({ - kind: 'kiken', - sideA: { name: 'Tora' }, - sideB: { name: 'Kuma' }, - defaultSide: 'shiro', - askReason: true, - onCancel: vi.fn(), - onSubmit, - submitting: false, - }); - expect(tree.type).toBe('form'); - expect(typeof tree.props.onSubmit).toBe('function'); - - tree.props.onSubmit({ preventDefault: () => {} }); - expect(onSubmit).toHaveBeenCalledWith({ - decisionBy: 'shiro', - decisionReason: '', - }); - }); - - it('DecisionPrompt onSubmit defaults side to "shiro" when defaultSide is missing', () => { + // The side-picker and submit-flow tests live in + // render/decision_prompt_side.render.test.jsx (real React). Here, with the + // fake useState, only the initial state is visible: no side is picked. + it('DecisionPrompt submits nothing while no side is picked (no preselected side)', () => { const onSubmit = vi.fn(); const tree = DecisionPrompt({ kind: 'fusenpai', @@ -314,59 +292,9 @@ describe('DecisionPrompt → /decision POST integration', () => { submitting: false, }); tree.props.onSubmit({ preventDefault: () => {} }); - expect(onSubmit).toHaveBeenCalledWith({ - decisionBy: 'shiro', - decisionReason: '', - }); - }); - - it('DecisionPrompt onSubmit is a no-op while submitting', () => { - // Guards against double-submit when the operator double-clicks - // the Record button. - const onSubmit = vi.fn(); - const tree = DecisionPrompt({ - kind: 'kiken', - sideA: { name: 'Tora' }, - sideB: { name: 'Kuma' }, - defaultSide: 'aka', - askReason: false, - onCancel: vi.fn(), - onSubmit, - submitting: true, - }); - tree.props.onSubmit({ preventDefault: () => {} }); expect(onSubmit).not.toHaveBeenCalled(); }); - it('the parent flow: DecisionPrompt onSubmit → submitDecisionRequest → recordDecision', async () => { - // Route the DecisionPrompt callback through submitDecisionRequest. - // the same path ScoreEditorModal.submitDecision takes; so the test - // would fail if the password stopped flowing to recordDecision. - const onSubmit = vi.fn((payload) => - submitDecisionRequest('comp-1', 'match-1', 'kiken-voluntary', payload, 0, 'explicit-pw'), - ); - - const tree = DecisionPrompt({ - kind: 'kiken', - sideA: { name: 'Tora' }, - sideB: { name: 'Kuma' }, - defaultSide: 'aka', - askReason: true, - onCancel: vi.fn(), - onSubmit, - submitting: false, - }); - - await tree.props.onSubmit({ preventDefault: () => {} }); - - expect(window.API.recordDecision).toHaveBeenCalledWith( - 'comp-1', - 'match-1', - { decision: 'kiken-voluntary', decisionBy: 'aka' }, - 'explicit-pw', - ); - }); - it('regression: submitDecision path forwards the modal password prop to recordDecision', async () => { // This is the production path used by ScoreEditorModal/TeamScoreEditorModal. await submitDecisionRequest( @@ -385,36 +313,6 @@ describe('DecisionPrompt → /decision POST integration', () => { ); }); - it('parent flow includes encho.periodCount in the body when > 0', async () => { - // The encho counter rides alongside the decision so the server can - // attach periodCount to MatchResult.Encho. Pinned here so the - // wiring through buildDecisionBody isn't dropped during a refactor. - const onSubmit = vi.fn((payload) => { - const body = buildDecisionBody('hikiwake', payload, 3); // 3 encho periods - const password = resolveDecisionPassword('pw'); - return window.API.recordDecision('comp-1', 'match-1', body, password); - }); - const tree = DecisionPrompt({ - kind: 'hikiwake', - sideA: { name: 'A' }, - sideB: { name: 'B' }, - defaultSide: 'shiro', - askReason: false, - onCancel: vi.fn(), - onSubmit, - submitting: false, - }); - - await tree.props.onSubmit({ preventDefault: () => {} }); - - expect(window.API.recordDecision).toHaveBeenCalledWith( - 'comp-1', - 'match-1', - { decision: 'hikiwake', decisionBy: 'shiro', encho: { periodCount: 3 } }, - 'pw', - ); - }); - it("an unknown 409 on a decision shows the server's message and never retries with force", async () => { window.API = { recordDecision: vi.fn().mockRejectedValue(new Error('some_other_conflict')), @@ -439,32 +337,6 @@ describe('DecisionPrompt → /decision POST integration', () => { expect(setDecisionErr).toHaveBeenLastCalledWith('some_other_conflict'); }); - it('fusenpai: decisionBy is the ABSENT/LOSING side, not the winning side', () => { - // The UI label was previously "Which side gets the default win?" - // operators interpreted it as picking the WINNER and sent the wrong - // side as decisionBy, inverting the result. The label is now - // "Which side did not show up?" so operators pick the ABSENT (losing) - // side. This test pins the wire contract: selecting "shiro" means - // SHIRO forfeits and AKA receives the auto-filled 2-0 win. - const onSubmit = vi.fn((payload) => { - return buildDecisionBody('fusenpai', payload, 0); - }); - const tree = DecisionPrompt({ - kind: 'fusenpai', - sideA: { name: 'Hayashi' }, - sideB: { name: 'Nakamura' }, - defaultSide: 'shiro', - askReason: false, - onCancel: vi.fn(), - onSubmit, - submitting: false, - }); - tree.props.onSubmit({ preventDefault: () => {} }); - // decisionBy = "shiro" → SHIRO is the absent side → engine gives win to AKA - expect(onSubmit).toHaveBeenCalledWith({ decisionBy: 'shiro', decisionReason: '' }); - const body = buildDecisionBody('fusenpai', { decisionBy: 'shiro', decisionReason: '' }, 0); - expect(body).toEqual({ decision: 'fusenpai', decisionBy: 'shiro' }); - }); }); describe('isBoutDecided / MAX_IPPONS_PER_SIDE', () => { diff --git a/web-mobile/js/__tests__/render/decision_prompt_side.render.test.jsx b/web-mobile/js/__tests__/render/decision_prompt_side.render.test.jsx new file mode 100644 index 000000000..1a4f9ed5b --- /dev/null +++ b/web-mobile/js/__tests__/render/decision_prompt_side.render.test.jsx @@ -0,0 +1,152 @@ +// bc-dsid: the withdrawal / no-show prompt preselects no side. Record stays +// off until the operator picks one, then names it. The submit-flow tests +// moved here from the fake-React admin_scoring_modal.test.jsx, each picking a +// side first. + +import React from 'react'; +import { render, fireEvent, screen } from '@testing-library/react'; +import { describe, it, expect, vi, beforeAll, afterAll, beforeEach } from 'vitest'; +import { installWindowStubs } from '../helpers/stub_globals.js'; + +let restoreGlobals; +let DecisionPrompt; +let buildDecisionBody; +let submitDecisionRequest; +let withdrawalConsequence; + +beforeAll(async () => { + restoreGlobals = installWindowStubs({ + isKikenDecision: (d) => d === 'kiken' || d === 'kiken-voluntary' || d === 'kiken-injury', + }); + ({ DecisionPrompt, buildDecisionBody, submitDecisionRequest, withdrawalConsequence } = + await import('../../admin_scoring_shared.jsx')); +}); + +afterAll(() => restoreGlobals()); + +beforeEach(() => { + window.API = { recordDecision: vi.fn().mockResolvedValue({}) }; +}); + +function mount(props = {}) { + const onSubmit = props.onSubmit || vi.fn(); + const utils = render( + , + ); + return { ...utils, onSubmit }; +} + +const pick = (container, value) => + fireEvent.click(container.querySelector(`input[name="decision-side"][value="${value}"]`)); +const recordButton = () => screen.getByRole('button', { name: /^Record/ }); + +describe('DecisionPrompt side picker', () => { + it('starts with no side picked, Record off, and a hint', () => { + const { container, onSubmit } = mount(); + for (const r of container.querySelectorAll('input[name="decision-side"]')) expect(r.checked).toBe(false); + expect(recordButton().disabled).toBe(true); + expect(recordButton().textContent).toBe('Record'); + expect(screen.getByTestId('decision-prompt-hint').textContent).toBe('Pick the side that withdrew.'); + fireEvent.submit(container.querySelector('form')); + expect(onSubmit).not.toHaveBeenCalled(); + }); + + it('hint for a no-show reads "did not show up"', () => { + mount({ kind: 'fusenpai' }); + expect(screen.getByTestId('decision-prompt-hint').textContent).toBe('Pick the side that did not show up.'); + }); + + it('picking Aka enables Record, names the side and shows the consequence', () => { + const { container, onSubmit } = mount(); + pick(container, 'aka'); + expect(recordButton().disabled).toBe(false); + expect(recordButton().textContent).toBe('Record: AKA withdrew'); + expect(screen.queryByTestId('decision-prompt-hint')).toBeNull(); + expect(screen.getByTestId('decision-prompt-consequence').textContent) + .toBe('Tora cannot fight again in this competition. Kuma wins this match.'); + fireEvent.submit(container.querySelector('form')); + expect(onSubmit).toHaveBeenCalledWith({ decisionBy: 'aka', decisionReason: '' }); + }); + + it('names SHIRO for a no-show, and the consequence follows the kind', () => { + const { container } = mount({ kind: 'fusenpai' }); + pick(container, 'shiro'); + expect(recordButton().textContent).toBe('Record: SHIRO did not show up'); + expect(screen.getByTestId('decision-prompt-consequence').textContent) + .toBe('Kuma cannot fight again in this competition. Tora wins this match.'); + }); + + it('a kiken-injury consequence says the competitor can be reinstated', () => { + const { container } = mount({ kind: 'kiken-injury' }); + pick(container, 'shiro'); + expect(screen.getByTestId('decision-prompt-consequence').textContent) + .toBe('Kuma cannot fight again unless reinstated. Tora wins this match.'); + expect(withdrawalConsequence('kiken', 'A', 'B')).toBe('A cannot fight again in this competition. B wins this match.'); + }); + + it('each option is named by its side word and its competitor', () => { + mount(); + expect(screen.getByRole('radio', { name: 'SHIRO (White): Kuma' })).toBeTruthy(); + expect(screen.getByRole('radio', { name: 'AKA (Red): Tora' })).toBeTruthy(); + }); + + it('is a no-op while submitting', () => { + const { container, onSubmit } = mount({ submitting: true }); + pick(container, 'aka'); + fireEvent.submit(container.querySelector('form')); + expect(onSubmit).not.toHaveBeenCalled(); + }); + + it('sends the trimmed reason when one is asked for', () => { + const { container, onSubmit } = mount({ askReason: true }); + pick(container, 'shiro'); + fireEvent.input(screen.getByTestId('decision-reason'), { target: { value: ' injury ' } }); + fireEvent.submit(container.querySelector('form')); + expect(onSubmit).toHaveBeenCalledWith({ decisionBy: 'shiro', decisionReason: 'injury' }); + }); + + it('the parent flow: onSubmit -> submitDecisionRequest -> recordDecision', async () => { + const onSubmit = vi.fn((payload) => + submitDecisionRequest('comp-1', 'match-1', 'kiken-voluntary', payload, 0, 'explicit-pw'), + ); + const { container } = mount({ askReason: true, onSubmit }); + pick(container, 'aka'); + fireEvent.submit(container.querySelector('form')); + await Promise.resolve(); + expect(window.API.recordDecision).toHaveBeenCalledWith( + 'comp-1', 'match-1', { decision: 'kiken-voluntary', decisionBy: 'aka' }, 'explicit-pw', + ); + }); + + it('parent flow includes encho.periodCount in the body when > 0', async () => { + const onSubmit = vi.fn((payload) => + window.API.recordDecision('comp-1', 'match-1', buildDecisionBody('hikiwake', payload, 3), 'pw'), + ); + const { container } = mount({ kind: 'hikiwake', onSubmit }); + pick(container, 'shiro'); + fireEvent.submit(container.querySelector('form')); + await Promise.resolve(); + expect(window.API.recordDecision).toHaveBeenCalledWith( + 'comp-1', 'match-1', { decision: 'hikiwake', decisionBy: 'shiro', encho: { periodCount: 3 } }, 'pw', + ); + }); + + it('fusenpai: decisionBy is the ABSENT/LOSING side, not the winning side', () => { + // Picking "shiro" means SHIRO did not show up, so AKA wins. + const onSubmit = vi.fn((payload) => buildDecisionBody('fusenpai', payload, 0)); + const { container } = mount({ kind: 'fusenpai', sideA: { name: 'Hayashi' }, sideB: { name: 'Nakamura' }, onSubmit }); + pick(container, 'shiro'); + fireEvent.submit(container.querySelector('form')); + expect(onSubmit).toHaveBeenCalledWith({ decisionBy: 'shiro', decisionReason: '' }); + expect(onSubmit.mock.results[0].value).toEqual({ decision: 'fusenpai', decisionBy: 'shiro' }); + }); +}); diff --git a/web-mobile/js/__tests__/render/kachinuki_reopen.render.test.jsx b/web-mobile/js/__tests__/render/kachinuki_reopen.render.test.jsx index c086ab849..cf49b5887 100644 --- a/web-mobile/js/__tests__/render/kachinuki_reopen.render.test.jsx +++ b/web-mobile/js/__tests__/render/kachinuki_reopen.render.test.jsx @@ -427,7 +427,10 @@ describe('kachinuki reopen: a busy court gets a remedy, not a dead end', () => { expect(screen.queryByTestId('decision-reason')).toBeNull(); expect(screen.queryByText(/ending it again needs a reason/)).toBeNull(); - expect(screen.getByRole('button', { name: 'Record' }).disabled).toBe(false); + // No side is preselected: Record is off until one is picked, never because of a reason. + expect(screen.getByRole('button', { name: 'Record' }).disabled).toBe(true); + fireEvent.click(document.querySelector('input[name="decision-side"][value="aka"]')); + expect(screen.getByRole('button', { name: /^Record: / }).disabled).toBe(false); }); it('leaves the reason optional when the match was not reopened', async () => { @@ -435,7 +438,10 @@ describe('kachinuki reopen: a busy court gets a remedy, not a dead end', () => { await act(async () => { fireEvent.click(screen.getByTestId('scoring-modal-fusenpai-button')); }); expect(screen.queryByTestId('decision-reason')).toBeNull(); - expect(screen.getByRole('button', { name: 'Record' }).disabled).toBe(false); + // No side is preselected: Record is off until one is picked, never because of a reason. + expect(screen.getByRole('button', { name: 'Record' }).disabled).toBe(true); + fireEvent.click(document.querySelector('input[name="decision-side"][value="aka"]')); + expect(screen.getByRole('button', { name: /^Record: / }).disabled).toBe(false); }); it('recovers when the atomic remedy fails on the target (downstream fought)', async () => { @@ -959,7 +965,8 @@ describe('kachinuki encho stays on the bout, never on the encounter', () => { window.API.recordDecision = vi.fn().mockResolvedValue({ id: 'm1', status: 'completed' }); await renderEditor({ match: reopenedKachinukiMatch({ encho: { periodCount: 1 } }) }); await act(async () => { fireEvent.click(screen.getByTestId('scoring-modal-fusenpai-button')); }); - await act(async () => { fireEvent.click(screen.getByRole('button', { name: 'Record' })); }); + await act(async () => { fireEvent.click(document.querySelector('input[name="decision-side"][value="aka"]')); }); + await act(async () => { fireEvent.click(screen.getByRole('button', { name: /^Record: / })); }); await waitFor(() => expect(window.API.recordDecision).toHaveBeenCalledTimes(1)); const body = window.API.recordDecision.mock.calls[0][2]; expect(body.decision).toBe('fusenpai'); @@ -976,7 +983,8 @@ describe('kachinuki encho stays on the bout, never on the encounter', () => { expect(screen.getByTestId('scoring-modal-encho-checkbox')).toBeTruthy(); expect(document.querySelector('.editor-modal__eyebrow').textContent).toContain('Overtime'); await act(async () => { fireEvent.click(screen.getByTestId('scoring-modal-fusenpai-button')); }); - await act(async () => { fireEvent.click(screen.getByRole('button', { name: 'Record' })); }); + await act(async () => { fireEvent.click(document.querySelector('input[name="decision-side"][value="aka"]')); }); + await act(async () => { fireEvent.click(screen.getByRole('button', { name: /^Record: / })); }); await waitFor(() => expect(window.API.recordDecision).toHaveBeenCalledTimes(1)); expect(window.API.recordDecision.mock.calls[0][2].encho).toEqual({ periodCount: 1 }); }); diff --git a/web-mobile/js/__tests__/render/term_not_inside_control.render.test.jsx b/web-mobile/js/__tests__/render/term_not_inside_control.render.test.jsx new file mode 100644 index 000000000..4a1d791b3 --- /dev/null +++ b/web-mobile/js/__tests__/render/term_not_inside_control.render.test.jsx @@ -0,0 +1,81 @@ +// bc-otpl: a glossary Term is itself a control (its click handler stops +// propagation), so one nested in a button or label swallows the tap meant for +// the outer control. These tests install the REAL Term: a stub cannot see it. + +import React from 'react'; +import { render, fireEvent, screen, within } from '@testing-library/react'; +import { describe, it, expect, vi, beforeAll, afterAll } from 'vitest'; +import { installWindowStubs } from '../helpers/stub_globals.js'; + +let restoreGlobals; +let EnchoControl; +let DecisionPrompt; + +beforeAll(async () => { + restoreGlobals = installWindowStubs({ + isKikenDecision: (d) => d === 'kiken' || d === 'kiken-voluntary' || d === 'kiken-injury', + }); + await import('../../glossary.jsx'); + ({ EnchoControl, DecisionPrompt } = await import('../../admin_scoring_shared.jsx')); +}); + +afterAll(() => restoreGlobals()); + +const NESTED = 'button [data-testid="term-wrapper"], label [data-testid="term-wrapper"]'; + +function promptFor(kind) { + return ( + + ); +} + +describe('a Term never swallows the tap of the control it sits in', () => { + it('tapping the word Overtime on the collapsed pill opens the counter', () => { + render(); + expect(screen.queryByTestId('scoring-modal-encho-checkbox')).toBeNull(); + // The word itself, inside the pill (the hint's tooltip also says Overtime). + fireEvent.click(within(screen.getByTestId('scoring-modal-encho-pill')).getByText('Overtime')); + expect(screen.getByTestId('scoring-modal-encho-checkbox')).toBeTruthy(); + }); + + it('keeps a glossary hint beside the pill, outside the button', () => { + const { container } = render(); + const hint = container.querySelector('.glossary-hint [data-testid="term-wrapper"]'); + expect(hint).toBeTruthy(); + expect(hint.closest('button')).toBeNull(); + }); +}); + +describe('no Term inside a button or label', () => { + it('EnchoControl, collapsed', () => { + const { container } = render(); + expect(container.querySelectorAll('[data-testid="term-wrapper"]').length).toBeGreaterThan(0); + expect(container.querySelectorAll(NESTED)).toHaveLength(0); + }); + + it('EnchoControl, expanded', () => { + const { container } = render(); + expect(container.querySelectorAll('[data-testid="term-wrapper"]').length).toBeGreaterThan(0); + expect(container.querySelectorAll(NESTED)).toHaveLength(0); + }); + + for (const kind of ['kiken-voluntary', 'kiken-injury', 'fusenpai']) { + it(`DecisionPrompt ${kind}, before and after a side is picked`, () => { + const { container } = render(promptFor(kind)); + // The title is not a control, so its Term is allowed and proves the + // real Term is installed. + expect(container.querySelectorAll('[data-testid="term-wrapper"]').length).toBeGreaterThan(0); + expect(container.querySelectorAll(NESTED)).toHaveLength(0); + fireEvent.click(container.querySelector('input[name="decision-side"][value="aka"]')); + expect(container.querySelectorAll(NESTED)).toHaveLength(0); + }); + } +}); diff --git a/web-mobile/js/admin_scoring_individual.jsx b/web-mobile/js/admin_scoring_individual.jsx index 9c910a443..c860d980b 100644 --- a/web-mobile/js/admin_scoring_individual.jsx +++ b/web-mobile/js/admin_scoring_individual.jsx @@ -1326,7 +1326,6 @@ export function ScoreEditorModal({ match, onClose, onSubmit, onSubmitAndNext, on kind={decisionPromptKind} sideA={m.sideA} sideB={m.sideB} - defaultSide="shiro" askReason={window.isKikenDecision(decisionPromptKind)} submitting={decisionSubmitting} onCancel={() => { setDecisionPromptKind(""); setDecisionErr(""); }} diff --git a/web-mobile/js/admin_scoring_shared.jsx b/web-mobile/js/admin_scoring_shared.jsx index d11a53681..99acebf2f 100644 --- a/web-mobile/js/admin_scoring_shared.jsx +++ b/web-mobile/js/admin_scoring_shared.jsx @@ -434,6 +434,12 @@ function nextFoulOnDecrement(currentFouls) { // are type="module" scripts and may execute in any order). Falls back // to a plain pass-through when window.Term isn't available yet (e.g. // vitest harness, or pre-mount of the glossary module). +// +// RULE (bc-otpl): a Term is never inside a button or label. A Term is itself +// a control (its own click handler stops propagation), so nested in another +// control it swallows the tap meant for the outer one. Put the control's text +// in plain words and GlossaryHintAS BESIDE the control, as the Kiken/Fusenpai +// buttons and EnchoControl do. function TermAS(props) { if (typeof window !== 'undefined' && window.Term) { return React.createElement(window.Term, props, props.children); @@ -736,8 +742,9 @@ function EnchoControl({ enchoPeriodCount, setEnchoPeriodCount }) { aria-label="Show overtime (encho) controls" > - Overtime + Overtime + ); } @@ -754,8 +761,9 @@ function EnchoControl({ enchoPeriodCount, setEnchoPeriodCount }) { if (!e.target.checked) setShowCounter(false); }} /> - Encho started (overtime) + Encho started (overtime) + {enchoPeriodCount > 0 && (
-
@@ -1314,6 +1339,17 @@ function withdrawalLabel(decision) { return decision === "kiken-injury" ? "Kiken – Injury" : "Kiken – Voluntary"; } +// withdrawalConsequence: the one sentence that says what recording a +// withdrawal or no-show does, shown once the operator has picked the side +// (DecisionPrompt). The kiken-injury kind is the reinstateable one; every +// other kiken, the legacy bare one included, and the no-show are permanent. +function withdrawalConsequence(kind, withdrawnName, otherName) { + const barred = kind === "kiken-injury" + ? "cannot fight again unless reinstated" + : "cannot fight again in this competition"; + return `${withdrawnName} ${barred}. ${otherName} wins this match.`; +} + // withdrawnSideOf: the side a recorded withdrawal names as the one that // withdrew (or did not appear), or null when the match cannot say. // decisionBy is authoritative (aka = sideA, shiro = sideB, the server's own @@ -2219,6 +2255,7 @@ export { ReasonPrompt, CORRECTION_PRESETS, withdrawalLabel, + withdrawalConsequence, withdrawnSideOf, withdrawnKeyOf, withdrawalInForce, diff --git a/web-mobile/js/admin_scoring_team.jsx b/web-mobile/js/admin_scoring_team.jsx index 897e6eeb2..fd3b82b60 100644 --- a/web-mobile/js/admin_scoring_team.jsx +++ b/web-mobile/js/admin_scoring_team.jsx @@ -4474,10 +4474,9 @@ export function TeamScoreEditorModal({ match, teamSize, onClose, onSubmit, onSub ? <>This encounter is tied. Add a representative bout (daihyosen) to decide it. Each side picks one eligible competitor, scored like any other sub-match. : <>A knockout encounter must have a winner. If the bouts end tied, add a representative bout (daihyosen) to break it.} - {/* Plain-text label only: a glossary inside the - button would swallow the tap via stopPropagation (the - term's own click handler), leaving a dead-zone over the - word. The term is taught in the title/hint above instead. */} + {/* Plain-text label only (the rule above TermAS in + admin_scoring_shared.jsx). The term is taught in the + title/hint above instead. */} {!removingWithdrawal &&
@@ -1613,7 +1676,12 @@ function AdminShiaijoPage({ tournament, court: routeCourt, onBack, onEditScore, )}
- {startError && startError.key === matchKey(upNext) &&
{startError.msg}
} + {(() => { + // The newer tap's notice wins over a stored start refusal. + const msg = refusalNotice(matchKey(upNext)) + || (startError && startError.key === matchKey(upNext) ? startError.msg : null); + return msg ?
{msg}
: null; + })()}
{calledKey === matchKey(upNext) ? "Announced to spectators. Start the match when both are at the line." @@ -1632,7 +1700,7 @@ function AdminShiaijoPage({ tournament, court: routeCourt, onBack, onEditScore, label="Upcoming" subGroup matches={upcomingQueueMatches} courts={courts} onMoveCourt={requestMoveCourt} onMove={moveMatch} onEnterLineup={(m) => setLineupKey(matchKey(m))} - onPick={pickMatch} + onPick={pickMatch} noticeFor={(m) => refusalNotice(matchKey(m))} onCall={callToCourt} callingKey={callingKey} calledKey={calledKey} startingKey={startingKey} scheduled={filteredScheduled} password={password} @@ -1679,6 +1747,7 @@ function AdminShiaijoPage({ tournament, court: routeCourt, onBack, onEditScore, refusalNotice(matchKey(m))} /> {filteredCompleted.length > COMPLETED_PREVIEW && ( @@ -2026,10 +2114,11 @@ export function groupQueueMatches(matches) { return order.map((k) => byKey.get(k)); } -function ShiaijoQueueGroup({ label, matches, subGroup, scheduled, courts, onMoveCourt, onMove, onEnterLineup, onPick, onCorrect, onCall, callingKey, calledKey, startingKey, password }) { +function ShiaijoQueueGroup({ label, matches, subGroup, scheduled, courts, onMoveCourt, onMove, onEnterLineup, onPick, onCorrect, noticeFor, onCall, callingKey, calledKey, startingKey, password }) { const renderRow = (m) => ( onCorrect(m)} title="Reopen or fix this completed match">Correct
)} + {notice && isComplete &&
{notice}
} {showActions && (
e.stopPropagation()}> {onMoveCourt && courts.length > 1 && ( @@ -2194,13 +2284,15 @@ export function ShiaijoQueueRow({ m, scheduled, courts, onMoveCourt, onMove, onE )} {/* Start match is the primary per-row action: pushed to the end (the "go" slot). - Same pickMatch path as the Up Next card: defers an unscored running bout, - blocks while one is being scored, then starts this match for scoring. + Same pickMatch path as the Up Next card: refused while a correction is + open (the notice below the actions), a running bout goes back to the + queue keeping its score (bc-sbq), then this match starts for scoring. bc-cse: never offered on a barred row -- BarredMatchNotice above owns its one-tap resolution instead, since the server would just refuse a Start. */} {onPick && !barred && }
)} + {notice && !isComplete &&
{notice}
} {/* Pending placeholder final: the ONLY affordance is the opt-in "Run now" recovery, which opens ResolveFeedersModal so the operator can assert the unsynced feeder results. No Start/Call/move here. */} diff --git a/web-mobile/js/pool_ids.jsx b/web-mobile/js/pool_ids.jsx index 94b4ec4a5..c90cab8ef 100644 --- a/web-mobile/js/pool_ids.jsx +++ b/web-mobile/js/pool_ids.jsx @@ -117,6 +117,16 @@ export function scoreRowMatchLabel(m) { return pool ? `${pool} · Match ${n}` : `Match ${n}`; } +// scoreRowMatchName: a match named the way an operator line quotes it, the +// row label with the pairing appended, Shiro first ("Pool A · Match 2 · Kato +// vs Sato"); the bare pairing when the row carries no label (a daihyosen or +// tiebreaker bout, an unnumbered knockout row). +export function scoreRowMatchName(m) { + const pairing = `${m.sideB?.name || "Shiro"} vs ${m.sideA?.name || "Aka"}`; + const label = scoreRowMatchLabel(m); + return label ? `${label} · ${pairing}` : pairing; +} + // This regex constant is intentionally module-private: callers use the // exported isSupplementaryBout() wrapper below, never the raw pattern. const SUPPLEMENTARY_BOUT_RE = /-(?:DH|TB)-\d+$/; diff --git a/web-mobile/js/write_result.jsx b/web-mobile/js/write_result.jsx index f631e1195..c686685d0 100644 --- a/web-mobile/js/write_result.jsx +++ b/web-mobile/js/write_result.jsx @@ -831,6 +831,17 @@ export function courtBusyMessage({ court, label }) { return `Shiaijo ${court} is running ${label}. Finish it or send it back to the queue first.`; } +// The court console never holds a running match and an open correction at +// once (operator ruling 2026-09-27, bc-crpn). Each refused tap names the other +// one, with the label string the caller built (scoreRowMatchName). +export function startWhileCorrectingMessage({ label }) { + return `Finish or cancel the correction of ${label} first, then start this match.`; +} + +export function correctWhileRunningMessage({ court, label }) { + return `Shiaijo ${court} is running ${label}. Finish it or send it back to the queue first, then correct this match.`; +} + // runningParts names the matches being fought and where, the same words as // engine's runningSubject: "Match 3 (Final) is being fought now on Shiaijo // A", and for two, "The 3rd-place match is being fought now on Shiaijo B and From f49bb5260a1f0500ba6c29e3229b522054f5b8fd Mon Sep 17 00:00:00 2001 From: Ricardo Oliveira Date: Wed, 7 Oct 2026 18:58:38 +0100 Subject: [PATCH 05/16] fix(bc-htsd): the hantei side buttons pick, Finish commits Once hantei was armed in the individual score editor, SHIRO wins and AKA wins sat 6px apart and each committed the verdict on one tap, advancing the bracket and starting the next match, so a neighbour tap recorded the opposite winner. Per the operator decision of 2026-10-07 the buttons now only pick a side, as the team editor's representative-bout hantei already does, and Finish (two taps) or Enter commits the picked side. A verdict recorded on another device is adopted as the pick. Co-Authored-By: Claude Opus 5.5 --- .../court-operators/recording-decisions.md | 2 + scripts/screenshots/recipes/public.mjs | 11 +- .../js/__tests__/admin_scoring_modal.test.jsx | 3 +- .../render/hantei_single_tap.render.test.jsx | 146 ++++++++++++++++++ ...l_editor_hantei_correction.render.test.jsx | 14 ++ ...ividual_editor_live_hantei.render.test.jsx | 13 +- ...move_withdrawal_correction.render.test.jsx | 3 +- web-mobile/js/admin_scoring_individual.jsx | 119 ++++++-------- 8 files changed, 224 insertions(+), 87 deletions(-) create mode 100644 web-mobile/js/__tests__/render/hantei_single_tap.render.test.jsx diff --git a/docs/user-guide/court-operators/recording-decisions.md b/docs/user-guide/court-operators/recording-decisions.md index 868b7c321..05a328ad1 100644 --- a/docs/user-guide/court-operators/recording-decisions.md +++ b/docs/user-guide/court-operators/recording-decisions.md @@ -42,6 +42,8 @@ Encho is the extra period played when a match is level at the end of regulation An individual knockout match cannot be finished level. While the score is tied, the **Finish** button reads **Needs a winner** instead: fight encho, then record a hantei if the competitors are still level after it. +To record a hantei, tap **Decide by hantei**, pick the winning side with **SHIRO wins** or **AKA wins**, then tap **Finish** and tap it again to confirm. Picking a side on its own records nothing, so a slip of the thumb between the two buttons can be put right before anything is saved. + In the score editor, open the **Overtime** control and tick **Encho started**. A counter appears so you can record how many overtime periods were fought, using the **+** and **-** buttons. The counter starts at 1 and has no upper limit. How many periods are fought, and how a match still level after them is finally settled, is the shimpan's call. That may be a judges' decision for an individual bout, or a daihyosen for a team encounter. Record what actually happened on court, however many periods that took. ![The score editor during a second overtime period: the Encho started box is ticked, the period counter reads two, and the header shows the overtime readout.](../../screenshots/mobile-encho-overtime.png) diff --git a/scripts/screenshots/recipes/public.mjs b/scripts/screenshots/recipes/public.mjs index 35a5a95bd..7238fbe41 100644 --- a/scripts/screenshots/recipes/public.mjs +++ b/scripts/screenshots/recipes/public.mjs @@ -58,13 +58,16 @@ async function armEncho(page) { } // Settle a TIED match by referee decision. The individual editor keeps this -// behind a two-step control (admin_scoring_individual.jsx:946-1003): "Decide by -// hantei..." arms it, then a SHIRO/AKA button commits - and that commit is also -// what advances to the next match, exactly as Finish does. The verdict is -// recorded as an "Ht" ippon in the winner's free slot, never as a centre mark. +// behind a pick and a commit (admin_scoring_individual.jsx): "Decide by +// hantei..." arms it, a SHIRO/AKA button only PICKS the winning side, and +// Finish commits it under the usual two-tap guard (finishMatch waits out the +// bounce window, TAP_DWELL_MS). That commit also advances to the next match, +// exactly as Finish does. The verdict is recorded as an "Ht" ippon in the +// winner's free slot, never as a centre mark. async function decideByHantei(page, side) { await page.locator('[data-testid="scoring-modal-hantei-arm"]').click(); await page.locator(`[data-testid="scoring-modal-hantei-${side}"]`).click(); + await finishMatch(page); } export const families = { diff --git a/web-mobile/js/__tests__/admin_scoring_modal.test.jsx b/web-mobile/js/__tests__/admin_scoring_modal.test.jsx index 92462accc..93b8ef4a8 100644 --- a/web-mobile/js/__tests__/admin_scoring_modal.test.jsx +++ b/web-mobile/js/__tests__/admin_scoring_modal.test.jsx @@ -1130,7 +1130,8 @@ describe('subBoutHasBeenPlayed (drops untouched kachinuki bouts)', () => { // Item 7: hantei and fusenpai must route through onSubmitAndNext/onAfterDecision // so the next match on the same court is started without an extra operator tap. describe('item 7: non-points decisions advance to next match', () => { - // submitHantei is component-internal, but the routing logic + // A hantei is committed by Finish (bc-htsd; hantei_single_tap.render.test.jsx + // pins it through the real editor), whose routing logic // `(!isComplete && onSubmitAndNext) ? onSubmitAndNext: onSubmit` // is a pure predicate we can test directly. it('routes hantei to onSubmitAndNext when provided and match is not a correction', () => { diff --git a/web-mobile/js/__tests__/render/hantei_single_tap.render.test.jsx b/web-mobile/js/__tests__/render/hantei_single_tap.render.test.jsx new file mode 100644 index 000000000..99cac814d --- /dev/null +++ b/web-mobile/js/__tests__/render/hantei_single_tap.render.test.jsx @@ -0,0 +1,146 @@ +import React from 'react'; +import { render, fireEvent, act, screen } from '@testing-library/react'; +import { describe, it, expect, vi, beforeAll, afterAll, afterEach } from 'vitest'; +import { installWindowStubs } from '../helpers/stub_globals.js'; +import { AUTOSAVE_DEBOUNCE_MS } from '../../admin_scoring_autosave.jsx'; + +// bc-htsd: the SHIRO wins / AKA wins buttons only PICK a side. Finish (under +// the two-tap guard) commits, so a mis-tap on the neighbouring button cannot +// record the opposite verdict and advance the bracket. Mirrors the team +// editor's daihyosen hantei. + +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(null), + recordScore: vi.fn(), + 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()); +afterEach(() => { vi.useRealTimers(); }); + +const tied = (overrides = {}) => ({ + id: 'm1', status: 'running', phase: 'knockout', round: 'Semi-final', court: 'A', + sideA: { id: 'p1', name: 'Yamada' }, // AKA + sideB: { id: 'p2', name: 'Tanaka' }, // SHIRO + ipponsA: ['M'], ipponsB: ['K'], hansokuA: 0, hansokuB: 0, + ...overrides, +}); + +const click = (el) => act(async () => { fireEvent.click(el); }); +const finishBtn = () => [...document.querySelectorAll('.score-nav button')].find((b) => /Finish|Tap again/.test(b.textContent)); +const armAndPick = async (testid) => { + await click(screen.getByTestId('scoring-modal-hantei-arm')); + await click(screen.getByTestId(testid)); +}; + +describe('hantei side buttons only pick (bc-htsd)', () => { + it('picking a side commits nothing; Finish, tapped twice, commits exactly the pick', async () => { + const onSubmit = vi.fn().mockResolvedValue(undefined); + const onSubmitAndNext = vi.fn().mockResolvedValue(undefined); + render(); + await armAndPick('scoring-modal-hantei-aka'); + expect(onSubmit).not.toHaveBeenCalled(); + expect(onSubmitAndNext).not.toHaveBeenCalled(); + expect(screen.getByTestId('scoring-modal-hantei-aka').getAttribute('aria-pressed')).toBe('true'); + expect(screen.getByTestId('scoring-modal-hantei-shiro').getAttribute('aria-pressed')).toBe('false'); + + await click(finishBtn()); + expect(onSubmitAndNext, 'the first Finish tap only arms').not.toHaveBeenCalled(); + await click(finishBtn()); + expect(onSubmitAndNext).toHaveBeenCalledTimes(1); + expect(onSubmit).not.toHaveBeenCalled(); + const patch = onSubmitAndNext.mock.calls[0][0]; + expect(patch.winner).toMatchObject({ id: 'p1', name: 'Yamada' }); + expect(patch.decidedByHantei).toBe(true); + expect(patch.status).toBe('completed'); + expect(patch.ipponsA).toEqual(['M']); + expect(patch.ipponsB).toEqual(['K']); + }); + + it('re-picking the other side before committing changes the committed side', async () => { + const onSubmit = vi.fn().mockResolvedValue(undefined); + render(); + await armAndPick('scoring-modal-hantei-aka'); + await click(finishBtn()); // armed + await click(screen.getByTestId('scoring-modal-hantei-shiro')); + expect(finishBtn().textContent, 'changing the pick disarms Finish').not.toMatch(/Tap again/); + await click(finishBtn()); + expect(onSubmit).not.toHaveBeenCalled(); + await click(finishBtn()); + expect(onSubmit).toHaveBeenCalledTimes(1); + expect(onSubmit.mock.calls[0][0].winner).toMatchObject({ id: 'p2', name: 'Tanaka' }); + }); + + it('Finish needs a pick, even on a 0-0 tie', async () => { + render(); + await click(screen.getByTestId('scoring-modal-hantei-arm')); + expect(finishBtn().disabled).toBe(true); + expect(finishBtn().textContent).not.toMatch(/Needs a winner/); + await click(screen.getByTestId('scoring-modal-hantei-shiro')); + expect(finishBtn().disabled).toBe(false); + }); + + it('a verdict recorded on another device is adopted as the pick', async () => { + const onSubmit = vi.fn().mockResolvedValue(undefined); + const { rerender } = render(); + await act(async () => { rerender(); }); + expect(screen.getByTestId('scoring-modal-hantei-shiro').className).toContain('btn--primary'); + expect(screen.getByTestId('scoring-modal-hantei-aka').className).not.toContain('btn--primary'); + }); + + it('the running autosave patch never carries the hantei verdict', async () => { + vi.useFakeTimers(); + const onSubmit = vi.fn().mockResolvedValue(undefined); + render(); + const add = (side, letter) => [...document.querySelectorAll(`.sb-side--${side} .ipt-btn`)].find((b) => b.textContent === letter); + await click(add('aka', 'M')); + await click(add('shiro', 'K')); + await armAndPick('scoring-modal-hantei-aka'); + await act(async () => { vi.advanceTimersByTime(AUTOSAVE_DEBOUNCE_MS + 50); }); + expect(onSubmit).toHaveBeenCalled(); + for (const [patch] of onSubmit.mock.calls) { + expect(patch.status).toBe('running'); + expect(patch.decidedByHantei).not.toBe(true); + } + }); + + it('Enter commits only the picked side', async () => { + const onSubmit = vi.fn().mockResolvedValue(undefined); + render(); + await click(screen.getByTestId('scoring-modal-hantei-arm')); + await act(async () => { fireEvent.keyDown(document, { key: 'Enter' }); }); + expect(onSubmit, 'no pick, no commit').not.toHaveBeenCalled(); + await click(screen.getByTestId('scoring-modal-hantei-shiro')); + document.activeElement?.blur?.(); + await act(async () => { fireEvent.keyDown(document, { key: 'Enter' }); }); + expect(onSubmit).toHaveBeenCalledTimes(1); + expect(onSubmit.mock.calls[0][0].winner).toMatchObject({ id: 'p2', name: 'Tanaka' }); + expect(onSubmit.mock.calls[0][0].decidedByHantei).toBe(true); + }); +}); diff --git a/web-mobile/js/__tests__/render/individual_editor_hantei_correction.render.test.jsx b/web-mobile/js/__tests__/render/individual_editor_hantei_correction.render.test.jsx index ad53bd053..3325eadd5 100644 --- a/web-mobile/js/__tests__/render/individual_editor_hantei_correction.render.test.jsx +++ b/web-mobile/js/__tests__/render/individual_editor_hantei_correction.render.test.jsx @@ -63,6 +63,12 @@ const hanteiForYamada = (overrides = {}) => ({ ...overrides, }); +// Save correction: the commit for a finished match, which asks for its reason. +const saveCorrection = async () => { + const btn = [...document.querySelectorAll('.score-nav button')].find((b) => b.textContent === 'Save correction'); + await act(async () => { fireEvent.click(btn); }); +}; + const confirmReason = async () => { const prompt = document.querySelector('.reason-prompt'); expect(prompt, 'the correction asks for its reason').toBeTruthy(); @@ -75,6 +81,8 @@ describe('correcting a hantei verdict to the other side (bc-htcr)', () => { render(); await act(async () => { fireEvent.click(screen.getByTestId('scoring-modal-hantei-shiro')); }); + expect(onSubmit, 'picking a side writes nothing').not.toHaveBeenCalled(); + await saveCorrection(); expect(onSubmit, 'nothing is written before the reason is given').not.toHaveBeenCalled(); await confirmReason(); @@ -94,6 +102,7 @@ describe('correcting a hantei verdict to the other side (bc-htcr)', () => { render(); await act(async () => { fireEvent.click(screen.getByTestId('scoring-modal-hantei-shiro')); }); + await saveCorrection(); const cancel = [...document.querySelectorAll('.reason-prompt button')].find((b) => b.textContent === 'Cancel'); await act(async () => { fireEvent.click(cancel); }); expect(document.querySelector('.reason-prompt')).toBeNull(); @@ -108,6 +117,11 @@ describe('correcting a hantei verdict to the other side (bc-htcr)', () => { await act(async () => { fireEvent.click(screen.getByTestId('scoring-modal-hantei-arm')); }); await act(async () => { fireEvent.click(screen.getByTestId('scoring-modal-hantei-aka')); }); + expect(onSubmit, 'picking a side writes nothing').not.toHaveBeenCalled(); + // Finish is the two-tap commit: the first tap arms, the second writes. + const finishBtn = () => [...document.querySelectorAll('.score-nav button')].find((b) => /Finish|Tap again/.test(b.textContent)); + await act(async () => { fireEvent.click(finishBtn()); }); + await act(async () => { fireEvent.click(finishBtn()); }); expect(document.querySelector('.reason-prompt')).toBeNull(); expect(onSubmit).toHaveBeenCalledTimes(1); expect(onSubmit.mock.calls[0][0].correctionReason).toBeUndefined(); diff --git a/web-mobile/js/__tests__/render/individual_editor_live_hantei.render.test.jsx b/web-mobile/js/__tests__/render/individual_editor_live_hantei.render.test.jsx index d18c3b1e4..d9b099408 100644 --- a/web-mobile/js/__tests__/render/individual_editor_live_hantei.render.test.jsx +++ b/web-mobile/js/__tests__/render/individual_editor_live_hantei.render.test.jsx @@ -178,8 +178,9 @@ describe('the editor shows the verdict the server holds', () => { }); it('cannot be finished past a verdict without ruling on it', async () => { - // The adopted verdict locks scoring: Finish is disabled while it stands, - // so no write can quietly land a different result over it. + // The adopted verdict locks scoring, and its side is adopted as the pick + // (bc-htsd): Finish commits that verdict and nothing else, so no write can + // quietly land a different result over it. const onSubmit = vi.fn(); const { rerender, container } = render( @@ -188,9 +189,11 @@ describe('the editor shows the verdict the server holds', () => { ); }); - const btn = await finish(container); - expect(btn.disabled).toBe(true); - expect(onSubmit).not.toHaveBeenCalled(); + await finish(container); + expect(onSubmit).toHaveBeenCalledTimes(1); + const patch = onSubmit.mock.calls[0][0]; + expect(patch.decidedByHantei).toBe(true); + expect(patch.winner).toMatchObject({ id: 'p1', name: 'Yamada' }); }); it('adopting is not an unsaved change of the operators', async () => { diff --git a/web-mobile/js/__tests__/render/remove_withdrawal_correction.render.test.jsx b/web-mobile/js/__tests__/render/remove_withdrawal_correction.render.test.jsx index 9be885867..809b2ffa7 100644 --- a/web-mobile/js/__tests__/render/remove_withdrawal_correction.render.test.jsx +++ b/web-mobile/js/__tests__/render/remove_withdrawal_correction.render.test.jsx @@ -230,7 +230,7 @@ describe('individual editor: Remove withdrawal', () => { }); // A judges' decision is a real result too: decided after Remove, it goes - // out through its own write (submitHantei), which must clear the ruling + // out through Save correction with the picked side, which must clear the ruling // exactly as Save correction does. it('a hantei decided after Remove replaces the ruling as well', async () => { const onSubmit = vi.fn().mockResolvedValue(undefined); @@ -239,6 +239,7 @@ describe('individual editor: Remove withdrawal', () => { await tap(addButton('aka', 'K')); await tap(screen.getByTestId('scoring-modal-hantei-arm')); await tap(screen.getByTestId('scoring-modal-hantei-aka')); + await tap(navButton('Save correction')); const confirm = [...document.querySelectorAll('.reason-prompt button')].find((b) => b.textContent === 'Confirm'); await tap(confirm); expect(onSubmit).toHaveBeenCalledTimes(1); diff --git a/web-mobile/js/admin_scoring_individual.jsx b/web-mobile/js/admin_scoring_individual.jsx index c860d980b..cf6687d0c 100644 --- a/web-mobile/js/admin_scoring_individual.jsx +++ b/web-mobile/js/admin_scoring_individual.jsx @@ -166,9 +166,13 @@ export function ScoreEditorModal({ match, onClose, onSubmit, onSubmitAndNext, on // depending on which screen you look at is the worse failure. Adopting means // an explicit `false` below is always the operator ruling on something in // front of them. + // bc-htsd: the side picked for the verdict is adopted with it, keyed on the + // two VALUES (never on m), so a local pick stands until the server changes. + const recordedHtKey = hanteiRecorded ? hanteiWinnerKey(m) : ""; + const [hanteiPick, setHanteiPick] = useStateA(recordedHtKey); useAdoptFromServer({ - signature: hanteiRecorded, - apply: () => setDecidedByHantei(hanteiRecorded), + signature: JSON.stringify([hanteiRecorded, recordedHtKey]), + apply: () => { setDecidedByHantei(hanteiRecorded); setHanteiPick(recordedHtKey); }, }); // Which side ("a"/"b"/"") holds a RECORDED hantei verdict, for the display // chip in the slot grid. Gated on the SERVER's verdict — not the local armed @@ -177,7 +181,6 @@ export function ScoreEditorModal({ match, onClose, onSubmit, onSubmitAndNext, on // side. Empty when the winner is unattributable (same-name pair: mirror the // scoreboard, mark neither). // The tie gate is applied at the render site against the CURRENT pts. - const recordedHtKey = hanteiRecorded ? hanteiWinnerKey(m) : ""; const [submitting, setSubmitting] = useStateA(false); // F5: pending-write state: set when a terminal submit resolves { queued:true } // (offline / transient failure). While pending the modal stays open and shows a @@ -209,14 +212,11 @@ export function ScoreEditorModal({ match, onClose, onSubmit, onSubmitAndNext, on const [decisionErr, setDecisionErr] = useStateA(""); // Audit reason collected when correcting a completed match. correctionPrompt // is the open ReasonPrompt (null when closed); correctionReason carries the - // confirmed string. bc-htcr: the prompt holds the write it confirms, handed - // the reason. A null action is the default correction (Save correction, - // Enter: the buildPatch("completed") write); submitHantei passes its own, - // because a hantei verdict is not a buildPatch write. Wrapped in an object: - // a bare function handed to a state setter would run as an updater. + // confirmed string. The prompt confirms the one correction write, + // buildPatch("completed") (a hantei verdict included, bc-htsd). const [correctionReason, setCorrectionReason] = useStateA(""); const [correctionPrompt, setCorrectionPrompt] = useStateA(null); - const askCorrectionReason = (action = null) => setCorrectionPrompt({ action }); + const askCorrectionReason = () => setCorrectionPrompt(true); // mp-62vr: for a team daihyosen/tiebreaker rep bout the sides are TEAM names; // the operator picks which player each team fields from its roster. repPlayerA // = Aka (sideA), repPlayerB = Shiro (sideB). Only rendered when m.repIsTeam. @@ -302,8 +302,7 @@ export function ScoreEditorModal({ match, onClose, onSubmit, onSubmitAndNext, on // Item 7: a decision (fusenpai, kiken, or any future non-points decision) // routes through onAfterDecision when the host page provides it (and this // isn't a correction) so the court advances to the next match: mirroring - // the Finish + Start Next flow. Hantei advance is handled separately in - // submitHantei below. + // the Finish + Start Next flow. A hantei is committed by Finish (bc-htsd). const submitDecision = makeSubmitDecision({ match: m, enchoPeriodCount, password, mountedRef, setDecisionSubmitting, setDecisionErr, setDecisionPromptKind, @@ -358,7 +357,7 @@ export function ScoreEditorModal({ match, onClose, onSubmit, onSubmitAndNext, on // The recorded verdict comes back too: a hantei armed during the removal // would otherwise outlive the tie it was armed on (the circles return, // the hantei row and its Cancel go, and every control stays disabled). - onUndo: () => { applyServerScore(); setDecidedByHantei(hanteiRecorded); }, + onUndo: () => { applyServerScore(); setDecidedByHantei(hanteiRecorded); setHanteiPick(recordedHtKey); }, }); const recordedWithdrawnKey = recordedWithdrawal ? withdrawnKeyOf(m) : ""; const recordedLockedKey = recordedWithdrawnKey === "a" ? "b" : recordedWithdrawnKey === "b" ? "a" : ""; @@ -431,8 +430,8 @@ export function ScoreEditorModal({ match, onClose, onSubmit, onSubmitAndNext, on // updater EnchoControl's stepper hands over), then the save it schedules, as // for a point. The count adopted from the server does not come through here. const changeEnchoPeriodCount = (v) => { setEnchoPeriodCount(v); markScoringDirty(); }; - // decidedByHantei is only set via the dedicated submitHantei path - // (SHIRO/AKA hantei buttons). The regular Finish/Enter buildPatch + // decidedByHantei rides only a completed buildPatch with a picked side + // (bc-htsd). A re-edit without hantei armed // explicitly clears the flag (sends false) when the match was previously // hantei-decided, so a re-edit via the normal flow removes the stale Ht // marker rather than preserving it on the server. @@ -470,6 +469,19 @@ export function ScoreEditorModal({ match, onClose, onSubmit, onSubmitAndNext, on // running and scheduled shapes never carry it (the server reads it on a // completed correction alone). const completedTail = { ...enchoBlock(), ...hanteiClear, ...correctionBlock, ...clearWithdrawalBlock, ...repBlock }; + // bc-htsd: a hantei verdict is committed by Finish, never by the side + // buttons (which only pick). Only a completed target carries the flag, so + // the running autosave body never does. The entered ippons stay (a 1-1 + // score stays visible beside the Ht mark). + if (decidedByHantei && hanteiPick) { + return { + winner: hanteiPick === "a" ? m.sideA : m.sideB, + ipponsA: realIppons(aPts).slice(0, MAX_IPPONS_PER_SIDE), + ipponsB: realIppons(bPts).slice(0, MAX_IPPONS_PER_SIDE), + hansokuA: aFouls, hansokuB: bFouls, status: "completed", + ...completedTail, decidedByHantei: true, + }; + } if (isDrawToggled) return { winner: null, ipponsA: [], ipponsB: [], hansokuA: aFouls, hansokuB: bFouls, status: "completed", score: { type: "hikiwake", winnerPts: 0, loserPts: 0, fouls, corrected: isComplete }, ...completedTail }; // ippon. Hansoku Hs are already physically present in the pts arrays // (folded in by applyFoulIncrement at the 2-foul boundary), so no @@ -491,50 +503,6 @@ export function ScoreEditorModal({ match, onClose, onSubmit, onSubmitAndNext, on _autosaveBuildPatchRef.current = buildPatch; _autosaveOnSubmitRef.current = onSubmit; - // Hantei submit: tied scoreline (with or without encho). Operator picks a - // side; we send a completed patch with the chosen side as winner, the - // *entered* ippon arrays preserved (so a 1–1 score stays visible alongside the Ht - // marker: clearing them would lose the tied score history that the - // viewer/Excel renderers display under the hantei suffix), and the - // decidedByHantei flag set. This is a dedicated affordance because the - // regular flow assumes an ippon-derived win. - // - // Item 7: mirror the Finish button's isComplete ? onSubmit: onSubmitAndNext - // choice so a hantei finish also advances to the next match on the same - // court (when onSubmitAndNext is available and this isn't a correction). - const submitHantei = (winnerSide) => { - const winner = winnerSide === "a" ? m.sideA : m.sideB; - const aFinal = realIppons(aPts).slice(0, MAX_IPPONS_PER_SIDE); - const bFinal = realIppons(bPts).slice(0, MAX_IPPONS_PER_SIDE); - const patch = { - winner, - ipponsA: aFinal, - ipponsB: bFinal, - hansokuA: aFouls, - hansokuB: bFouls, - status: "completed", - ...enchoBlock(), - decidedByHantei: true, - // A hantei is a real result too: after Remove withdrawal it replaces - // the ruling exactly as Save correction's own write does. - ...clearWithdrawalBlock, - }; - // bc-htcr: a hantei verdict on a completed match is a correction like - // any other, so it carries the audit reason the server requires, and - // asks for one first when none has been given, exactly as Save - // correction does. Without this the write was refused and the wrong - // verdict stood, with no other way to change it (Save correction is off - // while the hantei is set). - if (isComplete && !correctionReason) { - askCorrectionReason((r) => doSubmit(() => onSubmit(claimChanged({ ...patch, correctionReason: r })))); - return undefined; - } - if (isComplete) patch.correctionReason = correctionReason; - const submitFn = (!isComplete && onSubmitAndNext) ? onSubmitAndNext : onSubmit; - const claimed = claimChanged(patch); - return doSubmit(() => submitFn(claimed)); - }; - const doSubmit = async (fn) => { cancelScoringDebounce(); // C1: cancel any pending autosave before explicit submit setSubmitting(true); @@ -685,7 +653,7 @@ export function ScoreEditorModal({ match, onClose, onSubmit, onSubmitAndNext, on // the primary cells AND the score.ippons fallback through realIppons), so // raw and filtered agree here and the mark lands in a genuinely free cell. const htSlot = hanteiSlot( - decidedByHantei && hanteiTied(aPts, bPts) && recordedHtKey === s.key, s.pts); + decidedByHantei && hanteiTied(aPts, bPts) && hanteiPick === s.key, s.pts); // sideSlotOrder: the same visual mirror the read-only scoreboard and the // team editor apply, so DOM order is visual order and no CSS mirror is // needed here any more (result_slot.jsx owns the rule). @@ -755,10 +723,9 @@ export function ScoreEditorModal({ match, onClose, onSubmit, onSubmitAndNext, on // Marks a tap can clear: under a recorded withdrawal, the withdrawer's only. const clearableMarks = withdrawerPts ? realIppons(withdrawerPts).length : aTotal + bTotal; - // While hantei is armed the operator must commit via the dedicated SHIRO / - // AKA buttons (which route through submitHantei). Disable the regular - // Finish/Enter so the patch can't accidentally mark an ippon-decided match - // as hantei-decided. Keyboard Enter is also gated on canFinish. + // While hantei is armed the SHIRO / AKA buttons only PICK a side and + // Finish/Enter commit it (canFinish needs the pick); the scoring grid is + // locked so the patch can't mark an ippon-decided match as hantei-decided. // A knockout match can't end in a draw (it's decided by encho then hantei), // so the hikiwake toggle is suppressed in the bracket phase: m.phase === // "bracket" is the in-modal KO signal (see TeamScoreEditorModal). @@ -795,7 +762,8 @@ export function ScoreEditorModal({ match, onClose, onSubmit, onSubmitAndNext, on const KO_TIE_REASON = selfReport ? "Needs a winner: fight encho, then ask the organizer for a hantei if still tied." : "Needs a winner: fight encho, then record hantei if still tied."; - const canFinish = !decidedByHantei && !koTieBlocked && hasPointsOrDraw; + // bc-htsd: hantei is valid at 0-0, so the picked side stands in for points. + const canFinish = decidedByHantei ? !!hanteiPick : (!koTieBlocked && hasPointsOrDraw); // Finish guard (see TeamScoreEditorModal): one tap ARMS the button — its label // becomes an explicit "Tap again to finish" INSTRUCTION (not a verdict), so the @@ -807,7 +775,7 @@ export function ScoreEditorModal({ match, onClose, onSubmit, onSubmitAndNext, on // bc-dtfn: the arm-then-confirm guard with a dwell, so the bounce of the // arming tap cannot commit (tap_guard.jsx). const { armed: finishArmed, setArmed: setFinishArmed, confirm: confirmFinish } = useArmedConfirm(); - useEffectA(() => { setFinishArmed(false); }, [aTotal, bTotal, isDrawToggled]); + useEffectA(() => { setFinishArmed(false); }, [aTotal, bTotal, isDrawToggled, hanteiPick, decidedByHantei]); // "Has the OPERATOR changed anything", which gates the discard prompt — so // the verdict term compares against what the SERVER holds, not against a @@ -824,6 +792,7 @@ export function ScoreEditorModal({ match, onClose, onSubmit, onSubmitAndNext, on isDrawToggled !== initialIsDrawToggled || enchoPeriodCount !== initialEnchoPeriods || decidedByHantei !== hanteiRecorded || + hanteiPick !== recordedHtKey || // A removed withdrawal is unsaved until Save correction sends it. removingWithdrawal; // The scoreline half of the same rule, declared HERE because the hook needs @@ -867,8 +836,8 @@ export function ScoreEditorModal({ match, onClose, onSubmit, onSubmitAndNext, on // unmount writes one still inside the debounce window // (useDebouncedRunningWrite), so leaving discards nothing and asks // nothing. Two local states are NOT in buildPatch("running"): the hantei - // ARM, which is only a mode (the verdict is committed by the side buttons, - // submitHantei) and is dropped with no write (it never marks dirty), and + // ARM, which is only a mode (the verdict is committed by Finish with a + // picked side) and is dropped with no write (it never marks dirty), and // the hikiwake toggle, which is a result the operator entered; that one // keeps the prompt below, because leaving would lose it. if (m.status === "running" && isDrawToggled === initialIsDrawToggled) { @@ -1248,18 +1217,20 @@ export function ScoreEditorModal({ match, onClose, onSubmit, onSubmitAndNext, on highlight the only place the side is visible. */} diff --git a/web-mobile/js/admin_shiaijo.jsx b/web-mobile/js/admin_shiaijo.jsx index 9c81a21b7..9660ac2d7 100644 --- a/web-mobile/js/admin_shiaijo.jsx +++ b/web-mobile/js/admin_shiaijo.jsx @@ -1016,24 +1016,30 @@ function AdminShiaijoPage({ tournament, court: routeCourt, onBack, onEditScore, // The "Another bout is running" list: the bouts beyond the one the panel // shows, plus the running one itself while a correction holds the panel. const alsoRunning = openCorrection ? running : running.slice(1); - // The notice for a refused tap, derived: text only while its condition - // still holds, composed from the live match each render. + // What refuses a tap right now, by kind: a live bout refuses a Correct, + // an open correction refuses a Start. ONE place builds each sentence, for + // the toast and the notice alike. + const refusalBlocker = (why) => (why === "running" ? liveBout : openCorrection); + const refusalText = (why) => { + const blocker = refusalBlocker(why); + if (!blocker) return null; + return why === "running" + ? correctWhileRunningMessage({ court, label: scoreRowMatchName(blocker) }) + : startWhileCorrectingMessage({ label: scoreRowMatchName(blocker) }); + }; + // The notice for a refused tap, derived: shown only while the match that + // refused it still does, so it goes by itself when that match is finished, + // sent back or cancelled, and a later, unrelated one never revives it. const refusalNotice = (key) => { if (!refusedTap || refusedTap.key !== key) return null; - if (refusedTap.why === "running" && liveBout) { - return correctWhileRunningMessage({ court, label: scoreRowMatchName(liveBout) }); - } - if (refusedTap.why === "correcting" && openCorrection) { - return startWhileCorrectingMessage({ label: scoreRowMatchName(openCorrection) }); - } - return null; + const blocker = refusalBlocker(refusedTap.why); + return blocker && matchKey(blocker) === refusedTap.against ? refusalText(refusedTap.why) : null; + }; + // Refuse a tap: remember it against the match that blocks it, and toast. + const refuseTap = (m, why) => { + setRefusedTap({ key: matchKey(m), why, against: matchKey(refusalBlocker(why)) }); + if (showToast) showToast(refusalText(why), "error"); }; - // Drop the stored tap once its condition ends, so a later, unrelated - // running match or correction cannot bring an old refusal back. - const refusalHolds = !!refusedTap && (refusedTap.why === "running" ? !!liveBout : !!openCorrection); - useEffectSh(() => { - if (refusedTap && !refusalHolds) setRefusedTap(null); - }, [refusedTap, refusalHolds]); // For pool daihyosen/tiebreaker bouts, enrich the selected match with // rep-player roster data so ScoreEditorModal renders the rep-picker dropdowns @@ -1116,6 +1122,11 @@ function AdminShiaijoPage({ tournament, court: routeCourt, onBack, onEditScore, // queue row (the default-win action, or Reinstate) rather than having it // offered as the next thing to start. const upNext = filteredScheduled.find((m) => !isBarredMatch(m)) || null; + // The Up next card's alert line: a refused tap's notice wins over a stored + // start refusal, being the newer tap. + const upNextNotice = upNext + ? refusalNotice(matchKey(upNext)) || (startError && startError.key === matchKey(upNext) ? startError.msg : null) + : null; // Everything else in Upcoming: the whole scheduled list minus whichever // match became Up Next (by key, not index: Up Next may not be [0] when a // barred match sits ahead of it). Any barred match stays here, rendered @@ -1275,8 +1286,7 @@ function AdminShiaijoPage({ tournament, court: routeCourt, onBack, onEditScore, const pickMatch = async (m) => { if (!m || m.status === "completed") return; if (openCorrection) { - setRefusedTap({ key: matchKey(m), why: "correcting" }); - if (showToast) showToast(startWhileCorrectingMessage({ label: scoreRowMatchName(openCorrection) }), "error"); + refuseTap(m, "correcting"); return; } setRefusedTap(null); @@ -1326,8 +1336,7 @@ function AdminShiaijoPage({ tournament, court: routeCourt, onBack, onEditScore, const correctMatch = (m) => { if (!m || m.status !== "completed") return; if (liveBout) { - setRefusedTap({ key: matchKey(m), why: "running" }); - if (showToast) showToast(correctWhileRunningMessage({ court, label: scoreRowMatchName(liveBout) }), "error"); + refuseTap(m, "running"); return; } setRefusedTap(null); @@ -1676,12 +1685,7 @@ function AdminShiaijoPage({ tournament, court: routeCourt, onBack, onEditScore, )} - {(() => { - // The newer tap's notice wins over a stored start refusal. - const msg = refusalNotice(matchKey(upNext)) - || (startError && startError.key === matchKey(upNext) ? startError.msg : null); - return msg ?
{msg}
: null; - })()} + {upNextNotice &&
{upNextNotice}
}
{calledKey === matchKey(upNext) ? "Announced to spectators. Start the match when both are at the line." diff --git a/web-mobile/js/write_result.jsx b/web-mobile/js/write_result.jsx index c686685d0..874b96929 100644 --- a/web-mobile/js/write_result.jsx +++ b/web-mobile/js/write_result.jsx @@ -839,7 +839,7 @@ export function startWhileCorrectingMessage({ label }) { } export function correctWhileRunningMessage({ court, label }) { - return `Shiaijo ${court} is running ${label}. Finish it or send it back to the queue first, then correct this match.`; + return `${courtBusyMessage({ court, label })} Then correct this match.`; } // runningParts names the matches being fought and where, the same words as From cb1a8345e2651ef7f3a769be154140461a74fc31 Mon Sep 17 00:00:00 2001 From: Ricardo Oliveira Date: Wed, 7 Oct 2026 20:45:46 +0100 Subject: [PATCH 11/16] fix: address code-review findings on the six operator fixes - bc-kosc: a knockout court that holds no pool bout starts after the latest pool end on any court, not at the day start; the doc says the pool end is read once, when the preview is built. - bc-htsd: leaving a running match with a hantei side picked asks before dropping it; a recorded hantei on an untied scoreline cannot be finished (the server refuses it). - bc-crpn: the court console asks writeKeepsEditorOpen before ending a correction. - scoreRowMatchName takes the missing-side word from sideWord. Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 2 +- internal/engine/scheduler_slots.go | 32 ++++++++++++++----- internal/engine/scheduler_slots_test.go | 2 +- .../render/hantei_single_tap.render.test.jsx | 31 ++++++++++++++++++ web-mobile/js/admin_scoring_individual.jsx | 16 ++++++---- web-mobile/js/admin_shiaijo.jsx | 4 +-- web-mobile/js/pool_ids.jsx | 9 +++--- 7 files changed, 73 insertions(+), 23 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index fab654390..a0a2ee9fb 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -326,7 +326,7 @@ Two distinctions that look like details and are not: **An edit still inside an editor's autosave debounce is unsent work** (bc-sync). `useDebouncedRunningWrite` (admin_scoring_autosave.jsx) registers it with `API.notePendingEdit`, so the sync pill reads "Syncing…" from the first tap, and releases it only once the write is dispatched. On `pagehide` or the tab being hidden it writes a pending edit at once with `durable: true` on the patch, and `recordScore` puts such a running write straight into the persisted outbox (the flag is never sent), because a fetch started then dies with the page. That relies on every score-editor host reaching `API.recordScore` before the page goes, i.e. with no `await` on I/O in front of it (pinned by `score_write_same_tick.test.jsx`: `attemptScoreWrite`, `editMatchScore` and every host's `onSubmit`); do not add one. `api_client` also keeps, on page hide, a running write already SENT but not yet answered (`_keepInflightRunning`, from `_inflightRunningWrites`), because the page going away cancels that request. The copy is an entry of its own (bc-mrgc phase 3): it never replaces a queued entry for the match, nothing coalesces into it, and it replays in its place in the match's order. The copy is dated when the write was sent, the flush skips it while its fetch is still open (`_heldByOpenFetch`), and the fetch's outcome removes or replaces it, so a tab that is only hidden never sends the write twice. -**Every way out of an editor saves a pending edit, and an autosave is stamped with the time of the edit it carries** (operator ruling 2026-09-27). The unmount writes an edit still inside the debounce window whatever caused it: Close, Prev/Next or their keys, another match picked, a correction opened, a court switch, or the court console moving on by itself (`selectedMatch` falls back to `running[0]`, so another device finishing the match re-keys the editor). The write carries the monotonic reading of the last tap (`editedPerf`, never sent), and `recordScore` takes its age off the stamp (`_editAge`). That is what makes the unmount safe: a tap made before another device finished the match is OLDER than that finish, so the server keeps the finish, where a write stamped at send would have reopened it. Discarding is the one way out that saves nothing: the editor cancels the debounce before it closes. Close and Prev/Next share one leave rule (same ruling): whatever autosave does not carry asks "Discard unsaved scoring changes?" first (the individual editor's Tie toggle on a running match, a kachinuki row the running patch drops, any unsaved change to a match that is not running); everything else leaves at once. So every user edit the running patch carries must call `markDirty`: an edit that arms no timer is not saved by the unmount or the page-hide flush (the overtime counter, the rep-player pickers and the team daihyosen verdict did not, bc-sync). An adopt for a value saved as it is made keeps the operator's value until the server holds it (`keepLocalEdits`), or the save of one tap, coming back, undoes a second tap made before it arrived. +**Every way out of an editor saves a pending edit, and an autosave is stamped with the time of the edit it carries** (operator ruling 2026-09-27). The unmount writes an edit still inside the debounce window whatever caused it: Close, Prev/Next or their keys, another match picked, a correction opened, a court switch, or the court console moving on by itself (`selectedMatch` falls back to `running[0]`, so another device finishing the match re-keys the editor). The write carries the monotonic reading of the last tap (`editedPerf`, never sent), and `recordScore` takes its age off the stamp (`_editAge`). That is what makes the unmount safe: a tap made before another device finished the match is OLDER than that finish, so the server keeps the finish, where a write stamped at send would have reopened it. Discarding is the one way out that saves nothing: the editor cancels the debounce before it closes. Close and Prev/Next share one leave rule (same ruling): whatever autosave does not carry asks "Discard unsaved scoring changes?" first (the individual editor's Tie toggle or hantei side pick on a running match, a kachinuki row the running patch drops, any unsaved change to a match that is not running); everything else leaves at once. So every user edit the running patch carries must call `markDirty`: an edit that arms no timer is not saved by the unmount or the page-hide flush (the overtime counter, the rep-player pickers and the team daihyosen verdict did not, bc-sync). An adopt for a value saved as it is made keeps the operator's value until the server holds it (`keepLocalEdits`), or the save of one tap, coming back, undoes a second tap made before it arrived. **Self-run participants run the representative bout; a hantei stays the organiser's** (operator decisions 2026-09-28, bc-dhas). An anonymous self-run `PUT /score` may carry the representative-bout row (position -1, sub decision `daihyosen`) and score it like any bout. `POST`/`DELETE .../daihyosen` are public, but an anonymous caller adds or removes only on a RUNNING match (`selfRunDaihyosenRefusal`, handlers_daihyosen.go: 409 `result_finalized` with the `/score` sentence once it has finished, 409 `match_not_running` before it starts). The organiser keeps no status rule there, since an add to a finished fixed-order knockout match may be their correction (no `/reopen` exists for one). The add keeps a recorded kiken, fusenpai or fusensho; the organiser's remove of an unscored representative bout does not: it clears the match's decision and puts it back to running (`clearMatchVerdict`), while the competitor's bar stays recorded. Accepted as it is (operator decision 2026-10-02); do not report it as a new defect. An encounter holds ONE representative bout: a second add is 409 `daihyosen_exists` for every caller, and the add refuses engi, kachinuki and individual competitions and pool matches. `state.DaihyosenSubIndex` is the one finder of that row (the first -1 row). diff --git a/internal/engine/scheduler_slots.go b/internal/engine/scheduler_slots.go index 6450d05cb..afbb42f7c 100644 --- a/internal/engine/scheduler_slots.go +++ b/internal/engine/scheduler_slots.go @@ -261,9 +261,11 @@ func assignPoolMatchSlots(matches []state.MatchResult, comp *state.Competition, // poolPhaseEndByCourt returns, per court, the clock time the pool phase ends: // the latest STORED pool ScheduledAt plus one pool slot // (perMatchElapsedMinutes, pool clock). Reading the stored times, rather than -// a cursor threaded out of assignPoolMatchSlots, makes it right for every -// caller, including a pool time the operator moved by hand and the quarantine -// rebuild. Rows with no parseable time are skipped. bc-kosc. +// a cursor threaded out of assignPoolMatchSlots, makes it right for both +// callers that build the preview bracket: the draw and the quarantine rebuild. +// It is read once, when the preview is built; a pool bout injected or moved +// later does not re-time the knockout. Rows with no parseable time are +// skipped. bc-kosc. func poolPhaseEndByCourt(matches []state.MatchResult, comp *state.Competition, tournament *state.Tournament) map[string]time.Time { ends := map[string]time.Time{} slot := time.Duration(perMatchElapsedMinutes(comp, tournament, false)) * time.Minute @@ -298,8 +300,10 @@ func poolPhaseEndByCourt(matches []state.MatchResult, comp *state.Competition, t // courtStart is the per-court earliest start (bc-kosc): a court's cursor // begins at the later of dayStart + OpeningBlock and courtStart[court], so a // pools + knockout competition schedules each court's knockout after THAT -// court's last pool bout (see poolPhaseEndByCourt). A nil map, or a court -// absent from it, keeps the day start (knockout-only competitions pass nil). +// court's last pool bout (see poolPhaseEndByCourt). A court absent from a +// non-empty map (one that holds no pool bout) starts after the latest pool +// end on any court. A nil or empty map keeps the day start (knockout-only +// competitions pass nil). // Byes still take their court's start and consume nothing. // // Returns the maximum per-court end-cursor (the clock time when the @@ -319,11 +323,23 @@ func assignBracketMatchSlots(rounds [][]state.BracketMatch, comp *state.Competit return openAt } + // A court that holds no pool bout still waits for the pool phase: its + // knockout is fed by qualifiers from other courts, so it starts after + // the latest pool end anywhere. + latestPoolEnd := openAt + for _, t := range courtStart { + if t.After(latestPoolEnd) { + latestPoolEnd = t + } + } startFor := func(court string) time.Time { - if t, ok := courtStart[court]; ok && t.After(openAt) { - return t + if t, ok := courtStart[court]; ok { + if t.After(openAt) { + return t + } + return openAt } - return openAt + return latestPoolEnd } courtCursor := map[string]time.Time{} for _, court := range comp.Courts { diff --git a/internal/engine/scheduler_slots_test.go b/internal/engine/scheduler_slots_test.go index 79d2b6898..9be6b790a 100644 --- a/internal/engine/scheduler_slots_test.go +++ b/internal/engine/scheduler_slots_test.go @@ -621,7 +621,7 @@ func TestAssignBracketSlots_StartAfterPoolPhase(t *testing.T) { assert.Equal(t, "09:24", rounds[0][0].ScheduledAt, "bye takes the court start") assert.Equal(t, "09:24", rounds[0][1].ScheduledAt) assert.Equal(t, "09:30", rounds[0][2].ScheduledAt, "bye consumed nothing") - assert.Equal(t, "09:00", rounds[0][3].ScheduledAt, "a court absent from the map keeps the day start") + assert.Equal(t, "09:24", rounds[0][3].ScheduledAt, "a court with no pool bout waits for the latest pool end") rounds = build() assignBracketMatchSlots(rounds, comp, tournament, map[string]time.Time{"A": parseClockHHMM("08:00")}) diff --git a/web-mobile/js/__tests__/render/hantei_single_tap.render.test.jsx b/web-mobile/js/__tests__/render/hantei_single_tap.render.test.jsx index b16319107..4395a45ee 100644 --- a/web-mobile/js/__tests__/render/hantei_single_tap.render.test.jsx +++ b/web-mobile/js/__tests__/render/hantei_single_tap.render.test.jsx @@ -152,4 +152,35 @@ describe('hantei side buttons only pick (bc-htsd)', () => { expect(onSubmit.mock.calls[0][0].winner).toMatchObject({ id: 'p2', name: 'Tanaka' }); expect(onSubmit.mock.calls[0][0].decidedByHantei).toBe(true); }); + + it('leaving a running match with hantei only armed asks nothing', async () => { + const onClose = vi.fn(); + window.confirmDialog.mockClear(); + render(); + await click(screen.getByTestId('scoring-modal-hantei-arm')); + await act(async () => { fireEvent.keyDown(document, { key: 'Escape' }); }); + expect(window.confirmDialog, 'the arm alone is a mode: leaving asks nothing').not.toHaveBeenCalled(); + expect(onClose).toHaveBeenCalledTimes(1); + }); + + it('leaving a running match after picking a side prompts, and staying keeps the editor', async () => { + const onClose = vi.fn(); + window.confirmDialog.mockClear(); + window.confirmDialog.mockResolvedValueOnce(false); + render(); + await armAndPick('scoring-modal-hantei-aka'); + await act(async () => { fireEvent.keyDown(document, { key: 'Escape' }); }); + expect(window.confirmDialog).toHaveBeenCalledTimes(1); + expect(onClose).not.toHaveBeenCalled(); + }); + + it('a recorded hantei on an untied scoreline (legacy data) cannot be finished', async () => { + const onSubmit = vi.fn().mockResolvedValue(undefined); + render(); + expect(screen.queryByTestId('scoring-modal-hantei-row')).toBeNull(); + const save = [...document.querySelectorAll('.score-nav button')].find((x) => x.textContent === 'Save correction'); + expect(save.disabled, 'a Save would send a hantei the server refuses on an untied line').toBe(true); + }); }); diff --git a/web-mobile/js/admin_scoring_individual.jsx b/web-mobile/js/admin_scoring_individual.jsx index cf6687d0c..51f2db0bb 100644 --- a/web-mobile/js/admin_scoring_individual.jsx +++ b/web-mobile/js/admin_scoring_individual.jsx @@ -763,7 +763,9 @@ export function ScoreEditorModal({ match, onClose, onSubmit, onSubmitAndNext, on ? "Needs a winner: fight encho, then ask the organizer for a hantei if still tied." : "Needs a winner: fight encho, then record hantei if still tied."; // bc-htsd: hantei is valid at 0-0, so the picked side stands in for points. - const canFinish = decidedByHantei ? !!hanteiPick : (!koTieBlocked && hasPointsOrDraw); + // It needs the tied scoreline the hantei row is shown on: a recorded hantei + // on an untied line (legacy data) cannot be finished, as before. + const canFinish = decidedByHantei ? (!!hanteiPick && aTotal === bTotal) : (!koTieBlocked && hasPointsOrDraw); // Finish guard (see TeamScoreEditorModal): one tap ARMS the button — its label // becomes an explicit "Tap again to finish" INSTRUCTION (not a verdict), so the @@ -835,12 +837,12 @@ export function ScoreEditorModal({ match, onClose, onSubmit, onSubmitAndNext, on // bc-dscn: on a RUNNING match every scoring edit is autosaved, and the // unmount writes one still inside the debounce window // (useDebouncedRunningWrite), so leaving discards nothing and asks - // nothing. Two local states are NOT in buildPatch("running"): the hantei - // ARM, which is only a mode (the verdict is committed by Finish with a - // picked side) and is dropped with no write (it never marks dirty), and - // the hikiwake toggle, which is a result the operator entered; that one - // keeps the prompt below, because leaving would lose it. - if (m.status === "running" && isDrawToggled === initialIsDrawToggled) { + // nothing. Local states NOT in buildPatch("running"): the hantei ARM, + // which is only a mode and is dropped with no write (it never marks + // dirty), and two results the operator entered, the hantei side pick + // (committed only by Finish) and the hikiwake toggle; those keep the + // prompt below, because leaving would lose them. + if (m.status === "running" && isDrawToggled === initialIsDrawToggled && hanteiPick === recordedHtKey) { go(); return; } diff --git a/web-mobile/js/admin_shiaijo.jsx b/web-mobile/js/admin_shiaijo.jsx index 9660ac2d7..a63cd1fe4 100644 --- a/web-mobile/js/admin_shiaijo.jsx +++ b/web-mobile/js/admin_shiaijo.jsx @@ -25,7 +25,7 @@ import { useDialogFocus } from './dialog_focus.jsx'; // api_client.jsx does not apply to it — the same move admin_scoring_shared.jsx // already makes. import { - writeDidNotLand, writeWasSuperseded, writeWasRefusedForClock, CLOCK_SKEW_REASON_TEXT, + writeDidNotLand, writeKeepsEditorOpen, writeWasSuperseded, writeWasRefusedForClock, CLOCK_SKEW_REASON_TEXT, attemptScoreWrite, DOWNSTREAM_KNOCKOUT_PLAYED_CANCELLED, OVERRIDE_HELD_NOTICE, startWhileCorrectingMessage, correctWhileRunningMessage, } from './write_result.jsx'; @@ -1856,7 +1856,7 @@ function AdminShiaijoPage({ tournament, court: routeCourt, onBack, onEditScore, // A landed Save correction ends the correction (a // write that did not land keeps it open, with the // editor's not-saved banner). - if (res && !writeDidNotLand(res) && patch && patch.status === "completed" + if (res && patch && patch.status === "completed" && !writeKeepsEditorOpen(patch, res) && correctingKey && correctingKey === matchKey(selectedMatch) && mountedRef.current) { setCorrectingKey(null); } diff --git a/web-mobile/js/pool_ids.jsx b/web-mobile/js/pool_ids.jsx index c90cab8ef..41d92c7bd 100644 --- a/web-mobile/js/pool_ids.jsx +++ b/web-mobile/js/pool_ids.jsx @@ -1,7 +1,7 @@ // pool_ids.jsx: canonical parser for pool-match ids. A leaf that imports only -// write_result.jsx (itself a leaf with no imports, for scoreRowMatchLabel's -// matchLabel below), so both display_helpers.jsx and admin_pools.jsx can -// import it without pulling a real transitive import chain into either: +// write_result.jsx and side_cell.jsx (each a leaf with no imports, for +// scoreRowMatchLabel's matchLabel and scoreRowMatchName's sideWord), so both +// display_helpers.jsx and admin_pools.jsx can import it without pulling a real transitive import chain into either: // admin_pools.jsx otherwise relies on window globals rather than ESM // imports, so a leaf this close to dependency-free keeps its module graph // trivial. Single source of truth for the pool-id parse rule used across the @@ -18,6 +18,7 @@ // This regex constant is intentionally module-private: callers use the // exported poolNameOf() wrapper below, never the raw pattern. import { matchLabel } from './write_result.jsx'; +import { sideWord } from './side_cell.jsx'; // DAIHYOSEN_POSITION is the sentinel `position` value marking a sub-bout as // the daihyosen (representative bout) rather than a numbered roster bout @@ -122,7 +123,7 @@ export function scoreRowMatchLabel(m) { // vs Sato"); the bare pairing when the row carries no label (a daihyosen or // tiebreaker bout, an unnumbered knockout row). export function scoreRowMatchName(m) { - const pairing = `${m.sideB?.name || "Shiro"} vs ${m.sideA?.name || "Aka"}`; + const pairing = `${m.sideB?.name || sideWord("shiro")} vs ${m.sideA?.name || sideWord("aka")}`; const label = scoreRowMatchLabel(m); return label ? `${label} · ${pairing}` : pairing; } From 5232a3232702392cb3fb56c8451ce65a46c39abe Mon Sep 17 00:00:00 2001 From: Ricardo Oliveira Date: Wed, 7 Oct 2026 22:17:27 +0100 Subject: [PATCH 12/16] fix(bc-kosc): move the knockout past a tie-break added after the draw A tie-break or representative bout added once a pool is finished is appended after its court's last pool bout, which is where that court's knockout was timed, so the two ran at the same time. pushKnockoutPastPools moves a court's scheduled knockout matches after the new pool end, keeping their order and gaps and skipping lunch, in the same transaction as the injection. A court whose knockout already starts after its pools, or where a knockout match has started, is left as it is. Co-Authored-By: Claude Opus 5.5 --- internal/engine/daihyosen.go | 22 ++++++- internal/engine/mixed_schedule_test.go | 84 +++++++++++++++++++++++++ internal/engine/scheduler_slots.go | 86 +++++++++++++++++++++++++- internal/engine/tiebreaker.go | 4 +- 4 files changed, 190 insertions(+), 6 deletions(-) diff --git a/internal/engine/daihyosen.go b/internal/engine/daihyosen.go index d2b5b239e..6bf92a17e 100644 --- a/internal/engine/daihyosen.go +++ b/internal/engine/daihyosen.go @@ -292,7 +292,7 @@ func (e *Engine) InjectPoolDaihyosenMatches(compID string) ([]state.MatchResult, if tournErr != nil { return tournErr } - return tx.SavePoolMatches(compID, appendWithSlots(allMatches, injected, comp, tournament)) + return savePoolMatchesAndKnockoutTimes(tx, compID, appendWithSlots(allMatches, injected, comp, tournament), comp, tournament) }) if err != nil { return nil, err @@ -331,6 +331,26 @@ func appendWithSlots(allMatches, injected []state.MatchResult, comp *state.Compe return allMatches } +// savePoolMatchesAndKnockoutTimes saves a pool phase that gained bouts after +// the draw and, in a pools + knockout competition, moves the knockout matches +// those bouts now run into (pushKnockoutPastPools), in the same transaction. +func savePoolMatchesAndKnockoutTimes(tx state.StoreTx, compID string, matches []state.MatchResult, comp *state.Competition, tournament *state.Tournament) error { + if err := tx.SavePoolMatches(compID, matches); err != nil { + return err + } + if !comp.IsKnockoutEnabled() { + return nil + } + bracket, err := tx.LoadBracket(compID) + if err != nil { + return err + } + if !pushKnockoutPastPools(bracket, comp, tournament, matches) { + return nil + } + return tx.SaveBracket(compID, bracket) +} + // ComputeTeamSummary aggregates SubMatchResult entries into TeamSummary // values for the two named sides. Sub-results whose Winner is empty // (drawn bouts / hikiwake) contribute nothing to IV but their ippons diff --git a/internal/engine/mixed_schedule_test.go b/internal/engine/mixed_schedule_test.go index eaa9edabe..3373d3520 100644 --- a/internal/engine/mixed_schedule_test.go +++ b/internal/engine/mixed_schedule_test.go @@ -80,3 +80,87 @@ func TestGenerateDraw_KnockoutOnly_StartsAtDayStart(t *testing.T) { } assert.Equal(t, "09:00", first) } + +// bc-kosc: a tie-break bout added after the draw is appended after its +// court's last pool bout, where the knockout used to start; the knockout +// moves past it. +func TestInjectTiebreaker_MovesTheKnockoutPastTheNewBout(t *testing.T) { + eng, store, _ := setupTestEngine(t) + id := "bc-kosc-tb" + createTestCompetition(t, store, id, state.CompFormatMixed, 3, func(c *state.Competition) { c.Courts = []string{"A"} }) + saveTestParticipants(t, store, id, kosc6) + require.NoError(t, eng.GenerateDraw(id)) + + pm, err := store.LoadPoolMatches(id) + require.NoError(t, err) + pool := "" + for _, m := range pm { + pn, ok := poolNameFromMatchID(m.ID) + require.True(t, ok) + if pool == "" { + pool = pn + } + if pn != pool { + continue + } + found, err := store.UpdatePoolMatchByID(id, m.ID, func(r *state.MatchResult) error { + r.Status = state.MatchStatusCompleted + r.Decision = "hikiwake" + return nil + }) + require.NoError(t, err) + require.True(t, found) + } + + injected, err := eng.InjectTiebreakerMatches(id) + require.NoError(t, err) + require.NotEmpty(t, injected, "a three-way draw for first needs a tie-break") + + pm, err = store.LoadPoolMatches(id) + require.NoError(t, err) + comp, err := store.LoadCompetition(id) + require.NoError(t, err) + tourn, err := store.LoadTournament() + require.NoError(t, err) + end := poolPhaseEndByCourt(pm, comp, tourn)["A"] + b, err := store.LoadBracket(id) + require.NoError(t, err) + times := numberedKnockoutTimes(b)["A"] + require.NotEmpty(t, times) + for _, ts := range times { + assert.Falsef(t, ts.Before(end), "knockout at %s starts before the tie-break ends at %s", ts.Format("15:04"), end.Format("15:04")) + } +} + +// pushKnockoutPastPools leaves a court alone when its knockout already starts +// after its pools (a time the operator set survives) and when a knockout +// match there has started; otherwise it keeps the order and gaps. +func TestPushKnockoutPastPools(t *testing.T) { + comp := &state.Competition{StartTime: "09:00", KnockoutMatchDurationSeconds: 240, PoolMatchDurationSeconds: 240, Courts: []string{"A", "B", "C"}} + pool := func(court, at string) state.MatchResult { + return state.MatchResult{ID: "p", Court: court, ScheduledAt: at} + } + ko := func(id, court, at string, n int, st state.MatchStatus) state.BracketMatch { + return state.BracketMatch{ID: id, Court: court, ScheduledAt: at, MatchNumber: n, Status: st} + } + b := &state.Bracket{Rounds: [][]state.BracketMatch{{ + ko("a2", "A", "10:30", 2, state.MatchStatusScheduled), + ko("a1", "A", "10:00", 1, state.MatchStatusScheduled), + ko("b1", "B", "11:00", 3, state.MatchStatusScheduled), + ko("c1", "C", "09:30", 4, state.MatchStatusRunning), + ko("c2", "C", "09:36", 5, state.MatchStatusScheduled), + }}} + // One pool slot is 4 * 1.5 = 6 minutes: A's pools end 10:06, B's 10:06. + pm := []state.MatchResult{pool("A", "10:00"), pool("B", "10:00"), pool("C", "10:00")} + require.True(t, pushKnockoutPastPools(b, comp, nil, pm)) + got := map[string]string{} + for _, m := range b.Rounds[0] { + got[m.ID] = m.ScheduledAt + } + assert.Equal(t, "10:06", got["a1"], "moved to the pool end") + assert.Equal(t, "10:30", got["a2"], "already clear of a1, keeps its time") + assert.Equal(t, "11:00", got["b1"], "already after its pools: left as set") + assert.Equal(t, "09:36", got["c2"], "a court whose knockout has started is not touched") + + assert.False(t, pushKnockoutPastPools(b, comp, nil, pm), "nothing left to move") +} diff --git a/internal/engine/scheduler_slots.go b/internal/engine/scheduler_slots.go index afbb42f7c..3cb920d58 100644 --- a/internal/engine/scheduler_slots.go +++ b/internal/engine/scheduler_slots.go @@ -262,9 +262,10 @@ func assignPoolMatchSlots(matches []state.MatchResult, comp *state.Competition, // the latest STORED pool ScheduledAt plus one pool slot // (perMatchElapsedMinutes, pool clock). Reading the stored times, rather than // a cursor threaded out of assignPoolMatchSlots, makes it right for both -// callers that build the preview bracket: the draw and the quarantine rebuild. -// It is read once, when the preview is built; a pool bout injected or moved -// later does not re-time the knockout. Rows with no parseable time are +// callers that build the preview bracket (the draw and the quarantine +// rebuild) and for pushKnockoutPastPools, which moves the knockout when a +// tie-break or representative bout is added later. A pool time the operator +// moves by hand does not move the knockout. Rows with no parseable time are // skipped. bc-kosc. func poolPhaseEndByCourt(matches []state.MatchResult, comp *state.Competition, tournament *state.Tournament) map[string]time.Time { ends := map[string]time.Time{} @@ -285,6 +286,85 @@ func poolPhaseEndByCourt(matches []state.MatchResult, comp *state.Competition, t return ends } +// pushKnockoutPastPools moves a court's knockout matches after that court's +// pool phase when a pool bout added after the draw (a tie-break or +// representative bout, appended at the end of its court) now runs into them +// (bc-kosc). A court whose first scheduled knockout match already starts at or +// after its pool end is left exactly as it is, so a time the operator set +// survives. Otherwise its scheduled matches keep their order and gaps and are +// moved only as far as they must be: each starts no earlier than the pool end, +// and no earlier than the end of the match before it, past lunch. A court +// where a knockout match is already running or finished is not touched. +// Courts are judged as assignBracketMatchSlots judges them: one with no pool +// bout waits for the latest pool end on any court. Reports whether anything +// moved. +func pushKnockoutPastPools(b *state.Bracket, comp *state.Competition, tournament *state.Tournament, poolMatches []state.MatchResult) bool { + if b == nil || comp == nil { + return false + } + ends := poolPhaseEndByCourt(poolMatches, comp, tournament) + if len(ends) == 0 { + return false + } + var latest time.Time + for _, t := range ends { + if t.After(latest) { + latest = t + } + } + byCourt := map[string][]*state.BracketMatch{} + started := map[string]bool{} + visit := func(m *state.BracketMatch) { + switch { + case m.Status == state.MatchStatusScheduled && m.ScheduledAt != "": + byCourt[m.Court] = append(byCourt[m.Court], m) + case m.Status != state.MatchStatusScheduled && m.MatchNumber > 0: + started[m.Court] = true + } + } + for r := range b.Rounds { + for i := range b.Rounds[r] { + visit(&b.Rounds[r][i]) + } + } + if b.ThirdPlaceMatch != nil { + visit(b.ThirdPlaceMatch) + } + + _, _, lunchMin, lunchStart := parseCeremonyParams(comp, tournament) + slot := time.Duration(perMatchElapsedMinutes(comp, tournament, true)) * time.Minute + moved := false + for court, ms := range byCourt { + if started[court] { + continue + } + poolEnd, ok := ends[court] + if !ok { + poolEnd = latest + } + sort.SliceStable(ms, func(i, j int) bool { + return parseClockHHMM(ms[i].ScheduledAt).Before(parseClockHHMM(ms[j].ScheduledAt)) + }) + if !parseClockHHMM(ms[0].ScheduledAt).Before(poolEnd) { + continue + } + cursor := poolEnd + for _, m := range ms { + t := parseClockHHMM(m.ScheduledAt) + if t.Before(cursor) { + t = cursor + } + t = skipCeremonyBlocks(t, lunchStart, lunchMin) + if at := t.Format(scheduleClockLayout); at != m.ScheduledAt { + m.ScheduledAt = at + moved = true + } + cursor = t.Add(slot) + } + } + return moved +} + // assignBracketMatchSlots is the bracket analogue of // assignPoolMatchSlots. Bracket matches carry the same Court field // as pool matches; matches are walked in match-number order (see the diff --git a/internal/engine/tiebreaker.go b/internal/engine/tiebreaker.go index 5722e5d9f..2d2174720 100644 --- a/internal/engine/tiebreaker.go +++ b/internal/engine/tiebreaker.go @@ -548,8 +548,8 @@ func (e *Engine) InjectTiebreakerMatches(compID string) ([]state.MatchResult, er return tournErr } // Reassign slots so the new TB matches get ScheduledAt values, - // keeping operator-adjusted times. - return tx.SavePoolMatches(compID, appendWithSlots(allMatches, injected, comp, tournament)) + // keeping operator-adjusted times, and move the knockout past them. + return savePoolMatchesAndKnockoutTimes(tx, compID, appendWithSlots(allMatches, injected, comp, tournament), comp, tournament) }) if err != nil { return nil, err From f5cab2b1802d3d666d74d2d0455716934bcd90cb Mon Sep 17 00:00:00 2001 From: Ricardo Oliveira Date: Wed, 7 Oct 2026 22:25:52 +0100 Subject: [PATCH 13/16] refactor(bc-kosc): drop a nil-competition check no caller can reach Co-Authored-By: Claude Opus 5.5 --- internal/engine/scheduler_slots.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/engine/scheduler_slots.go b/internal/engine/scheduler_slots.go index 3cb920d58..c5a287fbf 100644 --- a/internal/engine/scheduler_slots.go +++ b/internal/engine/scheduler_slots.go @@ -299,7 +299,7 @@ func poolPhaseEndByCourt(matches []state.MatchResult, comp *state.Competition, t // bout waits for the latest pool end on any court. Reports whether anything // moved. func pushKnockoutPastPools(b *state.Bracket, comp *state.Competition, tournament *state.Tournament, poolMatches []state.MatchResult) bool { - if b == nil || comp == nil { + if b == nil { return false } ends := poolPhaseEndByCourt(poolMatches, comp, tournament) From 1972b66f08c5c5cc72240fe17638b9362a17030f Mon Sep 17 00:00:00 2001 From: Ricardo Oliveira Date: Wed, 7 Oct 2026 22:37:03 +0100 Subject: [PATCH 14/16] fix(bc-kosc): a court move keeps that court's knockout after its pools A pool bout moved onto another court keeps its time, and on that court it can now run into the knockout. UpdateMatchCourt moves that court's knockout past it in the same transaction, and the handler announces a schedule change so every screen picks up the new times. A time set by hand still moves nothing: it is the operator ordering the court, and the queue's up/down is two such edits. Pushing there would undo moving a knockout match ahead of a remaining pool bout. The draw passes the pool matches generatePools saved straight to the preview bracket instead of reading them back; the quarantine rebuild reads them from disk itself. Co-Authored-By: Claude Opus 5.5 --- internal/engine/bracket.go | 13 ++- internal/engine/competition.go | 5 +- internal/engine/daihyosen.go | 20 ----- internal/engine/mixed_schedule_test.go | 88 +++++++++++++++++++ internal/engine/pools.go | 26 +++--- .../pools_extra_qualifiers_order_test.go | 3 +- internal/engine/quarantine.go | 6 +- internal/engine/scheduler_slots.go | 39 ++++++-- internal/engine/scoring.go | 45 +++++++++- internal/mobileapp/handlers_match.go | 3 + 10 files changed, 193 insertions(+), 55 deletions(-) diff --git a/internal/engine/bracket.go b/internal/engine/bracket.go index 1bbbe7654..a5682df48 100644 --- a/internal/engine/bracket.go +++ b/internal/engine/bracket.go @@ -111,7 +111,11 @@ func (e *Engine) generateKnockout(comp *state.Competition, players []domain.Play // with pools.csv on disk and no knockout to score into). Draw building goes // through buildPoolFedDraw (knockout_skeleton.go), shared with the export // path's poolDraw, so both agree on which builder a given competition uses. -func (e *Engine) generatePoolPreviewBracket(comp *state.Competition) error { +// +// poolMatches is the saved pool phase (the draw passes what generatePools +// saved, the quarantine rebuild what is on disk): each court's knockout is +// timed after that court's last pool bout (bc-kosc). +func (e *Engine) generatePoolPreviewBracket(comp *state.Competition, poolMatches []state.MatchResult) error { pools, err := e.store.LoadPools(comp.ID) if err != nil { return fmt.Errorf("loading pools for preview bracket: %w", err) @@ -154,13 +158,6 @@ func (e *Engine) generatePoolPreviewBracket(comp *state.Competition) error { // never resolved competitors at draw time, so there is nothing to stamp // (see buildBracketFromDraw's own doc comment). ResolveQualifiedPools // stamps ids as each placeholder resolves to a real pool finisher. - // The pool matches are already saved (generatePools, or the quarantine - // rebuild's existing file): each court's knockout is timed after that - // court's last pool bout (bc-kosc). - poolMatches, err := e.store.LoadPoolMatches(comp.ID) - if err != nil { - return fmt.Errorf("loading pool matches for preview bracket: %w", err) - } bracket, err := e.buildBracketFromDraw(comp, draw, nil, poolMatches) if err != nil { return err diff --git a/internal/engine/competition.go b/internal/engine/competition.go index 15c3dbe3b..77ff52b45 100644 --- a/internal/engine/competition.go +++ b/internal/engine/competition.go @@ -1276,7 +1276,8 @@ func (e *Engine) runDrawPipeline(id string) error { if err := clearEarlierDraw(); err != nil { return err } - if err := e.generatePools(comp, players, seeds); err != nil { + poolMatches, err := e.generatePools(comp, players, seeds) + if err != nil { return err } // mp-9dz: a mixed (Pools + Knockout) competition feeds a knockout @@ -1285,7 +1286,7 @@ func (e *Engine) runDrawPipeline(id string) error { // source competition at draw time, mirroring the Excel Tree sheet. // League has no knockout stage, so skip it there. if comp.Format == state.CompFormatMixed { - if err := e.generatePoolPreviewBracket(comp); err != nil { + if err := e.generatePoolPreviewBracket(comp, poolMatches); err != nil { return err } } diff --git a/internal/engine/daihyosen.go b/internal/engine/daihyosen.go index 6bf92a17e..2b88c2017 100644 --- a/internal/engine/daihyosen.go +++ b/internal/engine/daihyosen.go @@ -331,26 +331,6 @@ func appendWithSlots(allMatches, injected []state.MatchResult, comp *state.Compe return allMatches } -// savePoolMatchesAndKnockoutTimes saves a pool phase that gained bouts after -// the draw and, in a pools + knockout competition, moves the knockout matches -// those bouts now run into (pushKnockoutPastPools), in the same transaction. -func savePoolMatchesAndKnockoutTimes(tx state.StoreTx, compID string, matches []state.MatchResult, comp *state.Competition, tournament *state.Tournament) error { - if err := tx.SavePoolMatches(compID, matches); err != nil { - return err - } - if !comp.IsKnockoutEnabled() { - return nil - } - bracket, err := tx.LoadBracket(compID) - if err != nil { - return err - } - if !pushKnockoutPastPools(bracket, comp, tournament, matches) { - return nil - } - return tx.SaveBracket(compID, bracket) -} - // ComputeTeamSummary aggregates SubMatchResult entries into TeamSummary // values for the two named sides. Sub-results whose Winner is empty // (drawn bouts / hikiwake) contribute nothing to IV but their ippons diff --git a/internal/engine/mixed_schedule_test.go b/internal/engine/mixed_schedule_test.go index 3373d3520..d47b3dbec 100644 --- a/internal/engine/mixed_schedule_test.go +++ b/internal/engine/mixed_schedule_test.go @@ -164,3 +164,91 @@ func TestPushKnockoutPastPools(t *testing.T) { assert.False(t, pushKnockoutPastPools(b, comp, nil, pm), "nothing left to move") } + +// bc-kosc: a pool bout moved onto another court moves that court's knockout +// past it. A time set by hand moves nothing: it is the operator ordering the +// court, and the queue's up/down is two of them. +func TestPoolScheduleEdit_CourtMoveMovesTheKnockout(t *testing.T) { + setup := func(t *testing.T, courts []string) (*Engine, *state.Store, string) { + eng, store, _ := setupTestEngine(t) + id := "bc-kosc-edit" + createTestCompetition(t, store, id, state.CompFormatMixed, 3, func(c *state.Competition) { c.Courts = courts }) + saveTestParticipants(t, store, id, kosc6) + require.NoError(t, eng.GenerateDraw(id)) + return eng, store, id + } + bracket := func(t *testing.T, store *state.Store, id string) *state.Bracket { + b, err := store.LoadBracket(id) + require.NoError(t, err) + return b + } + pool := func(t *testing.T, store *state.Store, id string) []state.MatchResult { + pm, err := store.LoadPoolMatches(id) + require.NoError(t, err) + require.NotEmpty(t, pm) + return pm + } + + t.Run("a court move", func(t *testing.T) { + eng, store, id := setup(t, []string{"A", "B"}) + m := pool(t, store, id)[0] + other := "A" + if m.Court == "A" { + other = "B" + } + // Give the bout a time inside the other court's knockout, on its own + // court first (a time edit, which moves nothing), then move it across. + ko := numberedKnockoutTimes(bracket(t, store, id))[other] + require.NotEmpty(t, ko) + require.NoError(t, eng.UpdateMatchTime(id, m.ID, ko[0].Format(scheduleClockLayout))) + require.NoError(t, eng.UpdateMatchCourt(id, m.ID, other)) + comp, err := store.LoadCompetition(id) + require.NoError(t, err) + tourn, err := store.LoadTournament() + require.NoError(t, err) + end := poolPhaseEndByCourt(pool(t, store, id), comp, tourn)[other] + for _, ts := range numberedKnockoutTimes(bracket(t, store, id))[other] { + assert.Falsef(t, ts.Before(end), "court %s knockout at %s before the moved bout ends at %s", other, ts.Format("15:04"), end.Format("15:04")) + } + }) + + t.Run("a time set by hand", func(t *testing.T) { + eng, store, id := setup(t, []string{"A"}) + before := numberedKnockoutTimes(bracket(t, store, id)) + last := before["A"][len(before["A"])-1] + require.NoError(t, eng.UpdateMatchTime(id, pool(t, store, id)[0].ID, last.Add(time.Hour).Format(scheduleClockLayout))) + assert.Equal(t, before, numberedKnockoutTimes(bracket(t, store, id))) + }) + + t.Run("up on the first knockout match stays", func(t *testing.T) { + eng, store, id := setup(t, []string{"A"}) + pm := pool(t, store, id) + lastPool := pm[0] + for _, m := range pm { + if parseClockHHMM(m.ScheduledAt).After(parseClockHHMM(lastPool.ScheduledAt)) { + lastPool = m + } + } + var first state.BracketMatch + for _, r := range bracket(t, store, id).Rounds { + for _, m := range r { + if m.MatchNumber == 1 { + first = m + } + } + } + require.NotEmpty(t, first.ID) + // The queue's swap: the moved match first, then its neighbour. + require.NoError(t, eng.UpdateMatchTime(id, first.ID, lastPool.ScheduledAt)) + require.NoError(t, eng.UpdateMatchTime(id, lastPool.ID, first.ScheduledAt)) + got := "" + for _, r := range bracket(t, store, id).Rounds { + for _, m := range r { + if m.ID == first.ID { + got = m.ScheduledAt + } + } + } + assert.Equal(t, lastPool.ScheduledAt, got, "the knockout match the operator moved up stays ahead of the pool bout") + }) +} diff --git a/internal/engine/pools.go b/internal/engine/pools.go index af81191e4..df5b5ebd0 100644 --- a/internal/engine/pools.go +++ b/internal/engine/pools.go @@ -10,14 +10,14 @@ import ( "github.com/gitrgoliveira/bracket-creator/internal/state" ) -func (e *Engine) generatePools(comp *state.Competition, players []domain.Player, seeds []domain.SeedAssignment) error { +func (e *Engine) generatePools(comp *state.Competition, players []domain.Player, seeds []domain.SeedAssignment) ([]state.MatchResult, error) { // PoolSize is the divisor in CreatePools (and is fed to PoolSeeding); a // zero/negative value would otherwise reach helper.CreatePools and the // guard there returns a plain error mapped to HTTP 500. Validate up front // so a competition started with an unset PoolSize fails as a clean 400 // (validationErrorf → *ValidationError) with an actionable message. (mp-ebgz) if comp.PoolSize <= 0 { - return validationErrorf("competition %s cannot start: pool size must be at least 1, got %d, set a pool size before starting", comp.ID, comp.PoolSize) + return nil, validationErrorf("competition %s cannot start: pool size must be at least 1, got %d, set a pool size before starting", comp.ID, comp.PoolSize) } isMax := comp.PoolSizeMode == "max" @@ -47,7 +47,7 @@ func (e *Engine) generatePools(comp *state.Competition, players []domain.Player, poolFedKnockout := comp.Format == state.CompFormatMixed if poolFedKnockout { if err := state.ValidateExtraQualifiers(comp.ExtraQualifiers, comp.PoolSizeMode, comp.EffectivePoolWinners()); err != nil { - return wrapValidationErrorf(err, "competition %s cannot start: %s", comp.ID, err.Error()) + return nil, wrapValidationErrorf(err, "competition %s cannot start: %s", comp.ID, err.Error()) } } @@ -66,7 +66,7 @@ func (e *Engine) generatePools(comp *state.Competition, players []domain.Player, // Excel-coupled helpers accept domain values directly. if len(seeds) > 0 { if err := helper.ApplySeeds(players, seeds); err != nil { - return fmt.Errorf("applying seeds: %w", err) + return nil, fmt.Errorf("applying seeds: %w", err) } } @@ -103,7 +103,7 @@ func (e *Engine) generatePools(comp *state.Competition, players []domain.Player, // count and minimum pool size, so it is wrapped as a clean, // actionable *ValidationError (-> HTTP 400) rather than // restated. - return wrapValidationErrorf(err, "competition %s cannot start: %s", comp.ID, err.Error()) + return nil, wrapValidationErrorf(err, "competition %s cannot start: %s", comp.ID, err.Error()) } } else { // helper.BuildPoolPhaseTreeAwareWithMode, not plain @@ -152,7 +152,7 @@ func (e *Engine) generatePools(comp *state.Competition, players []domain.Player, // guard belongs to the actionable class -- that is what would // let a future writer add a genuine internal fault here and // assume 400 was the considered answer. - return wrapValidationErrorf(err, "competition %s cannot start: %s", comp.ID, err.Error()) + return nil, wrapValidationErrorf(err, "competition %s cannot start: %s", comp.ID, err.Error()) } } @@ -165,7 +165,7 @@ func (e *Engine) generatePools(comp *state.Competition, players []domain.Player, // (league/swiss legitimately produce 1 pool, exempted.) if comp.Format == state.CompFormatMixed { if len(pools) < 2 { - return validationErrorf("mixed (Pools + Knockout) competition %s requires at least 2 pools, got %d with %d participants at PoolSize=%d; reduce PoolSize, add participants, or change format to league", comp.ID, len(pools), len(players), comp.PoolSize) + return nil, validationErrorf("mixed (Pools + Knockout) competition %s requires at least 2 pools, got %d with %d participants at PoolSize=%d; reduce PoolSize, add participants, or change format to league", comp.ID, len(pools), len(players), comp.PoolSize) } // Every pool must be able to supply PoolWinners finishers to the knockout. // In "max" mode an odd participant count can leave an under-filled last @@ -177,7 +177,7 @@ func (e *Engine) generatePools(comp *state.Competition, players []domain.Player, poolWinners := comp.EffectivePoolWinners() for _, p := range pools { if len(p.Players) < poolWinners { - return validationErrorf("mixed (Pools + Knockout) competition %s: pool %q has only %d participant(s) but %d advance to the knockout (PoolWinners=%d), every pool needs at least PoolWinners participants; reduce PoolWinners, adjust PoolSize/pool-size-mode, or add participants", comp.ID, p.PoolName, len(p.Players), poolWinners, poolWinners) + return nil, validationErrorf("mixed (Pools + Knockout) competition %s: pool %q has only %d participant(s) but %d advance to the knockout (PoolWinners=%d), every pool needs at least PoolWinners participants; reduce PoolWinners, adjust PoolSize/pool-size-mode, or add participants", comp.ID, p.PoolName, len(p.Players), poolWinners, poolWinners) } } // bc-qual LP-3c's defense-in-depth ValidateExtraQualifiers check used @@ -217,13 +217,13 @@ func (e *Engine) generatePools(comp *state.Competition, players []domain.Player, // Same prefix as this function's three other refusals: the // operator sees these side by side and a bare sentence here read // as coming from somewhere else entirely. - return wrapValidationErrorf(err, "competition %s cannot start: %s", comp.ID, err.Error()) + return nil, wrapValidationErrorf(err, "competition %s cannot start: %s", comp.ID, err.Error()) } } // Save pools if err := e.store.SavePools(comp.ID, pools); err != nil { - return err + return nil, err } // drawCourts, derived above, is reused rather than recomputed: this @@ -231,7 +231,7 @@ func (e *Engine) generatePools(comp *state.Competition, players []domain.Player, // second derivation is a second thing to keep in step. courtAssign, err := helper.AssignPoolsToCourts(len(pools), drawCourts) if err != nil { - return fmt.Errorf("assigning pools to courts: %w", err) + return nil, fmt.Errorf("assigning pools to courts: %w", err) } var results []state.MatchResult @@ -276,7 +276,7 @@ func (e *Engine) generatePools(comp *state.Competition, players []domain.Player, // pipeline. tournament, err := e.store.LoadTournament() if err != nil { - return err + return nil, err } if comp.Format == state.CompFormatLeague { @@ -316,7 +316,7 @@ func (e *Engine) generatePools(comp *state.Competition, players []domain.Player, } numberPoolMatchesInPlayingOrder(results) - return e.store.SavePoolMatches(comp.ID, results) + return results, e.store.SavePoolMatches(comp.ID, results) } // numberPoolMatchesInPlayingOrder gives each pool's bouts their ids in the diff --git a/internal/engine/pools_extra_qualifiers_order_test.go b/internal/engine/pools_extra_qualifiers_order_test.go index afec77072..809f44a52 100644 --- a/internal/engine/pools_extra_qualifiers_order_test.go +++ b/internal/engine/pools_extra_qualifiers_order_test.go @@ -106,7 +106,8 @@ func TestGeneratePools_LeagueIgnoresFillBracketFormation(t *testing.T) { comp, err := store.LoadCompetition(compID) require.NoError(t, err) - require.NoError(t, eng.generatePools(comp, players, nil)) + _, err = eng.generatePools(comp, players, nil) + require.NoError(t, err) pools, err := store.LoadPools(compID) require.NoError(t, err) diff --git a/internal/engine/quarantine.go b/internal/engine/quarantine.go index 8557eb7ab..25a984af0 100644 --- a/internal/engine/quarantine.go +++ b/internal/engine/quarantine.go @@ -121,7 +121,11 @@ func (e *Engine) QuarantineCorruptBracket(id string) (*QuarantineResult, error) // The pool draw survives in pools.csv, so the knockout structure is // rebuildable from it by the same builder that drew it. - if err := e.generatePoolPreviewBracket(comp); err != nil { + poolMatches, err := e.store.LoadPoolMatches(id) + if err != nil { + return nil, fmt.Errorf("loading the pool matches for %s: %w", id, err) + } + if err := e.generatePoolPreviewBracket(comp, poolMatches); err != nil { return nil, fmt.Errorf("rebuilding the bracket for %s: %w", id, err) } result.Rebuilt = true diff --git a/internal/engine/scheduler_slots.go b/internal/engine/scheduler_slots.go index c5a287fbf..41cf801b4 100644 --- a/internal/engine/scheduler_slots.go +++ b/internal/engine/scheduler_slots.go @@ -264,9 +264,8 @@ func assignPoolMatchSlots(matches []state.MatchResult, comp *state.Competition, // a cursor threaded out of assignPoolMatchSlots, makes it right for both // callers that build the preview bracket (the draw and the quarantine // rebuild) and for pushKnockoutPastPools, which moves the knockout when a -// tie-break or representative bout is added later. A pool time the operator -// moves by hand does not move the knockout. Rows with no parseable time are -// skipped. bc-kosc. +// pool bout is added after the draw or moved onto another court. Rows with no +// parseable time are skipped. bc-kosc. func poolPhaseEndByCourt(matches []state.MatchResult, comp *state.Competition, tournament *state.Tournament) map[string]time.Time { ends := map[string]time.Time{} slot := time.Duration(perMatchElapsedMinutes(comp, tournament, false)) * time.Minute @@ -287,9 +286,10 @@ func poolPhaseEndByCourt(matches []state.MatchResult, comp *state.Competition, t } // pushKnockoutPastPools moves a court's knockout matches after that court's -// pool phase when a pool bout added after the draw (a tie-break or -// representative bout, appended at the end of its court) now runs into them -// (bc-kosc). A court whose first scheduled knockout match already starts at or +// pool phase when a pool bout now runs into them (bc-kosc): a tie-break or +// representative bout added after the draw (appended at the end of its +// court), or a pool bout the operator moved onto another court. A time set by +// hand moves nothing (UpdateMatchTime). It only ever moves the knockout later. A court whose first scheduled knockout match already starts at or // after its pool end is left exactly as it is, so a time the operator set // survives. Otherwise its scheduled matches keep their order and gaps and are // moved only as far as they must be: each starts no earlier than the pool end, @@ -365,6 +365,33 @@ func pushKnockoutPastPools(b *state.Bracket, comp *state.Competition, tournament return moved } +// savePoolMatchesAndKnockoutTimes saves a pool phase that gained bouts after +// the draw and moves the knockout matches those bouts now run into, in the +// same transaction. +func savePoolMatchesAndKnockoutTimes(tx state.StoreTx, compID string, matches []state.MatchResult, comp *state.Competition, tournament *state.Tournament) error { + if err := tx.SavePoolMatches(compID, matches); err != nil { + return err + } + return moveKnockoutPastPools(tx, compID, matches, comp, tournament) +} + +// moveKnockoutPastPools applies pushKnockoutPastPools to the stored bracket of +// a pools + knockout competition, inside the caller's transaction, and saves +// it only when something moved. +func moveKnockoutPastPools(tx state.StoreTx, compID string, poolMatches []state.MatchResult, comp *state.Competition, tournament *state.Tournament) error { + if !comp.IsKnockoutEnabled() { + return nil + } + bracket, err := tx.LoadBracket(compID) + if err != nil { + return err + } + if !pushKnockoutPastPools(bracket, comp, tournament, poolMatches) { + return nil + } + return tx.SaveBracket(compID, bracket) +} + // assignBracketMatchSlots is the bracket analogue of // assignPoolMatchSlots. Bracket matches carry the same Court field // as pool matches; matches are walked in match-number order (see the diff --git a/internal/engine/scoring.go b/internal/engine/scoring.go index 3a9b29d38..b989b5408 100644 --- a/internal/engine/scoring.go +++ b/internal/engine/scoring.go @@ -3326,10 +3326,7 @@ func parseWinnerOf(s string, numRounds int) (int, int) { } func (e *Engine) UpdateMatchCourt(compId string, matchId string, newCourt string) error { - err := e.withPoolMatch(e.store, compId, matchId, func(r *state.MatchResult) error { - r.Court = newCourt - return nil - }) + err := e.updatePoolMatchCourt(compId, matchId, newCourt) if err == nil { return nil } @@ -3547,6 +3544,46 @@ func (e *Engine) OverrideBracketWinner(compId string, matchId string, winnerName return true, nil } +// updatePoolMatchCourt moves a pool match to another court and, in a pools + +// knockout competition, moves that court's knockout matches past it when it +// now runs into them (moveKnockoutPastPools), in one transaction (bc-kosc). +// The move picks no time on the new court, so the app keeps that court's +// pools before its knockout. Returns errMatchNotFound, with nothing written, +// when matchId is not a pool match. +func (e *Engine) updatePoolMatchCourt(compId, matchId, newCourt string) error { + // Read before the transaction: the tournament has its own lock. + tournament, tournErr := e.store.LoadTournament() + // The reads below run under the competition's lock, which skips the + // first-read legacy conversion the store's own reads run. + e.store.EnsureLegacyUpgraded(compId) + return e.store.WithTransaction(compId, func(tx state.StoreTx) error { + if err := e.withPoolMatch(tx, compId, matchId, func(r *state.MatchResult) error { + r.Court = newCourt + return nil + }); err != nil { + return err + } + comp, err := tx.LoadCompetition(compId) + if err != nil { + return err + } + if !comp.IsKnockoutEnabled() { + return nil + } + if tournErr != nil { + return tournErr + } + matches, err := tx.LoadPoolMatches(compId) + if err != nil { + return err + } + return moveKnockoutPastPools(tx, compId, matches, comp, tournament) + }) +} + +// UpdateMatchTime sets a match's time and moves nothing else: a time set by +// hand is the operator ordering the court (the queue's up/down is two of +// these), so the knockout is not pushed past a pool bout moved later. func (e *Engine) UpdateMatchTime(compId string, matchId string, scheduledAt string) error { err := e.withPoolMatch(e.store, compId, matchId, func(r *state.MatchResult) error { r.ScheduledAt = scheduledAt diff --git a/internal/mobileapp/handlers_match.go b/internal/mobileapp/handlers_match.go index bcd2ce221..fd9b412de 100644 --- a/internal/mobileapp/handlers_match.go +++ b/internal/mobileapp/handlers_match.go @@ -1301,6 +1301,9 @@ func RegisterMatchHandlers(r *gin.RouterGroup, eng *engine.Engine, store Competi "matchId": mid, "court": req.Court, }) + // A pool bout moved onto a court may move that court's knockout + // later (bc-kosc), so the schedule is announced as the time edit's is. + hub.Broadcast(EventScheduleUpdated, nil) c.Status(http.StatusOK) }) From fcbded99a19a9c36485bd04fc20e4a8aada65465 Mon Sep 17 00:00:00 2001 From: Ricardo Oliveira Date: Wed, 7 Oct 2026 23:18:41 +0100 Subject: [PATCH 15/16] fix: name the side's colour on hantei buttons; address review findings - The hantei winner buttons (individual editor and team representative bout) read "SHIRO (White) wins" / "AKA (Red) wins", with the competitor in the individual editor, so an operator who does not know the words can tell them apart. side_cell.jsx's sideWithColour owns the label; the decision side picker and the team summary band read it too. - A Save correction answered after another correction was opened no longer closes that one (endCorrectionOf, functional update). - A court move is saved even when tournament.md cannot be read; the knockout push is skipped and logged instead of aborting the move. - pushKnockoutPastPools moves past lunch only a match it moves, and leaves a knockout row whose time does not parse alone. - An armed hantei says why Finish is off, and a recorded hantei on an untied scoreline (legacy) keeps its Cancel reachable. - The Start refusal during a correction names the console's buttons. Co-Authored-By: Claude Opus 5.5 --- .../court-operators/recording-decisions.md | 2 +- .../court-operators/scoring-a-match.md | 4 +- internal/engine/mixed_schedule_test.go | 58 +++++++++++++++++++ internal/engine/scheduler_slots.go | 31 ++++++---- internal/engine/scoring.go | 6 +- .../render/admin_shiaijo.render.test.jsx | 22 ++++++- .../render/hantei_single_tap.render.test.jsx | 15 ++++- ...ividual_editor_live_hantei.render.test.jsx | 2 +- ...ite_result_court_console_refusals.test.jsx | 2 +- web-mobile/js/admin_scoring_individual.jsx | 34 ++++++++--- web-mobile/js/admin_scoring_shared.jsx | 8 +-- web-mobile/js/admin_scoring_team.jsx | 12 ++-- web-mobile/js/admin_shiaijo.jsx | 17 ++++-- web-mobile/js/side_cell.jsx | 8 +++ web-mobile/js/write_result.jsx | 5 +- 15 files changed, 179 insertions(+), 47 deletions(-) diff --git a/docs/user-guide/court-operators/recording-decisions.md b/docs/user-guide/court-operators/recording-decisions.md index 05a328ad1..5e20fee6f 100644 --- a/docs/user-guide/court-operators/recording-decisions.md +++ b/docs/user-guide/court-operators/recording-decisions.md @@ -42,7 +42,7 @@ Encho is the extra period played when a match is level at the end of regulation An individual knockout match cannot be finished level. While the score is tied, the **Finish** button reads **Needs a winner** instead: fight encho, then record a hantei if the competitors are still level after it. -To record a hantei, tap **Decide by hantei**, pick the winning side with **SHIRO wins** or **AKA wins**, then tap **Finish** and tap it again to confirm. Picking a side on its own records nothing, so a slip of the thumb between the two buttons can be put right before anything is saved. +To record a hantei, tap **Decide by hantei** and pick the winning side. Each button names the side's colour and competitor, for example **SHIRO (White) wins: Tanaka** and **AKA (Red) wins: Yamada**. Then finish the match as usual: **Finish** (on the court console, **Finish + Start Next**) and tap it again to confirm, or **Save correction** when you are correcting a result. Picking a side on its own records nothing, so a slip of the thumb between the two buttons can be put right before anything is saved. In the score editor, open the **Overtime** control and tick **Encho started**. A counter appears so you can record how many overtime periods were fought, using the **+** and **-** buttons. The counter starts at 1 and has no upper limit. How many periods are fought, and how a match still level after them is finally settled, is the shimpan's call. That may be a judges' decision for an individual bout, or a daihyosen for a team encounter. Record what actually happened on court, however many periods that took. diff --git a/docs/user-guide/court-operators/scoring-a-match.md b/docs/user-guide/court-operators/scoring-a-match.md index f98c956af..1fa107a18 100644 --- a/docs/user-guide/court-operators/scoring-a-match.md +++ b/docs/user-guide/court-operators/scoring-a-match.md @@ -42,10 +42,10 @@ Once a match is scored it moves to the **Completed** list on the court console, What Correct offers depends on the format: -- For most matches, the editor reopens with the recorded scores ready to edit. Adjust the ippons, fouls, or winner, then use **Save correction**. A short reason is required so the change stays traceable. A judges' decision (hantei) given to the wrong competitor is corrected by tapping the other competitor's hantei button; the same reason is asked for first, and the new decision replaces the old one. +- For most matches, the editor reopens with the recorded scores ready to edit. Adjust the ippons, fouls, or winner, then use **Save correction**. A short reason is required so the change stays traceable. A judges' decision (hantei) given to the wrong competitor is corrected by picking the other competitor's hantei button and then **Save correction**; the new decision replaces the old one. - For a kachinuki team encounter, the editor shows the recorded bouts with a **Reopen match** button. An encounter that ended with a withdrawal or a no-show shows **Clear kiken and reopen** or **Clear fusenpai and reopen** instead; refer to [Correcting a withdrawal recorded by mistake](recording-decisions.md#correcting-a-withdrawal-recorded-by-mistake). Reopen is immediate: one tap returns the encounter to running with its bout log intact, so you can carry on or fix a bout. No reason is needed, and none is asked for when you end the match again, whether you finish on a scored bout or record a withdrawal or a no-show. Correcting a mistake never costs you more than a tap. -While a correction is open, **Start match** does not start another match: it tells you to finish or cancel the correction first. **Save correction** finishes it, and **Back to court** cancels it without saving. If a match is started on the court from another screen while you correct, the correction stays open and the running match is listed above it until you save or go back to the court. +While a correction is open, **Start match** does not start another match: it tells you to save the correction or leave it with **Back to court** first. **Save correction** finishes it, and **Back to court** cancels it without saving. If a match is started on the court from another screen while you correct, the correction stays open and the running match is listed above it until you save or go back to the court. Reopening puts the encounter back into play, and a court can only run one match at a time. On the court console nothing else is running while you correct, but when you correct from the Scores page another match may be running on that court. Then the editor names it and offers two ways forward. Leave it running, or send it back to the queue and reopen in a single step with **Queue it and reopen**. The match sent back keeps any score already entered for it and carries on from there when it is started again. diff --git a/internal/engine/mixed_schedule_test.go b/internal/engine/mixed_schedule_test.go index d47b3dbec..4c9ebbb75 100644 --- a/internal/engine/mixed_schedule_test.go +++ b/internal/engine/mixed_schedule_test.go @@ -1,6 +1,8 @@ package engine import ( + "os" + "path/filepath" "testing" "time" @@ -165,6 +167,37 @@ func TestPushKnockoutPastPools(t *testing.T) { assert.False(t, pushKnockoutPastPools(b, comp, nil, pm), "nothing left to move") } +// The push moves only what it must: a later time the operator set, even one +// inside lunch, stays as set, and a row whose time does not parse is left +// alone rather than read as 09:00 and re-timed. +func TestPushKnockoutPastPools_LeavesWhatItNeedNotMove(t *testing.T) { + comp := &state.Competition{StartTime: "09:00", KnockoutMatchDurationSeconds: 240, PoolMatchDurationSeconds: 240, Courts: []string{"A", "B"}} + tournament := &state.Tournament{LunchBlock: "1h"} + ko := func(id, court, at string, n int) state.BracketMatch { + return state.BracketMatch{ID: id, Court: court, ScheduledAt: at, MatchNumber: n, Status: state.MatchStatusScheduled} + } + b := &state.Bracket{Rounds: [][]state.BracketMatch{{ + ko("a1", "A", "11:30", 1), + ko("a2", "A", "12:30", 2), + ko("b1", "B", "9.30", 3), + ko("b2", "B", "09:00", 4), + }}} + // One pool slot is 6 minutes: A's pools end 11:50, B's 10:06. + pm := []state.MatchResult{ + {ID: "p1", Court: "A", ScheduledAt: "11:44"}, + {ID: "p2", Court: "B", ScheduledAt: "10:00"}, + } + require.True(t, pushKnockoutPastPools(b, comp, tournament, pm)) + got := map[string]string{} + for _, m := range b.Rounds[0] { + got[m.ID] = m.ScheduledAt + } + assert.Equal(t, "11:50", got["a1"], "moved to the pool end") + assert.Equal(t, "12:30", got["a2"], "already clear of a1: the operator's time inside lunch stays") + assert.Equal(t, "10:06", got["b2"], "moved to the pool end") + assert.Equal(t, "9.30", got["b1"], "a time that does not parse is left as it is") +} + // bc-kosc: a pool bout moved onto another court moves that court's knockout // past it. A time set by hand moves nothing: it is the operator ordering the // court, and the queue's up/down is two of them. @@ -212,6 +245,31 @@ func TestPoolScheduleEdit_CourtMoveMovesTheKnockout(t *testing.T) { } }) + t.Run("a court move with the tournament unreadable keeps the move", func(t *testing.T) { + eng, store, dir := setupTestEngine(t) + id := "bc-kosc-tourn" + createTestCompetition(t, store, id, state.CompFormatMixed, 3, func(c *state.Competition) { c.Courts = []string{"A", "B"} }) + saveTestParticipants(t, store, id, kosc6) + require.NoError(t, eng.GenerateDraw(id)) + before := numberedKnockoutTimes(bracket(t, store, id)) + m := pool(t, store, id)[0] + other := "A" + if m.Court == "A" { + other = "B" + } + // A directory where tournament.md belongs: the read fails. + path := filepath.Join(dir, "tournament.md") + require.NoError(t, os.RemoveAll(path)) + require.NoError(t, os.Mkdir(path, 0o755)) + require.NoError(t, eng.UpdateMatchCourt(id, m.ID, other)) + for _, r := range pool(t, store, id) { + if r.ID == m.ID { + assert.Equal(t, other, r.Court, "the court move is saved") + } + } + assert.Equal(t, before, numberedKnockoutTimes(bracket(t, store, id)), "the knockout times are left as they were") + }) + t.Run("a time set by hand", func(t *testing.T) { eng, store, id := setup(t, []string{"A"}) before := numberedKnockoutTimes(bracket(t, store, id)) diff --git a/internal/engine/scheduler_slots.go b/internal/engine/scheduler_slots.go index 41cf801b4..bea6d7726 100644 --- a/internal/engine/scheduler_slots.go +++ b/internal/engine/scheduler_slots.go @@ -312,12 +312,20 @@ func pushKnockoutPastPools(b *state.Bracket, comp *state.Competition, tournament latest = t } } - byCourt := map[string][]*state.BracketMatch{} + // A row whose time does not parse is left alone, as poolPhaseEndByCourt + // leaves it out: parseClockHHMM would read it as 09:00 and re-time it. + type timed struct { + m *state.BracketMatch + at time.Time + } + byCourt := map[string][]timed{} started := map[string]bool{} visit := func(m *state.BracketMatch) { switch { case m.Status == state.MatchStatusScheduled && m.ScheduledAt != "": - byCourt[m.Court] = append(byCourt[m.Court], m) + if at, err := time.Parse(scheduleClockLayout, m.ScheduledAt); err == nil { + byCourt[m.Court] = append(byCourt[m.Court], timed{m, at}) + } case m.Status != state.MatchStatusScheduled && m.MatchNumber > 0: started[m.Court] = true } @@ -342,21 +350,20 @@ func pushKnockoutPastPools(b *state.Bracket, comp *state.Competition, tournament if !ok { poolEnd = latest } - sort.SliceStable(ms, func(i, j int) bool { - return parseClockHHMM(ms[i].ScheduledAt).Before(parseClockHHMM(ms[j].ScheduledAt)) - }) - if !parseClockHHMM(ms[0].ScheduledAt).Before(poolEnd) { + sort.SliceStable(ms, func(i, j int) bool { return ms[i].at.Before(ms[j].at) }) + if !ms[0].at.Before(poolEnd) { continue } cursor := poolEnd - for _, m := range ms { - t := parseClockHHMM(m.ScheduledAt) + for _, x := range ms { + t := x.at + // Only a match the push moves is moved past lunch: a later + // time the operator set, even one inside lunch, stays theirs. if t.Before(cursor) { - t = cursor + t = skipCeremonyBlocks(cursor, lunchStart, lunchMin) } - t = skipCeremonyBlocks(t, lunchStart, lunchMin) - if at := t.Format(scheduleClockLayout); at != m.ScheduledAt { - m.ScheduledAt = at + if !t.Equal(x.at) { + x.m.ScheduledAt = t.Format(scheduleClockLayout) moved = true } cursor = t.Add(slot) diff --git a/internal/engine/scoring.go b/internal/engine/scoring.go index b989b5408..a28186367 100644 --- a/internal/engine/scoring.go +++ b/internal/engine/scoring.go @@ -3570,8 +3570,12 @@ func (e *Engine) updatePoolMatchCourt(compId, matchId, newCourt string) error { if !comp.IsKnockoutEnabled() { return nil } + // The move is what the operator asked for; the knockout push only + // follows it. A tournament that cannot be read leaves the knockout + // times as they are rather than refusing the move. if tournErr != nil { - return tournErr + log.Printf("engine: court move of %s/%s kept the knockout times: tournament unreadable: %v", compId, matchId, tournErr) + return nil } matches, err := tx.LoadPoolMatches(compId) if err != nil { diff --git a/web-mobile/js/__tests__/render/admin_shiaijo.render.test.jsx b/web-mobile/js/__tests__/render/admin_shiaijo.render.test.jsx index 34c70644d..9d202dc00 100644 --- a/web-mobile/js/__tests__/render/admin_shiaijo.render.test.jsx +++ b/web-mobile/js/__tests__/render/admin_shiaijo.render.test.jsx @@ -1627,7 +1627,7 @@ describe('a running match and an open correction never coexist on the console (b expect(onEditScore).not.toHaveBeenCalled(); await act(async () => { c.utils.getByRole('button', { name: /back to court/i }).click(); }); expect(heading(c)).not.toContain('Correcting'); - expect(alertsWith(c, 'Finish or cancel the correction')).toHaveLength(0); + expect(alertsWith(c, 'Save the correction of')).toHaveLength(0); await tapUpNextStart(c); expect(onEditScore).toHaveBeenCalledTimes(1); expect(onEditScore.mock.calls[0][1]).toBe('m2'); @@ -1668,6 +1668,26 @@ describe('a running match and an open correction never coexist on the console (b } finally { c3.restore(); } }); + it('g) a Save correction answered after another correction was opened leaves that one open', async () => { + let answer; + const onEditScore = vi.fn().mockImplementation(() => new Promise((r) => { answer = r; })); + const c = await mountCourt([ + courtMatch('m1', 'completed', { modifiedAt: 1000 }), + courtMatch('m3', 'completed', { modifiedAt: 1100 }), + ], { onEditScore }); + try { + await tapCorrect(c, 'Aka m1'); + const saveM1 = probe.props.onSubmit; + let saving; + await act(async () => { saving = saveM1({ status: 'completed' }); }); + await tapCorrect(c, 'Aka m3'); + expect(c.editorMatch()).toBe('m3'); + await act(async () => { answer({ applied: true }); await saving; }); + expect(c.editorMatch(), "m1's answer does not close m3's correction").toBe('m3'); + expect(heading(c)).toContain('Correcting'); + } finally { c.restore(); } + }); + it('e) a landed decision on a correction (onClose) ends it; with no correction onClose changes nothing', async () => { const m1 = courtMatch('m1', 'completed', { modifiedAt: 1000 }); const c = await mountCourt([m1, courtMatch('m2', 'scheduled')]); diff --git a/web-mobile/js/__tests__/render/hantei_single_tap.render.test.jsx b/web-mobile/js/__tests__/render/hantei_single_tap.render.test.jsx index 4395a45ee..f0c76aecc 100644 --- a/web-mobile/js/__tests__/render/hantei_single_tap.render.test.jsx +++ b/web-mobile/js/__tests__/render/hantei_single_tap.render.test.jsx @@ -63,6 +63,13 @@ const armAndPick = async (testid) => { }; describe('hantei side buttons only pick (bc-htsd)', () => { + it('each button names its colour and competitor, for an operator who does not know SHIRO and AKA', async () => { + render(); + await click(screen.getByTestId('scoring-modal-hantei-arm')); + expect(screen.getByTestId('scoring-modal-hantei-shiro').textContent).toBe('SHIRO (White) wins: Tanaka'); + expect(screen.getByTestId('scoring-modal-hantei-aka').textContent).toBe('AKA (Red) wins: Yamada'); + }); + it('picking a side commits nothing; Finish, tapped twice, commits exactly the pick', async () => { vi.useFakeTimers(); const onSubmit = vi.fn().mockResolvedValue(undefined); @@ -109,8 +116,10 @@ describe('hantei side buttons only pick (bc-htsd)', () => { await click(screen.getByTestId('scoring-modal-hantei-arm')); expect(finishBtn().disabled).toBe(true); expect(finishBtn().textContent).not.toMatch(/Needs a winner/); + expect(screen.getByTestId('scoring-modal-hantei-hint').textContent, 'a disabled Finish says why').toBe('Pick the hantei winner, then finish.'); await click(screen.getByTestId('scoring-modal-hantei-shiro')); expect(finishBtn().disabled).toBe(false); + expect(screen.queryByTestId('scoring-modal-hantei-hint')).toBeNull(); }); it('a verdict recorded on another device is adopted as the pick', async () => { @@ -179,8 +188,12 @@ describe('hantei side buttons only pick (bc-htsd)', () => { render(); - expect(screen.queryByTestId('scoring-modal-hantei-row')).toBeNull(); const save = [...document.querySelectorAll('.score-nav button')].find((x) => x.textContent === 'Save correction'); expect(save.disabled, 'a Save would send a hantei the server refuses on an untied line').toBe(true); + // Not stuck: the row stays with its Cancel and says why, so the + // operator can drop the hantei and correct the result. + expect(screen.getByTestId('scoring-modal-hantei-hint').textContent).toMatch(/needs a tied score/); + await click(screen.getByTestId('scoring-modal-hantei-cancel')); + expect(screen.queryByTestId('scoring-modal-hantei-row'), 'untied and not armed: no hantei row').toBeNull(); }); }); diff --git a/web-mobile/js/__tests__/render/individual_editor_live_hantei.render.test.jsx b/web-mobile/js/__tests__/render/individual_editor_live_hantei.render.test.jsx index d9b099408..6dd446313 100644 --- a/web-mobile/js/__tests__/render/individual_editor_live_hantei.render.test.jsx +++ b/web-mobile/js/__tests__/render/individual_editor_live_hantei.render.test.jsx @@ -264,7 +264,7 @@ describe('the team editor shows the daihyosen verdict the server holds', () => { const { rerender, container } = render( ); - const armed = c => /Cancel hantei|SHIRO wins|AKA wins/.test(c.textContent); + const armed = c => /Cancel hantei|SHIRO \(White\) wins|AKA \(Red\) wins/.test(c.textContent); expect(armed(container)).toBe(false); await act(async () => { rerender( diff --git a/web-mobile/js/__tests__/write_result_court_console_refusals.test.jsx b/web-mobile/js/__tests__/write_result_court_console_refusals.test.jsx index b85390f3b..9aef82e11 100644 --- a/web-mobile/js/__tests__/write_result_court_console_refusals.test.jsx +++ b/web-mobile/js/__tests__/write_result_court_console_refusals.test.jsx @@ -8,7 +8,7 @@ import { startWhileCorrectingMessage, correctWhileRunningMessage, courtBusyMessa describe('startWhileCorrectingMessage', () => { it('names the correction and the remedy', () => { expect(startWhileCorrectingMessage({ label: 'Pool A · Match 2 · Kato vs Sato' })) - .toBe('Finish or cancel the correction of Pool A · Match 2 · Kato vs Sato first, then start this match.'); + .toBe('Save the correction of Pool A · Match 2 · Kato vs Sato, or leave it with Back to court, then start this match.'); }); }); diff --git a/web-mobile/js/admin_scoring_individual.jsx b/web-mobile/js/admin_scoring_individual.jsx index 51f2db0bb..17e9a5f78 100644 --- a/web-mobile/js/admin_scoring_individual.jsx +++ b/web-mobile/js/admin_scoring_individual.jsx @@ -9,7 +9,7 @@ const { useState: useStateA, useEffect: useEffectA, useRef: useRefA, useMemo: us // daihyosen-specific; the rep pickers below stay gated on m.repIsTeam (a "-TB-" // tiebreaker is also a rep bout, just not a daihyosen). import { isPoolDaihyosenBout } from './pool_ids.jsx'; -import { SideLabel } from './side_cell.jsx'; +import { SideLabel, sideWithColour } from './side_cell.jsx'; import { realIppons, hanteiTied, hanteiSlot, hanteiWinnerKey, sideSlotOrder, struckIppons } from './result_slot.jsx'; import { sameCompetitor } from './competitor_identity.jsx'; // Imported from the leaf, not read off `window`: this editor is ES-imported by @@ -766,6 +766,12 @@ export function ScoreEditorModal({ match, onClose, onSubmit, onSubmitAndNext, on // It needs the tied scoreline the hantei row is shown on: a recorded hantei // on an untied line (legacy data) cannot be finished, as before. const canFinish = decidedByHantei ? (!!hanteiPick && aTotal === bTotal) : (!koTieBlocked && hasPointsOrDraw); + // Why Finish is off while hantei is armed: no winner picked yet, or a + // recorded hantei on an untied scoreline (legacy data). Shown under the + // hantei row, since a title never shows on a touchscreen. + const hanteiFinishBlock = !decidedByHantei ? "" : !hanteiPick + ? "Pick the hantei winner, then finish." + : aTotal !== bTotal ? "Hantei needs a tied score: cancel hantei, or correct the score." : ""; // Finish guard (see TeamScoreEditorModal): one tap ARMS the button — its label // becomes an explicit "Tap again to finish" INSTRUCTION (not a verdict), so the @@ -1180,8 +1186,10 @@ export function ScoreEditorModal({ match, onClose, onSubmit, onSubmitAndNext, on {/* A tied match may be decided by referee hantei. The winner is recorded with the hantei flag, distinguishable from an ippon-derived win for stats, audit, and Excel. */} - {aTotal === bTotal && ( -
+ {/* Kept while armed on an untied scoreline (a legacy record), + so its Cancel stays reachable: the grid is locked while armed. */} + {(aTotal === bTotal || decidedByHantei) && ( +
Hantei (judges' decision) {!decidedByHantei && ( @@ -1211,8 +1219,11 @@ export function ScoreEditorModal({ match, onClose, onSubmit, onSubmitAndNext, on )} {decidedByHantei && ( -
- {/* The recorded side renders primary, like the team +
+ {/* Each button names its side's colour and competitor + (sideWithColour), so an operator who does not know + SHIRO and AKA can still tell them apart. + The recorded side renders primary, like the team panel: this row is the verdict's second channel, so it must SHOW the verdict, not just offer buttons — on a drifted 2-2 the dropped loose mark makes this @@ -1225,7 +1236,7 @@ export function ScoreEditorModal({ match, onClose, onSubmit, onSubmitAndNext, on onClick={() => setHanteiPick("b")} disabled={submitting || decisionSubmitting} > - SHIRO wins + {sideWithColour("shiro")} wins{m.sideB?.name ? `: ${m.sideB.name}` : ""}
)} + {hanteiFinishBlock && ( +
+ {hanteiFinishBlock} +
+ )}
)} {!decisionPromptKind && ( @@ -1430,7 +1446,7 @@ export function ScoreEditorModal({ match, onClose, onSubmit, onSubmitAndNext, on if (!isComplete && !confirmFinish(ev)) return; doSubmit(() => (isComplete ? onSubmit : onSubmitAndNext)(buildPatch("completed"))); }} disabled={submitting || !canFinish} - title={koTieBlocked ? KO_TIE_REASON : undefined}> + title={koTieBlocked ? KO_TIE_REASON : hanteiFinishBlock || undefined}> {submitting ? "Saving…" : koTieBlocked ? "Needs a winner" : isComplete ? "Save correction" : finishArmed ? "Tap again to finish →" : "Finish + Start Next →"} ) : ( @@ -1439,7 +1455,7 @@ export function ScoreEditorModal({ match, onClose, onSubmit, onSubmitAndNext, on if (!isComplete && !confirmFinish(ev)) return; doSubmit(() => onSubmit(buildPatch("completed"))); }} disabled={submitting || !canFinish} - title={koTieBlocked ? KO_TIE_REASON : undefined}> + title={koTieBlocked ? KO_TIE_REASON : hanteiFinishBlock || undefined}> {submitting ? "Saving…" : koTieBlocked ? "Needs a winner" : isComplete ? "Save correction" : finishArmed ? "Tap again to finish" : "Finish"} )} diff --git a/web-mobile/js/admin_scoring_shared.jsx b/web-mobile/js/admin_scoring_shared.jsx index 616f083b3..a5902d149 100644 --- a/web-mobile/js/admin_scoring_shared.jsx +++ b/web-mobile/js/admin_scoring_shared.jsx @@ -13,7 +13,7 @@ import { courtBusyMessage, HELD_WRITE_DISCARD_LABEL, heldWriteDiscardConfirm, queuedNotice, } from './write_result.jsx'; import { sameCompetitor } from './competitor_identity.jsx'; -import { sideWord } from './side_cell.jsx'; +import { sideWord, sideWithColour } from './side_cell.jsx'; import { sideMarks, defaultWinMaru } from './bracket.jsx'; import { NumberedName, numberFollowsName } from './numbered_name.jsx'; import { withdrawnSideKey } from './ineligible_match.jsx'; @@ -814,8 +814,8 @@ function DecisionPrompt({ kind, sideA, sideB, askReason, onCancel, onSubmit, sub // Each side's words and competitor, in one place: Shiro is sideB, Aka is // sideA, as everywhere in the editors. The side word comes from sideWord. const sides = { - shiro: { colour: "White", name: sideB?.name }, - aka: { colour: "Red", name: sideA?.name }, + shiro: { name: sideB?.name }, + aka: { name: sideA?.name }, }; const nameOf = (key) => sides[key].name || sideWord(key); @@ -829,7 +829,7 @@ function DecisionPrompt({ kind, sideA, sideB, askReason, onCancel, onSubmit, sub const sideOption = (key) => ( ); diff --git a/web-mobile/js/admin_scoring_team.jsx b/web-mobile/js/admin_scoring_team.jsx index fd3b82b60..eeac5faa6 100644 --- a/web-mobile/js/admin_scoring_team.jsx +++ b/web-mobile/js/admin_scoring_team.jsx @@ -53,7 +53,7 @@ import { useDebouncedRunningWrite, SyncStatusPill, useChangedGroups, useKeptInHi import { MatchHistoryDisclosure } from './match_history_view.jsx'; import { serverNowMs } from './server_clock.jsx'; import { publishHeight } from './published_height.jsx'; -import { SideLabel } from './side_cell.jsx'; +import { SideLabel, sideWithColour } from './side_cell.jsx'; // Imported from the leaf, not read off `window`, for the same reason // admin_scoring_shared.jsx does it: write_result.jsx is import-only, and this @@ -3578,8 +3578,8 @@ export function TeamScoreEditorModal({ match, teamSize, onClose, onSubmit, onSub // left = SHIRO (White), right = AKA (Red) const teamSides = [ - { key: "b", name: m.sideB?.name || m.sideB, number: m.sideB?.number, label: "SHIRO (White)", color: "shiro", iv: ivB, pw: pwB }, - { key: "a", name: m.sideA?.name || m.sideA, number: m.sideA?.number, label: "AKA (Red)", color: "aka", iv: ivA, pw: pwA }, + { key: "b", name: m.sideB?.name || m.sideB, number: m.sideB?.number, label: sideWithColour("shiro"), color: "shiro", iv: ivB, pw: pwB }, + { key: "a", name: m.sideA?.name || m.sideA, number: m.sideA?.number, label: sideWithColour("aka"), color: "aka", iv: ivA, pw: pwA }, ]; // a11y: label the dialog with the match/court context (mirrors the @@ -4407,11 +4407,11 @@ export function TeamScoreEditorModal({ match, teamSize, onClose, onSubmit, onSub )} {!selfReport && daihyosenHanteiArmed && ( -
+
+ onClick={() => pickDaihyosenHantei("b")} disabled={submitting || decisionSubmitting}>{sideWithColour("shiro")} wins + onClick={() => pickDaihyosenHantei("a")} disabled={submitting || decisionSubmitting}>{sideWithColour("aka")} wins
diff --git a/web-mobile/js/admin_shiaijo.jsx b/web-mobile/js/admin_shiaijo.jsx index a63cd1fe4..85e5a9c82 100644 --- a/web-mobile/js/admin_shiaijo.jsx +++ b/web-mobile/js/admin_shiaijo.jsx @@ -1347,6 +1347,9 @@ function AdminShiaijoPage({ tournament, court: routeCourt, onBack, onEditScore, // the live bout, finished via End match / Finish or sent back to the // queue like any other. const stopCorrecting = () => { setCorrectingKey(null); }; + // End the correction of the match keyed `key` only if it is still the one + // open: a write's answer may arrive after another correction was opened. + const endCorrectionOf = (key) => { setCorrectingKey((k) => (k === key ? null : k)); }; // Call to court: optional. Broadcasts a tournament announcement so the // competitors (and anyone watching the public app) are notified they're @@ -1845,20 +1848,22 @@ function AdminShiaijoPage({ tournament, court: routeCourt, onBack, onEditScore, // (which ends it) and the daihyosen add/remove // on a running match, where nothing is open: a // no-op. - onClose={() => { - if (correctingKey && correctingKey === matchKey(selectedMatch)) setCorrectingKey(null); - }} + // Both close only THIS match's correction: the answer can + // arrive after the operator opened a correction on another + // match, so the key is captured before the write and + // compared against the current one (endCorrectionOf). + onClose={() => endCorrectionOf(matchKey(selectedMatch))} started={isStartedSnapshot(selectedMatch)} canClose={false} onSubmit={async (patch) => { + const key = matchKey(selectedMatch); try { const res = await onEditScore(selectedMatch.compId, selectedMatch.id, patch, selectedMatch); // A landed Save correction ends the correction (a // write that did not land keeps it open, with the // editor's not-saved banner). - if (res && patch && patch.status === "completed" && !writeKeepsEditorOpen(patch, res) - && correctingKey && correctingKey === matchKey(selectedMatch) && mountedRef.current) { - setCorrectingKey(null); + if (res && patch && patch.status === "completed" && !writeKeepsEditorOpen(patch, res) && mountedRef.current) { + endCorrectionOf(key); } // Optimistically advance the local bracket so an offline court // sees the next match resolve (reconciled by refetch online). diff --git a/web-mobile/js/side_cell.jsx b/web-mobile/js/side_cell.jsx index 061865813..d58fa6952 100644 --- a/web-mobile/js/side_cell.jsx +++ b/web-mobile/js/side_cell.jsx @@ -24,6 +24,14 @@ export function sideWord(side) { return side === "aka" ? "Aka" : "Shiro"; } +// The side as a control that PICKS it names it: the kendo word and its colour, +// "SHIRO (White)" / "AKA (Red)", because an operator who does not know the +// words must still tell the two buttons apart. The decision side picker, the +// hantei winner buttons and the team summary band all read it from here. +export function sideWithColour(side) { + return `${sideWord(side).toUpperCase()} (${side === "aka" ? "Red" : "White"})`; +} + // The fill class alone. SideCell builds its own from this, and exactly ONE // other caller is sanctioned: the watchlist hero, which names the side in // VISIBLE text (CLAUDE.md's size exception) and so must not also carry the diff --git a/web-mobile/js/write_result.jsx b/web-mobile/js/write_result.jsx index 874b96929..9f53d6951 100644 --- a/web-mobile/js/write_result.jsx +++ b/web-mobile/js/write_result.jsx @@ -833,9 +833,10 @@ export function courtBusyMessage({ court, label }) { // The court console never holds a running match and an open correction at // once (operator ruling 2026-09-27, bc-crpn). Each refused tap names the other -// one, with the label string the caller built (scoreRowMatchName). +// one, with the label string the caller built (scoreRowMatchName), and names +// the console's own buttons: there is no "Cancel" there. export function startWhileCorrectingMessage({ label }) { - return `Finish or cancel the correction of ${label} first, then start this match.`; + return `Save the correction of ${label}, or leave it with Back to court, then start this match.`; } export function correctWhileRunningMessage({ court, label }) { From a07a15702b3b89fe2fdf97113b0d463177c06f98 Mon Sep 17 00:00:00 2001 From: Ricardo Oliveira Date: Wed, 7 Oct 2026 23:28:13 +0100 Subject: [PATCH 16/16] fix(bc-htsd): hint says to cancel hantei before correcting; pin the team labels Co-Authored-By: Claude Opus 5.5 --- .../render/autosave_every_scoring_edit.render.test.jsx | 3 +++ web-mobile/js/admin_scoring_individual.jsx | 2 +- 2 files changed, 4 insertions(+), 1 deletion(-) 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 bae5b4f0b..e7486426d 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 @@ -288,6 +288,9 @@ describe('a daihyosen verdict on a running encounter is saved on its own', () => it('picking a side writes the verdict', async () => { await mount(teamWithDaihyosen()); await arm(); + // Each button names its colour, for an operator who does not know the words. + expect(screen.getByTestId('team-daihyosen-hantei-shiro').textContent).toBe('SHIRO (White) wins'); + expect(screen.getByTestId('team-daihyosen-hantei-aka').textContent).toBe('AKA (Red) wins'); await pick('shiro'); await settle(); expect(writes).toHaveLength(1); diff --git a/web-mobile/js/admin_scoring_individual.jsx b/web-mobile/js/admin_scoring_individual.jsx index 17e9a5f78..6cead828e 100644 --- a/web-mobile/js/admin_scoring_individual.jsx +++ b/web-mobile/js/admin_scoring_individual.jsx @@ -771,7 +771,7 @@ export function ScoreEditorModal({ match, onClose, onSubmit, onSubmitAndNext, on // hantei row, since a title never shows on a touchscreen. const hanteiFinishBlock = !decidedByHantei ? "" : !hanteiPick ? "Pick the hantei winner, then finish." - : aTotal !== bTotal ? "Hantei needs a tied score: cancel hantei, or correct the score." : ""; + : aTotal !== bTotal ? "Hantei needs a tied score: cancel hantei, then correct the score." : ""; // Finish guard (see TeamScoreEditorModal): one tap ARMS the button — its label // becomes an explicit "Tap again to finish" INSTRUCTION (not a verdict), so the