diff --git a/CLAUDE.md b/CLAUDE.md index 8f427ab4c..c5493b520 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -187,7 +187,7 @@ tournament-data/ - **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). The start's representative-bout winner is derived AFTER that swap (`deriveDaihyosenWinner`, scoring_tx.go), so it comes from the bouts the start keeps, never from rows it discards. 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. The server keeps them too: `engine.AddDaihyosen` stores the match's team names on the row when the bout is added, and `seatBracketSide` (bracket_seat.go, the ONE owner of seating a knockout side) keeps the row's `sideA`/`sideB` equal to the match's on every seat, and carries the row's `Winner` to the new name when the side keeps its team and the Winner names the side by its old name, the row's own or the match's (a rename: `nameRepBoutRow` with `another` false and `matchOld` the match's name before the seat, since a row's side name can be blank or stale; the carry also runs when the row already reads the new name; `ReplaceParticipantInDraw` seats a side matched by id through it, `renameBracketSide`, and renames an id-less side's row name without clearing a pick), judging "another team" by id when both sides carry one and by name otherwise; a side given another team loses its representative pick there, dated after the pick it clears. `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`. 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`). A kachinuki encounter never carries a match-level encho (operator ruling 2026-09-24, bc-kheb): one bout fought on in encho does not put the encounter in overtime, so (E) lives on that bout's row only. `state.Competition.ClearKachinukiEncounterEncho(matchID, ...)` is the one owner, and it leaves a pool representative bout or tie-break bout (`Pool X-DH-N`, `Pool X-TB-N`, the `state.IsPoolDaihyosenMatchID`/`IsTiebreakerMatchID` grammar) alone at all three sites below: that match IS one individual bout, whose overtime lives at match level: `applyKachinukiMerge` strips the field on every score write (logged, never refused, since an older client's queued write inherits it), the engine's decision write (`recordDecisionTx`) drops it before the decision's circles are counted, and the load repair `upgradeKachinukiEncounterEnchoLocked` clears what older releases stored, gated on the config.md marker `kachinuki_encounter_encho_cleared`, which `POST /competitions` sets on a competition created as kachinuki (it has nothing to repair) and which a match file the repair cannot read leaves unset, so a later start retries. The team editor sends none (`encounterEnchoCount`) and offers no match-level overtime stepper on a kachinuki encounter. 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 kiken, kiken-injury, fusenpai or fusensho decided; a 400 otherwise) flips a completed match back to running, clearing winner/decision but keeping the bout log -- EXCEPT a kiken, fusenpai or fusensho whose `decisionBy` side is STILL barred by a withdrawal recorded on a DIFFERENT match: `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 (clearing the match that recorded a bar moves that bar onto a chained fusenpai rather than lifting it); once the bar is gone, the competitor reinstated or the match that recorded it cleared, the identical reopen goes 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 ", worded once by `alreadyPlacedNote`, the position named by `lineupPositionLabel` on every surface; a change to a position takes a refusal down) 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. 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. Positions are ordered in ONE way, `domain.ComparePositions` (the FIK names in fighting order, then numbered positions by number, then any other by name), which the roster walk (`OrderedMembers`), the export and the member-id load repair share; a text sort put Chuken before Senpo and "10" before "2". +- **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`). A kachinuki encounter never carries a match-level encho (operator ruling 2026-09-24, bc-kheb): one bout fought on in encho does not put the encounter in overtime, so (E) lives on that bout's row only. `state.Competition.ClearKachinukiEncounterEncho(matchID, ...)` is the one owner, and it leaves a pool representative bout or tie-break bout (`Pool X-DH-N`, `Pool X-TB-N`, the `state.IsPoolDaihyosenMatchID`/`IsTiebreakerMatchID` grammar) alone at all three sites below: that match IS one individual bout, whose overtime lives at match level: `applyKachinukiMerge` strips the field on every score write (logged, never refused, since an older client's queued write inherits it), the engine's decision write (`recordDecisionTx`) drops it before the decision's circles are counted, and the load repair `upgradeKachinukiEncounterEnchoLocked` clears what older releases stored, gated on the config.md marker `kachinuki_encounter_encho_cleared`, which `POST /competitions` sets on a competition created as kachinuki (it has nothing to repair) and which a match file the repair cannot read leaves unset, so a later start retries. The team editor sends none (`encounterEnchoCount`) and offers no match-level overtime stepper on a kachinuki encounter. 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 kiken, kiken-injury, fusenpai or fusensho decided; a 400 otherwise) flips a completed match back to running, clearing winner/decision but keeping the bout log -- EXCEPT a kiken, fusenpai or fusensho whose `decisionBy` side is STILL barred by a withdrawal recorded on a DIFFERENT match: `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 (clearing the match that recorded a bar moves that bar onto a chained fusenpai rather than lifting it); once the bar is gone, the competitor reinstated or the match that recorded it cleared, the identical reopen goes 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. The live bout is the highest-position numbered row (`lastNumberedBout`), never the slice's last row: Record bout numbers its pairing one past it (`nextKachinukiBoutPosition`), and the removal, the un-record and the roster read all name that row. Record bout marks the bout it closes recorded (`SubMatchResult.Recorded`, wire `recorded`, the server-only merge group `recorded`), with or without a next pairing; removing a pairing Record bout appended clears it, and a write that adds an overtime period to the bout, or changes its result (winner side or decision), clears it too unless it is older than the mark or unstamped (`mergeHold.unrecordReopenedBouts`; an unstamped write cannot be ordered after the Record bout); Remove clears it only when the advance line that appended the removed pairing also set the mark; the advisory line's roster (`engine.KachinukiRoster`) keeps both fighters of the last bout on until it is recorded as a win (a running score is no result), and it answers Record bout's own decision on the stored match, asked quietly through the one `decideKachinukiAdvance` the advance writes from, over the same queues and retirements (`readKachinukiQueues`, built once per read): `nextPairing` (a tap would append; false on a recorded live bout greys Record bout out, but only from a read for the current bout log and lineups, never from an older answer the closing keeps) and each side's `exhausted` (the loser of a recorded win whose LINEUP has nobody left; never after a draw or without a lineup). A recorded last bout with no pairing after it reads closed (read-only, with the no-fighters-left line) exactly when those flags say so; the sheet never re-derives them from the queues. A bout copy never moves the mark (`state.CopyGroup`), and the merge re-applies the stored `recorded` group once every hold and displacement is done, so a mark a payload carries never lands; the sheet does not send it. 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 ", worded once by `alreadyPlacedNote`, the position named by `lineupPositionLabel` on every surface; a change to a position takes a refusal down) 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. 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. Positions are ordered in ONE way, `domain.ComparePositions` (the FIK names in fighting order, then numbered positions by number, then any other by name), which the roster walk (`OrderedMembers`), the export and the member-id load repair share; a text sort put Chuken before Senpo and "10" before "2". - **A team carries the lineup of its previous team match** (operator ruling 2026-10-05: "by default, a team carries the previous team match lineup. You can have a different team lineup in every team match"). `engine.LineupInForce` (`internal/engine/lineup_in_force.go`) is the ONE owner: the match's own lineup (read whole, even one that holds no position), else the latest lineup the team had before it in match order (an earlier match's, or its starting lineup, which comes before every match); a match the draw does not hold gets its own lineup or the starting lineup. A lineup saved for a later round is never read (see the migration bullet below). A lineup is matched by the team's participant id only, never by its name; an id-less side has none. Discarding a draw removes the lineups saved for its matches. Match order is the pool-match number, then the knockout by round and position, the 3rd-place match last; `state.TeamMatches` is the one list of a draw's team matches (a bye, a pool daihyosen or tie-break bout, and a league's or Swiss competition's vestigial bracket are none), for the rule and the migration alike. The kachinuki roster, the Kachinuki Detail export and every client surface (through `GET .../lineup-in-force/:matchId` and `resolveMatchLineup`) ask it; no surface derives a round to read a lineup. The Lineups page edits each team's starting lineup (stored as its round-0 entry) and any match's own lineup; **Use the previous match's lineup** removes a match's own lineup so it inherits again. **Copy from previous match** was removed because carrying over is the default; do not re-add it. A match the Lineups page has chosen that the team is no longer in (a correction seated another team) stays chosen, locked, Use the previous match's lineup included, with a notice naming it (`notInMatch`), rather than the page silently editing the starting lineup. The at-court lineup panel follows the match it was opened for: its host keeps the match's key and reads the row every render, so a side given another team gets that team's editor and a match that leaves the data closes the panel. - **A lineup save names the positions it changed** (operator decision 2026-10-07, "Only changed positions"). `PUT .../match-lineups/:matchId` and `PUT .../lineups/0` take `changed` (`LineupRequest.Changed`), and the server lands only those positions (`domain.TeamLineup.ApplyChanges`), under the competition's lock, on the base it reads there (`lineupSave.base`): the match's own stored lineup, else the lineup in force there (`engine.LineupInForceFrom`, the one rule over what the transaction loaded, since the store's own reads would wait on the lock it holds), else empty; for the starting lineup, its stored entry, else empty. The answer is the whole lineup as stored. So two devices changing different positions both keep their change in either arrival order, a save sent later from a device that was offline included; the same position keeps the later arrival (accepted with that decision; do not report it as a new defect). A save without `changed` replaces the whole lineup, which is how a save queued by an older build replays. An empty `changed`, or a changed position `positions` has no entry for, is a 400; a member at two positions of the composed lineup is the existing duplicate 400 naming both. `web-mobile/js/lineup_save.jsx` (a leaf) is the client owner: `changedLineupSave` builds the body every writer sends (the Lineups page, the panel, and the sheet's name box through `buildInlineLineupWrite`), a cleared position as an empty name with no id, and `joinQueuedLineupSave` joins a save into a queued save of the same lineup (the union of `changed`, the later value per position; a queued entry with no `changed` stays whole with the change applied), for `_placeWrite`: a save of a lineup with a save of it queued joins that entry rather than going straight to the server, where it could land before the queued replay and lose to it. Both lineup editors share `useLineupForm` and `LineupSourceLine` (`lineup_draft.jsx`): a lineup is saved only after it was read (`loadedKey`), `lineupToSave()` is the form with `changed` and reads nothing, and `confirmSaved` adopts the answered lineup, so a position another device changed shows once the save lands. Removing a match's own lineup waits while a save of it is still queued (`API.queuedLineupSave`, a boolean). The team score sheet's name box sends `changed: [posKey]` on the lineup it holds for that side (`lineupHeldFor`), which the duplicate check and the rows need. A pick on a side it never read (the first read still out, or failed) reads that side itself and is refused when that read fails (`LINEUP_NOT_READ_NOTICE`): with every save now landing only its own position, the reason is no longer an overwrite but that the rows of a lineup never read would show empty positions that may not be, which the operator would then fill over fighters they cannot see; such a side is read again when the connection returns, and a write of the side, sent or queued, counts as the side's latest read, so a read still out from before it never puts the old lineup back. Both lineup editors follow a `lineup-updated` naming their team (one naming no team counts for every team; one naming another is ignored), once a read of the lineup still out has shown it: the lineup is read again and shown while the form has no unsaved change and no removal is out, and the team's members are read again either way, since the list is not the operator's edit (reads begun in the same tick share one `fetchSquads`, `requestMembers`). A Save makes no read: a member another device adds is announced, so the editors' lists hold it. If this device missed that event, a name typed for that member resolves against the stale list, the server refuses a second member of that name (`squadDuplicateNameCheck`), and the position saves by name with the identity warning (accepted, weighed 2026-10-07: a bounded read at every Save added a second wait on a link that does not answer, against a rare race that ends visible). - **Team members carry a server stamp** (operator decision 2026-10-07): `TeamMember.ModifiedAt`, set by every member write (see the team-members paragraph under Common Pitfalls). `mergeMembers` (lineup_draft.jsx) is the ONE client rule: the union by member id (no screen removes a member), each member's copy with the larger stamp (the arriving copy on a tie), in member number order. Every list that reaches an editor or the sheet (a read's answer, the host's `teamMembers` copy, the viewer payload) and every member write's answer (`addTeamMember`, `renameTeamMember` and `clearTeamMember` each return the stamped member, bounded through `_memberWrite`; an answer with no id is a failed write) goes through it, so an older list can never put back an older name and a later change made elsewhere always shows. The editors' own changes go through `useLineupForm.changeMembers`/`memberRenamed(member)`, the sheet's through `wroteSideMembers` and its lists through `takeSideMembers`; none of them reads again, since every member write is announced. A member object built by hand carries no stamp and loses to every list: an own write's member is the server's answer. A list or an answer made for a team the editor has since left lands nowhere (`teamKey` against `squadTeam`). A name typed before the team's members have been read waits for them, bounded by `FETCH_TIMEOUT_MS` (`newMembersWait`, lineup_draft.jsx, the one owner: `useLineupForm.waitForMembers()` for the panel's Save and the Lineups page's add row, the sheet's per-side wait for its name boxes): resolved against no members, a new name would be minted where the position's seeded slot is free, and the name of a member the team has would be refused by the server as a second one. Only a READ's list ends that wait, even an empty one, never an own write. A read that fails, or one not answered in time, ends the wait for good (so a read that hangs holds up one name, not every name typed after it), and the name is resolved without the members, with the members-unavailable warning. A sheet side is given another team (a correction made elsewhere seats another one) only when both its entry in the match (`sideLookupKey`) and the team it resolves to change: a side that carried only its team's name gaining that team's id is not one. Then the sheet drops that side's list and its wait (`dropSideMembers`) and the old team's lineup (`dropLineup`: a read or write of it still out never lands, and the side counts as never read until the new team's lineup does), and reads the new team's. A pick is checked against the lineup sync's epoch for its side (`watchLineupSide`) after its wait and after its member writes, and when the side was given another team meanwhile nothing more is written and the row says so. A side takes a members list only once its team is known (`sideTeam`: the side carries an id, or the roster resolved its name), and a pick writes for the team the side has when its wait ends (`sideSeen`). The identity effect runs ahead of the lineup and members reads, pinned by source order in `team_editor_members_read_first.render.test.jsx`, since no behavioural test can see the order while the lineup read awaits first. The two lineup reads (`fetchLineupInForce`, `fetchTeamLineup`) are bounded by that same deadline, and an answer that is not JSON is a failed read, never an empty lineup. @@ -293,8 +293,8 @@ The engine-side contradiction guard is `stripInvalidHantei` (replaced `preserveM Operator ruling 2026-10-03: "Nothing should be dropped. All events must be ordered." A match write is merged GROUP BY GROUP onto the stored match, ordered by the stamp of when the change was made (`modifiedAt`, server-relative, never arrival order), and what does not apply is kept in the match's history. Each rule below has one owner; change the owner, never a call site. -- **The groups** (`internal/state/match_groups.go`, the ONE owner of which fields each holds): `points` (IpponsA/B, HansokuA/B), `result` (Status, Winner/WinnerID, Decision/DecisionBy/DecisionReason, and the CorrectionReason/ResultSource that describe it), `encho`, `flags` (engi), `rep` (pool rep players), `repPickA` and `repPickB` (each side's representative picked for a team match's representative bout, SideAMemberID and SideBMemberID of the -1 row: each dated apart from the other side's and from `bout:-1`, so two captains picking at the same moment both land, a point on that bout never erases a pick and a pick never erases a point; the row's WinnerMemberID stays with the bout and the merge re-derives it when the two disagree, `ReconcileWinnerMemberID`), and `bout:` per SubResults row (1..n, -1). Sides and ids, court, schedule, round, ReopenPending, IsOverridden and the bracket structure are NOT groups: their own writers keep them. `CopyGroup` moves a group, `GroupDiffers` compares one, `GroupValue` projects one for the history; nothing hand-copies a field list. -- **The stamps**: `GroupStamps map[string]int64` on `MatchResult` and `BracketMatch` (pool-matches.csv: the `GroupStamps` column, index 29, JSON, appended after `ModifiedAt`; bracket.json: `groupStamps`). A match with NO map reads every group as stamped at its `ModifiedAt` (legacy files behave exactly as the whole-match guard did); once a map exists, a group with no entry was never written (0). `ModifiedAt` stays the newest group stamp (recency and `keepNewerMatches` read it). A bout whose row is gone keeps its stamp: a TOMBSTONE, so an older write still carrying the row cannot resurrect it. `stampGroups` (`MatchResult.StampGroups`/`BracketMatch.StampGroups`) is the ONE stamper: it materializes a legacy match's implicit stamps before stamping one group, or every other group would start reading the new `ModifiedAt`, and it never LOWERS a group's stamp (a caller that already holds a later one for a group -- an engi finish let through as a HeldEcho under an older stamp -- must not drag it backwards, or a change genuinely made between the two would wrongly read as applying after a group it never actually followed; mirrors `ModifiedAt`'s own never-lower rule); a stamp the MERGE decided, which may be lower (S2's displacement moves a newer invalid recount out and gives the group the finish's own stamp), is written as decided through `ApplyMergedGroupStamps`, never through `stampGroups`. Every writer that changes a group outside the merge stamps it through it: the reopen (`reopenPoolMatch`/`reopenBracketMatch`, server now), the requeue (`RevertMatchToQueue`/`requeueBracketMatch`: the verdict only, the kept score keeps its stamps), override-winner (`result`, and its own comparison is on that group), the engi finish (`result`+`flags`, at the client's stamp once the merge has ordered it, `engiFinishHeld`; server now only for an unstamped writer), the kachinuki advance (the appended `bout:n` + `result`, at the server's time and never older than the match, as `writeStamp` stamps every server-built write; the Record-bout answer echoes the bout log AND the stamps the advance left, `engine.KachinukiAdvance`, and the team editor adopts the stamp with the log, so its next tap on the new bout is floored by it, bc-hlck), `RemoveTrailingKachinukiBout` (the removed bout's tombstone), and a downstream reopen (`reopenDisplacedBracketMatch`, `bout:-1` with it). Byes auto-completed at draw time carry no stamp, which is right. +- **The groups** (`internal/state/match_groups.go`, the ONE owner of which fields each holds): `points` (IpponsA/B, HansokuA/B), `result` (Status, Winner/WinnerID, Decision/DecisionBy/DecisionReason, and the CorrectionReason/ResultSource that describe it), `encho`, `flags` (engi), `rep` (pool rep players), `recorded` (the SERVER's alone, `state.ServerGroups`: which numbered kachinuki bouts Record bout closed, as sorted positions; a client naming it in `changed` is a 400, the merge copies the stored marks over a client's rows, and `bout:N` does not hold the mark, so a point on bout N and Record bout never fence each other), `repPickA` and `repPickB` (each side's representative picked for a team match's representative bout, SideAMemberID and SideBMemberID of the -1 row: each dated apart from the other side's and from `bout:-1`, so two captains picking at the same moment both land, a point on that bout never erases a pick and a pick never erases a point; the row's WinnerMemberID stays with the bout and the merge re-derives it when the two disagree, `ReconcileWinnerMemberID`), and `bout:` per SubResults row (1..n, -1). Sides and ids, court, schedule, round, ReopenPending, IsOverridden and the bracket structure are NOT groups: their own writers keep them. `CopyGroup` moves a group, `GroupDiffers` compares one, `GroupValue` projects one for the history; nothing hand-copies a field list. +- **The stamps**: `GroupStamps map[string]int64` on `MatchResult` and `BracketMatch` (pool-matches.csv: the `GroupStamps` column, index 29, JSON, appended after `ModifiedAt`; bracket.json: `groupStamps`). A match with NO map reads every group as stamped at its `ModifiedAt` (legacy files behave exactly as the whole-match guard did); once a map exists, a group with no entry was never written (0). `ModifiedAt` stays the newest group stamp (recency and `keepNewerMatches` read it). A bout whose row is gone keeps its stamp: a TOMBSTONE, so an older write still carrying the row cannot resurrect it. `stampGroups` (`MatchResult.StampGroups`/`BracketMatch.StampGroups`) is the ONE stamper: it materializes a legacy match's implicit stamps before stamping one group, or every other group would start reading the new `ModifiedAt`, and it never LOWERS a group's stamp (a caller that already holds a later one for a group -- an engi finish let through as a HeldEcho under an older stamp -- must not drag it backwards, or a change genuinely made between the two would wrongly read as applying after a group it never actually followed; mirrors `ModifiedAt`'s own never-lower rule); a stamp the MERGE decided, which may be lower (S2's displacement moves a newer invalid recount out and gives the group the finish's own stamp), is written as decided through `ApplyMergedGroupStamps`, never through `stampGroups`. Every writer that changes a group outside the merge stamps it through it: the reopen (`reopenPoolMatch`/`reopenBracketMatch`, server now), the requeue (`RevertMatchToQueue`/`requeueBracketMatch`: the verdict only, the kept score keeps its stamps), override-winner (`result`, and its own comparison is on that group), the engi finish (`result`+`flags`, at the client's stamp once the merge has ordered it, `engiFinishHeld`; server now only for an unstamped writer), the kachinuki advance (the `recorded` mark, and the appended `bout:n` + `result`, at the server's time and strictly after the match's `ModifiedAt`, so every device's `keepNewerMatches` orders the marked copy after the one before it; the Record-bout answer echoes the bout log AND the stamps the advance left, `engine.KachinukiAdvance`, and the team editor adopts the stamp with the log, so its next tap on the new bout is floored by it, bc-hlck), `RemoveTrailingKachinukiBout` (the removed bout's tombstone, and `recorded` when it un-records), the merge itself for `recorded` when a write raises a recorded bout's overtime, and a downstream reopen (`reopenDisplacedBracketMatch`, `bout:-1` with it). Byes auto-completed at draw time carry no stamp, which is right. - **The wire**: `MatchResult.Changed` (`json:"-"`, request-only, beside `ClearsWithdrawal`) is read from `/score`'s and each bulk entry's `changed`; a name that is not a group is a 400. ABSENT means every group the payload carries (`defaultChangedGroups`; on the pool branch a nil SubResults still names the stored rows, the whole-struct write's old meaning), EXCEPT that a client's running or scheduled write over a COMPLETED match never carries `result` (`effectiveChangedGroups`). Every server-built write names its groups: decision `result`+`points`+`encho` (`decisionChangedGroups`), both-barred hikiwake `result`, daihyosen add `bout:-1`+`result`+`repPickA`+`repPickB` and remove `bout:-1`+`result`+`encho`+`repPickA`+`repPickB` (both date both sides' picks, so a pick made on the previous representative bout is held, never landed on the new one; +`points` when clearing the verdict took a hantei mark out, `daihyosenChangedGroups`), quick-score every group and every stored row (`WholeMatchChangedGroups`), a start (`startOnly`) `result` alone. The rows the server itself changes after the writer sent them (kachinuki merge, the padding a match-level decision adds) are added by `noteServerBoutChanges`; in a kachinuki write, which merges its bout log by position, a stored row the writer left out counts too, so the finish's strip of a trailing unscored pairing is named and not copied back. It and `daihyosenChangedGroups` (mobileapp, the representative-bout add and remove, built from the stored match under the same lock) are the two places the server diffs, each its own values under the write's lock; a CLIENT payload is never diffed against the store, or an echoed stale group with a newer stamp would read as an edit. A server-built door reaching the merge with `Changed` nil is logged as a BUG (`serverBuiltWithoutGroups`). A bracket door that gives a side another team without reopening that match records a `reseat` history line naming the cleared pick group (`recordRepPickClears`, bracket_seat_audit.go: a snapshot of every match's picks before the door's write, diffed after, in the same transaction; `ResolveQualifiedPools`, the resolver run after a pool write or a `DELETE .../overrides`, is such a door, in its own `WithTransaction`); a reopened match's `downstream-reopen` line names the pick instead, and a refused write's restore records nothing. `MatchResult.WriteDoor` names the door for the history. - **The merge owner**: `mergeMatchWrite` (engine/match_merge.go), called by BOTH branches before anything else judges the write (`applyPoolWrite`; `applyBracketResultIn` for rounds and bronze, ahead of `preserveWithdrawalRuling` and the downstream guard), each after folding the stored pairing into the write (`reconcileSides`; on the bracket branch `sidesBeforeMerge`), so a winner worked out again reads the stored sides, never a payload's omitted ones. An UNSTAMPED write the server orders itself (a completing write, or any write a server door builds: quick-score, an engine caller, a decision with no stamp) takes the server's time there, never older than the stored `ModifiedAt` (`writeStamp`), so it leaves a fence a later stale replay is ordered against; only a client's unstamped running or scheduled write keeps the unstamped bypass. A changed group applies when `domain.ApplyByTimestamp(stamp, storedGroupStamp)` (0 on either side applies) and takes the write's stamp; otherwise the stored value is copied in and the group is HELD (its incoming value goes to `MergeReport.HeldValues`). A group the write does not change always takes the stored value. An applied group whose value equals the stored one is `Unchanged` (an echo); for a write that NAMES NO groups at all (`Changed` nil: bulk-score, an older client, which cannot tell an echo from a change) it keeps the stamp it already had rather than taking the write's, and `ModifiedAt` moves only when some applied group actually changed, so a whole-match echo can never fence out a real, older change made on another device in the gap. A write that NAMES a group takes the write's stamp there even when the group echoes: naming it is a real action taken at that time (an operator re-sends the value their screen showed, or a replayed decision), not a re-send of everything the write happened to carry, and `ModifiedAt` moves with it. A HELD group whose value equals the stored one is no loss (`HeldEcho`, bc-mrgc phase 3: a decision's "no overtime" over a match with none): it is neither listed in `heldGroups` nor kept in `HeldValues`, and its history outcome is `unchanged`. The write is SUPERSEDED only when something is held (or echo-held) and every applied group is an echo (`MergeReport.Superseded`): then nothing is written to the match file, exactly as before. The report rides on `MatchResult.Merge`; `ResultChanged` false (the verdict did not move: held, or applied as an echo of the stored one, e.g. the same withdrawal sent again under a later stamp) skips the eligibility record, since re-recording a stored withdrawal would rewrite its status and undo a reinstatement made since. Corrections get no exemption: a stale correction is held like any other change. - **R2** (operator: "points scored means the withdrawal was a mistake"): a kiken, fusenpai or fusensho is ATOMIC with what it changes and is held whole when any of its groups is held or ANY stored points or bout stamp is newer than it (`withdrawalOutranked`); `recordDecisionTx` asks the same merge on a copy first (`decisionHeldByMerge`), so a stale decision is superseded before its refusals judge it. A scoring change (points, a bout, the engi flags, with a different value) in a RUNNING write stamped after a stored withdrawal clears it (`ClearsWithdrawal` set, the cleared result kept in history as `clearedWithdrawal`); `restoreIfWithdrawalRemoved` restores eligibility. That clear needs an actual SCORING change against a withdrawal of the match itself -- an overtime toggle alone says nothing about whether the withdrawal was a mistake -- and it must give the match its winner: a clear that would leave a knockout tied is abandoned and the decision stands, held as below, never answered `needs_winner`. Overtime enters this rule only when its value actually changes (an echoed (E) is a plain no-op), and a hold records a reason only when it held something that is not an echo. A kiken, fusenpai or fusensho (a fusensho is awarded for a bar recorded on ANOTHER match: the other side cannot fight) already decided who won, and neither a point, a bout, nor an (E) tap the board still sends says otherwise, so the decision stands and the whole scoring side of the write -- points and overtime alike -- is held together instead (`holdDefaultWinScoring`; the history reason names the decision, `defaultWinStandsReason`, e.g. "a fusensho closed this match"), answered with its own code, `heldReason: "default_win_stands"`, and the decision, `heldDecision` (`MergeReport.DefaultWinStands`/`StandingDecision`; the message from `messageForHeldReason`, worded through `domain.DecisionWord`; the client's `writeDefaultWinStands`/`writeHeldDecision` and `decisionWord` in write_result.jsx; `HeldDecision()` is non-empty exactly when `HeldReason()` is `default_win_stands`, so every answer carries the two together), so the operator is told which decision closed the match and to correct it from the match's score editor (the copy names no button: a kachinuki editor offers no Remove), rather than told a newer change exists or to correct the result with a winner. A COMPLETED correction keeps a stored withdrawal exactly as before (`KeepsWithdrawalRuling`, the `clearWithdrawal` flag). diff --git a/docs/screenshots/kachinuki-correct-bout.png b/docs/screenshots/kachinuki-correct-bout.png index e544f6fbd..b62bb41c2 100644 Binary files a/docs/screenshots/kachinuki-correct-bout.png and b/docs/screenshots/kachinuki-correct-bout.png differ diff --git a/docs/screenshots/kachinuki-knockout-tie-encho.png b/docs/screenshots/kachinuki-knockout-tie-encho.png index caf52d6d6..0188f1913 100644 Binary files a/docs/screenshots/kachinuki-knockout-tie-encho.png and b/docs/screenshots/kachinuki-knockout-tie-encho.png differ diff --git a/docs/screenshots/kachinuki-scoring-buttons.png b/docs/screenshots/kachinuki-scoring-buttons.png index 3d090923a..661b985d0 100644 Binary files a/docs/screenshots/kachinuki-scoring-buttons.png and b/docs/screenshots/kachinuki-scoring-buttons.png differ diff --git a/docs/user-guide/court-operators/recording-decisions.md b/docs/user-guide/court-operators/recording-decisions.md index a40168504..345556f57 100644 --- a/docs/user-guide/court-operators/recording-decisions.md +++ b/docs/user-guide/court-operators/recording-decisions.md @@ -50,7 +50,7 @@ In the score editor, open the **Overtime** control and tick **Encho started**. A Completed results follow the paper score sheet's layout. The centre, between the two sides' points, only ever carries one mark: **vs** when no special mark applies, **X** for a tie, **(E)** for a match that went to overtime, or **(DH)** for a team encounter sent to a representative bout. The marks never combine: a match that went to encho cannot end in a tie, and a daihyosen has no overtime. A side with no points shows a plain dash. A match won by men in regulation reads **M vs –**, and the same win in overtime reads **M (E) –**, on the court console and the public viewer. The exported results workbook carries the same **(E)** marker in its centre column, but leaves a no-points cell blank rather than dashed. The marker is the same however many overtime periods were fought, because the counter records the number for the tournament log but results never show it. -Everything else is a result, written beside the competitor it names: **Ht** next to the winner of a judges' decision, **Kiken** next to the competitor who withdrew, **Fus.** next to a no-show, and also **Fus.** next to the winner when the match itself was decided by a fusensho. A match decided by hantei after a tied overtime reads **M Ht (E) K**, with the winner's mark on the winner's side of the centre. A team encounter shows its team totals in the score cell instead, so for those the marks appear on the bracket and in the exported workbook. +Everything else is a result, written beside the competitor it names: **Ht** next to the winner of a judges' decision, **Kiken** next to the competitor who withdrew, **Fus.** next to a no-show, and also **Fus.** next to the winner when the match itself was decided by a fusensho. A match decided by hantei after a tied overtime reads **M Ht (E) K**, with the winner's mark on the winner's side of the centre. A team encounter shows its team totals in the score cell instead, so its marks sit where they happened. A bout's own mark (**X**, **(E)**, or **Ht** for a representative bout decided by the judges) appears on that bout's row wherever the bouts are shown, such as the score editor, the match's page on the public viewer and the court display. A decision that settles the encounter is marked beside the team on the bracket and wherever the match is listed: **Kiken** beside a team that withdrew, **Fus.** beside a team that did not appear, and **Fus.** beside the winning team when the encounter was closed with a fusensho. On the bracket, the encounter also shows its winner with a tick, and **(DH)** when a representative bout decided it. The exported workbook carries each numbered bout's marks on its row, **Kiken** or **Fus.** beside the team's victory count, and **X** (a drawn encounter) or **(DH)** in the centre of the encounter's summary row; a knockout encounter's representative bout, with its **Ht**, is on the **Representative Bouts** sheet (refer to [Daihyosen](#daihyosen)). ![Completed results on the public viewer: an overtime win reading M, the E marker, then a dash for the side with no points, and a hantei result with the Ht mark beside the winner.](../../screenshots/viewer-result-encho.png) diff --git a/docs/user-guide/court-operators/scoring-a-match.md b/docs/user-guide/court-operators/scoring-a-match.md index b904fec41..53f28cbbc 100644 --- a/docs/user-guide/court-operators/scoring-a-match.md +++ b/docs/user-guide/court-operators/scoring-a-match.md @@ -69,6 +69,8 @@ A correction made while offline never carries a confirmation with it. If it reac A knockout final cannot be called until the earlier bouts that feed it are scored. While it is still waiting, it appears under a **Later** heading with a **Waiting** tag. Its competitors read "Winner of …" until the feeding bouts are known. +The 3rd-place match waits there too until both semi-finals are scored. A side still waiting reads "Loser of" and the semi-final's match number (for example, `Loser of M5`); once both semi-finals are scored, it moves to your queue as the 3rd-place match. It has no **Run now**: its competitors are the semi-finals' losers, so use the final's **Run now** (refer to [Run a match before results have synced](#run-a-match-before-results-have-synced)), on the court where the final is scheduled, to record both semi-final winners, which also places their losers here. + You cannot start a match that has no confirmed competitors. The Later heading lets you see that more play is scheduled for your court, so the queue never appears empty when further matches are still pending. ## Refresh the court view @@ -120,10 +122,10 @@ On a tied bout you also decide what the tie means, according to the [kachinuki m - **Record bout** retires both fighters. Under the taisho rule a Taisho who draws stays on, and **Record bout** pairs them with the next opponent automatically. - **Encho** keeps the same pair fighting when the pairing must produce a result. It is available in any stage. -- **End match** records a drawn encounter in pools or leagues. In a knockout a tied last bout cannot end the match, so continue with **Record bout** or **Encho**. +- **End match** records a drawn encounter in pools or leagues. In a knockout a tied last bout cannot end the match, so continue with **Record bout** or **Encho**. When both teams have nobody left, **Record bout** adds no pairing: use **Encho**, or **Add next bout manually** if a team has another fighter. If you finish too early, open the completed match and use **Reopen match** to carry on. Reopening takes a single tap, and ending the match again asks for no reason. The bouts you have already fought stay on screen as read-only rows above the current bout. The encounter reads like a regular team sheet, and you can check the winner-stays-on order at a glance. **× Remove this bout** takes back an unscored pairing the app added by mistake without ending the encounter. To fix a bout you have already recorded, tap its row: it reopens in place with the scoring controls. If your change flips who won, the app flags the later bouts for you to check. Refer to [Scoring a kachinuki encounter](../organisers/team-tournaments.md#scoring-a-kachinuki-encounter) for the full flow. -![The kachinuki score editor: the one-line Kachinuki (winner stays on) note above the bout list, the bouts already fought shown as read-only rows above the current bout (which carries the ippon buttons for each side), the × Remove this bout undo, and the Record bout and End match footer actions.](../../screenshots/kachinuki-scoring-buttons.png) +![The kachinuki score editor: the one-line Kachinuki (winner stays on) note above the bout list, the bouts already fought shown as read-only rows above the current bout (which carries the ippon buttons for each side), a muted line just above the current bout naming each team's fighter on and the fighters left, the × Remove this bout undo, and the Record bout and End match footer actions.](../../screenshots/kachinuki-scoring-buttons.png) diff --git a/docs/user-guide/organisers/team-tournaments.md b/docs/user-guide/organisers/team-tournaments.md index e5bc19cb2..75d052017 100644 --- a/docs/user-guide/organisers/team-tournaments.md +++ b/docs/user-guide/organisers/team-tournaments.md @@ -152,13 +152,24 @@ Kachinuki encounters are scored one bout at a time. Score the current bout, then - **Record bout** keeps the encounter going. The winner stays on and the app adds the next pairing; if the bout was a draw, both fighters retire and the next pair comes up. When the app cannot work out who fights next, for example when a team brings fighters it has not seen, use **Add next bout manually** and pick or type both players on the new row. - **End match** finishes the encounter on the last scored bout. The winning team is the one that won that bout; you do not pick it, the app reads it from the score. -If the app adds a pairing you did not want, for example an extra bout after the encounter was really over, use **× Remove this bout** to take it back. It only removes a bout that has no score yet, so nothing you have recorded is lost. The bouts you have already fought stay on screen as read-only rows above the current bout. The encounter reads like a regular team sheet, and you can check the winner-stays-on order at a glance. +The bouts you have already fought stay on screen as read-only rows above the current bout. The encounter reads like a regular team sheet, and you can check the winner-stays-on order at a glance. -![The kachinuki score editor: the bouts already fought shown as read-only rows above the current bout (which carries the ippon buttons for each side), the × Remove this bout undo for a pairing added by mistake, and the Record bout and End match footer actions.](../../screenshots/kachinuki-scoring-buttons.png) +Just above the current bout, a short line shows each team's fighter on the shiai-jo, how many fighters its lineup has left after them, and who they are, next fighter first, for example "Shiro: T1.2 Ueda on, 1 left (T1.3 Sato) · Aka: T2.1 Kudo on, last fighter". Each fighter carries their team member number, as on the bout rows. The line is read from the lineup and the bouts recorded so far, the same reading the app pairs the next bout from. It is a guide only: it never ends the encounter, and you still decide when the encounter is over. A team with no lineup entered shows only its fighter on, and when neither team has one the line is not shown. + +While a bout is being fought, the line names both of its fighters as on, whatever the score. After **Record bout**, the fighter who lost goes off and the winner stays on, and after a draw both go off. Some cases read differently: + +- **A team has nobody left.** When **Record bout** takes the last fighter in a team's lineup off, no pairing follows. The bout you recorded joins the read-only rows, and one line stands where the current bout was, for example "Aka has no fighters left. End match finishes the encounter." **Record bout** is greyed out, because that bout is already recorded; if the team does have another fighter, use **Add next bout manually**. You can still tap the recorded bout to correct it like any earlier bout: a correction that changes who won, or makes it a draw, makes it the current bout again, to record anew, while a different technique for the same winner does not. Until the editor has read the lineups, when it is opened again or on another device, the bout you recorded shows as the current bout, and tapping **Record bout** again changes nothing. +- **A draw leaves one team with nobody.** That team's last fighter stays on for the next pairing. +- **No lineup saved.** Without a lineup for the team that lost the bout, the app cannot tell whether that team has anyone left, so the bout you recorded stays the current bout, with **Record bout** greyed out because nothing can follow it, and you continue with **Add next bout manually** or **End match**. **Record bout** comes back if a lineup saved later gives that team another fighter. +- **A tie leaves both teams with nobody.** The bout stays the current bout, in pools, leagues and knockouts alike, and the line names both of its fighters as on. **Encho** is there if you want the pair to fight on, **Record bout** is greyed out because nothing can follow it, and **End match** decides the rest: a drawn encounter in a pool or league, held back in a knockout, which needs a winner. Scoring a point on that tie, or starting **Encho**, makes it a bout being fought again: both fighters stay on and **Record bout** records it anew. + +If the app adds a pairing you did not want, for example an extra bout after the encounter was really over, use **× Remove this bout** to take it back. It only removes a bout that has no score yet, so nothing you have recorded is lost. When the bout you remove is the pairing **Record bout** added, the bout before it becomes the current bout again, as if **Record bout** had not been pressed, so the line above names both of its fighters as on until you record it again. When you remove a bout you added with **Add next bout manually** after a team had nobody left, the sheet goes back to how it was before you added it: the recorded bout stays recorded and the line still names the team with nobody left. Editing a lineup in between does not change this. If the match's history does not show how the bout you removed was added, for example because that history could not be saved, the recorded bout stays recorded; tap it to correct it. + +![The kachinuki score editor: the bouts already fought shown as read-only rows above the current bout (which carries the ippon buttons for each side), a muted line just above the current bout naming each team's fighter on, the fighters left and who is next, the × Remove this bout undo for a pairing added by mistake, and the Record bout and End match footer actions.](../../screenshots/kachinuki-scoring-buttons.png) To fix a bout you have already recorded, without ending the encounter, tap its row. It reopens in place with the same scoring controls as the current bout. You can adjust the points or change who won, and the change saves as you go. Each fought bout carries a black triangle in its number column: it points right when the bout is collapsed and down when it is open. Tap it to expand the bout for a correction, and tap it again to collapse it when you are finished. -![The kachinuki score editor with an earlier bout reopened for correction: the bout sits in an accent frame with its ippon buttons for each side and a downward black triangle in its number column that collapses it again, while the other fought bouts stay as read-only rows above and below, each with a rightward triangle.](../../screenshots/kachinuki-correct-bout.png) +![The kachinuki score editor with an earlier bout reopened for correction: the bout sits in an accent frame with its ippon buttons for each side and a downward black triangle in its number column that collapses it again, while the other fought bouts stay as read-only rows above and below, each with a rightward triangle, and the line naming each team's fighter on and fighters left sits just above the current bout.](../../screenshots/kachinuki-correct-bout.png) If your correction changes who won that bout, the bouts after it were fought on the old result. The app flags them for you to check and put right. It never re-shuffles the later bouts on its own: only you, at the court, know how they actually went. @@ -166,9 +177,9 @@ When the last bout is tied, the editor offers every legitimate way forward and y - **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. 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. Its place beside Encho is kept empty until there is something to undo, so no button moves under your finger when it appears. -- **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. +- **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. When both teams have nobody left, **Record bout** adds no pairing: use **Encho**, or **Add next bout manually** if a team has another fighter. -![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) +![The kachinuki score editor on a tied knockout bout: the line above bout 1 reads "Shiro: T2.1 Mori on, 4 left (T2.2 Ueno, T2.3 Kimura, T2.4 Aoki, T2.5 Saito) · Aka: T1.1 Ishida on, 4 left (T1.2 Kono, T1.3 Sano, T1.4 Hara, T1.5 Abe)", the notice that a knockout cannot end in a draw sits above the Record bout and Encho buttons, and the End match button is held back.](../../screenshots/kachinuki-knockout-tie-encho.png) There is no representative bout (daihyosen) in kachinuki: a tied pairing that must produce a result is settled by encho on that same bout, not by a separate rep bout. diff --git a/docs/videos/kachinuki-demo.webm b/docs/videos/kachinuki-demo.webm index fb77013e7..eaeca30ce 100644 Binary files a/docs/videos/kachinuki-demo.webm and b/docs/videos/kachinuki-demo.webm differ diff --git a/internal/engine/kachinuki.go b/internal/engine/kachinuki.go index d5654ec02..1f958aec8 100644 --- a/internal/engine/kachinuki.go +++ b/internal/engine/kachinuki.go @@ -34,6 +34,7 @@ import ( "fmt" "log" "reflect" + "slices" "sort" "strings" @@ -72,6 +73,17 @@ type AdvanceKachinukiInput struct { LastBout state.SubMatchResult SideA []kachinukiFighter SideB []kachinukiFighter + // Quiet drops the advance's breadcrumb lines: the roster read + // (KachinukiRoster) asks the same decision without making it, and a read + // must not log as if Record bout had been tapped. + Quiet bool +} + +// logf is the advance's breadcrumb, silent for a Quiet input. +func (in AdvanceKachinukiInput) logf(format string, args ...any) { + if !in.Quiet { + log.Printf(format, args...) + } } // AdvanceKachinukiResult is the engine's verdict. Under the operator-led @@ -113,6 +125,13 @@ type AdvanceKachinukiResult struct { // (drawn in pools/league, continued via next bout or encho in a // knockout). BothExhausted bool + + // OutcomeUnread is true when the last bout carries a winner the advance + // could not attribute to either side (by member id or by name): it cannot + // say who stayed on, so it appends nothing, and MaybeAdvanceKachinuki + // writes nothing either, not even the recorded mark (the bout is not + // recorded on an outcome nobody could read). + OutcomeUnread bool } // AdvanceKachinuki computes the post-bout transition. @@ -187,9 +206,9 @@ func AdvanceKachinuki(in AdvanceKachinukiInput) AdvanceKachinukiResult { // side by id or by name. Treat as a no-op (no advancement) so // callers fall back to manual scheduling instead of silently // producing a wrong pairing. - log.Printf("engine.AdvanceKachinuki: unrecognized bout outcome, winner=%q sideA=%q sideB=%q decision=%q; no advancement", + in.logf("engine.AdvanceKachinuki: unrecognized bout outcome, winner=%q sideA=%q sideB=%q decision=%q; no advancement", last.Winner, last.SideA, last.SideB, last.Decision) - return AdvanceKachinukiResult{} + return AdvanceKachinukiResult{OutcomeUnread: true} } } @@ -247,7 +266,7 @@ func advanceAfterHikiwake(in AdvanceKachinukiInput) AdvanceKachinukiResult { // BothExhausted and append nothing. The operator ends the encounter: // drawn in pools/league, continued (next bout or encho) in a // knockout. See MaybeAdvanceKachinuki. - log.Printf("engine.AdvanceKachinuki: hikiwake exhausted both teams simultaneously at position %d (advisory); operator ends the encounter", + in.logf("engine.AdvanceKachinuki: hikiwake exhausted both teams simultaneously at position %d (advisory); operator ends the encounter", in.LastBout.Position) return AdvanceKachinukiResult{BothExhausted: true} case len(in.SideA) == 0: @@ -261,7 +280,7 @@ func advanceAfterHikiwake(in AdvanceKachinukiInput) AdvanceKachinukiResult { // and Ends on that point (the walkover); under the taisho rule the // pairing is fought as-is. An abandoned trailing unscored slot is // stripped on the completed write. - log.Printf("engine.AdvanceKachinuki: hikiwake left side A without a replacement at position %d (advisory); pairing %s against %s", + in.logf("engine.AdvanceKachinuki: hikiwake left side A without a replacement at position %d (advisory); pairing %s against %s", in.LastBout.Position, in.LastBout.SideA, in.SideB[0].Name) return AdvanceKachinukiResult{ Next: &state.SubMatchResult{ @@ -273,7 +292,7 @@ func advanceAfterHikiwake(in AdvanceKachinukiInput) AdvanceKachinukiResult { }, } case len(in.SideB) == 0: - log.Printf("engine.AdvanceKachinuki: hikiwake left side B without a replacement at position %d (advisory); pairing %s against %s", + in.logf("engine.AdvanceKachinuki: hikiwake left side B without a replacement at position %d (advisory); pairing %s against %s", in.LastBout.Position, in.SideA[0].Name, in.LastBout.SideB) return AdvanceKachinukiResult{ Next: &state.SubMatchResult{ @@ -409,7 +428,7 @@ func IsMemberRetired(f kachinukiFighter, retired RetiredMemberSet, ambiguousName // Helper for callers building AdvanceKachinukiInput.{SideA,SideB} from a // roster; they filter the initial roster by IsMemberRetired against the // returned sets to derive the remaining un-retired queue. BOTH branches of -// kachinukiRemainingRoster do that through filterRemainingFighters -- the +// the advance's queues (kachinukiRosterQueues) do that through filterRemainingFighters -- the // lineup-resolved one and the bout-log-only heuristic alike -- because the // name tier needs the whole roster as context and cannot judge one fighter // at a time. The heuristic branch simply supplies fighters whose MemberID @@ -478,7 +497,7 @@ func RetiredPlayersFromBoutLog(boutLog []state.SubMatchResult, teamAName, teamBN // filterRemainingFighters filters a slice of kachinukiFighter (name plus a // possible member id) via IsMemberRetired, preserving order. It is the ONE -// filter kachinukiRemainingRoster uses, on both branches: the +// filter the advance's queues (kachinukiRosterQueues) use, on both branches: the // lineup-resolved one passes fighters carrying member ids, the bout-log-only // heuristic passes fighters whose MemberID is empty and lets the name tier // decide. Routing the heuristic through here too is deliberate and is the @@ -516,7 +535,7 @@ func describeKachinukiResult(r AdvanceKachinukiResult) string { // encounter stays running with no match-level winner or decision. // Shared by the rounds loop and the bronze (3rd-place) branch. func appendNextKachinukiBout(bm *state.BracketMatch, next state.SubMatchResult) { - next.Position = len(bm.SubResults) + 1 + next.Position = nextKachinukiBoutPosition(bm.SubResults) bm.SubResults = append(bm.SubResults, next) bm.Status = state.MatchStatusRunning bm.Winner = "" @@ -547,11 +566,12 @@ func appendNextKachinukiBout(bm *state.BracketMatch, next state.SubMatchResult) // may be incomplete); the operator ends the encounter with an explicit // completed score write from the score editor. // -// Reports (advanced, post, err): `advanced` is whether SubResults or the -// parent match was mutated (the handler uses it to decide whether to emit an -// extra match-updated SSE event), and `post` (KachinukiAdvance) is the FULL -// bout log AFTER the append plus the stamps the advance left, when advanced -// is true (nil otherwise). Returning them lets the caller echo the appended +// Reports (advanced, post, err): `advanced` is whether a next pairing was +// appended, and `post` (KachinukiAdvance) is the FULL bout +// log AFTER the write plus the stamps the advance left, whenever the advance +// wrote anything: a pairing appended, or only the closed bout marked +// recorded (a side has nobody left), which mutates SubResults and returns +// (false, non-nil post). post is nil when nothing was written. Returning them lets the caller echo the appended // pairing to the open editor without re-reading the match from the store; // the read this replaced was ~the 9th store read on a request already doing // several, once per advancing bout, during play (mp-gmcg review E1). @@ -559,58 +579,68 @@ func appendNextKachinukiBout(bm *state.BracketMatch, next state.SubMatchResult) // try again. // // FR-044, T135, T137. -func (e *Engine) advanceKachinukiOnce(compID, matchID string) (bool, *KachinukiAdvance, error) { - comp, err := e.store.LoadCompetition(compID) - if err != nil { - return false, nil, err - } - if !comp.IsKachinuki() { - return false, nil, nil - } +// kachinukiAdvanceVerdict is what Record bout decides on a stored kachinuki +// encounter (decideKachinukiAdvance): the last numbered bout it closes, its +// index in the log, the advance's verdict on it, and whether a lineup fed +// each side's queue. +type kachinukiAdvanceVerdict struct { + lastIdx int + last state.SubMatchResult + out AdvanceKachinukiResult + lineupA bool + lineupB bool + rosterAvailable bool +} - // Locate the parent match in either the pool or bracket store: - // advancement runs in both (bracket bouts append via - // appendNextKachinukiBout, with propagateBracketWinner on - // exhaustion). - located, err := e.findTeamMatch(compID, matchID) - if err != nil { - return false, nil, err - } - if located == nil || len(located.Result.SubResults) == 0 { - return false, nil, nil - } - parent, isBracket := located.Result, located.IsBracket +// kachinukiQueues is ONE reading of a kachinuki encounter's roster: each +// side's queue (kachinukiRosterQueues: a saved lineup's full ordered roster, +// A2 / GAP 1 / GAP 2a, else the bout log's first-appearance order) and the +// fighters the bout log retires. The advance (decideKachinukiAdvance) and the +// roster read (KachinukiRoster) both take it from readKachinukiQueues, so +// the line the operator reads and the pairing Record bout appends share one +// computation by construction. +type kachinukiQueues struct { + queueA, queueB kachinukiSideQueue + retiredA, retiredB RetiredMemberSet +} + +// readKachinukiQueues builds the kachinukiQueues of parent under rule. +func readKachinukiQueues(comp *state.Competition, parent *state.MatchResult, rule *lineupRule) kachinukiQueues { + queueA, queueB := kachinukiRosterQueues(comp, parent, rule) + retiredA, retiredB := RetiredPlayersFromBoutLog(parent.SubResults, parent.SideA, parent.SideB) + return kachinukiQueues{queueA: queueA, queueB: queueB, retiredA: retiredA, retiredB: retiredB} +} + +// decideKachinukiAdvance is the ONE decision Record bout makes on a stored +// kachinuki encounter, asked by the advance (advanceKachinukiOnce, which then +// writes it) and by the roster read (KachinukiRoster, which only reports it, +// quiet). decided is false when there is nothing to advance off: a completed +// match, no numbered bout, a last bout with no outcome yet, or one that names +// no fighter. lineupA/lineupB say a side's queue came from a lineup fielding +// someone (the roster's LineupFound), never from the bout-log heuristic. +func decideKachinukiAdvance(comp *state.Competition, parent *state.MatchResult, q kachinukiQueues, quiet bool) (kachinukiAdvanceVerdict, bool) { // A completed match is final: corrections re-submit the bout log of a // finished match and must never re-run advancement (which would append // a phantom next bout onto the completed result). Defense in depth on // top of the handler's kachinukiBoutFinal gating. - if parent.Status == state.MatchStatusCompleted { - return false, nil, nil + if parent == nil || parent.Status == state.MatchStatusCompleted { + return kachinukiAdvanceVerdict{}, false } - // Advancement is driven by the last NUMBERED bout. A daihyosen sub-result // (Position == DaihyosenSubPosition) is not a kachinuki bout: a bracket // encounter that reaches simultaneous exhaustion stays open until the // operator adds a daihyosen, and mergeKachinukiSubResults orders that row // last, so keying off the final slice element would advance off the rep // bout. Scan from the end past any daihyosen placeholder to the real bout. - lastIdx := -1 - for i := len(parent.SubResults) - 1; i >= 0; i-- { - if parent.SubResults[i].Position != state.DaihyosenSubPosition { - lastIdx = i - break - } - } + lastIdx := lastNumberedBout(parent.SubResults) if lastIdx < 0 { - // Only the daihyosen placeholder is present: nothing to advance off. - return false, nil, nil + return kachinukiAdvanceVerdict{}, false } last := parent.SubResults[lastIdx] // Only act when the last bout has a final outcome. A bout written // with no Winner AND no Decision is still being scored; bail. - hasOutcome := last.Winner != "" || last.Decision != "" - if !hasOutcome { - return false, nil, nil + if last.Winner == "" && last.Decision == "" { + return kachinukiAdvanceVerdict{}, false } // Identity guard: retirement math needs to know WHO fought. A bout // carrying an outcome but no side identity at all (e.g. a client that @@ -620,26 +650,66 @@ func (e *Engine) advanceKachinukiOnce(compID, matchID string) (bool, *KachinukiA // untouched so the operator can correct the bout. A side is identified // by its name OR its member id: a fighter picked by squad number and // not yet named (bc-dnst) carries only the id, and that is enough. - if last.SideA == "" && last.SideAMemberID == "" && last.SideB == "" && last.SideBMemberID == "" { - log.Printf("engine.MaybeAdvanceKachinuki compId=%s matchId=%s: last bout (position %d) has an outcome but no side names; skipping advancement", compID, matchID, last.Position) - return false, nil, nil + if !kachinukiFighterKnown(kachinukiFighter{Name: last.SideA, MemberID: last.SideAMemberID}) && + !kachinukiFighterKnown(kachinukiFighter{Name: last.SideB, MemberID: last.SideBMemberID}) { + if !quiet { + log.Printf("engine.MaybeAdvanceKachinuki compId=%s matchId=%s: last bout (position %d) has an outcome but no side names; skipping advancement", comp.ID, parent.ID, last.Position) + } + return kachinukiAdvanceVerdict{}, false } - // Build remaining-roster snapshot. When a TeamLineup has been saved - // for the team, use the full ordered roster filtered by bout-log - // retirements (A2, GAP 1 / GAP 2a). Without a lineup the function - // degrades to the bout-log-only heuristic so existing competitions - // without lineups continue to work. - rule := e.lineupRuleOrNone("engine.MaybeAdvanceKachinuki", compID, comp.IsKnockoutEnabled(), located.PoolMatches, located.Bracket) - remainingA, remainingB, rosterAvailable := kachinukiRemainingRoster(comp, parent, rule) - + // The remaining-roster snapshot comes from q (readKachinukiQueues), the + // one reading the roster read shows as well. out := AdvanceKachinuki(AdvanceKachinukiInput{ LastBout: last, - SideA: remainingA, - SideB: remainingB, + SideA: q.queueA.remaining(q.retiredA), + SideB: q.queueB.remaining(q.retiredB), + Quiet: quiet, }) + return kachinukiAdvanceVerdict{ + lastIdx: lastIdx, last: last, out: out, + lineupA: q.queueA.fromLineup(), + lineupB: q.queueB.fromLineup(), + rosterAvailable: q.queueA.found || q.queueB.found, + }, true +} + +// kachinukiFighterKnown is the ONE test of "this side names a fighter": a +// name or a member id (a fighter picked by number and not named yet carries +// only the id). +func kachinukiFighterKnown(f kachinukiFighter) bool { + return f.Name != "" || f.MemberID != "" +} + +func (e *Engine) advanceKachinukiOnce(compID, matchID string) (bool, *KachinukiAdvance, error) { + comp, err := e.store.LoadCompetition(compID) + if err != nil { + return false, nil, err + } + if !comp.IsKachinuki() { + return false, nil, nil + } + + // Locate the parent match in either the pool or bracket store: + // advancement runs in both (bracket bouts append via + // appendNextKachinukiBout, with propagateBracketWinner on + // exhaustion). + located, err := e.findTeamMatch(compID, matchID) + if err != nil { + return false, nil, err + } + if located == nil || len(located.Result.SubResults) == 0 { + return false, nil, nil + } + parent, isBracket := located.Result, located.IsBracket + rule := e.lineupRuleOrNone("engine.MaybeAdvanceKachinuki", compID, comp.IsKnockoutEnabled(), located.PoolMatches, located.Bracket) + verdict, decided := decideKachinukiAdvance(comp, parent, readKachinukiQueues(comp, parent, rule), false) + if !decided { + return false, nil, nil + } + lastIdx, last, out := verdict.lastIdx, verdict.last, verdict.out log.Printf("engine.MaybeAdvanceKachinuki compId=%s matchId=%s rosterAvailable=%t result=%s", - compID, matchID, rosterAvailable, describeKachinukiResult(out)) + compID, matchID, verdict.rosterAvailable, describeKachinukiResult(out)) // Operator-led completion (mp-gmcg): the engine NEVER auto-finalizes a // kachinuki encounter. Team sizes are unregulated and lineup vacancies @@ -651,7 +721,23 @@ func (e *Engine) advanceKachinukiOnce(compID, matchID string) (bool, *KachinukiA // (winner from the last decisive bout, covering both win rules -- full // exhaustion AND taisho-defeated -- or hikiwake in pools/league; a // knockout tie is resolved by encho on the final bout, never a draw). - if out.Next == nil { + // + // Record bout also marks the bout it closes as RECORDED (bc-kfnl, operator + // decision 2026-10-11), in the same locked write as the pairing it appends, + // or alone when it appends none (a side has nobody left). Nothing else in + // the stored row tells a recorded bout from the one being fought: the + // running autosave writes a winner at a one-point lead and a hikiwake at + // 1-1. A bout already recorded with nothing to append writes nothing, so a + // replayed Record bout adds no stamp and no history line. + // An outcome the advance could not attribute (a winner naming neither + // side) is not a result Record bout can close: nothing is marked and no + // history line is written, so the bout stays the one being fought and the + // operator corrects it. + if out.OutcomeUnread { + return false, nil, nil + } + markRecorded := !last.Recorded + if out.Next == nil && !markRecorded { return false, nil, nil } @@ -666,9 +752,23 @@ func (e *Engine) advanceKachinukiOnce(compID, matchID string) (bool, *KachinukiA // the write this advance answers later than the server's now, and the // bout it appends must not read as made before the save that recorded the // one it follows), and the advance is recorded in the match's history, so - // a write made before it is ordered against it (bc-mrgc). - stamp := max(serverNowMs(), parent.ModifiedAt) - var appendedGroup string + // a write made before it is ordered against it (bc-mrgc). Strictly newer + // than the match, so ModifiedAt moves and every device orders the copy this + // write leaves after the one before it (keepNewerMatches replaces a copy on + // an equal stamp). + stamp := max(serverNowMs(), parent.ModifiedAt+1) + // The groups this write changes and stamps: the recorded marks + // (GroupRecorded, never the closed bout's own group, so a change to that + // bout made before Record bout and arriving after it still applies), and the + // appended pairing with the verdict it clears. + var stamped []string + if markRecorded { + stamped = append(stamped, state.GroupRecorded) + } + door := doorKachinukiRecord + if out.Next != nil { + door = doorKachinukiAdvance + } // The read above ran without the lock; the write below re-checks, under // it, that the match is still the one the pairing was worked out from @@ -697,9 +797,14 @@ func (e *Engine) advanceKachinukiOnce(compID, matchID string) (bool, *KachinukiA if !kachinukiAdvanceStillHolds(snapshot, bm.Status, bm.ModifiedAt, bm.SubResults) { return errKachinukiAdvanceStale } - appendNextKachinukiBout(bm, *out.Next) - appendedGroup = state.BoutGroup(bm.SubResults[len(bm.SubResults)-1].Position) - bm.StampGroups(stamp, appendedGroup, state.GroupResult) + // The re-check holds the log equal to the snapshot, so lastIdx + // still names the bout Record bout closed. + bm.SubResults[lastIdx].Recorded = true + if out.Next != nil { + appendNextKachinukiBout(bm, *out.Next) + stamped = append(stamped, state.BoutGroup(bm.SubResults[lastNumberedBout(bm.SubResults)].Position), state.GroupResult) + } + bm.StampGroups(stamp, stamped...) post = &KachinukiAdvance{ BoutLog: append([]state.SubMatchResult(nil), bm.SubResults...), ModifiedAt: bm.ModifiedAt, @@ -709,13 +814,13 @@ func (e *Engine) advanceKachinukiOnce(compID, matchID string) (bool, *KachinukiA }); uerr != nil { return uerr } - e.recordDirectHistory(tx, compID, matchID, doorKachinukiAdvance, stamp, appendedGroup, state.GroupResult) + e.recordDirectHistory(tx, compID, matchID, door, stamp, stamped...) return nil }) if err != nil { return false, nil, err } - return true, post, nil + return out.Next != nil, post, nil } var found bool @@ -725,16 +830,19 @@ func (e *Engine) advanceKachinukiOnce(compID, matchID string) (bool, *KachinukiA if !kachinukiAdvanceStillHolds(snapshot, parent.Status, parent.ModifiedAt, parent.SubResults) { return errKachinukiAdvanceStale } - // Append the next bout. Appending means the encounter continues: the - // parent match must stay running with no match-level winner/decision. - out.Next.Position = len(parent.SubResults) + 1 - parent.SubResults = append(parent.SubResults, *out.Next) - parent.Status = state.MatchStatusRunning - parent.Winner = "" - parent.WinnerID = "" // bc-brid: the verdict's id half, cleared with the name. - parent.Decision = "" - appendedGroup = state.BoutGroup(out.Next.Position) - parent.StampGroups(stamp, appendedGroup, state.GroupResult) + parent.SubResults[lastIdx].Recorded = true + if out.Next != nil { + // Append the next bout. Appending means the encounter continues: the + // parent match must stay running with no match-level winner/decision. + out.Next.Position = nextKachinukiBoutPosition(parent.SubResults) + parent.SubResults = append(parent.SubResults, *out.Next) + parent.Status = state.MatchStatusRunning + parent.Winner = "" + parent.WinnerID = "" // bc-brid: the verdict's id half, cleared with the name. + parent.Decision = "" + stamped = append(stamped, state.BoutGroup(out.Next.Position), state.GroupResult) + } + parent.StampGroups(stamp, stamped...) post = &KachinukiAdvance{ BoutLog: append([]state.SubMatchResult(nil), parent.SubResults...), ModifiedAt: parent.ModifiedAt, @@ -748,7 +856,7 @@ func (e *Engine) advanceKachinukiOnce(compID, matchID string) (bool, *KachinukiA if !found { return nil } - e.recordDirectHistory(tx, compID, matchID, doorKachinukiAdvance, stamp, appendedGroup, state.GroupResult) + e.recordDirectHistory(tx, compID, matchID, door, stamp, stamped...) return nil }) if err != nil { @@ -757,11 +865,14 @@ func (e *Engine) advanceKachinukiOnce(compID, matchID string) (bool, *KachinukiA if !found { return false, nil, nil } - return true, post, nil + return out.Next != nil, post, nil } -// KachinukiAdvance is what MaybeAdvanceKachinuki hands back when it appended a -// bout: the FULL bout log after the append, and the match's stamps as the +// KachinukiAdvance is what MaybeAdvanceKachinuki hands back when it wrote: it +// appended a bout, or marked the bout Record bout closed as recorded (the +// bool it returns beside it says whether a pairing was appended; a recorded +// mark alone, at a side's exhaustion, returns false with a non-nil +// KachinukiAdvance). It holds the FULL bout log after the write, and the match's stamps as the // advance left them. The handler echoes all of it on the Record-bout answer, // so the open editor renders the appended pairing at once without a store // re-read (mp-gmcg review E1) and shows the stamp the advance gave the match, @@ -1511,9 +1622,16 @@ func (e *Engine) RemoveTrailingKachinukiBout(compID, matchID string) (*state.Mat // the encounter must be RUNNING and must carry a trailing unscored bout. // One closure so pool/round/bronze cannot drift (same reasoning as // reopenResultPreconditionTx, the reopen's single result-precondition helper). - strip := func(status state.MatchStatus, subs []state.SubMatchResult) ([]state.SubMatchResult, error) { + // + // The removed bout is the LIVE one, the highest-position numbered row + // (lastNumberedBout), not the slice's last row: the advance appends + // one past it, the unrecord below and the roster read name it, so a + // log stored out of position order removes the same bout every one of + // them calls the trailing pairing. A -1 row is never it. strip + // returns the removed bout's group with the stripped log. + strip := func(status state.MatchStatus, subs []state.SubMatchResult) ([]state.SubMatchResult, string, error) { if status != state.MatchStatusRunning { - return nil, ErrRemoveBoutNotRunning + return nil, "", ErrRemoveBoutNotRunning } // Remove ONLY the single trailing bout, and never the last remaining // one: the operator's "× Remove this bout" is singular, and a RUNNING @@ -1523,52 +1641,96 @@ func (e *Engine) RemoveTrailingKachinukiBout(compID, matchID string) (*state.Mat // (a completed match always carries a scored bout, so it never // empties) but not here, where bout 1 could be unscored, or two // "Add next bout manually" pairings could be appended and one tap - // must not strip both. - n := len(subs) - if n <= 1 { - return nil, ErrNoRemovableBout + // must not strip both. The floor counts NUMBERED bouts only: a + // -1 row is never removable, so a [bout 1, -1] log has no + // trailing bout to take. + numbered := 0 + for _, s := range subs { + if s.Position > 0 { + numbered++ + } + } + if numbered <= 1 { + return nil, "", ErrNoRemovableBout } - last := subs[n-1] - if last.Position <= 0 || !isUnscoredKachinukiBout(last) { - return nil, ErrNoRemovableBout + i := lastNumberedBout(subs) + if i < 0 || !isUnscoredKachinukiBout(subs[i]) { + return nil, "", ErrNoRemovableBout } - return subs[:n-1], nil + stripped := make([]state.SubMatchResult, 0, len(subs)-1) + stripped = append(stripped, subs[:i]...) + stripped = append(stripped, subs[i+1:]...) + return stripped, state.BoutGroup(subs[i].Position), nil } // The removed bout keeps its stamp as a tombstone (bc-mrgc), so a // write made before the removal that still carries it cannot bring - // it back; one made after it can. - stamp := serverNowMs() + // it back; one made after it can. Strictly newer than the match (read + // in findMatchHome below), as the advance's stamp is, so the copy this + // leaves is ordered after the one before it on every device. + now := serverNowMs() + // unrecord takes the recorded mark off the bout before the removed one + // only when the removal undoes the Record bout that appended it: the + // history shows the removed row was put there by Record bout's advance + // (boutAppendedByRecordBout). A bout added by hand after a side had + // nobody left leaves that bout recorded, whatever the lineups say now. + // Taking the mark off is a change of GroupRecorded, stamped and named + // in the history line with the removed bout. + // + // The match's history, read through the transaction (it sees its own + // staged lines), is read only when the bout left last IS recorded: + // otherwise there is no mark to take off and nothing for it to decide. + // A history that cannot be read decides nothing: the recorded bout + // stays recorded (boutAppendedByRecordBout's fallback), and the + // removal never fails on it. + unrecord := func(removedGroup string, stripped []state.SubMatchResult) []string { + i := lastNumberedBout(stripped) + if i < 0 || !stripped[i].Recorded { + return nil + } + history, herr := tx.LoadMatchHistory(compID, matchID) + if herr != nil { + log.Printf("engine.RemoveTrailingKachinukiBout compId=%s matchId=%s: history read error: %v; keeping the recorded bout recorded", compID, matchID, herr) + history = nil + } + if !boutAppendedByRecordBout(history, removedGroup) { + return nil + } + stripped[i].Recorded = false + return []string{state.GroupRecorded} + } found, ferr := findMatchHome(tx, compID, matchID, func(h matchHome) error { if h.Pool != nil { - stripped, serr := strip(h.Pool.Status, h.Pool.SubResults) + stripped, removedGroup, serr := strip(h.Pool.Status, h.Pool.SubResults) if serr != nil { opErr = serr return nil } - removed := state.BoutGroup(h.Pool.SubResults[len(h.Pool.SubResults)-1].Position) - h.Pool.StampGroups(stamp, removed) + removed := append([]string{removedGroup}, unrecord(removedGroup, stripped)...) + stamp := max(now, h.Pool.ModifiedAt+1) + h.Pool.StampGroups(stamp, removed...) h.Pool.SubResults = stripped if werr := h.Save(); werr != nil { return werr } - e.recordDirectHistory(tx, compID, matchID, doorKachinukiRemove, stamp, removed) + e.recordDirectHistory(tx, compID, matchID, doorKachinukiRemove, stamp, removed...) u := *h.Pool updated = &u return nil } - stripped, serr := strip(h.Bracket.Status, h.Bracket.SubResults) + stripped, removedGroup, serr := strip(h.Bracket.Status, h.Bracket.SubResults) if serr != nil { opErr = serr return nil } - removed := state.BoutGroup(h.Bracket.SubResults[len(h.Bracket.SubResults)-1].Position) - h.Bracket.StampGroups(stamp, removed) + removed := append([]string{removedGroup}, unrecord(removedGroup, stripped)...) + stamp := max(now, h.Bracket.ModifiedAt+1) + h.Bracket.StampGroups(stamp, removed...) h.Bracket.SubResults = stripped if werr := h.Save(); werr != nil { return werr } - e.recordDirectHistory(tx, compID, matchID, doorKachinukiRemove, stamp, removed) + e.recordDirectHistory(tx, compID, matchID, doorKachinukiRemove, stamp, removed...) updated = bracketMatchToTeamResult(*h.Bracket) return nil }) @@ -2523,6 +2685,81 @@ func preserveKachinukiMemberIDs(storedByPos map[int]state.SubMatchResult, in *st } } +// boutAppendedByRecordBout reports whether the match's history shows that the +// bout row of group (a "bout:N" group) as it stands now was put there by +// Record bout's advance (doorKachinukiAdvance), i.e. whether removing it undoes +// that Record bout (bc-kfnl). It reads the stored fact rather than asking the +// advance again, so a lineup edited between Record bout and Remove changes +// nothing. +// +// The history is walked in the order the store appended it (LoadMatchHistory's +// order), never in stamp order: an advance is stamped no older than the match, +// so after a write from a device whose clock runs ahead it carries a stamp +// later than a Remove made after it. Positions are reused, so +// what counts is the row's current life: a kachinuki-remove-bout line whose +// trailing bout group is this one (the row it removed; the bout it un-records, +// one position lower, is named too and is not removed) ends it, and so does any +// reopen (doorReopen, doorDownstreamReopen), since a match is reopened only +// from completed and the finishing write strips a trailing unscored pairing. +// The row's creator is then the door of the first later line that APPLIED the +// group (a held or unchanged outcome wrote nothing), except that an advance +// line creates only its trailing bout group, the pairing it appended: a bout +// group it names that is not its trailing one existed before it. +// +// And only an advance that SET the mark counts: its line applied +// GroupRecorded. An advance that appended a pairing after a bout already +// recorded (recorded with nobody left, a lineup then given a fighter, Record +// bout tapped again) did not record that bout, so removing what it appended +// leaves the mark the earlier Record bout set (advanceWithoutMark). +// +// Fallback: no history (a match written before the history existed, an append +// that failed, a history that cannot be read) or no creator in it answers +// false, so the bout Record bout closed stays recorded. That is the safe side: +// the closed state stands and the operator can still correct the bout. +func boutAppendedByRecordBout(history []state.MatchHistoryEntry, group string) bool { + // advanceWithoutMark names, as a creator, an advance line that appended the + // row without applying GroupRecorded: it is not the Record bout whose mark a + // removal undoes. + const advanceWithoutMark = "kachinuki-advance-without-mark" + creator := "" + for _, h := range history { + trailing := trailingBoutGroup(h.Changed) == group + switch h.Door { + case doorKachinukiRemove: + if trailing { + creator = "" + } + continue + case doorReopen, doorDownstreamReopen: + creator = "" + continue + case doorKachinukiAdvance: + if !trailing { + continue + } + } + if creator == "" && slices.Contains(h.Changed, group) && h.Outcomes[group] == state.HistoryOutcomeApplied { + creator = h.Door + if h.Door == doorKachinukiAdvance && h.Outcomes[state.GroupRecorded] != state.HistoryOutcomeApplied { + creator = advanceWithoutMark + } + } + } + return creator == doorKachinukiAdvance +} + +// trailingBoutGroup is the numbered bout group with the highest position among +// groups, "" when there is none. +func trailingBoutGroup(groups []string) string { + best, bestPos := "", 0 + for _, g := range groups { + if pos, ok := state.ParseBoutGroup(g); ok && pos > bestPos { + best, bestPos = g, pos + } + } + return best +} + // teamMatch is a team match located by findTeamMatch, with the draw it was found // in. type teamMatch struct { @@ -2612,40 +2849,44 @@ func bracketMatchToTeamResult(bm state.BracketMatch) *state.MatchResult { } } -// kachinukiRemainingRoster derives the remaining un-retired roster per side -// for a team match. Returns (sideA, sideB, rosterAvailable). rosterAvailable -// is true when at least one side's roster was resolved from a saved TeamLineup; -// false means both sides fell back to the bout-log-only heuristic. -// -// Priority per side (GAP 1 / GAP 2a): -// 1. The lineup in force for the side's team at this match (see -// lineup_in_force.go: the match's own lineup, else the one the team -// carries from its previous match or round), matched by the side's -// participant id; a side with no id has none. -// 2. Bout-log-only heuristic (anyone who appeared in a bout, minus retired). -// -// The full ordered roster (from lineup.OrderedMembers, bc-tmid pass 2) is -// filtered by IsMemberRetired against RetiredPlayersFromBoutLog's sets to -// produce the remaining queue, so a lineup-resolved fighter's member id -// (when the slot has one) rather than their possibly-stale name decides -// whether they have retired -- falling back to the name only where the -// retiring bout row carried no id to match against and the name belongs to -// one member of this roster alone. See IsMemberRetired for why both tiers -// are needed and what the gate protects. -func kachinukiRemainingRoster(comp *state.Competition, parent *state.MatchResult, rule *lineupRule) ([]kachinukiFighter, []kachinukiFighter, bool) { - retiredA, retiredB := RetiredPlayersFromBoutLog(parent.SubResults, parent.SideA, parent.SideB) +// kachinukiSideQueue is one side's queue BEFORE retirements are taken out: +// the lineup in force's fielded members in order when found, else the +// fighters the bout log has seen, in first-appearance order. +type kachinukiSideQueue struct { + fighters []kachinukiFighter + found bool +} + +// remaining is the queue with every fighter `retired` holds taken out, +// through filterRemainingFighters (IsMemberRetired, the one identity rule). +func (q kachinukiSideQueue) remaining(retired RetiredMemberSet) []kachinukiFighter { + return filterRemainingFighters(q.fighters, retired) +} + +// fromLineup reports whether the queue came from a lineup in force that +// fields someone (KachinukiRosterSide.LineupFound), never from the bout-log +// heuristic, which knows only the fighters already seen. +func (q kachinukiSideQueue) fromLineup() bool { + return q.found && len(q.fighters) > 0 +} - // resolveRoster builds one side's remaining roster. teamID is the side's +// kachinukiRosterQueues builds both sides' queues (see kachinukiSideQueue). +// It is the ONE queue source of the advance (decideKachinukiAdvance) and of +// the advisory roster read (KachinukiRoster, kachinuki_roster.go), which then +// take retirements out through the same filter, so the two can never disagree +// about who is next. +func kachinukiRosterQueues(comp *state.Competition, parent *state.MatchResult, rule *lineupRule) (queueA, queueB kachinukiSideQueue) { + // resolveQueue builds one side's queue. teamID is the side's // participant id, which is what its lineup is matched by; sideA says which // column of the bout log the side is, so nothing here compares team names. - resolveRoster := func(teamID string, sideA bool, retired RetiredMemberSet) ([]kachinukiFighter, bool) { + resolveQueue := func(teamID string, sideA bool) kachinukiSideQueue { if in := rule.inForce(teamID, parent.ID); in.Found { full := in.Lineup.OrderedMembers(comp.TeamSize) fighters := make([]kachinukiFighter, len(full)) for i, slot := range full { fighters[i] = kachinukiFighter{Name: slot.Name, MemberID: slot.MemberID} } - return filterRemainingFighters(fighters, retired), true + return kachinukiSideQueue{fighters: fighters, found: true} } // Preserve first-appearance order from the bout log: AdvanceKachinuki // treats this slice as an ordered queue (index 0 is the next fighter @@ -2656,6 +2897,15 @@ func kachinukiRemainingRoster(comp *state.Competition, parent *state.MatchResult // has an id and no name, bc-dnst) and by name otherwise, so a // nameless pick is still a queue entry and IsMemberRetired settles // each by the same id-then-name order the lineup branch uses. + // + // Retirements are taken out by the caller, through the SHARED helper, + // in a second pass, for the reason its own doc gives: the queue IS the + // context the name tier needs, so it cannot be judged one fighter at a + // time while the queue is still being built. Filtering inline passed a + // nil ambiguity set, which let the name tier fire for a fighter whose + // OWN member id proves they have not retired: two teammates sharing a + // display name (grandfathered pre-uniqueness data) meant one losing + // retired the other, who was then never fielded. seen := map[string]struct{}{} out := make([]kachinukiFighter, 0) for _, b := range parent.SubResults { @@ -2679,18 +2929,40 @@ func kachinukiRemainingRoster(comp *state.Competition, parent *state.MatchResult seen[key] = struct{}{} out = append(out, f) } - // Filtered through the SHARED helper, in a second pass, for the reason - // its own doc gives: the roster IS the context the name tier needs, so - // it cannot be judged one fighter at a time while the roster is still - // being built. Filtering inline passed a nil ambiguity set, which let - // the name tier fire for a fighter whose OWN member id proves they have - // not retired: two teammates sharing a display name (grandfathered - // pre-uniqueness data) meant one losing retired the other, who was then - // never fielded. The lineup branch above has always used this helper. - return filterRemainingFighters(out, retired), false - } - - remainingA, foundA := resolveRoster(parent.SideAID, true, retiredA) - remainingB, foundB := resolveRoster(parent.SideBID, false, retiredB) - return remainingA, remainingB, foundA || foundB + return kachinukiSideQueue{fighters: out} + } + + return resolveQueue(parent.SideAID, true), resolveQueue(parent.SideBID, false) +} + +// lastNumberedBout returns the index of the last NUMBERED bout row, the live +// one while the encounter runs, or -1 when there is none. Numbered means +// Position > 0 (a daihyosen row, DaihyosenSubPosition, is not a kachinuki +// bout), and last means the HIGHEST position, whatever the slice's order: a +// bout's position is its number (the advance numbers the next pairing after +// it, the merge orders rows by it), while the slice's order is only how the +// rows were written, which a hand-edited file can get wrong. The same rule as +// the client's lastNumberedBout (kachinuki_advisory.jsx), so the server and +// the sheet always read the same row as the live bout; the first of two rows +// sharing a position wins in both. The advance, the roster read and the +// removal's unrecord all ask it. +func lastNumberedBout(subs []state.SubMatchResult) int { + last, lastPos := -1, 0 + for i, s := range subs { + if s.Position > lastPos { + last, lastPos = i, s.Position + } + } + return last +} + +// nextKachinukiBoutPosition is the position a pairing appended to this bout +// log takes: one past the live bout (lastNumberedBout), never the row count, +// so a log with a gap ([1, 3]) appends 4 and the appended row becomes the +// live bout that remove, unrecord, the advance and the roster read all name. +func nextKachinukiBoutPosition(subs []state.SubMatchResult) int { + if i := lastNumberedBout(subs); i >= 0 { + return subs[i].Position + 1 + } + return 1 } diff --git a/internal/engine/kachinuki_live_bout_test.go b/internal/engine/kachinuki_live_bout_test.go new file mode 100644 index 000000000..547dfbda0 --- /dev/null +++ b/internal/engine/kachinuki_live_bout_test.go @@ -0,0 +1,166 @@ +package engine + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/gitrgoliveira/bracket-creator/internal/state" +) + +// The live bout of a kachinuki encounter is the highest-position numbered row +// (lastNumberedBout), and every door names the same one: Record bout appends +// one past it, the removal removes it, the unrecord and the roster read read +// it. A log with a gap or stored out of position order is where a slice +// position and a bout position part, so these pin both shapes on both +// branches. + +func TestMaybeAdvanceKachinuki_AGappedLogAppendsPastTheLiveBout(t *testing.T) { + for _, bracket := range []bool{false, true} { + name := branchName(bracket) + t.Run(name, func(t *testing.T) { + eng, store, comp := setupKachinukiComp(t, "kachinuki-gapped-advance-"+name, 3, + func(c *state.Competition) { c.Format = state.CompFormatMixed }) + rt := seedRosterTeams(t, store, comp.ID, 3, + []string{"A1", "A2", "A3"}, []string{"B1", "B2", "B3"}, true, true) + a, b := rt.a, rt.b + // Bouts 1 and 3, no bout 2: A1 beat B1, then A1 beat B2. + matchID := saveRosterMatch(t, store, comp.ID, rt, []state.SubMatchResult{ + rosterBout(1, a[0], b[0], &a[0], false), + rosterBout(3, a[0], b[1], &a[0], false), + }, bracket) + + appended, post, err := eng.MaybeAdvanceKachinuki(comp.ID, matchID) + require.NoError(t, err) + require.True(t, appended) + require.Len(t, post.BoutLog, 3) + next := post.BoutLog[2] + assert.Equal(t, 4, next.Position, "the pairing is numbered one past the live bout, not by the row count") + assert.Equal(t, a[0].ID, next.SideAMemberID, "A1 stays on") + assert.Equal(t, b[2].ID, next.SideBMemberID, "B3 is next") + assert.True(t, post.BoutLog[1].Recorded, "bout 3, the live bout, is the one recorded") + assert.False(t, post.BoutLog[0].Recorded) + assert.NotZero(t, storedGroupStamp(t, store, comp.ID, matchID, bracket, state.BoutGroup(4)), + "the appended bout's own group is stamped") + }) + } +} + +func TestRemoveTrailingKachinukiBout_AnOutOfOrderLogRemovesTheLiveBout(t *testing.T) { + for _, bracket := range []bool{false, true} { + name := branchName(bracket) + t.Run(name, func(t *testing.T) { + eng, store, comp := setupKachinukiComp(t, "kachinuki-out-of-order-remove-"+name, 3, + func(c *state.Competition) { c.Format = state.CompFormatMixed }) + rt := seedRosterTeams(t, store, comp.ID, 3, + []string{"A1", "A2", "A3"}, []string{"B1", "B2", "B3"}, true, true) + a, b := rt.a, rt.b + // The unscored pairing (bout 2) is stored BEFORE the scored bout 1. + matchID := saveRosterMatch(t, store, comp.ID, rt, []state.SubMatchResult{ + rosterBout(2, a[0], b[1], nil, false), + rosterBout(1, a[0], b[0], &a[0], false), + }, bracket) + + updated, err := eng.RemoveTrailingKachinukiBout(comp.ID, matchID) + require.NoError(t, err) + require.Len(t, updated.SubResults, 1) + assert.Equal(t, 1, updated.SubResults[0].Position, "bout 2, the live bout, is the one removed") + subs := storedSubs(t, store, comp.ID, matchID, bracket) + require.Len(t, subs, 1) + assert.Equal(t, 1, subs[0].Position) + assert.NotZero(t, storedGroupStamp(t, store, comp.ID, matchID, bracket, state.BoutGroup(2)), + "the removed bout leaves its tombstone") + assert.Zero(t, storedGroupStamp(t, store, comp.ID, matchID, bracket, state.BoutGroup(1)), + "the scored bout's group is untouched") + }) + } +} + +func TestRemoveTrailingKachinukiBout_AScoredLiveBoutStoredFirstIsRefused(t *testing.T) { + for _, bracket := range []bool{false, true} { + name := branchName(bracket) + t.Run(name, func(t *testing.T) { + eng, store, comp := setupKachinukiComp(t, "kachinuki-out-of-order-refuse-"+name, 3, + func(c *state.Competition) { c.Format = state.CompFormatMixed }) + rt := seedRosterTeams(t, store, comp.ID, 3, + []string{"A1", "A2", "A3"}, []string{"B1", "B2", "B3"}, true, true) + a, b := rt.a, rt.b + // The live bout (2) is scored, stored first; an unscored bout 1 is + // the slice's last row. Neither is the unscored trailing pairing. + matchID := saveRosterMatch(t, store, comp.ID, rt, []state.SubMatchResult{ + rosterBout(2, a[0], b[1], &a[0], false), + rosterBout(1, a[0], b[0], nil, false), + }, bracket) + + _, err := eng.RemoveTrailingKachinukiBout(comp.ID, matchID) + require.ErrorIs(t, err, ErrNoRemovableBout) + assert.Len(t, storedSubs(t, store, comp.ID, matchID, bracket), 2, "nothing is removed") + }) + } +} + +// The floor counts numbered bouts only: a legacy log holding one unscored +// numbered bout beside a -1 row has no bout to remove, so the encounter is +// never left without a numbered bout. +func TestRemoveTrailingKachinukiBout_ARepRowDoesNotCountTowardTheFloor(t *testing.T) { + for _, bracket := range []bool{false, true} { + name := branchName(bracket) + t.Run(name, func(t *testing.T) { + eng, store, comp := setupKachinukiComp(t, "kachinuki-remove-floor-rep-row-"+name, 3, + func(c *state.Competition) { c.Format = state.CompFormatMixed }) + rt := seedRosterTeams(t, store, comp.ID, 3, + []string{"A1", "A2", "A3"}, []string{"B1", "B2", "B3"}, true, true) + a, b := rt.a, rt.b + matchID := saveRosterMatch(t, store, comp.ID, rt, []state.SubMatchResult{ + rosterBout(1, a[0], b[0], nil, false), + {Position: -1}, + }, bracket) + + _, err := eng.RemoveTrailingKachinukiBout(comp.ID, matchID) + require.ErrorIs(t, err, ErrNoRemovableBout) + assert.Len(t, storedSubs(t, store, comp.ID, matchID, bracket), 2, "nothing is removed") + }) + } +} + +// An UNSTAMPED write cannot be ordered after the Record bout that set a mark, +// so it never takes the mark off, even when it raises the bout's overtime. +func TestKachinukiRecorded_AnUnstampedWriteNeverClearsIt(t *testing.T) { + for _, bracket := range []bool{false, true} { + name := branchName(bracket) + t.Run(name, func(t *testing.T) { + eng, store, comp := setupKachinukiComp(t, "kachinuki-recorded-unstamped-"+name, 2, + func(c *state.Competition) { c.Format = state.CompFormatMixed }) + rt := seedRosterTeams(t, store, comp.ID, 2, + []string{"A1", "A2"}, []string{"B1", "B2"}, true, true) + a, b := rt.a, rt.b + tie := rosterBout(3, a[1], b[1], nil, true) + tie.IpponsA, tie.IpponsB = []string{"M"}, []string{"K"} + matchID := saveRosterMatch(t, store, comp.ID, rt, []state.SubMatchResult{ + rosterBout(1, a[0], b[0], &b[0], false), + rosterBout(2, a[1], b[0], &a[1], false), + tie, + }, bracket) + appended, post, err := eng.MaybeAdvanceKachinuki(comp.ID, matchID) + require.NoError(t, err) + require.False(t, appended) + require.True(t, post.BoutLog[2].Recorded) + recordedStamp := storedGroupStamp(t, store, comp.ID, matchID, bracket, state.GroupRecorded) + + sent := append([]state.SubMatchResult(nil), post.BoutLog...) + sent[2].Decision = "" + sent[2].Encho = &state.EnchoMetadata{PeriodCount: 1} + require.NoError(t, eng.RecordMatchResult(comp.ID, matchID, &state.MatchResult{ + ID: matchID, SideA: "Ryu", SideAID: rt.aID, SideB: "Tora", SideBID: rt.bID, + Status: state.MatchStatusRunning, + SubResults: sent, + Changed: []string{state.BoutGroup(3)}, WriteDoor: DoorScore, + })) + subs := storedSubs(t, store, comp.ID, matchID, bracket) + assert.True(t, subs[2].Recorded, "an unstamped write leaves the recorded mark on") + assert.Equal(t, recordedStamp, storedGroupStamp(t, store, comp.ID, matchID, bracket, state.GroupRecorded), + "and does not restamp the recorded group") + }) + } +} diff --git a/internal/engine/kachinuki_roster.go b/internal/engine/kachinuki_roster.go new file mode 100644 index 000000000..7a289f9ff --- /dev/null +++ b/internal/engine/kachinuki_roster.go @@ -0,0 +1,231 @@ +package engine + +import ( + "errors" + + "github.com/gitrgoliveira/bracket-creator/internal/state" +) + +// ErrNotKachinuki is returned by KachinukiRoster for a competition whose team +// matches are not fought winner-stays-on: only a kachinuki encounter has a +// queue of fighters to read. +var ErrNotKachinuki = errors.New("not a kachinuki competition") + +// KachinukiRosterFighter is one fighter of a side's queue: the member id when +// the lineup position or bout row carries one, and the name it holds (empty +// for a member picked by number and not named yet). The client resolves the +// fighter's current name and team-member number from its own member list. +type KachinukiRosterFighter struct { + Name string `json:"name"` + MemberID string `json:"memberId,omitempty"` +} + +// KachinukiRosterSide is one side's queue, split by the server so the client +// never has to decide which queue entry is the fighter on the court. +// +// On is the side's fighter in the live bout (the last numbered bout row), as +// that row names them; nil when the row names nobody for the side, when there +// is no bout row yet, or when Record bout has recorded that row as a WIN that +// retires this fighter (see KachinukiRoster). A recorded draw that is still +// the last bout keeps both its fighters on, in every phase. +// +// Remaining is the queue the advance takes this side's next fighter from if +// the fighter on loses: every fighter of the lineup in force who has not +// retired, the fighter on taken out by the same identity rule +// (IsMemberRetired), in queue order, so index 0 is next. +// +// LineupFound is false when no lineup is in force for the side's team at this +// match, or when the one in force fields nobody (a match's own empty lineup, +// positions: {}, shows none). Remaining is then EMPTY, because the only other +// source, the bout log, knows only the fighters already seen and any count +// taken from it would be false; On is still given. +// +// Exhausted is the advance's own verdict that the side has nobody left: the +// last numbered bout is RECORDED, it was a win (the advance's MatchEnded: +// the loser's queue is empty, so Record bout appended nothing after it), +// this side lost it, and this side's queue came from a lineup (LineupFound). +// Without a lineup the advance's queue is the bout log's, which knows only +// the fighters already seen and so empties after any loss: Exhausted is then +// false, and the bout stays live as it always did. A recorded draw exhausts +// nobody (both fighters stay on; the advance reports BothExhausted, never +// MatchEnded), and an unrecorded last bout is no result, so it exhausts +// nobody either. At most one side is exhausted. +type KachinukiRosterSide struct { + LineupFound bool `json:"lineupFound"` + On *KachinukiRosterFighter `json:"on"` + Remaining []KachinukiRosterFighter `json:"remaining"` + Exhausted bool `json:"exhausted"` +} + +// KachinukiRoster is the advisory read of a kachinuki encounter's two queues. +// SideA and SideB are the match's sides as stored (SideA is Aka on the sheet). +// +// NextPairing is whether Record bout, tapped now on the match as stored, +// would append a pairing: the very decision MaybeAdvanceKachinuki makes +// (decideKachinukiAdvance), asked without writing. On a RECORDED last bout it +// says whether a second tap could append anything (false: Record bout writes +// nothing, a lineup saved since may make it true). On an unrecorded last bout +// it reads the row's stored outcome as Record bout would (the running +// autosave's winner at a one-point lead counts), so it is current only as of +// the read. False for a completed match, a log with no numbered bout, a last +// bout with no outcome or no fighter, and an outcome the advance cannot read. +type KachinukiRoster struct { + SideA KachinukiRosterSide `json:"sideA"` + SideB KachinukiRosterSide `json:"sideB"` + NextPairing bool `json:"nextPairing"` +} + +// KachinukiRoster answers, for a kachinuki team match, who each side has on +// the court and who it has left. It is ADVISORY ONLY (operator ruling +// 2026-09-24): kachinuki is operator-led, team sizes are unregulated, and a +// side that reads as exhausted may still field fighters the app has never +// seen, so nothing ends the encounter or arms End match on this answer. The +// sheet reads two verdicts from it, both the advance's own: NextPairing (Record +// bout on a recorded bout would write nothing, so the button is disabled) and +// Exhausted (a recorded win left that side's lineup with nobody, so the bout +// is shown closed). +// +// Every numbered bout before the last retires by its result, as the advance +// reads it (RetiredPlayersFromBoutLog: the attribution owners, id first, and +// state.IsDraw). The last numbered bout is the one being fought: both its +// fighters are on whatever its score, because the running autosave writes a +// one-point lead's winner and a 1-1's hikiwake on that row and neither is a +// result (bc-kfnl, operator decision 2026-10-11). Only once Record bout has +// marked it recorded (state.SubMatchResult.Recorded, which the server alone +// writes) is its result read the same way: the loser is no longer on, the +// winner stays on. The mark is stored, so a fresh +// sheet, a second device and the public sheet read the same line. When a draw +// leaves one side with nobody, the advance appends a pairing that keeps that +// side's fighter (advanceAfterHikiwake), which is then the last bout and on. +// A recorded draw that is still the last bout keeps both its fighters on, in +// every phase: nothing followed it because both sides had nobody left, and +// whether that pair fights on in encho is the operator's call (a write that +// starts the encho takes the mark off); End match decides how it ends. +// +// The queues come from kachinukiRosterQueues and the retirements from +// RetiredPlayersFromBoutLog, as the advance (MaybeAdvanceKachinuki) takes +// them, and Remaining is the advance's remaining list with the fighter on +// retired too: the fighter it names as next is the one the advance appends +// when the fighter on loses. It writes nothing. A lineups.yaml that cannot be +// read is an error here (the advance logs it and reads no lineup). +func (e *Engine) KachinukiRoster(compID, matchID string) (KachinukiRoster, error) { + comp, err := e.store.LoadCompetition(compID) + if err != nil { + return KachinukiRoster{}, err + } + if comp == nil { + return KachinukiRoster{}, notFoundErrorf("competition %s not found", compID) + } + if !comp.IsKachinuki() { + return KachinukiRoster{}, ErrNotKachinuki + } + located, err := e.findTeamMatch(compID, matchID) + if err != nil { + return KachinukiRoster{}, err + } + if located == nil || located.Result == nil { + return KachinukiRoster{}, notFoundErrorf("match %s not found", matchID) + } + parent := located.Result + // The queues are built inside the read, and hold their own copies of the + // names and ids they take from the lineups. The advance's own decision is + // asked quietly (no breadcrumb: nothing is tapped) on that SAME reading + // (readKachinukiQueues), so the read's verdict is what Record bout does. + var q kachinukiQueues + if err := e.readLineupRule(compID, comp.IsKnockoutEnabled(), located.PoolMatches, located.Bracket, func(rule *lineupRule) { + q = readKachinukiQueues(comp, parent, rule) + }); err != nil { + return KachinukiRoster{}, err + } + verdict, decided := decideKachinukiAdvance(comp, parent, q, true) + queueA, queueB, retiredA, retiredB := q.queueA, q.queueB, q.retiredA, q.retiredB + + // The last numbered bout. Its fighters are on while it is being fought, + // whatever its score: the running autosave writes a winner at a one-point + // lead and a hikiwake at 1-1, and neither is a result (bc-kfnl, operator + // decision 2026-10-11). Once Record bout has marked it recorded + // (SubMatchResult.Recorded) its result says who is no longer on + // (liveRetiredA/B, from that row alone), as the advance reads it. Every + // bout before it retires by its result in retiredA/B; the last row's own + // retirement there changes nothing below, because the fighter on is taken + // out of the queue either way (retired.with(on)). + var onA, onB kachinukiFighter + liveRetiredA, liveRetiredB := newRetiredMemberSet(), newRetiredMemberSet() + if i := lastNumberedBout(parent.SubResults); i >= 0 { + live := parent.SubResults[i] + onA = kachinukiFighter{Name: live.SideA, MemberID: live.SideAMemberID} + onB = kachinukiFighter{Name: live.SideB, MemberID: live.SideBMemberID} + // A recorded DRAW that is still the last bout keeps both its fighters + // on, in every phase: the advance appended nothing after it, which + // after a draw means both sides had nobody left (a draw leaving one + // side empty appends the pairing that keeps that side's fighter, + // advanceAfterHikiwake). Whether that pair fights on in encho is the + // operator's call on any bout in any phase, and an encho tap clears + // the mark (mergeHold.unrecordReopenedBouts); End match decides the rest (a drawn + // encounter in a pool or league, blocked in a knockout). So the line + // never reads "no fighters left" while that bout can still be fought + // on. Only a recorded WIN takes a fighter off here. + if live.Recorded && !state.IsDraw(live.Decision) { + liveRetiredA, liveRetiredB = RetiredPlayersFromBoutLog(parent.SubResults[i:i+1], parent.SideA, parent.SideB) + } + } + out := KachinukiRoster{ + SideA: kachinukiRosterSide(queueA, retiredA, onA, liveRetiredA), + SideB: kachinukiRosterSide(queueB, retiredB, onB, liveRetiredB), + NextPairing: decided && verdict.out.Next != nil, + } + // Exhausted: a recorded win whose loser the advance found with nobody + // left (MatchEnded), on a lineup's queue only (see KachinukiRosterSide). + if decided && verdict.last.Recorded && verdict.out.MatchEnded { + switch verdict.out.WinningSide { + case "A": + out.SideB.Exhausted = verdict.lineupB + case "B": + out.SideA.Exhausted = verdict.lineupA + } + } + return out, nil +} + +// kachinukiRosterSide shapes one side's answer (see KachinukiRosterSide). on +// is the live bout row's fighter (zero when there is none); liveRetired holds +// whom that bout's RECORDED outcome retired (empty while it is live). The list +// is never nil, so the wire carries [] rather than null. +func kachinukiRosterSide(queue kachinukiSideQueue, retired RetiredMemberSet, on kachinukiFighter, liveRetired RetiredMemberSet) KachinukiRosterSide { + out := KachinukiRosterSide{ + LineupFound: queue.fromLineup(), + Remaining: []KachinukiRosterFighter{}, + } + onKnown := kachinukiFighterKnown(on) + if onKnown && !IsMemberRetired(on, liveRetired, nil) { + f := KachinukiRosterFighter(on) + out.On = &f + } + if !out.LineupFound { + return out + } + // The advance's remaining list once the fighter on has retired: the same + // queue, the same filter, the retired set with the fighter on added. + if onKnown { + retired = retired.with(on) + } + for _, f := range queue.remaining(retired) { + out.Remaining = append(out.Remaining, KachinukiRosterFighter(f)) + } + return out +} + +// with returns a copy of r with f retired as well, keyed both ways exactly as +// a bout row retires its fighter (retire), so IsMemberRetired settles f +// against the queue by its own id-then-unambiguous-name tiers. +func (r RetiredMemberSet) with(f kachinukiFighter) RetiredMemberSet { + out := newRetiredMemberSet() + for id := range r.IDs { + out.IDs[id] = struct{}{} + } + for name := range r.Names { + out.Names[name] = struct{}{} + } + out.retire(f.Name, f.MemberID) + return out +} diff --git a/internal/engine/kachinuki_roster_test.go b/internal/engine/kachinuki_roster_test.go new file mode 100644 index 000000000..7f2659b36 --- /dev/null +++ b/internal/engine/kachinuki_roster_test.go @@ -0,0 +1,1330 @@ +package engine + +import ( + "errors" + "fmt" + "os" + "path/filepath" + "testing" + + "github.com/gitrgoliveira/bracket-creator/internal/domain" + "github.com/gitrgoliveira/bracket-creator/internal/helper" + "github.com/gitrgoliveira/bracket-creator/internal/state" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// rosterTeams is a kachinuki competition's two teams as the store holds them, +// each with named members (ids minted by the store) and, when asked, a +// starting lineup that fields them in order. +type rosterTeams struct { + aID, bID string + a, b []domain.TeamMember +} + +func seedRosterTeams(t *testing.T, store *state.Store, compID string, size int, aNames, bNames []string, lineupA, lineupB bool) rosterTeams { + t.Helper() + rt := rosterTeams{aID: helper.NewUUID4(), bID: helper.NewUUID4()} + require.NoError(t, store.SaveParticipants(compID, []domain.Player{ + {ID: rt.aID, Name: "Ryu", Dojo: "DojoR"}, + {ID: rt.bID, Name: "Tora", Dojo: "DojoT"}, + })) + add := func(teamID string, names []string) []domain.TeamMember { + out := make([]domain.TeamMember, 0, len(names)) + for _, n := range names { + m, err := store.AddTeamMember(compID, teamID, n) + require.NoError(t, err) + out = append(out, m) + } + return out + } + rt.a = add(rt.aID, aNames) + rt.b = add(rt.bID, bNames) + save := func(teamID string, members []domain.TeamMember) { + l := domain.TeamLineup{TeamID: teamID, Round: 0, Positions: map[domain.Position]string{}, MemberIDs: map[domain.Position]string{}} + for i, m := range members { + l.Positions[domain.PositionNumbered(i+1)] = m.Name + l.MemberIDs[domain.PositionNumbered(i+1)] = m.ID + } + require.NoError(t, store.SetTeamLineup(compID, l, size)) + } + if lineupA { + save(rt.aID, rt.a) + } + if lineupB { + save(rt.bID, rt.b) + } + return rt +} + +// rosterBout is a numbered bout row between two members, with the winner (by +// member id, as the editor records it) or a hikiwake; neither is a bout not +// fought yet. +func rosterBout(pos int, a, b domain.TeamMember, winner *domain.TeamMember, hikiwake bool) state.SubMatchResult { + s := state.SubMatchResult{Position: pos, SideA: a.Name, SideAMemberID: a.ID, SideB: b.Name, SideBMemberID: b.ID} + switch { + case hikiwake: + s.Decision = "hikiwake" + case winner != nil: + s.Winner, s.WinnerMemberID, s.Decision = winner.Name, winner.ID, "fought" + } + return s +} + +func rosterIDs(side KachinukiRosterSide) []string { + out := make([]string, 0, len(side.Remaining)) + for _, f := range side.Remaining { + out = append(out, f.MemberID) + } + return out +} + +func memberIDs(ms ...domain.TeamMember) []string { + out := make([]string, 0, len(ms)) + for _, m := range ms { + out = append(out, m.ID) + } + return out +} + +// onID is the member id of the side's fighter on, "" when nobody is on. +func onID(side KachinukiRosterSide) string { + if side.On == nil { + return "" + } + return side.On.MemberID +} + +// A win retires the loser, a hikiwake retires both, and the live bout (the +// last numbered row) names each side's fighter on. Remaining is what is left +// behind the fighter on, in lineup order: the next fighter first. +func TestKachinukiRoster_SplitsTheFighterOnFromTheQueue(t *testing.T) { + // Four a side: a five-person team's positions are the FIK names, and + // this pins the queue, not the position keys. + eng, store, comp := setupKachinukiComp(t, "kachinuki-roster-basic", 4, + func(c *state.Competition) { c.Format = state.CompFormatMixed }) + rt := seedRosterTeams(t, store, comp.ID, 4, + []string{"A1", "A2", "A3", "A4"}, []string{"B1", "B2", "B3", "B4"}, true, true) + a, b := rt.a, rt.b + require.NoError(t, store.SavePoolMatches(comp.ID, []state.MatchResult{{ + ID: "Pool A-0", SideA: "Ryu", SideAID: rt.aID, SideB: "Tora", SideBID: rt.bID, Status: state.MatchStatusRunning, + SubResults: []state.SubMatchResult{ + rosterBout(1, a[0], b[0], &a[0], false), + rosterBout(2, a[0], b[1], nil, true), + rosterBout(3, a[1], b[2], nil, false), + }, + }})) + + got, err := eng.KachinukiRoster(comp.ID, "Pool A-0") + require.NoError(t, err) + assert.True(t, got.SideA.LineupFound) + assert.True(t, got.SideB.LineupFound) + require.NotNil(t, got.SideA.On) + assert.Equal(t, "A2", got.SideA.On.Name) + assert.Equal(t, a[1].ID, onID(got.SideA)) + assert.Equal(t, b[2].ID, onID(got.SideB)) + assert.Equal(t, memberIDs(a[2:]...), rosterIDs(got.SideA), "A1 drew and retired; A2 is on") + assert.Equal(t, memberIDs(b[3:]...), rosterIDs(got.SideB), "B1 lost, B2 drew; B3 is on") +} + +// The fighter on is taken out of the queue by the engine's identity rule +// (IsMemberRetired), not by a second one: a live row that names the fighter +// without the member id the lineup slot carries still matches that slot by +// its unambiguous name, so the fighter on is never counted again or named as +// next. +func TestKachinukiRoster_FighterOnWithoutAnIdIsMatchedByName(t *testing.T) { + eng, store, comp := setupKachinukiComp(t, "kachinuki-roster-on-no-id", 3, + func(c *state.Competition) { c.Format = state.CompFormatMixed }) + rt := seedRosterTeams(t, store, comp.ID, 3, + []string{"A1", "Ueda", "A3"}, []string{"B1", "B2", "B3"}, true, true) + a, b := rt.a, rt.b + live := rosterBout(2, a[1], b[0], nil, false) + live.SideAMemberID = "" // the row names Ueda only + require.NoError(t, store.SavePoolMatches(comp.ID, []state.MatchResult{{ + ID: "Pool A-0", SideA: "Ryu", SideAID: rt.aID, SideB: "Tora", SideBID: rt.bID, Status: state.MatchStatusRunning, + SubResults: []state.SubMatchResult{rosterBout(1, a[0], b[0], &b[0], false), live}, + }})) + + got, err := eng.KachinukiRoster(comp.ID, "Pool A-0") + require.NoError(t, err) + require.NotNil(t, got.SideA.On) + assert.Equal(t, "Ueda", got.SideA.On.Name) + assert.Equal(t, memberIDs(a[2]), rosterIDs(got.SideA), "Ueda is on, not next; A3 is next") +} + +// Two opposing fighters may share a display name. Who retired is settled by +// the bout row's member ids, never by the name the two share. +func TestKachinukiRoster_SameNameIsSettledByMemberID(t *testing.T) { + eng, store, comp := setupKachinukiComp(t, "kachinuki-roster-same-name", 2, + func(c *state.Competition) { c.Format = state.CompFormatMixed }) + rt := seedRosterTeams(t, store, comp.ID, 2, + []string{"Yamada", "Ito"}, []string{"Yamada", "Kudo"}, true, true) + a, b := rt.a, rt.b + // Shiro's (side B's) Yamada wins the recorded bout: Aka's Yamada retires, + // and only he does. + recorded := rosterBout(1, a[0], b[0], &b[0], false) + recorded.Recorded = true + require.NoError(t, store.SavePoolMatches(comp.ID, []state.MatchResult{{ + ID: "Pool A-0", SideA: "Ryu", SideAID: rt.aID, SideB: "Tora", SideBID: rt.bID, Status: state.MatchStatusRunning, + SubResults: []state.SubMatchResult{recorded}, + }})) + + got, err := eng.KachinukiRoster(comp.ID, "Pool A-0") + require.NoError(t, err) + assert.Nil(t, got.SideA.On, "the recorded bout retired Aka's Yamada") + assert.Equal(t, memberIDs(a[1]), rosterIDs(got.SideA), "Ito is next") + assert.Equal(t, b[0].ID, onID(got.SideB), "Shiro's Yamada stays on") + assert.Equal(t, memberIDs(b[1]), rosterIDs(got.SideB)) +} + +// The last bout row's own result says who is still on, for every reader: a +// winner takes the loser off (a bout recorded when a side has nobody left +// appends nothing, so this is how the line learns it is over), a hikiwake +// takes both off, and a row with no result keeps both on. +func TestKachinukiRoster_TheBoutBeingFoughtKeepsBothFightersOn(t *testing.T) { + eng, store, comp := setupKachinukiComp(t, "kachinuki-roster-live", 2, + func(c *state.Competition) { c.Format = state.CompFormatMixed }) + rt := seedRosterTeams(t, store, comp.ID, 2, + []string{"A1", "A2"}, []string{"B1", "B2"}, true, true) + a, b := rt.a, rt.b + read := func(last state.SubMatchResult) KachinukiRoster { + t.Helper() + saveRosterMatch(t, store, comp.ID, rt, []state.SubMatchResult{rosterBout(1, a[0], b[0], &a[0], false), last}, false) + got, err := eng.KachinukiRoster(comp.ID, "Pool A-0") + require.NoError(t, err) + return got + } + + // The running autosave writes a 1-0 lead's winner and a 1-1's hikiwake on + // the live row. Neither is a result until Record bout records the bout. + for name, last := range map[string]state.SubMatchResult{ + "no score": rosterBout(2, a[0], b[1], nil, false), + "a 1-0 lead": rosterBout(2, a[0], b[1], &a[0], false), + "1-1": rosterBout(2, a[0], b[1], nil, true), + } { + got := read(last) + assert.Equal(t, a[0].ID, onID(got.SideA), "%s: A1 is on", name) + assert.Equal(t, b[1].ID, onID(got.SideB), "%s: B2 is on", name) + assert.Equal(t, memberIDs(a[1]), rosterIDs(got.SideA), "%s", name) + assert.Empty(t, got.SideB.Remaining, "%s: B2 is B's last fighter", name) + assert.True(t, got.SideB.LineupFound, "%s", name) + } +} + +func TestKachinukiRoster_RecordBoutAtExhaustionTakesTheLoserOff(t *testing.T) { + for _, bracket := range []bool{false, true} { + name := "pool" + if bracket { + name = "bracket" + } + t.Run(name, func(t *testing.T) { + eng, store, comp := setupKachinukiComp(t, "kachinuki-roster-exhausted-"+name, 2, + func(c *state.Competition) { c.Format = state.CompFormatMixed }) + rt := seedRosterTeams(t, store, comp.ID, 2, + []string{"A1", "A2"}, []string{"B1", "B2"}, true, true) + a, b := rt.a, rt.b + // A1 beat B1, and wins bout 2 against B2, B's last fighter. + matchID := saveRosterMatch(t, store, comp.ID, rt, []state.SubMatchResult{ + rosterBout(1, a[0], b[0], &a[0], false), + rosterBout(2, a[0], b[1], &a[0], false), + }, bracket) + + before, err := eng.findTeamMatch(comp.ID, matchID) + require.NoError(t, err) + require.NotNil(t, before) + + // Record bout: nobody left on B, so nothing is appended, but the + // bout is marked recorded in a write of its own. + appended, post, err := eng.MaybeAdvanceKachinuki(comp.ID, matchID) + require.NoError(t, err) + assert.False(t, appended, "nobody left on B: nothing is appended") + require.NotNil(t, post, "the recorded mark is a write the answer carries") + require.Len(t, post.BoutLog, 2) + assert.True(t, post.BoutLog[1].Recorded, "the bout Record bout closed is recorded") + assert.False(t, post.BoutLog[0].Recorded, "an earlier bout is not touched") + // Review round 6: the mark is GroupRecorded, stamped strictly after + // the match it was made on, so every device orders the marked copy + // after the unmarked one; the bout's own group keeps its stamp. + assert.Greater(t, post.ModifiedAt, before.Result.ModifiedAt, "the mark moves ModifiedAt") + assert.Equal(t, post.ModifiedAt, post.GroupStamps[state.GroupRecorded], "the mark is stamped as its own group") + assert.Equal(t, before.Result.GroupStamp(state.BoutGroup(2)), post.GroupStamps[state.BoutGroup(2)], + "the closed bout's own group is not stamped") + + got, err := eng.KachinukiRoster(comp.ID, matchID) + require.NoError(t, err) + assert.Nil(t, got.SideB.On, "B2 lost the recorded bout and is off") + assert.Empty(t, got.SideB.Remaining, "no fighters left on B") + assert.True(t, got.SideB.LineupFound) + assert.Equal(t, a[0].ID, onID(got.SideA), "the winner stays on") + assert.Equal(t, memberIDs(a[1]), rosterIDs(got.SideA)) + + history, err := store.LoadMatchHistory(comp.ID, matchID) + require.NoError(t, err) + require.NotEmpty(t, history) + lastEntry := history[len(history)-1] + assert.Equal(t, doorKachinukiRecord, lastEntry.Door) + assert.Equal(t, []string{state.GroupRecorded}, lastEntry.Changed, "the history line names the recorded group") + + // A second Record bout on the recorded bout writes nothing. + again, post2, err := eng.MaybeAdvanceKachinuki(comp.ID, matchID) + require.NoError(t, err) + assert.False(t, again) + assert.Nil(t, post2, "a replayed Record bout adds no stamp and no history line") + history2, err := store.LoadMatchHistory(comp.ID, matchID) + require.NoError(t, err) + assert.Len(t, history2, len(history)) + }) + } +} + +func TestKachinukiRoster_ARecordedDrawAtExhaustionKeepsBothOn(t *testing.T) { + for _, bracket := range []bool{false, true} { + name := branchName(bracket) + t.Run(name, func(t *testing.T) { + eng, store, comp := setupKachinukiComp(t, "kachinuki-roster-recorded-draw-"+name, 2, + func(c *state.Competition) { c.Format = state.CompFormatMixed }) + rt := seedRosterTeams(t, store, comp.ID, 2, + []string{"A1", "A2"}, []string{"B1", "B2"}, true, true) + a, b := rt.a, rt.b + // B1 beat A1, A2 beat B1, then A2 and B2 draw: both teams are out. + matchID := saveRosterMatch(t, store, comp.ID, rt, []state.SubMatchResult{ + rosterBout(1, a[0], b[0], &b[0], false), + rosterBout(2, a[1], b[0], &a[1], false), + rosterBout(3, a[1], b[1], nil, true), + }, bracket) + appended, post, err := eng.MaybeAdvanceKachinuki(comp.ID, matchID) + require.NoError(t, err) + assert.False(t, appended, "both teams are out: nothing is appended") + require.NotNil(t, post) + assert.True(t, post.BoutLog[2].Recorded) + + // The recorded draw is still the last bout, so the pair may fight on + // in encho (the operator's call, in any phase): both stay on. + got, err := eng.KachinukiRoster(comp.ID, matchID) + require.NoError(t, err) + assert.Equal(t, a[1].ID, onID(got.SideA), "a recorded draw as the last bout keeps A2 on") + assert.Equal(t, b[1].ID, onID(got.SideB), "and B2") + assert.Empty(t, got.SideA.Remaining) + assert.Empty(t, got.SideB.Remaining) + }) + } +} + +func TestKachinukiRoster_ARecordedDrawKeepsTheLastFighterOfAnExhaustedSide(t *testing.T) { + eng, store, comp := setupKachinukiComp(t, "kachinuki-roster-taisho", 2, + func(c *state.Competition) { c.Format = state.CompFormatMixed }) + rt := seedRosterTeams(t, store, comp.ID, 2, + []string{"A1", "A2"}, []string{"B1", "B2"}, true, true) + a, b := rt.a, rt.b + // A1 beat B1, then A1 and B2 draw: B has nobody left, A has A2. The advance + // keeps B2 on against A2 (advanceAfterHikiwake), and the line reads it so. + saveRosterMatch(t, store, comp.ID, rt, []state.SubMatchResult{ + rosterBout(1, a[0], b[0], &a[0], false), + rosterBout(2, a[0], b[1], nil, true), + }, false) + appended, post, err := eng.MaybeAdvanceKachinuki(comp.ID, "Pool A-0") + require.NoError(t, err) + require.True(t, appended) + require.Len(t, post.BoutLog, 3) + assert.True(t, post.BoutLog[1].Recorded, "the drawn bout is recorded") + assert.False(t, post.BoutLog[2].Recorded, "the appended pairing is the bout being fought") + assert.Equal(t, b[1].ID, post.BoutLog[2].SideBMemberID, "B2 stays on") + + got, err := eng.KachinukiRoster(comp.ID, "Pool A-0") + require.NoError(t, err) + assert.Equal(t, a[1].ID, onID(got.SideA)) + assert.Equal(t, b[1].ID, onID(got.SideB), "B's last fighter stays on after the draw") + assert.Empty(t, got.SideB.Remaining) + assert.Empty(t, got.SideA.Remaining) +} + +func TestKachinukiRoster_RemovingTheAppendedPairingUnrecordsTheBout(t *testing.T) { + for _, bracket := range []bool{false, true} { + name := "pool" + if bracket { + name = "bracket" + } + t.Run(name, func(t *testing.T) { + eng, store, comp := setupKachinukiComp(t, "kachinuki-roster-undo-"+name, 3, + func(c *state.Competition) { c.Format = state.CompFormatMixed }) + rt := seedRosterTeams(t, store, comp.ID, 3, + []string{"A1", "A2", "A3"}, []string{"B1", "B2", "B3"}, true, true) + a, b := rt.a, rt.b + matchID := saveRosterMatch(t, store, comp.ID, rt, []state.SubMatchResult{ + rosterBout(1, a[0], b[0], &a[0], false), + }, bracket) + appended, post, err := eng.MaybeAdvanceKachinuki(comp.ID, matchID) + require.NoError(t, err) + require.True(t, appended) + require.True(t, post.BoutLog[0].Recorded) + + // Removing the appended pairing undoes the Record bout: bout 1 is + // the bout being fought again, both its fighters on. + updated, err := eng.RemoveTrailingKachinukiBout(comp.ID, matchID) + require.NoError(t, err) + require.Len(t, updated.SubResults, 1) + assert.False(t, updated.SubResults[0].Recorded) + got, err := eng.KachinukiRoster(comp.ID, matchID) + require.NoError(t, err) + assert.Equal(t, a[0].ID, onID(got.SideA)) + assert.Equal(t, b[0].ID, onID(got.SideB), "B1 is on again") + + history, err := store.LoadMatchHistory(comp.ID, matchID) + require.NoError(t, err) + lastEntry := history[len(history)-1] + assert.Equal(t, doorKachinukiRemove, lastEntry.Door) + assert.ElementsMatch(t, []string{state.BoutGroup(2), state.GroupRecorded}, lastEntry.Changed) + // The un-record is stamped with the removal, strictly after the + // copy Record bout left, so every device orders it after that one. + recordedStamp := storedGroupStamp(t, store, comp.ID, matchID, bracket, state.GroupRecorded) + assert.Greater(t, recordedStamp, post.ModifiedAt, "taking the mark off is stamped after Record bout") + assert.Equal(t, recordedStamp, storedGroupStamp(t, store, comp.ID, matchID, bracket, state.BoutGroup(2)), + "one stamp for the removal and the un-record") + assert.Equal(t, recordedStamp, updated.ModifiedAt, "the removal moves ModifiedAt") + }) + } +} + +func TestKachinukiRecorded_AWriteNeitherClearsNorSetsIt(t *testing.T) { + for _, bracket := range []bool{false, true} { + name := "pool" + if bracket { + name = "bracket" + } + t.Run(name, func(t *testing.T) { + eng, store, comp := setupKachinukiComp(t, "kachinuki-recorded-carry-"+name, 3, + func(c *state.Competition) { c.Format = state.CompFormatMixed }) + rt := seedRosterTeams(t, store, comp.ID, 3, + []string{"A1", "A2", "A3"}, []string{"B1", "B2", "B3"}, true, true) + a, b := rt.a, rt.b + first := rosterBout(1, a[0], b[0], &a[0], false) + first.Recorded = true + live := rosterBout(2, a[0], b[1], nil, false) + matchID := saveRosterMatch(t, store, comp.ID, rt, []state.SubMatchResult{first, live}, bracket) + + recordedBefore := storedGroupStamp(t, store, comp.ID, matchID, bracket, state.GroupRecorded) + + // The running autosave: bout 1 echoed WITHOUT its mark, and bout 2 + // (a point) claiming a mark the server never gave it. It even names + // the recorded group, which no client write may change: the merge + // drops the name (the handler refuses it with a 400 before that). + sentFirst := first + sentFirst.Recorded = false + sentLive := live + sentLive.IpponsA = []string{"M"} + sentLive.Recorded = true + require.NoError(t, eng.RecordMatchResult(comp.ID, matchID, &state.MatchResult{ + ID: matchID, SideA: "Ryu", SideAID: rt.aID, SideB: "Tora", SideBID: rt.bID, + Status: state.MatchStatusRunning, ModifiedAt: serverNowMs() + 1000, + SubResults: []state.SubMatchResult{sentFirst, sentLive}, + Changed: []string{state.BoutGroup(2), state.GroupRecorded}, WriteDoor: DoorScore, + })) + + subs := storedSubs(t, store, comp.ID, matchID, bracket) + require.Len(t, subs, 2) + assert.True(t, subs[0].Recorded, "a write that leaves the mark out keeps it") + assert.False(t, subs[1].Recorded, "a client cannot mark a bout recorded") + assert.Equal(t, []string{"M"}, subs[1].IpponsA, "the point itself landed") + + history, err := store.LoadMatchHistory(comp.ID, matchID) + require.NoError(t, err) + require.NotEmpty(t, history) + assert.Equal(t, []string{state.BoutGroup(2)}, history[len(history)-1].Changed, + "leaving the mark out is not a change of bout 1, and a client never changes the recorded group") + assert.Equal(t, recordedBefore, storedGroupStamp(t, store, comp.ID, matchID, bracket, state.GroupRecorded), + "the recorded group keeps its stamp") + }) + } +} + +// branchName names a pool/bracket subtest. +func branchName(bracket bool) string { + if bracket { + return "bracket" + } + return "pool" +} + +// storedGroupStamp is the stored match's stamp for group. +func storedGroupStamp(t *testing.T, store *state.Store, compID, matchID string, bracket bool, group string) int64 { + t.Helper() + if !bracket { + pms, err := store.LoadPoolMatches(compID) + require.NoError(t, err) + for i := range pms { + if pms[i].ID == matchID { + return pms[i].GroupStamp(group) + } + } + t.Fatalf("pool match %s not found", matchID) + } + br, err := store.LoadBracket(compID) + require.NoError(t, err) + bm := br.MatchByID(matchID) + require.NotNil(t, bm) + return bm.GroupStamp(group) +} + +// A bout added by hand after a side had nobody left is not a pairing Record +// bout appended: removing it leaves the bout Record bout closed recorded, and +// the sheet back in its closed state (review round 3, finding 1). +func TestRemoveTrailingKachinukiBout_AManualBoutAfterExhaustionKeepsTheBoutRecorded(t *testing.T) { + for _, bracket := range []bool{false, true} { + name := branchName(bracket) + t.Run(name, func(t *testing.T) { + eng, store, comp := setupKachinukiComp(t, "kachinuki-manual-remove-"+name, 2, + func(c *state.Competition) { c.Format = state.CompFormatMixed }) + rt := seedRosterTeams(t, store, comp.ID, 2, + []string{"A1", "A2"}, []string{"B1", "B2"}, true, true) + a, b := rt.a, rt.b + matchID := saveRosterMatch(t, store, comp.ID, rt, []state.SubMatchResult{ + rosterBout(1, a[0], b[0], &a[0], false), + rosterBout(2, a[0], b[1], &a[0], false), + }, bracket) + appended, post, err := eng.MaybeAdvanceKachinuki(comp.ID, matchID) + require.NoError(t, err) + require.False(t, appended, "nobody left on B: nothing is appended") + require.True(t, post.BoutLog[1].Recorded) + + // "+ Add next bout manually": the fighter pick autosaves the row. + manual := state.SubMatchResult{Position: 3, SideA: a[0].Name, SideAMemberID: a[0].ID, SideB: b[1].Name, SideBMemberID: b[1].ID} + require.NoError(t, eng.RecordMatchResult(comp.ID, matchID, &state.MatchResult{ + ID: matchID, SideA: "Ryu", SideAID: rt.aID, SideB: "Tora", SideBID: rt.bID, + Status: state.MatchStatusRunning, ModifiedAt: serverNowMs(), + SubResults: append(append([]state.SubMatchResult(nil), post.BoutLog...), manual), + Changed: []string{state.BoutGroup(3)}, WriteDoor: DoorScore, + })) + require.Len(t, storedSubs(t, store, comp.ID, matchID, bracket), 3) + + // A lineup edit between the manual add and Remove gives B a fighter + // who has not fought (B3 in B2's place): asked again now, the + // advance would append a pairing. Remove still reads how the row + // got there, from the history (review round 4, finding 3). + b3 := replaceLineupPosition(t, store, comp.ID, rt.bID, rt.b, 2, "B3") + + updated, err := eng.RemoveTrailingKachinukiBout(comp.ID, matchID) + require.NoError(t, err) + require.Len(t, updated.SubResults, 2) + assert.True(t, updated.SubResults[1].Recorded, "the removed bout was added by hand: bout 2 stays recorded") + subs := storedSubs(t, store, comp.ID, matchID, bracket) + require.Len(t, subs, 2) + assert.True(t, subs[1].Recorded) + + got, err := eng.KachinukiRoster(comp.ID, matchID) + require.NoError(t, err) + assert.Nil(t, got.SideB.On, "B2 is still off: the closed state stands") + assert.Equal(t, []string{b3.ID}, rosterIDs(got.SideB), "the edited lineup's B3 is next") + + history, err := store.LoadMatchHistory(comp.ID, matchID) + require.NoError(t, err) + lastEntry := history[len(history)-1] + assert.Equal(t, doorKachinukiRemove, lastEntry.Door) + assert.Equal(t, []string{state.BoutGroup(3)}, lastEntry.Changed, "nothing was un-recorded") + }) + } +} + +// An advance that appended a pairing after a bout already recorded did not set +// that bout's mark: removing what it appended leaves the mark the earlier +// Record bout set (review round 8, finding 1). +func TestRemoveTrailingKachinukiBout_AnAdvanceOverARecordedBoutKeepsTheMark(t *testing.T) { + for _, bracket := range []bool{false, true} { + name := branchName(bracket) + t.Run(name, func(t *testing.T) { + eng, store, comp := setupKachinukiComp(t, "kachinuki-remove-unmarked-advance-"+name, 2, + func(c *state.Competition) { c.Format = state.CompFormatMixed }) + rt := seedRosterTeams(t, store, comp.ID, 2, + []string{"A1", "A2"}, []string{"B1", "B2"}, true, true) + a, b := rt.a, rt.b + matchID := saveRosterMatch(t, store, comp.ID, rt, []state.SubMatchResult{ + rosterBout(1, a[0], b[0], &a[0], false), + rosterBout(2, a[0], b[1], &a[0], false), + }, bracket) + // Record bout with nobody left on B: bout 2 is marked, nothing appended. + appended, post, err := eng.MaybeAdvanceKachinuki(comp.ID, matchID) + require.NoError(t, err) + require.False(t, appended) + require.True(t, post.BoutLog[1].Recorded) + + // B's lineup gains B3, and Record bout is tapped again: it appends + // A1 v B3 but sets no mark (bout 2 was already recorded). + b3 := replaceLineupPosition(t, store, comp.ID, rt.bID, b, 2, "B3") + appended, post, err = eng.MaybeAdvanceKachinuki(comp.ID, matchID) + require.NoError(t, err) + require.True(t, appended) + require.Len(t, post.BoutLog, 3) + require.Equal(t, b3.ID, post.BoutLog[2].SideBMemberID) + history, err := store.LoadMatchHistory(comp.ID, matchID) + require.NoError(t, err) + require.NotContains(t, history[len(history)-1].Changed, state.GroupRecorded, + "the second advance did not set the mark") + + updated, err := eng.RemoveTrailingKachinukiBout(comp.ID, matchID) + require.NoError(t, err) + require.Len(t, updated.SubResults, 2) + assert.True(t, updated.SubResults[1].Recorded, "the earlier Record bout's mark stays") + assert.True(t, storedSubs(t, store, comp.ID, matchID, bracket)[1].Recorded) + history, err = store.LoadMatchHistory(comp.ID, matchID) + require.NoError(t, err) + last := history[len(history)-1] + assert.Equal(t, doorKachinukiRemove, last.Door) + assert.Equal(t, []string{state.BoutGroup(3)}, last.Changed, "nothing was un-recorded") + }) + } +} + +// replaceLineupPosition edits a team's starting lineup: members in order, with +// the member at pos (1-based) replaced by a new member named name, or with the +// lineup cut to the positions before pos when name is "". It returns the new +// member (zero when cut). +func replaceLineupPosition(t *testing.T, store *state.Store, compID, teamID string, members []domain.TeamMember, pos int, name string) domain.TeamMember { + t.Helper() + var added domain.TeamMember + l := domain.TeamLineup{TeamID: teamID, Round: 0, Positions: map[domain.Position]string{}, MemberIDs: map[domain.Position]string{}} + for i, m := range members { + p := i + 1 + if p == pos { + if name == "" { + break + } + var err error + added, err = store.AddTeamMember(compID, teamID, name) + require.NoError(t, err) + m = added + } + l.Positions[domain.PositionNumbered(p)] = m.Name + l.MemberIDs[domain.PositionNumbered(p)] = m.ID + } + require.NoError(t, store.SetTeamLineup(compID, l, len(members))) + return added +} + +// Removing the pairing Record bout appended undoes that Record bout even when +// a lineup edit since then means the advance, asked again, would append +// nothing: Remove reads how the row got there from the match's history, not +// from the lineups (review round 4, finding 3). +func TestRemoveTrailingKachinukiBout_AnAppendedPairingUnrecordsAfterALineupEdit(t *testing.T) { + for _, bracket := range []bool{false, true} { + name := branchName(bracket) + t.Run(name, func(t *testing.T) { + eng, store, comp := setupKachinukiComp(t, "kachinuki-remove-after-edit-"+name, 3, + func(c *state.Competition) { c.Format = state.CompFormatMixed }) + rt := seedRosterTeams(t, store, comp.ID, 3, + []string{"A1", "A2", "A3"}, []string{"B1", "B2", "B3"}, true, true) + a, b := rt.a, rt.b + matchID := saveRosterMatch(t, store, comp.ID, rt, []state.SubMatchResult{ + rosterBout(1, a[0], b[0], &a[0], false), + }, bracket) + appended, post, err := eng.MaybeAdvanceKachinuki(comp.ID, matchID) + require.NoError(t, err) + require.True(t, appended) + require.True(t, post.BoutLog[0].Recorded) + + // B's lineup cut to B1 alone: asked now, the advance would append + // nothing after bout 1. + replaceLineupPosition(t, store, comp.ID, rt.bID, b, 2, "") + + updated, err := eng.RemoveTrailingKachinukiBout(comp.ID, matchID) + require.NoError(t, err) + require.Len(t, updated.SubResults, 1) + assert.False(t, updated.SubResults[0].Recorded, "the removed pairing was Record bout's: bout 1 is live again") + assert.False(t, storedSubs(t, store, comp.ID, matchID, bracket)[0].Recorded) + }) + } +} + +// With no history to read (a match written before the history existed, or an +// append that failed), Remove cannot tell how the row got there, so the bout +// Record bout closed stays recorded: the closed state stands and the operator +// can still correct the bout (review round 4, finding 3). +func TestRemoveTrailingKachinukiBout_WithoutHistoryKeepsTheBoutRecorded(t *testing.T) { + for _, bracket := range []bool{false, true} { + name := branchName(bracket) + t.Run(name, func(t *testing.T) { + eng, store, comp := setupKachinukiComp(t, "kachinuki-remove-no-history-"+name, 3, + func(c *state.Competition) { c.Format = state.CompFormatMixed }) + rt := seedRosterTeams(t, store, comp.ID, 3, + []string{"A1", "A2", "A3"}, []string{"B1", "B2", "B3"}, true, true) + a, b := rt.a, rt.b + matchID := saveRosterMatch(t, store, comp.ID, rt, []state.SubMatchResult{ + rosterBout(1, a[0], b[0], &a[0], false), + }, bracket) + appended, _, err := eng.MaybeAdvanceKachinuki(comp.ID, matchID) + require.NoError(t, err) + require.True(t, appended) + require.NoError(t, store.DeleteMatchHistory(comp.ID)) + + updated, err := eng.RemoveTrailingKachinukiBout(comp.ID, matchID) + require.NoError(t, err) + require.Len(t, updated.SubResults, 1) + assert.True(t, updated.SubResults[0].Recorded, "no history: the recorded bout stays recorded") + assert.True(t, storedSubs(t, store, comp.ID, matchID, bracket)[0].Recorded) + }) + } +} + +// Positions are reused: a pairing Record bout appended and Remove took out, +// then a bout added by hand at the same position, is the hand-added row. Its +// removal leaves the bout before it recorded (review round 4, finding 3). +func TestRemoveTrailingKachinukiBout_AReusedPositionReadsItsCurrentRow(t *testing.T) { + // ahead puts the match's stamp in the future before Record bout, as a + // device whose clock runs ahead leaves it: the advance line is then + // stamped AFTER the Remove that follows it, so only the order the lines + // were written in (never stamp order) reads the row's life right. + for _, ahead := range []int64{0, 3000} { + for _, bracket := range []bool{false, true} { + name := fmt.Sprintf("%s/ahead-%d", branchName(bracket), ahead) + t.Run(name, func(t *testing.T) { + eng, store, comp := setupKachinukiComp(t, fmt.Sprintf("kachinuki-remove-reused-%s-%d", branchName(bracket), ahead), 3, + func(c *state.Competition) { c.Format = state.CompFormatMixed }) + rt := seedRosterTeams(t, store, comp.ID, 3, + []string{"A1", "A2", "A3"}, []string{"B1", "B2", "B3"}, true, true) + a, b := rt.a, rt.b + var at int64 + if ahead > 0 { + at = serverNowMs() + ahead + } + matchID := saveRosterMatchAt(t, store, comp.ID, rt, []state.SubMatchResult{ + rosterBout(1, a[0], b[0], &a[0], false), + }, bracket, at) + appended, _, err := eng.MaybeAdvanceKachinuki(comp.ID, matchID) + require.NoError(t, err) + require.True(t, appended) + _, err = eng.RemoveTrailingKachinukiBout(comp.ID, matchID) + require.NoError(t, err) + require.False(t, storedSubs(t, store, comp.ID, matchID, bracket)[0].Recorded) + + // B cut to B1: Record bout now closes bout 1 and appends nothing. + replaceLineupPosition(t, store, comp.ID, rt.bID, b, 2, "") + appended, post, err := eng.MaybeAdvanceKachinuki(comp.ID, matchID) + require.NoError(t, err) + require.False(t, appended) + require.True(t, post.BoutLog[0].Recorded) + + // "+ Add next bout manually" at position 2, after the tombstone + // and after the match as this device saw it. + manual := state.SubMatchResult{Position: 2, SideA: a[0].Name, SideAMemberID: a[0].ID, SideB: b[1].Name, SideBMemberID: b[1].ID} + require.NoError(t, eng.RecordMatchResult(comp.ID, matchID, &state.MatchResult{ + ID: matchID, SideA: "Ryu", SideAID: rt.aID, SideB: "Tora", SideBID: rt.bID, + Status: state.MatchStatusRunning, ModifiedAt: serverNowMs() + ahead + 1, + SubResults: append(append([]state.SubMatchResult(nil), post.BoutLog...), manual), + Changed: []string{state.BoutGroup(2)}, WriteDoor: DoorScore, + })) + require.Len(t, storedSubs(t, store, comp.ID, matchID, bracket), 2) + + updated, err := eng.RemoveTrailingKachinukiBout(comp.ID, matchID) + require.NoError(t, err) + require.Len(t, updated.SubResults, 1) + assert.True(t, updated.SubResults[0].Recorded, "the row at position 2 now was added by hand") + }) + } + } +} + +// boutAppendedByRecordBout reads the row's current life: a reopen ends it (a +// finish strips a trailing unscored pairing), and a line that names the group +// without applying it creates nothing. +func TestBoutAppendedByRecordBout(t *testing.T) { + g := state.BoutGroup(2) + applied := func(door string, groups ...string) state.MatchHistoryEntry { + out := map[string]string{} + for _, x := range groups { + out[x] = state.HistoryOutcomeApplied + } + return state.MatchHistoryEntry{Door: door, Changed: groups, Outcomes: out} + } + advance := applied(doorKachinukiAdvance, state.GroupRecorded, g, state.GroupResult) + assert.True(t, boutAppendedByRecordBout([]state.MatchHistoryEntry{advance}, g)) + assert.False(t, boutAppendedByRecordBout([]state.MatchHistoryEntry{applied(doorKachinukiAdvance, g, state.GroupResult)}, g), + "an advance that appended the row without setting the mark did not record the bout before it") + assert.False(t, boutAppendedByRecordBout(nil, g), "no history keeps the mark") + assert.False(t, boutAppendedByRecordBout([]state.MatchHistoryEntry{advance, applied(doorReopen, state.GroupResult)}, g), + "a reopen ends the row's life") + assert.False(t, boutAppendedByRecordBout([]state.MatchHistoryEntry{ + applied(DoorScore, g), applied(doorKachinukiAdvance, g, state.BoutGroup(3), state.GroupResult), + }, g), "an advance closing bout 2 did not create it") + held := state.MatchHistoryEntry{Door: DoorScore, Changed: []string{g}, Outcomes: map[string]string{g: state.HistoryOutcomeHeld}} + assert.True(t, boutAppendedByRecordBout([]state.MatchHistoryEntry{held, advance}, g), "a held line wrote nothing") +} + +// Review round 5, finding 8: the live bout is the highest numbered position, +// whatever the slice's order, the same rule as the client's lastNumberedBout +// (kachinuki_advisory_model.test.jsx), so an out-of-order log reads the same +// row on both sides. +func TestLastNumberedBout_OutOfOrderLog(t *testing.T) { + rows := []state.SubMatchResult{{Position: 1}, {Position: 3}, {Position: 2}, {Position: state.DaihyosenSubPosition}} + assert.Equal(t, 1, lastNumberedBout(rows), "position 3 is the live bout, not the array tail") + dup := []state.SubMatchResult{{Position: 2, SideA: "first"}, {Position: 2, SideA: "second"}, {Position: 0}} + assert.Equal(t, 0, lastNumberedBout(dup), "the first of two rows sharing a position, as the client picks") + assert.Equal(t, -1, lastNumberedBout([]state.SubMatchResult{{Position: 0}}), "position 0 is not a numbered bout") + assert.Equal(t, -1, lastNumberedBout([]state.SubMatchResult{{Position: state.DaihyosenSubPosition}})) + assert.Equal(t, -1, lastNumberedBout(nil)) +} + +// A Record bout whose winner names neither side records nothing: the advance +// could not read the outcome, so the bout is not marked and no history line is +// written (review round 4, finding 4). +func TestMaybeAdvanceKachinuki_AnUnreadOutcomeIsNotRecorded(t *testing.T) { + for _, bracket := range []bool{false, true} { + name := branchName(bracket) + t.Run(name, func(t *testing.T) { + eng, store, comp := setupKachinukiComp(t, "kachinuki-unread-outcome-"+name, 2, + func(c *state.Competition) { c.Format = state.CompFormatMixed }) + rt := seedRosterTeams(t, store, comp.ID, 2, + []string{"A1", "A2"}, []string{"B1", "B2"}, true, true) + a, b := rt.a, rt.b + bout := rosterBout(1, a[0], b[0], nil, false) + bout.Winner, bout.Decision = "Someone Else", "fought" + matchID := saveRosterMatch(t, store, comp.ID, rt, []state.SubMatchResult{bout}, bracket) + before, err := store.LoadMatchHistory(comp.ID, matchID) + require.NoError(t, err) + + appended, post, err := eng.MaybeAdvanceKachinuki(comp.ID, matchID) + require.NoError(t, err) + assert.False(t, appended) + assert.Nil(t, post) + assert.False(t, storedSubs(t, store, comp.ID, matchID, bracket)[0].Recorded) + after, err := store.LoadMatchHistory(comp.ID, matchID) + require.NoError(t, err) + assert.Len(t, after, len(before), "no history line") + }) + } +} + +// Record bout's mark takes no stamp, so a change to the closed bout stamped +// before Record bout and arriving after it (another device, a queued or +// debounced autosave) applies, and the bout stays recorded (review round 3, +// finding 3). Both shapes: at exhaustion (nothing appended) and with a +// pairing appended. +func TestKachinukiRecordBout_AnEarlierChangeToTheClosedBoutStillApplies(t *testing.T) { + cases := []struct { + name string + slug string + size int + closed int // the position Record bout closes + appended bool + }{ + {name: "exhaustion", slug: "exh", size: 2, closed: 2, appended: false}, + {name: "pairing appended", slug: "app", size: 3, closed: 1, appended: true}, + } + for _, tc := range cases { + for _, bracket := range []bool{false, true} { + name := tc.name + "/" + branchName(bracket) + t.Run(name, func(t *testing.T) { + eng, store, comp := setupKachinukiComp(t, "kachinuki-record-nostamp-"+tc.slug+"-"+branchName(bracket), tc.size, + func(c *state.Competition) { c.Format = state.CompFormatMixed }) + aNames := []string{"A1", "A2", "A3"}[:tc.size] + bNames := []string{"B1", "B2", "B3"}[:tc.size] + rt := seedRosterTeams(t, store, comp.ID, tc.size, aNames, bNames, true, true) + a, b := rt.a, rt.b + bouts := []state.SubMatchResult{rosterBout(1, a[0], b[0], &a[0], false)} + if tc.closed == 2 { + bouts = append(bouts, rosterBout(2, a[0], b[1], &a[0], false)) + } + t0 := serverNowMs() - 60_000 + matchID := saveRosterMatchAt(t, store, comp.ID, rt, bouts, bracket, t0) + group := state.BoutGroup(tc.closed) + + appended, post, err := eng.MaybeAdvanceKachinuki(comp.ID, matchID) + require.NoError(t, err) + require.Equal(t, tc.appended, appended) + require.True(t, post.BoutLog[tc.closed-1].Recorded) + assert.Equal(t, t0, storedGroupStamp(t, store, comp.ID, matchID, bracket, group), + "the recorded mark does not move the closed bout's stamp") + recordedStamp := storedGroupStamp(t, store, comp.ID, matchID, bracket, state.GroupRecorded) + assert.Greater(t, recordedStamp, t0+1_000, "the mark is stamped as its own group, after the point below") + + // A second point on the closed bout, made before Record bout. + sent := append([]state.SubMatchResult(nil), post.BoutLog...) + sent[tc.closed-1].IpponsA = []string{"M", "K"} + sent[tc.closed-1].Recorded = false + require.NoError(t, eng.RecordMatchResult(comp.ID, matchID, &state.MatchResult{ + ID: matchID, SideA: "Ryu", SideAID: rt.aID, SideB: "Tora", SideBID: rt.bID, + Status: state.MatchStatusRunning, ModifiedAt: t0 + 1_000, + SubResults: sent, + Changed: []string{group}, WriteDoor: DoorScore, + })) + + subs := storedSubs(t, store, comp.ID, matchID, bracket) + assert.Equal(t, []string{"M", "K"}, subs[tc.closed-1].IpponsA, "the earlier change applied") + assert.True(t, subs[tc.closed-1].Recorded, "and the bout stays recorded") + assert.Equal(t, recordedStamp, storedGroupStamp(t, store, comp.ID, matchID, bracket, state.GroupRecorded), + "the recorded group keeps its stamp") + history, err := store.LoadMatchHistory(comp.ID, matchID) + require.NoError(t, err) + last := history[len(history)-1] + assert.Equal(t, []string{group}, last.Changed, "the point names its bout only") + assert.Equal(t, state.HistoryOutcomeApplied, last.Outcomes[group]) + }) + } + } +} + +// A draw recorded when both teams had nobody left may be fought on in encho on +// that bout, in a pool as in a knockout (the operator's call, review round 4, +// finding 1): its fighters stay on in the roster read, and the write +// that starts the encho takes the recorded mark off (review round 3, finding +// 4). A write that does not raise the encho keeps it. +func TestKachinukiRecorded_EnchoOnARecordedTieClearsIt(t *testing.T) { + for _, bracket := range []bool{false, true} { + name := branchName(bracket) + t.Run(name, func(t *testing.T) { + eng, store, comp := setupKachinukiComp(t, "kachinuki-recorded-encho-"+name, 2, + func(c *state.Competition) { c.Format = state.CompFormatMixed }) + rt := seedRosterTeams(t, store, comp.ID, 2, + []string{"A1", "A2"}, []string{"B1", "B2"}, true, true) + a, b := rt.a, rt.b + // B1 beat A1, A2 beat B1, then A2 and B2 draw: both teams are out. + tie := rosterBout(3, a[1], b[1], nil, true) + tie.IpponsA, tie.IpponsB = []string{"M"}, []string{"K"} + matchID := saveRosterMatch(t, store, comp.ID, rt, []state.SubMatchResult{ + rosterBout(1, a[0], b[0], &b[0], false), + rosterBout(2, a[1], b[0], &a[1], false), + tie, + }, bracket) + appended, post, err := eng.MaybeAdvanceKachinuki(comp.ID, matchID) + require.NoError(t, err) + require.False(t, appended) + require.True(t, post.BoutLog[2].Recorded) + + got, err := eng.KachinukiRoster(comp.ID, matchID) + require.NoError(t, err) + // In every phase: encho on that bout is the operator's call. + assert.Equal(t, a[1].ID, onID(got.SideA), "a recorded draw as the last bout keeps A2 on") + assert.Equal(t, b[1].ID, onID(got.SideB), "and B2") + + write := func(encho *state.EnchoMetadata, stamp int64) { + sent := append([]state.SubMatchResult(nil), post.BoutLog...) + sent[2].Decision = "" + sent[2].Encho = encho + require.NoError(t, eng.RecordMatchResult(comp.ID, matchID, &state.MatchResult{ + ID: matchID, SideA: "Ryu", SideAID: rt.aID, SideB: "Tora", SideBID: rt.bID, + Status: state.MatchStatusRunning, ModifiedAt: stamp, + SubResults: sent, + Changed: []string{state.BoutGroup(3)}, WriteDoor: DoorScore, + })) + } + recordedStamp := storedGroupStamp(t, store, comp.ID, matchID, bracket, state.GroupRecorded) + // An Encho tap made BEFORE Record bout and arriving after it: in + // stamp order the bout was recorded after that overtime, so the + // mark stays while the overtime itself, the bout's own change, lands. + write(&state.EnchoMetadata{PeriodCount: 1}, recordedStamp-1) + older := storedSubs(t, store, comp.ID, matchID, bracket)[2] + require.True(t, older.Recorded, "an overtime older than the mark does not take it off") + require.True(t, older.Encho.On(), "the overtime itself lands") + assert.Equal(t, recordedStamp, storedGroupStamp(t, store, comp.ID, matchID, bracket, state.GroupRecorded)) + + // A write that changes the bout without raising its encho keeps it. + write(nil, recordedStamp+1_000) + require.True(t, storedSubs(t, store, comp.ID, matchID, bracket)[2].Recorded) + + // The Encho tap: the bout is fought again. + enchoStamp := recordedStamp + 2_000 + write(&state.EnchoMetadata{PeriodCount: 1}, enchoStamp) + subs := storedSubs(t, store, comp.ID, matchID, bracket) + assert.False(t, subs[2].Recorded, "starting encho on the recorded bout takes the mark off") + assert.True(t, subs[2].Encho.On()) + assert.Equal(t, enchoStamp, storedGroupStamp(t, store, comp.ID, matchID, bracket, state.GroupRecorded), + "taking the mark off is a change of the recorded group at the write's stamp") + history, err := store.LoadMatchHistory(comp.ID, matchID) + require.NoError(t, err) + last := history[len(history)-1] + assert.ElementsMatch(t, []string{state.BoutGroup(3), state.GroupRecorded}, last.Changed, + "the history line names the recorded group") + assert.Equal(t, state.HistoryOutcomeApplied, last.Outcomes[state.GroupRecorded]) + + got, err = eng.KachinukiRoster(comp.ID, matchID) + require.NoError(t, err) + assert.Equal(t, a[1].ID, onID(got.SideA), "both fighters are on in encho") + assert.Equal(t, b[1].ID, onID(got.SideB)) + }) + } +} + +// A write that changes a recorded bout's RESULT takes the mark off, as an +// encho does: the bout Record bout closed is no longer the one on the row +// (review round 8, finding 2). A recorded tie with both sides out scored to a +// one-point lead is fought again, both fighters on, and nothing closes. +func TestKachinukiRecorded_APointOnARecordedTieClearsIt(t *testing.T) { + for _, bracket := range []bool{false, true} { + name := branchName(bracket) + t.Run(name, func(t *testing.T) { + eng, store, comp := setupKachinukiComp(t, "kachinuki-recorded-tie-point-"+name, 2, + func(c *state.Competition) { c.Format = state.CompFormatMixed }) + rt := seedRosterTeams(t, store, comp.ID, 2, + []string{"A1", "A2"}, []string{"B1", "B2"}, true, true) + a, b := rt.a, rt.b + tie := rosterBout(3, a[1], b[1], nil, true) + tie.IpponsA, tie.IpponsB = []string{"M"}, []string{"K"} + matchID := saveRosterMatch(t, store, comp.ID, rt, []state.SubMatchResult{ + rosterBout(1, a[0], b[0], &b[0], false), + rosterBout(2, a[1], b[0], &a[1], false), + tie, + }, bracket) + appended, post, err := eng.MaybeAdvanceKachinuki(comp.ID, matchID) + require.NoError(t, err) + require.False(t, appended) + require.True(t, post.BoutLog[2].Recorded) + recordedStamp := storedGroupStamp(t, store, comp.ID, matchID, bracket, state.GroupRecorded) + + // The autosave of a point scored on the recorded tie: A2 leads. + sent := append([]state.SubMatchResult(nil), post.BoutLog...) + sent[2].IpponsA = []string{"M", "D"} + sent[2].Winner, sent[2].WinnerMemberID, sent[2].Decision = a[1].Name, a[1].ID, "" + pointStamp := recordedStamp + 1_000 + require.NoError(t, eng.RecordMatchResult(comp.ID, matchID, &state.MatchResult{ + ID: matchID, SideA: "Ryu", SideAID: rt.aID, SideB: "Tora", SideBID: rt.bID, + Status: state.MatchStatusRunning, ModifiedAt: pointStamp, + SubResults: sent, + Changed: []string{state.BoutGroup(3)}, WriteDoor: DoorScore, + })) + subs := storedSubs(t, store, comp.ID, matchID, bracket) + assert.False(t, subs[2].Recorded, "a point on the recorded tie takes the mark off") + assert.Equal(t, []string{"M", "D"}, subs[2].IpponsA, "the point itself lands") + assert.Equal(t, pointStamp, storedGroupStamp(t, store, comp.ID, matchID, bracket, state.GroupRecorded)) + history, err := store.LoadMatchHistory(comp.ID, matchID) + require.NoError(t, err) + last := history[len(history)-1] + assert.ElementsMatch(t, []string{state.BoutGroup(3), state.GroupRecorded}, last.Changed, + "the history line names the recorded group") + assert.Equal(t, state.HistoryOutcomeApplied, last.Outcomes[state.GroupRecorded]) + + got, err := eng.KachinukiRoster(comp.ID, matchID) + require.NoError(t, err) + assert.Equal(t, a[1].ID, onID(got.SideA), "both fighters are on while the bout is fought") + assert.Equal(t, b[1].ID, onID(got.SideB), "B2 is not taken off by an unrecorded lead") + }) + } +} + +// A correction that keeps a recorded bout's result (another waza letter, the +// same winner) keeps the mark; one that hands the bout to the other side +// takes it off, so the operator records the bout again. An older write and a +// held bout change nothing (review round 8, finding 2). +func TestKachinukiRecorded_AResultCorrectionClearsItAndAWazaCorrectionKeepsIt(t *testing.T) { + for _, bracket := range []bool{false, true} { + name := branchName(bracket) + t.Run(name, func(t *testing.T) { + eng, store, comp := setupKachinukiComp(t, "kachinuki-recorded-correction-"+name, 2, + func(c *state.Competition) { c.Format = state.CompFormatMixed }) + rt := seedRosterTeams(t, store, comp.ID, 2, + []string{"A1", "A2"}, []string{"B1", "B2"}, true, true) + a, b := rt.a, rt.b + win := rosterBout(2, a[0], b[1], &a[0], false) + win.IpponsA = []string{"M"} + matchID := saveRosterMatch(t, store, comp.ID, rt, []state.SubMatchResult{ + rosterBout(1, a[0], b[0], &a[0], false), + win, + }, bracket) + _, post, err := eng.MaybeAdvanceKachinuki(comp.ID, matchID) + require.NoError(t, err) + require.True(t, post.BoutLog[1].Recorded) + recordedStamp := storedGroupStamp(t, store, comp.ID, matchID, bracket, state.GroupRecorded) + boutStamp := storedGroupStamp(t, store, comp.ID, matchID, bracket, state.BoutGroup(2)) + + write := func(edit func(*state.SubMatchResult), stamp int64) error { + sent := append([]state.SubMatchResult(nil), storedSubs(t, store, comp.ID, matchID, bracket)...) + edit(&sent[1]) + return eng.RecordMatchResult(comp.ID, matchID, &state.MatchResult{ + ID: matchID, SideA: "Ryu", SideAID: rt.aID, SideB: "Tora", SideBID: rt.bID, + Status: state.MatchStatusRunning, ModifiedAt: stamp, + SubResults: sent, + Changed: []string{state.BoutGroup(2)}, WriteDoor: DoorScore, + }) + } + toB := func(s *state.SubMatchResult) { + s.IpponsA, s.IpponsB = nil, []string{"K"} + s.Winner, s.WinnerMemberID, s.Decision = b[1].Name, b[1].ID, "fought" + } + + // Another waza letter for the same winner: the result is unchanged. + require.NoError(t, write(func(s *state.SubMatchResult) { s.IpponsA, s.Decision = []string{"K"}, "" }, recordedStamp+1_000)) + subs := storedSubs(t, store, comp.ID, matchID, bracket) + require.Equal(t, []string{"K"}, subs[1].IpponsA) + assert.True(t, subs[1].Recorded, "a waza correction keeps the mark") + assert.Equal(t, recordedStamp, storedGroupStamp(t, store, comp.ID, matchID, bracket, state.GroupRecorded)) + + // A bout change older than the bout's own stamp is held: nothing changes. + require.ErrorIs(t, write(toB, boutStamp-1), ErrMatchSuperseded) + subs = storedSubs(t, store, comp.ID, matchID, bracket) + assert.Equal(t, a[0].ID, subs[1].WinnerMemberID, "the held bout did not land") + assert.True(t, subs[1].Recorded, "a held bout changes nothing") + + // The other side wins: the bout is recorded again by the operator. + flip := recordedStamp + 2_000 + require.NoError(t, write(toB, flip)) + subs = storedSubs(t, store, comp.ID, matchID, bracket) + assert.Equal(t, b[1].ID, subs[1].WinnerMemberID) + assert.False(t, subs[1].Recorded, "a result correction takes the mark off") + assert.Equal(t, flip, storedGroupStamp(t, store, comp.ID, matchID, bracket, state.GroupRecorded)) + history, err := store.LoadMatchHistory(comp.ID, matchID) + require.NoError(t, err) + assert.ElementsMatch(t, []string{state.BoutGroup(2), state.GroupRecorded}, history[len(history)-1].Changed) + }) + } +} + +func TestBoutResultChanged(t *testing.T) { + row := func(winner, winnerID, decision string) state.SubMatchResult { + return state.SubMatchResult{Position: 1, SideA: "Kenji", SideAMemberID: "ka", SideB: "Taro", SideBMemberID: "tb", + Winner: winner, WinnerMemberID: winnerID, Decision: decision} + } + cases := []struct { + name string + stored, row state.SubMatchResult + changed bool + }{ + {"same winner, fought vs none", row("Kenji", "ka", "fought"), row("Kenji", "ka", ""), false}, + {"other winner", row("Kenji", "ka", "fought"), row("Taro", "tb", "fought"), true}, + {"draw to a lead", row("", "", "hikiwake"), row("Kenji", "ka", ""), true}, + {"draw stays a draw", row("", "", "hikiwake"), row("", "", "hikiwake"), false}, + {"win to no result", row("Kenji", "ka", "fought"), row("", "", ""), true}, + {"fought to fusensho, same winner", row("Kenji", "ka", "fought"), row("Kenji", "ka", "fusensho"), true}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + assert.Equal(t, tc.changed, boutResultChanged(tc.stored, tc.row, "Ryu", "Tora")) + }) + } +} + +// storedSubs reads a match's bout log back from whichever store holds it. +func storedSubs(t *testing.T, store *state.Store, compID, matchID string, bracket bool) []state.SubMatchResult { + t.Helper() + if !bracket { + pms, err := store.LoadPoolMatches(compID) + require.NoError(t, err) + for _, m := range pms { + if m.ID == matchID { + return m.SubResults + } + } + t.Fatalf("pool match %s not found", matchID) + } + br, err := store.LoadBracket(compID) + require.NoError(t, err) + bm := br.MatchByID(matchID) + require.NotNil(t, bm) + return bm.SubResults +} + +// A side with no lineup in force reads no fighters at all: the bout log knows +// only who has fought, so a count from it would be false. Its fighter on is +// still named. +func TestKachinukiRoster_ASideWithNoLineupReadsNoFighters(t *testing.T) { + eng, store, comp := setupKachinukiComp(t, "kachinuki-roster-no-lineup", 3, + func(c *state.Competition) { c.Format = state.CompFormatMixed }) + rt := seedRosterTeams(t, store, comp.ID, 3, + []string{"A1", "A2", "A3"}, []string{"B1", "B2", "B3"}, true, false) + require.NoError(t, store.SavePoolMatches(comp.ID, []state.MatchResult{{ + ID: "Pool A-0", SideA: "Ryu", SideAID: rt.aID, SideB: "Tora", SideBID: rt.bID, Status: state.MatchStatusRunning, + SubResults: []state.SubMatchResult{rosterBout(1, rt.a[0], rt.b[0], nil, false)}, + }})) + + got, err := eng.KachinukiRoster(comp.ID, "Pool A-0") + require.NoError(t, err) + assert.True(t, got.SideA.LineupFound) + assert.Equal(t, memberIDs(rt.a[1:]...), rosterIDs(got.SideA)) + assert.False(t, got.SideB.LineupFound) + assert.Equal(t, rt.b[0].ID, onID(got.SideB)) + assert.NotNil(t, got.SideB.Remaining, "the wire carries [] rather than null") + assert.Empty(t, got.SideB.Remaining) +} + +// A lineup in force that fields nobody (the match's own empty lineup, which +// shows none) is no lineup for the read: never "last fighter" off an empty +// list. +func TestKachinukiRoster_AnEmptyLineupInForceIsNoLineup(t *testing.T) { + eng, store, comp := setupKachinukiComp(t, "kachinuki-roster-empty-lineup", 3, + func(c *state.Competition) { c.Format = state.CompFormatMixed }) + rt := seedRosterTeams(t, store, comp.ID, 3, + []string{"A1", "A2", "A3"}, []string{"B1", "B2", "B3"}, true, true) + require.NoError(t, store.SavePoolMatches(comp.ID, []state.MatchResult{{ + ID: "Pool A-0", SideA: "Ryu", SideAID: rt.aID, SideB: "Tora", SideBID: rt.bID, Status: state.MatchStatusRunning, + SubResults: []state.SubMatchResult{rosterBout(1, rt.a[0], rt.b[0], nil, false)}, + }})) + require.NoError(t, store.SetTeamLineup(comp.ID, domain.TeamLineup{ + TeamID: rt.bID, MatchID: "Pool A-0", Positions: map[domain.Position]string{}, + }, 3)) + + got, err := eng.KachinukiRoster(comp.ID, "Pool A-0") + require.NoError(t, err) + assert.True(t, got.SideA.LineupFound) + assert.False(t, got.SideB.LineupFound, "the empty own lineup fields nobody") + assert.Empty(t, got.SideB.Remaining) + assert.Equal(t, rt.b[0].ID, onID(got.SideB), "the fighter on is still named") +} + +// No numbered bout yet: nobody is on, and the whole lineup is the queue. +func TestKachinukiRoster_NoBoutYet(t *testing.T) { + eng, store, comp := setupKachinukiComp(t, "kachinuki-roster-no-bout", 2, + func(c *state.Competition) { c.Format = state.CompFormatMixed }) + rt := seedRosterTeams(t, store, comp.ID, 2, []string{"A1", "A2"}, []string{"B1", "B2"}, true, true) + require.NoError(t, store.SavePoolMatches(comp.ID, []state.MatchResult{{ + ID: "Pool A-0", SideA: "Ryu", SideAID: rt.aID, SideB: "Tora", SideBID: rt.bID, Status: state.MatchStatusRunning, + }})) + + got, err := eng.KachinukiRoster(comp.ID, "Pool A-0") + require.NoError(t, err) + assert.Nil(t, got.SideA.On) + assert.Nil(t, got.SideB.On) + assert.Equal(t, memberIDs(rt.a...), rosterIDs(got.SideA)) + assert.Equal(t, memberIDs(rt.b...), rosterIDs(got.SideB)) +} + +func TestKachinukiRoster_Refusals(t *testing.T) { + t.Run("not kachinuki", func(t *testing.T) { + eng, store, _ := setupTestEngine(t) + require.NoError(t, store.SaveCompetition(&state.Competition{ID: "fixed", Kind: "team", TeamSize: 3})) + _, err := eng.KachinukiRoster("fixed", "Pool A-0") + assert.ErrorIs(t, err, ErrNotKachinuki) + }) + t.Run("unknown match", func(t *testing.T) { + eng, _, comp := setupKachinukiComp(t, "kachinuki-roster-unknown", 3) + _, err := eng.KachinukiRoster(comp.ID, "Pool Z-9") + var notFound *NotFoundError + require.ErrorAs(t, err, ¬Found) + assert.Equal(t, "match Pool Z-9 not found", notFound.Error(), "named as a match, not a competition") + }) + t.Run("unknown competition", func(t *testing.T) { + eng, _, _ := setupTestEngine(t) + _, err := eng.KachinukiRoster("nope", "Pool A-0") + var notFound *NotFoundError + assert.ErrorAs(t, err, ¬Found, "the handler answers it 404") + }) + // The read answers an unreadable lineups.yaml with the error, never with + // "no lineup": the advance alone degrades to the bout log there. + t.Run("lineups that cannot be read", func(t *testing.T) { + eng, store, dir := setupTestEngine(t) + comp := &state.Competition{ID: "kachinuki-roster-unreadable", Kind: "team", TeamSize: 2, + TeamMatchType: state.TeamMatchTypeKachinuki, Format: state.CompFormatMixed} + require.NoError(t, store.SaveCompetition(comp)) + require.NoError(t, store.SavePoolMatches(comp.ID, []state.MatchResult{{ + ID: "Pool A-0", SideA: "Ryu", SideB: "Tora", Status: state.MatchStatusRunning, + }})) + // A directory where the file should be: every read fails, even as root. + require.NoError(t, os.Mkdir(filepath.Join(dir, "competitions", comp.ID, "lineups.yaml"), 0o755)) + _, err := eng.KachinukiRoster(comp.ID, "Pool A-0") + assert.Error(t, err) + var notFound *NotFoundError + assert.False(t, errors.As(err, ¬Found), "an unreadable file is no not-found") + }) +} + +// The consistency pin: the advisory read and the advance build the queues +// through the same call and take retirements out through the same filter, so +// the fighter the read names as the losing side's NEXT is the one the advance +// appends once the fighter on loses, and after the append each side's fighter +// on is the appended pairing's. Run on a pool match and a bracket match, with +// lineups (the advisory names the queue) and without (the advance still pairs +// from the bout log, and the advisory reads no fighters). +func TestKachinukiRoster_NamesTheFighterTheAdvanceAppends(t *testing.T) { + type place struct { + name string + bracket bool + } + for _, p := range []place{{"pool", false}, {"bracket", true}} { + t.Run(p.name+" with lineups", func(t *testing.T) { + eng, store, comp := setupKachinukiComp(t, "kachinuki-roster-pin-"+p.name, 3, + func(c *state.Competition) { c.Format = state.CompFormatMixed }) + rt := seedRosterTeams(t, store, comp.ID, 3, + []string{"A1", "A2", "A3"}, []string{"B1", "B2", "B3"}, true, true) + // Bout 2 live: A1 against B2, A1 losing it. + bouts := []state.SubMatchResult{ + rosterBout(1, rt.a[0], rt.b[0], &rt.a[0], false), + rosterBout(2, rt.a[0], rt.b[1], nil, false), + } + matchID := saveRosterMatch(t, store, comp.ID, rt, bouts, p.bracket) + + before, err := eng.KachinukiRoster(comp.ID, matchID) + require.NoError(t, err) + require.NotEmpty(t, before.SideA.Remaining) + nextA := before.SideA.Remaining[0].MemberID + assert.Equal(t, rt.a[0].ID, onID(before.SideA)) + assert.Equal(t, rt.b[1].ID, onID(before.SideB)) + + // B2 leads bout 2: the row carries a winner before Record bout, but + // the bout is not recorded, so both fighters are still on and the + // same fighter is next. + bouts[1] = rosterBout(2, rt.a[0], rt.b[1], &rt.b[1], false) + matchID = saveRosterMatch(t, store, comp.ID, rt, bouts, p.bracket) + decided, err := eng.KachinukiRoster(comp.ID, matchID) + require.NoError(t, err) + assert.Equal(t, rt.a[0].ID, onID(decided.SideA), "bout 2 is not recorded: A1 is still on") + assert.Equal(t, rt.b[1].ID, onID(decided.SideB), "B2 is on") + require.NotEmpty(t, decided.SideA.Remaining) + assert.Equal(t, nextA, decided.SideA.Remaining[0].MemberID) + changed, post, err := eng.MaybeAdvanceKachinuki(comp.ID, matchID) + require.NoError(t, err) + require.True(t, changed) + assert.True(t, post.BoutLog[1].Recorded, "Record bout recorded bout 2 in the write that appended bout 3") + appended := post.BoutLog[len(post.BoutLog)-1] + assert.False(t, appended.Recorded) + assert.Equal(t, nextA, appended.SideAMemberID, "the read named the fighter the advance sent in") + assert.Equal(t, rt.a[1].ID, appended.SideAMemberID) + + after, err := eng.KachinukiRoster(comp.ID, matchID) + require.NoError(t, err) + assert.Equal(t, appended.SideAMemberID, onID(after.SideA)) + assert.Equal(t, appended.SideBMemberID, onID(after.SideB), "the fighter who stayed on") + assert.Equal(t, memberIDs(rt.a[2:]...), rosterIDs(after.SideA)) + assert.Equal(t, memberIDs(rt.b[2:]...), rosterIDs(after.SideB)) + }) + t.Run(p.name+" without lineups", func(t *testing.T) { + eng, store, comp := setupKachinukiComp(t, "kachinuki-roster-pin-bare-"+p.name, 3, + func(c *state.Competition) { c.Format = state.CompFormatMixed }) + rt := seedRosterTeams(t, store, comp.ID, 3, + []string{"A1", "A2", "A3"}, []string{"B1", "B2", "B3"}, false, false) + // The bout log's queue holds only the fighters it has seen: B1 + // beat A1, then A2 beat B2, so the advance pairs A2 (who stayed + // on) with B1, the one B fighter seen and not retired. + bouts := []state.SubMatchResult{ + rosterBout(1, rt.a[0], rt.b[0], &rt.b[0], false), + rosterBout(2, rt.a[1], rt.b[1], &rt.a[1], false), + } + matchID := saveRosterMatch(t, store, comp.ID, rt, bouts, p.bracket) + + changed, post, err := eng.MaybeAdvanceKachinuki(comp.ID, matchID) + require.NoError(t, err) + require.True(t, changed, "the advance still pairs from the bout log") + appended := post.BoutLog[len(post.BoutLog)-1] + assert.Equal(t, rt.a[1].ID, appended.SideAMemberID) + assert.Equal(t, rt.b[0].ID, appended.SideBMemberID) + + got, err := eng.KachinukiRoster(comp.ID, matchID) + require.NoError(t, err) + assert.False(t, got.SideA.LineupFound) + assert.False(t, got.SideB.LineupFound) + assert.Empty(t, got.SideA.Remaining) + assert.Empty(t, got.SideB.Remaining) + assert.Equal(t, rt.a[1].ID, onID(got.SideA), "the appended pairing is on") + assert.Equal(t, rt.b[0].ID, onID(got.SideB)) + }) + } +} + +// saveRosterMatch stores one running kachinuki encounter between the two +// teams, as a pool match or as the final of a one-round bracket, and returns +// its id. +func saveRosterMatch(t *testing.T, store *state.Store, compID string, rt rosterTeams, bouts []state.SubMatchResult, bracket bool) string { + t.Helper() + return saveRosterMatchAt(t, store, compID, rt, bouts, bracket, 0) +} + +// saveRosterMatchAt is saveRosterMatch with the match stamped at modifiedAt. +func saveRosterMatchAt(t *testing.T, store *state.Store, compID string, rt rosterTeams, bouts []state.SubMatchResult, bracket bool, modifiedAt int64) string { + t.Helper() + bouts = append([]state.SubMatchResult(nil), bouts...) + if !bracket { + require.NoError(t, store.SavePoolMatches(compID, []state.MatchResult{{ + ID: "Pool A-0", SideA: "Ryu", SideAID: rt.aID, SideB: "Tora", SideBID: rt.bID, + Status: state.MatchStatusRunning, SubResults: bouts, ModifiedAt: modifiedAt, + }})) + return "Pool A-0" + } + require.NoError(t, store.SaveBracket(compID, &state.Bracket{Rounds: [][]state.BracketMatch{{{ + ID: "r0-m0", SideA: "Ryu", SideAID: rt.aID, SideB: "Tora", SideBID: rt.bID, + Status: state.MatchStatusRunning, SubResults: bouts, ModifiedAt: modifiedAt, + }}}})) + return "r0-m0" +} diff --git a/internal/engine/kachinuki_roster_verdict_test.go b/internal/engine/kachinuki_roster_verdict_test.go new file mode 100644 index 000000000..7b0c02f9c --- /dev/null +++ b/internal/engine/kachinuki_roster_verdict_test.go @@ -0,0 +1,116 @@ +package engine + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/gitrgoliveira/bracket-creator/internal/state" +) + +// The roster read answers the advance's own verdict (decideKachinukiAdvance): +// NextPairing is what Record bout would then do, and Exhausted names a side a +// recorded win left with nobody, on a lineup's knowledge only. Each case reads +// the roster, then taps Record bout, and pins the read against what the tap +// did, on both branches. +func TestKachinukiRoster_AnswersTheAdvancesVerdict(t *testing.T) { + cases := []struct { + name string + size int + aNames, bNames []string + lineupA, lineupB bool + // bouts builds the stored log from the seeded teams' members. + bouts func(rt rosterTeams) []state.SubMatchResult + // recordFirst taps Record bout once before the read, so the last bout + // is stored as recorded. + recordFirst bool + wantNext bool + wantExhaustedA, wantExhaustedB bool + }{ + { + name: "an unrecorded lead with fighters behind appends", size: 3, + aNames: []string{"A1", "A2", "A3"}, bNames: []string{"B1", "B2", "B3"}, lineupA: true, lineupB: true, + bouts: func(rt rosterTeams) []state.SubMatchResult { + return []state.SubMatchResult{rosterBout(1, rt.a[0], rt.b[0], &rt.a[0], false)} + }, + wantNext: true, + }, + { + name: "a recorded win that leaves the loser's lineup with nobody", size: 2, + aNames: []string{"A1", "A2"}, bNames: []string{"B1", "B2"}, lineupA: true, lineupB: true, + bouts: func(rt rosterTeams) []state.SubMatchResult { + return []state.SubMatchResult{ + rosterBout(1, rt.a[0], rt.b[0], &rt.a[0], false), + rosterBout(2, rt.a[0], rt.b[1], &rt.a[0], false), + } + }, + recordFirst: true, wantExhaustedB: true, + }, + { + name: "an unrecorded win at exhaustion is no result yet", size: 2, + aNames: []string{"A1", "A2"}, bNames: []string{"B1", "B2"}, lineupA: true, lineupB: true, + bouts: func(rt rosterTeams) []state.SubMatchResult { + return []state.SubMatchResult{ + rosterBout(1, rt.a[0], rt.b[0], &rt.a[0], false), + rosterBout(2, rt.a[0], rt.b[1], &rt.a[0], false), + } + }, + }, + { + name: "a recorded win over a side with no lineup exhausts nobody", size: 2, + aNames: []string{"A1", "A2"}, bNames: []string{"B1", "B2"}, lineupA: true, lineupB: false, + bouts: func(rt rosterTeams) []state.SubMatchResult { + return []state.SubMatchResult{ + rosterBout(1, rt.a[0], rt.b[0], &rt.a[0], false), + rosterBout(2, rt.a[0], rt.b[1], &rt.a[0], false), + } + }, + recordFirst: true, + }, + { + name: "a recorded draw with both sides out exhausts nobody", size: 2, + aNames: []string{"A1", "A2"}, bNames: []string{"B1", "B2"}, lineupA: true, lineupB: true, + bouts: func(rt rosterTeams) []state.SubMatchResult { + return []state.SubMatchResult{ + rosterBout(1, rt.a[0], rt.b[0], nil, true), + rosterBout(2, rt.a[1], rt.b[1], nil, true), + } + }, + recordFirst: true, + }, + { + name: "a bout with no outcome appends nothing", size: 2, + aNames: []string{"A1", "A2"}, bNames: []string{"B1", "B2"}, lineupA: true, lineupB: true, + bouts: func(rt rosterTeams) []state.SubMatchResult { + return []state.SubMatchResult{rosterBout(1, rt.a[0], rt.b[0], nil, false)} + }, + }, + } + for _, tc := range cases { + for _, bracket := range []bool{false, true} { + t.Run(tc.name+"/"+branchName(bracket), func(t *testing.T) { + eng, store, comp := setupKachinukiComp(t, "kachinuki-roster-verdict", tc.size, + func(c *state.Competition) { c.Format = state.CompFormatMixed }) + rt := seedRosterTeams(t, store, comp.ID, tc.size, tc.aNames, tc.bNames, tc.lineupA, tc.lineupB) + matchID := saveRosterMatch(t, store, comp.ID, rt, tc.bouts(rt), bracket) + if tc.recordFirst { + appended, _, err := eng.MaybeAdvanceKachinuki(comp.ID, matchID) + require.NoError(t, err) + require.False(t, appended, "the fixture's recorded bout stays last") + } + + got, err := eng.KachinukiRoster(comp.ID, matchID) + require.NoError(t, err) + assert.Equal(t, tc.wantNext, got.NextPairing, "nextPairing") + assert.Equal(t, tc.wantExhaustedA, got.SideA.Exhausted, "sideA.exhausted") + assert.Equal(t, tc.wantExhaustedB, got.SideB.Exhausted, "sideB.exhausted") + + // The pin: the read's verdict is what Record bout then does. + appended, _, err := eng.MaybeAdvanceKachinuki(comp.ID, matchID) + require.NoError(t, err) + assert.Equal(t, got.NextPairing, appended, "the read said what Record bout does") + }) + } + } +} diff --git a/internal/engine/kachinuki_test.go b/internal/engine/kachinuki_test.go index 79cbb2bb6..f58139f5b 100644 --- a/internal/engine/kachinuki_test.go +++ b/internal/engine/kachinuki_test.go @@ -409,6 +409,7 @@ func TestAdvanceKachinukiUnrecognizedOutcome(t *testing.T) { }) assert.Nil(t, res.Next) assert.False(t, res.MatchEnded) + assert.True(t, res.OutcomeUnread) } // TestDescribeKachinukiResult smoke-tests the stringer (used by handler logs). @@ -933,7 +934,8 @@ func TestKachinukiRemainingRoster_MatchesALineupByTheSidesParticipantID(t *testi t.Run("a lineup stored under the id is the side's, though one under its name sits beside it", func(t *testing.T) { parent := &state.MatchResult{ID: "P1-0", SideA: "Ryu", SideAID: "red-id", SideB: "Tora", SideBID: "white-id", SubResults: bouts} - remainingA, remainingB, ok := kachinukiRemainingRoster(comp, parent, ruleOver(lineups, nil, nil)) + remainingA, remainingB, foundA, foundB := kachinukiRemainingRoster(comp, parent, ruleOver(lineups, nil, nil)) + ok := foundA || foundB require.True(t, ok, "Red's lineup resolves") assert.Equal(t, fighters("R-1", "R-2", "R-3"), remainingA) @@ -943,7 +945,8 @@ func TestKachinukiRemainingRoster_MatchesALineupByTheSidesParticipantID(t *testi t.Run("a side with no id has no lineup, whatever is stored", func(t *testing.T) { parent := &state.MatchResult{ID: "P1-0", SideA: "Ryu", SideB: "Tora", SubResults: bouts} - _, _, ok := kachinukiRemainingRoster(comp, parent, ruleOver(lineups, nil, nil)) + _, _, foundA, foundB := kachinukiRemainingRoster(comp, parent, ruleOver(lineups, nil, nil)) + ok := foundA || foundB assert.False(t, ok, "no side carries an id, so no lineup resolves") }) @@ -3968,9 +3971,10 @@ func TestKachinukiRemainingRoster_BoutLogBranchKeepsANamesakeWithADistinctID(t * }, } - // The third return is "a lineup resolved it", which is false here by - // construction; the roster itself is what this pins. - remainingA, _, _ := kachinukiRemainingRoster(comp, parent, &lineupRule{}) + // The third and fourth returns say whether a lineup resolved side A and + // side B, both false here by construction; the roster itself is what + // this pins. + remainingA, _, _, _ := kachinukiRemainingRoster(comp, parent, &lineupRule{}) ids := make([]string, 0, len(remainingA)) for _, f := range remainingA { @@ -3995,7 +3999,8 @@ func TestKachinukiRemainingRoster_BoutLogBranchTakesEachSidesOwnColumn(t *testin }, } - remainingA, remainingB, ok := kachinukiRemainingRoster(comp, parent, &lineupRule{}) + remainingA, remainingB, foundA, foundB := kachinukiRemainingRoster(comp, parent, &lineupRule{}) + ok := foundA || foundB assert.False(t, ok, "no lineup resolved it") assert.Equal(t, fighters("A-1"), remainingA) @@ -4087,3 +4092,39 @@ func TestMaybeAdvanceKachinuki_AdvancesOffABoutFoughtEntirelyByNumber(t *testing assert.Equal(t, "red-1", post.BoutLog[1].SideAMemberID, "the winner stays on, by id") assert.Equal(t, "white-2", post.BoutLog[1].SideBMemberID, "the next White member comes in, by id") } + +// kachinukiRemainingRoster is a TEST helper: it composes the advance's own +// queues (kachinukiRosterQueues) with RetiredPlayersFromBoutLog the way the +// advance does, so the tests below pin that composition. It derives the +// remaining un-retired roster per side +// for a team match. Returns (sideA, sideB, foundA, foundB): found is per +// side, true when that side's roster was resolved from a saved TeamLineup and +// false when it fell back to the bout-log-only heuristic. The advance logs +// foundA || foundB. The queues come from kachinukiRosterQueues, which the +// advisory read (KachinukiRoster) builds from too. +// +// A lineup in force that fields nobody (a match's own empty lineup, +// positions: {}) is FOUND here and yields an empty queue, so the advance reads +// that side as exhausted and appends nothing; the advisory read instead +// reports such a side as having no lineup (see KachinukiRosterSide). +// +// Priority per side (GAP 1 / GAP 2a): +// 1. The lineup in force for the side's team at this match (see +// lineup_in_force.go: the match's own lineup, else the one the team +// carries from its previous match or round), matched by the side's +// participant id; a side with no id has none. +// 2. Bout-log-only heuristic (anyone who appeared in a bout, minus retired). +// +// The full ordered roster (from lineup.OrderedMembers, bc-tmid pass 2) is +// filtered by IsMemberRetired against RetiredPlayersFromBoutLog's sets to +// produce the remaining queue, so a lineup-resolved fighter's member id +// (when the slot has one) rather than their possibly-stale name decides +// whether they have retired -- falling back to the name only where the +// retiring bout row carried no id to match against and the name belongs to +// one member of this roster alone. See IsMemberRetired for why both tiers +// are needed and what the gate protects. +func kachinukiRemainingRoster(comp *state.Competition, parent *state.MatchResult, rule *lineupRule) (remainingA, remainingB []kachinukiFighter, foundA, foundB bool) { + queueA, queueB := kachinukiRosterQueues(comp, parent, rule) + retiredA, retiredB := RetiredPlayersFromBoutLog(parent.SubResults, parent.SideA, parent.SideB) + return queueA.remaining(retiredA), queueB.remaining(retiredB), queueA.found, queueB.found +} diff --git a/internal/engine/lineup_in_force.go b/internal/engine/lineup_in_force.go index 4c91a5170..6ca86e082 100644 --- a/internal/engine/lineup_in_force.go +++ b/internal/engine/lineup_in_force.go @@ -197,6 +197,19 @@ func (e *Engine) lineupRuleOrNone(caller, compID string, knockout bool, poolMatc return newLineupRuleFrom(lineups, poolMatches, bracket, knockout) } +// readLineupRule calls read with the rule built like lineupRuleOrNone's, over +// the stored lineups where they are cached (ReadTeamLineups, no copy), and +// hands back a lineups.yaml that cannot be read as an error. read must not +// modify a lineup the rule holds, nor keep the rule after it returns. The +// kachinuki roster read (KachinukiRoster) uses it: a read that answered "no +// lineup" over a file it could not read would tell the operator a side has no +// lineup when it may. +func (e *Engine) readLineupRule(compID string, knockout bool, poolMatches []state.MatchResult, bracket *state.Bracket, read func(rule *lineupRule)) error { + return e.store.ReadTeamLineups(compID, func(lineups map[string]domain.TeamLineup) { + read(newLineupRuleFrom(lineups, poolMatches, bracket, knockout)) + }) +} + // LineupInForce returns the lineup the team with participant id teamID fields // at match matchID, and where it was saved. Found is false when the team has // no saved lineup that applies. The lineup is a copy of the stored one. diff --git a/internal/engine/match_history.go b/internal/engine/match_history.go index 67051c2eb..5fc955079 100644 --- a/internal/engine/match_history.go +++ b/internal/engine/match_history.go @@ -31,6 +31,10 @@ const ( doorOverride = "override-winner" doorEngi = "engi" doorKachinukiAdvance = "kachinuki-advance" + // doorKachinukiRecord names Record bout when it appends no next pairing + // (a side has nobody left): the write marks the bout recorded and nothing + // else (bc-kfnl). An appending Record bout stays doorKachinukiAdvance. + doorKachinukiRecord = "kachinuki-record" doorKachinukiRemove = "kachinuki-remove-bout" doorDownstreamReopen = "downstream-reopen" // doorReseat names the line a match gets when a bracket door gave one of diff --git a/internal/engine/match_merge.go b/internal/engine/match_merge.go index 48b9dcf35..3544e93d7 100644 --- a/internal/engine/match_merge.go +++ b/internal/engine/match_merge.go @@ -144,7 +144,9 @@ func effectiveChangedGroups(stored, incoming *state.MatchResult, nilSubsClear bo changed = defaultChangedGroups(stored, incoming, nilSubsClear) } else { for _, g := range incoming.Changed { - if state.ValidGroup(g) && !slices.Contains(changed, g) { + // A server group (the recorded marks) is never a write's to name: + // the merge alone changes it, below (mergeHold.unrecordReopenedBouts). + if state.ValidGroup(g) && !state.IsServerGroup(g) && !slices.Contains(changed, g) { changed = append(changed, g) } } @@ -290,6 +292,10 @@ func mergeMatchWrite(stored, incoming *state.MatchResult, policy matchWritePolic // Every group either side holds, plus a changed bout neither holds (a // removal over a tombstone). groups := append([]string(nil), state.ScalarGroups...) + // The server's groups are copied from the stored match here, and again + // once every row is in place (below), since a bout copy never moves a + // mark (CopyGroup). + groups = append(groups, state.ServerGroups...) groups = append(groups, storedBoutGroups(stored)...) for _, p := range state.SubPositions(incoming.SubResults) { if g := state.BoutGroup(p); !slices.Contains(groups, g) { @@ -457,6 +463,15 @@ func mergeMatchWrite(stored, incoming *state.MatchResult, policy matchWritePolic if mh.settleOrphanPicks(payloadPickA, payloadPickB, removalWrite, stamp) { recomputeModifiedAt = true } + // The recorded marks are the server's (ServerGroups): no write names + // them, so once every hold and displacement above has put its rows in + // place, every row takes the stored match's marks, whichever copy (the + // stored match's or the write's own) the row came from. A mark the + // payload carried can never land. + state.CopyGroup(incoming, stored, state.GroupRecorded) + // Last: a recorded bout whose overtime this write raises, or whose + // result it changes, is being fought again. + mh.unrecordReopenedBouts(stamp) for _, g := range rep.Applied { if !state.GroupDiffers(stored, incoming, g) { @@ -761,6 +776,91 @@ func (h *mergeHold) holdDefaultWinScoring(decision string) { h.rep.StandingDecision = decision } +// unrecordReopenedBouts takes the recorded mark off every numbered bout this +// write reopens, as a change of GroupRecorded at the write's stamp, named in +// its history line. A recorded bout is reopened when the write either +// +// - raises its overtime (bc-kfnl: the bout is being fought again), or +// - changes its RESULT (boutResultChanged: the side its winner names, or its +// decision, a draw read as a draw), e.g. a point scored on a tie recorded +// with both sides out: the bout Record bout closed is no longer the one +// on the row, so the operator records it again (review round 8). A +// correction that keeps the result (another waza letter, the same winner) +// keeps the mark. +// +// Only a bout whose own group applied counts: a held bout changed nothing. And +// only when the write is not older than the stored marks (a Record bout +// stamped after this change keeps its mark, as it would arriving second): the +// stamp decides, as for any group. An UNSTAMPED write (stamp 0, which +// ApplyByTimestamp applies over anything) never clears a mark: it cannot be +// ordered after the Record bout that set it. +func (h *mergeHold) unrecordReopenedBouts(stamp int64) { + if stamp <= 0 { + return + } + var raised []int + for _, g := range h.rep.Applied { + pos, ok := state.ParseBoutGroup(g) + if !ok || pos <= 0 { + continue + } + storedRow := state.SubResultAt(h.stored.SubResults, pos) + row := state.SubResultAt(h.incoming.SubResults, pos) + if !storedRow.Recorded || row.Position != pos { + continue + } + if enchoPeriodsRise(storedRow.Encho, row.Encho) || boutResultChanged(storedRow, row, h.stored.SideA, h.stored.SideB) { + raised = append(raised, pos) + } + } + if len(raised) == 0 || !domain.ApplyByTimestamp(stamp, h.stored.GroupStamp(state.GroupRecorded)) { + return + } + for i := range h.incoming.SubResults { + if slices.Contains(raised, h.incoming.SubResults[i].Position) { + h.incoming.SubResults[i].Recorded = false + } + } + h.rep.Changed = append(slices.Clone(h.rep.Changed), state.GroupRecorded) + h.rep.Applied = append(h.rep.Applied, state.GroupRecorded) + h.stamps[state.GroupRecorded] = stamp +} + +// enchoPeriodsRise reports whether a bout row's overtime period count rises +// from stored to incoming: overtime started (from none) or extended. A write +// that raises it on a recorded bout takes the bout's recorded mark off +// (mergeHold.unrecordReopenedBouts): the bout is being fought again, so both its +// fighters are on, e.g. a knockout tie recorded when both teams had nobody +// left, then fought on in encho. +func enchoPeriodsRise(stored, incoming *state.EnchoMetadata) bool { + periods := func(e *state.EnchoMetadata) int { + if e == nil { + return 0 + } + return e.PeriodCount + } + return periods(incoming) > periods(stored) +} + +// boutResultChanged reports whether row says a different result for its bout +// than stored: the side its winner names (state.SubBoutWinnerSide, ids first, +// read against the match's sides sideA/sideB) or its decision, every draw +// decision read as one draw and "fought" as no decision. A different waza +// letter for the same winner is not a different result. +func boutResultChanged(stored, row state.SubMatchResult, sideA, sideB string) bool { + decision := func(sub state.SubMatchResult) string { + switch { + case state.IsDraw(sub.Decision): + return string(domain.DecisionHikiwake) + case sub.Decision == string(domain.DecisionFought): + return "" + } + return sub.Decision + } + return state.SubBoutWinnerSide(stored, sideA, sideB) != state.SubBoutWinnerSide(row, sideA, sideB) || + decision(stored) != decision(row) +} + // restoreStamp puts group's stamp back to the one the stored match had, or // takes it out again when the stored match never stamped it. func (h *mergeHold) restoreStamp(group string) { diff --git a/internal/engine/recorded_merge_invariant_test.go b/internal/engine/recorded_merge_invariant_test.go new file mode 100644 index 000000000..97a1f3373 --- /dev/null +++ b/internal/engine/recorded_merge_invariant_test.go @@ -0,0 +1,80 @@ +package engine + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/gitrgoliveira/bracket-creator/internal/state" +) + +// TestMerge_ADisplacedRowNeverBringsTheWritesRecordedMark pins that a client +// can never set a kachinuki bout's recorded mark, on the one merge path that +// copies a row from the write's OWN payload into the merged match: S2's +// displacement (mergeHold.displaceNewerScoring copies the finish's scoreline +// over newer stored scoring that would leave a knockout tied). Here the +// stored match removed bout 2 after the finish was made (a tombstone newer +// than it), so the merged match lacks the row and the displacement brings the +// finish's own row back in, the row the client sent with recorded: true. +func TestMerge_ADisplacedRowNeverBringsTheWritesRecordedMark(t *testing.T) { + comp := &state.Competition{Kind: "team", TeamSize: 2} + stored := &state.MatchResult{ + ID: "r1-m1", SideA: "Ryu", SideB: "Tora", Status: state.MatchStatusRunning, + SubResults: []state.SubMatchResult{ + {Position: 1, SideA: "A1", SideB: "B1", Decision: "hikiwake"}, + }, + ModifiedAt: 300, + GroupStamps: map[string]int64{ + state.GroupPoints: 100, state.GroupResult: 100, + state.BoutGroup(1): 100, state.BoutGroup(2): 300, + }, + } + finish := &state.MatchResult{ + ID: "r1-m1", SideA: "Ryu", SideB: "Tora", Status: state.MatchStatusCompleted, Winner: "Ryu", + SubResults: []state.SubMatchResult{ + {Position: 1, SideA: "A1", SideB: "B1", Decision: "hikiwake"}, + {Position: 2, SideA: "A1", SideB: "B2", Winner: "A1", IpponsA: []string{"M"}, Recorded: true}, + }, + ModifiedAt: 200, + Changed: []string{state.GroupResult, state.BoutGroup(1), state.BoutGroup(2)}, + WriteDoor: DoorScore, + } + + rep := mergeMatchWrite(stored, finish, matchWriteForward, mergeCtx{comp: comp, knockout: true}) + require.NotNil(t, rep) + require.NotEmpty(t, rep.Displaced, "the newer removal of bout 2 is moved to the history (S2's displacement)") + row := state.SubResultAt(finish.SubResults, 2) + require.Equal(t, 2, row.Position, "the finish's own bout 2 is back in the match") + assert.Equal(t, "Ryu", finish.Winner) + assert.False(t, row.Recorded, "the mark the client sent never lands") + assert.Empty(t, state.RecordedPositions(finish.SubResults)) +} + +// TestMerge_TheStoredRecordedMarksAlwaysLand pins the other half: a row the +// stored match marked keeps its mark whichever copy of it the merge kept, and +// a row the write marked does not gain one. +func TestMerge_TheStoredRecordedMarksAlwaysLand(t *testing.T) { + comp := &state.Competition{Kind: "team", TeamSize: 3, TeamMatchType: state.TeamMatchTypeKachinuki} + stored := &state.MatchResult{ + ID: "Pool A-0", SideA: "Ryu", SideB: "Tora", Status: state.MatchStatusRunning, + SubResults: []state.SubMatchResult{ + {Position: 1, SideA: "A1", SideB: "B1", Winner: "A1", IpponsA: []string{"M", "K"}, Recorded: true}, + {Position: 2, SideA: "A1", SideB: "B2"}, + }, + ModifiedAt: 100, + } + write := &state.MatchResult{ + ID: "Pool A-0", SideA: "Ryu", SideB: "Tora", Status: state.MatchStatusRunning, + SubResults: []state.SubMatchResult{ + {Position: 1, SideA: "A1", SideB: "B1", Winner: "A1", IpponsA: []string{"M", "K"}}, + {Position: 2, SideA: "A1", SideB: "B2", IpponsB: []string{"D"}, Recorded: true}, + }, + ModifiedAt: 200, + Changed: []string{state.BoutGroup(2)}, + WriteDoor: DoorScore, + } + mergeMatchWrite(stored, write, matchWriteForward, mergeCtx{comp: comp, nilSubsClear: true}) + assert.Equal(t, []int{1}, state.RecordedPositions(write.SubResults)) + assert.Equal(t, []string{"D"}, state.SubResultAt(write.SubResults, 2).IpponsB, "the point itself applies") +} diff --git a/internal/mobileapp/deps.go b/internal/mobileapp/deps.go index 673a1a6c3..cd67d16a7 100644 --- a/internal/mobileapp/deps.go +++ b/internal/mobileapp/deps.go @@ -218,11 +218,14 @@ type TeamLineupStore interface { } // LineupEngine is the consumer-boundary view of engine.Engine used by the -// public lineup-in-force read in handlers_lineup.go: which lineup a team -// fields at a match, and where it was saved. Mirrors -// engine.Engine.LineupInForce, which owns the rule. +// public lineup reads in handlers_lineup.go: which lineup a team fields at a +// match, and where it was saved (engine.Engine.LineupInForce, which owns the +// rule); and a kachinuki encounter's two queues of fighters not yet retired +// (engine.Engine.KachinukiRoster, read through the same call the advance +// appends the next pairing from). type LineupEngine interface { LineupInForce(compID, teamID, matchID string) (engine.InForceLineup, error) + KachinukiRoster(compID, matchID string) (engine.KachinukiRoster, error) } // SquadStore is the consumer-boundary view of state.Store used by diff --git a/internal/mobileapp/handlers_kachinuki_roster_test.go b/internal/mobileapp/handlers_kachinuki_roster_test.go new file mode 100644 index 000000000..6283d903a --- /dev/null +++ b/internal/mobileapp/handlers_kachinuki_roster_test.go @@ -0,0 +1,222 @@ +package mobileapp + +// GET /api/competitions/:id/matches/:mid/kachinuki-roster (bc-kfnl): a +// kachinuki encounter's fighters not yet retired, for the advisory line the +// team score sheet shows above the live bout. A public read: the self-run +// public score sheet mounts the same team editor. + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/gin-gonic/gin" + "github.com/gitrgoliveira/bracket-creator/internal/domain" + "github.com/gitrgoliveira/bracket-creator/internal/helper" + "github.com/gitrgoliveira/bracket-creator/internal/state" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// seedKachinukiRosterComp stores a kachinuki competition "k1" with two teams, +// a starting lineup for team A only, and one running pool encounter whose +// first bout A1 won. +func seedKachinukiRosterComp(t *testing.T, store *state.Store) (a1, a2 domain.TeamMember) { + t.Helper() + aID, bID := helper.NewUUID4(), helper.NewUUID4() + require.NoError(t, store.SaveCompetition(&state.Competition{ + ID: "k1", Name: "Kachinuki", Kind: "team", TeamSize: 2, + TeamMatchType: state.TeamMatchTypeKachinuki, Format: state.CompFormatMixed, + })) + require.NoError(t, store.SaveParticipants("k1", []domain.Player{ + {ID: aID, Name: "Ryu", Dojo: "DojoR"}, + {ID: bID, Name: "Tora", Dojo: "DojoT"}, + })) + var err error + a1, err = store.AddTeamMember("k1", aID, "A1") + require.NoError(t, err) + a2, err = store.AddTeamMember("k1", aID, "A2") + require.NoError(t, err) + require.NoError(t, store.SetTeamLineup("k1", domain.TeamLineup{ + TeamID: aID, Round: 0, + Positions: map[domain.Position]string{domain.PositionNumbered(1): "A1", domain.PositionNumbered(2): "A2"}, + MemberIDs: map[domain.Position]string{domain.PositionNumbered(1): a1.ID, domain.PositionNumbered(2): a2.ID}, + }, 2)) + require.NoError(t, store.SavePoolMatches("k1", []state.MatchResult{{ + ID: "Pool A-0", SideA: "Ryu", SideAID: aID, SideB: "Tora", SideBID: bID, Status: state.MatchStatusRunning, + SubResults: []state.SubMatchResult{ + {Position: 1, SideA: "A1", SideAMemberID: a1.ID, SideB: "B1", Winner: "A1", WinnerMemberID: a1.ID, Decision: "fought"}, + {Position: 2, SideA: "A1", SideAMemberID: a1.ID, SideB: "B2"}, + }, + }})) + return a1, a2 +} + +func getRoster(r http.Handler, path string) *httptest.ResponseRecorder { + req := httptest.NewRequest(http.MethodGet, path, nil) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + return w +} + +func TestKachinukiRosterGET(t *testing.T) { + r, store, _ := setupLineupTestRouter(t) + a1, a2 := seedKachinukiRosterComp(t, store) + require.NoError(t, store.SaveCompetition(&state.Competition{ID: "fixed", Kind: "team", TeamSize: 3})) + + type fighter struct { + Name string `json:"name"` + MemberID string `json:"memberId"` + } + type side struct { + LineupFound bool `json:"lineupFound"` + On *fighter `json:"on"` + Remaining []fighter `json:"remaining"` + } + type rosterBody struct { + SideA side `json:"sideA"` + SideB side `json:"sideB"` + } + + t.Run("200 with each side's fighter on and queue", func(t *testing.T) { + w := getRoster(r, "/api/competitions/k1/matches/Pool%20A-0/kachinuki-roster") + require.Equal(t, http.StatusOK, w.Code, w.Body.String()) + var body rosterBody + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body)) + assert.True(t, body.SideA.LineupFound) + require.NotNil(t, body.SideA.On) + assert.Equal(t, a1.ID, body.SideA.On.MemberID, "A1 won bout 1 and is on in bout 2") + require.Len(t, body.SideA.Remaining, 1, "the fighter on is not in the queue") + assert.Equal(t, "A2", body.SideA.Remaining[0].Name) + assert.Equal(t, a2.ID, body.SideA.Remaining[0].MemberID) + assert.False(t, body.SideB.LineupFound, "team B has no lineup") + require.NotNil(t, body.SideB.On) + assert.Equal(t, "B2", body.SideB.On.Name) + assert.Empty(t, body.SideB.Remaining) + assert.Contains(t, w.Body.String(), `"remaining":[]`, "an empty queue is [] on the wire, never null") + // Bout 2 has no outcome yet: Record bout would append nothing, and an + // unrecorded bout exhausts nobody. + assert.Contains(t, w.Body.String(), `"nextPairing":false`) + assert.Contains(t, w.Body.String(), `"exhausted":false`) + }) + t.Run("nextPairing says Record bout would append", func(t *testing.T) { + // B1 leads A1 in bout 1: Record bout keeps B1 on against A2, the next + // fighter of Ryu's lineup. + matches, err := store.LoadPoolMatches("k1") + require.NoError(t, err) + aID, bID := matches[0].SideAID, matches[0].SideBID + matches = append(matches, state.MatchResult{ + ID: "Pool A-3", SideA: "Ryu", SideAID: aID, SideB: "Tora", SideBID: bID, Status: state.MatchStatusRunning, + SubResults: []state.SubMatchResult{ + {Position: 1, SideA: "A1", SideAMemberID: a1.ID, SideB: "B1", Winner: "B1"}, + }, + }) + require.NoError(t, store.SavePoolMatches("k1", matches)) + w := getRoster(r, "/api/competitions/k1/matches/Pool%20A-3/kachinuki-roster") + require.Equal(t, http.StatusOK, w.Code, w.Body.String()) + var body struct { + NextPairing bool `json:"nextPairing"` + SideA struct { + Exhausted bool `json:"exhausted"` + } `json:"sideA"` + } + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body)) + assert.True(t, body.NextPairing) + assert.False(t, body.SideA.Exhausted, "the bout is not recorded") + }) + t.Run("a last bout carrying a lead keeps both fighters on", func(t *testing.T) { + // The running autosave writes a 1-0 lead's winner on the bout being + // fought: it is not a result until Record bout records the bout + // (bc-kfnl). Added beside Pool A-0, which the other subtests read. + matches, err := store.LoadPoolMatches("k1") + require.NoError(t, err) + matches = append(matches, state.MatchResult{ + ID: "Pool A-2", SideA: "Ryu", SideB: "Tora", Status: state.MatchStatusRunning, + SubResults: []state.SubMatchResult{ + {Position: 1, SideA: "A1", SideAMemberID: a1.ID, SideB: "B2", Winner: "A1", WinnerMemberID: a1.ID}, + }, + }) + require.NoError(t, store.SavePoolMatches("k1", matches)) + w := getRoster(r, "/api/competitions/k1/matches/Pool%20A-2/kachinuki-roster") + require.Equal(t, http.StatusOK, w.Code, w.Body.String()) + var body rosterBody + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body)) + require.NotNil(t, body.SideB.On, "B2 is still on") + assert.Equal(t, "B2", body.SideB.On.Name) + require.NotNil(t, body.SideA.On) + assert.Equal(t, a1.ID, body.SideA.On.MemberID) + }) + t.Run("a recorded last bout takes its loser off", func(t *testing.T) { + // A1 beats B2 in bout 1 of a second encounter and Record bout records + // it, with nothing appended (Tora has nobody left). + matches, err := store.LoadPoolMatches("k1") + require.NoError(t, err) + matches = append(matches, state.MatchResult{ + ID: "Pool A-1", SideA: "Ryu", SideB: "Tora", Status: state.MatchStatusRunning, + SubResults: []state.SubMatchResult{ + {Position: 1, SideA: "A1", SideAMemberID: a1.ID, SideB: "B2", Winner: "A1", WinnerMemberID: a1.ID, Decision: "fought", Recorded: true}, + }, + }) + require.NoError(t, store.SavePoolMatches("k1", matches)) + w := getRoster(r, "/api/competitions/k1/matches/Pool%20A-1/kachinuki-roster") + require.Equal(t, http.StatusOK, w.Code, w.Body.String()) + var body rosterBody + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body)) + assert.Nil(t, body.SideB.On, "the recorded bout's result retired B2") + assert.Contains(t, w.Body.String(), `"on":null`, "nobody on is null on the wire") + require.NotNil(t, body.SideA.On) + assert.Equal(t, a1.ID, body.SideA.On.MemberID, "the winner stays on") + }) + t.Run("404 for an unknown match", func(t *testing.T) { + w := getRoster(r, "/api/competitions/k1/matches/nope/kachinuki-roster") + assert.Equal(t, http.StatusNotFound, w.Code, w.Body.String()) + assert.Contains(t, w.Body.String(), "match nope not found", "the answer names the match, not the competition") + }) + t.Run("404 for an unknown competition", func(t *testing.T) { + w := getRoster(r, "/api/competitions/missing/matches/Pool%20A-0/kachinuki-roster") + assert.Equal(t, http.StatusNotFound, w.Code, w.Body.String()) + assert.Contains(t, w.Body.String(), "competition missing not found") + }) + t.Run("400 for a competition that is not kachinuki", func(t *testing.T) { + w := getRoster(r, "/api/competitions/fixed/matches/Pool%20A-0/kachinuki-roster") + assert.Equal(t, http.StatusBadRequest, w.Code, w.Body.String()) + }) + t.Run("400 for an invalid competition id", func(t *testing.T) { + w := getRoster(r, "/api/competitions/bad%21id/matches/m/kachinuki-roster") + assert.Equal(t, http.StatusBadRequest, w.Code, w.Body.String()) + }) +} + +// A read the engine cannot make is the server's fault: a 500. +func TestKachinukiRosterGET_ReadFailureIs500(t *testing.T) { + gin.SetMode(gin.TestMode) + store, err := state.NewStore(t.TempDir()) + require.NoError(t, err) + require.NoError(t, store.SaveCompetition(&state.Competition{ID: "c1", TeamSize: 5})) + r := gin.New() + RegisterPublicLineupHandlers(r.Group("/api"), store, store, failingLineupEngine{}) + + w := getRoster(r, "/api/competitions/c1/matches/m1/kachinuki-roster") + assert.Equal(t, http.StatusInternalServerError, w.Code, w.Body.String()) +} + +// The read is on the PUBLIC side in a self-run tournament: the public score +// sheet mounts the team editor that shows the advisory line, and sends an +// EMPTY password. It is a read that reveals nothing the public lineup reads do +// not, so it is not in isSelfRunMainGatedConfigRoute. +func TestSelfRun_KachinukiRosterIsPublic(t *testing.T) { + store := newTempStore(t) + seedSelfRunTournament(t, store, "admin-pw") + seedKachinukiRosterComp(t, store) + r := setupSelfRunRouter(t, store, NewFileVerifier(store)) + + req := httptest.NewRequest(http.MethodGet, "/api/competitions/k1/matches/Pool%20A-0/kachinuki-roster", nil) + req.Header.Set("X-Tournament-Password", "") + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + assert.Equal(t, http.StatusOK, w.Code, w.Body.String()) + + assert.False(t, isSelfRunMainGatedConfigRoute(http.MethodGet, "/api/competitions/:id/matches/:mid/kachinuki-roster"), + "the roster read stays public in self-run") +} diff --git a/internal/mobileapp/handlers_lineup.go b/internal/mobileapp/handlers_lineup.go index 93d4064e3..c4d3c63bf 100644 --- a/internal/mobileapp/handlers_lineup.go +++ b/internal/mobileapp/handlers_lineup.go @@ -9,7 +9,8 @@ // name the positions it changed (LineupRequest.Changed) and then lands only // those on the lineup stored when it arrives (lineupSave.base). A third public GET, // .../lineup-in-force/:matchId, answers which lineup the team fields at a match -// (see lineupInForceRead). +// (see lineupInForceRead), and a fourth, .../matches/:mid/kachinuki-roster, a +// kachinuki encounter's fighters not yet retired (engine.KachinukiRoster). // // All store I/O goes through the TeamLineupStore + CompetitionStore // interfaces (deps.go) rather than the concrete *state.Store @@ -23,6 +24,7 @@ package mobileapp import ( + "errors" "log" "net/http" "strconv" @@ -155,7 +157,8 @@ func validLineupRequest(c *gin.Context, req LineupRequest) bool { // RegisterPublicLineupHandlers wires the read-only GET // /competitions/:id/teams/:tid/lineups/:round, // /competitions/:id/teams/:tid/match-lineups/:matchId and -// /competitions/:id/teams/:tid/lineup-in-force/:matchId endpoints on an +// /competitions/:id/teams/:tid/lineup-in-force/:matchId endpoints, and the +// kachinuki queue read /competitions/:id/matches/:mid/kachinuki-roster, on an // unauthenticated router group. Lineup data (position assignments) is not // sensitive, coaches and viewers can see who plays where, and the // AdminLineup form needs to load the current lineup without holding @@ -164,7 +167,8 @@ func validLineupRequest(c *gin.Context, req LineupRequest) bool { // // The first two GETs answer with a teamLineupRead and the third with a // lineupInForceRead (see their docs); a 404 means the competition does not -// exist. +// exist. The kachinuki roster read is public for the same reason, and because +// the self-run public score sheet mounts the same team editor that shows it. // // Slice 7.B / T127. func RegisterPublicLineupHandlers(r *gin.RouterGroup, store TeamLineupStore, comps CompetitionStore, eng LineupEngine) { @@ -231,6 +235,40 @@ func RegisterPublicLineupHandlers(r *gin.RouterGroup, store TeamLineupStore, com } c.JSON(http.StatusOK, newLineupInForceRead(compID, teamID, matchID, in)) }) + + // The kachinuki queue (bc-kfnl, operator ruling 2026-09-24): each side's + // fighters who have not retired, read through the same engine call that + // appends the next pairing, for the advisory line the score sheet shows + // above the live bout. Read-only and advisory: nothing is decided on it. + // 200 with each side's {lineupFound, on, remaining}; 404 for an unknown + // competition or match; 400 for a competition that is not kachinuki; 500 + // when the lineups cannot be read. + r.GET("/competitions/:id/matches/:mid/kachinuki-roster", func(c *gin.Context) { + compID, ok := requireValidCompID(c) + if !ok { + return + } + matchID := c.Param("mid") + if matchID == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "match ID is required"}) + return + } + // The engine reads config.md once and answers an unknown competition or + // match with a NotFoundError naming which, so the handler reads nothing + // of its own and answers with that error's own words. + roster, err := eng.KachinukiRoster(compID, matchID) + var notFound *engine.NotFoundError + switch { + case errors.As(err, ¬Found): + c.JSON(http.StatusNotFound, gin.H{"error": notFound.Error()}) + case errors.Is(err, engine.ErrNotKachinuki): + c.JSON(http.StatusBadRequest, gin.H{"error": "this competition's team matches are not kachinuki"}) + case err != nil: + internalError(c, err) + default: + c.JSON(http.StatusOK, roster) + } + }) } // lineupInForceRead is the response body of the lineup-in-force GET. It embeds diff --git a/internal/mobileapp/handlers_lineup_test.go b/internal/mobileapp/handlers_lineup_test.go index c42167ad4..2066f7c98 100644 --- a/internal/mobileapp/handlers_lineup_test.go +++ b/internal/mobileapp/handlers_lineup_test.go @@ -803,6 +803,10 @@ func TestPublicLineupInForceGET_BadParamsStay400(t *testing.T) { // failingLineupEngine is a LineupEngine whose read fails. type failingLineupEngine struct{} +func (failingLineupEngine) KachinukiRoster(string, string) (engine.KachinukiRoster, error) { + return engine.KachinukiRoster{}, errors.New("disk on fire") +} + func (failingLineupEngine) LineupInForce(string, string, string) (engine.InForceLineup, error) { return engine.InForceLineup{}, errors.New("read lineups.yaml: input/output error") } diff --git a/internal/mobileapp/handlers_match.go b/internal/mobileapp/handlers_match.go index c3e093d2f..cb9c32d2b 100644 --- a/internal/mobileapp/handlers_match.go +++ b/internal/mobileapp/handlers_match.go @@ -2055,7 +2055,7 @@ var errRepBoutAdded = &selfRunRefusal{ // Fails closed: the caller's read of the snapshot rejects the request on a load // error rather than allowing the write (matchSnapshotOrErr). A match in neither // store (found false) passes on purpose: the write that follows under the same -// lock refuses it (errMatchNotFound), so nothing can land on it. +// lock refuses it (the engine's errMatchNotFound), so nothing can land on it. func holdSelfReportedWriteUnderTx(compID, matchID string, snap matchSnapshot, found bool, result *state.MatchResult, startOnly bool) error { if !found { return nil @@ -3178,7 +3178,13 @@ type scoreRequestBody struct { func changedGroupsError(changed []string) error { for _, g := range changed { if !state.ValidGroup(g) { - return fmt.Errorf("changed: %q is not a match group (points, result, encho, flags, rep, bout:)", g) + return fmt.Errorf("changed: %q is not a match group (%s, bout:)", g, strings.Join(state.ScalarGroups, ", ")) + } + // A server group (the kachinuki recorded marks) is never a client's to + // change: refused rather than ignored, so a client that names one is + // told rather than silently written around. + if state.IsServerGroup(g) { + return fmt.Errorf("changed: %q is written by the server alone and cannot be named in a write", g) } } return nil @@ -3891,9 +3897,13 @@ func registerScoreHandler(r *gin.RouterGroup, eng ScoringEngine, store Competiti // to retry and double-record. Mirrors the recordIneligibility // non-fatal pattern. if body.KachinukiBoutFinal { - if advanced, post, kerr := eng.MaybeAdvanceKachinuki(id, mid); kerr != nil { + // The advance also marks the bout Record bout closed as recorded + // (bc-kfnl), appending a pairing or not: post is non-nil whenever + // it wrote, so the answer and the broadcast carry the mark at a + // side's exhaustion too, where nothing is appended. + if _, post, kerr := eng.MaybeAdvanceKachinuki(id, mid); kerr != nil { log.Printf("engine.MaybeAdvanceKachinuki(%s, %s): %v", id, mid, kerr) - } else if advanced { + } else if post != nil { // Echo the POST-advance bout log so the open score editor can // render the appended pairing immediately (Record bout → // next bout appears, mp-gmcg): the `result` echoed below @@ -3903,11 +3913,9 @@ func registerScoreHandler(r *gin.RouterGroup, eng ScoringEngine, store Competiti // (mp-gmcg review E1), and the stamps the advance left, so // the editor's next tap on the new bout is floored by them // (bc-hlck) rather than by the pre-advance stamp. - if post != nil { - result.SubResults = post.BoutLog - result.ModifiedAt = post.ModifiedAt - result.GroupStamps = post.GroupStamps - } + result.SubResults = post.BoutLog + result.ModifiedAt = post.ModifiedAt + result.GroupStamps = post.GroupStamps 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 501aa9086..a9dde0f05 100644 --- a/internal/mobileapp/handlers_match_kachinuki_test.go +++ b/internal/mobileapp/handlers_match_kachinuki_test.go @@ -204,6 +204,9 @@ func TestScoreHandler_KachinukiBoutFinalAppendsNextBout(t *testing.T) { require.Len(t, echoed.SubResults, 2, "response must carry the appended bout") assert.Equal(t, "R-1", echoed.SubResults[1].SideA) assert.Equal(t, "W-2", echoed.SubResults[1].SideB) + assert.True(t, echoed.SubResults[0].Recorded, "the answer marks the bout Record bout closed (bc-kfnl)") + assert.False(t, echoed.SubResults[1].Recorded) + assert.Contains(t, w.Body.String(), `"recorded":true`) matches, err := store.LoadPoolMatches(compID) require.NoError(t, err) @@ -222,6 +225,70 @@ func TestScoreHandler_KachinukiBoutFinalAppendsNextBout(t *testing.T) { assert.Empty(t, matches[0].Decision, "no match-level decision while bouts remain") } +// TestScoreHandler_KachinukiRecordBoutAtExhaustionMarksTheBout pins bc-kfnl +// (operator decision 2026-10-11) through the wire: Record bout on a bout that +// leaves a side with nobody appends nothing, but still marks the bout +// recorded, and the answer carries the mark, so the open sheet's advisory +// line (engine.KachinukiRoster) reads that side as having no fighters left. A later autosave that +// leaves the mark out keeps it, and a write marking an unrecorded bout does +// not set it. +func TestScoreHandler_KachinukiRecordBoutAtExhaustionMarksTheBout(t *testing.T) { + compID := "kachinuki-record-exhausted" + r, store := setupKachinukiScoreServer(t, compID) + // R-1 has beaten W-1 and W-2; bout 3 against W-3, Tora's last fighter. + 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"}, IpponsB: []string{}, Winner: "R-1", Decision: "fought", Recorded: true}, + {Position: 2, SideA: "R-1", SideB: "W-2", IpponsA: []string{"K"}, IpponsB: []string{}, Winner: "R-1", Decision: "fought", Recorded: true}, + {Position: 3, SideA: "R-1", SideB: "W-3"}, + }, + }})) + rows := func(third map[string]any) []map[string]any { + return []map[string]any{ + kachinukiSub(1, "R-1", "W-1", []string{"M"}, "R-1", "fought"), + kachinukiSub(2, "R-1", "W-2", []string{"K"}, "R-1", "fought"), + third, + } + } + + // The autosave at a 1-0 lead, claiming the bout is recorded: not set. + lead := kachinukiSub(3, "R-1", "W-3", []string{"M"}, "R-1", "") + lead["recorded"] = true + w := putScore(t, r, compID, "P1-0", map[string]any{ + "sideA": "Ryu", "sideB": "Tora", "status": "running", "subResults": rows(lead), + "changed": []string{state.BoutGroup(3)}, + }) + require.Equal(t, http.StatusOK, w.Code, w.Body.String()) + matches, err := store.LoadPoolMatches(compID) + require.NoError(t, err) + require.Len(t, matches[0].SubResults, 3) + assert.True(t, matches[0].SubResults[0].Recorded, "an autosave that leaves the mark out keeps it") + assert.False(t, matches[0].SubResults[2].Recorded, "a client cannot mark a bout recorded") + // Bouts 1 and 2 were sent without their mark: that is no change of + // theirs, so the write names bout 3 alone (noteServerBoutChanges). + history, err := store.LoadMatchHistory(compID, "P1-0") + require.NoError(t, err) + require.NotEmpty(t, history) + assert.Equal(t, []string{state.BoutGroup(3)}, history[len(history)-1].Changed) + + // Record bout: nobody left on Tora, nothing appended, the bout recorded. + w = putScore(t, r, compID, "P1-0", map[string]any{ + "sideA": "Ryu", "sideB": "Tora", "status": "running", "kachinukiBoutFinal": true, + "subResults": rows(kachinukiSub(3, "R-1", "W-3", []string{"M"}, "R-1", "fought")), + }) + require.Equal(t, http.StatusOK, w.Code, w.Body.String()) + var echoed state.MatchResult + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &echoed)) + require.Len(t, echoed.SubResults, 3, "nothing is appended") + assert.True(t, echoed.SubResults[2].Recorded, "the answer carries the recorded mark") + matches, err = store.LoadPoolMatches(compID) + require.NoError(t, err) + require.Len(t, matches[0].SubResults, 3) + assert.True(t, matches[0].SubResults[2].Recorded) + assert.Equal(t, matches[0].ModifiedAt, echoed.ModifiedAt, "the echo carries the stamp the mark left") +} + // TestScoreHandler_KachinukiHikiwakeAppendsWalkoverSlot pins spec 006 // decision 2 + the stays-on ruling through the wire: a hikiwake that // leaves one side without a replacement while the other side still has diff --git a/internal/mobileapp/handlers_match_merge_test.go b/internal/mobileapp/handlers_match_merge_test.go index 48930e198..37b1a11db 100644 --- a/internal/mobileapp/handlers_match_merge_test.go +++ b/internal/mobileapp/handlers_match_merge_test.go @@ -116,6 +116,18 @@ func TestScoreHandler_AnswersWhatTheMergeHeld(t *testing.T) { w := serveJSON(r, "PUT", "/api/competitions/"+compID+"/matches/Pool A-0/score", body) assert.Equal(t, http.StatusBadRequest, w.Code, w.Body.String()) }) + + // The kachinuki recorded marks are the server's alone (review round 6): + // a client naming that group is refused, on the score write and on each + // bulk entry, rather than silently written around. + t.Run("a server group is refused", func(t *testing.T) { + body := map[string]any{"sideA": mgAlice, "sideB": mgBob, "status": "running", "changed": []string{state.GroupRecorded}} + w := serveJSON(r, "PUT", "/api/competitions/"+compID+"/matches/Pool A-0/score", body) + assert.Equal(t, http.StatusBadRequest, w.Code, w.Body.String()) + assert.Contains(t, w.Body.String(), "written by the server alone") + assert.Error(t, changedGroupsError([]string{state.BoutGroup(1), state.GroupRecorded})) + assert.NoError(t, changedGroupsError([]string{state.BoutGroup(1), state.GroupPoints})) + }) } // R3 through the door: a correction typed on a board after the match diff --git a/internal/state/match_groups.go b/internal/state/match_groups.go index 8b9f776e4..830e53cd3 100644 --- a/internal/state/match_groups.go +++ b/internal/state/match_groups.go @@ -61,13 +61,33 @@ const ( // group of a side. GroupRepPickA = "repPickA" GroupRepPickB = "repPickB" + // GroupRecorded holds which numbered kachinuki bouts Record bout closed + // (SubMatchResult.Recorded on every row with a position > 0), as the sorted + // list of their positions. It is the SERVER's (ServerGroups): the advance + // sets it, RemoveTrailingKachinukiBout takes it off the bout whose pairing it + // removes, and the merge takes it off a recorded bout whose overtime a write + // raises. Each stamps it like any group, so a copy holding the mark is ordered + // after one without it on every device. A bout row's group (bout:N) does not + // hold the mark, so a point on bout N and Record bout never fence each other. + GroupRecorded = "recorded" boutGroupPrefix = "bout:" ) -// ScalarGroups lists the scalar groups in their fixed order. +// ScalarGroups lists the scalar groups a client write names, in their fixed +// order. var ScalarGroups = []string{GroupPoints, GroupResult, GroupEncho, GroupFlags, GroupRep, GroupRepPickA, GroupRepPickB} +// ServerGroups lists the groups only the server writes. No client write names +// one: the score handler refuses it in `changed`, and the merge copies the +// stored value over whatever a client's rows say. +var ServerGroups = []string{GroupRecorded} + +// IsServerGroup reports whether group is one only the server writes. +func IsServerGroup(group string) bool { + return slices.Contains(ServerGroups, group) +} + // RepPickGroup names the group of side's representative pick, "" for a value // that is neither side. func RepPickGroup(side domain.MatchSide) string { @@ -105,13 +125,12 @@ func ParseBoutGroup(group string) (int, bool) { return pos, true } -// ValidGroup reports whether group names a scalar group or a bout row. The -// score handler refuses a `changed` entry that is neither. +// ValidGroup reports whether group names a scalar group, a server group or a +// bout row. The score handler refuses a `changed` entry that is not one, or +// that is a server group (IsServerGroup). func ValidGroup(group string) bool { - for _, g := range ScalarGroups { - if g == group { - return true - } + if slices.Contains(ScalarGroups, group) || IsServerGroup(group) { + return true } _, ok := ParseBoutGroup(group) return ok @@ -282,9 +301,24 @@ func CopyGroup(dst, src *MatchResult, group string) { case GroupRepPickB: _, b := src.RepPicks() dst.SetRepPick(domain.MatchSideB, b) + case GroupRecorded: + setRecordedPositions(dst.SubResults, RecordedPositions(src.SubResults)) default: if pos, ok := ParseBoutGroup(group); ok { row := subAt(src.SubResults, pos) + // The recorded mark is GroupRecorded's, not the bout's: a copy of + // the bout keeps the mark the destination row already holds, and a + // row the destination lacks arrives unmarked. A bout copy never + // moves a mark, so a row copied from a client's payload cannot + // bring one; only GroupRecorded does. + if row != nil { + kept := *row + kept.Recorded = false + if dstRow := subAt(dst.SubResults, pos); dstRow != nil { + kept.Recorded = dstRow.Recorded + } + row = &kept + } // The representatives are not the bout's: a copy of the bout keeps the // picks the destination row already holds (GroupRepPickA and // GroupRepPickB move them), so the copies give the same result in @@ -348,6 +382,37 @@ type repPickBGroup struct { SideBMemberID string `json:"sideBMemberId"` } +// recordedGroup is GroupRecorded's value: the positions of the numbered bouts +// Record bout closed, sorted, never nil (so an empty set compares and +// serializes as one value). +type recordedGroup struct { + Positions []int `json:"positions"` +} + +// RecordedPositions lists, sorted, the positions of the numbered rows (position +// > 0) marked recorded: GroupRecorded's value. +func RecordedPositions(subs []SubMatchResult) []int { + out := []int{} + for i := range subs { + if subs[i].Position > 0 && subs[i].Recorded { + out = append(out, subs[i].Position) + } + } + sort.Ints(out) + return out +} + +// setRecordedPositions marks each numbered row recorded exactly when its +// position is in positions. A position with no row marks nothing. +func setRecordedPositions(subs []SubMatchResult, positions []int) { + for i := range subs { + // Only a numbered bout can be recorded: a mark on any other row (the + // representative bout, -1) can only have come from a client, which may + // never write the mark, so it is cleared rather than kept. + subs[i].Recorded = subs[i].Position > 0 && slices.Contains(positions, subs[i].Position) + } +} + type repGroup struct { RepPlayerA string `json:"repPlayerA"` RepPlayerB string `json:"repPlayerB"` @@ -380,6 +445,8 @@ func groupProjection(m *MatchResult, group string) any { case GroupRepPickB: _, b := m.RepPicks() return repPickBGroup{SideBMemberID: b} + case GroupRecorded: + return recordedGroup{Positions: RecordedPositions(m.SubResults)} } if pos, ok := ParseBoutGroup(group); ok { row := subAt(m.SubResults, pos) @@ -388,6 +455,8 @@ func groupProjection(m *MatchResult, group string) any { } c := CloneSubResults([]SubMatchResult{*row})[0] c.IpponsA, c.IpponsB = nonNilStrings(c.IpponsA), nonNilStrings(c.IpponsB) + // The recorded mark is GroupRecorded's. + c.Recorded = false if pos == DaihyosenSubPosition { // The representatives are GroupRepPickA's and GroupRepPickB's, not // the bout's. The row's WinnerMemberID is derived from the winner's diff --git a/internal/state/match_groups_table_test.go b/internal/state/match_groups_table_test.go index 045262f09..a0e6bff3a 100644 --- a/internal/state/match_groups_table_test.go +++ b/internal/state/match_groups_table_test.go @@ -25,6 +25,7 @@ func TestMatchGroups_SharedTable(t *testing.T) { var table struct { ScalarGroups []string `json:"scalarGroups"` + ServerGroups []string `json:"serverGroups"` Bouts []struct { Position int `json:"position"` Group string `json:"group"` @@ -33,12 +34,19 @@ func TestMatchGroups_SharedTable(t *testing.T) { } require.NoError(t, json.Unmarshal(raw, &table)) require.NotEmpty(t, table.ScalarGroups, "table parsed to no scalar groups: it would assert nothing") + require.NotEmpty(t, table.ServerGroups, "table parsed to no server groups: it would assert nothing") require.NotEmpty(t, table.Bouts, "table parsed to no bout groups: it would assert nothing") require.NotEmpty(t, table.Invalid, "table parsed to no invalid names: it would assert nothing") assert.Equal(t, table.ScalarGroups, ScalarGroups, "the scalar groups and their order") for _, g := range table.ScalarGroups { assert.True(t, ValidGroup(g), "%q is a group", g) + assert.False(t, IsServerGroup(g), "%q is a client's group", g) + } + assert.Equal(t, table.ServerGroups, ServerGroups, "the server groups and their order") + for _, g := range table.ServerGroups { + assert.True(t, ValidGroup(g), "%q is a group", g) + assert.True(t, IsServerGroup(g), "%q is the server's alone", g) } for _, b := range table.Bouts { assert.Equal(t, b.Group, BoutGroup(b.Position)) diff --git a/internal/state/models.go b/internal/state/models.go index f1c72c5c1..ea259af32 100644 --- a/internal/state/models.go +++ b/internal/state/models.go @@ -1331,6 +1331,21 @@ type SubMatchResult struct { SideAMemberID string `json:"sideAMemberId,omitempty" yaml:"side_a_member_id,omitempty"` SideBMemberID string `json:"sideBMemberId,omitempty" yaml:"side_b_member_id,omitempty"` WinnerMemberID string `json:"winnerMemberId,omitempty" yaml:"winner_member_id,omitempty"` + // Recorded marks a kachinuki bout the operator closed with Record bout + // (operator decision 2026-10-11, bc-kfnl). Only the server writes it: + // engine.advanceKachinukiOnce sets it on the bout Record bout closes, in the + // same locked write as the next pairing (or alone, when no pairing is + // appended). It is its own merge group, GroupRecorded (match_groups.go), a + // server group no client write names: the merge copies the stored marks over + // whatever a client's rows say, takes one off when a write raises that bout's + // encho (the bout being fought again), and RemoveTrailingKachinukiBout takes + // it off the bout whose appended pairing it removes; each stamps the group, + // never the bout's. It tells a recorded bout from the one being fought, which the + // row's result cannot: the running autosave writes a winner at a one-point + // lead and a hikiwake at 1-1. engine.KachinukiRoster reads the last numbered + // bout's result only when it is set. omitempty: absent on every row written + // before it, which reads as not recorded, so old data needs no migration. + Recorded bool `json:"recorded,omitempty" yaml:"recorded,omitempty"` } // HanteiDecided reports whether a hantei verdict stands on this sub-bout: diff --git a/internal/state/sub_recorded_test.go b/internal/state/sub_recorded_test.go new file mode 100644 index 000000000..66921b453 --- /dev/null +++ b/internal/state/sub_recorded_test.go @@ -0,0 +1,111 @@ +package state + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestSubMatchResultRecordedRoundTrips pins the kachinuki recorded mark +// (bc-kfnl) on both stores: pool-matches.csv's SubResults column and +// bracket.json each keep it, and a row that never carried it reads as not +// recorded (old data needs no migration). +func TestSubMatchResultRecordedRoundTrips(t *testing.T) { + store, cleanup := newTestStore(t) + defer cleanup() + const compID = "recorded-roundtrip" + require.NoError(t, store.SaveCompetition(&Competition{ID: compID})) + + subs := []SubMatchResult{ + {Position: 1, SideA: "A1", SideB: "B1", Winner: "A1", Decision: "fought", Recorded: true}, + {Position: 2, SideA: "A1", SideB: "B2"}, + } + require.NoError(t, store.SavePoolMatches(compID, []MatchResult{{ + ID: "Pool A-0", SideA: "Ryu", SideB: "Tora", Status: MatchStatusRunning, SubResults: CloneSubResults(subs), + }})) + pms, err := store.LoadPoolMatches(compID) + require.NoError(t, err) + require.Len(t, pms, 1) + require.Len(t, pms[0].SubResults, 2) + assert.True(t, pms[0].SubResults[0].Recorded) + assert.False(t, pms[0].SubResults[1].Recorded) + + require.NoError(t, store.SaveBracket(compID, &Bracket{Rounds: [][]BracketMatch{{{ + ID: "r0-m0", SideA: "Ryu", SideB: "Tora", Status: MatchStatusRunning, SubResults: CloneSubResults(subs), + }}}})) + br, err := store.LoadBracket(compID) + require.NoError(t, err) + bm := br.MatchByID("r0-m0") + require.NotNil(t, bm) + require.Len(t, bm.SubResults, 2) + assert.True(t, bm.SubResults[0].Recorded) + assert.False(t, bm.SubResults[1].Recorded) + + // The wire: present when set, absent otherwise. + b, err := json.Marshal(subs) + require.NoError(t, err) + var raw []map[string]any + require.NoError(t, json.Unmarshal(b, &raw)) + assert.Equal(t, true, raw[0]["recorded"]) + _, has := raw[1]["recorded"] + assert.False(t, has, "omitempty: an unrecorded row carries no key") + +} + +// TestRecordedGroup pins the recorded mark as a group of its own (review round +// 6): a bout's group never holds it, GroupRecorded holds exactly the numbered +// rows' marks, and each copy moves only its own half in either order. +func TestRecordedGroup(t *testing.T) { + subs := func() []SubMatchResult { + return []SubMatchResult{ + {Position: 1, SideA: "A1", SideB: "B1", Winner: "A1", Recorded: true}, + {Position: 2, SideA: "A1", SideB: "B2", IpponsA: []string{"M"}}, + {Position: DaihyosenSubPosition, SideA: "Ryu", SideB: "Tora", Recorded: true}, + } + } + a := &MatchResult{SubResults: subs()} + c := &MatchResult{SubResults: subs()} + c.SubResults[0].Recorded = false + + // Projection: only GroupRecorded sees the mark; the -1 row is no kachinuki + // bout and never counts. + assert.False(t, GroupDiffers(a, c, BoutGroup(1)), "the bout's group does not hold the mark") + assert.True(t, GroupDiffers(a, c, GroupRecorded)) + assert.Equal(t, []int{1}, RecordedPositions(a.SubResults)) + assert.JSONEq(t, `{"positions":[1]}`, string(GroupValue(a, GroupRecorded))) + assert.JSONEq(t, `{"positions":[]}`, string(GroupValue(c, GroupRecorded))) + + // A server group: valid in the history, never a client's. + assert.True(t, ValidGroup(GroupRecorded)) + assert.True(t, IsServerGroup(GroupRecorded)) + assert.False(t, IsServerGroup(GroupPoints)) + assert.NotContains(t, ScalarGroups, GroupRecorded) + + // Copying a bout keeps the destination's mark; copying the group moves the + // marks and nothing else. + dst := &MatchResult{SubResults: subs()} + dst.SubResults[0].Recorded = false + src := &MatchResult{SubResults: subs()} + src.SubResults[0].Winner = "B1" + CopyGroup(dst, src, BoutGroup(1)) + assert.Equal(t, "B1", dst.SubResults[0].Winner) + assert.False(t, dst.SubResults[0].Recorded, "a bout copy keeps the destination's mark") + CopyGroup(dst, src, GroupRecorded) + assert.True(t, dst.SubResults[0].Recorded) + assert.Equal(t, []string{"M"}, dst.SubResults[1].IpponsA, "a recorded copy moves no score") + // Only a numbered bout can carry the mark: copying the group clears one a + // client put on the representative bout, which no server writer marks. + assert.False(t, dst.SubResults[2].Recorded, "the -1 row never keeps a mark") + + // A row the destination lacks arrives unmarked, whatever the source's + // mark: a bout copy never moves one, so a row a client's payload brings + // in cannot carry it. GroupRecorded alone marks the row afterwards. + one := &MatchResult{SubResults: subs()[1:2]} + CopyGroup(one, a, BoutGroup(1)) + assert.Empty(t, RecordedPositions(one.SubResults), "a copied-in row arrives unmarked") + assert.Equal(t, "A1", SubResultAt(one.SubResults, 1).Winner, "the bout itself is copied") + CopyGroup(one, a, GroupRecorded) + assert.Equal(t, []int{1}, RecordedPositions(one.SubResults)) +} diff --git a/internal/state/testdata/match_groups.json b/internal/state/testdata/match_groups.json index 34d8d4a94..fa872d497 100644 --- a/internal/state/testdata/match_groups.json +++ b/internal/state/testdata/match_groups.json @@ -1,6 +1,7 @@ { - "_comment": "Shared Go/JS table of the groups a match write is merged by (bc-mrgc). Go half: TestMatchGroups_SharedTable (internal/state/match_groups_table_test.go) against state.ScalarGroups (repPickA and repPickB: each side's representative of the representative bout, a change of its own, dated apart from the other side's and from bout:-1), BoutGroup, ParseBoutGroup and ValidGroup. JS half: web-mobile/js/__tests__/match_groups_table.test.jsx against match_groups.jsx's SCALAR_GROUPS, boutGroup, parseBoutGroup and isValidGroup. A client write names its groups by these exact strings in `changed`, and the server refuses a name it does not know, so the two lists must never drift.", + "_comment": "Shared Go/JS table of the groups a match write is merged by (bc-mrgc). Go half: TestMatchGroups_SharedTable (internal/state/match_groups_table_test.go) against state.ScalarGroups (repPickA and repPickB: each side's representative of the representative bout, a change of its own, dated apart from the other side's and from bout:-1), state.ServerGroups (recorded: the kachinuki bouts Record bout closed, written by the server alone and refused in a client's `changed`), BoutGroup, ParseBoutGroup and ValidGroup. JS half: web-mobile/js/__tests__/match_groups_table.test.jsx against match_groups.jsx's SCALAR_GROUPS, SERVER_GROUPS, boutGroup, parseBoutGroup and isValidGroup. A client write names its groups by these exact strings in `changed`, and the server refuses a name it does not know, so the two lists must never drift.", "scalarGroups": ["points", "result", "encho", "flags", "rep", "repPickA", "repPickB"], + "serverGroups": ["recorded"], "bouts": [ { "position": 1, "group": "bout:1" }, { "position": 2, "group": "bout:2" }, diff --git a/scripts/screenshots/recipes/adminsetup.mjs b/scripts/screenshots/recipes/adminsetup.mjs index 6cdc7305c..485353e27 100644 --- a/scripts/screenshots/recipes/adminsetup.mjs +++ b/scripts/screenshots/recipes/adminsetup.mjs @@ -24,20 +24,21 @@ const OVERVIEW_ID = 'up-to-2nd-dan'; const DRAW_ID = 'third-dan-and-above'; const KACHINUKI_ID = 'kachinuki-teams'; -// The create-competition form and the settings screen both render this -// field via competition_fields.jsx's PillGroup/`.field` markup -// (web-mobile/js/admin_setup.jsx:1176-1227), so a field is found by its -// visible label rather than a CSS hook the markup doesn't expose one for. +// Every field on the create-competition form is tied to its visible label +// (htmlFor/id, admin_setup.jsx's AdminCreateCompetition and +// competition_fields.jsx), so a field is found by the label a person reads. async function fillFieldByLabel(page, label, value) { - const field = page.locator('.field').filter({ has: page.locator('.field__label', { hasText: label }) }).first(); - await field.locator('input').first().fill(value); + await page.getByLabel(label, { exact: true }).fill(value); } -// PillGroup renders each option as a `.radio-pill` button carrying its -// label verbatim (web-mobile/js/competition_fields.jsx:68-86), used for -// both the Format and Competition-type groups on the create form. -async function clickPill(page, label) { - await page.locator('.radio-pill', { hasText: label }).first().click(); +// PillGroup renders each option row as a role="group" named by its label, +// and each option as a button carrying aria-pressed +// (competition_fields.jsx's PillGroup and PillButton), so a pill is found +// inside the group it belongs to and the click is checked through aria-pressed. +async function clickPill(page, group, label) { + const pill = page.getByRole('group', { name: group }).getByRole('button', { name: label, exact: true }); + await pill.click(); + await pill.and(page.locator('[aria-pressed="true"]')).waitFor(); } // Seed-rank input: aria-label is `Seed rank for ${p.name}` @@ -168,7 +169,7 @@ export const recipes = [ // Default format is Knockout only (COMPETITION_DEFAULTS.format, // competition_shape.jsx:1202); the committed shot has "Pools + // Knockout" selected. - await clickPill(page, 'Pools + Knockout'); + await clickPill(page, 'Format', 'Pools + Knockout'); }, }, diff --git a/specs/openapi.yaml b/specs/openapi.yaml index f4b8e5533..2753d4e2c 100644 --- a/specs/openapi.yaml +++ b/specs/openapi.yaml @@ -21,6 +21,7 @@ info: - **`GET /api/competitions/{id}/teams/{tid}/lineups/{round}`**: public read; `PUT`/`DELETE` are protected. - **`GET /api/competitions/{id}/teams/{tid}/match-lineups/{matchId}`**: public read; `PUT`/`DELETE` are protected (in self-run mode the `PUT` is open, see below). - **`GET /api/competitions/{id}/teams/{tid}/lineup-in-force/{matchId}`**: public read; the lineup a team fields at a match. + - **`GET /api/competitions/{id}/matches/{mid}/kachinuki-roster`**: public read, in self-run mode too; a kachinuki encounter's fighters each side has left. - **`GET /api/competitions/{id}/swiss/standings`**: public read; always public. - **`GET /api/tournament/announcement`**: public read; returns the currently active admin broadcast announcement (204 when none/expired). @@ -3390,6 +3391,87 @@ paths: '500': $ref: '#/components/responses/InternalError' + /api/competitions/{id}/matches/{mid}/kachinuki-roster: + get: + tags: [matches] + summary: The fighters each side of a kachinuki encounter has left + description: > + For a kachinuki (winner stays on) team match: each side's fighter on + the court (`on`, from the last numbered bout) and the fighters behind + them who have not retired (`remaining`), read from the lineup in force + for the side's team at this match and the bouts so far. A fighter + retires on losing a bout, and both fighters retire on a drawn bout. + The last numbered bout is the one being fought, and both its fighters + are `on` whatever its score (the running autosave already writes a + lead's winner and a 1-1's draw on it) until Record bout records it + (`SubMatchResult.recorded`): then its loser is no longer `on`. A + recorded draw that is still the last bout keeps both its fighters + `on` (Record bout appended nothing after it, so both sides have + nobody behind them, and whether that pair fights on in encho is the + operator's call); an earlier recorded draw, with a bout after it, + has retired both. + `remaining` is in queue order and never holds the fighter on: its + first entry is the fighter the server pairs next if the fighter on + loses. A side whose team has no lineup in force, or one that fields + nobody, answers `lineupFound: false` and an empty `remaining`, because + the bouts alone do not say how many fighters a team has. The answer is + a guide only: the server never ends an encounter on it. A public read, + in a self-run tournament too, since the public score sheet shows it. + `nextPairing` and each side's `exhausted` are the server's own Record + bout decision on the match as stored, read without writing: the + sheet disables Record bout on a recorded bout when `nextPairing` is + false, and shows a recorded win's bout closed when a side is + `exhausted`. + security: [] + parameters: + - $ref: '#/components/parameters/CompetitionId' + - $ref: '#/components/parameters/MatchId' + responses: + '200': + description: Each side's fighters left. + content: + application/json: + schema: + type: object + required: [sideA, sideB, nextPairing] + properties: + sideA: + $ref: '#/components/schemas/KachinukiRosterSide' + sideB: + $ref: '#/components/schemas/KachinukiRosterSide' + nextPairing: + type: boolean + description: > + Whether Record bout, tapped now on the match as stored, + would append a pairing: the decision the server makes + when it is tapped, asked without writing. On a recorded + last bout, false means a further tap writes nothing (a + lineup saved since may make it true). On an unrecorded + last bout it reads that bout's stored outcome as Record + bout would, so it is current only as of the read. False + for a completed match, a match with no numbered bout, and + a last bout with no outcome, no fighter, or a winner the + server cannot place on either side. + '400': + description: > + An invalid competition id, or a competition whose team matches + are not kachinuki. + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '404': + $ref: '#/components/responses/NotFound' + '500': + description: > + The match or the competition's lineups could not be read. A + lineups file that cannot be read is an error here, never an answer + of no lineup. + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + /api/competitions/{id}/matches/{mid}/override-winner: put: tags: [matches] @@ -5517,7 +5599,9 @@ components: correction to an earlier match, and the side's representative pick went with it): score, bulk-score, quick-score, decision, daihyosen-add, daihyosen-remove, reopen, requeue, override-winner, - engi, kachinuki-advance, kachinuki-remove-bout, downstream-reopen, + engi, kachinuki-advance (Record bout appending the next pairing), + kachinuki-record (Record bout appending none, only marking the bout + it closes recorded), kachinuki-remove-bout, downstream-reopen, or engine for a write the server made itself. example: score stamp: @@ -5590,10 +5674,26 @@ components: never alters and that never alters the bout's score; and `bout:` one bout row (1..n, or -1 for the representative bout, without the two representatives or the winner's member id, which the server - derives). The sides, court, schedule and round are not - groups. A write + derives, and without a kachinuki bout's `recorded` mark). `recorded` + is the kachinuki bouts Record bout closed (each numbered row's + `recorded` mark): the server's alone, set by Record bout, taken off + by removing the pairing Record bout appended, and by a write not + older than the mark that raises that bout's overtime or changes its + result (the side its winner names, or its decision), each stamped + like any group; it appears + in the history and in `groupStamps`, and a write that names it in + `changed` is refused with 400 (a write names only a + WritableMergeGroup). The sides, court, schedule and round + are not groups. A write names the groups it changes in `changed`; the match records the stamp of the last applied change to each in `groupStamps`. + pattern: '^(points|result|encho|flags|rep|repPickA|repPickB|recorded|bout:-?[0-9]+)$' + + WritableMergeGroup: + type: string + description: > + A MergeGroup a write may name in `changed`: every group but + `recorded`, which the server alone writes. pattern: '^(points|result|encho|flags|rep|repPickA|repPickB|bout:-?[0-9]+)$' DaihyosenWriteStamp: @@ -5619,6 +5719,58 @@ components: error: type: string + KachinukiRosterSide: + type: object + required: [lineupFound, on, remaining, exhausted] + properties: + exhausted: + type: boolean + description: > + Whether the side has nobody left, as the server's Record bout + decision reads it: the last numbered bout is recorded, it was a + win, this side lost it, and the lineup in force (`lineupFound`) + has nobody behind the loser, so Record bout appended nothing. + False without a lineup (the bouts alone know only the fighters + already seen), after a recorded draw (both fighters stay on), and + while the last bout is not recorded. At most one side is exhausted. + lineupFound: + type: boolean + description: > + Whether a lineup that fields at least one fighter is in force for + the side's team at this match. + on: + description: > + The side's fighter in the last numbered bout, as that bout names + them; null when there is no bout yet, the bout names nobody for + the side, or Record bout has recorded the bout as a win and they + lost it. A recorded draw that is still the last numbered bout keeps + both of its fighters on, in every phase: nothing followed it + because both sides had nobody left, and whether that pair fights on + in encho is the operator's call. Given whether or not + `lineupFound`. + oneOf: + - $ref: '#/components/schemas/KachinukiRosterFighter' + - type: 'null' + remaining: + type: array + description: > + The side's fighters behind the fighter on who have not retired, in + queue order: the first is next in if the fighter on loses. Never + holds the fighter on. Empty when `lineupFound` is false. + items: + $ref: '#/components/schemas/KachinukiRosterFighter' + + KachinukiRosterFighter: + type: object + required: [name] + properties: + name: + type: string + description: The name the lineup or bout holds; empty for a team member not named yet. + memberId: + type: string + description: The team member's id, when the lineup position or bout carries one. + DownstreamKnockoutPlayedError: type: object description: > @@ -6422,7 +6574,7 @@ components: type: array writeOnly: true items: - $ref: '#/components/schemas/MergeGroup' + $ref: '#/components/schemas/WritableMergeGroup' description: > The groups this write changes (PUT /score and each /bulk-score entry). The server applies each one only if the write's @@ -6762,6 +6914,30 @@ components: rejects a non-zero `periodCount` on any numbered bout (fixed regulation time, no overtime). Hantei is NOT relaxed for kachinuki: it remains daihyosen-only. + recorded: + type: boolean + readOnly: true + description: > + Kachinuki only: true on a numbered bout the operator closed with + Record bout (`kachinukiBoutFinal`). The server is its only + writer, and the marks are a merge group of their own + (`recorded`, see MergeGroup), stamped on every change: Record + bout sets it, in the same write as the next pairing or on its + own when no pairing is appended because a side has nobody left; + removing the pairing that Record bout appended + (`DELETE .../kachinuki-bout`) clears it; and a score write that + raises that bout's overtime (`encho.periodCount` up, the bout + being fought again) or changes its result (the side its winner + names, or its decision) clears it, unless the write is older + than the mark; a correction that keeps the result (another waza + letter for the same winner) keeps it. A client cannot set or clear it: a row that leaves the + field out keeps it, a row that sends `true` for a bout the + server has not recorded does not set it, and a write that names + the `recorded` group in `changed` is refused with 400. Absent + (false) on every other bout, + including a last bout Record bout has not recorded yet: the kachinuki roster + (`GET .../kachinuki-roster`) reads the last bout's result only + once it is recorded. BracketMatch: type: object diff --git a/web-mobile/check-imports.mjs b/web-mobile/check-imports.mjs index e2419e34b..dd31094af 100644 --- a/web-mobile/check-imports.mjs +++ b/web-mobile/check-imports.mjs @@ -93,6 +93,18 @@ const CHECK_MODULES = [ // and side_marks.jsx; a typo'd or renamed export in any of them would fail // only in a native browser. 'admin_schedule_score_editor.jsx', + // bc-lbla: the shared competition-config controls import useStableId from + // the stable_id.jsx leaf, which the glossary's imports too. + 'competition_fields.jsx', + 'glossary.jsx', + // PR #467: the announcement form imports useStableId too; the bracket + // helpers import BRONZE_MATCH_ID from write_result.jsx and bronzeFeederSemis + // from the bronze_feeders.jsx leaf; the kachinuki advisory line imports the + // squad-member label and the lineup resolver (and the draw test from + // api_serializers.jsx). + 'admin_announcement.jsx', + 'admin_helpers.jsx', + 'kachinuki_advisory.jsx', ]; // --------------------------------------------------------------------------- diff --git a/web-mobile/css/styles.css b/web-mobile/css/styles.css index 9b6a2a6fd..5ddfbfe8d 100644 --- a/web-mobile/css/styles.css +++ b/web-mobile/css/styles.css @@ -8424,6 +8424,15 @@ abbr.msb-lab { text-decoration: none; cursor: help; } color: var(--ink-1); } +/* bc-kfnl: the advisory line above the live kachinuki bout (fighters on, left, + next). A guide, not a warning: muted ink, no tint, no motion. */ +.kachinuki-advisory { + margin: 4px 0 6px; + font-size: 12px; + line-height: 1.4; + color: var(--ink-2); +} + /* bc-dnst: the bout list's two secondary actions share one row, each with a short visible hint beside it. An error takes the full width so it reads as its own line. */ diff --git a/web-mobile/js/__tests__/admin_helpers.test.jsx b/web-mobile/js/__tests__/admin_helpers.test.jsx index 14d146a32..614965258 100644 --- a/web-mobile/js/__tests__/admin_helpers.test.jsx +++ b/web-mobile/js/__tests__/admin_helpers.test.jsx @@ -192,6 +192,37 @@ describe('isPendingBracketMatch', () => { expect(isPendingBracketMatch({ status: "scheduled", sideA: "Alice", sideB: "" })).toBe(false); expect(isPendingBracketMatch(null)).toBe(false); }); + + // bc-bzlb: the 3rd-place match has no "Winner of" slot; its sides stay empty + // until the semi-finals (the round before the final) seat their losers, semi 0 + // into sideA and semi 1 into sideB. It waits while an empty side's semi-final + // is unplayed, so it needs the competition's rounds. + describe('the 3rd-place match', () => { + const empty = { id: "", name: "" }; + const bronze = (sideA, sideB) => ({ id: "m-bronze", status: "scheduled", sideA, sideB }); + const rounds = (s0, s1) => [ + [{ id: "m-r1-0", status: s0 }, { id: "m-r1-1", status: s1 }], + [{ id: "m-r2-0", status: "scheduled" }], + ]; + + it('is pending while its sides are empty and the semi-finals are unplayed', () => { + expect(isPendingBracketMatch(bronze(empty, empty), rounds("scheduled", "scheduled"))).toBe(true); + // One semi-final done: its loser is seated, the other side still waits. + expect(isPendingBracketMatch(bronze({ id: "p1", name: "Alice" }, empty), rounds("completed", "running"))).toBe(true); + }); + + it('is not pending once both sides are seated', () => { + expect(isPendingBracketMatch(bronze({ id: "p1", name: "Alice" }, { id: "p2", name: "Bob" }), rounds("completed", "completed"))).toBe(false); + }); + + it('is not pending when an empty side\'s semi-final is completed (a bye semi-final has no loser)', () => { + expect(isPendingBracketMatch(bronze({ id: "p1", name: "Alice" }, empty), rounds("completed", "completed"))).toBe(false); + }); + + it('is not pending without the rounds', () => { + expect(isPendingBracketMatch(bronze(empty, empty))).toBe(false); + }); + }); }); describe('compMatchStats', () => { diff --git a/web-mobile/js/__tests__/bracket_slot_label.test.jsx b/web-mobile/js/__tests__/bracket_slot_label.test.jsx index a26a73b77..61ba73e48 100644 --- a/web-mobile/js/__tests__/bracket_slot_label.test.jsx +++ b/web-mobile/js/__tests__/bracket_slot_label.test.jsx @@ -1,6 +1,7 @@ import { describe, it, expect } from 'vitest'; -import { slotDisplayName, makeSlotLabeller, bracketSlotLabeller } from '../bracket.jsx'; +import { slotDisplayName, makeSlotLabeller, bracketSlotLabeller, bronzeSlotLabels, buildDisplayModel } from '../bracket.jsx'; import { hasBothSides, isPendingBracketMatch, bracketFullyComplete, compMatchStats } from '../admin_helpers.jsx'; +import { bronzeFeederSemis } from '../bronze_feeders.jsx'; // The engine writes an unresolved bracket feeder as "Winner of r-m" // (engine.winnerOfPlaceholder). That is a WIRE value: an internal round/index @@ -117,6 +118,65 @@ describe('makeSlotLabeller: resolves a slot to the number the bracket prints', ( // admin_helpers.BRACKET_PLACEHOLDER_RE and display_helpers.DISPLAY_PLACEHOLDER_RE // all decide playability/schedulability from it. Relabelling is DISPLAY-ONLY, so // every one of those classifications must be untouched. +// bc-bzlb: the 3rd-place match's sides are EMPTY until a semi-final's loser is +// seated there by position (semi 0 -> sideA, semi 1 -> sideB, Go +// propagateBracketWinner), so they are named after the semi-final cards. +describe('bronzeSlotLabels: the 3rd-place match names the semi-finals that fill it', () => { + it('reads "Loser of M" for numbered semi-finals, by position', () => { + const rounds = [ + [{ id: 'm-r1-0', matchNumber: 1 }, { id: 'm-r1-1', matchNumber: 2 }, { id: 'm-r1-2', matchNumber: 3 }, { id: 'm-r1-3', matchNumber: 4 }], + [{ id: 'm-r2-0', matchNumber: 5 }, { id: 'm-r2-1', matchNumber: 6 }], + [{ id: 'm-r3-0', matchNumber: 7 }], + ]; + expect(bronzeSlotLabels(rounds)).toEqual({ a: 'Loser of M5', b: 'Loser of M6' }); + }); + + it('degrades a hidden bye semi-final (no number) to TBD on that side only', () => { + // fivePlayerRounds: m-r2-0 is the latent-bye semi-final, m-r2-1 is M3. + expect(bronzeSlotLabels(fivePlayerRounds())).toEqual({ a: 'TBD', b: 'Loser of M3' }); + }); + + it('labels nothing when there is no semi-final round', () => { + // No semi-final to wait on: an empty side keeps its plain rendering. + expect(bronzeSlotLabels([[{ id: 'm-r1-0', matchNumber: 1 }]])).toEqual({ a: '', b: '' }); + expect(bronzeSlotLabels(undefined)).toEqual({ a: '', b: '' }); + }); + + // Review round 9, finding 4: a side is labelled only while its semi-final + // is still to finish (isPendingBracketMatch's condition). A completed semi + // seated its loser, or (a bye semi) never gives one, and once the bronze is + // played both semis are long done. + it('labels only a side still waiting on its semi-final', () => { + const rounds = (statusA, statusB) => [ + [{ id: 'm-r1-0', matchNumber: 1 }, { id: 'm-r1-1', matchNumber: 2 }, { id: 'm-r1-2', matchNumber: 3 }, { id: 'm-r1-3', matchNumber: 4 }], + [{ id: 'm-r2-0', matchNumber: 5, status: statusA }, { id: 'm-r2-1', matchNumber: 6, status: statusB }], + [{ id: 'm-r3-0', matchNumber: 7 }], + ]; + expect(bronzeSlotLabels(rounds('completed', 'scheduled'))).toEqual({ a: '', b: 'Loser of M6' }); + expect(bronzeSlotLabels(rounds('running', 'completed'))).toEqual({ a: 'Loser of M5', b: '' }); + expect(bronzeSlotLabels(rounds('completed', 'completed'))).toEqual({ a: '', b: '' }); + // The same condition the pending-bracket filter asks of the bronze. + const bronze = { id: 'm-bronze', status: 'scheduled', sideA: { name: '' }, sideB: { name: '' } }; + const r = rounds('completed', 'scheduled'); + expect(isPendingBracketMatch(bronze, r)).toBe(true); + expect(isPendingBracketMatch(bronze, rounds('completed', 'completed'))).toBe(false); + }); + + it('labels nothing for a completed bye semi-final', () => { + const rounds = fivePlayerRounds(); + rounds[1][0] = { ...rounds[1][0], status: 'completed' }; + expect(bronzeSlotLabels(rounds)).toEqual({ a: '', b: 'Loser of M3' }); + }); + + it('reads the numbers from a display model the caller already holds', () => { + const rounds = fivePlayerRounds(); + const numbers = buildDisplayModel(rounds).matchNumById; + expect(bronzeSlotLabels(rounds, numbers)).toEqual(bronzeSlotLabels(rounds)); + // The numbers handed in are the ones used, not rebuilt from the rounds. + expect(bronzeSlotLabels(rounds, { 'm-r2-1': 9 })).toEqual({ a: 'TBD', b: 'Loser of M9' }); + }); +}); + describe('placeholder filtering is unchanged by the display label', () => { const feederMatch = () => ({ id: 'm-r2-1', @@ -153,3 +213,21 @@ describe('placeholder filtering is unchanged by the display label', () => { expect(hasBothSides(relabelled)).toBe(true); }); }); + +// The one owner of which semi-final feeds which bronze side: index 0 feeds +// side A, index 1 side B (Go propagateBracketWinner). The pending-row test and +// the empty-side labels both ask it. +describe('bronzeFeederSemis', () => { + const r0 = [{ id: 'r0-m0' }, { id: 'r0-m1' }, { id: 'r0-m2' }, { id: 'r0-m3' }]; + const semis = [{ id: 'r1-m0' }, { id: 'r1-m1' }]; + const final = [{ id: 'r2-m0' }]; + it('pairs the first semi-final with side A and the second with side B', () => { + expect(bronzeFeederSemis([r0, semis, final])).toEqual({ a: semis[0], b: semis[1] }); + expect(bronzeFeederSemis([semis, final])).toEqual({ a: semis[0], b: semis[1] }); + }); + it('is null on each side when there is no round before the final', () => { + expect(bronzeFeederSemis([final])).toEqual({ a: null, b: null }); + expect(bronzeFeederSemis(undefined)).toEqual({ a: null, b: null }); + expect(bronzeFeederSemis([[semis[0]], final])).toEqual({ a: semis[0], b: null }); + }); +}); diff --git a/web-mobile/js/__tests__/kachinuki_advisory_model.test.jsx b/web-mobile/js/__tests__/kachinuki_advisory_model.test.jsx new file mode 100644 index 000000000..f6c3ed8da --- /dev/null +++ b/web-mobile/js/__tests__/kachinuki_advisory_model.test.jsx @@ -0,0 +1,255 @@ +// bc-kfnl: the advisory line's pure model (kachinuki_advisory.jsx). The queue is +// the server's, split there into each side's fighter on and the queue behind +// them (the client never decides which entry is the fighter on); this pins how +// it is shaped for display: every fighter named with their +// team-member number as the bout rows name them, "last fighter" when the lineup +// has nobody behind the fighter on, a side with no lineup showing its fighter +// alone, and no line when neither side has a lineup. The line never asks for a +// lineup. +import { describe, it, expect } from 'vitest'; +import { + kachinukiAdvisoryModel, + kachinukiAdvisorySide, + kachinukiFighterKnown, + kachinukiFighterLabel, + kachinukiNoPairingText, + kachinukiRecordedBoutCloses, + kachinukiRosterKey, + lastNumberedBout, + storedBoutRecorded, +} from '../kachinuki_advisory.jsx'; + +// Review round 4, finding 8: the one client owner of "the stored last numbered +// bout", the twin of engine.lastNumberedBout. +describe('lastNumberedBout', () => { + it('is the highest positive position, whatever the order, never the representative bout', () => { + const rows = [{ position: 2 }, { position: -1 }, { position: 3 }, { position: 1 }]; + expect(lastNumberedBout(rows)).toBe(rows[2]); + expect(lastNumberedBout([{ position: -1 }])).toBeNull(); + expect(lastNumberedBout(undefined)).toBeNull(); + }); + // Review round 5, finding 8: the same rule as engine.lastNumberedBout + // (TestLastNumberedBout_OutOfOrderLog), so an out-of-order log reads the + // same live bout on both sides. + it('reads an out-of-order log by position, not by the array tail, as the server does', () => { + const rows = [{ position: 1 }, { position: 3 }, { position: 2 }, { position: -1 }]; + expect(lastNumberedBout(rows)).toBe(rows[1]); + const dup = [{ position: 2, tag: 'first' }, { position: 2, tag: 'second' }, { position: 0 }]; + expect(lastNumberedBout(dup)).toBe(dup[0]); + expect(lastNumberedBout([{ position: 0 }])).toBeNull(); + }); +}); + +// Review round 4, finding 1: only a recorded WIN may close the last bout. +describe('kachinukiRecordedBoutCloses', () => { + it('a recorded win closes, a recorded draw or an unrecorded bout does not', () => { + expect(kachinukiRecordedBoutCloses([{ position: 1, recorded: true, decision: 'fought' }])).toBe(true); + expect(kachinukiRecordedBoutCloses([{ position: 1, recorded: true, decision: 'hikiwake' }])).toBe(false); + expect(kachinukiRecordedBoutCloses([{ position: 1, decision: 'fought' }])).toBe(false); + expect(kachinukiRecordedBoutCloses([{ position: 1, recorded: true }, { position: 2 }])).toBe(false); + expect(kachinukiRecordedBoutCloses([])).toBe(false); + }); +}); + +const squadA = [ + { id: 'a1', index: 1, name: 'Kudo' }, + { id: 'a2', index: 2, name: '' }, + { id: 'a3', index: 3, name: 'Mori' }, +]; +const squadB = [ + { id: 'b1', index: 1, name: 'Taki' }, + { id: 'b2', index: 2, name: 'Ueda' }, + { id: 'b3', index: 3, name: 'Sato' }, +]; + +const side = (lineupFound, ...remaining) => ({ lineupFound, remaining }); + +describe('kachinukiFighterLabel', () => { + it('names a fighter by team-member number then current name', () => { + expect(kachinukiFighterLabel({ squad: squadB, teamNumber: 'T1', memberId: 'b2', name: 'Old name' })).toBe('T1.2 Ueda'); + }); + it('reads a member with no name by number alone', () => { + expect(kachinukiFighterLabel({ squad: squadA, teamNumber: 'T2', memberId: 'a2', name: '' })).toBe('T2.2'); + }); + it('reads by name alone before the draw gives the team a number', () => { + expect(kachinukiFighterLabel({ squad: squadA, teamNumber: '', memberId: 'a1', name: 'Kudo' })).toBe('Kudo'); + }); +}); + +describe('kachinukiAdvisorySide', () => { + it('names the server\'s fighter on and the queue behind them, next first', () => { + const withOn = kachinukiAdvisorySide({ + side: { ...side(true, { name: 'Ueda', memberId: 'b2' }, { name: 'Sato', memberId: 'b3' }), on: { name: 'Old name', memberId: 'b1' } }, + squad: squadB, teamNumber: 'T1', + }); + expect(withOn.text).toBe('T1.1 Taki on, 2 left (T1.2 Ueda, T1.3 Sato)'); + }); + it('takes the queue as the server split it, never matching the fighter on itself', () => { + // A queue entry with the fighter on's name is listed: deciding which entry + // is the fighter on is the server's (engine.KachinukiRoster). + const out = kachinukiAdvisorySide({ + side: { ...side(true, { name: 'Taki', memberId: 'x9' }), on: { name: 'Taki' } }, + squad: [], teamNumber: '', + }); + expect(out.text).toBe('Taki on, 1 left (Taki)'); + }); + it('reads "last fighter" when nobody is left behind the fighter on', () => { + const out = kachinukiAdvisorySide({ + side: { ...side(true), on: { name: 'Kudo', memberId: 'a1' } }, + squad: squadA, teamNumber: 'T2', + }); + expect(out.text).toBe('T2.1 Kudo on, last fighter'); + }); + it('reads "no fighters left" once a recorded bout has taken the side\'s last fighter off', () => { + const out = kachinukiAdvisorySide({ side: { ...side(true), on: null }, squad: squadA, teamNumber: 'T2' }); + expect(out.text).toBe('no fighters left'); + }); + it('shows only the fighter on, with no count, for a side with no lineup', () => { + const out = kachinukiAdvisorySide({ + side: { ...side(false), on: { name: 'Kudo', memberId: 'a1' } }, + squad: squadA, teamNumber: 'T2', + }); + expect(out.text).toBe('T2.1 Kudo on'); + expect(out.left).toBeNull(); + }); + it('says nothing for a side with no lineup and no fighter on', () => { + expect(kachinukiAdvisorySide({ side: { ...side(false), on: null }, squad: [], teamNumber: '' })).toBeNull(); + expect(kachinukiAdvisorySide({ side: { ...side(false), on: { name: '', memberId: '' } }, squad: [], teamNumber: '' })).toBeNull(); + expect(kachinukiAdvisorySide({ side: null, squad: [], teamNumber: '' })).toBeNull(); + }); +}); + +describe('kachinukiAdvisoryModel', () => { + const roster = { + sideA: { ...side(true), on: { name: 'Kudo', memberId: 'a1' } }, + sideB: { ...side(true, { name: 'Ueda', memberId: 'b2' }, { name: 'Sato', memberId: 'b3' }), on: { name: 'Taki', memberId: 'b1' } }, + }; + const numbers = { squadA, squadB, teamNumberA: 'T2', teamNumberB: 'T1' }; + it('reads Shiro (side B) first, then Aka (side A)', () => { + const out = kachinukiAdvisoryModel({ roster, ...numbers }); + expect(out.text).toBe('Shiro: T1.1 Taki on, 2 left (T1.2 Ueda, T1.3 Sato) · Aka: T2.1 Kudo on, last fighter'); + }); + it('is null when neither side has a lineup', () => { + expect(kachinukiAdvisoryModel({ + roster: { sideA: { ...side(false), on: roster.sideA.on }, sideB: { ...side(false), on: roster.sideB.on } }, + ...numbers, + })).toBeNull(); + }); + it('is null with no roster', () => { + expect(kachinukiAdvisoryModel({ roster: null })).toBeNull(); + }); + it('shows a side with no lineup by its fighter on alone', () => { + const out = kachinukiAdvisoryModel({ + roster: { sideA: { ...side(false), on: roster.sideA.on }, sideB: roster.sideB }, + ...numbers, + }); + expect(out.text).toBe('Shiro: T1.1 Taki on, 2 left (T1.2 Ueda, T1.3 Sato) · Aka: T2.1 Kudo on'); + }); + it('never asks for a lineup, whatever the sides hold', () => { + const shapes = [ + { sideA: { ...side(false), on: roster.sideA.on }, sideB: roster.sideB }, + { sideA: roster.sideA, sideB: { ...side(false), on: null } }, + roster, + { sideA: { ...side(true), on: null }, sideB: { ...side(true), on: null } }, + ]; + for (const r of shapes) { + const out = kachinukiAdvisoryModel({ roster: r, ...numbers }); + expect(out?.text || '').not.toMatch(/lineup|incomplete|add/i); + } + }); +}); + +describe('kachinukiRosterKey', () => { + const log = [ + { position: 1, sideA: 'Kudo', sideAMemberId: 'a1', sideB: 'Taki', sideBMemberId: 'b1', winner: 'Kudo', decision: 'fought' }, + { position: 2, sideA: 'Kudo', sideAMemberId: 'a1', sideB: 'Ueda', sideBMemberId: 'b2' }, + ]; + const key = kachinukiRosterKey; + it('is the same for an equal bout log in a new object', () => { + expect(key('m1', log)).toBe(key('m1', JSON.parse(JSON.stringify(log)))); + }); + it('reads nothing while the live bout is being fought, whatever its score', () => { + // The running autosave writes a 1-0 lead's winner and a 1-1's hikiwake on + // the live row: neither is a result until Record bout records the bout, + // and the server keeps both fighters on meanwhile (bc-kfnl). + const pointed = log.map((s, i) => (i === 1 ? { ...s, ipponsA: ['M'] } : s)); + expect(key('m1', pointed)).toBe(key('m1', log)); + const lead = log.map((s, i) => (i === 1 ? { ...s, ipponsA: ['M'], winner: 'Kudo', winnerMemberId: 'a1' } : s)); + expect(key('m1', lead)).toBe(key('m1', log)); + const tied = log.map((s, i) => (i === 1 ? { ...s, ipponsA: ['M'], ipponsB: ['K'], decision: 'hikiwake' } : s)); + expect(key('m1', tied)).toBe(key('m1', log)); + }); + it('reads again when Record bout records the live bout, and follows its result after', () => { + const won = log.map((s, i) => (i === 1 ? { ...s, winner: 'Kudo', winnerMemberId: 'a1', decision: 'fought' } : s)); + const recorded = won.map((s, i) => (i === 1 ? { ...s, recorded: true } : s)); + expect(key('m1', recorded)).not.toBe(key('m1', won)); + // More points on a recorded bout that keep its result read nothing. + const more = recorded.map((s, i) => (i === 1 ? { ...s, ipponsA: ['M', 'K'] } : s)); + expect(key('m1', more)).toBe(key('m1', recorded)); + // A correction of a recorded bout's result reads again. + const corrected = recorded.map((s, i) => (i === 1 ? { ...s, winner: 'Ueda', winnerMemberId: 'b2' } : s)); + expect(key('m1', corrected)).not.toBe(key('m1', recorded)); + const drawn = recorded.map((s, i) => (i === 1 ? { ...s, winner: '', winnerMemberId: '', decision: 'hikiwake' } : s)); + expect(key('m1', drawn)).not.toBe(key('m1', recorded)); + }); + it('follows the result of every bout before the last, recorded or not', () => { + // A bout before the last retires by its result on the server, a row + // written before the recorded mark existed included. + const corrected = log.map((s, i) => (i === 0 ? { ...s, winner: 'Taki', decision: 'fought' } : s)); + expect(key('m1', corrected)).not.toBe(key('m1', log)); + }); + it('follows an appended bout', () => { + const appended = [...log, { position: 3, sideA: 'Mori', sideAMemberId: 'a3', sideB: 'Ueda', sideBMemberId: 'b2' }]; + expect(key('m1', appended)).not.toBe(key('m1', log)); + }); + it('differs by match and leaves the representative bout out', () => { + expect(key('m2', log)).not.toBe(key('m1', log)); + expect(key('m1', [...log, { position: -1, sideA: 'R', sideB: 'T' }])).toBe(key('m1', log)); + }); +}); + +describe('storedBoutRecorded', () => { + it('reads the server\'s recorded mark on the bout at a position', () => { + const m = { subResults: [{ position: 1, recorded: true }, { position: 2 }, { position: -1, recorded: true }] }; + expect(storedBoutRecorded(m, 1)).toBe(true); + expect(storedBoutRecorded(m, 2)).toBe(false); + expect(storedBoutRecorded(m, 3)).toBe(false); + expect(storedBoutRecorded({}, 1)).toBe(false); + expect(storedBoutRecorded(null, 1)).toBe(false); + }); +}); + +// bc-kbrc, review round 9: the line in place of the live bout once the last +// bout is recorded and no pairing follows. It reads the SERVER's verdict +// (nextPairing, exhausted), never re-derives it from the queues. +describe('kachinukiNoPairingText', () => { + const side = (exhausted) => ({ lineupFound: true, on: null, remaining: [], exhausted }); + it('names the side the server says has nobody left', () => { + expect(kachinukiNoPairingText({ nextPairing: false, sideA: side(false), sideB: side(true) })) + .toBe('Shiro has no fighters left. End match finishes the encounter.'); + expect(kachinukiNoPairingText({ nextPairing: false, sideA: side(true), sideB: side(false) })) + .toBe('Aka has no fighters left. End match finishes the encounter.'); + }); + it('is null when a pairing would follow, or the verdict is absent', () => { + expect(kachinukiNoPairingText({ nextPairing: true, sideA: side(true), sideB: side(false) })).toBeNull(); + expect(kachinukiNoPairingText({ sideA: side(true), sideB: side(false) })).toBeNull(); + }); + it('is null without a roster or an exhausted side, whatever the queues say', () => { + expect(kachinukiNoPairingText(null)).toBeNull(); + // Both queues empty, nobody on: a recorded draw, or no lineup's knowledge. + // Only the server's exhausted flag names a side. + expect(kachinukiNoPairingText({ nextPairing: false, sideA: side(false), sideB: side(false) })).toBeNull(); + }); +}); + +// Review round 9, finding 7: the one "the roster names a fighter" test. +describe('kachinukiFighterKnown', () => { + it('is true for a member id or a name', () => { + expect(kachinukiFighterKnown({ memberId: 'm1', name: '' })).toBe(true); + expect(kachinukiFighterKnown({ name: 'Kudo' })).toBe(true); + }); + it('is false for nothing', () => { + expect(kachinukiFighterKnown(null)).toBe(false); + expect(kachinukiFighterKnown({ name: '', memberId: '' })).toBe(false); + }); +}); diff --git a/web-mobile/js/__tests__/kachinuki_bout_mode.test.jsx b/web-mobile/js/__tests__/kachinuki_bout_mode.test.jsx index 18b559225..506b68d45 100644 --- a/web-mobile/js/__tests__/kachinuki_bout_mode.test.jsx +++ b/web-mobile/js/__tests__/kachinuki_bout_mode.test.jsx @@ -138,6 +138,9 @@ describe('isKachinukiBoutRemovable', () => { it('is false outside bout mode (correction / non-kachinuki / completed)', () => { expect(isKachinukiBoutRemovable({ boutMode: false, currentBoutPlayed: false, lastScoredIdx: 0 })).toBe(false); }); + it('is false with no live bout (the last bout recorded, no pairing, bc-kbrc)', () => { + expect(isKachinukiBoutRemovable({ boutMode: true, currentBoutPlayed: false, lastScoredIdx: 1, hasLiveBout: false })).toBe(false); + }); }); // The single played-bout primitive (operator input determines the bout diff --git a/web-mobile/js/__tests__/kachinuki_visible_positions.test.jsx b/web-mobile/js/__tests__/kachinuki_visible_positions.test.jsx index 6cc550d5f..e5498f2d0 100644 --- a/web-mobile/js/__tests__/kachinuki_visible_positions.test.jsx +++ b/web-mobile/js/__tests__/kachinuki_visible_positions.test.jsx @@ -28,6 +28,49 @@ describe('kachinukiVisiblePositions', () => { })).toEqual(['1']); }); + // bc-kbrc: Record bout recorded the last bout and appended no pairing. + it('shows no live bout when the last bout is recorded and may close', () => { + const subResults = [{ position: 1, recorded: true }, { position: 2, recorded: true }]; + expect(kachinukiVisiblePositions({ + positions: POSITIONS, daihyosenIdx: -1, subResults, + isComplete: false, isPlayedAt: playedAt([true, true]), closeRecordedLast: true, + })).toEqual([]); + }); + + // Review round 4, finding 1: a recorded DRAW still last never closes, in any + // phase (Encho on it is the operator's call). + it('keeps a recorded draw as the last bout live even when it may close', () => { + const subResults = [{ position: 1, recorded: true }, { position: 2, recorded: true, decision: 'hikiwake' }]; + expect(kachinukiVisiblePositions({ + positions: POSITIONS, daihyosenIdx: -1, subResults, + isComplete: false, isPlayedAt: playedAt([true, true]), closeRecordedLast: true, + })).toEqual(['2']); + }); + + it('reads the last bout by position, whatever the array order', () => { + const subResults = [{ position: 2, recorded: true }, { position: 1, recorded: true }]; + expect(kachinukiVisiblePositions({ + positions: POSITIONS, daihyosenIdx: -1, subResults, + isComplete: false, isPlayedAt: playedAt([true, true]), closeRecordedLast: true, + })).toEqual([]); + }); + + it('keeps a recorded last bout live when it may not close (a knockout tie)', () => { + const subResults = [{ position: 1, recorded: true }, { position: 2, recorded: true }]; + expect(kachinukiVisiblePositions({ + positions: POSITIONS, daihyosenIdx: -1, subResults, + isComplete: false, isPlayedAt: playedAt([true, true]), closeRecordedLast: false, + })).toEqual(['2']); + }); + + it('a bout added by hand after a recorded last bout is the live bout', () => { + const subResults = [{ position: 1, recorded: true }, { position: 2, recorded: true }, { position: 3 }]; + expect(kachinukiVisiblePositions({ + positions: POSITIONS, daihyosenIdx: -1, subResults, + isComplete: false, isPlayedAt: playedAt([true, true, false]), closeRecordedLast: true, + })).toEqual(['3']); + }); + it('shows the last bout when every server bout is already scored', () => { const subResults = [{ position: 1 }, { position: 2 }, { position: 3 }]; expect(kachinukiVisiblePositions({ diff --git a/web-mobile/js/__tests__/match_groups_table.test.jsx b/web-mobile/js/__tests__/match_groups_table.test.jsx index 7a9449270..a4759a72a 100644 --- a/web-mobile/js/__tests__/match_groups_table.test.jsx +++ b/web-mobile/js/__tests__/match_groups_table.test.jsx @@ -11,7 +11,7 @@ import { describe, it, expect } from 'vitest'; import { readFileSync } from 'node:fs'; import { resolve } from 'node:path'; import { - SCALAR_GROUPS, boutGroup, parseBoutGroup, isValidGroup, + SCALAR_GROUPS, SERVER_GROUPS, GROUP_RECORDED, boutGroup, parseBoutGroup, isValidGroup, changedGroups, unionChanged, groupLabel, groupsLabel, } from '../match_groups.jsx'; import { toBackendMatchResult, matchWire } from '../api_serializers.jsx'; @@ -22,6 +22,7 @@ const table = JSON.parse(readFileSync( describe('the shared group table', () => { it('is not empty', () => { expect(table.scalarGroups.length).toBeGreaterThan(0); + expect(table.serverGroups.length).toBeGreaterThan(0); expect(table.bouts.length).toBeGreaterThan(0); expect(table.invalid.length).toBeGreaterThan(0); }); @@ -31,6 +32,15 @@ describe('the shared group table', () => { for (const g of table.scalarGroups) expect(isValidGroup(g)).toBe(true); }); + it('names the server\'s own groups, which no client write states', () => { + expect(SERVER_GROUPS).toEqual(table.serverGroups); + for (const g of table.serverGroups) { + expect(isValidGroup(g)).toBe(true); + expect(SCALAR_GROUPS).not.toContain(g); + } + expect(groupLabel(GROUP_RECORDED)).toBe('the recorded bouts'); + }); + it.each(table.bouts)('bout $position is "$group"', ({ position, group }) => { expect(boutGroup(position)).toBe(group); expect(parseBoutGroup(group)).toBe(position); @@ -89,6 +99,17 @@ describe('changedGroups', () => { expect(changedGroups(silent, stored)).toEqual([]); }); + it('a kachinuki bout\'s recorded mark is never a change of the bout', () => { + // Only the server writes it (Record bout): the sheet echoes it, and a + // baseline agreed before the mark arrived must not name the bout (bc-kfnl). + const row = { position: 1, sideA: 'Kudo', sideB: 'Taki', ipponsA: ['M'], ipponsB: [], hansokuA: 0, hansokuB: 0, winner: 'Kudo', decision: 'fought' }; + const agreed = { ...base, subResults: [row] }; + expect(changedGroups({ ...base, subResults: [{ ...row, recorded: true }] }, agreed)).toEqual([]); + expect(changedGroups({ ...base, subResults: [row] }, { ...base, subResults: [{ ...row, recorded: true }] })).toEqual([]); + // A real change on the same row still names it. + expect(changedGroups({ ...base, subResults: [{ ...row, recorded: true, ipponsA: ['M', 'K'] }] }, agreed)).toEqual(['bout:1']); + }); + it('a baseline may be a function of the group', () => { const next = { ...base, ipponsB: ['K'], encho: { periodCount: 1 } }; const perGroup = (g) => (g === 'encho' ? { encho: { periodCount: 1 } } : base); diff --git a/web-mobile/js/__tests__/render/admin_bracket_layout.render.test.jsx b/web-mobile/js/__tests__/render/admin_bracket_layout.render.test.jsx index 8689b0c23..ee6505196 100644 --- a/web-mobile/js/__tests__/render/admin_bracket_layout.render.test.jsx +++ b/web-mobile/js/__tests__/render/admin_bracket_layout.render.test.jsx @@ -14,6 +14,7 @@ const STUBBED_GLOBALS = { BracketTree: () => null, // per-test override drives the pick MatchCard: ({ match }) =>
, bracketColumnCount: () => 3, + bronzeSlotLabels: () => ({ a: 'TBD', b: 'TBD' }), CourtPicker: () => null, API: { fetchCompetitionDetails: vi.fn().mockResolvedValue(null), diff --git a/web-mobile/js/__tests__/render/admin_competition.render.test.jsx b/web-mobile/js/__tests__/render/admin_competition.render.test.jsx index 3ec9993b9..3103ce4e4 100644 --- a/web-mobile/js/__tests__/render/admin_competition.render.test.jsx +++ b/web-mobile/js/__tests__/render/admin_competition.render.test.jsx @@ -1,5 +1,5 @@ import React from 'react'; -import { render, act, fireEvent, waitFor } from '@testing-library/react'; +import { render, act, fireEvent, waitFor, screen, within } from '@testing-library/react'; import { describe, it, expect, vi, beforeAll, afterAll, afterEach } from 'vitest'; import { installWindowStubs } from '../helpers/stub_globals.js'; // Pure helper (no fetch, no DOM, no globals), imported directly so the @@ -1176,3 +1176,41 @@ describe('AdminCompetition page-head subtitle (bc-shfu)', () => { .toBe('Individual · 2 players · A'); }); }); + +// bc-lbla: the settings twin of the create form's hand-written fields is +// reachable by label, and its two hand-written pill rows are named groups +// whose pills say which are chosen (aria-pressed through PillButton). +describe('AdminSettings names its hand-written fields (bc-lbla)', () => { + const pressed = (group) => within(group).getAllByRole('button') + .filter((b) => b.getAttribute('aria-pressed') === 'true') + .map((b) => b.textContent.trim()); + + it('Display name, Day and Start time are reachable by their labels', async () => { + await mountSection('settings'); + expect(screen.getByLabelText('Display name').value).toBe('Mudansha'); + expect(screen.getByLabelText('Day').type).toBe('date'); + expect(screen.getByLabelText('Start time').value).toBe('09:00'); + }); + + it('Day is the day select when the tournament has days', async () => { + await mountSection('settings', { tournament: { date: '10-08-2026', durationDays: 2 } }); + expect(screen.getByLabelText('Day').tagName).toBe('SELECT'); + }); + + it('Assigned shiaijo is a named group, the orphan pill included', async () => { + const comp = makeCompetition({ courts: ['A', 'D'], format: 'knockout' }); + await mountSection('settings', { comp, tournament: { courts: ['A', 'B', 'C'] } }); + const group = screen.getByRole('group', { name: 'Assigned shiaijo (courts)' }); + expect(pressed(group)).toEqual(['Shiaijo (court) A', 'Shiaijo (court) D (not in tournament)']); + expect(within(group).getByRole('button', { name: 'Shiaijo (court) B' }).getAttribute('aria-pressed')).toBe('false'); + }); + + it('Knockout qualifiers is a named group; a stored "" announces Standard', async () => { + const comp = makeCompetition({ format: 'mixed', poolSizeMode: 'min', extraQualifiers: '' }); + await mountSection('settings', { comp }); + const group = screen.getByRole('group', { name: 'Knockout qualifiers' }); + expect(within(group).getAllByRole('button')).toHaveLength(3); + expect(pressed(group)).toHaveLength(1); + expect(within(group).getAllByRole('button')[0].getAttribute('aria-pressed')).toBe('true'); + }); +}); 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 353b0e8aa..bd00958f7 100644 --- a/web-mobile/js/__tests__/render/admin_shiaijo.render.test.jsx +++ b/web-mobile/js/__tests__/render/admin_shiaijo.render.test.jsx @@ -271,6 +271,96 @@ describe('AdminShiaijoPage render-smoke', () => { expect(queryByText('Start match')).toBeNull(); }); + // bc-bzlb (operator decision 2026-10-11): the 3rd-place match has no match + // number, so the console names it with the owner's words, the ones the Scores + // list and the refusal dialogs use, rather than with nothing. + it('names the 3rd-place match on the Up next card', () => { + const bronze = { + id: 'm-bronze', compId: 'c1', compName: 'Cup', status: 'scheduled', + phase: 'bracket', matchNumber: 0, court: 'A', scheduledAt: '10:55', + sideA: { id: 'p1', name: 'Yamada' }, sideB: { id: 'p2', name: 'Tanaka' }, + }; + window.tournamentMatches = () => [bronze]; + window.filterMatchesByCourt = (matches) => matches; + const { container } = renderPage(makeMinimalTournament()); + expect(container.querySelector('.shiaijo-upnext__time').textContent).toBe('10:55 · Cup · the 3rd-place match'); + }); + + // bc-bzlb: before the semi-finals, the 3rd-place match's sides are EMPTY (no + // "Winner of" slot), so it used to be missing from the queue altogether. It is + // listed under Later, its sides named after the semi-finals that fill them, + // and it offers no "Run now" (that modal records WINNERS, not losers). + it('lists the 3rd-place match under Later with "Loser of M" before the semi-finals, without Run now', async () => { + // The real slot-text owner, imported without leaving bracket.jsx's window + // globals behind (other tests here rely on bracketSlotLabeller being absent). + const before = new Map(Object.keys(window).map((k) => [k, window[k]])); + const { bronzeSlotLabels } = await import('../../bracket.jsx'); + for (const k of Object.keys(window)) { + if (!before.has(k)) delete window[k]; + else if (window[k] !== before.get(k)) window[k] = before.get(k); + } + const rounds = [ + [ + { id: 'm-r1-0', status: 'scheduled', matchNumber: 1, sideA: { id: 'a', name: 'Alice' }, sideB: { id: 'b', name: 'Bob' } }, + { id: 'm-r1-1', status: 'scheduled', matchNumber: 2, sideA: { id: 'c', name: 'Carol' }, sideB: { id: 'd', name: 'Dan' } }, + ], + [ + { id: 'm-r2-0', status: 'scheduled', matchNumber: 3, sideA: { id: '', name: 'Winner of r2-m0' }, sideB: { id: '', name: 'Winner of r2-m1' } }, + ], + ]; + const bronze = { + id: 'm-bronze', compId: 'c1', compName: 'Cup', status: 'scheduled', + phase: 'bracket', matchNumber: 0, court: 'A', + sideA: { id: '', name: '' }, sideB: { id: '', name: '' }, + }; + window.bronzeSlotLabels = bronzeSlotLabels; + window.tournamentMatches = () => [bronze]; + window.filterMatchesByCourt = (matches) => matches; + try { + let utils; + await act(async () => { + utils = renderPage(makeMinimalTournament({ competitions: [{ id: 'c1', name: 'Cup', bracket: { rounds } }] })); + }); + const later = utils.container.querySelector('.shiaijo-pending'); + expect(later).toBeTruthy(); + expect(within(later).getByText('Loser of M1')).toBeTruthy(); + expect(within(later).getByText('Loser of M2')).toBeTruthy(); + expect(later.textContent).toContain('the 3rd-place match'); + expect(utils.queryByRole('button', { name: /run now/i })).toBeNull(); + expect(utils.queryByText('Start match')).toBeNull(); + } finally { + delete window.bronzeSlotLabels; + } + }); + + // The bronze labels are read through the bracket module's window global, as + // its sibling slot labeller is: guarded the same way, so a console without + // the bracket module still lists the 3rd-place match (its sides unnamed). + it('lists the 3rd-place match under Later without the bracket module loaded', async () => { + const rounds = [ + [ + { id: 'm-r1-0', status: 'scheduled', matchNumber: 1, sideA: { id: 'a', name: 'Alice' }, sideB: { id: 'b', name: 'Bob' } }, + { id: 'm-r1-1', status: 'scheduled', matchNumber: 2, sideA: { id: 'c', name: 'Carol' }, sideB: { id: 'd', name: 'Dan' } }, + ], + [{ id: 'm-r2-0', status: 'scheduled', matchNumber: 3, sideA: { id: '', name: 'Winner of r2-m0' }, sideB: { id: '', name: 'Winner of r2-m1' } }], + ]; + const bronze = { + id: 'm-bronze', compId: 'c1', compName: 'Cup', status: 'scheduled', + phase: 'bracket', matchNumber: 0, court: 'A', + sideA: { id: '', name: '' }, sideB: { id: '', name: '' }, + }; + expect(window.bronzeSlotLabels).toBeUndefined(); + window.tournamentMatches = () => [bronze]; + window.filterMatchesByCourt = (matches) => matches; + let utils; + await act(async () => { + utils = renderPage(makeMinimalTournament({ competitions: [{ id: 'c1', name: 'Cup', bracket: { rounds } }] })); + }); + const later = utils.container.querySelector('.shiaijo-pending'); + expect(later).toBeTruthy(); + expect(later.textContent).toContain('the 3rd-place match'); + }); + // A court back online refetches its feed when the stream reopens, with no // manual Refresh. app.jsx reopens the stream on 'online' by closing it, which // fires no error, so the reopen reads as a bare 'open' (the stale court seen diff --git a/web-mobile/js/__tests__/render/admin_shiaijo_shared_court.render.test.jsx b/web-mobile/js/__tests__/render/admin_shiaijo_shared_court.render.test.jsx new file mode 100644 index 000000000..bad32e70d --- /dev/null +++ b/web-mobile/js/__tests__/render/admin_shiaijo_shared_court.render.test.jsx @@ -0,0 +1,230 @@ +import React from 'react'; +import { render, act, fireEvent } from '@testing-library/react'; +import { describe, it, expect, vi, beforeAll, afterAll, afterEach } from 'vitest'; +import { installWindowStubs } from '../helpers/stub_globals.js'; + +// bc-shcr: one shiaijo hosting two competitions. Pins only what the operator +// rulings call correct (2026-09-26/27: one match at a time on a court; the +// operator may switch the console to the other competition on the same court; +// an automatic advance stays in the scored match's competition). It does NOT +// pin which competition the console shows by itself when nothing was picked. + +// Captures the latest ScoreEditorModal props so a test can drive +// onSubmitAndNext / onAfterDecision directly. +const probe = { props: null }; + +const STUBBED_GLOBALS = { + // MODULE-EVAL-TIME: captured at import, so set before the dynamic import. + AdminTopbar: ({ children }) =>
{children}
, + Breadcrumbs: () => null, + ScoreEditorModal: (props) => { probe.props = props; return
; }, + CourtPicker: () => null, + BracketTree: () => null, + Icon: ({ name }) => {name}, + filterMatchesByCourt: (matches) => matches, + tournamentMatches: () => [], + API: { + fetchCompetitionDetails: vi.fn().mockResolvedValue(null), + fetchCourtMatches: vi.fn().mockResolvedValue([]), + subscribeToEvents: () => () => {}, + sendAnnouncement: vi.fn(), + updateMatchTime: vi.fn(), + startMatch: vi.fn(), + recordDecision: vi.fn().mockResolvedValue({ applied: true }), + reinstateCompetitor: vi.fn().mockResolvedValue({}), + revertMatchToQueue: vi.fn().mockResolvedValue(true), + }, + confirmDialog: vi.fn().mockResolvedValue(true), + PoolsViewer: () => null, + compMatches: () => [], +}; + +let restoreGlobals; +let AdminShiaijoPage; + +beforeAll(async () => { + restoreGlobals = installWindowStubs(STUBBED_GLOBALS); + await import('../../admin_shiaijo.jsx'); + AdminShiaijoPage = window.AdminShiaijoPage; +}); + +afterAll(() => restoreGlobals()); + +afterEach(() => { + window.tournamentMatches = STUBBED_GLOBALS.tournamentMatches; + probe.props = null; +}); + +// Two competitions on Shiaijo A: Cup (c1, individual pools) and League (c2). +const side = (id, name) => ({ id, name }); +const row = (id, status, at, over = {}) => ({ + id, compId: 'c1', compName: 'Cup', status, phase: 'pool', poolName: 'Pool A', court: 'A', scheduledAt: at, + sideA: side(`${id}-a`, `Aka ${id}`), sideB: side(`${id}-b`, `Shiro ${id}`), + ...over, +}); +const league = { compId: 'c2', compName: 'League' }; + +async function mountCourt(initial, onEditScore = vi.fn().mockResolvedValue({ status: 'running' })) { + const feed = { current: initial }; + window.tournamentMatches = () => feed.current; + let utils; + await act(async () => { + utils = render( + + ); + }); + const picker = () => utils.container.querySelector('select[aria-label="Select competition to officiate"]'); + return { + utils, + feed, + onEditScore, + picker, + pick: async (compId) => { await act(async () => { fireEvent.change(picker(), { target: { value: compId } }); }); }, + refresh: async () => { await act(async () => { utils.getByRole('button', { name: /refresh/i }).click(); }); }, + nudges: () => utils.queryAllByTestId('shiaijo-nudge').map((n) => n.querySelector('.shiaijo-nudge__text').textContent), + upNext: () => { + const title = [...utils.container.querySelectorAll('.section-title')].find((t) => t.textContent === 'Up next'); + return title ? title.parentElement.textContent : ''; + }, + }; +} + +describe('the competition picker on a shared court (bc-shcr)', () => { + it('is not offered when only one competition is on the court', async () => { + const c = await mountCourt([row('m1', 'running', '09:01'), row('m2', 'scheduled', '09:02')]); + expect(c.picker(), 'one competition: a plain heading, no picker').toBeNull(); + expect(c.utils.container.querySelector('.shiaijo-officiating__name').textContent).toBe('Cup'); + }); + + it('is offered with both competitions once a second one is on the court', async () => { + const c = await mountCourt([ + row('m1', 'running', '09:01'), + row('m2', 'scheduled', '09:02'), + row('m3', 'scheduled', '09:03', league), + ]); + expect(c.picker(), 'two competitions: the picker').toBeTruthy(); + expect([...c.picker().options].map((o) => o.textContent)).toEqual(['Cup', 'League']); + }); + + it("keeps an explicit pick and shows the picked competition's queue", async () => { + const c = await mountCourt([ + row('m1', 'running', '09:01'), + row('m2', 'scheduled', '09:02'), + row('m3', 'scheduled', '09:03', league), + ]); + await c.pick('c2'); + expect(c.picker().value).toBe('c2'); + expect(c.upNext(), "League's queue").toContain('Shiro m3'); + expect(c.upNext()).not.toContain('Shiro m2'); + // A refetch keeps the operator's pick even though Cup's bout still runs here. + await c.refresh(); + expect(c.picker().value, 'the pick is held across a refresh').toBe('c2'); + expect(c.upNext()).toContain('Shiro m3'); + }); +}); + +describe('the switch nudge on a shared court (bc-shcr)', () => { + it('says "Switch to" with the other competition\'s count only when the picked one has nothing running or scheduled here', async () => { + const c = await mountCourt([ + row('m1', 'completed', '09:01', { winner: side('m1-a', 'Aka m1') }), + row('m2', 'scheduled', '09:02', league), + row('m3', 'scheduled', '09:03', league), + ]); + await c.pick('c1'); + expect(c.nudges()).toEqual(['Switch to League: 2 matches waiting on this court.']); + expect(c.utils.getByTestId('shiaijo-nudge').classList.contains('alert--warn')).toBe(true); + }); + + it('stays quiet while the picked competition still has a scheduled match here', async () => { + const c = await mountCourt([ + row('m1', 'scheduled', '09:01'), + row('m2', 'scheduled', '09:02', league), + row('m3', 'scheduled', '09:03', league), + ]); + await c.pick('c1'); + expect(c.nudges()).toEqual(['2 League matches also waiting on this court.']); + expect(c.utils.getByTestId('shiaijo-nudge').classList.contains('alert--warn')).toBe(false); + }); + + it('stays quiet while the picked competition still has a running match here', async () => { + const c = await mountCourt([ + row('m1', 'running', '09:01'), + row('m2', 'scheduled', '09:02', league), + ]); + await c.pick('c1'); + expect(c.nudges()).toEqual(['1 League match also waiting on this court.']); + }); +}); + +describe('the "Another bout is running" alert on a shared court (bc-shcr)', () => { + it('lists every running bout beyond the first, with its competition', async () => { + const c = await mountCourt([ + row('m1', 'running', '09:01'), + row('m2', 'running', '09:02', league), + row('m3', 'running', '09:03', league), + ]); + const alert = c.utils.container.querySelector('.shiaijo-also-running'); + expect(alert, 'two bouts running on one court is flagged').toBeTruthy(); + expect(alert.querySelector('.shiaijo-also-running__title').textContent).toBe('Another bout is running on Shiaijo A'); + const items = [...alert.querySelectorAll('.shiaijo-also-running__list li')].map((li) => li.textContent); + expect(items).toEqual(['Shiro m2 vs Aka m2 · League', 'Shiro m3 vs Aka m3 · League']); + }); + + it('is not shown while only one bout runs on the court', async () => { + const c = await mountCourt([ + row('m1', 'running', '09:01'), + row('m2', 'scheduled', '09:02', league), + ]); + expect(c.utils.container.querySelector('.shiaijo-also-running')).toBeNull(); + }); +}); + +describe('the automatic advance stays in the scored competition on a shared court (bc-shcr)', () => { + // League's m2 is scheduled EARLIER than Cup's m3: the advance must still + // start Cup's next match, never the other competition's. + const sharedQueue = () => [ + row('m1', 'running', '09:01'), + row('m2', 'scheduled', '09:02', league), + row('m3', 'scheduled', '09:03'), + ]; + + it("Finish + Start Next starts the scored competition's next match", async () => { + const onEditScore = vi.fn().mockImplementation((_cid, _mid, patch) => + Promise.resolve({ applied: true, status: patch && patch.status })); + const c = await mountCourt(sharedQueue(), onEditScore); + expect(probe.props.match?.id).toBe('m1'); + await act(async () => { + await probe.props.onSubmitAndNext({ status: 'completed', winner: 'Aka m1' }); + }); + expect(c.onEditScore).toHaveBeenCalledTimes(2); + expect(c.onEditScore.mock.calls[0].slice(0, 2), 'the finish').toEqual(['c1', 'm1']); + expect(c.onEditScore.mock.calls[1].slice(0, 2), "Cup's next match, not League's earlier one").toEqual(['c1', 'm3']); + }); + + it("the start after a withdrawal starts the scored competition's next match", async () => { + const c = await mountCourt(sharedQueue()); + expect(probe.props.match?.id).toBe('m1'); + await act(async () => { + await probe.props.onAfterDecision({ + id: 'm1', sideA: 'Aka m1', sideB: 'Shiro m1', sideAId: 'm1-a', sideBId: 'm1-b', + winner: 'Shiro m1', winnerId: 'm1-b', status: 'completed', decision: 'kiken-voluntary', decisionBy: 'aka', + }); + }); + expect(c.onEditScore).toHaveBeenCalledTimes(1); + expect(c.onEditScore.mock.calls[0].slice(0, 2), "Cup's next match, not League's earlier one").toEqual(['c1', 'm3']); + }); + + it("the start after a no-show starts the scored competition's next match", async () => { + const c = await mountCourt(sharedQueue()); + await act(async () => { + await probe.props.onAfterDecision({ + id: 'm1', sideA: 'Aka m1', sideB: 'Shiro m1', sideAId: 'm1-a', sideBId: 'm1-b', + winner: 'Aka m1', winnerId: 'm1-a', status: 'completed', decision: 'fusenpai', decisionBy: 'shiro', + }); + }); + expect(c.onEditScore).toHaveBeenCalledTimes(1); + expect(c.onEditScore.mock.calls[0].slice(0, 2)).toEqual(['c1', 'm3']); + }); +}); diff --git a/web-mobile/js/__tests__/render/auth_modal_labels.render.test.jsx b/web-mobile/js/__tests__/render/auth_modal_labels.render.test.jsx new file mode 100644 index 000000000..f68471cad --- /dev/null +++ b/web-mobile/js/__tests__/render/auth_modal_labels.render.test.jsx @@ -0,0 +1,47 @@ +// bc-lbla: the sign-in dialog's Password field is named by its visible label. +// Its input already carried id="admin-password" but the label had no htmlFor, +// so the field's accessible name fell back to the "••••••••" placeholder. +// +// AuthModal is not exported, so the real App is mounted on / with a probe in +// place of the home page, whose button is the Admin control (the harness of +// auth_modal_opened_tap.render.test.jsx). A keyboard-style click (detail 0) +// opens it, which the opened-tap guard never swallows. +import React from 'react'; +import { act, screen, fireEvent } from '@testing-library/react'; +import { describe, it, expect, vi, beforeAll, afterAll } from 'vitest'; +import { mountApp } from '../helpers/mount_app.js'; + +function ProbeViewerHome(props) { + return ; +} + +const STUBBED_GLOBALS = { + ViewerHome: ProbeViewerHome, + API: { + fetchTournament: vi.fn(async () => ({ name: 'London Cup', courts: ['A'] })), + fetchCompetitions: vi.fn(async () => []), + fetchAuthConfig: vi.fn(async () => ({ mode: 'file', resetEnabled: true })), + fetchAnnouncements: vi.fn(async () => []), + fetchCompetitionDetails: vi.fn(async () => null), + subscribeToEvents: vi.fn(() => () => {}), + reconnectEvents: vi.fn(), + resumeAfterAuth: vi.fn(), + }, +}; + +let unmount; + +beforeAll(async () => { + ({ unmount } = await mountApp({ path: '/', globals: STUBBED_GLOBALS })); +}); + +afterAll(() => { unmount(); }); + +describe('the sign-in dialog', () => { + it('names the password field by its visible label', async () => { + await act(async () => { fireEvent.click(screen.getByRole('button', { name: 'Open admin' })); }); + const input = screen.getByLabelText('Password'); + expect(input.id).toBe('admin-password'); + expect(input.type).toBe('password'); + }); +}); diff --git a/web-mobile/js/__tests__/render/bracket_feeder_slot.render.test.jsx b/web-mobile/js/__tests__/render/bracket_feeder_slot.render.test.jsx index d546511e0..327305404 100644 --- a/web-mobile/js/__tests__/render/bracket_feeder_slot.render.test.jsx +++ b/web-mobile/js/__tests__/render/bracket_feeder_slot.render.test.jsx @@ -105,3 +105,24 @@ describe('MatchCard without a bracket context still cannot leak the id', () => { expect(screen.getByText('Pool B-2nd')).toBeTruthy(); }); }); + +// bc-bzlb: the 3rd-place card's sides are EMPTY until the semi-finals seat +// their losers, so its callers pass bronzeSlotLabels and an empty side names +// the semi-final card that fills it instead of reading blank. +describe('the 3rd-place card names the semi-finals that fill it', () => { + const bronze = (sideA, sideB) => ({ id: 'm-bronze', court: 'A', status: 'scheduled', sideA, sideB }); + + it('reads "Loser of M" on an empty side, and TBD for a bye semi-final', () => { + // fivePlayerRounds: semi-final 0 is the latent bye (unnumbered), semi-final 1 is M3. + render(); + expect(screen.getByText('TBD')).toBeTruthy(); + expect(screen.getByText('Loser of M3')).toBeTruthy(); + }); + + it('shows a seated competitor as themself, labelling only the empty side', () => { + render(); + expect(screen.getByText('Alice')).toBeTruthy(); + expect(screen.queryByText('Loser of M1')).toBeNull(); + expect(screen.getByText('Loser of M2')).toBeTruthy(); + }); +}); diff --git a/web-mobile/js/__tests__/render/competition_fields_labels.render.test.jsx b/web-mobile/js/__tests__/render/competition_fields_labels.render.test.jsx new file mode 100644 index 000000000..f4ea3d60f --- /dev/null +++ b/web-mobile/js/__tests__/render/competition_fields_labels.render.test.jsx @@ -0,0 +1,212 @@ +// bc-lbla: every competition-config control is reachable by its visible label, +// and every option pill says whether it is the chosen one. +// +// Before this, competition_fields.jsx's FieldLabel tied no label to its input, +// PillGroup's label named no control, and the pills carried their state only +// as the is-active class, so a screen reader announced a nameless text box and +// a row of plain buttons, and getByLabel found none of them. The create form's +// hand-written fields (Display name, Day, Start time, Knockout qualifiers, +// Assigned shiaijo) copied the same pattern. +// +// The shared components are rendered directly; the create form is mounted +// with real React, its window.* deps stubbed before the import (the same +// harness as admin_create_competition.render.test.jsx). +import React from 'react'; +import { render, screen, within, act, fireEvent } from '@testing-library/react'; +import { describe, it, expect, vi, beforeAll, afterAll } from 'vitest'; +import { installWindowStubs } from '../helpers/stub_globals.js'; +import { PillGroup, PillButton, NumberField, TextField } from '../../competition_fields.jsx'; +import { + LABEL_TEAM_MATCH_TYPE, TEAM_MATCH_TYPE_OPTIONS, teamMatchTypeActive, +} from '../../competition_shape.jsx'; + +const noop = () => {}; + +const pressedNames = (group) => within(group).getAllByRole('button') + .filter((b) => b.getAttribute('aria-pressed') === 'true') + .map((b) => b.textContent.trim()); + +describe('the shared competition fields name their controls', () => { + it('NumberField: the label names the number input', () => { + render(); + const input = screen.getByLabelText('Players per pool'); + expect(input.tagName).toBe('INPUT'); + expect(input.type).toBe('number'); + expect(input.value).toBe('3'); + }); + + it('NumberField: an optional field is named by its label and the suffix', () => { + render(); + expect(screen.getByLabelText('Team size (optional)').value).toBe('5'); + }); + + it('TextField: the label names the text input', () => { + render(); + const input = screen.getByLabelText('Player number prefix'); + expect(input.tagName).toBe('INPUT'); + expect(input.value).toBe('K'); + }); + + it('two fields on one page get two different ids', () => { + render( + <> + + + + ); + const a = screen.getByLabelText('Players per pool'); + const b = screen.getByLabelText('Winners per pool'); + expect(a.id).not.toBe(''); + expect(a.id).not.toBe(b.id); + }); + + it('PillGroup: the label names the row and only the active pill is pressed', () => { + render( + + ); + const group = screen.getByRole('group', { name: 'Format' }); + const pills = within(group).getAllByRole('button'); + expect(pills).toHaveLength(2); + // Every pill states its state: the inactive one says false, it does not + // just leave the attribute off. + expect(pills.map((b) => b.getAttribute('aria-pressed'))).toEqual(['false', 'true']); + expect(pressedNames(group)).toEqual(['Pools + Knockout']); + }); + + it('PillGroup: aria-pressed reads the isActive predicate, so a stored "" announces Regular', () => { + render( + teamMatchTypeActive(v, '')} + onChange={noop} + /> + ); + const group = screen.getByRole('group', { name: LABEL_TEAM_MATCH_TYPE }); + expect(pressedNames(group)).toEqual(['Regular']); + // The class and the announced state agree. + const regular = within(group).getByRole('button', { name: 'Regular' }); + expect(regular.className).toContain('is-active'); + }); + + it('PillGroup: a click still reports the option value', () => { + const onChange = vi.fn(); + render( + + ); + fireEvent.click(screen.getByRole('button', { name: 'League' })); + expect(onChange).toHaveBeenCalledWith('league'); + }); + + it('PillButton: passes extra props through and keeps the pill markup', () => { + render(Shiaijo (court) D); + const pill = screen.getByTestId('orphan-court-D'); + expect(pill.getAttribute('type')).toBe('button'); + expect(pill.className).toBe('radio-pill'); + expect(pill.getAttribute('aria-pressed')).toBe('false'); + expect(pill.style.color).toBe('red'); + }); + + // Review round 9, finding 8: a caller's className joins the pill's classes + // rather than being dropped, and the component keeps type and aria-pressed. + it('PillButton: merges a caller className and owns type and aria-pressed', () => { + render( + + Court D + + ); + const pill = screen.getByTestId('pill'); + expect(pill.className.split(' ')).toEqual(['radio-pill', 'is-active', 'orphan-court']); + expect(pill.getAttribute('type')).toBe('button'); + expect(pill.getAttribute('aria-pressed')).toBe('true'); + }); +}); + +// --------------------------------------------------------------------------- +// The create-competition form's hand-written fields +// --------------------------------------------------------------------------- + +const Stub = (name) => { + const C = () =>
; + C.displayName = `Stub(${name})`; + return C; +}; + +const STUBBED_GLOBALS = { + AdminTopbar: Stub('AdminTopbar'), + Breadcrumbs: Stub('Breadcrumbs'), + BrandingManager: Stub('BrandingManager'), + SponsorsManager: Stub('SponsorsManager'), + isNonPublicOrigin: () => false, + buildCompetition: (cfg) => ({ ...cfg }), + addMinutes: (t) => t, + API: { + estimateCompetitionSchedule: vi.fn().mockResolvedValue(null), + getNumberPrefixDefault: vi.fn().mockResolvedValue({ numberPrefix: '' }), + }, +}; + +let restoreGlobals; +let AdminCreateCompetition; + +beforeAll(async () => { + restoreGlobals = installWindowStubs(STUBBED_GLOBALS); + await import('../../admin_setup.jsx'); + AdminCreateCompetition = window.AdminCreateCompetition; +}); + +afterAll(() => restoreGlobals()); + +async function mountForm(tournament) { + await act(async () => { + render( + + ); + }); +} + +describe('the create-competition form names its hand-written fields', () => { + it('Display name, Day (select) and Start time are reachable by their labels', async () => { + await mountForm({ date: '10-08-2026', durationDays: 2 }); + expect(screen.getByLabelText('Display name').tagName).toBe('INPUT'); + expect(screen.getByLabelText('Day').tagName).toBe('SELECT'); + expect(screen.getByLabelText('Start time').type).toBe('time'); + }); + + it('Day is still named when the tournament has no date and it is a date picker', async () => { + await mountForm({ date: '' }); + expect(screen.getByLabelText('Day').type).toBe('date'); + }); + + it('Assigned shiaijo is a named group whose pills say which are picked', async () => { + await mountForm({ date: '10-08-2026' }); + const group = screen.getByRole('group', { name: 'Assigned shiaijo (courts)' }); + expect(within(group).getAllByRole('button')).toHaveLength(4); + // The form starts on A + B. + expect(pressedNames(group)).toEqual(['Shiaijo (court) A', 'Shiaijo (court) B']); + await act(async () => { fireEvent.click(within(group).getByRole('button', { name: 'Shiaijo (court) C' })); }); + expect(pressedNames(group)).toContain('Shiaijo (court) C'); + }); + + it('Knockout qualifiers is a named group with one pressed pill', async () => { + await mountForm({ date: '10-08-2026' }); + await act(async () => { + fireEvent.click(within(screen.getByRole('group', { name: 'Format' })).getByRole('button', { name: 'Pools + Knockout' })); + }); + await act(async () => { + fireEvent.click(within(screen.getByRole('group', { name: 'Pool size is a' })).getAllByRole('button') + .find((b) => /minimum/i.test(b.textContent))); + }); + const group = screen.getByRole('group', { name: 'Knockout qualifiers' }); + expect(within(group).getAllByRole('button')).toHaveLength(3); + expect(pressedNames(group)).toHaveLength(1); + }); +}); diff --git a/web-mobile/js/__tests__/render/create_tournament_labels.render.test.jsx b/web-mobile/js/__tests__/render/create_tournament_labels.render.test.jsx new file mode 100644 index 000000000..708431d5e --- /dev/null +++ b/web-mobile/js/__tests__/render/create_tournament_labels.render.test.jsx @@ -0,0 +1,56 @@ +// bc-lbla: the first-run "Welcome to Bracket Creator" form names every field +// by its visible label, and its Tournament type buttons are a group named by +// the label above them. Before this, no label on the form was tied to its +// input, so getByLabel found none of them and a screen reader announced +// unnamed text boxes. +// +// CreateTournament is not exported: the real App renders it when the server +// has no tournament (fetchTournament answers null), so the App is mounted with +// that answer. +import React from 'react'; +import { act, screen, within, fireEvent } from '@testing-library/react'; +import { describe, it, expect, vi, beforeAll, afterAll } from 'vitest'; +import { mountApp } from '../helpers/mount_app.js'; + +const STUBBED_GLOBALS = { + API: { + fetchTournament: vi.fn(async () => null), + fetchCompetitions: vi.fn(async () => []), + fetchAuthConfig: vi.fn(async () => ({ mode: 'file', resetEnabled: true })), + fetchAnnouncements: vi.fn(async () => []), + fetchCompetitionDetails: vi.fn(async () => null), + subscribeToEvents: vi.fn(() => () => {}), + reconnectEvents: vi.fn(), + resumeAfterAuth: vi.fn(), + }, +}; + +let unmount; + +beforeAll(async () => { + ({ unmount } = await mountApp({ path: '/', globals: STUBBED_GLOBALS })); +}); + +afterAll(() => { unmount(); }); + +describe('the first-run tournament form', () => { + it('names every field by its visible label', () => { + expect(screen.getByText('Welcome to Bracket Creator')).toBeInTheDocument(); + expect(screen.getByLabelText('Tournament Name').tagName).toBe('INPUT'); + expect(screen.getByLabelText('Date').type).toBe('date'); + expect(screen.getByLabelText('Venue').tagName).toBe('INPUT'); + expect(screen.getByLabelText('Number of Shiaijo (courts)').type).toBe('number'); + expect(screen.getByLabelText('Admin Password').type).toBe('password'); + }); + + it('names the Tournament type group by its label, its buttons saying which is chosen', () => { + const group = screen.getByRole('group', { name: 'Tournament type' }); + expect(within(group).getByRole('button', { name: 'Officiated' }).getAttribute('aria-pressed')).toBe('true'); + expect(within(group).getByRole('button', { name: 'Self-run' }).getAttribute('aria-pressed')).toBe('false'); + }); + + it('names the self-run destructive-ops password once Self-run is chosen', async () => { + await act(async () => { fireEvent.click(screen.getByRole('button', { name: 'Self-run' })); }); + expect(screen.getByLabelText('Destructive-ops password (required for self-run)').type).toBe('password'); + }); +}); diff --git a/web-mobile/js/__tests__/render/kachinuki_advisory_line.render.test.jsx b/web-mobile/js/__tests__/render/kachinuki_advisory_line.render.test.jsx new file mode 100644 index 000000000..3a0163296 --- /dev/null +++ b/web-mobile/js/__tests__/render/kachinuki_advisory_line.render.test.jsx @@ -0,0 +1,428 @@ +// bc-kfnl (operator ruling 2026-09-24): in kachinuki bout mode the team score +// sheet shows one advisory line after the fought bouts and immediately before +// the live bout: each side's fighter on, fighters left and who is next, every +// fighter with their team-member number. The queue is the server's, read with +// API.fetchKachinukiRoster and keyed on a VALUE (the bout log's fighters and +// recorded marks, the result of every bout but the live one until Record bout +// records it, and the lineups as the server last answered them), never on the +// match object a broadcast re-creates. +// +// NEGATIVE PINS: the line is advisory only. A roster that says a side has +// nobody left must not arm End match, change its label, or hold back Record +// bout or Encho; the buttons read exactly as they do with no roster at all. +// There is no line in the completed (correction) view, for a team match that +// is not kachinuki, or when the read fails, and the line never asks for a +// lineup. +import React from 'react'; +import { render, act, screen, fireEvent } 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: () => false, + isTextEntry: () => false, + isInteractiveTarget: () => false, + confirmDialog: vi.fn().mockResolvedValue(true), + 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()); + +// Aka is the match's side A (team-A, number T2), Shiro side B (team-B, T1). +const SQUADS = { + 'team-A': [ + { id: 'a1', index: 1, name: 'Kudo' }, + { id: 'a2', index: 2, name: 'Mori' }, + { id: 'a3', index: 3, name: '' }, + ], + 'team-B': [ + { id: 'b1', index: 1, name: 'Taki' }, + { id: 'b2', index: 2, name: 'Ueda' }, + { id: 'b3', index: 3, name: 'Sato' }, + ], +}; + +// Bout 1: Kudo (Aka) beat Taki (Shiro). Bout 2, live: Kudo against Ueda. +const BOUT_1 = { + position: 1, sideA: 'Kudo', sideAMemberId: 'a1', sideB: 'Taki', sideBMemberId: 'b1', + winner: 'Kudo', winnerMemberId: 'a1', decision: 'fought', ipponsA: ['M'], ipponsB: [], +}; +const BOUT_2 = { position: 2, sideA: 'Kudo', sideAMemberId: 'a1', sideB: 'Ueda', sideBMemberId: 'b2', ipponsA: [], ipponsB: [] }; + +function kachinukiMatch(overrides = {}) { + return { + id: 'm1', + compId: 'comp1', + status: 'running', + phase: 'pool', + poolName: 'Pool 1', + court: 'A', + compKind: 'team', + teamSize: 3, + compFormat: 'mixed', + teamMatchType: 'kachinuki', + sideA: { id: 'team-A', name: 'Team A', number: 'T2' }, + sideB: { id: 'team-B', name: 'Team B', number: 'T1' }, + subResults: [BOUT_1, BOUT_2], + ...overrides, + }; +} + +// The server's split (engine.KachinukiRoster): each side's fighter on, and the +// queue behind them, next first. The client never matches the two itself. +const FULL_ROSTER = { + sideA: { lineupFound: true, on: { name: 'Kudo', memberId: 'a1' }, remaining: [ + { name: 'Mori', memberId: 'a2' }, { name: '', memberId: 'a3' }, + ] }, + sideB: { lineupFound: true, on: { name: 'Ueda', memberId: 'b2' }, remaining: [{ name: 'Sato', memberId: 'b3' }] }, +}; +// Aka's lineup has nobody behind Kudo. +const AKA_EXHAUSTED = { + sideA: { lineupFound: true, on: { name: 'Kudo', memberId: 'a1' }, remaining: [] }, + sideB: FULL_ROSTER.sideB, +}; +// Each team's lineup in force, as the sheet reads it on mount. +const LINEUPS = { + 'team-A': { positions: { '1': 'Kudo', '2': 'Mori', '3': '' }, memberIds: { '1': 'a1', '2': 'a2', '3': 'a3' } }, + 'team-B': { positions: { '1': 'Taki', '2': 'Ueda', '3': 'Sato' }, memberIds: { '1': 'b1', '2': 'b2', '3': 'b3' } }, +}; + +function stubApi(fetchKachinukiRoster) { + window.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(), + fetchSquads: vi.fn().mockResolvedValue(SQUADS), + fetchLineupInForce: vi.fn((_c, teamId) => Promise.resolve(LINEUPS[teamId] || null)), + fetchKachinukiRoster, + }; +} + +beforeEach(() => { + window.compMatches = () => []; + window.compMatchesForCompetition = () => []; + stubApi(vi.fn().mockResolvedValue(FULL_ROSTER)); +}); + +// Several turns: the competition read, then both lineup reads, then the roster +// read, each lands on its own. +async function settle() { + for (let i = 0; i < 6; i++) { + await act(async () => { await Promise.resolve(); }); + } +} + +async function mount(match, onSubmit = vi.fn()) { + let view; + await act(async () => { + view = render(); + }); + await settle(); + return view; +} + +async function remount(view, match) { + await act(async () => { + view.rerender(); + }); + await act(async () => { await Promise.resolve(); }); +} + +const advisory = () => screen.queryByTestId('kachinuki-advisory'); + +function buttonStates() { + const end = screen.getByTestId('kachinuki-end-match-button'); + const record = screen.getAllByRole('button').find(b => b.textContent === 'Record bout'); + const encho = screen.queryByTestId('kachinuki-encho-button'); + return { + endLabel: end.textContent, + endArmed: end.classList.contains('btn--confirm'), + endDisabled: end.disabled, + recordDisabled: record ? record.disabled : 'absent', + enchoDisabled: encho ? encho.disabled : 'absent', + }; +} + +describe('the kachinuki advisory line', () => { + it('shows each side on, left and next, after the fought bouts and before the live bout', async () => { + await mount(kachinukiMatch()); + const line = advisory(); + expect(line).not.toBeNull(); + expect(line.textContent).toBe('Shiro: T1.2 Ueda on, 1 left (T1.3 Sato) · Aka: T2.1 Kudo on, 2 left (T2.2 Mori, T2.3)'); + expect(window.API.fetchKachinukiRoster).toHaveBeenCalledWith('comp1', 'm1'); + // One read on mount: it waits for both lineup reads rather than reading + // once without them and again when they land. + expect(window.API.fetchLineupInForce).toHaveBeenCalledTimes(2); + expect(window.API.fetchKachinukiRoster).toHaveBeenCalledTimes(1); + + const fought = screen.getByTestId('kachinuki-done-bout-0'); + expect(fought.compareDocumentPosition(line) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); + const liveButtons = document.querySelector('.team-sub-match__btns'); + expect(liveButtons).not.toBeNull(); + expect(line.compareDocumentPosition(liveButtons) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); + }); + + it('reads again when a bout is recorded, and not for a new object with the same bout log', async () => { + const view = await mount(kachinukiMatch()); + expect(window.API.fetchKachinukiRoster).toHaveBeenCalledTimes(1); + + await remount(view, JSON.parse(JSON.stringify(kachinukiMatch()))); + expect(window.API.fetchKachinukiRoster).toHaveBeenCalledTimes(1); + + // Bout 2 recorded (Ueda beat Kudo); the server appended Mori against Ueda. + window.API.fetchKachinukiRoster.mockResolvedValue({ + sideA: { lineupFound: true, on: { name: 'Mori', memberId: 'a2' }, remaining: [{ name: '', memberId: 'a3' }] }, + sideB: FULL_ROSTER.sideB, + }); + await remount(view, kachinukiMatch({ + subResults: [ + BOUT_1, + { ...BOUT_2, winner: 'Ueda', winnerMemberId: 'b2', decision: 'fought', ipponsB: ['K'] }, + { position: 3, sideA: 'Mori', sideAMemberId: 'a2', sideB: 'Ueda', sideBMemberId: 'b2', ipponsA: [], ipponsB: [] }, + ], + })); + expect(window.API.fetchKachinukiRoster).toHaveBeenCalledTimes(2); + expect(advisory().textContent).toBe('Shiro: T1.2 Ueda on, 1 left (T1.3 Sato) · Aka: T2.2 Mori on, 1 left (T2.3)'); + }); + + it('reads "last fighter" and leaves every button as it is with no roster', async () => { + stubApi(vi.fn().mockResolvedValue(null)); + const bare = await mount(kachinukiMatch()); + expect(advisory()).toBeNull(); + const without = buttonStates(); + bare.unmount(); + + stubApi(vi.fn().mockResolvedValue(AKA_EXHAUSTED)); + await mount(kachinukiMatch()); + expect(advisory().textContent).toContain('Aka: T2.1 Kudo on, last fighter'); + const withLine = buttonStates(); + expect(withLine).toEqual(without); + expect(withLine.endArmed).toBe(false); + expect(withLine.endLabel).toBe('End match'); + }); + + it('Record bout at exhaustion reads once and takes the beaten fighter off, on every mount', async () => { + // Bout 2: Kudo leads Ueda, Shiro's last fighter. The lead is not a result + // (bc-kfnl): the server keeps Ueda on until Record bout records the bout. + const BOUT_1_RECORDED = { ...BOUT_1, recorded: true }; + const leading = { ...BOUT_2, winner: 'Kudo', winnerMemberId: 'a1', decision: 'fought', ipponsA: ['M', 'K'] }; + const live = kachinukiMatch({ subResults: [BOUT_1_RECORDED, leading] }); + const recorded = kachinukiMatch({ subResults: [BOUT_1_RECORDED, { ...leading, recorded: true }] }); + const uedaLast = { + sideA: FULL_ROSTER.sideA, + sideB: { lineupFound: true, on: { name: 'Ueda', memberId: 'b2' }, remaining: [] }, + }; + // The server's Record bout verdict once the bout is recorded: no pairing + // follows and Shiro's lineup has nobody left. + const exhausted = { + nextPairing: false, + sideA: { ...FULL_ROSTER.sideA, exhausted: false }, + sideB: { lineupFound: true, on: null, remaining: [], exhausted: true }, + }; + stubApi(vi.fn().mockResolvedValue(uedaLast)); + // Record bout appends nothing (Shiro has nobody left) but the answer + // carries the bout as recorded. + const onSubmit = vi.fn(async (patch) => ({ ...recorded, ...patch, subResults: recorded.subResults, modifiedAt: 1234 })); + const view = await mount(live, onSubmit); + const aka = 'Aka: T2.1 Kudo on, 2 left (T2.2 Mori, T2.3)'; + expect(advisory().textContent).toBe(`Shiro: T1.2 Ueda on, last fighter · ${aka}`); + expect(window.API.fetchKachinukiRoster).toHaveBeenCalledTimes(1); + + // Once Record bout has recorded the bout, the server's roster has Ueda off. + window.API.fetchKachinukiRoster.mockResolvedValue(exhausted); + const record = screen.getAllByRole('button').find(b => b.textContent === 'Record bout'); + await act(async () => { fireEvent.click(record); }); + await settle(); + expect(onSubmit).toHaveBeenCalledTimes(1); + const sent = onSubmit.mock.calls[0][0]; + expect(sent.kachinukiBoutFinal).toBe(true); + // Review round 9, finding 6: no row carries the recorded mark on the + // wire, not even the recorded bout 1 (only the server writes it, and it + // ignores what a write says). + expect(sent.subResults.find(s => s.position === 1).recorded).toBeUndefined(); + expect(sent.subResults.find(s => s.position === 2).recorded).toBeUndefined(); + + // The sheet shows the answer's recorded bout at once (review round 5, + // finding 1: it holds the mark until a newer stamp), and again once the + // host passes it: the roster reads once more, Ueda off. No pairing + // follows, so the recorded bout renders read-only and the no-pairing line + // stands in the advisory's place (bc-kbrc). + await remount(view, recorded); + await settle(); + expect(window.API.fetchKachinukiRoster).toHaveBeenCalledTimes(2); + const line = 'Shiro has no fighters left. End match finishes the encounter.'; + expect(advisory()).toBeNull(); + expect(screen.getByTestId('kachinuki-no-pairing').textContent).toBe(line); + + // Reopening the sheet (a reload, another device) reads the same line. + view.unmount(); + stubApi(vi.fn().mockResolvedValue(exhausted)); + await mount(JSON.parse(JSON.stringify(recorded))); + expect(window.API.fetchKachinukiRoster).toHaveBeenCalledTimes(1); + expect(window.API.fetchKachinukiRoster).toHaveBeenCalledWith('comp1', 'm1'); + expect(screen.getByTestId('kachinuki-no-pairing').textContent).toBe(line); + }); + + it('reads nothing for a point, a lead or a 1-1 on the live bout, and once when Record bout records it', async () => { + const view = await mount(kachinukiMatch()); + expect(window.API.fetchKachinukiRoster).toHaveBeenCalledTimes(1); + // A point on the live bout reads nothing. + await remount(view, kachinukiMatch({ subResults: [BOUT_1, { ...BOUT_2, ipponsA: ['M'], ipponsB: [] }] })); + await settle(); + expect(window.API.fetchKachinukiRoster).toHaveBeenCalledTimes(1); + // Nor does the winner the running autosave writes at a 1-0 lead, or the + // hikiwake it writes at 1-1: neither is a result until the bout is + // recorded (bc-kfnl). + const won = { ...BOUT_2, winner: 'Kudo', winnerMemberId: 'a1', decision: 'fought', ipponsA: ['M'] }; + await remount(view, kachinukiMatch({ subResults: [BOUT_1, won] })); + await settle(); + expect(window.API.fetchKachinukiRoster).toHaveBeenCalledTimes(1); + await remount(view, kachinukiMatch({ subResults: [BOUT_1, { ...BOUT_2, decision: 'hikiwake', ipponsA: ['M'], ipponsB: ['K'] }] })); + await settle(); + expect(window.API.fetchKachinukiRoster).toHaveBeenCalledTimes(1); + // Record bout recording the bout reads once. + await remount(view, kachinukiMatch({ subResults: [BOUT_1, { ...won, recorded: true }] })); + await settle(); + expect(window.API.fetchKachinukiRoster).toHaveBeenCalledTimes(2); + }); + + it('reads again once when a lineup change is announced and read, not before and not for the same lineup', async () => { + await mount(kachinukiMatch()); + expect(window.API.fetchKachinukiRoster).toHaveBeenCalledTimes(1); + const announce = async (teamId) => { + await act(async () => { + window.dispatchEvent(new CustomEvent('lineup-updated', { detail: { competitionId: 'comp1', teamId } })); + }); + await settle(); + }; + + // An announcement whose read answers the lineup already read: nothing. + await announce('team-A'); + expect(window.API.fetchLineupInForce).toHaveBeenCalledTimes(3); + expect(window.API.fetchKachinukiRoster).toHaveBeenCalledTimes(1); + + // A save that landed (from another device, or this one's from the outbox) + // is announced; the server now holds it, so the roster is read once. + const saved = { positions: { '1': 'Kudo', '2': '', '3': 'Mori' }, memberIds: { '1': 'a1', '3': 'a2' } }; + window.API.fetchLineupInForce.mockImplementation((_c, teamId) => Promise.resolve(teamId === 'team-A' ? saved : LINEUPS[teamId])); + await announce('team-A'); + expect(window.API.fetchKachinukiRoster).toHaveBeenCalledTimes(2); + + // While this device still holds a save of that lineup in its outbox, the + // sheet keeps showing its own pick, but the roster follows what the server + // answered: a change made elsewhere meanwhile is read once. + window.API.queuedLineupSave = vi.fn().mockReturnValue(true); + const elsewhere = { positions: { '1': 'Kudo', '2': 'Mori', '3': '' }, memberIds: { '1': 'a1', '2': 'a2' } }; + window.API.fetchLineupInForce.mockImplementation((_c, teamId) => Promise.resolve(teamId === 'team-A' ? elsewhere : LINEUPS[teamId])); + await announce('team-A'); + expect(window.API.fetchKachinukiRoster).toHaveBeenCalledTimes(3); + // The other team's announcement with its lineup unchanged reads nothing. + await announce('team-B'); + expect(window.API.fetchKachinukiRoster).toHaveBeenCalledTimes(3); + }); + + it('reads once, not twice, when a side is given another team', async () => { + window.API.fetchLineupInForce.mockImplementation((_c, teamId) => Promise.resolve( + teamId === 'team-C' + ? { positions: { '1': 'Kudo', '2': 'Mori', '3': '' }, memberIds: { '1': 'a1', '2': 'a2', '3': 'a3' } } + : LINEUPS[teamId] || null, + )); + const view = await mount(kachinukiMatch()); + expect(window.API.fetchKachinukiRoster).toHaveBeenCalledTimes(1); + await act(async () => { + view.rerender(); + }); + await settle(); + expect(window.API.fetchLineupInForce).toHaveBeenCalledWith('comp1', 'team-C', 'm1'); + expect(window.API.fetchKachinukiRoster).toHaveBeenCalledTimes(2); + }); + + it('reads once more, not twice, when Record bout appends the next pairing', async () => { + const live = kachinukiMatch({ + subResults: [BOUT_1, { ...BOUT_2, winner: 'Ueda', winnerMemberId: 'b2', ipponsB: ['K'] }], + }); + const appended = [ + ...live.subResults, + { position: 3, sideA: 'Mori', sideAMemberId: 'a2', sideB: 'Ueda', sideBMemberId: 'b2', ipponsA: [], ipponsB: [] }, + ]; + const onSubmit = vi.fn(async (patch) => ({ ...live, ...patch, subResults: appended, modifiedAt: 1234 })); + const view = await mount(live, onSubmit); + expect(window.API.fetchKachinukiRoster).toHaveBeenCalledTimes(1); + + const record = screen.getAllByRole('button').find(b => b.textContent === 'Record bout'); + await act(async () => { fireEvent.click(record); }); + await settle(); + // The answer's log grew past the recorded bout: the append alone re-keys. + expect(window.API.fetchKachinukiRoster).toHaveBeenCalledTimes(1); + + await act(async () => { + view.rerender(); + }); + await settle(); + expect(window.API.fetchKachinukiRoster).toHaveBeenCalledTimes(2); + expect(window.API.fetchKachinukiRoster).toHaveBeenLastCalledWith('comp1', 'm1'); + }); + + it('never asks for a lineup', async () => { + stubApi(vi.fn().mockResolvedValue({ sideA: { lineupFound: false, on: FULL_ROSTER.sideA.on, remaining: [] }, sideB: FULL_ROSTER.sideB })); + await mount(kachinukiMatch()); + expect(advisory().textContent).toBe('Shiro: T1.2 Ueda on, 1 left (T1.3 Sato) · Aka: T2.1 Kudo on'); + expect(advisory().textContent).not.toMatch(/lineup|incomplete|add/i); + }); + + it('shows no line when neither side has a lineup', async () => { + stubApi(vi.fn().mockResolvedValue({ + sideA: { lineupFound: false, on: FULL_ROSTER.sideA.on, remaining: [] }, + sideB: { lineupFound: false, on: FULL_ROSTER.sideB.on, remaining: [] }, + })); + await mount(kachinukiMatch()); + expect(advisory()).toBeNull(); + }); + + it('shows no line when the read fails', async () => { + stubApi(vi.fn().mockRejectedValue(new Error('offline'))); + await mount(kachinukiMatch()); + expect(advisory()).toBeNull(); + expect(screen.getByTestId('kachinuki-end-match-button')).toBeTruthy(); + }); + + it('shows no line in the completed (correction) view', async () => { + await mount(kachinukiMatch({ + status: 'completed', winner: 'Team A', decision: 'kachinuki-exhaustion', + subResults: [BOUT_1, { ...BOUT_2, winner: 'Kudo', winnerMemberId: 'a1', decision: 'fought', ipponsA: ['M'] }], + })); + expect(advisory()).toBeNull(); + expect(window.API.fetchKachinukiRoster).not.toHaveBeenCalled(); + }); + + it('shows no line for a team match that is not kachinuki', async () => { + window.API.fetchCompetitionDetails = vi.fn().mockResolvedValue({ + id: 'comp1', config: { format: 'mixed', teamMatchType: '', naginata: false, players: [] }, + }); + await mount(kachinukiMatch({ teamMatchType: '' })); + expect(advisory()).toBeNull(); + expect(window.API.fetchKachinukiRoster).not.toHaveBeenCalled(); + }); +}); diff --git a/web-mobile/js/__tests__/render/kachinuki_recorded_last_bout.render.test.jsx b/web-mobile/js/__tests__/render/kachinuki_recorded_last_bout.render.test.jsx new file mode 100644 index 000000000..f4d5c53d2 --- /dev/null +++ b/web-mobile/js/__tests__/render/kachinuki_recorded_last_bout.render.test.jsx @@ -0,0 +1,471 @@ +// bc-kbrc: when Record bout records the last bout and appends no pairing (a +// side has nobody left), that bout reads as recorded: it renders read-only +// like every other recorded bout, correctable through the same tap, and one +// line stands where the live bout was, naming who has nobody left and that +// End match finishes the encounter. It comes from the server's stored mark +// (subResults[].recorded), so a reload and a second device show the same. +// Record bout cannot record it again; "+ Add next bout manually" brings a +// live bout back. A recorded draw keeps its bout live in every phase (Encho on +// it is the operator's call), and a bout scored but not recorded stays live as +// before. +import React from 'react'; +import { render, act, screen, fireEvent } from '@testing-library/react'; +import { describe, it, expect, vi, beforeAll, afterAll, beforeEach } from 'vitest'; +import { installWindowStubs } from '../helpers/stub_globals.js'; +import { keepNewerMatches, keepNewerCompetitions } from '../../patch.jsx'; + +const STUBBED_GLOBALS = { + isHikiwake: () => false, + arraysEqual: (a, b) => a.length === b.length && a.every((v, i) => v === b[i]), + isKikenDecision: () => false, + isTextEntry: () => false, + isInteractiveTarget: () => false, + confirmDialog: vi.fn().mockResolvedValue(true), + API: {}, + 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 SQUADS = { + 'team-A': [{ id: 'a1', index: 1, name: 'Kudo' }, { id: 'a2', index: 2, name: 'Mori' }], + 'team-B': [{ id: 'b1', index: 1, name: 'Taki' }, { id: 'b2', index: 2, name: 'Ueda' }], +}; +const LINEUPS = { + 'team-A': { positions: { '1': 'Kudo', '2': 'Mori' }, memberIds: { '1': 'a1', '2': 'a2' } }, + 'team-B': { positions: { '1': 'Taki', '2': 'Ueda' }, memberIds: { '1': 'b1', '2': 'b2' } }, +}; + +function stubApi(roster) { + window.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(), + fetchSquads: vi.fn().mockResolvedValue(SQUADS), + fetchLineupInForce: vi.fn((_c, teamId) => Promise.resolve(LINEUPS[teamId] || null)), + fetchKachinukiRoster: vi.fn().mockResolvedValue(roster), + }; +} + +beforeEach(() => stubApi(null)); + +// Kudo (Aka) beat Taki, then Ueda, Shiro's last fighter: both recorded. +const BOUT_1 = { + position: 1, sideA: 'Kudo', sideAMemberId: 'a1', sideB: 'Taki', sideBMemberId: 'b1', + winner: 'Kudo', winnerMemberId: 'a1', decision: 'fought', ipponsA: ['M'], ipponsB: [], recorded: true, +}; +const BOUT_2 = { + position: 2, sideA: 'Kudo', sideAMemberId: 'a1', sideB: 'Ueda', sideBMemberId: 'b2', + winner: 'Kudo', winnerMemberId: 'a1', decision: 'fought', ipponsA: ['M'], ipponsB: [], recorded: true, +}; +// The server's Record bout verdict (engine.KachinukiRoster): no pairing +// follows, and the loser's lineup has nobody left. +const SHIRO_OUT = { + nextPairing: false, + sideA: { lineupFound: true, on: { name: 'Kudo', memberId: 'a1' }, remaining: [{ name: 'Mori', memberId: 'a2' }], exhausted: false }, + sideB: { lineupFound: true, on: null, remaining: [], exhausted: true }, +}; +// A forged answer the server never gives (it exhausts at most one side, and +// never after a draw): the sheet's own draw rule must still keep a recorded +// draw live. +const BOTH_OUT = { + nextPairing: false, + sideA: { lineupFound: true, on: null, remaining: [], exhausted: true }, + sideB: { lineupFound: true, on: null, remaining: [], exhausted: true }, +}; + +function kachinukiMatch(subResults, phase = 'pool') { + return { + id: 'm1', compId: 'comp1', status: 'running', phase, poolName: 'Pool 1', court: 'A', + compKind: 'team', teamSize: 2, compFormat: 'mixed', teamMatchType: 'kachinuki', + sideA: { id: 'team-A', name: 'Team A', number: 'T2' }, + sideB: { id: 'team-B', name: 'Team B', number: 'T1' }, + subResults, + }; +} + +async function settle() { + for (let i = 0; i < 6; i++) await act(async () => { await Promise.resolve(); }); +} + +async function mount(match, extra = {}) { + let view; + await act(async () => { + view = render(); + }); + await settle(); + return view; +} + +const liveRows = (container) => container.querySelectorAll('.team-sub-match:not(.team-sub-match--readonly)'); +const recordButton = () => screen.getAllByRole('button').find(b => b.textContent === 'Record bout'); + +describe('a recorded last kachinuki bout with no pairing after it', () => { + it('renders read-only with the others, and the line says who has nobody left', async () => { + stubApi(SHIRO_OUT); + const { container } = await mount(kachinukiMatch([BOUT_1, BOUT_2])); + expect(screen.getByTestId('kachinuki-done-bout-0')).toBeTruthy(); + expect(screen.getByTestId('kachinuki-done-bout-1')).toBeTruthy(); + expect(liveRows(container).length).toBe(0); + expect(screen.getByTestId('kachinuki-no-pairing').textContent) + .toBe('Shiro has no fighters left. End match finishes the encounter.'); + expect(screen.queryByTestId('kachinuki-advisory')).toBeNull(); + // Record bout would record the same bout again; End match finishes. + expect(recordButton().disabled).toBe(true); + expect(screen.getByTestId('kachinuki-end-match-button').disabled).toBe(false); + expect(screen.queryByTestId('kachinuki-encho-button')).toBeNull(); + expect(screen.queryByTestId('kachinuki-remove-bout-button')).toBeNull(); + expect(screen.queryByTestId('kachinuki-end-hint')).toBeNull(); + expect(screen.queryByTestId('kachinuki-record-hint')).toBeNull(); + expect(screen.getByTestId('kachinuki-add-bout-button').disabled).toBe(false); + }); + + it('is correctable through the same tap as an earlier bout', async () => { + stubApi(SHIRO_OUT); + const { container } = await mount(kachinukiMatch([BOUT_1, BOUT_2])); + await act(async () => { fireEvent.click(screen.getByTestId('kachinuki-done-bout-1'), { detail: 1 }); }); + await settle(); + expect(screen.queryByTestId('kachinuki-done-bout-1')).toBeNull(); + expect(screen.getByTestId('kachinuki-done-bout-0')).toBeTruthy(); + expect(liveRows(container).length).toBe(1); + }); + + it('"+ Add next bout manually" brings a live bout back', async () => { + stubApi(SHIRO_OUT); + const { container } = await mount(kachinukiMatch([BOUT_1, BOUT_2])); + await act(async () => { fireEvent.click(screen.getByTestId('kachinuki-add-bout-button')); }); + await settle(); + expect(screen.getByTestId('kachinuki-done-bout-1')).toBeTruthy(); + expect(liveRows(container).length).toBe(1); + expect(screen.queryByTestId('kachinuki-no-pairing')).toBeNull(); + }); + + it('stays live, as before, when no lineup says a side has nobody left', async () => { + // No roster read, and the server's answer for a loser with no lineup: no + // pairing follows, but no side is exhausted (the bouts alone cannot say). + // Review round 9, finding 2: with that answer Record bout would write + // nothing, so it is disabled; with no read it stays enabled. + for (const [roster, recordDisabled] of [[null, false], [{ + nextPairing: false, + sideA: { lineupFound: true, on: { name: 'Kudo', memberId: 'a1' }, remaining: [], exhausted: false }, + sideB: { lineupFound: false, on: null, remaining: [], exhausted: false }, + }, true]]) { + stubApi(roster); + const view = await mount(JSON.parse(JSON.stringify(kachinukiMatch([BOUT_1, BOUT_2])))); + expect(liveRows(view.container).length).toBe(1); + expect(screen.getByTestId('kachinuki-done-bout-0')).toBeTruthy(); + expect(screen.queryByTestId('kachinuki-done-bout-1')).toBeNull(); + expect(screen.queryByTestId('kachinuki-no-pairing')).toBeNull(); + expect(recordButton().disabled).toBe(recordDisabled); + view.unmount(); + } + }); + + it('keeps Record bout open on a recorded bout when the server says a pairing would follow', async () => { + // A lineup saved after the bout was recorded gives the loser's side a + // fighter: a tap now appends, so the button stays open and nothing closes. + stubApi({ + nextPairing: true, + sideA: { lineupFound: true, on: { name: 'Kudo', memberId: 'a1' }, remaining: [], exhausted: false }, + sideB: { lineupFound: true, on: null, remaining: [{ name: 'Sato', memberId: 'b3' }], exhausted: false }, + }); + const { container } = await mount(kachinukiMatch([BOUT_1, BOUT_2])); + expect(liveRows(container).length).toBe(1); + expect(screen.queryByTestId('kachinuki-no-pairing')).toBeNull(); + expect(recordButton().disabled).toBe(false); + }); + + it('a fresh mount keeps the bout live until the roster read lands, then closes it', async () => { + let resolveRoster; + stubApi(null); + window.API.fetchKachinukiRoster = vi.fn(() => new Promise((r) => { resolveRoster = r; })); + const { container } = await mount(JSON.parse(JSON.stringify(kachinukiMatch([BOUT_1, BOUT_2])))); + expect(window.API.fetchKachinukiRoster).toHaveBeenCalledTimes(1); + expect(liveRows(container).length).toBe(1); + expect(screen.queryByTestId('kachinuki-no-pairing')).toBeNull(); + await act(async () => { resolveRoster(SHIRO_OUT); }); + await settle(); + expect(liveRows(container).length).toBe(0); + expect(screen.getByTestId('kachinuki-no-pairing').textContent) + .toBe('Shiro has no fighters left. End match finishes the encounter.'); + }); + + // Review round 4, finding 1: a recorded draw that is still the last bout + // (both sides had nobody left) stays live in every phase, Encho offered: the + // operator decides whether the pair fights on, End match decides the rest. + // The roster read stubbed here says both sides are out (an answer the server + // no longer gives for it), so the sheet's own rule is what keeps it live. + it.each(['pool', 'bracket'])('keeps a recorded draw live in a %s match, Encho offered', async (phase) => { + stubApi(BOTH_OUT); + const draw = { ...BOUT_2, winner: '', winnerMemberId: '', decision: 'hikiwake', ipponsA: ['M'], ipponsB: ['K'] }; + const { container } = await mount(kachinukiMatch([BOUT_1, draw], phase)); + expect(liveRows(container).length).toBe(1); + expect(screen.queryByTestId('kachinuki-no-pairing')).toBeNull(); + expect(screen.getByTestId('kachinuki-encho-button')).toBeTruthy(); + }); + + it('keeps a scored but unrecorded last bout live', async () => { + stubApi(SHIRO_OUT); + const { recorded: _r, ...unrecorded } = BOUT_2; + const { container } = await mount(kachinukiMatch([BOUT_1, unrecorded])); + expect(liveRows(container).length).toBe(1); + expect(screen.queryByTestId('kachinuki-no-pairing')).toBeNull(); + expect(recordButton().disabled).toBe(false); + }); +}); + +// Review round 3, finding 2: the closed state keeps the last roster answer that +// landed for the same recorded bout while a newer read is out or after one +// fails, and the stored log decides first, at once. +describe('a closed recorded last bout and a roster re-read', () => { + const announce = async (teamId) => { + await act(async () => { + window.dispatchEvent(new CustomEvent('lineup-updated', { detail: { competitionId: 'comp1', teamId } })); + }); + await settle(); + }; + // A lineup the server now holds for Aka that differs from the one read. + const changedLineupA = { positions: { '1': 'Kudo', '2': 'Mori', '3': '' }, memberIds: { '1': 'a1', '2': 'a2' }, matchId: 'm1' }; + const rerender = async (view, match) => { + await act(async () => { + view.rerender(); + }); + await settle(); + }; + const expectClosed = () => { + expect(screen.getByTestId('kachinuki-no-pairing').textContent) + .toBe('Shiro has no fighters left. End match finishes the encounter.'); + }; + + it('does not reopen while a lineup-updated re-read is in flight', async () => { + stubApi(SHIRO_OUT); + const { container } = await mount(kachinukiMatch([BOUT_1, BOUT_2])); + expect(liveRows(container).length).toBe(0); + window.API.fetchKachinukiRoster = vi.fn(() => new Promise(() => {})); + window.API.fetchLineupInForce.mockImplementation((_c, teamId) => Promise.resolve(teamId === 'team-A' ? changedLineupA : LINEUPS[teamId])); + await announce('team-A'); + expect(window.API.fetchKachinukiRoster).toHaveBeenCalledTimes(1); + expect(liveRows(container).length).toBe(0); + expectClosed(); + }); + + it('does not reopen when the re-read fails', async () => { + stubApi(SHIRO_OUT); + const { container } = await mount(kachinukiMatch([BOUT_1, BOUT_2])); + expect(liveRows(container).length).toBe(0); + // fetchKachinukiRoster answers a failed read with null. + window.API.fetchKachinukiRoster = vi.fn().mockResolvedValue(null); + window.API.fetchLineupInForce.mockImplementation((_c, teamId) => Promise.resolve(teamId === 'team-A' ? changedLineupA : LINEUPS[teamId])); + await announce('team-A'); + expect(window.API.fetchKachinukiRoster).toHaveBeenCalledTimes(1); + expect(liveRows(container).length).toBe(0); + expectClosed(); + }); + + it('reopens at once when the bout is un-recorded, before any re-read lands', async () => { + stubApi(SHIRO_OUT); + const view = await mount(kachinukiMatch([BOUT_1, BOUT_2])); + expect(liveRows(view.container).length).toBe(0); + window.API.fetchKachinukiRoster = vi.fn(() => new Promise(() => {})); + const { recorded: _r, ...unrecorded } = BOUT_2; + await rerender(view, kachinukiMatch([BOUT_1, unrecorded])); + expect(liveRows(view.container).length).toBe(1); + expect(screen.queryByTestId('kachinuki-no-pairing')).toBeNull(); + }); +}); + +// Review round 3, finding 4: a knockout tie recorded when both teams had nobody +// left is fought on in encho on that bout. The server keeps both fighters on +// for it (engine.KachinukiRoster), and the write that starts the encho takes +// the recorded mark off, so the line names both on before and during encho. +describe('a recorded knockout tie fought on in encho', () => { + const BOTH_ON = { + nextPairing: false, + sideA: { lineupFound: true, on: { name: 'Mori', memberId: 'a2' }, remaining: [], exhausted: false }, + sideB: { lineupFound: true, on: { name: 'Ueda', memberId: 'b2' }, remaining: [], exhausted: false }, + }; + const T1 = { + position: 1, sideA: 'Kudo', sideAMemberId: 'a1', sideB: 'Taki', sideBMemberId: 'b1', + winner: 'Taki', winnerMemberId: 'b1', decision: 'fought', ipponsA: [], ipponsB: ['M'], recorded: true, + }; + const T2 = { + position: 2, sideA: 'Mori', sideAMemberId: 'a2', sideB: 'Taki', sideBMemberId: 'b1', + winner: 'Mori', winnerMemberId: 'a2', decision: 'fought', ipponsA: ['K'], ipponsB: [], recorded: true, + }; + const TIE = { + position: 3, sideA: 'Mori', sideAMemberId: 'a2', sideB: 'Ueda', sideBMemberId: 'b2', + winner: '', winnerMemberId: '', decision: 'hikiwake', ipponsA: ['M'], ipponsB: ['K'], recorded: true, + }; + + it('names both fighters on after the recorded tie and in encho, the bout live', async () => { + stubApi(BOTH_ON); + const view = await mount(kachinukiMatch([T1, T2, TIE], 'bracket')); + expect(liveRows(view.container).length).toBe(1); + expect(screen.queryByTestId('kachinuki-no-pairing')).toBeNull(); + const line = () => screen.getByTestId('kachinuki-advisory').textContent; + expect(line()).toContain('Ueda on'); + expect(line()).toContain('Mori on'); + expect(line()).not.toContain('no fighters left'); + + // The Encho tap lands: the stored row is no longer recorded. + const { recorded: _r, ...inEncho } = { ...TIE, decision: '', encho: { periodCount: 1 } }; + await act(async () => { + view.rerender(); + }); + await settle(); + expect(liveRows(view.container).length).toBe(1); + expect(line()).toContain('Ueda on'); + expect(line()).toContain('Mori on'); + }); + + // Review round 8, finding 4: the recorded tie stays live, but Record bout + // would write nothing on it, so it is disabled and says why. + it('disables Record bout while the live bout is stored as recorded', async () => { + stubApi(BOTH_ON); + await mount(kachinukiMatch([T1, T2, TIE])); + expect(recordButton().disabled).toBe(true); + expect(recordButton().getAttribute('title')).toBe('Bout recorded'); + }); + + + // Review round 10, finding 3: a lineup saved since may make a pairing + // possible. When the roster re-read that save causes fails, the answer read + // before it is stale and must not keep Record bout disabled. + it('a lineup save whose roster re-read fails re-enables Record bout', async () => { + stubApi(BOTH_ON); + await mount(kachinukiMatch([T1, T2, TIE])); + expect(recordButton().disabled).toBe(true); + // fetchKachinukiRoster answers a failed read with null. + window.API.fetchKachinukiRoster = vi.fn().mockResolvedValue(null); + const savedA = { positions: { '1': 'Kudo', '2': 'Mori', '3': 'Sato' }, memberIds: { '1': 'a1', '2': 'a2' }, matchId: 'm1' }; + window.API.fetchLineupInForce.mockImplementation((_c, teamId) => Promise.resolve(teamId === 'team-A' ? savedA : LINEUPS[teamId])); + await act(async () => { + window.dispatchEvent(new CustomEvent('lineup-updated', { detail: { competitionId: 'comp1', teamId: 'team-A' } })); + }); + await settle(); + expect(window.API.fetchKachinukiRoster).toHaveBeenCalledTimes(1); + expect(recordButton().disabled).toBe(false); + }); + + // Review round 8, finding 2: a point scored on the recorded tie changes the + // bout's result, and the server's merge takes the mark off. The bout scored + // to 1-0 stays live with both fighters on, nothing closes, and Record bout + // is open again. + it('a recorded tie scored to 1-0 does not close, and Record bout comes back', async () => { + stubApi(BOTH_ON); + const view = await mount(kachinukiMatch([T1, T2, TIE])); + const { recorded: _r, ...scored } = { ...TIE, ipponsA: ['M', 'D'], winner: 'Mori', winnerMemberId: 'a2', decision: '' }; + await act(async () => { + view.rerender(); + }); + await settle(); + expect(liveRows(view.container).length).toBe(1); + expect(screen.queryByTestId('kachinuki-no-pairing')).toBeNull(); + const line = screen.getByTestId('kachinuki-advisory').textContent; + expect(line).toContain('Ueda on'); + expect(line).toContain('Mori on'); + expect(recordButton().disabled).toBe(false); + }); +}); + +// Review rounds 5 and 6: a Record bout that appends no pairing only marks its +// bout recorded. The mark is a merge group of its own (GroupRecorded), stamped +// strictly after the match it was made on, so the copy carrying it is NEWER +// than every copy read before it, and each host's keepNewerMatches (patch.jsx) +// keeps it over a refetch that read the match before the mark and answers +// after it. No hold in the editor: the props here go through the host's own +// rule, as on every device. +describe('the recorded mark against a stale copy', () => { + const { recorded: _r, ...UNRECORDED_2 } = BOUT_2; + const stamped = (subResults, modifiedAt) => ({ ...kachinukiMatch(subResults), modifiedAt }); + + // host keeps the detail a host holds and renders the editor from it, every + // copy delivered through keepNewerMatches as a refetch is. + function host(view, props) { + let held = null; + return async (match) => { + held = keepNewerMatches(held, { poolMatches: [match] }); + await act(async () => { view.rerender(); }); + await settle(); + }; + } + + it('this device: a stale unmarked copy arriving after the marked one does not reopen the bout', async () => { + stubApi(SHIRO_OUT); + // The server's answer: the mark's stamp is strictly after the match's. + const onSubmit = vi.fn().mockResolvedValue({ subResults: [BOUT_1, BOUT_2], modifiedAt: 101 }); + const props = { onClose: vi.fn(), onSubmit, password: 'secret' }; + let view; + await act(async () => { + view = render(); + }); + await settle(); + const show = host(view, props); + await show(stamped([BOUT_1, UNRECORDED_2], 100)); + expect(liveRows(view.container).length).toBe(1); + await act(async () => { fireEvent.click(recordButton()); }); + await settle(); + expect(onSubmit).toHaveBeenCalledTimes(1); + // The push that brings the mark, then the refetch read before it. + await show(stamped([BOUT_1, BOUT_2], 101)); + expect(liveRows(view.container).length).toBe(0); + await show(stamped([BOUT_1, UNRECORDED_2], 100)); + expect(liveRows(view.container).length).toBe(0); + expect(screen.getByTestId('kachinuki-done-bout-1')).toBeTruthy(); + expect(screen.getByTestId('kachinuki-no-pairing')).toBeTruthy(); + }); + + it('a second device: a list refetch read before the mark keeps the marked copy', async () => { + stubApi(SHIRO_OUT); + const props = { onClose: vi.fn(), onSubmit: vi.fn(), password: 'secret' }; + const unmarked = stamped([BOUT_1, UNRECORDED_2], 100); + let view; + await act(async () => { view = render(); }); + await settle(); + expect(liveRows(view.container).length).toBe(1); + // The tournament's competitions, as the Scores tab and the console hold + // them (keepNewerCompetitions): the push with the mark, then a list + // refetch that read the match before it. + let list = keepNewerCompetitions(null, [{ id: 'comp1', poolMatches: [unmarked] }]); + const deliver = async (fetched) => { + list = keepNewerCompetitions(list, fetched); + await act(async () => { view.rerender(); }); + await settle(); + }; + await deliver([{ id: 'comp1', poolMatches: [stamped([BOUT_1, BOUT_2], 101)] }]); + expect(liveRows(view.container).length).toBe(0); + await deliver([{ id: 'comp1', poolMatches: [unmarked] }]); + expect(liveRows(view.container).length).toBe(0); + expect(screen.getByTestId('kachinuki-no-pairing')).toBeTruthy(); + }); + + it('a strictly newer unmarked copy does reopen it', async () => { + stubApi(SHIRO_OUT); + const view = await mount(stamped([BOUT_1, BOUT_2], 101)); + expect(liveRows(view.container).length).toBe(0); + const show = host(view, { onClose: vi.fn(), onSubmit: vi.fn(), password: 'secret' }); + await show(stamped([BOUT_1, BOUT_2], 101)); + await show(stamped([BOUT_1, UNRECORDED_2], 102)); + expect(liveRows(view.container).length).toBe(1); + expect(screen.queryByTestId('kachinuki-no-pairing')).toBeNull(); + }); +}); diff --git a/web-mobile/js/__tests__/render/kachinuki_unscored_bout_hint.render.test.jsx b/web-mobile/js/__tests__/render/kachinuki_unscored_bout_hint.render.test.jsx new file mode 100644 index 000000000..30aa361e3 --- /dev/null +++ b/web-mobile/js/__tests__/render/kachinuki_unscored_bout_hint.render.test.jsx @@ -0,0 +1,105 @@ +// bc-ktih: an unscored kachinuki bout never reads as tied. The footer's End +// hint is worked out from the bout End would judge, which is the LAST SCORED +// bout; after Record bout appends the next pairing that is still the bout just +// recorded, so a 1-1 recorded on bout 1 used to make the fresh, unscored bout 2 +// read "Tied bout. End match records a drawn encounter. Record bout retires +// both and brings the next pair up." (seen after a tie that left Aka's last +// fighter on, but any recorded tie did it). The unscored bout shows the normal +// "Score this bout to enable Record bout" hint instead, and a scored tie on the +// live bout still reads "Tied bout". +import React from 'react'; +import { render, act, 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: () => false, + isTextEntry: () => false, + isInteractiveTarget: () => false, + confirmDialog: vi.fn().mockResolvedValue(true), + 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()); + +beforeEach(() => { + window.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(), + fetchSquads: vi.fn().mockResolvedValue({}), + fetchLineupInForce: vi.fn().mockResolvedValue(null), + fetchKachinukiRoster: vi.fn().mockResolvedValue(null), + }; +}); + +// Bout 1: Sato (Shiro) and Nomura (Aka, their taisho) drew 1-1, recorded. +// Aka has nobody behind Nomura, so the advance kept Nomura on against Ito. +const TIED_RECORDED = { + position: 1, sideA: 'Nomura', sideB: 'Sato', decision: 'hikiwake', + ipponsA: ['M'], ipponsB: ['K'], recorded: true, +}; +const APPENDED = { position: 2, sideA: 'Nomura', sideB: 'Ito', ipponsA: [], ipponsB: [] }; + +function kachinukiMatch(subResults, phase = 'pool') { + return { + id: 'm1', compId: 'comp1', status: 'running', phase, 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, + }; +} + +async function mount(match) { + await act(async () => { + render(); + }); + for (let i = 0; i < 6; i++) await act(async () => { await Promise.resolve(); }); +} + +describe('an unscored kachinuki bout after a recorded tie', () => { + it('never reads as tied and shows the Score-this-bout hint (pool)', async () => { + await mount(kachinukiMatch([TIED_RECORDED, APPENDED])); + const end = screen.queryByTestId('kachinuki-end-hint'); + expect(end ? end.textContent : '').not.toMatch(/Tied bout/); + expect(screen.getByTestId('kachinuki-record-hint').textContent) + .toBe('Score this bout to enable Record bout, or End match to finish on the last scored bout.'); + }); + + it('never reads as tied in a knockout, and does not offer End match there', async () => { + await mount(kachinukiMatch([TIED_RECORDED, APPENDED], 'bracket')); + const end = screen.queryByTestId('kachinuki-end-hint'); + expect(end ? end.textContent : '').not.toMatch(/Tied bout|Record bout brings/); + expect(screen.getByTestId('kachinuki-record-hint').textContent) + .toBe('Score this bout to enable Record bout. No draws in a knockout: End match needs a bout with a point.'); + }); + + it('still reads "Tied bout" for a tie on the live bout itself', async () => { + const liveTie = { ...APPENDED, decision: 'hikiwake', ipponsA: ['M'], ipponsB: ['K'] }; + await mount(kachinukiMatch([TIED_RECORDED, liveTie])); + expect(screen.getByTestId('kachinuki-end-hint').textContent).toMatch(/^Tied bout\. End match records a drawn encounter\./); + expect(screen.queryByTestId('kachinuki-record-hint')).toBeNull(); + }); +}); diff --git a/web-mobile/js/__tests__/render/lineup_editors_save_only_changes.render.test.jsx b/web-mobile/js/__tests__/render/lineup_editors_save_only_changes.render.test.jsx index 0bc8a6fe0..b2173f8c5 100644 --- a/web-mobile/js/__tests__/render/lineup_editors_save_only_changes.render.test.jsx +++ b/web-mobile/js/__tests__/render/lineup_editors_save_only_changes.render.test.jsx @@ -838,6 +838,37 @@ describe('the Lineups page', () => { expect(saveButton(utils).disabled, 'the operator can still fix it and save').toBe(false); }); + // A five-person team's positions have FIK names, and the page's refusal uses them as + // the at-court panel and the score sheet do (lineupPositionLabel), on both of its + // paths: the Save and the add row. + it('on a five-person team, names the position by its FIK name when a Save would field one member twice', async () => { + startingHolds({ + positions: { senpo: 'Mori', jiho: 'Mori', chuken: 'Ito' }, memberIds: { senpo: 'mem-4', jiho: 'mem-4', chuken: 'mem-3' }, + }); + const utils = await mountPage({ ...COMP, teamSize: 5 }); + await pick(utils, 'chuken', 'mem-1'); + + await click(saveButton(utils)); + + expect(utils.getByText('Mori is already at Senpo.')).toBeTruthy(); + expect(api.putTeamLineup).not.toHaveBeenCalled(); + }); + + it('on a five-person team, names the position the member holds, not the one the operator typed into, when the add row names him', async () => { + startingHolds({ + positions: { senpo: 'Sato', jiho: 'Mori', chuken: 'Ito' }, memberIds: { senpo: 'mem-2', jiho: 'mem-4', chuken: 'mem-3' }, + }); + const utils = await mountPage({ ...COMP, teamSize: 5 }); + await pick(utils, 'senpo', '__add__'); + await act(async () => { fireEvent.change(utils.getByLabelText('New member name for Senpo'), { target: { value: 'Mori' } }); }); + await click(utils.getByRole('button', { name: 'Add' })); + + expect(utils.getByText('Mori is already at Jiho.')).toBeTruthy(); + expect(api.addTeamMember).not.toHaveBeenCalled(); + expect(api.renameTeamMember).not.toHaveBeenCalled(); + expect(api.putTeamLineup).not.toHaveBeenCalled(); + }); + it('keeps a member it added while a read of the team\'s members begun before the add is out: that read answers without them', async () => { const utils = await mountPage(); // Another device saves a lineup: the page reads the lineup and the team's members again. diff --git a/web-mobile/js/__tests__/render/sweep_form_labels.render.test.jsx b/web-mobile/js/__tests__/render/sweep_form_labels.render.test.jsx new file mode 100644 index 000000000..9c9485de3 --- /dev/null +++ b/web-mobile/js/__tests__/render/sweep_form_labels.render.test.jsx @@ -0,0 +1,143 @@ +// bc-lbla, the sweep: the operator forms outside the competition-config +// screens name their fields by their visible labels too, and a toggle row +// says which option is chosen. Representative sites: the tournament Edit +// details form, the password reset form, the branding and sponsor managers, +// and the announcement composer (which renders in two places, so its ids are +// minted rather than fixed). +import React from 'react'; +import { render, screen, within, act } from '@testing-library/react'; +import { describe, it, expect, vi, beforeAll, afterAll } from 'vitest'; +import { installWindowStubs } from '../helpers/stub_globals.js'; + +const noop = () => {}; +const Stub = (name) => { + const C = () =>
; + C.displayName = `Stub(${name})`; + return C; +}; + +const STUBBED_GLOBALS = { + AdminTopbar: Stub('AdminTopbar'), + Breadcrumbs: Stub('Breadcrumbs'), + isNonPublicOrigin: () => false, + buildCompetition: (cfg) => ({ ...cfg }), + addMinutes: (t) => t, + setCachedAuthConfig: noop, + API: { + estimateCompetitionSchedule: vi.fn().mockResolvedValue(null), + getNumberPrefixDefault: vi.fn().mockResolvedValue({ numberPrefix: '' }), + fetchAnnouncements: vi.fn().mockResolvedValue([]), + }, +}; + +let restoreGlobals; +let AdminEditTournament; +let ResetPasswordForm; +let BrandingManager; +let SponsorsManager; +let AnnouncementComposer; + +beforeAll(async () => { + restoreGlobals = installWindowStubs(STUBBED_GLOBALS); + ({ BrandingManager } = await import('../../admin_branding.jsx')); + ({ SponsorsManager } = await import('../../admin_sponsors.jsx')); + ({ ResetPasswordForm } = await import('../../reset.jsx')); + await import('../../admin_announcement.jsx'); + AnnouncementComposer = window.AnnouncementComposer; + await import('../../admin_setup.jsx'); + AdminEditTournament = window.AdminEditTournament; +}); + +afterAll(() => restoreGlobals()); + +const tournament = { + name: 'Spring Taikai', date: '10-08-2026', durationDays: 1, venue: 'Kendo Hall', + courts: ['A', 'B'], competitions: [], mode: 'officiated', + publicURL: 'https://example.test', contacts: [{ label: 'Email', value: 'a@b.test' }], +}; + +describe('the tournament Edit details form', () => { + it('names every field by its visible label', async () => { + await act(async () => { + render( + + ); + }); + expect(screen.getByLabelText('Name').value).toBe('Spring Taikai'); + expect(screen.getByLabelText('Start date (Day 1)').type).toBe('date'); + expect(screen.getByLabelText('Number of days').value).toBe('1'); + expect(screen.getByLabelText('Venue').value).toBe('Kendo Hall'); + expect(screen.getByLabelText('Number of Shiaijo (courts)').value).toBe('2'); + expect(screen.getByLabelText('Opening ceremony (duration)').tagName).toBe('INPUT'); + expect(screen.getByLabelText('Lunch break (duration)').tagName).toBe('INPUT'); + expect(screen.getByLabelText('Closing ceremony (duration)').tagName).toBe('INPUT'); + // The public block opens by default because the tournament has public data. + expect(screen.getByLabelText('Public URL').value).toBe('https://example.test'); + expect(screen.getByLabelText('Venue address').tagName).toBe('INPUT'); + expect(screen.getByLabelText('Map link').tagName).toBe('INPUT'); + expect(screen.getByLabelText('Opening time').type).toBe('time'); + expect(screen.getByLabelText('Closing time').type).toBe('time'); + expect(screen.getByLabelText('Website link').tagName).toBe('INPUT'); + expect(screen.getByLabelText('Awards').tagName).toBe('TEXTAREA'); + expect(screen.getByLabelText('Notes').tagName).toBe('TEXTAREA'); + expect(screen.getByLabelText('Contact 1 value').value).toBe('a@b.test'); + expect(screen.getByRole('button', { name: 'Remove contact 1' })).toBeInTheDocument(); + expect(screen.getByLabelText('Admin password').type).toBe('password'); + expect(screen.getByLabelText('Destructive-ops password (set)').type).toBe('password'); + expect(screen.getByLabelText('Current destructive-ops password').type).toBe('password'); + }); +}); + +describe('the password reset form', () => { + it('names both password fields', () => { + render(); + expect(screen.getByLabelText('New password').type).toBe('password'); + expect(screen.getByLabelText('Confirm new password').type).toBe('password'); + }); +}); + +describe('the branding and sponsor managers', () => { + it('names the branding fields', async () => { + await act(async () => { + render(); + }); + expect(screen.getByLabelText('Browser tab / window title').tagName).toBe('INPUT'); + expect(screen.getByLabelText('Primary accent color').type).toBe('color'); + expect(screen.getByLabelText('Soft accent (background tint)').type).toBe('color'); + expect(screen.getByLabelText(/Upload logo/).type).toBe('file'); + }); + + it('names the sponsor fields', () => { + render(); + expect(screen.getByLabelText('Sponsor name').tagName).toBe('INPUT'); + expect(screen.getByLabelText('Link (optional)').tagName).toBe('INPUT'); + expect(screen.getByLabelText(/^Logo/).type).toBe('file'); + }); +}); + +describe('the announcement composer', () => { + it('names its message box and duration choices, with distinct ids for two composers', async () => { + await act(async () => { + render( + <> +
+
+ + ); + }); + const one = within(screen.getByTestId('one')); + const two = within(screen.getByTestId('two')); + const a = one.getByLabelText('Message'); + const b = two.getByLabelText('Message'); + expect(a.tagName).toBe('TEXTAREA'); + expect(a.id).not.toBe(b.id); + expect(one.getByRole('radiogroup', { name: 'Duration' })).toBeInTheDocument(); + // A browser groups radios by name across the whole document, so the two + // composers' duration choices must not share one. + const nameOne = one.getAllByRole('radio')[0].name; + const nameTwo = two.getAllByRole('radio')[0].name; + expect(nameOne).not.toBe(''); + expect(nameOne).not.toBe(nameTwo); + }); +}); diff --git a/web-mobile/js/__tests__/render/team_editor_member_writes_stand.render.test.jsx b/web-mobile/js/__tests__/render/team_editor_member_writes_stand.render.test.jsx index 36d21ed17..e1e6e0fa1 100644 --- a/web-mobile/js/__tests__/render/team_editor_member_writes_stand.render.test.jsx +++ b/web-mobile/js/__tests__/render/team_editor_member_writes_stand.render.test.jsx @@ -176,6 +176,25 @@ describe('team editor: a members read that predates the sheet\'s own write does expect(notices(container)[0].textContent).toBe('Ito is already at Senpo.'); }); + // A team whose positions have no FIK names words the refusal as the at-court panel + // and the Lineups page do ("Position N", lineupPositionLabel), never by the bare key, + // and names the position the member holds, here the LATER one, not the row typed into. + it('on a three-person team, names the position the member holds as "Position N"', async () => { + const before = teams(blank('b')); + const { container } = await mountWithSecondReadOut(before, teamMatch({ teamSize: 3 })); + + await typeName(bout(container, 1, 'shiro'), 'Ito'); + expect(window.API.renameTeamMember).toHaveBeenCalledWith('comp1', 'team-B', 'b2', 'Ito', ''); + await act(async () => { memberReads[1](before); }); + await flush(); + await typeName(bout(container, 0, 'shiro'), 'Ito'); + + expect(window.API.addTeamMember).not.toHaveBeenCalled(); + expect(window.API.renameTeamMember).toHaveBeenCalledTimes(1); + expect(notices(container)).toHaveLength(1); + expect(notices(container)[0].textContent).toBe('Ito is already at Position 2.'); + }); + it('keeps the member the sheet added, with its number', async () => { const before = teams(SHIRO_NAMED); const { container } = await mountWithSecondReadOut(before); diff --git a/web-mobile/js/__tests__/render/team_unfought_bout_reads_unfought.render.test.jsx b/web-mobile/js/__tests__/render/team_unfought_bout_reads_unfought.render.test.jsx new file mode 100644 index 000000000..74124230c --- /dev/null +++ b/web-mobile/js/__tests__/render/team_unfought_bout_reads_unfought.render.test.jsx @@ -0,0 +1,166 @@ +// An unfought bout of a fixed-order team encounter reads as unfought on every +// surface while the encounter runs, and only a Tie tap makes it a draw (the +// tie-marking rule: an empty row is not a tie). +// +// The bug this pins: the team editor's running autosave wrote every bout with +// no winner as decision "hikiwake", untouched ones included. Every surface +// reads a stored "hikiwake" as a draw, so after the first point the TV board +// and the public match card showed X on every bout still to come, and the +// score sheet, opened again, showed them as "✓ Tie (hikiwake)", where a Tie +// tap then turned the draw OFF. buildPatch now writes such a bout with +// decision "" (admin_scoring_team.jsx, subBoutHasBeenPlayed). +// +// The test goes the whole way round: the sheet scores the first point, its +// write is what the server stores, and the sheet (opened again), the TV board +// and the public match card all render that stored match. So reverting the +// write rule reddens the display assertions too, not only the wire one +// (team_finish_refuses_unfought_bouts.render.test.jsx pins the wire alone). + +import React from 'react'; +import { render, act, fireEvent, cleanup } from '@testing-library/react'; +import { describe, it, expect, vi, beforeAll, afterAll, beforeEach, afterEach } from 'vitest'; +import { installWindowStubs } from '../helpers/stub_globals.js'; + +let writes; + +const STUBBED_GLOBALS = { + isHikiwake: () => false, + arraysEqual: (a, b) => a.length === b.length && a.every((v, i) => v === b[i]), + isKikenDecision: () => false, + isTextEntry: () => false, + isInteractiveTarget: () => false, + confirmDialog: vi.fn().mockResolvedValue(true), + API: { + fetchCompetitionDetails: vi.fn().mockResolvedValue(null), + recordScore: vi.fn(), + recordDaihyosen: vi.fn(), + removeDaihyosen: vi.fn(), + putMatchLineup: vi.fn(), + recordDecision: vi.fn(), + notePendingEdit: vi.fn(), + }, + AdminLineupHelpers: { rosterFor: vi.fn().mockReturnValue([]) }, + compMatches: () => [], + Term: ({ children }) => {children}, + GlossaryHint: ({ name }) => , +}; + +let restoreGlobals; +let ScoreEditorModal, MatchDetailCard, TvDisplay; + +beforeAll(async () => { + restoreGlobals = installWindowStubs(STUBBED_GLOBALS); + await import('../../bracket.jsx'); + await import('../../admin_scoring_modal.jsx'); + ScoreEditorModal = window.ScoreEditorModal; + ({ MatchDetailCard } = await import('../../viewer_match.jsx')); + ({ TvDisplay } = await import('../../display.jsx')); +}); + +afterAll(() => restoreGlobals()); + +beforeEach(() => { + writes = []; + window.API.recordScore.mockReset(); + window.API.recordScore.mockImplementation((_c, _m, patch) => { + writes.push(patch); + return Promise.resolve(undefined); + }); +}); + +afterEach(() => cleanup()); + +const MATCH = { + id: 'Pool A-0', compId: 'c1', status: 'running', phase: 'pool', poolName: 'Pool A', court: 'A', + compKind: 'team', teamSize: 3, + sideA: { id: 'team-kyoto', name: 'Kyoto' }, sideB: { id: 'team-osaka', name: 'Osaka' }, + subResults: [], +}; + +const click = (el) => act(async () => { fireEvent.click(el); }); +const sheetRow = (i) => [...document.querySelectorAll('.team-sub-match')][i]; +const sheetPoint = (i, side, letter) => [...sheetRow(i).querySelectorAll(`.team-sub-match__side--${side} button.ipt-btn`)] + .find((b) => b.textContent === letter); +const tieButtons = () => [...document.querySelectorAll('[data-testid="scoring-modal-tie-button"]')]; + +async function mountSheet(match) { + let view; + const onSubmit = (p) => window.API.recordScore('c1', match.id, p, '', match); + await act(async () => { + view = render(); + }); + return view; +} + +// The sheet scores the encounter's first point; the unmount writes it at +// once (the autosave's every-way-out rule), and that write is what the server +// stores for the match. +async function storedAfterFirstPoint() { + const view = await mountSheet(MATCH); + await click(sheetPoint(0, 'shiro', 'M')); + await act(async () => { view.unmount(); }); + expect(writes).toHaveLength(1); + const patch = writes[0]; + expect(patch.status).toBe('running'); + cleanup(); + writes = []; + return { ...MATCH, subResults: patch.subResults }; +} + +const numbered = (subs) => subs.filter((s) => s.position > 0); + +describe('an unfought bout of a running team encounter', () => { + it('is written with no result, not as a draw', async () => { + const stored = await storedAfterFirstPoint(); + const rows = numbered(stored.subResults); + expect(rows).toHaveLength(3); + expect(rows[0].ipponsB).toEqual(['M']); + expect(rows.slice(1).map((s) => s.decision)).toEqual(['', '']); + }); + + it('reads unfought on the score sheet opened again, and a Tie tap marks it a draw', async () => { + const stored = await storedAfterFirstPoint(); + await mountSheet(stored); + // No bout reads as a draw: no X in any row's centre, no Tie switched on. + expect(document.querySelector('.tsm-draw')).toBeNull(); + expect(tieButtons().length).toBeGreaterThan(0); + for (const b of tieButtons()) expect(b.textContent).toBe('Tie (hikiwake)'); + + // A Tie tap on bout 2 marks it a draw, on screen and on the wire, and + // leaves bout 3 unfought. + expect(document.querySelectorAll('.team-sub-match')).toHaveLength(3); + const tie = sheetRow(1).querySelector('[data-testid="scoring-modal-tie-button"]'); + expect(tie).not.toBeNull(); + await click(tie); + expect(document.querySelector('.tsm-draw')?.textContent).toBe('X'); + expect(document.body.textContent).toContain('✓ Tie (hikiwake)'); + cleanup(); + expect(writes.length).toBeGreaterThan(0); + const last = writes[writes.length - 1]; + expect(numbered(last.subResults).map((s) => s.decision)).toEqual(['', 'hikiwake', '']); + }); + + it('reads unfought on the public match card: no X in any bout centre', async () => { + const stored = await storedAfterFirstPoint(); + let container; + await act(async () => { ({ container } = render()); }); + const centres = [...container.querySelectorAll('[data-testid="team-scoreboard"] .msb-vs')].map((c) => c.textContent); + expect(centres.length).toBeGreaterThanOrEqual(3); + expect(centres).not.toContain('X'); + }); + + it('reads unfought on the TV board: no X in any bout centre', async () => { + const stored = await storedAfterFirstPoint(); + const comp = { + id: 'c1', name: 'Cup', format: 'mixed', withZekkenName: false, kind: 'team', teamSize: 3, + poolMatches: [stored], bracket: { rounds: [] }, + }; + let container; + await act(async () => { ({ container } = render()); }); + const board = container.querySelector('[data-testid="team-scoreboard"]'); + expect(board).not.toBeNull(); + const centres = [...board.querySelectorAll('.msb-vs')].map((c) => c.textContent); + expect(centres.length).toBeGreaterThanOrEqual(3); + expect(centres).not.toContain('X'); + }); +}); diff --git a/web-mobile/js/__tests__/viewer_bronze_match.test.jsx b/web-mobile/js/__tests__/viewer_bronze_match.test.jsx index d5e94bdb0..2525d487f 100644 --- a/web-mobile/js/__tests__/viewer_bronze_match.test.jsx +++ b/web-mobile/js/__tests__/viewer_bronze_match.test.jsx @@ -57,7 +57,7 @@ describe('ViewerCompetition bronze / 3rd-place match rendering (mp-gy6g)', () => 'BracketTree', 'MatchCard', 'buildBracket', 'roundLabel', 'bracketRoundLabel', 'formatIpponsScore', 'isHikiwake', 'hasBothSides', 'compareDmy', 'queueLabel', 'queueLabelCompact', 'teamIVScore', 'matchScoreStr', - 'EmptyState', 'bronzeUnderFinalStyle', + 'EmptyState', 'bronzeUnderFinalStyle', 'bronzeSlotLabels', ]; const mkComp = (overrides = {}) => ({ @@ -141,6 +141,8 @@ describe('ViewerCompetition bronze / 3rd-place match rendering (mp-gy6g)', () => // bronzeUnderFinalStyle returns the inline size/offset style for the bronze // section (real impl in bracket.jsx); a no-op object is enough for render. global.window.bronzeUnderFinalStyle = () => ({}); + // bronzeSlotLabels names the bronze's empty sides (real impl in bracket.jsx). + global.window.bronzeSlotLabels = () => ({ a: 'TBD', b: 'TBD' }); vi.resetModules(); ({ ViewerCompetition } = await import('../viewer.jsx')); diff --git a/web-mobile/js/admin_announcement.jsx b/web-mobile/js/admin_announcement.jsx index 4251c5585..12b5c732b 100644 --- a/web-mobile/js/admin_announcement.jsx +++ b/web-mobile/js/admin_announcement.jsx @@ -4,6 +4,8 @@ // admin dashboard (mp-djc). Endpoint is unchanged: POST /api/tournament/announce // (handlers_announcement.go), driven through window.API.sendAnnouncement. +import { useStableId } from './stable_id.jsx'; + const { useState: useStateAn, useEffect: useEffectAn, useCallback: useCallbackAn } = React; // Pure helpers for the announcement-broadcast controls. @@ -26,6 +28,10 @@ function AnnouncementComposer({ password, showToast }) { const [announcementDuration, setAnnouncementDuration] = useStateAn(5); const [announcementInFlight, setAnnouncementInFlight] = useStateAn(false); const [activeAnnouncements, setActiveAnnouncements] = useStateAn([]); + // The composer is reachable from two places (Edit details and the + // dashboard's Announce modal), so its label ids are minted, not fixed. + const messageId = useStableId('announce-message'); + const durationLabelId = useStableId('announce-duration'); const refreshAnnouncements = useCallbackAn(async () => { try { @@ -97,8 +103,9 @@ function AnnouncementComposer({ password, showToast }) {
)}
- +