diff --git a/CLAUDE.md b/CLAUDE.md index ebe5df516..2bb3c1dd7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -170,10 +170,11 @@ tournament-data/ - **Clearing a scored mark is a tap on the mark, and each editor says so ONCE** (operator ruling 2026-09-16, bc-dnst). The individual board puts the line under its slot groups; the TEAM sheet puts it after the team names and BEFORE the first bout, never per bout, because the sheet repeats a row per bout. Both use the same words and the same `.sb-hint` owner (`.sb-hint--encounter` only adds the spacing a line between two blocks needs), and both show it only while a mark exists to clear. A hantei mark does not raise it: `realIppons` drops it and it undoes through its own "click to undo" control. - **Team Match Labels**: Summaries use **"IV"** (Individual Victories) and **"PW"** (Points Won). - **Court limit**: `--courts` is hard-capped at 16 (`helper.MaxCourts`, derived from the court label alphabet A–P) and any value over that returns an error rather than silently truncating. 16 is the largest allocation any one competition can legally hold, so a venue beyond it could never give all its shiaijo to a single competition. -- **Competitor Eligibility** (`internal/state/competitor_status.go`, `internal/engine/eligibility.go`): a kiken/fusenpai decision auto-writes a `CompetitorStatus{Eligible: false}` for the loser; `engine.StartMatch(compID, matchID)` is the pre-flight gate that returns `*IneligibleCompetitorError` (matches `errors.Is(err, ErrIneligibleCompetitor)`). Maps to HTTP 409, with `reasonHuman` (`reasonHumanForBarredCompetitor`/`barredCompetitorSentence`, `mobileapp/errors.go`) composing one operator sentence naming the competitor, the match (`engine.OperatorMatchLabel`), and what to do, e.g. "Alice withdrew in Pool A · Match 1 and cannot fight again. Record the default win for Carol." (kiken-injury: "...Reinstate Alice if the doctor allows, or record the default win for Carol."). Kiken-injury (FIK Art. 30) sets `CompetitorStatus.Reinstateable: true`; the admin can call `POST /api/competitions/:cid/competitors/:pid/reinstate` to restore eligibility. Kiken-voluntary (Art. 31) and fusenpai are not reinstateable. `engine.BarredSides(statuses, matchID, sideAID, sideBID)` is the ONE extraction point for "is this side barred by a match OTHER than the one being checked" (the `st.MatchID != matchID` undo-path exemption lets a match always be re-scored past its own prior kiken/fusenpai); it replaced three hand-copied loops (`StartMatchTx`, `checkEligibilityExcludingMatch`, `annotateIneligibleSides`). A SCHEDULED match with a barred side carries a READ-ONLY `ineligibleSides` stamp (`state.IneligibleSidesAnnotation{A, B string}`, both `omitempty` decision-code strings) on `MatchResult`/`BracketMatch`, computed at REQUEST TIME ONLY by `mobileapp.annotateIneligibleSides` (`handlers_match.go`; never persisted, `yaml:"-"` on the pool side); a stamped match also has its `queuePosition` zeroed and so omitted from the wire (`state.DeriveQueuePositions`/`annotateBracketQueuePositions`), so it is skipped exactly like an unqueued match. `web-mobile/js/ineligible_match.jsx` is the ONE JS owner reading this stamp: `isBarredMatch`/`awaitedDefaultWin` skip such a match from Up next, Finish + Start Next, the TV next line, the lobby, and on-deck alerts, and drive the operator's one-tap `Record default win for ` (`POST /decision {decision: "fusensho", decisionBy: }`, which does not touch the barred competitor's own `CompetitorStatus`) and, for a kiken-injury, `Reinstate `. +- **Competitor Eligibility** (`internal/state/competitor_status.go`, `internal/engine/eligibility.go`): a kiken/fusenpai decision auto-writes a `CompetitorStatus{Eligible: false}` for the loser; `engine.StartMatch(compID, matchID)` is the pre-flight gate that returns `*IneligibleCompetitorError` (matches `errors.Is(err, ErrIneligibleCompetitor)`). Maps to HTTP 409, with `reasonHuman` (`reasonHumanForBarredCompetitor`/`barredCompetitorSentence`, `mobileapp/errors.go`) composing one operator sentence naming the competitor, the match (`engine.OperatorMatchLabel`), and what to do, e.g. "Alice withdrew in Pool A · Match 1 and cannot fight again. Record the default win for Carol." (kiken-injury: "...Reinstate Alice if they can fight again, or record the default win for Carol."). Kiken-injury (FIK Art. 30) sets `CompetitorStatus.Reinstateable: true`; the admin can call `POST /api/competitions/:cid/competitors/:pid/reinstate` to restore eligibility. Kiken-voluntary (Art. 31) and fusenpai are not reinstateable. A withdrawal decision against a competitor ANOTHER match already barred is decided by ONE rule, `engine.alreadyBarredRefusal` (bc-kfup), asked by both T105 halves (`checkConcurrentIneligibility` before the write, the K2 check-and-set in `recordIneligibilityFromDecision`): a kiken is refused with `already_ineligible` (a competitor withdraws once), while a fusenpai is their default loss on a remaining match and records NO new `CompetitorStatus`, so the existing one keeps naming the match where they withdrew. Do not re-add a fusenpai refusal there: StartMatchTx refuses to start that match, so the fusenpai is the operator's exit. Clearing the match that DID record the bar moves it onto such a chained fusenpai rather than lifting it (`standingWithdrawalOf`, fusenpai only: a kiken still on record beside a later bar was lifted by a reinstatement before that bar existed, since a second kiken is refused), and a reopen that clears it lands `scheduled` because `reopenTargetStatusTx` judges the bars the restore will leave (`restoredStatus`, shared with the restore). Accepted as it is (operator decision 2026-09-26): a fusenpai recorded before a reinstatement still carries the bar on, so a competitor who withdrew injured, had a no-show recorded on another match, was reinstated, and then has a later withdrawal cleared as a mistake is barred again by that earlier no-show; the editor names the no-show match, where **Clear default win** lifts it. Telling the two apart needs the time of each reinstatement, which is not worth storing for this. Do not report it as a new defect. `engine.BarredSides(statuses, matchID, sideAID, sideBID)` is the ONE extraction point for "is this side barred by a match OTHER than the one being checked" (the `st.MatchID != matchID` undo-path exemption lets a match always be re-scored past its own prior kiken/fusenpai); it replaced three hand-copied loops (`StartMatchTx`, `checkEligibilityExcludingMatch`, `annotateEligibility`). A SCHEDULED match with a barred side carries a READ-ONLY `ineligibleSides` stamp (`state.IneligibleSidesAnnotation{A, B string}`, both `omitempty` decision-code strings) on `MatchResult`/`BracketMatch`, computed at REQUEST TIME ONLY by `mobileapp.annotateEligibility` (`handlers_match.go`; never persisted, `yaml:"-"` on the pool side); a stamped match also has its `queuePosition` zeroed and so omitted from the wire (`state.DeriveQueuePositions`/`annotateBracketQueuePositions`), so it is skipped exactly like an unqueued match. `web-mobile/js/ineligible_match.jsx` is the ONE JS owner reading this stamp: `isBarredMatch`/`awaitedDefaultWin` skip such a match from Up next, Finish + Start Next, the TV next line, the lobby, and on-deck alerts, and drive the operator's one-tap `Record default win for ` (`POST /decision {decision: "fusensho", decisionBy: }`, which does not touch the barred competitor's own `CompetitorStatus`) and, for a kiken-injury, `Reinstate `. The same request-time pass stamps a COMPLETED match a withdrawal or default win decided with `withdrawnStatus` (`state.WithdrawnStatusAnnotation`: the withdrawn side's status now, eligible / the match that bars it / reinstateable), which `RecordedWithdrawal` words its clear from instead of fetching statuses. The viewer payloads load statuses whenever a match needs either stamp (`anyMatchToAnnotate`), and the `/decision`, `/score` and bulk-score writes stamp the result they push (`stampWithdrawnStatus`), so a pushed row and its refetch read the same. **Recording a withdrawal changes only that match** (operator ruling 2026-09-26, "do not cascade"): the post-kiken Remaining matches panel that offered to close the competitor's other matches was removed, and each of them is closed from its own notice when it comes up. Do not re-add a cascade from one match's editor. Recording one still starts the court's next match in the same competition (mp-nwds item 7, the operator's request), on the court console and the Scores tab alike; both skip the withdrawn competitor's matches through `sideBarredByDecision`/`involvesCompetitor` (`ineligible_match.jsx`), because the list shows them barred only after it refreshes. +- **Send back to queue keeps the score** (operator ruling 2026-09-26, bc-sbq). `RevertMatchToQueue`/`requeueBracketMatch` clear only the verdict and the audit notes; points, penalties, overtime, bouts, flags and rep-bout fighters stay, the operator removes a wrong mark themselves, and the button is always offered (picking another bout sends a scored one back the same way). Starting the match again keeps them: the SPA's `startPatch` sends the request-only `startOnly` flag and `engine.keepQueuedScore` copies the stored score onto that write, because the wire cannot say "no score sent" (the start's empty arrays are the bytes of an operator clearing every mark, which must still clear, so the editors' own Start stays unflagged; `startOnly` with any status but `running` is a 400, since the stored score is swapped in after validation). While queued, every display shows the match as NOT STARTED through `matchShowsScore` (match_shows_score.jsx, a leaf every display ES-imports), which `IndividualScore`/`TeamScoreboard` apply to themselves; the score editors are the one exception. A queued knockout match's kept points do not make it "fought" (`bracketMatchStartedOrDecided` reads status and winner only), so correcting or reopening the match that feeds it re-seats it with its points and asks nothing when it starts. A FINISHED later match that a confirmed correction reopens keeps its points, bouts and flags too, only its verdict goes (`reopenDisplacedBracketMatch` through `reopenBracketMatchKeepingTheFight`, both the knockout and the pool-requalification doors). Engi flags are saved as they are entered: a running engi write is not judged on the flag total (`engiRunningWrite`, scoring_tx.go) and the bracket write carries flags, so a send-back or switch keeps them. - **A fixed-order team bout row records NO fighter name, never the team's** (operator ruling 2026-09-17, bc-dnst: "the storage is the main source of truth"). The bout settles at the MATCH level, so when no lineup names a fighter the row knows no per-fighter identity and `buildPatch` writes `sideA`/`sideB` EMPTY. It used to write the team name there, which is not a person: every display then had to filter it back out, and the ones that did not showed the team's name where the competitor goes. Nothing needed the copy, because attribution reads the MATCH-level name first (`state.SubBoutWinnerSide`, whose `sub.SideA` arm is guarded against `"" == ""`; the JS `teamIVScore` fallback mirrors it) and the server's own quick-score path already wrote this shape. **The DAIHYOSEN row is the exception and keeps the team names**: the hantei mark is placed on the winner's side by comparing the winner against THAT row's `sideA`/`sideB` (`placeHt`), so blanking them drops the mark from the wire. `resolveBoutSideName`'s `teamNameA`/`teamNameB` filter stays for rows written BEFORE this rule; it is legacy handling, not a live guard. - **A lineup vacancy is never flagged on the score sheet** (operator ruling 2026-09-16, bc-dnst). Team sizes are unregulated and a vacancy is legitimate play, so the sheet must not tell the operator to complete a lineup. The "SHIRO/AKA: Lineup incomplete, add the remaining players" notice was REMOVED with its predicate and its CSS; it never blocked anything, but it read as a requirement. Pinned by `team_editor_vacancy_not_flagged.render.test.jsx`, which exists because a removed warning otherwise leaves no failing test behind. Do not re-add it in any form. -- **Team Lineups & Kachinuki** (`internal/domain/team_lineup.go`, `internal/engine/kachinuki.go`): TeamLineup pins position→player for a round or a specific match (mp-825). Team sizes are unregulated and vacancies are never enforced: `TeamLineup.Validate`/`validateFive` (the FIK 5-person back-fill/DQ rule) were REMOVED (mp-gmcg); only the key-only `ValidatePositions` check remains. Kachinuki ("winner-stays-on") is operator-led: `engine.MaybeAdvanceKachinuki` is append-only (adds the next pairing; NEVER auto-finalizes; the roster snapshot is advisory) and the encounter ends only on an explicit `status: completed` score write, which strips trailing unscored auto-appended bouts. Both win rules, exhaustion and taisho-defeated, record `decision: "kachinuki-exhaustion"`; a drawn pool/league encounter is `hikiwake`. A tied pairing may be fought on in encho on that same bout in ANY phase; whether it must produce a result (e.g. the taisho must be defeated) is OPERATOR DISCRETION, never derived from pool-vs-bracket (the phase-independence rule lives in `allowNumberedEnchoFromStore` (`handlers_match.go`) and `subBoutNeedsNumberedEnchoAllowance` (`validation.go`); do NOT re-scope numbered-bout encho by match-ID phase; that was tried and reverted). Only the knockout END stays blocked while tied (the bracket needs a winner, `validateBracketCompletion`). Daihyosen does not exist in kachinuki → `POST .../daihyosen` returns 400. Mistakes: `POST /api/competitions/:id/matches/:mid/reopen` (a kachinuki match, or any match a withdrawal OR default win decided -- kiken, kiken-injury, fusenpai, or fusensho; a 400 otherwise) flips a completed match back to running, clearing winner/decision but keeping the bout log -- EXCEPT a match-level fusensho whose `decisionBy` side is STILL barred by an earlier, unrelated withdrawal: `engine.reopenTargetStatus` (`kachinuki.go`) sends that one to `scheduled` instead of `running`, because going straight to `running` would strand the operator behind the eligibility gate with no way to start it; once that competitor is reinstated, the identical fusensho reopens to `running` like any other decision; a reopen that lands `scheduled` takes no court, so neither court gate applies to it (the target status is decided before either gate runs); in a knockout, when the winner of the match being reopened or corrected went straight through a bye (a hidden, unnumbered match the draw leaves with one empty side, so nobody can open or reopen it directly), the write takes that winner back out of it (or re-seats the new one) and answers for the first real match past it (`propagatedDownstreamOf`), rather than refusing; a POOL reopen in a mixed competition is never refused for a seated knockout, because the write that finishes it runs the pool requalification check (`engine/pool_requalify.go`); `POST .../matches/:mid/requeue-blocker-and-reopen` atomically frees the target's court (requeuing the blocking match) and reopens under one court-lock hold; `DELETE .../matches/:mid/kachinuki-bout` removes the current trailing unscored auto-appended pairing. Every numbered bout row of a team match shows the fighter's squad number and, when the position is unnamed, a typeable name box (operator ruling 2026-09-15, bc-dnst). The number belongs to the squad member, not the row: team order can change between matches, so picking a different member into a position moves that member's number onto the row. A fresh team's seeded blank slots are members with a number and no name, so an unnamed fixed-order position shows the number of the blank member seeded for it, and a name typed there names that member through `resolveMemberIdsForPositions` (rename, never a new mint while a blank slot with that number exists), so the name stays attached to the number it was shown with. A team's squad is seeded with team size + 2 numbered slots (`SquadReserveSlots`, two reserves), and the row's list shows every slot with its number, blank ones by number alone, so any number the team can field can be picked and then named; a picked slot is written by id (`buildInlineLineupWrite` with a member) and a name typed into it renames that member (operator ruling 2026-09-15, bc-dnst). That makes a picked-but-unnamed slot a real placement everywhere: every lineup writer (the score sheet row, the Up Next "Enter lineup" panel, the Lineups page) sends the position with an empty name and the member id rather than dropping it; `TeamLineup.OrderedMembers` counts a position occupied when it carries a name OR an id, so such a fighter is on the kachinuki roster; and `AdvanceKachinuki` decides which side stayed on through `domain.SubBoutAttribution`/`AttributeWinnerSide` (ids first), because the client records a nameless winner as its TEAM name plus the member id and a name comparison alone can never see it stay on. A lineup may not place one member id at two positions: `memberPlacedElsewhere` (lineup_resolver.jsx) is the ONE client predicate, asked by every writer before it writes (`buildInlineLineupWrite`, the panel's save, the Lineups page's add path) and rendered as the list filter `rosterWithoutPlacedElsewhere` on every picker, so the offer and the refusal (" is already at ") never disagree; `ErrLineupDuplicateMember` from `ValidatePositions` (a 400 naming the two positions) is the server backstop. A lineup that already held a duplicate before this guard existed is repaired by clearing one of the two rows. Under kachinuki only the FIRST row is a lineup position; every later row's pick rides the bout itself (`pickManual` with the member id, never a lineup PUT), the list there is unfiltered because the fighter who stayed on legitimately repeats, and a name typed over a picked blank member renames that member and keeps its id. Which side stayed on is decided through `domain.AttributeWinnerSide`, whose id tier names a side when the winner id equals THAT side's id even if the other side carries none (JS mirror `attributeWinnerSide`, result_slot.jsx), with the same name-only residue `RetiredPlayersFromBoutLog` keeps for a row no id can settle; `RetiredMemberSet.Count` tallies ids plus id-only names so a nameless fighter is counted. Before the draw a team has no number, so the Lineups page, the one operator surface for a team's people, labels a slot through `squadSlotLabel` ("Slot 3"), a sibling of `squadMemberLabel` in the same owner; spectator surfaces keep `squadMemberLabel` and stay bare before the draw. The competition Settings page's Squad members section was REMOVED on 2026-09-15 by operator decision (bc-dnst): the Lineups page is the one home for a team's people, and its "Team members" list's "Clear name" control (beside Rename) is where clearing a member's name now lives. The Lineups page's "+ Add" confirm is derived from the resolver's own three outcomes (an existing member is selected with no confirm, a blank slot is named, else a member is minted). A rename or a clear reaches every stored lineup: `RenameTeamMember`/`ClearTeamMemberName` rewrite the name of every lineup position holding that member by id (`renameMemberInLineupsLocked`), because the score sheet and the export read the lineup's stored name; fought bouts keep their frozen names. The Excel export indexes lineup positions by member id as well as by name (`memberKey`, `resolveKachinukiBoutPosition`) so a nameless fighter still gets its position label. +- **Team Lineups & Kachinuki** (`internal/domain/team_lineup.go`, `internal/engine/kachinuki.go`): TeamLineup pins position→player for a round or a specific match (mp-825). Team sizes are unregulated and vacancies are never enforced: `TeamLineup.Validate`/`validateFive` (the FIK 5-person back-fill/DQ rule) were REMOVED (mp-gmcg); only the key-only `ValidatePositions` check remains. Kachinuki ("winner-stays-on") is operator-led: `engine.MaybeAdvanceKachinuki` is append-only (adds the next pairing; NEVER auto-finalizes; the roster snapshot is advisory) and the encounter ends only on an explicit `status: completed` score write, which strips trailing unscored auto-appended bouts. Both win rules, exhaustion and taisho-defeated, record `decision: "kachinuki-exhaustion"`; a drawn pool/league encounter is `hikiwake`. Whether a tied pair fights on in encho is the operator's call, on ANY bout and in ANY phase: the app records what happens and never decides how a match is run (operator ruling 2026-09-26). A taisho-only rule (bc-kten: the editor withheld Encho from any pair but the two taisho, and the server refused a new encho on such a pair) was built on 2026-09-25 and reversed the next day; do NOT re-add a pairing gate or a server refusal for encho. Whether a tied pairing must produce a result (e.g. the taisho must be defeated) is likewise OPERATOR DISCRETION, never derived from pool-vs-bracket (the phase-independence rule lives in `allowNumberedEnchoFromStore` (`handlers_match.go`) and `subBoutNeedsNumberedEnchoAllowance` (`validation.go`); do NOT re-scope numbered-bout encho by match-ID phase; that was tried and reverted). Only the knockout END stays blocked while tied (the bracket needs a winner, `validateBracketCompletion`). Daihyosen does not exist in kachinuki → `POST .../daihyosen` returns 400. Mistakes: `POST /api/competitions/:id/matches/:mid/reopen` (a kachinuki match, or any match a withdrawal OR default win decided -- kiken, kiken-injury, fusenpai, or fusensho; a 400 otherwise) flips a completed match back to running, clearing winner/decision but keeping the bout log -- EXCEPT a match-level fusensho whose `decisionBy` side is STILL barred by an earlier, unrelated withdrawal: `engine.reopenTargetStatus` (`kachinuki.go`) sends that one to `scheduled` instead of `running`, because going straight to `running` would strand the operator behind the eligibility gate with no way to start it; once that competitor is reinstated, the identical fusensho reopens to `running` like any other decision; a reopen that lands `scheduled` takes no court, so neither court gate applies to it (the target status is decided before either gate runs); in a knockout, when the winner of the match being reopened or corrected went straight through a bye (a hidden, unnumbered match the draw leaves with one empty side, so nobody can open or reopen it directly), the write takes that winner back out of it (or re-seats the new one) and answers for the first real match past it (`propagatedDownstreamOf`), rather than refusing; a POOL reopen in a mixed competition is never refused for a seated knockout, because the write that finishes it runs the pool requalification check (`engine/pool_requalify.go`); `POST .../matches/:mid/requeue-blocker-and-reopen` atomically frees the target's court (requeuing the blocking match) and reopens under one court-lock hold; `DELETE .../matches/:mid/kachinuki-bout` removes the current trailing unscored auto-appended pairing. Every numbered bout row of a team match shows the fighter's squad number and, when the position is unnamed, a typeable name box (operator ruling 2026-09-15, bc-dnst). The number belongs to the squad member, not the row: team order can change between matches, so picking a different member into a position moves that member's number onto the row. A fresh team's seeded blank slots are members with a number and no name, so an unnamed fixed-order position shows the number of the blank member seeded for it, and a name typed there names that member through `resolveMemberIdsForPositions` (rename, never a new mint while a blank slot with that number exists), so the name stays attached to the number it was shown with. A team's squad is seeded with team size + 2 numbered slots (`SquadReserveSlots`, two reserves), and the row's list shows every slot with its number, blank ones by number alone, so any number the team can field can be picked and then named; a picked slot is written by id (`buildInlineLineupWrite` with a member) and a name typed into it renames that member (operator ruling 2026-09-15, bc-dnst). That makes a picked-but-unnamed slot a real placement everywhere: every lineup writer (the score sheet row, the Up Next "Enter lineup" panel, the Lineups page) sends the position with an empty name and the member id rather than dropping it; `TeamLineup.OrderedMembers` counts a position occupied when it carries a name OR an id, so such a fighter is on the kachinuki roster; and `AdvanceKachinuki` decides which side stayed on through `domain.SubBoutAttribution`/`AttributeWinnerSide` (ids first), because the client records a nameless winner as its TEAM name plus the member id and a name comparison alone can never see it stay on. A lineup may not place one member id at two positions: `memberPlacedElsewhere` (lineup_resolver.jsx) is the ONE client predicate, asked by every writer before it writes (`buildInlineLineupWrite`, the panel's save, the Lineups page's add path) and rendered as the list filter `rosterWithoutPlacedElsewhere` on every picker, so the offer and the refusal (" is already at ") never disagree; `ErrLineupDuplicateMember` from `ValidatePositions` (a 400 naming the two positions) is the server backstop. A lineup that already held a duplicate before this guard existed is repaired by clearing one of the two rows. Under kachinuki only the FIRST row is a lineup position; every later row's pick rides the bout itself (`pickManual` with the member id, never a lineup PUT), the list there is unfiltered because the fighter who stayed on legitimately repeats, and a name typed over a picked blank member renames that member and keeps its id. Which side stayed on is decided through `domain.AttributeWinnerSide`, whose id tier names a side when the winner id equals THAT side's id even if the other side carries none (JS mirror `attributeWinnerSide`, result_slot.jsx), with the same name-only residue `RetiredPlayersFromBoutLog` keeps for a row no id can settle; `RetiredMemberSet.Count` tallies ids plus id-only names so a nameless fighter is counted. Before the draw a team has no number, so the Lineups page, the one operator surface for a team's people, labels a slot through `squadSlotLabel` ("Slot 3"), a sibling of `squadMemberLabel` in the same owner; spectator surfaces keep `squadMemberLabel` and stay bare before the draw. The competition Settings page's Squad members section was REMOVED on 2026-09-15 by operator decision (bc-dnst): the Lineups page is the one home for a team's people, and its "Team members" list's "Clear name" control (beside Rename) is where clearing a member's name now lives. The Lineups page's "+ Add" confirm is derived from the resolver's own three outcomes (an existing member is selected with no confirm, a blank slot is named, else a member is minted). A rename or a clear reaches every stored lineup: `RenameTeamMember`/`ClearTeamMemberName` rewrite the name of every lineup position holding that member by id (`renameMemberInLineupsLocked`), because the score sheet and the export read the lineup's stored name; fought bouts keep their frozen names. The Excel export indexes lineup positions by member id as well as by name (`memberKey`, `resolveKachinukiBoutPosition`) so a nameless fighter still gets its position label. - **Schedule Estimator** (`internal/engine/schedule.go`): `EstimateSchedule(EstimateInput) ScheduleEstimate` produces total/per-court minutes from match duration × multiplier × slowest-court buffer. Exposed via stateless `GET /api/schedule/estimate` on both the CLI web server and the mobile app. - **Store Transactions** (`internal/state/transactions.go`): `Store.WithTransaction(compID, fn)` holds the per-comp lock once across multiple load/save operations. Use the `StoreTx` handle inside `fn`. Do NOT call public Store methods (they would deadlock the non-reentrant mutex). - **Cache invalidation: never key a cache on mtime alone** (`internal/state/store.go`): filesystem mtimes come from the kernel coarse clock (~1ms granularity), so two writes inside one tick are indistinguishable and an mtime-keyed cache serves pre-write data (mp-n6ke: stale standings vs fresh matches injected a phantom tiebreaker bout and stalled bracket advancement; mp-p7n was the same class). Each `fileCache` therefore carries a monotonic `version` counter, exposed as `Store.FileVersion(compID, filename)`; `engine.CalculatePoolStandings` requires BOTH version and mtime to match (the counter catches same-millisecond in-process writes, mtime still catches out-of-process edits). **If you add a writer for a file some cache derives from, call `s.bumpFileVersion` AFTER the bytes land**: an extra bump only costs a recompute, a missed bump serves stale data, and bumping before the write lets a reader stamp OLD bytes with the NEW token. Existing bumps sit at the chokepoints every writer funnels through (`savePoolMatchesLocked`, `saveOverridesLocked`) plus draw deletion and transaction abort. Two further rules follow from the counter being the cache key: **every read path must validate it, not just the fast path** (the `CalculatePoolStandings` single-flight loser returned the winner's entry unvalidated, and the winner stamps *pre-compute* tokens, so it could predate the loser's own write), and **the counter must stay monotonic for the life of the process, including across a competition being deleted and recreated** (IDs are name slugs, so `DeleteCompetition` calls `discardCompCacheBodies` to clear the cached bodies while leaving the counters climbing, rather than `compCache.Delete`, which reset them to 0 and let a recreated competition match its predecessor's stale entry). Prefer a deterministic counter over a sleep, which only hides the race. @@ -276,9 +277,9 @@ A match write can be REFUSED by the server, and a refusal is always **HTTP 200** - `superseded`: the timestamp last-write-wins guard dropped it; a NEWER result is already stored. The operator must **not** blindly re-enter: a fresh entry is stamped now and would overwrite the result that won. - `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` and, since mp-jnvl, `/decision` each carry the client's stamp, so any of them can be refused for either reason. Quick-score and the two daihyosen paths still build their result with NO stamp and take `ApplyByTimestamp`'s unstamped bypass, so they always apply; they map the refusal shape defensively anyway. A decision is stamped because it is the write that CLOSES a match, and a match closed while still `scheduled` (the withdrawal panel's default win) has no earlier write to inherit a stamp from, so the result carried no time at all and nothing downstream could order it. 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` and, since mp-jnvl, `/decision` each carry the client's stamp, so any of them can be refused for either reason. Quick-score and the two daihyosen paths still build their result with NO stamp and take `ApplyByTimestamp`'s unstamped bypass, so they always apply; they map the refusal shape defensively anyway. 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 default win) has no earlier write to inherit a stamp from, so the result carried no time at all and nothing downstream could order it. 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)`, and every operator-facing string. 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` 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. +**`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), and every operator-facing string. 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` 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. Two distinctions that look like details and are not: @@ -287,6 +288,8 @@ Two distinctions that look like details and are not: **A dropped write leaves NO FOOTPRINT, on either branch.** `UpdatePoolMatchByID`'s mutate returns an `error` and the store skips the save when it does, the contract `UpdateBracket` has always had, so `applyPoolWrite`'s drop verdict aborts rather than re-serializing the untouched row and bumping the standings-cache version. Two rules come with it. The abort is reported through the ERROR and NEVER through `found`: `found` stays true because the match WAS there, and signalling `(false, nil)` would make `writeToPoolOrBracket` read a rejected pool write as "not a pool match" and go hunting for it in the bracket. And BOTH `storeTx` branches must honour it, the WAL-staged one included, or a drop leaves no trace outside a transaction and a WAL entry inside one. Pinned across all six cells of `superseded_matrix_test.go`, which asserts the file bytes AND `Store.FileVersion` are unchanged after a drop; reverting the abort reddens exactly the two pool cells. +**On the client a live score never goes back in time under a score editor.** A refetch can read the data just before a write commits and answer after a newer push or a newer refetch has landed; taken whole, it put the older scoreline back, and an open score editor that had caught up adopted it, so its next save wrote the lost point away. The refetch sinks a score editor reads from therefore merge through `patch.jsx`'s `keepNewerMatches` (`keepNewerCompetitions` for a list, `keepNewerDetail` for a competition detail, `keepNewerTournament` for the tournament): the court console's feed, the admin competition view, and the tournament's competitions (the Scores tab's source, fed by the admin's refreshes and by the app's `load()` outside display mode). A held match is kept only when BOTH copies are RUNNING and the fetched one is stamped and older. Stamps come from two clocks: a client write carries the device's estimate of server time (accepted up to 5s ahead), a reopen, requeue or send-back the server's own, so a newer server stamp can read older; every server-stamped change moves the status, so running-against-running never weighs one clock against the other. An unstamped fetched copy (a draw discarded and drawn again reuses the match ids) always replaces. The public competition page and the display take a refetch whole: no editor reads them, and the display's reload must discard its optimistic broadcast overlay. Accepted residual: a holder that never saw a match leave running (the Scores tab's list, which takes no pushes) can keep a running copy stamped by a device whose clock runs a few seconds ahead over the server-stamped running row of a reopen made within those seconds (e.g. a mistaken withdrawal reopened at once). Both show the match live; the held copy can still show the winner's letters that the withdrawal replaced with its default-win marks, until the next write to that match replaces it. + Engine side: `state.IsTransactional(h)` lets a caller whose correctness depends on a live transaction check what handle it was given; `recordIneligibilityFromDecision`'s K2 check-and-set logs if handed the bare store, which is what pins the entry-point `WithTransaction` shims. `applyMatchWrite` is pure timestamp LWW for every write **including corrections** (the old `correctionReason` exemption was a workaround for refusals being silent, and it let a replayed offline correction overwrite newer results). The remaining unstamped-bypass overwrite is logged rather than silent. ### The competitor-number search rule (bc-nsrc) @@ -382,7 +385,7 @@ Name[, Zekken/DisplayName], Dojo[, DanGrade][, source] - Web UI changes (`web/index.html`) should be validated in a running browser, not just by reading diffs. Use `make run` - Mobile app frontend changes (`web-mobile/`) require rebuilding the binary to take effect. The files are embedded at `go build` time via `//go:embed web-mobile/*` in `main.go`. Run `make run-mobile` which rebuilds automatically, or run `make go/build` then restart. - Competitor identity is (name, dojo), never the name alone. Two competitors sharing a NAME from different dojos are different people and are allowed everywhere; only same-name AND same-dojo is a duplicate. The CLI/web CSV path rejects duplicate ROWS up front via `helper.CheckDuplicateEntries` and the web handler surfaces them; the tournament app enforces the (name, dojo) rule via `helper.CheckDuplicateEntriesByNameDojo`, with the floor guard in `state.saveParticipantsNoLock` so every persistence path is covered. A blank dojo has TWO floors with TWO distinct sentinels: `state.ErrBlankDojo` refuses every roster save (loading stays tolerant on purpose so the roster can be repaired), and `helper.ErrBlankDojoInDraw` refuses the tree-aware draw over a loaded legacy roster (the draw path never crosses the save floor; the refusal names the offending row and is NOT a bug to relax). They are different errors in different packages; `errors.Is` against the wrong one silently never matches. TEAM names are the exception: they must be unique even across dojos (`helper.DuplicateNamesWithKeys`), because a team name is its identity in results. **A record that carries an id field is resolved by id only** (operator ruling bc-pnum): an empty id resolves to NOTHING, never to a name or (name, dojo) match, and no name/(name, dojo) fallback arm may sit beside an id lookup on the SAME record (`lookupStandingsPlayer`/`resolveWinnerSide` in `internal/engine/engi.go`, `groupMemberIDs` in `tiebreaker.go`, `resolveParticipantIndex` in `internal/state/participants.go`, `matchesParticipant` in `replace_participant.go`, `attachPoolMatches`/`standingMap` in `internal/export/builder.go`, and the pool-rank-override / league-tiebreak selection paths are all id-only end to end). `helper.CompetitorKey(id, name, dojo)`'s (name, dojo) fallback survives for exactly two legitimate uses: duplicate detection over an input list BEFORE ids exist (`helper.CheckDuplicateEntriesByNameDojo`, and an explicit `helper.CompetitorKey("", name, dojo)` comparison for that purpose), and records that carry NO id field at all (`state.SubMatchResult`'s lineup names). A third, narrower carve-out belongs to `state.BracketMatch` (bc-brid): its sides now carry `SideAID`/`SideBID`/`WinnerID`, so it is NOT a no-id record, and an id is present exactly when the side is a resolved competitor. A side legitimately carries no id in exactly three shapes: an empty bye slot, an unresolved feeder (a "Winner of ..." advancement or a "Pool A-1st"-style placeholder before the pool phase resolves it), and a legacy row a repair has not yet reached. For `BracketMatch` the rule is therefore resolve-by-id-when-present, name-fallback ONLY for those three shapes: `buildSideByIDOrName` (`internal/mobileapp/handlers_display.go`), `matchesBracketSide` (`internal/engine/scoring_tx.go`), and pass 2 of `ReplaceParticipantInDraw` (`internal/engine/replace_participant.go`) are the three sites that do it. A pool row has no equivalent unresolved shape: its side ids are stamped when the draw runs, and a legacy row is repaired on load where the name resolves unambiguously, so an id-less pool side is a fault the operator is told about rather than a normal state. That is why its stricter id-only rule above stands unchanged. A fourth carve-out, the same shape as `BracketMatch`'s, belongs to `domain.SeedAssignment` (bc-sdid): it now carries `ID` too, so it left the no-id list above. `state.SaveSeeds` stamps it from the roster on every write, and the legacy-upgrade pass (`upgradeSeedRowsLocked`, `internal/state/legacy_upgrade.go`) backfills it on load where the row resolves unambiguously; an id is present exactly when a resolver has already attached the row to a participant. The one shape that legitimately still carries no id is a row a repair has not yet reached (an ambiguous bare name, or a file predating the id column). `domain.RosterIndex.LookupSeed` is the id-first-then-(name, dojo) resolver every seed matcher (the seeds.csv-onto-roster merge, `helper.ApplySeeds`, `mobileapp.seedsOffRoster`) now calls, so an id, once present, is never re-checked against a possibly-stale Name/Dojo. Anything that keys a competitor with a name/(name, dojo) fallback OUTSIDE those four carve-outs is a bug: use the participant id. -- Chained match navigation in the admin score editor (Prev/Next buttons, Finish + Start Next, ←/→ keys) must stay on the current match's shiaijo. Operators run matches per-court, so hopping courts mid-flow breaks the workflow. See `AdminScoreEditor` in `web-mobile/js/admin_schedule_score_editor.jsx` (`admin_schedule.jsx` only re-exports it): filter to `(m.court || "") === (openMatch.court || "")` so empty/undefined courts share one "unassigned" bucket. +- Chained match navigation in the admin score editor (Prev/Next buttons, Finish + Start Next, ←/→ keys) must stay on the current match's shiaijo, and the AUTOMATIC advance (Finish + Start Next, the start after a withdrawal or no-show) also stays in the current match's competition (operator ruling 2026-09-26: one operator per court, scoring from the court view; an operator view never switches competition or court by itself). The tournament-wide Scores page lists every competition, so its `nextActiveFrom` filters on the open match's `compId`; the court console's `nextActiveAfter` always did. Prev/Next, which the operator taps, still move along the whole court. Operators run matches per-court, so hopping courts mid-flow breaks the workflow. See `AdminScoreEditor` in `web-mobile/js/admin_schedule_score_editor.jsx` (`admin_schedule.jsx` only re-exports it): filter to `(m.court || "") === (openMatch.court || "")` so empty/undefined courts share one "unassigned" bucket. - Docs under `docs/` are PUBLIC-facing: no beads (`mp-xxxx`)/`bd`, no internal-tooling or `CLAUDE.md` references, and NO em-dashes. The PR template gates `make docs/build`; run it before pushing docs changes. - `mkdocs build --strict` fails on broken cross-file links (WARNING) but only logs missing same-page anchors as INFO (build still passes), so verify anchors by hand. Anchors use pymdownx slugs: lowercase, punctuation dropped, `--`/parens collapsed to single hyphens (e.g. `### Locked mode (--lock-password)` becomes `#locked-mode-lock-password`). Note TWO files named `mobile-app.md` exist (`docs/user-guide/mobile-app.md` guide vs `docs/user-guide/commands/mobile-app.md` reference); a relative link from `commands/` resolves to the sibling, so confirm the target file before trusting a flagged anchor. diff --git a/docs/user-guide/court-operators/recording-decisions.md b/docs/user-guide/court-operators/recording-decisions.md index 427443445..bcdeca5b4 100644 --- a/docs/user-guide/court-operators/recording-decisions.md +++ b/docs/user-guide/court-operators/recording-decisions.md @@ -87,6 +87,10 @@ A barred competitor's other scheduled matches cannot be started, so the app stee On the court console queue, the Scores tab, and the score editor, the match instead carries a note explaining why (for example "Kyoto withdrew: record the default win.") and a button naming the opponent, for example **Record default win for Sato**. One tap records the match as a fusensho for the opponent, without touching the barred competitor's eligibility record. For an injury kiken, a **Reinstate** button naming the barred competitor sits alongside it, restoring eligibility so the match can be fought instead of defaulted. Once recorded, the match reads, for example, "Recorded: Default win (fusensho) for Sato. Kyoto had withdrawn.", with a **Clear default win** action if it was recorded by mistake: it reopens the match, back to the queue if the competitor is still barred (record the default win again, or reinstate them first; a match going back to the queue takes no court, so another match running on that court does not stop it), or back to in progress, ready to score, if they have since been reinstated. +Recording a kiken or fusenpai changes only that match. It does not do anything to the withdrawn competitor's other scheduled matches: each one shows its own notice and one-tap **Record default win for ``** button, described above, when it comes up in the queue or you open it in the score editor. As with **Finish + Start Next**, the court console and the Scores tab then start the court's next match in the same competition, passing over the withdrawn competitor's own. The app never switches you to another competition or court by itself. + +You can also open one of the competitor's other matches in the score editor and record **Fusenpai** for the barred competitor. The match records their default loss, and their eligibility record still names the match where they withdrew, so an injury kiken can still be reinstated. That match then offers **Clear default win**, which works as it does for a fusensho: while the competitor is still withdrawn, the match goes back to the queue. Recording a second **Kiken** for a competitor who already withdrew elsewhere is refused: a competitor withdraws only once. + If both competitors due to meet are already barred, neither can fight. In a pool or league match, record it as drawn with **Record as drawn (neither can fight)**: neither side receives a win or points. A knockout match has no equivalent, since the bracket needs a winner; correct the earlier withdrawal, or the draw, instead. **Clear withdrawal and reopen** on the original withdrawal (refer to [Correcting a withdrawal recorded by mistake](#correcting-a-withdrawal-recorded-by-mistake)) lists the later matches that were given a default win because of it, each with its pairing, so you know what is affected. Those matches keep their default win; reopen any of them individually with **Clear default win** once the competitor is eligible again. @@ -105,16 +109,16 @@ What the reopen keeps depends on the match: A kachinuki team match that ended with a withdrawal or no-show shows the same **Clear withdrawal and reopen** in place of its **Reopen match** button, because the reopen also makes the withdrawn team eligible again. A kachinuki match that ended any other way keeps **Reopen match**. -Removing a withdrawal makes the withdrawn competitor or team eligible again, whichever kind of withdrawal it was, because a withdrawal entered by mistake never happened. This includes a voluntary kiken, which otherwise cannot be reversed. +Removing a withdrawal makes the withdrawn competitor or team eligible again, whichever kind of withdrawal it was, because a withdrawal entered by mistake never happened. This includes a voluntary kiken, which otherwise cannot be reversed. The one exception is a competitor who also has a **Fusenpai** recorded on another match: they did not appear there either, so they stay withdrawn, now because of that match, and the match you cleared goes back to the queue instead of in progress. The editor says so beside the button, before you tap it. **Save correction** on its own never removes a withdrawal. It saves the points or bouts you enter and keeps the withdrawal, its winner and the competitor's eligibility as they were recorded. In an individual match the winner's default-win circles are shown but cannot be changed. Only the withdrawn competitor's points can be corrected, and they are limited to one point, because a competitor with two points has already won the bout. 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 withdrawal in a knockout match warns you when the next match has already been played. If you go ahead, that next match is reopened so it can be fought and scored again, 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. +- Clearing a withdrawal in a knockout match 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 withdrawal can be cleared. If another match is running on this match's court, the app names it and offers to send it back to the queue, which clears any score entered for it. +In a knockout, a next match that is still in progress must be finished or sent back to the queue before a withdrawal can be cleared. If another match is running on this match's court, the app names it and offers to send it back to the queue, which keeps any score entered for it. In a pool of a competition that ends in a knockout, clearing a withdrawal is not refused because of the knockout. Clearing it moves nobody in the bracket: the pool simply has an unfinished match again. When you finish that match, the app checks whether the result changes who qualifies from the pool. The same result is saved as usual. A result that moves someone who has already fought in the knockout is handled like any pool correction: you are shown who moves and which knockout matches are affected before anything is saved. Refer to [Correct a pool result after the knockout has started](scoring-a-match.md#correct-a-pool-result-after-the-knockout-has-started). diff --git a/docs/user-guide/court-operators/scoring-a-match.md b/docs/user-guide/court-operators/scoring-a-match.md index ed8a9c69e..06c2fd643 100644 --- a/docs/user-guide/court-operators/scoring-a-match.md +++ b/docs/user-guide/court-operators/scoring-a-match.md @@ -17,7 +17,13 @@ To take back a strike, tap the scored mark itself in the centre of the board. Th ## Send a match back to the queue -If you start the wrong bout, use **Send back to queue** on the running match. The action clears any partial score, removes the match from the active view, and returns it to the Upcoming list so the correct match can start. +If you start the wrong bout, use **Send back to queue** on the running match. It is always offered, whatever has been scored. The match leaves the active view and returns to the Upcoming list so the correct match can start, and it keeps any score already entered: its points, fouls, overtime and, for a team match, its bouts. When you start it again, the score board opens where it left off. If a mark was entered by mistake, tap it on the board to take it back. + +While a match waits in the queue, every screen shows it as not started, the court display and the public viewer included, even when it holds points. They appear again when the match is started. + +Starting a different match from the Upcoming list works the same way: the match in progress goes back to the queue with its score, so if you switched by mistake, tap **Start match** on it to carry on. + +In an engi match the flags are saved as you enter them once the match has started, so they are kept too. !!! note Send back to queue only works on a running, unfinished match. A completed, scored match is not affected. To fix a result that has already been recorded, use **Correct** on the match in the Completed list (refer to [Correct a completed result](#correct-a-completed-result)). @@ -30,12 +36,12 @@ Once a match is scored it moves to the **Completed** list on the court console, What Correct offers depends on the format: -- For most matches, the editor reopens with the recorded scores ready to edit. Adjust the ippons, fouls, or winner, then use **Save correction**. A short reason is required so the change stays traceable. +- 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 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 withdrawal 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 clear its score, send it back to the queue, and reopen in a single step. Sending a match back to the queue clears any score already entered for it, so if that match is part way through, finish it first instead. +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. -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: its recorded result, including its bouts, is cleared, and the new pairing must be fought and scored 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 enter the result of the match you have just re-fought, 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. A match that is under way is not blocked or cleared either: it keeps what has been scored so far, now shown under the new competitor's name, and whoever is at the shiaijo finishes it as normal. When you are done, use **Back to court** to return to the live match. +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. A match that is under way is not blocked or cleared either: it keeps what has been scored so far, now shown under the new competitor's name, and whoever is at the shiaijo finishes it as normal. The same goes for a later match that was sent back to the queue with points already scored: it 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. ### Correct a pool result after the knockout has started @@ -43,7 +49,7 @@ In a competition that has pools and then a knockout, a pool result decides who g - If the correction does not change who holds any qualifying place in that pool, it is saved as usual. - If it changes a qualifier whose knockout match has not been played yet, it is saved and the new qualifier takes the old one's place in the bracket. -- If it changes a qualifier who has already fought a knockout match, the app shows you who moves and which knockout match was fought before anything is saved, for example: "Changing this result moves Pool A's 1st place from Aoki to Sato, who takes Aoki's place in the knockout. Match 9 (Quarterfinals) was already fought with Aoki: it will be reopened, its result cleared, and it must be fought again." A knockout match is always named with its round, so it cannot be mistaken for the pool match you are correcting, which has its own Match 1, Match 2 and so on. Cancel to leave everything as it was, or choose **Apply and reopen**. The correction is then saved, the new qualifier is placed in that match, and the match is reopened where it is, to be fought and scored again. If a withdrawal decided that match, the competitor it barred can compete again. A later round that nobody has played yet stops showing the old winner. A later round that has already been played is not changed now: when you enter the result of the re-fought match, the app asks you about that round in the same way. +- If it changes a qualifier who has already fought a knockout match, the app shows you who moves and which knockout match was fought before anything is saved, for example: "Changing this result moves Pool A's 1st place from Aoki to Sato, who takes Aoki's place in the knockout. Match 9 (Quarterfinals) was already fought with Aoki: it will be reopened with its winner cleared and its points kept, and it must be finished again." A knockout match is always named with its round, so it cannot be mistaken for the pool match you are correcting, which has its own Match 1, Match 2 and so on. Cancel to leave everything as it was, or choose **Apply and reopen**. The correction is then saved, the new qualifier is placed in that match, and the match is reopened where it is with its points kept: check them, take back any that no longer apply, and finish it again. If a withdrawal decided that match, the competitor it barred can compete again. A later round that nobody has played yet stops showing the old winner. A later round that has already been played is not changed now: when you finish the reopened match, the app asks you about that round in the same way. - If a knockout match that the change reaches is being fought right now, the correction is refused: "Match 9 (Quarterfinals) is being fought now. Finish it or send it back to the queue, then save again." - If the correction leaves a qualifying place tied, that place goes back to waiting in the bracket until the tie-break is fought, and the tie-break winner is placed there. A knockout match on that place that was already fought is reopened, and it is fought again once the tie-break decides who holds the place. diff --git a/docs/user-guide/organisers/team-tournaments.md b/docs/user-guide/organisers/team-tournaments.md index 9b21eab51..c1945f605 100644 --- a/docs/user-guide/organisers/team-tournaments.md +++ b/docs/user-guide/organisers/team-tournaments.md @@ -146,7 +146,7 @@ If your correction changes who won that bout, the bouts after it were fought on When the last bout is tied, the editor offers every legitimate way forward and you choose, according to the [kachinuki mode](#kachinuki-modes) in force; the app never decides it from the stage: - **Record bout** retires both fighters and brings the next pair up. -- **Encho** keeps the same pair fighting on that bout until one of them takes a point. Use it whenever your rules say this pairing must have a result, in any stage. +- **Encho** keeps the same pair fighting on that bout until one of them takes a point. It is offered on any tied bout: whether a pair fights on is your call, and the app records it. Use it whenever your rules say the pairing must have a result, in any stage. Tapped Encho by mistake, or twice? **Undo encho**, beside it, takes back one overtime period per tap while nothing has been scored in overtime; taking back the last one returns the bout to its tie. - **End match** finishes the encounter on the tie. In pools and leagues this records a drawn encounter. In a knockout the bracket needs a winner, so End match is held back while the last bout is tied; continue with Record bout or Encho instead. ![The kachinuki score editor on a tied knockout bout, showing the notice that a knockout cannot end in a draw with an Encho button, and the End match button held back.](../../screenshots/kachinuki-knockout-tie-encho.png) diff --git a/docs/videos/kachinuki-demo.webm b/docs/videos/kachinuki-demo.webm index c4bcee4cb..c4c1c5736 100644 Binary files a/docs/videos/kachinuki-demo.webm and b/docs/videos/kachinuki-demo.webm differ diff --git a/internal/domain/glossary.go b/internal/domain/glossary.go index d2e11fc0f..c059f1bd4 100644 --- a/internal/domain/glossary.go +++ b/internal/domain/glossary.go @@ -64,7 +64,7 @@ var Glossary = map[string]Term{ ID: "kiken", Kanji: "棄権", Short: "Withdrawal", - Tooltip: "The competitor withdraws. Two sub-types: Voluntary (FIK Art. 31), permanent disqualification from the competition; Injury (FIK Art. 30), can be reinstated if a doctor and shinpan permit it. The opponent wins 2–0 by default.", + Tooltip: "The competitor withdraws. Two sub-types: Voluntary (FIK Art. 31), permanent disqualification from the competition; Injury (FIK Art. 30), can be reinstated if they are fit to fight again. The opponent wins 2–0 by default.", }, "kiken-voluntary": { ID: "kiken-voluntary", @@ -76,7 +76,7 @@ var Glossary = map[string]Term{ ID: "kiken-injury", Kanji: "棄権", Short: "Injury withdrawal", - Tooltip: "FIK Article 30, the competitor withdraws due to injury or illness. They forfeit this match (opponent wins 2–0) and are blocked from later matches, but can be reinstated if a doctor and shinpan-in approve.", + Tooltip: "FIK Article 30, the competitor withdraws due to injury or illness. They forfeit this match (opponent wins 2–0) and are blocked from later matches, but can be reinstated if they are fit to fight again.", }, "fusenpai": { ID: "fusenpai", diff --git a/internal/engine/decision_stamp_test.go b/internal/engine/decision_stamp_test.go index c5735e22e..8f8d3d3e1 100644 --- a/internal/engine/decision_stamp_test.go +++ b/internal/engine/decision_stamp_test.go @@ -11,9 +11,9 @@ import ( ) // A decision closes a match, and until mp-jnvl it was the one completion that -// could carry no time at all: the withdrawal panel lists only `scheduled` -// matches, so the match it closes was never started and had no stored stamp to -// inherit. Nothing downstream could then order that result against the bouts +// could carry no time at all: a queue row's Record default win closes a match +// that is still `scheduled`, which was never started and had no stored stamp +// to inherit. Nothing downstream could then order that result against the bouts // around it. RecordDecision now takes the client's write stamp. func TestRecordDecision_StoresTheClientWriteStamp(t *testing.T) { setup := func(t *testing.T) (*Engine, *state.Store, string) { diff --git a/internal/engine/downstream_correction_guard_test.go b/internal/engine/downstream_correction_guard_test.go index 6d230475e..2ad4e1123 100644 --- a/internal/engine/downstream_correction_guard_test.go +++ b/internal/engine/downstream_correction_guard_test.go @@ -216,7 +216,8 @@ func TestDownstreamKnockoutCorrection_Force(t *testing.T) { assert.Equal(t, state.MatchStatusScheduled, next.Status, "reopened IN PLACE (the queue is not touched) and waiting to be fought again, NOT claimed as in progress") assert.Empty(t, next.Winner, "its winner is cleared") - assert.Empty(t, next.IpponsA, "its ippons are cleared") + assert.Equal(t, []string{"M", "M"}, next.IpponsA, + "its points are kept (operator ruling 2026-09-26): the operator removes a wrong mark") assert.Contains(t, next.CorrectionReason, "m-r1-0", "its audit note describes its OWN reopen and names the correction that caused it") assert.False(t, next.ReopenPending, @@ -352,9 +353,9 @@ func TestReopenMatch_ByeResolvedDownstream_IsUnwound(t *testing.T) { } // TestReopenBracketDownstreamCheck_ScheduledWithStrayDataIsNotResolvedByBye -// covers the second half of bc-cse item 8: bracketMatchStartedOrScored is -// true on more than a completed match (Winner/SubResults/Ippons set is -// enough), so a downstream row that is still SCHEDULED but carries stray +// covers the second half of bc-cse item 8: bracketMatchStartedOrDecided is +// true on more than a completed match (a Winner set is enough), so a +// downstream row that is still SCHEDULED but carries stray // Winner data must not be classified "resolved by a bye" -- that label // promises the specific, clean completed-via-bye shape // TestReopenMatch_ByeResolvedDownstream_IsUnwound pins, diff --git a/internal/engine/downstream_guard_review_test.go b/internal/engine/downstream_guard_review_test.go index b8e430458..4f14c06cd 100644 --- a/internal/engine/downstream_guard_review_test.go +++ b/internal/engine/downstream_guard_review_test.go @@ -3,6 +3,7 @@ package engine import ( "testing" + "github.com/gitrgoliveira/bracket-creator/internal/domain" "github.com/gitrgoliveira/bracket-creator/internal/state" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -42,17 +43,13 @@ func TestDownstreamGuard_StatusOmittedWriteIsStillGuarded(t *testing.T) { "the refused write must not have repainted the played next round") } -// TestDownstreamGuard_ReopenClearsTheOldPairingsBouts pins what a reopened -// match keeps and what it loses. -// -// The reopen reuses reopenBracketMatch, which KEEPS SubResults on purpose: it -// serves kachinuki, where the same two teams fight on and every bout already -// fought is a fact about them. Here the correction repaints this match's side, -// so those bouts belong to a pairing that is no longer in it, and leaving them -// filed one team's bouts under the name of the team that replaced them. The -// operator is promised otherwise in both the guide and the confirm dialog -// ("its recorded result, including its bouts, is cleared"). -func TestDownstreamGuard_ReopenClearsTheOldPairingsBouts(t *testing.T) { +// TestDownstreamGuard_ReopenKeepsTheFight pins what a reopened match keeps +// and what it loses. The correction puts a different competitor in this +// match's slot and discards only the verdict: its bouts and an engi panel's +// flags stay (operator ruling 2026-09-26: scores are never cleared, the +// operator removes a wrong mark), exactly as a match still waiting in the +// queue keeps its points when the name in it changes. +func TestDownstreamGuard_ReopenKeepsTheFight(t *testing.T) { eng, store, _ := setupTestEngine(t) compID := "kcdg-reopen-bouts" seedThreeRoundBracket(t, store, compID) @@ -81,9 +78,61 @@ func TestDownstreamGuard_ReopenClearsTheOldPairingsBouts(t *testing.T) { require.NoError(t, err) next := got.Rounds[1][0] assert.Equal(t, "Bob", next.SideA, "precondition: the slot was repainted") - assert.Empty(t, next.SubResults, "the previous pairing's bouts must not survive under the new competitor's name") - assert.Zero(t, next.FlagsA, "nor an engi panel's flags for the pair that was in the slot") - assert.Zero(t, next.FlagsB) + assert.Empty(t, next.Winner, "the verdict goes") + require.Len(t, next.SubResults, 1, "the bouts stay") + assert.Equal(t, []string{"M"}, next.SubResults[0].IpponsA) + assert.Equal(t, 3, next.FlagsA, "and the engi flags") + assert.Equal(t, 2, next.FlagsB) +} + +// A daihyosen row settles the encounter on its own, and after the correction +// its winner is the competitor taken out. The reopen keeps its points and +// drops that verdict, so starting the match again does not hand the win to the +// competitor put in, who never fought it. +func TestDownstreamGuard_ReopenDropsTheDaihyosenVerdict(t *testing.T) { + for _, tc := range []struct { + name string + ippons []string + wantIppons []string + }{ + {"won by a point", []string{"M"}, []string{"M"}}, + {"won by hantei", []string{domain.HanteiMark}, nil}, + } { + t.Run(tc.name, func(t *testing.T) { + eng, store, _ := setupTestEngine(t) + compID := "kcdg-reopen-dh" + seedThreeRoundBracket(t, store, compID) + require.NoError(t, store.UpdateBracket(compID, func(b *state.Bracket) error { + b.Rounds[1][0].SubResults = []state.SubMatchResult{ + {Position: 1, SideA: "Alice A", SideB: "Charlie A", Decision: "hikiwake"}, + {Position: state.DaihyosenSubPosition, SideA: "Alice", SideB: "Charlie", Winner: "Alice", IpponsA: tc.ippons}, + } + return nil + })) + + var reopened []ReopenedMatch + require.NoError(t, inTx(t, store, compID, func(tx state.StoreTx) error { + _, err := eng.RecordMatchResultWithIneligibilityTx(tx, compID, "m-r1-0", correctR1ToBob("confirmed"), + ForceOptions{Force: true, Reopened: &reopened}) + return err + })) + require.Equal(t, []string{"m-r2-0"}, reopenedIDs(reopened)) + + got, err := store.LoadBracket(compID) + require.NoError(t, err) + require.Len(t, got.Rounds[1][0].SubResults, 2, "the bouts stay") + dh := got.Rounds[1][0].SubResults[1] + assert.Empty(t, dh.Winner, "the daihyosen's verdict goes") + assert.Equal(t, tc.wantIppons, dh.IpponsA, "its struck points stay, a hantei mark goes") + + _, err = eng.RecordMatchResultWithIneligibility(compID, "m-r2-0", startWrite(), ForceOptions{StartOnly: true}) + require.NoError(t, err) + got, err = store.LoadBracket(compID) + require.NoError(t, err) + assert.Equal(t, state.MatchStatusRunning, got.Rounds[1][0].Status) + assert.Empty(t, got.Rounds[1][0].Winner, "starting it again hands Bob no win he never fought") + }) + } } // TestDownstreamGuard_RefusalNamesNoOneWhenTwoMatchesBlock pins the scope of diff --git a/internal/engine/eligibility.go b/internal/engine/eligibility.go index 59aa62511..eb902e1ca 100644 --- a/internal/engine/eligibility.go +++ b/internal/engine/eligibility.go @@ -121,7 +121,7 @@ func (e *AlreadyIneligibleError) Error() string { // check, extracted to ONE place (bc-cse) from what used to be three // hand-copied loops: StartMatchTx (scoring_tx.go), checkEligibilityExcludingMatch // (below), and the mobileapp ineligibleSides read-time annotation -// (annotateIneligibleSides, handlers_match.go), which needs the SAME +// (annotateEligibility, handlers_match.go), which needs the SAME // per-side answer to stamp a scheduled match without gating a write. // // A side is barred when it carries a non-empty id, a status entry exists @@ -263,8 +263,12 @@ func participantIDByName(h state.StoreTx, compID, name string) (string, error) { // recordIneligibilityFromDecision), so unlike that function this one // does not need to assert h is transactional. // +// decision is the withdrawal being recorded: alreadyBarredRefusal decides +// whether a loser barred elsewhere refuses it (a kiken) or chains onto the +// existing bar (a fusenpai, bc-kfup). +// // CHK047, T105. -func (e *Engine) checkConcurrentIneligibility(h state.StoreTx, compID, matchID, loserID, loserName string) error { +func (e *Engine) checkConcurrentIneligibility(h state.StoreTx, compID, matchID, decision, loserID, loserName string) error { if loserID == "" && loserName == "" { return nil } @@ -290,10 +294,48 @@ func (e *Engine) checkConcurrentIneligibility(h state.StoreTx, compID, matchID, // check and its sideB arm always returns nil for an empty id, so the // A-side return is the only one that can ever fire here. barred, _ := BarredSides(statuses, matchID, playerID, "") - if barred != nil { - return alreadyIneligibleErrorFor(playerID, barred) + return alreadyBarredRefusal(decision, playerID, barred) +} + +// alreadyBarredRefusal is the ONE statement of what a withdrawal decision +// does when its loser is already barred by a DIFFERENT match (barred, from +// BarredSides; nil means they are not). Both T105 halves ask it: the +// pre-write guard (checkConcurrentIneligibility) and the K2 check-and-set in +// recordIneligibilityFromDecision, so the two cannot disagree about which +// decision is refused. +// +// - A kiken is refused with *AlreadyIneligibleError: a competitor withdraws +// once, so a second kiken from another match is two operators recording +// the same withdrawal (the CHK047 race the guard exists for). +// - A fusenpai is NOT refused (bc-kfup). Against a competitor already +// barred it is the default loss that closes one of their remaining +// 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 +// 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 { + if barred == nil || decision == string(domain.DecisionFusenpai) { + return nil + } + return alreadyIneligibleErrorFor(playerID, barred) +} + +// withdrawalStatus is the bar a withdrawal decision on matchID records +// against playerID: not eligible, and reinstateable only after an injury kiken +// (FIK Art. 30). recordIneligibilityFromDecision writes it when the decision +// is recorded; standingWithdrawalOf rebuilds it when the bar moves to a +// fusenpai still on record. +func withdrawalStatus(playerID, decision, matchID string) domain.CompetitorStatus { + return domain.CompetitorStatus{ + PlayerID: playerID, + Eligible: false, + Reinstateable: decision == string(domain.DecisionKikenInjury), + Reason: fmt.Sprintf("%s at %s", decision, matchID), + MatchID: matchID, + RecordedAt: time.Now().UTC(), } - return nil } // CheckEligibility consults the competitor-status store for compID and @@ -820,10 +862,12 @@ func combinedPlayerPool(compPlayers []domain.Player, participants []domain.Playe // // Returns the persisted CompetitorStatus when a status was written // (so the handler layer can broadcast the corresponding -// `competitor-status-updated` SSE event), (nil, nil) when no status change -// applies (non-kiken/fusenpai decision, unresolvable loser, or unknown -// player), or a non-nil error when the row's own ids prove the loser -// ambiguous. +// `competitor-status-updated` SSE event), or, for a fusenpai against a +// competitor another match already barred (alreadyBarredRefusal chains it +// onto that bar), their existing status, unchanged; (nil, nil) when no +// status applies (non-kiken/fusenpai decision, unresolvable loser, or +// unknown player); or a non-nil error when the row's own ids prove the loser +// ambiguous or a kiken finds them already barred elsewhere. // // FR-036, contracts/match-decisions.md §side-effects. func (e *Engine) recordIneligibilityFromDecision(h state.StoreTx, compID, matchID string, result *state.MatchResult) (*domain.CompetitorStatus, error) { @@ -868,14 +912,7 @@ func (e *Engine) recordIneligibilityFromDecision(h state.StoreTx, compID, matchI } playerID = found } - status := domain.CompetitorStatus{ - PlayerID: playerID, - Eligible: false, - Reinstateable: result.Decision == string(domain.DecisionKikenInjury), - Reason: fmt.Sprintf("%s at %s", result.Decision, matchID), - MatchID: matchID, - RecordedAt: time.Now().UTC(), - } + status := withdrawalStatus(playerID, result.Decision, matchID) // K2/CHK047: check-and-set — the load, the check and the write MUST happen // under one per-competition lock acquire, or two concurrent kiken writes on // the same player from different matches can both pass the check (TOCTOU). @@ -910,7 +947,18 @@ func (e *Engine) recordIneligibilityFromDecision(h state.StoreTx, compID, matchI // passes playerID as sideA and "" as sideB (BarredSides is a per-side // check whose sideB arm always returns nil for an empty id). if barred, _ := BarredSides(statuses, matchID, playerID, ""); barred != nil { - return nil, alreadyIneligibleErrorFor(playerID, barred) + if err := alreadyBarredRefusal(result.Decision, playerID, barred); err != nil { + return nil, err + } + // A fusenpai chained onto an existing bar (bc-kfup): the match + // records the default loss and the status that barred them stays + // exactly as it was, still naming its own match. It is returned, + // unchanged, as the loser's status in force: restoreIfWithdrawalRemoved + // reads a nil loser as "could not resolve who lost" and restores + // nobody, so a nil here would leave barred the competitor a wrong + // kiken on THIS match had marked, when the operator corrects it to + // this fusenpai. Broadcasting it again changes nothing. + return barred, nil } if err := h.SetCompetitorStatus(compID, status); err != nil { return nil, err diff --git a/internal/engine/eligibility_test.go b/internal/engine/eligibility_test.go index a1d40cb7e..53720728e 100644 --- a/internal/engine/eligibility_test.go +++ b/internal/engine/eligibility_test.go @@ -365,6 +365,214 @@ func TestRecordDecision_FusenshoSkipsConcurrentCheck(t *testing.T) { assert.NoErrorf(t, err, "fusensho on an already-ineligible player must not trigger AlreadyIneligibleError; got %v", err) } +// TestRecordDecision_FusenpaiChainsOntoExistingBar pins bc-kfup: once a +// competitor withdraws, a fusenpai on each of their remaining matches is the +// 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 +// 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 +// probe and K2 check-and-set would each refuse it on their own). +func TestRecordDecision_FusenpaiChainsOntoExistingBar(t *testing.T) { + eng, store, _ := setupTestEngine(t) + compID := "fusenpai-chain" + createTestCompetition(t, store, compID, "league", 2) + + aliceID := helper.NewUUID4() + bobID := helper.NewUUID4() + carolID := helper.NewUUID4() + daveID := 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"}, + {ID: daveID, Name: "Dave", Dojo: "D"}, + })) + 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: "Carol", SideAID: carolID, SideB: "Alice", SideBID: aliceID, Status: state.MatchStatusScheduled}, + {ID: "Pool A-2", SideA: "Alice", SideAID: aliceID, SideB: "Dave", SideBID: daveID, Status: state.MatchStatusScheduled}, + })) + + // 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) + require.NoError(t, err) + barred := func() domain.CompetitorStatus { + t.Helper() + statuses, err := store.LoadCompetitorStatus(compID) + require.NoError(t, err) + st, ok := statuses[aliceID] + require.True(t, ok, "Alice must still carry a competitor-status record") + return st + } + origin := barred() + 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) + 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") + require.NotNil(t, res) + assert.Equal(t, state.MatchStatusCompleted, res.Status) + assert.Equal(t, "fusenpai", res.Decision) + assert.Equal(t, carolID, res.WinnerID, "the opponent takes the default win") + + // /score door: fusenpai on Alice (aka, sideA) in Pool A-2. + status, err = eng.RecordMatchResultWithIneligibility(compID, "Pool A-2", &state.MatchResult{ + Winner: "Dave", + WinnerID: daveID, + WinnerSide: "B", + Status: state.MatchStatusCompleted, + Decision: "fusenpai", + DecisionBy: "aka", + }) + require.NoError(t, err, "the /score door must chain the fusenpai exactly as /decision does") + require.NotNil(t, status) + assert.Equal(t, "Pool A-0", status.MatchID) + + after := barred() + assert.Equal(t, "Pool A-0", after.MatchID, "the status must still name the match that barred Alice") + assert.Equal(t, origin.Reason, after.Reason) + assert.False(t, after.Eligible) + assert.True(t, after.Reinstateable, "an injury kiken stays reinstateable after its chained default losses") + + matches, err := store.LoadPoolMatches(compID) + require.NoError(t, err) + for _, m := range matches { + assert.Equalf(t, state.MatchStatusCompleted, m.Status, "%s must be closed", m.ID) + } + + // A second KIKEN from another match is still the CHK047 conflict. + 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) + 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) +} + +// TestRecordDecision_FusenpaiChainCorrectsAWrongKiken pins the correction +// path through a chained fusenpai (bc-kfup review). Alice withdrew in Pool +// A-0. On Pool A-1 the operator marks the wrong side, recording a kiken for +// Carol, then corrects it to Alice's fusenpai (recording-decisions.md, "The +// wrong competitor or team was marked"). Carol never withdrew, so the +// correction must restore her; Alice's status must still name Pool A-0. +// The chained fusenpai returns Alice's existing status as the loser in +// force, which is what lets restoreIfWithdrawalRemoved restore Carol: a nil +// loser there reads as "could not resolve who lost" and restores nobody. +func TestRecordDecision_FusenpaiChainCorrectsAWrongKiken(t *testing.T) { + eng, store, _ := setupTestEngine(t) + compID := "fusenpai-chain-correction" + createTestCompetition(t, store, compID, "league", 2) + + aliceID := helper.NewUUID4() + bobID := helper.NewUUID4() + carolID := 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: "Carol", SideAID: carolID, SideB: "Alice", SideBID: aliceID, Status: state.MatchStatusScheduled}, + })) + + _, _, err := eng.RecordDecision(compID, "Pool A-0", "kiken-voluntary", "aka", "withdrew", nil, false) + 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) + 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) + require.NoError(t, err) + require.NotNil(t, status) + assert.Equal(t, carolID, status.PlayerID, "the restored competitor is the one the write reports") + + statuses, err = store.LoadCompetitorStatus(compID) + require.NoError(t, err) + if carol, ok := statuses[carolID]; ok { + assert.True(t, carol.Eligible, "Carol never withdrew: the correction must restore her") + } + alice := statuses[aliceID] + assert.False(t, alice.Eligible) + assert.Equal(t, "Pool A-0", alice.MatchID, "Alice's status still names the match where she withdrew") +} + +// TestRecordDecision_FusenpaiChainClosesARunningMatch pins bc-kfup's second +// repro: the kiken is recorded (here as a correction of a fought result) +// after the competitor's next match has already started. That running match +// still has to be closed, and a fusenpai on it records the default loss. +func TestRecordDecision_FusenpaiChainClosesARunningMatch(t *testing.T) { + eng, store, _ := setupTestEngine(t) + compID := "fusenpai-chain-running" + createTestCompetition(t, store, compID, "league", 2) + + aliceID := helper.NewUUID4() + bobID := helper.NewUUID4() + carolID := 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.MatchStatusCompleted, + Winner: "Alice", WinnerID: aliceID, IpponsA: []string{"M", "K"}}, + {ID: "Pool A-1", SideA: "Carol", SideAID: carolID, SideB: "Alice", SideBID: aliceID, Status: state.MatchStatusRunning}, + })) + + // 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) + 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) + 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) + + statuses, err := store.LoadCompetitorStatus(compID) + require.NoError(t, err) + assert.Equal(t, "Pool A-0", statuses[aliceID].MatchID) +} + +// TestAlreadyBarredRefusal pins the one rule both T105 halves ask: which +// withdrawal decision a loser barred by another match refuses. +func TestAlreadyBarredRefusal(t *testing.T) { + barred := &domain.CompetitorStatus{PlayerID: "p1", Eligible: false, MatchID: "Pool A-0", Reason: "kiken-voluntary at Pool A-0"} + for _, tc := range []struct { + decision string + barred *domain.CompetitorStatus + refused bool + }{ + {"kiken", barred, true}, + {"kiken-voluntary", barred, true}, + {"kiken-injury", barred, true}, + {"fusenpai", barred, false}, + {"kiken-voluntary", nil, false}, + {"fusenpai", nil, false}, + } { + err := alreadyBarredRefusal(tc.decision, "p1", tc.barred) + if tc.refused { + var alreadyErr *AlreadyIneligibleError + assert.ErrorAsf(t, err, &alreadyErr, "%s against a barred loser must be refused", tc.decision) + } else { + assert.NoErrorf(t, err, "%s (barred=%v) must not be refused", tc.decision, tc.barred != nil) + } + } +} + // TestCheckEligibility_AllEligible verifies that CheckEligibility returns // nil when no competitor-status records exist (default-eligible per FR-034). // TestCheckEligibility_OneIneligible verifies that CheckEligibility @@ -682,7 +890,7 @@ func TestCheckConcurrentIneligibility_EmptyLoser(t *testing.T) { eng, store, _ := setupTestEngine(t) compID := "conc-empty-loser" require.NoError(t, store.SaveCompetition(&state.Competition{ID: compID})) - err := eng.checkConcurrentIneligibility(eng.store, compID, "M1", "", "") + err := eng.checkConcurrentIneligibility(eng.store, compID, "M1", "kiken-voluntary", "", "") assert.NoError(t, err, "both loserID and loserName empty should return nil") } @@ -693,7 +901,7 @@ func TestCheckConcurrentIneligibility_PlayerNotInParticipants(t *testing.T) { compID := "conc-unknown" require.NoError(t, store.SaveCompetition(&state.Competition{ID: compID})) // No participants saved → lookupPlayerID returns "" - err := eng.checkConcurrentIneligibility(eng.store, compID, "M1", "", "Ghost Player") + err := eng.checkConcurrentIneligibility(eng.store, compID, "M1", "kiken-voluntary", "", "Ghost Player") assert.NoError(t, err, "unknown player should return nil without error") } @@ -813,7 +1021,7 @@ func TestCheckConcurrentIneligibility_AlreadyIneligible(t *testing.T) { MatchID: "M-prev", })) - err := eng.checkConcurrentIneligibility(eng.store, compID, "M-new", "", "Alice") + err := eng.checkConcurrentIneligibility(eng.store, compID, "M-new", "kiken-voluntary", "", "Alice") require.Error(t, err) var alreadyErr *AlreadyIneligibleError require.ErrorAs(t, err, &alreadyErr) @@ -840,7 +1048,7 @@ func TestCheckConcurrentIneligibility_SameMatchNotBlocked(t *testing.T) { MatchID: "M-current", // same as what we're re-scoring })) - err := eng.checkConcurrentIneligibility(eng.store, compID, "M-current", "", "Bob") + err := eng.checkConcurrentIneligibility(eng.store, compID, "M-current", "kiken-voluntary", "", "Bob") assert.NoError(t, err, "same-match ineligibility should not block re-scoring") } diff --git a/internal/engine/engi_running_flags_test.go b/internal/engine/engi_running_flags_test.go new file mode 100644 index 000000000..0e75af12e --- /dev/null +++ b/internal/engine/engi_running_flags_test.go @@ -0,0 +1,85 @@ +package engine + +import ( + "testing" + + "github.com/gitrgoliveira/bracket-creator/internal/state" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// Engi flags are saved as they are entered (operator ruling 2026-09-26): a +// running write carries the flags so far, and a panel part-way through its +// count has no valid total yet, so only a completing write is judged on the +// flag total and decides a winner. + +func TestEngiRunningWrite_SavesThePoolFlagsSoFar(t *testing.T) { + const compID = "engi-running-pool" + eng, store := setupStartComp(t, &state.Competition{ID: compID, Name: "Engi", Format: state.CompFormatMixed, Courts: []string{"A"}, Engi: true}) + require.NoError(t, store.SavePoolMatches(compID, []state.MatchResult{{ + ID: "Pool A-0", SideA: "Pair One", SideB: "Pair Two", Court: "A", Status: state.MatchStatusRunning, + }})) + + _, err := eng.RecordMatchResultWithIneligibility(compID, "Pool A-0", &state.MatchResult{Status: state.MatchStatusRunning, FlagsA: 1, FlagsB: 1}) + require.NoError(t, err, "an even count so far is not a finished panel") + + m := poolMatch(t, store, compID, "Pool A-0") + assert.Equal(t, state.MatchStatusRunning, m.Status) + assert.Equal(t, 1, m.FlagsA) + assert.Equal(t, 1, m.FlagsB) + assert.Empty(t, m.Winner, "nobody has won yet") +} + +func TestEngiRunningWrite_SavesTheBracketFlagsSoFar(t *testing.T) { + const compID = "engi-running-ko" + eng, store := setupStartComp(t, &state.Competition{ID: compID, Name: "Engi KO", Format: state.CompFormatKnockout, Courts: []string{"A"}, Engi: true}) + require.NoError(t, store.SaveBracket(compID, &state.Bracket{Rounds: [][]state.BracketMatch{{ + {ID: "m-r1-0", SideA: "Pair One", SideB: "Pair Two", Court: "A", Status: state.MatchStatusRunning}, + }}})) + + _, err := eng.RecordMatchResultWithIneligibility(compID, "m-r1-0", &state.MatchResult{Status: state.MatchStatusRunning, FlagsA: 2, FlagsB: 0}) + require.NoError(t, err) + + b, err := store.LoadBracket(compID) + require.NoError(t, err) + m := b.Rounds[0][0] + assert.Equal(t, state.MatchStatusRunning, m.Status) + assert.Equal(t, 2, m.FlagsA) + assert.Equal(t, 0, m.FlagsB) + assert.Empty(t, m.Winner) +} + +func TestEngiCompletingWrite_IsStillJudgedOnTheTotal(t *testing.T) { + const compID = "engi-complete-judged" + eng, store := setupStartComp(t, &state.Competition{ID: compID, Name: "Engi", Format: state.CompFormatMixed, Courts: []string{"A"}, Engi: true}) + require.NoError(t, store.SavePoolMatches(compID, []state.MatchResult{{ + ID: "Pool A-0", SideA: "Pair One", SideB: "Pair Two", Court: "A", Status: state.MatchStatusRunning, + }})) + + _, err := eng.RecordMatchResultWithIneligibility(compID, "Pool A-0", &state.MatchResult{Status: state.MatchStatusCompleted, FlagsA: 1, FlagsB: 1}) + var verr *ValidationError + require.ErrorAs(t, err, &verr, "a finished panel cannot draw") +} + +func TestEngiFlags_SendBackAndStartKeepsThem(t *testing.T) { + // The whole operator path on a knockout: flags entered, the match sent + // back to the queue, started again from the court console. + const compID = "engi-flags-roundtrip" + eng, store := setupStartComp(t, &state.Competition{ID: compID, Name: "Engi KO", Format: state.CompFormatKnockout, Courts: []string{"A"}, Engi: true}) + require.NoError(t, store.SaveBracket(compID, &state.Bracket{Rounds: [][]state.BracketMatch{{ + {ID: "m-r1-0", SideA: "Pair One", SideB: "Pair Two", Court: "A", Status: state.MatchStatusRunning}, + }}})) + _, err := eng.RecordMatchResultWithIneligibility(compID, "m-r1-0", &state.MatchResult{Status: state.MatchStatusRunning, FlagsA: 2, FlagsB: 1}) + require.NoError(t, err) + + require.NoError(t, eng.RevertMatchToQueue(compID, "m-r1-0")) + _, err = eng.RecordMatchResultWithIneligibility(compID, "m-r1-0", startWrite(), ForceOptions{StartOnly: true}) + require.NoError(t, err) + + b, err := store.LoadBracket(compID) + require.NoError(t, err) + m := b.Rounds[0][0] + assert.Equal(t, state.MatchStatusRunning, m.Status) + assert.Equal(t, 2, m.FlagsA) + assert.Equal(t, 1, m.FlagsB) +} diff --git a/internal/engine/errors.go b/internal/engine/errors.go index a6a83453f..a88e33bba 100644 --- a/internal/engine/errors.go +++ b/internal/engine/errors.go @@ -180,7 +180,7 @@ func (e *DownstreamKnockoutPlayedError) Error() string { // A pool-rank override (OverridePoolRanks) corrects no match. change = fmt.Sprintf("changing the ranking of %s changes who qualified from it", pool) } - return fmt.Sprintf("%s, and %s %s already fought by %s. Retry with forceDownstreamReopen to apply the change and reopen %s to be fought again", + return fmt.Sprintf("%s, and %s %s already fought by %s. Retry with forceDownstreamReopen to apply the change and reopen %s with the new competitor in, the winner cleared and the points kept", change, strings.Join(labels, " and "), verb, who, them) } labels := make([]string, 0, len(e.Blocking)) @@ -194,10 +194,10 @@ func (e *DownstreamKnockoutPlayedError) Error() string { if len(e.Blocking) > 1 { // No Displaced clause: it names one competitor, and these matches do // not share one. - return fmt.Sprintf("correcting %s would change the winner already propagated into %s, which have recorded their own results. Retry with forceDownstreamReopen to apply the correction and reopen both to be fought again", + return fmt.Sprintf("correcting %s would change the winner already propagated into %s, which have recorded their own results. Retry with forceDownstreamReopen to apply the correction and reopen both with the new competitors in, the winners cleared and the points kept", label, blocked) } - return fmt.Sprintf("correcting %s would change the winner already propagated into %s, which has recorded its own result; this would displace %q without updating that result. Retry with forceDownstreamReopen to apply the correction and reopen %s to be fought again", + return fmt.Sprintf("correcting %s would change the winner already propagated into %s, which has recorded its own result; this would displace %q without updating that result. Retry with forceDownstreamReopen to apply the correction and reopen %s with the new competitor in, the winner cleared and the points kept", label, blocked, e.Displaced, blocked) } diff --git a/internal/engine/kachinuki.go b/internal/engine/kachinuki.go index 576ed4d95..41623f688 100644 --- a/internal/engine/kachinuki.go +++ b/internal/engine/kachinuki.go @@ -19,7 +19,8 @@ // domain.DecisionKachinukiExhaustion. // - A tied final bout is a drawn encounter in pools/league; a knockout // tie is resolved by encho on that same bout (daihyosen does not -// exist in kachinuki). +// exist in kachinuki). Whether a tied pair fights on in encho is the +// operator's call; the app records it (operator ruling 2026-09-26). // // AdvanceKachinuki encapsulates the pure decision logic. Callers // (typically a score handler, see handlers_match.go) pass a snapshot @@ -1067,13 +1068,7 @@ func (e *Engine) reopenUnderCourtLock(compID string, comp *state.Competition, ma } } prior := h.Bracket.Decision - fight, single := singleBoutFightOf(h.Bracket.SubResults, h.Bracket.IpponsA, h.Bracket.IpponsB, h.Bracket.HansokuA, h.Bracket.HansokuB, h.Bracket.Encho) - reopenBracketMatch(h.Bracket, reason, targetStatus) - if single { - h.Bracket.IpponsA, h.Bracket.IpponsB = fight.IpponsA, fight.IpponsB - h.Bracket.HansokuA, h.Bracket.HansokuB = fight.HansokuA, fight.HansokuB - h.Bracket.Encho = fight.Encho - } + reopenBracketMatchKeepingTheFight(h.Bracket, reason, targetStatus) if serr := h.Save(); serr != nil { return serr } @@ -1140,18 +1135,20 @@ func (e *Engine) RequeueBlockerAndReopen(targetComp, targetMatch, blockerComp, b return verr } // Pre-check the TARGET's RESULT preconditions (completed + - // downstream-not-fought) read-only BEFORE the destructive revert, so a - // target that cannot be reopened — because its result already fed a - // fought knockout — does not cost the blocker its live on-court score - // (mp-gmcg review). The court half is deliberately NOT checked here: the - // revert is what frees the court, so the reopen's own court gate is the - // authoritative one. This NARROWS the wipe window but does NOT close it: + // downstream-not-fought) read-only BEFORE the revert, so a target that + // cannot be reopened — because its result already fed a fought + // knockout — does not send the blocker off the court for nothing + // (mp-gmcg review; the blocker keeps its score either way, bc-sbq). + // The court half is deliberately NOT checked here: the revert is what + // frees the court, so the reopen's own court gate is the authoritative + // one. This NARROWS that window but does NOT close it: // PUT /score takes the court lock we hold, but POST /decision and POST // /bulk-score complete a match under the per-comp lock alone (see the // ErrMatchAlreadyCompleted case in the requeue handler), so a downstream // write landing between // this pre-check's tx close and the reopen's own in-tx re-check can still - // make the reopen fail AFTER the revert — costing the blocker its score. + // make the reopen fail AFTER the revert — leaving the blocker in the queue + // (its score kept) to be started again. // The reopen's re-check is the backstop that preserves bracket integrity // in that race regardless; closing the residual window deterministically // would need the pre-check, revert, and reopen under one target-comp @@ -1163,7 +1160,7 @@ func (e *Engine) RequeueBlockerAndReopen(targetComp, targetMatch, blockerComp, b } // A target that reopens to SCHEDULED (a default win whose barred // competitor is still barred) takes no court, so requeuing the - // blocker would wipe its live score to free a court nobody needs. + // blocker would take it off the court to free a court nobody needs. // Refused before the revert, naming the one step that works: the // plain reopen, which no busy court refuses for such a target. if landsScheduled { @@ -1301,8 +1298,8 @@ func reopenBracketDownstreamCheck(bracket *state.Bracket, rIdx, mIdx int, force // the pre-check passed (the accepted window the requeue comment documents). // // It also reports whether the reopen lands SCHEDULED (reopenTargetStatus: a -// match-level fusensho whose decisionBy side is still barred), read in the same -// tx. Such a reopen takes no court, so reopenUnderCourtLock skips both court +// default win whose decisionBy side stays barred by another match), read in +// the same tx. Such a reopen takes no court, so reopenUnderCourtLock skips both court // gates for it and holds it to scheduled even if the bar lifts in between (see // the COURT GATE note on ReopenMatch). func (e *Engine) checkTargetReopenable(compID string, comp *state.Competition, matchID string, force bool) (bool, error) { @@ -1591,33 +1588,41 @@ func findMatchHome(tx state.StoreTx, compID, matchID string, visit func(matchHom // as an offline-queued Save correction replayed afterwards, is older than the // reopen and is refused as superseded instead of completing the match again. // reopenTargetStatus decides RUNNING vs SCHEDULED for a reopen (bc-cse item -// 9). Every reopen but one goes to RUNNING, as it always has: the operator -// tapped Reopen to fight on. The exception is a match-level FUSENSHO +// 9). Every reopen goes to RUNNING, as it always has (the operator tapped +// Reopen to fight on), except a default win whose barred side is barred by +// ANOTHER match. The first such case was a match-level FUSENSHO // (domain.DecisionFusensho) recorded to close a barred competitor's // remaining match with a default win for the opponent -- fusensho itself // never writes a CompetitorStatus (recordIneligibilityFromDecision only // fires for domain.IsWithdrawalDecisionStr, which deliberately excludes it; // see ReopenMatch's ELIGIBILITY doc), so the bar this match's decisionBy // side carries, if any, was recorded by an EARLIER withdrawal elsewhere and -// this reopen restores nothing. If that earlier bar still holds, the -// competitor still cannot fight: reopening to RUNNING would only let +// this reopen restores nothing. The second is a FUSENPAI chained onto such a +// bar (bc-kfup, alreadyBarredRefusal): it records the default loss and no +// status of its own, so the same holds. If that earlier bar still holds, +// the competitor still cannot fight: reopening to RUNNING would only let // StartMatchTx refuse every later write on this match with no way forward, // so it goes to SCHEDULED instead, which re-shows the barred-match notice -// (annotateIneligibleSides) exactly as it did before the fusensho was ever -// recorded, and, taking no court, is not refused for a busy one (see the -// COURT GATE note on ReopenMatch). Once the competitor is reinstated or the -// earlier withdrawal is itself cleared, the SAME reopen goes to RUNNING like +// (annotateEligibility) exactly as it did before the default win was +// ever recorded, and, taking no court, is not refused for a busy one (see +// the COURT GATE note on ReopenMatch). Once the competitor is reinstated or +// the earlier withdrawal is itself cleared, the SAME reopen goes to RUNNING +// like any other. An ordinary kiken or fusenpai goes to RUNNING: the status +// it recorded names THIS match, which BarredSides' undo-path exemption (a +// status recorded BY matchID never bars it) ignores, and which the reopen +// restores. The third SCHEDULED case is that restore moving the bar instead: +// a fusenpai chained onto it is still on record, so the competitor stays +// barred by THAT match. reopenTargetStatusTx hands this function the +// statuses as the restore will leave them, so it reads the moved bar like // any other. // // decisionBy names the WITHDRAWING side (recordDecisionTx's own convention: // "aka" -> sideA lost, "shiro" -> sideB lost), so it is read against // BarredSides' A/B return the same way. statuses/matchID/sideAID/sideBID are // the reopened match's OWN identity, read before reopenPoolMatch/ -// reopenBracketMatch clear Decision/DecisionBy -- BarredSides' own -// undo-path exemption (a status recorded BY matchID itself never bars it) -// does not apply here, since fusensho never recorded one. +// reopenBracketMatch clear Decision/DecisionBy. func reopenTargetStatus(statuses map[string]domain.CompetitorStatus, matchID, decision, decisionBy, sideAID, sideBID string) state.MatchStatus { - if decision != string(domain.DecisionFusensho) { + if !domain.IsDefaultWinDecisionStr(decision) { return state.MatchStatusRunning } a, b := BarredSides(statuses, matchID, sideAID, sideBID) @@ -1629,13 +1634,15 @@ func reopenTargetStatus(statuses map[string]domain.CompetitorStatus, matchID, de } // reopenTargetStatusTx is reopenTargetStatus's tx-aware wrapper: it loads -// CompetitorStatus LAZILY, only when decision is a fusensho (the one case +// CompetitorStatus LAZILY, only when decision is a default win (the one case // reopenTargetStatus needs it for), through the live transaction handle. A // load failure is logged rather than discarded and defaults to RUNNING // (today's behaviour), since a read the operator cannot diagnose must never -// silently trade one stuck state for another. +// silently trade one stuck state for another. For a withdrawal it hands +// reopenTargetStatus the statuses as the reopen's restore will leave them +// (restoredStatus), so a bar that moves onto a chained fusenpai counts. func (e *Engine) reopenTargetStatusTx(tx state.StoreTx, compID, matchID, decision, decisionBy, sideAID, sideBID string) state.MatchStatus { - if decision != string(domain.DecisionFusensho) { + if !domain.IsDefaultWinDecisionStr(decision) { return state.MatchStatusRunning } statuses, err := tx.LoadCompetitorStatus(compID) @@ -1643,6 +1650,20 @@ func (e *Engine) reopenTargetStatusTx(tx state.StoreTx, compID, matchID, decisio log.Printf("engine: ReopenMatch: LoadCompetitorStatus compId=%s matchId=%s: %v (defaulting to running)", compID, matchID, err) return state.MatchStatusRunning } + // Judge the bars the reopen will leave, not the ones it finds: clearing a + // withdrawal this match recorded restores its loser, unless a fusenpai + // chained onto that bar is still on record, which then carries it + // (restoredStatus). A copy, since the loaded map may be the store's own. + if domain.IsWithdrawalDecisionStr(decision) { + after := make(map[string]domain.CompetitorStatus, len(statuses)) + for id, st := range statuses { + if st.MatchID == matchID && !st.Eligible { + st = restoredStatus(tx, compID, id, matchID) + } + after[id] = st + } + statuses = after + } return reopenTargetStatus(statuses, matchID, decision, decisionBy, sideAID, sideBID) } @@ -1695,9 +1716,12 @@ func reopenPoolMatch(m *state.MatchResult, reason string, targetStatus state.Mat // the reopen threw away. // // This clears the verdict fields RevertMatchToQueue clears (engine/scoring.go) -// that a kachinuki result can actually carry, MINUS SubResults (which requeue -// drops and reopen exists to preserve) and CorrectionReason/ReopenPending -// (which the reopen is itself setting). Match-level HansokuA/B ARE mirrored +// that a kachinuki result can actually carry, plus the match-level scoreline, +// fouls and overtime, which on a kachinuki encounter are a verdict about the +// bouts (a reopen of a single-bout match puts its fight back through +// reopenBracketMatchKeepingTheFight). SubResults stay, as a requeue keeps them +// too, and CorrectionReason/ReopenPending are what the reopen itself sets. +// Match-level HansokuA/B ARE mirrored // here, even though today's team-editor wire path never sets them on a // kachinuki match: NormalizeLegacy (state/legacy_hantei.go) folds a legacy // ScoreA/ScoreB string into IpponsA/HansokuA on every bracket read, and @@ -1709,10 +1733,9 @@ func reopenPoolMatch(m *state.MatchResult, reason string, targetStatus state.Mat // applyHansokuIppons fold a stale count into the H ippons of a scoreline the // reopen just cleared. FlagsA/B remain unmirrored: they are engi-only, and // engi has no kachinuki, so neither reopen path ever sees them populated. -// ModifiedAt is left at the completion stamp on purpose — it still -// fences any stale pre-completion offline write via ApplyByTimestamp and -// never blocks the re-End. If you add a match-level verdict field a -// kachinuki result CAN carry, clear it here too. +// ModifiedAt is stamped with the server's clock (the revert fence below), so a +// write stamped before the reopen is refused as superseded. If you add a +// match-level verdict field a kachinuki result CAN carry, clear it here too. func reopenBracketMatch(bm *state.BracketMatch, reason string, targetStatus state.MatchStatus) { bm.Status = targetStatus bm.Winner = "" @@ -1735,6 +1758,22 @@ func reopenBracketMatch(bm *state.BracketMatch, reason string, targetStatus stat bm.ModifiedAt = time.Now().UnixMilli() } +// reopenBracketMatchKeepingTheFight is reopenBracketMatch for a reopen that +// keeps what was fought. A team match's bouts already survive +// reopenBracketMatch; on a single-bout match (an individual match) the +// match-level scoreline IS the fight, so the letters actually struck, the +// fouls and the overtime are put back (singleBoutFightOf; a default win's +// maru go with the verdict). Engi flags are never touched by the reopen. +func reopenBracketMatchKeepingTheFight(bm *state.BracketMatch, reason string, targetStatus state.MatchStatus) { + fight, single := singleBoutFightOf(bm.SubResults, bm.IpponsA, bm.IpponsB, bm.HansokuA, bm.HansokuB, bm.Encho) + reopenBracketMatch(bm, reason, targetStatus) + if single { + bm.IpponsA, bm.IpponsB = fight.IpponsA, fight.IpponsB + bm.HansokuA, bm.HansokuB = fight.HansokuA, fight.HansokuB + bm.Encho = fight.Encho + } +} + // reopenPending reports whether a reopen was made without an audit reason: // true when none was supplied, false when the operator gave one. Nothing is // refused for it (see state.MatchResult.ReopenPending). One helper rather than a `reason == ""` test @@ -1771,11 +1810,12 @@ type singleBoutFight struct { // own letters from before the withdrawal are NOT recoverable here: the // withdrawal replaced them with the maru when it was recorded. // -// Called by reopenUnderCourtLock for the match being reopened ONLY. The -// primitives reopenPoolMatch/reopenBracketMatch stay "discard the verdict" -// and nothing more, because forceReopenDownstreamChain also uses -// reopenBracketMatch on a DOWNSTREAM match whose sides were just repainted, -// where every letter belongs to a pairing no longer in it. +// Called through reopenBracketMatchKeepingTheFight, by reopenUnderCourtLock +// for the match being reopened and by reopenDisplacedBracketMatch for a +// DOWNSTREAM match whose sides were just repainted (operator ruling +// 2026-09-26: its points are kept too, and the operator takes back any that +// no longer apply). The primitives reopenPoolMatch/reopenBracketMatch stay +// "discard the verdict" and nothing more. func singleBoutFightOf(subs []state.SubMatchResult, ipponsA, ipponsB []string, hansokuA, hansokuB int, encho *state.EnchoMetadata) (singleBoutFight, bool) { if len(subs) > 0 { return singleBoutFight{}, false @@ -1952,8 +1992,8 @@ func (d propagatedDownstream) displacedSlot(blocking []*state.BracketMatch, mIdx // case: the earlier check missed it. func retractPropagatedWinner(bracket *state.Bracket, rIdx, mIdx int) error { d := propagatedDownstreamOf(bracket, rIdx, mIdx) - if (d.bronze != nil && bracketMatchStartedOrScored(d.bronze)) || - (d.next != nil && bracketMatchStartedOrScored(d.next)) { + if (d.bronze != nil && bracketMatchStartedOrDecided(d.bronze)) || + (d.next != nil && bracketMatchStartedOrDecided(d.next)) { return ErrReopenDownstreamFought } clearPropagatedSlots(bracket, rIdx, mIdx, d.bronze, nil) @@ -2005,7 +2045,7 @@ func (d propagatedDownstream) unwindChain(bracket *state.Bracket, rIdx, mIdx int func retractIntoUntouched(bracket *state.Bracket, rIdx, mIdx int) { d := propagatedDownstreamOf(bracket, rIdx, mIdx) kept := func(t *state.BracketMatch) bool { - if t == nil || !bracketMatchStartedOrScored(t) { + if t == nil || !bracketMatchStartedOrDecided(t) { return false } log.Printf("engine: bracket match %s keeps the winner propagated into it: it is %s, so the reopened match %s does not retract it", t.ID, t.Status, bracket.Rounds[rIdx][mIdx].ID) @@ -2057,16 +2097,17 @@ func clearPropagatedSlots(bracket *state.Bracket, rIdx, mIdx int, bronze, next * } } -// bracketMatchStartedOrScored reports whether a downstream bracket match -// is anything other than an untouched scheduled slot: running/completed -// status, a winner, recorded bouts, or a scoreline all block a reopen. -func bracketMatchStartedOrScored(bm *state.BracketMatch) bool { +// bracketMatchStartedOrDecided reports whether a downstream bracket match +// is being or has been fought: running or completed, or carrying a winner. +// A QUEUED match's points and bouts do not count: a match sent back to the +// queue keeps its score, and when the match feeding it is corrected or +// reopened it takes the new name with its points kept (operator ruling +// 2026-09-26, bc-sbq). Judging those points as "fought" refused the reopen +// and left the old winner seated in the match. +func bracketMatchStartedOrDecided(bm *state.BracketMatch) bool { return bm.Status == state.MatchStatusRunning || bm.Status == state.MatchStatusCompleted || - bm.Winner != "" || - len(bm.SubResults) > 0 || - len(bm.IpponsA) > 0 || - len(bm.IpponsB) > 0 + bm.Winner != "" } // applyKachinukiMerge merges an incoming kachinuki bout log into the stored @@ -2507,11 +2548,11 @@ func (e *Engine) kachinukiRemainingRoster(compID, matchID string, comp *state.Co // match-scoped lineup first, else the round-scoped one for roundIdx, per // state.FindBestLineupAny's tiers. The resolver answers false when the side // has no saved lineup, including when the lineups could not be loaded (the -// error is logged under the name of its one caller, kachinukiRemainingRoster). +// error is logged). func (e *Engine) lineupInForce(compID, matchID string, comp *state.Competition, roundIdx int) func(teamName string) (domain.TeamLineup, bool) { lineups, err := e.store.LoadTeamLineups(compID) if err != nil { - log.Printf("engine.kachinukiRemainingRoster compId=%s matchId=%s: lineup load error: %v; resolving no lineup", compID, matchID, err) + log.Printf("engine.lineupInForce compId=%s matchId=%s: lineup load error: %v; resolving no lineup", compID, matchID, err) lineups = nil } @@ -2524,7 +2565,7 @@ func (e *Engine) lineupInForce(compID, matchID string, comp *state.Competition, if len(lineups) > 0 { participants, err = e.store.LoadParticipants(compID, comp.EffectiveWithZekkenName()) if err != nil { - log.Printf("engine.kachinukiRemainingRoster compId=%s matchId=%s: participant load error: %v; lineup lookup degrades to name-only", compID, matchID, err) + log.Printf("engine.lineupInForce compId=%s matchId=%s: participant load error: %v; lineup lookup degrades to name-only", compID, matchID, err) participants = nil } } diff --git a/internal/engine/pool_requalify_test.go b/internal/engine/pool_requalify_test.go index 591c95ba1..7f167a335 100644 --- a/internal/engine/pool_requalify_test.go +++ b/internal/engine/pool_requalify_test.go @@ -200,7 +200,7 @@ func TestRequalify_FirstSecondSwap_WarnsThenReopensAndRepaints(t *testing.T) { got := findBracketMatchInBracket(b, m1.ID) assert.Equal(t, state.MatchStatusScheduled, got.Status) assert.Empty(t, got.Winner) - assert.Empty(t, got.IpponsA) + assert.Equal(t, []string{"M"}, got.IpponsA, "its points are kept (operator ruling 2026-09-26)") n, id := sideOf(*got, s1) assert.Equal(t, "A2", n) assert.Equal(t, rqID("A2"), id) diff --git a/internal/engine/queued_downstream_reseat_test.go b/internal/engine/queued_downstream_reseat_test.go new file mode 100644 index 000000000..479e274ae --- /dev/null +++ b/internal/engine/queued_downstream_reseat_test.go @@ -0,0 +1,69 @@ +package engine + +import ( + "testing" + + "github.com/gitrgoliveira/bracket-creator/internal/state" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// bc-sbq (operator ruling 2026-09-26): a later knockout match sent back to the +// queue keeps its score. When the earlier match that feeds it is corrected so +// a different competitor goes through, the later match takes the new name and +// KEEPS its points; nothing is asked when it is started, the operator clears +// or finishes it themselves. A queued match is not "fought": only running and +// completed ones are. + +// seedQueuedFinal is a two-round knockout: m-r1-0 decided (by decision, so a +// reopen is allowed), and the final queued with a point kept on Alice's side. +func seedQueuedFinal(t *testing.T, store *state.Store, compID, decision string) { + t.Helper() + require.NoError(t, store.SaveCompetition(&state.Competition{ID: compID, Name: compID, Status: state.CompStatusKnockout})) + require.NoError(t, store.SaveBracket(compID, &state.Bracket{Rounds: [][]state.BracketMatch{ + {{ID: "m-r1-0", SideA: "Alice", SideB: "Bob", SideAID: "alice", SideBID: "bob", + Winner: "Alice", WinnerID: "alice", Status: state.MatchStatusCompleted, IpponsA: []string{"M", "M"}, + Decision: decision, MatchNumber: 1, DisplayRound: 2}}, + {{ID: "m-r2-0", SideA: "Alice", SideB: "Charlie", SideAID: "alice", SideBID: "charlie", + Status: state.MatchStatusScheduled, IpponsA: []string{"M"}, HansokuB: 1, + MatchNumber: 2, DisplayRound: 1}}, + }})) +} + +func TestCorrection_ReseatsAQueuedLaterMatchKeepingItsPoints(t *testing.T) { + eng, store, _ := setupTestEngine(t) + const compID = "queued-final-correction" + seedQueuedFinal(t, store, compID, "") + + _, err := eng.RecordMatchResultWithIneligibility(compID, "m-r1-0", &state.MatchResult{ + SideA: "Alice", SideB: "Bob", Winner: "Bob", IpponsA: []string{}, IpponsB: []string{"M", "M"}, + Status: state.MatchStatusCompleted, CorrectionReason: "wrong winner", + }) + require.NoError(t, err) + + b, err := store.LoadBracket(compID) + require.NoError(t, err) + final := b.Rounds[1][0] + assert.Equal(t, "Bob", final.SideA, "the new name goes through") + assert.Equal(t, "bob", final.SideAID) + assert.Equal(t, state.MatchStatusScheduled, final.Status, "still waiting in the queue") + assert.Equal(t, []string{"M"}, final.IpponsA, "its kept point stays") + assert.Equal(t, 1, final.HansokuB, "its kept penalty stays") +} + +func TestReopen_UnseatsFromAQueuedLaterMatchKeepingItsPoints(t *testing.T) { + eng, store, _ := setupTestEngine(t) + const compID = "queued-final-reopen" + seedQueuedFinal(t, store, compID, "kiken-voluntary") + + _, err := eng.ReopenMatch(compID, "m-r1-0", "wrong withdrawal") + require.NoError(t, err, "a queued later match holding kept points is not a fought one") + + b, err := store.LoadBracket(compID) + require.NoError(t, err) + final := b.Rounds[1][0] + assert.Equal(t, winnerOfPlaceholder(2, 0), final.SideA, "waits for the reopened match's winner") + assert.Empty(t, final.SideAID) + assert.Equal(t, []string{"M"}, final.IpponsA, "its kept point stays") + assert.Equal(t, 1, final.HansokuB, "its kept penalty stays") +} diff --git a/internal/engine/reopen_bye_unwind_test.go b/internal/engine/reopen_bye_unwind_test.go index 5dc206829..3f0263c0d 100644 --- a/internal/engine/reopen_bye_unwind_test.go +++ b/internal/engine/reopen_bye_unwind_test.go @@ -232,7 +232,7 @@ func TestCorrectionThroughAByeWarnsForAPlayedFinalThenReopensIt(t *testing.T) { assert.Equal(t, "P01", final.SideA) assert.Equal(t, state.MatchStatusScheduled, final.Status) assert.Empty(t, final.Winner) - assert.Empty(t, final.IpponsA) + assert.Equal(t, []string{"M"}, final.IpponsA, "its points are kept (operator ruling 2026-09-26)") } // The manual winner override is the same correction through another door. diff --git a/internal/engine/reopen_fusensho_status_test.go b/internal/engine/reopen_fusensho_status_test.go index 5c1f6c860..7d3c64438 100644 --- a/internal/engine/reopen_fusensho_status_test.go +++ b/internal/engine/reopen_fusensho_status_test.go @@ -119,3 +119,189 @@ func TestReopenMatch_FusenshoMatch_NoLongerBarred_GoesToRunning(t *testing.T) { assert.Equal(t, state.MatchStatusRunning, m1.Status, "Alice is no longer barred by anything, so this reopen behaves like any other") } + +// bc-kfup: a FUSENPAI chained onto an earlier bar (alreadyBarredRefusal) +// records the default loss and no CompetitorStatus of its own, the same +// shape as the match-level fusensho above. Reopening it restores nobody, so +// while the earlier bar holds it must go to SCHEDULED, not RUNNING. +func TestReopenMatch_ChainedFusenpai_StillBarred_GoesToScheduled(t *testing.T) { + eng, store, _ := setupTestEngine(t) + compID := "reopen-chained-fusenpai" + require.NoError(t, store.SaveCompetition(&state.Competition{ID: compID, Name: compID, Status: state.CompStatusPools})) + 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", 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) + require.NoError(t, err) + _, _, err = eng.RecordDecision(compID, "Pool A-1", "fusenpai", "aka", "did not appear", nil, false) + require.NoError(t, err) + + _, err = eng.ReopenMatch(compID, "Pool A-1", "") + require.NoError(t, err) + + matches, err := store.LoadPoolMatches(compID) + require.NoError(t, err) + for _, m := range matches { + if m.ID == "Pool A-1" { + assert.Equal(t, state.MatchStatusScheduled, m.Status, + "Alice is still barred by Pool A-0; RUNNING would strand the operator behind StartMatchTx") + assert.Empty(t, m.Decision) + } + } + statuses, err := store.LoadCompetitorStatus(compID) + require.NoError(t, err) + assert.False(t, statuses[aliceID].Eligible) + assert.Equal(t, "Pool A-0", statuses[aliceID].MatchID, "the reopen of the chained match leaves the Pool A-0 bar as it was") +} + +// An ordinary fusenpai (the loser's own bar, recorded by THIS match) still +// reopens to RUNNING: BarredSides ignores a status recorded by the match +// being checked, and the reopen restores it. +func TestReopenMatch_OrdinaryFusenpai_GoesToRunning(t *testing.T) { + eng, store, _ := setupTestEngine(t) + compID := "reopen-ordinary-fusenpai" + require.NoError(t, store.SaveCompetition(&state.Competition{ID: compID, Name: compID, 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"}, + })) + 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) + require.NoError(t, err) + + _, err = eng.ReopenMatch(compID, "Pool A-0", "") + require.NoError(t, err) + + matches, err := store.LoadPoolMatches(compID) + require.NoError(t, err) + require.Len(t, matches, 1) + assert.Equal(t, state.MatchStatusRunning, matches[0].Status) + statuses, err := store.LoadCompetitorStatus(compID) + require.NoError(t, err) + if st, ok := statuses[aliceID]; ok { + assert.True(t, st.Eligible, "the reopen restores the competitor this match barred") + } +} + +// bc-kfup review: one status is kept per competitor, and a chained fusenpai +// records none of its own. Clearing the match that DID record the bar must +// not un-bar a competitor who still has a withdrawal on record elsewhere: the +// bar moves to that match. Clearing that one too makes them eligible. +func TestReopenMatch_ClearingTheOriginMovesTheBarToAChainedFusenpai(t *testing.T) { + eng, store, _ := setupTestEngine(t) + compID := "reopen-origin-rebar" + require.NoError(t, store.SaveCompetition(&state.Competition{ID: compID, Name: compID, Status: state.CompStatusPools})) + 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", 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) + require.NoError(t, err) + _, _, err = eng.RecordDecision(compID, "Pool A-1", "fusenpai", "aka", "did not appear", nil, false) + require.NoError(t, err) + + restored, err := eng.ReopenMatch(compID, "Pool A-0", "") + require.NoError(t, err) + require.NotNil(t, restored) + statuses, err := store.LoadCompetitorStatus(compID) + require.NoError(t, err) + alice := statuses[aliceID] + assert.False(t, alice.Eligible, "Alice still has the Pool A-1 no-show on record") + assert.Equal(t, "Pool A-1", alice.MatchID, "the bar moves to the withdrawal still on record") + assert.Equal(t, "fusenpai at Pool A-1", alice.Reason) + assert.False(t, alice.Reinstateable) + matches, err := store.LoadPoolMatches(compID) + require.NoError(t, err) + for _, m := range matches { + if m.ID == "Pool A-0" { + assert.Equal(t, state.MatchStatusScheduled, m.Status, + "Alice is barred by Pool A-1 once the bar moves; RUNNING would strand the operator behind StartMatchTx") + } + } + + _, err = eng.ReopenMatch(compID, "Pool A-1", "") + require.NoError(t, err) + statuses, err = store.LoadCompetitorStatus(compID) + require.NoError(t, err) + assert.True(t, statuses[aliceID].Eligible, "with no withdrawal left on record, Alice can fight again") +} + +// A kiken on another match is never a bar to move to: a second kiken is +// refused while a bar stands (alreadyBarredRefusal), so one still on record +// beside a later bar was lifted before that bar was recorded. Here the +// operator reinstated Alice after an injury withdrawal; clearing a later +// withdrawal recorded by mistake must leave her able to fight, not bar her +// again from the withdrawal she was reinstated after. +func TestReopenMatch_ClearingAWithdrawalAfterAReinstatementLeavesThemEligible(t *testing.T) { + eng, store, _ := setupTestEngine(t) + compID := "reopen-after-reinstatement" + require.NoError(t, store.SaveCompetition(&state.Competition{ID: compID, Name: compID, Status: state.CompStatusPools})) + 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", 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) + 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) + require.NoError(t, err) + + _, err = eng.ReopenMatch(compID, "Pool A-1", "") + require.NoError(t, err) + + statuses, err := store.LoadCompetitorStatus(compID) + require.NoError(t, err) + assert.True(t, statuses[aliceID].Eligible, "Alice was reinstated; clearing the mistaken withdrawal must not bar her again") +} + +// standingWithdrawalOf reads bracket matches too, through the same +// bracketMatchAsResult projection and losingSide attribution a bracket +// decision is written with (winner and side ids, bc-brid). A row carrying +// neither ids nor a winner cannot be attributed, and is not a standing +// withdrawal (the re-bar then does not happen, as before it existed). +func TestStandingWithdrawalOf_ReadsBracketMatches(t *testing.T) { + _, store, _ := setupTestEngine(t) + compID := "standing-withdrawal-bracket" + require.NoError(t, store.SaveCompetition(&state.Competition{ID: compID, Name: compID})) + aliceID, bobID := helper.NewUUID4(), helper.NewUUID4() + require.NoError(t, store.SaveBracket(compID, &state.Bracket{Rounds: [][]state.BracketMatch{{ + {ID: "r1-m0", SideA: "Alice", SideAID: aliceID, SideB: "Bob", SideBID: bobID, + Status: state.MatchStatusCompleted, Decision: "fusenpai", DecisionBy: "aka", Winner: "Bob", WinnerID: bobID}, + {ID: "r1-m1", SideA: "Carol", SideB: "Dan", Status: state.MatchStatusCompleted, Decision: "fusenpai", DecisionBy: "aka"}, + }}})) + require.NoError(t, store.WithTransaction(compID, func(tx state.StoreTx) error { + st, ok := standingWithdrawalOf(tx, compID, aliceID, "Pool A-0") + require.True(t, ok, "a bracket fusenpai is a standing withdrawal") + assert.Equal(t, "r1-m0", st.MatchID) + assert.False(t, st.Eligible) + assert.Equal(t, "fusenpai at r1-m0", st.Reason) + _, ok = standingWithdrawalOf(tx, compID, aliceID, "r1-m0") + assert.False(t, ok, "the match being cleared is never its own standing withdrawal") + _, ok = standingWithdrawalOf(tx, compID, bobID, "") + assert.False(t, ok, "the winner is not barred") + return nil + })) +} diff --git a/internal/engine/reopen_withdrawal_test.go b/internal/engine/reopen_withdrawal_test.go index 9445c466c..ae3e73731 100644 --- a/internal/engine/reopen_withdrawal_test.go +++ b/internal/engine/reopen_withdrawal_test.go @@ -149,7 +149,7 @@ func TestReopenWithdrawal_KnockoutDownstreamIsWarnAndProceed(t *testing.T) { final := after.Rounds[1][0] assert.Equal(t, state.MatchStatusScheduled, final.Status, "the final is reopened for re-entry") assert.Empty(t, final.Winner) - assert.Empty(t, final.SubResults, "its bouts belonged to a pairing no longer in it") + require.Len(t, final.SubResults, 1, "its bouts are kept (operator ruling 2026-09-26): the operator removes a wrong one") assert.Equal(t, "Winner of r2-m0", final.SideA, "the retracted slot waits for this match again") assert.Equal(t, wrTeamC, final.SideB) } diff --git a/internal/engine/scoring.go b/internal/engine/scoring.go index 40fb8a5b9..25332a421 100644 --- a/internal/engine/scoring.go +++ b/internal/engine/scoring.go @@ -604,6 +604,14 @@ type ForceOptions struct { // false, or when nothing needed reopening). The caller uses it to // broadcast match_updated for each one, not just the corrected match. Reopened *[]ReopenedMatch + // StartOnly marks a write that only STARTS the match (the court console's + // Start match and Finish + Start Next, which have no score board of their + // own): it changes the status and keeps whatever score the stored match + // holds (keepQueuedScore). It exists because the wire cannot say "no score + // sent": such a write carries empty ippon arrays and zero counts, the same + // bytes as an operator clearing every mark on an editor board, which must + // still clear them. + StartOnly bool } // firstForceOptions returns the caller's ForceOptions, or the zero value @@ -2541,6 +2549,11 @@ func applyBracketMatchResult(bm *state.BracketMatch, result *state.MatchResult, bm.DecisionBy = result.DecisionBy bm.DecisionReason = result.DecisionReason bm.Encho = result.Encho + // Engi flags, as entered: a running engi write carries the flags so far + // (saved as entered, operator ruling 2026-09-26), and a rollback restores + // the snapshot's (bracketMatchAsResult). A completing engi write takes the + // engi seam instead, and a kendo match has none. + bm.FlagsA, bm.FlagsB = result.FlagsA, result.FlagsB // Set-if-non-empty on the FORWARD path: a client payload that omits either // field is inheriting stored context, not clearing it. Under restore the // snapshot is authoritative, so both are assigned straight through — an @@ -2702,9 +2715,9 @@ func (e *Engine) applyBracketResultIn(bracket *state.Bracket, compID, matchID st // slot through (operator ruling bc-kcdg #2: a bye must never block a // correction). // -// This is deliberately narrower than bracketMatchStartedOrScored -// (kachinuki.go), which asks whether a match has been touched at all, a bye -// completion included; the retractions no longer apply it to a bye, which +// This is deliberately narrower than bracketMatchStartedOrDecided +// (kachinuki.go), which asks whether a match is being or has been fought +// (running, completed, or carrying a winner), a bye completion included; the retractions no longer apply it to a bye, which // they unwind instead (propagatedDownstreamOf). The two predicates answer // different questions for different rules; do not merge them. // @@ -2714,10 +2727,9 @@ func (e *Engine) applyBracketResultIn(bracket *state.Bracket, compID, matchID st // // There is deliberately no exemption for a match this bead's own forced // correction has already dealt with. forceReopenDownstreamChain REQUEUES its -// downstream matches (reopenBracketMatch: reopened in place, no verdict, no -// marks), -// so they fail both arms naturally and the operator's re-entry is never -// refused. An earlier fix instead left them RUNNING and exempted them by their +// downstream matches (reopenDisplacedBracketMatch: reopened in place as +// scheduled, no verdict, its points kept), so they fail the closed +// precondition and the operator's re-entry is never refused. An earlier fix instead left them RUNNING and exempted them by their // ReopenPending flag, which had to be unpicked twice: the flag alone also // exempted a match PART WAY through its re-fight, so a second correction // silently repainted it while keeping the ippons already struck for the @@ -2800,7 +2812,7 @@ func winnerActuallyChanged(priorWinner, priorWinnerID string, bm *state.BracketM // reopened match had sent on (retractIntoUntouched); one already played is // asked about in its own turn, when the re-fought result propagates into it. // -// reopenBracketMatch, not requeueBracketMatch: the match was already played +// A reopen (reopenDisplacedBracketMatch), not requeueBracketMatch: the match was already played // and stays where it is, reopened in place, so the queue is left alone // (operator ruling 2026-09-19: "no changes in the queue necessary if the // matches were already played"). It carries its own audit note rather than @@ -2829,23 +2841,17 @@ func forceReopenDownstreamChain(bracket *state.Bracket, rIdx, mIdx int, correcte // qualified into it), so the two cannot drift on what "reopened because its // competitor was replaced" leaves behind. func reopenDisplacedBracketMatch(m *state.BracketMatch, reason string) ReopenedMatch { - // Captured before reopenBracketMatch clears it, for the eligibility + // Captured before the reopen clears it, for the eligibility // restore the caller runs once the bracket is saved // (restoreForceReopened). It cannot run here: this runs inside an // UpdateBracket callback, which on the bare store holds the // non-reentrant per-competition lock a status write would take again. priorDecision := m.Decision - // The Running placeholder here is immediately overridden to Scheduled - // below regardless of decision (this door never leaves a match - // RUNNING), so it needs none of reopenTargetStatus's fusensho-barred - // check -- that check exists only to pick RUNNING vs SCHEDULED, and this - // call site always ends up SCHEDULED either way. - reopenBracketMatch(m, reason, state.MatchStatusRunning) // A downstream reopen leaves the match SCHEDULED, where the kachinuki // reopen leaves it RUNNING, and the difference is who is standing at // the shiaijo. Kachinuki reopens the encounter the operator has this // second, to fight on; this one reopens a match that was played - // earlier and must be fought AGAIN, with nobody on the court yet. + // earlier, with nobody on the court yet. // // Running is not merely inaccurate here, it is unusable: a running // match holds its court, and a semifinal reopens BOTH the final and @@ -2860,30 +2866,36 @@ func reopenDisplacedBracketMatch(m *state.BracketMatch, reason string) ReopenedM // (operator ruling 2026-09-19, "no changes in the queue necessary if // the matches were already played"), it simply is not claimed as in // progress until someone starts it. - m.Status = state.MatchStatusScheduled - // The BOUT LOG goes too, which is the other place this parts company - // with the kachinuki reopen. That one keeps SubResults on purpose: - // the same two teams are still fighting and every bout already fought - // is a fact about them. Here the correction REPAINTS this match's - // side, so the bouts describe a pairing that is no longer in it, and - // leaving them behind files one team's bouts under the name of the - // team that replaced them. The operator is told this is what happens - // ("its recorded result, including its bouts, is cleared" -- the - // court-operator guide, and the confirm dialog says the same), so the - // record has to match the promise. // - // Engi flags go for the same reason: an engi bracket match carries - // its panel's flag counts, which belong to the pair that was in the - // slot. reopenBracketMatch leaves both alone because it serves - // kachinuki, where neither can be stale. - m.SubResults = nil - m.FlagsA = 0 - m.FlagsB = 0 + // The fight stays: what was struck, its bouts and its engi flags (operator + // ruling 2026-09-26: scores are never cleared, the operator removes a wrong + // mark). The correction puts a different competitor in this match's slot + // and discards only the verdict, exactly as a match still waiting in the + // queue keeps its points when the name in it changes. + reopenBracketMatchKeepingTheFight(m, reason, state.MatchStatusScheduled) + clearDaihyosenVerdict(m.SubResults) ref := bracketMatchRef(m) ref.PriorDecision = priorDecision return ref } +// clearDaihyosenVerdict drops the verdict a daihyosen row carries, its +// winner and a hantei mark, and keeps the points struck in it. The row +// settles the encounter on its own: the next write copies its winner to the +// match (deriveDaihyosenWinner), matching it through the row's own side +// names. After a correction those still name the competitor the correction +// took out, so the one put in their place would inherit a win they never +// fought. Only the verdict goes, exactly as at match level. +func clearDaihyosenVerdict(subs []state.SubMatchResult) { + for i := range subs { + if subs[i].Position != state.DaihyosenSubPosition { + continue + } + subs[i].Winner, subs[i].WinnerMemberID = "", "" + subs[i].IpponsA, subs[i].IpponsB = struckIppons(subs[i].IpponsA), struckIppons(subs[i].IpponsB) + } +} + // restoreForceReopened is the eligibility half of forceReopenDownstreamChain. // A downstream match that a withdrawal decided and that a confirmed reopen or // correction has just reopened no longer carries that withdrawal, so the @@ -2913,32 +2925,26 @@ func downstreamReopenReason(correctedID string) string { return fmt.Sprintf("reopened: the result of match %s was corrected", correctedID) } -// requeueBracketMatch normalises a bracket match to a CLEAN SCHEDULED match: -// no verdict, no scoreline, no provenance, no reopen flag. It is +// requeueBracketMatch sends a bracket match back to the queue: scheduled, no +// verdict, no provenance, no reopen flag, and its score KEPT (points, +// penalties, overtime, bouts, flags; operator ruling 2026-09-26, bc-sbq). It is // RevertMatchToQueue's bracket branch, extracted so the body has a name and a // doc rather than sitting inline in a closure. // // Sole caller today. bc-kcdg's forced correction briefly used it too, until // the operator ruled that a downstream match invalidated by a correction was // already played and should be reopened where it is, leaving the queue alone -// (see forceReopenDownstreamChain, which calls reopenBracketMatch instead). -// Keep the two distinct: requeue is "this match has not happened", reopen is -// "this match happened and must happen again". +// (see reopenDisplacedBracketMatch, which forceReopenDownstreamChain calls +// instead). Keep the two distinct: requeue is "this match is not finished, +// carry on from where it stopped", reopen is "this match finished and its +// verdict no longer stands". Both keep what was scored. func requeueBracketMatch(m *state.BracketMatch) { m.Status = state.MatchStatusScheduled m.Winner = "" m.WinnerID = "" // bc-brid: the verdict's id half, cleared with the name. - m.IpponsA = nil - m.IpponsB = nil - m.HansokuA = 0 - m.HansokuB = 0 m.Decision = "" m.DecisionBy = "" m.DecisionReason = "" - m.Encho = nil - m.SubResults = nil - m.FlagsA = 0 - m.FlagsB = 0 m.IsOverridden = false m.ResultSource = "" m.CorrectionReason = "" @@ -3485,8 +3491,9 @@ func (e *Engine) UpdateMatchTime(compId string, matchId string, scheduledAt stri } // RevertMatchToQueue reverts a running match back to the scheduled (queued) -// state, clearing any partial score so the bout can be restarted correctly. -// It is idempotent for already-scheduled matches (no-op success). Completed +// state. It keeps the match's score: a match sent back to the queue never +// loses what was entered, and starting it again carries on from it (the +// StartOnly write, keepQueuedScore). It is idempotent for already-scheduled matches (no-op success). Completed // matches return ErrMatchAlreadyCompleted (HTTP 409); the operator must use // the score editor to correct a recorded result instead. // @@ -3516,23 +3523,19 @@ func (e *Engine) RevertMatchToQueue(compId, matchId string) error { return nil } // Any non-completed match (running, or an already-scheduled match that - // still carries stale score/audit metadata from an earlier partial - // write) is normalised to a CLEAN scheduled match. Idempotent: a - // pristine scheduled match is left effectively unchanged. + // still carries a stale verdict or audit note from an earlier partial + // write) goes back to the queue WITH ITS SCORE: points, penalties, + // overtime, bouts, flags and rep-bout fighters stay (keepQueuedScore + // names them), and the operator removes a wrong mark themselves + // (operator ruling 2026-09-26, bc-sbq). Only the verdict and the + // audit notes go. Idempotent: a pristine scheduled match is left + // effectively unchanged. r.Status = state.MatchStatusScheduled r.Winner = "" r.WinnerID = "" - r.IpponsA = nil - r.IpponsB = nil - r.HansokuA = 0 - r.HansokuB = 0 r.Decision = "" r.DecisionBy = "" r.DecisionReason = "" - r.Encho = nil - r.SubResults = nil - r.FlagsA = 0 - r.FlagsB = 0 r.ResultSource = "" r.CorrectionReason = "" // ReopenPending is a match-level field a reopened result carries @@ -3541,11 +3544,6 @@ func (e *Engine) RevertMatchToQueue(compId, matchId string) error { // queue. reopenBracketMatch's doc names this mirror obligation on // RevertMatchToQueue. r.ReopenPending = false - // Rep-bout nominations name who fought a pool/league daihyosen; they are - // result data for that supplementary bout, so a requeued match must not - // keep them (bracket matches have no rep fields). - r.RepPlayerA = "" - r.RepPlayerB = "" // Revert fence, the same one the bracket branch sets // (requeueBracketMatch, mp-y3nk): a write stamped before the requeue, // such as an offline-queued score replayed afterwards, loses the @@ -3572,9 +3570,9 @@ func (e *Engine) RevertMatchToQueue(compId, matchId string) error { alreadyCompleted = true return } - // Same contract as the pool path: normalise any non-completed match to - // a clean scheduled match, clearing stale score/provenance/audit fields - // even if it was already scheduled. + // Same contract as the pool path: any non-completed match goes back + // to the queue with its score, losing only the verdict and the audit + // notes, even if it was already scheduled. requeueBracketMatch(m) }); err != nil { // Neither pool nor bracket holds this match: surface a typed diff --git a/internal/engine/scoring_test.go b/internal/engine/scoring_test.go index 04ea9c42d..703bcd458 100644 --- a/internal/engine/scoring_test.go +++ b/internal/engine/scoring_test.go @@ -1999,13 +1999,20 @@ func TestRevertMatchToQueue(t *testing.T) { compID := "revert-comp" require.NoError(t, store.SaveCompetition(&state.Competition{ID: compID, Name: "Revert Test"})) - t.Run("running pool match reverts to scheduled", func(t *testing.T) { + // bc-sbq (operator ruling 2026-09-26): sending a match back to the queue + // never clears its score. The operator removes a wrong mark themselves. + // What a running write can carry stays (points, penalties, overtime, + // bouts, flags, rep-bout fighters); the verdict and the audit notes go. + t.Run("running pool match reverts to scheduled and keeps its score", func(t *testing.T) { matches := []state.MatchResult{ { ID: "P1-run", SideA: "Alice", SideB: "Bob", Status: state.MatchStatusRunning, IpponsA: []string{"M"}, HansokuB: 1, + Encho: &state.EnchoMetadata{PeriodCount: 1}, + FlagsA: 2, + SubResults: []state.SubMatchResult{{Position: 1, SideA: "A1", SideB: "B1", IpponsA: []string{"K"}}}, Decision: "fought", ResultSource: "admin", CorrectionReason: "Scoring error: wrong waza", @@ -2022,25 +2029,29 @@ func TestRevertMatchToQueue(t *testing.T) { require.NoError(t, err) require.Len(t, updated, 1) assert.Equal(t, state.MatchStatusScheduled, updated[0].Status) + assert.Equal(t, []string{"M"}, updated[0].IpponsA, "the point stays") + assert.Equal(t, 1, updated[0].HansokuB, "the penalty stays") + require.NotNil(t, updated[0].Encho, "the overtime stays") + assert.Equal(t, 1, updated[0].Encho.PeriodCount) + assert.Equal(t, 2, updated[0].FlagsA, "the flags stay") + require.Len(t, updated[0].SubResults, 1, "the bouts stay") + assert.Equal(t, []string{"K"}, updated[0].SubResults[0].IpponsA) + assert.Equal(t, "Alice-rep", updated[0].RepPlayerA, "the rep-bout fighters stay") + assert.Equal(t, "Bob-rep", updated[0].RepPlayerB) + // The verdict and the audit notes do not describe a queued match. assert.Empty(t, updated[0].Winner) - assert.Nil(t, updated[0].IpponsA) - assert.Equal(t, 0, updated[0].HansokuB) assert.Empty(t, updated[0].Decision) - // Provenance/audit fields must be cleared so a requeued match carries - // no misleading result metadata. assert.Empty(t, updated[0].ResultSource) assert.Empty(t, updated[0].CorrectionReason) - assert.Empty(t, updated[0].RepPlayerA) - assert.Empty(t, updated[0].RepPlayerB) // Identity fields must be preserved assert.Equal(t, "Alice", updated[0].SideA) assert.Equal(t, "Bob", updated[0].SideB) }) - t.Run("already-scheduled match with stale metadata is cleaned", func(t *testing.T) { - // A scheduled match that still carries stale score/audit data from an - // earlier partial write must be normalised to a clean scheduled match, - // not left as-is. + t.Run("already-scheduled match with stale metadata loses the verdict, keeps the score", func(t *testing.T) { + // A scheduled match that still carries a stale verdict or audit note + // from an earlier partial write is normalised: those go, the score + // stays. matches := []state.MatchResult{ { ID: "P1-stale", SideA: "Ivan", SideB: "Judy", @@ -2066,18 +2077,21 @@ func TestRevertMatchToQueue(t *testing.T) { } assert.Equal(t, state.MatchStatusScheduled, m.Status) assert.Empty(t, m.Winner) - assert.Nil(t, m.IpponsA) + assert.Equal(t, []string{"K"}, m.IpponsA) assert.Empty(t, m.Decision) assert.Empty(t, m.ResultSource) assert.Equal(t, "Ivan", m.SideA) // identity preserved }) - t.Run("running bracket match reverts to scheduled, downstream untouched", func(t *testing.T) { + t.Run("running bracket match reverts to scheduled keeping its score, downstream untouched", func(t *testing.T) { bracket := &state.Bracket{ Rounds: [][]state.BracketMatch{ { {ID: "B-run", SideA: "Carol", SideB: "Dave", - Status: state.MatchStatusRunning, IpponsA: []string{"M"}, Decision: "fought"}, + Status: state.MatchStatusRunning, IpponsA: []string{"M"}, HansokuB: 1, + Encho: &state.EnchoMetadata{PeriodCount: 1}, FlagsB: 3, + SubResults: []state.SubMatchResult{{Position: 1, SideA: "C1", SideB: "D1", IpponsB: []string{"D"}}}, + Decision: "fought"}, {ID: "B-other", SideA: "Eve", SideB: "Frank", Status: state.MatchStatusScheduled}, }, @@ -2090,10 +2104,16 @@ func TestRevertMatchToQueue(t *testing.T) { updated, err := store.LoadBracket(compID) require.NoError(t, err) - assert.Equal(t, state.MatchStatusScheduled, updated.Rounds[0][0].Status) - assert.Empty(t, updated.Rounds[0][0].Winner) - assert.Nil(t, updated.Rounds[0][0].IpponsA) - assert.Empty(t, updated.Rounds[0][0].Decision) + requeued := updated.Rounds[0][0] + assert.Equal(t, state.MatchStatusScheduled, requeued.Status) + assert.Empty(t, requeued.Winner) + assert.Empty(t, requeued.Decision) + assert.Equal(t, []string{"M"}, requeued.IpponsA, "the point stays") + assert.Equal(t, 1, requeued.HansokuB, "the penalty stays") + require.NotNil(t, requeued.Encho, "the overtime stays") + assert.Equal(t, 3, requeued.FlagsB, "the flags stay") + require.Len(t, requeued.SubResults, 1, "the bouts stay") + assert.Equal(t, []string{"D"}, requeued.SubResults[0].IpponsB) assert.Equal(t, "Carol", updated.Rounds[0][0].SideA) assert.Equal(t, "Dave", updated.Rounds[0][0].SideB) // Downstream match must be untouched diff --git a/internal/engine/scoring_tx.go b/internal/engine/scoring_tx.go index 30be8af9a..0c73ce59c 100644 --- a/internal/engine/scoring_tx.go +++ b/internal/engine/scoring_tx.go @@ -113,7 +113,12 @@ func (e *Engine) RecordMatchResultWithIneligibilityTx(tx state.StoreTx, compID, // callers do, so gating on Status == completed would have silently stopped // stamping winners -- caught by TestWinnerIDInvariant_EveryWritePathStampsASideID). engiStartWrite := result.FlagsA == 0 && result.FlagsB == 0 && result.Status != state.MatchStatusCompleted - if comp != nil && comp.Engi && !engiStartWrite { + // A RUNNING write is not judged either: it carries the flags entered so + // far, which are saved as entered (operator ruling 2026-09-26) so a match + // sent back to the queue or switched away from keeps them, and a panel + // part-way through its count has no valid total yet. + engiRunningWrite := result.Status == state.MatchStatusRunning + if comp != nil && comp.Engi && !engiStartWrite && !engiRunningWrite { // A pool write in a mixed engi competition answers for the knockout // its pool feeds exactly as a kendo one does (below): prior is read // first, so a refusal can put the pool row back. Only a pool write in @@ -157,6 +162,9 @@ func (e *Engine) RecordMatchResultWithIneligibilityTx(tx state.StoreTx, compID, if err != nil { return nil, err } + if fo.StartOnly { + keepQueuedScore(prior, result) + } // Kachinuki bout logs merge BY POSITION rather than replace wholesale // (ACID: a client whose local log is behind the server must never @@ -317,11 +325,37 @@ func (e *Engine) RecordMatchResultWithIneligibilityTx(tx state.StoreTx, compID, return status, nil } +// keepQueuedScore makes a StartOnly write carry the score the stored match +// already holds, so starting a match never wipes it (operator ruling +// 2026-09-26, bc-sbq: a match sent back to the queue keeps its score, and +// starting it again carries on from there). What it copies is exactly what a +// running write can carry and RevertMatchToQueue keeps: points, penalties, +// overtime, bouts, flags and rep-bout fighters. +// +// Any stored status but completed: a court list a moment behind can show as +// scheduled a match another device has already started and scored, and the +// start must not wipe those points either. A completed match is left to the +// write as it always was. +func keepQueuedScore(prior, result *state.MatchResult) { + if prior.Status == state.MatchStatusCompleted { + return + } + result.IpponsA = append([]string(nil), prior.IpponsA...) + result.IpponsB = append([]string(nil), prior.IpponsB...) + result.HansokuA, result.HansokuB = prior.HansokuA, prior.HansokuB + // Deep copies: prior is also the rollback snapshot. + result.Encho = prior.Encho.Clone() + result.SubResults = state.CloneSubResults(prior.SubResults) + result.FlagsA, result.FlagsB = prior.FlagsA, prior.FlagsB + result.RepPlayerA, result.RepPlayerB = prior.RepPlayerA, prior.RepPlayerB +} + // refuseConcurrentWithdrawal is K3's pre-write half: for a withdrawal decision // it names the loser the write would record and refuses with // AlreadyIneligibleError when a different match has already made them -// ineligible (checkConcurrentIneligibility, the check recordDecisionTx makes -// before its own write). The loser is read off a scratch copy with the stored +// ineligible and the decision is a kiken (checkConcurrentIneligibility, the +// check recordDecisionTx makes before its own write; a fusenpai there chains +// onto the existing bar instead, alreadyBarredRefusal). The loser is read off a scratch copy with the stored // identity folded in by backfillMatchIdentity, the same fold the write // applies, so it is the loser the post-write check would name. A payload that // fold rejects, or whose losing side cannot be attributed, is left to the @@ -338,7 +372,7 @@ func (e *Engine) refuseConcurrentWithdrawal(tx state.StoreTx, compID, matchID st if !ok { return nil } - return e.checkConcurrentIneligibility(tx, compID, matchID, loserID, loserName) + return e.checkConcurrentIneligibility(tx, compID, matchID, result.Decision, loserID, loserName) } // restoreIfWithdrawalRemoved is the one statement of "the eligibility record @@ -915,7 +949,10 @@ func (e *Engine) recordDecisionTx(tx state.StoreTx, compID, matchID, decision, d // T105/CHK047: reject concurrent kiken, if the intended loser is // already ineligible from a *different* match, two operators are // trying to kiken the same player simultaneously. Return 409 so the - // second operator sees the conflict before any write happens. + // second operator sees the conflict before any write happens. A + // fusenpai against such a loser is not refused: it is the default loss + // that closes their remaining match (bc-kfup; alreadyBarredRefusal owns + // which decision is refused). // // Only kiken and fusenpai actually mark the loser ineligible; for // fusensho/daihyosen this check would surface a misleading @@ -937,7 +974,7 @@ func (e *Engine) recordDecisionTx(tx state.StoreTx, compID, matchID, decision, d loserID = sideAID } if domain.IsWithdrawalDecisionStr(decision) { - if cerr := e.checkConcurrentIneligibility(tx, compID, matchID, loserID, loserName); cerr != nil { + if cerr := e.checkConcurrentIneligibility(tx, compID, matchID, decision, loserID, loserName); cerr != nil { return nil, nil, cerr } } @@ -1085,15 +1122,7 @@ func (e *Engine) restoreEligibilityRecordedByMatch(tx state.StoreTx, compID, mat if st.MatchID != matchID || st.Eligible || playerID == keepPlayerID { continue } - // A fresh minimal status, not the stale record with Eligible - // flipped: Reason/Reinstateable describe why the player WAS - // ineligible, which no longer applies once restored. - restored := domain.CompetitorStatus{ - PlayerID: playerID, - Eligible: true, - MatchID: matchID, - RecordedAt: time.Now().UTC(), - } + restored := restoredStatus(tx, compID, playerID, matchID) if werr := tx.SetCompetitorStatus(compID, restored); werr != nil { log.Printf("engine: restoreEligibilityRecordedByMatch compId=%s matchId=%s: restoring playerId=%s: %v", compID, matchID, playerID, werr) continue @@ -1102,3 +1131,96 @@ func (e *Engine) restoreEligibilityRecordedByMatch(tx state.StoreTx, compID, mat } return last } + +// restoredStatus is what the restore writes for a competitor whose bar +// matchID recorded, once that withdrawal is removed: eligible again, unless a +// fusenpai chained onto the bar (bc-kfup) is still on record, which then +// carries it (standingWithdrawalOf). The reopen asks it too +// (reopenTargetStatusTx), so it judges the bars the restore will leave. +func restoredStatus(tx state.StoreTx, compID, playerID, matchID string) domain.CompetitorStatus { + if rebar, ok := standingWithdrawalOf(tx, compID, playerID, matchID); ok { + return rebar + } + // A fresh minimal status, not the stale record with Eligible flipped: + // Reason/Reinstateable describe why the player WAS ineligible, which no + // longer applies once restored. + return domain.CompetitorStatus{ + PlayerID: playerID, + Eligible: true, + MatchID: matchID, + RecordedAt: time.Now().UTC(), + } +} + +// standingWithdrawalOf finds a completed match OTHER than excludeMatchID that +// still records a fusenpai against playerID, and returns the status that +// fusenpai bars them with. One status is kept per competitor, and a fusenpai +// chained onto an earlier bar (bc-kfup, alreadyBarredRefusal) records none of +// its own, so when the match that DID record the bar is cleared, the bar moves +// to the fusenpai still on record instead of lapsing: the eligibility record +// follows the rulings on disk. +// +// Only a fusenpai, never a kiken: a second kiken is refused while a bar +// stands, so a kiken still on record beside the bar being cleared was lifted +// before that bar was recorded (the operator reinstated an injury withdrawal), +// and moving the bar onto it would undo the reinstatement. The one shape this +// cannot see is a fusenpai chained onto an injury bar that the operator +// reinstated afterwards: clearing a LATER withdrawal then moves the bar onto it. +// +// The loser is attributed by losingSide, pool and bracket alike (a bracket row through +// bracketMatchAsResult); a legacy row with neither a winner nor side ids +// cannot be attributed and is not counted. ok is false when there is none, or +// the matches cannot be read (logged; the caller then restores as before). +func standingWithdrawalOf(tx state.StoreTx, compID, playerID, excludeMatchID string) (domain.CompetitorStatus, bool) { + barsPlayer := func(r *state.MatchResult) bool { + if r.ID == excludeMatchID || r.Status != state.MatchStatusCompleted || r.Decision != string(domain.DecisionFusenpai) { + return false + } + id, _, ok := losingSide(r) + return ok && id == playerID + } + var found *state.MatchResult + pool, err := tx.LoadPoolMatches(compID) + if err != nil { + log.Printf("engine: standingWithdrawalOf compId=%s playerId=%s: LoadPoolMatches: %v", compID, playerID, err) + return domain.CompetitorStatus{}, false + } + for i := range pool { + if barsPlayer(&pool[i]) { + found = &pool[i] + break + } + } + if found == nil { + bracket, err := tx.LoadBracket(compID) + if err != nil { + log.Printf("engine: standingWithdrawalOf compId=%s playerId=%s: LoadBracket: %v", compID, playerID, err) + return domain.CompetitorStatus{}, false + } + if bracket != nil { + found = firstBracketResult(bracket, barsPlayer) + } + } + if found == nil { + return domain.CompetitorStatus{}, false + } + return withdrawalStatus(playerID, found.Decision, found.ID), true +} + +// firstBracketResult returns the first bracket match, round by round and then +// the 3rd-place match, whose result projection satisfies want, or nil. +func firstBracketResult(b *state.Bracket, want func(*state.MatchResult) bool) *state.MatchResult { + for r := range b.Rounds { + for i := range b.Rounds[r] { + if res := bracketMatchAsResult(&b.Rounds[r][i]); want(res) { + return res + } + } + } + if b.ThirdPlaceMatch != nil { + if res := bracketMatchAsResult(b.ThirdPlaceMatch); want(res) { + return res + } + } + return nil +} diff --git a/internal/engine/scoring_tx_internal_test.go b/internal/engine/scoring_tx_internal_test.go index 2aaa90145..b3a011b04 100644 --- a/internal/engine/scoring_tx_internal_test.go +++ b/internal/engine/scoring_tx_internal_test.go @@ -273,7 +273,7 @@ func TestCheckConcurrentIneligibilityTx_AlreadyIneligible(t *testing.T) { var txErr error _ = store.WithTransaction(compID, func(tx state.StoreTx) error { // "Alice" is the loser of a different match "Pool A-1". - txErr = eng.checkConcurrentIneligibility(tx, compID, "Pool A-1", "", "Alice") + txErr = eng.checkConcurrentIneligibility(tx, compID, "Pool A-1", "kiken-voluntary", "", "Alice") return nil }) require.Error(t, txErr) @@ -301,7 +301,7 @@ func TestCheckConcurrentIneligibilityTx_SameMatchAllowed(t *testing.T) { var txErr error _ = store.WithTransaction(compID, func(tx state.StoreTx) error { - txErr = eng.checkConcurrentIneligibility(tx, compID, "Pool A-0", "", "Alice") + txErr = eng.checkConcurrentIneligibility(tx, compID, "Pool A-0", "kiken-voluntary", "", "Alice") return nil }) require.NoError(t, txErr, "same-match ineligibility must be allowed (undo path)") @@ -316,7 +316,7 @@ func TestCheckConcurrentIneligibilityTx_EmptyLoser(t *testing.T) { var txErr error _ = store.WithTransaction(compID, func(tx state.StoreTx) error { - txErr = eng.checkConcurrentIneligibility(tx, compID, "M1", "", "") + txErr = eng.checkConcurrentIneligibility(tx, compID, "M1", "kiken-voluntary", "", "") return nil }) require.NoError(t, txErr) @@ -621,7 +621,7 @@ func TestCheckConcurrentIneligibilityTx_PlayerNotInPool(t *testing.T) { var txErr error _ = store.WithTransaction(compID, func(tx state.StoreTx) error { - txErr = eng.checkConcurrentIneligibility(tx, compID, "Pool A-0", "", "Unknown") + txErr = eng.checkConcurrentIneligibility(tx, compID, "Pool A-0", "kiken-voluntary", "", "Unknown") return nil }) require.NoError(t, txErr, "unknown player must not trigger an error (best-effort)") diff --git a/internal/engine/start_keeps_queued_score_test.go b/internal/engine/start_keeps_queued_score_test.go new file mode 100644 index 000000000..bc1f10834 --- /dev/null +++ b/internal/engine/start_keeps_queued_score_test.go @@ -0,0 +1,174 @@ +package engine + +import ( + "testing" + + "github.com/gitrgoliveira/bracket-creator/internal/state" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// bc-sbq (operator ruling 2026-09-26): a match sent back to the queue keeps +// its score, and starting it again carries on from there. The court console's +// Start match sends empty ippon arrays and zero counts (the wire has no "no +// score sent"), so a StartOnly write keeps the stored score instead of +// replacing it; an unflagged write with the same empty payload is an operator +// clearing every mark and still clears them. + +// startWrite is the payload the SPA's startPatch sends. +func startWrite() *state.MatchResult { + return &state.MatchResult{Status: state.MatchStatusRunning, IpponsA: []string{}, IpponsB: []string{}} +} + +func setupStartComp(t *testing.T, comp *state.Competition) (*Engine, *state.Store) { + t.Helper() + eng, store, _ := setupTestEngine(t) + require.NoError(t, store.SaveCompetition(comp)) + return eng, store +} + +func poolMatch(t *testing.T, store *state.Store, compID, id string) state.MatchResult { + t.Helper() + ms, err := store.LoadPoolMatches(compID) + require.NoError(t, err) + for _, m := range ms { + if m.ID == id { + return m + } + } + t.Fatalf("pool match %s not found", id) + return state.MatchResult{} +} + +func TestStartOnly_PoolIndividualKeepsTheQueuedScore(t *testing.T) { + const compID = "start-keeps-ind" + eng, store := setupStartComp(t, &state.Competition{ID: compID, Name: "Ind", Format: state.CompFormatMixed, Courts: []string{"A"}}) + require.NoError(t, store.SavePoolMatches(compID, []state.MatchResult{{ + ID: "Pool A-0", SideA: "Alice", SideB: "Bob", Court: "A", Status: state.MatchStatusScheduled, + IpponsA: []string{"M"}, HansokuB: 1, Encho: &state.EnchoMetadata{PeriodCount: 1}, + }})) + + _, err := eng.RecordMatchResultWithIneligibility(compID, "Pool A-0", startWrite(), ForceOptions{StartOnly: true}) + require.NoError(t, err) + + m := poolMatch(t, store, compID, "Pool A-0") + assert.Equal(t, state.MatchStatusRunning, m.Status) + assert.Equal(t, []string{"M"}, m.IpponsA, "the kept point is still there") + assert.Equal(t, 1, m.HansokuB, "the kept penalty is still there") + require.NotNil(t, m.Encho) + assert.Equal(t, 1, m.Encho.PeriodCount) +} + +func TestStartOnly_UnflaggedEmptyWriteStillClears(t *testing.T) { + // The editor's own Start sends its board. An operator who cleared every + // kept mark on it sends exactly the empty payload, and that must clear. + const compID = "start-unflagged-clears" + eng, store := setupStartComp(t, &state.Competition{ID: compID, Name: "Ind", Format: state.CompFormatMixed, Courts: []string{"A"}}) + require.NoError(t, store.SavePoolMatches(compID, []state.MatchResult{{ + ID: "Pool A-0", SideA: "Alice", SideB: "Bob", Court: "A", Status: state.MatchStatusScheduled, + IpponsA: []string{"M"}, HansokuB: 1, + }})) + + _, err := eng.RecordMatchResultWithIneligibility(compID, "Pool A-0", startWrite()) + require.NoError(t, err) + + m := poolMatch(t, store, compID, "Pool A-0") + assert.Equal(t, state.MatchStatusRunning, m.Status) + assert.Empty(t, m.IpponsA, "the operator's cleared board is their word") + assert.Equal(t, 0, m.HansokuB) +} + +func TestStartOnly_DoesNotWipeAMatchAlreadyRunning(t *testing.T) { + // A court list a moment behind shows the match as scheduled; another + // device has already started it and struck a point. + const compID = "start-keeps-running" + eng, store := setupStartComp(t, &state.Competition{ID: compID, Name: "Ind", Format: state.CompFormatMixed, Courts: []string{"A"}}) + require.NoError(t, store.SavePoolMatches(compID, []state.MatchResult{{ + ID: "Pool A-0", SideA: "Alice", SideB: "Bob", Court: "A", Status: state.MatchStatusRunning, + IpponsB: []string{"K"}, + }})) + + _, err := eng.RecordMatchResultWithIneligibility(compID, "Pool A-0", startWrite(), ForceOptions{StartOnly: true}) + require.NoError(t, err) + + assert.Equal(t, []string{"K"}, poolMatch(t, store, compID, "Pool A-0").IpponsB) +} + +func TestStartOnly_PoolTeamKeepsItsBouts(t *testing.T) { + const compID = "start-keeps-team" + eng, store := setupStartComp(t, &state.Competition{ID: compID, Name: "Teams", Format: state.CompFormatMixed, Courts: []string{"A"}, TeamSize: 3}) + require.NoError(t, store.SavePoolMatches(compID, []state.MatchResult{{ + ID: "Pool A-0", SideA: "Red", SideB: "White", Court: "A", Status: state.MatchStatusScheduled, + SubResults: []state.SubMatchResult{ + {Position: 1, SideA: "R1", SideB: "W1", IpponsA: []string{"M", "K"}, Winner: "R1", Decision: "fought"}, + {Position: 2, SideA: "R2", SideB: "W2", IpponsB: []string{"D"}}, + }, + }})) + + _, err := eng.RecordMatchResultWithIneligibility(compID, "Pool A-0", startWrite(), ForceOptions{StartOnly: true}) + require.NoError(t, err) + + m := poolMatch(t, store, compID, "Pool A-0") + assert.Equal(t, state.MatchStatusRunning, m.Status) + require.Len(t, m.SubResults, 2, "the fought bouts are still there") + assert.Equal(t, []string{"M", "K"}, m.SubResults[0].IpponsA) + assert.Equal(t, []string{"D"}, m.SubResults[1].IpponsB) +} + +func TestStartOnly_EngiKeepsItsFlags(t *testing.T) { + const compID = "start-keeps-engi" + eng, store := setupStartComp(t, &state.Competition{ID: compID, Name: "Engi", Format: state.CompFormatMixed, Courts: []string{"A"}, Engi: true}) + require.NoError(t, store.SavePoolMatches(compID, []state.MatchResult{{ + ID: "Pool A-0", SideA: "Pair One", SideB: "Pair Two", Court: "A", Status: state.MatchStatusScheduled, + FlagsA: 2, FlagsB: 1, + }})) + + _, err := eng.RecordMatchResultWithIneligibility(compID, "Pool A-0", startWrite(), ForceOptions{StartOnly: true}) + require.NoError(t, err) + + m := poolMatch(t, store, compID, "Pool A-0") + assert.Equal(t, state.MatchStatusRunning, m.Status) + assert.Equal(t, 2, m.FlagsA) + assert.Equal(t, 1, m.FlagsB) +} + +func TestStartOnly_BracketKeepsTheQueuedScore(t *testing.T) { + const compID = "start-keeps-ko" + eng, store := setupStartComp(t, &state.Competition{ID: compID, Name: "KO", Format: state.CompFormatKnockout, Courts: []string{"A"}, TeamSize: 3}) + require.NoError(t, store.SaveBracket(compID, &state.Bracket{Rounds: [][]state.BracketMatch{{ + {ID: "m-r1-0", SideA: "Carol", SideB: "Dave", Court: "A", Status: state.MatchStatusScheduled, + IpponsA: []string{"M"}, HansokuB: 1, + SubResults: []state.SubMatchResult{{Position: 1, SideA: "C1", SideB: "D1", IpponsA: []string{"M"}}}}, + }}})) + + _, err := eng.RecordMatchResultWithIneligibility(compID, "m-r1-0", startWrite(), ForceOptions{StartOnly: true}) + require.NoError(t, err) + + b, err := store.LoadBracket(compID) + require.NoError(t, err) + m := b.Rounds[0][0] + assert.Equal(t, state.MatchStatusRunning, m.Status) + assert.Equal(t, []string{"M"}, m.IpponsA, "the kept point is still there") + assert.Equal(t, 1, m.HansokuB, "the kept penalty is still there") + require.Len(t, m.SubResults, 1, "the kept bout is still there") +} + +func TestSendBackThenStart_RoundTripKeepsTheScore(t *testing.T) { + // The whole operator path: a running match is sent back to the queue and + // started again from the court console. + const compID = "requeue-roundtrip" + eng, store := setupStartComp(t, &state.Competition{ID: compID, Name: "Ind", Format: state.CompFormatMixed, Courts: []string{"A"}}) + require.NoError(t, store.SavePoolMatches(compID, []state.MatchResult{{ + ID: "Pool A-0", SideA: "Alice", SideB: "Bob", Court: "A", Status: state.MatchStatusRunning, + IpponsA: []string{"M"}, HansokuB: 1, + }})) + + require.NoError(t, eng.RevertMatchToQueue(compID, "Pool A-0")) + _, err := eng.RecordMatchResultWithIneligibility(compID, "Pool A-0", startWrite(), ForceOptions{StartOnly: true}) + require.NoError(t, err) + + m := poolMatch(t, store, compID, "Pool A-0") + assert.Equal(t, state.MatchStatusRunning, m.Status) + assert.Equal(t, []string{"M"}, m.IpponsA) + assert.Equal(t, 1, m.HansokuB) +} diff --git a/internal/mobileapp/deps.go b/internal/mobileapp/deps.go index 2e4bed31e..515688d38 100644 --- a/internal/mobileapp/deps.go +++ b/internal/mobileapp/deps.go @@ -58,9 +58,14 @@ type CompetitionStore interface { // backfill a score payload's sides/ids ahead of the request-boundary // legacy-hantei fold and the hantei mark-placement validation, both of // which run before the engine's own reconcileSides/backfillMatchIdentity - // backfill. sideAID/sideBID are always "" for a bracket match (it - // persists no ids). + // backfill. sideAID/sideBID are "" for a bracket side that is not a + // resolved competitor yet (a bye slot or an unresolved feeder). MatchSidesByID(compID, matchID string) (sideA, sideB, sideAID, sideBID string, found bool, err error) + // LoadCompetitorStatus returns the competition's eligibility records, + // keyed by participant id. Mirrors state.Store.LoadCompetitorStatus; a + // match write reads it to stamp the withdrawal it pushes + // (stampWithdrawnStatus). + LoadCompetitorStatus(compID string) (map[string]domain.CompetitorStatus, error) } // ScoringEngine is the consumer-boundary view of engine.Engine used by diff --git a/internal/mobileapp/deps_test.go b/internal/mobileapp/deps_test.go index 1c39aca42..29bdc5239 100644 --- a/internal/mobileapp/deps_test.go +++ b/internal/mobileapp/deps_test.go @@ -34,6 +34,10 @@ func (stubCompetitionStore) MatchSidesByID(string, string) (string, string, stri return "", "", "", "", false, nil } +func (stubCompetitionStore) LoadCompetitorStatus(string) (map[string]domain.CompetitorStatus, error) { + return nil, nil +} + // stubTournamentLoader is a no-op implementation of TournamentLoader. Same // rationale as stubCompetitionStore. type stubTournamentLoader struct{} diff --git a/internal/mobileapp/errors.go b/internal/mobileapp/errors.go index 6fab3da9c..85d7c409d 100644 --- a/internal/mobileapp/errors.go +++ b/internal/mobileapp/errors.go @@ -188,9 +188,9 @@ func barredCompetitorSentence(name, label, decision, opponent string) string { switch decision { case string(domain.DecisionKikenInjury): if opponent == "" { - return fmt.Sprintf("%s withdrew injured in %s. Reinstate %s if the doctor allows.", name, label, name) + return fmt.Sprintf("%s withdrew injured in %s. Reinstate %s if they can fight again.", name, label, name) } - return fmt.Sprintf("%s withdrew injured in %s. Reinstate %s if the doctor allows, or record the default win for %s.", name, label, name, opponent) + return fmt.Sprintf("%s withdrew injured in %s. Reinstate %s if they can fight again, or record the default win for %s.", name, label, name, opponent) case string(domain.DecisionFusenpai): if opponent == "" { return fmt.Sprintf("%s did not appear for %s and cannot fight again.", name, label) diff --git a/internal/mobileapp/handlers_decision.go b/internal/mobileapp/handlers_decision.go index e43d65b8e..0cd40eecd 100644 --- a/internal/mobileapp/handlers_decision.go +++ b/internal/mobileapp/handlers_decision.go @@ -40,7 +40,7 @@ type DecisionRequest struct { // /score carries (mp-y3nk). Sending it puts decision writes under the // timestamp last-write-wins guard instead of its unstamped bypass, and // gives a decision-completed match a recency the UI can order by: a match - // closed while still `scheduled` (the withdrawal panel's default win) was + // closed while still `scheduled` (a queue row's Record default win) was // otherwise the one completion that carried no time at all (mp-jnvl). ModifiedAt int64 `json:"modifiedAt,omitempty"` // ForceDownstreamReopen bypasses bc-kcdg's downstream-knockout-correction @@ -243,6 +243,7 @@ func RegisterDecisionHandlers(r *gin.RouterGroup, eng ScoringEngine, store Compe return } + stampWithdrawnStatus(store, id, result) hub.Broadcast(EventMatchUpdated, gin.H{ "competitionId": id, "matchId": mid, diff --git a/internal/mobileapp/handlers_match.go b/internal/mobileapp/handlers_match.go index 4324e9674..4040e720e 100644 --- a/internal/mobileapp/handlers_match.go +++ b/internal/mobileapp/handlers_match.go @@ -201,19 +201,27 @@ func annotateBracketQueuePositions(b *state.Bracket) { } } -// anyScheduledMatchHasBothSides reports whether poolMatches or bracket carry -// at least one SCHEDULED match with both side ids stamped -- the ONLY shape -// annotateIneligibleSides can ever act on (engine.BarredSides needs an id to -// look anyone up). Gates the LoadCompetitorStatus read in the viewer -// handlers behind it, so a competition with no scheduled match yet (or one -// whose scheduled rows are all byes/unresolved feeders) never pays for a -// status-file read it cannot use. -func anyScheduledMatchHasBothSides(poolMatches []state.MatchResult, bracket *state.Bracket) bool { - hasBoth := func(status state.MatchStatus, idA, idB string) bool { - return status == state.MatchStatusScheduled && idA != "" && idB != "" +// anyMatchToAnnotate reports whether poolMatches or bracket carry a match +// annotateEligibility can act on: a SCHEDULED match with both side ids +// stamped (its ineligibleSides stamp; engine.BarredSides needs an id to look +// anyone up), or a COMPLETED match a withdrawal or default win decided (its +// withdrawnStatus stamp, which the score editor words its clear from, so it +// must be there once every match is finished too). Gates the +// LoadCompetitorStatus read in the viewer handlers behind it, so a +// competition with neither never pays for a status-file read it cannot use. +func anyMatchToAnnotate(poolMatches []state.MatchResult, bracket *state.Bracket) bool { + acts := func(status state.MatchStatus, decision, idA, idB string) bool { + switch status { + case state.MatchStatusScheduled: + return idA != "" && idB != "" + case state.MatchStatusCompleted: + return domain.IsDefaultWinDecisionStr(decision) + } + return false } for i := range poolMatches { - if hasBoth(poolMatches[i].Status, poolMatches[i].SideAID, poolMatches[i].SideBID) { + m := &poolMatches[i] + if acts(m.Status, m.Decision, m.SideAID, m.SideBID) { return true } } @@ -223,22 +231,26 @@ func anyScheduledMatchHasBothSides(poolMatches []state.MatchResult, bracket *sta for ri := range bracket.Rounds { for mi := range bracket.Rounds[ri] { bm := &bracket.Rounds[ri][mi] - if hasBoth(bm.Status, bm.SideAID, bm.SideBID) { + if acts(bm.Status, bm.Decision, bm.SideAID, bm.SideBID) { return true } } } - if bm := bracket.ThirdPlaceMatch; bm != nil && hasBoth(bm.Status, bm.SideAID, bm.SideBID) { + if bm := bracket.ThirdPlaceMatch; bm != nil && acts(bm.Status, bm.Decision, bm.SideAID, bm.SideBID) { return true } return false } -// annotateIneligibleSides stamps state.IneligibleSidesAnnotation (bc-cse) on +// annotateEligibility stamps state.IneligibleSidesAnnotation (bc-cse) on // every SCHEDULED pool match and bracket match whose resolved side ids are // currently barred by a withdrawal recorded on a DIFFERENT match // (engine.BarredSides, the SAME check StartMatchTx gates a write on), so a -// match LIST can grey/skip the row without attempting the write first. +// match LIST can grey/skip the row without attempting the write first. On a +// COMPLETED match a withdrawal or default win decided it stamps +// state.WithdrawnStatusAnnotation instead: where the withdrawn side's +// competitor status stands now, which the editor's clear control words its +// consequence from. // // Read-only, request-time only, exactly like annotateQueuePositions: called // on the copy a viewer endpoint is about to serve, NEVER on a slice/bracket @@ -247,7 +259,7 @@ func anyScheduledMatchHasBothSides(poolMatches []state.MatchResult, bracket *sta // models.go). MUST run BEFORE annotateQueuePositions/ // annotateBracketQueuePositions, which read the stamp back to decide // whether a barred match counts toward the queue (position 0 for one). -func annotateIneligibleSides(poolMatches []state.MatchResult, bracket *state.Bracket, statuses map[string]domain.CompetitorStatus) { +func annotateEligibility(poolMatches []state.MatchResult, bracket *state.Bracket, statuses map[string]domain.CompetitorStatus) { if len(statuses) == 0 { return } @@ -289,22 +301,69 @@ func annotateIneligibleSides(poolMatches []state.MatchResult, bracket *state.Bra } return out } + // The withdrawn side is the one decisionBy names, else the side that did + // not win (withdrawnSideKey, ineligible_match.jsx, reads it the same way). + withdrawn := func(status state.MatchStatus, decision, decisionBy, winnerID, sideAID, sideBID string) *state.WithdrawnStatusAnnotation { + if status != state.MatchStatusCompleted || !domain.IsDefaultWinDecisionStr(decision) { + return nil + } + var id string + switch { + case decisionBy == "aka": + id = sideAID + case decisionBy == "shiro": + id = sideBID + case winnerID != "" && winnerID == sideAID: + id = sideBID + case winnerID != "" && winnerID == sideBID: + id = sideAID + } + st, ok := statuses[id] + if id == "" || !ok { + return nil + } + return &state.WithdrawnStatusAnnotation{Eligible: st.Eligible, MatchID: st.MatchID, Reinstateable: st.Reinstateable} + } for i := range poolMatches { m := &poolMatches[i] m.IneligibleSides = stamp(m.Status, m.ID, m.SideAID, m.SideBID) + m.WithdrawnStatus = withdrawn(m.Status, m.Decision, m.DecisionBy, m.WinnerID, m.SideAID, m.SideBID) } if bracket == nil { return } + stampBracket := func(bm *state.BracketMatch) { + bm.IneligibleSides = stamp(bm.Status, bm.ID, bm.SideAID, bm.SideBID) + bm.WithdrawnStatus = withdrawn(bm.Status, bm.Decision, bm.DecisionBy, bm.WinnerID, bm.SideAID, bm.SideBID) + } for ri := range bracket.Rounds { for mi := range bracket.Rounds[ri] { - bm := &bracket.Rounds[ri][mi] - bm.IneligibleSides = stamp(bm.Status, bm.ID, bm.SideAID, bm.SideBID) + stampBracket(&bracket.Rounds[ri][mi]) } } if bm := bracket.ThirdPlaceMatch; bm != nil { - bm.IneligibleSides = stamp(bm.Status, bm.ID, bm.SideAID, bm.SideBID) + stampBracket(bm) + } +} + +// stampWithdrawnStatus gives a match write's result, before it is pushed, the +// withdrawnStatus stamp the viewer payloads carry (annotateEligibility). A +// host applies the push at once and refetches a moment later, so without it a +// score editor left on a withdrawal it had just recorded worded the clear from +// no stamp until the refetch landed. Only a completed withdrawal or default +// win carries the stamp, so only those pay for the status read. +func stampWithdrawnStatus(store CompetitionStore, compID string, r *state.MatchResult) { + if r.Status != state.MatchStatusCompleted || !domain.IsDefaultWinDecisionStr(r.Decision) { + return } + statuses, err := store.LoadCompetitorStatus(compID) + if err != nil { + log.Printf("mobileapp: push %s/%s: load competitor status: %v", compID, r.ID, err) + return + } + one := []state.MatchResult{*r} + annotateEligibility(one, nil, statuses) + r.WithdrawnStatus = one[0].WithdrawnStatus } // anyNumberedBoutHasEncho reports whether any NUMBERED sub-result (a real @@ -873,6 +932,9 @@ func RegisterMatchHandlers(r *gin.RouterGroup, eng *engine.Engine, store Competi } if len(successful) > 0 { + for i := range successful { + stampWithdrawnStatus(store, id, &successful[i]) + } hub.Broadcast(EventMatchUpdated, gin.H{ "competitionId": id, "results": matchesForBroadcast(successful), @@ -1168,8 +1230,8 @@ func RegisterMatchHandlers(r *gin.RouterGroup, eng *engine.Engine, store Competi }) // POST /competitions/:id/matches/:mid/revert-to-queue - // Reverts a running match back to the scheduled (queued) state, discarding - // any partial score so the operator can restart the correct bout. Idempotent + // Reverts a running match back to the scheduled (queued) state, keeping its + // score: starting it again carries on from it (bc-sbq). Idempotent // for already-scheduled matches. Completed matches return 409 (use the score // editor to correct a recorded result); an unknown match id returns 404. // @@ -1234,7 +1296,7 @@ func RegisterMatchHandlers(r *gin.RouterGroup, eng *engine.Engine, store Competi // 409 downstream_knockout_played (the same wire contract as a knockout // correction's, respondIfDownstreamKnockoutPlayed): the knockout match // this one fed already has a result of its own, and confirming reopens it - // too, to be fought again. The response then names every match that was + // too, its winner cleared and its points kept. The response then names every match that was // reopened that way ({"reopenedMatches": [{id, number, label}]}). // // The reason is OPTIONAL, and an absent body is equally @@ -1375,8 +1437,8 @@ func RegisterMatchHandlers(r *gin.RouterGroup, eng *engine.Engine, store Competi // between the two steps the old two-call client flow made. The blocker may // be in the same competition (competitions run across several courts, so a // sibling match can hold this one's court) or in a different one — the body - // carries whichever competition owns it. Destructive (the blocker loses any - // partial score), so it is main-password-gated in self-run mode via the + // carries whichever competition owns it. It reopens a recorded result (the + // blocker keeps its score), so it is main-password-gated in self-run mode via the // central allowlist, the same class as reopen/override-winner. // Body: {blockerCompId, blockerMatchId, reason?, forceDownstreamReopen?} — // reason and forceDownstreamReopen optional, exactly like reopen (an @@ -2327,6 +2389,10 @@ type scoreRequestBody struct { // (engine.requalifyAfterPoolWrite): confirmed, those knockout matches are // reopened and the new qualifier is seated. ForceDownstreamReopen bool `json:"forceDownstreamReopen"` + // StartOnly marks a write that only starts the match (the SPA's + // startPatch): the score the stored match holds is kept rather than + // replaced by the payload's empty one (engine.ForceOptions.StartOnly). + StartOnly bool `json:"startOnly"` } // scoreResponseWithReopened is the score write's reply: the stored @@ -2403,6 +2469,13 @@ func registerScoreHandler(r *gin.RouterGroup, eng ScoringEngine, store Competiti return } req := body.ScoreRequest + // startOnly keeps the stored score in place of the payload's, after + // the payload is validated, so on anything but a start the verdict + // would rest on a scoreline nothing checked it against. + if body.StartOnly && req.Status != state.MatchStatusRunning { + c.JSON(http.StatusBadRequest, gin.H{"error": "startOnly is only for a write that starts the match (status running)"}) + return + } // Kachinuki exception (mp-gmcg): a tied kachinuki pairing may be // fought on in overtime on that same bout, in ANY phase — whether the // final pairing must produce a result is operator discretion @@ -2674,8 +2747,9 @@ func registerScoreHandler(r *gin.RouterGroup, eng ScoringEngine, store Competiti } } engStatus, engErr = eng.RecordMatchResultWithIneligibilityTx(stx, id, mid, result, engine.ForceOptions{ - Force: body.ForceDownstreamReopen, - Reopened: &reopenedDownstream, + Force: body.ForceDownstreamReopen, + Reopened: &reopenedDownstream, + StartOnly: body.StartOnly, }) // engErr is a normal application-level signal (AlreadyIneligible // → 409, validation/not-found → other codes); we surface it @@ -2874,6 +2948,7 @@ func registerScoreHandler(r *gin.RouterGroup, eng ScoringEngine, store Competiti runningRevStore.Delete(matchKey) } if coalescer.Allow(matchKey, isRunning) { + stampWithdrawnStatus(store, id, result) hub.Broadcast(EventMatchUpdated, gin.H{ "competitionId": id, "matchId": mid, diff --git a/internal/mobileapp/handlers_match_kachinuki_test.go b/internal/mobileapp/handlers_match_kachinuki_test.go index c8bcbadef..e22e8e530 100644 --- a/internal/mobileapp/handlers_match_kachinuki_test.go +++ b/internal/mobileapp/handlers_match_kachinuki_test.go @@ -565,7 +565,10 @@ func TestScoreHandler_KachinukiEnchoFinalBoutPersists(t *testing.T) { // result (e.g. the taisho must be defeated) is OPERATOR DISCRETION — the // operator may fight a tied pool pairing on in overtime rather than accept // the draw, and the app must never hard-code that rule by phase (operator -// ruling superseding an earlier bracket-only scoping). +// ruling superseding an earlier bracket-only scoping). Nor by pairing: this is +// the first fighters' bout, and any tied pair may go to encho (operator ruling +// 2026-09-26: the operator decides how a match is run, the app records it; a +// taisho-only rule was tried the day before and reversed). func TestScoreHandler_KachinukiPoolBoutEnchoAccepted(t *testing.T) { compID := "kachinuki-pool-encho-accepted" r, store := setupKachinukiScoreServer(t, compID) @@ -1560,8 +1563,8 @@ func TestReopenHandler_DecisionEndsAReopenedBracketMatch(t *testing.T) { } // TestDecisionHandler_UnreopenedMatchNeedsNoReason: the ordinary kiken/fusenpai -// flow (by far the common case, and the one RemainingMatchesPanel drives in -// bulk) needs no reason, and a first finalization records no correction. +// flow (by far the common case) needs no reason, and a first finalization +// records no correction. func TestDecisionHandler_UnreopenedMatchNeedsNoReason(t *testing.T) { compID := "kachinuki-decision-no-reopen" r, store := setupKachinukiScoreServer(t, compID) diff --git a/internal/mobileapp/handlers_match_test.go b/internal/mobileapp/handlers_match_test.go index 88d501cf5..ebddac6ac 100644 --- a/internal/mobileapp/handlers_match_test.go +++ b/internal/mobileapp/handlers_match_test.go @@ -703,6 +703,10 @@ func (f *fixedSidesCompetitionStore) MatchSidesByID(string, string) (string, str return f.sideA, f.sideB, f.sideAID, f.sideBID, f.found, f.sidesErr } +func (fixedSidesCompetitionStore) LoadCompetitorStatus(string) (map[string]domain.CompetitorStatus, error) { + return nil, nil +} + // TestBackfillMatchIdentityForHantei is a table-driven unit test of the // merged helper (bc-qual + bc-dmsr reviews; this replaced two hand-copied // twins, backfillMatchLevelSidesForLegacyHantei which backfilled only @@ -2624,6 +2628,10 @@ func (f failingCompetitionStore) MatchSidesByID(string, string) (string, string, return "", "", "", "", false, f.err } +func (f failingCompetitionStore) LoadCompetitorStatus(string) (map[string]domain.CompetitorStatus, error) { + return nil, f.err +} + // TestAnnotateQueuePositions_NonEmpty verifies that annotateQueuePositions // fills in per-court queue positions for a non-empty match list. func TestAnnotateQueuePositions_NonEmpty(t *testing.T) { diff --git a/internal/mobileapp/handlers_viewer.go b/internal/mobileapp/handlers_viewer.go index 6b0d2fbf9..b5961c877 100644 --- a/internal/mobileapp/handlers_viewer.go +++ b/internal/mobileapp/handlers_viewer.go @@ -369,15 +369,15 @@ func buildViewerCompetitionPayload(store *state.Store, compID, courtFilter strin // bc-cse: stamp ineligibleSides BEFORE the queue-position derivation // below, which reads the stamp back to skip a barred scheduled match. - // Gated on anyScheduledMatchHasBothSides so a competition with nothing + // Gated on anyMatchToAnnotate so a competition with nothing // this annotation could ever act on never pays for the // LoadCompetitorStatus read. - if anyScheduledMatchHasBothSides(poolMatches, bracket) { + if anyMatchToAnnotate(poolMatches, bracket) { statuses, stErr := store.LoadCompetitorStatus(compID) if stErr != nil { log.Printf("mobileapp: viewer payload %s: load competitor status: %v", compID, stErr) } else { - annotateIneligibleSides(poolMatches, bracket, statuses) + annotateEligibility(poolMatches, bracket, statuses) } } @@ -790,13 +790,13 @@ func RegisterViewerHandlers(r *gin.RouterGroup, store *state.Store, eng *engine. // bc-cse: same gate and ordering as buildViewerCompetitionPayload's // own ineligibleSides stamp (this endpoint's sibling aggregate); - // see anyScheduledMatchHasBothSides for why the read is gated. - if anyScheduledMatchHasBothSides(poolMatches, bracket) { + // see anyMatchToAnnotate for why the read is gated. + if anyMatchToAnnotate(poolMatches, bracket) { statuses, stErr := store.LoadCompetitorStatus(id) if stErr != nil { log.Printf("mobileapp: viewer payload %s: load competitor status: %v", id, stErr) } else { - annotateIneligibleSides(poolMatches, bracket, statuses) + annotateEligibility(poolMatches, bracket, statuses) } } diff --git a/internal/mobileapp/ineligible_sides_annotation_test.go b/internal/mobileapp/ineligible_sides_annotation_test.go index 5ea15fc28..f4d7bb751 100644 --- a/internal/mobileapp/ineligible_sides_annotation_test.go +++ b/internal/mobileapp/ineligible_sides_annotation_test.go @@ -19,50 +19,67 @@ import ( "github.com/stretchr/testify/require" ) -func TestAnyScheduledMatchHasBothSides(t *testing.T) { +func TestAnyMatchToAnnotate(t *testing.T) { t.Run("no matches", func(t *testing.T) { - assert.False(t, anyScheduledMatchHasBothSides(nil, nil)) + assert.False(t, anyMatchToAnnotate(nil, nil)) }) t.Run("scheduled pool match with both ids", func(t *testing.T) { ms := []state.MatchResult{{Status: state.MatchStatusScheduled, SideAID: "a", SideBID: "b"}} - assert.True(t, anyScheduledMatchHasBothSides(ms, nil)) + assert.True(t, anyMatchToAnnotate(ms, nil)) }) t.Run("scheduled pool match missing one id", func(t *testing.T) { ms := []state.MatchResult{{Status: state.MatchStatusScheduled, SideAID: "a"}} - assert.False(t, anyScheduledMatchHasBothSides(ms, nil)) + assert.False(t, anyMatchToAnnotate(ms, nil)) }) t.Run("running match with both ids does not count", func(t *testing.T) { ms := []state.MatchResult{{Status: state.MatchStatusRunning, SideAID: "a", SideBID: "b"}} - assert.False(t, anyScheduledMatchHasBothSides(ms, nil)) + assert.False(t, anyMatchToAnnotate(ms, nil)) }) t.Run("bracket round match", func(t *testing.T) { b := &state.Bracket{Rounds: [][]state.BracketMatch{{ {Status: state.MatchStatusScheduled, SideAID: "a", SideBID: "b"}, }}} - assert.True(t, anyScheduledMatchHasBothSides(nil, b)) + assert.True(t, anyMatchToAnnotate(nil, b)) }) t.Run("bronze match", func(t *testing.T) { b := &state.Bracket{ThirdPlaceMatch: &state.BracketMatch{ Status: state.MatchStatusScheduled, SideAID: "a", SideBID: "b", }} - assert.True(t, anyScheduledMatchHasBothSides(nil, b)) + assert.True(t, anyMatchToAnnotate(nil, b)) + }) + // The withdrawnStatus stamp goes on a completed match a withdrawal or + // default win decided, which is all that is left once every match is + // finished. + t.Run("completed pool match a withdrawal decided", func(t *testing.T) { + ms := []state.MatchResult{{Status: state.MatchStatusCompleted, Decision: "fusenpai", SideAID: "a", SideBID: "b"}} + assert.True(t, anyMatchToAnnotate(ms, nil)) + }) + t.Run("completed bracket match a default win decided", func(t *testing.T) { + b := &state.Bracket{Rounds: [][]state.BracketMatch{{ + {Status: state.MatchStatusCompleted, Decision: "fusensho", SideAID: "a", SideBID: "b"}, + }}} + assert.True(t, anyMatchToAnnotate(nil, b)) + }) + t.Run("completed match that was fought does not count", func(t *testing.T) { + ms := []state.MatchResult{{Status: state.MatchStatusCompleted, Decision: "fought", SideAID: "a", SideBID: "b"}} + assert.False(t, anyMatchToAnnotate(ms, nil)) }) } -func TestAnnotateIneligibleSides(t *testing.T) { +func TestAnnotateEligibility(t *testing.T) { statuses := map[string]domain.CompetitorStatus{ "alice": {PlayerID: "alice", Eligible: false, MatchID: "Pool A-0", Reason: "kiken-voluntary at Pool A-0"}, } t.Run("no statuses: no-op, whatever the matches", func(t *testing.T) { ms := []state.MatchResult{{ID: "Pool A-1", Status: state.MatchStatusScheduled, SideAID: "alice", SideBID: "carol"}} - annotateIneligibleSides(ms, nil, nil) + annotateEligibility(ms, nil, nil) assert.Nil(t, ms[0].IneligibleSides) }) t.Run("scheduled match with a barred side A is stamped", func(t *testing.T) { ms := []state.MatchResult{{ID: "Pool A-1", Status: state.MatchStatusScheduled, SideAID: "alice", SideBID: "carol"}} - annotateIneligibleSides(ms, nil, statuses) + annotateEligibility(ms, nil, statuses) require.NotNil(t, ms[0].IneligibleSides) assert.Equal(t, "kiken-voluntary", ms[0].IneligibleSides.A) assert.Empty(t, ms[0].IneligibleSides.B) @@ -70,7 +87,7 @@ func TestAnnotateIneligibleSides(t *testing.T) { t.Run("scheduled match with a barred side B is stamped", func(t *testing.T) { ms := []state.MatchResult{{ID: "Pool A-1", Status: state.MatchStatusScheduled, SideAID: "carol", SideBID: "alice"}} - annotateIneligibleSides(ms, nil, statuses) + annotateEligibility(ms, nil, statuses) require.NotNil(t, ms[0].IneligibleSides) assert.Empty(t, ms[0].IneligibleSides.A) assert.Equal(t, "kiken-voluntary", ms[0].IneligibleSides.B) @@ -78,7 +95,7 @@ func TestAnnotateIneligibleSides(t *testing.T) { t.Run("the undo path: a match barred by ITS OWN prior decision is not stamped", func(t *testing.T) { ms := []state.MatchResult{{ID: "Pool A-0", Status: state.MatchStatusScheduled, SideAID: "alice", SideBID: "bob"}} - annotateIneligibleSides(ms, nil, statuses) + annotateEligibility(ms, nil, statuses) assert.Nil(t, ms[0].IneligibleSides, "Pool A-0 recorded alice's own status; it must not bar itself") }) @@ -87,14 +104,14 @@ func TestAnnotateIneligibleSides(t *testing.T) { {ID: "Pool A-2", Status: state.MatchStatusRunning, SideAID: "alice", SideBID: "carol"}, {ID: "Pool A-3", Status: state.MatchStatusCompleted, SideAID: "alice", SideBID: "carol"}, } - annotateIneligibleSides(ms, nil, statuses) + annotateEligibility(ms, nil, statuses) assert.Nil(t, ms[0].IneligibleSides) assert.Nil(t, ms[1].IneligibleSides) }) t.Run("no barred side: not stamped", func(t *testing.T) { ms := []state.MatchResult{{ID: "Pool A-4", Status: state.MatchStatusScheduled, SideAID: "bob", SideBID: "carol"}} - annotateIneligibleSides(ms, nil, statuses) + annotateEligibility(ms, nil, statuses) assert.Nil(t, ms[0].IneligibleSides) }) @@ -102,7 +119,7 @@ func TestAnnotateIneligibleSides(t *testing.T) { b := &state.Bracket{Rounds: [][]state.BracketMatch{{ {ID: "m-r1-0", Status: state.MatchStatusScheduled, SideAID: "alice", SideBID: "carol"}, }}} - annotateIneligibleSides(nil, b, statuses) + annotateEligibility(nil, b, statuses) require.NotNil(t, b.Rounds[0][0].IneligibleSides) assert.Equal(t, "kiken-voluntary", b.Rounds[0][0].IneligibleSides.A) }) @@ -111,7 +128,7 @@ func TestAnnotateIneligibleSides(t *testing.T) { b := &state.Bracket{ThirdPlaceMatch: &state.BracketMatch{ ID: "m-bronze", Status: state.MatchStatusScheduled, SideAID: "alice", SideBID: "carol", }} - annotateIneligibleSides(nil, b, statuses) + annotateEligibility(nil, b, statuses) require.NotNil(t, b.ThirdPlaceMatch.IneligibleSides) }) @@ -127,7 +144,7 @@ func TestAnnotateIneligibleSides(t *testing.T) { "dave": {PlayerID: "dave", Eligible: false, Reason: "disqualified for unsporting conduct"}, } ms := []state.MatchResult{{ID: "Pool A-9", Status: state.MatchStatusScheduled, SideAID: "dave", SideBID: "carol"}} - annotateIneligibleSides(ms, nil, freeTextStatuses) + annotateEligibility(ms, nil, freeTextStatuses) assert.Nil(t, ms[0].IneligibleSides, "an unparseable barring reason must leave the annotation nil, not an empty {}") }) @@ -137,7 +154,7 @@ func TestAnnotateIneligibleSides(t *testing.T) { "dave": {PlayerID: "dave", Eligible: false, Reason: "disqualified for unsporting conduct"}, } ms := []state.MatchResult{{ID: "Pool A-9", Status: state.MatchStatusScheduled, SideAID: "alice", SideBID: "dave"}} - annotateIneligibleSides(ms, nil, mixedStatuses) + annotateEligibility(ms, nil, mixedStatuses) require.NotNil(t, ms[0].IneligibleSides) assert.Equal(t, "kiken-voluntary", ms[0].IneligibleSides.A) assert.Empty(t, ms[0].IneligibleSides.B, "dave's reason does not parse, so his side is not stamped") @@ -244,3 +261,64 @@ func TestViewerCompetitionDetail_IneligibleSides_RemovedAfterReinstate(t *testin assert.Nil(t, after["ineligibleSides"], "the annotation must be gone once Alice is reinstated") assert.Equal(t, float64(1), after["queuePosition"], "the match rejoins the queue") } + +// The withdrawnStatus stamp must still be served once every match is +// finished. The score editor words a no-show's clear from it: without it, a +// no-show chained onto an earlier withdrawal read "goes back to running, can +// compete again" while the server sent the match back to the queue. +func TestViewerCompetitionDetail_WithdrawnStatus_AfterEveryMatchIsFinished(t *testing.T) { + store, err := state.NewStore(t.TempDir()) + require.NoError(t, err) + eng := engine.New(store) + + // A league: nothing follows its table, so once its last match is played + // no scheduled match is left anywhere. (A mixed competition's knockout + // would still hold scheduled matches here; the same holds for it once the + // final is played.) + compID := "withdrawn-status-all-finished" + require.NoError(t, store.SaveCompetition(&state.Competition{ + ID: compID, Format: state.CompFormatLeague, Status: state.CompStatusPools, + })) + 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", SideB: "Bob", SideAID: aliceID, SideBID: bobID, Court: "A", Status: state.MatchStatusScheduled}, + {ID: "Pool A-1", SideA: "Alice", SideB: "Carol", SideAID: aliceID, SideBID: carolID, Court: "A", Status: state.MatchStatusScheduled}, + {ID: "Pool A-2", SideA: "Bob", SideB: "Carol", SideAID: bobID, SideBID: carolID, Court: "A", Status: state.MatchStatusCompleted, + Decision: "fought", Winner: "Bob", WinnerID: bobID, IpponsA: []string{"M"}}, + })) + // 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) + require.NoError(t, err) + _, _, err = eng.RecordDecision(compID, "Pool A-1", "fusenpai", "aka", "", nil, false) + require.NoError(t, err) + + gin.SetMode(gin.TestMode) + r := gin.New() + RegisterViewerHandlers(r.Group("/api/viewer"), store, eng) + w := httptest.NewRecorder() + req, err := http.NewRequest(http.MethodGet, "/api/viewer/competitions/"+compID, nil) + require.NoError(t, err) + r.ServeHTTP(w, req) + require.Equal(t, http.StatusOK, w.Code, w.Body.String()) + var body map[string]any + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body)) + + var noShow map[string]any + for _, raw := range body["poolMatches"].([]any) { + if m := raw.(map[string]any); m["id"] == "Pool A-1" { + noShow = m + } + } + require.NotNil(t, noShow) + require.Equal(t, "completed", noShow["status"], "precondition: no match is left to play") + stamp, ok := noShow["withdrawnStatus"].(map[string]any) + require.True(t, ok, "the no-show's match carries withdrawnStatus, got %+v", noShow) + assert.Equal(t, false, stamp["eligible"]) + assert.Equal(t, "Pool A-0", stamp["matchId"], "the bar is the withdrawal's, not the no-show's") +} diff --git a/internal/mobileapp/kachinuki_cleared_bout_test.go b/internal/mobileapp/kachinuki_cleared_bout_test.go new file mode 100644 index 000000000..4bacf277f --- /dev/null +++ b/internal/mobileapp/kachinuki_cleared_bout_test.go @@ -0,0 +1,64 @@ +package mobileapp + +import ( + "net/http" + "testing" + + "github.com/gitrgoliveira/bracket-creator/internal/state" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// bc-kclr: the server half of taking a mark back on a kachinuki bout. The +// merge (engine.mergeKachinukiSubResults) keeps any stored row a payload +// omits, so the editor now sends a bout the operator cleared as an explicit +// empty row (kachinukiRowCleared, admin_scoring_team.jsx). These pin that such +// a row is stored as cleared, on a running write and on End match. + +func TestScoreHandler_KachinukiClearedBoutIsStored(t *testing.T) { + compID := "kachinuki-cleared-bout" + r, store := setupKachinukiScoreServer(t, compID) + require.NoError(t, store.SavePoolMatches(compID, []state.MatchResult{{ + ID: "P1-0", SideA: "Ryu", SideB: "Tora", Status: state.MatchStatusRunning, + SubResults: []state.SubMatchResult{{Position: 1, SideA: "R-1", SideB: "W-1", IpponsA: []string{"M"}}}, + }})) + + w := putScore(t, r, compID, "P1-0", map[string]any{ + "sideA": "Ryu", "sideB": "Tora", "status": "running", + "subResults": []map[string]any{kachinukiSub(1, "R-1", "W-1", []string{}, "", "")}, + }) + require.Equal(t, http.StatusOK, w.Code, w.Body.String()) + + m := loadPoolMatch(t, store, compID, "P1-0") + require.Len(t, m.SubResults, 1) + assert.Empty(t, m.SubResults[0].IpponsA, "the point taken back must not survive the write") + assert.Equal(t, "R-1", m.SubResults[0].SideA, "the pairing stays") +} + +func TestScoreHandler_KachinukiEndAfterClearingTheCurrentBout(t *testing.T) { + compID := "kachinuki-end-after-clear" + r, store := setupKachinukiScoreServer(t, compID) + require.NoError(t, store.SavePoolMatches(compID, []state.MatchResult{{ + ID: "P1-0", SideA: "Ryu", SideB: "Tora", Status: state.MatchStatusRunning, + SubResults: []state.SubMatchResult{ + {Position: 1, SideA: "R-1", SideB: "W-1", IpponsA: []string{"M", "K"}, Winner: "R-1", Decision: "fought"}, + // A stray point on the current bout, autosaved, then taken back. + {Position: 2, SideA: "R-1", SideB: "W-2", IpponsB: []string{"K"}}, + }, + }})) + + w := putScore(t, r, compID, "P1-0", map[string]any{ + "sideA": "Ryu", "sideB": "Tora", "winner": "Ryu", "status": "completed", "decision": "kachinuki-exhaustion", + "subResults": []map[string]any{ + kachinukiSub(1, "R-1", "W-1", []string{"M", "K"}, "R-1", "fought"), + kachinukiSub(2, "R-1", "W-2", []string{}, "", ""), + }, + }) + require.Equal(t, http.StatusOK, w.Code, w.Body.String()) + + m := loadPoolMatch(t, store, compID, "P1-0") + assert.Equal(t, state.MatchStatusCompleted, m.Status) + assert.Equal(t, "Ryu", m.Winner) + require.Len(t, m.SubResults, 1, "the cleared current bout is an unscored trailing row, stripped on End match") + assert.Equal(t, 1, m.SubResults[0].Position) +} diff --git a/internal/mobileapp/reason_human_barred_test.go b/internal/mobileapp/reason_human_barred_test.go index e5a0d5454..e27b94356 100644 --- a/internal/mobileapp/reason_human_barred_test.go +++ b/internal/mobileapp/reason_human_barred_test.go @@ -108,7 +108,7 @@ func TestScoreHandler_IneligibleCompetitor_ReasonHumanNamesMatchAndRemedy(t *tes }, { "kiken-injury", "kiken-injury", - "Alice withdrew injured in Pool A · Match 1. Reinstate Alice if the doctor allows, or record the default win for Carol.", + "Alice withdrew injured in Pool A · Match 1. Reinstate Alice if they can fight again, or record the default win for Carol.", }, { "fusenpai", "fusenpai", diff --git a/internal/mobileapp/start_keeps_queued_score_test.go b/internal/mobileapp/start_keeps_queued_score_test.go new file mode 100644 index 000000000..853b31a19 --- /dev/null +++ b/internal/mobileapp/start_keeps_queued_score_test.go @@ -0,0 +1,68 @@ +package mobileapp + +import ( + "net/http" + "testing" + + "github.com/gitrgoliveira/bracket-creator/internal/state" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// bc-sbq: the SPA's startPatch sends startOnly and no scoreline, and +// the /score handler must hand that flag to the engine so the stored score +// of a queued match survives its start (engine.keepQueuedScore). The same +// payload without the flag is an operator clearing every mark. +func TestScoreHandler_StartOnlyKeepsTheQueuedScore(t *testing.T) { + const compID = "start-only-wire" + r, store := setupKachinukiScoreServer(t, compID) + // An individual competition: a kachinuki bout log is kept by its own + // merge, so it could not tell whether the flag arrived. + require.NoError(t, store.SaveCompetition(&state.Competition{ID: compID, Format: state.CompFormatMixed, Status: state.CompStatusPools})) + require.NoError(t, store.SavePoolMatches(compID, []state.MatchResult{ + {ID: "P1-0", SideA: "Alice", SideB: "Bob", Status: state.MatchStatusScheduled, IpponsA: []string{"M"}, HansokuB: 1}, + {ID: "P1-1", SideA: "Carol", SideB: "Dave", Status: state.MatchStatusScheduled, IpponsA: []string{"K"}, HansokuB: 1}, + })) + // The two payloads the SPA sends: startPatch's (startOnly, no scoreline, + // toBackendMatchResult) and an editor board cleared to nothing. + start := func(id, a, b string, flag bool) { + payload := map[string]any{"sideA": a, "sideB": b, "status": "running", "startOnly": true} + if !flag { + payload = map[string]any{"sideA": a, "sideB": b, "status": "running", "ipponsA": []string{}, "ipponsB": []string{}, "hansokuA": 0, "hansokuB": 0} + } + w := putScore(t, r, compID, id, payload) + require.Equal(t, http.StatusOK, w.Code, w.Body.String()) + } + + start("P1-0", "Alice", "Bob", true) + kept := loadPoolMatch(t, store, compID, "P1-0") + assert.Equal(t, state.MatchStatusRunning, kept.Status) + assert.Equal(t, []string{"M"}, kept.IpponsA, "a start keeps the queued point") + assert.Equal(t, 1, kept.HansokuB, "a start keeps the queued penalty") + + start("P1-1", "Carol", "Dave", false) + cleared := loadPoolMatch(t, store, compID, "P1-1") + assert.Equal(t, state.MatchStatusRunning, cleared.Status) + assert.Empty(t, cleared.IpponsA, "without the flag the empty board is the operator's word") + assert.Equal(t, 0, cleared.HansokuB) +} + +// startOnly swaps the stored score in after validation, so a write that also +// completes the match would store a winner on a scoreline nothing checked. +func TestScoreHandler_StartOnlyIsOnlyForAStart(t *testing.T) { + const compID = "start-only-completes" + r, store := setupKachinukiScoreServer(t, compID) + require.NoError(t, store.SaveCompetition(&state.Competition{ID: compID, Format: state.CompFormatMixed, Status: state.CompStatusPools})) + require.NoError(t, store.SavePoolMatches(compID, []state.MatchResult{ + {ID: "P1-0", SideA: "Alice", SideB: "Bob", Status: state.MatchStatusScheduled, IpponsB: []string{"M"}}, + })) + + w := putScore(t, r, compID, "P1-0", map[string]any{ + "sideA": "Alice", "sideB": "Bob", "status": "completed", "winner": "Alice", + "ipponsA": []string{"M", "K"}, "ipponsB": []string{}, "startOnly": true, + }) + require.Equal(t, http.StatusBadRequest, w.Code, w.Body.String()) + m := loadPoolMatch(t, store, compID, "P1-0") + assert.Equal(t, state.MatchStatusScheduled, m.Status, "nothing was stored") + assert.Empty(t, m.Winner) +} diff --git a/internal/mobileapp/withdrawn_status_annotation_test.go b/internal/mobileapp/withdrawn_status_annotation_test.go new file mode 100644 index 000000000..07dbeccfe --- /dev/null +++ b/internal/mobileapp/withdrawn_status_annotation_test.go @@ -0,0 +1,184 @@ +package mobileapp + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/gin-gonic/gin" + "github.com/gitrgoliveira/bracket-creator/internal/domain" + "github.com/gitrgoliveira/bracket-creator/internal/engine" + "github.com/gitrgoliveira/bracket-creator/internal/helper" + "github.com/gitrgoliveira/bracket-creator/internal/state" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// annotateEligibility stamps, on a COMPLETED match a withdrawal or default win +// decided, where the withdrawn side's competitor status stands now, so the +// editor's clear control can word its consequence without fetching the +// statuses itself. +func TestAnnotateEligibility_WithdrawnStatus(t *testing.T) { + statuses := map[string]domain.CompetitorStatus{ + "alice": {PlayerID: "alice", Eligible: false, MatchID: "Pool A-0", Reason: "kiken-voluntary at Pool A-0"}, + "bob": {PlayerID: "bob", Eligible: false, MatchID: "Pool A-3", Reinstateable: true}, + "carol": {PlayerID: "carol", Eligible: true, MatchID: "m-r1-0"}, + } + pool := []state.MatchResult{ + {ID: "Pool A-0", SideAID: "alice", SideBID: "dave", Status: state.MatchStatusCompleted, Decision: "kiken-voluntary", DecisionBy: "aka"}, + {ID: "Pool A-1", SideAID: "erin", SideBID: "bob", Status: state.MatchStatusCompleted, Decision: "fusensho", DecisionBy: "shiro"}, + {ID: "Pool A-2", SideAID: "alice", SideBID: "erin", Status: state.MatchStatusCompleted, WinnerID: "erin"}, + {ID: "Pool A-4", SideAID: "alice", SideBID: "frank", Status: state.MatchStatusScheduled}, + } + bracket := &state.Bracket{Rounds: [][]state.BracketMatch{{ + {ID: "m-r1-0", SideAID: "carol", SideBID: "gina", Status: state.MatchStatusCompleted, Decision: "fusenpai", WinnerID: "gina"}, + }}} + + annotateEligibility(pool, bracket, statuses) + + require.NotNil(t, pool[0].WithdrawnStatus, "a kiken names the side that withdrew") + assert.Equal(t, state.WithdrawnStatusAnnotation{Eligible: false, MatchID: "Pool A-0"}, *pool[0].WithdrawnStatus) + require.NotNil(t, pool[1].WithdrawnStatus, "a default win names the barred side") + assert.Equal(t, state.WithdrawnStatusAnnotation{Eligible: false, MatchID: "Pool A-3", Reinstateable: true}, *pool[1].WithdrawnStatus) + assert.Nil(t, pool[2].WithdrawnStatus, "a fought match withdrew nobody") + assert.Nil(t, pool[3].WithdrawnStatus, "a scheduled match is not decided") + assert.NotNil(t, pool[3].IneligibleSides, "and keeps its own barred-side stamp") + b := bracket.Rounds[0][0] + require.NotNil(t, b.WithdrawnStatus, "with no decisionBy, the side that did not win") + assert.Equal(t, state.WithdrawnStatusAnnotation{Eligible: true, MatchID: "m-r1-0"}, *b.WithdrawnStatus) +} + +// pushRecorder keeps each match_updated push's result, keyed by match id. +type pushRecorder struct{ results map[string]*state.MatchResult } + +func (p *pushRecorder) Broadcast(t EventType, data any) { + if t != EventMatchUpdated { + return + } + if h, ok := data.(gin.H); ok { + if r, ok := h["result"].(*state.MatchResult); ok && r != nil { + p.results[r.ID] = r + } + } +} + +// The push that records a withdrawal carries the stamp too. A host applies the +// push at once and refetches a moment later, so a pushed row without it left +// an editor on that match wording its clear from no stamp until the refetch. +func TestDecisionPush_CarriesWithdrawnStatus(t *testing.T) { + for _, door := range []string{"/decision", "/score"} { + t.Run(door, func(t *testing.T) { + store, err := state.NewStore(t.TempDir()) + require.NoError(t, err) + eng := engine.New(store) + compID := "withdrawn-status-push" + require.NoError(t, store.SaveCompetition(&state.Competition{ + ID: compID, Format: state.CompFormatLeague, Status: state.CompStatusPools, + })) + 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", SideB: "Bob", SideAID: aliceID, SideBID: bobID, Status: state.MatchStatusScheduled}, + {ID: "Pool A-1", SideA: "Alice", SideB: "Carol", SideAID: aliceID, SideBID: carolID, Status: state.MatchStatusScheduled}, + })) + + gin.SetMode(gin.TestMode) + r := gin.New() + pushes := &pushRecorder{results: map[string]*state.MatchResult{}} + admin := r.Group("/api") + registerScoreHandler(admin, eng, store, store, pushes, NewFileVerifier(store), store) + RegisterDecisionHandlers(admin, eng, store, store, pushes) + + // Alice withdraws in Pool A-0, then does not appear for Pool A-1: + // that no-show chains onto the withdrawal's bar (bc-kfup). + resp := postDecisionJSON(t, r, compID, "Pool A-0", DecisionRequest{Decision: "kiken-voluntary", DecisionBy: "aka", DecisionReason: "test"}) + require.Equal(t, http.StatusOK, resp.Code, resp.Body.String()) + if door == "/decision" { + resp = postDecisionJSON(t, r, compID, "Pool A-1", DecisionRequest{Decision: "fusenpai", DecisionBy: "aka"}) + } else { + resp = putScoreJSON(t, r, compID, "Pool A-1", state.MatchResult{ + ID: "Pool A-1", Status: state.MatchStatusCompleted, Decision: "fusenpai", DecisionBy: "aka", + Winner: "Carol", IpponsB: domain.DefaultWinIppons(false), + }) + } + require.Equal(t, http.StatusOK, resp.Code, resp.Body.String()) + + pushed := pushes.results["Pool A-1"] + require.NotNil(t, pushed, "the no-show was pushed") + require.NotNil(t, pushed.WithdrawnStatus, "the pushed no-show carries the stamp") + assert.False(t, pushed.WithdrawnStatus.Eligible) + assert.Equal(t, "Pool A-0", pushed.WithdrawnStatus.MatchID, "the bar is the withdrawal's") + }) + } +} + +// Bulk-score can record a no-show too, and its push carries the stamp the same +// way. The bulk handler is wired to the real hub, so this reads the push off a +// subscription. +func TestBulkScorePush_CarriesWithdrawnStatus(t *testing.T) { + store, err := state.NewStore(t.TempDir()) + require.NoError(t, err) + eng := engine.New(store) + compID := "withdrawn-status-bulk" + require.NoError(t, store.SaveCompetition(&state.Competition{ + ID: compID, Format: state.CompFormatLeague, Status: state.CompStatusPools, + })) + 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", SideB: "Bob", SideAID: aliceID, SideBID: bobID, Status: state.MatchStatusScheduled}, + {ID: "Pool A-1", SideA: "Alice", SideB: "Carol", SideAID: aliceID, SideBID: carolID, Status: state.MatchStatusScheduled}, + })) + + gin.SetMode(gin.TestMode) + r := gin.New() + hub := NewHub() + defer hub.Close() + admin := r.Group("/api") + RegisterMatchHandlers(admin, eng, store, store, hub, NewFileVerifier(store), store) + RegisterDecisionHandlers(admin, eng, store, store, hub) + + resp := postDecisionJSON(t, r, compID, "Pool A-0", DecisionRequest{Decision: "kiken-voluntary", DecisionBy: "aka", DecisionReason: "test"}) + require.Equal(t, http.StatusOK, resp.Code, resp.Body.String()) + + sub := hub.Subscribe() + defer hub.Unsubscribe(sub) + body, err := json.Marshal([]state.MatchResult{{ + ID: "Pool A-1", Status: state.MatchStatusCompleted, Decision: "fusenpai", DecisionBy: "aka", + Winner: "Carol", IpponsB: domain.DefaultWinIppons(false), + }}) + require.NoError(t, err) + w := httptest.NewRecorder() + req, err := http.NewRequest(http.MethodPost, "/api/competitions/"+compID+"/matches/bulk-score", bytes.NewBuffer(body)) + require.NoError(t, err) + req.Header.Set("Content-Type", "application/json") + r.ServeHTTP(w, req) + require.Equal(t, http.StatusOK, w.Code, w.Body.String()) + + deadline := time.After(2 * time.Second) + for { + select { + case ev := <-sub: + if !strings.Contains(ev.payload, `"match_updated"`) || !strings.Contains(ev.payload, `"Pool A-1"`) { + continue + } + assert.Contains(t, ev.payload, `"withdrawnStatus":{"eligible":false,"matchId":"Pool A-0"}`, + "the pushed no-show carries the stamp") + return + case <-deadline: + t.Fatal("no match_updated push for Pool A-1") + } + } +} diff --git a/internal/state/bracket.go b/internal/state/bracket.go index 7ac013b09..664b6415e 100644 --- a/internal/state/bracket.go +++ b/internal/state/bracket.go @@ -208,7 +208,7 @@ func (s *Store) copyBracket(b *Bracket) *Bracket { // match copy path (copyMatchResults). for j := range res.Rounds[i] { res.Rounds[i][j].Encho = round[j].Encho.Clone() - res.Rounds[i][j].SubResults = cloneSubResults(round[j].SubResults) + res.Rounds[i][j].SubResults = CloneSubResults(round[j].SubResults) if round[j].Feeders != nil { res.Rounds[i][j].Feeders = append([]string(nil), round[j].Feeders...) } @@ -226,7 +226,7 @@ func (s *Store) copyBracket(b *Bracket) *Bracket { if b.ThirdPlaceMatch != nil { tpm := *b.ThirdPlaceMatch tpm.Encho = b.ThirdPlaceMatch.Encho.Clone() - tpm.SubResults = cloneSubResults(b.ThirdPlaceMatch.SubResults) + tpm.SubResults = CloneSubResults(b.ThirdPlaceMatch.SubResults) if b.ThirdPlaceMatch.Feeders != nil { tpm.Feeders = append([]string(nil), b.ThirdPlaceMatch.Feeders...) } diff --git a/internal/state/bracket_test.go b/internal/state/bracket_test.go index 593d9d9e2..c8d1f8114 100644 --- a/internal/state/bracket_test.go +++ b/internal/state/bracket_test.go @@ -621,7 +621,7 @@ func TestSaveBracket_DrawOrderRoundTrip(t *testing.T) { // each SubMatchResult's IpponsA/IpponsB/Encho). A shallow copy would alias the // cached backing array/pointers, so a caller mutating a returned match in place // could silently corrupt cached state without going through Save/UpdateBracket. -// Mirrors the pool-match copy path (copyMatchResults / cloneSubResults). +// Mirrors the pool-match copy path (copyMatchResults / CloneSubResults). func TestLoadBracket_DeepCopyIsolation(t *testing.T) { store, err := NewStore(t.TempDir()) require.NoError(t, err) diff --git a/internal/state/match.go b/internal/state/match.go index 55ac29ee4..cef0667a3 100644 --- a/internal/state/match.go +++ b/internal/state/match.go @@ -158,7 +158,7 @@ func DeriveQueuePositions(matches []MatchResult) []int { for _, e := range entries { // bc-cse: a scheduled match whose resolved side ids are // currently barred (IneligibleSides, stamped by the caller - // BEFORE this runs -- see mobileapp.annotateIneligibleSides) + // BEFORE this runs -- see mobileapp.annotateEligibility) // is not queued: it cannot be started until the operator // reinstates the barred side or records the default win, so // "Next up" and on-deck alerts must skip it rather than diff --git a/internal/state/models.go b/internal/state/models.go index fea67ba4d..62781822b 100644 --- a/internal/state/models.go +++ b/internal/state/models.go @@ -1314,6 +1314,18 @@ type IneligibleSidesAnnotation struct { B string `json:"b,omitempty"` } +// WithdrawnStatusAnnotation is where the withdrawn side of a COMPLETED match +// that a withdrawal or default win decided stands now: its competitor status +// (eligible, the match that bars it, whether it can be reinstated). The +// editor's clear control words its consequence from it (a bar recorded by +// another match, eligible again, reinstateable) without a status fetch of its +// own. Request-time only, like IneligibleSidesAnnotation. +type WithdrawnStatusAnnotation struct { + Eligible bool `json:"eligible"` + MatchID string `json:"matchId,omitempty"` + Reinstateable bool `json:"reinstateable,omitempty"` +} + type MatchResult struct { ID string `json:"id"` SideA string `json:"sideA"` // Player/Team Name @@ -1392,7 +1404,7 @@ type MatchResult struct { QueuePosition int `json:"queuePosition,omitempty" yaml:"-"` // IneligibleSides is a READ-ONLY, request-time annotation (bc-cse), // exactly like QueuePosition above: stamped only on the copy a viewer - // endpoint serves (mobileapp.annotateIneligibleSides), never on an + // endpoint serves (mobileapp.annotateEligibility), never on an // object bound for a write, so it never reaches pool-matches.csv (no // entry in poolMatchColumns, pools.go) the same way QueuePosition does // not. Non-nil only for a SCHEDULED match whose stamped SideAID/SideBID @@ -1401,6 +1413,11 @@ type MatchResult struct { // without a second round trip. Omitted on the wire entirely when // neither side is barred. IneligibleSides *IneligibleSidesAnnotation `json:"ineligibleSides,omitempty" yaml:"-"` + // WithdrawnStatus is a READ-ONLY, request-time annotation stamped with + // IneligibleSides (mobileapp.annotateEligibility) and kept off disk the + // same way. Non-nil only on a COMPLETED match a withdrawal or default win + // decided whose withdrawn side has a competitor status. + WithdrawnStatus *WithdrawnStatusAnnotation `json:"withdrawnStatus,omitempty" yaml:"-"` // DecidedByHantei is a LEGACY READ-ONLY channel, exactly as on // SubMatchResult (see there and legacy_hantei.go): the verdict is the // domain.HanteiMark entry in the winner's IpponsA/IpponsB. A hantei on a @@ -1575,12 +1592,13 @@ func (e *EnchoMetadata) Clone() *EnchoMetadata { return &c } -// cloneSubResults deep-copies a sub-result slice so cached state never shares +// CloneSubResults deep-copies a sub-result slice so cached state never shares // the IpponsA/IpponsB slices or nested Encho pointers with a returned value. // Used by both the pool match copy path (copyMatchResults) and the bracket -// copy path (copyBracket); keep them aligned. Returns nil for a nil input so +// copy path (copyBracket); keep them aligned. The engine's keepQueuedScore +// uses it to copy a stored bout log onto a start. Returns nil for a nil input so // the omitempty/preserve semantics round-trip unchanged. -func cloneSubResults(subs []SubMatchResult) []SubMatchResult { +func CloneSubResults(subs []SubMatchResult) []SubMatchResult { if subs == nil { return nil } @@ -1688,7 +1706,7 @@ type BracketMatch struct { QueuePosition int `json:"queuePosition,omitempty"` // IneligibleSides mirrors MatchResult.IneligibleSides for a bracket // match (bc-cse): a request-time-only annotation, stamped by - // mobileapp.annotateIneligibleSides on the copy a viewer endpoint + // mobileapp.annotateEligibility on the copy a viewer endpoint // serves, never on the object a write persists to bracket.json (the // same discipline QueuePosition above already relies on -- see its own // doc comment on MatchResult for why that is safe without a json:"-" @@ -1696,6 +1714,10 @@ type BracketMatch struct { // marshal, so what keeps a derived field off disk is WHEN it is set, // not a wire/disk type split). IneligibleSides *IneligibleSidesAnnotation `json:"ineligibleSides,omitempty"` + // WithdrawnStatus mirrors MatchResult.WithdrawnStatus for a bracket + // match, set only on the copy a viewer endpoint serves (see + // IneligibleSides above for why that keeps it off bracket.json). + WithdrawnStatus *WithdrawnStatusAnnotation `json:"withdrawnStatus,omitempty"` // MatchNumber is the sequential bracket match number, matching the // "Match N" label printed on the Excel tree sheet. 0 means unset; for a // BracketMatch that is a hidden/bye placeholder, or a legacy bracket saved diff --git a/internal/state/pool_roundtrip_test.go b/internal/state/pool_roundtrip_test.go index 8ac119559..dc4f0ef38 100644 --- a/internal/state/pool_roundtrip_test.go +++ b/internal/state/pool_roundtrip_test.go @@ -39,9 +39,12 @@ var notPersistedInPoolCSV = map[string]string{ "(DeriveQueuePositions), never authored, so persisting it would create a " + "second source of truth that could disagree with the schedule.", "IneligibleSides": "request-time-only annotation (bc-cse), stamped by " + - "mobileapp.annotateIneligibleSides on the served copy from the " + + "mobileapp.annotateEligibility on the served copy from the " + "competitor-status store, never authored onto the object a write " + "persists -- same discipline as QueuePosition above.", + "WithdrawnStatus": "request-time-only annotation stamped with IneligibleSides " + + "by mobileapp.annotateEligibility on the served copy, from the " + + "competitor-status store; never persisted, same discipline.", "WinnerSide": "derived: the winner name compared against SideA/SideB.", "SubResultsRaw": "the SubResults cell's UNPARSED bytes, retained only when " + "that cell failed to parse so the whole-file rewrite cannot destroy an " + diff --git a/internal/state/pools.go b/internal/state/pools.go index ad0a96824..e99702b83 100644 --- a/internal/state/pools.go +++ b/internal/state/pools.go @@ -159,7 +159,7 @@ func (s *Store) copyMatchResults(results []MatchResult) []MatchResult { res[i].IpponsB = make([]string, len(r.IpponsB)) copy(res[i].IpponsB, r.IpponsB) } - res[i].SubResults = cloneSubResults(r.SubResults) + res[i].SubResults = CloneSubResults(r.SubResults) // Deep-copy the pointer fields so a caller mutating a returned // result through *Encho / *DecidedByHantei cannot corrupt cached // state. Mirrors copyBracket, which already clones its Encho pointer. diff --git a/specs/003-tournament-gap-closure/inputs/gaps_ARCHITECTURE.md b/specs/003-tournament-gap-closure/inputs/gaps_ARCHITECTURE.md index 27caec63c..d508e7a2c 100644 --- a/specs/003-tournament-gap-closure/inputs/gaps_ARCHITECTURE.md +++ b/specs/003-tournament-gap-closure/inputs/gaps_ARCHITECTURE.md @@ -305,7 +305,7 @@ type CompetitorStatus struct { } ``` -The engine should check eligibility before allowing a match to start, and auto-resolve remaining matches for ineligible competitors. +The engine should check eligibility before allowing a match to start. It does not auto-resolve the remaining matches of an ineligible competitor (operator ruling 2026-09-26: recording a withdrawal changes only that match); each remaining match is closed from its own notice when it comes up. **Risk**: Medium. Changes the match data model, which affects storage, API responses, SSE events, and frontend rendering. Best done before other features that depend on richer match metadata. diff --git a/specs/003-tournament-gap-closure/inputs/gaps_spec_brief.md b/specs/003-tournament-gap-closure/inputs/gaps_spec_brief.md index a8e6cc076..29ac07671 100644 --- a/specs/003-tournament-gap-closure/inputs/gaps_spec_brief.md +++ b/specs/003-tournament-gap-closure/inputs/gaps_spec_brief.md @@ -172,7 +172,7 @@ Today `MatchResult.Decision` is a draw flag only. A fought 2–0, a kiken 2–0, - New `CompetitorStatus { PlayerID, Eligible, Reason, MatchID }` state. - Engine checks eligibility before allowing a match to start; rejects starting a match where a competitor is marked ineligible. -- After kiken/fusenpai is recorded, the app surfaces the competitor's remaining scheduled matches and prompts the operator to resolve them (award default wins to opponents or remove from bracket). +- After kiken/fusenpai is recorded, each of the competitor's remaining scheduled matches carries its own notice with a one-tap default win for the opponent, resolved when it comes up (operator ruling 2026-09-26: recording a withdrawal changes only that match, so nothing is resolved in bulk from the match where they withdrew). - Team kiken under FIK rules: validate that the correct positions are vacated (Jiho for 1 withdrawal, Jiho + Fukusho for 2; Senpo and Taisho cannot be forfeited; 3+ withdrawals disqualify the team). --- diff --git a/specs/openapi.yaml b/specs/openapi.yaml index 3333a08e6..3b15ab981 100644 --- a/specs/openapi.yaml +++ b/specs/openapi.yaml @@ -2265,6 +2265,13 @@ paths: - $ref: '#/components/parameters/MatchId' requestBody: required: true + description: | + A MatchResult. Request-only, never stored: `startOnly: true` marks a + write that only starts the match (sets it running): the score the + stored match already holds (a match sent back to the queue keeps + one) is kept rather than replaced by the payload's empty scoreline. + Without it, an empty scoreline clears the stored one. `startOnly` + with any status other than `running` is a 400. content: application/json: schema: @@ -2485,10 +2492,12 @@ paths: tags: [matches] summary: Send a running match back to the queue description: | - Reverts a running (not-yet-completed) match to the `scheduled` state, - discarding any partial score and audit metadata, so an operator who - started the wrong bout can start the correct one instead. Idempotent: an - already-scheduled match is normalised to a clean scheduled match. + Reverts a running (not-yet-completed) match to the `scheduled` state. + The match KEEPS its score (points, penalties, overtime, bouts, flags, + rep-bout fighters); only its verdict and audit metadata are cleared. + Starting it again with `startOnly` carries on from that score, and the + operator removes a wrong mark themselves. Idempotent: an + already-scheduled match keeps its score and loses any stale verdict. Broadcasts a `match_updated` event so viewers drop it from the current-matches view. Intentionally accessible to court operators in self-run mode (operational play, the inverse of starting a match). @@ -2757,13 +2766,13 @@ paths: client's former two separate calls (revert-to-queue then reopen), which raced. On success the target returns to `running` (with `reopenPending` set when no `reason` is given, exactly like reopen) and the blocker - returns to `scheduled` with its partial score cleared; a `match_updated` + returns to `scheduled` with its score kept; a `match_updated` event is broadcast for BOTH matches. A target a withdrawal ended has that withdrawal removed and its eligibility restored exactly as on reopen, broadcast as `competitor_status_updated`. - Doubly destructive (it discards the target's finalized result AND the - blocker's partial score), so it is main-password-gated in self-run mode + Destructive (it discards the target's finalized result; the blocker + keeps its score), so it is main-password-gated in self-run mode via the central allowlist, the same class as reopen and override-winner. security: - TournamentPassword: [] @@ -5059,6 +5068,22 @@ components: items: $ref: '#/components/schemas/Player' + WithdrawnStatusAnnotation: + type: object + description: > + Request-time only, never stored. On a completed match a withdrawal or + default win decided: where the withdrawn side's competitor status + stands now. Set on the viewer payloads, and on the result a /decision, + /score or bulk-score write returns and pushes. + properties: + eligible: + type: boolean + matchId: + type: string + description: The match whose withdrawal the status records. + reinstateable: + type: boolean + description: True after an injury withdrawal the operator can reinstate. IneligibleSidesAnnotation: type: object readOnly: true @@ -5230,6 +5255,10 @@ components: readOnly: true allOf: - $ref: '#/components/schemas/IneligibleSidesAnnotation' + withdrawnStatus: + readOnly: true + allOf: + - $ref: '#/components/schemas/WithdrawnStatusAnnotation' court: type: string maxLength: 32 @@ -5417,6 +5446,10 @@ components: readOnly: true allOf: - $ref: '#/components/schemas/IneligibleSidesAnnotation' + withdrawnStatus: + readOnly: true + allOf: + - $ref: '#/components/schemas/WithdrawnStatusAnnotation' court: type: string maxLength: 32 diff --git a/web-mobile/.oxlintrc.json b/web-mobile/.oxlintrc.json index e84b1f455..c7d9d9938 100644 --- a/web-mobile/.oxlintrc.json +++ b/web-mobile/.oxlintrc.json @@ -21,6 +21,7 @@ "caughtErrorsIgnorePattern": "^_" } ], + "no-undef": "error", "no-unused-expressions": [ "error", { "allowShortCircuit": true, "allowTernary": true } diff --git a/web-mobile/js/__tests__/admin_scoring_modal.test.jsx b/web-mobile/js/__tests__/admin_scoring_modal.test.jsx index 2b94cbb3f..b2e5b8e7d 100644 --- a/web-mobile/js/__tests__/admin_scoring_modal.test.jsx +++ b/web-mobile/js/__tests__/admin_scoring_modal.test.jsx @@ -1281,7 +1281,6 @@ describe('item 7: non-points decisions advance to next match', () => { mountedRef: { current: true }, setDecisionSubmitting: vi.fn(), setDecisionErr: vi.fn(), - setWithdrawnPlayer: vi.fn(), setDecisionPromptKind: vi.fn(), }); @@ -1339,47 +1338,34 @@ describe('item 7: non-points decisions advance to next match', () => { expect(onAfterDecision).not.toHaveBeenCalled(); }); - it('does NOT call onAfterDecision for a kiken decision (modal stays open for RemainingMatchesPanel)', async () => { + // Operator ruling 2026-09-26: recording a withdrawal changes only the + // match it is recorded on. Kiken no longer parks the modal on a + // "remaining matches" panel; it follows the exact same onAfterDecision / + // onClose rule as any other decision. + it('calls onAfterDecision for a kiken decision, same as any other, when not a correction', async () => { const onAfterDecision = vi.fn().mockResolvedValue(undefined); const onClose = vi.fn(); - const setWithdrawnPlayer = vi.fn(); const submit = makeSubmitDecision({ match: makeMatch('m4'), enchoPeriodCount: 0, password: 'pw', - ...makeSetters(), setWithdrawnPlayer, onClose, onAfterDecision, isComplete: false, + ...makeSetters(), onClose, onAfterDecision, isComplete: false, entityLabel: 'competitors', }); await submit('kiken-voluntary', { decisionBy: 'aka', decisionReason: '' }); - // Kiken neither advances nor closes; it parks on RemainingMatchesPanel. - expect(onAfterDecision).not.toHaveBeenCalled(); + expect(onAfterDecision).toHaveBeenCalled(); expect(onClose).not.toHaveBeenCalled(); - expect(setWithdrawnPlayer).toHaveBeenCalled(); }); - // bc-pnum: the loser used to be re-derived from the /decision response's - // plain winner/sideA/sideB NAME strings. Two participants sharing a - // display name from different dojos make those strings identical on - // both sides, so a name compare always resolves to the SAME side - // regardless of who actually withdrew. decisionBy ("aka"/"shiro") - // already names the withdrawn side unambiguously and matches the - // server's own attribution (scoring_tx.go: aka=sideA, shiro=sideB). - it('resolves the withdrawn player by decisionBy, not by name, when both sides share a display name', async () => { - window.API.recordDecision = vi.fn().mockResolvedValue({ - winner: 'Sato', sideA: 'Sato', sideB: 'Sato', - }); - const match = { - compId: 'c1', id: 'm5', - sideA: { id: 'S1', name: 'Sato', dojo: 'Tokyo' }, - sideB: { id: 'S2', name: 'Sato', dojo: 'Osaka' }, - }; - const setWithdrawnPlayer = vi.fn(); + it('falls back to onClose for a kiken correction (isComplete=true)', async () => { + const onAfterDecision = vi.fn().mockResolvedValue(undefined); + const onClose = vi.fn(); const submit = makeSubmitDecision({ - match, enchoPeriodCount: 0, password: 'pw', - ...makeSetters(), setWithdrawnPlayer, onClose: vi.fn(), isComplete: false, + match: makeMatch('m4b'), enchoPeriodCount: 0, password: 'pw', + ...makeSetters(), onClose, onAfterDecision, isComplete: true, entityLabel: 'competitors', }); - // Shiro withdrew: the loser is sideB (Osaka), never sideA (Tokyo). - await submit('kiken-voluntary', { decisionBy: 'shiro', decisionReason: '' }); - expect(setWithdrawnPlayer).toHaveBeenCalledWith(match.sideB); + await submit('kiken-voluntary', { decisionBy: 'aka', decisionReason: '' }); + expect(onClose).toHaveBeenCalled(); + expect(onAfterDecision).not.toHaveBeenCalled(); }); }); }); @@ -1465,7 +1451,6 @@ describe('submitDecisionRequest / makeSubmitDecision: downstream_knockout_played mountedRef: { current: true }, setDecisionSubmitting: vi.fn(), setDecisionErr, - setWithdrawnPlayer: vi.fn(), setDecisionPromptKind: vi.fn(), onClose: vi.fn(), isComplete: true, // correcting an already-completed match @@ -1504,16 +1489,15 @@ describe('submitDecisionRequest / makeSubmitDecision: downstream_knockout_played mountedRef: { current: true }, setDecisionSubmitting: vi.fn(), setDecisionErr, - setWithdrawnPlayer: vi.fn(), setDecisionPromptKind: vi.fn(), onClose, isComplete: true, entityLabel: 'competitors', }); - // fusenpai (not kiken): kiken always parks on RemainingMatchesPanel - // regardless of isComplete, so a correction that should close the modal - // needs a non-kiken decision to exercise the else-branch onClose() path. + // fusenpai here, but kiken takes the identical isComplete gate now + // (operator ruling 2026-09-26): any decision on a correction closes via + // the else-branch onClose() path. await submit('fusenpai', { decisionBy: 'aka', decisionReason: '' }); expect(window.API.recordDecision).toHaveBeenCalledTimes(2); @@ -1523,13 +1507,13 @@ describe('submitDecisionRequest / makeSubmitDecision: downstream_knockout_played }); }); -// bc-pnum: RemainingMatchesPanel's match filter, award() side resolution, -// and its opponent-render lookup all call sameCompetitor (competitor_ -// identity.jsx) directly to decide whether a match side is the withdrawn -// player -- never an OR that could count a name hit even when both sides -// carry ids and differ. These fixtures pin that call-site usage, on top of +// bc-pnum: RecordedWithdrawal's laterDefaultWins fetch (admin_scoring_ +// shared.jsx) calls sameCompetitor (competitor_identity.jsx) directly to +// decide whether a later match's side is the withdrawn competitor -- never +// an OR that could count a name hit even when both sides carry ids and +// differ. These fixtures pin that call-site usage, on top of // sameCompetitor's own coverage in competitor_identity.test.jsx. -describe('sameCompetitor (RemainingMatchesPanel identity, bc-pnum)', () => { +describe('sameCompetitor (default-win identity matching, bc-pnum)', () => { it('decides by id when both the withdrawn player and the side carry one', () => { const side = { id: 'S2', name: 'Sato' }; // Same name, different id: an id compare must say "no", never fall @@ -1562,25 +1546,24 @@ describe('sameCompetitor (RemainingMatchesPanel identity, bc-pnum)', () => { }); // Item 7 (UI-reachable fixture): the same mixed-case refusal, exercised - // through the SAME shape RemainingMatchesPanel actually builds -- - // withdrawnPlayer as resolved by makeSubmitDecision's kiken branch - // (match.sideA/sideB, an {id,name} object with a real UUID) against a - // remaining match's side that resolveSide left id-less (a bracket row - // api_serializers.jsx could not resolve at all). - it('UI-reachable: a kiken-resolved withdrawn player never lights an id-less remaining-match side sharing its name', () => { - // Shape makeSubmitDecision's kiken branch actually produces (see that - // test file's own withdrawn-player assertions): match.sideA/sideB. + // through the SAME shape RecordedWithdrawal's laterDefaultWins fetch + // actually builds -- the withdrawn side (an {id,name} object with a real + // UUID, taken from the originating match's sideA/sideB via withdrawnSideOf) + // against a later match's side that resolveSide left id-less (a bracket + // row api_serializers.jsx could not resolve at all). + it('UI-reachable: a withdrawn competitor never lights an id-less later-match side sharing its name', () => { + // Shape withdrawnSideOf actually produces: match.sideA/sideB. const originatingMatch = { sideA: { id: 'S1', name: 'Sato', dojo: 'Tokyo' }, sideB: { id: 'S9', name: 'Someone Else', dojo: 'Nagoya' }, }; - const withdrawnPlayer = originatingMatch.sideA; // aka withdrew. + const withdrawnSide = originatingMatch.sideA; // aka withdrew. // A different, later bracket round match whose side never got a real id // (resolveSide's own residual "not found" fallback would invent one from // the name in production; here we model the ALREADY id-less shape a // caller must not misattribute). const remainingMatchSide = { id: '', name: 'Sato' }; - expect(sameCompetitor(remainingMatchSide, withdrawnPlayer)).toBe(false); + expect(sameCompetitor(remainingMatchSide, withdrawnSide)).toBe(false); }); }); diff --git a/web-mobile/js/__tests__/api.test.jsx b/web-mobile/js/__tests__/api.test.jsx index 26fde08aa..ba525a1b2 100644 --- a/web-mobile/js/__tests__/api.test.jsx +++ b/web-mobile/js/__tests__/api.test.jsx @@ -1866,3 +1866,26 @@ describe('normalizeMatch: same-name winner attribution is arbitrary but CONSISTE expect(norm.winner.id).toBe('uuid-s'); }); }); + +// bc-sbq: the court console's Start carries startOnly so the server keeps the +// score a queued match already holds; the wire has no other way to say "no +// score sent", since the start's empty arrays are the same bytes as an +// operator clearing every mark. +describe('startOnly reaches the wire only from startPatch (bc-sbq)', () => { + it('startPatch is flagged, and the serializer carries the flag', async () => { + const { startPatch } = await import('../admin_schedule_score_editor.jsx'); + const patch = startPatch(); + expect(patch.startOnly).toBe(true); + const wire = toBackendMatchResult(patch, { sideA: 'Alice', sideB: 'Bob' }); + expect(wire.startOnly).toBe(true); + expect(wire.status).toBe('running'); + // A start sends no scoreline: the server keeps the stored one. + expect(wire.ipponsA).toBeUndefined(); + expect(wire.hansokuB).toBeUndefined(); + }); + + it('an editor board sent as running is not flagged', () => { + const wire = toBackendMatchResult({ status: 'running', ipponsA: [], ipponsB: [] }, { sideA: 'Alice', sideB: 'Bob' }); + expect(wire.startOnly).toBeUndefined(); + }); +}); diff --git a/web-mobile/js/__tests__/api_client_export_pdfs.test.jsx b/web-mobile/js/__tests__/api_client_export_pdfs.test.jsx index 23fda5fc5..fbcd0c7de 100644 --- a/web-mobile/js/__tests__/api_client_export_pdfs.test.jsx +++ b/web-mobile/js/__tests__/api_client_export_pdfs.test.jsx @@ -16,7 +16,7 @@ // reason -- a synthetic fixture with no semicolon in it is exactly what let // this bug ship the first time. -import { describe, it, expect, vi } from 'vitest'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { API } from '../api_client.jsx'; function mockFetchBlob(status, { headers = {}, blob = new Blob([]), jsonBody = {} } = {}) { diff --git a/web-mobile/js/__tests__/bout_side_squad_member.test.jsx b/web-mobile/js/__tests__/bout_side_squad_member.test.jsx index 4489ff969..2b7c93351 100644 --- a/web-mobile/js/__tests__/bout_side_squad_member.test.jsx +++ b/web-mobile/js/__tests__/bout_side_squad_member.test.jsx @@ -15,7 +15,7 @@ // sees beside a bout row's fighter name -- not just of the two primitives in // isolation. -import { describe, it, expect } from 'vitest'; +import { describe, it, expect, beforeEach } from 'vitest'; import { resolveBoutSideMemberId, resolveSquadMember } from '../lineup_resolver.jsx'; import { squadMemberLabel } from '../squad_member_label.jsx'; diff --git a/web-mobile/js/__tests__/comp_matches_from_detail.test.jsx b/web-mobile/js/__tests__/comp_matches_from_detail.test.jsx index 6b9e38956..e14c9ea5e 100644 --- a/web-mobile/js/__tests__/comp_matches_from_detail.test.jsx +++ b/web-mobile/js/__tests__/comp_matches_from_detail.test.jsx @@ -60,10 +60,10 @@ describe('compMatchesForCompetition; recombines the split viewer detail payload' }); it('is published on window, which two admin surfaces depend on', () => { - // admin_scoring_shared.jsx's withdrawal panel and admin_scoring_team.jsx's - // reopen-conflict panel both read this off window rather than importing it - // (the same way they already read window.compMatches), and the team one - // GUARDS on it: if the publish ever disappeared, that panel would silently + // admin_scoring_shared.jsx's reopen-conflict panel and RecordedWithdrawal's + // list of later default wins both read this off window rather than + // importing it (the same way they already read window.compMatches), and + // the conflict panel GUARDS on it: if the publish ever disappeared, that panel would silently // return early and go back to naming a blocker by its raw match id. expect(typeof window.compMatchesForCompetition).toBe('function'); expect(window.compMatchesForCompetition(detail().config, detail()).map((m) => m.id)) diff --git a/web-mobile/js/__tests__/display_white_board.test.jsx b/web-mobile/js/__tests__/display_white_board.test.jsx index 155c1d99f..20cb138d7 100644 --- a/web-mobile/js/__tests__/display_white_board.test.jsx +++ b/web-mobile/js/__tests__/display_white_board.test.jsx @@ -55,6 +55,10 @@ function teamPromoted(promotedKind = 'running') { kind: promotedKind, match: { id: 'm1', round: 'Round 1', + // The board shows a match's score only while it is running or + // completed (matchShowsScore; bc-sbq), and an up-next + // match is a scheduled one. + status: promotedKind === 'running' ? 'running' : 'scheduled', sideA: { name: 'Red Team' }, sideB: { name: 'White Team' }, subResults: [ { position: 1, ipponsB: ['M'], ipponsA: [] }, diff --git a/web-mobile/js/__tests__/ineligible_match.test.jsx b/web-mobile/js/__tests__/ineligible_match.test.jsx index 6ebac3e22..994183010 100644 --- a/web-mobile/js/__tests__/ineligible_match.test.jsx +++ b/web-mobile/js/__tests__/ineligible_match.test.jsx @@ -2,7 +2,10 @@ import { describe, it, expect } from 'vitest'; import { barredSides, isBarredMatch, awaitedDefaultWin, defaultWinDecisionBody, barredNote, defaultWinActionLabel, bothBarredDrawable, bothBarredDrawAction, + sideBarredByDecision, involvesCompetitor, } from '../ineligible_match.jsx'; +// Sets window.isKikenDecision, which sideBarredByDecision reads at call time. +import '../api_serializers.jsx'; // The server stamps `ineligibleSides` on scheduled matches only; the sides // arrive normalized ({id, name}) on every surface that reads them. @@ -97,3 +100,30 @@ describe('ineligible_match', () => { }); }); }); + +// The advance after a decision skips the competitor it barred, because the +// court list shows their matches barred only after it refreshes. +describe('sideBarredByDecision', () => { + // What /decision answers with: the stored match, sides as bare names. + const stored = (over) => ({ id: 'Pool A-1', sideA: 'Umi E', sideB: 'Yama C', sideAId: 'u', sideBId: 'y', status: 'completed', ...over }); + const open = match({ id: 'Pool A-1', status: 'running' }); + + it('names the side a withdrawal or no-show bars, from the caller\'s own match', () => { + expect(sideBarredByDecision(stored({ decision: 'kiken-voluntary', decisionBy: 'aka', winner: 'Yama C' }), open)).toBe(open.sideA); + expect(sideBarredByDecision(stored({ decision: 'kiken-injury', decisionBy: 'shiro', winner: 'Umi E' }), open)).toBe(open.sideB); + expect(sideBarredByDecision(stored({ decision: 'fusenpai', decisionBy: 'shiro', winner: 'Umi E' }), open)).toBe(open.sideB); + }); + + it('bars nobody for any other decision, or for a write that has not come back', () => { + expect(sideBarredByDecision(stored({ decision: 'fought', winner: 'Umi E' }), open)).toBeNull(); + expect(sideBarredByDecision(stored({ decision: 'fusensho', decisionBy: 'shiro', winner: 'Umi E' }), open)).toBeNull(); + expect(sideBarredByDecision({ queued: true }, open)).toBeNull(); + }); + + it('involvesCompetitor finds the side on either side of a match, and nothing for no side', () => { + const later = match({ id: 'Pool A-3', sideA: { id: 'k', name: 'Kawa D' }, sideB: { id: 'u', name: 'Umi E' } }); + expect(involvesCompetitor(later, open.sideA)).toBe(true); + expect(involvesCompetitor(later, open.sideB)).toBe(false); + expect(involvesCompetitor(later, null)).toBe(false); + }); +}); diff --git a/web-mobile/js/__tests__/kachinuki_scoreboard.test.jsx b/web-mobile/js/__tests__/kachinuki_scoreboard.test.jsx index f705e916c..c6f9493f9 100644 --- a/web-mobile/js/__tests__/kachinuki_scoreboard.test.jsx +++ b/web-mobile/js/__tests__/kachinuki_scoreboard.test.jsx @@ -70,7 +70,7 @@ describe('T4 kachinuki: TeamScoreboard row count', () => { { position: 3, sideA: 'WinnerA3', sideB: 'WinnerB3', ipponsB: ['D'], ipponsA: [] }, ]; const tree = runtime.mount(TeamScoreboard, { - subResults, lineupA: linupA, lineupB: linupB, teamSize: 5, + subResults, lineupA: linupA, lineupB: linupB, teamSize: 5, status: 'running', kachinuki: true, shiroName: 'ShiroTeam', akaName: 'AkaTeam', }); const rows = boutRows(tree); @@ -85,7 +85,7 @@ describe('T4 kachinuki: TeamScoreboard row count', () => { ipponsA: [], ipponsB: [], })); const tree = runtime.mount(TeamScoreboard, { - subResults, lineupA: linupA, lineupB: linupB, teamSize: 5, + subResults, lineupA: linupA, lineupB: linupB, teamSize: 5, status: 'running', kachinuki: true, shiroName: 'ShiroTeam', akaName: 'AkaTeam', }); expect(boutRows(tree)).toHaveLength(7); @@ -98,7 +98,7 @@ describe('T4 kachinuki: TeamScoreboard row count', () => { { position: 3, ipponsB: [], ipponsA: [] }, ]; const tree = runtime.mount(TeamScoreboard, { - subResults, lineupA: linupA, lineupB: linupB, teamSize: 5, + subResults, lineupA: linupA, lineupB: linupB, teamSize: 5, status: 'running', kachinuki: false, shiroName: 'ShiroTeam', akaName: 'AkaTeam', }); expect(boutRows(tree)).toHaveLength(5); diff --git a/web-mobile/js/__tests__/match_scoreboard.test.jsx b/web-mobile/js/__tests__/match_scoreboard.test.jsx index 8bd896843..70ea9123e 100644 --- a/web-mobile/js/__tests__/match_scoreboard.test.jsx +++ b/web-mobile/js/__tests__/match_scoreboard.test.jsx @@ -100,7 +100,7 @@ describe('match_scoreboard: teamIVPW', () => { const el = TeamScoreboard({ subResults: [{ position: 1, sideA: 'Sato', sideB: 'Ito', winner: 'Ito', ipponsA: [], ipponsB: ['M'] }], teamResult: { shiroIV: 2, akaIV: 0, shiroPW: 5, akaPW: 1 }, - matchSideA: 'Team A', matchSideB: 'Team B', teamSize: 3, + matchSideA: 'Team A', matchSideB: 'Team B', teamSize: 3, status: 'completed', }); const text = collectText(el); expect(text).toContain('2'); @@ -111,7 +111,7 @@ describe('match_scoreboard: teamIVPW', () => { const { TeamScoreboard } = await import('../match_scoreboard.jsx'); const el = TeamScoreboard({ subResults: [{ position: 1, sideA: 'Sato', sideB: 'Ito', winner: 'Ito', ipponsA: [], ipponsB: ['M'] }], - matchSideA: 'Team A', matchSideB: 'Team B', teamSize: 3, + matchSideA: 'Team A', matchSideB: 'Team B', teamSize: 3, status: 'completed', }); expect(collectText(el)).toContain('1'); }); @@ -363,7 +363,7 @@ describe('match_scoreboard components', () => { // centreMarks logic compares winner === sideA/sideB by key). Without the // guard, both shiro and aka would render a win mark. const match = { - sideA: 'Same Name', sideB: 'Same Name', + sideA: 'Same Name', sideB: 'Same Name', status: 'completed', ipponsA: [], ipponsB: [], decidedByHantei: true, winner: 'Same Name', }; @@ -385,7 +385,7 @@ describe('match_scoreboard components', () => { // would, giving [K][ ] vs [Ht][M] with Aka (sideA) the hantei winner. const match = { sideA: { id: 'p1', name: 'Aka' }, sideB: { id: 'p2', name: 'Shiro' }, - ipponsA: ['M'], ipponsB: ['K'], decidedByHantei: true, + ipponsA: ['M'], ipponsB: ['K'], decidedByHantei: true, status: 'completed', winner: { id: 'p1', name: 'Aka' }, }; const tree = runtime.mount(IndividualScore, { match }); @@ -411,7 +411,7 @@ describe('match_scoreboard components', () => { const match = { sideA: { id: 'p1', name: 'Aka' }, sideB: { id: 'p2', name: 'Shiro' }, ipponsA: ['M'], ipponsB: [], winner: { id: 'p1', name: 'Aka' }, - encho: { periodCount: 1 }, + encho: { periodCount: 1 }, status: 'completed', }; const tree = runtime.mount(IndividualScore, { match }); expect(centreText(tree)).toBe('(E)'); @@ -425,7 +425,7 @@ describe('match_scoreboard components', () => { const match = { sideA: { id: 'p1', name: 'Aka' }, sideB: { id: 'p2', name: 'Shiro' }, ipponsA: [], ipponsB: [], winner: { id: 'p1', name: 'Aka' }, - decision: 'fusensho', encho: { periodCount: 1 }, + decision: 'fusensho', encho: { periodCount: 1 }, status: 'completed', }; // Collect the whole aka SLOT GROUP: the sub-win-a testid sits on cell 0 // only, which reads a single maru in both cases and would not distinguish. @@ -444,7 +444,7 @@ describe('match_scoreboard components', () => { const match = { sideA: { id: 'p1', name: 'Aka' }, sideB: { id: 'p2', name: 'Shiro' }, ipponsA: [], ipponsB: [], winner: { id: 'p1', name: 'Aka' }, - decision: 'fusensho', encho: null, + decision: 'fusensho', encho: null, status: 'completed', }; const tree = runtime.mount(IndividualScore, { match }); expect(collectText(findInTree(tree, n => @@ -505,7 +505,7 @@ describe('match_scoreboard components', () => { // overwrite a recorded point and never write the shared centre. const match = { sideA: { id: 'p1', name: 'Aka' }, sideB: { id: 'p2', name: 'Shiro' }, - ipponsA: ['M', 'K'], ipponsB: ['M', 'K'], decidedByHantei: true, + ipponsA: ['M', 'K'], ipponsB: ['M', 'K'], decidedByHantei: true, status: 'completed', winner: { id: 'p1', name: 'Aka' }, }; const tree = runtime.mount(IndividualScore, { match }); @@ -524,7 +524,7 @@ describe('match_scoreboard components', () => { // winning side's slot gets the Ht mark. const match = { sideA: { id: 'p1', name: 'Same Name' }, sideB: { id: 'p2', name: 'Same Name' }, - ipponsA: [], ipponsB: [], decidedByHantei: true, + ipponsA: [], ipponsB: [], decidedByHantei: true, status: 'completed', winner: { id: 'p1', name: 'Same Name' }, }; const tree = runtime.mount(IndividualScore, { match }); @@ -535,7 +535,7 @@ describe('match_scoreboard components', () => { }); it('IndividualScore renders the ippon-letter slots (§263)', () => { - const tree = runtime.mount(IndividualScore, { match: { ipponsB: ['M'], ipponsA: ['K', 'M'] } }); + const tree = runtime.mount(IndividualScore, { match: { status: 'completed', ipponsB: ['M'], ipponsA: ['K', 'M'] } }); const text = collectText(tree); expect(findInTree(tree, n => n?.props?.['data-testid'] === 'individual-score')).toBeTruthy(); expect(text).toContain('M'); expect(text).toContain('K'); @@ -545,7 +545,7 @@ describe('match_scoreboard components', () => { // marks are a NUMBER; every other type renders ippon letters (tested above). // flagsA=sideA=Aka, flagsB=sideB=Shiro (same convention as engiFlagScore). it('IndividualScore renders the engi flag COUNT per side, not ippon letters', () => { - const tree = runtime.mount(IndividualScore, { match: { flagsA: 3, flagsB: 2, ipponsA: [], ipponsB: [] } }); + const tree = runtime.mount(IndividualScore, { match: { status: 'completed', flagsA: 3, flagsB: 2, ipponsA: [], ipponsB: [] } }); const text = collectText(tree); expect(findInTree(tree, n => n?.props?.['data-testid'] === 'sub-flags-b')).toBeTruthy(); expect(findInTree(tree, n => n?.props?.['data-testid'] === 'sub-flags-a')).toBeTruthy(); @@ -553,14 +553,14 @@ describe('match_scoreboard components', () => { }); it('IndividualScore marks the Aka slot as winner when flagsA > flagsB', () => { - const tree = runtime.mount(IndividualScore, { match: { flagsA: 4, flagsB: 1, ipponsA: [], ipponsB: [] } }); + const tree = runtime.mount(IndividualScore, { match: { status: 'completed', flagsA: 4, flagsB: 1, ipponsA: [], ipponsB: [] } }); const akaSlots = findInTree(tree, n => typeof n?.props?.className === 'string' && n.props.className.includes('msb-slots--aka')); expect(akaSlots?.props?.className).toContain('msb-slots--win'); }); it('IndividualScore marks the Shiro slot as winner when flagsB > flagsA', () => { - const tree = runtime.mount(IndividualScore, { match: { flagsA: 0, flagsB: 5, ipponsA: [], ipponsB: [] } }); + const tree = runtime.mount(IndividualScore, { match: { status: 'completed', flagsA: 0, flagsB: 5, ipponsA: [], ipponsB: [] } }); const shiroSlots = findInTree(tree, n => typeof n?.props?.className === 'string' && /\bmsb-slots\b/.test(n.props.className) && !n.props.className.includes('--aka')); expect(shiroSlots?.props?.className).toContain('msb-slots--win'); @@ -615,7 +615,7 @@ describe('match_scoreboard components', () => { { position: 1, ipponsB: ['M'], ipponsA: [] }, { position: 2, ipponsB: [], ipponsA: ['D'] }, ]; - const tree = runtime.mount(TeamScoreboard, { subResults, lineupA: null, lineupB: null, teamSize: 5, showDH: false }); + const tree = runtime.mount(TeamScoreboard, { subResults, lineupA: null, lineupB: null, teamSize: 5, showDH: false, status: 'running' }); expect(findInTree(tree, n => n?.props?.['data-testid'] === 'team-summary')).toBeTruthy(); const text = collectText(tree); expect(text).toContain('IV'); expect(text).toContain('PW'); @@ -629,7 +629,7 @@ describe('match_scoreboard components', () => { { position: 1, ipponsB: ['M'], ipponsA: [] }, { position: 2, ipponsB: [], ipponsA: ['D'] }, ]; - const tree = runtime.mount(TeamScoreboard, { subResults, lineupA: null, lineupB: null, teamSize: 5, showDH: false, isRunning: true }); + const tree = runtime.mount(TeamScoreboard, { subResults, lineupA: null, lineupB: null, teamSize: 5, showDH: false, isRunning: true, status: 'running' }); expect(boutRows(tree).map(r => r.state)).toEqual(['done', 'done', 'now', 'queued', 'queued']); }); @@ -641,14 +641,14 @@ describe('match_scoreboard components', () => { { position: 2, ipponsB: [], ipponsA: ['D'] }, { position: 3, ipponsB: ['K'], ipponsA: [] }, ]; - const tree = runtime.mount(TeamScoreboard, { subResults, lineupA: null, lineupB: null, teamSize: 5, showDH: false /* not running */ }); + const tree = runtime.mount(TeamScoreboard, { subResults, lineupA: null, lineupB: null, teamSize: 5, showDH: false, status: 'completed' /* not running */ }); const states = boutRows(tree).map(r => r.state); expect(states).toEqual(['done', 'done', 'done', 'queued', 'queued']); expect(states).not.toContain('now'); }); it('TeamScoreboard leaves an up-next board (no scored bouts) all queued: no "now"', () => { - const tree = runtime.mount(TeamScoreboard, { subResults: [], lineupA: null, lineupB: null, teamSize: 5, showDH: false }); + const tree = runtime.mount(TeamScoreboard, { subResults: [], lineupA: null, lineupB: null, teamSize: 5, showDH: false, status: 'scheduled' }); const states = boutRows(tree).map(r => r.state); expect(states.length).toBe(5); expect(states.every(s => s === 'queued')).toBe(true); @@ -657,7 +657,7 @@ describe('match_scoreboard components', () => { it('TeamScoreboard highlights bout 1 as "now" for a RUNNING 0-0 encounter (isRunning)', () => { // A running team match with no bouts scored yet must still mark the first // bout live, so it doesn't look identical to an up-next board. - const tree = runtime.mount(TeamScoreboard, { subResults: [], lineupA: null, lineupB: null, teamSize: 5, showDH: false, isRunning: true }); + const tree = runtime.mount(TeamScoreboard, { subResults: [], lineupA: null, lineupB: null, teamSize: 5, showDH: false, isRunning: true, status: 'running' }); expect(boutRows(tree).map(r => r.state)).toEqual(['now', 'queued', 'queued', 'queued', 'queued']); }); @@ -666,7 +666,7 @@ describe('match_scoreboard components', () => { { position: 1, ipponsB: ['M'], ipponsA: ['M'] }, // 1-1 → IV 0-0, PW 1-1 → tied { position: -1, ipponsB: ['M'], ipponsA: [] }, ]; - const tree = runtime.mount(TeamScoreboard, { subResults, lineupA: null, lineupB: null, teamSize: 5, showDH: true }); + const tree = runtime.mount(TeamScoreboard, { subResults, lineupA: null, lineupB: null, teamSize: 5, showDH: true, status: 'completed' }); // The rep bout carries its own (DH) centre mark, so the old text banner // was removed as redundant. expect(boutRows(tree).some(r => r.isDH)).toBe(true); @@ -679,7 +679,7 @@ describe('match_scoreboard components', () => { { position: 2, ipponsB: ['M'], ipponsA: [] }, // shiro wins → IV 1-0 (NOT tied) { position: -1, sideA: 'Aka T', sideB: 'Shiro T', winner: 'Aka T' }, // stale/invalid DH ]; - const tree = runtime.mount(TeamScoreboard, { subResults, lineupA: null, lineupB: null, teamSize: 5, showDH: true }); + const tree = runtime.mount(TeamScoreboard, { subResults, lineupA: null, lineupB: null, teamSize: 5, showDH: true, status: 'completed' }); expect(boutRows(tree).some(r => r.isDH)).toBe(false); expect(collectText(tree)).not.toContain('DAIHYOSEN'); }); @@ -760,7 +760,7 @@ describe('match_scoreboard components', () => { ]; const tree = runtime.mount(TeamScoreboard, { subResults, lineupA: null, lineupB: null, teamSize: 3, showDH: false, - matchSideA: 'Kyoto', matchSideB: 'Osaka', + matchSideA: 'Kyoto', matchSideB: 'Osaka', status: 'completed', }); // Guard the fixture: the encounter really is tied, so an aggregate-level // mark would have something to describe if the rule allowed one. @@ -822,7 +822,7 @@ describe('match_scoreboard components', () => { { position: -1, winner: 'White Team', decidedByHantei: true, ipponsA: [], ipponsB: [] }, ]; const tree = runtime.mount(TeamScoreboard, { - subResults, lineupA: null, lineupB: null, teamSize: 5, showDH: true, + subResults, lineupA: null, lineupB: null, teamSize: 5, showDH: true, status: 'completed', shiroName: 'White Team', akaName: 'Red Team', }); // Find the DH bout row: its sub must carry teamB/teamA. diff --git a/web-mobile/js/__tests__/match_shows_score.test.jsx b/web-mobile/js/__tests__/match_shows_score.test.jsx new file mode 100644 index 000000000..2efcb16f0 --- /dev/null +++ b/web-mobile/js/__tests__/match_shows_score.test.jsx @@ -0,0 +1,42 @@ +// bc-sbq: matchShowsScore (match_shows_score.jsx) is the one answer to "does a display +// show this match's recorded score". A match sent back to the queue keeps its +// score on the server but reads as NOT STARTED until it runs again (operator +// ruling 2026-09-26). The render suite pins each host +// (render/queued_match_reads_not_started.render.test.jsx); this pins the +// predicate itself and matchStateCell, the pool-list centre cell built on it. +import { describe, it, expect } from 'vitest'; +import { matchShowsScore } from '../match_shows_score.jsx'; +import { matchStateCell } from '../bracket.jsx'; + +describe('matchShowsScore', () => { + it('shows the score of a running or a completed match', () => { + expect(matchShowsScore({ status: 'running' })).toBe(true); + expect(matchShowsScore({ status: 'completed' })).toBe(true); + }); + + it('shows no score for a scheduled match, even one that kept its score', () => { + expect(matchShowsScore({ status: 'scheduled' })).toBe(false); + expect(matchShowsScore({ status: 'scheduled', ipponsA: ['M'], hansokuB: 1, encho: { periodCount: 1 } })).toBe(false); + }); + + it('shows no score when there is no match or no status to go by', () => { + expect(matchShowsScore(null)).toBe(false); + expect(matchShowsScore(undefined)).toBe(false); + expect(matchShowsScore({})).toBe(false); + }); +}); + +describe('matchStateCell: a queued match reads "vs"', () => { + const kept = (status) => ({ + status, sideA: { name: 'Aoki' }, sideB: { name: 'Endo' }, + ipponsA: [], ipponsB: [], encho: { periodCount: 1 }, + }); + + it('prints the plain "vs" for a scheduled match that kept an overtime', () => { + expect(matchStateCell(kept('scheduled'))).toBe('vs'); + }); + + it('prints the kept overtime once the match runs again', () => { + expect(matchStateCell(kept('running'))).toBe('(E)'); + }); +}); diff --git a/web-mobile/js/__tests__/patch.test.jsx b/web-mobile/js/__tests__/patch.test.jsx index 1e1d8d7c0..42c59ffe2 100644 --- a/web-mobile/js/__tests__/patch.test.jsx +++ b/web-mobile/js/__tests__/patch.test.jsx @@ -1,5 +1,8 @@ import { describe, it, expect } from 'vitest'; -import { applyPatch, recomputeQueuePositions, recomputeBracketQueuePositions } from '../patch.jsx'; +import { + applyPatch, recomputeQueuePositions, recomputeBracketQueuePositions, + keepNewerMatches, keepNewerDetail, keepNewerCompetitions, keepNewerTournament, +} from '../patch.jsx'; // Tests for the centralised SSE-patch applier (Slice 0 / NFR-006). // applyPatch composes mergeMatchPatch (covered in mergeMatchPatch.test.jsx) @@ -747,3 +750,100 @@ describe('recomputeBracketQueuePositions: bronze participates in its court queue expect(out.rounds[1][0].queuePosition).toBe(2); // final second }); }); + +// A refetch can read the data just before a write commits and answer after +// that write's push was applied: the match it carries is older than the one +// already held, and must not replace it. +describe('keepNewerMatches', () => { + const row = (id, modifiedAt, ipponsA = []) => ({ id, status: 'running', modifiedAt, ipponsA }); + const comp = (pool, rounds = [], third = null) => ({ id: 'c1', poolMatches: pool, bracket: { rounds, thirdPlaceMatch: third } }); + + it('keeps a held match newer than the fetched one, everywhere a match lives', () => { + const held = comp([row('P1', 300, ['M', 'K'])], [[row('K1', 300, ['D'])]], row('B3', 300, ['T'])); + const fetched = comp([row('P1', 200, ['M'])], [[row('K1', 200)]], row('B3', 200)); + const out = keepNewerMatches(held, fetched); + expect(out.poolMatches[0].ipponsA).toEqual(['M', 'K']); + expect(out.bracket.rounds[0][0].ipponsA).toEqual(['D']); + expect(out.bracket.thirdPlaceMatch.ipponsA).toEqual(['T']); + }); + + it('takes the fetched match when it is as new as the held one or newer', () => { + const held = comp([row('P1', 300, ['M']), row('P2', 300, ['M'])]); + const fetched = comp([row('P1', 300, ['M', 'K']), row('P2', 400, [])]); + const out = keepNewerMatches(held, fetched); + expect(out.poolMatches[0].ipponsA).toEqual(['M', 'K']); + expect(out.poolMatches[1].ipponsA).toEqual([]); + }); + + // A draw discarded and drawn again reuses the match ids, and its matches + // carry no stamp until something is written to them. + it('takes a fetched match nothing has written since it was built', () => { + const played = { ...row('P1', 300, ['M', 'K']), status: 'completed', sideA: 'Old' }; + const rebuilt = { ...row('P1', 0), status: 'scheduled', sideA: 'New' }; + delete rebuilt.modifiedAt; + const out = keepNewerMatches(comp([played]), comp([rebuilt])); + expect(out.poolMatches[0].sideA).toBe('New'); + expect(out.poolMatches[0].status).toBe('scheduled'); + }); + + // A send-back or a reopen is stamped by the server's clock, which can read + // older than the device-stamped write just before it (the server accepts a + // device stamp up to 5s ahead). Both move the status, and only a running + // match over a running copy is ever kept. + it('takes a send-back whose server stamp reads older than the running match held', () => { + const out = keepNewerMatches(comp([row('P1', 300, ['M'])]), comp([{ ...row('P1', 250, ['M']), status: 'scheduled' }])); + expect(out.poolMatches[0].status).toBe('scheduled'); + }); + + it('takes a reopen whose server stamp reads older than the finished match held', () => { + const out = keepNewerMatches(comp([{ ...row('P1', 300, ['M']), status: 'completed' }]), comp([row('P1', 250, ['M'])])); + expect(out.poolMatches[0].status).toBe('running'); + }); + + it('takes the fetch whole when nothing is held yet', () => { + const fetched = comp([row('P1', 100)]); + expect(keepNewerMatches(undefined, fetched)).toBe(fetched); + }); +}); + +describe('keepNewerDetail', () => { + const detail = (id, modifiedAt, ipponsA) => ({ config: { id }, poolMatches: [{ id: 'Pool A-0', status: 'running', modifiedAt, ipponsA }] }); + + it('keeps the newer held match of the same competition', () => { + expect(keepNewerDetail(detail('c1', 300, ['M', 'K']), detail('c1', 200, ['M'])).poolMatches[0].ipponsA).toEqual(['M', 'K']); + }); + + it('takes the fetch whole for another competition, whose match ids may repeat', () => { + const fetched = detail('c2', 100, []); + expect(keepNewerDetail(detail('c1', 300, ['M']), fetched)).toBe(fetched); + expect(keepNewerDetail(null, fetched)).toBe(fetched); + }); +}); + +describe('keepNewerCompetitions / keepNewerTournament', () => { + const comp = (id, modifiedAt, ipponsA) => ({ id, poolMatches: [{ id: 'Pool A-0', status: 'running', modifiedAt, ipponsA }] }); + + it('pairs competitions by id, so a match id repeated in another competition is not confused with it', () => { + const held = [comp('c1', 300, ['M', 'K']), comp('c2', 100, [])]; + const out = keepNewerCompetitions(held, [comp('c1', 200, ['M']), comp('c2', 200, ['D'])]); + expect(out[0].poolMatches[0].ipponsA, 'c1 keeps its newer match').toEqual(['M', 'K']); + expect(out[1].poolMatches[0].ipponsA, 'c2 takes its newer fetch').toEqual(['D']); + }); + + it('takes the list whole when nothing is held yet, and drops a competition the fetch no longer has', () => { + const fetched = [comp('c1', 100, [])]; + expect(keepNewerCompetitions(null, fetched)).toBe(fetched); + expect(keepNewerCompetitions([comp('c9', 900, ['M'])], fetched).map((c) => c.id)).toEqual(['c1']); + }); + + it('keeps the tournament fields from the fetch and the newer matches in its competitions', () => { + const out = keepNewerTournament( + { name: 'Old name', competitions: [comp('c1', 300, ['M', 'K'])] }, + { name: 'New name', competitions: [comp('c1', 200, ['M'])] }, + ); + expect(out.name).toBe('New name'); + expect(out.competitions[0].poolMatches[0].ipponsA).toEqual(['M', 'K']); + const first = { name: 'T', competitions: [] }; + expect(keepNewerTournament(null, first)).toBe(first); + }); +}); diff --git a/web-mobile/js/__tests__/render/admin_schedule_smoke.render.test.jsx b/web-mobile/js/__tests__/render/admin_schedule_smoke.render.test.jsx index d175839b8..d60af3950 100644 --- a/web-mobile/js/__tests__/render/admin_schedule_smoke.render.test.jsx +++ b/web-mobile/js/__tests__/render/admin_schedule_smoke.render.test.jsx @@ -17,7 +17,7 @@ import React from 'react'; import { render, screen } from '@testing-library/react'; -import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { describe, it, expect, beforeAll, afterAll, beforeEach, afterEach } from 'vitest'; import { installWindowStubs } from '../helpers/stub_globals.js'; // The REAL score formatter, not a stub: a stub returning a fixed string // cannot distinguish a working gate from a broken one. diff --git a/web-mobile/js/__tests__/render/admin_scoring_engi.render.test.jsx b/web-mobile/js/__tests__/render/admin_scoring_engi.render.test.jsx index 57a80c2f8..2dd59799e 100644 --- a/web-mobile/js/__tests__/render/admin_scoring_engi.render.test.jsx +++ b/web-mobile/js/__tests__/render/admin_scoring_engi.render.test.jsx @@ -1,6 +1,6 @@ import React from 'react'; -import { render, fireEvent, screen, waitFor } from '@testing-library/react'; -import { describe, it, expect, vi } from 'vitest'; +import { render, fireEvent, screen, waitFor, act } from '@testing-library/react'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { EngiScoreEditorModal } from '../../admin_scoring_engi.jsx'; // Regression coverage for a real orientation bug: sideB is Shiro and sideA is @@ -399,3 +399,48 @@ describe('bc-kbhn: engi ←/→ need a neighbour match', () => { expect(onNext).toHaveBeenCalledTimes(1); }); }); + +// Operator ruling 2026-09-26: engi flags are saved as they are entered, so a +// match sent back to the queue or switched away from keeps them. While the +// match is running each change rides the same debounced running write the +// kendo editors use; before it starts nothing is written until the result is. +describe('EngiScoreEditorModal saves flags as they are entered', () => { + beforeEach(() => { vi.useFakeTimers(); }); + afterEach(() => { vi.useRealTimers(); }); + + it('a running match saves each change as a running write', async () => { + const onSubmit = vi.fn().mockResolvedValue({ status: 'running' }); + render( {}} onSubmit={onSubmit} />); + fireEvent.click(screen.getByTestId('engi-aka-inc')); + fireEvent.click(screen.getByTestId('engi-shiro-inc')); + await act(async () => { vi.advanceTimersByTime(400); }); + expect(onSubmit).toHaveBeenCalledTimes(1); + expect(onSubmit).toHaveBeenCalledWith({ flagsA: 1, flagsB: 1, status: 'running' }); + }); + + it('a keyboard change saves too', async () => { + const onSubmit = vi.fn().mockResolvedValue({ status: 'running' }); + render( {}} onSubmit={onSubmit} />); + await act(async () => { fireEvent.keyDown(window, { key: 's' }); }); + await act(async () => { vi.advanceTimersByTime(400); }); + expect(onSubmit).toHaveBeenCalledWith({ flagsA: 0, flagsB: 1, status: 'running' }); + }); + + it('a match not yet started writes nothing until the result is saved', async () => { + const onSubmit = vi.fn().mockResolvedValue(undefined); + render( {}} onSubmit={onSubmit} />); + fireEvent.click(screen.getByTestId('engi-aka-inc')); + await act(async () => { vi.advanceTimersByTime(400); }); + expect(onSubmit).not.toHaveBeenCalled(); + }); + + it('closing a running match with a change still pending saves it and asks nothing', async () => { + const onSubmit = vi.fn().mockResolvedValue({ status: 'running' }); + const onClose = vi.fn(); + render(); + fireEvent.click(screen.getByTestId('engi-aka-inc')); + await act(async () => { fireEvent.click(screen.getByTestId('engi-close-btn')); }); + expect(onSubmit).toHaveBeenCalledWith({ flagsA: 1, flagsB: 0, status: 'running' }); + expect(onClose).toHaveBeenCalled(); + }); +}); 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 598dc96b6..43af4571f 100644 --- a/web-mobile/js/__tests__/render/admin_shiaijo.render.test.jsx +++ b/web-mobile/js/__tests__/render/admin_shiaijo.render.test.jsx @@ -1021,3 +1021,251 @@ describe('a barred match is skipped by every auto-pick (bc-cse)', () => { expect(buttonText.some((t) => /call to court/i.test(t))).toBe(false); }); }); + +// 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) { + const feed = { current: initial }; + window.tournamentMatches = () => feed.current; + window.filterMatchesByCourt = (matches) => matches; + const prev = { + fetch: window.API.fetchCourtMatches, + sub: window.API.subscribeToEvents, + revert: window.API.revertMatchToQueue, + }; + window.API.fetchCourtMatches = vi.fn().mockImplementation(() => Promise.resolve([{ id: 'c1', name: 'Cup' }])); + window.API.subscribeToEvents = () => () => {}; + window.API.revertMatchToQueue = vi.fn().mockResolvedValue(true); + const showToast = vi.fn(); + let utils; + await act(async () => { + utils = render( + + ); + }); + return { + utils, + feed, + showToast, + editorMatch: () => { const el = utils.queryByTestId('score-editor'); return el ? el.getAttribute('data-match') : null; }, + refresh: async () => { await act(async () => { utils.getByRole('button', { name: /refresh/i }).click(); }); }, + restore: () => { + window.API.fetchCourtMatches = prev.fetch; + window.API.subscribeToEvents = prev.sub; + window.API.revertMatchToQueue = prev.revert; + }, + }; +} + +const courtSide = (id, name) => ({ id, name }); +const courtMatch = (id, status, over = {}) => ({ + id, compId: 'c1', compName: 'Cup', status, phase: 'pool', poolName: 'Pool A', court: 'A', + scheduledAt: `09:0${id.slice(-1)}`, + sideA: courtSide(`${id}-a`, `Aka ${id}`), sideB: courtSide(`${id}-b`, `Shiro ${id}`), + ...over, +}); + +// bc-sbq (operator ruling 2026-09-26): a match sent back to the queue keeps +// its score, so Send back to queue is always offered, its confirm says the +// score is kept, and picking another bout sends a scored one back rather than +// refusing to switch (a mistaken switch is undone with one tap on its Start). +describe('Send back to queue keeps the score and is always offered (bc-sbq)', () => { + const tapSendBack = async (c) => { + await act(async () => { c.utils.getByRole('button', { name: /send back to queue/i }).click(); }); + return c.utils.container.querySelector('.shiaijo-move-confirm[role="dialog"]'); + }; + + it('is offered on a team match with a fought bout, and says the score is kept', async () => { + const c = await mountCourt([courtMatch('m1', 'running', { + compKind: 'team', teamSize: 3, reopenPending: true, + subResults: [ + { position: 1, sideA: 'A1', sideB: 'B1', ipponsA: ['M'], ipponsB: [], winner: 'A1' }, + { position: 2, sideA: 'A2', sideB: 'B2', ipponsA: [], ipponsB: [] }, + ], + })]); + try { + expect(c.editorMatch()).toBe('m1'); + const dialog = await tapSendBack(c); + expect(dialog.textContent).toContain('Any score entered is kept'); + expect(dialog.textContent).not.toMatch(/discard|lost/i); + } finally { c.restore(); } + }); + + it('sends a scored bout back when confirmed', async () => { + const c = await mountCourt([courtMatch('m1', 'running', { ipponsA: ['M'], hansokuB: 1 })]); + try { + await tapSendBack(c); + const confirm = [...c.utils.container.querySelectorAll('.shiaijo-move-confirm button')] + .find((b) => /send back to queue/i.test(b.textContent)); + await act(async () => { confirm.click(); }); + expect(window.API.revertMatchToQueue).toHaveBeenCalledWith('c1', 'm1', ''); + } finally { c.restore(); } + }); + + it('picking another bout sends a scored one back to the queue and starts the pick', async () => { + const c = await mountCourt([courtMatch('m1', 'running', { ipponsA: ['M'] }), courtMatch('m2', 'scheduled')]); + try { + const startNext = c.utils.getAllByRole('button').find((b) => /^start/i.test(b.textContent.trim())); + expect(startNext, 'the Up Next card offers Start for m2').toBeTruthy(); + await act(async () => { startNext.click(); }); + expect(window.API.revertMatchToQueue).toHaveBeenCalledWith('c1', 'm1', ''); + expect(c.showToast).not.toHaveBeenCalledWith('Finish or correct the bout in progress first', 'error'); + } finally { c.restore(); } + }); +}); + +// bc-strt: the console opens a match its Up next card started while the court +// list still says scheduled. It tells the editor so (`started`), for the +// scheduled snapshot the start was made from and no other, so a point struck +// before the refetch saves at once and a later send back to the queue is not +// mistaken for a start. +describe('the editor is told a match the console started is running (bc-strt)', () => { + it('passes started for the snapshot it started, and only that one', async () => { + // The probe is module-wide: drop a previous test's editor, or the refused + // start below (which mounts no editor) would read that one's props. + probe.props = null; + const side = (id, name) => ({ id, name }); + const m1 = { + id: 'm1', compId: 'c1', compName: 'Cup', status: 'scheduled', phase: 'pool', poolName: 'Pool A', + court: 'A', scheduledAt: '09:00', sideA: side('p1', 'Yamada'), sideB: side('p2', 'Tanaka'), modifiedAt: 100, + }; + const m2 = { ...m1, id: 'm2', scheduledAt: '09:05', sideA: side('p3', 'Sato'), sideB: side('p4', 'Kato') }; + let current = [m1, m2]; + window.tournamentMatches = () => current; + const prevFetch = window.API.fetchCourtMatches; + const prevSub = window.API.subscribeToEvents; + window.API.fetchCourtMatches = vi.fn().mockImplementation(() => Promise.resolve([{ id: 'c1', name: 'Cup' }])); + window.API.subscribeToEvents = () => () => {}; + let startResult = { applied: false, reason: 'clock_skew' }; + const onEditScore = vi.fn().mockImplementation(() => Promise.resolve(startResult)); + try { + let utils; + await act(async () => { utils = renderPage(makeMinimalTournament(), 'A', { onEditScore }); }); + const start = async () => { + const card = utils.container.querySelector('.shiaijo-upnext__card'); + const btn = [...card.querySelectorAll('button')].find((b) => /start match/i.test(b.textContent)); + await act(async () => { btn.click(); }); + }; + const refresh = async () => { await act(async () => { utils.getByRole('button', { name: /refresh/i }).click(); }); }; + + // A refused start is no start. + await start(); + expect(onEditScore).toHaveBeenCalledTimes(1); + expect(probe.props?.started || false).toBe(false); + + startResult = { applied: true }; + await start(); + expect(probe.props.match.id).toBe('m1'); + expect(probe.props.match.status, 'the list has not caught up yet').toBe('scheduled'); + expect(probe.props.started).toBe(true); + + // The list catches up; later the match goes back to the queue, which + // stamps it. That scheduled snapshot was not started. + current = [{ ...m1, status: 'running', modifiedAt: 200 }, m2]; + await refresh(); + current = [{ ...m1, modifiedAt: 300 }, m2]; + await refresh(); + expect(probe.props.match.id, 'the pick still holds the panel').toBe('m1'); + expect(probe.props.match.status).toBe('scheduled'); + expect(probe.props.started).toBe(false); + } finally { + window.API.fetchCourtMatches = prevFetch; + window.API.subscribeToEvents = prevSub; + } + }); +}); + +// The console used to refetch its whole court feed for every event, and a +// running match broadcasts every saved point. A running match's score update +// changes only its own row, so the pushed result is shown at once, a push +// older than the row it would replace is ignored, and the refetch that still +// follows (for what a push cannot carry) happens once per burst. +describe('the court console shows a pushed running score at once', () => { + it('applies the push to its row, ignores a stale one, and refetches once per burst', async () => { + vi.useFakeTimers(); + const side = (id, name) => ({ id, name }); + const running = (o = {}) => ({ + id: 'm1', status: 'running', phase: 'pool', poolName: 'Pool A', court: 'A', scheduledAt: '09:00', + sideA: side('p1', 'Yamada'), sideB: side('p2', 'Tanaka'), ipponsA: [], ipponsB: [], modifiedAt: 100, ...o, + }); + const comp = { id: 'c1', name: 'Cup', status: 'pools', poolMatches: [running()] }; + let emit = () => {}; + const prev = { + fetch: window.API.fetchCourtMatches, sub: window.API.subscribeToEvents, + tm: window.tournamentMatches, fbc: window.filterMatchesByCourt, + }; + const fetchCourtMatches = vi.fn().mockResolvedValue([comp]); + window.API.fetchCourtMatches = fetchCourtMatches; + window.API.subscribeToEvents = (cb) => { emit = cb; return () => {}; }; + window.tournamentMatches = (t) => (t.competitions || []) + .flatMap((c) => (c.poolMatches || []).map((m) => ({ ...m, compId: c.id, compName: c.name }))); + window.filterMatchesByCourt = (matches) => matches; + try { + await act(async () => { renderPage(makeMinimalTournament(), 'A'); }); + await act(async () => { await Promise.resolve(); }); + expect(probe.props.match?.id).toBe('m1'); + const fetchesBefore = fetchCourtMatches.mock.calls.length; + const push = (o) => act(async () => { + emit({ type: 'match_updated', data: { competitionId: 'c1', matchId: 'm1', result: running(o) } }); + }); + + await push({ ipponsA: ['M'], modifiedAt: 200 }); + expect(probe.props.match.ipponsA, 'shown at once, before any refetch').toEqual(['M']); + await push({ ipponsA: ['M', 'K'], modifiedAt: 300 }); + await push({ ipponsA: [], modifiedAt: 250 }); + expect(probe.props.match.ipponsA, 'a push older than the row is not applied').toEqual(['M', 'K']); + expect(fetchCourtMatches.mock.calls.length, 'nothing refetched yet').toBe(fetchesBefore); + + await act(async () => { vi.advanceTimersByTime(700); }); + await act(async () => { await Promise.resolve(); }); + expect(fetchCourtMatches.mock.calls.length, 'one refetch for the burst of three').toBe(fetchesBefore + 1); + // That refetch answers with the row as it stood before the pushes (it + // read the data before the writes committed): the newer row stays. + expect(probe.props.match.ipponsA, 'a refetch older than the row does not put the old score back').toEqual(['M', 'K']); + + // A refetch as new as the row, or newer, replaces it as before. + fetchCourtMatches.mockResolvedValue([{ ...comp, poolMatches: [running({ ipponsA: ['M', 'K'], ipponsB: ['D'], modifiedAt: 400 })] }]); + await act(async () => { emit({ type: 'schedule_updated', data: {} }); }); + await act(async () => { vi.advanceTimersByTime(700); }); + await act(async () => { await Promise.resolve(); }); + expect(probe.props.match.ipponsB, 'a newer refetch is taken').toEqual(['D']); + } finally { + window.API.fetchCourtMatches = prev.fetch; + window.API.subscribeToEvents = prev.sub; + window.tournamentMatches = prev.tm; + window.filterMatchesByCourt = prev.fbc; + vi.useRealTimers(); + } + }); +}); + +// Recording a withdrawal advances the court like any other decision (operator +// ruling 2026-09-26). This court's list does not show the withdrawn +// competitor's matches barred until it is refetched, so the advance passes +// over them itself rather than starting one the server then refuses. +describe('the court moves on past the withdrawn competitor', () => { + it('starts the next match the withdrawn competitor is not in', async () => { + const side = (id, name) => ({ id, name }); + const row = (id, a, b, status, at) => ({ + id, compId: 'c1', compName: 'Cup', status, phase: 'pool', poolName: 'Pool A', court: 'A', scheduledAt: at, sideA: a, sideB: b, + }); + const aoki = side('p1', 'Aoki'); + const run = row('m1', aoki, side('p2', 'Baba'), 'running', '09:00'); + window.tournamentMatches = () => [run, row('m2', side('p3', 'Endo'), aoki, 'scheduled', '09:05'), row('m3', side('p4', 'Doi'), side('p5', 'Fujii'), 'scheduled', '09:10')]; + window.filterMatchesByCourt = (matches) => matches; + const onEditScore = vi.fn().mockResolvedValue({ status: 'running' }); + await act(async () => { renderPage(makeMinimalTournament(), 'A', { onEditScore }); }); + expect(probe.props.match?.id).toBe('m1'); + await act(async () => { + // What /decision answers with: the stored match, sides as bare names. + await probe.props.onAfterDecision({ + id: 'm1', sideA: 'Aoki', sideB: 'Baba', sideAId: 'p1', sideBId: 'p2', winner: 'Baba', winnerId: 'p2', + status: 'completed', decision: 'kiken-voluntary', decisionBy: 'aka', + }); + }); + expect(onEditScore).toHaveBeenCalledTimes(1); + expect(onEditScore.mock.calls[0][1], "Aoki's next match is passed over").toBe('m3'); + }); +}); diff --git a/web-mobile/js/__tests__/render/decision_form_closes.render.test.jsx b/web-mobile/js/__tests__/render/decision_form_closes.render.test.jsx new file mode 100644 index 000000000..7fd3e94bc --- /dev/null +++ b/web-mobile/js/__tests__/render/decision_form_closes.render.test.jsx @@ -0,0 +1,93 @@ +// A recorded decision closes its form, in both editors, whatever the host +// does next. A host that cannot move on (the court's next match refused to +// start, e.g. another competition's match is running there) leaves the +// decided match on screen; the form left open there offered Record again and +// hid the recorded decision. Before the Remaining matches panel was removed, +// a withdrawal closed its form on the panel's own path; a no-show never did. + +import React from 'react'; +import { render, act, fireEvent, screen } from '@testing-library/react'; +import { describe, it, expect, vi, beforeAll, afterAll, beforeEach } from 'vitest'; +import { installWindowStubs } from '../helpers/stub_globals.js'; + +const STUBBED_GLOBALS = { + isHikiwake: () => false, + arraysEqual: (a, b) => a.length === b.length && a.every((v, i) => v === b[i]), + isKikenDecision: (d) => d === 'kiken' || d === 'kiken-voluntary' || d === 'kiken-injury', + isTextEntry: () => false, + isInteractiveTarget: () => false, + confirmDialog: vi.fn().mockResolvedValue(true), + resolveRoundIndex: () => 0, + API: {}, + AdminLineupHelpers: { rosterFor: vi.fn().mockReturnValue([]) }, + compMatches: () => [], + compMatchesForCompetition: () => [], + 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 stored = { status: 'completed', decision: 'kiken-voluntary', decisionBy: 'aka', winner: 'Tanaka', applied: true }; + +beforeEach(() => { + window.API = { + fetchCompetitionDetails: vi.fn().mockResolvedValue(null), + recordScore: vi.fn().mockResolvedValue(undefined), + recordDecision: vi.fn().mockResolvedValue(stored), + putMatchLineup: vi.fn(), + }; +}); + +const individual = { + id: 'Pool A-1', compId: 'c1', status: 'running', phase: 'pool', poolName: 'Pool A', court: 'A', + sideA: { id: 'p1', name: 'Yamada' }, sideB: { id: 'p2', name: 'Tanaka' }, + ipponsA: [], ipponsB: [], hansokuA: 0, hansokuB: 0, +}; +const team = { + id: 'Pool A-1', compId: 'c1', status: 'running', phase: 'pool', poolName: 'Pool A', court: 'A', + compKind: 'team', teamSize: 3, + sideA: { id: 't1', name: 'Kyoto' }, sideB: { id: 't2', name: 'Osaka' }, +}; + +// Records a withdrawal by Aka through the editor's own form, with a host +// whose advance does not move the editor on. +async function recordWithdrawal(match) { + const onAfterDecision = vi.fn().mockResolvedValue(undefined); + await act(async () => { + render(); + }); + await act(async () => { fireEvent.click(screen.getByRole('button', { name: /^Kiken . Voluntary$/ })); }); + const form = document.querySelector('form.decision-prompt'); + expect(form, 'the withdrawal form opened').not.toBeNull(); + fireEvent.click(form.querySelector('input[name="decision-side"][value="aka"]')); + await act(async () => { fireEvent.submit(form); }); + return onAfterDecision; +} + +describe('a recorded decision closes its form', () => { + for (const [label, match] of [['individual', individual], ['team', team]]) { + it(`${label}: the form closes once the withdrawal is stored`, async () => { + const onAfterDecision = await recordWithdrawal(match); + expect(window.API.recordDecision).toHaveBeenCalledTimes(1); + expect(onAfterDecision).toHaveBeenCalledTimes(1); + expect(document.querySelector('form.decision-prompt')).toBeNull(); + }); + + it(`${label}: the form stays open when the withdrawal did not land`, async () => { + window.API.recordDecision = vi.fn().mockResolvedValue({ queued: true }); + const onAfterDecision = await recordWithdrawal(match); + expect(onAfterDecision).not.toHaveBeenCalled(); + expect(document.querySelector('form.decision-prompt')).not.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 new file mode 100644 index 000000000..a3b6fd99c --- /dev/null +++ b/web-mobile/js/__tests__/render/individual_editor_hantei_correction.render.test.jsx @@ -0,0 +1,116 @@ +import React from 'react'; +import { render, fireEvent, act, screen } from '@testing-library/react'; +import { describe, it, expect, vi, beforeAll, afterAll } from 'vitest'; +import { installWindowStubs } from '../helpers/stub_globals.js'; + +// bc-htcr: a hantei verdict recorded for the wrong side could not be +// corrected. The hantei buttons wrote straight through, with no correction +// reason, so the server refused the completed -> completed write +// ("correcting a completed match result requires a non-empty +// correctionReason") and the wrong verdict stood. There was no other way: +// Save correction is off while the hantei is set, and cancelling the hantei +// leaves a tie a knockout refuses. A hantei tap on a completed match is a +// correction like any other, so it asks for the reason first and carries it. + +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), + resolveRoundIndex: () => 0, + 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()); + +// A finished knockout bout, 1-1, that the judges gave to Yamada (AKA). +const hanteiForYamada = (overrides = {}) => ({ + id: 'm1', + compId: 'c1', + status: 'completed', + 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, + decidedByHantei: true, + winner: { id: 'p1', name: 'Yamada' }, + ...overrides, +}); + +const confirmReason = async () => { + const prompt = document.querySelector('.reason-prompt'); + expect(prompt, 'the correction asks for its reason').toBeTruthy(); + await act(async () => { fireEvent.click(prompt.querySelector('button[type="submit"]')); }); +}; + +describe('correcting a hantei verdict to the other side (bc-htcr)', () => { + it('asks for the reason first, then writes the new verdict with it', async () => { + const onSubmit = vi.fn().mockResolvedValue(undefined); + render(); + + await act(async () => { fireEvent.click(screen.getByTestId('scoring-modal-hantei-shiro')); }); + expect(onSubmit, 'nothing is written before the reason is given').not.toHaveBeenCalled(); + + await confirmReason(); + expect(onSubmit).toHaveBeenCalledTimes(1); + const patch = onSubmit.mock.calls[0][0]; + expect(patch.winner).toMatchObject({ id: 'p2', name: 'Tanaka' }); + expect(patch.decidedByHantei).toBe(true); + expect(patch.status).toBe('completed'); + expect(patch.correctionReason, 'the reason rides the hantei write').toBeTruthy(); + // The tied scoreline the verdict rests on is kept. + expect(patch.ipponsA).toEqual(['M']); + expect(patch.ipponsB).toEqual(['K']); + }); + + it('cancelling the reason writes nothing', async () => { + const onSubmit = vi.fn().mockResolvedValue(undefined); + render(); + + await act(async () => { fireEvent.click(screen.getByTestId('scoring-modal-hantei-shiro')); }); + const cancel = [...document.querySelectorAll('.reason-prompt button')].find((b) => b.textContent === 'Cancel'); + await act(async () => { fireEvent.click(cancel); }); + expect(document.querySelector('.reason-prompt')).toBeNull(); + expect(onSubmit).not.toHaveBeenCalled(); + }); + + it('a verdict on a match still being fought is not a correction and asks nothing', async () => { + const onSubmit = vi.fn().mockResolvedValue(undefined); + render(); + + await act(async () => { fireEvent.click(screen.getByTestId('scoring-modal-hantei-arm')); }); + await act(async () => { fireEvent.click(screen.getByTestId('scoring-modal-hantei-aka')); }); + 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/kachinuki_cleared_bout_reaches_server.render.test.jsx b/web-mobile/js/__tests__/render/kachinuki_cleared_bout_reaches_server.render.test.jsx new file mode 100644 index 000000000..989f1f30a --- /dev/null +++ b/web-mobile/js/__tests__/render/kachinuki_cleared_bout_reaches_server.render.test.jsx @@ -0,0 +1,204 @@ +// bc-kclr: a mark taken back on a kachinuki bout came back. The team editor's +// patch drops every kachinuki row subBoutHasBeenPlayed rejects (so untouched +// trailing positions never reach the wire), and a bout the operator cleared +// back to 0-0 is exactly such a row, so the write simply left it out; the +// server merge (mergeKachinukiSubResults) keeps any stored row a payload omits, +// so the stored point survived every save and came back on reload. A row the +// operator cleared, whose stored row still has a result, is now sent as an +// explicit empty row, which the merge stores; rows never played stay out. + +import React from 'react'; +import { render, act, fireEvent, screen } from '@testing-library/react'; +import { describe, it, expect, vi, beforeAll, afterAll, beforeEach, afterEach } from 'vitest'; +import { installWindowStubs } from '../helpers/stub_globals.js'; + +const 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), + resolveRoundIndex: () => 0, + API: { + fetchCompetitionDetails: vi.fn().mockResolvedValue({ + id: 'comp1', config: { format: 'mixed', teamMatchType: 'kachinuki', naginata: false, players: [] }, + }), + recordScore: vi.fn().mockResolvedValue(undefined), + recordDaihyosen: vi.fn(), + removeDaihyosen: vi.fn(), + putMatchLineup: vi.fn(), + recordDecision: vi.fn(), + }, + AdminLineupHelpers: { rosterFor: vi.fn().mockReturnValue([]) }, + compMatches: () => [], + Term: ({ children }) => {children}, + GlossaryHint: ({ name }) => , +}; + +let restoreGlobals; +let ScoreEditorModal; + +beforeAll(async () => { + restoreGlobals = installWindowStubs(STUBBED_GLOBALS); + await import('../../admin_scoring_modal.jsx'); + ScoreEditorModal = window.ScoreEditorModal; +}); + +afterAll(() => restoreGlobals()); + +beforeEach(() => { vi.useFakeTimers(); }); +afterEach(() => { vi.useRealTimers(); }); + +const kachinukiMatch = (subResults, modifiedAt = 0) => ({ + id: 'k1', compId: 'comp1', status: 'running', phase: 'pool', poolName: 'Pool 1', court: 'A', modifiedAt, + compKind: 'team', teamSize: 3, compFormat: 'mixed', teamMatchType: 'kachinuki', + sideA: { id: 'team-A', name: 'Team A' }, + sideB: { id: 'team-B', name: 'Team B' }, + subResults, +}); + +async function mount(match) { + const onSubmit = vi.fn().mockResolvedValue(undefined); + await act(async () => { + render(); + }); + return onSubmit; +} + +// Tap the scored mark itself (the editor's "tap a scored mark to clear it"). +async function tapMark(side, letter) { + const mark = [...document.querySelectorAll(`.team-sub-match:not(.team-sub-match--readonly) .tsm-center-pts--${side} .editor-side__pt`)] + .find((b) => b.textContent === letter); + expect(mark, `a scored ${letter} on ${side}`).toBeTruthy(); + await act(async () => { fireEvent.click(mark); }); +} + +const rowAt = (patch, position) => (patch.subResults || []).find((s) => s.position === position); + +describe('a mark taken back on a kachinuki bout reaches the server (bc-kclr)', () => { + it('the autosave carries the cleared bout as an explicit empty row', async () => { + const onSubmit = await mount(kachinukiMatch([ + { position: 1, sideA: 'Aka 1', sideB: 'Shiro 1', ipponsA: ['M'], ipponsB: [] }, + ])); + await tapMark('aka', 'M'); + await act(async () => { vi.advanceTimersByTime(400); }); + + expect(onSubmit).toHaveBeenCalled(); + const patch = onSubmit.mock.calls[onSubmit.mock.calls.length - 1][0]; + expect(patch.status).toBe('running'); + const bout1 = rowAt(patch, 1); + expect(bout1, 'the cleared bout must be on the wire, or the stored point survives').toBeTruthy(); + expect(bout1.ipponsA).toEqual([]); + expect(bout1.ipponsB).toEqual([]); + expect(bout1.decision || '').toBe(''); + // Positions nobody ever played stay off the wire, as before. + expect(rowAt(patch, 2)).toBeUndefined(); + expect(rowAt(patch, 3)).toBeUndefined(); + }); + + // The case the first fix missed, found in the browser: the court feed lags + // the editor, so when a point is struck and taken back within a moment, + // this board never saw the point stored. The clear must be written anyway. + const strikeAkaMen = async () => { + await act(async () => { fireEvent.keyDown(window, { key: 'M', shiftKey: true }); }); + }; + const lastPatch = (onSubmit) => onSubmit.mock.calls[onSubmit.mock.calls.length - 1][0]; + + it('writes the clear even when the court feed never showed the stored point', async () => { + const onSubmit = await mount(kachinukiMatch([])); + await strikeAkaMen(); + await act(async () => { vi.advanceTimersByTime(400); }); + expect(rowAt(lastPatch(onSubmit), 1).ipponsA, 'precondition: the point was written').toEqual(['M']); + + await tapMark('aka', 'M'); + await act(async () => { vi.advanceTimersByTime(400); }); + const bout1 = rowAt(lastPatch(onSubmit), 1); + expect(bout1, 'the bout being fought is always sent').toBeTruthy(); + expect(bout1.ipponsA).toEqual([]); + }); + + it('a lagging snapshot carrying the point does not put it back', async () => { + // Each write is stamped when it is sent (server_clock.jsx's frame; the + // offset is 0 here), and a snapshot of it carries that stamp. + const sentAt = []; + const onSubmit = vi.fn().mockImplementation(() => { sentAt.push(Date.now()); return Promise.resolve(undefined); }); + let utils; + const el = (match) => ; + await act(async () => { utils = render(el(kachinukiMatch([]))); }); + await strikeAkaMen(); + await act(async () => { vi.advanceTimersByTime(400); }); + await tapMark('aka', 'M'); + // The feed now delivers the snapshot of the EARLIER write, with the point: + // written before the clear, so it cannot know about it. + await act(async () => { vi.advanceTimersByTime(150); }); + await act(async () => { + utils.rerender(el(kachinukiMatch([{ position: 1, sideA: '', sideB: '', ipponsA: ['M'], ipponsB: [] }], sentAt[0]))); + }); + const filled = document.querySelectorAll('.team-sub-match:not(.team-sub-match--readonly) .tsm-center-pts--aka .editor-side__pt--filled'); + expect(filled.length, 'the point taken back stays taken back').toBe(0); + await act(async () => { vi.advanceTimersByTime(400); }); + expect(rowAt(lastPatch(onSubmit), 1).ipponsA, 'and the write carries the clear, not the stale point').toEqual([]); + }); + + // Review finding: the guard used to be a time window, so a change another + // device saved to the same bout right after the operator's edit was never + // adopted. It is keyed on the snapshot's stamp now: written after the edit, + // it is news, and a row the operator has put back where it was follows it. + it('a change saved on another device after the edit is adopted', async () => { + const onSubmit = vi.fn().mockResolvedValue(undefined); + let utils; + const el = (match) => ; + await act(async () => { utils = render(el(kachinukiMatch([]))); }); + await strikeAkaMen(); + await act(async () => { vi.advanceTimersByTime(400); }); + await tapMark('aka', 'M'); + await act(async () => { vi.advanceTimersByTime(400); }); + // Another device records Shiro's K on bout 1, well inside the old window. + await act(async () => { + utils.rerender(el(kachinukiMatch([{ position: 1, sideA: '', sideB: '', ipponsA: [], ipponsB: ['K'] }], Date.now()))); + }); + const shiroFilled = document.querySelectorAll('.team-sub-match:not(.team-sub-match--readonly) .tsm-center-pts--shiro .editor-side__pt--filled'); + expect(shiroFilled.length, "the other device's point shows").toBe(1); + }); + + // The close prompt existed because a cleared bout could not be flushed + // (runningPatchDropsAnEdit). Now it can, so closing flushes it and asks + // nothing. + it('closing right after the clear flushes it with no discard prompt', async () => { + window.confirmDialog = vi.fn().mockResolvedValue(false); + const onSubmit = vi.fn().mockResolvedValue(undefined); + const onClose = vi.fn(); + await act(async () => { + render(); + }); + await tapMark('aka', 'M'); + await act(async () => { fireEvent.click(screen.getByText('✕ Close')); }); + + expect(window.confirmDialog).not.toHaveBeenCalled(); + expect(onClose).toHaveBeenCalledTimes(1); + expect(onSubmit).toHaveBeenCalledTimes(1); + expect(rowAt(onSubmit.mock.calls[0][0], 1).ipponsA).toEqual([]); + }); + + it('End match after clearing the current bout stores no stale mark on it', async () => { + const onSubmit = await mount(kachinukiMatch([ + { position: 1, sideA: 'Aka 1', sideB: 'Shiro 1', ipponsA: ['M', 'M'], ipponsB: [], winner: 'Aka 1' }, + { position: 2, sideA: 'Aka 1', sideB: 'Shiro 2', ipponsA: [], ipponsB: ['K'] }, + ])); + await tapMark('shiro', 'K'); + const end = document.querySelector('[data-testid="kachinuki-end-match-button"]'); + expect(end).toBeTruthy(); + await act(async () => { fireEvent.click(end); }); + await act(async () => { fireEvent.click(end); }); + await act(async () => { vi.advanceTimersByTime(400); }); + + const completed = onSubmit.mock.calls.map((c) => c[0]).find((p) => p.status === 'completed'); + expect(completed, 'End match wrote the encounter').toBeTruthy(); + const bout2 = rowAt(completed, 2); + expect(bout2, 'the cleared current bout is sent, so the server drops it as unscored').toBeTruthy(); + expect(bout2.ipponsB).toEqual([]); + }); +}); 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 9ab058c28..1f29bb57e 100644 --- a/web-mobile/js/__tests__/render/kachinuki_reopen.render.test.jsx +++ b/web-mobile/js/__tests__/render/kachinuki_reopen.render.test.jsx @@ -224,7 +224,7 @@ describe('kachinuki reopen: a later knockout match already fought', () => { await waitFor(() => expect(window.API.reopenMatch).toHaveBeenCalledTimes(2)); expect(window.API.reopenMatch.mock.calls[1][3]).toEqual({ reason: '', force: true }); await waitFor(() => expect(screen.getByTestId('kachinuki-reopen-notice').textContent) - .toBe('Match 3 was reopened: it must be fought and scored again.')); + .toBe('Match 3 was reopened with its points kept: check them, then finish it again.')); expect(onClose).not.toHaveBeenCalled(); // The notice is read once the match is running again, which is when the @@ -237,7 +237,7 @@ describe('kachinuki reopen: a later knockout match already fought', () => { }); expect(screen.queryByTestId('kachinuki-reopen-button')).toBeNull(); expect(screen.getByTestId('kachinuki-reopen-notice').textContent) - .toBe('Match 3 was reopened: it must be fought and scored again.'); + .toBe('Match 3 was reopened with its points kept: check them, then finish it again.'); expect(onClose).not.toHaveBeenCalled(); }); @@ -308,12 +308,12 @@ describe('kachinuki reopen: a busy court gets a remedy, not a dead end', () => { const panel = await screen.findByTestId('kachinuki-reopen-conflict'); // The blocking match, by court and by competitors: acting on an opaque - // match id alone is how the wrong score gets wiped. + // match id alone is how the wrong match gets taken off the court. await waitFor(() => expect(panel.textContent).toContain('Team D vs Team C')); expect(panel.textContent).toContain('Shiaijo A'); // The warning is on screen, in words, BEFORE the operator commits. expect(screen.getByTestId('kachinuki-reopen-conflict-warning').textContent) - .toContain('clears any score already entered for it'); + .toContain('keeps any score already entered for it'); // bc-rawm: the server's own raw sentence is GONE, not shown alongside the // heading -- it named the same internal id and told the operator to // "finish that match before reopening", the opposite of this panel's own @@ -659,6 +659,69 @@ describe('kachinuki Encho is offered only on the current tied bout', () => { const hint = screen.queryByTestId('kachinuki-end-hint'); if (hint) expect(hint.textContent).not.toContain('Encho keeps'); }); + + // bc-kenu (operator ruling 2026-09-26): the operator is tired and clumsy, so + // every mistake is undoable in one step. Bout mode hides the overtime + // stepper, so a mistaken or double-tapped Encho needs its own undo. + it('takes back a double-tapped Encho one period at a time, back to the tie', async () => { + await renderEditor({ + match: completedKachinukiMatch({ status: 'running', winner: null, subResults: [tiedBout(1)] }), + }); + expect(screen.queryByTestId('kachinuki-encho-undo-button')).toBeNull(); + await act(async () => { fireEvent.click(screen.getByTestId('kachinuki-encho-button')); }); + await act(async () => { fireEvent.click(screen.getByTestId('kachinuki-encho-button')); }); + expect(document.body.textContent).toContain('(E)'); + + const undo = () => act(async () => { fireEvent.click(screen.getByTestId('kachinuki-encho-undo-button')); }); + await undo(); + expect(document.body.textContent, 'one period still on').toContain('(E)'); + await undo(); + expect(document.body.textContent).not.toContain('(E)'); + expect(screen.queryByTestId('kachinuki-encho-undo-button')).toBeNull(); + // Back to the tie it was: Encho is offered again. + expect(screen.getByTestId('kachinuki-encho-button')).toBeTruthy(); + }); + + it('undoing the only period of a 0-0 tie restores the Tie', async () => { + await renderEditor({ + match: completedKachinukiMatch({ + status: 'running', winner: null, + subResults: [{ position: 1, sideA: 'A1', sideB: 'B1', ipponsA: [], ipponsB: [], decision: 'hikiwake' }], + }), + }); + await act(async () => { fireEvent.click(screen.getByTestId('kachinuki-encho-button')); }); + await act(async () => { fireEvent.click(screen.getByTestId('kachinuki-encho-undo-button')); }); + expect(screen.getByTestId('scoring-modal-tie-button').textContent).toContain('✓'); + expect(screen.getByTestId('kachinuki-encho-button'), 'still a tied bout, so Encho is offered again').toBeTruthy(); + }); + + it('offers no undo once a point has been struck in encho', async () => { + await renderEditor({ + match: completedKachinukiMatch({ status: 'running', winner: null, subResults: [tiedBout(1)] }), + }); + await act(async () => { fireEvent.click(screen.getByTestId('kachinuki-encho-button')); }); + expect(screen.getByTestId('kachinuki-encho-undo-button')).toBeTruthy(); + await act(async () => { fireEvent.keyDown(window, { key: 'K', shiftKey: true }); }); + expect(screen.queryByTestId('kachinuki-encho-undo-button')).toBeNull(); + }); + + // Operator ruling 2026-09-26: whether a tied pair fights on is the + // operator's call, the app records it. A taisho-only rule (bc-kten) was + // tried the day before and reversed, so with both lineups in force a tie + // between the FIRST fighters still offers Encho, and the tap records it. + it('offers Encho on a tie between any pair, lineups in force', async () => { + const lineupFor = (p) => ({ positions: { 1: `${p}1`, 2: `${p}2`, 3: `${p}3` } }); + window.API.fetchMatchLineup = vi.fn().mockImplementation(async (_c, teamId) => ( + teamId === 'team-A' ? lineupFor('A') : teamId === 'team-B' ? lineupFor('B') : null + )); + await renderEditor({ + match: completedKachinukiMatch({ status: 'running', winner: null, subResults: [tiedBout(1)] }), + }); + await waitFor(() => expect(window.API.fetchMatchLineup).toHaveBeenCalledTimes(2)); + const encho = screen.getByTestId('kachinuki-encho-button'); + await act(async () => { fireEvent.click(encho); }); + expect(document.body.textContent).toContain('(E)'); + }); }); // mp-gmcg review: a per-bout kachinuki encho also bumped the MATCH-level diff --git a/web-mobile/js/__tests__/render/msb_centre_sep.render.test.jsx b/web-mobile/js/__tests__/render/msb_centre_sep.render.test.jsx index 283348cf8..cc0ae8003 100644 --- a/web-mobile/js/__tests__/render/msb_centre_sep.render.test.jsx +++ b/web-mobile/js/__tests__/render/msb_centre_sep.render.test.jsx @@ -21,7 +21,7 @@ describe('msb-vs centre cell (centre separator)', () => { it('renders a plain "vs" msb-sep span when there is no draw/hantei mark', () => { const { container } = render( - + ); const vs = container.querySelector('.msb-vs'); expect(vs).toBeTruthy(); @@ -45,7 +45,7 @@ describe('msb-vs centre cell (centre separator)', () => { it('renders the draw X and no msb-sep on a hikiwake → no separator collision', () => { const { container } = render( - + ); const vs = container.querySelector('.msb-vs'); expect(vs.textContent).toContain('X'); diff --git a/web-mobile/js/__tests__/render/msb_name_cell.render.test.jsx b/web-mobile/js/__tests__/render/msb_name_cell.render.test.jsx index fa0323fd4..28a4178bc 100644 --- a/web-mobile/js/__tests__/render/msb_name_cell.render.test.jsx +++ b/web-mobile/js/__tests__/render/msb_name_cell.render.test.jsx @@ -114,7 +114,7 @@ describe('msb name cell (dojo line + winner emphasis)', () => { // win), which is why an ordinary 2-0 needs this. it('marks the winner name on a plain ippon win', () => { const { container } = render( - ); expect(akaCell(container).className).toContain('msb-name--win'); @@ -123,7 +123,7 @@ describe('msb name cell (dojo line + winner emphasis)', () => { it('marks the Shiro side when Shiro won', () => { const { container } = render( - ); expect(shiroCell(container).className).toContain('msb-name--win'); @@ -132,7 +132,7 @@ describe('msb name cell (dojo line + winner emphasis)', () => { it('marks neither side while the match is undecided', () => { const { container } = render( - + ); expect(container.querySelectorAll('.msb-name--win')).toHaveLength(0); }); @@ -144,7 +144,7 @@ describe('msb name cell (dojo line + winner emphasis)', () => { const { container } = render( ); expect(container.querySelectorAll('.msb-name--win')).toHaveLength(0); diff --git a/web-mobile/js/__tests__/render/queued_match_reads_not_started.render.test.jsx b/web-mobile/js/__tests__/render/queued_match_reads_not_started.render.test.jsx new file mode 100644 index 000000000..016f4fdff --- /dev/null +++ b/web-mobile/js/__tests__/render/queued_match_reads_not_started.render.test.jsx @@ -0,0 +1,227 @@ +// bc-sbq (operator ruling 2026-09-26): a match sent back to the queue KEEPS +// its score on the server (points, penalties, overtime, team bouts), but while +// it waits in the queue every screen shows it as NOT STARTED: the pairing, a +// plain "vs", empty slots, no penalty triangle, no IV/PW. The kept score +// reappears once the match runs again. matchShowsScore (match_shows_score.jsx) is the +// one predicate; the shared scoreboard components gate themselves on it, so +// every host inherits the rule. Each describe below pins one host: the queued +// case hides the kept score, and the SAME match running shows it, so the +// hiding is attributable to the status and not to a fixture that never +// carried a score. +// +// bracket.jsx is imported first: it publishes the window.* globals +// (matchMiddleMark, matchScoreStr, boutMiddle, ...) the scoreboard and the +// viewer rows reach bracket.jsx through. matchShowsScore itself is a leaf +// every host imports directly. +import React from 'react'; +import { render, cleanup } from '@testing-library/react'; +import { describe, it, expect, beforeAll, afterAll, afterEach, vi } from 'vitest'; +import { installWindowStubs } from '../helpers/stub_globals.js'; + +let IndividualScore, TeamScoreboard, MatchDetailCard, VSchedItem, MatchCard, LobbyMatchCell, TvDisplay; + +beforeAll(async () => { + await import('../../bracket.jsx'); + MatchCard = window.MatchCard; + ({ IndividualScore, TeamScoreboard } = await import('../../match_scoreboard.jsx')); + ({ MatchDetailCard, VSchedItem } = await import('../../viewer_match.jsx')); + ({ LobbyMatchCell } = await import('../../display_lobby.jsx')); + ({ TvDisplay } = await import('../../display.jsx')); +}); + +afterEach(() => cleanup()); + +const aka = { id: 'p-aka', name: 'Aoki Taro' }; +const shiro = { id: 'p-shiro', name: 'Endo Goro' }; + +// An individual match holding a kept Men for Aka, one outstanding hansoku for +// Shiro, and an overtime: everything a queued match can carry. +const individual = (status) => ({ + id: 'Pool A-0', compId: 'c1', court: 'A', phase: 'bracket', round: 'QF', status, + sideA: aka, sideB: shiro, + ipponsA: ['M'], ipponsB: [], hansokuA: 0, hansokuB: 1, + encho: { periodCount: 1 }, +}); + +// Team bouts already fought: Shiro's fighter won bout 1 with a Men. +const teamSubs = () => [ + { position: 1, sideA: 'Aka One', sideB: 'Shiro One', winner: 'Shiro One', ipponsA: [], ipponsB: ['M'] }, +]; +const team = (status) => ({ + id: 'Pool A-0', compId: 'c1', court: 'A', phase: 'bracket', round: 'QF', status, + compKind: 'team', teamSize: 3, + sideA: { id: 't-aka', name: 'Red Team' }, sideB: { id: 't-shiro', name: 'White Team' }, + subResults: teamSubs(), + teamResult: { shiroIV: 1, akaIV: 0, shiroPW: 1, akaPW: 0 }, +}); + +const slotText = (container) => + [...container.querySelectorAll('.msb-slot')].map((s) => s.textContent).join(''); + +describe('IndividualScore: a queued match reads not started', () => { + it('shows no kept point, no penalty mark and a plain "vs" while scheduled', () => { + const { container } = render(); + expect(slotText(container)).not.toContain('M'); + expect(container.querySelector('.msb-hansoku')).toBeNull(); + expect(container.querySelector('.msb-vs').textContent).toBe('vs'); + // The pairing itself still shows. + expect(container.textContent).toContain('Aoki Taro'); + expect(container.textContent).toContain('Endo Goro'); + }); + + it('shows the kept score again once the match runs', () => { + const { container } = render(); + expect(slotText(container)).toContain('M'); + expect(container.querySelector('[data-testid="foul-mark-b"]').textContent).toBe('▲'); + expect(container.querySelector('.msb-vs').textContent).toBe('(E)'); + }); +}); + +describe('TeamScoreboard: a queued team match reads not started', () => { + const props = (status) => ({ + subResults: teamSubs(), teamResult: { shiroIV: 1, akaIV: 0, shiroPW: 1, akaPW: 0 }, + teamSize: 3, showDH: true, variant: 'card', status, + shiroName: 'White Team', akaName: 'Red Team', matchSideA: 'Red Team', matchSideB: 'White Team', + }); + const summaryFigures = (container) => + [...container.querySelectorAll('[data-testid="team-summary"] .msb-sum')].map((s) => s.textContent); + + it('shows no fought bout, IV/PW 0 and no Daihyosen row while scheduled', () => { + const { container } = render(); + expect(slotText(container).replace(/IV|PW|\d/g, '')).not.toContain('M'); + expect(summaryFigures(container)).toEqual(['IV0', 'PW0', 'PW0', 'IV0']); + // showDH arrives true from a host that read the REAL bouts; the blanked + // aggregate reads tied, so without the gate this would say "pending". + expect(container.textContent).not.toContain('Daihyosen pending'); + }); + + it('shows the fought bout and the aggregate once the match runs', () => { + const { container } = render(); + expect(slotText(container)).toContain('M'); + expect(summaryFigures(container)).toEqual(['IV1', 'PW1', 'PW0', 'IV0']); + }); +}); + +describe('public match card (MatchDetailCard): a queued match reads not started', () => { + it('individual: no kept point and no penalty mark while scheduled', () => { + const { container } = render(); + expect(slotText(container)).not.toContain('M'); + expect(container.querySelector('.msb-hansoku')).toBeNull(); + expect(container.querySelector('.msb-vs').textContent).toBe('vs'); + }); + + it('individual: the kept point and penalty mark return once running', () => { + const { container } = render(); + expect(slotText(container)).toContain('M'); + expect(container.querySelector('[data-testid="foul-mark-b"]')).not.toBeNull(); + }); + + it('team: no fought bout and IV/PW 0 while scheduled, both back once running', () => { + const queued = render(); + expect(slotText(queued.container).replace(/IV|PW|\d/g, '')).not.toContain('M'); + expect(queued.container.querySelector('[data-testid="team-summary"]').textContent).not.toContain('IV1'); + cleanup(); + const running = render(); + expect(slotText(running.container)).toContain('M'); + expect(running.container.querySelector('[data-testid="team-summary"]').textContent).toContain('IV1'); + }); +}); + +describe('TV board: an up-next match that kept its score reads not started', () => { + const comp = (matches, extra = {}) => ({ + id: 'c1', name: 'Cup', format: 'mixed', withZekkenName: false, + poolMatches: matches, bracket: { rounds: [] }, ...extra, + }); + + it('individual pool board: the promoted up-next row shows no kept point or penalty', () => { + const m = { ...individual('scheduled'), id: 'Pool A-0' }; + const { container } = render( + + ); + expect(container.querySelector('[data-testid="individual-score"]')).not.toBeNull(); + expect(slotText(container)).not.toContain('M'); + expect(container.querySelector('.msb-hansoku')).toBeNull(); + }); + + it('team board: the headline IV/PW of an up-next match is 0/0, and the running one shows its own', () => { + const teamComp = (status) => comp([{ ...team(status), id: 'Pool A-0' }], { kind: 'team', teamSize: 3 }); + const queued = render(); + expect(queued.container.querySelector('[data-testid="headline-ivpw-shiro"]').textContent).toBe('IV 0PW 0'); + expect(slotText(queued.container)).not.toContain('M'); + cleanup(); + const running = render(); + expect(running.container.querySelector('[data-testid="headline-ivpw-shiro"]').textContent).toBe('IV 1PW 1'); + }); +}); + +describe('lobby board: a queued team encounter reads "vs"', () => { + const cell = (status) => render( + + +
+ ); + + it('shows the plain "vs" while scheduled and the IV/PW once running', () => { + expect(cell('scheduled').container.querySelector('[data-testid="lobby-team-centre"]').textContent).toBe('vs'); + cleanup(); + expect(cell('running').container.querySelector('[data-testid="lobby-team-centre"]').textContent).toContain('IV'); + }); +}); + +describe('bracket card: a queued match carries no (E) chip', () => { + it('hides the kept overtime while scheduled and shows it once running', () => { + const queued = render(); + expect(queued.container.querySelector('.bc-encho')).toBeNull(); + cleanup(); + const running = render(); + expect(running.container.querySelector('.bc-encho')).not.toBeNull(); + }); +}); + +describe('public schedule row (VSchedItem): a queued match reads "vs"', () => { + it('shows the plain "vs" for a scheduled match that kept an overtime', () => { + const { container } = render(); + expect(container.querySelector('.vsched-item__vs').textContent).toBe('vs'); + expect(container.textContent).not.toContain('(E)'); + }); +}); + +describe('admin Scores tab: a queued match shows no foul mark', () => { + const queued = { ...individual('scheduled'), id: 'Pool A-0', scheduledAt: '10:00' }; + const running = { ...individual('running'), id: 'Pool A-1', scheduledAt: '10:10', + sideA: { id: 'p-a2', name: 'Sato Ken' }, sideB: { id: 'p-b2', name: 'Ito Rei' } }; + const STUBS = { + ScoreEditorModal: () =>
, + AdminTopbar: ({ children }) =>
{children}
, + Breadcrumbs: () => null, + CourtPicker: () => null, + getScoreBtnClass: () => 'test-score-open', + filterMatchesByCourt: (matches) => matches, + tournamentMatches: () => [], + compMatches: () => [queued, running], + confirmDialog: vi.fn().mockResolvedValue(true), + pluralize: (n, s, p) => `${n} ${n === 1 ? s : (p || s + 's')}`, + API: { fetchCompetitionDetails: vi.fn().mockResolvedValue(null) }, + }; + let restore, AdminScoreEditor; + beforeAll(async () => { + window.scrollTo = vi.fn(); + restore = installWindowStubs(STUBS); + ({ AdminScoreEditor } = await import('../../admin_schedule_score_editor.jsx')); + }); + afterAll(() => restore()); + + it('marks the running row\'s penalty and not the queued row\'s', () => { + const { container } = render( + + ); + const rows = [...container.querySelectorAll('.score-edit-row')]; + const queuedRow = rows.find((r) => r.textContent.includes('Aoki Taro')); + const runningRow = rows.find((r) => r.textContent.includes('Sato Ken')); + expect(queuedRow).toBeTruthy(); + expect(runningRow).toBeTruthy(); + expect(queuedRow.querySelector('[data-testid="foul-mark-b"]')).toBeNull(); + expect(queuedRow.querySelector('.score-edit-row__scoreval').textContent).toBe('vs'); + expect(runningRow.querySelector('[data-testid="foul-mark-b"]').textContent).toBe('▲'); + }); +}); diff --git a/web-mobile/js/__tests__/render/recorded_withdrawal_reopen.render.test.jsx b/web-mobile/js/__tests__/render/recorded_withdrawal_reopen.render.test.jsx index eedb88e4d..841b595db 100644 --- a/web-mobile/js/__tests__/render/recorded_withdrawal_reopen.render.test.jsx +++ b/web-mobile/js/__tests__/render/recorded_withdrawal_reopen.render.test.jsx @@ -17,7 +17,7 @@ import { describe, it, expect, vi, beforeAll, afterAll, beforeEach } from 'vites import { installWindowStubs } from '../helpers/stub_globals.js'; import { readStylesheet, cssBlock } from '../helpers/source.js'; import { DOWNSTREAM_KNOCKOUT_REOPEN_CANCELLED } from '../../write_result.jsx'; -import { withdrawalLabel } from '../../admin_scoring_shared.jsx'; +import { withdrawalLabel, LATER_MATCHES_HOLD_MS } from '../../admin_scoring_shared.jsx'; const STUBBED_GLOBALS = { isHikiwake: () => false, @@ -189,7 +189,7 @@ describe.each([ expect(screen.queryByTestId('recorded-withdrawal')).toBeNull(); expect(onClose).not.toHaveBeenCalled(); expect(screen.getByTestId('withdrawal-reopen-notice').textContent) - .toBe('Match 2 was reopened: it must be fought and scored again.'); + .toBe('Match 2 was reopened with its points kept: check them, then finish it again.'); // A new result on the same match (here another kiken) ends that reopen: // the notice described it, not the match now in front of the operator. @@ -218,7 +218,7 @@ describe.each([ await mount(fixture()); await clearWithdrawal(); await waitFor(() => expect(screen.getByTestId('withdrawal-reopen-conflict')).toBeTruthy()); - expect(screen.getByTestId('withdrawal-reopen-conflict-warning').textContent).toMatch(/clears any score already entered/); + expect(screen.getByTestId('withdrawal-reopen-conflict-warning').textContent).toMatch(/keeps any score already entered/); await act(async () => { fireEvent.click(screen.getByTestId('withdrawal-reopen-requeue-button')); }); expect(window.API.requeueBlockerAndReopen).toHaveBeenCalledWith( 'comp1', fixture().id, 'comp1', 'm-blk', 'secret', { reason: '', force: false }, @@ -491,15 +491,16 @@ describe('Clear withdrawal and reopen: pool match feeding a knockout', () => { }); // bc-rawm: withdrawalInForce widened to the match-level DEFAULT-WIN class -// (RemainingMatchesPanel.award writes a whole-match `decision: "fusensho"` -// for a scheduled match against a competitor already withdrawn elsewhere, -// since fusenpai there is refused with 409 already_ineligible). Same -// RecordedWithdrawal door as kiken/fusenpai, different copy: the Recorded -// line names the winner, and the reopen button reads "Clear default win and -// reopen". +// (a match-level default win writes a whole-match `decision: "fusensho"` for +// a match against a competitor already withdrawn elsewhere, recorded from +// that match's own notice: BarredMatchNotice / the queue row's Record +// default win). Same RecordedWithdrawal door as kiken/fusenpai, different +// copy: the Recorded line names the winner, and the reopen button reads +// "Clear default win and reopen". describe('a match-level fusensho default win (bc-rawm)', () => { // Yamada (aka) got the default win when Tanaka (shiro) withdrew elsewhere - // and RemainingMatchesPanel awarded this still-scheduled match to Yamada. + // and the opponent's default win was recorded for this still-scheduled + // match against Yamada. const defaultWin = (o = {}) => individualWithdrawal({ decision: 'fusensho', decisionBy: 'shiro', decisionReason: 'auto: Tanaka withdrawn', ipponsA: ['○', '○'], ipponsB: [], @@ -520,9 +521,9 @@ describe('a match-level fusensho default win (bc-rawm)', () => { // the barred competitor (Tanaka) is still barred -- almost always true, // since whatever barred them elsewhere is still on record -- so the // consequence says that plainly rather than promising "it goes back to - // running". No fetchCompetitorStatuses stub here (the untouched default - // API mock), so the reinstate clause is correctly absent: unknown reads - // as "not reinstateable", never as a promise that may not hold. + // running". No withdrawnStatus on the match here, so the reinstate clause + // is correctly absent: unknown reads as "not reinstateable", never as a + // promise that may not hold. it('the reopen consequence says the match queues again and the default win must be re-recorded', async () => { await mount(defaultWin()); const text = screen.getByTestId('clear-withdrawal-consequence').textContent; @@ -531,14 +532,10 @@ describe('a match-level fusensho default win (bc-rawm)', () => { expect(text).not.toContain('goes back to running'); }); - // bc-cse: when a live check finds Tanaka reinstateable (kiken-injury, - // still ineligible), the consequence ALSO offers that route. + // bc-cse: when the server's stamp says Tanaka is reinstateable + // (kiken-injury, still ineligible), the consequence ALSO offers that route. it('offers the reinstate route too when the barred competitor is still ineligible and reinstateable', async () => { - window.API.fetchCompetitorStatuses = vi.fn().mockResolvedValue([ - { playerId: 'p2', eligible: false, reinstateable: true }, - ]); - await mount(defaultWin()); - await waitFor(() => expect(screen.getByTestId('clear-withdrawal-consequence').textContent).toContain('reinstate')); + await mount(defaultWin({ withdrawnStatus: { eligible: false, reinstateable: true } })); const text = screen.getByTestId('clear-withdrawal-consequence').textContent; expect(text).toBe('The match goes back to the queue. Tanaka is still withdrawn, so record the default win again, or reinstate Tanaka first to fight it.'); }); @@ -546,11 +543,7 @@ describe('a match-level fusensho default win (bc-rawm)', () => { // Not reinstateable (e.g. kiken-voluntary) or no longer barred: no // reinstate clause either way -- only kiken-injury/still-ineligible earns it. it('does not offer reinstate when the status says it is not reinstateable', async () => { - window.API.fetchCompetitorStatuses = vi.fn().mockResolvedValue([ - { playerId: 'p2', eligible: false, reinstateable: false }, - ]); - await mount(defaultWin()); - await waitFor(() => expect(window.API.fetchCompetitorStatuses).toHaveBeenCalled()); + await mount(defaultWin({ withdrawnStatus: { eligible: false } })); const text = screen.getByTestId('clear-withdrawal-consequence').textContent; expect(text).not.toContain('reinstate'); }); @@ -561,11 +554,7 @@ describe('a match-level fusensho default win (bc-rawm)', () => { // queue, and the copy says that instead of promising the queue and a // re-recorded default win. it('says the match reopens in progress when the barred competitor is eligible again', async () => { - window.API.fetchCompetitorStatuses = vi.fn().mockResolvedValue([ - { playerId: 'p2', eligible: true }, - ]); - await mount(defaultWin()); - await waitFor(() => expect(window.API.fetchCompetitorStatuses).toHaveBeenCalled()); + await mount(defaultWin({ withdrawnStatus: { eligible: true } })); const text = screen.getByTestId('clear-withdrawal-consequence').textContent; expect(text).toBe('Tanaka can fight again, so the match reopens in progress. Score it and finish it as usual.'); expect(text).not.toContain('goes back to the queue'); @@ -593,11 +582,12 @@ describe('a match-level fusensho default win (bc-rawm)', () => { }); // bc-cse: clearing THIS withdrawal never touches a LATER match the same -// withdrawn competitor's default win (RemainingMatchesPanel.award) already -// closed -- that match keeps its own recorded result. The consequence lists it +// withdrawn competitor's default win already closed (recorded from that +// match's own notice, BarredMatchNotice / the queue row's Record default +// win) -- that match keeps its own recorded result. The consequence lists it // as a consequence (a warning, not a lock, per the 2026-09-24 ruling): the // operator can still reopen it separately to fight it. Fetched the same way -// RemainingMatchesPanel finds Tanaka/Kyoto's other matches. +// (fetch + compMatchesForCompetition) that finds Tanaka/Kyoto's other matches. describe('Clear withdrawal and reopen: a later default win from the same withdrawal (bc-cse)', () => { it('lists a later default win as a consequence, named by its scores-list label plus the pairing', async () => { const laterMatch = { @@ -653,3 +643,79 @@ describe('Clear withdrawal and reopen: a later default win from the same withdra expect(screen.queryByTestId('clear-withdrawal-default-win-consequences')).toBeNull(); }); }); + +// bc-kfup: a fusenpai recorded on a remaining match of a competitor who had +// already withdrawn elsewhere chains onto that bar and records no status of +// its own. Clearing it restores nobody, so the server sends the match back +// to the queue while the bar holds (engine.reopenTargetStatus), the same as +// a fusensho. The editor tells the two fusenpai apart by the competitor's +// status, which the server stamps on the match (withdrawnStatus): an +// ordinary fusenpai's names THIS match. +describe('clearing a fusenpai chained onto an earlier withdrawal (bc-kfup)', () => { + it('says the match goes back to the queue, not that the competitor can compete again', async () => { + await mount(individualWithdrawal({ withdrawnStatus: { eligible: false, matchId: 'Pool A-0' } })); + await waitFor(() => expect(screen.getByTestId('clear-withdrawal-reopen').textContent).toBe('Clear default win')); + const text = screen.getByTestId('clear-withdrawal-consequence').textContent; + expect(text).toBe('The match goes back to the queue. Tanaka is still withdrawn, so record the default win again.'); + expect(text).not.toContain('can compete again'); + }); + + it('keeps the ordinary copy for a fusenpai that barred the competitor itself', async () => { + await mount(individualWithdrawal({ withdrawnStatus: { eligible: false, matchId: 'm-r1-0' } })); + expect(screen.getByTestId('clear-withdrawal-reopen').textContent).toBe('Clear withdrawal and reopen'); + expect(screen.getByTestId('clear-withdrawal-consequence').textContent).toContain('can compete again'); + }); + + // Review finding: a kiken whose competitor was reinstated and then withdrew + // again elsewhere no longer names this match, so its reopen also goes to + // the queue. The copy must not promise "back to running". + it('a kiken whose competitor is barred by another match also says the match goes back to the queue', async () => { + await mount(individualWithdrawal({ decision: 'kiken-voluntary', withdrawnStatus: { eligible: false, matchId: 'Pool A-5' } })); + await waitFor(() => expect(screen.getByTestId('clear-withdrawal-reopen').textContent).toBe('Clear withdrawal')); + expect(screen.getByTestId('clear-withdrawal-consequence').textContent).toContain('goes back to the queue'); + }); + + // The later-matches fetch is best-effort with no timeout. A request that + // hangs must not keep the correction disabled: the hold is capped. + it('releases the clear after a short cap when the later-matches request hangs', async () => { + window.API.fetchCompetitionDetails = vi.fn().mockImplementation(() => new Promise(() => {})); + await mount(individualWithdrawal()); + expect(screen.getByTestId('clear-withdrawal-reopen').disabled).toBe(true); + await waitFor(() => expect(screen.getByTestId('clear-withdrawal-reopen').disabled).toBe(false), + { timeout: LATER_MATCHES_HOLD_MS + 1500 }); + }); + + it("lists a chained fusenpai among the original withdrawal's later default wins", async () => { + window.API.fetchCompetitionDetails = vi.fn().mockResolvedValue({ config: {} }); + window.compMatchesForCompetition = () => [{ + id: 'Pool A-3', compId: 'comp1', status: 'completed', phase: 'pool', poolName: 'Pool A', matchNumber: 3, + decision: 'fusenpai', decisionBy: 'aka', + sideA: { id: 'p2', name: 'Tanaka' }, sideB: { id: 'p3', name: 'Suzuki' }, + }]; + try { + await mount(individualWithdrawal({ decision: 'kiken-voluntary' })); + await waitFor(() => expect(screen.getByTestId('clear-withdrawal-default-win-Pool A-3')).toBeTruthy()); + expect(screen.getByTestId('clear-withdrawal-default-win-Pool A-3').textContent).toContain('keeps its default win'); + // Clearing this kiken moves the bar onto that no-show (the server's + // standingWithdrawalOf), so the match goes to the queue, not the court. + expect(screen.getByTestId('clear-withdrawal-consequence').textContent).toBe( + 'The match goes back to the queue. Tanaka also did not appear for a later match, listed below, so they are still withdrawn because of it.'); + expect(screen.getByTestId('clear-withdrawal-reopen').textContent).toBe('Clear withdrawal'); + } finally { + window.compMatchesForCompetition = STUBBED_GLOBALS.compMatchesForCompetition; + } + }); + + // A chained no-show in the later list changes the copy, so the one-tap + // clear waits for that list rather than acting under the wrong promise. + it('holds the clear until the later matches are in', async () => { + // The editor fetches the competition from more than one place; release + // every call. + const pending = []; + window.API.fetchCompetitionDetails = vi.fn().mockImplementation(() => new Promise((r) => { pending.push(r); })); + await mount(individualWithdrawal({ decision: 'kiken-voluntary' })); + expect(screen.getByTestId('clear-withdrawal-reopen').disabled).toBe(true); + await act(async () => { pending.forEach((r) => r({ config: {} })); }); + await waitFor(() => expect(screen.getByTestId('clear-withdrawal-reopen').disabled).toBe(false)); + }); +}); diff --git a/web-mobile/js/__tests__/render/remaining_matches_panel_award.render.test.jsx b/web-mobile/js/__tests__/render/remaining_matches_panel_award.render.test.jsx deleted file mode 100644 index 545665bbb..000000000 --- a/web-mobile/js/__tests__/render/remaining_matches_panel_award.render.test.jsx +++ /dev/null @@ -1,152 +0,0 @@ -// bc-rawm: RemainingMatchesPanel.award referenced an undefined `wname` -// (ReferenceError on every click, admin_scoring_shared.jsx). The unit suite -// never caught it because it mounts no real React and never invokes the -// click handler; only a REAL render, actually clicked, executes the function -// body. This drives the individual editor through a genuine kiken flow (the -// only door that opens the panel) and then clicks "Award default win to -// opponent", pinning both that it no longer throws and the exact -// recordDecision arguments: fusensho (not fusenpai, which the server refuses -// with 409 already_ineligible for a competitor already withdrawn elsewhere), -// decisionBy naming the withdrawn competitor's side, and a decisionReason -// naming them. - -import React from 'react'; -import { render, act, screen, fireEvent, waitFor } from '@testing-library/react'; -import { describe, it, expect, vi, beforeAll, afterAll, beforeEach } from 'vitest'; -import { installWindowStubs } from '../helpers/stub_globals.js'; - -const STUBBED_GLOBALS = { - isHikiwake: () => false, - arraysEqual: (a, b) => a.length === b.length && a.every((v, i) => v === b[i]), - // Unlike the config-matrix / withdrawal-reopen render suites (which stub - // this false because their kiken fixtures are already-completed matches, - // never SUBMITTED through it), this test drives a LIVE kiken submit and - // depends on the branch this predicate gates (makeSubmitDecision, - // admin_scoring_shared.jsx): true routes to RemainingMatchesPanel, the one - // door this file exists to test. - isKikenDecision: (k) => typeof k === 'string' && k.startsWith('kiken'), - isTextEntry: () => false, - isInteractiveTarget: () => false, - confirmDialog: vi.fn().mockResolvedValue(true), - resolveRoundIndex: () => 0, - API: {}, - AdminLineupHelpers: { rosterFor: vi.fn().mockReturnValue([]) }, - compMatches: () => [], - poolLabel: (m) => m.poolName || 'Pool', - 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()); - -// Yamada (aka) vs Tanaka (shiro), fresh 0-0, a knockout Round 1 match. -const match = { - id: 'm-r1-0', compId: 'comp1', status: 'running', phase: 'knockout', round: 'Round 1', court: 'A', - sideA: { id: 'p1', name: 'Yamada' }, - sideB: { id: 'p2', name: 'Tanaka' }, - ipponsA: [], ipponsB: [], hansokuA: 0, hansokuB: 0, -}; - -// A second, still-SCHEDULED match of Tanaka's, in a different pool, for the -// panel to list and the operator to award. -const remaining = { - id: 'Pool A-3', compId: 'comp1', phase: 'pool', poolName: 'Pool A', court: 'B', status: 'scheduled', - sideA: { id: 'p2', name: 'Tanaka' }, - sideB: { id: 'p3', name: 'Suzuki' }, -}; - -beforeEach(() => { - window.confirmDialog = vi.fn().mockResolvedValue(true); - window.API = { - fetchCompetitionDetails: vi.fn().mockResolvedValue({ id: 'comp1', config: { format: 'knockout', naginata: false, players: [] } }), - recordScore: vi.fn().mockResolvedValue(undefined), - recordDaihyosen: vi.fn(), - removeDaihyosen: vi.fn(), - putMatchLineup: vi.fn(), - recordDecision: vi.fn().mockResolvedValue({ id: 'm-r1-0', status: 'completed' }), - }; - window.compMatchesForCompetition = vi.fn().mockReturnValue([remaining]); -}); - -async function mount(props = {}) { - let view; - await act(async () => { - view = render(); - }); - return view; -} - -async function recordTanakaKiken() { - await mount(); - await act(async () => { fireEvent.click(screen.getByTestId('scoring-modal-kiken-voluntary-button')); }); - // defaultSide="shiro" on DecisionPrompt: Tanaka (sideB) is the withdrawn - // side without touching the radio. - const record = [...document.querySelectorAll('.decision-prompt button')].find((b) => b.textContent === 'Record'); - await act(async () => { fireEvent.click(record); }); - await waitFor(() => expect(window.API.recordDecision).toHaveBeenCalledTimes(1)); -} - -describe('RemainingMatchesPanel.award (bc-rawm)', () => { - it('lists the remaining scheduled match and awards fusensho with the withdrawn side and a reason naming them', async () => { - await recordTanakaKiken(); - - // The panel opened, fetched, and listed Tanaka's remaining match. - await waitFor(() => expect(screen.getByText('Suzuki')).toBeTruthy()); - const award = screen.getByText('Award default win to opponent'); - expect(award.disabled).toBe(false); - - // The click must not throw (the ReferenceError this test exists to - // catch would surface here) and must record the right decision. - await act(async () => { fireEvent.click(award); }); - - expect(window.API.recordDecision).toHaveBeenCalledTimes(2); - const [compId, matchId, body, password] = window.API.recordDecision.mock.calls[1]; - expect(compId).toBe('comp1'); - expect(matchId).toBe('Pool A-3'); - // fusensho, not fusenpai: fusenpai would be refused (409 - // already_ineligible) for a competitor already withdrawn elsewhere. - expect(body.decision).toBe('fusensho'); - // Tanaka occupies sideA of the remaining match, so decisionBy names AKA - // -- the withdrawn competitor's side, exactly as fusenpai's did. - expect(body.decisionBy).toBe('aka'); - expect(body.decisionReason).toBe('auto: Tanaka withdrawn'); - expect(password).toBe('secret'); - - // The awarded match drops off the list; nothing left to walk. - await waitFor(() => expect(screen.queryByText('Suzuki')).toBeNull()); - expect(screen.getByText('No remaining scheduled matches.')).toBeTruthy(); - }); - - it('shows no remaining matches when Tanaka has none scheduled', async () => { - window.compMatchesForCompetition = vi.fn().mockReturnValue([]); - await recordTanakaKiken(); - await waitFor(() => expect(screen.getByText('No remaining scheduled matches.')).toBeTruthy()); - }); - - // bc-cse: notLandedBanner, not writeWasSuperseded alone -- that predicate - // is TRUE for both the superseded AND clock_skew refusal, so a clock_skew - // award used to read "a newer result for this match is already recorded", - // which sends the operator to check a result that was never written. - it('a clock_skew refusal on the award shows the clock copy, not the superseded one', async () => { - await recordTanakaKiken(); - await waitFor(() => expect(screen.getByText('Suzuki')).toBeTruthy()); - - window.API.recordDecision = vi.fn().mockResolvedValue({ applied: false, reason: 'clock_skew' }); - const award = screen.getByText('Award default win to opponent'); - await act(async () => { fireEvent.click(award); }); - - await waitFor(() => expect(screen.getByText(/this device's clock was out of step with the server/)).toBeTruthy()); - expect(screen.queryByText(/a newer result for this match is already recorded/)).toBeNull(); - // Not dropped from the list: the operator can retry once resynced. - expect(screen.getByText('Suzuki')).toBeTruthy(); - }); -}); diff --git a/web-mobile/js/__tests__/render/score_editor_dismiss_prompt.render.test.jsx b/web-mobile/js/__tests__/render/score_editor_dismiss_prompt.render.test.jsx index 3eb99d960..bac5c552c 100644 --- a/web-mobile/js/__tests__/render/score_editor_dismiss_prompt.render.test.jsx +++ b/web-mobile/js/__tests__/render/score_editor_dismiss_prompt.render.test.jsx @@ -211,10 +211,11 @@ describe('bc-dscn: team editor', () => { expect(onClose).toHaveBeenCalledTimes(1); }); - // Under kachinuki the running patch drops every unplayed row, so a fighter - // picked on the current bout (it rides the bout, not a lineup PUT) is not - // carried by a flush: closing would lose it, so the prompt stays. - it('a RUNNING kachinuki match with a fighter picked on the unscored current bout still prompts', async () => { + // A fighter picked on the current bout rides the bout, not a lineup PUT. + // The running patch used to drop every unplayed row, so the pick was not + // carried by a flush and closing had to prompt. Since bc-kclr the current + // bout is always sent, so closing flushes the pick and asks nothing. + it('a RUNNING kachinuki match with a fighter picked on the unscored current bout flushes the pick on close', async () => { const savedAPI = window.API; window.API = { ...savedAPI, @@ -248,9 +249,12 @@ describe('bc-dscn: team editor', () => { window.confirmDialog = vi.fn().mockResolvedValue(false); await clickClose(); - expect(window.confirmDialog).toHaveBeenCalledTimes(1); - expect(utils.onClose).not.toHaveBeenCalled(); - expect(utils.onSubmit).not.toHaveBeenCalled(); + expect(window.confirmDialog).not.toHaveBeenCalled(); + expect(utils.onClose).toHaveBeenCalledTimes(1); + expect(utils.onSubmit).toHaveBeenCalledTimes(1); + const bout6 = utils.onSubmit.mock.calls[0][0].subResults.find((r) => r.position === 6); + expect(bout6, 'the current bout carries the pick').toBeTruthy(); + expect(bout6.sideAMemberId).toBe('m-fresh'); } finally { window.API = savedAPI; } diff --git a/web-mobile/js/__tests__/render/score_editor_mount_sites.render.test.jsx b/web-mobile/js/__tests__/render/score_editor_mount_sites.render.test.jsx index 8b6e0e69f..57fd1d651 100644 --- a/web-mobile/js/__tests__/render/score_editor_mount_sites.render.test.jsx +++ b/web-mobile/js/__tests__/render/score_editor_mount_sites.render.test.jsx @@ -135,22 +135,120 @@ function runningMatch(overrides = {}) { }; } +// ── the five surfaces, each driven until its editor mounts ─────────────────── +// onEditScore is the write every admin host routes through; the public +// self-run surface calls window.API.recordScore itself instead. + +async function mountShiaijo(onEditScore = vi.fn()) { + window.tournamentMatches = () => [runningMatch()]; + await act(async () => { + render( + + ); + }); +} + +async function mountPools(onEditScore = vi.fn()) { + const rawPoolMatch = { + id: 'Pool 1-1', status: 'scheduled', + sideA: { id: 'p1', name: 'Yamada' }, sideB: { id: 'p2', name: 'Tanaka' }, + }; + // PoolsViewer probe: expose the surface's onMatchClick as a button. + window.PoolsViewer = (props) => ( + + ); + // rawPoolMatch must be IN poolMatches below: the real PoolsViewer renders + // its rows from that list, and AdminPools now re-resolves the open match + // out of it every render rather than keeping the clicked object, so a + // fixture clicking a match the list does not contain opens nothing. + await act(async () => { + render( + + ); + }); + await act(async () => { + fireEvent.click(screen.getByTestId('open-pool-match')); + }); +} + +async function mountBracket(onEditScore = vi.fn()) { + // Raw bracket.rounds entries carry no phase/pool stamps: strip them so the + // panel's own enrichment (phase: "bracket") is what reaches the editor. + const bm = runningMatch({ id: 'bm1' }); + delete bm.phase; + delete bm.poolName; + // BracketTree probe: expose the tree's onMatchClick as a button. + window.BracketTree = (props) => ( + + ); + await act(async () => { + render( + + ); + }); + await act(async () => { + fireEvent.click(screen.getByTestId('open-bracket-match')); + }); +} + +async function mountSchedule(onEditScore = vi.fn(), matches = [runningMatch(), runningMatch({ id: 'm2', status: 'scheduled' })]) { + window.compMatches = () => matches; + await act(async () => { + render( + + ); + }); +} + +async function mountSelfRun() { + await act(async () => { + render( + + ); + }); + await act(async () => { + fireEvent.click(screen.getByRole('button', { name: 'Report result' })); + }); +} + // ── 1. admin_shiaijo.jsx: inline court console ─────────────────────────────── describe('mount site: admin_shiaijo.jsx (court console)', () => { it('wires Finish+StartNext and after-decision advance; inline, cannot close, NO Prev/Next', async () => { - window.tournamentMatches = () => [runningMatch()]; - await act(async () => { - render( - - ); - }); + await mountShiaijo(); expect(screen.getByTestId('probe-score-editor')).toBeTruthy(); expect(wiringOf(probe.props)).toEqual({ onSubmit: 'fn', @@ -173,34 +271,7 @@ describe('mount site: admin_shiaijo.jsx (court console)', () => { describe('mount site: admin_pools.jsx (pools tab)', () => { it('wires a bare modal: NO chaining (onSubmitAndNext null) and NO Prev/Next', async () => { - const rawPoolMatch = { - id: 'Pool 1-1', status: 'scheduled', - sideA: { id: 'p1', name: 'Yamada' }, sideB: { id: 'p2', name: 'Tanaka' }, - }; - // PoolsViewer probe: expose the surface's onMatchClick as a button. - window.PoolsViewer = (props) => ( - - ); - // rawPoolMatch must be IN poolMatches below: the real PoolsViewer renders - // its rows from that list, and AdminPools now re-resolves the open match - // out of it every render rather than keeping the clicked object, so a - // fixture clicking a match the list does not contain opens nothing. - await act(async () => { - render( - - ); - }); - await act(async () => { - fireEvent.click(screen.getByTestId('open-pool-match')); - }); + await mountPools(); expect(screen.getByTestId('probe-score-editor')).toBeTruthy(); expect(wiringOf(probe.props)).toEqual({ onSubmit: 'fn', @@ -223,31 +294,7 @@ describe('mount site: admin_pools.jsx (pools tab)', () => { describe('mount site: admin_competition_bracket.jsx (bracket panel)', () => { it('wires an inline no-chain editor; close only when the match is complete', async () => { - // Raw bracket.rounds entries carry no phase/pool stamps: strip them so the - // panel's own enrichment (phase: "bracket") is what reaches the editor. - const bm = runningMatch({ id: 'bm1' }); - delete bm.phase; - delete bm.poolName; - // BracketTree probe: expose the tree's onMatchClick as a button. - window.BracketTree = (props) => ( - - ); - await act(async () => { - render( - - ); - }); - await act(async () => { - fireEvent.click(screen.getByTestId('open-bracket-match')); - }); + await mountBracket(); expect(screen.getByTestId('probe-score-editor')).toBeTruthy(); expect(wiringOf(probe.props)).toEqual({ onSubmit: 'fn', @@ -273,20 +320,7 @@ describe('mount site: admin_competition_bracket.jsx (bracket panel)', () => { describe('mount site: admin_schedule_score_editor.jsx (Scores tab)', () => { it('wires the FULL navigation set: Prev/Next, Finish+StartNext, after-decision', async () => { - const m1 = runningMatch(); - const m2 = runningMatch({ id: 'm2', status: 'scheduled' }); - window.compMatches = () => [m1, m2]; - await act(async () => { - render( - - ); - }); + await mountSchedule(); // Open the first (running) match via its row button. const openBtns = document.querySelectorAll('button.test-score-open'); expect(openBtns.length).toBe(2); @@ -312,20 +346,79 @@ describe('mount site: admin_schedule_score_editor.jsx (Scores tab)', () => { }); it('onSubmitAndNext is NULL when no same-court active match remains', async () => { - window.compMatches = () => [runningMatch()]; - await act(async () => { - render( - - ); - }); + await mountSchedule(vi.fn(), [runningMatch()]); await act(async () => { fireEvent.click(document.querySelector('button.test-score-open')); }); const w = wiringOf(probe.props); expect(w.onSubmitAndNext).toBe('null'); expect(w.onAfterDecision).toBe('null'); }); + + // The automatic next match stays in the open match's competition (operator + // ruling 2026-09-26: an operator view never switches competition or court by + // itself). This page lists every competition, so another competition's match + // can come next on the same court; Prev/Next, which the operator taps, still + // move along the whole court. + describe('the automatic next match stays in the competition', () => { + const others = () => [ + runningMatch(), + runningMatch({ id: 'x1', compId: 'c2', compName: 'Other', status: 'scheduled', sideA: { id: 'q1', name: 'Kudo' }, sideB: { id: 'q2', name: 'Mori' } }), + runningMatch({ id: 'm3', status: 'scheduled', sideA: { id: 'p3', name: 'Suzuki' }, sideB: { id: 'p4', name: 'Ito' } }), + ]; + const openFirst = async (onEditScore) => { + await mountSchedule(onEditScore, others()); + await act(async () => { fireEvent.click(document.querySelector('button.test-score-open')); }); + }; + + it('Finish + Start Next starts the next match of the same competition', async () => { + const onEditScore = vi.fn().mockResolvedValue({ status: 'running' }); + await openFirst(onEditScore); + expect(probe.props.nextMatch.id, 'Next still moves along the whole court').toBe('x1'); + await act(async () => { await probe.props.onSubmitAndNext({ status: 'completed', winner: 'Yamada' }); }); + expect(onEditScore.mock.calls.map((c) => c[1])).toEqual(['m1', 'm3']); + }); + + it('the start after a decision picks the next match of the same competition', async () => { + const onEditScore = vi.fn().mockResolvedValue({ status: 'running' }); + await openFirst(onEditScore); + await act(async () => { await probe.props.onAfterDecision({ id: 'm1', status: 'completed', decision: 'hikiwake' }); }); + expect(onEditScore.mock.calls.map((c) => c[1])).toEqual(['m3']); + }); + }); + + // Recording a withdrawal starts the court's next match, as a no-show does + // (mp-nwds item 7). The list shows the withdrawn competitor's own matches + // barred only after it refreshes, so the advance passes over them itself. + describe('the advance after a withdrawal', () => { + const yamada = { id: 'p1', name: 'Yamada' }; + const tanaka = { id: 'p2', name: 'Tanaka' }; + const suzuki = { id: 'p3', name: 'Suzuki' }; + // What /decision answers with: the stored match, sides as bare names. + const kikenByYamada = { + id: 'm1', sideA: 'Yamada', sideB: 'Tanaka', sideAId: 'p1', sideBId: 'p2', winner: 'Tanaka', winnerId: 'p2', + status: 'completed', decision: 'kiken-voluntary', decisionBy: 'aka', + }; + const yamadaNext = runningMatch({ id: 'm2', status: 'scheduled', sideA: suzuki, sideB: yamada }); + const openFirst = async (onEditScore, matches) => { + await mountSchedule(onEditScore, matches); + await act(async () => { fireEvent.click(document.querySelector('button.test-score-open')); }); + }; + + it('starts the next match the withdrawn competitor is not in', async () => { + const onEditScore = vi.fn().mockResolvedValue({ status: 'running' }); + await openFirst(onEditScore, [runningMatch(), yamadaNext, runningMatch({ id: 'm3', status: 'scheduled', sideA: suzuki, sideB: tanaka })]); + await act(async () => { await probe.props.onAfterDecision(kikenByYamada); }); + expect(onEditScore).toHaveBeenCalledTimes(1); + expect(onEditScore.mock.calls[0][1], "Yamada's next match is passed over").toBe('m3'); + }); + + it('closes the editor when only the withdrawn competitor\'s matches are left', async () => { + const onEditScore = vi.fn(); + await openFirst(onEditScore, [runningMatch(), yamadaNext]); + await act(async () => { await probe.props.onAfterDecision(kikenByYamada); }); + expect(onEditScore).not.toHaveBeenCalled(); + expect(screen.queryByTestId('probe-score-editor')).toBeNull(); + }); + }); }); // ── 5. viewer_match.jsx: public self-run surface ───────────────────────────── @@ -337,19 +430,7 @@ describe('mount site: viewer_match.jsx (public self-run)', () => { // dispatch gap (the engi branch drops selfReport entirely: see // score_editor_dispatch.render.test.jsx), the self-run affordance set is // ruled on by mp-yqxn.5. - await act(async () => { - render( - - ); - }); - await act(async () => { - fireEvent.click(screen.getByRole('button', { name: 'Report result' })); - }); + await mountSelfRun(); expect(screen.getByTestId('probe-score-editor')).toBeTruthy(); expect(wiringOf(probe.props)).toEqual({ onSubmit: 'fn', @@ -367,3 +448,108 @@ describe('mount site: viewer_match.jsx (public self-run)', () => { }); }); }); + +// ── every surface: what a write came back with ─────────────────────────────── +// bc-strt: the editor's own Start match treats the match as running only for +// a write that reached the server. A refused one (court busy, a withdrawn +// competitor) throws inside the host, which reports it and hands back +// nothing. So every host must hand back what a landed write came back with, +// or no start made from its editor could ever count as landed. + +describe('every mount site hands the editor what a write came back with (bc-strt)', () => { + const openSchedule = async (onEditScore) => { + await mountSchedule(onEditScore); + await act(async () => { fireEvent.click(document.querySelector('button.test-score-open')); }); + }; + const sites = [ + ['admin_shiaijo.jsx', mountShiaijo], + ['admin_pools.jsx', mountPools], + ['admin_competition_bracket.jsx', mountBracket], + ['admin_schedule_score_editor.jsx', openSchedule], + ['viewer_match.jsx', async (write) => { + window.API.recordScore = write; + await mountSelfRun(); + }], + ]; + + it.each(sites)('%s', async (_name, mount) => { + const landed = { status: 'running' }; + const write = vi.fn().mockResolvedValueOnce(landed).mockRejectedValueOnce(new Error('court busy')); + // The self-run surface reports a refused write itself (it has no toast). + const alert = vi.spyOn(window, 'alert').mockImplementation(() => {}); + const logError = vi.spyOn(console, 'error').mockImplementation(() => {}); + const recordScore = window.API.recordScore; + try { + await mount(write); + const onSubmit = probe.props.onSubmit; + let res; + await act(async () => { res = await onSubmit({ status: 'running', winner: null }); }); + expect(res, 'a landed write').toBe(landed); + await act(async () => { res = await onSubmit({ status: 'running', winner: null }); }); + expect(res, 'a refused write').toBeUndefined(); + expect(write).toHaveBeenCalledTimes(2); + } finally { + window.API.recordScore = recordScore; + alert.mockRestore(); + logError.mockRestore(); + } + }); +}); + +// ── when an editor closes ──────────────────────────────────────────────────── +// A start, each autosaved point and a kachinuki Record bout are running writes +// the operator keeps scoring after, so an editor that closed on every landed +// write dropped them out of the match after one point. And a write that did +// not land (queued offline, or superseded) must not look saved. + +describe('the pools and bracket editors close only on a saved result (bc-plcl)', () => { + const editorOpen = () => !!screen.queryByTestId('probe-score-editor'); + const submit = async (patch) => { await act(async () => { await probe.props.onSubmit(patch); }); }; + + it('admin_pools.jsx stays open through running writes and a write that did not land', async () => { + const write = vi.fn() + .mockResolvedValueOnce({ status: 'running' }) + .mockResolvedValueOnce({ status: 'running' }) + .mockResolvedValueOnce({ queued: true }) + .mockResolvedValueOnce({ status: 'completed' }); + await mountPools(write); + await submit({ status: 'running', winner: null }); + expect(editorOpen(), 'after Start').toBe(true); + await submit({ status: 'running', ipponsA: ['M'] }); + expect(editorOpen(), 'after an autosaved point').toBe(true); + await submit({ status: 'completed', winner: { id: 'p1', name: 'Yamada' } }); + expect(editorOpen(), 'after a finish that only queued').toBe(true); + await submit({ status: 'completed', winner: { id: 'p1', name: 'Yamada' } }); + expect(editorOpen(), 'after a saved finish').toBe(false); + }); + + it('admin_competition_bracket.jsx keeps the editor on a correction that did not land', async () => { + const bm = runningMatch({ id: 'bm1', status: 'completed', winner: { id: 'p1', name: 'Yamada' } }); + delete bm.phase; + delete bm.poolName; + window.BracketTree = (props) => ( + + ); + const write = vi.fn().mockResolvedValueOnce({ queued: true }).mockResolvedValueOnce({ status: 'completed' }); + await act(async () => { + render( + + ); + }); + await act(async () => { fireEvent.click(screen.getByTestId('open-bracket-match')); }); + await act(async () => { fireEvent.click(screen.getByRole('button', { name: 'Edit result' })); }); + expect(editorOpen(), 'the correction editor is open').toBe(true); + await submit({ status: 'completed', winner: { id: 'p2', name: 'Tanaka' } }); + expect(editorOpen(), 'after a correction that only queued').toBe(true); + await submit({ status: 'completed', winner: { id: 'p2', name: 'Tanaka' } }); + expect(editorOpen(), 'after a saved correction').toBe(false); + }); +}); diff --git a/web-mobile/js/__tests__/render/start_then_strike_saves.render.test.jsx b/web-mobile/js/__tests__/render/start_then_strike_saves.render.test.jsx new file mode 100644 index 000000000..20895b352 --- /dev/null +++ b/web-mobile/js/__tests__/render/start_then_strike_saves.render.test.jsx @@ -0,0 +1,183 @@ +// bc-strt: a point struck right after Start match was not saved until the +// next edit. The editors autosave only a match they see as running, and the +// court list refetches a moment after each save, so for that moment the +// just-started match still read "scheduled" and the point stayed on this +// board alone. A start that has landed now makes the editor treat the +// scheduled snapshot it was made from as running: its own Start match, or +// the host's (`started`, the court console's Up next card). + +import React from 'react'; +import { render, act, fireEvent, screen } from '@testing-library/react'; +import { describe, it, expect, vi, beforeAll, afterAll, beforeEach, afterEach } from 'vitest'; +import { installWindowStubs } from '../helpers/stub_globals.js'; + +const 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), + resolveRoundIndex: () => 0, + API: { + fetchCompetitionDetails: vi.fn().mockResolvedValue({ + id: 'comp1', config: { format: 'mixed', teamMatchType: 'kachinuki', naginata: false, players: [] }, + }), + recordScore: vi.fn().mockResolvedValue(undefined), + recordDaihyosen: vi.fn(), + removeDaihyosen: vi.fn(), + putMatchLineup: vi.fn(), + recordDecision: vi.fn(), + }, + AdminLineupHelpers: { rosterFor: vi.fn().mockReturnValue([]) }, + compMatches: () => [], + Term: ({ children }) => {children}, + GlossaryHint: ({ name }) => , +}; + +let restoreGlobals; +let ScoreEditorModal; + +beforeAll(async () => { + restoreGlobals = installWindowStubs(STUBBED_GLOBALS); + await import('../../admin_scoring_modal.jsx'); + ScoreEditorModal = window.ScoreEditorModal; +}); + +afterAll(() => restoreGlobals()); + +beforeEach(() => { vi.useFakeTimers(); }); +afterEach(() => { vi.useRealTimers(); }); + +// No compId on the individual match: the competition fetch stays out of it. +const individual = (overrides = {}) => ({ + id: 'm1', status: 'scheduled', phase: 'pool', poolName: 'Pool 1', court: 'A', + sideA: { id: 'p1', name: 'Yamada' }, sideB: { id: 'p2', name: 'Tanaka' }, + modifiedAt: 1000, + ...overrides, +}); + +const kachinuki = (overrides = {}) => ({ + id: 'k1', compId: 'comp1', status: 'scheduled', phase: 'pool', poolName: 'Pool 1', court: 'A', + compKind: 'team', teamSize: 3, compFormat: 'mixed', teamMatchType: 'kachinuki', + sideA: { id: 'team-A', name: 'Team A' }, sideB: { id: 'team-B', name: 'Team B' }, + subResults: [], modifiedAt: 1000, + ...overrides, +}); + +const el = (match, onSubmit, props = {}) => ( + +); + +// What a host returns for a write that reached the server: the stored +// match. A refused write throws inside the host, which reports it and +// returns nothing. +const LANDED = { status: 'running' }; + +// The writes after the Start match one: the autosaves. +const autosaves = (onSubmit) => onSubmit.mock.calls.map((c) => c[0]).filter((p) => p.status === 'running').slice(1); + +const tapStart = async () => { + await act(async () => { fireEvent.click(screen.getByText('Start match')); }); +}; +const strikeIndividualMen = async () => { + await act(async () => { fireEvent.click(screen.getAllByText('M')[0]); }); + await act(async () => { vi.advanceTimersByTime(400); }); +}; +const strikeKachinukiAkaMen = async () => { + await act(async () => { fireEvent.keyDown(window, { key: 'M', shiftKey: true }); }); + await act(async () => { vi.advanceTimersByTime(400); }); +}; + +describe('a point struck right after Start match saves at once (bc-strt)', () => { + it('individual: the editor\'s own start lands, the list still says scheduled', async () => { + const onSubmit = vi.fn().mockResolvedValue(LANDED); + await act(async () => { render(el(individual(), onSubmit)); }); + await tapStart(); + expect(onSubmit.mock.calls[0][0].status, 'precondition: Start match wrote running').toBe('running'); + + await strikeIndividualMen(); + const saves = autosaves(onSubmit); + expect(saves.length, 'the point is autosaved without waiting for the list').toBe(1); + expect([...(saves[0].ipponsA || []), ...(saves[0].ipponsB || [])]).toContain('M'); + }); + + it('individual: a start the host made (the Up next card) counts too', async () => { + const onSubmit = vi.fn().mockResolvedValue(undefined); + await act(async () => { render(el(individual(), onSubmit, { started: true })); }); + await strikeIndividualMen(); + expect(onSubmit).toHaveBeenCalledTimes(1); + expect(onSubmit.mock.calls[0][0].status).toBe('running'); + }); + + it('a scheduled match nobody started still does not autosave', async () => { + const onSubmit = vi.fn().mockResolvedValue(undefined); + await act(async () => { render(el(individual(), onSubmit)); }); + await strikeIndividualMen(); + expect(onSubmit).not.toHaveBeenCalled(); + }); + + it('a refused start leaves the match scheduled', async () => { + const onSubmit = vi.fn().mockResolvedValue({ applied: false, reason: 'clock_skew' }); + await act(async () => { render(el(individual(), onSubmit)); }); + await tapStart(); + expect(onSubmit).toHaveBeenCalledTimes(1); + await strikeIndividualMen(); + expect(onSubmit, 'nothing was started, so nothing autosaves').toHaveBeenCalledTimes(1); + }); + + it('a later snapshot of the match (sent back to the queue) ends it', async () => { + const onSubmit = vi.fn().mockResolvedValue(LANDED); + let utils; + await act(async () => { utils = render(el(individual(), onSubmit)); }); + await tapStart(); + // The feed moves on: running, then sent back to the queue, which stamps it. + await act(async () => { utils.rerender(el(individual({ status: 'running', modifiedAt: 2000 }), onSubmit)); }); + await act(async () => { utils.rerender(el(individual({ status: 'scheduled', modifiedAt: 3000 }), onSubmit)); }); + await strikeIndividualMen(); + expect(onSubmit, 'a queued match is not written as running').toHaveBeenCalledTimes(1); + }); + + it('the host\'s start ends with its snapshot as well', async () => { + const onSubmit = vi.fn().mockResolvedValue(undefined); + await act(async () => { render(el(individual({ modifiedAt: 3000 }), onSubmit, { started: false })); }); + await strikeIndividualMen(); + expect(onSubmit).not.toHaveBeenCalled(); + }); + + it('kachinuki: the team editor\'s start lands, the list still says scheduled', async () => { + const onSubmit = vi.fn().mockResolvedValue(LANDED); + await act(async () => { render(el(kachinuki(), onSubmit)); }); + await tapStart(); + expect(onSubmit.mock.calls[0][0].status, 'precondition: Start match wrote running').toBe('running'); + + await strikeKachinukiAkaMen(); + const saves = autosaves(onSubmit); + expect(saves.length, 'the point is autosaved without waiting for the list').toBe(1); + const bout1 = (saves[0].subResults || []).find((s) => s.position === 1); + expect(bout1 && bout1.ipponsA).toEqual(['M']); + }); + + // The court is busy or a competitor is withdrawn: the server answers with + // an error, the host shows it and returns nothing. Nothing was stored, so + // the match is still waiting and a point struck now is not a running write. + it('individual: a start the server refused with an error leaves the match scheduled', async () => { + const onSubmit = vi.fn().mockResolvedValue(undefined); + await act(async () => { render(el(individual(), onSubmit)); }); + await tapStart(); + expect(onSubmit).toHaveBeenCalledTimes(1); + expect(screen.getByText('Start match'), 'the operator can try again').toBeTruthy(); + await strikeIndividualMen(); + expect(onSubmit, 'nothing was started, so nothing autosaves').toHaveBeenCalledTimes(1); + }); + + it('kachinuki: a start the server refused with an error leaves the match scheduled', async () => { + const onSubmit = vi.fn().mockResolvedValue(undefined); + await act(async () => { render(el(kachinuki(), onSubmit)); }); + await tapStart(); + expect(onSubmit).toHaveBeenCalledTimes(1); + expect(screen.getByText('Start match'), 'the operator can try again').toBeTruthy(); + await strikeKachinukiAkaMen(); + expect(onSubmit, 'nothing was started, so nothing autosaves').toHaveBeenCalledTimes(1); + }); +}); diff --git a/web-mobile/js/__tests__/result_recency.test.jsx b/web-mobile/js/__tests__/result_recency.test.jsx index 5b5b445d5..6faacb8e8 100644 --- a/web-mobile/js/__tests__/result_recency.test.jsx +++ b/web-mobile/js/__tests__/result_recency.test.jsx @@ -20,9 +20,9 @@ describe('resultRecencyDesc; newest result first', () => { expect([...rows].sort(resultRecencyDesc).map((m) => m.id)).toEqual(['stamped', 'unstamped']); }); - // The reachable unstamped case is a match that was NEVER started: the - // withdrawal panel lists only `scheduled` matches, so the default win it - // awards has no earlier write to inherit a stamp from. + // The reachable unstamped case is a match that was NEVER started: a queue + // row's Record default win closes a match still `scheduled`, which has no + // earlier write to inherit a stamp from. it('ranks a never-started default win below a bout carrying a stamp', () => { const rows = [at('default-win', '09:30'), at('fought', '09:00', 5)]; expect([...rows].sort(resultRecencyDesc).map((m) => m.id)).toEqual(['fought', 'default-win']); diff --git a/web-mobile/js/__tests__/write_result_downstream_knockout.test.jsx b/web-mobile/js/__tests__/write_result_downstream_knockout.test.jsx index 5f69539a3..6fa601861 100644 --- a/web-mobile/js/__tests__/write_result_downstream_knockout.test.jsx +++ b/web-mobile/js/__tests__/write_result_downstream_knockout.test.jsx @@ -58,14 +58,15 @@ describe('downstreamKnockoutPlayedConfirm', () => { expect(message).not.toContain('m-r2-0'); // Names the displaced competitor. expect(message).toContain('Suzuki Ichiro'); - // States plainly what confirming DOES to the later match -- reopens it, - // clearing its recorded result -- rather than just "proceed anyway". - // The wording says reopen, not "back to the queue", because the match - // was already played and is reopened IN PLACE: the queue is untouched - // (operator ruling 2026-09-19). + // States plainly what confirming DOES to the later match -- reopens it + // with its winner cleared and its points kept (operator ruling + // 2026-09-26) -- rather than just "proceed anyway". The wording says + // reopen, not "back to the queue", because the match was already + // played and is reopened IN PLACE: the queue is untouched (operator + // ruling 2026-09-19). expect(message.toLowerCase()).toContain('reopens match'); - expect(message.toLowerCase()).toContain('re-entry'); - expect(message.toLowerCase()).toContain('cleared'); + expect(message.toLowerCase()).toContain('winner is cleared'); + expect(message.toLowerCase()).toContain('points are kept'); // Never the word "mat" (kendo has no mats -- CLAUDE.md). expect(message.toLowerCase()).not.toMatch(/\bmat\b/); expect(confirmLabel).toBeTruthy(); @@ -177,7 +178,7 @@ describe('downstreamKnockoutReopenedNotice', () => { // settle. it('names the single match that was reopened, by its number', () => { expect(downstreamKnockoutReopenedNotice([{ id: 'm-r2-0', number: 3 }])) - .toBe('Match 3 was reopened: it must be fought and scored again.'); + .toBe('Match 3 was reopened with its points kept: check them, then finish it again.'); }); it('falls back to the id when a match carries no number', () => { @@ -242,7 +243,7 @@ describe('downstreamKnockoutPlayedConfirm with a qualifier change', () => { }); expect(message).toBe( "Changing this result moves Pool A's 1st place from Aoki Taro to Bob, who takes Aoki Taro's place in the knockout. " + - 'Match 9 was already fought with Aoki Taro: it will be reopened, its result cleared, and it must be fought again.', + 'Match 9 was already fought with Aoki Taro: it will be reopened with its winner cleared and its points kept, and it must be finished again.', ); expect(message).not.toMatch(/\n/); expect(confirmLabel).toBe('Apply and reopen'); @@ -261,7 +262,7 @@ describe('downstreamKnockoutPlayedConfirm with a qualifier change', () => { }); expect(message).toBe( "Changing this result changes who holds Pool A's 1st place (Aoki Taro to Bob) and Pool A's 2nd place (Bob to Aoki Taro), and the knockout is changed to match. " + - 'Match 9 and Match 10 were already fought with the competitors being replaced: they will be reopened, their results cleared, and they must be fought again.', + 'Match 9 and Match 10 were already fought with the competitors being replaced: they will be reopened with their winners cleared and their points kept, and they must be finished again.', ); }); @@ -273,7 +274,7 @@ describe('downstreamKnockoutPlayedConfirm with a qualifier change', () => { }); expect(message).toBe( "Changing this result leaves Pool A's 1st place tied, to be settled by a tie-break, so Aoki Taro no longer holds it in the knockout. " + - 'Match 9 was already fought with Aoki Taro: it will be reopened, its result cleared, and fought once the tie-break decides the place.', + 'Match 9 was already fought with Aoki Taro: it will be reopened with its winner cleared and its points kept, and finished once the tie-break decides the place.', ); }); @@ -290,9 +291,9 @@ describe('downstreamKnockoutPlayedConfirm with a qualifier change', () => { ], }); expect(allTied.message).toContain( - 'Match 9 and Match 10 were already fought with the competitors being replaced: they will be reopened, their results cleared, and fought once the tie-break decides the places.', + 'Match 9 and Match 10 were already fought with the competitors being replaced: they will be reopened with their winners cleared and their points kept, and finished once the tie-break decides the places.', ); - expect(allTied.message).not.toContain('must be fought again'); + expect(allTied.message).not.toContain('must be finished again'); const mixed = downstreamKnockoutPlayedConfirm({ ...blocking, @@ -303,7 +304,7 @@ describe('downstreamKnockoutPlayedConfirm with a qualifier change', () => { ], }); expect(mixed.message).toContain( - 'Match 9 was already fought with Aoki Taro: it will be reopened, its result cleared, and fought again; a match on a tied place waits for the tie-break to decide it.', + 'Match 9 was already fought with Aoki Taro: it will be reopened with its winner cleared and its points kept, and finished again; a match on a tied place waits for the tie-break to decide it.', ); }); @@ -318,7 +319,7 @@ describe('downstreamKnockoutPlayedConfirm with a qualifier change', () => { }); expect(message).toBe( "Recording this ranking moves Pool A's 1st place from Aoki Taro to Bob, who takes Aoki Taro's place in the knockout. " + - 'Match 9 was already fought with Aoki Taro: it will be reopened, its result cleared, and it must be fought again.', + 'Match 9 was already fought with Aoki Taro: it will be reopened with its winner cleared and its points kept, and it must be finished again.', ); expect(confirmLabel).toBe('Apply and reopen'); }); @@ -395,12 +396,12 @@ describe('a server-named knockout match keeps its round in every message', () => displaced: 'Goto Ken', }); expect(message).toContain('Goto Ken already played Match 3 (Final)'); - expect(message).toContain('reopens Match 3 (Final) for re-entry'); + expect(message).toContain('reopens Match 3 (Final) with the new competitor in it'); }); it('the reopened notice and the running refusal name it the same way', () => { expect(downstreamKnockoutReopenedNotice([semi])) - .toBe('Match 1 (Semifinals) was reopened: it must be fought and scored again.'); + .toBe('Match 1 (Semifinals) was reopened with its points kept: check them, then finish it again.'); expect(downstreamKnockoutRunningMessage([semi])) .toBe('Match 1 (Semifinals) is being fought now. Finish it or send it back to the queue, then save again.'); // A bracket saved before rounds were recorded is named "knockout Match diff --git a/web-mobile/js/__tests__/write_result_editor_open.test.jsx b/web-mobile/js/__tests__/write_result_editor_open.test.jsx new file mode 100644 index 000000000..aeb17b100 --- /dev/null +++ b/web-mobile/js/__tests__/write_result_editor_open.test.jsx @@ -0,0 +1,36 @@ +// bc-plcl: whether a score editor's host keeps the editor open after a write. +// One rule every host asks (the Pools tab, the Scores tab, the bracket panel); +// the Pools tab once closed on every write, Start match and each autosaved +// point included. + +import { describe, it, expect } from 'vitest'; +import { writeKeepsEditorOpen } from '../write_result.jsx'; + +describe('writeKeepsEditorOpen', () => { + const running = { status: 'running', winner: null }; + const finish = { status: 'completed', winner: { id: 'p1', name: 'Yamada' } }; + + it('a running write with no winner leaves the bout live on the board', () => { + expect(writeKeepsEditorOpen(running, { status: 'running' })).toBe(true); + }); + + it('a running write with a winner is not a start', () => { + expect(writeKeepsEditorOpen({ status: 'running', winner: finish.winner }, { status: 'running' })).toBe(false); + }); + + it('a saved finish is the editor\'s job done', () => { + expect(writeKeepsEditorOpen(finish, { applied: true })).toBe(false); + }); + + it.each([ + ['queued', { queued: true }], + ['superseded', { applied: false, reason: 'superseded' }], + ['refused for the clock', { applied: false, reason: 'clock_skew' }], + ])('a finish that did not land (%s) keeps its not-saved banner on screen', (_name, res) => { + expect(writeKeepsEditorOpen(finish, res)).toBe(true); + }); + + it('a write with nothing to report is not held open', () => { + expect(writeKeepsEditorOpen(finish, undefined)).toBe(false); + }); +}); diff --git a/web-mobile/js/admin.jsx b/web-mobile/js/admin.jsx index d8dcec65e..273eec843 100644 --- a/web-mobile/js/admin.jsx +++ b/web-mobile/js/admin.jsx @@ -1,7 +1,7 @@ // Admin side: single tournament. Tournament has multiple Competitions. // Top-level: Tournament dashboard (all competitions), per-competition pages. -import { applyPatch as patchCompetitionData, checkSeqGap } from './patch.jsx'; +import { applyPatch as patchCompetitionData, checkSeqGap, keepNewerDetail } from './patch.jsx'; import { createTimerPool } from './timer_pool.jsx'; // Imported from the leaf, not read off `window`: write_result.jsx is // import-only (see its header) and every consumer ES-imports it directly. @@ -263,7 +263,7 @@ function AdminApp({ tournament, onUpdate, onLogout, onViewerMode, onPasswordChan if (view.kind === "competition" && view.id === cid) { try { const details = await window.API.fetchCompetitionDetails(cid); - if (mountedRef.current) setAdminCompData(details); + if (mountedRef.current) setAdminCompData((prev) => keepNewerDetail(prev, details)); } catch (e) { console.error("Failed to refresh competition details after save:", e); } @@ -481,7 +481,7 @@ function AdminApp({ tournament, onUpdate, onLogout, onViewerMode, onPasswordChan setAdminLoading(true); window.API.fetchCompetitionDetails(view.id) .then(data => { - if (!cancelled) { setAdminCompData(data); setAdminLoading(false); } + if (!cancelled) { setAdminCompData((prev) => keepNewerDetail(prev, data)); setAdminLoading(false); } }) .catch(err => { if (!cancelled) { console.error(err); setAdminLoading(false); } @@ -553,7 +553,7 @@ function AdminApp({ tournament, onUpdate, onLogout, onViewerMode, onPasswordChan window.API.fetchCompetitionDetails(targetId) .then(data => { if (cancelled || data?.config?.id !== targetId) return; - setAdminCompData(data); + setAdminCompData((prev) => keepNewerDetail(prev, data)); }) .catch(err => console.error('tab-resume refresh failed:', err)); }); @@ -575,7 +575,7 @@ function AdminApp({ tournament, onUpdate, onLogout, onViewerMode, onPasswordChan window.API.fetchCompetitionDetails(targetId) .then(data => { if (cancelled || data?.config?.id !== targetId) return; - setAdminCompData(data); + setAdminCompData((prev) => keepNewerDetail(prev, data)); }) .catch(err => console.error('resync refresh failed:', err)); }); @@ -591,7 +591,7 @@ function AdminApp({ tournament, onUpdate, onLogout, onViewerMode, onPasswordChan window.API.fetchCompetitionDetails(targetId) .then(data => { if (cancelled || data?.config?.id !== targetId) return; - setAdminCompData(data); + setAdminCompData((prev) => keepNewerDetail(prev, data)); }) .catch(err => console.error('gap refetch failed:', err)); }); @@ -624,7 +624,7 @@ function AdminApp({ tournament, onUpdate, onLogout, onViewerMode, onPasswordChan // check is belt-and-braces in case a fetch comes back with // unexpected data shape. if (cancelled || data?.config?.id !== targetId) return; - setAdminCompData(data); + setAdminCompData((prev) => keepNewerDetail(prev, data)); }) .catch(err => console.error("Failed to refresh competition details", err)); }); @@ -849,7 +849,7 @@ function AdminApp({ tournament, onUpdate, onLogout, onViewerMode, onPasswordChan onOpenCompetition={(id, section) => setView({ kind: "competition", id, section: section || "overview" })} onCreateCompetition={() => setView({ kind: "createComp" })} onUpdate={(next) => updateCompetition(c.id, next)} - onRefreshCompetition={() => window.API.fetchCompetitionDetails(c.id).then(setAdminCompData).catch(err => console.error("refresh failed:", err))} + onRefreshCompetition={() => window.API.fetchCompetitionDetails(c.id).then((data) => setAdminCompData((prev) => keepNewerDetail(prev, data))).catch(err => console.error("refresh failed:", err))} onMoveCourt={moveMatchCourt} onEditScore={editMatchScore} onLogout={onLogout} diff --git a/web-mobile/js/admin_competition_bracket.jsx b/web-mobile/js/admin_competition_bracket.jsx index 7dd49af9b..2b4b15e91 100644 --- a/web-mobile/js/admin_competition_bracket.jsx +++ b/web-mobile/js/admin_competition_bracket.jsx @@ -4,6 +4,7 @@ // loadScoreboardPoints are ES-exported and re-exported by the entry for tests. import { realIppons } from './result_slot.jsx'; +import { writeKeepsEditorOpen } from './write_result.jsx'; const { useState: useStateA, useEffect: useEffectA, useRef: useRefA } = React; const hasBothSides = window.hasBothSides; @@ -165,8 +166,13 @@ const RunningMatchPanel = React.memo(({ match, compId, courts, matchNum, onMoveC onClose={() => setEditing(false)} onSubmit={async (patch) => { try { - await onEditScore(compId, match.id, patch, match); - setEditing(false); // recorded → fall back to the result card + const res = await onEditScore(compId, match.id, patch, match); + // Recorded → fall back to the result card, unless the editor's + // job is not done (writeKeepsEditorOpen). + if (!writeKeepsEditorOpen(patch, res)) setEditing(false); + // What the write came back with: a refused one threw and + // returns nothing, which is how the editor tells them apart. + return res; } catch (_e) { /* surfaced via toast in the parent */ } }} onSubmitAndNext={null} diff --git a/web-mobile/js/admin_helpers.jsx b/web-mobile/js/admin_helpers.jsx index d617f8e65..d6d5fd81f 100644 --- a/web-mobile/js/admin_helpers.jsx +++ b/web-mobile/js/admin_helpers.jsx @@ -417,8 +417,9 @@ const SHIAIJO_RULE_IS_PER_COMPETITION = "This is a rule about each competition, // One list joiner behind every enumeration this module renders: Oxford-comma- // free, ", " between all but the last, the conjunction before it. The separator // and the singleton handling are console microcopy, so they are decided once -// here rather than restated per message. -function joinList(list, conjunction, empty) { +// here rather than restated per message. Exported so the team Finish refusal +// uses this one rather than its own copy. +export function joinList(list, conjunction, empty) { if (!list.length) return empty; if (list.length === 1) return String(list[0] ?? ""); return `${list.slice(0, -1).join(", ")} ${conjunction} ${list[list.length - 1]}`; diff --git a/web-mobile/js/admin_pools.jsx b/web-mobile/js/admin_pools.jsx index 34d5e2471..a362657e7 100644 --- a/web-mobile/js/admin_pools.jsx +++ b/web-mobile/js/admin_pools.jsx @@ -18,7 +18,7 @@ import { NO_ID_POOL_HINT, NoIdHint } from './data_integrity.jsx'; // has started, and the server refuses it like a pool result correction // (downstream_knockout_played): the chusen writes go through the same // confirm-and-retry every score write uses. -import { attemptScoreWrite, DOWNSTREAM_KNOCKOUT_RANKING_CANCELLED } from './write_result.jsx'; +import { attemptScoreWrite, DOWNSTREAM_KNOCKOUT_RANKING_CANCELLED, writeKeepsEditorOpen } from './write_result.jsx'; import { rankOrdinal } from './viewer_standings.jsx'; const { useState: useStateA, useEffect: useEffectA, useRef: useRefA, useMemo: useMemoA } = React; @@ -735,8 +735,11 @@ function AdminPools({ c, pools, poolMatches, standings, tweaks, onEditScore, pas onClose={() => setScoreOpenId(null)} onSubmit={async (patch) => { try { - await onEditScore(c.id, scoreOpenMatch.id, patch, scoreOpenMatch); - if (mountedRef.current) setScoreOpenId(null); + const res = await onEditScore(c.id, scoreOpenMatch.id, patch, scoreOpenMatch); + // What the write came back with: a refused one threw and returns + // nothing, which is how the editor tells them apart. + if (mountedRef.current && !writeKeepsEditorOpen(patch, res)) setScoreOpenId(null); + return res; } catch (_err) { /* keep modal open on error */ } }} onSubmitAndNext={null} diff --git a/web-mobile/js/admin_schedule_page.jsx b/web-mobile/js/admin_schedule_page.jsx index 9698cd7bd..e3986350d 100644 --- a/web-mobile/js/admin_schedule_page.jsx +++ b/web-mobile/js/admin_schedule_page.jsx @@ -14,6 +14,7 @@ import { DurationInput } from './duration.jsx'; import { sameCompetitor } from './competitor_identity.jsx'; import { SideCell } from './side_cell.jsx'; import { NumberedName } from './numbered_name.jsx'; +import { matchShowsScore } from './match_shows_score.jsx'; const { useState: useStateA, useMemo: useMemoA } = React; @@ -30,7 +31,9 @@ const AdminTWMatch = React.memo(({ m, highlight, courts, onMove, onTimeChange }) // once both are id-less). const aWin = !!m.winner && !!m.sideA && sameCompetitor(m.winner, m.sideA); const bWin = !!m.winner && !!m.sideB && sameCompetitor(m.winner, m.sideB); - const scoreStr = (m.status === "completed" || m.status === "running") ? window.matchScoreStr(m) : ""; + // matchShowsScore is the gate: a queued match keeps its score but reads as + // not started (bc-sbq). + const scoreStr = matchShowsScore(m) ? window.matchScoreStr(m) : ""; const submitTime = (e) => { e.preventDefault(); setEditingTime(false); diff --git a/web-mobile/js/admin_schedule_score_editor.jsx b/web-mobile/js/admin_schedule_score_editor.jsx index d55a95567..724746643 100644 --- a/web-mobile/js/admin_schedule_score_editor.jsx +++ b/web-mobile/js/admin_schedule_score_editor.jsx @@ -1,7 +1,7 @@ // Score editor components extracted from admin_schedule.jsx (mp-d7tl). // startPatch, ScoreEditCourtBtn (local), AdminScoreEditor, AdminScoreEditorPage. -import { writeDidNotLand } from './write_result.jsx'; +import { writeDidNotLand, writeKeepsEditorOpen } from './write_result.jsx'; import { matchMentions } from './competitor_search.jsx'; import { SideCell } from './side_cell.jsx'; import { allMatchesCompleted } from './admin_schedule_utils.jsx'; @@ -16,11 +16,12 @@ import { NumberedName } from './numbered_name.jsx'; // no auto-pick may offer it and its row shows the default-win action // instead. One leaf owns the question (ineligible_match.jsx); BarredMatchNotice // (admin_scoring_shared.jsx) is the one component that renders it everywhere. -import { isBarredMatch } from './ineligible_match.jsx'; +import { isBarredMatch, sideBarredByDecision, involvesCompetitor } from './ineligible_match.jsx'; // Straight from its own leaf, not admin_scoring_shared.jsx (which also // imports bracket.jsx): see barred_match_notice.jsx's header for why that // matters for this file's own render suite. import { BarredMatchNotice } from './barred_match_notice.jsx'; +import { matchShowsScore } from './match_shows_score.jsx'; const { useState: useStateA, useMemo: useMemoA, useEffect: useEffectA, useRef: useRefA } = React; @@ -83,8 +84,14 @@ function ScoreEditCourtBtn({ m, courts, onMoveCourt }) { } // Module-level factory so admin_shiaijo.jsx can consume it via window.startPatch. +// startOnly (bc-sbq): this write only starts the match, so the server keeps the +// score the match already holds (a match sent back to the queue keeps one); +// toBackendMatchResult leaves the empty scoreline below off the wire. The +// editors' own Start sends their board unflagged, so an operator who cleared +// every mark still clears it. export function startPatch() { return { + startOnly: true, status: "running", winner: null, ipponsA: [], ipponsB: [], hansokuA: 0, hansokuB: 0, score: { type: "ippon", winnerPts: 0, loserPts: 0, ippons: [], fouls: { a: 0, b: 0 }, live: true, corrected: false }, }; @@ -219,14 +226,17 @@ export function AdminScoreEditor({ t, c, onEditScore, onMoveCourt, restrictToCom // match's own mark, already inline in its score string below, is // never doubled here. const { shiro: teamShiroMark, aka: teamAkaMark } = window.teamMatchMarks ? window.teamMatchMarks(m) : { shiro: "", aka: "" }; + // Show the live ippon score for a running bout too (not just completed) + // so the list reflects scoring in progress; "vs" only before it starts. + // matchShowsScore is the one gate: a match sent back to the queue + // keeps its score, but this row shows it as not started, penalty + // marks included, until it restarts (bc-sbq). + const showScore = matchShowsScore(m); // Outstanding single hansoku → red ▲ next to the offending side (same // mark as the scoresheet). hansoku may live on the match or under // score.fouls depending on the source; fall back across both. - const foulB = boutHansokuMark(m.hansokuB ?? m.score?.fouls?.b ?? 0); - const foulA = boutHansokuMark(m.hansokuA ?? m.score?.fouls?.a ?? 0); - // Show the live ippon score for a running bout too (not just completed) - // so the list reflects scoring in progress; "vs" only before it starts. - const showScore = m.status === "completed" || m.status === "running"; + const foulB = showScore ? boutHansokuMark(m.hansokuB ?? m.score?.fouls?.b ?? 0) : ""; + const foulA = showScore ? boutHansokuMark(m.hansokuA ?? m.score?.fouls?.a ?? 0) : ""; // A just-started running bout is 0–0, where formatIpponsScore returns "". // Fall back so the cell is never blank: an empty score renders the // boutMiddle placeholder (normally "vs"). Live techniques show once present. @@ -270,7 +280,7 @@ export function AdminScoreEditor({ t, c, onEditScore, onMoveCourt, restrictToCom
{foulB && {foulB}} - {seScore || {window.boutMiddle ? window.boutMiddle(m.decision, m.encho, m.score) : "vs"}} + {seScore || {showScore && window.boutMiddle ? window.boutMiddle(m.decision, m.encho, m.score) : "vs"}} {foulA && {foulA}}
@@ -361,9 +371,18 @@ export function AdminScoreEditor({ t, c, onEditScore, onMoveCourt, restrictToCom // bc-cse: skip a barred match (isBarredMatch, ineligible_match.jsx): // Finish + Start Next and the after-decision advance both feed this // straight into a Start write, which the server would just refuse. - const nextActiveMatch = openIdx >= 0 - ? sameCourt.slice(openIdx + 1).find(m => m.status !== 'completed' && !isBarredMatch(m)) || null + // `withdrawn` is a competitor a decision just barred + // (sideBarredByDecision): this list shows their matches barred only + // after it refreshes, so the after-decision advance skips them itself. + // The AUTOMATIC advance also stays in the open match's competition + // (operator ruling 2026-09-26: an operator view never switches + // competition or court by itself), as the court console's does; this + // page can list every competition, and Prev/Next, which the operator + // taps, still move along the whole court. + const nextActiveFrom = (withdrawn = null) => openIdx >= 0 + ? sameCourt.slice(openIdx + 1).find(m => m.compId === openMatch.compId && m.status !== 'completed' && !isBarredMatch(m) && !involvesCompetitor(m, withdrawn)) || null : null; + const nextActiveMatch = nextActiveFrom(); // Minimal "start" patch (status → running, empty score). Mirrors the // modal's own buildPatch("running") for an unscored match and works for // both individual and team matches (subResults is omitted, which the @@ -384,11 +403,12 @@ export function AdminScoreEditor({ t, c, onEditScore, onMoveCourt, restrictToCom try { const res = await onEditScore(openMatch.compId, openMatch.id, patch, openMatch); if (!mountedRef.current) return res; - // A write that did not land must not CLOSE the modal: doing so - // is a false success. Queued (F5) or superseded (bc-lww1) alike, - // the editor keeps the operator's entry on screen with its - // not-saved banner. - if (writeDidNotLand(res)) return res; + // Whether the modal stays open is writeKeepsEditorOpen's + // (write_result.jsx), the rule every host asks. A write that did + // not land must not CLOSE the modal: doing so is a false + // success. Queued (F5) or superseded (bc-lww1) alike, the editor + // keeps the operator's entry on screen with its not-saved banner. + // // ▶ Start Match: keep the operator IN the scoring surface rather // than dumping them back to the list (which forced a re-find + // reopen per match). A "start" patch is status:running with no @@ -408,7 +428,7 @@ export function AdminScoreEditor({ t, c, onEditScore, onMoveCourt, restrictToCom // two `setOpenMatch(prev => ({...prev, status:"running"}))` // patches that used to sit here (and the freshSubs they carried) // were compensating for the snapshot, partially. - if (patch.status === "running" && !patch.winner) { + if (writeKeepsEditorOpen(patch, res)) { // mp-gmcg review C1: also hand `res` back to the modal itself. A // prior [Remove this bout] can leave the modal's local // matchOverride shadowing THIS prop, and a Record-bout append can @@ -442,7 +462,8 @@ export function AdminScoreEditor({ t, c, onEditScore, onMoveCourt, restrictToCom // buried the message that actually explains what happened. if (writeDidNotLand(res)) return res; // "Finish + Start Next →": land on the next match on the SAME - // shiaijo AND actually start it (honest to the label). If the + // shiaijo in the SAME competition AND actually start it + // (honest to the label). If the // next match is already running/completed, just open it. Start // gating runs server-side (StartMatchTx); a 409 throws: we // catch it so the operator still lands on the next match (in @@ -456,15 +477,19 @@ export function AdminScoreEditor({ t, c, onEditScore, onMoveCourt, restrictToCom } } catch (_err) { /* keep modal open on error */ } } : null} - onAfterDecision={nextActiveMatch ? async () => { + onAfterDecision={nextActiveMatch ? async (result) => { // A kiken/fusenpai decision already persisted the bout via the // /decision POST: no score PUT here. Mirror onSubmitAndNext's - // start-next so a fusenpai advances the operator to (and starts) - // the next same-court match. - if (nextActiveMatch.status === "scheduled") { + // start-next so a decision advances the operator to (and starts) + // the next same-court match, passing over the matches of the + // competitor it barred. With none left, close as a decision with + // no next match does. + const next = nextActiveFrom(sideBarredByDecision(result, openMatch)); + if (!next) { if (mountedRef.current) setOpenKey(null); return; } + if (next.status === "scheduled") { try { - await onEditScore(nextActiveMatch.compId, nextActiveMatch.id, startPatch(), nextActiveMatch); - if (mountedRef.current) setOpenKey(scoreKeyOf(nextActiveMatch)); + await onEditScore(next.compId, next.id, startPatch(), next); + if (mountedRef.current) setOpenKey(scoreKeyOf(next)); } catch (_startErr) { /* gate rejected the start; leave the operator where they are */ } } } : null} diff --git a/web-mobile/js/admin_scoring_engi.jsx b/web-mobile/js/admin_scoring_engi.jsx index af41f5586..0019644ec 100644 --- a/web-mobile/js/admin_scoring_engi.jsx +++ b/web-mobile/js/admin_scoring_engi.jsx @@ -13,14 +13,16 @@ // modal/inline wrapper, editor-modal__head/body/foot, ReasonPrompt-gated // corrections, Escape-to-close with a dirty-state confirm) so an operator // moving between a kendo and an Engi match on the same court sees one -// consistent editor, not two different products. Engi has no live/"running" -// scoring phase (flags are entered all at once, no autosave), so it skips -// the kendo editor's PRE-MATCH pill, keyboard scoring shortcuts, and -// SyncStatusPill: those concepts don't apply here. +// consistent editor, not two different products. While the match is running +// the flags are saved as they are entered (operator ruling 2026-09-26), through +// the same debounced running write and SyncStatusPill the kendo editors use, so +// a match sent back to the queue or switched away from keeps them. It skips +// the kendo editor's PRE-MATCH pill and keyboard scoring shortcuts. const { useState: useStateE, useEffect: useEffectE, useRef: useRefE } = React; import { ReasonPrompt, CORRECTION_PRESETS, useAdoptFromServer } from './admin_scoring_shared.jsx'; +import { SyncStatusPill, useDebouncedRunningWrite } from './admin_scoring_autosave.jsx'; import { useEscapeToClose, confirmDialog } from './ui.jsx'; // NumberedName: single owner of the number-chip-on-the-outer-side rule. import { NumberedName } from './numbered_name.jsx'; @@ -97,7 +99,7 @@ export function EngiScoreEditorModal({ match, onClose, onSubmit, onSubmitAndNext const [submitting, setSubmitting] = useStateE(false); const [err, setErr] = useStateE(""); // Audit reason collected when correcting a completed match, mirroring - // ScoreEditorModal's showCorrectionPrompt/correctionReason pair. + // ScoreEditorModal's correctionPrompt/correctionReason pair. const [correctionReason, setCorrectionReason] = useStateE(""); const [showCorrectionPrompt, setShowCorrectionPrompt] = useStateE(false); @@ -115,6 +117,27 @@ export function EngiScoreEditorModal({ match, onClose, onSubmit, onSubmitAndNext const pendingFnRef = useRefE(null); const [writeFailed, setWriteFailed] = useStateE(null); // { reason, advice? } | null + // Flags saved as they are entered while the match is running: the refs are + // refreshed every render (below) so the debounce never reads a stale closure. + const autosaveIsRunningRef = useRefE(false); + const autosaveBuildPatchRef = useRefE(null); + const autosaveOnSubmitRef = useRefE(null); + const { markDirty, cancelDebounce, flushPending } = useDebouncedRunningWrite({ + isRunningRef: autosaveIsRunningRef, + buildPatchRef: autosaveBuildPatchRef, + onSubmitRef: autosaveOnSubmitRef, + mountedRef, + }); + autosaveIsRunningRef.current = m.status === "running"; + autosaveBuildPatchRef.current = (status) => ({ flagsA, flagsB, status }); + autosaveOnSubmitRef.current = onSubmit; + // An operator change to either count: the value, then the save it schedules. + // A count adopted from the server does not come through here. + const changeFlags = (side, n) => { + (side === "a" ? setFlagsA : setFlagsB)(clamp(n)); + markDirty(); + }; + const total = flagsA + flagsB; const isValidTotal = VALID_TOTALS.has(total); // winnerSide drives the per-side winner highlight below. A valid total is @@ -134,10 +157,9 @@ export function EngiScoreEditorModal({ match, onClose, onSubmit, onSubmitAndNext // there is no per-row seam to merge along. An operator with unsaved flags // keeps all of them. // - // No autosave here (flags are entered in one go), which makes the window - // WIDER than the kendo editors', not narrower: nothing this editor holds - // reaches the server until the operator submits, so a stale reading can sit - // in front of them for as long as the match is open. + // Until the match is running nothing this editor holds reaches the server + // before the operator submits, so a stale reading can sit in front of them + // for as long as the match is open. useAdoptFromServer({ signature: `${initialFlagsA}:${initialFlagsB}`, apply: () => { setFlagsA(initialFlagsA); setFlagsB(initialFlagsB); }, @@ -147,6 +169,13 @@ export function EngiScoreEditorModal({ match, onClose, onSubmit, onSubmitAndNext const handleDismiss = async () => { if (submitting) return; + // A running match's flags are saved as entered: save any still pending + // and close, with nothing to discard. + if (m.status === "running") { + if (isDirty) flushPending(); + onClose(); + return; + } if (isDirty && !(await confirmDialog({ message: "Discard unsaved scoring changes?", confirmLabel: "Discard changes", danger: true }))) return; onClose(); }; @@ -172,6 +201,7 @@ export function EngiScoreEditorModal({ match, onClose, onSubmit, onSubmitAndNext // doSubmit takes a submit CLOSURE (like ScoreEditorModal) so the caller picks // onSubmit vs onSubmitAndNext; F5 retry re-invokes the same closure. const doSubmit = async (fn) => { + cancelDebounce(); // the explicit save supersedes a pending autosave setSubmitting(true); setErr(""); // Clear any prior pending/failed state when the operator explicitly retries. @@ -277,7 +307,7 @@ export function EngiScoreEditorModal({ match, onClose, onSubmit, onSubmitAndNext // fresh state via kbRef. Escape stays owned by useEscapeToClose above. const kbRef = useRefE(null); const lastSideRef = useRefE(null); // "a" | "s" | null: which side Backspace undoes - kbRef.current = { submitting, canSubmit, showCorrectionPrompt, flagsA, flagsB, setFlagsA, setFlagsB, clamp, handleSubmit, onPrev, onNext, prevMatch, nextMatch }; + kbRef.current = { submitting, canSubmit, showCorrectionPrompt, flagsA, flagsB, changeFlags, handleSubmit, onPrev, onNext, prevMatch, nextMatch }; useEffectE(() => { const onKeyDown = (ev) => { const s = kbRef.current; @@ -303,13 +333,13 @@ export function EngiScoreEditorModal({ match, onClose, onSubmit, onSubmitAndNext } switch (ev.key) { case "a": case "A": - ev.preventDefault(); s.setFlagsA(s.clamp(s.flagsA + 1)); lastSideRef.current = "a"; break; + ev.preventDefault(); s.changeFlags("a", s.flagsA + 1); lastSideRef.current = "a"; break; case "s": case "S": - ev.preventDefault(); s.setFlagsB(s.clamp(s.flagsB + 1)); lastSideRef.current = "s"; break; + ev.preventDefault(); s.changeFlags("b", s.flagsB + 1); lastSideRef.current = "s"; break; case "Backspace": case "Delete": ev.preventDefault(); - if (lastSideRef.current === "a") s.setFlagsA(s.clamp(s.flagsA - 1)); - else if (lastSideRef.current === "s") s.setFlagsB(s.clamp(s.flagsB - 1)); + if (lastSideRef.current === "a") s.changeFlags("a", s.flagsA - 1); + else if (lastSideRef.current === "s") s.changeFlags("b", s.flagsB - 1); break; default: break; } @@ -343,6 +373,7 @@ export function EngiScoreEditorModal({ match, onClose, onSubmit, onSubmitAndNext {isComplete && (
CORRECTION
)} + {canClose && }
@@ -377,7 +408,7 @@ export function EngiScoreEditorModal({ match, onClose, onSubmit, onSubmitAndNext @@ -1320,11 +1342,16 @@ export function ScoreEditorModal({ match, onClose, onSubmit, onSubmitAndNext, on // button having saved nothing. Check the awaited result // directly instead of relying on the broadcast. const res = await doSubmit(() => onSubmit(buildPatch("running"))); + // A refused start (the court is busy, a competitor is + // withdrawn) stored nothing: the host reported it and + // returns nothing, so the match must not read as started. + if (!res) return; // bc-cse: which not-saved banner, if any. The clock-vs- // supersede ordering (and the silence on a queued write) // lives in notLandedBanner; see write_result.jsx. const banner = notLandedBanner(res); if (banner) setWriteFailed(banner); + else setStartedFrom({ at: match.modifiedAt }); }} disabled={submitting}> Start match @@ -1332,7 +1359,7 @@ export function ScoreEditorModal({ match, onClose, onSubmit, onSubmitAndNext, on {canClose && } {onSubmitAndNext ? ( ) : ( } - - {err &&
{err}
} - {matches === null &&
Loading…
} - {matches !== null && matches.length === 0 && ( -
No remaining scheduled matches.
- )} - {matches && matches.length > 0 && ( -
    - {matches.map(m => { - const isOnA = sameCompetitor(m.sideA, withdrawnPlayer); - const opponent = isOnA ? m.sideB : m.sideA; - return ( -
  • -
    - {opponent?.name || "?"} - - {/* window.poolLabel, not a raw m.poolName: these rows come - straight off window.compMatches, so a Swiss match carries - the synthetic "Swiss-R1" engine name and a league match - carries a pool name it does not have. poolLabel is the one - owner of that translation (mp-dej2); the sibling panels in - admin_schedule_page / admin_scoring_engi / admin_scoring_team - already use it. */} - {m.phase === "pool" ? window.poolLabel(m) : m.round}{m.court ? ` · Shiaijo ${m.court}` : ""}{m.scheduledAt ? ` · ${m.scheduledAt}` : ""} - -
    - -
  • - ); - })} -
- )} - - ); -} - // BarredMatchNotice lives in its own leaf (barred_match_notice.jsx): it // imports only ineligible_match.jsx and write_result.jsx, so a surface that // does not otherwise need this module's graph (which pulls in bracket.jsx @@ -1188,13 +1024,14 @@ const CORRECTION_PRESETS = ["Scoring error", "Wrong competitor", "Data entry", " // the legacy bare "kiken" included, which the server loads as voluntary too. function withdrawalLabel(decision) { if (decision === "fusenpai") return "Fusenpai"; - // bc-rawm: a match-level fusensho, the shape RemainingMatchesPanel.award - // writes for a scheduled match against an already-withdrawn competitor - // (see the file header there). Short label for the same "fact" slots - // kiken/fusenpai use (e.g. admin_scoring_team.jsx's team-summary-decision); - // the fuller "Default win (fusensho) for " sentence is composed in - // RecordedWithdrawal, which needs the winner's name this function does not - // have. + // bc-rawm: a match-level fusensho, the shape a match-level default win + // takes when the operator taps "Record default win for " on a + // match against an already-withdrawn competitor -- the match's own notice + // (BarredMatchNotice / defaultWinDecisionBodyForSide, ineligible_match.jsx). + // Short label for the same "fact" slots kiken/fusenpai use (e.g. + // admin_scoring_team.jsx's team-summary-decision); the fuller "Default win + // (fusensho) for " sentence is composed in RecordedWithdrawal, + // which needs the winner's name this function does not have. if (decision === "fusensho") return "Default win (fusensho)"; return decision === "kiken-injury" ? "Kiken – Injury" : "Kiken – Voluntary"; } @@ -1228,10 +1065,11 @@ function withdrawnKeyOf(m) { // match back to running. function withdrawalInForce(m) { // bc-rawm: widened to the default-win class. A match-level fusensho is - // RemainingMatchesPanel.award's shape for a SCHEDULED match against a - // competitor already withdrawn elsewhere (fusenpai is refused there with - // 409 already_ineligible, which is why that panel writes fusensho - // instead): same default-win outcome as a kiken/fusenpai on this match, so + // the shape a match-level default win takes for a match against a + // competitor already withdrawn elsewhere -- recorded from that match's own + // notice (BarredMatchNotice / the queue row's Record default win, both + // building the body via defaultWinDecisionBodyForSide): same default-win + // outcome as a kiken/fusenpai on this match, so // it belongs in the same "a recorded withdrawal decided this" class as // kiken/fusenpai, not a separate one. So this asks the match-level // default-win class, whose one JS owner is isTeamDefaultWinDecision @@ -1373,9 +1211,9 @@ function useMatchReopen({ match, password, isComplete }) { }; // The remedy: send the blocking match back to the queue and reopen this one, - // in ONE server call under one court lock (mp-gmcg review A4). DESTRUCTIVE: - // revert-to-queue clears that match's partial score, which is why the panel - // spells the consequence out before this can be tapped. + // in ONE server call under one court lock (mp-gmcg review A4). The blocking + // match keeps its score in the queue (bc-sbq), and the panel says so before + // this can be tapped, because it takes that match off the court. const requeueBlocker = async () => { const c = conflict; if (!c || busy || landed) return; @@ -1420,9 +1258,9 @@ function useMatchReopen({ match, password, isComplete }) { // numbered), so it fell through to the bare "Shiro vs Aka" -- correct // but silent about which pool, unlike every bracket blocker, which // names its round via matchNumber's "Match N". window.poolLabel is - // the one owner of that pool-name translation (mp-dej2; the same - // helper RemainingMatchesPanel above uses), so a pool blocker now - // leads with it exactly as a bracket blocker leads with its number. + // the one owner of that pool-name translation (mp-dej2), so a pool + // blocker now leads with it exactly as a bracket blocker leads with + // its number. const lead = hit.phase === "pool" && typeof window.poolLabel === "function" ? window.poolLabel(hit) : (hit.matchNumber ? `Match ${hit.matchNumber}` : ""); @@ -1478,8 +1316,8 @@ function ReopenFeedback({ ctl, testIdPrefix }) { {courtBusyMessage({ court: c.court, label: ctl.blockerLabel || c.label || "another match" })}
- Sending it back to the queue clears any score already entered for it. Finishing that - match instead keeps its score. + Sending it back to the queue keeps any score already entered for it: it carries on + from there when it is started again.
- {isDefaultWin ? ( + {clearsDefaultWin ? ( // bc-cse: a fusensho names the OTHER competitor as barred, not // this match's own withdrawal, so clearing it does not simply // "reopen to running" -- the barred competitor is almost always @@ -1687,6 +1549,11 @@ function RecordedWithdrawal({ match, ctl, disabled = false, singleBout = false } record the default win again{canReinstate ? `, or reinstate ${who || "them"} first to fight it` : ""}. }

+ ) : barMovesOn ? ( +

+ The match goes back to the queue. {who || "The withdrawn competitor"} also did not + appear for a later match, listed below, so they are still withdrawn because of it. +

) : singleBout && match.decision === "fusenpai" ? ( // A no-show fought nothing: there are no points to keep or lose, // so the reopen only removes the default win. @@ -1901,7 +1768,6 @@ export { useAdoptFromServer, EnchoControl, DecisionPrompt, - RemainingMatchesPanel, BarredMatchNotice, FoulCounter, LineupNameInput, diff --git a/web-mobile/js/admin_scoring_team.jsx b/web-mobile/js/admin_scoring_team.jsx index e1e91474a..8a347a61e 100644 --- a/web-mobile/js/admin_scoring_team.jsx +++ b/web-mobile/js/admin_scoring_team.jsx @@ -24,7 +24,6 @@ import { daihyosenEnchoFields, EnchoControl, DecisionPrompt, - RemainingMatchesPanel, LineupNameInput, ReasonPrompt, CORRECTION_PRESETS, @@ -44,6 +43,7 @@ import { import { isBarredMatch } from './ineligible_match.jsx'; import { useDebouncedRunningWrite, SyncStatusPill } from './admin_scoring_autosave.jsx'; +import { serverNowMs } from './server_clock.jsx'; import { SideLabel } from './side_cell.jsx'; // Imported from the leaf, not read off `window`, for the same reason @@ -140,6 +140,7 @@ export function preserveStoredDaihyosenVerdict({ armed, pickedSide, tied, existi // re-exports them onward) continue to work. import { resolveMatchLineup, resolveLineupTeamId, resolveBoutSideName, resolveBoutSideMemberId, resolveSquadMember, squadMemberIdForUniqueName, squadRosterEntries, rosterWithoutPlacedElsewhere, resolveBoutSideDisplayName, buildInlineLineupWrite, POS_KEYS_5, POS_LABELS_5 } from './lineup_resolver.jsx'; import { DAIHYOSEN_POSITION } from './pool_ids.jsx'; +import { joinList } from './admin_helpers.jsx'; // The shared owner of what an operator is told about unreadable data; the // editor gets the repair-oriented wording, the pool surfaces get theirs. import { matchDataUnreadable, UnreadableEditorNote } from './data_integrity.jsx'; @@ -263,7 +264,7 @@ export function unfinishedTeamBouts({ subs, teamSize }) { export function unfinishedTeamBoutsMessage(teamSize, bouts) { const labels = (bouts || []).map(b => `Bout ${b}${teamSize === 5 && b >= 1 && b <= 5 ? ` (${POS_LABELS_5[b - 1]})` : ""}`); if (labels.length === 0) return ""; - const list = labels.length === 1 ? labels[0] : `${labels.slice(0, -1).join(", ")} and ${labels[labels.length - 1]}`; + const list = joinList(labels, "and", ""); return `${list} ${labels.length === 1 ? "has" : "have"} no result. Record a score, a Tie, or a Fusensho before finishing.`; } @@ -456,7 +457,8 @@ export function kachinukiEndOutcomeLabel(outcome) { // but whether the pairing must produce a result, e.g. the taisho must be // defeated, is OPERATOR DISCRETION, never derived from the phase). Not // available when nothing is recorded or when the last bout already has a -// winner. +// winner. Nor by pairing: any tied pair may fight on, the operator decides +// and the app records it (operator ruling 2026-09-26). export function kachinukiEnchoAvailable(outcome) { if (!outcome) return false; return outcome.kind === "draw" || (outcome.kind === "blocked" && outcome.reason === "knockout-tie"); @@ -743,7 +745,7 @@ export function reconcileRowsToPositions(rows, serverRows) { return serverRows.map(ss => byPos.get(ss._pos) || ss); } -export function TeamScoreEditorModal({ match, teamSize, onClose, onSubmit, onSubmitAndNext, onAfterDecision, prevMatch, nextMatch, onPrev, onNext, password, selfReport, variant = "modal", canClose = true }) { +export function TeamScoreEditorModal({ match, teamSize, onClose, onSubmit, onSubmitAndNext, onAfterDecision, onStartLanded, prevMatch, nextMatch, onPrev, onNext, password, selfReport, variant = "modal", canClose = true }) { // mp-gmcg: a successful [× Remove this bout] shrinks the SERVER bout log, and // the parent may not have caught up when this render runs. matchOverride // shadows the prop so the removed bout disappears at once, and is cleared @@ -863,7 +865,6 @@ export function TeamScoreEditorModal({ match, teamSize, onClose, onSubmit, onSub const [decisionPromptKind, setDecisionPromptKind] = useStateA(""); const [decisionSubmitting, setDecisionSubmitting] = useStateA(false); const [decisionErr, setDecisionErr] = useStateA(""); - const [withdrawnPlayer, setWithdrawnPlayer] = useStateA(null); // Audit reason collected when correcting a completed team match: mirrors // the ScoreEditorModal correction flow (same ReasonPrompt), and rides the // completing write as correctionReason. @@ -1374,10 +1375,12 @@ 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). // Item 7: fusenpai routes through onAfterDecision (host-supplied) to advance - // the court, same as ScoreEditorModal. Kiken keeps the modal open regardless. + // the court, same as ScoreEditorModal. Kiken follows the same rule now too + // (operator ruling 2026-09-26): recording a withdrawal changes only the + // match it was recorded on. const submitDecision = makeSubmitDecision({ match: m, enchoPeriodCount, password, mountedRef, - setDecisionSubmitting, setDecisionErr, setWithdrawnPlayer, setDecisionPromptKind, + setDecisionSubmitting, setDecisionErr, setDecisionPromptKind, onClose, onAfterDecision, isComplete, entityLabel: "teams", }); @@ -1469,6 +1472,10 @@ export function TeamScoreEditorModal({ match, teamSize, onClose, onSubmit, onSub // that reaches for setSubs directly is therefore visibly out of pattern. const [operatorEditSeq, setOperatorEditSeq] = useStateA(0); const setSubsByOperator = (updater) => { setSubs(updater); setOperatorEditSeq(n => n + 1); }; + // bc-kclr: when the operator last edited each bout row (position -> ms, in + // the server's clock frame, server_clock.jsx), read by the per-row adopt + // below. + const lastRowEditRef = useRefA(new Map()); // C1: updateSub is the single choke-point for all sub-bout state // mutations. Calling markScoringDirty() here captures every edit // (pts add/remove, fouls, fusensho, draw) without repetition. @@ -1479,6 +1486,7 @@ export function TeamScoreEditorModal({ match, teamSize, onClose, onSubmit, onSub // row that is not theirs to edit, without a second mechanism racing the // render to commit the shape first. const updateSub = (idx, fn) => { + lastRowEditRef.current.set(subs[idx]._pos, serverNowMs()); setSubsByOperator(prev => { const rows = reconcileRowsToPositions(prev, serverSubs); return rows.map((s, i) => i === idx ? fn(s) : s); @@ -1794,11 +1802,29 @@ export function TeamScoreEditorModal({ match, teamSize, onClose, onSubmit, onSub // guard mirrors kachinukiEnchoOffered so the keyboard/programmatic path can // never target a bout the encounter has already advanced past. const applyKachinukiEncho = () => { - if (kachinukiLastScoredIdx < 0 || kachinukiLastScoredIdx !== kachinukiCurBoutIdx) return; + if (!kachinukiEnchoOffered) return; setEnchoPeriodCount(cnt => cnt + 1); updateSub(kachinukiLastScoredIdx, prev => ({ ...prev, encho: (prev.encho || 0) + 1, draw: false, _preFusensho: undefined })); setEndArmed(false); }; + // bc-kenu (operator ruling 2026-09-26: every mistake undoable in one step): + // bout mode hides the overtime stepper, so an Encho tapped by mistake (or + // twice) had no way back while the bout was fought. Undo takes back one + // period, offered while the bout is still level (nothing decided in encho). + // Taking back the last period restores the tie: Encho is only ever offered + // on a tied bout, and applying it cleared the Tie toggle. + // Level is counted through subTotals (realIppons), the file's one count. + const kachinukiEnchoUndoable = kachinukiCurBoutIdx >= 0 && (subs[kachinukiCurBoutIdx].encho || 0) > 0 + && subTotals[kachinukiCurBoutIdx].aTotal === subTotals[kachinukiCurBoutIdx].bTotal; + const undoKachinukiEncho = () => { + if (!kachinukiEnchoUndoable) return; + setEnchoPeriodCount(cnt => Math.max(0, cnt - 1)); + updateSub(kachinukiCurBoutIdx, prev => { + const encho = (prev.encho || 0) - 1; + return encho > 0 ? { ...prev, encho } : { ...prev, encho: 0, draw: true }; + }); + setEndArmed(false); + }; // Manual next bout (mp-gmcg): the server auto-append can only pair // fighters it knows (lineups + bout log) and team sizes are unregulated, // so the operator can append the next pairing locally. The row becomes a @@ -2195,6 +2221,25 @@ export function TeamScoreEditorModal({ match, teamSize, onClose, onSubmit, onSub // the explicit "Record bout" action (never for autosave, Start, Finish or // corrections). The server advances the kachinuki sequence only on // flagged writes (handlers_match.go scoreRequestBody). + // bc-kclr: which kachinuki rows a patch carries. The patch leaves unplayed + // rows out (untouched trailing positions must never reach the wire), but the + // server merge (mergeKachinukiSubResults) keeps any stored row a payload + // omits, so a bout the operator CLEARED back to 0-0 kept the point it was + // cleared of: it came back on reload, on every other surface, and under End + // match. So a row goes out, played or not, when it is: + // - the bout being fought (kachinukiCurBoutIdx) or the earlier bout being + // corrected in place (editingDoneBoutIdx): the rows an operator edits. + // This cannot ask what the server holds, because the court feed lags + // the editor: a point autosaved a moment ago is stored while this board's + // copy of the server row (serverSubs) still reads 0-0, and a clear made + // then would read as "nothing to clear". An unscored pairing row is also + // a shape the server already holds after every append, so storing the + // current bout early changes nothing else; + // - any other row whose stored copy still carries a result this board no + // longer shows. + const kachinukiRowSent = (idx) => + subBoutHasBeenPlayed(subs[idx]) || idx === kachinukiCurBoutIdx || idx === editingDoneBoutIdx + || subBoutHasBeenPlayed(serverSubs[idx]); const buildPatch = (targetStatus, opts = {}) => { if (targetStatus === "scheduled") return { winner: null, status: "scheduled", score: null, ipponsA: [], ipponsB: [], subResults: [] }; // ONE preserve verdict for this save: the sub-row overlay and the @@ -2383,11 +2428,12 @@ export function TeamScoreEditorModal({ match, teamSize, onClose, onSubmit, onSub return entry; }); // Kachinuki appends bouts dynamically, so the all-positions map above leaves - // untouched trailing positions. Drop them (keep the daihyosen and any played - // bout): see subBoutHasBeenPlayed. Team matches keep every position; an - // unplayed one carries decision "" and Finish refuses until it has a result. + // untouched trailing positions. Drop them, keeping the daihyosen and every + // row kachinukiRowSent names above (see subBoutHasBeenPlayed). Team matches + // keep every position; an unplayed one carries decision "" and Finish + // refuses until it has a result. if (isKachinuki) { - subResults = subResults.filter((_entry, idx) => idx === daihyosenIdx || subBoutHasBeenPlayed(subs[idx])); + subResults = subResults.filter((_entry, idx) => idx === daihyosenIdx || kachinukiRowSent(idx)); } // While an unattributable stored verdict is being preserved (armed, // unpicked), the MATCH-level result must survive too: deriving winner @@ -2527,14 +2573,16 @@ export function TeamScoreEditorModal({ match, teamSize, onClose, onSubmit, onSub // hantei arm (see daihyosenResultDirty). const scoringDirty = JSON.stringify(subs) !== serverSubsSig || daihyosenResultDirty; // bc-dscn: an edit the running patch would NOT carry. Under kachinuki - // buildPatch drops every row subBoutHasBeenPlayed rejects, so a changed but - // unplayed row (a fighter picked on the current bout past the first, which - // rides the bout and not a lineup PUT, or a bout cleared back to 0-0) never - // reaches the server by a flush. Closing would lose it, so it keeps the - // prompt. Same per-row server comparison isDirty makes: subs is aligned to - // serverSubs by reconcileRowsToPositions, so index idx is the same position. + // buildPatch drops every row kachinukiRowSent does not name, so a changed + // row it leaves out never reaches the server by a flush. Closing would lose + // it, so it keeps the prompt. Since bc-kclr the current bout and the bout + // being corrected are always sent, so a fighter picked on the current bout + // or a bout cleared back to 0-0 is no longer such an edit. Same per-row + // server comparison isDirty makes: subs is aligned to serverSubs by + // reconcileRowsToPositions, so index idx is the same position. const runningPatchDropsAnEdit = isKachinuki && subs.some((s, idx) => - idx !== daihyosenIdx && !subBoutHasBeenPlayed(s) && JSON.stringify(s) !== JSON.stringify(serverSubs[idx])); + idx !== daihyosenIdx && !kachinukiRowSent(idx) + && JSON.stringify(s) !== JSON.stringify(serverSubs[idx])); // RE-SEED the bout rows when the stored result moves, PER ROW. // // Without this an editor left open kept showing the board it opened with @@ -2569,6 +2617,18 @@ export function TeamScoreEditorModal({ match, teamSize, onClose, onSubmit, onSub return serverSubs.map(ss => { const local = localByPos.get(ss._pos); if (!local) return ss; + // bc-kclr: a snapshot written BEFORE the operator's last edit to + // this row cannot know that edit, so the row stays theirs, whatever + // it now equals. Striking a point and taking it back returns the + // row to the value the server showed BEFORE the strike, so the + // untouched test below read it as untouched, and the court feed's + // lagging snapshot carrying the autosaved point was adopted over + // the clear (and written back). Once a snapshot written after the + // edit arrives (this editor's own save of it, or a later change on + // another device) the ordinary rule applies. Both stamps are in the + // server's clock frame (server_clock.jsx), so no time window. + const editedAt = lastRowEditRef.current.get(ss._pos); + if (editedAt !== undefined && (m.modifiedAt || 0) < editedAt) return local; const prior = priorByPos.get(ss._pos); // Untouched: the operator's row still equals what the server last // said, so there is nothing of theirs to keep — take the new value. @@ -3531,10 +3591,10 @@ export function TeamScoreEditorModal({ match, teamSize, onClose, onSubmit, onSub 2026-09-24), by reopening the match. Switching it to the other team stays with the Withdrawal or no-show controls below. The same component serves the individual editor. */} - {recordedWithdrawal && !decisionPromptKind && !withdrawnPlayer && !selfReport && ( + {recordedWithdrawal && !decisionPromptKind && !selfReport && ( )} - {!withdrawnPlayer && !decisionPromptKind && !selfReport && ( + {!decisionPromptKind && !selfReport && (
Withdrawal or no-show (kiken · fusenpai)
@@ -3596,16 +3656,6 @@ export function TeamScoreEditorModal({ match, teamSize, onClose, onSubmit, onSub onSubmit={({ decisionBy, decisionReason }) => submitDecision(decisionPromptKind, { decisionBy, decisionReason })} /> )} - {withdrawnPlayer && ( - { /* stay open; operator decides when to close */ }} - onClose={() => { setWithdrawnPlayer(null); onClose(); }} - /> - )} - {/* FR-033 encho toggle. Placed at the BOTTOM, beside the End/Reopen controls (operator feedback: controls belong at the bottom, not the top). EnchoControl collapses to a pill when no overtime is active. @@ -3654,7 +3704,9 @@ export function TeamScoreEditorModal({ match, teamSize, onClose, onSubmit, onSub legitimate: whether the pairing must produce a result (e.g. the taisho must be defeated) is operator discretion, never derived from the phase. The Encho affordance therefore - renders for every tied last bout (kachinukiEnchoAvailable). + renders for every tied last bout (kachinukiEnchoAvailable), + whichever pair it is: the operator decides whether a pair + fights on (operator ruling 2026-09-26). This replaces the koTieBlocked gating for kachinuki: the correction-mode Finish buttons below never see a running kachinuki match, so there are no competing hints. */} @@ -3743,11 +3795,18 @@ export function TeamScoreEditorModal({ match, teamSize, onClose, onSubmit, onSub // silent for. Start match is an explicit operator tap, not // an autosave, so check the awaited result directly. const res = await doSubmit(() => onSubmit(buildPatch("running"))); + // A refused start (the court is busy, a competitor is + // withdrawn) stored nothing: the host reported it and + // returns nothing, so the match must not read as started. + if (!res) return; // bc-cse: which not-saved banner, if any. The clock-vs- // supersede ordering (and the silence on a queued write) // lives in notLandedBanner; see write_result.jsx. const banner = notLandedBanner(res); if (banner) setWriteFailed(banner); + // bc-strt: the start landed, so ScoreEditorModal treats the + // match as running before the host's list catches up. + else if (typeof onStartLanded === "function") onStartLanded(); }} disabled={submitting}>Start match )} {/* mp-gmcg: mistake recovery on a completed kachinuki match: @@ -3841,6 +3900,18 @@ export function TeamScoreEditorModal({ match, teamSize, onClose, onSubmit, onSub Encho )} + {kachinukiEnchoUndoable && ( + + )}
)} + {/* correctingMatch too: correcting the court's last + bout makes allDone true, and the pin must still + hold the editor open. */} {(!allDone || correctingMatch) && selectedMatch && ( {}} + started={!!startedFrom && startedFrom.key === matchKey(selectedMatch) && startedFrom.at === selectedMatch.modifiedAt} canClose={false} onSubmit={async (patch) => { try { @@ -1647,10 +1685,12 @@ function AdminShiaijoPage({ tournament, court: routeCourt, onBack, onEditScore, // them to go and check. A queued write still advances (it // reconciles on reconnect); see writeWasSuperseded. if (!writeWasSuperseded(res)) maybeAdvanceLocal(selectedMatch, patch); - // A write that did not land (queued, F5; or superseded by a - // newer stored result, bc-lww1) returns its signal so the - // editor shows the not-saved banner rather than looking saved. - if (writeDidNotLand(res)) return res; + // What the write came back with, so the editor shows the + // not-saved banner for one that did not land (queued, F5; or + // superseded by a newer stored result, bc-lww1) and tells a + // landed start from a refused one, which threw and returns + // nothing. + return res; } catch (_e) { /* surfaced via toast */ } }} @@ -1685,8 +1725,9 @@ function AdminShiaijoPage({ tournament, court: routeCourt, onBack, onEditScore, applyLocalBracketWin(selectedMatch.compId, selectedMatch.id, winnerName); } // Then start the next scheduled match so the panel advances - // (mirrors onSubmitAndNext). - const next = nextActiveAfter(selectedMatch); + // (mirrors onSubmitAndNext), passing over the matches of a + // competitor a withdrawal or no-show has just barred. + const next = nextActiveAfter(selectedMatch, sideBarredByDecision(result, selectedMatch)); if (next && next.status === "scheduled") { try { await onEditScore(next.compId, next.id, startPatch(), next); } catch (_s) { /* gate */ } } @@ -1713,14 +1754,15 @@ function AdminShiaijoPage({ tournament, court: routeCourt, onBack, onEditScore, `!correctingMatch` guard: a reopened correction IS the running bout on this court (it becomes the live pick as soon as the refetch shows it running), so - "Send back to queue" is its sanctioned exit - the - twin of "Back to court" above, which stopCorrecting + "Send back to queue" is its exit - the twin of + "Back to court" above, which stopCorrecting deliberately withholds while running (a stray Back to court could strand a result-less bout behind another running match). `status === "running"` alone keeps this off a still-completed correction, - which shows "Back to court" instead. Without this, - a reopened correction had no in-panel exit. */} + which shows "Back to court" instead. Offered + whatever has been scored: the match keeps its + score in the queue (bc-sbq). */} {!allDone && selectedMatch && selectedMatch.status === "running" && window.API && typeof window.API.revertMatchToQueue === "function" && (