diff --git a/CLAUDE.md b/CLAUDE.md index c6f77d3e3..a0a2ee9fb 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -236,7 +236,7 @@ Placement follows the point rule: the mark fills the winner's next FREE slot in The strip's heading names what it is showing whenever that is not the live bout: "Up next" before the court's first bout (the highlight there means "play this next"), "Just played" when it fell back to the last result, "Correcting" when the operator opened that result to fix it. With no such qualifier the lead names the panel's CONTENT - "Standings" for a pool, "Bracket" for a knockout fragment. The word "Context" was removed as meaningless (operator ruling 2026-09-19). -The slot rule has ONE owner, `resultSlot` in web-mobile/js/result_slot.jsx (a small leaf; the dependency reasoning is stated once in that file's header). Its three consumers are `resultCells` in match_scoreboard.jsx, `hanteiSlot`/`ptSlots` in admin_scoring_team.jsx, and the individual editor's slot grid in admin_scoring_individual.jsx (display-only `Ht` chip via `hanteiWinnerKey`). Change the contract and ALL THREE surfaces must be re-checked. +The slot rule has ONE owner, `resultSlot` in web-mobile/js/result_slot.jsx (a small leaf; the dependency reasoning is stated once in that file's header). Its three consumers are `resultCells` in match_scoreboard.jsx, `hanteiSlot`/`ptSlots` in admin_scoring_team.jsx, and the individual editor's slot grid in admin_scoring_individual.jsx (display-only `Ht` chip on the picked side, `hanteiPick`, which adopts `hanteiWinnerKey` from the server). Change the contract and ALL THREE surfaces must be re-checked. Score strings TRAIL loose marks as an unattributable-winner degradation, but the scoreboard has no centre-`Ht` equivalent: an unattributable winner gets no mark rather than one in the shared centre cell. ACCEPTED no-mark classes (all drifted/legacy data; the centre is forbidden by the operator ruling and no side is attributable): (i) same-name-no-id pairs; (ii) rename-drifted winner names (note the old centre fallback DID fire for these whenever the middle was empty, which a no-encho hantei makes normal, so this IS a regression there, accepted); (iii) winner-less legacy `decidedByHantei` rows (previously centre `Ht`); (iv) untied legacy `decidedByHantei` rows written before the tied-scoreline validation existed (letters still convey the winner; the judges'-decision marker is dropped). @@ -295,11 +295,11 @@ Operator ruling 2026-10-03: "Nothing should be dropped. All events must be order - **The history** (internal/state/match_history.go): one append-only file per match, `competitions//history/.jsonl`, one JSON line per write that reached the merge: stamp, server `receivedAt`, door, session, changed groups, per-group outcome (`applied`/`unchanged`/`held`), the held groups' incoming values, any cleared withdrawal, and the `reason` when one rule held the write's groups rather than their stamps (`MergeReport.HoldReason`: `HoldReasonOlderRevision`, `HoldReasonEngiAtomic`, `HoldReasonFinishAtomic`, `HoldReasonKnockoutNeedsWinner`, `HoldReasonEngiNeedsValidCount`; the History view shows it as "Not applied: "). `StoreTx.AppendMatchHistory` stages the whole small file in the SAME transaction as the write (`LoadMatchHistory` reads it, read-your-own-writes), so an aborted transaction leaves no entry. Written at the point the write can no longer be rolled back (`recordWriteHistory`: after K3/requalification in `RecordMatchResultWithIneligibilityTx`, in `writeMatchResult`, and on the superseded paths), so a write refused by validation, a side mismatch or K3 leaves none; every direct writer above appends its own (`recordDirectHistory`) INSIDE the transaction that makes its write: the doors already in one (the reopens, the kachinuki bout remove, the downstream reopen) pass their `tx`, and the kachinuki advance, `OverrideBracketWinner` and `RevertMatchToQueue` each open a `WithTransaction` for the write and the entry together (`withBracketMatch` takes the store handle, as `withPoolMatch` does), so a process dying between the two leaves no match change without its line; a held override writes nothing and appends on its own. `recordDirectHistory` logs a failed append rather than returning it, so a failure of the append alone still lets the write commit without its entry (accepted). The history directory is created only under an existing competition directory (`ensureMatchHistoryDirLocked`), so a write after `DeleteCompetition` fails rather than resurrecting it; `DiscardDraw` deletes it (`DeleteMatchHistory`), since a regenerated draw reuses the match ids. A fully-held write must COMMIT its transaction for its entry to land: every door returns nil from its closure on `ErrMatchSuperseded` and answers after (the score/decision handlers always did; bulk-score, both daihyosen paths and `RecordMatchResult` were changed to). - **The answers**: a write applied in part answers applied (the merged result) plus `heldGroups` (`scoreResponseWithReopened`, `withHeldGroups`, bulk-score's top-level `heldGroups`); a fully-held one keeps the 200 `{applied:false, reason:"superseded"}` shape the SPA keys on, plus `heldGroups` (`respondSuperseded`, `SupersededMessage`: "kept in the match's history"). Either answer adds `heldReason: "needs_winner"` when R4/S2 held them (`MergeReport.HeldReason`; a fully held one then carries `NeedsWinnerMessage`; bulk-score puts it on the entry), and an applied finish that moved stored newer scoring to the history adds `displacedGroups` (`MergeReport.DisplacedGroups`) with the same `heldReason` (score, daihyosen, bulk-score's `heldGroups[]` items). `ErrMatchSuperseded` is returned as `*SupersededError` carrying the held groups and that code (`errors.Is` still matches; `HeldGroupsOf`/`HeldReasonOf` read them). A completed write stamped OLDER than the stored `result` group over a finished match is not asked for a correction reason (`applyCorrectionReasonUnderTx`, `matchSnapshot.ResultStamp`): a write that completes the match is atomic with everything else it changes (`HoldReasonFinishAtomic`, mirroring `HoldReasonEngiAtomic`), so a stale finish is held WHOLE and kept in the history rather than landing its scoreline beside a verdict it never declared; an equal stamp is an exact replay and still needs one. The request-only fields (`Changed`, `WriteDoor`, `Merge`, `ClearsWithdrawal`) never ride on a stored copy: `MatchResult.ClearRequestFields` is the one list, called on the pool write's overwrite and on every pool-match copy the store hands out. -- **The client (phase 2)**: every score write names its groups. `web-mobile/js/match_groups.jsx` is the ONE client owner of the names (pinned against `state.ScalarGroups`/`BoutGroup` by the shared table `internal/state/testdata/match_groups.json`: `TestMatchGroups_SharedTable` and `match_groups_table.test.jsx`), of the comparison (`changedGroups`, on WIRE shapes only: both sides go through `toBackendMatchResult`, the adopted copy via `matchWire` in api_serializers.jsx, so a placeholder slot, an Ht placement or a winner object never reads as a change; a field the write leaves out, e.g. an omitted correction reason or a silent representative bout's ippons, is not compared, and a bout row the write does not carry is never a change), of the union (`unionChanged`: no list on either side means every group and stays so) and of a group's words (`groupLabel`). `useChangedGroups(m)` (admin_scoring_autosave.jsx) is the one hook the three editors call; every patch goes through its `claim` (the individual and team `buildPatch`, `submitHantei`, the engi save and autosave builder). A group counts against TWO baselines: the server value the editor last AGREED with for that group (seeded at mount, moved on at each build where local equals the prop), NOT the latest prop, or an edit held while another device changed a group the editor kept its own value for would put the old value back; and the last write the editor built, or a tap taken back before the first write's echo would name nothing. A reopen is a write the server makes, not a build, so when the match the editor renders leaves `completed` (a reopen, or a send back to the queue) the editor takes the result it now holds as agreed, that group alone (`useChangedGroups`): without it, ending a reopened match again with the verdict it had named nothing, and the server kept the match running with no error. It keys on the status, not on `reopenPending`: a reopen without a reason carries that stamp, but one a correction's reason comes with (a downstream reopen, a requalification, a pool-rank override) leaves the match scheduled with its points and no stamp, and an editor that watched only the stamp named no result for it. `startPatch` names `result` alone. The outbox keeps `changed` verbatim (persist, cross-tab, rehydrate, replay); a score write that takes a queued one's place claims the union (`_claimingQueuedGroups` in `_placeWrite`, and the absorb in `recordScore`, both against the one entry `_coalesceTarget` names); `_broadcastPatch` strips it. Answers: `writeHeldGroups`/`writePartlyHeld` (write_result.jsx) for a partial apply, which the editors answer with a quiet note (`useKeptInHistoryNote`/`KeptInHistoryNote`, worded by `heldGroupsNote`) and `editMatchScore` with a toast when the write closes the editor; a superseded write leads "Not applied", never "Not saved" (`SUPERSEDED_LEAD`, `notSavedText`'s `lead`, `supersededAlertText`). The editors' closed-by-default **History** disclosure (`MatchHistoryDisclosure`, match_history_view.jsx, `data-testid="match-history"`, hidden under `selfReport`) reads `GET /api/competitions/:id/matches/:mid/history` (`RegisterMatchHistoryHandler`, stamp order with an unstamped entry at its `receivedAt`, `[]` when none, 404 for an unknown competition or match, main-gated in self-run), readable values through `formatIpponsScore`/`withdrawalLabel`/`sideWord`. +- **The client (phase 2)**: every score write names its groups. `web-mobile/js/match_groups.jsx` is the ONE client owner of the names (pinned against `state.ScalarGroups`/`BoutGroup` by the shared table `internal/state/testdata/match_groups.json`: `TestMatchGroups_SharedTable` and `match_groups_table.test.jsx`), of the comparison (`changedGroups`, on WIRE shapes only: both sides go through `toBackendMatchResult`, the adopted copy via `matchWire` in api_serializers.jsx, so a placeholder slot, an Ht placement or a winner object never reads as a change; a field the write leaves out, e.g. an omitted correction reason or a silent representative bout's ippons, is not compared, and a bout row the write does not carry is never a change), of the union (`unionChanged`: no list on either side means every group and stays so) and of a group's words (`groupLabel`). `useChangedGroups(m)` (admin_scoring_autosave.jsx) is the one hook the three editors call; every patch goes through its `claim` (the individual and team `buildPatch`, a hantei included since bc-htsd, the engi save and autosave builder). A group counts against TWO baselines: the server value the editor last AGREED with for that group (seeded at mount, moved on at each build where local equals the prop), NOT the latest prop, or an edit held while another device changed a group the editor kept its own value for would put the old value back; and the last write the editor built, or a tap taken back before the first write's echo would name nothing. A reopen is a write the server makes, not a build, so when the match the editor renders leaves `completed` (a reopen, or a send back to the queue) the editor takes the result it now holds as agreed, that group alone (`useChangedGroups`): without it, ending a reopened match again with the verdict it had named nothing, and the server kept the match running with no error. It keys on the status, not on `reopenPending`: a reopen without a reason carries that stamp, but one a correction's reason comes with (a downstream reopen, a requalification, a pool-rank override) leaves the match scheduled with its points and no stamp, and an editor that watched only the stamp named no result for it. `startPatch` names `result` alone. The outbox keeps `changed` verbatim (persist, cross-tab, rehydrate, replay); a score write that takes a queued one's place claims the union (`_claimingQueuedGroups` in `_placeWrite`, and the absorb in `recordScore`, both against the one entry `_coalesceTarget` names); `_broadcastPatch` strips it. Answers: `writeHeldGroups`/`writePartlyHeld` (write_result.jsx) for a partial apply, which the editors answer with a quiet note (`useKeptInHistoryNote`/`KeptInHistoryNote`, worded by `heldGroupsNote`) and `editMatchScore` with a toast when the write closes the editor; a superseded write leads "Not applied", never "Not saved" (`SUPERSEDED_LEAD`, `notSavedText`'s `lead`, `supersededAlertText`). The editors' closed-by-default **History** disclosure (`MatchHistoryDisclosure`, match_history_view.jsx, `data-testid="match-history"`, hidden under `selfReport`) reads `GET /api/competitions/:id/matches/:mid/history` (`RegisterMatchHistoryHandler`, stamp order with an unstamped entry at its `receivedAt`, `[]` when none, 404 for an unknown competition or match, main-gated in self-run), readable values through `formatIpponsScore`/`withdrawalLabel`/`sideWord`. - **Phase 3, the remaining drop sites, each with one owner:** - **The running rev guard holds, never drops.** A running write with a lower `rev` than the same `revSession` already sent is answered by `holdOlderRevision` (handlers_match.go): one bare transaction, no court lock, no start gate, no correction check, and the high-water mark left alone. It calls `RecordMatchResultWithIneligibilityTx` with `engine.ForceOptions{HoldReason: engine.HoldReasonOlderRevision}`, whose `holdWriteTx` asks the merge owner on a copy with `mergeCtx.holdAll` (every changed group held, echoes excepted), records the history entry with the reason, and returns the superseded error; the answer is `respondSuperseded` with `heldGroups`. `{stale:true}` no longer exists anywhere (server, openapi, client: `_handleOkBody` and the admin toast that read it are gone). - - **The offline queue keeps, per match, a list of pending writes, each sent on its own** (api_client.jsx). Each pending write is its own entry, keyed `${base}${QUEUE_ENTRY_SEP}${id}` (`_newEntryKey`; the separator is `\u001f`), where the BASE is what the write is about (`_revKey` for a score or a decision, `override:…`, `lineup:…`; `_baseOf`). A key persisted by the previous build has no separator, so it is its own base: an old queue loads and replays unchanged, which is the migration. The ONE coalescing rule is `_coalesceTarget`: a score write takes the place of its match's LAST entry only when that entry is a score write not newer than it and not the page-hide copy of a write still being sent, and a RUNNING write only of a running one (`_placeWrite` then claims the union and carries a pending `kachinukiBoutFinal`); a LINEUP save joins a queued save of the same lineup (`joinQueuedLineupSave`, lineup_save.jsx: the union of `changed`, the later value per position): a lineup PUT carries no stamp the server could order it by, so a direct save could land before the queued one's replay and lose to it on a position both changed; the caller reads `{queued: true}` for the moment until the flush sends it. Nothing else replaces anything, so a running write never replaces a queued Finish or a decision, and a decision and a score are two entries. A pass sends entries in the ONE replay order, `_byEntryOrder` (the payload's `modifiedAt`, else `enqueuedAt`, then the key), but each entry on its own: one that does not settle (offline, 5xx, parked, re-stamped for skew, held by an open fetch) holds back nothing after it, and a new score, decision or override goes straight to the server whatever is queued, because the server merges by stamp, not arrival (bc-mrgc review C2: the old wait-behind rule let one write failing with a 5xx stop every later write for its match). The editors' "Retry now" on a queued Finish therefore sends it at once: landing, it drains the queued copy it claimed; failing, it takes that copy's place. A landed direct score write drains only the entry it claimed (`claimedEntry`), never a queued Finish or decision. The page-hide copy (`_keepInflightRunning`) is an entry of its own (`w.keptKey`); recordScore settles its OWN copy through the record it created (`inflightRecord`), even after a later running write took the map slot, and when it had one and its fetch answered it schedules a flush a microtask later, after the answer has drained its claimed entry. A queued answer says whether the write reached browser storage (`_queuedAnswer`: `persisted: false` only when `_persistQueue` failed, read before the enqueue kicks a flush). A write the server keeps refusing (past `SERVER_REJECTION_NOTICE_THRESHOLD`) can be discarded alone from its match's editor (`HeldWriteDiscard`, admin_scoring_shared.jsx; `API.heldWriteKeepsFailing`/`discardFailingHeldWrites`, that match's failing entries only, after a confirm). `resumeAfterAuth` gives the new password to every parked write AND every queued write still carrying a password the server refused, never to one queued with none (a self-run participant's). Every refusal on replay is reported: a decision's 409 (`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. @@ -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). @@ -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/screenshots/mobile-encho-overtime.png b/docs/screenshots/mobile-encho-overtime.png index 3673d71be..898a75319 100644 Binary files a/docs/screenshots/mobile-encho-overtime.png and b/docs/screenshots/mobile-encho-overtime.png differ diff --git a/docs/screenshots/mobile-pool-standings.png b/docs/screenshots/mobile-pool-standings.png index 9e2490186..2a18b0449 100644 Binary files a/docs/screenshots/mobile-pool-standings.png and b/docs/screenshots/mobile-pool-standings.png differ diff --git a/docs/screenshots/mobile-score-editor.png b/docs/screenshots/mobile-score-editor.png index a8a464599..7689e56b2 100644 Binary files a/docs/screenshots/mobile-score-editor.png and b/docs/screenshots/mobile-score-editor.png differ diff --git a/docs/user-guide/court-operators/recording-decisions.md b/docs/user-guide/court-operators/recording-decisions.md index 040e5276d..5e20fee6f 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. @@ -38,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** 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. ![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) @@ -101,7 +107,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 +124,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/docs/user-guide/court-operators/scoring-a-match.md b/docs/user-guide/court-operators/scoring-a-match.md index b4e854d36..1fa107a18 100644 --- a/docs/user-guide/court-operators/scoring-a-match.md +++ b/docs/user-guide/court-operators/scoring-a-match.md @@ -36,18 +36,20 @@ In an engi match the flags are saved as you enter them once the match has starte ## Correct a completed result -Once a match is scored it moves to the **Completed** list on the court console, and each row carries a **Correct** button. You do not have to leave the console to fix a mistake. Correct opens the finished match back in the score editor, even while the next match is already running on the same court. +Once a match is scored it moves to the **Completed** list on the court console, and each row carries a **Correct** button. You do not have to leave the console to fix a mistake. Correct opens the finished match back in the score editor. It works while no match is running on the court. If one is, tapping **Correct** names that match and tells you to finish it or send it back to the queue first, then correct. ![The court console Completed list showing a finished match with its result and a Correct button.](../../screenshots/console-correct-completed.png) 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. -Reopening puts the encounter back into play, and a court can only run one match at a time. If another match is already running on that court, 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. +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. -If the correction changes who won a knockout match, the later rounds update to follow the new winner. If the next match, or the bronze (3rd-place) match, has already been fought and scored, the app refuses the correction and names the match that is blocking it by its number and round, for example Match 3 (Final): the round is the heading of the bracket column you find it under. You can confirm anyway. Confirming applies the correction and reopens the named match where it is, with the new competitor in it: its winner is cleared, and its points and bouts are kept, so check them and take back any that no longer apply before you finish it again. The match was already played, so it stays where it is and nothing in the queue changes. A semifinal is the one case that names two matches at once, because it feeds both the final and the bronze match; confirming then reopens both. A round further on that nobody has played yet stops showing the winner of the reopened match. A round further on that has already been played is not changed yet: when you finish the match you have just reopened, the app asks you the same question again for that round, so you work forward one round at a time. A later slot that was only filled by a bye, and never fought, does not block a correction; it simply updates to follow the new winner. If the next match, or the bronze match, is being fought right now, the correction is refused and nothing is saved, for example "Match 3 (Final) is being fought now on Shiaijo A. Finish it or send it back to the queue, then save this correction again." Confirming cannot get past it. Sending the match back to the queue keeps its score. A later match that was sent back to the queue with points already scored takes the new competitor's name and keeps its points, so check them when you start it and take back any that no longer apply. When you are done, use **Back to court** to return to the live match. +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. + +If the correction changes who won a knockout match, the later rounds update to follow the new winner. If the next match, or the bronze (3rd-place) match, has already been fought and scored, the app refuses the correction and names the match that is blocking it by its number and round, for example Match 3 (Final): the round is the heading of the bracket column you find it under. You can confirm anyway. Confirming applies the correction and reopens the named match where it is, with the new competitor in it: its winner is cleared, and its points and bouts are kept, so check them and take back any that no longer apply before you finish it again. The match was already played, so it stays where it is and nothing in the queue changes. A semifinal is the one case that names two matches at once, because it feeds both the final and the bronze match; confirming then reopens both. A round further on that nobody has played yet stops showing the winner of the reopened match. A round further on that has already been played is not changed yet: when you finish the match you have just reopened, the app asks you the same question again for that round, so you work forward one round at a time. A later slot that was only filled by a bye, and never fought, does not block a correction; it simply updates to follow the new winner. If the next match, or the bronze match, is being fought right now, the correction is refused and nothing is saved, for example "Match 3 (Final) is being fought now on Shiaijo A. Finish it or send it back to the queue, then save this correction again." Confirming cannot get past it. Sending the match back to the queue keeps its score. A later match that was sent back to the queue with points already scored takes the new competitor's name and keeps its points, so check them when you start it and take back any that no longer apply. When you are done, **Save correction** ends the correction, or use **Back to court** to cancel it without saving. ### Correct a pool result after the knockout has started 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/bracket.go b/internal/engine/bracket.go index 76d50c0f0..a5682df48 100644 --- a/internal/engine/bracket.go +++ b/internal/engine/bracket.go @@ -76,7 +76,7 @@ func (e *Engine) generateKnockout(comp *state.Competition, players []domain.Play // bracket never carries it, its competitors are numbered pool by pool // instead, and generatePoolPreviewBracket passes nil for exactly that // reason. - bracket, err := e.buildBracketFromDraw(comp, draw, drawOrder) + bracket, err := e.buildBracketFromDraw(comp, draw, drawOrder, nil) if err != nil { return err } @@ -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,7 +158,7 @@ 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. - bracket, err := e.buildBracketFromDraw(comp, draw, nil) + bracket, err := e.buildBracketFromDraw(comp, draw, nil, poolMatches) if err != nil { return err } @@ -190,7 +194,13 @@ func (e *Engine) generatePoolPreviewBracket(comp *state.Competition) error { // be stamped from it (see state.Bracket.StampRoundZeroSideIDsFromDrawOrder); // generatePoolPreviewBracket passes nil, since a pool-fed bracket's leaves are // unresolved pool placeholders, not competitors, at draw time. -func (e *Engine) buildBracketFromDraw(comp *state.Competition, draw *helper.KnockoutDraw, drawOrder []string) (*state.Bracket, error) { +// +// poolMatches is the competition's stored pool phase (bc-kosc): when non-nil, +// each court's knockout slots start after that court's last pool slot +// (poolPhaseEndByCourt), the same pools-then-knockout sequencing the estimator +// uses. generateKnockout passes nil, so a knockout-only competition keeps the +// day start. +func (e *Engine) buildBracketFromDraw(comp *state.Competition, draw *helper.KnockoutDraw, drawOrder []string, poolMatches []state.MatchResult) (*state.Bracket, error) { if draw == nil || draw.Root == nil { return nil, fmt.Errorf("buildBracketFromDraw: no draw to build from") } @@ -368,7 +378,9 @@ func (e *Engine) buildBracketFromDraw(comp *state.Competition, draw *helper.Knoc // match-number order, so it runs after the numbering above. See pools.go // for the same wiring. Scheduled in number order, so the load-time repair // of the old storage-order scheduling owes it nothing (TimesSettled). - assignBracketMatchSlots(bracket.Rounds, comp, tournament) + // A pools + knockout draw starts each court's knockout after that court's + // last pool bout (bc-kosc); knockout-only (nil poolMatches) keeps the day start. + assignBracketMatchSlots(bracket.Rounds, comp, tournament, poolPhaseEndByCourt(poolMatches, comp, tournament)) bracket.TimesSettled = true // Bronze (3rd-place) knockout: only when this competition's format 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 d2b5b239e..2b88c2017 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 diff --git a/internal/engine/decision_no_cascade_test.go b/internal/engine/decision_no_cascade_test.go new file mode 100644 index 000000000..62f57c293 --- /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", 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/kachinuki_test.go b/internal/engine/kachinuki_test.go index 531ae2100..21a0b1f74 100644 --- a/internal/engine/kachinuki_test.go +++ b/internal/engine/kachinuki_test.go @@ -3380,7 +3380,7 @@ func saveMixedKachinukiCompForReopenTest(t *testing.T) (*Engine, *state.Store, s draw := helper.BuildKnockoutDraw(pools, 1, 1) comp, err := store.LoadCompetition(compID) require.NoError(t, err) - bracket, err := eng.buildBracketFromDraw(comp, draw, nil) + bracket, err := eng.buildBracketFromDraw(comp, draw, nil, nil) require.NoError(t, err) bracket.Preview = true require.NoError(t, store.SaveBracket(compID, bracket)) diff --git a/internal/engine/knockout_test.go b/internal/engine/knockout_test.go index c26a19350..8df9bf664 100644 --- a/internal/engine/knockout_test.go +++ b/internal/engine/knockout_test.go @@ -37,7 +37,7 @@ func saveMixedScaffold(t *testing.T, store *state.Store, compID string, pools [] draw := helper.BuildKnockoutDraw(pools, poolWinners, 1) eng := New(store) comp, _ := store.LoadCompetition(compID) - bracket, err := eng.buildBracketFromDraw(comp, draw, nil) + bracket, err := eng.buildBracketFromDraw(comp, draw, nil, nil) require.NoError(t, err) bracket.Preview = true require.NoError(t, store.SaveBracket(compID, bracket)) diff --git a/internal/engine/legacy_template_v1_test.go b/internal/engine/legacy_template_v1_test.go index 0f331358f..a87230850 100644 --- a/internal/engine/legacy_template_v1_test.go +++ b/internal/engine/legacy_template_v1_test.go @@ -35,7 +35,7 @@ func livePlaceholderBracket(t *testing.T, eng *Engine, comp *state.Competition, } draw := helper.BuildKnockoutDraw(pools, poolWinners, len(comp.Courts)) require.NotNil(t, draw) - bracket, err := eng.buildBracketFromDraw(comp, draw, nil) + bracket, err := eng.buildBracketFromDraw(comp, draw, nil, nil) require.NoError(t, err) return bracket } 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/mixed_schedule_test.go b/internal/engine/mixed_schedule_test.go new file mode 100644 index 000000000..4c9ebbb75 --- /dev/null +++ b/internal/engine/mixed_schedule_test.go @@ -0,0 +1,312 @@ +package engine + +import ( + "os" + "path/filepath" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/gitrgoliveira/bracket-creator/internal/state" +) + +// numberedKnockoutTimes returns the ScheduledAt of every numbered knockout +// match, by court. +func numberedKnockoutTimes(b *state.Bracket) map[string][]time.Time { + out := map[string][]time.Time{} + for _, r := range b.Rounds { + for _, m := range r { + if m.MatchNumber > 0 && m.ScheduledAt != "" { + out[m.Court] = append(out[m.Court], parseClockHHMM(m.ScheduledAt)) + } + } + } + return out +} + +func drawSchedule(t *testing.T, format string, courts []string, names []string) ([]state.MatchResult, *state.Bracket, *state.Competition, *state.Tournament) { + t.Helper() + eng, store, _ := setupTestEngine(t) + id := "bc-kosc-" + format + createTestCompetition(t, store, id, format, 3, func(c *state.Competition) { c.Courts = courts }) + saveTestParticipants(t, store, id, names) + require.NoError(t, eng.GenerateDraw(id)) + pm, err := store.LoadPoolMatches(id) + require.NoError(t, err) + b, err := store.LoadBracket(id) + require.NoError(t, err) + require.NotNil(t, b) + comp, err := store.LoadCompetition(id) + require.NoError(t, err) + tourn, err := store.LoadTournament() + require.NoError(t, err) + return pm, b, comp, tourn +} + +var kosc6 = []string{"Alice", "Bob", "Charlie", "Dave", "Eve", "Frank"} + +// bc-kosc: in a pools + knockout draw every knockout bout is timed after its +// court's last pool bout. +func TestGenerateDraw_Mixed_KnockoutScheduledAfterPools(t *testing.T) { + for name, courts := range map[string][]string{"one court": {"A"}, "two courts": {"A", "B"}} { + t.Run(name, func(t *testing.T) { + pm, b, comp, tourn := drawSchedule(t, state.CompFormatMixed, courts, kosc6) + ends := poolPhaseEndByCourt(pm, comp, tourn) + require.NotEmpty(t, ends) + ko := numberedKnockoutTimes(b) + require.NotEmpty(t, ko) + for court, times := range ko { + end, ok := ends[court] + require.Truef(t, ok, "court %s has knockout bouts but no pool bouts", court) + for _, ts := range times { + assert.Falsef(t, ts.Before(end), "court %s knockout at %s starts before its pools end at %s", court, ts.Format("15:04"), end.Format("15:04")) + } + } + assert.Lenf(t, ends, len(courts), "every court should hold pool bouts") + }) + } +} + +// Knockout-only competitions keep the day start. +func TestGenerateDraw_KnockoutOnly_StartsAtDayStart(t *testing.T) { + _, b, _, _ := drawSchedule(t, state.CompFormatKnockout, []string{"A"}, kosc6) + first := "" + for _, r := range b.Rounds { + for _, m := range r { + if m.MatchNumber == 1 { + first = m.ScheduledAt + } + } + } + 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") +} + +// 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. +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 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)) + 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/pool_requalify_test.go b/internal/engine/pool_requalify_test.go index c2c7162c4..95320661a 100644 --- a/internal/engine/pool_requalify_test.go +++ b/internal/engine/pool_requalify_test.go @@ -70,7 +70,7 @@ func newRQFixture(t *testing.T, compID string, poolWinners int, names [][]string draw := helper.BuildKnockoutDraw(pools, poolWinners, 1) stored, err := store.LoadCompetition(compID) require.NoError(t, err) - bracket, err := eng.buildBracketFromDraw(stored, draw, nil) + bracket, err := eng.buildBracketFromDraw(stored, draw, nil, nil) require.NoError(t, err) require.NoError(t, store.SaveBracket(compID, bracket)) return &rqFix{t: t, eng: eng, store: store, compID: compID} @@ -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/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/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/schedule.go b/internal/engine/schedule.go index 2018c98e0..ea3016381 100644 --- a/internal/engine/schedule.go +++ b/internal/engine/schedule.go @@ -347,17 +347,18 @@ func EstimateSchedule(in EstimateInput) ScheduleEstimate { // skipCeremonyBlocks helper. ClosingBlock is not entered by the cursor, it is // surfaced only as CeremonyMinutes. // -// Phase sequencing (intentional, and a SECOND divergence from the post-draw -// path): each court runs its pool matches and THEN its knockout matches on the -// same advancing cursor, pools-then-knockout, which is the realistic order -// (knockout seeding needs pool results). The post-draw slot assigners -// (assignPoolMatchSlots / assignBracketMatchSlots) are invoked as two separate -// calls that EACH re-anchor to dayStart+OpeningBlock, so they OVERLAP the two -// phases in clock time. A post-draw estimate must therefore SEQUENCE the two -// phases rather than max() them, but it must add the OpeningBlock offset ONCE, -// not once per phase. Summing the raw cursor durations would double-count -// OpeningBlock (each cursor = dayStart + OpeningBlock + match-time); instead sum -// the per-phase match durations and add OpeningBlock a single time. See mp-zoh. +// Phase sequencing: each court runs its pool matches and THEN its knockout +// matches on the same advancing cursor, pools-then-knockout, which is the +// realistic order (knockout seeding needs pool results). The post-draw path +// sequences the phases per court the same way: the knockout slot assigner +// (assignBracketMatchSlots) starts each court's cursor at the end of that +// court's stored pool schedule (poolPhaseEndByCourt, bc-kosc), while a +// knockout-only competition keeps the day start. A post-draw estimate must +// therefore SEQUENCE the two phases rather than max() them, and it must add +// the OpeningBlock offset ONCE, not once per phase. Summing the raw cursor +// durations would double-count OpeningBlock (each cursor = dayStart + +// OpeningBlock + match-time); instead sum the per-phase match durations and +// add OpeningBlock a single time. See mp-zoh. // // Negative counts are clamped to 0, the helper is exported and likely fed // derived/user inputs (mp-zoh), and a negative count would otherwise make diff --git a/internal/engine/scheduler_slots.go b/internal/engine/scheduler_slots.go index 4fbf942a6..bea6d7726 100644 --- a/internal/engine/scheduler_slots.go +++ b/internal/engine/scheduler_slots.go @@ -258,6 +258,147 @@ func assignPoolMatchSlots(matches []state.MatchResult, comp *state.Competition, return matches, maxCursor } +// 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 both +// callers that build the preview bracket (the draw and the quarantine +// rebuild) and for pushKnockoutPastPools, which moves the knockout when a +// 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 + for _, m := range matches { + if m.ScheduledAt == "" { + continue + } + start, err := time.Parse(scheduleClockLayout, m.ScheduledAt) + if err != nil { + continue + } + end := start.Add(slot) + if cur, ok := ends[m.Court]; !ok || end.After(cur) { + ends[m.Court] = end + } + } + return ends +} + +// pushKnockoutPastPools moves a court's knockout matches after that court's +// 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, +// 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 { + 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 + } + } + // 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 != "": + 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 + } + } + 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 ms[i].at.Before(ms[j].at) }) + if !ms[0].at.Before(poolEnd) { + continue + } + cursor := poolEnd + 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 = skipCeremonyBlocks(cursor, lunchStart, lunchMin) + } + if !t.Equal(x.at) { + x.m.ScheduledAt = t.Format(scheduleClockLayout) + moved = true + } + cursor = t.Add(slot) + } + } + 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 @@ -270,6 +411,15 @@ func assignPoolMatchSlots(matches []state.MatchResult, comp *state.Competition, // a match to play. The court cursor is NOT advanced for them (they consume // no court time). // +// 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 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 // last match on the busiest court finishes). Callers that only want // the in-place mutation may discard the return value. @@ -277,18 +427,37 @@ func assignPoolMatchSlots(matches []state.MatchResult, comp *state.Competition, // As with assignPoolMatchSlots, the end-cursor is the per-court start anchor // (comp.StartTime + OpeningBlock) when there are no rounds, and a zero // time.Time only when comp is nil. -func assignBracketMatchSlots(rounds [][]state.BracketMatch, comp *state.Competition, tournament *state.Tournament) time.Time { +func assignBracketMatchSlots(rounds [][]state.BracketMatch, comp *state.Competition, tournament *state.Tournament, courtStart map[string]time.Time) time.Time { if comp == nil { return time.Time{} } dayStart, openingMin, lunchMin, lunchStart := parseCeremonyParams(comp, tournament) + openAt := dayStart.Add(time.Duration(openingMin) * time.Minute) if len(rounds) == 0 { - return dayStart.Add(time.Duration(openingMin) * time.Minute) + 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 { + if t.After(openAt) { + return t + } + return openAt + } + return latestPoolEnd + } courtCursor := map[string]time.Time{} for _, court := range comp.Courts { - courtCursor[court] = dayStart.Add(time.Duration(openingMin) * time.Minute) + courtCursor[court] = startFor(court) } perMatchMin := perMatchElapsedMinutes(comp, tournament, true /*isKnockout*/) @@ -318,7 +487,7 @@ func assignBracketMatchSlots(rounds [][]state.BracketMatch, comp *state.Competit court := m.Court cursor, ok := courtCursor[court] if !ok { - cursor = dayStart.Add(time.Duration(openingMin) * time.Minute) + cursor = startFor(court) } cursor = skipCeremonyBlocks(cursor, lunchStart, lunchMin) m.ScheduledAt = cursor.Format(scheduleClockLayout) diff --git a/internal/engine/scheduler_slots_test.go b/internal/engine/scheduler_slots_test.go index 486340ed1..9be6b790a 100644 --- a/internal/engine/scheduler_slots_test.go +++ b/internal/engine/scheduler_slots_test.go @@ -411,7 +411,7 @@ func TestAssignSlotsBracketByesSkipCursor(t *testing.T) { {ID: "m-r1-2", Court: "A", Status: state.MatchStatusScheduled}, }, } - assignBracketMatchSlots(rounds, comp, tournament) + assignBracketMatchSlots(rounds, comp, tournament, nil) // Bye, then two real matches. All three get a time slot, but // the bye does not advance the cursor: 09:00 (bye), 09:00, 09:06. @@ -530,7 +530,7 @@ func TestAssignSlots_EmptyReturnsStartAnchorNotZero(t *testing.T) { assert.True(t, poolCur.Equal(dayStart), "empty pool slots (no opening) should return dayStart") assert.Equal(t, 0.0, poolCur.Sub(dayStart).Minutes(), "empty pool duration must be 0") - brkCur := assignBracketMatchSlots(nil, comp, nil) + brkCur := assignBracketMatchSlots(nil, comp, nil, nil) assert.True(t, brkCur.Equal(dayStart), "empty bracket slots (no opening) should return dayStart") // With a 30m opening block → anchor == dayStart + 30m (consistent with the @@ -538,7 +538,7 @@ func TestAssignSlots_EmptyReturnsStartAnchorNotZero(t *testing.T) { tourn := &state.Tournament{OpeningBlock: "30m"} _, poolCurOpen := assignPoolMatchSlots(nil, comp, tourn) assert.Equal(t, 30.0, poolCurOpen.Sub(dayStart).Minutes(), "empty pool with 30m opening must anchor at +30m") - brkCurOpen := assignBracketMatchSlots(nil, comp, tourn) + brkCurOpen := assignBracketMatchSlots(nil, comp, tourn, nil) assert.Equal(t, 30.0, brkCurOpen.Sub(dayStart).Minutes(), "empty bracket with 30m opening must anchor at +30m") // nil comp → zero time.Time (dayStart cannot be derived). @@ -560,7 +560,7 @@ func TestScheduleBronze_PlacesBronzeBeforeFinalOnSharedCourt(t *testing.T) { }, ThirdPlaceMatch: &state.BracketMatch{ID: "m-bronze", Court: "A", Status: state.MatchStatusScheduled}, } - assignBracketMatchSlots(bracket.Rounds, comp, nil) + assignBracketMatchSlots(bracket.Rounds, comp, nil, nil) finalBefore := bracket.Rounds[1][0].ScheduledAt require.NotEmpty(t, finalBefore) @@ -588,7 +588,7 @@ func TestScheduleBronze_DifferentCourtInheritsFinalTimeOnly(t *testing.T) { }, ThirdPlaceMatch: &state.BracketMatch{ID: "m-bronze", Court: "B", Status: state.MatchStatusScheduled}, } - assignBracketMatchSlots(bracket.Rounds, comp, nil) + assignBracketMatchSlots(bracket.Rounds, comp, nil, nil) finalBefore := bracket.Rounds[1][0].ScheduledAt scheduleBronze(bracket, comp, nil) @@ -596,3 +596,73 @@ func TestScheduleBronze_DifferentCourtInheritsFinalTimeOnly(t *testing.T) { assert.Equal(t, finalBefore, bracket.ThirdPlaceMatch.ScheduledAt, "different-court bronze inherits the final's time") assert.Equal(t, finalBefore, bracket.Rounds[1][0].ScheduledAt, "different-court bronze must not push the final") } + +// TestAssignBracketSlots_StartAfterPoolPhase pins bc-kosc: a per-court start +// moves that court's knockout slots after its pool phase, a bye still takes +// the court's start and consumes nothing, and a nil map keeps the day start. +func TestAssignBracketSlots_StartAfterPoolPhase(t *testing.T) { + comp := &state.Competition{ + StartTime: "09:00", + KnockoutMatchDurationSeconds: 240, + Courts: []string{"A", "B"}, + } + tournament := &state.Tournament{ClockToElapsedMultiplier: 1.5} + build := func() [][]state.BracketMatch { + return [][]state.BracketMatch{{ + {ID: "bye", Court: "A", Status: state.MatchStatusCompleted, Winner: "X"}, + {ID: "a1", Court: "A", Status: state.MatchStatusScheduled, MatchNumber: 1}, + {ID: "a2", Court: "A", Status: state.MatchStatusScheduled, MatchNumber: 2}, + {ID: "b1", Court: "B", Status: state.MatchStatusScheduled, MatchNumber: 3}, + }} + } + + rounds := build() + assignBracketMatchSlots(rounds, comp, tournament, map[string]time.Time{"A": parseClockHHMM("09:24")}) + 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: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")}) + assert.Equal(t, "09:00", rounds[0][1].ScheduledAt, "an earlier court start never pulls before the day start") + + rounds = build() + assignBracketMatchSlots(rounds, comp, tournament, nil) + assert.Equal(t, "09:00", rounds[0][1].ScheduledAt, "nil keeps today's behaviour") + assert.Equal(t, "09:06", rounds[0][2].ScheduledAt) +} + +func TestPoolPhaseEndByCourt(t *testing.T) { + tournament := &state.Tournament{ClockToElapsedMultiplier: 1.5} + + t.Run("latest per court, skips blank and unparseable rows", func(t *testing.T) { + comp := &state.Competition{PoolMatchDurationSeconds: 240, Courts: []string{"A", "B"}} + per := perMatchElapsedMinutes(comp, tournament, false) + require.Equal(t, 6, per) + ends := poolPhaseEndByCourt([]state.MatchResult{ + {Court: "A", ScheduledAt: "09:00"}, + {Court: "A", ScheduledAt: "09:06"}, + {Court: "A", ScheduledAt: ""}, + {Court: "B", ScheduledAt: "bogus"}, + }, comp, tournament) + assert.Equal(t, map[string]time.Time{"A": parseClockHHMM("09:12")}, ends) + }) + + t.Run("a hand-moved later time wins", func(t *testing.T) { + comp := &state.Competition{PoolMatchDurationSeconds: 240} + ends := poolPhaseEndByCourt([]state.MatchResult{ + {Court: "A", ScheduledAt: "14:00"}, + {Court: "A", ScheduledAt: "09:00"}, + }, comp, tournament) + assert.Equal(t, parseClockHHMM("14:06"), ends["A"]) + }) + + t.Run("team duration", func(t *testing.T) { + comp := &state.Competition{Kind: "team", TeamSize: 5, PoolMatchDurationSeconds: 240} + per := perMatchElapsedMinutes(comp, tournament, false) + require.Greater(t, per, 6) + ends := poolPhaseEndByCourt([]state.MatchResult{{Court: "A", ScheduledAt: "09:00"}}, comp, tournament) + assert.Equal(t, parseClockHHMM("09:00").Add(time.Duration(per)*time.Minute), ends["A"]) + }) +} diff --git a/internal/engine/scoring.go b/internal/engine/scoring.go index 8f87fb90f..a28186367 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 @@ -3327,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 } @@ -3548,6 +3544,50 @@ 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 + } + // 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 { + 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 { + 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/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..d29ddc1f8 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,60 +933,46 @@ 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 // RecordMatchResultWithIneligibility(Tx) and OverrideBracketWinner already // give their callers, so this decision entry point no longer silently drops // the list and leaves the caller unable to broadcast match_updated for the -// reopened matches. A caller that does not need to distinguish the two -// 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)) +// reopened matches. A caller with no bc-kcdg answer to give calls +// RecordDecisionTx, which passes an empty ForceOptions. +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 +983,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 +1021,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 +1032,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 +1094,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 +1222,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..1d7f92cea 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 @@ -765,7 +781,7 @@ func saveMixedCompForGuardTest(t *testing.T, teamSize int) (*Engine, *state.Stor draw := helper.BuildKnockoutDraw(pools, 1, 1) comp, err := store.LoadCompetition(compID) require.NoError(t, err) - bracket, err := eng.buildBracketFromDraw(comp, draw, nil) + bracket, err := eng.buildBracketFromDraw(comp, draw, nil, nil) require.NoError(t, err) bracket.Preview = true require.NoError(t, store.SaveBracket(compID, bracket)) @@ -1293,7 +1309,7 @@ func TestPoolRescore_CorruptBracket_FailsClosed(t *testing.T) { draw := helper.BuildKnockoutDraw(pools, 1, 1) comp, err := store.LoadCompetition(compID) require.NoError(t, err) - bracket, err := eng.buildBracketFromDraw(comp, draw, nil) + bracket, err := eng.buildBracketFromDraw(comp, draw, nil, nil) require.NoError(t, err) require.NoError(t, store.SaveBracket(compID, bracket)) bracketPath := filepath.Join(dir, "competitions", compID, "bracket.json") 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 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/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) }) 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/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..7238fbe41 100644 --- a/scripts/screenshots/recipes/public.mjs +++ b/scripts/screenshots/recipes/public.mjs @@ -51,24 +51,23 @@ 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(); } // 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/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/css/styles.css b/web-mobile/css/styles.css index 5e5669e57..6cc8d4843 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,32 @@ abbr.msb-lab { text-decoration: none; cursor: help; } box-shadow: var(--focus-ring); } +/* --- Decision prompt side picker (bc-dsid) ------------------------------ */ +/* The side pills sit in a .radio-group row. 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 7391f71a3..d5489034a 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'); }); }); @@ -305,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', @@ -342,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( @@ -413,91 +313,30 @@ 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('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', () => { - // 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', () => { @@ -1291,40 +1130,9 @@ 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 - // `(!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', () => { - const onSubmit = vi.fn(); - const onSubmitAndNext = vi.fn(); - const isComplete = false; // live match, not a correction - const submitFn = (!isComplete && onSubmitAndNext) ? onSubmitAndNext : onSubmit; - const patch = { winner: { id: 'p1', name: 'Hayashi' }, decidedByHantei: true, status: 'completed' }; - submitFn(patch); - expect(onSubmitAndNext).toHaveBeenCalledWith(patch); - expect(onSubmit).not.toHaveBeenCalled(); - }); - - it('routes hantei to onSubmit (not onSubmitAndNext) when correcting a completed match', () => { - const onSubmit = vi.fn(); - const onSubmitAndNext = vi.fn(); - const isComplete = true; // correction: do not auto-advance - const submitFn = (!isComplete && onSubmitAndNext) ? onSubmitAndNext : onSubmit; - const patch = { winner: { id: 'p1', name: 'Hayashi' }, decidedByHantei: true, status: 'completed' }; - submitFn(patch); - expect(onSubmit).toHaveBeenCalledWith(patch); - expect(onSubmitAndNext).not.toHaveBeenCalled(); - }); - - it('routes hantei to onSubmit when onSubmitAndNext is not provided', () => { - const onSubmit = vi.fn(); - const onSubmitAndNext = undefined; - const isComplete = false; - const submitFn = (!isComplete && onSubmitAndNext) ? onSubmitAndNext : onSubmit; - const patch = { winner: { id: 'p2', name: 'Mori' }, decidedByHantei: true, status: 'completed' }; - submitFn(patch); - expect(onSubmit).toHaveBeenCalledWith(patch); - }); + // A hantei is committed by Finish and routed like any finish; that path is + // pinned through the real editor in hantei_single_tap.render.test.jsx + // (bc-htsd), so no copy of the routing ternary is tested here. // makeSubmitDecision: onAfterDecision is called for fusenpai when provided // and the match is not complete. @@ -1372,7 +1180,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 +1195,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 +1206,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 +1218,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 +1235,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 +1247,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 +1339,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 +1348,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 +1376,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__/pool_ids.test.jsx b/web-mobile/js/__tests__/pool_ids.test.jsx index 8975cb7a1..4034280e6 100644 --- a/web-mobile/js/__tests__/pool_ids.test.jsx +++ b/web-mobile/js/__tests__/pool_ids.test.jsx @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest'; -import { poolNameOf, isSupplementaryBout, isPoolDaihyosenBout, poolMatchNumberOf } from '../pool_ids.jsx'; +import { poolNameOf, isSupplementaryBout, isPoolDaihyosenBout, poolMatchNumberOf, scoreRowMatchName } from '../pool_ids.jsx'; describe('poolNameOf', () => { it('parses regular / DH / TB ids to the pool name, hyphens preserved', () => { @@ -77,3 +77,27 @@ describe('poolMatchNumberOf (a pool bout is numbered inside its own pool)', () = expect(poolMatchNumberOf(null)).toBe(0); }); }); + +// bc-crpn: the label an operator line quotes a match by, Shiro first. +describe('scoreRowMatchName', () => { + const sides = { sideA: { name: 'Sato' }, sideB: { name: 'Kato' } }; + + it('a pool row reads label, then the pairing with Shiro (sideB) first', () => { + expect(scoreRowMatchName({ id: 'Pool A-1', phase: 'pool', poolName: 'Pool A', ...sides })) + .toBe('Pool A · Match 2 · Kato vs Sato'); + }); + + it('a daihyosen or tiebreaker row carries no label, so it is the bare pairing', () => { + expect(scoreRowMatchName({ id: 'Pool A-DH-0', phase: 'pool', poolName: 'Pool A', ...sides })).toBe('Kato vs Sato'); + expect(scoreRowMatchName({ id: 'Pool A-TB-0', phase: 'pool', poolName: 'Pool A', ...sides })).toBe('Kato vs Sato'); + }); + + it('a numbered knockout row reads "Match N", an unnumbered one the bare pairing', () => { + expect(scoreRowMatchName({ id: 'r1-m0', phase: 'bracket', matchNumber: 4, ...sides })).toBe('Match 4 · Kato vs Sato'); + expect(scoreRowMatchName({ id: 'r1-m0', phase: 'bracket', ...sides })).toBe('Kato vs Sato'); + }); + + it('a side with no name falls back to Shiro / Aka', () => { + expect(scoreRowMatchName({ id: 'Pool A-DH-0', phase: 'pool' })).toBe('Shiro vs Aka'); + }); +}); 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__/render/admin_shiaijo.render.test.jsx b/web-mobile/js/__tests__/render/admin_shiaijo.render.test.jsx index 94581898d..9d202dc00 100644 --- a/web-mobile/js/__tests__/render/admin_shiaijo.render.test.jsx +++ b/web-mobile/js/__tests__/render/admin_shiaijo.render.test.jsx @@ -2,7 +2,8 @@ import React from 'react'; import { render, act, fireEvent, within } from '@testing-library/react'; import { describe, it, expect, vi, beforeAll, afterAll, beforeEach, afterEach } from 'vitest'; import { installWindowStubs } from '../helpers/stub_globals.js'; -import { DOWNSTREAM_KNOCKOUT_PLAYED_CANCELLED } from '../../write_result.jsx'; +import { DOWNSTREAM_KNOCKOUT_PLAYED_CANCELLED, startWhileCorrectingMessage, correctWhileRunningMessage } from '../../write_result.jsx'; +import { scoreRowMatchName } from '../../pool_ids.jsx'; // Window globals required by admin_shiaijo.jsx. // MODULE-EVAL-TIME entries (e.g. `const AdminTopbar = window.AdminTopbar;`) // must be set before the dynamic import, or the module captures undefined. @@ -800,10 +801,10 @@ describe('AdminShiaijoPage render-smoke', () => { // withdrawal reopens to "scheduled" instead (engine.reopenTargetStatus): // starting it straight into "running" would strand the operator behind // the eligibility gate. The correction must still end, or the panel stays - // pinned to the now-cleared correction and hides whatever the court's - // normal view would show -- here, the OTHER match already running on this - // same court -- until a reload. - it('a correction cleared to "scheduled" (still-barred fusensho) releases the panel back to the running bout', async () => { + // pinned to the now-cleared correction (and, with the console's refusal to + // start a match during a correction, no Start would ever work) until a + // reload. + it('a correction cleared to "scheduled" (still-barred fusensho) ends, and nothing stays pinned to it', async () => { const side = (id, name) => ({ id, name }); const m1 = { id: 'm1', compId: 'c1', compName: 'Cup', status: 'completed', phase: 'pool', poolName: 'Pool A', @@ -812,7 +813,7 @@ describe('AdminShiaijoPage render-smoke', () => { decision: 'fusensho', decisionBy: 'aka', }; const m2 = { - id: 'm2', compId: 'c1', compName: 'Cup', status: 'running', phase: 'pool', poolName: 'Pool A', + id: 'm2', compId: 'c1', compName: 'Cup', status: 'scheduled', phase: 'pool', poolName: 'Pool A', court: 'A', scheduledAt: '09:05', sideA: side('p3', 'Sato'), sideB: side('p4', 'Kato'), }; let current = [m1, m2]; @@ -827,7 +828,7 @@ describe('AdminShiaijoPage render-smoke', () => { try { let utils; await act(async () => { utils = renderPage(makeMinimalTournament()); }); - const editorMatch = () => utils.getByTestId('score-editor').getAttribute('data-match'); + const editorMatch = () => { const el = utils.queryByTestId('score-editor'); return el ? el.getAttribute('data-match') : null; }; const heading = () => utils.container.querySelector('.shiaijo-context__toggle').textContent; const refresh = async () => { await act(async () => { utils.getByRole('button', { name: /refresh/i }).click(); }); }; @@ -837,15 +838,15 @@ describe('AdminShiaijoPage render-smoke', () => { // Clear the withdrawal and reopen: the barred fusensho side is still // barred elsewhere, so the server sends m1 to "scheduled", not - // "running" (m2 keeps running throughout, on the same court). + // "running" (m2 is only queued, on the same court). current = [{ ...m1, status: 'scheduled', decision: '', decisionBy: '', winner: undefined }, m2]; await refresh(); - // The correction must end: the panel falls back to the court's - // normal view -- the running bout (m2) -- instead of staying pinned - // to m1. - expect(editorMatch(), "the panel must release m1 and follow the court's running bout").toBe('m2'); + // The correction must end: nothing stays pinned to m1 and the panel + // is the court's ordinary view (m2 is only queued, so no editor). + expect(editorMatch(), 'the panel must release m1').toBeNull(); expect(heading()).not.toContain('Correcting'); + expect(utils.queryByRole('button', { name: /back to court/i })).toBeNull(); } finally { window.API.fetchCourtMatches = prevFetch; window.API.subscribeToEvents = prevSub; @@ -1179,7 +1180,7 @@ describe('a barred match is skipped by every auto-pick (bc-cse)', () => { // Shared by the render tests below: a mutable court feed, a Refresh that // re-reads it, and the editor probe's current match. -async function mountCourt(initial) { +async function mountCourt(initial, opts = {}) { const feed = { current: initial }; window.tournamentMatches = () => feed.current; window.filterMatchesByCourt = (matches) => matches; @@ -1195,7 +1196,7 @@ async function mountCourt(initial) { let utils; await act(async () => { utils = render( - ); @@ -1516,3 +1517,212 @@ describe('a court shared with another competition', () => { } finally { c.restore(); } }); }); + +// bc-crpn (operator ruling 2026-09-27): the court console never holds a +// running match and an open correction through its own taps, in either order. +// Correct is refused while a bout is live; Start match is refused while a +// correction is open; a landed Save correction ends the correction. A match +// started from another screen mid-correction does NOT close it (operator +// decision 2026-10-07): the running match is listed beside it instead. +describe('a running match and an open correction never coexist on the console (bc-crpn)', () => { + const tapCorrect = async (c, rowText) => { + const row = [...c.utils.container.querySelectorAll('.shiaijo-qrow--complete')].find((r) => r.textContent.includes(rowText)); + const btn = [...row.querySelectorAll('button')].find((b) => b.textContent.trim() === 'Correct'); + await act(async () => { btn.click(); }); + return row; + }; + const tapUpNextStart = async (c) => { + const card = c.utils.container.querySelector('.shiaijo-upnext__card'); + const btn = [...card.querySelectorAll('button')].find((b) => /start match/i.test(b.textContent)); + await act(async () => { btn.click(); }); + return card; + }; + const heading = (c) => c.utils.container.querySelector('.shiaijo-context__toggle').textContent; + const rowOf = (c, text) => [...c.utils.container.querySelectorAll('.shiaijo-qrow')].find((r) => r.textContent.includes(text)); + const alertsWith = (c, text) => [...c.utils.container.querySelectorAll('[role="alert"]')].filter((a) => a.textContent.includes(text)); + + it('a) Correct is refused while a bout runs on this court, and the notice goes with the bout', async () => { + const m1 = courtMatch('m1', 'completed', { modifiedAt: 1000, winner: courtSide('m1-a', 'Aka m1') }); + const m2 = courtMatch('m2', 'running'); + const c = await mountCourt([m1, m2]); + try { + const row = await tapCorrect(c, 'Aka m1'); + const text = correctWhileRunningMessage({ court: 'A', label: scoreRowMatchName(m2) }); + expect(c.editorMatch()).toBe('m2'); + expect(heading(c)).not.toContain('Correcting'); + const alert = row.querySelector('[role="alert"]'); + expect(alert && alert.textContent).toBe(text); + expect(c.showToast).toHaveBeenCalledWith(text, 'error'); + + c.feed.current = [m1, { ...m2, status: 'completed', modifiedAt: 2000 }]; + await c.refresh(); + expect(alertsWith(c, text)).toHaveLength(0); + } finally { c.restore(); } + }); + + it('b) Correct is refused in the window after this console\'s own Start, and while the start is in flight', async () => { + const m1 = courtMatch('m1', 'completed', { modifiedAt: 1000 }); + const m2 = courtMatch('m2', 'scheduled', { modifiedAt: 100 }); + const onEditScore = vi.fn().mockResolvedValue({ applied: true }); + const c = await mountCourt([m1, m2], { onEditScore }); + try { + await tapUpNextStart(c); + expect(onEditScore).toHaveBeenCalledTimes(1); + // The feed has NOT caught up: m2 still reads "scheduled". + await tapCorrect(c, 'Aka m1'); + const text = correctWhileRunningMessage({ court: 'A', label: scoreRowMatchName(m2) }); + expect(c.showToast).toHaveBeenCalledWith(text, 'error'); + expect(heading(c)).not.toContain('Correcting'); + } finally { c.restore(); } + + const m1b = courtMatch('m1', 'completed', { modifiedAt: 1000 }); + const m2b = courtMatch('m2', 'scheduled', { modifiedAt: 100 }); + const pending = vi.fn().mockImplementation(() => new Promise(() => {})); + const c2 = await mountCourt([m1b, m2b], { onEditScore: pending }); + try { + await tapUpNextStart(c2); + await tapCorrect(c2, 'Aka m1'); + expect(c2.showToast).toHaveBeenCalledWith(correctWhileRunningMessage({ court: 'A', label: scoreRowMatchName(m2b) }), 'error'); + expect(heading(c2)).not.toContain('Correcting'); + } finally { c2.restore(); } + }); + + it('c) Start is refused while a correction is open, on the Up next card and on an Upcoming row', async () => { + const m1 = courtMatch('m1', 'completed', { modifiedAt: 1000 }); + const m2 = courtMatch('m2', 'scheduled'); + const m3 = courtMatch('m3', 'scheduled'); + const onEditScore = vi.fn().mockResolvedValue({ applied: true }); + const c = await mountCourt([m1, m2, m3], { onEditScore }); + try { + await tapCorrect(c, 'Aka m1'); + expect(c.editorMatch()).toBe('m1'); + const text = startWhileCorrectingMessage({ label: scoreRowMatchName(m1) }); + + const card = await tapUpNextStart(c); + expect(onEditScore).not.toHaveBeenCalled(); + expect(window.API.revertMatchToQueue).not.toHaveBeenCalled(); + expect(c.editorMatch()).toBe('m1'); + expect(heading(c)).toContain('Correcting'); + expect(card.querySelector('.shiaijo-upnext__error[role="alert"]').textContent).toBe(text); + expect(c.showToast).toHaveBeenCalledWith(text, 'error'); + + const row3 = rowOf(c, 'Aka m3'); + const start3 = [...row3.querySelectorAll('button')].find((b) => /start match/i.test(b.textContent)); + await act(async () => { start3.click(); }); + expect(onEditScore).not.toHaveBeenCalled(); + expect(rowOf(c, 'Aka m3').querySelector('[role="alert"]').textContent).toBe(text); + // A newer tap replaces the older notice. + expect(c.utils.container.querySelector('.shiaijo-upnext__card .shiaijo-upnext__error')).toBeNull(); + } finally { c.restore(); } + }); + + it('f) Back to court cancels the correction, then Start works', async () => { + const m1 = courtMatch('m1', 'completed', { modifiedAt: 1000 }); + const m2 = courtMatch('m2', 'scheduled'); + const onEditScore = vi.fn().mockResolvedValue({ applied: true }); + const c = await mountCourt([m1, m2], { onEditScore }); + try { + await tapCorrect(c, 'Aka m1'); + await tapUpNextStart(c); + 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, 'Save the correction of')).toHaveLength(0); + await tapUpNextStart(c); + expect(onEditScore).toHaveBeenCalledTimes(1); + expect(onEditScore.mock.calls[0][1]).toBe('m2'); + } finally { c.restore(); } + }); + + it('d) a landed Save correction ends the correction, then Start works; a queued one keeps it open', async () => { + const m1 = courtMatch('m1', 'completed', { modifiedAt: 1000 }); + const m2 = courtMatch('m2', 'scheduled'); + const onEditScore = vi.fn().mockResolvedValue({ applied: true }); + const c = await mountCourt([m1, m2], { onEditScore }); + try { + await tapCorrect(c, 'Aka m1'); + expect(c.utils.queryByRole('button', { name: /back to court/i })).toBeTruthy(); + await act(async () => { await probe.props.onSubmit({ status: 'completed' }); }); + expect(c.utils.queryByRole('button', { name: /back to court/i })).toBeNull(); + expect(heading(c)).not.toContain('Correcting'); + await tapUpNextStart(c); + expect(onEditScore).toHaveBeenCalledTimes(2); + expect(onEditScore.mock.calls[1][1]).toBe('m2'); + } finally { c.restore(); } + + const queued = vi.fn().mockResolvedValue({ queued: true }); + const c2 = await mountCourt([courtMatch('m1', 'completed', { modifiedAt: 1000 }), courtMatch('m2', 'scheduled')], { onEditScore: queued }); + try { + await tapCorrect(c2, 'Aka m1'); + await act(async () => { await probe.props.onSubmit({ status: 'completed' }); }); + expect(c2.utils.queryByRole('button', { name: /back to court/i })).toBeTruthy(); + expect(heading(c2)).toContain('Correcting'); + } finally { c2.restore(); } + + // A write that is not a finish (a running patch) never ends it either. + const c3 = await mountCourt([courtMatch('m1', 'completed', { modifiedAt: 1000 })], { onEditScore: vi.fn().mockResolvedValue({ applied: true }) }); + try { + await tapCorrect(c3, 'Aka m1'); + await act(async () => { await probe.props.onSubmit({ status: 'running' }); }); + expect(heading(c3)).toContain('Correcting'); + } 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')]); + try { + await tapCorrect(c, 'Aka m1'); + expect(heading(c)).toContain('Correcting'); + await act(async () => { probe.props.onClose(); }); + expect(heading(c)).not.toContain('Correcting'); + expect(c.utils.queryByRole('button', { name: /back to court/i })).toBeNull(); + } finally { c.restore(); } + + const c2 = await mountCourt([courtMatch('m1', 'completed', { modifiedAt: 1000 }), courtMatch('m2', 'running')]); + try { + expect(c2.editorMatch()).toBe('m2'); + await act(async () => { probe.props.onClose(); }); + expect(c2.editorMatch()).toBe('m2'); + } finally { c2.restore(); } + }); + + it('g) a match started from another screen mid-correction leaves the correction open and is listed beside it', async () => { + const m1 = courtMatch('m1', 'completed', { modifiedAt: 1000 }); + const m2 = courtMatch('m2', 'scheduled'); + const c = await mountCourt([m1, m2]); + try { + await tapCorrect(c, 'Aka m1'); + expect(c.utils.queryByText(/another bout is running/i)).toBeNull(); + c.feed.current = [m1, { ...m2, status: 'running', modifiedAt: 3000 }]; + await c.refresh(); + expect(c.editorMatch()).toBe('m1'); + expect(heading(c)).toContain('Correcting'); + expect(c.showToast).not.toHaveBeenCalled(); + const block = c.utils.container.querySelector('.shiaijo-also-running'); + expect(block, 'the running match is listed above the correction').toBeTruthy(); + expect(block.textContent).toContain('Another bout is running on Shiaijo A'); + expect(block.textContent).toContain('Shiro m2 vs Aka m2'); + } finally { c.restore(); } + }); +}); 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/__tests__/render/correction_save_is_not_chained.render.test.jsx b/web-mobile/js/__tests__/render/correction_save_is_not_chained.render.test.jsx new file mode 100644 index 000000000..c4a47632a --- /dev/null +++ b/web-mobile/js/__tests__/render/correction_save_is_not_chained.render.test.jsx @@ -0,0 +1,90 @@ +import React from 'react'; +import { render, act, fireEvent } from '@testing-library/react'; +import { describe, it, expect, vi, beforeAll, afterAll } from 'vitest'; +import { installWindowStubs } from '../helpers/stub_globals.js'; + +// bc-crpn: the court console starts the next match only through +// onSubmitAndNext and onAfterDecision. A completed match shown as a +// correction must reach neither: Save correction calls onSubmit and never +// onSubmitAndNext, in the individual and the team editor (the engi editor is +// pinned in admin_scoring_engi.render.test.jsx). That is why the console needs +// no start gate on those two doors; if this changes, it needs one. + +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()); + +const buttonLike = (re) => [...document.querySelectorAll('button')].find((b) => re.test(b.textContent || '')); + +describe('Save correction saves the match alone (bc-crpn)', () => { + it('individual editor: onSubmit, never onSubmitAndNext', async () => { + const onSubmit = vi.fn().mockResolvedValue({ applied: true }); + const onSubmitAndNext = vi.fn().mockResolvedValue({ applied: true }); + const match = { + id: 'm1', compId: 'c1', status: 'completed', phase: 'pool', poolName: 'Pool 1', court: 'A', + sideA: { id: 'p1', name: 'Yamada' }, sideB: { id: 'p2', name: 'Tanaka' }, + ipponsA: ['M'], ipponsB: [], hansokuA: 0, hansokuB: 0, + winner: { id: 'p1', name: 'Yamada' }, + }; + await act(async () => { + render(); + }); + await act(async () => { fireEvent.click(buttonLike(/Save correction/)); }); + const prompt = document.querySelector('.reason-prompt'); + expect(prompt, 'a correction asks for its reason').toBeTruthy(); + await act(async () => { fireEvent.click(prompt.querySelector('button[type="submit"]')); }); + expect(onSubmit).toHaveBeenCalledTimes(1); + expect(onSubmit.mock.calls[0][0].status).toBe('completed'); + expect(onSubmitAndNext).not.toHaveBeenCalled(); + }); + + it('team editor: onSubmit, never onSubmitAndNext', async () => { + const onSubmit = vi.fn().mockResolvedValue({ applied: true }); + const onSubmitAndNext = vi.fn().mockResolvedValue({ applied: true }); + const match = { + id: 'm-pool-1', compId: 'comp1', status: 'completed', phase: 'pool', poolName: 'Pool 1', court: 'A', + compKind: 'team', teamSize: 3, + sideA: { id: 'team-kyoto', name: 'Kyoto' }, sideB: { id: 'team-osaka', name: 'Osaka' }, + decision: 'kiken-voluntary', decisionBy: 'aka', decisionReason: 'knee', + winner: { id: 'team-osaka', name: 'Osaka' }, ipponsB: ['○', '○'], + subResults: [{ position: 1, sideA: '', sideB: '', ipponsA: ['M'], ipponsB: [], winner: 'Kyoto', decision: '' }], + }; + await act(async () => { + render(); + }); + await act(async () => { fireEvent.click(buttonLike(/Save correction/)); }); + const confirm = buttonLike(/Tap again|Confirm/); + if (confirm) await act(async () => { fireEvent.click(confirm); }); + expect(onSubmit).toHaveBeenCalledTimes(1); + expect(onSubmit.mock.calls[0][0].status).toBe('completed'); + expect(onSubmitAndNext).not.toHaveBeenCalled(); + }); +}); 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/hantei_single_tap.render.test.jsx b/web-mobile/js/__tests__/render/hantei_single_tap.render.test.jsx new file mode 100644 index 000000000..f0c76aecc --- /dev/null +++ b/web-mobile/js/__tests__/render/hantei_single_tap.render.test.jsx @@ -0,0 +1,199 @@ +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'; +import { TAP_BOUNCE_MS } from '../../tap_guard.jsx'; +import { pointerTap, keyboardClick } from '../helpers/tap_events.js'; + +// 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, +}); + +// Most steps are keyboard-style clicks (no bounce guard); the Finish two-tap +// below is driven as a real finger would, through the bounce window. +const click = keyboardClick; +const wait = (ms) => act(async () => { vi.advanceTimersByTime(ms); }); +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('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); + 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 pointerTap(finishBtn()); + expect(onSubmitAndNext, 'the first Finish tap only arms').not.toHaveBeenCalled(); + await pointerTap(finishBtn()); + expect(onSubmitAndNext, 'the bounce of the arming tap does not commit').not.toHaveBeenCalled(); + await wait(TAP_BOUNCE_MS + 50); + await pointerTap(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/); + 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 () => { + 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); + }); + + 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(); + 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_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..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 @@ -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 () => { @@ -261,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__/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/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/__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/__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/__tests__/write_result_court_console_refusals.test.jsx b/web-mobile/js/__tests__/write_result_court_console_refusals.test.jsx new file mode 100644 index 000000000..9aef82e11 --- /dev/null +++ b/web-mobile/js/__tests__/write_result_court_console_refusals.test.jsx @@ -0,0 +1,30 @@ +// bc-crpn: the two sentences the court console shows when it refuses a tap +// because a match is live or a correction is open (operator ruling +// 2026-09-27). The words are the operator's, so they are pinned here once. + +import { describe, it, expect } from 'vitest'; +import { startWhileCorrectingMessage, correctWhileRunningMessage, courtBusyMessage } from '../write_result.jsx'; + +describe('startWhileCorrectingMessage', () => { + it('names the correction and the remedy', () => { + expect(startWhileCorrectingMessage({ label: 'Pool A · Match 2 · Kato vs Sato' })) + .toBe('Save the correction of Pool A · Match 2 · Kato vs Sato, or leave it with Back to court, then start this match.'); + }); +}); + +describe('correctWhileRunningMessage', () => { + it('names the court and the running bout, with the court-busy remedy', () => { + const msg = correctWhileRunningMessage({ court: 'A', label: 'Match 3 · Ito vs Abe' }); + expect(msg).toBe('Shiaijo A is running Match 3 · Ito vs Abe. Finish it or send it back to the queue first. Then correct this match.'); + // Same wording and order as the server's court-busy refusal, plus the next step. + expect(msg.startsWith(courtBusyMessage({ court: 'A', label: 'Match 3 · Ito vs Abe' }).replace(/\.$/, ''))).toBe(true); + }); +}); + +describe('the refusal copy follows the house words', () => { + it('carries no em-dash and never says mat', () => { + const all = startWhileCorrectingMessage({ label: 'x' }) + correctWhileRunningMessage({ court: 'A', label: 'x' }); + expect(all).not.toMatch(/—/); + expect(all).not.toMatch(/\bmats?\b/i); + }); +}); diff --git a/web-mobile/js/admin_scoring_individual.jsx b/web-mobile/js/admin_scoring_individual.jsx index 0d1ddf241..6cead828e 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 @@ -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. @@ -295,21 +295,18 @@ 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 - // 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, - 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, @@ -360,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" : ""; @@ -433,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. @@ -472,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 @@ -493,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); @@ -687,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). @@ -757,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). @@ -797,7 +762,16 @@ 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. + // 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, 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 @@ -809,7 +783,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 @@ -826,6 +800,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 @@ -868,12 +843,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 the side buttons, - // submitHantei) 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; } @@ -1211,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 && ( @@ -1242,41 +1219,51 @@ 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 highlight the only place the side is visible. */}
)} + {hanteiFinishBlock && ( +
+ {hanteiFinishBlock} +
+ )}
)} {!decisionPromptKind && ( @@ -1328,7 +1315,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(""); }} @@ -1349,10 +1335,6 @@ export function ScoreEditorModal({ match, onClose, onSubmit, onSubmitAndNext, on onConfirm={(r) => { setCorrectionReason(r); setCorrectionPrompt(null); - // A write that asked for this reason (the hantei buttons) - // runs itself; otherwise it is the default correction. - const { action } = correctionPrompt; - if (action) { action(r); return; } // Re-trigger submit with the now-populated reason. // buildPatch reads correctionReason from state, but state // updates are async: pass r inline via a local override @@ -1464,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 →"} ) : ( @@ -1473,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 9e7e9402c..a5902d149 100644 --- a/web-mobile/js/admin_scoring_shared.jsx +++ b/web-mobile/js/admin_scoring_shared.jsx @@ -6,14 +6,14 @@ const { useState: useStateA, useEffect: useEffectA, useRef: useRefA, useMemo: useMemoA, useLayoutEffect: useLayoutEffectA } = React; const Icon = window.Icon; -import { DAIHYOSEN_POSITION, scoreRowMatchLabel } from './pool_ids.jsx'; +import { DAIHYOSEN_POSITION, scoreRowMatchName } from './pool_ids.jsx'; import { writeDidNotLand, writeRetryable, decisionWord, attemptScoreWrite, DOWNSTREAM_KNOCKOUT_PLAYED_CANCELLED, DOWNSTREAM_KNOCKOUT_REOPEN_CANCELLED, downstreamKnockoutReopenedNotice, 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'; @@ -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); @@ -465,14 +471,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 +488,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 +514,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 +540,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). @@ -548,7 +547,7 @@ function makeSubmitDecision({ setPendingWrite, pendingFnRef, }) { - const submit = async (kind, { decisionBy, decisionReason }, opts = {}) => { + const submit = async (kind, { decisionBy, decisionReason }) => { setDecisionSubmitting(true); setDecisionErr(''); // F5: clear any prior pending-write state when the operator retries. @@ -556,7 +555,7 @@ function makeSubmitDecision({ try { const updated = await submitDecisionRequest( match.compId, match.id, kind, { decisionBy, decisionReason }, enchoPeriodCount, password, - { ...opts, seenModifiedAt: match.modifiedAt || 0 }, + { seenModifiedAt: match.modifiedAt || 0 }, ); if (!mountedRef.current) return; // A decision that did not land must not advance ANYTHING below this @@ -582,7 +581,7 @@ function makeSubmitDecision({ if (writeDidNotLand(updated)) { if (setPendingWrite && writeRetryable(updated)) { setPendingWrite(updated); - if (pendingFnRef) pendingFnRef.current = () => submit(kind, { decisionBy, decisionReason }, opts); + if (pendingFnRef) pendingFnRef.current = () => submit(kind, { decisionBy, decisionReason }); } return; } @@ -614,22 +613,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); } @@ -756,8 +742,9 @@ function EnchoControl({ enchoPeriodCount, setEnchoPeriodCount }) { aria-label="Show overtime (encho) controls" > - Overtime + Overtime +
); } @@ -774,8 +761,9 @@ function EnchoControl({ enchoPeriodCount, setEnchoPeriodCount }) { if (!e.target.checked) setShowCounter(false); }} /> - Encho started (overtime) + Encho started (overtime) + {enchoPeriodCount > 0 && (
-
@@ -1334,6 +1345,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 @@ -2052,21 +2074,17 @@ function RecordedWithdrawal({ match, ctl, disabled = false, singleBout = false, )} {laterDefaultWins && laterDefaultWins.length > 0 && (
- {/* bc-cse: named by scoreRowMatchLabel first -- a pairing alone - cannot be found in the scores list, which is where the + {/* bc-cse: named by the match's row label first -- a pairing + alone cannot be found in the scores list, which is where the operator has to go to reopen it -- with the pairing appended - the same way ReopenFeedback's own fetched blockerLabel joins - a lead onto a pairing ("Pool A · Match 2 · Shiro vs Aka"), - so the two operator lines that name a match this way agree. - Falls back to the bare pairing when the match carries no - number at all (see scoreRowMatchLabel's own doc). */} + ("Pool A · Match 2 · Shiro vs Aka"). scoreRowMatchName owns + that composition, shared with the court console's refusal + notices (bc-crpn). */} {laterDefaultWins.map((x) => { - const label = scoreRowMatchLabel(x); - const pairing = `${x.sideB?.name || "Shiro"} vs ${x.sideA?.name || "Aka"}`; const noun = decisionWord(x.decision) || "decision"; return (

- {label ? `${label} · ${pairing}` : pairing} keeps its {noun}; reopen it to fight it. + {scoreRowMatchName(x)} keeps its {noun}; reopen it to fight it.

); })} @@ -2239,6 +2257,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 b4a847700..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 @@ -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 || []; @@ -3579,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 @@ -4408,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
@@ -4475,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 +1688,7 @@ function AdminShiaijoPage({ tournament, court: routeCourt, onBack, onEditScore, )}
- {startError && startError.key === matchKey(upNext) &&
{startError.msg}
} + {upNextNotice &&
{upNextNotice}
}
{calledKey === matchKey(upNext) ? "Announced to spectators. Start the match when both are at the line." @@ -1632,7 +1707,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 +1754,7 @@ function AdminShiaijoPage({ tournament, court: routeCourt, onBack, onEditScore, refusalNotice(matchKey(m))} /> {filteredCompleted.length > COMPLETED_PREVIEW && ( @@ -2026,10 +2123,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 +2293,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/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/pool_ids.jsx b/web-mobile/js/pool_ids.jsx index 94b4ec4a5..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 @@ -117,6 +118,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 || sideWord("shiro")} vs ${m.sideA?.name || sideWord("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/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 746a476fd..9f53d6951 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. @@ -831,6 +831,18 @@ 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), and names +// the console's own buttons: there is no "Cancel" there. +export function startWhileCorrectingMessage({ label }) { + return `Save the correction of ${label}, or leave it with Back to court, then start this match.`; +} + +export function correctWhileRunningMessage({ court, label }) { + return `${courtBusyMessage({ court, label })} 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