diff --git a/CLAUDE.md b/CLAUDE.md index 90cf717f4..c6f77d3e3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -131,7 +131,7 @@ tournament-data/ ├── pool-matches.csv Pool phase match results ├── bracket.json Elimination bracket structure + results ├── competitor-status.yaml Eligibility records (kiken/fusenpai) - ├── lineups.yaml Team lineups, keyed by round + ├── lineups.yaml Team lineups, starting and per match ├── team-members.yaml A team's members, keyed by the team's participant id ├── overrides.json Manual ranking overrides └── history/ One append-only .jsonl per match: every write, applied or held @@ -171,14 +171,19 @@ tournament-data/ - **Excel Layout**: Uses an **8-column per court** layout. Columns A and G (and their court-shifted counterparts) are 30 units wide; others are 5 units wide. A blank row separates pools vertically. - **Team block bout rows** (`state.Competition.TeamBoutRows`, with `domain.KachinukiMaxBouts`): the ONE owner of how many numbered bout rows a team match's block has in any workbook: 0 for an individual competition, TeamSize for a team match, 2 x TeamSize - 1 for kachinuki (the most bouts a winner-stays-on encounter can take). `RenderCompetitionWorkbook` passes it to the renderers (the Pool Matches and Elimination Matches blocks, the 3rd-place block, their IV/PW formula ranges and page heights) and `BuildResultsWorkbook` to the overlays, which skip a bout Position past it rather than write into the next block. **The app's "Download blank template" is NOT `ExportCompetitionXlsx`**: `buildXlsxBody` (admin_schedule_export.jsx) posts the roster to the stateless `POST /create` (cmd), which re-draws it (mp-x0u9), sending `teamMatchType=kachinuki` for a kachinuki competition, `teamMatches` as the team size and `format=league` for a league (one pool and no knockout, which `/create` cannot tell from a pools competition that formed a single pool; any other `format` is a 400); cmd builds a `state.Competition` from them to ask the same owners (`TeamBoutRows`, `IsKnockoutEnabled`). An encounter with more bouts (reserves fielded) keeps them all on the Kachinuki Detail sheet, which lists a match's recorded bouts, or that many empty numbered rows for one with none, in every workbook (`helper.BlankKachinukiSections` for `/create`'s own draw, which leaves that sheet unprotected), so it can be filled in by hand (operator decision 2026-09-27, bc-kdsc). Before the draw, a knockout-only competition's detail sheet lists the skeleton the Elimination Matches sheet printed (the same rounds, through `helper.BlankKachinukiSections`), so the two sheets have one source. `helper.breakBeforeEliminationBlock` is the one page rule for every block on the Elimination Matches sheet, the 3rd-place block included, so a block that fits on one page never prints across two. A block taller than a page (a team of 17 or more, which only the CLI or API allows; the app caps teams at 9) still does. Never restate 2n-1 for a workbook, in Go or JS; the one other use is the score editor's own cap on manually added kachinuki bouts (`kachinukiMaxBouts`, admin_scoring_team.jsx), which is taken from `MAX_TEAM_SIZE`, not from a team's size. `TestCreateHandler_WithoutTeamMatchTypeMatchesTheExamples` reproduces four committed example workbooks byte for byte through `/create`, so a rendering change must regenerate them (`make examples`). - **Workbook owners.** `engine.AttachPoolMatches` is the ONE Pool Matches source for both engine exports (the results workbook, and the stored-draw export `ExportCompetitionXlsx` behind `GET /api/competitions/:id/export` and the Export PDFs prints): never rely on a cached `pool.Matches`, since pools.csv records membership only and a restart empties it (the stored-draw export printed no match block after a restart until bc-kdsc). `engine.PrintedBracket` is how those exports name a stored bracket match: its block title (`helper.EliminationMatchTitle`, or `helper.ThirdPlaceLabel` for the bronze) and its sides, a side not decided yet as `helper.MatchRefLabel`'s "M n", never an internal id such as "Winner of r2-m1". The results overlay and the Kachinuki Detail sheet both name through it, so a detail section and the "M n" that points at it read alike. `/create` has no stored bracket; `helper.BlankKachinukiSections` names its draw's matches through the same owners (`helper.EliminationMatchTitle`, `helper.MatchRefLabel`, `helper.ThirdPlaceLabel`, `helper.PoolMatchLabel`). -- **Clearing a scored mark is a tap on the mark, and each editor says so ONCE** (operator ruling 2026-09-16, bc-dnst). The individual board puts the line under its slot groups; the TEAM sheet puts it after the team names and BEFORE the first bout, never per bout, because the sheet repeats a row per bout. Both use the same words and the same `.sb-hint` owner (`.sb-hint--encounter` only adds the spacing a line between two blocks needs), and both show it only while a mark exists to clear. A hantei mark does not raise it: `realIppons` drops it and it undoes through its own "click to undo" control. +- **Clearing a scored mark is a tap on the mark, and each editor says so ONCE** (operator ruling 2026-09-16, bc-dnst). The individual board puts the line under its slot groups; the TEAM sheet puts it after the team names and BEFORE the first bout, never per bout, because the sheet repeats a row per bout. Both use the same words and the same `.sb-hint` owner (`.sb-hint--encounter` only adds the spacing a line between two blocks needs), and both show it only while a mark exists to clear. The team sheet keeps the line's space while it is hidden (`.holds-space`, the one owner of `visibility: hidden` for this, operator ruling 2026-10-06), because it sits above every bout row: appearing at a bout's first point (in kachinuki at every bout, since a recorded bout's marks no longer count) it moved every row 28 px down, and a quick second tap on M opened the fighter list. The individual board's line sits below what a scoring tap aims at, so it still comes and goes. A hantei mark does not raise it: `realIppons` drops it and it undoes through its own "click to undo" control. - **Team Match Labels**: Summaries use **"IV"** (Individual Victories) and **"PW"** (Points Won). - **Court limit**: `--courts` is hard-capped at 16 (`helper.MaxCourts`, derived from the court label alphabet A–P) and any value over that returns an error rather than silently truncating. 16 is the largest allocation any one competition can legally hold, so a venue beyond it could never give all its shiaijo to a single competition. - **Competitor Eligibility** (`internal/state/competitor_status.go`, `internal/engine/eligibility.go`): a kiken/fusenpai decision auto-writes a `CompetitorStatus{Eligible: false}` for the loser; `engine.StartMatch(compID, matchID)` is the pre-flight gate that returns `*IneligibleCompetitorError` (matches `errors.Is(err, ErrIneligibleCompetitor)`). Maps to HTTP 409, with `reasonHuman` (`reasonHumanForBarredCompetitor`/`barredCompetitorSentence`, `mobileapp/errors.go`) composing one operator sentence naming the competitor, the match (`engine.OperatorMatchLabel`), and what to do, e.g. "Alice withdrew in Pool A · Match 1 and cannot fight again. Record the fusensho for Carol." (kiken-injury: "...Reinstate Alice if they can fight again, or record the fusensho for Carol."). Kiken-injury (FIK Art. 30) sets `CompetitorStatus.Reinstateable: true`; the admin can call `POST /api/competitions/:cid/competitors/:pid/reinstate` to restore eligibility. Kiken-voluntary (Art. 31) and fusenpai are not reinstateable. A withdrawal decision against a competitor ANOTHER match already barred is decided by ONE rule, `engine.alreadyBarredRefusal` (bc-kfup), asked by both T105 halves (`checkConcurrentIneligibility` before the write, the K2 check-and-set in `recordIneligibilityFromDecision`): a kiken is refused with `already_ineligible` (a competitor withdraws once), while a fusenpai is their default loss on a remaining match and records NO new `CompetitorStatus`, so the existing one keeps naming the match where they withdrew. Do not re-add a fusenpai refusal there: StartMatchTx refuses to start that match, so the fusenpai is the operator's exit. Clearing the match that DID record the bar moves it onto such a chained fusenpai rather than lifting it (`standingWithdrawalOf`, fusenpai only: a kiken still on record beside a later bar was lifted by a reinstatement before that bar existed, since a second kiken is refused), and a reopen that clears it lands `scheduled` because `reopenTargetStatusTx` judges the bars the restore will leave (`restoredStatus`, shared with the restore). Accepted as it is (operator decision 2026-09-26): a fusenpai recorded before a reinstatement still carries the bar on, so a competitor who withdrew injured, had a no-show recorded on another match, was reinstated, and then has a later withdrawal cleared as a mistake is barred again by that earlier no-show; the editor names the no-show match, where clearing the fusenpai lifts it. Telling the two apart needs the time of each reinstatement, which is not worth storing for this. Do not report it as a new defect. `engine.BarredSides(statuses, matchID, sideAID, sideBID)` is the ONE extraction point for "is this side barred by a match OTHER than the one being checked" (the `st.MatchID != matchID` undo-path exemption lets a match always be re-scored past its own prior kiken/fusenpai); it replaced three hand-copied loops (`StartMatchTx`, `checkEligibilityExcludingMatch`, `annotateEligibility`). A SCHEDULED match with a barred side carries a READ-ONLY `ineligibleSides` stamp (`state.IneligibleSidesAnnotation{A, B string}`, both `omitempty` decision-code strings) on `MatchResult`/`BracketMatch`, computed at REQUEST TIME ONLY by `mobileapp.annotateEligibility` (`handlers_match.go`; never persisted, `yaml:"-"` on the pool side); a stamped match also has its `queuePosition` zeroed and so omitted from the wire (`state.DeriveQueuePositions`/`annotateBracketQueuePositions`), so it is skipped exactly like an unqueued match. `web-mobile/js/ineligible_match.jsx` is the ONE JS owner reading this stamp: `isBarredMatch`/`awaitedDefaultWin` skip such a match from Up next, Finish + Start Next, the TV next line, the lobby, and on-deck alerts, and drive the operator's one-tap `Record fusensho for ` (`POST /decision {decision: "fusensho", decisionBy: }`, which does not touch the barred competitor's own `CompetitorStatus`) and, for a kiken-injury, `Reinstate `. The same request-time pass stamps a COMPLETED match a kiken, fusenpai or fusensho decided with `withdrawnStatus` (`state.WithdrawnStatusAnnotation`: the withdrawn side's status now, eligible / the match that bars it / reinstateable), which `RecordedWithdrawal` words its clear from instead of fetching statuses. The viewer payloads load statuses whenever a match needs either stamp (`anyMatchToAnnotate`), and the `/decision`, `/score` and bulk-score writes stamp the result they push (`stampWithdrawnStatus`), so a pushed row and its refetch read the same. **Recording a withdrawal changes only that match** (operator ruling 2026-09-26, "do not cascade"): the post-kiken Remaining matches panel that offered to close the competitor's other matches was removed, and each of them is closed from its own notice when it comes up. Do not re-add a cascade from one match's editor. Recording one still starts the court's next match in the same competition (mp-nwds item 7, the operator's request), on the court console and the Scores tab alike; both skip the withdrawn competitor's matches through `sideBarredByDecision`/`involvesCompetitor` (`ineligible_match.jsx`), because the list shows them barred only after it refreshes. - **Send back to queue keeps the score** (operator ruling 2026-09-26, bc-sbq). `RevertMatchToQueue`/`requeueBracketMatch` clear only the verdict and the audit notes; points, penalties, overtime, bouts, flags and rep-bout fighters stay, the operator removes a wrong mark themselves, and the button is always offered (picking another bout sends a scored one back the same way). Starting the match again keeps them: the SPA's `startPatch` sends the request-only `startOnly` flag and `engine.keepQueuedScore` copies the stored score onto that write, because the wire cannot say "no score sent" (the start's empty arrays are the bytes of an operator clearing every mark, which must still clear, so the editors' own Start stays unflagged; `startOnly` with any status but `running` is a 400, since the stored score is swapped in after validation). 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. `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 ") 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. +- **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". +- **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. +- **Lineups saved by v2.1.1 and earlier are migrated, never read** (operator decisions 2026-10-06: "Migrate old content to new format", and 2026-10-07: "Show what v2.1.1 showed"; the lead's ruling that follows from them: a migrated tournament shows, at every match, exactly the lineup v2.1.1 showed there). `state.settleRoundLineups` (internal/state/round_lineups.go) is the ONE owner. A legacy team is one with a round lineup r >= 1, or one that, at the competition's FIRST settlement (`Competition.RoundLineupsLegacy` still nil), holds a match lineup stored under its id for a match of the current draw that seats it by id: v2.1.1 used a match's lineup for that match alone, so carrying it on would show the team's later matches a lineup v2.1.1 never showed there. That pass records every legacy team in config.md's `round_lineups_legacy` (`Competition.RoundLineupsLegacy`, sorted participant ids), and later passes read that list plus any team with a round lineup, so a match lineup saved by THIS release never makes a team legacy. Every match a legacy team is seated in by id gets a lineup of its own, written as `PUT .../match-lineups/:matchId` writes one, equal to v2.1.1's reading there (`legacyReading`, which answers `(lineup, ok)`: the match's own, else the round lineup with the highest round at or below the round the match was read at, else the highest round, else none, and then the match is given an EMPTY own lineup, `positions: {}`, which `inForce` honours whole, so it shows none, as v2.1.1 did; `legacyLineupRound`: a knockout match's bracket round index, the 3rd-place match `len(Rounds)`, a pool or league match's stored circle round, 0 for Swiss). It runs on load (`upgradeRoundLineupsLocked`, by EnsureLegacyUpgraded after the id repairs) and at every write of the draw (`storeTx.settleRoundLineupsAtCommit` at the end of WithTransaction, staged with the write; `Store.settleRoundLineupsAfterWrite` after the direct `SaveBracket`/`UpdateBracket`/`UpdateBracketMatchByID`/`SavePoolMatches`/`UpdatePoolMatchByID`), so a match the team is seated in later is given its lineup in the write that seats it. Each (team, match) pair is settled ONCE: config.md's `round_lineups_given` (`Competition.RoundLineupsGiven`, the team's participant id to its sorted match ids) lists them, so a lineup the operator removes (**Use the previous match's lineup**) stays removed. `Store.ClearDrawLineups` (a discard, and every new draw, which `runDrawPipeline`'s generators call just before their first write) removes the draw's match lineups, clears that list because the next draw reuses the match ids, and trims `round_lineups_legacy` to the teams that still hold a round lineup (the match lineups that made the others legacy went with the draw), setting the marker when none remains. A lineup stored under a team's NAME is keyed by the team's id when exactly one team has that name (one whose team has no id yet waits). On load, a legacy team that has round lineups and no starting lineup gets `legacyReading(rounds, 0)` as one: v2.1.1's own fallback, its HIGHEST round's lineup, so a frame-0 match it is seated in later reads what v2.1.1 showed. The round lineups stay, never read by `engine.LineupInForce`, until the competition is completed; then the next write of the draw or the next load removes them (completion itself is a config.md write the draw hooks do not see; nothing reads them meanwhile) and config.md's `round_lineups_converted` (`Competition.RoundLineupsConverted`, set by POST /competitions on every new competition, and by the settlement as soon as no legacy team waits: at once for a competition with none, else at completion or at a discard that leaves none; setting it clears `round_lineups_given` and `round_lineups_legacy`) stops the settlement. `PUT .../lineups/:round` refuses a round >= 1 with a 400, and the client has no later-round UI. The reading is v2.1.1's score sheet's and server's (`FindBestLineup`); v2.1.1's at-court lineup panel showed only a lineup saved at the match's exact round and blank boxes otherwise, so the two disagreed there, and the migrated match lineups make both show the sheet's (checked on data the real v2.1.1 release wrote, 2026-10-06). Accepted as they are (do not report them as new defects): a mixed competition's round-robin pools and its knockout shared v2.1.1's round numbers, so a knockout match reads a round lineup by its bracket round index, as v2.1.1 did (put to the operator 2026-10-06; answer: "all team matches need lineups"); a team seated in a match after the competition completed gets no reading and carries its previous match's lineup there; no `lineup-updated` event is sent for a lineup given in the write that seats a team; the settlement reads the competition's files on every write of the draw until the marker is set (a marked competition costs one cached read, `Store.roundLineupsSettled`); and a first settlement that finds no legacy team while a lineup still waits for its team's id records nothing, so the next pass settles first again, and a match lineup saved in between would read as one v2.1.1 saved (only a roster where some teams still lack an id reaches it). +- **Unsaved lineup edits are kept as a per-tab draft** (bc-lnul, operator decision 2026-10-05: option A, a draft, over a `beforeunload` prompt, which iPad Safari does not show and which cannot cover the app's Back or closing the panel). `web-mobile/js/lineup_draft.jsx` (`useLineupDraft`, `LineupDraftNotice`, `lineupDraftKey`) is the ONE owner: both lineup editors keep the difference between the lineup as loaded and as shown in `sessionStorage`, and offer it back with "Unsaved lineup changes restored" and **Discard** when the same lineup opens again (the at-court panel and the Lineups page share a match's key). A draft is never written to the server; one whose lineup changed meanwhile is dropped with a notice naming what was not restored, which stays through the operator's edits and goes when the lineup is saved or given up (`useLineupDraft`'s `resolved`) or left. A draft the stored lineup already holds (`holdsDraft`: at every position, the draft's name where the draft has one, and where it has none, no name and the same member id) is the operator's own save landing, a queued one or one sent as the panel closed, and is dropped with no notice: the save attaches member ids the draft never had, so comparing ids there would report a lost change that was saved. Do not add a `beforeunload` prompt. - **Schedule Estimator** (`internal/engine/schedule.go`): `EstimateSchedule(EstimateInput) ScheduleEstimate` produces total/per-court minutes from match duration × multiplier × slowest-court buffer. Exposed via stateless `GET /api/schedule/estimate` on both the CLI web server and the mobile app. - **The server refuses to start on data it cannot write** (operator decision 2026-10-04, "test permissions on load"). `checkDataWritable` (internal/state/writable.go), run by `Store.init` before the WAL replay, creates and removes a scratch file in the data folder, `competitions/`, `.wal/`, every competition folder and its `history/`, and `branding/`/`sponsors/` when present (`state.BrandingDirName`/`SponsorsDirName`, which the upload handlers use); every folder that fails is named in `ErrDataNotWritable`, so a folder the server cannot write is found at startup rather than as a score write failing mid-match. Folders only: every save writes a temporary file and renames it, which needs only the folder. - **Store Transactions** (`internal/state/transactions.go`): `Store.WithTransaction(compID, fn)` holds the per-comp lock once across multiple load/save operations. Use the `StoreTx` handle inside `fn`. Do NOT call public Store methods (they would deadlock the non-reentrant mutex). @@ -290,11 +295,11 @@ Operator ruling 2026-10-03: "Nothing should be dropped. All events must be order - **The history** (internal/state/match_history.go): one append-only file per match, `competitions//history/.jsonl`, one JSON line per write that reached the merge: stamp, server `receivedAt`, door, session, changed groups, per-group outcome (`applied`/`unchanged`/`held`), the held groups' incoming values, any cleared withdrawal, and the `reason` when one rule held the write's groups rather than their stamps (`MergeReport.HoldReason`: `HoldReasonOlderRevision`, `HoldReasonEngiAtomic`, `HoldReasonFinishAtomic`, `HoldReasonKnockoutNeedsWinner`, `HoldReasonEngiNeedsValidCount`; the History view shows it as "Not applied: "). `StoreTx.AppendMatchHistory` stages the whole small file in the SAME transaction as the write (`LoadMatchHistory` reads it, read-your-own-writes), so an aborted transaction leaves no entry. Written at the point the write can no longer be rolled back (`recordWriteHistory`: after K3/requalification in `RecordMatchResultWithIneligibilityTx`, in `writeMatchResult`, and on the superseded paths), so a write refused by validation, a side mismatch or K3 leaves none; every direct writer above appends its own (`recordDirectHistory`) INSIDE the transaction that makes its write: the doors already in one (the reopens, the kachinuki bout remove, the downstream reopen) pass their `tx`, and the kachinuki advance, `OverrideBracketWinner` and `RevertMatchToQueue` each open a `WithTransaction` for the write and the entry together (`withBracketMatch` takes the store handle, as `withPoolMatch` does), so a process dying between the two leaves no match change without its line; a held override writes nothing and appends on its own. `recordDirectHistory` logs a failed append rather than returning it, so a failure of the append alone still lets the write commit without its entry (accepted). The history directory is created only under an existing competition directory (`ensureMatchHistoryDirLocked`), so a write after `DeleteCompetition` fails rather than resurrecting it; `DiscardDraw` deletes it (`DeleteMatchHistory`), since a regenerated draw reuses the match ids. A fully-held write must COMMIT its transaction for its entry to land: every door returns nil from its closure on `ErrMatchSuperseded` and answers after (the score/decision handlers always did; bulk-score, both daihyosen paths and `RecordMatchResult` were changed to). - **The answers**: a write applied in part answers applied (the merged result) plus `heldGroups` (`scoreResponseWithReopened`, `withHeldGroups`, bulk-score's top-level `heldGroups`); a fully-held one keeps the 200 `{applied:false, reason:"superseded"}` shape the SPA keys on, plus `heldGroups` (`respondSuperseded`, `SupersededMessage`: "kept in the match's history"). Either answer adds `heldReason: "needs_winner"` when R4/S2 held them (`MergeReport.HeldReason`; a fully held one then carries `NeedsWinnerMessage`; bulk-score puts it on the entry), and an applied finish that moved stored newer scoring to the history adds `displacedGroups` (`MergeReport.DisplacedGroups`) with the same `heldReason` (score, daihyosen, bulk-score's `heldGroups[]` items). `ErrMatchSuperseded` is returned as `*SupersededError` carrying the held groups and that code (`errors.Is` still matches; `HeldGroupsOf`/`HeldReasonOf` read them). A completed write stamped OLDER than the stored `result` group over a finished match is not asked for a correction reason (`applyCorrectionReasonUnderTx`, `matchSnapshot.ResultStamp`): a write that completes the match is atomic with everything else it changes (`HoldReasonFinishAtomic`, mirroring `HoldReasonEngiAtomic`), so a stale finish is held WHOLE and kept in the history rather than landing its scoreline beside a verdict it never declared; an equal stamp is an exact replay and still needs one. The request-only fields (`Changed`, `WriteDoor`, `Merge`, `ClearsWithdrawal`) never ride on a stored copy: `MatchResult.ClearRequestFields` is the one list, called on the pool write's overwrite and on every pool-match copy the store hands out. -- **The client (phase 2)**: every score write names its groups. `web-mobile/js/match_groups.jsx` is the ONE client owner of the names (pinned against `state.ScalarGroups`/`BoutGroup` by the shared table `internal/state/testdata/match_groups.json`: `TestMatchGroups_SharedTable` and `match_groups_table.test.jsx`), of the comparison (`changedGroups`, on WIRE shapes only: both sides go through `toBackendMatchResult`, the adopted copy via `matchWire` in api_serializers.jsx, so a placeholder slot, an Ht placement or a winner object never reads as a change; a field the write leaves out, e.g. an omitted correction reason or a silent representative bout's ippons, is not compared, and a bout row the write does not carry is never a change), of the union (`unionChanged`: no list on either side means every group and stays so) and of a group's words (`groupLabel`). `useChangedGroups(m)` (admin_scoring_autosave.jsx) is the one hook the three editors call; every patch goes through its `claim` (the individual and team `buildPatch`, `submitHantei`, the engi save and autosave builder). A group counts against TWO baselines: the server value the editor last AGREED with for that group (seeded at mount, moved on at each build where local equals the prop), NOT the latest prop, or an edit held while another device changed a group the editor kept its own value for would put the old value back; and the last write the editor built, or a tap taken back before the first write's echo would name nothing. `startPatch` names `result` alone. The outbox keeps `changed` verbatim (persist, cross-tab, rehydrate, replay); a score write that takes a queued one's place claims the union (`_claimingQueuedGroups` in `_placeWrite`, and the absorb in `recordScore`, both against the one entry `_coalesceTarget` names); `_broadcastPatch` strips it. Answers: `writeHeldGroups`/`writePartlyHeld` (write_result.jsx) for a partial apply, which the editors answer with a quiet note (`useKeptInHistoryNote`/`KeptInHistoryNote`, worded by `heldGroupsNote`) and `editMatchScore` with a toast when the write closes the editor; a superseded write leads "Not applied", never "Not saved" (`SUPERSEDED_LEAD`, `notSavedText`'s `lead`, `supersededAlertText`). The editors' closed-by-default **History** disclosure (`MatchHistoryDisclosure`, match_history_view.jsx, `data-testid="match-history"`, hidden under `selfReport`) reads `GET /api/competitions/:id/matches/:mid/history` (`RegisterMatchHistoryHandler`, stamp order with an unstamped entry at its `receivedAt`, `[]` when none, 404 for an unknown competition or match, main-gated in self-run), readable values through `formatIpponsScore`/`withdrawalLabel`/`sideWord`. +- **The client (phase 2)**: every score write names its groups. `web-mobile/js/match_groups.jsx` is the ONE client owner of the names (pinned against `state.ScalarGroups`/`BoutGroup` by the shared table `internal/state/testdata/match_groups.json`: `TestMatchGroups_SharedTable` and `match_groups_table.test.jsx`), of the comparison (`changedGroups`, on WIRE shapes only: both sides go through `toBackendMatchResult`, the adopted copy via `matchWire` in api_serializers.jsx, so a placeholder slot, an Ht placement or a winner object never reads as a change; a field the write leaves out, e.g. an omitted correction reason or a silent representative bout's ippons, is not compared, and a bout row the write does not carry is never a change), of the union (`unionChanged`: no list on either side means every group and stays so) and of a group's words (`groupLabel`). `useChangedGroups(m)` (admin_scoring_autosave.jsx) is the one hook the three editors call; every patch goes through its `claim` (the individual and team `buildPatch`, `submitHantei`, the engi save and autosave builder). A group counts against TWO baselines: the server value the editor last AGREED with for that group (seeded at mount, moved on at each build where local equals the prop), NOT the latest prop, or an edit held while another device changed a group the editor kept its own value for would put the old value back; and the last write the editor built, or a tap taken back before the first write's echo would name nothing. A reopen is a write the server makes, not a build, so when the match the editor renders leaves `completed` (a reopen, or a send back to the queue) the editor takes the result it now holds as agreed, that group alone (`useChangedGroups`): without it, ending a reopened match again with the verdict it had named nothing, and the server kept the match running with no error. It keys on the status, not on `reopenPending`: a reopen without a reason carries that stamp, but one a correction's reason comes with (a downstream reopen, a requalification, a pool-rank override) leaves the match scheduled with its points and no stamp, and an editor that watched only the stamp named no result for it. `startPatch` names `result` alone. The outbox keeps `changed` verbatim (persist, cross-tab, rehydrate, replay); a score write that takes a queued one's place claims the union (`_claimingQueuedGroups` in `_placeWrite`, and the absorb in `recordScore`, both against the one entry `_coalesceTarget` names); `_broadcastPatch` strips it. Answers: `writeHeldGroups`/`writePartlyHeld` (write_result.jsx) for a partial apply, which the editors answer with a quiet note (`useKeptInHistoryNote`/`KeptInHistoryNote`, worded by `heldGroupsNote`) and `editMatchScore` with a toast when the write closes the editor; a superseded write leads "Not applied", never "Not saved" (`SUPERSEDED_LEAD`, `notSavedText`'s `lead`, `supersededAlertText`). The editors' closed-by-default **History** disclosure (`MatchHistoryDisclosure`, match_history_view.jsx, `data-testid="match-history"`, hidden under `selfReport`) reads `GET /api/competitions/:id/matches/:mid/history` (`RegisterMatchHistoryHandler`, stamp order with an unstamped entry at its `receivedAt`, `[]` when none, 404 for an unknown competition or match, main-gated in self-run), readable values through `formatIpponsScore`/`withdrawalLabel`/`sideWord`. - **Phase 3, the remaining drop sites, each with one owner:** - **The running rev guard holds, never drops.** A running write with a lower `rev` than the same `revSession` already sent is answered by `holdOlderRevision` (handlers_match.go): one bare transaction, no court lock, no start gate, no correction check, and the high-water mark left alone. It calls `RecordMatchResultWithIneligibilityTx` with `engine.ForceOptions{HoldReason: engine.HoldReasonOlderRevision}`, whose `holdWriteTx` asks the merge owner on a copy with `mergeCtx.holdAll` (every changed group held, echoes excepted), records the history entry with the reason, and returns the superseded error; the answer is `respondSuperseded` with `heldGroups`. `{stale:true}` no longer exists anywhere (server, openapi, client: `_handleOkBody` and the admin toast that read it are gone). - - **The offline queue keeps, per match, a list of pending writes, each sent on its own** (api_client.jsx). Each pending write is its own entry, keyed `${base}${QUEUE_ENTRY_SEP}${id}` (`_newEntryKey`; the separator is `\u001f`), where the BASE is what the write is about (`_revKey` for a score or a decision, `override:…`, `lineup:…`; `_baseOf`). A key persisted by the previous build has no separator, so it is its own base: an old queue loads and replays unchanged, which is the migration. The ONE coalescing rule is `_coalesceTarget`: a score write takes the place of its match's LAST entry only when that entry is a score write not newer than it and not the page-hide copy of a write still being sent, and a RUNNING write only of a running one (`_placeWrite` then claims the union and carries a pending `kachinukiBoutFinal`); a LINEUP save takes the place of a queued save of the same lineup (`_queuedLineupSave`: a lineup PUT restates the whole lineup and carries no stamp the server could order it by, so a direct save could be overwritten by the older one's replay; the caller reads `{queued: true}` for the moment until the flush sends it). Nothing else replaces anything, so a running write never replaces a queued Finish or a decision, and a decision and a score are two entries. A pass sends entries in the ONE replay order, `_byEntryOrder` (the payload's `modifiedAt`, else `enqueuedAt`, then the key), but each entry on its own: one that does not settle (offline, 5xx, parked, re-stamped for skew, held by an open fetch) holds back nothing after it, and a new score, decision or override goes straight to the server whatever is queued, because the server merges by stamp, not arrival (bc-mrgc review C2: the old wait-behind rule let one write failing with a 5xx stop every later write for its match). The editors' "Retry now" on a queued Finish therefore sends it at once: landing, it drains the queued copy it claimed; failing, it takes that copy's place. A landed direct score write drains only the entry it claimed (`claimedEntry`), never a queued Finish or decision. The page-hide copy (`_keepInflightRunning`) is an entry of its own (`w.keptKey`); recordScore settles its OWN copy through the record it created (`inflightRecord`), even after a later running write took the map slot, and when it had one and its fetch answered it schedules a flush a microtask later, after the answer has drained its claimed entry. A queued answer says whether the write reached browser storage (`_queuedAnswer`: `persisted: false` only when `_persistQueue` failed, read before the enqueue kicks a flush). A write the server keeps refusing (past `SERVER_REJECTION_NOTICE_THRESHOLD`) can be discarded alone from its match's editor (`HeldWriteDiscard`, admin_scoring_shared.jsx; `API.heldWriteKeepsFailing`/`discardFailingHeldWrites`, that match's failing entries only, after a confirm). `resumeAfterAuth` gives the new password to every parked write AND every queued write still carrying a password the server refused, never to one queued with none (a self-run participant's). Every refusal on replay is reported: a decision's 409 (`decision_locked`, `already_ineligible` included) goes to the terminal-fail and `rejected` alert channels like any other, because the server answers an EXACT replay of a recorded decision (same decision, side and stamp: a send whose answer was lost) as recorded before any of its refusals (`exactDecisionReplay`, scoring_tx.go). A held finish that lands and moves a later change to the history (`displacedGroups`) counts as sent and raises one `displaced` information alert per pass. Nothing is dropped for its age: `QUEUE_TTL_MS` and the `expired` alert are gone, and an old entry is sent; the server orders it and keeps what loses in the history. Pinned by `queue_ordered_writes.test.jsx` and `sync_queue.test.jsx`. + - **The offline queue keeps, per match, a list of pending writes, each sent on its own** (api_client.jsx). Each pending write is its own entry, keyed `${base}${QUEUE_ENTRY_SEP}${id}` (`_newEntryKey`; the separator is `\u001f`), where the BASE is what the write is about (`_revKey` for a score or a decision, `override:…`, `lineup:…`; `_baseOf`). A key persisted by the previous build has no separator, so it is its own base: an old queue loads and replays unchanged, which is the migration. The ONE coalescing rule is `_coalesceTarget`: a score write takes the place of its match's LAST entry only when that entry is a score write not newer than it and not the page-hide copy of a write still being sent, and a RUNNING write only of a running one (`_placeWrite` then claims the union and carries a pending `kachinukiBoutFinal`); a LINEUP save joins a queued save of the same lineup (`joinQueuedLineupSave`, lineup_save.jsx: the union of `changed`, the later value per position): a lineup PUT carries no stamp the server could order it by, so a direct save could land before the queued one's replay and lose to it on a position both changed; the caller reads `{queued: true}` for the moment until the flush sends it. Nothing else replaces anything, so a running write never replaces a queued Finish or a decision, and a decision and a score are two entries. A pass sends entries in the ONE replay order, `_byEntryOrder` (the payload's `modifiedAt`, else `enqueuedAt`, then the key), but each entry on its own: one that does not settle (offline, 5xx, parked, re-stamped for skew, held by an open fetch) holds back nothing after it, and a new score, decision or override goes straight to the server whatever is queued, because the server merges by stamp, not arrival (bc-mrgc review C2: the old wait-behind rule let one write failing with a 5xx stop every later write for its match). The editors' "Retry now" on a queued Finish therefore sends it at once: landing, it drains the queued copy it claimed; failing, it takes that copy's place. A landed direct score write drains only the entry it claimed (`claimedEntry`), never a queued Finish or decision. The page-hide copy (`_keepInflightRunning`) is an entry of its own (`w.keptKey`); recordScore settles its OWN copy through the record it created (`inflightRecord`), even after a later running write took the map slot, and when it had one and its fetch answered it schedules a flush a microtask later, after the answer has drained its claimed entry. A queued answer says whether the write reached browser storage (`_queuedAnswer`: `persisted: false` only when `_persistQueue` failed, read before the enqueue kicks a flush). A write the server keeps refusing (past `SERVER_REJECTION_NOTICE_THRESHOLD`) can be discarded alone from its match's editor (`HeldWriteDiscard`, admin_scoring_shared.jsx; `API.heldWriteKeepsFailing`/`discardFailingHeldWrites`, that match's failing entries only, after a confirm). `resumeAfterAuth` gives the new password to every parked write AND every queued write still carrying a password the server refused, never to one queued with none (a self-run participant's). Every refusal on replay is reported: a decision's 409 (`decision_locked`, `already_ineligible` included) goes to the terminal-fail and `rejected` alert channels like any other, because the server answers an EXACT replay of a recorded decision (same decision, side and stamp: a send whose answer was lost) as recorded before any of its refusals (`exactDecisionReplay`, scoring_tx.go). A held finish that lands and moves a later change to the history (`displacedGroups`) counts as sent and raises one `displaced` information alert per pass. Nothing is dropped for its age: `QUEUE_TTL_MS` and the `expired` alert are gone, and an old entry is sent; the server orders it and keeps what loses in the history. Pinned by `queue_ordered_writes.test.jsx` and `sync_queue.test.jsx`. - **Override-winner** stamps and orders the `result` group (phase 1); a held assertion is answered `respondSuperseded(["result"])` (reason `superseded`, `heldGroups`), the client returns `heldGroups`, the Resolve feeders toast says it was kept in the history (`OVERRIDE_HELD_NOTICE`, write_result.jsx), and a queued one the server held counts into the pass's superseded alert as well as asking for a bracket resync. - **The engi finish** is stamped by `recordScore` like every write, and the server orders it: `engiFinishHeld` (engi.go) asks the merge on a copy built from the stored match (`engiFinishProbe`), BEFORE the downstream guard; the flags and the winner they decide are atomic (either held → both held, `HoldReasonEngiAtomic`, reached when the flags are newer than the stored count but the result is older than the stored one, e.g. after a later reopen; a stale finish over a COMPLETED match is held whole one step earlier, by `HoldReasonFinishAtomic`) -- except that the NEWEST COUNT DECIDES (operator ruling 2026-10-04): when a finish's result applies but a newer stored VALID count holds its flags, the finish closes the match with the winner that count gives, the stored count stays, its stamp stamps the result, and the finish's own count is kept in the history as held, so both arrival orders end alike (`TestMerge_EngiNewestValidCountDecidesInBothOrders`); a newer INVALID count is still moved to the history by the displacement above -- and an ECHO of the stored flags (same count, under an older stamp) is not a hold at all, so the finish still applies; held → history and superseded; applied → `recordEngiMatch` writes `result`+`flags` with the stamps the merge decided (`engiFinishHeld` carries the probe's `GroupStamps` down, `stampEngiChanges`/`ApplyMergedGroupStamps`), so both arrival orders reach the same stamps after a displacement; an unstamped writer still gets the server's time. - **Read-modify-writes are one transaction.** `InjectPoolDaihyosenMatches`, `InjectTiebreakerMatches` (the engi self-heal too), `GenerateLeagueTiebreakMatches` and `AdvanceSwissRound`'s append each read pool-matches.csv, decide, and save under ONE `WithTransaction` (standings through `computeStandingsFrom(tx)`, the tournament read before the tx, `appendWithSlots` for the new slots); a score landing meanwhile waits for the lock and goes on top. `MaybeAdvanceKachinuki` still reads without the lock, and its locked write re-checks (`kachinukiAdvanceStillHolds`: not completed, same status, stamp and bout log) and on `errKachinukiAdvanceStale` reads the match again and works the pairing out from it (`advanceKachinukiOnce`, up to `kachinukiAdvanceAttempts` times, standing down only then: a stand-down answered the operator's Record bout with no next pairing; a finish that landed is final on the re-read, so it is never reopened; the bracket branch uses `UpdateBracket` with `Bracket.MatchByID` so it can abort). `Engine.afterMatchRead` is the test-only seam at each read (`lost_update_window_test.go` lands a real write there). @@ -439,20 +444,21 @@ Name[, Zekken/DisplayName], Dojo[, DanGrade][, source] ## Common Pitfalls -- **A team's people are its "team members", never its "squad"** in UI strings, in `docs/`, and on disk (operator ruling 2026-09-16, bc-dnst). The store file is `team-members.yaml`, renamed from `squads.yaml` (root key `squads` -> `members`). **A storage change carries a migration path on load covering the last TWO releases** (operator rule 2026-09-17): `upgradeTeamMembersFilenameLocked` (legacy_upgrade.go) adopts v2.0.0's file on first load and removes it once the new one is written, so a tournament recorded by that release keeps its members. v1.1.0 and earlier stored none, so that is the whole history. It converges rather than dual-reading, so only one shape stays live. The route is `GET /api/competitions/:id/team-members` and its payload key is `teamMembers`; both were renamed with the file, with no compatibility shim, because client and server ship in one binary. Three team writes are deliberately PUBLIC in self-run (operator decision 2026-09-27, bc-dhas, "Open lineups to participants"): the ones the public score sheet makes when a competitor names a bout's fighter, `PUT .../teams/:tid/match-lineups/:matchId`, `POST .../teams/:tid/members` and `PUT .../teams/:tid/members/:memberId`. They are NOT in `isSelfRunMainGatedConfigRoute`; their handlers hold a caller with an EMPTY password (`selfRunAnonymous`, middleware.go, shared with `/score`'s `enforceSelfRunPolicy` and the representative bout's add and remove) to the score path's rule. A password sent but wrong is an organiser with a stale one: it gets 401 with AuthMiddleware's body (`respondInvalidPassword`), never a participant's sentence. That is why the member POST asks `selfRunAnonymous` too. The rule has these parts: no lineup change once the match has finished (409 `result_finalized`, from the `resultFinalized` helper the score path and the representative bout's add and remove use, with the lineup's own sentence; an unknown match is 404) or for a team the match does not hold by id (404 `team_not_in_match`); no renaming a member who already has a name (409 `member_already_named`, a `selfRunRefusal`); no adding or naming a member once the competition has finished (409 `competition_finished`); and no add past `selfRunMemberLimit` (2 x team size + `SquadReserveSlots`, 409 `team_member_limit`), because a member can never be removed; the count and the add are ONE step under the store lock (`AddTeamMemberUpTo`, reached through the `deps.go` interface), since a count taken before the add let concurrent anonymous adds pass it together. The finished-competition refusal is judged there too, and in `NameUnnamedTeamMember`, under the lock the competition's completion takes (`state.ErrCompetitionFinished`), never from the handler's earlier read, which a finish could land after. The match-lineup PUT also refuses an anonymous caller's member id the named team does not hold (400 `team_member_not_in_team`, `memberIDsOutsideTeam`, read through `StoreTx.LoadSquads`), so a participant cannot seat another team's member, or an invented id, in a lineup; the organiser is not judged by it. A member name longer than `MaxLenPlayerName` is 400 for every caller. Each of these handlers resolves the caller before reading the body. The organiser, sending the password, keeps mp-q722's always-editable rule. Every other lineup or member route (`GET team-members`, `DELETE members/:memberId`, the round lineups PUT/DELETE, `DELETE match-lineups`) MUST stay in that list or it becomes publicly reachable in self-run; pinned by `self_run_team_writes_test.go`. When you add a route here, decide which side it is on and pin it there. The public page sends an EMPTY `X-Tournament-Password`, never omits it. A public surface reads a team's members from the viewer payload, never from `GET team-members`: the self-run score editor gets them through `MatchViewerModal`'s `teamMembers` prop, and `TeamScoreEditorModal` calls `fetchSquads` only when no host passed them. "Squad" survives as a code name only, in identifiers such as `squadMemberLabel`, `squadSlotLabel` and `squadRosterEntries`, and in the client-side `squads` property that `normalizeViewerCompItem` maps the wire key onto (note `normalizeCompetitionDetail` SPREADS the payload instead, so that shape carries `teamMembers`: two shapes, two names, one concept). The Lineups page heading reads "Team members"; write the same in any new operator copy or docs page. +- **A team's people are its "team members", never its "squad"** in UI strings, in `docs/`, and on disk (operator ruling 2026-09-16, bc-dnst). The store file is `team-members.yaml`, renamed from `squads.yaml` (root key `squads` -> `members`). **A storage change carries a migration path on load covering the last TWO releases** (operator rule 2026-09-17): `upgradeTeamMembersFilenameLocked` (legacy_upgrade.go) adopts v2.0.0's file on first load and removes it once the new one is written, so a tournament recorded by that release keeps its members. v1.1.0 and earlier stored none, so that is the whole history. It converges rather than dual-reading, so only one shape stays live. The route is `GET /api/competitions/:id/team-members` and its payload key is `teamMembers`; both were renamed with the file, with no compatibility shim, because client and server ship in one binary. Three team writes are deliberately PUBLIC in self-run (operator decision 2026-09-27, bc-dhas, "Open lineups to participants"): the ones the public score sheet makes when a competitor names a bout's fighter, `PUT .../teams/:tid/match-lineups/:matchId`, `POST .../teams/:tid/members` and `PUT .../teams/:tid/members/:memberId`. They are NOT in `isSelfRunMainGatedConfigRoute`; their handlers hold a caller with an EMPTY password (`selfRunAnonymous`, middleware.go, shared with `/score`'s `enforceSelfRunPolicy` and the representative bout's add and remove) to the score path's rule. A password sent but wrong is an organiser with a stale one: it gets 401 with AuthMiddleware's body (`respondInvalidPassword`), never a participant's sentence. That is why the member POST asks `selfRunAnonymous` too. The rule has these parts: no lineup change once the match has finished (409 `result_finalized`, from the `resultFinalized` helper the score path and the representative bout's add and remove use, with the lineup's own sentence; an unknown match is 404) or for a team the match does not hold by id (404 `team_not_in_match`); no renaming a member who already has a name (409 `member_already_named`, a `selfRunRefusal`); no adding or naming a member once the competition has finished (409 `competition_finished`); and no add past `selfRunMemberLimit` (2 x team size + `SquadReserveSlots`, 409 `team_member_limit`), because a member can never be removed; the count and the add are ONE step under the store lock (`AddTeamMemberUpTo`, reached through the `deps.go` interface), since a count taken before the add let concurrent anonymous adds pass it together. The finished-competition refusal is judged there too, and in `NameUnnamedTeamMember`, under the lock the competition's completion takes (`state.ErrCompetitionFinished`), never from the handler's earlier read, which a finish could land after. The match-lineup PUT also refuses an anonymous caller's member id the named team does not hold among the positions the save writes (the changed ones when it names them, `LineupRequest.memberIDsRead`) (400 `team_member_not_in_team`, `memberIDsOutsideTeam`, read through `StoreTx.LoadSquads`), so a participant cannot seat another team's member, or an invented id, in a lineup; the organiser is not judged by it. Every member write answers with the member as stored, stamped (`TeamMember.ModifiedAt`, `Store.stampMember`: the server's time in ms, or one more than the member's previous stamp, so one member's stamps only grow): POST 201, PUT and DELETE 200 (they were 204), and all three announce the change (`lineup-updated` naming the team), POST included. A member name longer than `MaxLenPlayerName` is 400 for every caller. Each of these handlers resolves the caller before reading the body. The organiser, sending the password, keeps mp-q722's always-editable rule. Every other lineup or member route (`GET team-members`, `DELETE members/:memberId`, the round lineups PUT/DELETE, `DELETE match-lineups`) MUST stay in that list or it becomes publicly reachable in self-run; pinned by `self_run_team_writes_test.go`. When you add a route here, decide which side it is on and pin it there. The public page sends an EMPTY `X-Tournament-Password`, never omits it. A public surface reads a team's members from the viewer payload, never from `GET team-members`: the self-run score editor gets them through `MatchViewerModal`'s `teamMembers` prop, and `TeamScoreEditorModal` calls `fetchSquads` only when no host passed them. "Squad" survives as a code name only, in identifiers such as `squadMemberLabel`, `squadSlotLabel` and `squadRosterEntries`, and in the client-side `squads` property that `normalizeViewerCompItem` maps the wire key onto (note `normalizeCompetitionDetail` SPREADS the payload instead, so that shape carries `teamMembers`: two shapes, two names, one concept). The Lineups page heading reads "Team members"; write the same in any new operator copy or docs page. - **Never write "default win"** in anything a person reads: a UI string, a docs page, an API message, a commit or PR (operator ruling 2026-10-04: "such thing does not exist"). Every finished match has a result: a scoreline, or a registered decision that names the winner (kiken, fusenpai, fusensho, hantei). Name the decision (`domain.DecisionWord` in Go, `decisionWord` in write_result.jsx); say "decision" only where several kinds are meant together. Internal identifiers keep the old name (`IsDefaultWinDecisionStr`, `DefaultWinIppons`, the wire code `default_win_stands`), since no person reads them. The other forms are the same concept and banned with it: "default loss", "default winner", and a side that wins, won (never the contraction "won't"), loses or is winning "by default". Enforced by three gates sharing three patterns, pinned by one shared case table (`internal/mobileapp/testdata/default_win_cases.json`, read by the Go and JS gate tests): "default" then win(s) or loss(es) with at least one separator (any space, a hyphen, `{}`, quotes or `+`, and a word boundary after, so a camelCase identifier or `variant="default" winner` passes); "default", whitespace, winner(s); and wins, won, winning or loses followed within 40 characters by "by", whitespace, "default" (crossing line breaks, stopping at a full stop or a table-cell `|`). The words "winners", "lost" and "losing" are deliberately left out of the last pattern: they refused ordinary settings and connection prose ("Winners per pool is 2 by default"). The gates: `TestNoDefaultWinInStringLiterals` (every Go string literal under internal/ and cmd/, `+`-joined literals checked as one) with `TestNoDefaultWinInPersonReadText` (every git-tracked person-read text file outside docs/: `specs/openapi.yaml`, tracked `specs/**/*.md`, and the root `*.md` except CLAUDE.md and AGENTS.md), a FORBIDDEN rule in `web-mobile/check-write-result.mjs` (the comment-stripped WHOLE file; also index.html and styles.css) and `docs/check_prose.py` (paragraph-wide, on tag- and emphasis-stripped text; a fenced block or comment ends a paragraph). - **Kendo uses NO MATS. Never write "mat" or "mats"** in code, comments, tests, UI strings, docs or commit messages. The fighting area is a **shiaijo** (in prose, a **court**); "shiai-jo" hyphenated on the public `docs/` site. "Mat" is judo/wrestling vocabulary and is simply wrong for this sport. Reaching for it to avoid repeating "court" is how it gets in. (The only legitimate `mat` in the tree is a QR bitmap variable in `web-mobile/js/qr.jsx`, plus the rule's own name inside `docs/check_prose.py`.) - Excel coordinates matter: changing match generation requires updating cell references and formula links across sheets -- `team-matches=0` means individual tournaments, not team tournaments +- `team-matches=0` means individual tournaments, not team tournaments. In the tournament app, `state.Competition.IsTeam()` (`Kind == "team" || TeamSize > 0`) is the one spelling of "a team competition"; do not respell it (several near-variants that differ on hand-edited data, e.g. `TeamSize > 0` alone, remain where they mean something narrower). - The `errcheck` linter is enabled (test files excepted). Don't introduce `_ =` or bare ignored returns in production code. Wrap and propagate, or log via `handleExcelError` - Web UI changes (`web/index.html`) should be validated in a running browser, not just by reading diffs. Use `make run` - Mobile app frontend changes (`web-mobile/`) require rebuilding the binary to take effect. The files are embedded at `go build` time via `//go:embed web-mobile/*` in `main.go`. Run `make run-mobile` which rebuilds automatically, or run `make go/build` then restart. - Competitor identity is (name, dojo), never the name alone. Two competitors sharing a NAME from different dojos are different people and are allowed everywhere; only same-name AND same-dojo is a duplicate. The CLI/web CSV path rejects duplicate ROWS up front via `helper.CheckDuplicateEntries` and the web handler surfaces them; the tournament app enforces the (name, dojo) rule via `helper.CheckDuplicateEntriesByNameDojo`, with the floor guard in `state.saveParticipantsNoLock` so every persistence path is covered. A blank dojo has TWO floors with TWO distinct sentinels: `state.ErrBlankDojo` refuses every roster save (loading stays tolerant on purpose so the roster can be repaired), and `helper.ErrBlankDojoInDraw` refuses the tree-aware draw over a loaded legacy roster (the draw path never crosses the save floor; the refusal names the offending row and is NOT a bug to relax). They are different errors in different packages; `errors.Is` against the wrong one silently never matches. TEAM names are the exception: they must be unique even across dojos (`helper.DuplicateNamesWithKeys`), because a team name is its identity in results. **A record that carries an id field is resolved by id only** (operator ruling bc-pnum): an empty id resolves to NOTHING, never to a name or (name, dojo) match, and no name/(name, dojo) fallback arm may sit beside an id lookup on the SAME record (`lookupStandingsPlayer`/`resolveWinnerSide` in `internal/engine/engi.go`, `groupMemberIDs` in `tiebreaker.go`, `resolveParticipantIndex` in `internal/state/participants.go`, `matchesParticipant` in `replace_participant.go`, `engine.AttachPoolMatches` (`internal/engine/workbook.go`) and `standingMap` in `internal/export/builder.go`, and the pool-rank-override / league-tiebreak selection paths are all id-only end to end). `helper.CompetitorKey(id, name, dojo)`'s (name, dojo) fallback survives for exactly two legitimate uses: duplicate detection over an input list BEFORE ids exist (`helper.CheckDuplicateEntriesByNameDojo`, and an explicit `helper.CompetitorKey("", name, dojo)` comparison for that purpose), and records that carry NO id field at all (`state.SubMatchResult`'s lineup names). A third, narrower carve-out belongs to `state.BracketMatch` (bc-brid): its sides now carry `SideAID`/`SideBID`/`WinnerID`, so it is NOT a no-id record, and an id is present exactly when the side is a resolved competitor. A side legitimately carries no id in exactly three shapes: an empty bye slot, an unresolved feeder (a "Winner of ..." advancement or a "Pool A-1st"-style placeholder before the pool phase resolves it), and a legacy row a repair has not yet reached. For `BracketMatch` the rule is therefore resolve-by-id-when-present, name-fallback ONLY for those three shapes: `buildSideByIDOrName` (`internal/mobileapp/handlers_display.go`), `matchesBracketSide` (`internal/engine/scoring_tx.go`), and pass 2 of `ReplaceParticipantInDraw` (`internal/engine/replace_participant.go`) are the three sites that do it. A pool row has no equivalent unresolved shape: its side ids are stamped when the draw runs, and a legacy row is repaired on load where the name resolves unambiguously, so an id-less pool side is a fault the operator is told about rather than a normal state. That is why its stricter id-only rule above stands unchanged. A fourth carve-out, the same shape as `BracketMatch`'s, belongs to `domain.SeedAssignment` (bc-sdid): it now carries `ID` too, so it left the no-id list above. `state.SaveSeeds` stamps it from the roster on every write, and the legacy-upgrade pass (`upgradeSeedRowsLocked`, `internal/state/legacy_upgrade.go`) backfills it on load where the row resolves unambiguously; an id is present exactly when a resolver has already attached the row to a participant. The one shape that legitimately still carries no id is a row a repair has not yet reached (an ambiguous bare name, or a file predating the id column). `domain.RosterIndex.LookupSeed` is the id-first-then-(name, dojo) resolver every seed matcher (the seeds.csv-onto-roster merge, `helper.ApplySeeds`, `mobileapp.seedsOffRoster`) now calls, so an id, once present, is never re-checked against a possibly-stale Name/Dojo. Anything that keys a competitor with a name/(name, dojo) fallback OUTSIDE those four carve-outs is a bug: use the participant id. -- **A double tap is two complete clicks** (bc-dtfn): Preact renders between them, so no state check can tell a bouncing thumb from a second tap. `web-mobile/js/tap_guard.jsx` is the ONE owner of "is this pointer tap the bounce of the previous one" (`TAP_BOUNCE_MS`, a click with `detail === 0` from the keyboard is never a bounce). Two-tap commits (Finish, Finish + Start Next, End match, and the engi editor's Save result, since the editors behave alike: operator ruling 2026-09-27) use `useArmedConfirm`; the ippon and foul `+` buttons guard per bout side through `acceptTap`. Deliberately NOT guarded, by the same ruling: the engi flag `+` (a flag count is not an ippon call, so a quick double tap is legitimate entry) and every removal tap (a scored mark, the engi `−`), where a bounce removes a second mark or flag and the operator re-enters it. The overtime counter's `+` (the `EnchoControl` stepper, admin_scoring_shared.jsx) is left unguarded too (operator ruling 2026-09-27): a bounce saves one period too many, which shows only as `×N` in that editor row, because everything else reads only whether overtime happened (`enchoOn`), and one tap on `−` takes it back. Kachinuki bout mode's Encho button is left unguarded as well (operator ruling 2026-10-02, bc-kheb): a bounce saves one period too many on that bout, and one tap on **Undo encho** beside it takes it back. Do not add any of these guards; the capture harness waits `TAP_DWELL_MS` (scripts/screenshots/lib/editor.mjs) between such taps. Do not add a per-file timer. The gap is timed with `Date.now()` when the handler runs, not with the event's `timeStamp` (weighed in review, 2026-09-27): `timeStamp` helps only where a browser stamps a tap's click at the touch rather than at dispatch, and the bout-list guard stamps with no event at all (`stampTap(boutListTapRef)`), so mixing the two would compare different clocks and silently disable the guard. +- **A double tap is two complete clicks** (bc-dtfn): Preact renders between them, so no state check can tell a bouncing thumb from a second tap. `web-mobile/js/tap_guard.jsx` is the ONE owner of "is this pointer tap the bounce of the previous one" (`TAP_BOUNCE_MS`, a click with `detail === 0` from the keyboard is never a bounce). Two-tap commits (Finish, Finish + Start Next, End match, and the engi editor's Save result, since the editors behave alike: operator ruling 2026-09-27) use `useArmedConfirm`; the ippon and foul `+` buttons guard per bout side through `acceptTap`. A layer a tap opens (a confirm, `Modal`, the overlay score editors, the participant Edit dialog, the public match card, the sign-in dialog) takes the bounce of that tap through `useOpenedTapGuard` (tap_guard.jsx); the overlay score editors, and the at-court lineup panel with them, guard their backdrop only (`backdropOnly`), so their controls work at once. That stays as it is (operator ruling 2026-10-06), with its known consequence: the second tap of a double tap that opens an overlay score editor lands on whatever control is under the finger (measured at 1180x820: a double tap on a running match's row on the Pools tab opened the editor and scored Shiro's M, and on a finished match it opened the Kiken – Injury panel; the Scores tab's Score buttons sit outside the editor). Do not report it as a new defect. Deliberately NOT guarded, by the same ruling: the engi flag `+` (a flag count is not an ippon call, so a quick double tap is legitimate entry) and every removal tap (a scored mark, the engi `−`), where a bounce removes a second mark or flag and the operator re-enters it. The overtime counter's `+` (the `EnchoControl` stepper, admin_scoring_shared.jsx) is left unguarded too (operator ruling 2026-09-27): a bounce saves one period too many, which shows only as `×N` in that editor row, because everything else reads only whether overtime happened (`enchoOn`), and one tap on `−` takes it back. Kachinuki bout mode's Encho button is left unguarded as well (operator ruling 2026-10-02, bc-kheb), and **Undo encho** holds its place beside it, held (`.holds-space`, and disabled) while there is nothing to undo, so neither button moves when the other comes or goes (operator ruling 2026-10-06): a bounce lands on Encho again and saves one period too many on that bout, which shows only as (E), and one tap on Undo encho takes it back. Before that ruling the centred row shifted, Undo encho took Encho's place under the finger, and a double tap cancelled itself. Do not add any of these guards; the capture harness waits `TAP_DWELL_MS` (scripts/screenshots/lib/editor.mjs) between such taps. Do not add a per-file timer. The gap is timed with `Date.now()` when the handler runs, not with the event's `timeStamp` (weighed in review, 2026-09-27): `timeStamp` helps only where a browser stamps a tap's click at the touch rather than at dispatch, and the bout-list guard stamps with no event at all (`stampTap(boutListTapRef)`), so mixing the two would compare different clocks and silently disable the guard. +- **The team sheet's pinned bars never hide what a scroll brings into view** (the pinned header and footer dock, bc-tmfd). `published_height.jsx` (`publishHeight`) is the one owner of a published bar height: `--topbar-stack-h` (AdminTopbar, on the root), `--team-pin-h` (the team sheet: on the root for an inline host, on the overlay's `.editor-modal__body` for an overlay) and `--team-dock-h` (inline only; an overlay's footer sits outside its scrolling body). The clearance is `scroll-margin` on the sheet's OWN content controls (inline: topbar plus pin above, dock below; overlay: pin above; the inline head: topbar), pinned by `team_editor_pinned_bars.render.test.jsx`, so a control a keyboard Tab or a `scrollIntoView` reaches stops clear of the bars (WCAG 2.2 2.4.11). Never `scroll-padding` on a scroller by a bar height: a bar's own buttons lie inside the padded strip, so focusing one (a Tab, a dialog returning focus) scrolled the page by about 300 px each time (measured 2026-10-06). One Escape closes one layer: an open fighter list takes it (`LineupNameInput` stops it there; a box with no list drawn lets it through, so the editor closes at once), as does an open rename box (`lineup_rename.jsx`), and the layer behind closes on the next. `dialog_focus.jsx` (`useDialogFocus`) is the one owner of "focus goes into a layer as it opens and back, without scrolling, to the control that opened it": the court console's two confirms (the court move, Send back to queue), which also cancel on Escape while their request is not out, and the at-court lineup panel, a `role="dialog"` that closes on Escape unless a lineup write is out. The panel is an editor like the overlay score editors, so it guards only its backdrop against the bounce of the tap that opened it and its controls work at once (the 2026-10-06 ruling for the editor overlays; a whole-layer guard made a Close tapped at once do nothing, found by PR #442's journeys). The court picker gives focus back to its button only for a close made inside it (Escape, a choice, its own button), never for a tap elsewhere, which keeps the focus it took (taking it back dropped the iPad keyboard of the tapped name box and scrolled the page). Layers stack by one scale: announcement cards 900, `.modal-backdrop` (every plain modal, the overlay score editors included) 1000, dialogs 1100, the toast 1200; the public match card and the lineup panel also sit at 1000, where DOM order decides. - Chained match navigation in the admin score editor (Prev/Next buttons, Finish + Start Next, ←/→ keys) must stay on the current match's shiaijo, and the AUTOMATIC advance (Finish + Start Next, the start after a withdrawal or no-show) also stays in the current match's competition (operator ruling 2026-09-26: one operator per court, scoring from the court view; an operator view never switches competition or court by itself). The tournament-wide Scores page lists every competition, so its `nextActiveFrom` filters on the open match's `compId`; the court console's `nextActiveAfter` always did. Prev/Next, which the operator taps, still move along the whole court. Operators run matches per-court, so hopping courts mid-flow breaks the workflow. See `AdminScoreEditor` in `web-mobile/js/admin_schedule_score_editor.jsx` (`admin_schedule.jsx` only re-exports it): filter to `(m.court || "") === (openMatch.court || "")` so empty/undefined courts share one "unassigned" bucket. - Docs under `docs/` are PUBLIC-facing: no beads (`mp-xxxx`)/`bd`, no internal-tooling or `CLAUDE.md` references, and NO em-dashes. The PR template gates `make docs/build`; run it before pushing docs changes. - `mkdocs build --strict` fails on broken cross-file links (WARNING) but only logs missing same-page anchors as INFO (build still passes), so verify anchors by hand. Anchors use pymdownx slugs: lowercase, punctuation dropped, `--`/parens collapsed to single hyphens (e.g. `### Locked mode (--lock-password)` becomes `#locked-mode-lock-password`). Note TWO files named `mobile-app.md` exist (`docs/user-guide/mobile-app.md` guide vs `docs/user-guide/commands/mobile-app.md` reference); a relative link from `commands/` resolves to the sibling, so confirm the target file before trusting a flagged anchor. -- **A lineup read never answers 404 for "nothing saved"** (bc-k404, operator decision 2026-09-27: "there either is one or there isn't and it's empty"). Both public lineup GETs (`internal/mobileapp/handlers_lineup.go`) answer 200 with `saved: false` and an empty lineup instead; `api_client.jsx`'s `fetchTeamLineup`/`fetchMatchLineup` are the ONE place that turns `saved: false` into `null`, and a 404 there means the competition itself does not exist. Do not re-add a not-found answer for an unsaved lineup. +- **A lineup read never answers 404 for "nothing saved"** (bc-k404, operator decision 2026-09-27: "there either is one or there isn't and it's empty"). All three public lineup GETs (`lineups/:round`, `match-lineups/:matchId`, `lineup-in-force/:matchId`) in `internal/mobileapp/handlers_lineup.go` answer 200 with `saved: false` and an empty lineup instead; `api_client.jsx`'s `lineupOrNull`, used by `fetchTeamLineup` and `fetchLineupInForce`, is the ONE place that turns `saved: false` into `null`, and a 404 there means the competition itself does not exist. Do not re-add a not-found answer for an unsaved lineup. ## PR Workflow diff --git a/docs/architecture/data-model.md b/docs/architecture/data-model.md index 1d25b1b23..31252eaa0 100644 --- a/docs/architecture/data-model.md +++ b/docs/architecture/data-model.md @@ -110,6 +110,7 @@ classDiagram +string ID +int Index +string Name + +int64 ModifiedAt } class Overrides { @@ -156,6 +157,12 @@ id, so a bout already fought names the same person whatever the name is changed member stays available at any time, including after the start, because a team fields replacements mid tournament. +Each member also records when it was last added, named, renamed or cleared (`ModifiedAt`, +server time, 0 until the first such write). One member's stamps only grow, so a page that +holds two copies of a member, one from a list it read and one from a write it made, keeps the +newer one whatever order they arrived in, and a list read before a rename can never put the +old name back. + The label an organiser reads is the team's competitor number followed by the member index, for example `T10.1`. That label is composed when it is shown and never stored, because a competitor number can change and the identity underneath it cannot. It is derived the same @@ -169,6 +176,52 @@ it may hold more members than the competition's team size: the extra entries are replacements an organiser can field, and the team size only fixes how many positions a round has. +A lineup is stored for a match or as a team's starting lineup (its round-0 entry), and the +lineup a team fields in a match is not stored: it is worked out when read, because a team +carries the lineup of its previous match unless one is entered for the match. The lineup in +force at a match is the match's own, else the latest one the team had before it: one saved for +an earlier match of the team, or its starting lineup, which comes before every match. +Matches are ordered by pool-match number first, then by knockout round and position, with the +3rd-place match last. A lineup saved for a match counts as an earlier lineup only while that +match is in the current draw and the team is seated in it by participant id. A team is its +participant id and nothing else: a lineup is the team's only when it is stored under that id, +a lineup stored under a team name is not the team's, and a side with no id has none. +Discarding a draw removes the lineups saved for its matches, because a draw generated again +reuses the match ids, and keeps the starting lineups. One rule in the engine owns this, and +the kachinuki roster, the Kachinuki Detail export and the public `lineup-in-force` read all +ask it. + +A lineup save names the positions it changed, and the server writes only those, under the +competition's lock, onto the lineup stored for that match or that starting lineup, or, for a +match with no lineup of its own yet, onto the lineup in force there. Two devices that change +different positions of one lineup both keep their change, whichever save arrives first, a +save sent later from a device that was offline included. Two changes to the same position +keep the later arrival. + +Releases up to v2.1.1 also saved a lineup for a round, and read the lineup a team fielded at +a match as the match's own, else the round lineup with the highest round at or below the +match's round, else the highest round, so a lineup saved for one match applied to that match +alone. Such lineups are converted into this form rather than read under that rule. A lineup +stored under a team's name is stored under the team's id when exactly one team has that name. +A legacy team is one with a lineup for round 1 or later, or one that, when the app first +settles the competition, holds a lineup saved for a match of the current draw that seats it +by participant id. `config.md` records that list at the first settlement +(`round_lineups_legacy`), so a lineup saved for a match afterwards never makes a team legacy. +A legacy team is given, at every match it is seated in by participant id, a lineup of its own +equal to the one v2.1.1 showed there, or an empty one where v2.1.1 showed none (no round +lineup and no lineup for that match), since carrying would show an earlier match's instead. A +match it is seated in later is given its lineup by the write that seats it: inside that +write's transaction when it runs in one, otherwise straight after it. `config.md` lists, under +each team's id, the matches already given one (`round_lineups_given`), so a lineup removed +from one of them is not given again. Discarding the draw, or generating a new one, clears that +list, since the next draw reuses the match ids, and drops from the legacy list every team with +no round lineup, whose converted match lineups went with the draw. A legacy team with round +lineups and no starting lineup gets the one v2.1.1 showed before its first saved round, its +highest round's. The round lineups stay, never read, until the competition is completed; then +the next write of the draw, or the next start of the app, removes them and `config.md` records +`round_lineups_converted`, which is set as soon as no legacy team waits. A new competition +starts with it set. + ## 3. The match and result model This is the detailed part of the model, because the rules it encodes are detailed. A match @@ -416,7 +469,7 @@ classDiagram } class lineups_yaml["lineups.yaml"] { <> - TeamLineup by round or match + TeamLineup per match, or the starting one position to name and member id a position may hold an id and an empty name } diff --git a/docs/screenshots/kachinuki-correct-bout.png b/docs/screenshots/kachinuki-correct-bout.png index 83b53b95f..e544f6fbd 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 a15e67138..caf52d6d6 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-reopen.png b/docs/screenshots/kachinuki-reopen.png index 45f7fe13b..365cfe8d1 100644 Binary files a/docs/screenshots/kachinuki-reopen.png and b/docs/screenshots/kachinuki-reopen.png differ diff --git a/docs/screenshots/kachinuki-scoring-buttons.png b/docs/screenshots/kachinuki-scoring-buttons.png index 6ec3ad5f7..3d090923a 100644 Binary files a/docs/screenshots/kachinuki-scoring-buttons.png and b/docs/screenshots/kachinuki-scoring-buttons.png differ diff --git a/docs/screenshots/team-lineup.png b/docs/screenshots/team-lineup.png index fd3d4c771..031891f4d 100644 Binary files a/docs/screenshots/team-lineup.png and b/docs/screenshots/team-lineup.png differ diff --git a/docs/user-guide/court-operators/recording-decisions.md b/docs/user-guide/court-operators/recording-decisions.md index 66d5ca69f..040e5276d 100644 --- a/docs/user-guide/court-operators/recording-decisions.md +++ b/docs/user-guide/court-operators/recording-decisions.md @@ -99,7 +99,7 @@ If both competitors due to meet are already barred, neither can fight. In a pool ## Correcting a withdrawal recorded by mistake -A kiken or fusenpai entered in error can always be fixed. Open the match in the score editor. It shows what is recorded, for example "Recorded: Kiken – Voluntary, Kyoto withdrew", and marks the side that withdrew with **Kiken** or **Fus.** beside its name. In a team match the result below the bouts names the recorded winner and the decision, and the bouts after the withdrawal are credited to the other team, even though nobody actually fought them. Pick the fix that matches what happened: +A kiken or fusenpai entered in error can always be fixed. Open the match in the score editor. It shows what is recorded, for example "Recorded: Kiken – Voluntary, Kyoto withdrew", and marks the side that withdrew with **Kiken** or **Fus.** beside its name. In a team match the result under the team names, at the top of the score sheet, names the recorded winner and the decision, and the bouts after the withdrawal are credited to the other team, even though nobody actually fought them. Pick the fix that matches what happened: - **The wrong competitor or team was marked.** Record the withdrawal again for the other side. In an individual match, use the **Kiken – Voluntary**, **Kiken – Injury** or **Fusenpai** button in the editor's **Decision** row. In a team match, open **Withdrawal or no-show** and use the same buttons there. The side you first marked is eligible again, and the other side becomes the one that withdrew. - **Nobody withdrew, and the match was fought to a result.** Choose **Remove kiken**, **Remove fusenpai**, or **Remove fusensho**: the button names the decision that was recorded. The editor takes that decision off the score board: its circles go, the points the withdrawn competitor struck stay, and you enter the result as it was fought, or a draw in a pool or league. In a team match, every bout needs a result, as when you finish one. Then use **Save correction**, which asks for a short reason like any correction. The match stays finished the whole time, so the fix never needs the court and a match being fought there carries on undisturbed. **Undo** puts the recorded decision back before you save; once the correction is saved on the device and waiting to be sent, it can no longer be undone this way. In a knockout match whose bouts were tied, a representative bout decided it: use **Clear kiken and reopen** (or **Clear fusenpai and reopen**) instead, which puts the match back on the court to fight it. diff --git a/docs/user-guide/organisers/team-tournaments.md b/docs/user-guide/organisers/team-tournaments.md index 9409790e3..d7288503b 100644 --- a/docs/user-guide/organisers/team-tournaments.md +++ b/docs/user-guide/organisers/team-tournaments.md @@ -4,11 +4,30 @@ Team tournaments work with any of the four formats described in [Tournament form ## Team lineups -Before each team encounter, set the fighting order for each team across the five positions: Senpo, Jiho, Chuken, Fukusho, and Taisho. Smaller teams use fewer positions. +A team's lineup is its fighting order across the five positions: Senpo, Jiho, Chuken, Fukusho, and Taisho. Smaller teams use fewer positions. -![The Lineups tab: a team and round selector above a completed fighting order, with a competitor picked for each of the five positions (Senpo, Jiho, Chuken, Fukusho, Taisho) from a numbered list, and the Team members list beneath it with a Rename and a Clear name control on each named position.](../../screenshots/team-lineup.png) +A team keeps the lineup of its previous team match unless you enter a new one for a match. The **Lineups** page sets each team's **Starting lineup**, which its first match uses unless that match has a lineup of its own, and it can set the lineup of any one of the team's matches. Pick the team, then choose **Starting lineup** or one of its matches in **Lineup for**. A lineup you enter for a match is used for that match and for every later match of the team, until you enter another. It never changes an earlier match. Changing a match's lineup also changes every later match of the team that carries it, including matches already played. -Each team encounter has its own lineup. To carry over the same order from the previous encounter, use **Copy from previous match** at the top of the lineup panel. +Matches count in their order: pool matches by their number in the pool, then the knockout by round, with the 3rd-place match last. **Lineup for** lists a team's matches in that order. In that order, the lineup a team fields in a match is the first of these that exists: + +1. The lineup entered for that match. +2. The latest lineup the team had before it: one entered for an earlier match of the team, or its **Starting lineup**. + +The lineup panel for a match says where the lineup it shows comes from: **Lineup for this match**, **Same as** the earlier match it is carried from, or **Starting lineup**. The score sheet, the viewer, the court display, the streaming overlay and the Kachinuki Detail sheet of the export all use the same lineup. + +![The Lineups tab: a team and lineup selector above a completed fighting order, with a competitor picked for each of the five positions (Senpo, Jiho, Chuken, Fukusho, Taisho) from a numbered list, and the Team members list beneath it with a Rename and a Clear name control on each named position.](../../screenshots/team-lineup.png) + +To go back to the previous match's lineup, choose **Use the previous match's lineup**, on the **Lineups** page or at the top of the lineup panel. It appears while the lineup shown is the match's own, asks first, and removes that lineup, so the match carries the team's previous lineup again. Every later match that has no lineup of its own follows it. + +Saving a lineup writes only the positions you changed. If someone saves a change to another position of the same lineup from a different device, both changes are kept, whichever save arrives first, even when yours is sent later because this device was offline. If you both changed the same position, the save that arrives last is kept. A save made while this device is offline says "Not sent yet: saved on this device, and sent when the connection returns." and is sent once the connection is back. + +Changes you make to a lineup and have not saved are kept in that browser tab, so a reload, going back, or closing the lineup panel does not lose them. When you open the same lineup again, on the **Lineups** page or in the lineup panel, it shows **Unsaved lineup changes restored** with a **Discard** button that puts the saved lineup back. Nothing is saved until you press **Save lineup**. If the lineup changed in the meantime, for example saved from another device, the kept changes are not applied, and a notice lists the names that were not restored. + +Lineups that an earlier version of the app saved on the **Lineups** page for a round are converted when the app starts, so every match shows the lineup that version showed for it. Each match the team is seated in gets that lineup as its own, and a match the team reaches later, such as its next knockout match, gets it as soon as the team is seated there. You can change or remove any of these like any other match's lineup, and a lineup you remove stays removed. A team with no **Starting lineup** gets the lineup of its latest round as its **Starting lineup**, which is what that version showed before the team's first saved round. The round lineups themselves are removed once the competition is completed. + +That version also used a lineup entered for a match for that match only. A team that had one is converted the same way, so each of its matches shows what that version showed there: the lineup entered for that match, else the team's **Starting lineup**, or no lineup when the team had none. A lineup you enter after the upgrade is carried to later matches as usual. + +Discarding a draw removes the lineups of its matches, including those converted from an earlier version, since a new draw makes new matches. The new draw's matches are given the lineups converted from round lineups again, and each team's **Starting lineup** stays. Lineups that version entered for a match went with the old draw, so those teams carry their lineups like any other. ### Team members @@ -146,7 +165,7 @@ If your correction changes who won that bout, the bouts after it were fought on When the last bout is tied, the editor offers every legitimate way forward and you choose, according to the [kachinuki mode](#kachinuki-modes) in force; the app never decides it from the stage: - **Record bout** retires both fighters and brings the next pair up. -- **Encho** keeps the same pair fighting on that bout until one of them takes a point. 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. +- **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. ![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) @@ -161,7 +180,7 @@ Reopening is refused while another match is already running on the same court, b ![The score editor for a completed kachinuki match, showing the recorded bouts and the Reopen match button, the correction control for a kachinuki match that did not end with a withdrawal.](../../screenshots/kachinuki-reopen.png) -The results workbook (**Export & print**, then **Download results (.xlsx)**) and the blank template (**Download blank template (.xlsx)**) include a **Kachinuki Detail** sheet with a section for every kachinuki encounter in the draw. Each section is titled with its match's name followed by (Kachinuki). A knockout section's name is the title of that match's block on the **Elimination Matches** sheet, so Round 2 - Match 3 there is Round 2 - Match 3 (Kachinuki) here, and a side that is not decided yet reads as the match it comes from, for example M 3. Each section is laid out like a match on the **Elimination Matches** sheet: White (Shiro) on the left and Red (Aka) on the right, matching the scoreboard, each over its own team's name. Each bout row shows the bout number with who fought it and their lineup position, each side's score, and a tie or overtime in the centre column. The result mark (Fus., Kiken) sits beside the fighter it names. An encounter with recorded bouts lists exactly those bouts. An encounter with none yet, such as every encounter in the blank template, gets empty numbered rows to fill in by hand: one for each bout the encounter can take, which is twice the team size less one (9 rows for teams of five). +The results workbook (**Export & print**, then **Download results (.xlsx)**) and the blank template (**Download blank template (.xlsx)**) include a **Kachinuki Detail** sheet with a section for every kachinuki encounter in the draw. Each section is titled with its match's name followed by (Kachinuki). A knockout section's name is the title of that match's block on the **Elimination Matches** sheet, so Round 2 - Match 3 there is Round 2 - Match 3 (Kachinuki) here, and a side that is not decided yet reads as the match it comes from, for example M 3. Each section is laid out like a match on the **Elimination Matches** sheet: White (Shiro) on the left and Red (Aka) on the right, matching the scoreboard, each over its own team's name. Each bout row shows the bout number with who fought it and their lineup position (the position in the lineup the team fielded in that encounter), each side's score, and a tie or overtime in the centre column. The result mark (Fus., Kiken) sits beside the fighter it names. An encounter with recorded bouts lists exactly those bouts. An encounter with none yet, such as every encounter in the blank template, gets empty numbered rows to fill in by hand: one for each bout the encounter can take, which is twice the team size less one (9 rows for teams of five). The **Pool Matches** and **Elimination Matches** sheets give each kachinuki encounter the same number of bout rows, the 3rd-place match included. A result fills the bouts fought, in order, and leaves the rest empty. If an encounter fields reserves and runs to more bouts than that, those sheets show its first bouts and the Kachinuki Detail sheet lists them all. diff --git a/docs/videos/kachinuki-demo.webm b/docs/videos/kachinuki-demo.webm index 43b833651..fb77013e7 100644 Binary files a/docs/videos/kachinuki-demo.webm and b/docs/videos/kachinuki-demo.webm differ diff --git a/internal/domain/team_lineup.go b/internal/domain/team_lineup.go index 8a8ff8aff..bf1ee7681 100644 --- a/internal/domain/team_lineup.go +++ b/internal/domain/team_lineup.go @@ -1,8 +1,11 @@ package domain import ( + "cmp" "errors" "fmt" + "maps" + "slices" "strconv" "strings" ) @@ -43,10 +46,10 @@ func (p Position) Label() string { // Keying (mp-825): when MatchID is non-empty the lineup is // match-scoped, a team may field a different order/roster for each // encounter (e.g. successive pool matches). When MatchID is empty the -// lineup is round-scoped (the legacy behavior, still used by bracket -// rounds and pre-mp-825 data): one lineup per (team, round). The two -// scopes coexist; a match-scoped entry shadows the round-scoped -// fallback for that match. +// lineup is round-scoped (the Lineups page's lineup, and pre-mp-825 data): +// one lineup per (team, round). The two scopes coexist, and which one a team +// fields in a match is not decided here: a team carries the lineup of its +// previous match unless one is entered for the match (engine.Engine.LineupInForce). // // FR-040, data-model §4. type TeamLineup struct { @@ -133,6 +136,71 @@ func (t TeamLineup) checkDuplicateMembers(teamSize int) error { return nil } +// ErrLineupNoChangedPositions is the refusal of a save that says it changed +// positions and names none. +var ErrLineupNoChangedPositions = errors.New("team_lineup: a save that lists the positions it changed must list at least one") + +// ErrLineupChangedPositionMissing is the refusal of a save that lists a changed +// position its positions map has no entry for: the new name is what the save +// says, so a changed position with nothing to say is a malformed save, not a +// request to clear it (a clear is an entry with no name). +var ErrLineupChangedPositionMissing = errors.New("team_lineup: a changed position has no entry in positions") + +// ApplyChanges is t with each position in changed set from a save's own maps and +// every other position as t holds it: the way a save that names the positions it +// changed lands on the lineup stored when it arrives, so two saves changing +// different positions both keep their change. positions and memberIDs may carry +// the whole of the sender's form; only the changed keys are read, and the rest +// are ignored, whatever they hold. +// +// For a changed position p, positions must have an entry for it +// (ErrLineupChangedPositionMissing otherwise). The name is positions[p] and the +// member id memberIDs[p], absent meaning none. A position with neither is cleared, +// removed from both maps; any other is set: the name, and the id, which is removed +// when it is empty so a new name never keeps the old member's id beside it. A name +// with no id is a person typed in; an id with no name places a member who has none +// yet. An empty changed list is ErrLineupNoChangedPositions. +// +// The result shares nothing with t, and its Positions is never nil. It is not +// validated: ValidatePositions answers for the composed lineup, which is where a +// member placed at two positions is caught, naming both. +func (t TeamLineup) ApplyChanges(changed []Position, positions, memberIDs map[Position]string) (TeamLineup, error) { + if len(changed) == 0 { + return TeamLineup{}, ErrLineupNoChangedPositions + } + out := t + out.Positions = maps.Clone(t.Positions) + if out.Positions == nil { + out.Positions = map[Position]string{} + } + out.MemberIDs = maps.Clone(t.MemberIDs) + for _, p := range changed { + name, ok := positions[p] + if !ok { + return TeamLineup{}, fmt.Errorf("%w: %q", ErrLineupChangedPositionMissing, p) + } + id := memberIDs[p] + if name == "" && id == "" { + delete(out.Positions, p) + delete(out.MemberIDs, p) + continue + } + out.Positions[p] = name + if id == "" { + delete(out.MemberIDs, p) + } else { + if out.MemberIDs == nil { + out.MemberIDs = map[Position]string{} + } + out.MemberIDs[p] = id + } + } + if len(out.MemberIDs) == 0 { + out.MemberIDs = nil + } + return out, nil +} + // LineupSlot is one OCCUPIED position from a lineup: the Position itself, // its display NAME (Positions), and its squad MEMBER ID (MemberIDs) when // that position has been repaired/resolved -- empty for an unrepaired @@ -151,14 +219,18 @@ type LineupSlot struct { MemberID string } +// fikPositionOrder is the five FIK names of a 5-person team in the order they +// are fielded, senpo first and taisho last. Both canonicalPositionOrder and +// ComparePositions read it, so the order is defined once. +var fikPositionOrder = [...]Position{PosSenpo, PosJiho, PosChuken, PosFukusho, PosTaisho} + // canonicalPositionOrder returns the position traversal order OrderedMembers // walks: the five FIK names for a 5-person team, else 1..teamSize -// numerically. It has a single consumer today, but stays its own named step -// rather than being inlined into OrderedMembers, so the order itself stays -// separately readable and testable. +// numerically. It stays its own named step rather than being inlined into +// OrderedMembers, so the order itself stays separately readable and testable. func canonicalPositionOrder(teamSize int) []Position { if teamSize == 5 { - return []Position{PosSenpo, PosJiho, PosChuken, PosFukusho, PosTaisho} + return slices.Clone(fikPositionOrder[:]) } order := make([]Position, teamSize) for i := 1; i <= teamSize; i++ { @@ -167,6 +239,36 @@ func canonicalPositionOrder(teamSize int) []Position { return order } +// ComparePositions orders two positions the way a lineup is fielded, the order +// canonicalPositionOrder walks: the five FIK names from senpo to taisho, then +// numbered positions by their number (10 after 2), then any other position by +// name. A reader that must walk the positions a lineup holds without a team size +// (the Kachinuki Detail export, which labels a fighter by the first position it +// holds) orders them with this, so it agrees with the roster that walks +// OrderedMembers about which position comes first. +func ComparePositions(a, b Position) int { + groupA, rankA := positionRank(a) + groupB, rankB := positionRank(b) + return cmp.Or( + cmp.Compare(groupA, groupB), + cmp.Compare(rankA, rankB), + cmp.Compare(a, b), + ) +} + +// positionRank places a position in the order ComparePositions gives: group 0 +// for a FIK name (its rank is its place in fikPositionOrder), group 1 for a +// numbered position (its rank is its number), group 2 for any other. +func positionRank(p Position) (group, rank int) { + if i := slices.Index(fikPositionOrder[:], p); i >= 0 { + return 0, i + } + if n, err := strconv.Atoi(string(p)); err == nil && n >= 0 { + return 1, n + } + return 2, 0 +} + // OrderedMembers returns the occupied lineup slots in canonical position // order, skipping vacancies. A slot is occupied when it carries a name OR a // member id: a squad slot picked by number before it is named is a real diff --git a/internal/domain/team_lineup_test.go b/internal/domain/team_lineup_test.go index 328872070..9ef2ec6c4 100644 --- a/internal/domain/team_lineup_test.go +++ b/internal/domain/team_lineup_test.go @@ -1,7 +1,12 @@ package domain_test import ( + "cmp" "errors" + "fmt" + "maps" + "slices" + "strings" "testing" "github.com/gitrgoliveira/bracket-creator/internal/domain" @@ -33,6 +38,156 @@ func TestTeamLineup_OrderedMembers_FivePerson(t *testing.T) { assert.Equal(t, want, got) } +// TestComparePositions_IsTheOrderOrderedMembersWalks pins that the order the +// Kachinuki Detail export labels a fighter's first position by is the order the +// roster walks the lineup in: for every team size, ordering the positions a full +// lineup holds with ComparePositions gives what OrderedMembers returns. +func TestComparePositions_IsTheOrderOrderedMembersWalks(t *testing.T) { + for size := 1; size <= 12; size++ { + t.Run(fmt.Sprintf("team of %d", size), func(t *testing.T) { + positions := map[domain.Position]string{} + var walked []domain.Position + if size == 5 { + for _, p := range []domain.Position{domain.PosTaisho, domain.PosChuken, domain.PosSenpo, domain.PosFukusho, domain.PosJiho} { + positions[p] = string(p) + } + } else { + for n := size; n >= 1; n-- { + positions[domain.PositionNumbered(n)] = fmt.Sprintf("fighter-%d", n) + } + } + for _, slot := range (domain.TeamLineup{Positions: positions}).OrderedMembers(size) { + walked = append(walked, slot.Position) + } + + ordered := slices.SortedFunc(maps.Keys(positions), domain.ComparePositions) + + assert.Equal(t, walked, ordered) + assert.Len(t, ordered, size) + }) + } +} + +// TestComparePositions: the FIK names come before the numbered positions, which +// are ordered by number and not as text, and anything else comes last, by name. +func TestComparePositions(t *testing.T) { + ordered := []domain.Position{ + domain.PosSenpo, domain.PosJiho, domain.PosChuken, domain.PosFukusho, domain.PosTaisho, + "1", "2", "10", "11", + "-1", "alpha", "beta", + } + for i, a := range ordered { + for j, b := range ordered { + assert.Equalf(t, cmp.Compare(i, j), domain.ComparePositions(a, b), "%q against %q", a, b) + } + } +} + +// TestTeamLineup_ApplyChanges: a save that names the positions it changed lands +// them on a base and leaves every other position as the base holds it. +func TestTeamLineup_ApplyChanges(t *testing.T) { + base := domain.TeamLineup{ + TeamID: "team", CompetitionID: "comp", MatchID: "Pool A-1", Round: 0, + Positions: map[domain.Position]string{domain.PosSenpo: "Ito", domain.PosJiho: "Ueno", domain.PosChuken: "Endo"}, + MemberIDs: map[domain.Position]string{domain.PosSenpo: "m-ito", domain.PosJiho: "m-ueno"}, + } + apply := func(t *testing.T, changed []domain.Position, positions, ids map[domain.Position]string) domain.TeamLineup { + t.Helper() + got, err := base.ApplyChanges(changed, positions, ids) + require.NoError(t, err) + return got + } + + t.Run("a name with no id sets the name and drops the id the position had", func(t *testing.T) { + got := apply(t, []domain.Position{domain.PosSenpo}, map[domain.Position]string{domain.PosSenpo: "Kato"}, nil) + + assert.Equal(t, "Kato", got.Positions[domain.PosSenpo]) + assert.NotContains(t, got.MemberIDs, domain.PosSenpo) + assert.Equal(t, "m-ueno", got.MemberIDs[domain.PosJiho], "another position keeps its id") + }) + + t.Run("a name and an id set both", func(t *testing.T) { + got := apply(t, []domain.Position{domain.PosChuken}, map[domain.Position]string{domain.PosChuken: "Kato"}, map[domain.Position]string{domain.PosChuken: "m-kato"}) + + assert.Equal(t, "Kato", got.Positions[domain.PosChuken]) + assert.Equal(t, "m-kato", got.MemberIDs[domain.PosChuken]) + }) + + t.Run("an id with no name places a member who has none yet", func(t *testing.T) { + got := apply(t, []domain.Position{domain.PosFukusho}, map[domain.Position]string{domain.PosFukusho: ""}, map[domain.Position]string{domain.PosFukusho: "m-blank"}) + + name, held := got.Positions[domain.PosFukusho] + assert.True(t, held) + assert.Empty(t, name) + assert.Equal(t, "m-blank", got.MemberIDs[domain.PosFukusho]) + }) + + t.Run("neither a name nor an id clears the position from both maps", func(t *testing.T) { + got := apply(t, []domain.Position{domain.PosSenpo}, map[domain.Position]string{domain.PosSenpo: ""}, nil) + + assert.NotContains(t, got.Positions, domain.PosSenpo) + assert.NotContains(t, got.MemberIDs, domain.PosSenpo) + }) + + t.Run("a position that is not changed is left as the base holds it, whatever the save carries", func(t *testing.T) { + got := apply(t, []domain.Position{domain.PosJiho}, + map[domain.Position]string{domain.PosJiho: "Ueda", domain.PosSenpo: "stale", domain.PosTaisho: "Kato", "nonsense": "x"}, + map[domain.Position]string{domain.PosSenpo: "m-stale", domain.PosTaisho: "m-kato"}) + + assert.Equal(t, map[domain.Position]string{domain.PosSenpo: "Ito", domain.PosJiho: "Ueda", domain.PosChuken: "Endo"}, got.Positions) + assert.Equal(t, map[domain.Position]string{domain.PosSenpo: "m-ito"}, got.MemberIDs) + }) + + t.Run("the identity of the base is kept and nothing is shared with it", func(t *testing.T) { + got := apply(t, []domain.Position{domain.PosSenpo}, map[domain.Position]string{domain.PosSenpo: "Kato"}, nil) + got.Positions[domain.PosJiho] = "changed afterwards" + + assert.Equal(t, "team", got.TeamID) + assert.Equal(t, "Pool A-1", got.MatchID) + assert.Equal(t, "Ueno", base.Positions[domain.PosJiho]) + assert.Equal(t, "Ito", base.Positions[domain.PosSenpo]) + assert.Equal(t, "m-ito", base.MemberIDs[domain.PosSenpo]) + }) + + t.Run("an empty base takes the changes, and clearing the last position leaves an empty map", func(t *testing.T) { + got, err := domain.TeamLineup{}.ApplyChanges([]domain.Position{domain.PosSenpo}, map[domain.Position]string{domain.PosSenpo: "Ito"}, nil) + require.NoError(t, err) + assert.Equal(t, map[domain.Position]string{domain.PosSenpo: "Ito"}, got.Positions) + assert.Nil(t, got.MemberIDs, "no ids is no map, as a lineup saved without ids has none") + + emptied, err := got.ApplyChanges([]domain.Position{domain.PosSenpo}, map[domain.Position]string{domain.PosSenpo: ""}, nil) + require.NoError(t, err) + assert.NotNil(t, emptied.Positions) + assert.Empty(t, emptied.Positions) + }) + + t.Run("a changed position with no entry in positions is refused, naming it", func(t *testing.T) { + _, err := base.ApplyChanges([]domain.Position{domain.PosSenpo, domain.PosJiho}, map[domain.Position]string{domain.PosSenpo: "Kato"}, nil) + + require.ErrorIs(t, err, domain.ErrLineupChangedPositionMissing) + assert.Contains(t, err.Error(), "jiho") + assert.True(t, strings.HasPrefix(err.Error(), "team_lineup:"), "it classifies as a client error like every lineup refusal") + }) + + t.Run("no changed positions is refused", func(t *testing.T) { + for _, changed := range [][]domain.Position{nil, {}} { + _, err := base.ApplyChanges(changed, map[domain.Position]string{domain.PosSenpo: "Kato"}, nil) + + require.ErrorIs(t, err, domain.ErrLineupNoChangedPositions) + } + }) + + t.Run("a member placed at two positions is left for ValidatePositions to name", func(t *testing.T) { + got := apply(t, []domain.Position{domain.PosChuken}, map[domain.Position]string{domain.PosChuken: "Ito"}, map[domain.Position]string{domain.PosChuken: "m-ito"}) + + err := got.ValidatePositions(5) + + require.ErrorIs(t, err, domain.ErrLineupDuplicateMember) + assert.Contains(t, err.Error(), "senpo") + assert.Contains(t, err.Error(), "chuken") + }) +} + // TestTeamLineup_OrderedMembers_ThreePerson verifies the numeric-position // path: positions "1", "2", "3" are returned in ascending numeric order. func TestTeamLineup_OrderedMembers_ThreePerson(t *testing.T) { diff --git a/internal/domain/team_member.go b/internal/domain/team_member.go index bd450b263..935ba3f4b 100644 --- a/internal/domain/team_member.go +++ b/internal/domain/team_member.go @@ -60,6 +60,19 @@ type TeamMember struct { // state.SquadReserveSlots reserves (state.upgradeSquadsFromMetadataLocked), // each already carrying its ID and Index, with Name blank until filled in. Name string `json:"name" yaml:"name"` + + // ModifiedAt is the server's time, in milliseconds since the epoch, of the + // last write that created this member or changed its name. The store stamps + // it under the competition's lock (state.Store.stampMember) with the later of + // the server's time and one more than the member's previous stamp, so one + // member's stamps only grow, whatever the server's clock does between writes. + // A client holding two copies of a member keeps the one with the larger stamp. + // + // It is 0 for a member no write of this release has touched: a seeded blank + // slot, or one recorded before the field existed. The wire always carries it, + // so a reader can tell 0 from a missing key; the file leaves it out at 0, so a + // team-members.yaml written before it round-trips byte for byte. + ModifiedAt int64 `json:"modifiedAt" yaml:"modifiedAt,omitempty"` } // SquadMemberLabel composes a squad member's visible label (bc-pnum, "make diff --git a/internal/engine/chusen.go b/internal/engine/chusen.go index 96c1d8c0a..6543efdcb 100644 --- a/internal/engine/chusen.go +++ b/internal/engine/chusen.go @@ -177,7 +177,7 @@ func (e *Engine) ChusenStatus(compID string) (ChusenReport, error) { if comp == nil { return report, notFoundErrorf("competition %s not found", compID) } - isTeam := comp.Kind == "team" || comp.TeamSize > 0 + isTeam := comp.IsTeam() if !isTeam || !comp.AcceptsPoolRankOverride() { return report, nil } diff --git a/internal/engine/competition.go b/internal/engine/competition.go index b21bad624..15c3dbe3b 100644 --- a/internal/engine/competition.go +++ b/internal/engine/competition.go @@ -659,7 +659,8 @@ func (e *Engine) GenerateDraw(id string) error { } // DiscardDraw discards the generated draw for a draw-ready competition, -// deleting the draw artifacts and resetting the competition to Setup. +// deleting the draw artifacts, the match histories and the lineups saved for +// the matches, and resetting the competition to Setup. // Returns an error when the competition is not in draw-ready state. // // Ordering rationale: files are deleted BEFORE the status flip. While the @@ -709,6 +710,15 @@ func (e *Engine) DiscardDraw(id string) error { if err := e.store.DeleteMatchHistory(id); err != nil { return fmt.Errorf("DiscardDraw: failed to delete the match history: %w", err) } + // So do the lineups saved for those matches, and the record of the pairs the + // round-lineup conversion settled in them: the next draw reuses the match ids, + // so a lineup left behind would become a team's own lineup at the reused id + // and be carried to every later match, and a pair left in the record would keep + // the new match from being given its lineup. The round lineups (the starting + // lineup is round 0) stay. + if err := e.store.ClearDrawLineups(id); err != nil { + return fmt.Errorf("DiscardDraw: failed to clear the match lineups: %w", err) + } _, err = e.store.UpdateCompetitionChanged(id, func(current *state.Competition) (*state.Competition, error) { if current == nil { return nil, notFoundErrorf("competition %s not found", id) @@ -1213,7 +1223,7 @@ func (e *Engine) runDrawPipeline(id string) error { // competition can start holding a duplicate in the first place. isTeam // mirrors the same Kind/TeamSize discriminator the write-floor check // uses (comp.TeamSize is already defaulted above when Kind=="team"). - isTeam := comp.Kind == "team" || comp.TeamSize > 0 + isTeam := comp.IsTeam() if err := helper.ValidateNoDuplicateTeamMembers(players, isTeam); err != nil { return validationErrorf("competition %s cannot generate a draw: %s", id, err.Error()) } @@ -1246,8 +1256,26 @@ func (e *Engine) runDrawPipeline(id string) error { // per-comp lock acquisitions, so they run OUTSIDE the // UpdateCompetitionChanged transform below (re-entering the lock would // deadlock). + // + // A draw generated from Setup starts with none of an earlier draw's lineups: + // a competition in Setup holds no draw that stands, so whatever is on disk is + // the leftover of an attempt that failed after it wrote its matches (the + // refusal and the drift check below both come after the writes), and the write + // that saved those matches gave the legacy teams lineups at them and recorded + // the pairs. This draw reuses the match ids with other pairings, so neither may + // outlive its draw, as DiscardDraw leaves things. Cleared just before the + // first write, after every refusal that leaves the files alone. + clearEarlierDraw := func() error { + if err := e.store.ClearDrawLineups(id); err != nil { + return fmt.Errorf("competition %s: failed to clear the lineups of an earlier draw: %w", id, err) + } + return nil + } switch comp.Format { case state.CompFormatMixed, state.CompFormatLeague: + if err := clearEarlierDraw(); err != nil { + return err + } if err := e.generatePools(comp, players, seeds); err != nil { return err } @@ -1289,6 +1317,9 @@ func (e *Engine) runDrawPipeline(id string) error { if err != nil { return err } + if err := clearEarlierDraw(); err != nil { + return err + } if err := e.store.SavePoolMatches(id, r1); err != nil { return err } @@ -1298,6 +1329,9 @@ func (e *Engine) runDrawPipeline(id string) error { // A standalone knockout competition (the only remaining knockout case // after the derived-knockout path was removed in mp-turx) uses standalone // seeding, there is no pool-preview topology to mirror. + if err := clearEarlierDraw(); err != nil { + return err + } if err := e.generateKnockout(comp, players, seeds); err != nil { return err } @@ -1327,7 +1361,10 @@ func (e *Engine) runDrawPipeline(id string) error { // Note: our generated pools.csv / bracket.json have already been // written by this point (see pipeline limitations in the function // comment), aborting here leaves them as orphaned artifacts that - // the next successful start overwrites. Pre-existing partial- + // the next successful start overwrites. So are the lineups the + // round-lineup settlement gave the legacy teams at those matches, and + // its record of the pairs: the next start clears them before it + // generates (clearEarlierDraw above). Pre-existing partial- // atomicity issue; the fix here only guarantees comp-config // consistency. _, err = e.store.UpdateCompetitionChanged(id, func(current *state.Competition) (*state.Competition, error) { diff --git a/internal/engine/competition_test.go b/internal/engine/competition_test.go index fa0635dbe..ea11e3c2f 100644 --- a/internal/engine/competition_test.go +++ b/internal/engine/competition_test.go @@ -705,6 +705,41 @@ func TestDiscardDraw_ResetsToSetup(t *testing.T) { } } +// TestDiscardDraw_RemovesTheLineupsSavedForItsMatches pins that discarding a draw +// takes the lineups saved for its matches with it, and leaves the round lineups +// (the starting lineup is round 0). A regenerated draw reuses the match ids, so +// a match lineup left behind would be a team's own lineup at the reused id and +// be carried to every later match of the team. +func TestDiscardDraw_RemovesTheLineupsSavedForItsMatches(t *testing.T) { + eng, store, _ := setupTestEngine(t) + compID := "discard-draw-lineups" + createTestCompetition(t, store, compID, state.CompFormatLeague, 3) + saveTestParticipants(t, store, compID, []string{"Alice", "Bob", "Charlie", "Dave", "Eve", "Frank"}) + require.NoError(t, eng.GenerateDraw(compID)) + matches, err := store.LoadPoolMatches(compID) + require.NoError(t, err) + require.NotEmpty(t, matches) + team, first := matches[0].SideAID, matches[0].ID + require.NotEmpty(t, team) + + require.NoError(t, store.SetTeamLineup(compID, lineupOf(team, "", 0, "start"), 5)) + require.NoError(t, store.SetTeamLineup(compID, lineupOf(team, first, 0, "stale"), 5)) + + require.NoError(t, eng.DiscardDraw(compID)) + + lineups, err := store.LoadTeamLineups(compID) + require.NoError(t, err) + require.Len(t, lineups, 1, "only the starting lineup is left") + for _, l := range lineups { + assert.Empty(t, l.MatchID) + assert.Equal(t, 0, l.Round) + } + in, err := eng.LineupInForce(compID, team, first) + require.NoError(t, err) + require.True(t, in.Found) + assert.Equal(t, "start", senpoOf(in), "the team's lineup at the reused id is its starting lineup, not the discarded draw's") +} + // TestDiscardDraw_RejectsNonDrawReady ensures DiscardDraw errors when not in // draw-ready state. func TestDiscardDraw_RejectsNonDrawReady(t *testing.T) { diff --git a/internal/engine/kachinuki.go b/internal/engine/kachinuki.go index 4f07560c0..be6ae6989 100644 --- a/internal/engine/kachinuki.go +++ b/internal/engine/kachinuki.go @@ -572,13 +572,14 @@ func (e *Engine) advanceKachinukiOnce(compID, matchID string) (bool, *KachinukiA // advancement runs in both (bracket bouts append via // appendNextKachinukiBout, with propagateBracketWinner on // exhaustion). - parent, isBracket, roundIdx, err := e.findTeamMatch(compID, matchID) + located, err := e.findTeamMatch(compID, matchID) if err != nil { return false, nil, err } - if parent == nil || len(parent.SubResults) == 0 { + if located == nil || len(located.Result.SubResults) == 0 { return false, nil, nil } + parent, isBracket := located.Result, located.IsBracket // 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 @@ -629,7 +630,8 @@ func (e *Engine) advanceKachinukiOnce(compID, matchID string) (bool, *KachinukiA // 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. - remainingA, remainingB, rosterAvailable := e.kachinukiRemainingRoster(compID, matchID, comp, parent, roundIdx) + rule := e.lineupRuleOrNone("engine.MaybeAdvanceKachinuki", compID, comp.IsKnockoutEnabled(), located.PoolMatches, located.Bracket) + remainingA, remainingB, rosterAvailable := kachinukiRemainingRoster(comp, parent, rule) out := AdvanceKachinuki(AdvanceKachinukiInput{ LastBout: last, @@ -1599,9 +1601,12 @@ type matchHome struct { // findMatchHome walks the three homes a match ID can have — pool matches, then // bracket rounds, then the bronze (3rd-place) match — in that FIXED order, and -// invokes visit for the owning home. It is the MUTATING, in-transaction walk, -// and the engine's only copy of it, so a new caller cannot drop the bronze -// branch by hand-copying the ~60-line skeleton (mp-gmcg review F6). +// invokes visit for the owning home. It is the engine's only copy of that walk, +// so a new caller cannot drop the bronze branch by hand-copying the ~60-line +// skeleton (mp-gmcg review F6). The visitor may mutate the home and persist it +// through h.Save, as the reopen and bout-removal paths do inside a transaction, +// or only read it, as findTeamMatch does over the bare store: nothing is saved +// unless the visitor asks. // found=false with a nil error means the ID is in neither store. A LOAD error // from either store is returned as it is, never read as "not in this store": a // missing file already loads as empty, so an error is a file that exists and @@ -2515,48 +2520,43 @@ func preserveKachinukiMemberIDs(storedByPos map[int]state.SubMatchResult, in *st } } -// findTeamMatch locates a match by ID, returning the parent record (a -// copy), a flag indicating whether it was found in the bracket store -// rather than the pool store, and the bracket round index (0 for pool -// matches, rIdx for bracket matches, len(Rounds) for the ThirdPlaceMatch -// so round-scoped lineup resolution prefers the bronze's own stage, -// matching the client's derivedBracket.rounds.length). -func (e *Engine) findTeamMatch(compID, matchID string) (*state.MatchResult, bool, int, error) { - // A load error is returned, never read as "no such match" (see - // findMatchHome): a nil parent is reserved for an id in neither store. - poolMatches, err := e.store.LoadPoolMatches(compID) - if err != nil { - return nil, false, 0, err - } - for i := range poolMatches { - if poolMatches[i].ID == matchID { - m := poolMatches[i] - return &m, false, 0, nil +// teamMatch is a team match located by findTeamMatch, with the draw it was found +// in. +type teamMatch struct { + // Result is the match as a pool-shaped record (a copy): a bracket match is + // projected through bracketMatchToTeamResult. + Result *state.MatchResult + IsBracket bool + // PoolMatches and Bracket are what findMatchHome loaded to find the match, + // so the lineup rule is built from them rather than from a second read of + // the draw. Bracket is nil for a pool match, which the walk finds before it + // reads the bracket: no knockout match is ever before a pool match in match + // order, so the rule answers the same without it. + PoolMatches []state.MatchResult + Bracket *state.Bracket +} + +// findTeamMatch locates a match by ID through findMatchHome, in the pool store, +// then the bracket rounds, then the 3rd-place match. It returns nil when the +// ID is in neither. +func (e *Engine) findTeamMatch(compID, matchID string) (*teamMatch, error) { + var found *teamMatch + // A load error comes back from findMatchHome, never read as "no such match": + // a nil result is reserved for an id in neither store. + _, err := findMatchHome(e.store, compID, matchID, func(h matchHome) error { + found = &teamMatch{PoolMatches: h.PoolMatches, Bracket: h.BracketRoot} + if h.Pool != nil { + m := *h.Pool + found.Result = &m + return nil } - } - bracket, err := e.store.LoadBracket(compID) + found.Result, found.IsBracket = bracketMatchToTeamResult(*h.Bracket), true + return nil + }) if err != nil { - return nil, false, 0, err - } - if bracket != nil { - for rIdx, round := range bracket.Rounds { - for _, bm := range round { - if bm.ID == matchID { - return bracketMatchToTeamResult(bm), true, rIdx, nil - } - } - } - // The single-3rd-place (bronze) match is a sibling of - // bracket.Rounds, not an element of it; look it up here. Its - // effective round index is len(Rounds) (one past the final round), - // mirroring the client's derivedBracket.rounds.length so a - // round-scoped lineup saved for the bronze stage resolves ahead of - // an earlier round's lineup. - if bm := bracket.ThirdPlaceMatch; bm != nil && bm.ID == matchID { - return bracketMatchToTeamResult(*bm), true, len(bracket.Rounds), nil - } + return nil, err } - return nil, false, 0, nil + return found, nil } // bracketMatchToTeamResult projects a BracketMatch into the *MatchResult shape @@ -2614,11 +2614,12 @@ func bracketMatchToTeamResult(bm state.BracketMatch) *state.MatchResult { // 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 (AMENDMENT 1 / GAP 1 / GAP 2a): -// 1. Match-scoped lineup for this matchID. -// 2. Round-scoped lineup: highest round <= roundIdx. -// 3. Round-scoped lineup: highest round overall (fallback). -// 4. Bout-log-only heuristic (anyone who appeared in a bout, minus retired). +// 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 @@ -2628,15 +2629,15 @@ func bracketMatchToTeamResult(bm state.BracketMatch) *state.MatchResult { // 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 (e *Engine) kachinukiRemainingRoster(compID, matchID string, comp *state.Competition, parent *state.MatchResult, roundIdx int) ([]kachinukiFighter, []kachinukiFighter, bool) { +func kachinukiRemainingRoster(comp *state.Competition, parent *state.MatchResult, rule *lineupRule) ([]kachinukiFighter, []kachinukiFighter, bool) { retiredA, retiredB := RetiredPlayersFromBoutLog(parent.SubResults, parent.SideA, parent.SideB) - // Attempt lineup-based roster resolution. - lineupFor := e.lineupInForce(compID, matchID, comp, roundIdx) - - resolveRoster := func(teamName string, retired RetiredMemberSet) ([]kachinukiFighter, bool) { - if lineup, found := lineupFor(teamName); found { - full := lineup.OrderedMembers(comp.TeamSize) + // resolveRoster builds one side's remaining roster. 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) { + 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} @@ -2654,13 +2655,12 @@ func (e *Engine) kachinukiRemainingRoster(compID, matchID string, comp *state.Co // each by the same id-then-name order the lineup branch uses. seen := map[string]struct{}{} out := make([]kachinukiFighter, 0) - isA := teamName == parent.SideA for _, b := range parent.SubResults { if b.Position == state.DaihyosenSubPosition { continue // rep bout, not a roster player (see RetiredPlayersFromBoutLog) } f := kachinukiFighter{Name: b.SideB, MemberID: b.SideBMemberID} - if isA { + if sideA { f = kachinukiFighter{Name: b.SideA, MemberID: b.SideAMemberID} } key := f.MemberID @@ -2687,57 +2687,7 @@ func (e *Engine) kachinukiRemainingRoster(compID, matchID string, comp *state.Co return filterRemainingFighters(out, retired), false } - remainingA, foundA := resolveRoster(parent.SideA, retiredA) - remainingB, foundB := resolveRoster(parent.SideB, retiredB) + remainingA, foundA := resolveRoster(parent.SideAID, true, retiredA) + remainingB, foundB := resolveRoster(parent.SideBID, false, retiredB) return remainingA, remainingB, foundA || foundB } - -// lineupInForce loads a competition's saved lineups once and returns the -// resolver for "which lineup is in force for this side of matchID": a -// match-scoped lineup first, else the round-scoped one for roundIdx, per -// state.FindBestLineupAny's tiers. The resolver answers false when the side -// has no saved lineup, including when the lineups could not be loaded (the -// error is logged). -func (e *Engine) lineupInForce(compID, matchID string, comp *state.Competition, roundIdx int) func(teamName string) (domain.TeamLineup, bool) { - lineups, err := e.store.LoadTeamLineups(compID) - if err != nil { - log.Printf("engine.lineupInForce compId=%s matchId=%s: lineup load error: %v; resolving no lineup", compID, matchID, err) - lineups = nil - } - - // The lineup editor keys lineups by the team PARTICIPANT ID - // (player.id, a UUID) while match sides carry the team display NAME, - // so translate each side name to its participant ID and try both keys - // ("match on id OR name"). A participant load failure only degrades - // the lookup to name-only. - var participants []domain.Player - if len(lineups) > 0 { - participants, err = e.store.LoadParticipants(compID, comp.EffectiveWithZekkenName()) - if err != nil { - log.Printf("engine.lineupInForce compId=%s matchId=%s: participant load error: %v; lineup lookup degrades to name-only", compID, matchID, err) - participants = nil - } - } - teamKeys := func(teamName string) []string { - // Participant ID FIRST, then the display name. The lineup editor's - // current storage key is the participant ID, so an id-keyed lineup - // must win a same-round tie over a legacy name-keyed one: - // FindBestLineupAny resolves same-tier ties by slice order. The name - // stays as a fallback for lineups saved under it (older data, or a - // team name that is not a participant id). Mirrors the id-first order - // in kachinuki_export.go's teamKeys. - var keys []string - for _, p := range participants { - if p.Name == teamName && p.ID != "" && p.ID != teamName { - keys = append(keys, p.ID) - } - } - return append(keys, teamName) - } - return func(teamName string) (domain.TeamLineup, bool) { - if lineups == nil { - return domain.TeamLineup{}, false - } - return state.FindBestLineupAny(lineups, teamKeys(teamName), matchID, roundIdx) - } -} diff --git a/internal/engine/kachinuki_export.go b/internal/engine/kachinuki_export.go index c24582618..d52b45c82 100644 --- a/internal/engine/kachinuki_export.go +++ b/internal/engine/kachinuki_export.go @@ -12,6 +12,7 @@ package engine import ( "cmp" "fmt" + "log" "maps" "slices" @@ -50,18 +51,33 @@ func (e *Engine) collectKachinukiMatches(compID string, comp *state.Competition) return nil, nil } - // Lookup table: teamName → position → playerName, used to derive a - // per-bout lineup-position annotation. Built once per export; lineups - // may be missing entirely (Slice 7 lineup integration is partial), in - // which case positions render as empty strings. - positionByPlayer := e.buildKachinukiPositionMap(compID, comp) + poolMatches, err := e.store.LoadPoolMatches(compID) + if err != nil { + return nil, err + } + pools, err := e.store.LoadPools(compID) + if err != nil { + return nil, err + } + // A bracket that cannot be read leaves the pool sections alone. + bracket, err := e.store.LoadBracket(compID) + if err != nil { + log.Printf("engine.collectKachinukiMatches compId=%s: bracket.json load error: %v; the export lists the pool sections only", compID, err) + bracket = nil + } + + // Each match's section reads its fighters' positions from the lineup in + // force for its teams at that match, over the draw loaded above. The + // lineups are read once per export; they may be missing entirely, in which + // case positions render as empty strings. + rule := e.lineupRuleOrNone("engine.collectKachinukiMatches", compID, comp.IsKnockoutEnabled(), poolMatches, bracket) // Squad member labels (bc-pnum: "make a team member's label available // to the public surfaces" -- the printed record is one of the // surfaces the operator ruling names). Both lookups are tolerant of - // every read failure the same way positionByPlayer is above: a - // missing/corrupt team-members.yaml, roster, pools.csv or bracket.json must - // degrade to blank labels, not fail the whole Kachinuki Detail export. + // every read failure the positions are: a missing/corrupt + // team-members.yaml, roster, pools.csv or bracket.json must degrade to + // blank labels, not fail the whole Kachinuki Detail export. teamNumbers := e.buildKachinukiTeamNumbers(compID, comp) squads := e.buildKachinukiSquads(compID) @@ -69,7 +85,7 @@ func (e *Engine) collectKachinukiMatches(compID string, comp *state.Competition) // section builds a match's section; one not yet decided and with no bout // recorded gets the empty rows for hand entry. section := func(m *state.MatchResult, label string) helper.KachinukiMatchDetail { - detail := buildKachinukiDetail(m, label, positionByPlayer, teamNumbers, squads) + detail := buildKachinukiDetail(m, label, rule.positionsForMatch(m), teamNumbers, squads) if len(detail.Bouts) == 0 && m.Status != state.MatchStatusCompleted { detail.BlankBoutRows = comp.TeamBoutRows() } @@ -80,14 +96,6 @@ func (e *Engine) collectKachinukiMatches(compID string, comp *state.Competition) // (helper.EachPoolMatch, shared with the blank template). A row the grid // has no block for (a tie-break or daihyosen) has a section only once it // has bouts, titled by its operator label rather than a draw number. - poolMatches, err := e.store.LoadPoolMatches(compID) - if err != nil { - return nil, err - } - pools, err := e.store.LoadPools(compID) - if err != nil { - return nil, err - } ordinals := AttachPoolMatches(pools, poolMatches) byID := make(map[string]*state.MatchResult, len(poolMatches)) for i := range poolMatches { @@ -116,8 +124,7 @@ func (e *Engine) collectKachinukiMatches(compID string, comp *state.Competition) // (state.Bracket.NumberMatches). A bye, hidden with one side empty, has no // section. Each is titled and its sides named as the sheet prints them, so // a side reading "M 3" leads to the section titled with match 3. - bracket, err := e.store.LoadBracket(compID) - if err == nil && bracket != nil { + if bracket != nil { printed := PrintedBracket(bracket) type stored struct { bm state.BracketMatch @@ -170,8 +177,7 @@ func (e *Engine) collectKachinukiMatches(compID string, comp *state.Competition) // yet, or an unreadable roster/pools/bracket file all return an empty // map, so the caller's labels fall back to blank rather than the whole // export failing over an auxiliary read -- the same tolerance -// buildKachinukiPositionMap already applies to a missing/corrupt lineups -// file, a few lines below in this file. +// lineupRuleOrNone applies to a missing/corrupt lineups file. func (e *Engine) buildKachinukiTeamNumbers(compID string, comp *state.Competition) map[string]string { out := map[string]string{} if comp == nil || comp.EffectiveNumberPrefix() == "" { @@ -276,10 +282,11 @@ func resolveKachinukiDisplayName(squads map[string][]domain.TeamMember, teamID, } // buildKachinukiDetail converts a single state.MatchResult into the -// helper-layer detail struct. +// helper-layer detail struct. positions is the match's own position lookup +// (lineupRule.positionsForMatch). func buildKachinukiDetail(m *state.MatchResult, label string, positions map[string]string, teamNumbers map[string]string, squads map[string][]domain.TeamMember) helper.KachinukiMatchDetail { resolvePos := func(team, memberID, player string) string { - return resolveKachinukiBoutPosition(positions, m.ID, team, memberID, player) + return resolveKachinukiBoutPosition(positions, team, memberID, player) } bouts := make([]helper.KachinukiBout, 0, len(m.SubResults)) for _, sub := range m.SubResults { @@ -317,20 +324,13 @@ func buildKachinukiDetail(m *state.MatchResult, label string, positions map[stri } // lineupKey is the composite key used to look up a player's lineup -// position from a ROUND-scoped lineup. Both team name and player name -// are needed because two teams may field players with the same name in -// different positions. +// position in a match's position lookup. Both team name and player name +// are needed because the two teams of a match may field players with the +// same name in different positions. func lineupKey(team, player string) string { return team + "\x00" + player } -// matchLineupKey is the composite key for a MATCH-scoped lineup -// position (mp-825): the same player may occupy a different position in -// successive encounters, so the match ID is part of the key. -func matchLineupKey(matchID, team, player string) string { - return matchID + "\x00" + team + "\x00" + player -} - // memberKey is the player half of a lineup key for a position held by // MEMBER ID rather than by name (bc-dnst): a fighter fielded by squad number // and not yet named has an id and an empty name, so a name-keyed lookup @@ -340,93 +340,70 @@ func memberKey(memberID string) string { return "\x00id\x00" + memberID } -// buildKachinukiPositionMap loads team lineups for the competition and -// flattens them into a position lookup. Two namespaces share one map: -// match-scoped entries (mp-825) keyed by matchLineupKey, and -// round-scoped (legacy) entries keyed by lineupKey as the fallback. -// resolveKachinukiPosition consults match-scoped first. Missing lineups -// yield an empty map, positions render as empty strings, the renderer -// handles it. -func (e *Engine) buildKachinukiPositionMap(compID string, comp *state.Competition) map[string]string { +// positionsForMatch answers, for a match's section of the export, the lineup +// position each fighter held there. The position is read from the lineup in +// force for the fighter's team at THAT match (lineup_in_force.go), never from +// whichever saved lineup holds the fighter's name, and the team is its +// participant id, as on every other read of the rule: a side with no id labels +// nothing. The empty rule, &lineupRule{}, labels nothing, and positions then +// render as empty strings, which the renderer handles. +// +// The result is keyed by lineupKey(team name, fighter): by name, and by member +// id (memberKey) as well, since a fighter fielded by number alone has an id and +// no name. +func (r *lineupRule) positionsForMatch(m *state.MatchResult) map[string]string { out := map[string]string{} - if comp == nil { - return out - } - lineups, err := e.store.LoadTeamLineups(compID) - if err != nil || len(lineups) == 0 { - return out - } - // The lineup editor keys lineups by the team PARTICIPANT ID - // (player.id, a UUID) while match sides, and therefore - // resolveKachinukiPosition's team argument, carry the team display - // NAME. Index every entry under BOTH keys ("match on id OR name") so - // UI-saved lineups resolve. A participant load failure only loses the - // id-to-name translation; raw TeamID keys are still emitted. - idToName := map[string]string{} - if participants, perr := e.store.LoadParticipants(compID, comp.EffectiveWithZekkenName()); perr == nil { - for _, p := range participants { - if p.ID != "" && p.Name != "" { - idToName[p.ID] = p.Name - } - } - } - for _, lineup := range lineups { - teamKeys := []string{lineup.TeamID} - if name, ok := idToName[lineup.TeamID]; ok && name != lineup.TeamID { - teamKeys = append(teamKeys, name) - } - index := func(player string, label string) { - for _, teamKey := range teamKeys { - if lineup.MatchID != "" { - out[matchLineupKey(lineup.MatchID, teamKey, player)] = label - } else { - out[lineupKey(teamKey, player)] = label - } - } - } - for pos, playerName := range lineup.Positions { - if playerName == "" { - continue - } - index(playerName, pos.Label()) - } - // Every position held by id is indexed under the id as well, so a - // nameless fighter (bc-dnst) still resolves; resolveKachinukiPosition - // tries the id first. - // - // Iterated over SORTED keys, and first-write-wins, because a lineup can - // still hold one member id at two positions: the duplicate guard is new - // and rows written before it are live data repaired by hand. Both - // iterations compute the same map key here, so a plain range let Go's - // randomised map order decide which position label survived, and the - // same competition exported "Senpo" on one run and "Chuken" on the - // next. Which of the two wins is arbitrary either way; that it is the - // SAME one every time is not. - indexedIDs := make(map[string]struct{}, len(lineup.MemberIDs)) - for _, pos := range slices.Sorted(maps.Keys(lineup.MemberIDs)) { - memberID := lineup.MemberIDs[pos] - if memberID == "" { - continue - } - if _, dup := indexedIDs[memberID]; dup { - continue - } - indexedIDs[memberID] = struct{}{} - index(memberKey(memberID), pos.Label()) + for _, side := range []struct{ id, name string }{{m.SideAID, m.SideA}, {m.SideBID, m.SideB}} { + if in := r.inForce(side.id, m.ID); in.Found { + indexLineupPositions(out, side.name, in.Lineup) } } return out } -// resolveKachinukiPosition returns the position label for (team, player) -// in the given match, preferring a match-scoped lineup and falling back -// to the round-scoped entry. -func resolveKachinukiPosition(positions map[string]string, matchID, team, player string) string { - if matchID != "" { - if label, ok := positions[matchLineupKey(matchID, team, player)]; ok { - return label +// indexLineupPositions adds each position a lineup fills to out, under +// lineupKey(team, fighter), by name and by member id. +// +// Both are indexed over the positions in the order a lineup is fielded +// (domain.ComparePositions), first write wins, because one fighter can still hold +// two positions of a lineup: the duplicate guard is new, and rows written before +// it are live data repaired by hand. Both loops compute the same map key for such +// a fighter, so a plain range let Go's randomised map order decide which position +// label survived, and the same competition exported "Senpo" on one run and +// "Chuken" on the next. The winner is the first position fielded, the one the +// roster (TeamLineup.OrderedMembers) fields the fighter at; a text order of the +// position names gave "Chuken" here for a fighter the roster fields as "Senpo", +// and "10" ahead of "2". +func indexLineupPositions(out map[string]string, team string, lineup domain.TeamLineup) { + indexFirstPositionHeld(out, team, lineup.Positions, func(name string) string { return name }) + // Every position held by id is indexed under the id as well, so a + // nameless fighter (bc-dnst) still resolves; resolveKachinukiBoutPosition + // tries the id first. + indexFirstPositionHeld(out, team, lineup.MemberIDs, memberKey) +} + +// indexFirstPositionHeld indexes held (position to a fighter's name or member +// id, empty for a vacant position) into out under lineupKey(team, key(held)), +// walking the positions in the order a lineup is fielded so a fighter held twice +// keeps the label of the first. +func indexFirstPositionHeld(out map[string]string, team string, held map[domain.Position]string, key func(string) string) { + indexed := make(map[string]struct{}, len(held)) + for _, pos := range slices.SortedFunc(maps.Keys(held), domain.ComparePositions) { + fighter := held[pos] + if fighter == "" { + continue + } + if _, dup := indexed[fighter]; dup { + continue } + indexed[fighter] = struct{}{} + out[lineupKey(team, key(fighter))] = pos.Label() } +} + +// resolveKachinukiPosition returns the position label for (team, player) in +// a match's position lookup. +func resolveKachinukiPosition(positions map[string]string, team, player string) string { return positions[lineupKey(team, player)] } @@ -434,11 +411,11 @@ func resolveKachinukiPosition(positions map[string]string, matchID, team, player // MEMBER ID first (the identity, present for every squad-era row and the // only handle a nameless fighter has), then by name for legacy rows that // carry no id. -func resolveKachinukiBoutPosition(positions map[string]string, matchID, team, memberID, player string) string { +func resolveKachinukiBoutPosition(positions map[string]string, team, memberID, player string) string { if memberID != "" { - if label := resolveKachinukiPosition(positions, matchID, team, memberKey(memberID)); label != "" { + if label := resolveKachinukiPosition(positions, team, memberKey(memberID)); label != "" { return label } } - return resolveKachinukiPosition(positions, matchID, team, player) + return resolveKachinukiPosition(positions, team, player) } diff --git a/internal/engine/kachinuki_export_test.go b/internal/engine/kachinuki_export_test.go index 3eeffdf0b..d7b860296 100644 --- a/internal/engine/kachinuki_export_test.go +++ b/internal/engine/kachinuki_export_test.go @@ -289,20 +289,22 @@ func TestCollectKachinukiMatches_PoolMatchesWithBouts(t *testing.T) { assert.Len(t, out[2].Bouts, 1) } -// TestBuildKachinukiPositionMap_WithLineups verifies that saved team lineups -// are correctly mapped to the (team, player) → position lookup table. -func TestBuildKachinukiPositionMap_WithLineups(t *testing.T) { +// TestKachinukiPositions_StartingLineup verifies that a saved team lineup is +// mapped to the (team, player) → position lookup of a match that has none of +// its own. +func TestKachinukiPositions_StartingLineup(t *testing.T) { eng, store, _ := setupTestEngine(t) compID := "pos-map-comp" - require.NoError(t, store.SaveCompetition(&state.Competition{ + comp := &state.Competition{ ID: compID, TeamMatchType: state.TeamMatchTypeKachinuki, TeamSize: 5, - })) + } + require.NoError(t, store.SaveCompetition(comp)) require.NoError(t, store.SetTeamLineup(compID, domain.TeamLineup{ - TeamID: "RedTeam", + TeamID: "red-id", Round: 0, Positions: map[domain.Position]string{ domain.PosSenpo: "R-Senpo", @@ -313,23 +315,20 @@ func TestBuildKachinukiPositionMap_WithLineups(t *testing.T) { }, }, 5)) - comp := &state.Competition{ - ID: compID, - TeamMatchType: state.TeamMatchTypeKachinuki, - TeamSize: 5, - } - posMap := eng.buildKachinukiPositionMap(compID, comp) + posMap := eng.lineupRuleOrNone("test", compID, true, nil, nil).positionsForMatch( + &state.MatchResult{ID: "P1-0", SideA: "RedTeam", SideAID: "red-id", SideB: "WhiteTeam", SideBID: "white-id"}) assert.Equal(t, "Senpo", posMap[lineupKey("RedTeam", "R-Senpo")]) assert.Equal(t, "Jiho", posMap[lineupKey("RedTeam", "R-Jiho")]) + assert.Empty(t, posMap[lineupKey("WhiteTeam", "R-Senpo")], "a team's lineup labels only that team's fighters") } -// TestBuildKachinukiPositionMap_ParticipantIDKeyed verifies that lineups -// saved by the UI (TeamID = team participant id, a UUID) still resolve -// position labels for match sides that carry the team display NAME. -// The map must be indexed under both keys so resolveKachinukiPosition, -// which is called with m.SideA/m.SideB (names), finds the label. -func TestBuildKachinukiPositionMap_ParticipantIDKeyed(t *testing.T) { +// TestKachinukiPositions_ParticipantIDKeyed verifies that lineups saved by the +// UI (TeamID = team participant id, a UUID) resolve position labels for a match +// side that carries the team's id, and that a side carrying only the team's +// display NAME has none: a team is its id. The lookup is keyed by the side's +// name, which buildKachinukiDetail is called with (m.SideA/m.SideB). +func TestKachinukiPositions_ParticipantIDKeyed(t *testing.T) { eng, store, _ := setupTestEngine(t) compID := "pos-map-pid-keyed" @@ -363,21 +362,52 @@ func TestBuildKachinukiPositionMap_ParticipantIDKeyed(t *testing.T) { }, }, 5)) - posMap := eng.buildKachinukiPositionMap(compID, comp) + // Older data stored under the team's NAME: not the team's lineup. + require.NoError(t, store.SetTeamLineup(compID, domain.TeamLineup{ + TeamID: "RedTeam", + Positions: map[domain.Position]string{domain.PosSenpo: "R-ByName"}, + }, 5)) - // Name-keyed lookups: what resolveKachinukiPosition uses with m.SideA/B. - assert.Equal(t, "Senpo", posMap[lineupKey("RedTeam", "R-Senpo")]) - assert.Equal(t, "Jiho", posMap[lineupKey("RedTeam", "R-Jiho")]) - assert.Equal(t, "Senpo", resolveKachinukiPosition(posMap, "SF-1", "RedTeam", "R-Sub")) - // Raw participant-id keys stay available too (match on id OR name). - assert.Equal(t, "Senpo", posMap[lineupKey(redID, "R-Senpo")]) + rule := eng.lineupRuleOrNone("test", compID, true, nil, nil) + + t.Run("by id", func(t *testing.T) { + posMap := rule.positionsForMatch(&state.MatchResult{ID: "SF-1", SideA: "RedTeam", SideAID: redID}) + assert.Equal(t, "Senpo", resolveKachinukiPosition(posMap, "RedTeam", "R-Sub"), "the match's own lineup") + assert.Empty(t, resolveKachinukiPosition(posMap, "RedTeam", "R-Jiho"), "the own lineup replaces the starting one") + }) + + t.Run("a side with only the team's name has no lineup", func(t *testing.T) { + posMap := rule.positionsForMatch(&state.MatchResult{ID: "SF-1", SideA: "RedTeam"}) + assert.Empty(t, posMap, "the roster knows the name, and a lineup stored under it is not the team's either") + }) + + other := rule.positionsForMatch(&state.MatchResult{ID: "SF-2", SideA: "RedTeam", SideAID: redID}) + assert.Equal(t, "Jiho", resolveKachinukiPosition(other, "RedTeam", "R-Jiho"), "a match with no lineup of its own falls back to the starting lineup") } -// TestBuildKachinukiPositionMap_NilComp verifies the nil guard. -func TestBuildKachinukiPositionMap_NilComp(t *testing.T) { - eng, _, _ := setupTestEngine(t) - posMap := eng.buildKachinukiPositionMap("any-comp", nil) - assert.Empty(t, posMap) +// TestKachinukiDetailMatches_UnreadableLineupsLabelNothingAndSayWhy pins that a +// lineups.yaml that cannot be read leaves the export's positions empty rather +// than failing it, and that the failure is logged, naming the file, instead of +// vanishing. +func TestKachinukiDetailMatches_UnreadableLineupsLabelNothingAndSayWhy(t *testing.T) { + eng, store, comp := setupKachinukiComp(t, "kx-unreadable-lineups", 3, func(c *state.Competition) { c.Format = state.CompFormatMixed }) + require.NoError(t, os.WriteFile( + filepath.Join(store.GetFolder(), "competitions", comp.ID, "lineups.yaml"), + []byte("lineups: [this is: not: valid yaml"), 0o600)) + require.NoError(t, store.SavePoolMatches(comp.ID, []state.MatchResult{{ + ID: "P1-0", SideA: "RedTeam", SideAID: "red-id", SideB: "WhiteTeam", SideBID: "white-id", + SubResults: []state.SubMatchResult{{Position: 1, SideA: "R-1", SideB: "W-1", Winner: "R-1", Decision: "fought"}}, + }})) + + var out []helper.KachinukiMatchDetail + var err error + logged := captureLog(t, func() { out, err = eng.KachinukiDetailMatches(comp.ID) }) + + require.NoError(t, err) + require.Len(t, out, 1) + require.Len(t, out[0].Bouts, 1) + assert.Empty(t, out[0].Bouts[0].SideAPos) + assert.Contains(t, logged, "lineups.yaml", "the failure is logged, naming the file") } // TestCollectKachinukiMatches_WithBracketStub verifies that a bracket match @@ -750,23 +780,14 @@ func TestCollectKachinukiMatches_MoreBoutsThanTheBlockKeepsThemAll(t *testing.T) assert.Zero(t, out[0].BlankBoutRows) } -// TestResolveKachinukiPosition_PrefersMatchScoped verifies the mp-825 -// selection: a match-scoped lineup wins over the round-scoped fallback, -// and the fallback applies when no match-scoped entry exists. -func TestResolveKachinukiPosition_PrefersMatchScoped(t *testing.T) { - positions := map[string]string{ - lineupKey("TeamA", "alice"): "Senpo", // round-scoped fallback - matchLineupKey("PoolA-1", "TeamA", "alice"): "Taisho", // match-scoped override for PoolA-1 - } +// TestResolveKachinukiPosition verifies the lookup is keyed by team AND player: +// the two teams of a match may field players with the same name. +func TestResolveKachinukiPosition(t *testing.T) { + positions := map[string]string{lineupKey("TeamA", "alice"): "Senpo"} - // Match PoolA-1 has a match-scoped override → Taisho. - assert.Equal(t, "Taisho", resolveKachinukiPosition(positions, "PoolA-1", "TeamA", "alice")) - // Match PoolA-2 has no match-scoped entry → round-scoped fallback. - assert.Equal(t, "Senpo", resolveKachinukiPosition(positions, "PoolA-2", "TeamA", "alice")) - // Empty matchID → fallback only. - assert.Equal(t, "Senpo", resolveKachinukiPosition(positions, "", "TeamA", "alice")) - // Unknown player → empty. - assert.Equal(t, "", resolveKachinukiPosition(positions, "PoolA-1", "TeamA", "bob")) + assert.Equal(t, "Senpo", resolveKachinukiPosition(positions, "TeamA", "alice")) + assert.Equal(t, "", resolveKachinukiPosition(positions, "TeamA", "bob"), "unknown player resolves nothing") + assert.Equal(t, "", resolveKachinukiPosition(positions, "TeamB", "alice"), "the same name on another team is another fighter") } // --- Engine.KachinukiDetailMatches (exported wrapper) --- @@ -940,10 +961,10 @@ func TestKachinukiDetailMatches_InvalidCompetitionID(t *testing.T) { assert.Contains(t, err.Error(), "invalid competition ID") } -// TestBuildKachinukiPositionMap_MatchScoped verifies the loader splits -// match-scoped and round-scoped lineups into their respective key -// namespaces. -func TestBuildKachinukiPositionMap_MatchScoped(t *testing.T) { +// TestKachinukiPositions_MatchScopedLineupReachesLaterMatchesOnly verifies that +// a lineup entered for a match relabels that match and the team's later ones, +// and never an earlier one. +func TestKachinukiPositions_MatchScopedLineupReachesLaterMatchesOnly(t *testing.T) { dir := t.TempDir() store, err := state.NewStore(dir) require.NoError(t, err) @@ -951,31 +972,38 @@ func TestBuildKachinukiPositionMap_MatchScoped(t *testing.T) { comp := &state.Competition{ID: compID, TeamSize: 5} require.NoError(t, store.SaveCompetition(comp)) - // Round-scoped lineup. + const teamID = "team-a-id" + // Starting lineup. require.NoError(t, store.SetTeamLineup(compID, domain.TeamLineup{ - TeamID: "TeamA", + TeamID: teamID, Positions: map[domain.Position]string{ domain.PosSenpo: "alice", domain.PosJiho: "b", domain.PosChuken: "c", domain.PosFukusho: "d", domain.PosTaisho: "e", }, }, 5)) - // Match-scoped lineup for PoolA-1 puts alice at Taisho. + // Lineup entered for the team's second match puts alice at Taisho. require.NoError(t, store.SetTeamLineup(compID, domain.TeamLineup{ - TeamID: "TeamA", - MatchID: "PoolA-1", + TeamID: teamID, + MatchID: "Pool A-1", Positions: map[domain.Position]string{ domain.PosSenpo: "e", domain.PosJiho: "b", domain.PosChuken: "c", domain.PosFukusho: "d", domain.PosTaisho: "alice", }, }, 5)) - e := New(store) - m := e.buildKachinukiPositionMap(compID, comp) + poolMatches := []state.MatchResult{ + {ID: "Pool A-0", SideA: "TeamA", SideAID: teamID, SideB: "TeamB", SideBID: "team-b-id"}, + {ID: "Pool A-1", SideA: "TeamA", SideAID: teamID, SideB: "TeamC", SideBID: "team-c-id"}, + {ID: "Pool A-2", SideA: "TeamA", SideAID: teamID, SideB: "TeamD", SideBID: "team-d-id"}, + } + rule := New(store).lineupRuleOrNone("test", compID, true, poolMatches, nil) + aliceAt := func(i int) string { + return resolveKachinukiPosition(rule.positionsForMatch(&poolMatches[i]), "TeamA", "alice") + } - assert.Equal(t, "Senpo", resolveKachinukiPosition(m, "PoolA-2", "TeamA", "alice"), - "no match-scoped entry for PoolA-2 → round fallback Senpo") - assert.Equal(t, "Taisho", resolveKachinukiPosition(m, "PoolA-1", "TeamA", "alice"), - "match-scoped PoolA-1 overrides alice to Taisho") + assert.Equal(t, "Senpo", aliceAt(0), "before the lineup was entered: the starting lineup") + assert.Equal(t, "Taisho", aliceAt(1), "the match it was entered for") + assert.Equal(t, "Taisho", aliceAt(2), "carried to the team's next match") } // --- Squad member labels (bc-pnum: "make a team member's label @@ -1306,12 +1334,12 @@ func TestKachinukiDetailMatches_SquadLabel_BracketMatch(t *testing.T) { assert.Equal(t, "T1.1", out[0].Bouts[0].SideALabel, "RedTeam is DrawOrder[0] -> T1, Alice is squad member index 1") } -// TestBuildKachinukiPositionMap_NamelessFighterResolvesByMemberID pins the +// TestKachinukiPositions_NamelessFighterResolvesByMemberID pins the // bc-dnst rule that a fighter fielded by squad number and not yet named (an -// id, an empty name) still gets a position label on the export: the map is +// id, an empty name) still gets a position label on the export: the lookup is // indexed by member id as well as by name, and a bout side resolves by its // id first, falling back to its name for legacy rows that carry no id. -func TestBuildKachinukiPositionMap_NamelessFighterResolvesByMemberID(t *testing.T) { +func TestKachinukiPositions_NamelessFighterResolvesByMemberID(t *testing.T) { eng, store, _ := setupTestEngine(t) compID := "pos-map-nameless" comp := &state.Competition{ID: compID, TeamMatchType: state.TeamMatchTypeKachinuki, TeamSize: 5} @@ -1325,12 +1353,12 @@ func TestBuildKachinukiPositionMap_NamelessFighterResolvesByMemberID(t *testing. MemberIDs: map[domain.Position]string{domain.PosSenpo: "mem-senpo", domain.PosJiho: "mem-jiho"}, }, 5)) - posMap := eng.buildKachinukiPositionMap(compID, comp) + posMap := eng.lineupRuleOrNone("test", compID, true, nil, nil).positionsForMatch(&state.MatchResult{ID: "SF-2", SideA: "RedTeam", SideAID: redID}) - assert.Equal(t, "Jiho", resolveKachinukiBoutPosition(posMap, "SF-2", "RedTeam", "mem-jiho", ""), "a nameless fighter resolves by id") - assert.Equal(t, "Senpo", resolveKachinukiBoutPosition(posMap, "SF-2", "RedTeam", "mem-senpo", "R-Senpo"), "id wins for a named fighter too") - assert.Equal(t, "Senpo", resolveKachinukiBoutPosition(posMap, "SF-2", "RedTeam", "", "R-Senpo"), "a row with no id still resolves by name") - assert.Equal(t, "", resolveKachinukiBoutPosition(posMap, "SF-2", "RedTeam", "", ""), "no id and no name resolves nothing") + assert.Equal(t, "Jiho", resolveKachinukiBoutPosition(posMap, "RedTeam", "mem-jiho", ""), "a nameless fighter resolves by id") + assert.Equal(t, "Senpo", resolveKachinukiBoutPosition(posMap, "RedTeam", "mem-senpo", "R-Senpo"), "id wins for a named fighter too") + assert.Equal(t, "Senpo", resolveKachinukiBoutPosition(posMap, "RedTeam", "", "R-Senpo"), "a row with no id still resolves by name") + assert.Equal(t, "", resolveKachinukiBoutPosition(posMap, "RedTeam", "", ""), "no id and no name resolves nothing") } // A lineup can still hold ONE member id at TWO positions: the duplicate guard @@ -1340,7 +1368,7 @@ func TestBuildKachinukiPositionMap_NamelessFighterResolvesByMemberID(t *testing. // same competition exported "Senpo" on one run and "Chuken" on the next from // byte-identical state. Which of the two wins is arbitrary; that it is the // SAME one every time is not. -func TestBuildKachinukiPositionMap_DuplicateMemberIDLabelsDeterministically(t *testing.T) { +func TestKachinukiPositions_DuplicateMemberIDLabelsDeterministically(t *testing.T) { eng, store, _ := setupTestEngine(t) compID := "pos-map-dup" @@ -1354,7 +1382,7 @@ func TestBuildKachinukiPositionMap_DuplicateMemberIDLabelsDeterministically(t *t Lineups []domain.TeamLineup `yaml:"lineups"` } body, mErr := yaml.Marshal(&lineupFileShape{Lineups: []domain.TeamLineup{{ - TeamID: "RedTeam", Round: 0, + TeamID: "red-id", Round: 0, Positions: map[domain.Position]string{ domain.PosSenpo: "R-Senpo", domain.PosChuken: "R-Chuken", @@ -1368,17 +1396,68 @@ func TestBuildKachinukiPositionMap_DuplicateMemberIDLabelsDeterministically(t *t require.NoError(t, os.WriteFile( filepath.Join(store.GetFolder(), "competitions", compID, "lineups.yaml"), body, 0o600)) - comp := &state.Competition{ID: compID, TeamMatchType: state.TeamMatchTypeKachinuki, TeamSize: 5} + match := &state.MatchResult{ID: "P1-0", SideA: "RedTeam", SideAID: "red-id", SideB: "WhiteTeam", SideBID: "white-id"} + labelOf := func() string { + return eng.lineupRuleOrNone("test", compID, true, nil, nil).positionsForMatch(match)[lineupKey("RedTeam", memberKey("m-dup"))] + } // Repeated because the defect was map-order-dependent: one run could agree // with the fix by luck, so the pin is that many runs agree with EACH OTHER. - first := eng.buildKachinukiPositionMap(compID, comp)[lineupKey("RedTeam", memberKey("m-dup"))] + first := labelOf() require.NotEmpty(t, first, "the duplicated member must still resolve to a position") for i := 0; i < 24; i++ { - got := eng.buildKachinukiPositionMap(compID, comp)[lineupKey("RedTeam", memberKey("m-dup"))] - require.Equal(t, first, got, "the surviving label must not depend on map iteration order") + require.Equal(t, first, labelOf(), "the surviving label must not depend on map iteration order") + } + assert.Equal(t, "Senpo", first, "the order a lineup is fielded in keeps the first, which is senpo before chuken: the roster fields this fighter at senpo") +} + +// The same defect for NAMES: a lineup can hold one name at two positions (two +// teammates sharing a display name, or a hand-edited file), both of which +// compute the same map key, and a plain range over Positions let Go's +// randomised map order decide which label survived, so the same lineup exported +// "Senpo" on one run and "Chuken" on the next. +func TestIndexLineupPositions_DuplicateNameLabelsDeterministically(t *testing.T) { + lineup := domain.TeamLineup{ + TeamID: "red-id", + Positions: map[domain.Position]string{ + domain.PosSenpo: "R-Twice", + domain.PosChuken: "R-Twice", + domain.PosTaisho: "R-Once", + }, + } + labelOf := func(fighter string) string { + out := map[string]string{} + indexLineupPositions(out, "RedTeam", lineup) + return out[lineupKey("RedTeam", fighter)] + } + + first := labelOf("R-Twice") + require.NotEmpty(t, first, "the repeated name must still resolve to a position") + for i := 0; i < 24; i++ { + require.Equal(t, first, labelOf("R-Twice"), "the surviving label must not depend on map iteration order") + } + assert.Equal(t, "Senpo", first, "the order a lineup is fielded in keeps the first, which is senpo before chuken") + assert.Equal(t, "Taisho", labelOf("R-Once"), "a name held once keeps its position") +} + +// A team of ten or more fields numbered positions, and a fighter held at "2" and +// at "10" is at 2: the roster walks numbered positions by their number +// (TeamLineup.OrderedMembers), and so does the export. A text order put "10" first. +func TestIndexLineupPositions_NumberedPositionsAreOrderedByNumber(t *testing.T) { + lineup := domain.TeamLineup{ + TeamID: "red-id", + Positions: map[domain.Position]string{ + domain.PositionNumbered(10): "R-Twice", + domain.PositionNumbered(2): "R-Twice", + domain.PositionNumbered(11): "R-Once", + }, } - assert.Equal(t, "Chuken", first, "sorted key order keeps the first, which is chuken before senpo") + out := map[string]string{} + + indexLineupPositions(out, "RedTeam", lineup) + + assert.Equal(t, "2", out[lineupKey("RedTeam", "R-Twice")]) + assert.Equal(t, "11", out[lineupKey("RedTeam", "R-Once")]) } // A pool section is numbered as BlankKachinukiSections numbers the same draw: @@ -1439,3 +1518,127 @@ func TestCollectKachinukiMatches_DecidedWithoutBoutsGetsNoBlankRows(t *testing.T assert.Zero(t, rows["AlphaTeam"], "a match decided by a withdrawal") assert.Equal(t, 9, rows["GammaTeam"], "a match not played yet keeps 2*5-1 rows") } + +// kachinukiLineupFor builds a lineup holding the named fighters at the first +// positions, round-scoped when matchID is empty. +func kachinukiLineupFor(team, matchID string, round int, fighters map[domain.Position]string) domain.TeamLineup { + return domain.TeamLineup{TeamID: team, MatchID: matchID, Round: round, Positions: fighters} +} + +// TestKachinukiDetail_PositionsFollowTheLineupInForce pins that a bout row's +// position label is read from the lineup in force for its team at ITS match +// (operator ruling 2026-10-05), never from whichever saved lineup happens to +// hold the fighter's name. +func TestKachinukiDetail_PositionsFollowTheLineupInForce(t *testing.T) { + positionOf := func(d helper.KachinukiMatchDetail, name string) string { + for _, b := range d.Bouts { + if b.SideAName == name { + return b.SideAPos + } + } + t.Fatalf("no bout with %s on side A in %q", name, d.Label) + return "" + } + + // The teams as the store holds them: a participant id and the team name. + saveTeams := func(store *state.Store, compID string, teams ...string) map[string]string { + ids := map[string]string{} + roster := make([]domain.Player, len(teams)) + for i, team := range teams { + ids[team] = bctest.StampPlayerID(team, "Dojo") + roster[i] = domain.Player{ID: ids[team], Name: team, Dojo: "Dojo"} + } + require.NoError(t, store.SaveParticipants(compID, roster)) + return ids + } + + t.Run("a lineup entered for a team's first match is carried to its second", func(t *testing.T) { + compID := "kx-pos-carried" + eng, store, _ := setupKachinukiComp(t, compID, 5, func(c *state.Competition) { c.Format = state.CompFormatMixed }) + ids := saveTeams(store, compID, "RedTeam", "WhiteTeam", "BlueTeam") + + require.NoError(t, store.SetTeamLineup(compID, kachinukiLineupFor(ids["RedTeam"], "", 0, map[domain.Position]string{ + domain.PosSenpo: "R-Senpo", domain.PosJiho: "R-Jiho", + }), 5)) + require.NoError(t, store.SetTeamLineup(compID, kachinukiLineupFor(ids["RedTeam"], "Pool A-0", 0, map[domain.Position]string{ + domain.PosSenpo: "R-Jiho", domain.PosJiho: "R-Senpo", + }), 5)) + bout := func(red, other string) []state.SubMatchResult { + return []state.SubMatchResult{{Position: 1, SideA: red, SideB: other, Winner: red, Decision: "fought"}} + } + require.NoError(t, store.SavePoolMatches(compID, []state.MatchResult{ + {ID: "Pool A-0", SideA: "RedTeam", SideB: "WhiteTeam", SideAID: ids["RedTeam"], SideBID: ids["WhiteTeam"], SubResults: bout("R-Senpo", "W-1")}, + {ID: "Pool A-1", SideA: "RedTeam", SideB: "BlueTeam", SideAID: ids["RedTeam"], SideBID: ids["BlueTeam"], SubResults: bout("R-Senpo", "B-1")}, + })) + + out, err := eng.KachinukiDetailMatches(compID) + require.NoError(t, err) + require.Len(t, out, 2) + assert.Equal(t, "Jiho", positionOf(out[0], "R-Senpo"), "the match's own lineup") + assert.Equal(t, "Jiho", positionOf(out[1], "R-Senpo"), + "the second match carries the lineup entered for the first, not the starting lineup") + }) + + // A round 1 lineup (round >= 1, which releases up to v2.1.1 let the Lineups + // page save) is given to the matches it applied to by the state layer when + // its team is seated; one written straight to the store afterwards is not + // read. + round1Bracket := func(t *testing.T, store *state.Store, compID string, ids map[string]string) { + t.Helper() + bouts := func(fighters ...string) []state.SubMatchResult { + out := make([]state.SubMatchResult, len(fighters)) + for i, f := range fighters { + out[i] = state.SubMatchResult{Position: i + 1, SideA: f, SideB: "X", Winner: f, Decision: "fought"} + } + return out + } + require.NoError(t, store.SaveBracket(compID, &state.Bracket{Rounds: [][]state.BracketMatch{ + { + {ID: "r0-m0", SideA: "RedTeam", SideB: "WhiteTeam", SideAID: ids["RedTeam"], SideBID: ids["WhiteTeam"], SubResults: bouts("R-A", "R-New")}, + {ID: "r0-m1", SideA: "BlueTeam", SideB: "Other", SideAID: ids["BlueTeam"], SubResults: bouts("B-1")}, + }, + { + {ID: "r1-m0", SideA: "RedTeam", SideB: "BlueTeam", SideAID: ids["RedTeam"], SideBID: ids["BlueTeam"], SubResults: bouts("R-A", "R-New")}, + }, + }})) + } + startAndRound1 := map[domain.Position]string{domain.PosSenpo: "R-B", domain.PosJiho: "R-A", domain.PosChuken: "R-New"} + + t.Run("a Lineups-page entry for round 1 is not read: the round 1 match carries the lineup before it", func(t *testing.T) { + compID := "kx-pos-round-ignored" + eng, store, _ := setupKachinukiComp(t, compID, 5, func(c *state.Competition) { c.Format = state.CompFormatKnockout }) + ids := saveTeams(store, compID, "RedTeam", "WhiteTeam", "BlueTeam") + require.NoError(t, store.SetTeamLineup(compID, kachinukiLineupFor(ids["RedTeam"], "", 0, map[domain.Position]string{ + domain.PosSenpo: "R-A", domain.PosJiho: "R-B", + }), 5)) + require.NoError(t, store.SetTeamLineup(compID, kachinukiLineupFor(ids["RedTeam"], "", 1, startAndRound1), 5)) + round1Bracket(t, store, compID, ids) + + out, err := eng.KachinukiDetailMatches(compID) + require.NoError(t, err) + require.Len(t, out, 3) + assert.Equal(t, "Senpo", positionOf(out[0], "R-A"), "round 0: the starting lineup") + assert.Equal(t, "", positionOf(out[0], "R-New"), "a fighter only the round 1 entry holds has no position") + assert.Equal(t, "Senpo", positionOf(out[2], "R-A"), "round 1 carries the starting lineup: the round 1 entry is not read") + assert.Equal(t, "", positionOf(out[2], "R-New")) + }) + + t.Run("a lineup entered for a round 1 match relabels from that match", func(t *testing.T) { + compID := "kx-pos-round-match" + eng, store, _ := setupKachinukiComp(t, compID, 5, func(c *state.Competition) { c.Format = state.CompFormatKnockout }) + ids := saveTeams(store, compID, "RedTeam", "WhiteTeam", "BlueTeam") + require.NoError(t, store.SetTeamLineup(compID, kachinukiLineupFor(ids["RedTeam"], "", 0, map[domain.Position]string{ + domain.PosSenpo: "R-A", domain.PosJiho: "R-B", + }), 5)) + require.NoError(t, store.SetTeamLineup(compID, kachinukiLineupFor(ids["RedTeam"], "r1-m0", 0, startAndRound1), 5)) + round1Bracket(t, store, compID, ids) + + out, err := eng.KachinukiDetailMatches(compID) + require.NoError(t, err) + require.Len(t, out, 3) + assert.Equal(t, "Senpo", positionOf(out[0], "R-A"), "round 0: the starting lineup") + assert.Equal(t, "", positionOf(out[0], "R-New"), "a fighter only the round 1 match's lineup holds has no position before it") + assert.Equal(t, "Jiho", positionOf(out[2], "R-A"), "round 1 starts with its own entry") + assert.Equal(t, "Chuken", positionOf(out[2], "R-New")) + }) +} diff --git a/internal/engine/kachinuki_test.go b/internal/engine/kachinuki_test.go index 532131c2b..531ae2100 100644 --- a/internal/engine/kachinuki_test.go +++ b/internal/engine/kachinuki_test.go @@ -1,6 +1,8 @@ package engine import ( + "os" + "path/filepath" "testing" "github.com/gitrgoliveira/bracket-creator/internal/domain" @@ -15,6 +17,14 @@ import ( // member id on any entry -- the shape every pre-bc-tmid test literal used // (a plain []string) now needs to take, since AdvanceKachinukiInput's // SideA/SideB carry the fighter's possible member id alongside the name. +// The participant ids the lineup fixtures seat RedTeam and WhiteTeam under: a +// lineup is a team's by its participant id, so a fixture that saves one stamps +// the same id on the match's side. +const ( + redTeamID = "red-id" + whiteTeamID = "white-id" +) + func fighters(names ...string) []kachinukiFighter { out := make([]kachinukiFighter, len(names)) for i, n := range names { @@ -513,7 +523,7 @@ func TestMaybeAdvanceKachinuki_IgnoresTrailingDaihyosen(t *testing.T) { // append is the proof the daihyosen row was skipped (operator-led // completion, mp-gmcg: appending is the only mutation the engine makes). require.NoError(t, store.SetTeamLineup(compID, domain.TeamLineup{ - TeamID: "RedTeam", Round: 0, + TeamID: redTeamID, Round: 0, Positions: map[domain.Position]string{ domain.PositionNumbered(1): "R-Senpo", domain.PositionNumbered(2): "R-2", @@ -521,7 +531,7 @@ func TestMaybeAdvanceKachinuki_IgnoresTrailingDaihyosen(t *testing.T) { }, }, 3)) require.NoError(t, store.SetTeamLineup(compID, domain.TeamLineup{ - TeamID: "WhiteTeam", Round: 0, + TeamID: whiteTeamID, Round: 0, Positions: map[domain.Position]string{ domain.PositionNumbered(1): "W-Senpo", domain.PositionNumbered(2): "W-2", @@ -535,9 +545,11 @@ func TestMaybeAdvanceKachinuki_IgnoresTrailingDaihyosen(t *testing.T) { // and bail (false), leaving the numbered result unprocessed. require.NoError(t, store.SavePoolMatches(compID, []state.MatchResult{ { - ID: "P1-0", - SideA: "RedTeam", - SideB: "WhiteTeam", + ID: "P1-0", + SideA: "RedTeam", + SideAID: redTeamID, + SideB: "WhiteTeam", + SideBID: whiteTeamID, SubResults: []state.SubMatchResult{ {Position: 1, SideA: "R-Senpo", SideB: "W-Senpo", Winner: "R-Senpo", Decision: "fought"}, {Position: state.DaihyosenSubPosition, SideA: "RedTeam", SideB: "WhiteTeam"}, @@ -776,7 +788,7 @@ func TestMaybeAdvanceKachinuki_RosterFromLineup(t *testing.T) { })) // Round-scoped lineups at round 0 for both teams. require.NoError(t, store.SetTeamLineup(compID, domain.TeamLineup{ - TeamID: "RedTeam", Round: 0, + TeamID: redTeamID, Round: 0, Positions: map[domain.Position]string{ domain.PositionNumbered(1): "R-1", domain.PositionNumbered(2): "R-2", @@ -784,7 +796,7 @@ func TestMaybeAdvanceKachinuki_RosterFromLineup(t *testing.T) { }, }, 3)) require.NoError(t, store.SetTeamLineup(compID, domain.TeamLineup{ - TeamID: "WhiteTeam", Round: 0, + TeamID: whiteTeamID, Round: 0, Positions: map[domain.Position]string{ domain.PositionNumbered(1): "W-1", domain.PositionNumbered(2): "W-2", @@ -796,9 +808,11 @@ func TestMaybeAdvanceKachinuki_RosterFromLineup(t *testing.T) { // Without lineup: remainingB=[] (bout-log-only) → wrongly ends match. require.NoError(t, store.SavePoolMatches(compID, []state.MatchResult{ { - ID: "P1-0", - SideA: "RedTeam", - SideB: "WhiteTeam", + ID: "P1-0", + SideA: "RedTeam", + SideAID: redTeamID, + SideB: "WhiteTeam", + SideBID: whiteTeamID, SubResults: []state.SubMatchResult{ {Position: 1, SideA: "R-1", SideB: "W-1", Winner: "R-1", Decision: "fought"}, }, @@ -821,11 +835,11 @@ func TestMaybeAdvanceKachinuki_RosterFromLineup(t *testing.T) { // TestMaybeAdvanceKachinuki_RosterFromLineup_ParticipantIDKeyed reproduces // the real UI flow: the lineup editor saves lineups keyed by the team // PARTICIPANT ID (teamIdOf(t) resolves to player.id, a UUID assigned by -// the store), while bracket/pool match sides carry the team NAME. The -// engine must translate the side name to the matching participant ID -// when looking up lineups (match on id OR name), otherwise every real -// lookup misses and the roster silently degrades to the bout-log-only -// path (GAP 2 stays broken in production). +// the store), and a match side finds them by that id. The pool row here +// carries the team NAME alone, as a row from before side ids does: the +// load-time legacy repair stamps its SideAID/SideBID from the roster, so +// the lookup still reaches the id-keyed lineups instead of silently +// degrading the roster to the bout-log-only path (GAP 2). func TestMaybeAdvanceKachinuki_RosterFromLineup_ParticipantIDKeyed(t *testing.T) { eng, store, _ := setupTestEngine(t) compID := "kachinuki-lineup-pid-keyed" @@ -894,46 +908,45 @@ func TestMaybeAdvanceKachinuki_RosterFromLineup_ParticipantIDKeyed(t *testing.T) assert.NotEqual(t, state.MatchStatusCompleted, matches[0].Status, "match must not be completed yet") } -// TestKachinukiRemainingRoster_IDKeyBeatsNameKey pins that when a team has BOTH -// an id-keyed and a name-keyed lineup at the same round, the participant-id -// lineup (the UI's storage key) wins. This guards the teamKeys id-first -// ordering against FindBestLineupAny's deterministic slice-order tie-break: a -// name-first order would let a legacy name-keyed lineup override the current -// id-keyed one and select the wrong roster. -func TestKachinukiRemainingRoster_IDKeyBeatsNameKey(t *testing.T) { - eng, store, comp := setupKachinukiComp(t, "kachinuki-idkey-wins", 3, func(c *state.Competition) { c.Format = state.CompFormatMixed }) +// TestKachinukiRemainingRoster_MatchesALineupByTheSidesParticipantID pins that a +// side's lineup is the one stored under its participant id (the UI's storage +// key) and no other: a lineup stored under the team's NAME sits beside it, or +// alone, and is not the team's, and a side that carries no id has none. +func TestKachinukiRemainingRoster_MatchesALineupByTheSidesParticipantID(t *testing.T) { + comp := &state.Competition{ID: "kachinuki-lineup-by-id", TeamSize: 3, TeamMatchType: state.TeamMatchTypeKachinuki} + lineup := func(team string, fighters ...string) domain.TeamLineup { + l := domain.TeamLineup{TeamID: team, Positions: map[domain.Position]string{}} + for i, f := range fighters { + l.Positions[domain.PositionNumbered(i+1)] = f + } + return l + } + // R-1 beat W-1, so White's bout-log roster is empty and any roster it has + // beyond that came from a lineup. + bouts := []state.SubMatchResult{{Position: 1, SideA: "R-1", SideB: "W-1", Winner: "R-1", Decision: "fought"}} + lineups := map[string]domain.TeamLineup{ + "by-id": lineup("red-id", "R-1", "R-2", "R-3"), + "red-by-name": lineup("Ryu", "X-1", "X-2", "X-3"), + "white-by-name": lineup("Tora", "W-1", "W-2", "W-3"), + } - ryuID := helper.NewUUID4() - toraID := helper.NewUUID4() - require.NoError(t, store.SaveParticipants(comp.ID, []domain.Player{ - {ID: ryuID, Name: "Ryu", Dojo: "DojoR"}, - {ID: toraID, Name: "Tora", Dojo: "DojoT"}, - })) + 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} - // Authoritative id-keyed lineup for Ryu at round 0. - require.NoError(t, store.SetTeamLineup(comp.ID, domain.TeamLineup{ - TeamID: ryuID, Round: 0, - Positions: map[domain.Position]string{ - domain.PositionNumbered(1): "R-1", - domain.PositionNumbered(2): "R-2", - domain.PositionNumbered(3): "R-3", - }, - }, 3)) - // Legacy name-keyed lineup for the SAME team + round, different roster. - require.NoError(t, store.SetTeamLineup(comp.ID, domain.TeamLineup{ - TeamID: "Ryu", Round: 0, - Positions: map[domain.Position]string{ - domain.PositionNumbered(1): "X-1", - domain.PositionNumbered(2): "X-2", - domain.PositionNumbered(3): "X-3", - }, - }, 3)) + remainingA, remainingB, ok := kachinukiRemainingRoster(comp, parent, ruleOver(lineups, nil, nil)) + + require.True(t, ok, "Red's lineup resolves") + assert.Equal(t, fighters("R-1", "R-2", "R-3"), remainingA) + assert.Empty(t, remainingB, "White has a lineup under its NAME only, which is not the team's: its roster is the bout log's") + }) - parent := &state.MatchResult{ID: "P1-0", SideA: "Ryu", SideB: "Tora"} - remainingA, _, ok := eng.kachinukiRemainingRoster(comp.ID, "P1-0", comp, parent, 0) - require.True(t, ok, "lineup roster must resolve") - assert.Equal(t, fighters("R-1", "R-2", "R-3"), remainingA, - "the participant-id-keyed lineup must win the same-round tie over the legacy name-keyed one") + 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)) + + assert.False(t, ok, "no side carries an id, so no lineup resolves") + }) } // TestMaybeAdvanceKachinuki_RenameDoesNotRequeueRetiredMember is THE @@ -992,7 +1005,8 @@ func TestMaybeAdvanceKachinuki_RenameDoesNotRequeueRetiredMember(t *testing.T) { // The operator renames Sato -> Suzuki (id unchanged) and re-saves the // SAME lineup slot under the new name, as re-selecting the same squad // member would. - require.NoError(t, store.RenameTeamMember(comp.ID, redID, sato.ID, "Suzuki")) + _, err = store.RenameTeamMember(comp.ID, redID, sato.ID, "Suzuki") + require.NoError(t, err) require.NoError(t, store.SetTeamLineup(comp.ID, domain.TeamLineup{ TeamID: redID, Round: 0, Positions: map[domain.Position]string{ @@ -1031,7 +1045,7 @@ func TestMaybeAdvanceKachinuki_CompletedMatchNoOp(t *testing.T) { Format: state.CompFormatMixed, })) require.NoError(t, store.SetTeamLineup(compID, domain.TeamLineup{ - TeamID: "RedTeam", Round: 0, + TeamID: redTeamID, Round: 0, Positions: map[domain.Position]string{ domain.PositionNumbered(1): "R-1", domain.PositionNumbered(2): "R-2", @@ -1039,7 +1053,7 @@ func TestMaybeAdvanceKachinuki_CompletedMatchNoOp(t *testing.T) { }, }, 3)) require.NoError(t, store.SetTeamLineup(compID, domain.TeamLineup{ - TeamID: "WhiteTeam", Round: 0, + TeamID: whiteTeamID, Round: 0, Positions: map[domain.Position]string{ domain.PositionNumbered(1): "W-1", domain.PositionNumbered(2): "W-2", @@ -1050,7 +1064,9 @@ func TestMaybeAdvanceKachinuki_CompletedMatchNoOp(t *testing.T) { { ID: "P1-0", SideA: "RedTeam", + SideAID: redTeamID, SideB: "WhiteTeam", + SideBID: whiteTeamID, Status: state.MatchStatusCompleted, Winner: "RedTeam", Decision: "kachinuki-exhaustion", @@ -1091,14 +1107,14 @@ func TestMaybeAdvanceKachinuki_FullSequence5v5EndsWithOperator(t *testing.T) { Format: state.CompFormatMixed, })) require.NoError(t, store.SetTeamLineup(compID, domain.TeamLineup{ - TeamID: "RedTeam", Round: 0, + TeamID: redTeamID, Round: 0, Positions: map[domain.Position]string{ domain.PosSenpo: "R-S", domain.PosJiho: "R-J", domain.PosChuken: "R-C", domain.PosFukusho: "R-F", domain.PosTaisho: "R-T", }, }, 5)) require.NoError(t, store.SetTeamLineup(compID, domain.TeamLineup{ - TeamID: "WhiteTeam", Round: 0, + TeamID: whiteTeamID, Round: 0, Positions: map[domain.Position]string{ domain.PosSenpo: "W-S", domain.PosJiho: "W-J", domain.PosChuken: "W-C", domain.PosFukusho: "W-F", domain.PosTaisho: "W-T", @@ -1109,10 +1125,12 @@ func TestMaybeAdvanceKachinuki_FullSequence5v5EndsWithOperator(t *testing.T) { // bout in turn (R-S keeps winning) and re-runs advancement. require.NoError(t, store.SavePoolMatches(compID, []state.MatchResult{ { - ID: "P1-0", - SideA: "RedTeam", - SideB: "WhiteTeam", - Status: state.MatchStatusRunning, + ID: "P1-0", + SideA: "RedTeam", + SideAID: redTeamID, + SideB: "WhiteTeam", + SideBID: whiteTeamID, + Status: state.MatchStatusRunning, SubResults: []state.SubMatchResult{ {Position: 1, SideA: "R-S", SideB: "W-S", Winner: "R-S", Decision: "fought"}, }, @@ -1214,7 +1232,7 @@ func TestMaybeAdvanceKachinuki_MatchScopedLineup(t *testing.T) { // Round-scoped lineup (generic): R-1, R-2, R-3. require.NoError(t, store.SetTeamLineup(compID, domain.TeamLineup{ - TeamID: "RedTeam", Round: 0, + TeamID: redTeamID, Round: 0, Positions: map[domain.Position]string{ domain.PositionNumbered(1): "R-1", domain.PositionNumbered(2): "R-2", @@ -1223,7 +1241,7 @@ func TestMaybeAdvanceKachinuki_MatchScopedLineup(t *testing.T) { }, 3)) // Match-scoped lineup (specific for "P1-0"): R-A, R-B, R-C. require.NoError(t, store.SetTeamLineup(compID, domain.TeamLineup{ - TeamID: "RedTeam", MatchID: "P1-0", + TeamID: redTeamID, MatchID: "P1-0", Positions: map[domain.Position]string{ domain.PositionNumbered(1): "R-A", domain.PositionNumbered(2): "R-B", @@ -1232,7 +1250,7 @@ func TestMaybeAdvanceKachinuki_MatchScopedLineup(t *testing.T) { }, 3)) // WhiteTeam round-scoped. require.NoError(t, store.SetTeamLineup(compID, domain.TeamLineup{ - TeamID: "WhiteTeam", Round: 0, + TeamID: whiteTeamID, Round: 0, Positions: map[domain.Position]string{ domain.PositionNumbered(1): "W-1", domain.PositionNumbered(2): "W-2", @@ -1243,9 +1261,11 @@ func TestMaybeAdvanceKachinuki_MatchScopedLineup(t *testing.T) { // Bout 1: W-1 beats R-A (from match-scoped lineup). require.NoError(t, store.SavePoolMatches(compID, []state.MatchResult{ { - ID: "P1-0", - SideA: "RedTeam", - SideB: "WhiteTeam", + ID: "P1-0", + SideA: "RedTeam", + SideAID: redTeamID, + SideB: "WhiteTeam", + SideBID: whiteTeamID, SubResults: []state.SubMatchResult{ {Position: 1, SideA: "R-A", SideB: "W-1", Winner: "W-1", Decision: "fought"}, }, @@ -1264,10 +1284,154 @@ func TestMaybeAdvanceKachinuki_MatchScopedLineup(t *testing.T) { assert.Equal(t, "W-1", matches[0].SubResults[1].SideB, "W-1 stays as winner") } -// TestMaybeAdvanceKachinuki_LatestRoundLineupFallback verifies AMENDMENT 1: -// when multiple round-scoped lineups exist for a team, the engine picks the -// highest round <= currentRound. For pool matches (currentRound=0), a round-1 -// lineup must be ignored in favour of round-0. +// TestMaybeAdvanceKachinuki_CarriesThePreviousMatchLineup pins the operator's +// rule (2026-10-05): a team keeps the lineup of its previous match unless a new +// one is entered for the match, so the roster the advance draws the next +// fighter from is the one saved for the team's EARLIER match, not its starting +// lineup. +func TestMaybeAdvanceKachinuki_CarriesThePreviousMatchLineup(t *testing.T) { + eng, store, comp := setupKachinukiComp(t, "kachinuki-carried-lineup", 3, + func(c *state.Competition) { c.Format = state.CompFormatMixed }) + + redID, whiteID, blueID := helper.NewUUID4(), helper.NewUUID4(), helper.NewUUID4() + require.NoError(t, store.SaveParticipants(comp.ID, []domain.Player{ + {ID: redID, Name: "RedTeam", Dojo: "DojoR"}, + {ID: whiteID, Name: "WhiteTeam", Dojo: "DojoW"}, + {ID: blueID, Name: "BlueTeam", Dojo: "DojoB"}, + })) + lineup := func(team, match string, names ...string) domain.TeamLineup { + l := domain.TeamLineup{TeamID: team, MatchID: match, Positions: map[domain.Position]string{}} + for i, n := range names { + l.Positions[domain.PositionNumbered(i+1)] = n + } + return l + } + // Red's starting lineup, then a different one entered for its first match. + require.NoError(t, store.SetTeamLineup(comp.ID, lineup(redID, "", "R-1", "R-2", "R-3"), 3)) + require.NoError(t, store.SetTeamLineup(comp.ID, lineup(redID, "Pool A-0", "R-A", "R-B", "R-C"), 3)) + require.NoError(t, store.SetTeamLineup(comp.ID, lineup(blueID, "", "B-1", "B-2", "B-3"), 3)) + + require.NoError(t, store.SavePoolMatches(comp.ID, []state.MatchResult{ + {ID: "Pool A-0", SideA: "RedTeam", SideB: "WhiteTeam", SideAID: redID, SideBID: whiteID, Status: state.MatchStatusCompleted}, + { + ID: "Pool A-1", SideA: "RedTeam", SideB: "BlueTeam", SideAID: redID, SideBID: blueID, + Status: state.MatchStatusRunning, + SubResults: []state.SubMatchResult{ + {Position: 1, SideA: "R-A", SideB: "B-1", Winner: "B-1", Decision: "fought"}, + }, + }, + })) + + changed, post, err := eng.MaybeAdvanceKachinuki(comp.ID, "Pool A-1") + require.NoError(t, err) + require.True(t, changed) + require.Len(t, post.BoutLog, 2) + assert.Equal(t, "R-B", post.BoutLog[1].SideA, + "R-A lost, and Red's next fighter comes from the lineup it carried out of Pool A-0, not its starting lineup") + assert.Equal(t, "B-1", post.BoutLog[1].SideB, "the bout-1 winner stays on") +} + +// TestMaybeAdvanceKachinuki_UnreadableLineupsDegradeToTheBoutLog pins that a +// lineups file that cannot be read never stops the advance: the roster falls +// back to who appeared in the bout log (logged, not returned), as it always +// has. +func TestMaybeAdvanceKachinuki_UnreadableLineupsDegradeToTheBoutLog(t *testing.T) { + eng, store, comp := setupKachinukiComp(t, "kachinuki-unreadable-lineups", 3) + require.NoError(t, os.WriteFile( + filepath.Join(store.GetFolder(), "competitions", comp.ID, "lineups.yaml"), + []byte("lineups: [this is: not: valid yaml"), 0o600)) + require.NoError(t, store.SavePoolMatches(comp.ID, []state.MatchResult{{ + ID: "P1-0", SideA: "RedTeam", SideB: "WhiteTeam", Status: state.MatchStatusRunning, + SubResults: []state.SubMatchResult{ + {Position: 1, SideA: "R-1", SideB: "W-1", Winner: "W-1", Decision: "fought"}, + {Position: 2, SideA: "R-2", SideB: "W-1", Winner: "R-2", Decision: "fought"}, + }, + }})) + + _, _, err := eng.MaybeAdvanceKachinuki(comp.ID, "P1-0") + + require.NoError(t, err, "an unreadable lineups file degrades the roster, it does not fail the advance") +} + +// TestMaybeAdvanceKachinuki_AnUnreadableBracketDoesNotCostAPoolMatchItsLineups +// pins that the advance builds the lineup rule from the draw its own match +// lookup loaded, and does not read the draw a second time. A pool match is found +// before the bracket is read, so a bracket.json that cannot be read has no part +// in it. It used to: the lineup read loaded the bracket again, its failure +// resolved no lineup, and the roster fell back to the bout log, which ends a +// side the lineup would have kept fielding. +func TestMaybeAdvanceKachinuki_AnUnreadableBracketDoesNotCostAPoolMatchItsLineups(t *testing.T) { + eng, store, comp := setupKachinukiComp(t, "kachinuki-advance-bad-bracket", 3, + func(c *state.Competition) { c.Format = state.CompFormatMixed }) + for team, fighters := range map[string][]string{redTeamID: {"R-1", "R-2", "R-3"}, whiteTeamID: {"W-1", "W-2", "W-3"}} { + l := domain.TeamLineup{TeamID: team, Positions: map[domain.Position]string{}} + for i, f := range fighters { + l.Positions[domain.PositionNumbered(i+1)] = f + } + require.NoError(t, store.SetTeamLineup(comp.ID, l, 3)) + } + require.NoError(t, store.SavePoolMatches(comp.ID, []state.MatchResult{{ + ID: "P1-0", SideA: "RedTeam", SideAID: redTeamID, SideB: "WhiteTeam", SideBID: whiteTeamID, + Status: state.MatchStatusRunning, + SubResults: []state.SubMatchResult{ + {Position: 1, SideA: "R-1", SideB: "W-1", Winner: "R-1", Decision: "fought"}, + }, + }})) + require.NoError(t, os.WriteFile( + filepath.Join(store.GetFolder(), "competitions", comp.ID, "bracket.json"), []byte("{not json"), 0o600)) + + changed, post, err := eng.MaybeAdvanceKachinuki(comp.ID, "P1-0") + + require.NoError(t, err) + require.True(t, changed, "White still has W-2 and W-3 in its lineup") + require.Len(t, post.BoutLog, 2) + assert.Equal(t, "W-2", post.BoutLog[1].SideB) +} + +// TestMaybeAdvanceKachinuki_TakesALineupFromTheTeamIDNotItsName pins, through the +// advance, that a team's lineup is the one stored under its participant id: the +// same rosters stored under the team NAMES, which the match carries as well, are +// not the teams', so the roster is the bout log's and White, whose only fighter +// so far lost, is not given a next one. +func TestMaybeAdvanceKachinuki_TakesALineupFromTheTeamIDNotItsName(t *testing.T) { + for _, tc := range []struct { + name string + red, white string + wantAdvancing bool + }{ + {"stored under the participant ids", redTeamID, whiteTeamID, true}, + {"stored under the team names", "RedTeam", "WhiteTeam", false}, + } { + t.Run(tc.name, func(t *testing.T) { + eng, store, comp := setupKachinukiComp(t, "kachinuki-lineup-key", 3, + func(c *state.Competition) { c.Format = state.CompFormatMixed }) + for team, fighters := range map[string][]string{tc.red: {"R-1", "R-2", "R-3"}, tc.white: {"W-1", "W-2", "W-3"}} { + l := domain.TeamLineup{TeamID: team, Positions: map[domain.Position]string{}} + for i, f := range fighters { + l.Positions[domain.PositionNumbered(i+1)] = f + } + require.NoError(t, store.SetTeamLineup(comp.ID, l, 3)) + } + require.NoError(t, store.SavePoolMatches(comp.ID, []state.MatchResult{{ + ID: "P1-0", SideA: "RedTeam", SideAID: redTeamID, SideB: "WhiteTeam", SideBID: whiteTeamID, + Status: state.MatchStatusRunning, + SubResults: []state.SubMatchResult{ + {Position: 1, SideA: "R-1", SideB: "W-1", Winner: "R-1", Decision: "fought"}, + }, + }})) + + changed, _, err := eng.MaybeAdvanceKachinuki(comp.ID, "P1-0") + + require.NoError(t, err) + assert.Equal(t, tc.wantAdvancing, changed) + }) + } +} + +// TestMaybeAdvanceKachinuki_LatestRoundLineupFallback verifies that when +// multiple round-scoped lineups exist for a team, the engine picks the one +// whose round has started: a round-1 lineup begins after the pool matches, so +// a pool match (round 0) must use the round-0 lineup. func TestMaybeAdvanceKachinuki_LatestRoundLineupFallback(t *testing.T) { eng, store, _ := setupTestEngine(t) compID := "kachinuki-latest-round-fallback" @@ -1281,7 +1445,7 @@ func TestMaybeAdvanceKachinuki_LatestRoundLineupFallback(t *testing.T) { // Round 0 lineup for RedTeam (pool phase). require.NoError(t, store.SetTeamLineup(compID, domain.TeamLineup{ - TeamID: "RedTeam", Round: 0, + TeamID: redTeamID, Round: 0, Positions: map[domain.Position]string{ domain.PositionNumbered(1): "R-Pool-1", domain.PositionNumbered(2): "R-Pool-2", @@ -1290,7 +1454,7 @@ func TestMaybeAdvanceKachinuki_LatestRoundLineupFallback(t *testing.T) { }, 3)) // Round 1 lineup for RedTeam (bracket phase, should NOT be used for pool match). require.NoError(t, store.SetTeamLineup(compID, domain.TeamLineup{ - TeamID: "RedTeam", Round: 1, + TeamID: redTeamID, Round: 1, Positions: map[domain.Position]string{ domain.PositionNumbered(1): "R-Bracket-1", domain.PositionNumbered(2): "R-Bracket-2", @@ -1298,7 +1462,7 @@ func TestMaybeAdvanceKachinuki_LatestRoundLineupFallback(t *testing.T) { }, }, 3)) require.NoError(t, store.SetTeamLineup(compID, domain.TeamLineup{ - TeamID: "WhiteTeam", Round: 0, + TeamID: whiteTeamID, Round: 0, Positions: map[domain.Position]string{ domain.PositionNumbered(1): "W-1", domain.PositionNumbered(2): "W-2", @@ -1306,13 +1470,15 @@ func TestMaybeAdvanceKachinuki_LatestRoundLineupFallback(t *testing.T) { }, }, 3)) - // Bout 1: R-Pool-1 beats W-1. With AMENDMENT 1 fallback, round-0 lineup is - // used (not round-1) for this pool match → remainingA=[R-Pool-1], remainingB=[W-2,W-3]. + // Bout 1: R-Pool-1 beats W-1. Round 1 has not started during the pool + // match, so the round-0 lineup is in force → remainingA=[R-Pool-1], remainingB=[W-2,W-3]. require.NoError(t, store.SavePoolMatches(compID, []state.MatchResult{ { - ID: "P1-0", - SideA: "RedTeam", - SideB: "WhiteTeam", + ID: "P1-0", + SideA: "RedTeam", + SideAID: redTeamID, + SideB: "WhiteTeam", + SideBID: whiteTeamID, SubResults: []state.SubMatchResult{ {Position: 1, SideA: "R-Pool-1", SideB: "W-1", Winner: "R-Pool-1", Decision: "fought"}, }, @@ -1642,12 +1808,10 @@ func TestMaybeAdvanceKachinuki_BronzeAppendsBout(t *testing.T) { assert.Equal(t, 3, bracket.ThirdPlaceMatch.SubResults[2].Position, "position must be 3") } -// TestFindTeamMatch_BronzeRoundIndex pins that the 3rd-place (bronze) match -// resolves to round index len(Rounds), not 0, so round-scoped lineup lookup -// prefers the bronze's own stage (matching the client's -// derivedBracket.rounds.length). A regular bracket match keeps its own rIdx. -func TestFindTeamMatch_BronzeRoundIndex(t *testing.T) { - compID := "kachinuki-bronze-round-index" +// TestFindTeamMatch_Bronze pins that the 3rd-place (bronze) match, a sibling of +// bracket.Rounds rather than an element of it, is found like any bracket match. +func TestFindTeamMatch_Bronze(t *testing.T) { + compID := "kachinuki-bronze-lookup" eng, store, _ := setupKachinukiComp(t, compID, 5, func(c *state.Competition) { c.Naginata = true }) require.NoError(t, store.SaveBracket(compID, &state.Bracket{ @@ -1658,14 +1822,44 @@ func TestFindTeamMatch_BronzeRoundIndex(t *testing.T) { ThirdPlaceMatch: &state.BracketMatch{ID: "m-bronze", SideA: "TeamB", SideB: "TeamD"}, })) - _, isBracket, roundIdx, err := eng.findTeamMatch(compID, "m-bronze") + bronze, err := eng.findTeamMatch(compID, "m-bronze") require.NoError(t, err) - assert.True(t, isBracket, "bronze is a bracket match") - assert.Equal(t, 2, roundIdx, "bronze round index is len(Rounds)=2, not 0") + require.NotNil(t, bronze, "the 3rd-place match is a sibling of Rounds and is still found") + assert.True(t, bronze.IsBracket, "bronze is a bracket match") + assert.Equal(t, "TeamB", bronze.Result.SideA) - _, _, sfRound, err := eng.findTeamMatch(compID, "SF1") + sf1, err := eng.findTeamMatch(compID, "SF1") require.NoError(t, err) - assert.Equal(t, 0, sfRound, "a first-round bracket match keeps rIdx 0") + require.NotNil(t, sf1) + assert.True(t, sf1.IsBracket) + require.NotNil(t, sf1.Bracket, "a bracket match comes with the bracket it was found in") + assert.Equal(t, "m-bronze", sf1.Bracket.ThirdPlaceMatch.ID) +} + +// TestFindTeamMatch_PoolMatchComesWithThePoolMatchesItWasFoundIn pins that the +// walk hands back what it loaded, so the lineup rule is built from it rather +// than from a second read of the draw: the pool matches, and no bracket, since +// the walk finds a pool match before it reads one. +func TestFindTeamMatch_PoolMatchComesWithThePoolMatchesItWasFoundIn(t *testing.T) { + compID := "kachinuki-pool-lookup" + eng, store, _ := setupKachinukiComp(t, compID, 5) + require.NoError(t, store.SavePoolMatches(compID, []state.MatchResult{ + {ID: "Pool A-0", SideA: "TeamA", SideAID: "a", SideB: "TeamB", SideBID: "b"}, + {ID: "Pool A-1", SideA: "TeamA", SideAID: "a", SideB: "TeamC", SideBID: "c"}, + })) + + found, err := eng.findTeamMatch(compID, "Pool A-1") + + require.NoError(t, err) + require.NotNil(t, found) + assert.False(t, found.IsBracket) + assert.Equal(t, "TeamC", found.Result.SideB) + assert.Len(t, found.PoolMatches, 2, "every pool match the walk loaded, not just the one found") + assert.Nil(t, found.Bracket, "a pool match is found before the bracket is read") + + missing, err := eng.findTeamMatch(compID, "no-such-match") + require.NoError(t, err) + assert.Nil(t, missing, "nil is reserved for an id in neither store") } // TestMergeKachinukiSubResults pins the by-position merge semantics the @@ -2939,7 +3133,7 @@ func TestMaybeAdvanceKachinuki_NamelessBoutNoOp(t *testing.T) { Format: state.CompFormatMixed, })) require.NoError(t, store.SetTeamLineup(compID, domain.TeamLineup{ - TeamID: "RedTeam", Round: 0, + TeamID: redTeamID, Round: 0, Positions: map[domain.Position]string{ domain.PositionNumbered(1): "R-1", domain.PositionNumbered(2): "R-2", @@ -2947,7 +3141,7 @@ func TestMaybeAdvanceKachinuki_NamelessBoutNoOp(t *testing.T) { }, }, 3)) require.NoError(t, store.SetTeamLineup(compID, domain.TeamLineup{ - TeamID: "WhiteTeam", Round: 0, + TeamID: whiteTeamID, Round: 0, Positions: map[domain.Position]string{ domain.PositionNumbered(1): "W-1", domain.PositionNumbered(2): "W-2", @@ -2956,7 +3150,7 @@ func TestMaybeAdvanceKachinuki_NamelessBoutNoOp(t *testing.T) { }, 3)) require.NoError(t, store.SavePoolMatches(compID, []state.MatchResult{ { - ID: "P1-0", SideA: "RedTeam", SideB: "WhiteTeam", Status: state.MatchStatusRunning, + ID: "P1-0", SideA: "RedTeam", SideAID: redTeamID, SideB: "WhiteTeam", SideBID: whiteTeamID, Status: state.MatchStatusRunning, SubResults: []state.SubMatchResult{ {Position: 1, SideA: "", SideB: "", Decision: "hikiwake"}, }, @@ -3001,7 +3195,7 @@ func TestMaybeAdvanceKachinuki_FallbackRosterFirstAppearanceOrder(t *testing.T) })) // Lineup saved only for WHITE; RED falls back to the bout log. require.NoError(t, store.SetTeamLineup(compID, domain.TeamLineup{ - TeamID: "WhiteTeam", Round: 0, + TeamID: whiteTeamID, Round: 0, Positions: map[domain.Position]string{ domain.PositionNumbered(1): "W-1", domain.PositionNumbered(2): "W-2", @@ -3016,7 +3210,7 @@ func TestMaybeAdvanceKachinuki_FallbackRosterFirstAppearanceOrder(t *testing.T) // first-appearance-ordered remaining fallback queue. require.NoError(t, store.SavePoolMatches(compID, []state.MatchResult{ { - ID: "P1-0", SideA: "RedTeam", SideB: "WhiteTeam", + ID: "P1-0", SideA: "RedTeam", SideAID: redTeamID, SideB: "WhiteTeam", SideBID: whiteTeamID, Status: state.MatchStatusRunning, SubResults: []state.SubMatchResult{ {Position: 1, SideA: "R-B", SideB: "W-stub1", Winner: "R-B", Decision: "fought"}, @@ -3060,7 +3254,7 @@ func TestMaybeAdvanceKachinuki_PoolSimultaneousExhaustionNoOp(t *testing.T) { // Lineups: 3-person rosters. Bouts 1 and 2 produce hikiwake, // both sides retire their last player in bout 3 simultaneously. require.NoError(t, store.SetTeamLineup(compID, domain.TeamLineup{ - TeamID: "RedTeam", Round: 0, + TeamID: redTeamID, Round: 0, Positions: map[domain.Position]string{ domain.PositionNumbered(1): "R-1", domain.PositionNumbered(2): "R-2", @@ -3068,7 +3262,7 @@ func TestMaybeAdvanceKachinuki_PoolSimultaneousExhaustionNoOp(t *testing.T) { }, }, 3)) require.NoError(t, store.SetTeamLineup(compID, domain.TeamLineup{ - TeamID: "WhiteTeam", Round: 0, + TeamID: whiteTeamID, Round: 0, Positions: map[domain.Position]string{ domain.PositionNumbered(1): "W-1", domain.PositionNumbered(2): "W-2", @@ -3080,9 +3274,11 @@ func TestMaybeAdvanceKachinuki_PoolSimultaneousExhaustionNoOp(t *testing.T) { // verdict the engine logs and otherwise ignores. require.NoError(t, store.SavePoolMatches(compID, []state.MatchResult{ { - ID: "P1-0", - SideA: "RedTeam", - SideB: "WhiteTeam", + ID: "P1-0", + SideA: "RedTeam", + SideAID: redTeamID, + SideB: "WhiteTeam", + SideBID: whiteTeamID, SubResults: []state.SubMatchResult{ {Position: 1, SideA: "R-1", SideB: "W-1", Decision: state.DecisionDraw}, {Position: 2, SideA: "R-2", SideB: "W-2", Decision: state.DecisionDraw}, @@ -3758,13 +3954,7 @@ func TestRetiredPlayersFromBoutLog_PendingNamelessRowRetiresNobody(t *testing.T) // branch has always guarded this by passing a real set, which is why the fix // is to route this branch through the SAME shared filter. func TestKachinukiRemainingRoster_BoutLogBranchKeepsANamesakeWithADistinctID(t *testing.T) { - eng, store, comp := setupKachinukiComp(t, "kachinuki-namesake-boutlog", 3, func(c *state.Competition) { - c.Format = state.CompFormatMixed - }) - require.NoError(t, store.SaveParticipants(comp.ID, []domain.Player{ - {ID: helper.NewUUID4(), Name: "Ryu", Dojo: "DojoR"}, - {ID: helper.NewUUID4(), Name: "Tora", Dojo: "DojoT"}, - })) + comp := &state.Competition{ID: "kachinuki-namesake-boutlog", TeamSize: 3, TeamMatchType: state.TeamMatchTypeKachinuki} // Deliberately NO lineup saved: that is what selects the branch under test. parent := &state.MatchResult{ @@ -3780,7 +3970,7 @@ 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, _, _ := eng.kachinukiRemainingRoster(comp.ID, "P1-0", comp, parent, 0) + remainingA, _, _ := kachinukiRemainingRoster(comp, parent, &lineupRule{}) ids := make([]string, 0, len(remainingA)) for _, f := range remainingA { @@ -3791,6 +3981,27 @@ func TestKachinukiRemainingRoster_BoutLogBranchKeepsANamesakeWithADistinctID(t * "the namesake with a DIFFERENT id has not retired and must still be fielded") } +// The bout-log roster takes its column of the log from WHICH SIDE it is +// resolving, never from comparing team names: two sides that share a team name +// (data from before team names had to be unique) must each get their own +// column. Deciding by name read both sides off column A. +func TestKachinukiRemainingRoster_BoutLogBranchTakesEachSidesOwnColumn(t *testing.T) { + comp := &state.Competition{ID: "kachinuki-same-team-name", TeamSize: 3, TeamMatchType: state.TeamMatchTypeKachinuki} + parent := &state.MatchResult{ + ID: "P1-0", SideA: "Dup", SideAID: "dup-a", SideB: "Dup", SideBID: "dup-b", + SubResults: []state.SubMatchResult{ + {Position: 1, SideA: "A-1", SideB: "B-1", Winner: "A-1", Decision: "fought"}, + {Position: 2, SideA: "A-1", SideB: "B-2"}, // the pairing the advance appended, not yet fought + }, + } + + remainingA, remainingB, ok := kachinukiRemainingRoster(comp, parent, &lineupRule{}) + + assert.False(t, ok, "no lineup resolved it") + assert.Equal(t, fighters("A-1"), remainingA) + assert.Equal(t, fighters("B-2"), remainingB, "B-1 lost and is retired; B-2 is the next from B's own column") +} + // TestMaybeAdvanceKachinuki_AdvancesOffABoutFoughtEntirelyByNumber pins the // identity guard's widening from two names to names-OR-member-ids. // @@ -3817,7 +4028,7 @@ func TestMaybeAdvanceKachinuki_AdvancesOffABoutFoughtEntirelyByNumber(t *testing })) // Both teams field blank numbered slots: ids present, names absent. require.NoError(t, store.SetTeamLineup(compID, domain.TeamLineup{ - TeamID: "RedTeam", Round: 0, + TeamID: redTeamID, Round: 0, Positions: map[domain.Position]string{ domain.PositionNumbered(1): "", domain.PositionNumbered(2): "", @@ -3828,7 +4039,7 @@ func TestMaybeAdvanceKachinuki_AdvancesOffABoutFoughtEntirelyByNumber(t *testing }, }, 3)) require.NoError(t, store.SetTeamLineup(compID, domain.TeamLineup{ - TeamID: "WhiteTeam", Round: 0, + TeamID: whiteTeamID, Round: 0, Positions: map[domain.Position]string{ domain.PositionNumbered(1): "", domain.PositionNumbered(2): "", @@ -3843,9 +4054,11 @@ func TestMaybeAdvanceKachinuki_AdvancesOffABoutFoughtEntirelyByNumber(t *testing // side carries a NAME, which is precisely what the old guard refused. require.NoError(t, store.SavePoolMatches(compID, []state.MatchResult{ { - ID: "P1-0", - SideA: "RedTeam", - SideB: "WhiteTeam", + ID: "P1-0", + SideA: "RedTeam", + SideAID: redTeamID, + SideB: "WhiteTeam", + SideBID: whiteTeamID, SubResults: []state.SubMatchResult{ { Position: 1, diff --git a/internal/engine/lineup_in_force.go b/internal/engine/lineup_in_force.go new file mode 100644 index 000000000..4c91a5170 --- /dev/null +++ b/internal/engine/lineup_in_force.go @@ -0,0 +1,245 @@ +package engine + +// lineup_in_force.go owns the rule for which lineup a team fields in a 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"). +// The kachinuki advance, the Kachinuki Detail export and the public +// lineup-in-force read all ask it, so no surface restates it. +// +// The lineup in force for team T at match M is, in order: +// +// 1. the lineup saved for T at M itself: a match's own lineup always wins, +// whole, even one that holds no position (the state layer stores one for a +// match v2.1.1 showed a team nothing at, so the match shows none rather than +// what an earlier match did); +// 2. else the latest lineup of T placed before M in match order: one saved for +// an earlier match of T, or, at the very start, T's starting lineup (the +// Lineups page's lineup, stored as round 0); +// 3. else, for a match the draw does not hold, the starting lineup. +// +// A lineup saved for a later round (round >= 1, which releases up to v2.1.1 let +// the Lineups page save) is not read. The state layer gives a legacy team, one +// that has such a lineup or that v2.1.1 had a lineup entered for a match for, a +// lineup of its own for every match it is seated in, equal to what v2.1.1 showed +// there (state.settleRoundLineups), on load and then in the write that seats the +// team in a match, so a round lineup that still exists is kept until the +// competition is completed, for a team a correction may still seat, and cannot +// apply. +// +// Match order is state.MatchPlace: pool and league matches in pool-match number, +// which is playing order (a Swiss team's rounds in round order); knockout matches +// after them at their bracket round index, in position order; the 3rd-place match +// last, at len(Rounds). Which matches are team matches is state.TeamMatches's to +// say, here and in the round-lineup settlement alike: a pool daihyosen or +// tiebreaker is one individual bout, a bye (a hidden bracket match, or a Swiss +// round's odd team out against nobody) is played by nobody, and a league or Swiss +// competition's vestigial bracket is no match of the team's. None of them holds a +// lineup or is a previous match. +// +// A team is its participant id, and nothing else. Every caller asks with the id +// (the HTTP read's :tid, a match's SideAID/SideBID) and a lineup is the team's +// only when it is stored under exactly that id, the key the lineup editor +// writes: a lineup stored under a team NAME is not the team's, and a side that +// carries no id (a bye, an unresolved feeder) has none. A lineup saved for a +// match only counts as one of T's earlier lineups while that match is in the +// current draw and T is seated in it by id, so an entry left behind by a re-seat +// is ignored. + +import ( + "log" + "maps" + "slices" + + "github.com/gitrgoliveira/bracket-creator/internal/domain" + "github.com/gitrgoliveira/bracket-creator/internal/state" +) + +// LineupSource says where an InForceLineup was saved. +type LineupSource struct { + // MatchID is the match the lineup was saved for: the match asked about + // (its own lineup) or an earlier match of the team it is carried from. + // Empty for a Lineups-page lineup. + MatchID string + // Round is the Lineups-page round the lineup was saved for, meaningful + // only when MatchID is empty. It is 0, the team's starting lineup: a + // lineup for a later round is given to the matches it applied to, never + // read as one. + Round int +} + +// InForceLineup is the lineup a team fields at a match and where it came from. +type InForceLineup struct { + // Lineup is the stored entry, so its own MatchID and Round name the source + // as well. It is the caller's to change: it is never the cache's own. + Lineup domain.TeamLineup + Source LineupSource + // Found is false when the team has no saved lineup that applies. + Found bool +} + +// lineupDraw is the draw as the rule sees it: every team match by id, each with +// its place in match order. Which matches are team matches is state.TeamMatches's +// to say, here as in the round-lineup settlement. +type lineupDraw map[string]state.TeamMatch + +// newLineupDraw indexes the team matches of a draw by id. +func newLineupDraw(matches []state.TeamMatch) lineupDraw { + draw := make(lineupDraw, len(matches)) + for _, m := range matches { + draw[m.ID] = m + } + return draw +} + +// lineupRule answers which lineup a team fields at a match, from lineups and a +// draw loaded once, so an export asking for every match loads them once rather +// than once per match. The zero value, &lineupRule{}, is the rule over no +// lineups: every team has none in force, and so no position is labelled. +type lineupRule struct { + lineups map[string]domain.TeamLineup + draw lineupDraw +} + +// newLineupRule builds the rule over lineups and the team matches of the draw +// they were saved against. With no lineups there is nothing to place, so the +// draw is not indexed. +func newLineupRule(lineups map[string]domain.TeamLineup, matches []state.TeamMatch) *lineupRule { + if len(lineups) == 0 { + return &lineupRule{} + } + return &lineupRule{lineups: lineups, draw: newLineupDraw(matches)} +} + +// newLineupRuleFrom is newLineupRule for a caller that already holds the pool +// matches and the bracket, which the kachinuki advance and the export do, so +// the draw is projected from what they loaded rather than read again. knockout +// is whether the competition plays a knockout stage (Competition.IsKnockoutEnabled); +// a nil bracket leaves the knockout out of the draw as well. +func newLineupRuleFrom(lineups map[string]domain.TeamLineup, poolMatches []state.MatchResult, bracket *state.Bracket, knockout bool) *lineupRule { + if len(lineups) == 0 { + return &lineupRule{} + } + return newLineupRule(lineups, state.TeamMatches(state.DrawMatchesFrom(poolMatches, bracket), knockout)) +} + +// placedLineup is a saved lineup of the team being asked about, for an earlier +// match, with where that match sits in match order. +type placedLineup struct { + lineup domain.TeamLineup + place state.MatchPlace +} + +// inForce resolves the lineup the team with participant id teamID fields at +// matchID. An empty teamID, a side with no id, has no lineup. A match the draw +// does not hold has no place in match order, so only its own lineup (1) and the +// starting lineup (3) can apply to it. +func (r *lineupRule) inForce(teamID, matchID string) InForceLineup { + if teamID == "" { + return InForceLineup{} + } + at, located := r.draw[matchID] + + var own, start []domain.TeamLineup + var before []placedLineup + for _, l := range r.lineups { + if l.TeamID != teamID { + continue + } + switch l.MatchID { + case "": + // Only round 0, the starting lineup, is read. Any other round's + // lineup is waiting for its team to be seated (see the rule above). + if l.Round == 0 { + start = append(start, l) + } + case matchID: + own = append(own, l) + default: + earlier, drawn := r.draw[l.MatchID] + if located && drawn && earlier.Seats(teamID) && earlier.Place.Compare(at.Place) < 0 { + before = append(before, placedLineup{lineup: l, place: earlier.Place}) + } + } + } + + // Each pick is unambiguous: a team has one lineup per match and one + // starting lineup, and no two places are equal. + var best domain.TeamLineup + switch { + case len(own) > 0: + best = own[0] + case len(before) > 0: + best = slices.MaxFunc(before, func(a, b placedLineup) int { return a.place.Compare(b.place) }).lineup + case len(start) > 0: + best = start[0] + default: + return InForceLineup{} + } + source := LineupSource{Round: best.Round} + if best.MatchID != "" { + source = LineupSource{MatchID: best.MatchID} + } + return InForceLineup{Lineup: best, Source: source, Found: true} +} + +// lineupRuleOrNone builds the rule for a caller that holds the pool matches and +// the bracket and keeps working without lineups, the kachinuki advance and the +// Kachinuki Detail export. knockout is whether the competition plays a knockout +// stage. A lineups.yaml that cannot be read is logged, naming caller, and answers +// no lineup, so the roster falls back to the bout log and no position is +// labelled. +func (e *Engine) lineupRuleOrNone(caller, compID string, knockout bool, poolMatches []state.MatchResult, bracket *state.Bracket) *lineupRule { + lineups, err := e.store.LoadTeamLineups(compID) + if err != nil { + log.Printf("%s compId=%s: lineups.yaml load error: %v; resolving no lineup", caller, compID, err) + return &lineupRule{} + } + return 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. +// +// A lineups.yaml that cannot be read is returned, since an answer of "nothing +// saved" would be false. A pool-matches.csv, bracket.json or config.md that +// cannot be read is logged, naming the file, and the draw is built from the ones +// that loaded, so a damaged file costs only the carry across the matches it +// held, not every lineup read. A competition with no saved lineups skips the +// draw read. The rule reads the stored lineups where they are cached and copies +// only the one it returns, so a read costs the same however many lineups the +// competition holds. +func (e *Engine) LineupInForce(compID, teamID, matchID string) (InForceLineup, error) { + var in InForceLineup + err := e.store.ReadTeamLineups(compID, func(lineups map[string]domain.TeamLineup) { + if len(lineups) == 0 { + return + } + matches, err := e.store.TeamMatches(compID) + if err != nil { + log.Printf("engine.LineupInForce compId=%s: %v; the draw is built from the files that loaded", compID, err) + } + in = newLineupRule(lineups, matches).inForce(teamID, matchID) + in.Lineup = copyLineup(in.Lineup) + }) + return in, err +} + +// LineupInForceFrom answers the same question as Engine.LineupInForce, over what +// the caller already holds, for a caller that cannot ask the store: one inside a +// transaction, which holds the competition's lock the store's own reads wait on. +// It is the one rule (lineupRule.inForce) over the stored lineups, the draw's pool +// matches and bracket, and whether the competition plays a knockout stage +// (Competition.IsKnockoutEnabled), which is how the engine's read builds its draw +// as well. The lineup it returns is the caller's own entry in lineups, not a copy +// of it. +func LineupInForceFrom(lineups map[string]domain.TeamLineup, poolMatches []state.MatchResult, bracket *state.Bracket, knockout bool, teamID, matchID string) InForceLineup { + return newLineupRuleFrom(lineups, poolMatches, bracket, knockout).inForce(teamID, matchID) +} + +// copyLineup is l with its own copies of the position maps. +func copyLineup(l domain.TeamLineup) domain.TeamLineup { + l.Positions = maps.Clone(l.Positions) + l.MemberIDs = maps.Clone(l.MemberIDs) + return l +} diff --git a/internal/engine/lineup_in_force_test.go b/internal/engine/lineup_in_force_test.go new file mode 100644 index 000000000..7b24e1850 --- /dev/null +++ b/internal/engine/lineup_in_force_test.go @@ -0,0 +1,704 @@ +package engine + +import ( + "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" +) + +// The team asked about in these tests is "t" (participant id), named "TeamT". +const lineupTeam = "t" + +// lineupOf is a lineup of team whose Senpo is tag, so a test can tell which +// saved lineup came back. matchID empty makes it a Lineups-page lineup for round. +func lineupOf(team, matchID string, round int, tag string) domain.TeamLineup { + return domain.TeamLineup{ + TeamID: team, MatchID: matchID, Round: round, + Positions: map[domain.Position]string{domain.PosSenpo: tag}, + } +} + +func lineupsOf(ls ...domain.TeamLineup) map[string]domain.TeamLineup { + out := make(map[string]domain.TeamLineup, len(ls)) + for i, l := range ls { + out[string(rune('a'+i))] = l + } + return out +} + +func drawnPoolMatch(id, sideAID, sideBID string) state.MatchResult { + return state.MatchResult{ID: id, SideA: "name-" + sideAID, SideB: "name-" + sideBID, SideAID: sideAID, SideBID: sideBID} +} + +func drawnKnockoutMatch(id, sideAID, sideBID string) state.BracketMatch { + return state.BracketMatch{ID: id, SideA: "name-" + sideAID, SideB: "name-" + sideBID, SideAID: sideAID, SideBID: sideBID} +} + +// lineupDrawFixture is a team's whole competition: three pool matches (and the +// individual bouts that settled a tie in them), then a two-round knockout and a +// 3rd-place match. +// +// Pool A-0 t v o1 r0-m0 t v x r1-m0 t v o1 bronze t v o2 +// Pool A-1 t v o2 r0-m1 o1 v o2 +// Pool A-2 t v o3 +// Pool A-DH-0 t v o1 (a representative bout, not a team match) +// Pool A-TB-0 t v o2 (a tiebreaker, not a team match) +func lineupDrawFixture() ([]state.MatchResult, *state.Bracket) { + pool := []state.MatchResult{ + drawnPoolMatch("Pool A-0", lineupTeam, "o1"), + drawnPoolMatch("Pool A-1", lineupTeam, "o2"), + drawnPoolMatch("Pool A-2", lineupTeam, "o3"), + drawnPoolMatch("Pool A-DH-0", lineupTeam, "o1"), + drawnPoolMatch("Pool A-TB-0", lineupTeam, "o2"), + } + bracket := &state.Bracket{ + Rounds: [][]state.BracketMatch{ + {drawnKnockoutMatch("r0-m0", lineupTeam, "x"), drawnKnockoutMatch("r0-m1", "o1", "o2")}, + {drawnKnockoutMatch("r1-m0", lineupTeam, "o1")}, + }, + ThirdPlaceMatch: &state.BracketMatch{ID: "bronze", SideAID: lineupTeam, SideBID: "o2"}, + } + return pool, bracket +} + +func senpoOf(in InForceLineup) string { + return in.Lineup.Positions[domain.PosSenpo] +} + +// ruleOver is the rule over a draw loaded from pool and bracket, for a +// competition that plays a knockout stage: the bracket of every fixture here +// is one. A test about a competition that does not play one asks +// newLineupRuleFrom with false. +func ruleOver(lineups map[string]domain.TeamLineup, pool []state.MatchResult, bracket *state.Bracket) *lineupRule { + return newLineupRuleFrom(lineups, pool, bracket, true) +} + +// TestLineupInForce_Rule is the operator's rule (2026-10-05) as a table: a +// team keeps the lineup of its previous match unless one is entered for the +// match, and a Lineups-page lineup for round r begins at the start of round r. +func TestLineupInForce_Rule(t *testing.T) { + tests := []struct { + name string + lineups []domain.TeamLineup + match string + want string // the Senpo of the lineup in force; "" means none is + source LineupSource + }{ + { + name: "a match's own lineup wins over the one it would carry and the starting lineup", + lineups: []domain.TeamLineup{lineupOf(lineupTeam, "", 0, "start"), lineupOf(lineupTeam, "Pool A-0", 0, "m0"), lineupOf(lineupTeam, "Pool A-1", 0, "m1")}, + match: "Pool A-1", want: "m1", source: LineupSource{MatchID: "Pool A-1"}, + }, + { + name: "a match's own lineup wins even when the team is not seated in it", + lineups: []domain.TeamLineup{lineupOf(lineupTeam, "", 0, "start"), lineupOf(lineupTeam, "r0-m1", 0, "own")}, + match: "r0-m1", want: "own", source: LineupSource{MatchID: "r0-m1"}, + }, + { + name: "a match's place comes from the match, even where the team is not seated in it yet", + lineups: []domain.TeamLineup{lineupOf(lineupTeam, "", 0, "start"), lineupOf(lineupTeam, "Pool A-0", 0, "m0")}, + match: "r0-m1", want: "m0", source: LineupSource{MatchID: "Pool A-0"}, + }, + { + name: "the first match uses the starting lineup", + lineups: []domain.TeamLineup{lineupOf(lineupTeam, "", 0, "start"), lineupOf(lineupTeam, "Pool A-1", 0, "m1")}, + match: "Pool A-0", want: "start", source: LineupSource{Round: 0}, + }, + { + name: "a lineup entered for the first match is carried to the second", + lineups: []domain.TeamLineup{lineupOf(lineupTeam, "", 0, "start"), lineupOf(lineupTeam, "Pool A-0", 0, "m0")}, + match: "Pool A-1", want: "m0", source: LineupSource{MatchID: "Pool A-0"}, + }, + { + name: "and on to the third", + lineups: []domain.TeamLineup{lineupOf(lineupTeam, "", 0, "start"), lineupOf(lineupTeam, "Pool A-0", 0, "m0")}, + match: "Pool A-2", want: "m0", source: LineupSource{MatchID: "Pool A-0"}, + }, + { + name: "the latest earlier entry is the one carried", + lineups: []domain.TeamLineup{lineupOf(lineupTeam, "", 0, "start"), lineupOf(lineupTeam, "Pool A-0", 0, "m0"), lineupOf(lineupTeam, "Pool A-1", 0, "m1")}, + match: "Pool A-2", want: "m1", source: LineupSource{MatchID: "Pool A-1"}, + }, + { + name: "a lineup carries from the pool into the knockout", + lineups: []domain.TeamLineup{lineupOf(lineupTeam, "", 0, "start"), lineupOf(lineupTeam, "Pool A-2", 0, "m2")}, + match: "r0-m0", want: "m2", source: LineupSource{MatchID: "Pool A-2"}, + }, + { + name: "a later entry changes what later matches get, never an earlier match", + lineups: []domain.TeamLineup{lineupOf(lineupTeam, "", 0, "start"), lineupOf(lineupTeam, "Pool A-2", 0, "m2")}, + match: "Pool A-1", want: "start", source: LineupSource{Round: 0}, + }, + { + name: "an entry for a later match alone reaches back to nothing", + lineups: []domain.TeamLineup{lineupOf(lineupTeam, "Pool A-2", 0, "m2")}, + match: "Pool A-0", want: "", + }, + { + name: "a lineup for a later round is not read: its team's match was given it when the team was seated", + lineups: []domain.TeamLineup{lineupOf(lineupTeam, "", 0, "start"), lineupOf(lineupTeam, "", 1, "round1")}, + match: "r1-m0", want: "start", source: LineupSource{Round: 0}, + }, + { + name: "nor before it", + lineups: []domain.TeamLineup{lineupOf(lineupTeam, "", 0, "start"), lineupOf(lineupTeam, "", 1, "round1")}, + match: "r0-m0", want: "start", source: LineupSource{Round: 0}, + }, + { + name: "it does not displace the entry carried from an earlier match", + lineups: []domain.TeamLineup{lineupOf(lineupTeam, "", 0, "start"), lineupOf(lineupTeam, "Pool A-0", 0, "m0"), lineupOf(lineupTeam, "", 1, "round1")}, + match: "r1-m0", want: "m0", source: LineupSource{MatchID: "Pool A-0"}, + }, + { + name: "and the earlier match still carries its own entry", + lineups: []domain.TeamLineup{lineupOf(lineupTeam, "", 0, "start"), lineupOf(lineupTeam, "Pool A-0", 0, "m0"), lineupOf(lineupTeam, "", 1, "round1")}, + match: "r0-m0", want: "m0", source: LineupSource{MatchID: "Pool A-0"}, + }, + { + name: "an entry for a round 1 match is carried on, past a lineup for the round", + lineups: []domain.TeamLineup{lineupOf(lineupTeam, "", 1, "round1"), lineupOf(lineupTeam, "r1-m0", 0, "m-r1")}, + match: "bronze", want: "m-r1", source: LineupSource{MatchID: "r1-m0"}, + }, + { + name: "the 3rd-place match is last, and takes the starting lineup when nothing is carried to it", + lineups: []domain.TeamLineup{lineupOf(lineupTeam, "", 0, "start"), lineupOf(lineupTeam, "", 2, "round2")}, + match: "bronze", want: "start", source: LineupSource{Round: 0}, + }, + { + name: "a team with only lineups for later rounds has none in force: they wait for the team to be seated", + lineups: []domain.TeamLineup{lineupOf(lineupTeam, "", 2, "round2"), lineupOf(lineupTeam, "", 1, "round1")}, + match: "Pool A-0", want: "", + }, + { + name: "a lineup entered for an earlier match is carried whatever lineups wait for later rounds", + lineups: []domain.TeamLineup{lineupOf(lineupTeam, "", 1, "round1"), lineupOf(lineupTeam, "Pool A-0", 0, "m0")}, + match: "Pool A-1", want: "m0", source: LineupSource{MatchID: "Pool A-0"}, + }, + { + name: "a match that is not in the draw takes its own lineup", + lineups: []domain.TeamLineup{lineupOf(lineupTeam, "", 0, "start"), lineupOf(lineupTeam, "nope", 0, "own")}, + match: "nope", want: "own", source: LineupSource{MatchID: "nope"}, + }, + { + name: "and otherwise the starting lineup, having no place in match order", + lineups: []domain.TeamLineup{lineupOf(lineupTeam, "", 1, "round1"), lineupOf(lineupTeam, "", 0, "start"), lineupOf(lineupTeam, "Pool A-0", 0, "m0")}, + match: "nope", want: "start", source: LineupSource{Round: 0}, + }, + { + name: "an entry for a match that is not in the draw is ignored", + lineups: []domain.TeamLineup{lineupOf(lineupTeam, "", 0, "start"), lineupOf(lineupTeam, "Pool Z-0", 0, "stale")}, + match: "Pool A-1", want: "start", source: LineupSource{Round: 0}, + }, + { + name: "a pool daihyosen or tiebreaker is not a team match, so an entry for one is ignored", + lineups: []domain.TeamLineup{lineupOf(lineupTeam, "", 0, "start"), lineupOf(lineupTeam, "Pool A-DH-0", 0, "dh"), lineupOf(lineupTeam, "Pool A-TB-0", 0, "tb")}, + match: "r0-m0", want: "start", source: LineupSource{Round: 0}, + }, + { + name: "another team's lineup is never taken", + lineups: []domain.TeamLineup{lineupOf(lineupTeam, "", 0, "start"), lineupOf("o1", "Pool A-0", 0, "theirs"), lineupOf("o1", "", 0, "their-start")}, + match: "Pool A-1", want: "start", source: LineupSource{Round: 0}, + }, + { + name: "a team with no lineup at all has none in force", + lineups: []domain.TeamLineup{lineupOf("o1", "", 0, "theirs")}, + match: "Pool A-1", want: "", + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + pool, bracket := lineupDrawFixture() + rule := ruleOver(lineupsOf(tc.lineups...), pool, bracket) + + got := rule.inForce(lineupTeam, tc.match) + + if tc.want == "" { + assert.False(t, got.Found, "nothing is in force, got %q", senpoOf(got)) + return + } + require.True(t, got.Found) + assert.Equal(t, tc.want, senpoOf(got)) + assert.Equal(t, tc.source, got.Source) + }) + } +} + +// TestLineupInForce_SameRoundOrder covers a round in which a team plays more +// than once, as it always does in the pool phase: a per-match entry on an +// earlier match is carried to the team's next match of the round, and a lineup +// still waiting for a later round never stands in its way. +func TestLineupInForce_SameRoundOrder(t *testing.T) { + bracket := &state.Bracket{Rounds: [][]state.BracketMatch{ + {drawnKnockoutMatch("a", "o1", "o2")}, + {drawnKnockoutMatch("b", lineupTeam, "o1"), drawnKnockoutMatch("c", lineupTeam, "o2")}, + }} + rule := ruleOver(lineupsOf( + lineupOf(lineupTeam, "", 1, "round1"), + lineupOf(lineupTeam, "b", 0, "b"), + ), nil, bracket) + + assert.Equal(t, "b", senpoOf(rule.inForce(lineupTeam, "b")), "the entry saved for b") + assert.Equal(t, "b", senpoOf(rule.inForce(lineupTeam, "c")), + "c carries the entry on b, which comes before it in the round") +} + +// TestLineupInForce_MatchOrder pins the order a team's matches are played in. +func TestLineupInForce_MatchOrder(t *testing.T) { + t.Run("pool matches follow their number, not their stored order", func(t *testing.T) { + pool := []state.MatchResult{ + drawnPoolMatch("Pool A-2", lineupTeam, "o3"), + drawnPoolMatch("Pool A-0", lineupTeam, "o1"), + drawnPoolMatch("Pool A-1", lineupTeam, "o2"), + } + rule := ruleOver(lineupsOf(lineupOf(lineupTeam, "Pool A-0", 0, "m0")), pool, nil) + + assert.True(t, rule.inForce(lineupTeam, "Pool A-1").Found, "match 2 follows match 1") + assert.True(t, rule.inForce(lineupTeam, "Pool A-2").Found, "match 3 follows match 1, though stored first") + }) + + t.Run("a pool match numbered 10 follows number 2", func(t *testing.T) { + pool := []state.MatchResult{ + drawnPoolMatch("Pool A-10", lineupTeam, "o1"), + drawnPoolMatch("Pool A-2", lineupTeam, "o2"), + } + rule := ruleOver(lineupsOf(lineupOf(lineupTeam, "Pool A-2", 0, "m2")), pool, nil) + + assert.Equal(t, "m2", senpoOf(rule.inForce(lineupTeam, "Pool A-10")), "numbered numerically, not as text") + }) + + t.Run("Swiss rounds are played in round order", func(t *testing.T) { + pool := []state.MatchResult{ + drawnPoolMatch("Swiss-R2-0", lineupTeam, "o2"), + drawnPoolMatch("Swiss-R1-3", lineupTeam, "o1"), + } + rule := ruleOver(lineupsOf(lineupOf(lineupTeam, "Swiss-R1-3", 0, "round1")), pool, nil) + + assert.Equal(t, "round1", senpoOf(rule.inForce(lineupTeam, "Swiss-R2-0"))) + assert.Equal(t, "round1", senpoOf(rule.inForce(lineupTeam, "Swiss-R1-3")), "its own entry") + }) + + t.Run("the knockout follows the pool, and rounds follow one another", func(t *testing.T) { + pool, bracket := lineupDrawFixture() + rule := ruleOver(lineupsOf(lineupOf(lineupTeam, "r0-m0", 0, "k0")), pool, bracket) + + assert.False(t, rule.inForce(lineupTeam, "Pool A-2").Found, "the pool is before the knockout") + assert.Equal(t, "k0", senpoOf(rule.inForce(lineupTeam, "r1-m0"))) + assert.Equal(t, "k0", senpoOf(rule.inForce(lineupTeam, "bronze"))) + }) + + t.Run("a bracket the competition does not play holds no team match", func(t *testing.T) { + pool, bracket := lineupDrawFixture() + lineups := lineupsOf(lineupOf(lineupTeam, "", 0, "start"), lineupOf(lineupTeam, "Pool A-0", 0, "m0")) + + played := newLineupRuleFrom(lineups, pool, bracket, true).inForce(lineupTeam, "r0-m0") + vestigial := newLineupRuleFrom(lineups, pool, bracket, false).inForce(lineupTeam, "r0-m0") + + assert.Equal(t, "m0", senpoOf(played), "a knockout match comes after the pool, so the pool's lineup is carried to it") + assert.Equal(t, "start", senpoOf(vestigial), "a vestigial one has no place in the order: only its own lineup and the starting one apply") + }) + + t.Run("a Swiss bye is not a match the team plays", func(t *testing.T) { + // A Swiss round with an odd number of teams stores its odd team out as a + // completed match against nobody. + pool := []state.MatchResult{ + drawnPoolMatch("Swiss-R1-0", lineupTeam, "o1"), + {ID: "Swiss-R2-0", SideA: "name-" + lineupTeam, SideAID: lineupTeam, Status: state.MatchStatusCompleted}, + drawnPoolMatch("Swiss-R3-0", lineupTeam, "o2"), + } + rule := ruleOver(lineupsOf( + lineupOf(lineupTeam, "Swiss-R1-0", 0, "round1"), + lineupOf(lineupTeam, "Swiss-R2-0", 0, "bye"), + ), pool, nil) + + assert.Equal(t, "round1", senpoOf(rule.inForce(lineupTeam, "Swiss-R3-0")), "the bye is no earlier match of the team") + }) + + t.Run("a structural bye is not a match the team plays", func(t *testing.T) { + bracket := &state.Bracket{Rounds: [][]state.BracketMatch{ + {drawnKnockoutMatch("r0-m0", "o1", "o2")}, + {{ID: "r1-m0", SideA: "name-" + lineupTeam, SideAID: lineupTeam, Hidden: true}, drawnKnockoutMatch("r1-m1", "o3", "o4")}, + {drawnKnockoutMatch("r2-m0", lineupTeam, "o1")}, + }} + rule := ruleOver(lineupsOf( + lineupOf(lineupTeam, "", 0, "start"), + lineupOf(lineupTeam, "r1-m0", 0, "bye"), + ), nil, bracket) + + assert.Equal(t, "start", senpoOf(rule.inForce(lineupTeam, "r2-m0")), "the bye is no earlier match of the team") + }) +} + +// TestLineupInForce_TeamIdentity pins that a team is identified by participant +// id on a match, never by name. +func TestLineupInForce_TeamIdentity(t *testing.T) { + t.Run("a team renamed since its earlier match is still seated in it", func(t *testing.T) { + pool := []state.MatchResult{ + {ID: "Pool A-0", SideA: "Old Name", SideAID: lineupTeam, SideB: "Other", SideBID: "o1"}, + drawnPoolMatch("Pool A-1", lineupTeam, "o2"), + } + rule := ruleOver(lineupsOf(lineupOf(lineupTeam, "Pool A-0", 0, "m0")), pool, nil) + + assert.Equal(t, "m0", senpoOf(rule.inForce(lineupTeam, "Pool A-1"))) + }) + + t.Run("a match naming the team but seating another id is not the team's match", func(t *testing.T) { + pool := []state.MatchResult{ + {ID: "Pool A-0", SideA: "TeamT", SideAID: "imposter", SideB: "Other", SideBID: "o1"}, + drawnPoolMatch("Pool A-1", lineupTeam, "o2"), + } + rule := ruleOver(lineupsOf( + lineupOf(lineupTeam, "", 0, "start"), + lineupOf(lineupTeam, "Pool A-0", 0, "stale"), + ), pool, nil) + + assert.Equal(t, "start", senpoOf(rule.inForce(lineupTeam, "Pool A-1")), + "the entry on a match the team is not seated in by id is a stale one") + }) + + t.Run("a side with no id seats nobody", func(t *testing.T) { + pool := []state.MatchResult{ + {ID: "Pool A-0", SideA: "TeamT", SideB: "Other"}, + drawnPoolMatch("Pool A-1", lineupTeam, "o2"), + } + rule := ruleOver(lineupsOf( + lineupOf(lineupTeam, "", 0, "start"), + lineupOf(lineupTeam, "Pool A-0", 0, "stale"), + ), pool, nil) + + assert.Equal(t, "start", senpoOf(rule.inForce(lineupTeam, "Pool A-1"))) + }) + + t.Run("a lineup stored under the team's name is not the team's", func(t *testing.T) { + pool, bracket := lineupDrawFixture() + rule := ruleOver(lineupsOf( + lineupOf("TeamT", "", 0, "start-by-name"), + lineupOf("TeamT", "Pool A-0", 0, "m0-by-name"), + ), pool, bracket) + + for _, match := range []string{"Pool A-0", "Pool A-1", "r0-m0"} { + assert.False(t, rule.inForce(lineupTeam, match).Found, + "%s: the team is its participant id, and nothing is stored under it", match) + } + }) + + t.Run("an id-less side has no lineup, whatever is stored", func(t *testing.T) { + pool, bracket := lineupDrawFixture() + rule := ruleOver(lineupsOf(lineupOf("", "", 0, "under-no-key"), lineupOf(lineupTeam, "", 0, "start")), pool, bracket) + + assert.False(t, rule.inForce("", "Pool A-0").Found) + }) +} + +// TestLineupInForce_ThroughTheStore runs the rule over what a competition +// stores, loaded by Engine.LineupInForce. +func TestLineupInForce_ThroughTheStore(t *testing.T) { + eng, store, _ := setupTestEngine(t) + const compID = "lineup-in-force" + comp := &state.Competition{ID: compID, TeamSize: 5, Kind: "team"} + require.NoError(t, store.SaveCompetition(comp)) + + t.Run("nothing saved is not an error", func(t *testing.T) { + got, err := eng.LineupInForce(compID, lineupTeam, "Pool A-0") + require.NoError(t, err) + assert.False(t, got.Found) + }) + + t.Run("an unknown competition has nothing in force", func(t *testing.T) { + got, err := eng.LineupInForce("no-such-comp", lineupTeam, "Pool A-0") + require.NoError(t, err) + assert.False(t, got.Found) + }) + + t.Run("before the draw, a Lineups-page lineup is already in force", func(t *testing.T) { + const early = "lineup-before-draw" + require.NoError(t, store.SaveCompetition(&state.Competition{ID: early, TeamSize: 5, Kind: "team"})) + require.NoError(t, store.SetTeamLineup(early, lineupOf(lineupTeam, "", 0, "start"), 5)) + + got, err := eng.LineupInForce(early, lineupTeam, "Pool A-0") + require.NoError(t, err) + require.True(t, got.Found) + assert.Equal(t, "start", senpoOf(got)) + assert.Equal(t, LineupSource{Round: 0}, got.Source) + }) + + t.Run("a league's vestigial bracket is not a match the team plays", func(t *testing.T) { + const league = "lineup-league" + require.NoError(t, store.SaveCompetition(&state.Competition{ID: league, TeamSize: 5, Kind: "team", Format: state.CompFormatLeague})) + pool, bracket := lineupDrawFixture() + require.NoError(t, store.SavePoolMatches(league, pool)) + require.NoError(t, store.SaveBracket(league, bracket)) + require.NoError(t, store.SetTeamLineup(league, lineupOf(lineupTeam, "", 0, "start"), 5)) + require.NoError(t, store.SetTeamLineup(league, lineupOf(lineupTeam, "Pool A-0", 0, "m0"), 5)) + + got, err := eng.LineupInForce(league, lineupTeam, "r0-m0") + + require.NoError(t, err) + require.True(t, got.Found) + assert.Equal(t, "start", senpoOf(got), "the league plays no knockout, so the bracket match is no team match") + got, err = eng.LineupInForce(league, lineupTeam, "Pool A-2") + require.NoError(t, err) + assert.Equal(t, "m0", senpoOf(got), "a pool match carries as ever") + }) + + t.Run("pool matches, the bracket and the lineups are read from the store", func(t *testing.T) { + pool, bracket := lineupDrawFixture() + require.NoError(t, store.SavePoolMatches(compID, pool)) + require.NoError(t, store.SaveBracket(compID, bracket)) + require.NoError(t, store.SetTeamLineup(compID, lineupOf(lineupTeam, "", 0, "start"), 5)) + require.NoError(t, store.SetTeamLineup(compID, lineupOf(lineupTeam, "Pool A-0", 0, "m0"), 5)) + // A lineup for round 1 written straight to the store, as an older + // release left one: nothing reads it. + require.NoError(t, store.SetTeamLineup(compID, lineupOf(lineupTeam, "", 1, "round1"), 5)) + + cases := []struct { + match, want string + source LineupSource + }{ + {"Pool A-0", "m0", LineupSource{MatchID: "Pool A-0"}}, + {"Pool A-2", "m0", LineupSource{MatchID: "Pool A-0"}}, + {"r0-m0", "m0", LineupSource{MatchID: "Pool A-0"}}, + {"r1-m0", "m0", LineupSource{MatchID: "Pool A-0"}}, + {"bronze", "m0", LineupSource{MatchID: "Pool A-0"}}, + } + for _, c := range cases { + got, err := eng.LineupInForce(compID, lineupTeam, c.match) + require.NoError(t, err) + require.True(t, got.Found, c.match) + assert.Equal(t, c.want, senpoOf(got), c.match) + assert.Equal(t, c.source, got.Source, c.match) + } + }) +} + +// TestLineupInForce_ReadsByTheTeamIDAlone pins, through the store read, that a +// lineup is the team's only when it is stored under the team's participant id: +// the roster knows the team's name too, which is what a name lookup would +// translate through, and it must not. +func TestLineupInForce_ReadsByTheTeamIDAlone(t *testing.T) { + eng, store, _ := setupTestEngine(t) + const compID = "lineup-by-id-alone" + require.NoError(t, store.SaveCompetition(&state.Competition{ID: compID, TeamSize: 5, Kind: "team"})) + teamID := helper.NewUUID4() + require.NoError(t, store.SaveParticipants(compID, []domain.Player{{ID: teamID, Name: "TeamT", Dojo: "Dojo"}})) + require.NoError(t, store.SavePoolMatches(compID, []state.MatchResult{ + {ID: "Pool A-0", SideA: "TeamT", SideAID: teamID, SideB: "Other", SideBID: "other"}, + {ID: "Pool A-1", SideA: "TeamT", SideAID: teamID, SideB: "Third", SideBID: "third"}, + })) + require.NoError(t, store.SetTeamLineup(compID, lineupOf("TeamT", "", 0, "start-by-name"), 5)) + require.NoError(t, store.SetTeamLineup(compID, lineupOf("TeamT", "Pool A-0", 0, "m0-by-name"), 5)) + + for _, match := range []string{"Pool A-0", "Pool A-1"} { + got, err := eng.LineupInForce(compID, teamID, match) + require.NoError(t, err) + assert.False(t, got.Found, "%s: a lineup stored under the team's name is not the team's", match) + } + + require.NoError(t, store.SetTeamLineup(compID, lineupOf(teamID, "", 0, "start"), 5)) + got, err := eng.LineupInForce(compID, teamID, "Pool A-1") + require.NoError(t, err) + require.True(t, got.Found, "stored under its id, it is found") + assert.Equal(t, "start", senpoOf(got)) +} + +// bigLeagueWithLineups is an eight-team league of five-person teams in which +// every team holds a lineup of its own for each of its seven matches, as the +// round-lineup conversion leaves a competition recorded by an older release: 56 +// match lineups and 8 starting lineups, each with its member ids. It returns the +// engine and the first team's id and last match. +func bigLeagueWithLineups(t testing.TB) (eng *Engine, teamID, matchID string) { + t.Helper() + store, err := state.NewStore(t.TempDir()) + require.NoError(t, err) + eng = New(store) + const compID = "big-league" + require.NoError(t, store.SaveCompetition(&state.Competition{ + ID: compID, Kind: "team", TeamSize: 5, Format: state.CompFormatLeague, RoundLineupsConverted: true, + })) + players := make([]domain.Player, 8) + for i := range players { + players[i] = domain.Player{ID: helper.NewUUID4(), Name: fmt.Sprintf("Team%d", i), Dojo: fmt.Sprintf("Dojo%d", i)} + } + require.NoError(t, store.SaveParticipants(compID, players)) + var matches []state.MatchResult + for i := range players { + for j := i + 1; j < len(players); j++ { + matches = append(matches, state.MatchResult{ + ID: fmt.Sprintf("Pool A-%d", len(matches)), + SideA: players[i].Name, SideAID: players[i].ID, SideB: players[j].Name, SideBID: players[j].ID, + }) + } + } + require.NoError(t, store.SavePoolMatches(compID, matches)) + lineupFor := func(team domain.Player, matchID string) domain.TeamLineup { + l := domain.TeamLineup{ + TeamID: team.ID, MatchID: matchID, + Positions: map[domain.Position]string{}, MemberIDs: map[domain.Position]string{}, + } + for _, pos := range []domain.Position{domain.PosSenpo, domain.PosJiho, domain.PosChuken, domain.PosFukusho, domain.PosTaisho} { + l.Positions[pos] = team.Name + "-" + string(pos) + l.MemberIDs[pos] = helper.NewUUID4() + } + return l + } + byID := map[string]domain.Player{} + for _, p := range players { + byID[p.ID] = p + require.NoError(t, store.SetTeamLineup(compID, lineupFor(p, ""), 5)) + } + for _, m := range matches { + require.NoError(t, store.SetTeamLineup(compID, lineupFor(byID[m.SideAID], m.ID), 5)) + require.NoError(t, store.SetTeamLineup(compID, lineupFor(byID[m.SideBID], m.ID), 5)) + } + return eng, players[0].ID, matches[len(matches)-1].ID +} + +// A read answers with a copy of the one lineup it returns: changing it leaves the +// stored lineups, and the next read, alone. +func TestLineupInForce_ReturnsACopyOfTheStoredLineup(t *testing.T) { + eng, teamID, matchID := bigLeagueWithLineups(t) + first, err := eng.LineupInForce("big-league", teamID, matchID) + require.NoError(t, err) + require.True(t, first.Found) + was := first.Lineup.Positions[domain.PosSenpo] + require.NotEmpty(t, was) + + first.Lineup.Positions[domain.PosSenpo] = "changed by the caller" + first.Lineup.MemberIDs[domain.PosSenpo] = "changed by the caller" + again, err := eng.LineupInForce("big-league", teamID, matchID) + + require.NoError(t, err) + assert.Equal(t, was, again.Lineup.Positions[domain.PosSenpo]) + assert.NotEqual(t, "changed by the caller", again.Lineup.MemberIDs[domain.PosSenpo]) +} + +// LineupInForceFrom is the one rule asked over what a caller already holds, for a +// caller inside a transaction: it answers as the engine's read does from the store, +// for a draw with a knockout and for one without. +func TestLineupInForceFrom_IsTheEnginesRuleOverWhatTheCallerHolds(t *testing.T) { + pool, bracket := lineupDrawFixture() + lineups := lineupsOf(lineupOf(lineupTeam, "", 0, "start"), lineupOf(lineupTeam, "Pool A-0", 0, "m0")) + + t.Run("a draw with a knockout", func(t *testing.T) { + for _, match := range []string{"Pool A-0", "Pool A-1", "Pool A-2", "r0-m0", "bronze", "not-in-the-draw"} { + got := LineupInForceFrom(lineups, pool, bracket, true, lineupTeam, match) + + assert.Equal(t, ruleOver(lineups, pool, bracket).inForce(lineupTeam, match), got, match) + } + got := LineupInForceFrom(lineups, pool, bracket, true, lineupTeam, "Pool A-1") + require.True(t, got.Found) + assert.Equal(t, "m0", senpoOf(got), "carried from the previous match") + assert.Equal(t, LineupSource{MatchID: "Pool A-0"}, got.Source) + }) + + t.Run("a draw with no knockout leaves its vestigial bracket out", func(t *testing.T) { + got := LineupInForceFrom(lineups, pool, bracket, false, lineupTeam, "r0-m0") + + assert.Equal(t, "start", senpoOf(got), "the bracket match is no team match, so only the starting lineup applies") + }) + + t.Run("a team with no lineup has none in force", func(t *testing.T) { + assert.False(t, LineupInForceFrom(lineups, pool, bracket, true, "somebody-else", "Pool A-1").Found) + assert.False(t, LineupInForceFrom(nil, pool, bracket, true, lineupTeam, "Pool A-1").Found) + }) +} + +// The read copies only the lineup it returns, so what it costs does not grow with +// the lineups the competition holds: against 64 stored lineups it allocates a +// small fraction of what copying them all does (LoadTeamLineups). +func TestLineupInForce_DoesNotCopyEveryLineupOfTheCompetition(t *testing.T) { + eng, teamID, matchID := bigLeagueWithLineups(t) + _, err := eng.LineupInForce("big-league", teamID, matchID) // warm the caches and the one-time legacy upgrade + require.NoError(t, err) + + read := testing.AllocsPerRun(20, func() { _, _ = eng.LineupInForce("big-league", teamID, matchID) }) + copyAll := testing.AllocsPerRun(20, func() { _, _ = eng.store.LoadTeamLineups("big-league") }) + + t.Logf("allocations per read: LineupInForce %.0f, copying every lineup %.0f", read, copyAll) + assert.Less(t, read*3, copyAll, "a read allocates %.0f against %.0f for copying every stored lineup", read, copyAll) +} + +// BenchmarkLineupInForce is the cost of one read of the lineup in force, in a +// competition holding a lineup for every match of every team. +func BenchmarkLineupInForce(b *testing.B) { + eng, teamID, matchID := bigLeagueWithLineups(b) + b.ReportAllocs() + b.ResetTimer() + for i := 0; i < b.N; i++ { + if _, err := eng.LineupInForce("big-league", teamID, matchID); err != nil { + b.Fatal(err) + } + } +} + +// TestLineupInForce_DegradesOnAnUnreadableMatchFile pins that a damaged match +// file costs the read the part of the draw it held, never the read: the lineups +// are intact, so the answer is built from the file that loaded and the other is +// logged by name. A lineups.yaml that cannot be read is still an error, since +// "nothing saved" would be false. +func TestLineupInForce_DegradesOnAnUnreadableMatchFile(t *testing.T) { + garbage := func(t *testing.T, store *state.Store, compID, file, body string) { + t.Helper() + require.NoError(t, os.WriteFile(filepath.Join(store.GetFolder(), "competitions", compID, file), []byte(body), 0o600)) + } + team := func(t *testing.T, id string) (*Engine, *state.Store) { + t.Helper() + eng, store, _ := setupTestEngine(t) + require.NoError(t, store.SaveCompetition(&state.Competition{ID: id, TeamSize: 5, Kind: "team"})) + return eng, store + } + + t.Run("an unreadable bracket.json still answers from the pool matches", func(t *testing.T) { + const compID = "degrade-bracket" + eng, store := team(t, compID) + pool, _ := lineupDrawFixture() + require.NoError(t, store.SavePoolMatches(compID, pool)) + garbage(t, store, compID, "bracket.json", "{not json") + require.NoError(t, store.SetTeamLineup(compID, lineupOf(lineupTeam, "", 0, "start"), 5)) + require.NoError(t, store.SetTeamLineup(compID, lineupOf(lineupTeam, "Pool A-0", 0, "m0"), 5)) + + var got InForceLineup + var err error + out := captureLog(t, func() { got, err = eng.LineupInForce(compID, lineupTeam, "Pool A-2") }) + + require.NoError(t, err) + require.True(t, got.Found) + assert.Equal(t, "m0", senpoOf(got), "carried from Pool A-0, which the pool file still places") + assert.Contains(t, out, "bracket.json", "the file that could not be read is named") + }) + + t.Run("an unreadable pool-matches.csv still answers from the bracket", func(t *testing.T) { + const compID = "degrade-pool" + eng, store := team(t, compID) + _, bracket := lineupDrawFixture() + require.NoError(t, store.SaveBracket(compID, bracket)) + garbage(t, store, compID, "pool-matches.csv", "\"unterminated") + require.NoError(t, store.SetTeamLineup(compID, lineupOf(lineupTeam, "", 0, "start"), 5)) + require.NoError(t, store.SetTeamLineup(compID, lineupOf(lineupTeam, "r0-m0", 0, "k0"), 5)) + + var got InForceLineup + var err error + out := captureLog(t, func() { got, err = eng.LineupInForce(compID, lineupTeam, "r1-m0") }) + + require.NoError(t, err) + require.True(t, got.Found) + assert.Equal(t, "k0", senpoOf(got), "carried from r0-m0, which the bracket still places") + assert.Contains(t, out, "pool-matches.csv", "the file that could not be read is named") + }) + + t.Run("an unreadable lineups.yaml is an error", func(t *testing.T) { + const compID = "degrade-lineups" + eng, store := team(t, compID) + garbage(t, store, compID, "lineups.yaml", "lineups: [this is: not: valid yaml") + + _, err := eng.LineupInForce(compID, lineupTeam, "Pool A-0") + + require.Error(t, err) + }) +} diff --git a/internal/engine/load_error_propagation_test.go b/internal/engine/load_error_propagation_test.go index dc2a002e7..2ec4bc452 100644 --- a/internal/engine/load_error_propagation_test.go +++ b/internal/engine/load_error_propagation_test.go @@ -110,7 +110,7 @@ func TestMatchLookupsPropagateLoadErrors(t *testing.T) { }) }}, {"findTeamMatch", func() error { - _, _, _, err := eng.findTeamMatch(compID, matchID) + _, err := eng.findTeamMatch(compID, matchID) return err }}, {"ReopenMatch", func() error { @@ -143,7 +143,7 @@ func TestMatchLookupsOnAMissingFileStayNotFound(t *testing.T) { var notFound *NotFoundError require.ErrorAs(t, err, ¬Found) - parent, _, _, err := eng.findTeamMatch(compID, "m-r1-0") + found, err := eng.findTeamMatch(compID, "m-r1-0") require.NoError(t, err) - assert.Nil(t, parent) + assert.Nil(t, found) } diff --git a/internal/engine/match_label.go b/internal/engine/match_label.go index 612eddf74..385f3ef91 100644 --- a/internal/engine/match_label.go +++ b/internal/engine/match_label.go @@ -18,8 +18,6 @@ package engine import ( "fmt" "log" - "strconv" - "strings" "github.com/gitrgoliveira/bracket-creator/internal/state" ) @@ -141,19 +139,11 @@ func supplementaryOrNumberedLabel(heading, matchID string) string { // 1-based match number WITHIN its pool/round, taken from the id's own // trailing "-" ordinal (0-based on disk, so +1 here). Returns 0 for // a supplementary id (DH/TB) or an id with no numeric ordinal at all. +// +// Delegates to state.PoolPhaseMatchNumber, where the id grammar lives: match +// order (state.DrawMatch.Place) reads the same number. func poolPhaseMatchNumber(matchID string) int { - if IsPoolDaihyosenMatchID(matchID) || IsTiebreakerMatchID(matchID) { - return 0 - } - i := strings.LastIndexByte(matchID, '-') - if i < 0 { - return 0 - } - n, err := strconv.Atoi(matchID[i+1:]) - if err != nil { - return 0 - } - return n + 1 + return state.PoolPhaseMatchNumber(matchID) } // bracketReopenedMatchFor resolves matchID's Number/DisplayRound through diff --git a/internal/engine/pdf_export.go b/internal/engine/pdf_export.go index 0708805d3..0d3a7ce39 100644 --- a/internal/engine/pdf_export.go +++ b/internal/engine/pdf_export.go @@ -25,7 +25,7 @@ type SkippedCompetition struct { // mobile-app Export-PDFs endpoint. // // Each competition's display name becomes the title-page text; team -// competitions (TeamSize > 0 or Kind == "team") are flagged so the Tags group +// competitions (Competition.IsTeam) are flagged so the Tags group // can exclude them. compIDs, when non-empty, restricts export to those // competitions; otherwise all competitions are exported. // @@ -104,7 +104,7 @@ func (e *Engine) ExportTournamentWorkbooks(tmpDir string, compIDs ...string) ([] sources = append(sources, pdf.SourceWorkbook{ Path: xlsxPath, Title: title, - IsTeam: comp.TeamSize > 0 || comp.Kind == "team", + IsTeam: comp.IsTeam(), }) } return sources, skipped, nil diff --git a/internal/engine/round_lineups_match_only_test.go b/internal/engine/round_lineups_match_only_test.go new file mode 100644 index 000000000..cf3313545 --- /dev/null +++ b/internal/engine/round_lineups_match_only_test.go @@ -0,0 +1,202 @@ +package engine + +// round_lineups_match_only_test.go pins, through the engine's own reads, what a +// team that v2.1.1 had a lineup entered for a match for, and none for a round, +// fields once the data folder is loaded (operator decision 2026-10-07: "Show what +// v2.1.1 showed"). v2.1.1 showed such a team its own lineup at that match, its +// starting lineup anywhere else, or nothing where it had none. A team now carries +// the lineup of its previous match, so each match it is seated in is given, as its +// own, what v2.1.1 showed there; for a team with no lineup at all that is an EMPTY +// lineup of its own, which the rule honours as the match's own, so the match shows +// none rather than what an earlier one does. The teams found are recorded once, so +// a lineup saved by this release afterwards carries as it should. + +import ( + "slices" + "testing" + + "github.com/gitrgoliveira/bracket-creator/internal/domain" + "github.com/gitrgoliveira/bracket-creator/internal/state" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const matchOnlyComp = "match-only-legacy" + +// matchOnlyRecording is a four-team pool drawn and not yet played, whose data +// folder is as v2.1.1 left it: Alice has a lineup entered for Pool A-0 and none +// for a round; Bob has a starting lineup and a lineup entered for Pool A-1; +// Charlie and Dave have none. +type matchOnlyRecording struct { + dir string + ids map[string]string + pool []state.MatchResult + legacy []domain.TeamLineup +} + +func newMatchOnlyRecording(t *testing.T) matchOnlyRecording { + t.Helper() + _, seed, dir := setupTestEngine(t) + createTestCompetition(t, seed, matchOnlyComp, "league", 4, func(c *state.Competition) { + c.Kind, c.TeamSize, c.Status = "team", 3, state.CompStatusDrawReady + }) + names := []string{"Alice", "Bob", "Charlie", "Dave"} + saveTestParticipants(t, seed, matchOnlyComp, names) + roster, err := seed.LoadParticipants(matchOnlyComp, false) + require.NoError(t, err) + ids := map[string]string{} + for _, p := range roster { + ids[p.Name] = p.ID + } + bout := func(id string, round int, a, b string) state.MatchResult { + return state.MatchResult{ID: id, SideA: a, SideAID: ids[a], SideB: b, SideBID: ids[b], Round: round} + } + pool := []state.MatchResult{ + bout("Pool A-0", 1, "Alice", "Bob"), + bout("Pool A-1", 0, "Bob", "Charlie"), + bout("Pool A-2", 1, "Charlie", "Dave"), + bout("Pool A-3", 0, "Dave", "Alice"), + bout("Pool A-4", 2, "Alice", "Charlie"), + bout("Pool A-5", 2, "Bob", "Dave"), + } + require.NoError(t, seed.SavePoolMatches(matchOnlyComp, pool)) + lineup := func(team, matchID, tag string) domain.TeamLineup { + return domain.TeamLineup{ + TeamID: ids[team], CompetitionID: matchOnlyComp, MatchID: matchID, + Positions: map[domain.Position]string{domain.PositionNumbered(1): tag}, + } + } + legacy := []domain.TeamLineup{ + lineup("Alice", "Pool A-0", "Alice-entered"), + lineup("Bob", "", "Bob-start"), + lineup("Bob", "Pool A-1", "Bob-entered"), + } + writeLegacyLineups(t, seed, dir, matchOnlyComp, legacy) + return matchOnlyRecording{dir: dir, ids: ids, pool: pool, legacy: legacy} +} + +// open loads the data folder, which runs the load repair, and the engine over it. +func (r matchOnlyRecording) open(t *testing.T) (*state.Store, *Engine) { + t.Helper() + store, err := state.NewStore(r.dir) + require.NoError(t, err) + return store, New(store) +} + +func (r matchOnlyRecording) legacyTeams() []string { + return slices.Sorted(slices.Values([]string{r.ids["Alice"], r.ids["Bob"]})) +} + +func inForceOf(t *testing.T, eng *Engine, team, matchID string) InForceLineup { + t.Helper() + got, err := eng.LineupInForce(matchOnlyComp, team, matchID) + require.NoError(t, err) + return got +} + +// Every match shows what v2.1.1 showed, and a match v2.1.1 showed nothing at +// holds an empty lineup of its own: the answer is saved, names the match itself as +// its source, and holds no position, where carrying would have shown an earlier +// match's. +func TestRoundLineups_AMatchOnlyLegacyTeamFieldsWhatV211Showed(t *testing.T) { + rec := newMatchOnlyRecording(t) + store, eng := rec.open(t) + + for _, m := range rec.pool { + for _, side := range []string{m.SideAID, m.SideBID} { + got := inForceOf(t, eng, side, m.ID) + assert.Equal(t, v211Reading(rec.legacy, side, m.ID, m.Round), legacyLineupTag(got.Lineup), "%s at %s", side, m.ID) + } + } + for _, matchID := range []string{"Pool A-3", "Pool A-4"} { + got := inForceOf(t, eng, rec.ids["Alice"], matchID) + assert.True(t, got.Found, "v2.1.1 showed Alice nothing at %s, and the match holds a lineup of its own that says so", matchID) + assert.Equal(t, matchID, got.Source.MatchID, "it is the match's own lineup, not one carried from Pool A-0") + assert.Empty(t, got.Lineup.Positions) + } + assert.Equal(t, "Alice-entered", legacyLineupTag(inForceOf(t, eng, rec.ids["Alice"], "Pool A-0").Lineup), "the lineup entered is left as it is") + for _, matchID := range []string{"Pool A-0", "Pool A-5"} { + got := inForceOf(t, eng, rec.ids["Bob"], matchID) + assert.Equal(t, "Bob-start", legacyLineupTag(got.Lineup), "Bob's starting lineup is what v2.1.1 showed at %s", matchID) + assert.Equal(t, matchID, got.Source.MatchID) + } + for _, team := range []string{"Charlie", "Dave"} { + assert.False(t, inForceOf(t, eng, rec.ids[team], "Pool A-2").Found, "%s had no lineup at all", team) + } + + comp, err := store.LoadCompetition(matchOnlyComp) + require.NoError(t, err) + assert.Equal(t, rec.legacyTeams(), comp.RoundLineupsLegacy) + assert.False(t, comp.RoundLineupsConverted, "the two legacy teams wait: a correction can still seat one in a match") +} + +// A lineup saved by this release after the first pass is the operator's own, and +// carries to the team's later matches, through a write of the draw and a restart +// alike: the record of the legacy teams is what keeps it from being found as one. +func TestRoundLineups_ALineupSavedAfterTheFirstSettlementCarries(t *testing.T) { + rec := newMatchOnlyRecording(t) + store, eng := rec.open(t) + charlie := rec.ids["Charlie"] + require.False(t, inForceOf(t, eng, charlie, "Pool A-2").Found, "precondition: Charlie has no lineup") + + require.NoError(t, store.SetTeamLineup(matchOnlyComp, domain.TeamLineup{ + TeamID: charlie, CompetitionID: matchOnlyComp, MatchID: "Pool A-1", + Positions: map[domain.Position]string{domain.PositionNumbered(1): "Charlie-new"}, + }, 3)) + + carries := func(t *testing.T, eng *Engine) { + t.Helper() + for _, matchID := range []string{"Pool A-2", "Pool A-4"} { + got := inForceOf(t, eng, charlie, matchID) + assert.Equal(t, "Charlie-new", legacyLineupTag(got.Lineup), "Charlie at %s", matchID) + assert.Equal(t, "Pool A-1", got.Source.MatchID, "carried from the match it was saved for") + } + } + carries(t, eng) + + found, err := store.UpdatePoolMatchByID(matchOnlyComp, "Pool A-3", func(m *state.MatchResult) error { + m.Status = state.MatchStatusRunning + return nil + }) + require.NoError(t, err) + require.True(t, found) + carries(t, eng) + + restarted, restartedEng := rec.open(t) + carries(t, restartedEng) + comp, err := restarted.LoadCompetition(matchOnlyComp) + require.NoError(t, err) + assert.Equal(t, rec.legacyTeams(), comp.RoundLineupsLegacy, "the legacy teams are the ones the first pass found") +} + +// A discard takes the match lineups with the draw, and a team that was legacy only +// for them has nothing left to be shown: with none left the competition is +// marked, so the next draw is not pinned, and its matches carry from the +// starting lineup as they should. +func TestRoundLineups_ADiscardEndsTheConversionOfMatchOnlyLegacyTeams(t *testing.T) { + rec := newMatchOnlyRecording(t) + store, eng := rec.open(t) + comp, err := store.LoadCompetition(matchOnlyComp) + require.NoError(t, err) + require.Equal(t, rec.legacyTeams(), comp.RoundLineupsLegacy, "precondition") + + require.NoError(t, eng.DiscardDraw(matchOnlyComp)) + + comp, err = store.LoadCompetition(matchOnlyComp) + require.NoError(t, err) + assert.Empty(t, comp.RoundLineupsLegacy) + assert.Empty(t, comp.RoundLineupsGiven) + assert.True(t, comp.RoundLineupsConverted, "no legacy team remains") + lineups, err := store.LoadTeamLineups(matchOnlyComp) + require.NoError(t, err) + for _, l := range lineups { + assert.Empty(t, l.MatchID, "the lineups of the discarded draw's matches are gone") + } + + // The next draw reuses the match ids, and carries: nothing is pinned. + require.NoError(t, store.SavePoolMatches(matchOnlyComp, rec.pool)) + bob := inForceOf(t, eng, rec.ids["Bob"], "Pool A-0") + assert.Equal(t, "Bob-start", legacyLineupTag(bob.Lineup)) + assert.Empty(t, bob.Source.MatchID, "Bob's starting lineup is carried, not copied onto the match") + assert.False(t, inForceOf(t, eng, rec.ids["Alice"], "Pool A-3").Found, "Alice has no lineup") +} diff --git a/internal/engine/round_lineups_settlement_test.go b/internal/engine/round_lineups_settlement_test.go new file mode 100644 index 000000000..ec61278ea --- /dev/null +++ b/internal/engine/round_lineups_settlement_test.go @@ -0,0 +1,693 @@ +package engine + +import ( + "fmt" + "os" + "path/filepath" + "testing" + + "github.com/gitrgoliveira/bracket-creator/internal/domain" + "github.com/gitrgoliveira/bracket-creator/internal/state" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "gopkg.in/yaml.v3" +) + +// A competition recorded by v2.1.1 keeps each team's lineup for a later round +// until no match can seat a team, and the engine's own write that advances a +// winner gives the team its lineup for the match it is seated in: the lineup in +// force for the final is the one saved for round 2 (stored round 1), for the +// finalists, and the round lineups are removed once the final's sides are +// decided. +func TestRoundLineups_GivenWhenTheEngineAdvancesAWinner(t *testing.T) { + _, seed, dir := setupTestEngine(t) + const compID = "round-lineups-engine" + createTestCompetition(t, seed, compID, "knockout", 3, func(c *state.Competition) { + c.Kind, c.TeamSize = "team", 3 + }) + saveTestParticipants(t, seed, compID, []string{"Alice", "Bob", "Charlie", "Dave"}) + roster, err := seed.LoadParticipants(compID, false) + require.NoError(t, err) + ids := map[string]string{} + for _, p := range roster { + ids[p.Name] = p.ID + } + // The data folder as v2.1.1 left it: no marker, and a starting lineup and a + // lineup for the second round (stored round 1) for every team, as its + // Lineups page wrote them. + stored, err := seed.LoadCompetition(compID) + require.NoError(t, err) + stored.RoundLineupsConverted = false + require.NoError(t, seed.SaveCompetition(stored)) + var legacy []domain.TeamLineup + for _, name := range []string{"Alice", "Bob", "Charlie", "Dave"} { + for round, tag := range []string{"start", "second"} { + legacy = append(legacy, domain.TeamLineup{ + TeamID: ids[name], CompetitionID: compID, Round: round, + Positions: map[domain.Position]string{domain.PositionNumbered(1): name + "-" + tag}, + }) + } + } + raw, err := yaml.Marshal(struct { + Lineups []domain.TeamLineup `yaml:"lineups"` + }{Lineups: legacy}) + require.NoError(t, err) + require.NoError(t, os.WriteFile(filepath.Join(dir, "competitions", compID, "lineups.yaml"), raw, 0o600)) + + store, err := state.NewStore(dir) // the upgrade: the load repair runs + require.NoError(t, err) + eng := New(store) + require.NoError(t, eng.StartCompetition(compID)) + + bracket, err := store.LoadBracket(compID) + require.NoError(t, err) + semi0, semi1, final := bracket.Rounds[0][0], bracket.Rounds[0][1], bracket.Rounds[1][0] + inForce := func(team, matchID string) string { + got, err := eng.LineupInForce(compID, ids[team], matchID) + require.NoError(t, err) + require.True(t, got.Found, "%s at %s", team, matchID) + return got.Lineup.Positions[domain.PositionNumbered(1)] + } + marker := func() bool { + comp, err := store.LoadCompetition(compID) + require.NoError(t, err) + return comp.RoundLineupsConverted + } + require.False(t, marker(), "precondition: the lineups for the second round wait for their teams to reach it") + winnerA, loserA := semi0.SideA, semi0.SideB + winnerB, loserB := semi1.SideA, semi1.SideB + + _, err = eng.OverrideBracketWinner(compID, semi0.ID, winnerA, 0) + require.NoError(t, err) + assert.Equal(t, winnerA+"-second", inForce(winnerA, final.ID), "the winner of the first semifinal has its second round lineup in the final") + assert.Equal(t, winnerA+"-start", inForce(winnerA, semi0.ID), "and the starting lineup before it") + assert.False(t, marker(), "the other semifinal is still to be played") + + _, err = eng.OverrideBracketWinner(compID, semi1.ID, winnerB, 0) + require.NoError(t, err) + assert.Equal(t, winnerB+"-second", inForce(winnerB, final.ID)) + assert.False(t, marker(), "the final's sides are decided, but the competition is not completed: a correction can still seat another team") + roundLineups := func(st *state.Store) (held int) { + lineups, err := st.LoadTeamLineups(compID) + require.NoError(t, err) + for _, l := range lineups { + if l.MatchID == "" && l.Round >= 1 { + held++ + } + } + return held + } + assert.Equal(t, 4, roundLineups(store), "every team still holds its lineup for the second round") + for _, loser := range []string{loserA, loserB} { + assert.Equal(t, loser+"-start", inForce(loser, final.ID), "a team that did not reach the final keeps its starting lineup") + } + + // The second semifinal's result was recorded the wrong way round, and is + // corrected before the final is played: the other team is seated in the final, + // with the lineup v2.1.1 showed there. + _, err = eng.OverrideBracketWinner(compID, semi1.ID, loserB, 0) + require.NoError(t, err) + assert.Equal(t, loserB+"-second", inForce(loserB, final.ID), "the team the correction seats has its second round lineup in the final") + assert.Equal(t, loserB+"-start", inForce(loserB, semi1.ID)) + + // The competition is completed in a write the draw's hooks do not see; the + // next load retires the round lineups, and what v2.1.1 showed stays. + _, err = store.UpdateCompetitionChanged(compID, func(c *state.Competition) (*state.Competition, error) { + c.Status = state.CompStatusComplete + return c, nil + }) + require.NoError(t, err) + restarted, err := state.NewStore(dir) + require.NoError(t, err) + eng = New(restarted) + comp, err := restarted.LoadCompetition(compID) + require.NoError(t, err) + assert.True(t, comp.RoundLineupsConverted) + assert.Zero(t, roundLineups(restarted), "no lineup for a later round is left") + assert.Equal(t, winnerA+"-second", inForce(winnerA, final.ID)) + assert.Equal(t, loserB+"-second", inForce(loserB, final.ID)) +} + +// legacyLineupTag is what a lineup saved by v2.1.1's Lineups page is told apart +// by: the name in its first position. +func legacyLineupTag(l domain.TeamLineup) string { return l.Positions[domain.PositionNumbered(1)] } + +// v211Reading is the lineup v2.1.1 showed a team at a match read at matchRound, +// from the lineups its Lineups page had saved: the team's own lineup for the +// match; else the round lineup with the highest round at or below the match's; +// else the highest round there was. A lineup entered for another match was never +// carried. This is v2.1.1's state.FindBestLineup, restated so the code under +// test is not its own oracle. +func v211Reading(lineups []domain.TeamLineup, team, matchID string, matchRound int) string { + for _, l := range lineups { + if l.TeamID == team && l.MatchID == matchID { + return legacyLineupTag(l) + } + } + bestRound, highestRound := -1, -1 + var best, highest string + for _, l := range lineups { + if l.TeamID != team || l.MatchID != "" { + continue + } + if l.Round <= matchRound && l.Round > bestRound { + bestRound, best = l.Round, legacyLineupTag(l) + } + if l.Round > highestRound { + highestRound, highest = l.Round, legacyLineupTag(l) + } + } + if bestRound >= 0 { + return best + } + return highest +} + +// writeLegacyLineups leaves compID's data folder as v2.1.1 did: lineups.yaml as +// its Lineups page wrote it, and no marker (it recorded none). Opening the folder +// again with state.NewStore then runs the load repair. +func writeLegacyLineups(t *testing.T, store *state.Store, dir, compID string, lineups []domain.TeamLineup) { + t.Helper() + stored, err := store.LoadCompetition(compID) + require.NoError(t, err) + stored.RoundLineupsConverted = false + require.NoError(t, store.SaveCompetition(stored)) + raw, err := yaml.Marshal(struct { + Lineups []domain.TeamLineup `yaml:"lineups"` + }{Lineups: lineups}) + require.NoError(t, err) + require.NoError(t, os.WriteFile(filepath.Join(dir, "competitions", compID, "lineups.yaml"), raw, 0o600)) +} + +// TestRoundLineups_EveryMatchOfARealDrawShowsWhatV211Showed draws a team +// competition, gives every team a lineup for every round its pool plays (as +// v2.1.1's Lineups page could), runs the load repair, and compares the lineup in +// force at every match each team is seated in with what v2.1.1 showed there. +// The pools of a real draw number their bouts in playing order, and a team's +// stored rounds in that order do not rise, so a lineup given to "the team's first +// match at that round or later" would show another round's lineup at the matches +// between. +func TestRoundLineups_EveryMatchOfARealDrawShowsWhatV211Showed(t *testing.T) { + for _, tc := range []struct { + format string + teams int + poolSize int + courts []string + }{ + {state.CompFormatLeague, 5, 5, []string{"A"}}, + {state.CompFormatLeague, 6, 6, []string{"A", "B"}}, + {state.CompFormatMixed, 8, 4, []string{"A", "B"}}, + } { + t.Run(fmt.Sprintf("%s of %d", tc.format, tc.teams), func(t *testing.T) { + _, seed, dir := setupTestEngine(t) + const compID = "round-lineups-real-draw" + createTestCompetition(t, seed, compID, tc.format, tc.poolSize, func(c *state.Competition) { + c.Kind, c.TeamSize, c.Courts = "team", 3, tc.courts + }) + names := make([]string, tc.teams) + for i := range names { + names[i] = fmt.Sprintf("Team%02d", i) + } + saveTestParticipants(t, seed, compID, names) + require.NoError(t, New(seed).StartCompetition(compID)) + roster, err := seed.LoadParticipants(compID, false) + require.NoError(t, err) + ids := map[string]string{} + for _, p := range roster { + ids[p.Name] = p.ID + } + pool, err := seed.LoadPoolMatches(compID) + require.NoError(t, err) + lastRound := 0 + for _, m := range pool { + lastRound = max(lastRound, m.Round) + } + require.Positive(t, lastRound, "precondition: the pool has rounds to read lineups by") + + var legacy []domain.TeamLineup + for _, name := range names { + for round := 0; round <= lastRound; round++ { + legacy = append(legacy, domain.TeamLineup{ + TeamID: ids[name], CompetitionID: compID, Round: round, + Positions: map[domain.Position]string{domain.PositionNumbered(1): fmt.Sprintf("%s-round%d", name, round)}, + }) + } + } + writeLegacyLineups(t, seed, dir, compID, legacy) + + store, err := state.NewStore(dir) // the upgrade: the load repair runs + require.NoError(t, err) + everyMatchShowsWhatV211Showed := func(st *state.Store) { + t.Helper() + eng := New(st) + for _, m := range pool { + for _, side := range []string{m.SideAID, m.SideBID} { + want := v211Reading(legacy, side, m.ID, max(m.Round, 0)) + got, err := eng.LineupInForce(compID, side, m.ID) + require.NoError(t, err) + assert.Equal(t, want, legacyLineupTag(got.Lineup), "the lineup in force at %s (round %d) for %s", m.ID, m.Round, side) + } + } + } + roundLineupsHeld := func(st *state.Store) (held int, marked bool) { + t.Helper() + stored, err := st.LoadTeamLineups(compID) + require.NoError(t, err) + for _, l := range stored { + if l.MatchID == "" && l.Round >= 1 { + held++ + } + } + comp, err := st.LoadCompetition(compID) + require.NoError(t, err) + return held, comp.RoundLineupsConverted + } + + everyMatchShowsWhatV211Showed(store) + held, marked := roundLineupsHeld(store) + assert.Equal(t, tc.teams*lastRound, held, "every round lineup stays until the competition is completed") + assert.False(t, marked) + + // The competition is completed in a write the draw's hooks do not see: + // the next load retires the round lineups, and every match still shows + // what v2.1.1 showed. + _, err = store.UpdateCompetitionChanged(compID, func(c *state.Competition) (*state.Competition, error) { + c.Status = state.CompStatusComplete + return c, nil + }) + require.NoError(t, err) + restarted, err := state.NewStore(dir) + require.NoError(t, err) + everyMatchShowsWhatV211Showed(restarted) + held, marked = roundLineupsHeld(restarted) + assert.Zero(t, held, "every match holds its lineup, so the round lineups are gone") + assert.True(t, marked) + }) + } +} + +// TestRoundLineups_ARetriedDrawIsGivenItsOwnLineups: a draw that fails after it +// wrote its matches (here the fill-bracket half-capacity refusal, which +// generatePoolPreviewBracket raises once generatePools has saved the pools) leaves +// the competition in Setup, with those matches on disk. The write that saved them +// gave every legacy team a lineup at each match it was seated in and recorded the +// pairs. The operator seeds the field, as the refusal advises, and retries: the new +// draw reuses the match ids with other pairings, so what the failed attempt +// settled belongs to a draw that no longer exists. The retried draw is settled +// from nothing, and each team shows what v2.1.1 showed at the matches of the draw +// that stands. +func TestRoundLineups_ARetriedDrawIsGivenItsOwnLineups(t *testing.T) { + _, seed, dir := setupTestEngine(t) + const compID = "round-lineups-retried-draw" + createTestCompetition(t, seed, compID, state.CompFormatMixed, 3, func(c *state.Competition) { + c.Kind, c.TeamSize = "team", 3 + c.PoolWinners = 1 + c.ExtraQualifiers = state.ExtraQualifiersFillBracket + c.Courts = []string{"A", "B", "C", "D"} + }) + names := make([]string, 18) + players := make([]domain.Player, len(names)) + for i := range names { + names[i] = fmt.Sprintf("Team%02d", i) + players[i] = domain.Player{Name: names[i], Dojo: fmt.Sprintf("Dojo%02d", i)} + } + require.NoError(t, seed.SaveParticipants(compID, players)) + roster, err := seed.LoadParticipants(compID, false) + require.NoError(t, err) + ids := map[string]string{} + for _, p := range roster { + ids[p.Name] = p.ID + } + // Every team has a lineup for each round its pool could play, as v2.1.1's + // Lineups page could save them, so the lineup shown at a match depends on the + // round the match was read at. + var legacy []domain.TeamLineup + for _, name := range names { + for round := 0; round <= 3; round++ { + legacy = append(legacy, domain.TeamLineup{ + TeamID: ids[name], CompetitionID: compID, Round: round, + Positions: map[domain.Position]string{domain.PositionNumbered(1): fmt.Sprintf("%s-round%d", name, round)}, + }) + } + } + writeLegacyLineups(t, seed, dir, compID, legacy) + store, err := state.NewStore(dir) // the upgrade: the load repair runs + require.NoError(t, err) + eng := New(store) + + err = eng.StartCompetition(compID) + var refusal *ValidationError + require.ErrorAs(t, err, &refusal, "precondition: the unseeded draw is refused after its pools were saved") + failed, err := store.LoadPoolMatches(compID) + require.NoError(t, err) + require.NotEmpty(t, failed, "precondition: the failed attempt left its matches on disk") + comp, err := store.LoadCompetition(compID) + require.NoError(t, err) + require.Equal(t, state.CompStatusSetup, comp.Status) + require.NotEmpty(t, comp.RoundLineupsGiven, "precondition: the failed attempt's matches were settled, and the pairs recorded") + // What the failed attempt gave a team at a match is marked, so a team seated at + // the same match id by the retried draw shows whether it was given its lineup + // again or kept the failed attempt's. + failedLineups, err := store.LoadTeamLineups(compID) + require.NoError(t, err) + for _, l := range failedLineups { + if l.MatchID != "" { + l.Positions = map[domain.Position]string{domain.PositionNumbered(1): "from-the-failed-draw"} + require.NoError(t, store.SetTeamLineup(compID, l, 3)) + } + } + + var seeds []domain.SeedAssignment + for i := 0; i < 4; i++ { + seeds = append(seeds, domain.SeedAssignment{Name: names[i], SeedRank: i + 1}) + } + require.NoError(t, store.SaveSeeds(compID, seeds)) + require.NoError(t, eng.StartCompetition(compID), "the seeded draw succeeds") + + pool, err := store.LoadPoolMatches(compID) + require.NoError(t, err) + seated := map[string]bool{} + for _, m := range pool { + seated[m.SideAID+"|"+m.ID] = true + seated[m.SideBID+"|"+m.ID] = true + } + stored, err := store.LoadTeamLineups(compID) + require.NoError(t, err) + for _, l := range stored { + if l.MatchID != "" { + assert.Truef(t, seated[l.TeamID+"|"+l.MatchID], "team %s holds a lineup for %s, where the draw that stands does not seat it", l.TeamID, l.MatchID) + } + } + comp, err = store.LoadCompetition(compID) + require.NoError(t, err) + for team, matchIDs := range comp.RoundLineupsGiven { + for _, id := range matchIDs { + assert.Truef(t, seated[team+"|"+id], "the record lists team %s at %s, where the draw that stands does not seat it", team, id) + } + } + for _, m := range pool { + for _, side := range []string{m.SideAID, m.SideBID} { + got, err := eng.LineupInForce(compID, side, m.ID) + require.NoError(t, err) + assert.Equal(t, v211Reading(legacy, side, m.ID, max(m.Round, 0)), legacyLineupTag(got.Lineup), + "the lineup in force at %s (round %d) for %s", m.ID, m.Round, side) + } + } +} + +// TestRoundLineups_EveryFormatsDrawStartsFromNoLineupsOfAnEarlierDraw: whichever +// generator the pipeline runs (pools, a knockout, a Swiss round), a draw from +// Setup first clears the match lineups and the record of settled pairs that an +// attempt that failed after its writes left behind, and keeps the round lineups. +func TestRoundLineups_EveryFormatsDrawStartsFromNoLineupsOfAnEarlierDraw(t *testing.T) { + for _, format := range []string{state.CompFormatMixed, state.CompFormatLeague, state.CompFormatKnockout, state.CompFormatSwiss} { + t.Run(format, func(t *testing.T) { + _, seed, dir := setupTestEngine(t) + const compID = "round-lineups-earlier-draw" + createTestCompetition(t, seed, compID, format, 3, func(c *state.Competition) { + c.Kind, c.TeamSize = "team", 3 + }) + names := []string{"Tora", "Usagi", "Kuma", "Saru", "Inu", "Neko", "Tori", "Uma"} + saveTestParticipants(t, seed, compID, names) + roster, err := seed.LoadParticipants(compID, false) + require.NoError(t, err) + ids := map[string]string{} + for _, p := range roster { + ids[p.Name] = p.ID + } + lineup := func(team, matchID string, round int) domain.TeamLineup { + return domain.TeamLineup{ + TeamID: ids[team], CompetitionID: compID, Round: round, MatchID: matchID, + Positions: map[domain.Position]string{domain.PositionNumbered(1): team}, + } + } + // Every team has a lineup for the second round (stored 1), so the + // competition has legacy teams and the record is kept; one of them + // also has a lineup for a match of an earlier draw, and that draw's + // pair is recorded. + var legacy []domain.TeamLineup + for _, name := range names { + legacy = append(legacy, lineup(name, "", 1)) + } + legacy = append(legacy, lineup("Tora", "earlier-draw-match", 0)) + writeLegacyLineups(t, seed, dir, compID, legacy) + _, err = seed.UpdateCompetitionChanged(compID, func(c *state.Competition) (*state.Competition, error) { + c.RoundLineupsGiven = map[string][]string{ids["Tora"]: {"earlier-draw-match"}} + return c, nil + }) + require.NoError(t, err) + store, err := state.NewStore(dir) + require.NoError(t, err) + + require.NoError(t, New(store).StartCompetition(compID)) + + lineups, err := store.LoadTeamLineups(compID) + require.NoError(t, err) + for _, l := range lineups { + assert.NotEqual(t, "earlier-draw-match", l.MatchID, "the earlier draw's lineup is gone") + } + comp, err := store.LoadCompetition(compID) + require.NoError(t, err) + assert.NotContains(t, comp.RoundLineupsGiven[ids["Tora"]], "earlier-draw-match", "and so is its pair in the record") + held := 0 + for _, l := range lineups { + if l.MatchID == "" && l.Round >= 1 { + held++ + } + } + assert.Equal(t, len(names), held, "the round lineups stay") + assert.False(t, comp.RoundLineupsConverted, "the legacy teams still wait for the competition to be completed") + }) + } +} + +// TestRoundLineups_ASemiFinalOnlyLineupDoesNotHideTheRoundLineupAtTheFinal: a +// team has a lineup for the second round (stored 1) and one entered for its +// semi-final alone. v2.1.1 showed the semi-final lineup at the semi-final only, +// and the round lineup at the semi-final's round and after it, so again at the +// final: it must not be given up because the semi-final has a lineup of its own. +func TestRoundLineups_ASemiFinalOnlyLineupDoesNotHideTheRoundLineupAtTheFinal(t *testing.T) { + _, seed, dir := setupTestEngine(t) + const compID = "round-lineups-semi-final" + createTestCompetition(t, seed, compID, "knockout", 3, func(c *state.Competition) { + c.Kind, c.TeamSize = "team", 3 + }) + saveTestParticipants(t, seed, compID, []string{"Tora", "Usagi", "Kuma", "Saru", "Inu", "Neko", "Tori", "Uma"}) + eng := New(seed) + require.NoError(t, eng.StartCompetition(compID)) + bracket, err := seed.LoadBracket(compID) + require.NoError(t, err) + require.Len(t, bracket.Rounds, 3) + for _, m := range bracket.Rounds[0] { + _, err := eng.OverrideBracketWinner(compID, m.ID, m.SideA, 0) + require.NoError(t, err) + } + bracket, err = seed.LoadBracket(compID) + require.NoError(t, err) + semi, otherSemi := bracket.Rounds[1][0], bracket.Rounds[1][1] + team := semi.SideAID + require.NotEmpty(t, team) + _, err = eng.OverrideBracketWinner(compID, semi.ID, semi.SideA, 0) + require.NoError(t, err) + bracket, err = seed.LoadBracket(compID) + require.NoError(t, err) + final := bracket.Rounds[2][0] + require.True(t, final.SideAID == team || final.SideBID == team, "precondition: the team is seated in the final") + var quarterFinal string + for _, m := range bracket.Rounds[0] { + if m.SideAID == team || m.SideBID == team { + quarterFinal = m.ID + } + } + require.NotEmpty(t, quarterFinal) + + legacy := []domain.TeamLineup{ + {TeamID: team, CompetitionID: compID, Round: 0, Positions: map[domain.Position]string{domain.PositionNumbered(1): "start"}}, + {TeamID: team, CompetitionID: compID, Round: 1, Positions: map[domain.Position]string{domain.PositionNumbered(1): "round2-onwards"}}, + {TeamID: team, CompetitionID: compID, MatchID: semi.ID, Positions: map[domain.Position]string{domain.PositionNumbered(1): "semi-final-only"}}, + } + writeLegacyLineups(t, seed, dir, compID, legacy) + store, err := state.NewStore(dir) + require.NoError(t, err) + eng = New(store) + inForce := func(matchID string) string { + got, err := eng.LineupInForce(compID, team, matchID) + require.NoError(t, err) + require.True(t, got.Found, matchID) + return legacyLineupTag(got.Lineup) + } + roundLineupKept := func() bool { + lineups, err := store.LoadTeamLineups(compID) + require.NoError(t, err) + for _, l := range lineups { + if l.TeamID == team && l.MatchID == "" && l.Round == 1 { + return true + } + } + return false + } + + assert.Equal(t, "start", inForce(quarterFinal)) + assert.Equal(t, "semi-final-only", inForce(semi.ID)) + assert.Equal(t, "round2-onwards", inForce(final.ID), "v2.1.1 showed the round lineup again at the final") + assert.True(t, roundLineupKept(), "the competition is on, so the round lineup is kept") + + _, err = eng.OverrideBracketWinner(compID, otherSemi.ID, otherSemi.SideA, 0) + require.NoError(t, err) + + assert.Equal(t, "round2-onwards", inForce(final.ID)) + assert.Equal(t, "semi-final-only", inForce(semi.ID)) + assert.True(t, roundLineupKept(), "the final has both its sides, but the competition is not completed") + comp, err := store.LoadCompetition(compID) + require.NoError(t, err) + assert.False(t, comp.RoundLineupsConverted) + + // Completed in a write the draw's hooks do not see: the next load retires it, + // and the final still shows the round lineup. + _, err = store.UpdateCompetitionChanged(compID, func(c *state.Competition) (*state.Competition, error) { + c.Status = state.CompStatusComplete + return c, nil + }) + require.NoError(t, err) + store, err = state.NewStore(dir) + require.NoError(t, err) + eng = New(store) + assert.Equal(t, "round2-onwards", inForce(final.ID)) + assert.Equal(t, "semi-final-only", inForce(semi.ID)) + assert.False(t, roundLineupKept(), "the round lineup has been given to every match it was shown at") + comp, err = store.LoadCompetition(compID) + require.NoError(t, err) + assert.True(t, comp.RoundLineupsConverted) +} + +// TestRoundLineups_AnEditToAnEarlierMatchDoesNotChangeALaterOne: after the +// upgrade every match a team is seated in holds a lineup of its own, so a lineup +// the operator then enters for an earlier match changes that match and nothing +// after it. (A match without a lineup of its own would carry the edit.) +func TestRoundLineups_AnEditToAnEarlierMatchDoesNotChangeALaterOne(t *testing.T) { + _, seed, dir := setupTestEngine(t) + const compID = "round-lineups-edit" + createTestCompetition(t, seed, compID, "league", 4, func(c *state.Competition) { + c.Kind, c.TeamSize = "team", 3 + }) + names := []string{"Alice", "Bob", "Charlie", "Dave"} + saveTestParticipants(t, seed, compID, names) + roster, err := seed.LoadParticipants(compID, false) + require.NoError(t, err) + ids := map[string]string{} + for _, p := range roster { + ids[p.Name] = p.ID + } + // A pool of four numbered as a real draw numbers it: Alice plays rounds 1, 0, 2. + bout := func(id string, round int, a, b string) state.MatchResult { + return state.MatchResult{ID: id, SideA: a, SideAID: ids[a], SideB: b, SideBID: ids[b], Round: round} + } + pool := []state.MatchResult{ + bout("Pool A-0", 1, "Alice", "Bob"), + bout("Pool A-1", 0, "Bob", "Charlie"), + bout("Pool A-2", 1, "Charlie", "Dave"), + bout("Pool A-3", 0, "Dave", "Alice"), + bout("Pool A-4", 2, "Alice", "Charlie"), + bout("Pool A-5", 2, "Bob", "Dave"), + } + require.NoError(t, seed.SavePoolMatches(compID, pool)) + var legacy []domain.TeamLineup + for _, name := range names { + for round := 0; round <= 2; round++ { + legacy = append(legacy, domain.TeamLineup{ + TeamID: ids[name], CompetitionID: compID, Round: round, + Positions: map[domain.Position]string{domain.PositionNumbered(1): fmt.Sprintf("%s-round%d", name, round)}, + }) + } + } + writeLegacyLineups(t, seed, dir, compID, legacy) + store, err := state.NewStore(dir) + require.NoError(t, err) + eng := New(store) + inForce := func(matchID string) string { + got, err := eng.LineupInForce(compID, ids["Alice"], matchID) + require.NoError(t, err) + require.True(t, got.Found, matchID) + return legacyLineupTag(got.Lineup) + } + assert.Equal(t, "Alice-round0", inForce("Pool A-3"), "v2.1.1 showed the starting lineup at Alice's second bout") + + require.NoError(t, store.SetTeamLineup(compID, domain.TeamLineup{ + TeamID: ids["Alice"], MatchID: "Pool A-0", + Positions: map[domain.Position]string{domain.PositionNumbered(1): "edited"}, + }, 3)) + + assert.Equal(t, "edited", inForce("Pool A-0"), "the match that was edited") + assert.Equal(t, "Alice-round0", inForce("Pool A-3"), "a later match keeps what v2.1.1 showed there") + assert.Equal(t, "Alice-round2", inForce("Pool A-4")) +} + +// TestRoundLineups_AfterDiscardDrawTheNextDrawIsGivenItsReadingsAgain: discarding +// a draw takes the lineups saved for its matches with it, and the pairs the +// conversion had settled: the next draw reuses the match ids, and its matches +// must be given their lineups again, not left as the operator's removals. +func TestRoundLineups_AfterDiscardDrawTheNextDrawIsGivenItsReadingsAgain(t *testing.T) { + _, seed, dir := setupTestEngine(t) + const compID = "round-lineups-discard" + createTestCompetition(t, seed, compID, "league", 4, func(c *state.Competition) { + c.Kind, c.TeamSize, c.Status = "team", 3, state.CompStatusDrawReady + }) + names := []string{"Alice", "Bob", "Charlie", "Dave"} + saveTestParticipants(t, seed, compID, names) + roster, err := seed.LoadParticipants(compID, false) + require.NoError(t, err) + ids := map[string]string{} + for _, p := range roster { + ids[p.Name] = p.ID + } + bout := func(id string, round int, a, b string) state.MatchResult { + return state.MatchResult{ID: id, SideA: a, SideAID: ids[a], SideB: b, SideBID: ids[b], Round: round} + } + pool := []state.MatchResult{ + bout("Pool A-0", 1, "Alice", "Bob"), + bout("Pool A-1", 0, "Bob", "Charlie"), + bout("Pool A-2", 1, "Charlie", "Dave"), + bout("Pool A-3", 0, "Dave", "Alice"), + bout("Pool A-4", 2, "Alice", "Charlie"), + bout("Pool A-5", 2, "Bob", "Dave"), + } + require.NoError(t, seed.SavePoolMatches(compID, pool)) + var legacy []domain.TeamLineup + for _, name := range names { + for round := 0; round <= 2; round++ { + legacy = append(legacy, domain.TeamLineup{ + TeamID: ids[name], CompetitionID: compID, Round: round, + Positions: map[domain.Position]string{domain.PositionNumbered(1): fmt.Sprintf("%s-round%d", name, round)}, + }) + } + } + writeLegacyLineups(t, seed, dir, compID, legacy) + store, err := state.NewStore(dir) + require.NoError(t, err) + eng := New(store) + everyMatchShowsWhatV211Showed := func() { + t.Helper() + for _, m := range pool { + for _, side := range []string{m.SideAID, m.SideBID} { + got, err := eng.LineupInForce(compID, side, m.ID) + require.NoError(t, err) + assert.Equal(t, v211Reading(legacy, side, m.ID, m.Round), legacyLineupTag(got.Lineup), "%s at %s", side, m.ID) + } + } + } + everyMatchShowsWhatV211Showed() + comp, err := store.LoadCompetition(compID) + require.NoError(t, err) + require.NotEmpty(t, comp.RoundLineupsGiven, "precondition: the pairs of the draw are recorded as settled") + + require.NoError(t, eng.DiscardDraw(compID)) + + comp, err = store.LoadCompetition(compID) + require.NoError(t, err) + assert.Empty(t, comp.RoundLineupsGiven, "the pairs of the discarded draw are not kept") + assert.False(t, comp.RoundLineupsConverted) + + // The next draw reuses the match ids (here with the same pairings). + require.NoError(t, store.SavePoolMatches(compID, pool)) + everyMatchShowsWhatV211Showed() +} diff --git a/internal/engine/swiss.go b/internal/engine/swiss.go index d03c0c204..6deadcec8 100644 --- a/internal/engine/swiss.go +++ b/internal/engine/swiss.go @@ -6,8 +6,6 @@ import ( "fmt" "math/rand" "sort" - "strconv" - "strings" "github.com/gitrgoliveira/bracket-creator/internal/domain" "github.com/gitrgoliveira/bracket-creator/internal/helper" @@ -24,7 +22,10 @@ import ( // existing scoring endpoint, eligibility gate, and SSE broadcast all // key off MatchResult.ID. Adding a new "Swiss match" file would // duplicate that infrastructure for no semantic gain. -const swissMatchIDPrefix = "Swiss-R" +// +// The prefix lives in state (state.SwissMatchIDPrefix) with the rest of the id +// grammar, because match order (state.DrawMatch.Place) reads it too. +const swissMatchIDPrefix = state.SwissMatchIDPrefix // swissPoolName returns the synthetic "pool name" prefix used for a // Swiss round so that helper.parsePoolMatchesFile / scoring.go can @@ -46,19 +47,7 @@ func swissMatchID(round, idx int) string { // match ID or malformed shape. Used by SwissStandings to scope its // match scan to Swiss matches only. func parseSwissMatchRound(id string) (int, bool) { - if !strings.HasPrefix(id, swissMatchIDPrefix) { - return 0, false - } - rest := strings.TrimPrefix(id, swissMatchIDPrefix) - dash := strings.Index(rest, "-") - if dash < 0 { - return 0, false - } - n, err := strconv.Atoi(rest[:dash]) - if err != nil || n < 1 { - return 0, false - } - return n, true + return state.ParseSwissMatchRound(id) } // buildSwissRosterIndex builds the identity-lookup table over roster (the diff --git a/internal/mobileapp/deps.go b/internal/mobileapp/deps.go index cec960596..d9ba4cab4 100644 --- a/internal/mobileapp/deps.go +++ b/internal/mobileapp/deps.go @@ -219,6 +219,14 @@ type TeamLineupStore interface { DeleteTeamLineupForMatch(compID, teamID, matchID string) error } +// 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. +type LineupEngine interface { + LineupInForce(compID, teamID, matchID string) (engine.InForceLineup, error) +} + // SquadStore is the consumer-boundary view of state.Store used by // handlers_squad.go (bc-tmid pass 1, clearing added bc-pnum): a team's // squad, the people on it, each with a stable id and a display index @@ -229,9 +237,11 @@ type SquadStore interface { LoadSquads(compID string) (map[string][]domain.TeamMember, error) AddTeamMember(compID, teamID, name string) (domain.TeamMember, error) AddTeamMemberUpTo(compID, teamID, name string, limit int) (domain.TeamMember, error) - RenameTeamMember(compID, teamID, memberID, newName string) error - NameUnnamedTeamMember(compID, teamID, memberID, newName string) error - ClearTeamMemberName(compID, teamID, memberID string) error + // The three name writers answer with the member as written, carrying the + // server stamp that write gave it (domain.TeamMember.ModifiedAt). + RenameTeamMember(compID, teamID, memberID, newName string) (domain.TeamMember, error) + NameUnnamedTeamMember(compID, teamID, memberID, newName string) (domain.TeamMember, error) + ClearTeamMemberName(compID, teamID, memberID string) (domain.TeamMember, error) } // Broadcaster is the consumer-boundary view of *Hub used by handlers diff --git a/internal/mobileapp/handlers_competition.go b/internal/mobileapp/handlers_competition.go index 6de7e5b9d..d7c88c310 100644 --- a/internal/mobileapp/handlers_competition.go +++ b/internal/mobileapp/handlers_competition.go @@ -825,7 +825,7 @@ func RegisterCompetitionHandlers(r *gin.RouterGroup, store *state.Store, eng *en // admin UI hides the Engi toggle unless kind=individual, but reject the // contradictory combination here so a hand-crafted POST can't create a // team comp with engi=true and route its matches to the wrong scorer. - if comp.Engi && (comp.Kind == "team" || comp.TeamSize > 0) { + if comp.Engi && comp.IsTeam() { c.JSON(http.StatusBadRequest, gin.H{"error": "engi is only valid for individual competitions, not team"}) return } @@ -1042,6 +1042,9 @@ func RegisterCompetitionHandlers(r *gin.RouterGroup, store *state.Store, eng *en // repair to clear, since every write strips it, so it starts marked // rather than having its config.md rewritten on the next start. comp.KachinukiEncounterEnchoCleared = comp.IsKachinuki() + // Likewise it has no lineup for a round to give to its matches, and no + // writer left that creates one. + comp.RoundLineupsConverted = true var validationErr, idErr error lockErr := store.WithCompetitionRenameLock(func() error { if existing, _ := store.LoadCompetition(comp.ID); existing != nil { @@ -2058,7 +2061,7 @@ func RegisterCompetitionHandlers(r *gin.RouterGroup, store *state.Store, eng *en // competitions (engi is never a team). Kind/TeamSize were already // applied above, so reject rather than persist a contradictory // team+engi state that would route matches to the wrong scorer. - if comp.Engi && (comp.Kind == "team" || comp.TeamSize > 0) { + if comp.Engi && comp.IsTeam() { validationErr = fmt.Errorf("engi is only valid for individual competitions, not team") return nil, nil } diff --git a/internal/mobileapp/handlers_lineup.go b/internal/mobileapp/handlers_lineup.go index c3f31de08..4f024fcd2 100644 --- a/internal/mobileapp/handlers_lineup.go +++ b/internal/mobileapp/handlers_lineup.go @@ -4,7 +4,12 @@ // // GET returns a teamLineupRead for a (team, round) tuple (200, saved false // when nothing is stored; 404 only for an unknown competition). PUT -// sets/replaces it, DELETE removes it. +// sets/replaces it, for round 0 only (the team's starting lineup: a later round +// is a 400, lineupRoundRefused), DELETE removes it. A PUT, of either scope, may +// 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). // // All store I/O goes through the TeamLineupStore + CompetitionStore // interfaces (deps.go) rather than the concrete *state.Store @@ -25,9 +30,17 @@ import ( "github.com/gin-gonic/gin" "github.com/gitrgoliveira/bracket-creator/internal/domain" + "github.com/gitrgoliveira/bracket-creator/internal/engine" "github.com/gitrgoliveira/bracket-creator/internal/state" ) +// lineupRoundRefused is the 400 a PUT of a lineup for round 1 or later answers. +// Releases up to v2.1.1 saved one from the Lineups page; the state layer gives a +// team that has one a lineup for each match it is seated in +// (state.settleRoundLineups), and a team now carries the lineup of its previous +// match instead. +const lineupRoundRefused = "A lineup is saved as the team's starting lineup or for a match." + // lineupSetStatus maps a SetTeamLineup error to the right HTTP status. Domain // lineup validation failures (a position key not valid for the team size, or a // non-positive team size) all carry the "team_lineup:" prefix and are client @@ -68,16 +81,69 @@ func lineupSetStatus(err error) int { // Positions keys already went through (it walks MemberIDs as its own loop // beside Positions), so an invalid position key here is rejected exactly // like an invalid Positions key, with no separate check needed here. +// +// Changed (operator decision 2026-10-07, "Only changed positions") names the +// positions the sender changed, so two devices changing different positions of +// one lineup both keep their change. Absent, the body is the whole lineup and +// replaces the stored one, which is how a save queued by an older build replays. +// Present, it must list at least one position: the server then reads only those +// keys, whatever else the body holds (the client may send its whole form), and +// lands them on the lineup it reads under the write's lock (lineupSave.base). A +// pointer, because an empty list is a refusal and an absent one is not. type LineupRequest struct { Positions map[domain.Position]string `json:"positions"` MemberIDs map[domain.Position]string `json:"memberIds,omitempty"` + Changed *[]domain.Position `json:"changed,omitempty"` +} + +// changedPositions are the positions the save names as changed, and whether it +// names any list at all: absent means the body is the whole lineup. +func (r LineupRequest) changedPositions() (changed []domain.Position, partial bool) { + if r.Changed == nil { + return nil, false + } + return *r.Changed, true +} + +// memberIDsRead is the member ids a save is judged on, the positions it reads: +// those of the changed positions when it names them, every one when it is the +// whole lineup. +func (r LineupRequest) memberIDsRead() map[domain.Position]string { + changed, partial := r.changedPositions() + if !partial { + return r.MemberIDs + } + read := make(map[domain.Position]string, len(changed)) + for _, p := range changed { + if id := r.MemberIDs[p]; id != "" { + read[p] = id + } + } + return read } -// validLineupNames answers 400, and reports false, when a position's name is -// longer than a competitor's (MaxLenPlayerName). Both lineup PUTs ask it, as -// both store the names they are sent. -func validLineupNames(c *gin.Context, req LineupRequest) bool { - for pos, name := range req.Positions { +// validLineupRequest answers 400, and reports false, for a save no write could +// make sense of before it reads any lineup: a list of changed positions that is +// empty, or a name longer than a competitor's (MaxLenPlayerName) in any position +// the save stores, which is every position of a whole lineup and only the changed +// ones of a partial save (the rest of its body is ignored). Both lineup PUTs ask +// it, as both store the names they are sent. +func validLineupRequest(c *gin.Context, req LineupRequest) bool { + changed, partial := req.changedPositions() + if partial && len(changed) == 0 { + c.JSON(http.StatusBadRequest, gin.H{"error": domain.ErrLineupNoChangedPositions.Error()}) + return false + } + stored := req.Positions + if partial { + stored = make(map[domain.Position]string, len(changed)) + for _, p := range changed { + if name, ok := req.Positions[p]; ok { + stored[p] = name + } + } + } + for pos, name := range stored { if err := validateMaxLen("positions."+string(pos), name, MaxLenPlayerName); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return false @@ -87,19 +153,21 @@ func validLineupNames(c *gin.Context, req LineupRequest) bool { } // RegisterPublicLineupHandlers wires the read-only GET -// /competitions/:id/teams/:tid/lineups/:round and -// /competitions/:id/teams/:tid/match-lineups/:matchId endpoints on an +// /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 // 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 // admin credentials for the initial read. PUT and DELETE remain on the // admin group via RegisterLineupHandlers. // -// Both GETs answer with a teamLineupRead (see its doc); a 404 means the -// competition does not exist. +// 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. // // Slice 7.B / T127. -func RegisterPublicLineupHandlers(r *gin.RouterGroup, store TeamLineupStore, comps CompetitionStore) { +func RegisterPublicLineupHandlers(r *gin.RouterGroup, store TeamLineupStore, comps CompetitionStore, eng LineupEngine) { r.GET("/competitions/:id/teams/:tid/lineups/:round", func(c *gin.Context) { compID, teamID, round, ok := parseLineupParams(c) if !ok { @@ -114,29 +182,15 @@ func RegisterPublicLineupHandlers(r *gin.RouterGroup, store TeamLineupStore, com return } lineup, found := findRoundLineup(lineups, teamID, round) - if !found && c.Query("fallback") == "best" { - // Best-effort mode, the client-side twin of AMENDMENT 1: the - // scoring modal asks for the match's own round index, but - // operators typically save one round-0 lineup for the whole - // day, so a knockout final (round 1+) would find nothing saved - // and leave the modal without names. Resolve via the - // FindBestLineup round tiers (highest round <= requested, else - // highest overall; match-scoped entries are skipped by passing - // an empty matchID). Default behavior without the param stays - // exact: nothing saved for THIS round is reported as such - // (saved: false), never silently swapped for another round. - lineup, found = state.FindBestLineup(lineups, teamID, "", round) - } if !found { lineup = domain.TeamLineup{TeamID: teamID, CompetitionID: compID, Round: round, Positions: map[domain.Position]string{}} } c.JSON(http.StatusOK, teamLineupRead{TeamLineup: lineup, Saved: found}) }) - // Match-scoped read (mp-825). The server never falls back to the - // round-scoped lineup on this route: that tier belongs to the CLIENT - // (resolveMatchLineup), which reads `saved: false` here and asks the - // round-scoped route above itself. + // Match-scoped read (mp-825): exactly the lineup saved for this match, + // never a lineup carried from elsewhere. Whatever a team carries into a + // match is read from the lineup-in-force route below. r.GET("/competitions/:id/teams/:tid/match-lineups/:matchId", func(c *gin.Context) { compID, teamID, matchID, ok := parseMatchLineupParams(c) if !ok { @@ -156,6 +210,61 @@ func RegisterPublicLineupHandlers(r *gin.RouterGroup, store TeamLineupStore, com } c.JSON(http.StatusOK, teamLineupRead{TeamLineup: lineup, Saved: found}) }) + + // The lineup in force (operator ruling 2026-10-05): the match's own lineup, + // else the one the team carries from its previous match or round. The + // engine owns the rule; this is the one door every surface reads it + // through. Same contract as the two reads above: a competition that exists + // always answers 200, with `saved: false` when no lineup applies. + r.GET("/competitions/:id/teams/:tid/lineup-in-force/:matchId", func(c *gin.Context) { + compID, teamID, matchID, ok := parseMatchLineupParams(c) + if !ok { + return + } + if !requireExistingCompetition(c, comps, compID) { + return + } + in, err := eng.LineupInForce(compID, teamID, matchID) + if err != nil { + internalError(c, err) + return + } + c.JSON(http.StatusOK, newLineupInForceRead(compID, teamID, matchID, in)) + }) +} + +// lineupInForceRead is the response body of the lineup-in-force GET. It embeds +// the stored lineup that is in force, so its own `matchId`/`round` are the +// source's, and adds the same `saved` marker teamLineupRead carries plus the +// source spelled out: `sourceMatchId` when the lineup was saved for a match +// (the one asked about, or an earlier match of the team it is carried from), +// `sourceRound` when it is a Lineups-page lineup (round 0 is the starting +// lineup; a pointer, because 0 is a value to send). Neither is present when +// nothing is saved. +type lineupInForceRead struct { + domain.TeamLineup + Saved bool `json:"saved"` + SourceMatchID string `json:"sourceMatchId,omitempty"` + SourceRound *int `json:"sourceRound,omitempty"` +} + +// newLineupInForceRead shapes the engine's answer. A team with nothing in +// force gets an empty lineup that echoes what was asked, as the other reads do. +func newLineupInForceRead(compID, teamID, matchID string, in engine.InForceLineup) lineupInForceRead { + if !in.Found { + return lineupInForceRead{TeamLineup: domain.TeamLineup{ + TeamID: teamID, CompetitionID: compID, MatchID: matchID, Positions: map[domain.Position]string{}, + }} + } + out := lineupInForceRead{TeamLineup: in.Lineup, Saved: true, SourceMatchID: in.Source.MatchID} + if out.Positions == nil { + out.Positions = map[domain.Position]string{} + } + if in.Source.MatchID == "" { + round := in.Source.Round + out.SourceRound = &round + } + return out } // teamLineupRead is the response body for both public lineup GETs (bc-k404, @@ -233,23 +342,25 @@ func RegisterLineupHandlers(r *gin.RouterGroup, store TeamLineupStore, comps Com if !ok { return } + // A lineup is the team's starting lineup (round 0) or one for a match: a + // team carries the lineup of its previous match, so there is nothing a + // lineup for a later round would mean. Answered before the body or the + // competition is read, whatever either holds. + if round >= 1 { + c.JSON(http.StatusBadRequest, gin.H{"error": lineupRoundRefused}) + return + } var req LineupRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } - if !validLineupNames(c, req) { + if !validLineupRequest(c, req) { return } - lineup := domain.TeamLineup{ - TeamID: teamID, - CompetitionID: compID, - Round: round, - Positions: req.Positions, - MemberIDs: req.MemberIDs, - } - saveLineup(c, tx, hub, lineup, nil, func(lineups map[string]domain.TeamLineup) (domain.TeamLineup, bool) { + save := lineupSave{compID: compID, teamID: teamID, round: round, req: req} + saveLineup(c, tx, hub, save, nil, func(lineups map[string]domain.TeamLineup) (domain.TeamLineup, bool) { return findRoundLineup(lineups, teamID, round) }) }) @@ -264,7 +375,7 @@ func RegisterLineupHandlers(r *gin.RouterGroup, store TeamLineupStore, comps Com return } c.Status(http.StatusNoContent) - hub.Broadcast(EventLineupUpdated, gin.H{"competitionId": compID}) + hub.Broadcast(EventLineupUpdated, lineupUpdatedPayload(compID, teamID, "")) }) // Match-scoped PUT/DELETE (mp-825). Mirrors the round-scoped flow but @@ -285,17 +396,11 @@ func RegisterLineupHandlers(r *gin.RouterGroup, store TeamLineupStore, comps Com return } - if !validLineupNames(c, req) { + if !validLineupRequest(c, req) { return } - lineup := domain.TeamLineup{ - TeamID: teamID, - CompetitionID: compID, - MatchID: matchID, - Positions: req.Positions, - MemberIDs: req.MemberIDs, - } + save := lineupSave{compID: compID, teamID: teamID, matchID: matchID, req: req} // An anonymous self-run caller writes from the public score sheet, // so the score path's rule holds: the match must exist and hold the // team by id (errTeamNotInMatch), and once it has finished its lineup @@ -320,10 +425,10 @@ func RegisterLineupHandlers(r *gin.RouterGroup, store TeamLineupStore, comps Com if isMatchFinalized(snap.Status) { return resultFinalized("This match has finished, so its lineup can no longer be changed. Contact the tournament organizer to correct it.").response() } - return memberIDsOutsideTeam(stx, compID, teamID, req.MemberIDs) + return memberIDsOutsideTeam(stx, compID, teamID, req.memberIDsRead()) } } - saveLineup(c, tx, hub, lineup, guard, func(lineups map[string]domain.TeamLineup) (domain.TeamLineup, bool) { + saveLineup(c, tx, hub, save, guard, func(lineups map[string]domain.TeamLineup) (domain.TeamLineup, bool) { return findMatchLineup(lineups, teamID, matchID) }) }) @@ -338,7 +443,7 @@ func RegisterLineupHandlers(r *gin.RouterGroup, store TeamLineupStore, comps Com return } c.Status(http.StatusNoContent) - hub.Broadcast(EventLineupUpdated, gin.H{"competitionId": compID}) + hub.Broadcast(EventLineupUpdated, lineupUpdatedPayload(compID, teamID, matchID)) }) } @@ -385,15 +490,85 @@ func errLineupMemberNotInTeam(pos domain.Position) *selfRunRefusal { } } +// lineupSave is one PUT of a lineup: whose it is, the match it is for (none for +// the team's starting lineup, round 0) and what the body said. +type lineupSave struct { + compID, teamID string + matchID string + round int + req LineupRequest +} + +// lineupToStore is the lineup this save stores, built under the write's lock. +// A body that names no changed positions is the whole lineup. One that does has +// its changed positions landed on the base read here (base), so what another +// device saved a moment ago is kept, and the result is the lineup stored for this +// team at this scope. +func (w lineupSave) lineupToStore(stx state.StoreTx, comp *state.Competition) (domain.TeamLineup, *txResponse) { + changed, partial := w.req.changedPositions() + if !partial { + return domain.TeamLineup{ + TeamID: w.teamID, CompetitionID: w.compID, Round: w.round, MatchID: w.matchID, + Positions: w.req.Positions, MemberIDs: w.req.MemberIDs, + }, nil + } + base, resp := w.base(stx, comp) + if resp != nil { + return domain.TeamLineup{}, resp + } + lineup, err := base.ApplyChanges(changed, w.req.Positions, w.req.MemberIDs) + if err != nil { + return domain.TeamLineup{}, &txResponse{status: lineupSetStatus(err), body: gin.H{"error": err.Error()}} + } + lineup.TeamID, lineup.CompetitionID, lineup.Round, lineup.MatchID = w.teamID, w.compID, w.round, w.matchID + return lineup, nil +} + +// base is the lineup a partial save lands on. For a match, its own stored +// lineup; with none, the lineup in force there, which is what the team fields +// there and so what a read of the match shows: the match's own lineup is read +// whole, never merged with the one before it, so a base left empty would drop +// every carried position from the next read. Failing both, empty. For the +// starting lineup, the one stored, else empty. The in-force rule is the +// engine's one (engine.LineupInForceFrom), asked over what this transaction +// loads, since the store's own reads would wait on the lock it holds. +func (w lineupSave) base(stx state.StoreTx, comp *state.Competition) (domain.TeamLineup, *txResponse) { + internal := func(what string, err error) (domain.TeamLineup, *txResponse) { + log.Printf("mobileapp: lineup save for %s: %s: %v", w.compID, what, err) + return domain.TeamLineup{}, &txResponse{status: http.StatusInternalServerError, body: gin.H{"error": "internal error"}} + } + lineups, err := stx.LoadTeamLineups(w.compID) + if err != nil { + return internal("LoadTeamLineups", err) + } + if w.matchID == "" { + start, _ := findRoundLineup(lineups, w.teamID, w.round) + return start, nil + } + if own, ok := findMatchLineup(lineups, w.teamID, w.matchID); ok { + return own, nil + } + pool, err := stx.LoadPoolMatches(w.compID) + if err != nil { + return internal("LoadPoolMatches", err) + } + bracket, err := stx.LoadBracket(w.compID) + if err != nil { + return internal("LoadBracket", err) + } + return engine.LineupInForceFrom(lineups, pool, bracket, comp.IsKnockoutEnabled(), w.teamID, w.matchID).Lineup, nil +} + // saveLineup is the body the round and the match lineup PUTs share. Under one // per-comp lock (T156, the same atomicity argument the engine // UpdatePoolMatchByID / UpdateBracket primitives make) it loads the // competition for its team size, runs guard (the match PUT's rule for a -// participant; nil when there is none), saves the lineup and reads it back -// with find for the response. The answer is written after the lock releases -// (txResponse), and a saved lineup is broadcast so SSE clients re-fetch it. -func saveLineup(c *gin.Context, tx CompetitionTransactor, hub Broadcaster, lineup domain.TeamLineup, guard func(stx state.StoreTx) *txResponse, find func(map[string]domain.TeamLineup) (domain.TeamLineup, bool)) { - compID := lineup.CompetitionID +// participant; nil when there is none), builds the lineup to store +// (lineupSave.lineupToStore), saves it and reads it back with find for the +// response. The answer is written after the lock releases (txResponse), and a +// saved lineup is broadcast so SSE clients re-fetch it. +func saveLineup(c *gin.Context, tx CompetitionTransactor, hub Broadcaster, save lineupSave, guard func(stx state.StoreTx) *txResponse, find func(map[string]domain.TeamLineup) (domain.TeamLineup, bool)) { + compID := save.compID var respErr *txResponse var persistedLineup domain.TeamLineup txErr := tx.WithTransaction(compID, func(stx state.StoreTx) error { @@ -423,6 +598,10 @@ func saveLineup(c *gin.Context, tx CompetitionTransactor, hub Broadcaster, lineu return nil } } + var lineup domain.TeamLineup + if lineup, respErr = save.lineupToStore(stx, comp); respErr != nil { + return nil + } if err := stx.SetTeamLineup(compID, lineup, teamSize); err != nil { // Domain validation errors ("team_lineup:" prefix) are 400; a // YAML/disk fault is a 500 (see lineupSetStatus). @@ -459,7 +638,21 @@ func saveLineup(c *gin.Context, tx CompetitionTransactor, hub Broadcaster, lineu return } c.JSON(http.StatusOK, persistedLineup) - hub.Broadcast(EventLineupUpdated, gin.H{"competitionId": compID}) + hub.Broadcast(EventLineupUpdated, lineupUpdatedPayload(compID, save.teamID, save.matchID)) +} + +// lineupUpdatedPayload is the data of an EventLineupUpdated, the one place it is +// built: the competition, the team whose lineups changed and, for a lineup saved +// for a match, that match. A team's starting lineup names no match, and nor does +// a change to a team's members (a rename or a clear), which can reach the team's +// lineups at several matches. A listener that reads one team's lineup can tell by +// this whether the change is its own. +func lineupUpdatedPayload(compID, teamID, matchID string) gin.H { + data := gin.H{"competitionId": compID, "teamId": teamID} + if matchID != "" { + data["matchId"] = matchID + } + return data } // parseLineupParams extracts (compID, teamID, round) from the URL and diff --git a/internal/mobileapp/handlers_lineup_changed_test.go b/internal/mobileapp/handlers_lineup_changed_test.go new file mode 100644 index 000000000..7310396f6 --- /dev/null +++ b/internal/mobileapp/handlers_lineup_changed_test.go @@ -0,0 +1,496 @@ +package mobileapp + +// handlers_lineup_changed_test.go pins what a lineup save may say about which +// positions it changed (operator decision 2026-10-07: "Only changed positions"). +// PUT .../match-lineups/:matchId and PUT .../lineups/0 take an optional +// `changed` list of position keys. Absent, the body is the whole lineup and +// replaces the stored one, as a queued save persisted by the previous build +// replays. Present, the server reads only the changed keys (the client may send +// its whole form beside them) and lands them on a base it reads under the write's +// lock: the match's own stored lineup, else the lineup in force there (what the +// team carries from its previous match, else its starting lineup), else empty. +// Two devices changing different positions then both keep their change, in +// either arrival order. + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/gin-gonic/gin" + "github.com/gitrgoliveira/bracket-creator/internal/domain" + "github.com/gitrgoliveira/bracket-creator/internal/state" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// changedLineupFixture is a self-run team competition of five-person teams in a +// league: Tora (the team the lineups are for) plays PoolA-0 and PoolA-1, both +// running, and PoolA-2 is a match Tora is not seated in. +type changedLineupFixture struct { + r *gin.Engine + store *state.Store + team string +} + +const ( + organiserPassword = "main-pw" + publicPassword = "" +) + +func newChangedLineupFixture(t *testing.T) changedLineupFixture { + t.Helper() + store := newTempStore(t) + seedSelfRunTournament(t, store, "admin-pw") + require.NoError(t, store.SaveCompetition(&state.Competition{ + ID: "c1", Name: "Teams", Kind: "team", TeamSize: 5, Format: state.CompFormatLeague, Status: state.CompStatusPools, + })) + require.NoError(t, store.SaveParticipants("c1", []domain.Player{ + {Name: "Tora", Dojo: "Tora Dojo"}, {Name: "Kuma", Dojo: "Kuma Dojo"}, {Name: "Saru", Dojo: "Saru Dojo"}, + })) + teams, err := store.LoadParticipants("c1", false) + require.NoError(t, err) + require.Len(t, teams, 3) + tora, kuma, saru := teams[0], teams[1], teams[2] + require.NoError(t, store.SavePoolMatches("c1", []state.MatchResult{ + {ID: "PoolA-0", SideA: tora.Name, SideAID: tora.ID, SideB: kuma.Name, SideBID: kuma.ID, Status: state.MatchStatusRunning}, + {ID: "PoolA-1", SideA: tora.Name, SideAID: tora.ID, SideB: saru.Name, SideBID: saru.ID, Status: state.MatchStatusRunning}, + {ID: "PoolA-2", SideA: kuma.Name, SideAID: kuma.ID, SideB: saru.Name, SideBID: saru.ID, Status: state.MatchStatusRunning}, + })) + store.EnsureLegacyUpgraded("c1") + return changedLineupFixture{r: setupSelfRunRouter(t, store, NewFileVerifier(store)), store: store, team: tora.ID} +} + +func (f changedLineupFixture) matchPath(matchID string) string { + return "/api/competitions/c1/teams/" + f.team + "/match-lineups/" + matchID +} + +func (f changedLineupFixture) startingPath() string { + return "/api/competitions/c1/teams/" + f.team + "/lineups/0" +} + +func (f changedLineupFixture) put(path, password string, body any) *httptest.ResponseRecorder { + req := jsonReq(http.MethodPut, path, body) + req.Header.Set("X-Tournament-Password", password) + w := httptest.NewRecorder() + f.r.ServeHTTP(w, req) + return w +} + +// lineupFrom decodes the whole lineup a save answered with. +func lineupFrom(t *testing.T, w *httptest.ResponseRecorder) domain.TeamLineup { + t.Helper() + require.Equal(t, http.StatusOK, w.Code, w.Body.String()) + var l domain.TeamLineup + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &l)) + return l +} + +// storedMatch is the lineup stored for Tora at a match, and whether there is one. +func (f changedLineupFixture) storedMatch(t *testing.T, matchID string) (domain.TeamLineup, bool) { + t.Helper() + lineups, err := f.store.LoadTeamLineups("c1") + require.NoError(t, err) + return findMatchLineup(lineups, f.team, matchID) +} + +func (f changedLineupFixture) storedStarting(t *testing.T) (domain.TeamLineup, bool) { + t.Helper() + lineups, err := f.store.LoadTeamLineups("c1") + require.NoError(t, err) + return findRoundLineup(lineups, f.team, 0) +} + +// positions builds a positions map from alternating position and name arguments. +func posMap(pairs ...string) map[domain.Position]string { + out := map[domain.Position]string{} + for i := 0; i+1 < len(pairs); i += 2 { + out[domain.Position(pairs[i])] = pairs[i+1] + } + return out +} + +// save is a body in the shape the client sends: positions, member ids when given, +// and, when changed names any position, the positions the save changed. +func saveBody(p, ids map[domain.Position]string, changed ...domain.Position) map[string]any { + body := map[string]any{"positions": p} + if ids != nil { + body["memberIds"] = ids + } + if len(changed) > 0 { + body["changed"] = changed + } + return body +} + +var ( + pSenpo = domain.PosSenpo + pJiho = domain.PosJiho + pChuken = domain.PosChuken +) + +// A first partial save at a match with no lineup of its own lands on the lineup +// the team carries there, not on an empty one: the match's own lineup is read +// whole (the previous match's is never merged in), so an empty base would drop +// every carried position on the next read. +func TestChangedLineup_LandsOnTheLineupInForce(t *testing.T) { + t.Run("carried from the team's previous match", func(t *testing.T) { + f := newChangedLineupFixture(t) + whole := posMap("senpo", "Ito", "jiho", "Ueno", "chuken", "Endo") + require.Equal(t, http.StatusOK, f.put(f.matchPath("PoolA-0"), organiserPassword, saveBody(whole, nil)).Code) + + w := f.put(f.matchPath("PoolA-1"), organiserPassword, saveBody(posMap("jiho", "Ueda"), nil, pJiho)) + + got := lineupFrom(t, w) + assert.Equal(t, posMap("senpo", "Ito", "jiho", "Ueda", "chuken", "Endo"), got.Positions, + "the answer is the whole lineup as stored: the carried positions and the change") + stored, ok := f.storedMatch(t, "PoolA-1") + require.True(t, ok) + assert.Equal(t, got.Positions, stored.Positions) + first, _ := f.storedMatch(t, "PoolA-0") + assert.Equal(t, whole, first.Positions, "the match it was carried from is left alone") + }) + + t.Run("carried from the starting lineup when no earlier match has one", func(t *testing.T) { + f := newChangedLineupFixture(t) + require.Equal(t, http.StatusOK, f.put(f.startingPath(), organiserPassword, + saveBody(posMap("senpo", "Ito", "jiho", "Ueno"), map[domain.Position]string{pSenpo: "m-ito", pJiho: "m-ueno"})).Code) + + w := f.put(f.matchPath("PoolA-0"), organiserPassword, saveBody(posMap("jiho", "Ueda"), nil, pJiho)) + + got := lineupFrom(t, w) + assert.Equal(t, posMap("senpo", "Ito", "jiho", "Ueda"), got.Positions) + assert.Equal(t, map[domain.Position]string{pSenpo: "m-ito"}, got.MemberIDs, + "the changed position took no id, so its carried id is gone with the name it belonged to, and the other keeps its own") + }) + + t.Run("an empty base when the team carries nothing", func(t *testing.T) { + f := newChangedLineupFixture(t) + + w := f.put(f.matchPath("PoolA-0"), organiserPassword, saveBody(posMap("senpo", "Ito"), nil, pSenpo)) + + assert.Equal(t, posMap("senpo", "Ito"), lineupFrom(t, w).Positions) + }) + + t.Run("a match the team is not seated in has the base the read shows there", func(t *testing.T) { + f := newChangedLineupFixture(t) + require.Equal(t, http.StatusOK, f.put(f.startingPath(), organiserPassword, saveBody(posMap("senpo", "Ito", "jiho", "Ueno"), nil)).Code) + require.Equal(t, http.StatusOK, f.put(f.matchPath("PoolA-0"), organiserPassword, saveBody(posMap("senpo", "Endo", "jiho", "Ueno"), nil)).Code) + + w := f.put(f.matchPath("PoolA-2"), organiserPassword, saveBody(posMap("jiho", "Ueda"), nil, pJiho)) + + assert.Equal(t, posMap("senpo", "Endo", "jiho", "Ueda"), lineupFrom(t, w).Positions, + "the match's place in the draw is what counts, as in the lineup-in-force read: PoolA-0 comes before it") + }) + + t.Run("a match the draw does not hold has the starting lineup as its base", func(t *testing.T) { + f := newChangedLineupFixture(t) + require.Equal(t, http.StatusOK, f.put(f.startingPath(), organiserPassword, saveBody(posMap("senpo", "Ito", "jiho", "Ueno"), nil)).Code) + require.Equal(t, http.StatusOK, f.put(f.matchPath("PoolA-0"), organiserPassword, saveBody(posMap("senpo", "Endo", "jiho", "Ueno"), nil)).Code) + + w := f.put(f.matchPath("NoSuchMatch"), organiserPassword, saveBody(posMap("jiho", "Ueda"), nil, pJiho)) + + assert.Equal(t, posMap("senpo", "Ito", "jiho", "Ueda"), lineupFrom(t, w).Positions) + }) +} + +// Two devices that change different positions of one lineup both keep their +// change, whichever arrives first, because each lands on what is stored when it +// arrives. Each sends its whole form, as the client does, with the position it +// did not touch as it last read it. +func TestChangedLineup_TwoDevicesChangingDifferentPositionsBothKeepTheirs(t *testing.T) { + deviceA := saveBody(posMap("senpo", "Ito-new", "jiho", "Ueno-old"), nil, pSenpo) + deviceB := saveBody(posMap("senpo", "Ito-old", "jiho", "Ueno-new"), nil, pJiho) + want := posMap("senpo", "Ito-new", "jiho", "Ueno-new") + + for name, order := range map[string][]map[string]any{"A then B": {deviceA, deviceB}, "B then A": {deviceB, deviceA}} { + t.Run(name, func(t *testing.T) { + f := newChangedLineupFixture(t) + require.Equal(t, http.StatusOK, f.put(f.matchPath("PoolA-0"), organiserPassword, saveBody(posMap("senpo", "Ito-old", "jiho", "Ueno-old"), nil)).Code) + + var last *httptest.ResponseRecorder + for _, body := range order { + last = f.put(f.matchPath("PoolA-0"), organiserPassword, body) + require.Equal(t, http.StatusOK, last.Code, last.Body.String()) + } + + assert.Equal(t, want, lineupFrom(t, last).Positions) + stored, _ := f.storedMatch(t, "PoolA-0") + assert.Equal(t, want, stored.Positions) + }) + } + + t.Run("on a match with no lineup of its own", func(t *testing.T) { + f := newChangedLineupFixture(t) + require.Equal(t, http.StatusOK, f.put(f.startingPath(), organiserPassword, saveBody(posMap("senpo", "Ito-old", "jiho", "Ueno-old"), nil)).Code) + + require.Equal(t, http.StatusOK, f.put(f.matchPath("PoolA-1"), organiserPassword, deviceB).Code) + require.Equal(t, http.StatusOK, f.put(f.matchPath("PoolA-1"), organiserPassword, deviceA).Code) + + stored, _ := f.storedMatch(t, "PoolA-1") + assert.Equal(t, want, stored.Positions, "the first save made the carried lineup the match's own, and the second landed on it") + }) +} + +func TestChangedLineup_TheSamePositionTheLaterArrivalWins(t *testing.T) { + f := newChangedLineupFixture(t) + require.Equal(t, http.StatusOK, f.put(f.matchPath("PoolA-0"), organiserPassword, saveBody(posMap("senpo", "Ito"), nil)).Code) + + require.Equal(t, http.StatusOK, f.put(f.matchPath("PoolA-0"), organiserPassword, saveBody(posMap("senpo", "first"), nil, pSenpo)).Code) + w := f.put(f.matchPath("PoolA-0"), organiserPassword, saveBody(posMap("senpo", "second"), nil, pSenpo)) + + assert.Equal(t, posMap("senpo", "second"), lineupFrom(t, w).Positions) +} + +func TestChangedLineup_Refusals(t *testing.T) { + t.Run("a changed position with no entry in positions is a 400 and writes nothing", func(t *testing.T) { + f := newChangedLineupFixture(t) + require.Equal(t, http.StatusOK, f.put(f.matchPath("PoolA-0"), organiserPassword, saveBody(posMap("senpo", "Ito"), nil)).Code) + + w := f.put(f.matchPath("PoolA-0"), organiserPassword, saveBody(posMap("senpo", "Ito"), nil, pSenpo, pJiho)) + + require.Equal(t, http.StatusBadRequest, w.Code, w.Body.String()) + assert.Contains(t, w.Body.String(), "jiho") + stored, _ := f.storedMatch(t, "PoolA-0") + assert.Equal(t, posMap("senpo", "Ito"), stored.Positions) + }) + + t.Run("an empty changed list is a 400", func(t *testing.T) { + f := newChangedLineupFixture(t) + + w := f.put(f.matchPath("PoolA-0"), organiserPassword, map[string]any{"positions": posMap("senpo", "Ito"), "changed": []string{}}) + + require.Equal(t, http.StatusBadRequest, w.Code, w.Body.String()) + _, ok := f.storedMatch(t, "PoolA-0") + assert.False(t, ok, "nothing was stored") + }) + + t.Run("a changed position the team size does not have is a 400", func(t *testing.T) { + f := newChangedLineupFixture(t) + + w := f.put(f.matchPath("PoolA-0"), organiserPassword, saveBody(posMap("7", "Ito"), nil, "7")) + + require.Equal(t, http.StatusBadRequest, w.Code, w.Body.String()) + }) + + t.Run("a member at two positions is the duplicate refusal, naming both, not a silent drop", func(t *testing.T) { + f := newChangedLineupFixture(t) + require.Equal(t, http.StatusOK, f.put(f.matchPath("PoolA-0"), organiserPassword, + saveBody(posMap("senpo", "Ito", "jiho", "Ueno"), map[domain.Position]string{pSenpo: "m-ito", pJiho: "m-ueno"})).Code) + + w := f.put(f.matchPath("PoolA-0"), organiserPassword, + saveBody(posMap("jiho", "Ito"), map[domain.Position]string{pJiho: "m-ito"}, pJiho)) + + require.Equal(t, http.StatusBadRequest, w.Code, w.Body.String()) + assert.Contains(t, w.Body.String(), "senpo", "the position the member already holds") + assert.Contains(t, w.Body.String(), "jiho", "the position the change put it at") + stored, _ := f.storedMatch(t, "PoolA-0") + assert.Equal(t, "Ueno", stored.Positions[pJiho], "the refused change is not stored") + }) +} + +// Everything in the body that is not a changed position is ignored, including a +// key a whole lineup would be refused for. +func TestChangedLineup_IgnoresWhatIsNotChanged(t *testing.T) { + f := newChangedLineupFixture(t) + require.Equal(t, http.StatusOK, f.put(f.matchPath("PoolA-0"), organiserPassword, + saveBody(posMap("senpo", "Ito", "jiho", "Ueno"), map[domain.Position]string{pSenpo: "m-ito"})).Code) + form := posMap("senpo", "Ito-stale", "jiho", "Ueno-new", "taisho", "Kato", "nonsense", "x", "fukusho", strings.Repeat("x", MaxLenPlayerName+1)) + ids := map[domain.Position]string{pSenpo: "m-stale", pJiho: "m-ueno", "nonsense": "m-x"} + + w := f.put(f.matchPath("PoolA-0"), organiserPassword, saveBody(form, ids, pJiho)) + + got := lineupFrom(t, w) + assert.Equal(t, posMap("senpo", "Ito", "jiho", "Ueno-new"), got.Positions, + "only pJiho changed: another position's stale value, a position the lineup never held, an invalid key and an overlong name are all ignored") + assert.Equal(t, map[domain.Position]string{pSenpo: "m-ito", pJiho: "m-ueno"}, got.MemberIDs) +} + +func TestChangedLineup_ClearsAndPlacesUnnamedMembers(t *testing.T) { + t.Run("a changed position with no name and no id is removed from both maps", func(t *testing.T) { + f := newChangedLineupFixture(t) + require.Equal(t, http.StatusOK, f.put(f.matchPath("PoolA-0"), organiserPassword, + saveBody(posMap("senpo", "Ito", "jiho", "Ueno"), map[domain.Position]string{pSenpo: "m-ito", pJiho: "m-ueno"})).Code) + + w := f.put(f.matchPath("PoolA-0"), organiserPassword, saveBody(posMap("jiho", ""), nil, pJiho)) + + got := lineupFrom(t, w) + assert.Equal(t, posMap("senpo", "Ito"), got.Positions, "the key is gone, not kept with an empty name") + assert.Equal(t, map[domain.Position]string{pSenpo: "m-ito"}, got.MemberIDs) + }) + + t.Run("a changed position with an id and no name places an unnamed member", func(t *testing.T) { + f := newChangedLineupFixture(t) + require.Equal(t, http.StatusOK, f.put(f.matchPath("PoolA-0"), organiserPassword, saveBody(posMap("senpo", "Ito"), nil)).Code) + + w := f.put(f.matchPath("PoolA-0"), organiserPassword, saveBody(posMap("chuken", ""), map[domain.Position]string{pChuken: "m-blank"}, pChuken)) + + got := lineupFrom(t, w) + name, held := got.Positions[pChuken] + assert.True(t, held, "the position is held") + assert.Empty(t, name) + assert.Equal(t, "m-blank", got.MemberIDs[pChuken]) + }) + + t.Run("a changed position with a name and no id drops the id the position had", func(t *testing.T) { + f := newChangedLineupFixture(t) + require.Equal(t, http.StatusOK, f.put(f.matchPath("PoolA-0"), organiserPassword, + saveBody(posMap("senpo", "Ito"), map[domain.Position]string{pSenpo: "m-ito"})).Code) + + w := f.put(f.matchPath("PoolA-0"), organiserPassword, saveBody(posMap("senpo", "Someone new"), nil, pSenpo)) + + got := lineupFrom(t, w) + assert.Equal(t, posMap("senpo", "Someone new"), got.Positions) + assert.NotContains(t, got.MemberIDs, pSenpo, "the old member's id does not stay beside a new name") + }) + + t.Run("clearing every position leaves an own lineup that is empty", func(t *testing.T) { + f := newChangedLineupFixture(t) + require.Equal(t, http.StatusOK, f.put(f.matchPath("PoolA-0"), organiserPassword, saveBody(posMap("senpo", "Ito"), nil)).Code) + + w := f.put(f.matchPath("PoolA-0"), organiserPassword, saveBody(posMap("senpo", ""), nil, pSenpo)) + + assert.Empty(t, lineupFrom(t, w).Positions) + _, ok := f.storedMatch(t, "PoolA-0") + assert.True(t, ok, "the match still has a lineup of its own, and it is empty") + }) +} + +// Without `changed` the body is the whole lineup and replaces the stored one: the +// shape a save queued by the previous build replays in. +func TestChangedLineup_WholeReplacementWhenChangedIsAbsent(t *testing.T) { + f := newChangedLineupFixture(t) + require.Equal(t, http.StatusOK, f.put(f.matchPath("PoolA-0"), organiserPassword, + saveBody(posMap("senpo", "Ito", "jiho", "Ueno"), map[domain.Position]string{pSenpo: "m-ito"})).Code) + + w := f.put(f.matchPath("PoolA-0"), organiserPassword, saveBody(posMap("chuken", "Endo"), nil)) + + got := lineupFrom(t, w) + assert.Equal(t, posMap("chuken", "Endo"), got.Positions) + assert.Empty(t, got.MemberIDs) + + t.Run("an empty positions map clears the lineup, and still is a whole lineup", func(t *testing.T) { + w := f.put(f.matchPath("PoolA-0"), organiserPassword, map[string]any{"positions": map[string]string{}}) + + assert.Empty(t, lineupFrom(t, w).Positions) + }) +} + +func TestChangedLineup_TheStartingLineup(t *testing.T) { + t.Run("lands on the stored starting lineup", func(t *testing.T) { + f := newChangedLineupFixture(t) + require.Equal(t, http.StatusOK, f.put(f.startingPath(), organiserPassword, saveBody(posMap("senpo", "Ito", "jiho", "Ueno"), nil)).Code) + + w := f.put(f.startingPath(), organiserPassword, saveBody(posMap("senpo", "Ito-stale", "jiho", "Ueda"), nil, pJiho)) + + got := lineupFrom(t, w) + assert.Equal(t, posMap("senpo", "Ito", "jiho", "Ueda"), got.Positions) + assert.Zero(t, got.Round) + assert.Empty(t, got.MatchID) + stored, ok := f.storedStarting(t) + require.True(t, ok) + assert.Equal(t, got.Positions, stored.Positions) + }) + + t.Run("an empty base when none is stored, never a match's lineup", func(t *testing.T) { + f := newChangedLineupFixture(t) + require.Equal(t, http.StatusOK, f.put(f.matchPath("PoolA-0"), organiserPassword, saveBody(posMap("senpo", "Endo"), nil)).Code) + + w := f.put(f.startingPath(), organiserPassword, saveBody(posMap("jiho", "Ueda"), nil, pJiho)) + + assert.Equal(t, posMap("jiho", "Ueda"), lineupFrom(t, w).Positions) + }) + + t.Run("a later round is still refused", func(t *testing.T) { + f := newChangedLineupFixture(t) + + w := f.put("/api/competitions/c1/teams/"+f.team+"/lineups/1", organiserPassword, saveBody(posMap("jiho", "Ueda"), nil, pJiho)) + + require.Equal(t, http.StatusBadRequest, w.Code, w.Body.String()) + }) +} + +// A participant of a self-run tournament saves a match's lineup from the public +// score sheet. The refusals that depend on the match are unchanged, and the check +// that a member id belongs to the team judges the ids of the changed positions +// only: the rest of the body is ignored, so a stale id in it cannot refuse a save. +func TestChangedLineup_AnAnonymousCaller(t *testing.T) { + f := newChangedLineupFixture(t) + squads, err := f.store.LoadSquads("c1") + require.NoError(t, err) + require.NotEmpty(t, squads[f.team]) + mine := squads[f.team][0].ID + + t.Run("an id outside the team in a position that did not change is not judged", func(t *testing.T) { + w := f.put(f.matchPath("PoolA-0"), publicPassword, + saveBody(posMap("senpo", "Ito", "jiho", "Ueno"), map[domain.Position]string{pSenpo: mine, pJiho: "not-on-this-team"}, pSenpo)) + + got := lineupFrom(t, w) + assert.Equal(t, mine, got.MemberIDs[pSenpo]) + assert.Equal(t, posMap("senpo", "Ito"), got.Positions, "and the unchanged position's name is not stored either") + }) + + t.Run("an id outside the team in a changed position is refused", func(t *testing.T) { + w := f.put(f.matchPath("PoolA-1"), publicPassword, + saveBody(posMap("jiho", "Ueno"), map[domain.Position]string{pJiho: "not-on-this-team"}, pJiho)) + + require.Equal(t, http.StatusBadRequest, w.Code, w.Body.String()) + assert.Contains(t, w.Body.String(), "team_member_not_in_team") + _, ok := f.storedMatch(t, "PoolA-1") + assert.False(t, ok) + }) + + t.Run("a whole lineup is judged on every id, as before", func(t *testing.T) { + w := f.put(f.matchPath("PoolA-1"), publicPassword, + saveBody(posMap("senpo", "Ito", "jiho", "Ueno"), map[domain.Position]string{pSenpo: mine, pJiho: "not-on-this-team"})) + + require.Equal(t, http.StatusBadRequest, w.Code, w.Body.String()) + }) + + t.Run("a team the match does not hold is still refused", func(t *testing.T) { + w := f.put(f.matchPath("PoolA-2"), publicPassword, saveBody(posMap("senpo", "Ito"), nil, pSenpo)) + + require.Equal(t, http.StatusNotFound, w.Code, w.Body.String()) + assert.Contains(t, w.Body.String(), "team_not_in_match") + }) + + t.Run("a finished match is still refused", func(t *testing.T) { + _, err := f.store.UpdatePoolMatchByID("c1", "PoolA-0", func(m *state.MatchResult) error { + m.Status = state.MatchStatusCompleted + return nil + }) + require.NoError(t, err) + + w := f.put(f.matchPath("PoolA-0"), publicPassword, saveBody(posMap("senpo", "Ito-again"), map[domain.Position]string{pSenpo: mine}, pSenpo)) + + require.Equal(t, http.StatusConflict, w.Code, w.Body.String()) + assert.Contains(t, w.Body.String(), "result_finalized") + }) + + t.Run("the starting lineup stays the organiser's", func(t *testing.T) { + w := f.put(f.startingPath(), publicPassword, saveBody(posMap("jiho", "Ueda"), nil, pJiho)) + + assert.Equal(t, http.StatusUnauthorized, w.Code, w.Body.String()) + }) +} + +// A save is announced as it was before, naming the team and the match. +func TestChangedLineup_IsAnnouncedLikeAnyLineupSave(t *testing.T) { + gin.SetMode(gin.TestMode) + f := newChangedLineupFixture(t) + hub := &payloadBroadcaster{} + r := gin.New() + admin := r.Group("/api") + admin.Use(AuthMiddleware(NewFileVerifier(f.store), f.store)) + RegisterLineupHandlers(admin, f.store, f.store, f.store, hub, f.store, NewFileVerifier(f.store)) + + req := jsonReq(http.MethodPut, f.matchPath("PoolA-0"), saveBody(posMap("senpo", "Ito"), nil, pSenpo)) + req.Header.Set("X-Tournament-Password", organiserPassword) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + require.Equal(t, http.StatusOK, w.Code, w.Body.String()) + assert.Equal(t, gin.H{"competitionId": "c1", "teamId": f.team, "matchId": "PoolA-0"}, hub.last(t)) +} diff --git a/internal/mobileapp/handlers_lineup_event_test.go b/internal/mobileapp/handlers_lineup_event_test.go new file mode 100644 index 000000000..db0531253 --- /dev/null +++ b/internal/mobileapp/handlers_lineup_event_test.go @@ -0,0 +1,97 @@ +package mobileapp + +import ( + "net/http" + "net/http/httptest" + "os" + "testing" + + "github.com/gin-gonic/gin" + "github.com/gitrgoliveira/bracket-creator/internal/domain" + "github.com/gitrgoliveira/bracket-creator/internal/state" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// payloadBroadcaster records what each broadcast carried, which +// recordingBroadcaster leaves out: the data of a lineup event is what a +// listener decides whether to refetch by. +type payloadBroadcaster struct { + events []broadcastEvent +} + +type broadcastEvent struct { + typ EventType + data any +} + +func (b *payloadBroadcaster) Broadcast(t EventType, data any) { + b.events = append(b.events, broadcastEvent{typ: t, data: data}) +} + +// last is the data of the most recent lineup event, failing when none was sent. +func (b *payloadBroadcaster) last(t *testing.T) any { + t.Helper() + require.NotEmpty(t, b.events, "no event was broadcast") + ev := b.events[len(b.events)-1] + require.Equal(t, EventLineupUpdated, ev.typ) + return ev.data +} + +// TestLineupUpdatedEvent_NamesTheTeamAndTheMatch: every writer of the lineup +// event says whose lineups changed, so a listener that only reads one team's +// lineup need not refetch for another's. The competition stays in the payload, +// which is what a listener filters by first. A match's lineup names the match as +// well; the team's starting lineup does not, and nor does a member rename or +// clear, which can reach the team's lineups at several matches. +func TestLineupUpdatedEvent_NamesTheTeamAndTheMatch(t *testing.T) { + gin.SetMode(gin.TestMode) + dir, err := os.MkdirTemp("", "lineup-event-*") + require.NoError(t, err) + t.Cleanup(func() { os.RemoveAll(dir) }) + store, err := state.NewStore(dir) + require.NoError(t, err) + require.NoError(t, store.SaveTournament(&state.Tournament{Name: "Test", Password: "secret"})) + require.NoError(t, store.SaveCompetition(&state.Competition{ID: "c1", Kind: "team", TeamSize: 3})) + require.NoError(t, store.SaveParticipants("c1", []domain.Player{{Name: "Tora", Dojo: "Tora Dojo"}})) + teams, err := store.LoadParticipants("c1", false) + require.NoError(t, err) + require.Len(t, teams, 1) + teamID := teams[0].ID + sato, err := store.AddTeamMember("c1", teamID, "Sato") + require.NoError(t, err) + + hub := &payloadBroadcaster{} + r := gin.New() + admin := r.Group("/api") + admin.Use(AuthMiddleware(NewFileVerifier(store), store)) + RegisterLineupHandlers(admin, store, store, store, hub, store, NewFileVerifier(store)) + RegisterSquadHandlers(admin, store, store, hub, store, NewFileVerifier(store)) + + send := func(method, path string, body any) { + t.Helper() + w := httptest.NewRecorder() + r.ServeHTTP(w, squadJSONReq(method, path, "secret", body)) + require.Contains(t, []int{http.StatusOK, http.StatusNoContent}, w.Code, "%s %s: %s", method, path, w.Body.String()) + } + lineup := LineupRequest{Positions: map[domain.Position]string{domain.PositionNumbered(1): "Sato"}} + team := "/api/competitions/c1/teams/" + teamID + + send(http.MethodPut, team+"/lineups/0", lineup) + assert.Equal(t, gin.H{"competitionId": "c1", "teamId": teamID}, hub.last(t), "PUT of the starting lineup") + + send(http.MethodDelete, team+"/lineups/0", nil) + assert.Equal(t, gin.H{"competitionId": "c1", "teamId": teamID}, hub.last(t), "DELETE of the starting lineup") + + send(http.MethodPut, team+"/match-lineups/Pool%20A-0", lineup) + assert.Equal(t, gin.H{"competitionId": "c1", "teamId": teamID, "matchId": "Pool A-0"}, hub.last(t), "PUT of a match's lineup") + + send(http.MethodDelete, team+"/match-lineups/Pool%20A-0", nil) + assert.Equal(t, gin.H{"competitionId": "c1", "teamId": teamID, "matchId": "Pool A-0"}, hub.last(t), "DELETE of a match's lineup") + + send(http.MethodPut, team+"/members/"+sato.ID, map[string]any{"name": "Sato Kenji"}) + assert.Equal(t, gin.H{"competitionId": "c1", "teamId": teamID}, hub.last(t), "a member renamed") + + send(http.MethodDelete, team+"/members/"+sato.ID, nil) + assert.Equal(t, gin.H{"competitionId": "c1", "teamId": teamID}, hub.last(t), "a member's name cleared") +} diff --git a/internal/mobileapp/handlers_lineup_test.go b/internal/mobileapp/handlers_lineup_test.go index 7ce512ee7..c42167ad4 100644 --- a/internal/mobileapp/handlers_lineup_test.go +++ b/internal/mobileapp/handlers_lineup_test.go @@ -6,11 +6,14 @@ import ( "errors" "net/http" "net/http/httptest" + "net/url" "os" + "path/filepath" "testing" "github.com/gin-gonic/gin" "github.com/gitrgoliveira/bracket-creator/internal/domain" + "github.com/gitrgoliveira/bracket-creator/internal/engine" "github.com/gitrgoliveira/bracket-creator/internal/state" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -48,7 +51,7 @@ func setupLineupTestRouter(t *testing.T) (*gin.Engine, *state.Store, string) { // Public group, same as production server.go api := r.Group("/api") - RegisterPublicLineupHandlers(api, store, store) + RegisterPublicLineupHandlers(api, store, store, engine.New(store)) // Admin group, AuthMiddleware gates all writes admin := r.Group("/api") @@ -200,7 +203,7 @@ func TestLineupPUT_RequiresAuth(t *testing.T) { t.Run("no password header returns 401", func(t *testing.T) { req := httptest.NewRequest(http.MethodPut, - "/api/competitions/c1/teams/teamA/lineups/1", + "/api/competitions/c1/teams/teamA/lineups/0", bytes.NewBuffer(body)) req.Header.Set("Content-Type", "application/json") w := httptest.NewRecorder() @@ -211,7 +214,7 @@ func TestLineupPUT_RequiresAuth(t *testing.T) { t.Run("correct password header succeeds", func(t *testing.T) { req := httptest.NewRequest(http.MethodPut, - "/api/competitions/c1/teams/teamA/lineups/1", + "/api/competitions/c1/teams/teamA/lineups/0", bytes.NewBuffer(body)) req.Header.Set("Content-Type", "application/json") req.Header.Set("X-Tournament-Password", "secret") @@ -316,7 +319,7 @@ func TestLineupPUT_NoCompetition(t *testing.T) { }, }) req := httptest.NewRequest(http.MethodPut, - "/api/competitions/no-such-comp/teams/teamA/lineups/1", + "/api/competitions/no-such-comp/teams/teamA/lineups/0", bytes.NewBuffer(body)) req.Header.Set("Content-Type", "application/json") req.Header.Set("X-Tournament-Password", "secret") @@ -336,7 +339,7 @@ func TestLineupPUT_ZeroTeamSize(t *testing.T) { "positions": map[string]string{"senpo": "p1"}, }) req := httptest.NewRequest(http.MethodPut, - "/api/competitions/c1/teams/teamA/lineups/1", + "/api/competitions/c1/teams/teamA/lineups/0", bytes.NewBuffer(body)) req.Header.Set("Content-Type", "application/json") req.Header.Set("X-Tournament-Password", "secret") @@ -361,7 +364,7 @@ func TestLineupPUT_ValidationError(t *testing.T) { }, }) req := httptest.NewRequest(http.MethodPut, - "/api/competitions/c1/teams/teamA/lineups/1", + "/api/competitions/c1/teams/teamA/lineups/0", bytes.NewBuffer(body)) req.Header.Set("Content-Type", "application/json") req.Header.Set("X-Tournament-Password", "secret") @@ -377,7 +380,7 @@ func TestLineupPUT_InvalidJSON(t *testing.T) { require.NoError(t, store.SaveCompetition(&state.Competition{ID: "c1", TeamSize: 5})) req := httptest.NewRequest(http.MethodPut, - "/api/competitions/c1/teams/teamA/lineups/1", + "/api/competitions/c1/teams/teamA/lineups/0", bytes.NewBufferString("{bad-json")) req.Header.Set("Content-Type", "application/json") req.Header.Set("X-Tournament-Password", "secret") @@ -386,6 +389,59 @@ func TestLineupPUT_InvalidJSON(t *testing.T) { assert.Equal(t, http.StatusBadRequest, w.Code) } +// TestLineupPUT_RoundsAboveZeroAreRefused: a lineup is saved as the team's +// starting lineup (round 0) or for a match. The Lineups page no longer saves a +// lineup for a later round and a team carries the lineup of its previous match, +// so a PUT for round 1 or later answers 400 in plain words, stores nothing, and +// answers before it reads the body or looks for the competition. GET and +// DELETE for those rounds still answer, for what an older release left. +func TestLineupPUT_RoundsAboveZeroAreRefused(t *testing.T) { + r, store, _ := setupLineupTestRouter(t) + require.NoError(t, store.SaveTournament(&state.Tournament{Name: "Test", Password: "secret"})) + require.NoError(t, store.SaveCompetition(&state.Competition{ID: "c1", TeamSize: 5})) + const sentence = "A lineup is saved as the team's starting lineup or for a match." + + put := func(path, body string) *httptest.ResponseRecorder { + req := httptest.NewRequest(http.MethodPut, path, bytes.NewBufferString(body)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("X-Tournament-Password", "secret") + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + return w + } + const valid = `{"positions":{"senpo":"p1"}}` + + for _, round := range []string{"1", "2", "10"} { + t.Run("round "+round+" is refused and stores nothing", func(t *testing.T) { + w := put("/api/competitions/c1/teams/teamA/lineups/"+round, valid) + + require.Equal(t, http.StatusBadRequest, w.Code, w.Body.String()) + var body map[string]string + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body)) + assert.Equal(t, sentence, body["error"]) + lineups, err := store.LoadTeamLineups("c1") + require.NoError(t, err) + assert.Empty(t, lineups) + }) + } + + t.Run("the refusal does not wait for the body or the competition", func(t *testing.T) { + assert.Equal(t, http.StatusBadRequest, put("/api/competitions/c1/teams/teamA/lineups/1", "{bad-json").Code) + w := put("/api/competitions/no-such-comp/teams/teamA/lineups/1", valid) + assert.Equal(t, http.StatusBadRequest, w.Code) + assert.Contains(t, w.Body.String(), sentence) + }) + + t.Run("round 0, the starting lineup, is still saved", func(t *testing.T) { + w := put("/api/competitions/c1/teams/teamA/lineups/0", valid) + + require.Equal(t, http.StatusOK, w.Code, w.Body.String()) + lineups, err := store.LoadTeamLineups("c1") + require.NoError(t, err) + assert.Len(t, lineups, 1) + }) +} + // TestLineupPUT_MemberIDsRoundTrip (bc-tmid pass 3): a PUT body carrying // both "positions" and "memberIds" persists both, and the response (and a // subsequent GET) return the same memberIds keyed by the same positions. @@ -405,7 +461,7 @@ func TestLineupPUT_MemberIDsRoundTrip(t *testing.T) { }, }) req := httptest.NewRequest(http.MethodPut, - "/api/competitions/c1/teams/teamA/lineups/1", + "/api/competitions/c1/teams/teamA/lineups/0", bytes.NewBuffer(body)) req.Header.Set("Content-Type", "application/json") req.Header.Set("X-Tournament-Password", "secret") @@ -418,7 +474,7 @@ func TestLineupPUT_MemberIDsRoundTrip(t *testing.T) { assert.Equal(t, "member-sato", putResp.MemberIDs[domain.PosSenpo], "PUT response carries memberIds") assert.Equal(t, "member-ito", putResp.MemberIDs[domain.PosJiho]) - getReq := httptest.NewRequest(http.MethodGet, "/api/competitions/c1/teams/teamA/lineups/1", nil) + getReq := httptest.NewRequest(http.MethodGet, "/api/competitions/c1/teams/teamA/lineups/0", nil) getW := httptest.NewRecorder() r.ServeHTTP(getW, getReq) require.Equal(t, http.StatusOK, getW.Code) @@ -444,7 +500,7 @@ func TestLineupPUT_MemberIDsOmitted_BehavesAsBefore(t *testing.T) { }, }) req := httptest.NewRequest(http.MethodPut, - "/api/competitions/c1/teams/teamA/lineups/1", + "/api/competitions/c1/teams/teamA/lineups/0", bytes.NewBuffer(body)) req.Header.Set("Content-Type", "application/json") req.Header.Set("X-Tournament-Password", "secret") @@ -479,7 +535,7 @@ func TestLineupPUT_MemberIDsInvalidPositionKey(t *testing.T) { }, }) req := httptest.NewRequest(http.MethodPut, - "/api/competitions/c1/teams/teamA/lineups/1", + "/api/competitions/c1/teams/teamA/lineups/0", bytes.NewBuffer(body)) req.Header.Set("Content-Type", "application/json") req.Header.Set("X-Tournament-Password", "secret") @@ -488,23 +544,19 @@ func TestLineupPUT_MemberIDsInvalidPositionKey(t *testing.T) { assert.Equal(t, http.StatusBadRequest, w.Code) } -// TestPublicLineupGET_FallbackBest: the scoring modal is the client-side -// twin of AMENDMENT 1. Operators typically save one round-0 lineup for -// the whole day, but a knockout final asks for its own round index (1+), -// and an exact-only GET answers nothing saved, leaving the modal with no -// names (UAT: the final's bootstrapped bout 1 was submitted with empty -// sides). With ?fallback=best the handler resolves via the FindBestLineup -// round tiers (highest round <= requested, else highest overall). Without -// the param the exact-round semantics are unchanged (the lineup editor -// relies on saved: false meaning "no lineup submitted for THIS round"). -func TestPublicLineupGET_FallbackBest(t *testing.T) { +// TestPublicLineupGET_RoundIsExact: the round GET reads exactly the round asked +// for. A team with a round-0 lineup and nothing for round 1 answers "nothing +// saved" for round 1, echoing the round asked for and never a lineup swapped in +// from another round: the Lineups page reads its starting lineup (round 0) this +// way. What a team fields at a match is lineup-in-force, not this route. +func TestPublicLineupGET_RoundIsExact(t *testing.T) { r, store, _ := setupLineupTestRouter(t) require.NoError(t, store.SaveCompetition(&state.Competition{ - ID: "c-fb", + ID: "c-exact", TeamSize: 5, })) - require.NoError(t, store.SetTeamLineup("c-fb", domain.TeamLineup{ + require.NoError(t, store.SetTeamLineup("c-exact", domain.TeamLineup{ TeamID: "teamA", Round: 0, Positions: map[domain.Position]string{ @@ -516,92 +568,44 @@ func TestPublicLineupGET_FallbackBest(t *testing.T) { }, }, 5)) - t.Run("exact miss with fallback=best returns the round-0 lineup", func(t *testing.T) { - req := httptest.NewRequest(http.MethodGet, - "/api/competitions/c-fb/teams/teamA/lineups/1?fallback=best", nil) + get := func(t *testing.T, path string) map[string]any { + t.Helper() + req := httptest.NewRequest(http.MethodGet, path, nil) w := httptest.NewRecorder() r.ServeHTTP(w, req) - require.Equal(t, http.StatusOK, w.Code, w.Body.String()) var body map[string]any require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body)) - assert.Equal(t, true, body["saved"]) - var got domain.TeamLineup - require.NoError(t, json.Unmarshal(w.Body.Bytes(), &got)) - assert.Equal(t, "teamA", got.TeamID) - assert.Equal(t, 0, got.Round, "round-0 lineup resolved for the round-1 request") - assert.Equal(t, "p1", got.Positions[domain.PosSenpo]) - }) + return body + } - t.Run("exact miss without the param answers nothing saved", func(t *testing.T) { - req := httptest.NewRequest(http.MethodGet, - "/api/competitions/c-fb/teams/teamA/lineups/1", nil) - w := httptest.NewRecorder() - r.ServeHTTP(w, req) - require.Equal(t, http.StatusOK, w.Code, w.Body.String()) - var body map[string]any - require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body)) - assert.Equal(t, false, body["saved"]) - assert.Equal(t, map[string]any{}, body["positions"]) - assert.Equal(t, float64(1), body["round"], "echoes the requested round, not the fallback's") - }) + // The second query is what an older bundle may still send. + for _, query := range []string{"", "?fallback=best"} { + t.Run("an exact miss answers nothing saved"+query, func(t *testing.T) { + body := get(t, "/api/competitions/c-exact/teams/teamA/lineups/1"+query) + assert.Equal(t, false, body["saved"]) + assert.Equal(t, map[string]any{}, body["positions"]) + assert.Equal(t, float64(1), body["round"], "echoes the requested round, not another's") + }) + } - t.Run("fallback=best with no round lineup at all answers nothing saved", func(t *testing.T) { - req := httptest.NewRequest(http.MethodGet, - "/api/competitions/c-fb/teams/teamB/lineups/1?fallback=best", nil) - w := httptest.NewRecorder() - r.ServeHTTP(w, req) - require.Equal(t, http.StatusOK, w.Code, w.Body.String()) - var body map[string]any - require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body)) - assert.Equal(t, false, body["saved"]) - assert.Equal(t, float64(1), body["round"]) + t.Run("an exact hit is the round asked for", func(t *testing.T) { + body := get(t, "/api/competitions/c-exact/teams/teamA/lineups/0") + assert.Equal(t, true, body["saved"]) + assert.Equal(t, float64(0), body["round"]) + assert.Equal(t, "p1", body["positions"].(map[string]any)["senpo"]) + }) - // A MATCH-scoped lineup for the SAME team must never satisfy the - // round-scoped fallback: FindBestLineupAny skips match-scoped - // entries by design (AMENDMENT 1), so seeding one here and still - // reading saved: false pins that it was never consulted. - require.NoError(t, store.SetTeamLineup("c-fb", domain.TeamLineup{ + t.Run("a match-scoped lineup of the team never answers a round read", func(t *testing.T) { + require.NoError(t, store.SetTeamLineup("c-exact", domain.TeamLineup{ TeamID: "teamB", MatchID: "Pool A-0", Positions: map[domain.Position]string{ - domain.PosSenpo: "m1", - domain.PosJiho: "m2", - domain.PosChuken: "m3", - domain.PosFukusho: "m4", - domain.PosTaisho: "m5", + domain.PosSenpo: "m1", }, }, 5)) - req2 := httptest.NewRequest(http.MethodGet, - "/api/competitions/c-fb/teams/teamB/lineups/1?fallback=best", nil) - w2 := httptest.NewRecorder() - r.ServeHTTP(w2, req2) - require.Equal(t, http.StatusOK, w2.Code, w2.Body.String()) - var body2 map[string]any - require.NoError(t, json.Unmarshal(w2.Body.Bytes(), &body2)) - assert.Equal(t, false, body2["saved"], "a match-scoped entry must not satisfy the round fallback") - }) - - t.Run("exact hit ignores the param", func(t *testing.T) { - require.NoError(t, store.SetTeamLineup("c-fb", domain.TeamLineup{ - TeamID: "teamA", - Round: 1, - Positions: map[domain.Position]string{ - domain.PosSenpo: "q1", - domain.PosJiho: "q2", - domain.PosChuken: "q3", - domain.PosFukusho: "q4", - domain.PosTaisho: "q5", - }, - }, 5)) - req := httptest.NewRequest(http.MethodGet, - "/api/competitions/c-fb/teams/teamA/lineups/1?fallback=best", nil) - w := httptest.NewRecorder() - r.ServeHTTP(w, req) - require.Equal(t, http.StatusOK, w.Code) - var got domain.TeamLineup - require.NoError(t, json.Unmarshal(w.Body.Bytes(), &got)) - assert.Equal(t, 1, got.Round, "exact round-1 lineup wins over fallback") + body := get(t, "/api/competitions/c-exact/teams/teamB/lineups/0") + assert.Equal(t, false, body["saved"], "a match-scoped entry is not a round's lineup") }) } @@ -619,6 +623,7 @@ func TestPublicLineupGET_UnknownCompetition(t *testing.T) { }{ {"round", "/api/competitions/no-such-comp/teams/teamA/lineups/1"}, {"match", "/api/competitions/no-such-comp/teams/teamA/match-lineups/m1"}, + {"in force", "/api/competitions/no-such-comp/teams/teamA/lineup-in-force/m1"}, } { t.Run(tc.name, func(t *testing.T) { req := httptest.NewRequest(http.MethodGet, tc.path, nil) @@ -659,3 +664,162 @@ func TestPublicLineupGET_BadParamsStay400(t *testing.T) { }) } } + +// inForceBody GETs the lineup-in-force route as an anonymous caller and decodes +// the RAW body, so a field going missing fails here rather than reading as its +// zero value. +func inForceBody(t *testing.T, r *gin.Engine, compID, teamID, matchID string) map[string]any { + t.Helper() + req := httptest.NewRequest(http.MethodGet, + "/api/competitions/"+compID+"/teams/"+url.PathEscape(teamID)+"/lineup-in-force/"+url.PathEscape(matchID), nil) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + require.Equal(t, http.StatusOK, w.Code, w.Body.String()) + var body map[string]any + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body)) + return body +} + +// TestPublicLineupInForceGET answers which lineup a team fields at a match +// (operator ruling 2026-10-05) for the three shapes it can come from, and +// nothing. Public like the other lineup reads, and never a 404 for "nothing +// saved". +func TestPublicLineupInForceGET(t *testing.T) { + r, store, _ := setupLineupTestRouter(t) + require.NoError(t, store.SaveTournament(&state.Tournament{Name: "Test", Password: "secret"})) + require.NoError(t, store.SaveCompetition(&state.Competition{ID: "c1", TeamSize: 5})) + require.NoError(t, store.SavePoolMatches("c1", []state.MatchResult{ + {ID: "Pool A-0", SideA: "A", SideAID: "teamA", SideB: "B", SideBID: "teamB"}, + {ID: "Pool A-1", SideA: "A", SideAID: "teamA", SideB: "C", SideBID: "teamC"}, + })) + save := func(l domain.TeamLineup) { + l.Positions = map[domain.Position]string{domain.PosSenpo: "p-" + l.MatchID + "-" + string(rune('0'+l.Round))} + require.NoError(t, store.SetTeamLineup("c1", l, 5)) + } + + t.Run("nothing saved is a 200 with saved false, echoing what was asked", func(t *testing.T) { + body := inForceBody(t, r, "c1", "teamA", "Pool A-0") + + assert.Equal(t, false, body["saved"], "never omitted") + assert.Equal(t, map[string]any{}, body["positions"], "empty positions, not null") + assert.Equal(t, "teamA", body["teamId"]) + assert.Equal(t, "c1", body["competitionId"]) + assert.Equal(t, "Pool A-0", body["matchId"]) + assert.NotContains(t, body, "sourceMatchId") + assert.NotContains(t, body, "sourceRound") + }) + + t.Run("a Lineups-page lineup names its round, round 0 being the starting lineup", func(t *testing.T) { + save(domain.TeamLineup{TeamID: "teamA", Round: 0}) + body := inForceBody(t, r, "c1", "teamA", "Pool A-0") + + assert.Equal(t, true, body["saved"]) + assert.Equal(t, float64(0), body["sourceRound"], "round 0 is present, not omitted") + assert.NotContains(t, body, "sourceMatchId") + assert.Equal(t, map[string]any{"senpo": "p--0"}, body["positions"]) + }) + + t.Run("a lineup saved for the match is its own", func(t *testing.T) { + save(domain.TeamLineup{TeamID: "teamA", MatchID: "Pool A-0"}) + body := inForceBody(t, r, "c1", "teamA", "Pool A-0") + + assert.Equal(t, true, body["saved"]) + assert.Equal(t, "Pool A-0", body["sourceMatchId"]) + assert.NotContains(t, body, "sourceRound") + assert.Equal(t, map[string]any{"senpo": "p-Pool A-0-0"}, body["positions"]) + }) + + t.Run("the team's next match carries it, and names the match it came from", func(t *testing.T) { + body := inForceBody(t, r, "c1", "teamA", "Pool A-1") + + assert.Equal(t, true, body["saved"]) + assert.Equal(t, "Pool A-0", body["sourceMatchId"]) + assert.NotContains(t, body, "sourceRound") + assert.Equal(t, map[string]any{"senpo": "p-Pool A-0-0"}, body["positions"]) + }) + + t.Run("another team carries nothing of it", func(t *testing.T) { + body := inForceBody(t, r, "c1", "teamC", "Pool A-1") + + assert.Equal(t, false, body["saved"]) + }) +} + +// A damaged match file costs the read the part of the draw it held, never the +// read: the lineups are intact, so a pool match's carried lineup is still +// answered when bracket.json cannot be read, and the 500 stays for a lineups +// file that cannot be (TestPublicLineupInForceGET_ReadFailureIs500). +func TestPublicLineupInForceGET_AnUnreadableBracketStillAnswers(t *testing.T) { + r, store, dir := setupLineupTestRouter(t) + require.NoError(t, store.SaveCompetition(&state.Competition{ID: "c1", TeamSize: 5})) + require.NoError(t, store.SavePoolMatches("c1", []state.MatchResult{ + {ID: "Pool A-0", SideA: "A", SideAID: "teamA", SideB: "B", SideBID: "teamB"}, + {ID: "Pool A-1", SideA: "A", SideAID: "teamA", SideB: "C", SideBID: "teamC"}, + })) + require.NoError(t, os.WriteFile(filepath.Join(dir, "competitions", "c1", "bracket.json"), []byte("{not json"), 0o600)) + require.NoError(t, store.SetTeamLineup("c1", domain.TeamLineup{ + TeamID: "teamA", MatchID: "Pool A-0", Positions: map[domain.Position]string{domain.PosSenpo: "carried"}, + }, 5)) + + body := inForceBody(t, r, "c1", "teamA", "Pool A-1") + + assert.Equal(t, true, body["saved"]) + assert.Equal(t, "Pool A-0", body["sourceMatchId"], "carried from the previous match, which the pool file still places") + assert.Equal(t, map[string]any{"senpo": "carried"}, body["positions"]) +} + +// TestPublicLineupInForceGET_NoAuthRequired: like the other lineup reads, no +// password is asked even when the tournament has one. +func TestPublicLineupInForceGET_NoAuthRequired(t *testing.T) { + r, store, _ := setupLineupTestRouter(t) + require.NoError(t, store.SaveTournament(&state.Tournament{Name: "Test", Password: "secret"})) + require.NoError(t, store.SaveCompetition(&state.Competition{ID: "c1", TeamSize: 5})) + + req := httptest.NewRequest(http.MethodGet, "/api/competitions/c1/teams/teamA/lineup-in-force/m1", nil) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + assert.Equal(t, http.StatusOK, w.Code, w.Body.String()) +} + +func TestPublicLineupInForceGET_BadParamsStay400(t *testing.T) { + r, _, _ := setupLineupTestRouter(t) + + for _, tc := range []struct { + name, path string + }{ + {"bad competition id format", "/api/competitions/bad.id/teams/teamA/lineup-in-force/m1"}, + {"empty team id", "/api/competitions/c1/teams//lineup-in-force/m1"}, + } { + t.Run(tc.name, func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, tc.path, nil) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + assert.Equal(t, http.StatusBadRequest, w.Code, w.Body.String()) + }) + } +} + +// failingLineupEngine is a LineupEngine whose read fails. +type failingLineupEngine struct{} + +func (failingLineupEngine) LineupInForce(string, string, string) (engine.InForceLineup, error) { + return engine.InForceLineup{}, errors.New("read lineups.yaml: input/output error") +} + +// A read that fails is the server's fault: a 500, not an empty "nothing saved" +// that would let the sheet show a blank lineup over one it could not read. +func TestPublicLineupInForceGET_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{}) + + req := httptest.NewRequest(http.MethodGet, "/api/competitions/c1/teams/teamA/lineup-in-force/m1", nil) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + assert.Equal(t, http.StatusInternalServerError, w.Code, w.Body.String()) +} diff --git a/internal/mobileapp/handlers_round_lineups_marker_test.go b/internal/mobileapp/handlers_round_lineups_marker_test.go new file mode 100644 index 000000000..49a92951a --- /dev/null +++ b/internal/mobileapp/handlers_round_lineups_marker_test.go @@ -0,0 +1,104 @@ +package mobileapp + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "testing" + + "github.com/gitrgoliveira/bracket-creator/internal/domain" + "github.com/gitrgoliveira/bracket-creator/internal/helper" + "github.com/gitrgoliveira/bracket-creator/internal/state" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// A competition created by this release starts with the round-lineup marker +// set, whatever its kind: it has no lineup for a round to move onto a match, +// and no writer left that creates one (PUT .../lineups/:round refuses a round +// above 0), so the load repair and the writes that seat a team have nothing to +// settle for it and skip it. +func TestCreateCompetition_StartsWithTheRoundLineupMarker(t *testing.T) { + r, store, _, _, tempDir := setupTestRouter(t) + defer os.RemoveAll(tempDir) + for _, body := range []map[string]any{ + {"id": "create-fixed-team", "name": "Create Fixed Team", "kind": "team", "teamSize": 3, "format": state.CompFormatKnockout}, + {"id": "create-kachinuki-team", "name": "Create Kachinuki Team", "kind": "team", "teamSize": 3, + "teamMatchType": state.TeamMatchTypeKachinuki, "format": state.CompFormatMixed}, + {"id": "create-individual", "name": "Create Individual", "kind": "individual", "format": state.CompFormatKnockout}, + } { + id := body["id"].(string) + raw, err := json.Marshal(body) + require.NoError(t, err) + w := httptest.NewRecorder() + req, _ := http.NewRequest("POST", "/api/competitions", bytes.NewBuffer(raw)) + req.Header.Set("Content-Type", "application/json") + r.ServeHTTP(w, req) + require.Equal(t, http.StatusCreated, w.Code, w.Body.String()) + + comp, err := store.LoadCompetition(id) + require.NoError(t, err) + require.NotNil(t, comp) + assert.True(t, comp.RoundLineupsConverted, id) + } +} + +// The settings PUT merges what a client sends onto the stored record, and the +// round-lineup conversion's own fields are off the wire: a settings save made +// while the conversion is under way keeps the pairs it has settled, the legacy +// teams it found (or its marker), so it cannot give a lineup the operator removed +// back, nor make a team legacy again that a match lineup saved since would. +func TestPUTCompetition_KeepsWhatTheRoundLineupConversionRecorded(t *testing.T) { + for _, tc := range []struct { + name string + given map[string][]string + legacy []string + converted bool + }{ + // A record that lists no legacy team reads as one the first settlement has + // not run for, which that settlement would fill in, so every row that is not + // marked lists them. + {"the pairs settled", map[string][]string{"team-a": {"r0-m0", "r1-m0"}}, []string{"team-a"}, false}, + {"the legacy teams", nil, []string{"team-a", "team-b"}, false}, + {"the marker", nil, nil, true}, + } { + t.Run(tc.name, func(t *testing.T) { + r, store, _, _, tempDir := setupTestRouter(t) + defer os.RemoveAll(tempDir) + const compID = "round-lineups-record" + seed := state.Competition{ + ID: compID, Name: "Round Lineups", Kind: "team", TeamSize: 3, Format: state.CompFormatKnockout, + PoolSize: 3, Courts: []string{"A"}, RoundLineupsGiven: tc.given, RoundLineupsLegacy: tc.legacy, + RoundLineupsConverted: tc.converted, + } + require.NoError(t, store.SaveCompetition(&seed)) + // A team with a lineup for a later round: the conversion has it to wait + // for, so it does not clear the pairs the seed records. + tora := helper.NewUUID4() + require.NoError(t, store.SaveParticipants(compID, []domain.Player{{ID: tora, Name: "Tora", Dojo: "A"}})) + require.NoError(t, store.SetTeamLineup(compID, domain.TeamLineup{ + TeamID: tora, Round: 1, Positions: map[domain.Position]string{domain.PositionNumbered(1): "Sato"}, + }, 3)) + + update := seed + update.Name = "Round Lineups Renamed" + body, err := json.Marshal(update) // both fields are json:"-": a client never sends them + require.NoError(t, err) + w := httptest.NewRecorder() + req, _ := http.NewRequest("PUT", "/api/competitions/"+compID, bytes.NewBuffer(body)) + req.Header.Set("Content-Type", "application/json") + r.ServeHTTP(w, req) + require.Equal(t, http.StatusOK, w.Code, w.Body.String()) + + stored, err := store.LoadCompetition(compID) + require.NoError(t, err) + require.NotNil(t, stored) + assert.Equal(t, "Round Lineups Renamed", stored.Name) + assert.Equal(t, tc.given, stored.RoundLineupsGiven) + assert.Equal(t, tc.legacy, stored.RoundLineupsLegacy) + assert.Equal(t, tc.converted, stored.RoundLineupsConverted) + }) + } +} diff --git a/internal/mobileapp/handlers_round_lineups_upgrade_test.go b/internal/mobileapp/handlers_round_lineups_upgrade_test.go new file mode 100644 index 000000000..fe5be9b4e --- /dev/null +++ b/internal/mobileapp/handlers_round_lineups_upgrade_test.go @@ -0,0 +1,189 @@ +package mobileapp + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" + + "github.com/gin-gonic/gin" + "github.com/gitrgoliveira/bracket-creator/internal/domain" + "github.com/gitrgoliveira/bracket-creator/internal/engine" + "github.com/gitrgoliveira/bracket-creator/internal/helper" + "github.com/gitrgoliveira/bracket-creator/internal/state" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "gopkg.in/yaml.v3" +) + +// A competition recorded by v2.1.1 with a Lineups-page lineup for round 1 and +// one for round 2 (stored as rounds 0 and 1): once the data folder is loaded, the +// lineup-in-force read gives the team, at each match it is seated in, the lineup +// v2.1.1 showed there, naming the match it now belongs to. The round lineup +// itself is kept until the competition is completed. +func TestPublicLineupInForceGET_AfterTheRoundLineupsAreConverted(t *testing.T) { + gin.SetMode(gin.TestMode) + dir := t.TempDir() + seed, err := state.NewStore(dir) + require.NoError(t, err) + const compID = "recorded-by-v211" + tora, kuma, usagi, saru := helper.NewUUID4(), helper.NewUUID4(), helper.NewUUID4(), helper.NewUUID4() + require.NoError(t, seed.SaveCompetition(&state.Competition{ + ID: compID, Name: "Recorded By v2.1.1", Kind: "team", TeamSize: 3, Format: state.CompFormatKnockout, Status: state.CompStatusKnockout, + })) + require.NoError(t, seed.SaveParticipants(compID, []domain.Player{ + {ID: tora, Name: "Tora", Dojo: "A"}, {ID: kuma, Name: "Kuma", Dojo: "B"}, + {ID: usagi, Name: "Usagi", Dojo: "C"}, {ID: saru, Name: "Saru", Dojo: "D"}, + })) + require.NoError(t, seed.SaveBracket(compID, &state.Bracket{Rounds: [][]state.BracketMatch{ + { + {ID: "r0-m0", SideA: "Tora", SideAID: tora, SideB: "Kuma", SideBID: kuma, Status: state.MatchStatusCompleted, Winner: "Tora", WinnerID: tora}, + {ID: "r0-m1", SideA: "Usagi", SideAID: usagi, SideB: "Saru", SideBID: saru, Status: state.MatchStatusCompleted, Winner: "Usagi", WinnerID: usagi}, + }, + {{ID: "r1-m0", SideA: "Tora", SideAID: tora, SideB: "Usagi", SideBID: usagi}}, + }})) + // What v2.1.1 left in lineups.yaml, and no marker: it recorded none. + stored, err := seed.LoadCompetition(compID) + require.NoError(t, err) + stored.RoundLineupsConverted = false + require.NoError(t, seed.SaveCompetition(stored)) + lineup := func(round int, name string) domain.TeamLineup { + return domain.TeamLineup{ + TeamID: tora, CompetitionID: compID, Round: round, + Positions: map[domain.Position]string{domain.PositionNumbered(1): name}, + } + } + raw, err := yaml.Marshal(struct { + Lineups []domain.TeamLineup `yaml:"lineups"` + }{Lineups: []domain.TeamLineup{lineup(0, "Sato"), lineup(1, "Ito")}}) + require.NoError(t, err) + require.NoError(t, os.WriteFile(filepath.Join(dir, "competitions", compID, "lineups.yaml"), raw, 0o600)) + + store, err := state.NewStore(dir) + require.NoError(t, err) + r := gin.New() + RegisterPublicLineupHandlers(r.Group("/api"), store, store, engine.New(store)) + + first := inForceBody(t, r, compID, tora, "r0-m0") + assert.Equal(t, true, first["saved"]) + assert.Equal(t, map[string]any{"1": "Sato"}, first["positions"], "at its first match the team fields what v2.1.1 showed there: the lineup saved for round 1") + assert.Equal(t, "r0-m0", first["sourceMatchId"], "which is now the lineup of that match") + assert.NotContains(t, first, "sourceRound") + + final := inForceBody(t, r, compID, tora, "r1-m0") + assert.Equal(t, true, final["saved"]) + assert.Equal(t, map[string]any{"1": "Ito"}, final["positions"], "from its round 2 match the team fields the lineup saved for round 2") + assert.Equal(t, "r1-m0", final["sourceMatchId"], "which is now the lineup of that match") + assert.NotContains(t, final, "sourceRound") + + start := inForceBody(t, r, compID, tora, "a-match-the-draw-does-not-hold") + assert.Equal(t, true, start["saved"]) + assert.Equal(t, map[string]any{"1": "Sato"}, start["positions"], "a match the draw does not hold gets the starting lineup") + assert.Equal(t, float64(0), start["sourceRound"]) + assert.NotContains(t, start, "sourceMatchId") + + waiting := inForceBody(t, r, compID, usagi, "r1-m0") + assert.Equal(t, false, waiting["saved"], "a team with no lineup has none in force") + + roundOneSaved := func(r *gin.Engine) any { + req := httptest.NewRequest(http.MethodGet, "/api/competitions/"+compID+"/teams/"+tora+"/lineups/1", nil) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + require.Equal(t, http.StatusOK, w.Code, w.Body.String()) + var roundOne map[string]any + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &roundOne)) + return roundOne["saved"] + } + assert.Equal(t, true, roundOneSaved(r), "while the competition is on the round lineup is kept: a correction can still seat a team") + + // Completed in a write the draw's hooks do not see: the next load retires it. + _, err = store.UpdateCompetitionChanged(compID, func(c *state.Competition) (*state.Competition, error) { + c.Status = state.CompStatusComplete + return c, nil + }) + require.NoError(t, err) + restarted, err := state.NewStore(dir) + require.NoError(t, err) + r = gin.New() + RegisterPublicLineupHandlers(r.Group("/api"), restarted, restarted, engine.New(restarted)) + + assert.Equal(t, false, roundOneSaved(r), "the round lineup itself is gone: each match it was shown at holds its own lineup") + again := inForceBody(t, r, compID, tora, "r1-m0") + assert.Equal(t, map[string]any{"1": "Ito"}, again["positions"]) + assert.Equal(t, "r1-m0", again["sourceMatchId"]) +} + +// A team that v2.1.1 had a lineup entered for a match for, and none for a round, +// was shown nothing at its other matches (operator decision 2026-10-07: "Show what +// v2.1.1 showed"). Once the data folder is loaded each of those matches holds an +// EMPTY lineup of its own, and both lineup reads answer it as a saved one: saved +// true, the match itself as the source, and an empty positions object (never a +// missing one), where an unsaved lineup answers saved false. A team with no +// lineup at all has none in force. +func TestPublicLineupReads_AMatchOnlyLegacyTeamIsShownNothingElsewhere(t *testing.T) { + gin.SetMode(gin.TestMode) + dir := t.TempDir() + seed, err := state.NewStore(dir) + require.NoError(t, err) + const compID = "match-only-by-v211" + tora, kuma, usagi, saru := helper.NewUUID4(), helper.NewUUID4(), helper.NewUUID4(), helper.NewUUID4() + require.NoError(t, seed.SaveCompetition(&state.Competition{ + ID: compID, Name: "Match Only By v2.1.1", Kind: "team", TeamSize: 3, Format: state.CompFormatKnockout, Status: state.CompStatusKnockout, + })) + require.NoError(t, seed.SaveParticipants(compID, []domain.Player{ + {ID: tora, Name: "Tora", Dojo: "A"}, {ID: kuma, Name: "Kuma", Dojo: "B"}, + {ID: usagi, Name: "Usagi", Dojo: "C"}, {ID: saru, Name: "Saru", Dojo: "D"}, + })) + require.NoError(t, seed.SaveBracket(compID, &state.Bracket{Rounds: [][]state.BracketMatch{ + { + {ID: "r0-m0", SideA: "Tora", SideAID: tora, SideB: "Kuma", SideBID: kuma, Status: state.MatchStatusCompleted, Winner: "Tora", WinnerID: tora}, + {ID: "r0-m1", SideA: "Usagi", SideAID: usagi, SideB: "Saru", SideBID: saru, Status: state.MatchStatusCompleted, Winner: "Usagi", WinnerID: usagi}, + }, + {{ID: "r1-m0", SideA: "Tora", SideAID: tora, SideB: "Usagi", SideBID: usagi}}, + }})) + stored, err := seed.LoadCompetition(compID) + require.NoError(t, err) + stored.RoundLineupsConverted = false + require.NoError(t, seed.SaveCompetition(stored)) + raw, err := yaml.Marshal(struct { + Lineups []domain.TeamLineup `yaml:"lineups"` + }{Lineups: []domain.TeamLineup{{ + TeamID: tora, CompetitionID: compID, MatchID: "r0-m0", + Positions: map[domain.Position]string{domain.PositionNumbered(1): "Sato"}, + }}}) + require.NoError(t, err) + require.NoError(t, os.WriteFile(filepath.Join(dir, "competitions", compID, "lineups.yaml"), raw, 0o600)) + + store, err := state.NewStore(dir) + require.NoError(t, err) + r := gin.New() + RegisterPublicLineupHandlers(r.Group("/api"), store, store, engine.New(store)) + + entered := inForceBody(t, r, compID, tora, "r0-m0") + assert.Equal(t, true, entered["saved"]) + assert.Equal(t, map[string]any{"1": "Sato"}, entered["positions"], "the lineup entered is left as it is") + assert.Equal(t, "r0-m0", entered["sourceMatchId"]) + + final := inForceBody(t, r, compID, tora, "r1-m0") + assert.Equal(t, true, final["saved"], "an empty lineup of its own is a saved one") + assert.Equal(t, "r1-m0", final["sourceMatchId"], "it belongs to the match itself, not to the match it was entered for") + assert.NotContains(t, final, "sourceRound") + assert.Equal(t, map[string]any{}, final["positions"], "an empty object, not a missing field") + assert.Equal(t, "r1-m0", final["matchId"]) + assert.Equal(t, tora, final["teamId"]) + + // The match-lineup read of the same match: the same stored lineup. + req := httptest.NewRequest(http.MethodGet, "/api/competitions/"+compID+"/teams/"+tora+"/match-lineups/r1-m0", nil) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + require.Equal(t, http.StatusOK, w.Code, w.Body.String()) + var own map[string]any + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &own)) + assert.Equal(t, true, own["saved"]) + assert.Equal(t, map[string]any{}, own["positions"]) + + nothing := inForceBody(t, r, compID, kuma, "r0-m0") + assert.Equal(t, false, nothing["saved"], "a team with no lineup at all has none in force") +} diff --git a/internal/mobileapp/handlers_squad.go b/internal/mobileapp/handlers_squad.go index d05b72667..caf5ccbd5 100644 --- a/internal/mobileapp/handlers_squad.go +++ b/internal/mobileapp/handlers_squad.go @@ -19,36 +19,35 @@ // (isSelfRunMainGatedConfigRoute, middleware.go): the public page reads team // members from the viewer payload and never clears one. // -// ADD is deliberately silent; RENAME and CLEAR are not, and the split is the -// point. The original rule was that squad edits are setup done by one -// organiser, not the concurrent multi-device traffic the lineup broadcast -// exists for, so a member added on one device is invisible to a second admin -// session until it remounts. That consequence is still accepted for ADD. On -// the public score sheet an add is followed by the match lineup PUT, which -// broadcasts, so the other devices refetch the members anyway. -// -// Rename and clear outgrew it. They now rewrite lineups.yaml as well +// Every write fires EventLineupUpdated: ADD, RENAME and CLEAR. Rename and clear +// rewrite lineups.yaml as well as team-members.yaml // (state.renameMemberInLineupsLocked), because a lineup position stores a // display copy of the member's name that the score sheet and the export read. -// Every other writer of that file fires EventLineupUpdated, and without it the -// failure is not staleness but LOSS: a second admin holding a pre-rename -// lineup makes any unrelated inline pick, its write spreads the whole stale -// positions map, and the operator's correction is reverted on disk. So these -// two fire the EXISTING lineup event rather than a new squad one, which is why -// no squad reader needs a new subscriber. +// Every other writer of that file fires the event, and without it the failure +// is not staleness but LOSS: a second admin holding a pre-rename lineup makes +// any unrelated inline pick, its write spreads the whole stale positions map, +// and the operator's correction is reverted on disk. An ADD was silent until a +// member began to carry a server stamp (operator decision 2026-10-07): the other +// devices keep, of two copies of a member, the one with the larger stamp, so a +// device that added one must be told to read the team's members again. All three +// fire the EXISTING lineup event rather than a new members one, which is why no +// reader of a team's members needs a new subscriber. The event names the team +// (lineupUpdatedPayload) and no match, since a rename reaches the team's +// lineups at every match. // // They fire it unconditionally, without asking whether a position actually // changed. A spurious refetch costs one request; a missed one costs the // rename. Same safe direction bumpFileVersion takes in the store. // +// The three writes answer with the member as written, stamped (POST 201, PUT +// and DELETE 200), and GET team-members carries each member's stamp, so a client +// that holds the answer holds the copy this write produced. +// // The public READ surfaces do not call these routes: the viewer, the court // display and the streaming overlay read a team's squad from the viewer -// payload (handlers_viewer.go). A rename or a clear reaches them through that -// same lineup event, on which the SPA refetches the payload (app.jsx). An add -// reaches them only on their next payload fetch, which some OTHER broadcast -// triggers: on the public score sheet the lineup PUT that follows it, and on -// the Lineups page, where an add is setup, whatever broadcast comes next; the -// label it feeds is enrichment beside a name that is already correct. +// payload (handlers_viewer.go), which carries the same stamps. A write reaches +// them through that same lineup event, on which the SPA refetches the payload +// (app.jsx). package mobileapp import ( @@ -130,6 +129,7 @@ func RegisterSquadHandlers(r *gin.RouterGroup, store SquadStore, comps Competiti return } c.JSON(http.StatusCreated, member) + hub.Broadcast(EventLineupUpdated, lineupUpdatedPayload(compID, teamID, "")) }) r.PUT("/competitions/:id/teams/:tid/members/:memberId", func(c *gin.Context) { @@ -168,12 +168,13 @@ func RegisterSquadHandlers(r *gin.RouterGroup, store SquadStore, comps Competiti if anonymous { rename = store.NameUnnamedTeamMember } - if err := rename(compID, teamID, memberID, req.Name); err != nil { + member, err := rename(compID, teamID, memberID, req.Name) + if err != nil { respondSquadWriteError(c, err) return } - c.Status(http.StatusNoContent) - hub.Broadcast(EventLineupUpdated, gin.H{"competitionId": compID}) + c.JSON(http.StatusOK, member) + hub.Broadcast(EventLineupUpdated, lineupUpdatedPayload(compID, teamID, "")) }) // DELETE is the operator's "removal": it clears the member's Name back @@ -196,12 +197,13 @@ func RegisterSquadHandlers(r *gin.RouterGroup, store SquadStore, comps Competiti c.JSON(http.StatusBadRequest, gin.H{"error": "member ID is required"}) return } - if err := store.ClearTeamMemberName(compID, teamID, memberID); err != nil { + member, err := store.ClearTeamMemberName(compID, teamID, memberID) + if err != nil { respondSquadWriteError(c, err) return } - c.Status(http.StatusNoContent) - hub.Broadcast(EventLineupUpdated, gin.H{"competitionId": compID}) + c.JSON(http.StatusOK, member) + hub.Broadcast(EventLineupUpdated, lineupUpdatedPayload(compID, teamID, "")) }) } diff --git a/internal/mobileapp/handlers_squad_test.go b/internal/mobileapp/handlers_squad_test.go index 8581d620b..54df2bd30 100644 --- a/internal/mobileapp/handlers_squad_test.go +++ b/internal/mobileapp/handlers_squad_test.go @@ -135,7 +135,7 @@ func TestSquadHandlers_RenameMember(t *testing.T) { req := squadJSONReq(http.MethodPut, "/api/competitions/c1/teams/"+teamID+"/members/"+member.ID, "secret", SquadMemberRequest{Name: "Alicia"}) w := httptest.NewRecorder() r.ServeHTTP(w, req) - require.Equal(t, http.StatusNoContent, w.Code, w.Body.String()) + require.Equal(t, http.StatusOK, w.Code, w.Body.String()) squads, err := store.LoadSquads("c1") require.NoError(t, err) @@ -215,7 +215,7 @@ func TestSquadHandlers_ClearMember(t *testing.T) { req := squadJSONReq(http.MethodDelete, "/api/competitions/c1/teams/"+teamID+"/members/"+member.ID, "secret", nil) w := httptest.NewRecorder() r.ServeHTTP(w, req) - require.Equal(t, http.StatusNoContent, w.Code, w.Body.String()) + require.Equal(t, http.StatusOK, w.Code, w.Body.String()) squads, err := store.LoadSquads("c1") require.NoError(t, err) @@ -281,9 +281,10 @@ func TestSquadHandlers_AddToUnknownTeamIs404(t *testing.T) { // Without it the failure is LOSS, not staleness: a second admin holding a // pre-rename lineup makes any unrelated inline pick, its write spreads the // whole stale positions map, and the operator's correction is reverted on -// disk. ADD stays silent on purpose and is asserted here so the split cannot -// be "tidied" into one rule. -func TestSquadHandlers_RenameAndClearBroadcastTheLineupEvent(t *testing.T) { +// disk. An add fires it too (operator decision 2026-10-07, when a member began +// to carry a server stamp): a device that added a member is then seen by the +// others, whose copy of the team's members would otherwise lack it. +func TestSquadHandlers_EveryMemberWriteBroadcastsTheLineupEvent(t *testing.T) { gin.SetMode(gin.TestMode) dir, err := os.MkdirTemp("", "squad-bcast-*") @@ -308,11 +309,12 @@ func TestSquadHandlers_RenameAndClearBroadcastTheLineupEvent(t *testing.T) { base := "/api/competitions/c1/teams/" + teamID + "/members" - // ADD: deliberately silent. + // ADD: fires, so the other devices read the team's members again. w := httptest.NewRecorder() r.ServeHTTP(w, squadJSONReq(http.MethodPost, base, "secret", map[string]any{"name": "Sato"})) require.Equal(t, http.StatusCreated, w.Code, w.Body.String()) - assert.Empty(t, hub.events, "adding a member stays silent, as its own rationale says") + require.Len(t, hub.events, 1, "an add must announce the member it made") + assert.Equal(t, EventLineupUpdated, hub.events[0]) var added domain.TeamMember require.NoError(t, json.Unmarshal(w.Body.Bytes(), &added)) @@ -321,15 +323,15 @@ func TestSquadHandlers_RenameAndClearBroadcastTheLineupEvent(t *testing.T) { // RENAME: fires, because it rewrote lineups.yaml too. w = httptest.NewRecorder() r.ServeHTTP(w, squadJSONReq(http.MethodPut, base+"/"+added.ID, "secret", map[string]any{"name": "Sato Kenji"})) - require.Equal(t, http.StatusNoContent, w.Code, w.Body.String()) - require.Len(t, hub.events, 1, "a rename must announce the lineup change it made") - assert.Equal(t, EventLineupUpdated, hub.events[0], + require.Equal(t, http.StatusOK, w.Code, w.Body.String()) + require.Len(t, hub.events, 2, "a rename must announce the lineup change it made") + assert.Equal(t, EventLineupUpdated, hub.events[1], "the EXISTING lineup event, so no squad reader needs a new subscriber") // CLEAR: fires for the same reason. w = httptest.NewRecorder() r.ServeHTTP(w, squadJSONReq(http.MethodDelete, base+"/"+added.ID, "secret", nil)) - require.Equal(t, http.StatusNoContent, w.Code, w.Body.String()) - require.Len(t, hub.events, 2) - assert.Equal(t, EventLineupUpdated, hub.events[1]) + require.Equal(t, http.StatusOK, w.Code, w.Body.String()) + require.Len(t, hub.events, 3) + assert.Equal(t, EventLineupUpdated, hub.events[2]) } diff --git a/internal/mobileapp/handlers_team_member_stamp_test.go b/internal/mobileapp/handlers_team_member_stamp_test.go new file mode 100644 index 000000000..e37662929 --- /dev/null +++ b/internal/mobileapp/handlers_team_member_stamp_test.go @@ -0,0 +1,133 @@ +package mobileapp + +// handlers_team_member_stamp_test.go pins what the team member routes answer now +// that a member carries a server stamp (operator decision 2026-10-07). POST +// answers 201 with the member, stamped, and announces it like every other member +// write; PUT and DELETE answer 200 with the member, where they answered 204 with +// no body, so a client holding the answer holds the copy this write produced. GET +// team-members carries each member's stamp, 0 for one no write has touched. + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "testing" + + "github.com/gin-gonic/gin" + "github.com/gitrgoliveira/bracket-creator/internal/domain" + "github.com/gitrgoliveira/bracket-creator/internal/state" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// stampFixture is an officiated team competition with one team, its member +// routes and a broadcaster that keeps what each event carried. +type stampFixture struct { + r *gin.Engine + hub *payloadBroadcaster + teamID string + base string +} + +func newStampFixture(t *testing.T) stampFixture { + t.Helper() + gin.SetMode(gin.TestMode) + dir, err := os.MkdirTemp("", "member-stamp-*") + require.NoError(t, err) + t.Cleanup(func() { os.RemoveAll(dir) }) + store, err := state.NewStore(dir) + require.NoError(t, err) + require.NoError(t, store.SaveTournament(&state.Tournament{Name: "Test", Password: "secret"})) + require.NoError(t, store.SaveCompetition(&state.Competition{ID: "c1", Kind: "team", TeamSize: 3})) + require.NoError(t, store.SaveParticipants("c1", []domain.Player{{Name: "Tora", Dojo: "Tora Dojo"}})) + teams, err := store.LoadParticipants("c1", false) + require.NoError(t, err) + require.Len(t, teams, 1) + + hub := &payloadBroadcaster{} + r := gin.New() + admin := r.Group("/api") + admin.Use(AuthMiddleware(NewFileVerifier(store), store)) + RegisterSquadHandlers(admin, store, store, hub, store, NewFileVerifier(store)) + return stampFixture{r: r, hub: hub, teamID: teams[0].ID, base: "/api/competitions/c1/teams/" + teams[0].ID + "/members"} +} + +func (f stampFixture) send(method, path string, body any) *httptest.ResponseRecorder { + w := httptest.NewRecorder() + f.r.ServeHTTP(w, squadJSONReq(method, path, "secret", body)) + return w +} + +func memberFrom(t *testing.T, w *httptest.ResponseRecorder, status int) domain.TeamMember { + t.Helper() + require.Equal(t, status, w.Code, w.Body.String()) + var m domain.TeamMember + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &m), w.Body.String()) + return m +} + +func TestTeamMemberAnswers_CarryTheMemberAndItsStamp(t *testing.T) { + f := newStampFixture(t) + + added := memberFrom(t, f.send(http.MethodPost, f.base, map[string]any{"name": "Sato"}), http.StatusCreated) + require.NotEmpty(t, added.ID) + assert.Equal(t, "Sato", added.Name) + assert.Positive(t, added.ModifiedAt, "a new member is stamped") + assert.Equal(t, gin.H{"competitionId": "c1", "teamId": f.teamID}, f.hub.last(t), "an add is announced like every other write to a team's members") + + renamed := memberFrom(t, f.send(http.MethodPut, f.base+"/"+added.ID, map[string]any{"name": "Sato Kenji"}), http.StatusOK) + assert.Equal(t, added.ID, renamed.ID) + assert.Equal(t, added.Index, renamed.Index) + assert.Equal(t, "Sato Kenji", renamed.Name) + assert.Greater(t, renamed.ModifiedAt, added.ModifiedAt, "a rename stamps the member again, later") + + cleared := memberFrom(t, f.send(http.MethodDelete, f.base+"/"+added.ID, nil), http.StatusOK) + assert.Equal(t, added.ID, cleared.ID) + assert.Empty(t, cleared.Name) + assert.Greater(t, cleared.ModifiedAt, renamed.ModifiedAt, "so does a clear") +} + +func TestTeamMemberAnswers_TheListCarriesEachStamp(t *testing.T) { + f := newStampFixture(t) + added := memberFrom(t, f.send(http.MethodPost, f.base, map[string]any{"name": "Sato"}), http.StatusCreated) + require.Positive(t, added.ModifiedAt) + + w := f.send(http.MethodGet, "/api/competitions/c1/team-members", nil) + + require.Equal(t, http.StatusOK, w.Code, w.Body.String()) + var body struct { + TeamMembers map[string][]map[string]any `json:"teamMembers"` + } + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body)) + var stamped, untouched int + for _, m := range body.TeamMembers[f.teamID] { + stamp, present := m["modifiedAt"] + require.True(t, present, "every member carries the field, whether or not a write has touched it: %v", m) + if m["id"] == added.ID { + assert.EqualValues(t, added.ModifiedAt, stamp) + stamped++ + } else { + assert.EqualValues(t, 0, stamp, "a seeded slot no write has touched carries 0") + untouched++ + } + } + assert.Equal(t, 1, stamped) + assert.Positive(t, untouched) +} + +// A participant of a self-run tournament names a member who has no name from the +// public score sheet; the answer is the member as stamped there as well. +func TestTeamMemberAnswers_AnAnonymousNamingAnswersWithTheMember(t *testing.T) { + f := newTeamWritesFixture(t, true) + + w := f.send(http.MethodPut, f.membersPath()+"/"+f.blankA, "", map[string]any{"name": "Ren Abe"}) + + named := memberFrom(t, w, http.StatusOK) + assert.Equal(t, f.blankA, named.ID) + assert.Equal(t, "Ren Abe", named.Name) + assert.Positive(t, named.ModifiedAt) + + w = f.send(http.MethodPost, f.membersPath(), "", map[string]any{"name": "Added Late"}) + assert.Positive(t, memberFrom(t, w, http.StatusCreated).ModifiedAt) +} diff --git a/internal/mobileapp/handlers_viewer.go b/internal/mobileapp/handlers_viewer.go index b5961c877..e29fd6f64 100644 --- a/internal/mobileapp/handlers_viewer.go +++ b/internal/mobileapp/handlers_viewer.go @@ -252,7 +252,7 @@ func buildViewerCompetitionPayload(store *state.Store, compID, courtFilter strin // bc-pnum: "make a team member's label available to the public // surfaces". Same gate as the detail endpoint above (isTeamComp, // buildViewerCompetitionPayload's caller for GET /competitions/:id): - // Kind == "team" || TeamSize > 0, so an individual competition never + // Competition.IsTeam, so an individual competition never // attempts a team-members.yaml read at all. This matters MORE here than on // the detail endpoint: this function runs once per competition in the // tournament (buildViewerCompetitionPayloads' safeGo fan-out) on the @@ -261,7 +261,7 @@ func buildViewerCompetitionPayload(store *state.Store, compID, courtFilter strin // display and the streaming overlay -- an ungated read would multiply // an unnecessary stat+parse across every individual competition in the // tournament on every such poll, not just cost one extra read. - isTeamComp := comp.Kind == "team" || comp.TeamSize > 0 + isTeamComp := comp.IsTeam() // Global views like Scoring/Schedule need matches and brackets. poolMatches, pmErr := store.LoadPoolMatches(compID) @@ -632,14 +632,13 @@ func RegisterViewerHandlers(r *gin.RouterGroup, store *state.Store, eng *engine. // bc-pnum: "make a team member's label available to the public // surfaces". Gated on the same team-competition discriminator the - // rest of the codebase uses (Kind == "team" || TeamSize > 0, e.g. - // Competition.IsKachinuki's own condition and the JS twin in + // rest of the codebase uses (Competition.IsTeam, whose JS twin is in // admin_schedule_lineup.jsx) so an individual competition never // attempts a team-members.yaml read at all: this is a hot path (every // viewer/TV/streaming-overlay poll), and a file that can only // ever be empty for this shape of competition is not worth a // stat, let alone a read+parse. - isTeamComp := comp.Kind == "team" || comp.TeamSize > 0 + isTeamComp := comp.IsTeam() // Run all independent I/O concurrently. var ( diff --git a/internal/mobileapp/handlers_viewer_test.go b/internal/mobileapp/handlers_viewer_test.go index eb11b3fdd..1c961a252 100644 --- a/internal/mobileapp/handlers_viewer_test.go +++ b/internal/mobileapp/handlers_viewer_test.go @@ -755,6 +755,14 @@ func TestViewerCompetitionDetail_TeamCompetitionCarriesSquads(t *testing.T) { assert.Equal(t, "", body.Squads[redID][2].Name, "a floor-padded slot must be blank") assert.Equal(t, 4, body.Squads[redID][3].Index) assert.Equal(t, "", body.Squads[redID][3].Name, "a floor-padded slot must be blank") + + // A member carries the server stamp its write gave it (domain.TeamMember.ModifiedAt), + // and a slot no write has touched carries 0, in this payload as in the admin one. + require.Positive(t, member.ModifiedAt) + assert.Equal(t, member.ModifiedAt, body.Squads[redID][0].ModifiedAt) + assert.Positive(t, body.Squads[redID][1].ModifiedAt, "the blank member the test added was created by a write too") + assert.Zero(t, body.Squads[redID][2].ModifiedAt) + assert.Zero(t, body.Squads[redID][3].ModifiedAt) } // TestViewerCompetitionDetail_IndividualCompetitionSkipsSquadsRead diff --git a/internal/mobileapp/hub.go b/internal/mobileapp/hub.go index a6973a2e1..ad9464459 100644 --- a/internal/mobileapp/hub.go +++ b/internal/mobileapp/hub.go @@ -47,8 +47,11 @@ const ( // aside and the knockout stage rebuilt. Every device is showing a wedged // competition when this happens, so they all need to reload. EventBracketQuarantined EventType = "bracket_quarantined" - EventLineupUpdated EventType = "lineup_updated" - EventResyncRequired EventType = "resync_required" + // EventLineupUpdated: a team's lineups or members changed. Payload: + // {competitionId, teamId} and, for a lineup saved for a match, matchId + // (lineupUpdatedPayload builds it for every writer). + EventLineupUpdated EventType = "lineup_updated" + EventResyncRequired EventType = "resync_required" ) // AutoCompleteErrorHeader is set on score/start responses when the diff --git a/internal/mobileapp/self_run_team_writes_test.go b/internal/mobileapp/self_run_team_writes_test.go index 8daacfc78..5fec1d99e 100644 --- a/internal/mobileapp/self_run_team_writes_test.go +++ b/internal/mobileapp/self_run_team_writes_test.go @@ -180,7 +180,7 @@ func TestSelfRun_ScoreSheetTeamWrites_AnonymousCallerSaves(t *testing.T) { assert.Equal(t, f.blankA, lineup.MemberIDs[domain.PosSenpo]) w = f.send(http.MethodPut, f.membersPath()+"/"+f.blankA, "", map[string]any{"name": "Mei Ito"}) - require.Equal(t, http.StatusNoContent, w.Code, "naming a member with no name yet: %s", w.Body.String()) + require.Equal(t, http.StatusOK, w.Code, "naming a member with no name yet: %s", w.Body.String()) assert.Equal(t, "Mei Ito", f.memberName(t, f.blankA)) w = f.send(http.MethodPost, f.membersPath(), "", map[string]any{"name": "Ren Abe"}) @@ -265,9 +265,9 @@ func TestSelfRun_NamingABlankMemberAtOnceOneWins(t *testing.T) { } wg.Wait() - assert.ElementsMatch(t, []int{http.StatusNoContent, http.StatusConflict}, codes, "one caller names the member, the other is refused") + assert.ElementsMatch(t, []int{http.StatusOK, http.StatusConflict}, codes, "one caller names the member, the other is refused") winner := names[0] - if codes[1] == http.StatusNoContent { + if codes[1] == http.StatusOK { winner = names[1] } assert.Equal(t, winner, f.memberName(t, f.blankA), "the member keeps the name of the caller that succeeded") @@ -281,7 +281,7 @@ func TestSelfRun_ScoreSheetTeamWrites_OrganiserUnrestricted(t *testing.T) { require.Equal(t, http.StatusOK, w.Code, "a finished match's lineup stays editable: %s", w.Body.String()) w = f.send(http.MethodPut, f.membersPath()+"/"+f.namedA, "main-pw", map[string]any{"name": "Sato Kenji"}) - require.Equal(t, http.StatusNoContent, w.Code, "renaming a named member: %s", w.Body.String()) + require.Equal(t, http.StatusOK, w.Code, "renaming a named member: %s", w.Body.String()) assert.Equal(t, "Sato Kenji", f.memberName(t, f.namedA)) } @@ -432,7 +432,7 @@ func TestSelfRun_AddingAMemberIsBounded(t *testing.T) { w := f.send(http.MethodPost, f.membersPath(), "main-pw", map[string]any{"name": "Ren Abe"}) require.Equal(t, http.StatusCreated, w.Code, w.Body.String()) w = f.send(http.MethodPut, f.membersPath()+"/"+f.blankA, "main-pw", map[string]any{"name": "Mei Ito"}) - require.Equal(t, http.StatusNoContent, w.Code, w.Body.String()) + require.Equal(t, http.StatusOK, w.Code, w.Body.String()) }) } diff --git a/internal/mobileapp/server.go b/internal/mobileapp/server.go index 04d146de0..ebc75656c 100644 --- a/internal/mobileapp/server.go +++ b/internal/mobileapp/server.go @@ -158,12 +158,12 @@ func NewRouterWithHub(store *state.Store, eng *engine.Engine, res *resources.Res // GET /competitions/:id/competitor-status, eligibility state is // derivable from public match results; viewer/display surfaces need it // without admin credentials. - // GET /competitions/:id/teams/:tid/lineups/:round and - // .../match-lineups/:matchId, lineup assignments are visible to - // coaches and spectators; AdminLineup loads them before the operator - // has entered the admin password. + // GET /competitions/:id/teams/:tid/lineups/:round, + // .../match-lineups/:matchId and .../lineup-in-force/:matchId, lineup + // assignments are visible to coaches and spectators; AdminLineup loads + // them before the operator has entered the admin password. RegisterPublicEligibilityHandlers(api, store) - RegisterPublicLineupHandlers(api, store, store) + RegisterPublicLineupHandlers(api, store, store, eng) RegisterPublicSwissHandlers(api, store, eng) RegisterPublicLeagueHandlers(api, eng) RegisterPublicAnnouncementHandlers(api, store) diff --git a/internal/state/bracket.go b/internal/state/bracket.go index bf1073c2a..4205f0aa4 100644 --- a/internal/state/bracket.go +++ b/internal/state/bracket.go @@ -257,7 +257,11 @@ func (s *Store) SaveBracket(compID string, b *Bracket) error { mu.Lock() defer mu.Unlock() - return s.saveBracketLocked(compID, b, s.directWrite) + if err := s.saveBracketLocked(compID, b, s.directWrite); err != nil { + return err + } + s.settleRoundLineupsAfterWrite(compID) + return nil } // loadBracketLocked reads the bracket directly from disk WITHOUT @@ -363,7 +367,11 @@ func (s *Store) UpdateBracket(compID string, mutate func(*Bracket) error) error mu.Lock() defer mu.Unlock() - return s.updateBracketLocked(compID, mutate, s.directWrite) + if err := s.updateBracketLocked(compID, mutate, s.directWrite); err != nil { + return err + } + s.settleRoundLineupsAfterWrite(compID) + return nil } // updateBracketLocked is the lock-free body of UpdateBracket. Caller @@ -531,7 +539,11 @@ func (s *Store) UpdateBracketMatchByID(compID, matchID string, mutate func(*Brac mu := s.getCompLock(compID) mu.Lock() defer mu.Unlock() - return s.updateBracketMatchByIDLocked(compID, matchID, mutate, s.directWrite) + found, err := s.updateBracketMatchByIDLocked(compID, matchID, mutate, s.directWrite) + if found && err == nil { + s.settleRoundLineupsAfterWrite(compID) + } + return found, err } // updateBracketMatchByIDLocked is the lock-free body of UpdateBracketMatchByID diff --git a/internal/state/competition.go b/internal/state/competition.go index 385f87fc1..e0ffaa69a 100644 --- a/internal/state/competition.go +++ b/internal/state/competition.go @@ -5,6 +5,7 @@ import ( "fmt" "os" "path/filepath" + "slices" "time" "github.com/gitrgoliveira/bracket-creator/internal/domain" @@ -131,6 +132,8 @@ func (s *Store) copyCompetition(c *Competition) *Competition { cp.Players = make([]domain.Player, len(c.Players)) copy(cp.Players, c.Players) } + cp.RoundLineupsGiven = cloneRoundLineupsGiven(c.RoundLineupsGiven) + cp.RoundLineupsLegacy = slices.Clone(c.RoundLineupsLegacy) return &cp } diff --git a/internal/state/draw_matches.go b/internal/state/draw_matches.go new file mode 100644 index 000000000..434ee7efa --- /dev/null +++ b/internal/state/draw_matches.go @@ -0,0 +1,115 @@ +package state + +import ( + "errors" + "fmt" +) + +// DrawMatch is the part of a stored match that places it in the draw and says +// who is seated: its id, the two participant ids, and where a knockout match +// sits. It exists so a reader that needs only that (the lineup rule, which +// orders a team's matches) does not pay for the deep clone of every score, +// ippon slice and group stamp that LoadPoolMatches and LoadBracket make. +type DrawMatch struct { + ID string + SideAID, SideBID string + // Knockout is true for a bracket match, the 3rd-place match included. A + // pool, league or Swiss match is not: its order is read from its id. + Knockout bool + // Round is a knockout match's bracket round index, len(Rounds) for the + // 3rd-place match; 0 for any other match. + Round int + // Index is a knockout match's position within its round, 0 for the + // 3rd-place match; 0 for any other match. + Index int + // Hidden is true for a structural bye: a bracket match nobody plays, which + // the draw can still seat a team in (a bye's winner is seated at once). + Hidden bool + // Bye is true for a pool, league or Swiss match with a side that has neither + // a name nor a participant id: a Swiss round with an odd number of teams + // stores its odd team out as a completed match against nobody. A bracket's + // byes are Hidden instead, and a knockout match is never a Bye. + Bye bool + // PoolRound is a pool or league match's stored Round: its circle-method + // round, or -1 for a pool drawn without rounds. 0 for a knockout match, and + // for a Swiss match, which never stored one. + PoolRound int +} + +// DrawMatchesFrom projects loaded matches into DrawMatch values: every pool +// match, then each bracket round's matches, then the 3rd-place match, all in +// stored order. It keeps no reference into its arguments, so it is as safe over +// the store's cached parse as over a caller's own copy. +func DrawMatchesFrom(poolMatches []MatchResult, bracket *Bracket) []DrawMatch { + n := len(poolMatches) + if bracket != nil { + for _, round := range bracket.Rounds { + n += len(round) + } + if bracket.ThirdPlaceMatch != nil { + n++ + } + } + out := make([]DrawMatch, 0, n) + for i := range poolMatches { + m := &poolMatches[i] + bye := m.SideA == "" && m.SideAID == "" || m.SideB == "" && m.SideBID == "" + out = append(out, DrawMatch{ID: m.ID, SideAID: m.SideAID, SideBID: m.SideBID, PoolRound: m.Round, Bye: bye}) + } + if bracket == nil { + return out + } + for round := range bracket.Rounds { + for index := range bracket.Rounds[round] { + bm := &bracket.Rounds[round][index] + out = append(out, DrawMatch{ID: bm.ID, SideAID: bm.SideAID, SideBID: bm.SideBID, Knockout: true, Round: round, Index: index, Hidden: bm.Hidden}) + } + } + if bm := bracket.ThirdPlaceMatch; bm != nil { + out = append(out, DrawMatch{ID: bm.ID, SideAID: bm.SideAID, SideBID: bm.SideBID, Knockout: true, Round: len(bracket.Rounds), Hidden: bm.Hidden}) + } + return out +} + +// DrawMatches is DrawMatchesFrom over the store's CACHED parse of +// pool-matches.csv and bracket.json. It reads that cache without copying, as +// MatchSidesByID does, and adds none of its own, so the cache refresh every +// writer already performs covers it. Like LoadPoolMatches and LoadBracket, +// which it replaces for a reader of ids alone, it runs EnsureLegacyUpgraded +// first: the side ids it returns are what that upgrade stamps onto a legacy +// draw. +// +// A file that cannot be read contributes no matches and is named in the +// returned error, while the matches of the other file are still returned: a +// caller whose answer survives half a draw logs the error and carries on, and +// one that does not returns it. +func (s *Store) DrawMatches(compID string) ([]DrawMatch, error) { + if err := ValidateCompetitionID(compID); err != nil { + return nil, err + } + s.EnsureLegacyUpgraded(compID) + poolMatches, poolErr := s.cachedPoolMatches(compID) + if poolErr != nil { + poolErr = fmt.Errorf("pool-matches.csv: %w", poolErr) + } + bracket, bracketErr := s.cachedBracket(compID) + if bracketErr != nil { + bracketErr = fmt.Errorf("bracket.json: %w", bracketErr) + } + return DrawMatchesFrom(poolMatches, bracket), errors.Join(poolErr, bracketErr) +} + +// TeamMatches is TeamMatches over DrawMatches, with the competition's own +// format deciding whether its bracket is a knockout: the format is read from +// the cached config.md without copying it, so the call adds no parse of its own. +// A competition with no config.md plays a knockout as far as this goes, so no +// bracket match is dropped for want of a record. A file that cannot be read is +// named in the returned error and contributes nothing, as in DrawMatches. +func (s *Store) TeamMatches(compID string) ([]TeamMatch, error) { + draw, drawErr := s.DrawMatches(compID) + comp, compErr := s.cachedCompetition(compID) + if compErr != nil { + compErr = fmt.Errorf("config.md: %w", compErr) + } + return TeamMatches(draw, comp == nil || comp.IsKnockoutEnabled()), errors.Join(drawErr, compErr) +} diff --git a/internal/state/draw_matches_test.go b/internal/state/draw_matches_test.go new file mode 100644 index 000000000..cc75d9b2d --- /dev/null +++ b/internal/state/draw_matches_test.go @@ -0,0 +1,324 @@ +package state + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func drawMatchesFixture() ([]MatchResult, *Bracket) { + pool := []MatchResult{ + {ID: "Pool A-0", SideA: "A", SideB: "B", SideAID: "a", SideBID: "b", + SubResults: []SubMatchResult{{Position: 1, IpponsA: []string{"M"}, Winner: "A"}}}, + {ID: "Pool A-DH-0", SideAID: "a", SideBID: "b"}, + } + bracket := &Bracket{ + Rounds: [][]BracketMatch{ + {{ID: "r0-m0", SideAID: "a", SideBID: "x"}, {ID: "r0-m1", SideAID: "b", SideBID: "c"}}, + {{ID: "r1-m0"}}, + }, + ThirdPlaceMatch: &BracketMatch{ID: "bronze", SideAID: "b", SideBID: "c"}, + } + return pool, bracket +} + +// TestDrawMatchesFrom pins the walk: pool matches, then each bracket round in +// stored order, then the 3rd-place match, which sits at len(Rounds) because it +// is a sibling of the rounds and not one of them. +func TestDrawMatchesFrom(t *testing.T) { + pool, bracket := drawMatchesFixture() + + t.Run("pool matches, then the rounds, then the 3rd-place match", func(t *testing.T) { + assert.Equal(t, []DrawMatch{ + {ID: "Pool A-0", SideAID: "a", SideBID: "b"}, + {ID: "Pool A-DH-0", SideAID: "a", SideBID: "b"}, + {ID: "r0-m0", SideAID: "a", SideBID: "x", Knockout: true, Round: 0, Index: 0}, + {ID: "r0-m1", SideAID: "b", SideBID: "c", Knockout: true, Round: 0, Index: 1}, + {ID: "r1-m0", Knockout: true, Round: 1, Index: 0}, + {ID: "bronze", SideAID: "b", SideBID: "c", Knockout: true, Round: 2, Index: 0}, + }, DrawMatchesFrom(pool, bracket)) + }) + + t.Run("a pool-shaped match is a bye when a side has neither a name nor an id", func(t *testing.T) { + pool := []MatchResult{ + {ID: "Swiss-R1-0", SideA: "A", SideAID: "a"}, + {ID: "Swiss-R1-1", SideB: "B", SideBID: "b"}, + {ID: "Pool A-0", SideA: "A", SideB: "B"}, + {ID: "Pool A-1", SideAID: "a", SideBID: "b"}, + {ID: "Pool A-2", SideA: "A", SideAID: "a", SideB: "B"}, + {ID: "Pool A-3"}, + } + bracket := &Bracket{Rounds: [][]BracketMatch{{{ID: "r0-m0", SideA: "A", SideAID: "a"}}}} + + var got []bool + for _, m := range DrawMatchesFrom(pool, bracket) { + got = append(got, m.Bye) + } + + assert.Equal(t, []bool{ + true, // the odd team out of a Swiss round, in side A + true, // or in side B + false, // names alone name both sides, as a legacy row does before its ids are repaired + false, // and so do ids alone + false, // a side with a name and no id is still a side + true, // nothing at all is no pairing + false, // a knockout match is never a Bye: its byes are Hidden + }, got) + }) + + t.Run("a nil bracket leaves the knockout out", func(t *testing.T) { + got := DrawMatchesFrom(pool, nil) + + require.Len(t, got, 2) + assert.False(t, got[0].Knockout) + }) + + t.Run("no matches and no bracket is an empty draw", func(t *testing.T) { + assert.Empty(t, DrawMatchesFrom(nil, nil)) + assert.Empty(t, DrawMatchesFrom([]MatchResult{}, &Bracket{Rounds: [][]BracketMatch{}})) + }) + + t.Run("keeps no reference into its arguments", func(t *testing.T) { + got := DrawMatchesFrom(pool, bracket) + got[0].SideAID = "changed" + + assert.Equal(t, "a", pool[0].SideAID) + }) +} + +func newDrawMatchesStore(t *testing.T) (*Store, string) { + t.Helper() + store, err := NewStore(t.TempDir()) + require.NoError(t, err) + const compID = "draw-matches" + require.NoError(t, store.SaveCompetition(&Competition{ID: compID, Name: "Test"})) + return store, compID +} + +func writeCompFile(t *testing.T, store *Store, compID, file, body string) { + t.Helper() + require.NoError(t, os.WriteFile(filepath.Join(store.GetFolder(), "competitions", compID, file), []byte(body), 0o600)) +} + +// TestStoreDrawMatches covers the no-copy read of what the store holds. +func TestStoreDrawMatches(t *testing.T) { + t.Run("agrees with the full-copy loaders", func(t *testing.T) { + store, compID := newDrawMatchesStore(t) + pool, bracket := drawMatchesFixture() + require.NoError(t, store.SavePoolMatches(compID, pool)) + require.NoError(t, store.SaveBracket(compID, bracket)) + + loadedPool, err := store.LoadPoolMatches(compID) + require.NoError(t, err) + loadedBracket, err := store.LoadBracket(compID) + require.NoError(t, err) + + got, err := store.DrawMatches(compID) + + require.NoError(t, err) + assert.Equal(t, DrawMatchesFrom(loadedPool, loadedBracket), got) + assert.Len(t, got, 6) + }) + + t.Run("a competition with no draw has none", func(t *testing.T) { + store, compID := newDrawMatchesStore(t) + + got, err := store.DrawMatches(compID) + + require.NoError(t, err) + assert.Empty(t, got) + }) + + t.Run("sees every write at once", func(t *testing.T) { + store, compID := newDrawMatchesStore(t) + pool, bracket := drawMatchesFixture() + require.NoError(t, store.SavePoolMatches(compID, pool[:1])) + first, err := store.DrawMatches(compID) + require.NoError(t, err) + require.Len(t, first, 1) + + require.NoError(t, store.SavePoolMatches(compID, pool)) + require.NoError(t, store.SaveBracket(compID, bracket)) + found, err := store.UpdatePoolMatchByID(compID, "Pool A-0", func(m *MatchResult) error { + m.SideBID = "rewritten" + return nil + }) + require.NoError(t, err) + require.True(t, found) + + got, err := store.DrawMatches(compID) + + require.NoError(t, err) + require.Len(t, got, 6) + assert.Equal(t, "rewritten", got[0].SideBID, "the cache every writer refreshes is the cache it reads") + }) + + t.Run("an unreadable bracket.json is named and the pool matches are still returned", func(t *testing.T) { + store, compID := newDrawMatchesStore(t) + pool, _ := drawMatchesFixture() + require.NoError(t, store.SavePoolMatches(compID, pool)) + writeCompFile(t, store, compID, "bracket.json", "{not json") + + got, err := store.DrawMatches(compID) + + require.Error(t, err) + assert.Contains(t, err.Error(), "bracket.json") + assert.NotContains(t, err.Error(), "pool-matches.csv") + assert.Equal(t, DrawMatchesFrom(pool, nil), got) + }) + + t.Run("an unreadable pool-matches.csv is named and the bracket is still returned", func(t *testing.T) { + store, compID := newDrawMatchesStore(t) + _, bracket := drawMatchesFixture() + require.NoError(t, store.SaveBracket(compID, bracket)) + writeCompFile(t, store, compID, "pool-matches.csv", "\"unterminated") + + got, err := store.DrawMatches(compID) + + require.Error(t, err) + assert.Contains(t, err.Error(), "pool-matches.csv") + assert.NotContains(t, err.Error(), "bracket.json") + assert.Equal(t, DrawMatchesFrom(nil, bracket), got) + }) + + t.Run("both files unreadable name both", func(t *testing.T) { + store, compID := newDrawMatchesStore(t) + writeCompFile(t, store, compID, "bracket.json", "{not json") + writeCompFile(t, store, compID, "pool-matches.csv", "\"unterminated") + + got, err := store.DrawMatches(compID) + + require.Error(t, err) + assert.Contains(t, err.Error(), "bracket.json") + assert.Contains(t, err.Error(), "pool-matches.csv") + assert.Empty(t, got) + }) + + t.Run("invalid compID errors", func(t *testing.T) { + store, _ := newDrawMatchesStore(t) + + _, err := store.DrawMatches("../traversal") + + assert.Error(t, err) + }) + + t.Run("what it returns is its own: changing it leaves the next read alone", func(t *testing.T) { + store, compID := newDrawMatchesStore(t) + pool, bracket := drawMatchesFixture() + require.NoError(t, store.SavePoolMatches(compID, pool)) + require.NoError(t, store.SaveBracket(compID, bracket)) + got, err := store.DrawMatches(compID) + require.NoError(t, err) + got[0].SideAID = "changed" + + again, err := store.DrawMatches(compID) + + require.NoError(t, err) + assert.Equal(t, "a", again[0].SideAID) + }) + + t.Run("does not deep-clone the matches the loaders clone", func(t *testing.T) { + store, compID := newDrawMatchesStore(t) + var pool []MatchResult + for i := 0; i < 60; i++ { + subs := make([]SubMatchResult, 4) + for j := range subs { + subs[j] = SubMatchResult{Position: j + 1, IpponsA: []string{"M", "K"}, IpponsB: []string{"D"}, Winner: "A"} + } + pool = append(pool, MatchResult{ID: "Pool A-" + string(rune('a'+i%26)) + string(rune('a'+i/26)), SideAID: "a", SideBID: "b", SubResults: subs}) + } + require.NoError(t, store.SavePoolMatches(compID, pool)) + _, err := store.DrawMatches(compID) // warm the cache and the one-time legacy upgrade + require.NoError(t, err) + + projected := testing.AllocsPerRun(20, func() { _, _ = store.DrawMatches(compID) }) + cloned := testing.AllocsPerRun(20, func() { _, _ = store.LoadPoolMatches(compID) }) + + t.Logf("allocations per read: projection %.0f, deep-copying loader %.0f", projected, cloned) + assert.Less(t, projected*4, cloned, + "the projection allocates %.0f per read against the loader's %.0f: it must not copy each match's results", projected, cloned) + }) +} + +// TestStoreTeamMatches covers the team matches of what the store holds: the +// competition's own format says whether its bracket is a knockout. +func TestStoreTeamMatches(t *testing.T) { + seed := func(t *testing.T, format string) (*Store, string) { + t.Helper() + store, compID := newDrawMatchesStore(t) + comp, err := store.LoadCompetition(compID) + require.NoError(t, err) + comp.Format = format + require.NoError(t, store.SaveCompetition(comp)) + pool, bracket := drawMatchesFixture() + require.NoError(t, store.SavePoolMatches(compID, pool)) + require.NoError(t, store.SaveBracket(compID, bracket)) + return store, compID + } + idsOf := func(t *testing.T, store *Store, compID string) []string { + t.Helper() + matches, err := store.TeamMatches(compID) + require.NoError(t, err) + var ids []string + for _, m := range matches { + ids = append(ids, m.ID) + } + return ids + } + + for format, want := range map[string][]string{ + "": {"Pool A-0", "r0-m0", "r0-m1", "r1-m0", "bronze"}, + CompFormatKnockout: {"Pool A-0", "r0-m0", "r0-m1", "r1-m0", "bronze"}, + CompFormatMixed: {"Pool A-0", "r0-m0", "r0-m1", "r1-m0", "bronze"}, + CompFormatLeague: {"Pool A-0"}, + CompFormatSwiss: {"Pool A-0"}, + } { + t.Run("format "+format, func(t *testing.T) { + store, compID := seed(t, format) + + assert.Equal(t, want, idsOf(t, store, compID)) + }) + } + + t.Run("the format is read as it is now", func(t *testing.T) { + store, compID := seed(t, CompFormatMixed) + require.Len(t, idsOf(t, store, compID), 5) + + comp, err := store.LoadCompetition(compID) + require.NoError(t, err) + comp.Format = CompFormatLeague + require.NoError(t, store.SaveCompetition(comp)) + + assert.Equal(t, []string{"Pool A-0"}, idsOf(t, store, compID)) + }) + + t.Run("a competition with no config.md plays a knockout as far as this goes", func(t *testing.T) { + store, compID := seed(t, CompFormatLeague) + require.NoError(t, os.Remove(filepath.Join(store.GetFolder(), "competitions", compID, "config.md"))) + + assert.Equal(t, []string{"Pool A-0", "r0-m0", "r0-m1", "r1-m0", "bronze"}, idsOf(t, store, compID)) + }) + + t.Run("an unreadable file is named and the rest is still returned", func(t *testing.T) { + store, compID := seed(t, CompFormatMixed) + writeCompFile(t, store, compID, "bracket.json", "{not json") + + matches, err := store.TeamMatches(compID) + + require.Error(t, err) + assert.Contains(t, err.Error(), "bracket.json") + require.Len(t, matches, 1) + assert.Equal(t, "Pool A-0", matches[0].ID) + }) + + t.Run("invalid compID errors", func(t *testing.T) { + store, _ := newDrawMatchesStore(t) + + _, err := store.TeamMatches("../traversal") + + assert.Error(t, err) + }) +} diff --git a/internal/state/legacy_team_members_filename_test.go b/internal/state/legacy_team_members_filename_test.go index 04d551b04..d57d3e2b9 100644 --- a/internal/state/legacy_team_members_filename_test.go +++ b/internal/state/legacy_team_members_filename_test.go @@ -306,7 +306,7 @@ func TestSquadMutator_AdoptsTheLegacyFileBeforeWriting(t *testing.T) { func TestSquadMutator_RenameAdoptsTheLegacyFileBeforeLookingUp(t *testing.T) { s, id, teamID, _ := unadoptedLegacyCompetition(t, v200SquadsYAML) - err := s.RenameTeamMember(id, teamID, "11111111-1111-4111-8111-111111111111", "Haruki Sato") + err := renameMember(s, id, teamID, "11111111-1111-4111-8111-111111111111", "Haruki Sato") require.NoError(t, err, "a member recorded by v2.0.0 must be reachable by id after adoption") members, err := s.LoadSquads(id) diff --git a/internal/state/legacy_upgrade.go b/internal/state/legacy_upgrade.go index 73c7d61a4..f5386f16e 100644 --- a/internal/state/legacy_upgrade.go +++ b/internal/state/legacy_upgrade.go @@ -173,7 +173,10 @@ import ( // - a team's lineups.yaml positions (occupied Positions entries with no // MemberIDs counterpart) convert ON READ, below (bc-tmid pass 2), // AFTER the team-members.yaml migration above so a team migrated in the SAME -// pass is still resolvable. Unlike every upgrade above this one is not +// pass is still resolvable, and AFTER the round-lineup step in the next +// bullet, which keys a lineup saved under a team's NAME by the team's id +// and copies it, so the repair reaches every lineup that step moved or +// gave. Unlike every upgrade above this one is not // resolving against the roster at all, but against the TEAM'S OWN // squad (team-members.yaml), and needs none of the NameCount-gated // uniqueness dance those upgrades carry: two members of ONE team @@ -184,6 +187,24 @@ import ( // recorded at all -- is left alone; every id-aware reader falls back // to the name for exactly that slot. // +// - a team's lineups.yaml round lineups (round >= 1, saved by the Lineups +// page of releases up to v2.1.1) convert ON READ, below, AFTER the id +// repairs above (round_lineups.go owns the rule and the frame each kind of +// match was read at). A team now carries the lineup of its previous match, +// so a team with a round lineup is given, for every match it is seated in, +// a lineup of its own equal to what v2.1.1 showed there; a lineup saved +// under a team's NAME is keyed by the team's id; a team with no starting +// lineup gets what v2.1.1 showed before any round it saved (the highest) +// as one. The round lineups stay until the competition is completed, and +// a team seated in a match later (a correction can do it) is given its +// lineup in the write that seats it (Store.settleRoundLineupsAfterWrite, +// storeTx.settleRoundLineupsAtCommit, the one place the draw's writers +// share). Competition.RoundLineupsGiven records which (team, match) pairs +// were settled, so a lineup the operator removes stays removed. Keyed on +// Competition.RoundLineupsConverted, set as soon as nothing waits: at +// once for a competition with no team that has a round lineup, else when +// the competition is completed. +// // - pool-matches.csv / bracket.json SUB-BOUT rows (SubMatchResult's // SideAMemberID/SideBMemberID/WinnerMemberID) convert in the SAME pass // as the match-level upgrades above (bc-tmid pass 2), an EXTENSION of @@ -242,20 +263,24 @@ import ( // NEXT read after that save retries the repair with a roster that can now // resolve it, without requiring a restart. // -// ONLY the five PUBLIC, caller-does-not-already-hold-the-lock entry points on -// the READ path (loadParticipants, Store.LoadPools, Store.LoadPoolMatches, -// Store.LoadBracket, and ParticipantsFingerprint below, which calls it -// directly rather than through one of the other four), PLUS ONE caller on the -// startup path (sweepLegacyUpgrades below, called once from NewStore, which -// loops every id ListCompetitions finds so the whole data folder converges -// without waiting for each competition's files to be individually read) call -// this. All six are safe for the identical reason: none of them already hold -// compID's per-comp lock at the point they call in. The *Locked siblings -// (loadPoolsLocked, LoadPoolMatchesLocked, loadBracketLocked) and every -// storeTx method (including storeTx.LoadBracket) are called by something -// that ALREADY holds the per-comp lock (typically WithTransaction), so -// calling this from any of them would try to re-acquire a non-reentrant -// mutex and deadlock. Do not add a call here from inside that set. +// Every caller reaches this WITHOUT compID's per-comp lock held, which is the +// one thing that makes the call safe. The callers are of three kinds: the +// store's own read path (loadParticipants, Store.LoadPools, +// Store.LoadPoolMatches, Store.LoadBracket, Store.DrawMatches +// (draw_matches.go), and ParticipantsFingerprint below, which calls it directly +// rather than through one of the loaders); the startup path (sweepLegacyUpgrades +// below, called once from NewStore, which loops every id ListCompetitions finds +// so the whole data folder converges without waiting for each competition's +// files to be individually read); and the engine's read-modify-writes of the +// pool matches (internal/engine), which read through a transaction handle, and +// a transaction read skips this conversion, so each asks for it first, before it +// opens the transaction and from code no store closure encloses. The *Locked +// siblings (loadPoolsLocked, LoadPoolMatchesLocked, loadBracketLocked) and every +// storeTx method (including storeTx.LoadBracket) are called by something that +// ALREADY holds the per-comp lock (typically WithTransaction), so calling this +// from any of them, or from inside any closure the store runs under that lock +// (WithTransaction, the Update* methods), would try to re-acquire a +// non-reentrant mutex and deadlock. Do not add a call here from inside that set. // // Failure policy: a failed conversion is logged and NOT retried until the // next process start OR the next participants.csv write, whichever comes @@ -344,6 +369,16 @@ func (s *Store) EnsureLegacyUpgraded(compID string) { if err := s.upgradeBracketLocked(compID, roster); err != nil { log.Printf("state: legacy bracket upgrade for %s: %v", compID, err) } + // After the three id repairs above: a round lineup moves only onto a match + // its team is seated in by id. + if err := s.upgradeRoundLineupsLocked(compID, roster); err != nil { + log.Printf("state: legacy round-lineup upgrade for %s: %v", compID, err) + } + // After the round-lineup step, not before it: the repair resolves a position + // against the squad of the team a lineup is stored under, and a lineup stored + // under a team's NAME has none until that step has keyed it by the team's id. + // Run here it also reaches the starting lineup that step seeds and the lineup + // it gives each match, so none of them is left without its member ids. if err := s.upgradeLineupMemberIDsLocked(compID, roster); err != nil { log.Printf("state: legacy lineup-member-id upgrade for %s: %v", compID, err) } @@ -2104,9 +2139,12 @@ func (s *Store) upgradeLineupMemberIDsLocked(compID string, roster *legacyUpgrad // // Sorted, because which of the two positions keeps the id must not // depend on Go's randomised map order: the same file would otherwise - // repair differently on two loads. + // repair differently on two loads. In the order a lineup is fielded + // (domain.ComparePositions), so the position the roster fields first keeps + // it: for the named positions senpo, not chuken, which a text order put + // first. used := make(map[string]struct{}, len(l.MemberIDs)) - for _, pos := range slices.Sorted(maps.Keys(l.MemberIDs)) { + for _, pos := range slices.SortedFunc(maps.Keys(l.MemberIDs), domain.ComparePositions) { id := l.MemberIDs[pos] if id == "" { continue @@ -2119,7 +2157,7 @@ func (s *Store) upgradeLineupMemberIDsLocked(compID string, roster *legacyUpgrad } used[id] = struct{}{} } - for _, pos := range slices.Sorted(maps.Keys(l.Positions)) { + for _, pos := range slices.SortedFunc(maps.Keys(l.Positions), domain.ComparePositions) { name := l.Positions[pos] if name == "" || l.MemberIDs[pos] != "" { continue @@ -2149,6 +2187,32 @@ func (s *Store) upgradeLineupMemberIDsLocked(compID string, roster *legacyUpgrad return s.saveTeamLineupsLocked(compID, lineups, s.directWrite) } +// upgradeRoundLineupsLocked is the load repair for the round lineups releases up +// to v2.1.1 saved (round_lineups.go owns the rule): the first settlement of a +// competition that has not been settled, over what its files hold now. Runs +// after the side-id repairs (a lineup is given only for a match its team is +// seated in by id) and before the lineup member-id repair, which then gives the +// lineups this step keys by id, seeds and gives to matches their member ids. +// Caller holds the per-comp lock. +func (s *Store) upgradeRoundLineupsLocked(compID string, roster *legacyUpgradeRoster) error { + st := s.directRoundLineupStage(compID) + st.comp = roster.competition + st.players = func(*Competition) ([]domain.Player, error) { return roster.rosterPlayers() } + saved, err := st.settle(true) + if saved != nil { + // The roster's copy of the record is the one the steps after this one + // save from, so it has to carry what the settlement saved (the marker, + // the pairs it settled, or the legacy teams it found) or they would + // write it away. + if comp, cerr := roster.competition(); cerr == nil && comp != nil { + comp.RoundLineupsConverted = saved.RoundLineupsConverted + comp.RoundLineupsGiven = cloneRoundLineupsGiven(saved.RoundLineupsGiven) + comp.RoundLineupsLegacy = slices.Clone(saved.RoundLineupsLegacy) + } + } + return err +} + // upgradeTeamMembersFilenameLocked moves a competition recorded by v2.0.0 from // squads.yaml onto team-members.yaml, re-keying the document's root from // `squads` to `members`. Caller holds compID's per-competition write lock. diff --git a/internal/state/legacy_upgrade_lineup_ids_test.go b/internal/state/legacy_upgrade_lineup_ids_test.go index 089da948e..2cd4159cb 100644 --- a/internal/state/legacy_upgrade_lineup_ids_test.go +++ b/internal/state/legacy_upgrade_lineup_ids_test.go @@ -15,7 +15,6 @@ import ( "testing" "github.com/gitrgoliveira/bracket-creator/internal/domain" - "github.com/gitrgoliveira/bracket-creator/internal/state" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "gopkg.in/yaml.v3" @@ -61,7 +60,7 @@ func TestLegacyLineupMemberIDUpgradeOnRead(t *testing.T) { lineups, err := fresh.LoadTeamLineups("c1") require.NoError(t, err) - repaired, ok := state.FindBestLineup(lineups, teamID, "", 0) + repaired, ok := roundZeroLineup(lineups, teamID) require.True(t, ok, "the repaired lineup must still be found") assert.Equal(t, sato.ID, repaired.MemberIDs[domain.PositionNumbered(1)], "Sato's slot resolves against the squad by an exact, unambiguous name match") @@ -70,7 +69,7 @@ func TestLegacyLineupMemberIDUpgradeOnRead(t *testing.T) { assert.Equal(t, "Ghost", repaired.Positions[domain.PositionNumbered(2)], "the name itself is untouched; only the id half is ever filled") - noSquad, ok := state.FindBestLineup(lineups, noSquadTeamID, "", 0) + noSquad, ok := roundZeroLineup(lineups, noSquadTeamID) require.True(t, ok) assert.Empty(t, noSquad.MemberIDs, "a team with no squad recorded at all is left alone entirely") @@ -109,14 +108,14 @@ func TestLegacyLineupUpgrade_NeverFieldsOneMemberAtTwoPositions(t *testing.T) { lineups, err := fresh.LoadTeamLineups("c1") require.NoError(t, err) - repaired, ok := state.FindBestLineup(lineups, teamID, "", 0) + repaired, ok := roundZeroLineup(lineups, teamID) require.True(t, ok) ids := []string{ repaired.MemberIDs[domain.PositionNumbered(1)], repaired.MemberIDs[domain.PositionNumbered(2)], } - assert.Equal(t, sato.ID, ids[0], "the FIRST position in key order takes the id, deterministically") + assert.Equal(t, sato.ID, ids[0], "the FIRST position fielded takes the id, deterministically") assert.Empty(t, ids[1], "the second must not receive the same id: that row is refused on every future write") // Both positions keep their NAME: the repair touches only the id half, so @@ -167,7 +166,7 @@ func TestLegacyLineupUpgrade_RepairsADuplicateAlreadyOnDisk(t *testing.T) { lineups, err := fresh.LoadTeamLineups("c1") require.NoError(t, err) - repaired, ok := state.FindBestLineup(lineups, teamID, "", 0) + repaired, ok := roundZeroLineup(lineups, teamID) require.True(t, ok) assert.Equal(t, sato.ID, repaired.MemberIDs[domain.PositionNumbered(1)]) @@ -176,3 +175,75 @@ func TestLegacyLineupUpgrade_RepairsADuplicateAlreadyOnDisk(t *testing.T) { assert.Equal(t, "Sato", repaired.Positions[domain.PositionNumbered(2)], "its name is kept") require.NoError(t, repaired.ValidatePositions(3)) } + +// The position that keeps the id when one member is at two is the first the team +// fields (domain.ComparePositions, the order the roster walks), not the first as +// text: for the five named positions that is senpo before chuken, where a text +// order read chuken first and kept the id at the position the roster fields second. +func TestLegacyLineupUpgrade_AMemberAtTwoNamedPositionsKeepsTheIdAtTheFirstFielded(t *testing.T) { + writeLineup := func(t *testing.T, dir string, l domain.TeamLineup) { + t.Helper() + type lineupFileShape struct { + Lineups []domain.TeamLineup `yaml:"lineups"` + } + body, err := yaml.Marshal(&lineupFileShape{Lineups: []domain.TeamLineup{l}}) + require.NoError(t, err) + require.NoError(t, os.WriteFile(filepath.Join(dir, "competitions", "c1", "lineups.yaml"), body, 0o600)) + } + + t.Run("an id already at both positions stays at senpo", func(t *testing.T) { + dir, s := newLegacyUpgradeFixture(t) + teamID := legacyUpgradeTeams(t, s, "Tora")[0] + sato, err := s.AddTeamMember("c1", teamID, "Sato") + require.NoError(t, err) + writeLineup(t, dir, domain.TeamLineup{ + TeamID: teamID, Round: 0, + Positions: map[domain.Position]string{domain.PosSenpo: "Sato", domain.PosChuken: "Sato"}, + MemberIDs: map[domain.Position]string{domain.PosSenpo: sato.ID, domain.PosChuken: sato.ID}, + }) + + fresh := freshLegacyUpgradeStore(t, dir) + fresh.EnsureLegacyUpgraded("c1") + + lineups, err := fresh.LoadTeamLineups("c1") + require.NoError(t, err) + repaired, ok := roundZeroLineup(lineups, teamID) + require.True(t, ok) + assert.Equal(t, sato.ID, repaired.MemberIDs[domain.PosSenpo]) + assert.Empty(t, repaired.MemberIDs[domain.PosChuken], "the second position fielded loses the id, and keeps its name") + assert.Equal(t, "Sato", repaired.Positions[domain.PosChuken]) + }) + + t.Run("a name at both positions is given its id at senpo", func(t *testing.T) { + dir, s := newLegacyUpgradeFixture(t) + teamID := legacyUpgradeTeams(t, s, "Tora")[0] + sato, err := s.AddTeamMember("c1", teamID, "Sato") + require.NoError(t, err) + writeLineup(t, dir, domain.TeamLineup{ + TeamID: teamID, Round: 0, + Positions: map[domain.Position]string{domain.PosSenpo: "Sato", domain.PosChuken: "Sato"}, + }) + + fresh := freshLegacyUpgradeStore(t, dir) + fresh.EnsureLegacyUpgraded("c1") + + lineups, err := fresh.LoadTeamLineups("c1") + require.NoError(t, err) + repaired, ok := roundZeroLineup(lineups, teamID) + require.True(t, ok) + assert.Equal(t, sato.ID, repaired.MemberIDs[domain.PosSenpo]) + assert.Empty(t, repaired.MemberIDs[domain.PosChuken]) + }) +} + +// roundZeroLineup reads a team's round-0 lineup (the team's starting lineup) +// out of a loaded lineups map. The map's keys are the store's own, so it scans +// for the entry instead. +func roundZeroLineup(lineups map[string]domain.TeamLineup, teamID string) (domain.TeamLineup, bool) { + for _, l := range lineups { + if l.TeamID == teamID && l.MatchID == "" && l.Round == 0 { + return l, true + } + } + return domain.TeamLineup{}, false +} diff --git a/internal/state/match_order.go b/internal/state/match_order.go new file mode 100644 index 000000000..7069cf9b6 --- /dev/null +++ b/internal/state/match_order.go @@ -0,0 +1,149 @@ +package state + +// match_order.go owns which matches of a draw are team matches and the order a +// team plays them in. TeamMatches is the one list of them, and MatchPlace the +// one order, that the lineup rule (engine.LineupInForce, which asks "which of a +// team's lineups comes before this match") and the round-lineup settlement +// (round_lineups.go, which gives a team a lineup for each match it is seated in) +// both read, so the two cannot disagree about either. + +import ( + "cmp" + "slices" + "strconv" + "strings" +) + +// SwissMatchIDPrefix is the prefix of a Swiss match id ("Swiss-R2-0": round 2, +// match 0). Swiss matches are stored beside pool matches, so the id is what +// tells them apart. +const SwissMatchIDPrefix = "Swiss-R" + +// ParseSwissMatchRound extracts the round number from a Swiss match ID. +// Returns (round, true) on success; (0, false) for any non-Swiss match ID or +// malformed shape. +func ParseSwissMatchRound(id string) (int, bool) { + if !strings.HasPrefix(id, SwissMatchIDPrefix) { + return 0, false + } + rest := strings.TrimPrefix(id, SwissMatchIDPrefix) + dash := strings.Index(rest, "-") + if dash < 0 { + return 0, false + } + n, err := strconv.Atoi(rest[:dash]) + if err != nil || n < 1 { + return 0, false + } + return n, true +} + +// PoolPhaseMatchNumber mirrors pool_ids.jsx's poolMatchNumberOf: the bout's +// 1-based match number WITHIN its pool/round, taken from the id's own +// trailing "-" ordinal (0-based on disk, so +1 here). Returns 0 for +// a supplementary id (DH/TB) or an id with no numeric ordinal at all. +func PoolPhaseMatchNumber(matchID string) int { + if IsPoolDaihyosenMatchID(matchID) || IsTiebreakerMatchID(matchID) { + return 0 + } + i := strings.LastIndexByte(matchID, '-') + if i < 0 { + return 0 + } + n, err := strconv.Atoi(matchID[i+1:]) + if err != nil { + return 0 + } + return n + 1 +} + +// The phases of match order: the pool phase (pool, league and Swiss matches) +// comes before the knockout. +const ( + placePool = iota + placeKnockout +) + +// MatchPlace is where a team match falls in match order. Pool, league and +// Swiss matches come first, in pool-match number (the number is the place in +// the playing order; a Swiss team's rounds in round order); the knockout +// follows, each round in position order; the 3rd-place match is last, after +// the final's round. Two matches never share a place: the stored order breaks +// every tie. +type MatchPlace struct { + // round is 0 for the pool phase, the bracket round index for a knockout + // match, and len(Rounds) for the 3rd-place match. + round int + // phase is placePool or placeKnockout. + phase int + // group is the Swiss round, so a Swiss team's rounds play in order; 0 for + // every other match. + group int + // index is the pool-match number, or the match's position in its round. + index int + // seq is the stored order. + seq int +} + +// Compare orders two places: negative when a comes before b. +func (a MatchPlace) Compare(b MatchPlace) int { + return cmp.Or( + cmp.Compare(a.round, b.round), + cmp.Compare(a.phase, b.phase), + cmp.Compare(a.group, b.group), + cmp.Compare(a.index, b.index), + cmp.Compare(a.seq, b.seq), + ) +} + +// Place is where m falls in match order, seq being its position in the slice +// DrawMatchesFrom returned. ok is false for a match with no place in the order: +// a pool daihyosen or tiebreaker is one individual bout, and a match with no id +// cannot be named. TeamMatches leaves out the rest of what is not a team match. +func (m DrawMatch) Place(seq int) (place MatchPlace, ok bool) { + if m.ID == "" { + return MatchPlace{}, false + } + if m.Knockout { + return MatchPlace{round: m.Round, phase: placeKnockout, index: m.Index, seq: seq}, true + } + if IsPoolDaihyosenMatchID(m.ID) || IsTiebreakerMatchID(m.ID) { + return MatchPlace{}, false + } + swissRound, _ := ParseSwissMatchRound(m.ID) + return MatchPlace{phase: placePool, group: swissRound, index: PoolPhaseMatchNumber(m.ID), seq: seq}, true +} + +// Seats reports whether the team with participant id teamID is seated in the +// match. A side that carries no id (a bye, an unresolved feeder) seats nobody. +func (m DrawMatch) Seats(teamID string) bool { + return teamID != "" && (m.SideAID == teamID || m.SideBID == teamID) +} + +// TeamMatch is a team match of the draw and its place in match order. +type TeamMatch struct { + DrawMatch + Place MatchPlace +} + +// TeamMatches lists the team matches of a projected draw, the matches a team can +// be seated in and play, in match order. knockout says whether the competition +// plays a knockout stage (Competition.IsKnockoutEnabled): a league or Swiss +// competition has none, so whatever a vestigial bracket.json holds is left out. +// Also left out are a structural bye (Hidden) and a pool-shaped match with an +// empty side (Bye, a Swiss round's odd team out), which nobody plays; a pool +// representative bout or tie-break, which is one individual bout; and a match +// with no id, which cannot be named (DrawMatch.Place). +func TeamMatches(draw []DrawMatch, knockout bool) []TeamMatch { + matches := make([]TeamMatch, 0, len(draw)) + for seq, m := range draw { + if (m.Knockout && !knockout) || m.Hidden || m.Bye { + continue + } + if place, ok := m.Place(seq); ok { + matches = append(matches, TeamMatch{DrawMatch: m, Place: place}) + } + } + slices.SortFunc(matches, func(a, b TeamMatch) int { return a.Place.Compare(b.Place) }) + return matches +} diff --git a/internal/state/match_order_test.go b/internal/state/match_order_test.go new file mode 100644 index 000000000..0863ca843 --- /dev/null +++ b/internal/state/match_order_test.go @@ -0,0 +1,147 @@ +package state + +import ( + "slices" + "testing" + + "github.com/stretchr/testify/assert" +) + +// TestDrawMatchPlace pins the order a team plays its matches in, the one the +// lineup rule and the round-lineup settlement share: the pool phase in pool-match +// number, a Swiss team's rounds in round order, the knockout after it by round +// then position, the 3rd-place match last. +func TestDrawMatchPlace(t *testing.T) { + draw := []DrawMatch{ + {ID: "r1-m0", Knockout: true, Round: 1, Index: 0}, + {ID: "Pool A-10"}, + {ID: "bronze", Knockout: true, Round: 2}, + {ID: "r0-m1", Knockout: true, Round: 0, Index: 1}, + {ID: "Pool A-2"}, + {ID: "Swiss-R2-0"}, + {ID: "r0-m0", Knockout: true, Round: 0, Index: 0}, + {ID: "Swiss-R1-5"}, + {ID: "Pool A-DH-0"}, + {ID: "Pool A-TB-3"}, + {ID: ""}, + } + + var team []string + var order []MatchPlace + for seq, m := range draw { + if place, ok := m.Place(seq); ok { + team = append(team, m.ID) + order = append(order, place) + } + } + sorted := slices.Clone(team) + slices.SortFunc(sorted, func(a, b string) int { + return order[slices.Index(team, a)].Compare(order[slices.Index(team, b)]) + }) + + assert.Equal(t, []string{"Pool A-2", "Pool A-10", "Swiss-R1-5", "Swiss-R2-0", "r0-m0", "r0-m1", "r1-m0", "bronze"}, sorted, + "pool numbers compare as numbers; a pool match has Swiss round 0, so it comes before any Swiss round") + assert.NotContains(t, team, "Pool A-DH-0", "a representative bout is one individual bout, not a team match") + assert.NotContains(t, team, "Pool A-TB-3", "nor is a tiebreaker") + assert.NotContains(t, team, "", "a match with no id cannot be named") +} + +func TestMatchPlaceCompareBreaksTieBySeq(t *testing.T) { + a, _ := DrawMatch{ID: "Pool A-0"}.Place(0) + b, _ := DrawMatch{ID: "Pool A-0"}.Place(1) + + assert.Negative(t, a.Compare(b)) + assert.Positive(t, b.Compare(a)) + assert.Zero(t, a.Compare(a)) +} + +func TestDrawMatchSeats(t *testing.T) { + m := DrawMatch{ID: "m", SideAID: "a", SideBID: "b"} + + assert.True(t, m.Seats("a")) + assert.True(t, m.Seats("b")) + assert.False(t, m.Seats("c")) + assert.False(t, m.Seats(""), "a side with no id seats nobody") + assert.False(t, DrawMatch{SideAID: "a"}.Seats(""), "and an empty id is nobody's") +} + +// TestTeamMatches pins the one list of team matches the lineup rule and the +// round-lineup settlement both read: what is left out of it, what stays in it, +// and the order it is in. +func TestTeamMatches(t *testing.T) { + pool := []MatchResult{ + {ID: "Pool A-1", SideA: "A", SideAID: "a", SideB: "B", SideBID: "b"}, + {ID: "Pool A-0", SideA: "A", SideAID: "a", SideB: "C", SideBID: "c"}, + {ID: "Pool A-DH-0", SideA: "A", SideAID: "a", SideB: "B", SideBID: "b"}, + {ID: "Pool A-TB-0", SideA: "A", SideAID: "a", SideB: "B", SideBID: "b"}, + {ID: "Swiss-R1-2", SideA: "A", SideAID: "a", Status: MatchStatusCompleted}, + {ID: "Swiss-R2-0", SideB: "B", SideBID: "b", Status: MatchStatusCompleted}, + {SideA: "A", SideAID: "a", SideB: "B", SideBID: "b"}, + } + bracket := &Bracket{ + Rounds: [][]BracketMatch{ + { + {ID: "r0-m0", SideA: "A", SideAID: "a", SideB: "D", SideBID: "d"}, + {ID: "r0-m1", SideA: "B", SideAID: "b", Hidden: true}, + }, + {{ID: "r1-m0", SideA: "Winner of r0-m0", SideB: "Winner of r0-m1"}}, + }, + ThirdPlaceMatch: &BracketMatch{ID: "bronze", SideA: "Loser of r0-m0", SideB: "Loser of r0-m1"}, + } + idsOf := func(matches []TeamMatch) []string { + var ids []string + for _, m := range matches { + ids = append(ids, m.ID) + } + return ids + } + draw := DrawMatchesFrom(pool, bracket) + + t.Run("a knockout is played", func(t *testing.T) { + assert.Equal(t, []string{"Pool A-0", "Pool A-1", "r0-m0", "r1-m0", "bronze"}, idsOf(TeamMatches(draw, true)), + "in match order, the pool phase first by number; a match still waiting on its feeders is one the team can be seated in") + }) + + t.Run("a competition with no knockout leaves a vestigial bracket out", func(t *testing.T) { + assert.Equal(t, []string{"Pool A-0", "Pool A-1"}, idsOf(TeamMatches(draw, false))) + }) + + t.Run("what is left out is no team match", func(t *testing.T) { + all := idsOf(TeamMatches(draw, true)) + + assert.NotContains(t, all, "Pool A-DH-0", "a representative bout is one individual bout") + assert.NotContains(t, all, "Pool A-TB-0", "and so is a tiebreaker") + assert.NotContains(t, all, "Swiss-R1-2", "a Swiss bye, the odd team out against nobody, is played by nobody") + assert.NotContains(t, all, "Swiss-R2-0", "whichever side is empty") + assert.NotContains(t, all, "r0-m1", "a structural bye is played by nobody") + assert.Len(t, all, 5, "a match with no id cannot be named") + }) + + t.Run("each carries the place it sorts by", func(t *testing.T) { + matches := TeamMatches(draw, true) + + for i := 1; i < len(matches); i++ { + assert.Negative(t, matches[i-1].Place.Compare(matches[i].Place), "%s then %s", matches[i-1].ID, matches[i].ID) + } + }) + + t.Run("no draw has no team matches", func(t *testing.T) { + assert.Empty(t, TeamMatches(nil, true)) + }) +} + +func TestParseSwissMatchRoundAndPoolPhaseMatchNumber(t *testing.T) { + for id, want := range map[string]int{"Swiss-R1-0": 1, "Swiss-R12-3": 12} { + n, ok := ParseSwissMatchRound(id) + assert.True(t, ok, id) + assert.Equal(t, want, n, id) + } + for _, id := range []string{"Pool A-0", "Swiss-R0-1", "Swiss-Rx-1", "Swiss-R2", ""} { + _, ok := ParseSwissMatchRound(id) + assert.False(t, ok, id) + } + + for id, want := range map[string]int{"Pool A-0": 1, "Pool A-9": 10, "Swiss-R2-4": 5, "Pool A-DH-1": 0, "Pool A-TB-1": 0, "nodash": 0, "Pool A-x": 0} { + assert.Equal(t, want, PoolPhaseMatchNumber(id), id) + } +} diff --git a/internal/state/models.go b/internal/state/models.go index 7e6fa057e..7806b9568 100644 --- a/internal/state/models.go +++ b/internal/state/models.go @@ -513,6 +513,60 @@ type Competition struct { // record, so nothing a client sends can clear it. KachinukiEncounterEnchoCleared bool `yaml:"kachinuki_encounter_encho_cleared,omitempty" json:"-"` + // RoundLineupsConverted records that no lineup of this competition waits + // on a round: releases up to v2.1.1 let the Lineups page save a lineup for + // round r, and a team now carries the lineup of its previous match + // instead, so a team with such a lineup is given a lineup of its own for + // every match it is seated in, equal to what v2.1.1 showed there + // (settleRoundLineups, round_lineups.go). The load repair and the writes + // that seat a team in the draw run only while it is false, and set it as + // soon as nothing waits: at once for a competition with no team that has + // such a lineup, and for one that has, when the competition is completed + // (the round lineups are removed then); + // POST /competitions sets it on a new competition, which has nothing to + // convert and no writer left that creates a round lineup. Server-managed: + // `json:"-"` keeps it off the wire, and the settings PUT copies onto the + // stored record, so nothing a client sends can clear it. + RoundLineupsConverted bool `yaml:"round_lineups_converted,omitempty" json:"-"` + + // RoundLineupsGiven records which (team, match) pairs that conversion has + // settled, while RoundLineupsConverted is false: it maps a team's + // participant id to the ids of the matches settled for it, each list sorted + // and without duplicates, so config.md (which people read and edit) shows + // + // round_lineups_given: + // : + // - r0-m0 + // - r1-m0 + // + // A pair listed here is never given a lineup again, whatever lineups.yaml + // holds, so a lineup the operator removes from a match ("Use the previous + // match's lineup") stays removed; a pair seated and not listed is given the + // lineup v2.1.1 showed there when it has none, and listed either way. The + // conversion clears it when it sets the marker, and DiscardDraw clears it, + // because the next draw reuses the match ids and is given its lineups + // again. Server-managed like the marker: `json:"-"` keeps it off the wire, + // and the settings PUT copies onto the stored record, so nothing a client + // sends can change it. + RoundLineupsGiven map[string][]string `yaml:"round_lineups_given,omitempty" json:"-"` + + // RoundLineupsLegacy records, while RoundLineupsConverted is false, the teams + // the conversion's FIRST settlement of this competition found to be legacy: + // the participant ids, sorted, of every team with a lineup for a round r >= 1 + // or with a match lineup, stored under its id, for a match of the draw that + // seats it (v2.1.1 read a match's own lineup and never carried it to another + // match, so such a team is shown, at its other matches, what v2.1.1 showed + // and not what a team now carries). Later settlements read the list, plus any + // team with a lineup for a later round, so a match lineup saved by this + // release never makes a team legacy. It is also how a later settlement tells + // the first one is done: after it a competition is either marked or has a + // legacy team to wait for, so the list is not empty. The conversion clears it + // with the marker, and ClearDrawLineups drops from it every team with no lineup + // for a later round, whose match lineups went with the draw. Server-managed + // like the marker: `json:"-"` keeps it off the wire, and the settings PUT + // copies onto the stored record, so nothing a client sends can change it. + RoundLineupsLegacy []string `yaml:"round_lineups_legacy,omitempty" json:"-"` + Players []domain.Player `yaml:"-" json:"players"` } @@ -567,6 +621,16 @@ func (c Competition) EffectiveFormat() string { return c.Format } +// IsTeam reports whether c is a team competition: its Kind is "team" or its +// TeamSize is positive. The single spelling of this predicate: it was written +// out at a dozen call sites, since the engine identifies a team competition by +// Kind in some paths and by TeamSize in others (ValidateCompetitionTeamSize +// keeps the two in step for a competition that was created or edited through +// the API, so for one the two readings agree). +func (c *Competition) IsTeam() bool { + return c != nil && (c.Kind == "team" || c.TeamSize > 0) +} + // IsKachinuki reports whether c is a kachinuki (winner-stays-on) team // competition. The single spelling of this predicate (mp-gmcg review): before // this method it was reimplemented inline at 7 call sites across diff --git a/internal/state/models_test.go b/internal/state/models_test.go index ef03b569b..824212a63 100644 --- a/internal/state/models_test.go +++ b/internal/state/models_test.go @@ -772,6 +772,29 @@ func TestCompetition_IsKachinuki(t *testing.T) { }) } +// TestCompetition_IsTeam pins the one spelling of "is a team competition": Kind +// "team" or a positive TeamSize, whichever of the two marks it. +func TestCompetition_IsTeam(t *testing.T) { + cases := []struct { + name string + comp *Competition + want bool + }{ + {"nil receiver is false", nil, false}, + {"an individual competition", &Competition{Kind: "individual"}, false}, + {"no kind and no team size", &Competition{}, false}, + {"Kind team", &Competition{Kind: "team", TeamSize: 3}, true}, + {"Kind team with no team size yet", &Competition{Kind: "team"}, true}, + {"a team size with no kind", &Competition{TeamSize: 3}, true}, + {"a team size of one is positive", &Competition{TeamSize: 1}, true}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + assert.Equal(t, tc.want, tc.comp.IsTeam()) + }) + } +} + // TestCompetition_TeamBoutRows pins the one owner of a team block's bout-row // count: a team match prints TeamSize rows, a kachinuki encounter the // 2*TeamSize-1 bouts it can take at most, an individual competition none. diff --git a/internal/state/participants.go b/internal/state/participants.go index ecacf8811..7d3f66350 100644 --- a/internal/state/participants.go +++ b/internal/state/participants.go @@ -1498,7 +1498,9 @@ func (s *Store) saveParticipantsNoLock(compID string, players []domain.Player, w // only on stamped. Best-effort, same contract as the seeds-orphan prune // next to this write's callers: a failure here is logged, not // propagated, and the leftover orphan is retried on the next roster - // write. + // write. The lineup prune is given the stamped roster itself rather than + // keepIDs: it first keys a lineup saved under a team's NAME by the id this + // write just minted for that team, which a set of ids cannot say. keepIDs := make(map[string]bool, len(stamped)) for i := range stamped { keepIDs[stamped[i].ID] = true @@ -1509,7 +1511,7 @@ func (s *Store) saveParticipantsNoLock(compID string, players []domain.Player, w if err := s.pruneOrphanedTeamMembersLocked(compID, comp, keepIDs); err != nil { log.Printf("state: saveParticipants %s: prune orphaned team members: %v", compID, err) } - if err := s.pruneOrphanedTeamLineupsLocked(compID, comp, keepIDs); err != nil { + if err := s.pruneOrphanedTeamLineupsLocked(compID, comp, stamped); err != nil { log.Printf("state: saveParticipants %s: prune orphaned team lineups: %v", compID, err) } } diff --git a/internal/state/pools.go b/internal/state/pools.go index ddc8200b8..6f3f2212d 100644 --- a/internal/state/pools.go +++ b/internal/state/pools.go @@ -728,7 +728,11 @@ func (s *Store) SavePoolMatches(compID string, results []MatchResult) error { mu.Lock() defer mu.Unlock() - return s.savePoolMatchesLocked(compID, results, s.directWrite) + if err := s.savePoolMatchesLocked(compID, results, s.directWrite); err != nil { + return err + } + s.settleRoundLineupsAfterWrite(compID) + return nil } // savePoolMatchesLocked persists results to disk and refreshes the cache. @@ -826,7 +830,11 @@ func (s *Store) UpdatePoolMatchByID(compID, matchID string, mutate func(*MatchRe mu.Lock() defer mu.Unlock() - return s.updatePoolMatchByIDLocked(compID, matchID, mutate, s.directWrite) + found, err := s.updatePoolMatchByIDLocked(compID, matchID, mutate, s.directWrite) + if found && err == nil { + s.settleRoundLineupsAfterWrite(compID) + } + return found, err } // updatePoolMatchByIDLocked is the lock-free body of diff --git a/internal/state/round_lineups.go b/internal/state/round_lineups.go new file mode 100644 index 000000000..9348c8b5f --- /dev/null +++ b/internal/state/round_lineups.go @@ -0,0 +1,673 @@ +package state + +// round_lineups.go owns the one-time conversion of round lineups into match +// lineups (operator decision 2026-10-06: migrate old content to the new format +// instead of reading it). +// +// Releases up to v2.1.1 let the Lineups page save a lineup for round r (stored +// 0-based, entered 1-based), and read the lineup a team fielded at a match as: +// the match's own (never carried to another match), else the round lineup with +// the highest round at or below the match's round (FindBestLineup), else the +// highest round there was. A team now fields the lineup of its previous match +// unless one is entered for the match (engine.LineupInForce), which has no place +// for a lineup that waits for a round, and one entered for a match no longer stays +// at that match. So a legacy team, a team with a lineup for a round r >= 1 or, found +// at the competition's first settlement, one with a lineup entered for a match it is +// seated in (the match lineups it had were shown nowhere else, and the team carries +// them from now on), is given, for every team match it is seated in by id (in match +// order, MatchPlace; TeamMatches says which matches are team matches, a bye being +// none, since nobody plays it), a lineup of its own for that match equal to what +// v2.1.1 showed there, written exactly as PUT .../match-lineups/:matchId writes one +// (a match-scoped entry, Round 0). A team that had no lineup for a round at all was +// shown none at a match it had no lineup for, and is given an EMPTY lineup of its own +// there (positions {}, which the lineup rule honours as the match's own: it shows +// none rather than carrying an earlier match's). A match that already has an own +// lineup of the team keeps it: that is v2.1.1's own reading. Every match a legacy +// team is seated in then holds its own lineup, so nothing the operator enters later +// for another match changes it, and settling again finds nothing to give. A team +// that is not legacy is left exactly as it is. +// +// The round a match had is the one the client read the lineup by +// (resolveRoundIndex, web-mobile/js/admin_helpers.jsx, identical in v2.0.0, +// v2.1.0 and v2.1.1): a knockout match's index in bracket.Rounds, the 3rd-place +// match's len(Rounds), a pool or league match's stored Round (its circle-method +// round, or -1 for a pool drawn without rounds, read as 0), and 0 for a Swiss +// match, which never stored one. legacyLineupRound is that rule. A pool's bouts +// are numbered in playing order, and a team's stored rounds in that order need +// not rise, which is why a lineup is given per match rather than moved to the +// first match at its round. +// +// Which teams are legacy is decided ONCE, at the competition's first settlement +// (the load pass: no marker and no list recorded), and recorded in config.md +// (Competition.RoundLineupsLegacy). A lineup entered for a match is a reading of +// v2.1.1 only until the first pass; one saved by this release is the operator's +// own and carries to the team's later matches, so it must never make a team +// legacy afterwards, and a restart must not find it again. Later passes read the +// record, plus any team that has a lineup for a round r >= 1. A match lineup left +// behind by a re-seat, for a match the team is not seated in, is no reading v2.1.1 +// showed and makes no team legacy. +// +// The round lineups themselves stay on disk (engine.LineupInForce never reads +// one) until the competition is completed: a correction can seat a team in a +// match until it is over, and the match is given the lineup v2.1.1 showed there +// from them. When it is completed they are removed. A legacy team with no round +// lineup waits as well, for the same reason. The competition's +// RoundLineupsConverted marker is set as soon as nothing waits: at the first +// settlement, whatever the status, for a competition with no legacy team (and +// none waiting for an id, below), so such a competition costs nothing after it; +// for one that has a legacy team, when the competition is completed and the +// round lineups are removed, or when the draw's removal leaves none +// (ClearDrawLineups: a team found only for its match lineups has nothing left to +// be shown once they went with the draw). Every write of the draw settles again +// (Store.settleRoundLineupsAfterWrite for a write that goes straight to disk, +// storeTx.settleRoundLineupsAtCommit for a transaction), so a match a team is +// seated in later is given its lineup in the write that seats it; a competition +// that is completed in a write those hooks do not see is settled by the next +// load. +// +// Which (team, match) pairs have been settled is recorded in config.md +// (Competition.RoundLineupsGiven) while the round lineups are kept. A pair that +// is listed is never given a lineup again, whatever lineups.yaml holds, so a +// lineup the operator removes from a match ("Use the previous match's lineup") +// stays removed; a seated pair that is not listed is given the lineup v2.1.1 +// showed when the team has none there, and listed either way. The lineups are +// saved before the record, so a failure between the two leaves lineups the next +// pass finds and only lists. The marker clears the record (and the list of legacy +// teams), and ClearDrawLineups clears the record too, since the next draw reuses +// the match ids. +// +// A team with no starting lineup (round 0) is given, on the load pass, what +// v2.1.1 showed before any round it saved: its highest round's lineup, which +// v2.1.1 fell back to. A match seated after the load and read at a round below +// the team's lowest saved one then finds that lineup, as v2.1.1 did, whichever +// pass reads it. +// +// A lineup saved under a team's name is keyed by the team's id. While a team of +// that name has no id yet (a roster recorded without ids, until the participant +// list is applied) it waits too, with the marker unset. + +import ( + "cmp" + "fmt" + "log" + "maps" + "path/filepath" + "slices" + + "github.com/gitrgoliveira/bracket-creator/internal/domain" +) + +// legacyLineupRound is the round v2.1.1 and earlier read m's lineup by. +func legacyLineupRound(m DrawMatch) int { + if m.Knockout { + return m.Round + } + return max(m.PoolRound, 0) +} + +// lineupRoster is the teams of a competition, by participant id and by name. +type lineupRoster struct { + ids map[string]struct{} + byName map[string][]string + // withoutID holds the names of the players that have no participant id yet: + // a roster recorded before ids existed keeps none until the operator applies + // the participant list. + withoutID map[string]struct{} +} + +func newLineupRoster(players []domain.Player) lineupRoster { + r := lineupRoster{ + ids: make(map[string]struct{}, len(players)), + byName: make(map[string][]string, len(players)), + withoutID: map[string]struct{}{}, + } + for _, p := range players { + if p.ID == "" { + r.withoutID[p.Name] = struct{}{} + continue + } + r.ids[p.ID] = struct{}{} + r.byName[p.Name] = append(r.byName[p.Name], p.ID) + } + return r +} + +func (r lineupRoster) has(teamID string) bool { + _, ok := r.ids[teamID] + return ok +} + +// awaitsID reports whether a player named name has no participant id yet, so the +// name cannot be matched to exactly one team until it has one. +func (r lineupRoster) awaitsID(name string) bool { + _, ok := r.withoutID[name] + return ok +} + +// roundLineupSettlement is what settleRoundLineups decided: the lineups to +// store, whether they differ from those it was given, how many things still wait +// (a legacy team, until the competition is completed, or a lineup saved under the +// name of a team that has no id yet), the (team, match) pairs settled, the legacy +// teams found, and a note for the log on every match lineup given, round lineup +// removed and re-key. +type roundLineupSettlement struct { + lineups map[string]domain.TeamLineup + changed bool + waiting int + // given is every pair settled so far, as Competition.RoundLineupsGiven + // records them (a team's id to its sorted match ids; nil for none), and + // givenGrew whether that is more than the competition's record holds. + given map[string][]string + givenGrew bool + // legacy is the legacy teams this settlement found, sorted, and + // legacyFound whether this was the competition's first settlement and found + // any, so the list is to be recorded (Competition.RoundLineupsLegacy). + legacy []string + legacyFound bool + notes []string +} + +// cloneRoundLineupsGiven is a deep copy of a Competition.RoundLineupsGiven: the +// map and every list in it, so a copy of the record shares nothing with it. +func cloneRoundLineupsGiven(given map[string][]string) map[string][]string { + if given == nil { + return nil + } + out := make(map[string][]string, len(given)) + for team, matchIDs := range given { + out[team] = slices.Clone(matchIDs) + } + return out +} + +func (r *roundLineupSettlement) notef(format string, args ...any) { + r.notes = append(r.notes, fmt.Sprintf(format, args...)) +} + +// scopeOf names what a lineup was saved for, in a log line. +func scopeOf(l domain.TeamLineup) string { + if l.MatchID != "" { + return "match " + l.MatchID + } + return fmt.Sprintf("round %d", l.Round) +} + +// lineupCopy is l as a lineup of team for matchID (empty for the starting +// lineup, which is round 0), carrying its own copies of the maps. +func lineupCopy(l domain.TeamLineup, team, compID, matchID string, round int) domain.TeamLineup { + return domain.TeamLineup{ + TeamID: team, CompetitionID: compID, Round: round, MatchID: matchID, + Positions: maps.Clone(l.Positions), MemberIDs: maps.Clone(l.MemberIDs), + } +} + +// settleRoundLineups gives each legacy team the lineup v2.1.1 showed at every +// match it is seated in, and removes the round lineups once the competition is +// completed. It reads only its arguments: comp is the competition (its format +// says whether a knockout is played, its status whether it is over, +// RoundLineupsGiven which pairs were settled before and RoundLineupsLegacy which +// teams the first settlement found legacy), players its roster, lineups the +// stored lineups (never modified), and draw the matches as DrawMatchesFrom lists +// them. +// +// A pair (team, match) is settled once: when the team is seated in the match and +// the record does not list the pair, the team is given v2.1.1's reading unless it +// already has a lineup of its own for the match, and the pair is listed either +// way. A listed pair is left alone, whatever the lineups hold. +// +// A lineup saved under a team's name is keyed by the team's id on every pass +// (one whose team has no id yet waits), so the write that follows the minting of +// the ids converts it. onLoad is the load repair's pass, which also gives a +// legacy team that has no starting lineup what v2.1.1 showed before any round it +// saved as one; a write that seats a team settles without it, so a starting +// lineup removed since is not brought back. +func settleRoundLineups(comp *Competition, players []domain.Player, lineups map[string]domain.TeamLineup, draw []DrawMatch, onLoad bool) roundLineupSettlement { + res := roundLineupSettlement{lineups: maps.Clone(lineups)} + if res.lineups == nil { + res.lineups = map[string]domain.TeamLineup{} + } + roster := newLineupRoster(players) + res.keyByTeamID(roster, onLoad) + matches := TeamMatches(draw, comp.IsKnockoutEnabled()) + legacy := res.legacyTeams(comp, roster, matches) + res.giveMatchLineups(comp, legacy, matches) + if onLoad { + res.seedStartingLineups(comp.ID, legacy) + } + res.retireRoundLineups(legacy, comp.Status == CompStatusComplete) + return res +} + +// keyByTeamID re-keys a lineup whose team id is not a participant id but is +// exactly one team's name (v2.0.0's Lineups page addressed a team that had no +// id yet by its name) under that team's id. When the team already has a lineup +// for the same scope the id-keyed one stays and the name-keyed one is dropped. +// A name that matches no team, or several, is left as it is. One that matches a +// player with no id yet waits for it (a roster recorded without ids, until the +// participant list is applied), and so does a name shared with such a player, +// which cannot be told apart until it has one. The notes for the lineups left +// as they are are made on the load pass only, so a write does not repeat them. +func (r *roundLineupSettlement) keyByTeamID(roster lineupRoster, onLoad bool) { + for _, key := range slices.Sorted(maps.Keys(r.lineups)) { + l := r.lineups[key] + if l.TeamID == "" || roster.has(l.TeamID) { + continue + } + if roster.awaitsID(l.TeamID) { + r.waiting++ + if onLoad { + r.notef("the %s lineup of %q waits: a team of that name has no id yet", scopeOf(l), l.TeamID) + } + continue + } + teams := roster.byName[l.TeamID] + switch len(teams) { + case 0: + if onLoad { + r.notef("the %s lineup of %q is left as it is: no team has that name", scopeOf(l), l.TeamID) + } + case 1: + moved := l + moved.TeamID = teams[0] + delete(r.lineups, key) + r.changed = true + newKey := lineupStorageKey(moved) + if _, taken := r.lineups[newKey]; taken { + r.notef("the %s lineup of %q is dropped: team %s already has one for it", scopeOf(l), l.TeamID, teams[0]) + continue + } + r.lineups[newKey] = moved + r.notef("the %s lineup of %q is now keyed by team %s", scopeOf(l), l.TeamID, teams[0]) + default: + if onLoad { + r.notef("the %s lineup of %q is left as it is: %d teams have that name", scopeOf(l), l.TeamID, len(teams)) + } + } + } +} + +// legacyTeams returns, for each legacy team of the roster, all its round +// lineups, the starting lineup (round 0) included, lowest round first: none for a +// team that had no lineup for a round. A team is legacy when it +// +// - has a lineup for a round r >= 1, which only the Lineups page of releases up +// to v2.1.1 could save; +// - is one of the teams comp.RoundLineupsLegacy records, the legacy teams the +// competition's first settlement found; or +// - at that first settlement itself (nothing recorded yet), has a match lineup, +// stored under its id, for a match of the draw that seats it. v2.1.1 read a +// match's own lineup and never carried it to another match, so such a team +// was shown at its other matches what its round lineups said, or nothing, +// where a team now carries the lineup of its previous match. A match lineup +// for a match that does not seat the team, left behind by a re-seat, is no +// reading v2.1.1 showed and makes it none; and one saved after the first +// settlement is this release's, which carries, so it never does. +// +// A team that is none of these is left exactly as it is. Which teams were found +// is recorded when this is the first settlement (legacyFound), so the next one, +// and a restart's, reads them rather than deriving them again. +func (r *roundLineupSettlement) legacyTeams(comp *Competition, roster lineupRoster, matches []TeamMatch) map[string][]domain.TeamLineup { + rounds := map[string][]domain.TeamLineup{} + for _, l := range r.lineups { + if l.MatchID == "" && roster.has(l.TeamID) { + rounds[l.TeamID] = append(rounds[l.TeamID], l) + } + } + legacy := map[string][]domain.TeamLineup{} + add := func(team string) { + lineups := rounds[team] + slices.SortFunc(lineups, func(a, b domain.TeamLineup) int { return cmp.Compare(a.Round, b.Round) }) + legacy[team] = lineups + } + for team, lineups := range rounds { + if slices.ContainsFunc(lineups, func(l domain.TeamLineup) bool { return l.Round >= 1 }) { + add(team) + } + } + for _, team := range comp.RoundLineupsLegacy { + if roster.has(team) { + add(team) + } + } + first := comp.RoundLineupsLegacy == nil + if first { + seatedIn := func(team, matchID string) bool { + return slices.ContainsFunc(matches, func(m TeamMatch) bool { return m.ID == matchID && m.Seats(team) }) + } + for _, l := range r.lineups { + if l.MatchID != "" && roster.has(l.TeamID) && seatedIn(l.TeamID, l.MatchID) { + add(l.TeamID) + } + } + } + if first && len(legacy) > 0 { + r.legacy = slices.Sorted(maps.Keys(legacy)) + r.legacyFound = true + } + return legacy +} + +// teamsWithRoundLineups is the teams of the roster that have a lineup for a round +// r >= 1 in lineups, each once, sorted: the legacy teams a draw's removal leaves, +// since a team found only for the lineups entered for its matches has nothing left +// to be shown once they went with the draw. A lineup of a team that is no longer on +// the roster is no team's to wait for. nil when there are none, as the record of +// legacy teams (Competition.RoundLineupsLegacy) is when empty. +func teamsWithRoundLineups(lineups map[string]domain.TeamLineup, roster lineupRoster) []string { + var teams []string + for _, l := range lineups { + if l.MatchID == "" && l.Round >= 1 && roster.has(l.TeamID) { + teams = append(teams, l.TeamID) + } + } + slices.Sort(teams) + return slices.Compact(teams) +} + +// anyAwaitsID reports whether a lineup is stored under the name of a player that +// has no participant id yet, which keyByTeamID keeps waiting for the id: nothing +// may mark the conversion done while one does, or it would stay name-keyed for good. +func (r lineupRoster) anyAwaitsID(lineups map[string]domain.TeamLineup) bool { + for _, l := range lineups { + if l.TeamID != "" && !r.has(l.TeamID) && r.awaitsID(l.TeamID) { + return true + } + } + return false +} + +// legacyReading is the lineup v2.1.1 showed a team at a match read at round, +// from the team's round lineups, lowest round first: the one with the highest +// round at or below round, else the highest round there was. ok is false when the +// team had no lineup for a round at all, and v2.1.1 showed none. +func legacyReading(rounds []domain.TeamLineup, round int) (lineup domain.TeamLineup, ok bool) { + for i := len(rounds) - 1; i >= 0; i-- { + if rounds[i].Round <= round { + return rounds[i], true + } + } + if len(rounds) == 0 { + return domain.TeamLineup{}, false + } + // Every round is above round: v2.1.1 fell back to the highest there was. + return rounds[len(rounds)-1], true +} + +// giveMatchLineups settles each pair of a legacy team and a match it is seated in +// by id that the competition's record does not list: the team is given the lineup +// v2.1.1 showed there (legacyReading at the round the match was read at) unless +// it already has a lineup of its own for the match, which is v2.1.1's own reading +// of it and is left as it is, and the pair is listed either way. A team that had +// no lineup for a round at all was shown none, and is given an empty lineup of its +// own, which the lineup rule honours as the match's own, so the match shows none +// rather than what an earlier one does. A listed pair is left alone, so a lineup +// the operator removed from a match stays removed. The reading is taken from the +// round lineups as the pass finds them, so a starting lineup an earlier pass +// seeded is one of them: it is the one v2.1.1 fell back to for a match read below +// the team's lowest round, so it changes no reading. +func (r *roundLineupSettlement) giveMatchLineups(comp *Competition, legacy map[string][]domain.TeamLineup, matches []TeamMatch) { + settled := make(map[string]map[string]struct{}, len(comp.RoundLineupsGiven)) + for team, matchIDs := range comp.RoundLineupsGiven { + for _, id := range matchIDs { + if settled[team] == nil { + settled[team] = map[string]struct{}{} + } + settled[team][id] = struct{}{} + } + } + for _, team := range slices.Sorted(maps.Keys(legacy)) { + for _, m := range matches { + if !m.Seats(team) { + continue + } + if _, done := settled[team][m.ID]; done { + continue + } + if settled[team] == nil { + settled[team] = map[string]struct{}{} + } + settled[team][m.ID] = struct{}{} + r.givenGrew = true + key := teamLineupMatchKey(team, m.ID) + if _, own := r.lineups[key]; own { + continue + } + src, shown := legacyReading(legacy[team], legacyLineupRound(m.DrawMatch)) + given := lineupCopy(src, team, comp.ID, m.ID, 0) + if given.Positions == nil { + given.Positions = map[domain.Position]string{} + } + r.lineups[key] = given + r.changed = true + if shown { + r.notef("team %s: its round %d lineup is now its own lineup for match %s, where v2.1.1 showed it", team, src.Round, m.ID) + } else { + r.notef("team %s: an empty lineup is now its own lineup for match %s, where v2.1.1 showed none", team, m.ID) + } + } + } + r.given = nil + for team, matchIDs := range settled { + if r.given == nil { + r.given = make(map[string][]string, len(settled)) + } + r.given[team] = slices.Sorted(maps.Keys(matchIDs)) + } +} + +// seedStartingLineups gives each legacy team that has no starting lineup (round +// 0) what v2.1.1 showed it before any round it saved, legacyReading at round 0: +// its highest round's lineup, which v2.1.1 fell back to, as its starting lineup. +// A match the team is seated in after the load and read at a round below its +// lowest saved one then finds that lineup, as v2.1.1 did, in whichever pass +// reaches it. It shows on the Lineups page and for a match the draw does not +// hold. +func (r *roundLineupSettlement) seedStartingLineups(compID string, legacy map[string][]domain.TeamLineup) { + for _, team := range slices.Sorted(maps.Keys(legacy)) { + if len(legacy[team]) == 0 || legacy[team][0].Round == 0 { + continue + } + src, _ := legacyReading(legacy[team], 0) + r.lineups[teamLineupKey(team, 0)] = lineupCopy(src, team, compID, "", 0) + r.changed = true + r.notef("team %s had no starting lineup: its round %d lineup, which v2.1.1 showed before any round it saved, is now it", team, src.Round) + } +} + +// retireRoundLineups removes each legacy team's round lineups (round >= 1) once +// the competition is completed, and counts each legacy team as waiting until +// then: a correction can seat a team in a match until it is over, and the match is +// given the lineup v2.1.1 showed there. The starting lineup stays. A team with no +// round lineup to remove waits too, since the match it is seated in later is given +// its reading all the same. +func (r *roundLineupSettlement) retireRoundLineups(legacy map[string][]domain.TeamLineup, completed bool) { + for _, team := range slices.Sorted(maps.Keys(legacy)) { + held := 0 + for _, l := range legacy[team] { + if l.Round < 1 { + continue + } + held++ + if completed { + delete(r.lineups, lineupStorageKey(l)) + r.changed = true + r.notef("the round %d lineup of team %s is removed: the competition is completed, and each match the team is seated in holds its own lineup", l.Round, team) + } + } + if !completed { + r.waiting += max(held, 1) + } + } +} + +// roundLineupStage is what one settlement reads and writes: the competition +// files as its caller sees them (a transaction's own staged bytes first) and the +// caller's write function, so a transaction stages what a settlement decides +// with the write that seats a team and an abandoned transaction leaves +// lineups.yaml alone. +type roundLineupStage struct { + store *Store + compID string + write writeFn + comp func() (*Competition, error) + lineups func() (map[string]domain.TeamLineup, error) + poolMatches func() ([]MatchResult, error) + bracket func() (*Bracket, error) + players func(comp *Competition) ([]domain.Player, error) +} + +// directRoundLineupStage is the stage of a write that goes straight to disk. +// Caller holds the competition's per-comp lock. +func (s *Store) directRoundLineupStage(compID string) *roundLineupStage { + st := &roundLineupStage{store: s, compID: compID, write: s.directWrite} + st.comp = func() (*Competition, error) { return s.loadCompetitionLocked(compID) } + st.lineups = func() (map[string]domain.TeamLineup, error) { return s.loadTeamLineupsLocked(compID) } + st.poolMatches = func() ([]MatchResult, error) { return s.LoadPoolMatchesLocked(compID) } + st.bracket = func() (*Bracket, error) { return s.loadBracketLocked(compID) } + st.players = st.loadPlayers + return st +} + +// roundLineupStage is the stage of a transaction: every read sees what the +// transaction has staged, every write is staged with it. +func (t *storeTx) roundLineupStage() *roundLineupStage { + st := &roundLineupStage{store: t.store, compID: t.compID, write: t.txWriteFn()} + st.comp = func() (*Competition, error) { return t.LoadCompetition(t.compID) } + st.lineups = func() (map[string]domain.TeamLineup, error) { return t.LoadTeamLineups(t.compID) } + st.poolMatches = func() ([]MatchResult, error) { return t.LoadPoolMatches(t.compID) } + st.bracket = func() (*Bracket, error) { return t.LoadBracket(t.compID) } + st.players = st.loadPlayers + return st +} + +// loadPlayers reads the roster. participants.csv is never staged in a +// transaction (it is written directly), so both stages read it from disk. +func (st *roundLineupStage) loadPlayers(comp *Competition) ([]domain.Player, error) { + return st.store.loadParticipantsNoLock(st.compID, comp.EffectiveWithZekkenName(), LoadParticipantsOpts{HasIDs: comp.ParticipantIDsHint()}) +} + +// settle runs one settlement and stores what it decided: the lineups when they +// changed, then the competition's record when the pairs settled grew or the first +// settlement found the legacy teams (or, when nothing waits any more, with the +// marker set and the pairs and the legacy teams cleared). saved is the record as +// it was saved, nil when nothing was. A competition that is not a team +// competition, that is already marked, or that does not exist has nothing to +// settle. Everything is read and decided before the first write, so a failure +// leaves nothing half done; a failed write is retried by the next write of the +// draw, and lineups saved before the record failed to be are found there +// already, so the next settlement only lists their pairs. +func (st *roundLineupStage) settle(onLoad bool) (saved *Competition, err error) { + if st.store.roundLineupsSettled(st.compID) { + return nil, nil + } + comp, err := st.comp() + if err != nil || comp == nil || comp.RoundLineupsConverted || !comp.IsTeam() { + return nil, err + } + if comp.ID != st.compID { + // saveCompetitionChangedLocked paths and locks off comp.ID, not the + // directory this call was handed: see upgradeCompetitionFormatLocked's + // BUG 1. Leave the files as they are. + log.Printf("state: round-lineup settlement for %s: config.md id %q does not match its directory; left unconverted", st.compID, comp.ID) + return nil, nil + } + lineups, err := st.lineups() + if err != nil { + return nil, err + } + players, err := st.players(comp) + if err != nil { + return nil, err + } + pool, err := st.poolMatches() + if err != nil { + return nil, err + } + bracket, err := st.bracket() + if err != nil { + return nil, err + } + + res := settleRoundLineups(comp, players, lineups, DrawMatchesFrom(pool, bracket), onLoad) + + if res.changed { + if err := st.store.saveTeamLineupsLocked(st.compID, res.lineups, st.write); err != nil { + return nil, err + } + } + for _, note := range res.notes { + log.Printf("state: round-lineup upgrade for %s: %s", st.compID, note) + } + marked := res.waiting == 0 + if !marked && !res.givenGrew && !res.legacyFound { + return nil, nil + } + next := *comp + if marked { + next.RoundLineupsConverted = true + next.RoundLineupsGiven = nil + next.RoundLineupsLegacy = nil + } else { + next.RoundLineupsGiven = res.given + if res.legacyFound { + next.RoundLineupsLegacy = res.legacy + } + } + if _, err := st.store.saveCompetitionChangedLocked(&next, st.write); err != nil { + return nil, fmt.Errorf("round lineups settled but the competition's record of them was not saved: %w", err) + } + return &next, nil +} + +// roundLineupsSettled reports, from the competition record the store already +// holds and without reading config.md, that compID has nothing to settle: it is +// not a team competition, or its marker is set. Every write of the draw asks the +// settlement, and nearly every competition is marked (a new one is, and so is one +// with no legacy team), so the question is answered without a disk read and a +// parse of the file under the lock. The cached record is checked against the +// file's modification time before it is believed, as every cached read is, and a +// record that is not cached, or is not current, answers false: the settlement +// reads config.md itself then. Caller holds the per-comp lock, which is why this +// takes only the cache's own, and a transaction's staged competition, which the +// cache holds as well, is the record it reads there. +func (s *Store) roundLineupsSettled(compID string) bool { + cache := s.getFileCache(compID, "config.md") + cache.mu.RLock() + defer cache.mu.RUnlock() + comp, _ := cache.data.(*Competition) + if comp == nil || cache.mtime != s.FileMtime(compID, "config.md") { + return false + } + return comp.RoundLineupsConverted || !comp.IsTeam() +} + +// settleRoundLineupsAfterWrite settles after a draw write that went straight to +// disk, under the per-comp lock the write holds. It is best effort, like the +// load repair: the write has landed, and a failure here is retried by the next +// one. +func (s *Store) settleRoundLineupsAfterWrite(compID string) { + if _, err := s.directRoundLineupStage(compID).settle(false); err != nil { + log.Printf("state: round-lineup settlement for %s: %v", compID, err) + } +} + +// settleRoundLineupsAtCommit settles at the end of a transaction that wrote the +// draw (bracket.json or pool-matches.csv), against what the transaction staged, +// and stages the lineups it decides beside those writes so they commit or abort +// together. Once, at the end, so what a transaction later takes back (a +// rolled-back write) was never given a lineup. +func (t *storeTx) settleRoundLineupsAtCommit() { + wroteDraw := false + for _, in := range t.wal.Intents() { + if base := filepath.Base(in.Path); base == "bracket.json" || base == "pool-matches.csv" { + wroteDraw = true + break + } + } + if !wroteDraw { + return + } + if _, err := t.roundLineupStage().settle(false); err != nil { + log.Printf("state: round-lineup settlement for %s: %v", t.compID, err) + } +} diff --git a/internal/state/round_lineups_match_only_test.go b/internal/state/round_lineups_match_only_test.go new file mode 100644 index 000000000..983421551 --- /dev/null +++ b/internal/state/round_lineups_match_only_test.go @@ -0,0 +1,413 @@ +package state_test + +// round_lineups_match_only_test.go pins the legacy teams that have a v2.1.1 MATCH +// lineup and no lineup for a round (operator decision 2026-10-07: "Show what v2.1.1 +// showed"). v2.1.1 read a match's lineup as its own, never carried to another match, +// else the round lineup with the highest round at or below the match's, else the +// highest round, else none. A team now carries the lineup of its previous match, so +// such a team would show at its later matches what v2.1.1 showed only at the match it +// was entered for. The first settlement of a competition therefore treats a team +// with a match lineup, stored under its id, for a match of the current draw that +// seats it, as a legacy team too, and gives every match the team is seated in, as its +// own lineup, what v2.1.1 showed there: its starting lineup, or none (an empty +// lineup of its own) for a team that had no round lineup at all. The teams found are +// recorded in config.md, so a match lineup saved by this release never makes one. + +import ( + "os" + "path/filepath" + "slices" + "testing" + + "github.com/gitrgoliveira/bracket-creator/internal/domain" + "github.com/gitrgoliveira/bracket-creator/internal/state" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// rlThreeMatches is Tora's pool: three matches in playing order, each at its own +// round, and a fourth between two other teams. Pool A-1 is fought already. +func rlThreeMatches(teams rlTeams) []state.MatchResult { + pool := []state.MatchResult{ + rlBout(teams, "Pool A-0", 0, teams.tora, teams.usagi), + rlBout(teams, "Pool A-1", 1, teams.tora, teams.kuma), + rlBout(teams, "Pool A-2", 2, teams.tora, teams.saru), + rlBout(teams, "Pool A-3", 0, teams.usagi, teams.kuma), + } + pool[1].Status = state.MatchStatusCompleted + return pool +} + +// rlIsEmpty asserts a lineup is stored for the scope and holds no position. +func rlIsEmpty(t *testing.T, lineups map[string]domain.TeamLineup, teamID, matchID string) { + t.Helper() + l, ok := rlFind(lineups, teamID, matchID, 0) + if assert.Truef(t, ok, "no lineup for team %s match %q; have %v", teamID, matchID, lineups) { + assert.Empty(t, l.Positions) + assert.NotNil(t, l.Positions, "an own lineup that is empty, not a missing one") + } +} + +// TestRoundLineups_ATeamWithMatchLineupsAndAStartingLineupIsShownWhatV211Showed: v2.1.1 +// showed the starting lineup at every match the team has no lineup entered for, so +// each of those matches (a fought one too) gets it as its own, and the lineups entered +// stay as they are. +func TestRoundLineups_ATeamWithMatchLineupsAndAStartingLineupIsShownWhatV211Showed(t *testing.T) { + teams := newRLTeams() + pool := rlThreeMatches(teams) + legacy := []domain.TeamLineup{ + rlLineup(teams.tora, "", 0, "tora-start"), + rlLineup(teams.tora, "Pool A-0", 0, "tora-entered-m0"), + rlLineup(teams.tora, "Pool A-2", 0, "tora-entered-m2"), + } + dir := rlSeed(t, rlTeamComp(state.CompFormatLeague), teams, pool, nil, legacy...) + + store, err := state.NewStore(dir) + require.NoError(t, err) + lineups := rlLoad(t, store) + + rlAssertShowsWhatV211Showed(t, lineups, legacy, state.DrawMatchesFrom(pool, nil), teams.tora) + rlHas(t, lineups, teams.tora, "Pool A-0", 0, "tora-entered-m0") + rlHas(t, lineups, teams.tora, "Pool A-1", 0, "tora-start") // fought, and v2.1.1 showed the starting lineup there + rlHas(t, lineups, teams.tora, "Pool A-2", 0, "tora-entered-m2") + rlHas(t, lineups, teams.tora, "", 0, "tora-start") + assert.Equal(t, map[string][]string{teams.tora: {"Pool A-0", "Pool A-1", "Pool A-2"}}, rlGiven(t, store)) + assert.False(t, rlMarker(t, store), "the team waits for the competition to be completed: a match it is seated in later is given its reading") + for _, other := range []string{teams.usagi, teams.kuma, teams.saru} { + for _, id := range []string{"Pool A-0", "Pool A-1", "Pool A-2", "Pool A-3"} { + rlLacks(t, lineups, other, id, 0) + } + } +} + +// A team with a match lineup and no lineup for any round was shown nothing at every +// other match: its own lineups are kept, and each of its other matches gets an empty +// lineup of its own, so none shows what an earlier match did. +func TestRoundLineups_ATeamWithMatchLineupsAndNoRoundLineupIsShownNothingElsewhere(t *testing.T) { + teams := newRLTeams() + pool := rlThreeMatches(teams) + legacy := []domain.TeamLineup{rlLineup(teams.tora, "Pool A-0", 0, "tora-entered-m0")} + dir := rlSeed(t, rlTeamComp(state.CompFormatLeague), teams, pool, nil, legacy...) + + store, err := state.NewStore(dir) + require.NoError(t, err) + lineups := rlLoad(t, store) + + rlAssertShowsWhatV211Showed(t, lineups, legacy, state.DrawMatchesFrom(pool, nil), teams.tora) + rlHas(t, lineups, teams.tora, "Pool A-0", 0, "tora-entered-m0") + rlIsEmpty(t, lineups, teams.tora, "Pool A-1") + rlIsEmpty(t, lineups, teams.tora, "Pool A-2") + rlLacks(t, lineups, teams.tora, "", 0) + assert.False(t, rlMarker(t, store)) + // The stored shape of an empty own lineup: a match-scoped entry whose positions + // are an empty map, and no member ids. + raw := rlRaw(t, dir, "lineups.yaml") + assert.Contains(t, raw, "matchId: Pool A-1\n positions: {}\n") + assert.Contains(t, raw, "matchId: Pool A-2\n positions: {}\n") +} + +// A match lineup left behind by a re-seat, for a match the team is not seated in, is +// no reading v2.1.1 showed: it makes no team legacy, and nothing waits. +func TestRoundLineups_AMatchLineupForAMatchTheTeamIsNotSeatedInMakesNoTeamLegacy(t *testing.T) { + teams := newRLTeams() + pool := rlThreeMatches(teams) + dir := rlSeed(t, rlTeamComp(state.CompFormatLeague), teams, pool, nil, + rlLineup(teams.saru, "Pool A-3", 0, "saru-left-behind"), // Pool A-3 is Usagi against Kuma + rlLineup(teams.saru, "No Such Match", 0, "saru-no-such-match"), + ) + + store, err := state.NewStore(dir) + require.NoError(t, err) + lineups := rlLoad(t, store) + + rlLacks(t, lineups, teams.saru, "Pool A-2", 0) + rlHas(t, lineups, teams.saru, "Pool A-3", 0, "saru-left-behind") + assert.True(t, rlMarker(t, store), "no team is legacy, so nothing waits") +} + +// A completed v2.1.1 competition is pinned and then marked in the one pass: nothing +// can seat a team after it, so no team waits. +func TestRoundLineups_ACompletedCompetitionIsPinnedThenMarked(t *testing.T) { + teams := newRLTeams() + pool := rlThreeMatches(teams) + legacy := []domain.TeamLineup{ + rlLineup(teams.tora, "", 0, "tora-start"), + rlLineup(teams.tora, "Pool A-0", 0, "tora-entered-m0"), + } + dir := rlSeed(t, rlCompleted(rlTeamComp(state.CompFormatLeague)), teams, pool, nil, legacy...) + + store, err := state.NewStore(dir) + require.NoError(t, err) + lineups := rlLoad(t, store) + + rlAssertShowsWhatV211Showed(t, lineups, legacy, state.DrawMatchesFrom(pool, nil), teams.tora) + rlHas(t, lineups, teams.tora, "Pool A-1", 0, "tora-start") + rlHas(t, lineups, teams.tora, "Pool A-2", 0, "tora-start") + assert.True(t, rlMarker(t, store)) + assert.Empty(t, rlGiven(t, store), "the record goes with the marker") +} + +// A match-only legacy team is seated later by a write of the draw and is given its +// reading in that write: the starting lineup, not what the match it was entered for +// carries. +func TestRoundLineups_AMatchOnlyLegacyTeamSeatedLaterIsGivenItsReadingInThatWrite(t *testing.T) { + teams := newRLTeams() + legacy := []domain.TeamLineup{ + rlLineup(teams.usagi, "", 0, "usagi-start"), + rlLineup(teams.usagi, "r0-m1", 0, "usagi-entered-semi"), + } + dir := rlSeed(t, rlTeamComp(state.CompFormatKnockout), teams, nil, rlKnockout(teams), legacy...) + store, err := state.NewStore(dir) + require.NoError(t, err) + lineups := rlLoad(t, store) + rlHas(t, lineups, teams.usagi, "r0-m1", 0, "usagi-entered-semi") + rlLacks(t, lineups, teams.usagi, "r1-m0", 0) + require.False(t, rlMarker(t, store), "precondition: Usagi waits to be seated in the final") + + require.NoError(t, store.UpdateBracket(rlComp, rlSeatUsagi(teams))) + + lineups = rlLoad(t, store) + rlHas(t, lineups, teams.usagi, "r1-m0", 0, "usagi-start") + rlHas(t, lineups, teams.usagi, "r0-m1", 0, "usagi-entered-semi") + assert.False(t, rlMarker(t, store)) +} + +// rlRecordedLegacy is what the first settlement recorded in config.md as the +// competition's legacy teams, sorted. +func rlRecordedLegacy(teams ...string) []string { return slices.Sorted(slices.Values(teams)) } + +// The first settlement records the legacy teams, and the marker does not fire +// while one exists: it waits for the competition to be completed, with the record +// beside it in config.md. +func TestRoundLineups_TheFirstSettlementRecordsTheLegacyTeamsAndDoesNotMark(t *testing.T) { + teams := newRLTeams() + pool := rlThreeMatches(teams) + dir := rlSeed(t, rlTeamComp(state.CompFormatLeague), teams, pool, nil, + rlLineup(teams.tora, "", 1, "tora-round1"), // a lineup for a round + rlLineup(teams.kuma, "Pool A-1", 0, "kuma-entered-m1"), // a lineup entered for a match it plays + rlLineup(teams.saru, "Pool A-3", 0, "saru-left-behind"), // Pool A-3 is Usagi against Kuma + ) + + store, err := state.NewStore(dir) + require.NoError(t, err) + + assert.Equal(t, rlRecordedLegacy(teams.tora, teams.kuma), rlLegacy(t, store), "Saru's lineup is for a match it is not seated in") + assert.False(t, rlMarker(t, store)) + assert.Contains(t, rlRaw(t, dir, "config.md"), "round_lineups_legacy:\n") + assert.NotContains(t, rlRaw(t, dir, "config.md"), "round_lineups_converted") +} + +// A competition with no legacy team records none and is marked at once: nothing +// waits. +func TestRoundLineups_ACompetitionWithNoLegacyTeamIsMarkedAtTheFirstSettlement(t *testing.T) { + teams := newRLTeams() + dir := rlSeed(t, rlTeamComp(state.CompFormatLeague), teams, rlThreeMatches(teams), nil, + rlLineup(teams.tora, "", 0, "tora-start")) + + store, err := state.NewStore(dir) + require.NoError(t, err) + + assert.True(t, rlMarker(t, store)) + assert.Empty(t, rlLegacy(t, store)) + assert.NotContains(t, rlRaw(t, dir, "config.md"), "round_lineups_legacy") +} + +// A match lineup saved by this release after the first settlement is the +// operator's own, and carries to the team's later matches: the write that seats +// Usagi in the final gives it no lineup, where the same lineup found at the first +// pass would have pinned its reading there (the starting lineup, or none). +func TestRoundLineups_AMatchLineupSavedAfterTheFirstSettlementMakesNoTeamLegacy(t *testing.T) { + teams := newRLTeams() + dir := rlSeed(t, rlTeamComp(state.CompFormatKnockout), teams, nil, rlKnockout(teams), + rlLineup(teams.tora, "", 1, "tora-round1")) + store, err := state.NewStore(dir) + require.NoError(t, err) + require.False(t, rlMarker(t, store), "precondition: Tora waits for the competition to be completed") + require.Equal(t, rlRecordedLegacy(teams.tora), rlLegacy(t, store), "precondition: the first settlement recorded its legacy teams") + + require.NoError(t, store.SetTeamLineup(rlComp, rlLineup(teams.usagi, "r0-m1", 0, "usagi-saved-now"), 3)) + require.NoError(t, store.UpdateBracket(rlComp, rlSeatUsagi(teams))) + + lineups := rlLoad(t, store) + rlHas(t, lineups, teams.usagi, "r0-m1", 0, "usagi-saved-now") + rlLacks(t, lineups, teams.usagi, "r1-m0", 0) + assert.Equal(t, rlRecordedLegacy(teams.tora), rlLegacy(t, store)) + assert.NotContains(t, rlGiven(t, store), teams.usagi) + assert.False(t, rlMarker(t, store)) +} + +// A restart after the first pass reads the record instead of deriving the legacy +// teams again: the match lineup this release saved in between does not make its +// team legacy, and the load writes nothing. +func TestRoundLineups_ARestartAfterTheFirstSettlementDoesNotFindTheLegacyTeamsAgain(t *testing.T) { + teams := newRLTeams() + dir := rlSeed(t, rlTeamComp(state.CompFormatKnockout), teams, nil, rlKnockout(teams), + rlLineup(teams.tora, "", 1, "tora-round1")) + store, err := state.NewStore(dir) + require.NoError(t, err) + require.NoError(t, store.SetTeamLineup(rlComp, rlLineup(teams.usagi, "r0-m1", 0, "usagi-saved-now"), 3)) + config, lineupsFile := rlRaw(t, dir, "config.md"), rlRaw(t, dir, "lineups.yaml") + + restarted, err := state.NewStore(dir) + require.NoError(t, err) + + assert.Equal(t, config, rlRaw(t, dir, "config.md"), "the load writes nothing to config.md") + assert.Equal(t, lineupsFile, rlRaw(t, dir, "lineups.yaml"), "or to lineups.yaml") + assert.Equal(t, rlRecordedLegacy(teams.tora), rlLegacy(t, restarted)) + require.NoError(t, restarted.UpdateBracket(rlComp, rlSeatUsagi(teams))) + rlLacks(t, rlLoad(t, restarted), teams.usagi, "r1-m0", 0) + assert.Equal(t, rlRecordedLegacy(teams.tora), rlLegacy(t, restarted)) +} + +// A discard (or a new draw) takes the match lineups with it, and a team that was +// legacy only for them has nothing left to be shown: it leaves the record, and the +// marker is set when no legacy team remains. A team with a lineup for a round +// stays, since the next draw's matches are given theirs from it. +func TestRoundLineups_ADiscardDropsTheMatchOnlyLegacyTeamsAndMarksWhenNoneRemains(t *testing.T) { + for _, tc := range []struct { + name string + lineups func(rlTeams) []domain.TeamLineup + before func(rlTeams) []string + after func(rlTeams) []string + wantMarked bool + }{ + { + name: "only match-only legacy teams", + lineups: func(teams rlTeams) []domain.TeamLineup { + return []domain.TeamLineup{ + rlLineup(teams.tora, "", 0, "tora-start"), + rlLineup(teams.tora, "Pool A-0", 0, "tora-entered-m0"), + rlLineup(teams.kuma, "Pool A-1", 0, "kuma-entered-m1"), + } + }, + before: func(teams rlTeams) []string { return rlRecordedLegacy(teams.tora, teams.kuma) }, + after: func(rlTeams) []string { return nil }, + wantMarked: true, + }, + { + name: "a team with a lineup for a round stays", + lineups: func(teams rlTeams) []domain.TeamLineup { + return []domain.TeamLineup{ + rlLineup(teams.tora, "", 1, "tora-round1"), + rlLineup(teams.kuma, "Pool A-1", 0, "kuma-entered-m1"), + } + }, + before: func(teams rlTeams) []string { return rlRecordedLegacy(teams.tora, teams.kuma) }, + after: func(teams rlTeams) []string { return rlRecordedLegacy(teams.tora) }, + wantMarked: false, + }, + } { + t.Run(tc.name, func(t *testing.T) { + teams := newRLTeams() + dir := rlSeed(t, rlTeamComp(state.CompFormatLeague), teams, rlThreeMatches(teams), nil, tc.lineups(teams)...) + store, err := state.NewStore(dir) + require.NoError(t, err) + require.Equal(t, tc.before(teams), rlLegacy(t, store), "precondition") + require.False(t, rlMarker(t, store), "precondition") + require.NotEmpty(t, rlGiven(t, store), "precondition") + + require.NoError(t, store.ClearDrawLineups(rlComp)) + + assert.Equal(t, tc.after(teams), rlLegacy(t, store)) + assert.Equal(t, tc.wantMarked, rlMarker(t, store)) + assert.Empty(t, rlGiven(t, store), "the pairs of the discarded draw are not kept") + for _, l := range rlLoad(t, store) { + assert.Empty(t, l.MatchID, "every match lineup went with the draw") + } + // What was written is what a fresh Store reads back. + reloaded, err := state.NewStore(dir) + require.NoError(t, err) + assert.Equal(t, tc.after(teams), rlLegacy(t, reloaded)) + assert.Equal(t, tc.wantMarked, rlMarker(t, reloaded)) + }) + } +} + +// A match lineup v2.0.0 saved under a team's NAME is keyed by the team's id first, +// and then counts: the team is legacy for it like one whose lineup was saved by id. +func TestRoundLineups_ANameKeyedMatchLineupMakesItsTeamLegacyOnceKeyed(t *testing.T) { + teams := newRLTeams() + pool := rlThreeMatches(teams) + dir := rlSeed(t, rlTeamComp(state.CompFormatLeague), teams, pool, nil, + rlLineup("Kuma", "Pool A-1", 0, "kuma-by-name")) // Kuma plays Pool A-1 and Pool A-3 + + store, err := state.NewStore(dir) + require.NoError(t, err) + lineups := rlLoad(t, store) + + rlHas(t, lineups, teams.kuma, "Pool A-1", 0, "kuma-by-name") + rlLacks(t, lineups, "Kuma", "Pool A-1", 0) + rlIsEmpty(t, lineups, teams.kuma, "Pool A-3") + assert.Equal(t, rlRecordedLegacy(teams.kuma), rlLegacy(t, store)) + assert.False(t, rlMarker(t, store)) +} + +// A team with a lineup for a round is legacy whether or not the record lists it +// (its id can arrive after the first settlement), so a discard keeps waiting for +// it: the record is rewritten to the teams that still hold a round lineup, and the +// marker is not set while one does. +func TestRoundLineups_ADiscardKeepsWaitingForATeamTheRecordDoesNotList(t *testing.T) { + teams := newRLTeams() + comp := rlTeamComp(state.CompFormatLeague) + comp.RoundLineupsLegacy = []string{teams.kuma} + dir := rlSeed(t, comp, teams, rlThreeMatches(teams), nil, + rlLineup(teams.kuma, "Pool A-1", 0, "kuma-entered-m1"), + rlLineup(teams.tora, "", 1, "tora-round1")) + store, err := state.NewStore(dir) + require.NoError(t, err) + require.Equal(t, []string{teams.kuma}, rlLegacy(t, store), "precondition: the record is read, not derived again") + rlHas(t, rlLoad(t, store), teams.tora, "Pool A-0", 0, "tora-round1") + + require.NoError(t, store.ClearDrawLineups(rlComp)) + + assert.Equal(t, rlRecordedLegacy(teams.tora), rlLegacy(t, store)) + assert.False(t, rlMarker(t, store)) + rlHas(t, rlLoad(t, store), teams.tora, "", 1, "tora-round1") +} + +// A lineup left for a team that is no longer on the roster (an orphan) is no team's +// to wait for: a discard that leaves no team with a round lineup marks the +// conversion done, and the orphan's id is not recorded as a legacy team. +func TestRoundLineups_ADiscardIgnoresALineupOfATeamNoLongerOnTheRoster(t *testing.T) { + teams := newRLTeams() + const gone = "0a1b2c3d-0000-4000-8000-00000000dead" // no longer on the roster + dir := rlSeed(t, rlTeamComp(state.CompFormatLeague), teams, rlThreeMatches(teams), nil, + rlLineup(teams.kuma, "Pool A-1", 0, "kuma-entered-m1"), + rlLineup(gone, "", 1, "gone-round1")) + store, err := state.NewStore(dir) + require.NoError(t, err) + require.Equal(t, rlRecordedLegacy(teams.kuma), rlLegacy(t, store), "precondition: only Kuma is legacy") + + require.NoError(t, store.ClearDrawLineups(rlComp)) + + assert.Empty(t, rlLegacy(t, store)) + assert.True(t, rlMarker(t, store)) +} + +// A discard never marks the conversion done while a lineup still waits for a +// player's id: it would stay keyed by name for good. The record is emptied all the +// same, and the settlement that follows the minting of the ids reads the competition +// as it did at its first pass. +func TestRoundLineups_ADiscardNeverMarksWhileALineupWaitsForAnId(t *testing.T) { + dir := t.TempDir() + seed, err := state.NewStore(dir) + require.NoError(t, err) + require.NoError(t, seed.SaveCompetition(&state.Competition{ + ID: rlComp, Name: "Round Lineups", Kind: "team", TeamSize: 3, Format: state.CompFormatKnockout, Status: state.CompStatusSetup, + // A record the id-less roster below cannot match: it is hand-written here, since + // a roster gets all its ids at once. + RoundLineupsLegacy: []string{"11111111-1111-4111-8111-111111111111"}, + })) + roster := "Tora, Tora Dojo, 1dan\nUsagi, Usagi Dojo, 1dan\n" + require.NoError(t, os.WriteFile(filepath.Join(dir, "competitions", rlComp, "participants.csv"), []byte(roster), 0o600)) + rlWriteLineups(t, dir, rlComp, rlLineup("Tora", "", 1, "tora-round1")) + store, err := state.NewStore(dir) + require.NoError(t, err) + require.False(t, rlMarker(t, store), "precondition: Tora's lineup waits for an id") + + require.NoError(t, store.ClearDrawLineups(rlComp)) + + assert.False(t, rlMarker(t, store), "a lineup still waits for Tora's id") + assert.Empty(t, rlLegacy(t, store), "the team the record listed has no lineup for a round: it is dropped") + rlHas(t, rlLoad(t, store), "Tora", "", 1, "tora-round1") +} diff --git a/internal/state/round_lineups_test.go b/internal/state/round_lineups_test.go new file mode 100644 index 000000000..872890e67 --- /dev/null +++ b/internal/state/round_lineups_test.go @@ -0,0 +1,630 @@ +package state + +import ( + "fmt" + "os" + "slices" + "strings" + "testing" + "time" + + "github.com/gitrgoliveira/bracket-creator/internal/domain" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// A competition with no lineups at all has nothing to move, whether the +// caller read an empty file or none: the settlement answers a map it can write +// into, changes nothing and leaves nothing waiting. +func TestSettleRoundLineups_NoLineups(t *testing.T) { + comp := &Competition{ID: "c", Kind: "team", TeamSize: 3, Format: CompFormatKnockout} + players := []domain.Player{{ID: "t", Name: "Tora"}} + + for name, lineups := range map[string]map[string]domain.TeamLineup{"nil": nil, "empty": {}} { + t.Run(name, func(t *testing.T) { + for _, onLoad := range []bool{true, false} { + res := settleRoundLineups(comp, players, lineups, nil, onLoad) + + assert.NotNil(t, res.lineups) + assert.Empty(t, res.lineups) + assert.False(t, res.changed) + assert.Zero(t, res.waiting) + assert.Empty(t, res.given) + assert.False(t, res.givenGrew) + assert.Empty(t, res.notes) + } + }) + } +} + +// settleRoundLineups reads only its arguments: the lineups it was given are +// never modified, the map it answers is its own. +func TestSettleRoundLineups_NeverModifiesWhatItWasGiven(t *testing.T) { + settledBefore := map[string][]string{"t": {"r0-m0"}} + comp := &Competition{ID: "c", Kind: "team", TeamSize: 3, Format: CompFormatKnockout, RoundLineupsGiven: cloneRoundLineupsGiven(settledBefore)} + players := []domain.Player{{ID: "t", Name: "Tora"}} + round1 := domain.TeamLineup{TeamID: "t", CompetitionID: "c", Round: 1, + Positions: map[domain.Position]string{"1": "Sato"}, MemberIDs: map[domain.Position]string{"1": "m"}} + given := map[string]domain.TeamLineup{teamLineupKey("t", 1): round1} + draw := []DrawMatch{{ID: "r1-m0", SideAID: "t", SideBID: "o", Knockout: true, Round: 1}} + + res := settleRoundLineups(comp, players, given, draw, true) + + assert.True(t, res.changed) + assert.Len(t, given, 1, "the map it was given still holds its lineup") + assert.Contains(t, given, teamLineupKey("t", 1)) + moved := res.lineups[teamLineupMatchKey("t", "r1-m0")] + moved.Positions["1"] = "changed" + assert.Equal(t, "Sato", round1.Positions["1"], "a lineup it made carries its own copy of the maps") + assert.Equal(t, "Sato", given[teamLineupKey("t", 1)].Positions["1"]) + assert.Equal(t, settledBefore, comp.RoundLineupsGiven, "the competition's record of the pairs settled is not modified") + assert.Equal(t, map[string][]string{"t": {"r0-m0", "r1-m0"}}, res.given, "the answer is the record plus the pair it settled") + res.given["t"][0] = "changed" + assert.Equal(t, settledBefore, comp.RoundLineupsGiven, "and shares no list with it") +} + +// settleRoundLineups gives a team that has lineups for later rounds the lineup +// v2.1.1 showed at each match it is seated in: the round lineup with the highest +// round at or below the match's, else the highest round there was. +func TestSettleRoundLineups_ReadsAsV211Did(t *testing.T) { + comp := &Competition{ID: "c", Kind: "team", TeamSize: 3, Format: CompFormatLeague} + players := []domain.Player{{ID: "t", Name: "Tora"}, {ID: "o", Name: "Other"}} + for _, tc := range []struct { + name string + rounds []int + // reads maps a match's round to the round whose lineup it shows. + reads map[int]int + }{ + {"one later round only: it is the highest there is, so earlier matches show it too", []int{1}, map[int]int{0: 1, 1: 1, 2: 1}}, + {"a starting lineup and a later round", []int{0, 2}, map[int]int{0: 0, 1: 0, 2: 2, 3: 2}}, + {"two later rounds and no starting lineup: an earlier match shows the highest", []int{1, 2}, map[int]int{0: 2, 1: 1, 2: 2, 3: 2}}, + {"two later rounds, the lower of them above round 1", []int{2, 3}, map[int]int{0: 3, 1: 3, 2: 2, 3: 3, 4: 3}}, + {"every round", []int{0, 1, 2}, map[int]int{0: 0, 1: 1, 2: 2, 3: 2}}, + } { + t.Run(tc.name, func(t *testing.T) { + lineups := map[string]domain.TeamLineup{} + kept := 0 + for _, r := range tc.rounds { + lineups[teamLineupKey("t", r)] = domain.TeamLineup{TeamID: "t", CompetitionID: "c", Round: r, + Positions: map[domain.Position]string{"1": fmt.Sprintf("round%d", r)}} + if r >= 1 { + kept++ + } + } + bouts := func(pool string) []DrawMatch { + var draw []DrawMatch + for round := range tc.reads { + draw = append(draw, DrawMatch{ID: fmt.Sprintf("Pool %s-%d", pool, round), SideAID: "t", SideBID: "o", PoolRound: round}) + } + return draw + } + + // The load repair settles the matches the team is seated in at load. + first := settleRoundLineups(comp, players, lineups, bouts("A"), true) + // A write settles again, after matches it is seated in later were added. + later := *comp + later.RoundLineupsGiven = first.given + second := settleRoundLineups(&later, players, first.lineups, append(bouts("A"), bouts("B")...), false) + + for round, want := range tc.reads { + for pool, res := range map[string]roundLineupSettlement{"A": first, "B": second} { + got := res.lineups[teamLineupMatchKey("t", fmt.Sprintf("Pool %s-%d", pool, round))] + assert.Equal(t, fmt.Sprintf("round%d", want), got.Positions["1"], "a match at round %d, given by pass %s", round, pool) + } + } + assert.Equal(t, kept, first.waiting, "the round lineups stay until the competition is completed") + assert.Equal(t, kept, second.waiting) + for _, r := range tc.rounds { + if r >= 1 { + assert.Contains(t, second.lineups, teamLineupKey("t", r)) + } + } + }) + } +} + +// A legacy team's round lineup is kept until the competition is completed, +// whatever the draw holds (a team can still be seated in a match by a correction +// until then), and removed when it is; the marker follows, which is what waiting +// counts. +func TestSettleRoundLineups_WaitsUntilTheCompetitionIsCompleted(t *testing.T) { + round1 := map[string]domain.TeamLineup{teamLineupKey("t", 1): {TeamID: "t", CompetitionID: "c", Round: 1, + Positions: map[domain.Position]string{"1": "Sato"}}} + players := []domain.Player{{ID: "t", Name: "Tora"}, {ID: "o", Name: "Other"}} + comp := func(format string, status CompetitionStatus) *Competition { + return &Competition{ID: "c", Kind: "team", TeamSize: 3, Format: format, Status: status} + } + decided := DrawMatch{ID: "Pool A-0", SideAID: "t", SideBID: "o"} + undecided := DrawMatch{ID: "r1-m0", SideAID: "t", Knockout: true, Round: 1} + for _, tc := range []struct { + name string + comp *Competition + draw []DrawMatch + kept bool + }{ + {"nothing is drawn", comp(CompFormatLeague, CompStatusSetup), nil, true}, + {"every match of a league has both sides, and the league is on", comp(CompFormatLeague, CompStatusPools), []DrawMatch{decided}, true}, + {"a knockout with a side to be decided", comp(CompFormatKnockout, CompStatusKnockout), []DrawMatch{undecided}, true}, + {"a Swiss competition between rounds", comp(CompFormatSwiss, CompStatusPools), []DrawMatch{{ID: "Swiss-R1-0", SideAID: "t", SideBID: "o"}}, true}, + {"a competition that is drawn and not started", comp(CompFormatMixed, CompStatusDrawReady), []DrawMatch{decided}, true}, + {"a completed league", comp(CompFormatLeague, CompStatusComplete), []DrawMatch{decided}, false}, + {"a completed knockout, whatever is undecided", comp(CompFormatKnockout, CompStatusComplete), []DrawMatch{undecided}, false}, + {"a completed competition with no draw", comp(CompFormatKnockout, CompStatusComplete), nil, false}, + } { + t.Run(tc.name, func(t *testing.T) { + res := settleRoundLineups(tc.comp, players, round1, tc.draw, false) + + if tc.kept { + assert.Equal(t, 1, res.waiting) + assert.Contains(t, res.lineups, teamLineupKey("t", 1)) + } else { + assert.Zero(t, res.waiting) + assert.NotContains(t, res.lineups, teamLineupKey("t", 1)) + } + }) + } +} + +// A lineup saved under a team's name is keyed by the team's id when exactly one +// team has that name, and waits while a team of that name has no id yet. +func TestSettleRoundLineups_ANameAwaitsAnIdUntilEveryTeamOfThatNameHasOne(t *testing.T) { + comp := &Competition{ID: "c", Kind: "team", TeamSize: 3, Format: CompFormatKnockout} + byName := func(name string) map[string]domain.TeamLineup { + return map[string]domain.TeamLineup{teamLineupKey(name, 0): {TeamID: name, CompetitionID: "c", Positions: map[domain.Position]string{"1": "Sato"}}} + } + for _, tc := range []struct { + name string + players []domain.Player + keyedBy string + waiting int + }{ + {"one team, with an id", []domain.Player{{ID: "t", Name: "Tora"}}, "t", 0}, + {"one team, with no id yet", []domain.Player{{Name: "Tora"}}, "Tora", 1}, + {"two teams of the name, one with no id yet", []domain.Player{{ID: "t", Name: "Tora"}, {Name: "Tora"}}, "Tora", 1}, + {"two teams of the name, both with ids", []domain.Player{{ID: "t", Name: "Tora"}, {ID: "u", Name: "Tora"}}, "Tora", 0}, + {"no team of the name", []domain.Player{{ID: "t", Name: "Usagi"}}, "Tora", 0}, + } { + t.Run(tc.name, func(t *testing.T) { + res := settleRoundLineups(comp, tc.players, byName("Tora"), nil, true) + + assert.Contains(t, res.lineups, teamLineupKey(tc.keyedBy, 0), "keyed by %q", tc.keyedBy) + assert.Equal(t, tc.waiting, res.waiting) + }) + } +} + +// Settling what it has already settled decides nothing. +func TestSettleRoundLineups_IsIdempotent(t *testing.T) { + comp := &Competition{ID: "c", Kind: "team", TeamSize: 3, Format: CompFormatMixed} + players := []domain.Player{{ID: "t", Name: "Tora"}, {ID: "o", Name: "Other"}} + lineups := map[string]domain.TeamLineup{ + teamLineupKey("t", 0): {TeamID: "t", CompetitionID: "c", Round: 0, Positions: map[domain.Position]string{"1": "Sato"}}, + teamLineupKey("t", 1): {TeamID: "t", CompetitionID: "c", Round: 1, Positions: map[domain.Position]string{"1": "Ito"}}, + } + draw := []DrawMatch{ + {ID: "Pool A-0", SideAID: "t", SideBID: "o", PoolRound: 1}, + {ID: "r0-m0", Knockout: true}, + } + + first := settleRoundLineups(comp, players, lineups, draw, true) + again := *comp + again.RoundLineupsGiven = first.given + second := settleRoundLineups(&again, players, first.lineups, draw, false) + + assert.True(t, first.changed) + assert.True(t, first.givenGrew) + assert.Equal(t, map[string][]string{"t": {"Pool A-0"}}, first.given) + assert.Equal(t, 1, first.waiting) + assert.False(t, second.changed) + assert.False(t, second.givenGrew) + assert.Equal(t, first.lineups, second.lineups) + assert.Equal(t, first.given, second.given) + assert.Equal(t, 1, second.waiting) + assert.Empty(t, second.notes) +} + +// settlementComp is a team competition of the format, in the status. +func settlementComp(format string, status CompetitionStatus) *Competition { + return &Competition{ID: "c", Kind: "team", TeamSize: 3, Format: format, Status: status} +} + +// round1For is the lineups of team "t" that a Lineups page saved a round 1 +// lineup in, as settleRoundLineups is given them. +func round1For() map[string]domain.TeamLineup { + return map[string]domain.TeamLineup{teamLineupKey("t", 1): {TeamID: "t", CompetitionID: "c", Round: 1, + Positions: map[domain.Position]string{"1": "Sato"}}} +} + +// A legacy team is given a lineup for the team matches it is seated in by id, and +// for no other: a structural bye (hidden) is a match nobody plays, a pool +// representative bout or tie-break is one individual bout, a league's +// bracket.json is vestigial, and a match the team is not in is not its own. +func TestSettleRoundLineups_GivesLineupsOnlyForTheMatchesATeamPlays(t *testing.T) { + players := []domain.Player{{ID: "t", Name: "Tora"}, {ID: "o", Name: "Other"}} + decided := DrawMatch{ID: "Pool A-0", SideAID: "t", SideBID: "o"} + for _, tc := range []struct { + name string + comp *Competition + draw []DrawMatch + // matches are the ids of the matches a lineup is given for. + matches []string + }{ + {"the matches of a knockout", settlementComp(CompFormatKnockout, CompStatusKnockout), + []DrawMatch{{ID: "r0-m0", SideAID: "t", SideBID: "o", Knockout: true}, {ID: "r1-m0", SideAID: "t", Knockout: true, Round: 1}}, []string{"r0-m0", "r1-m0"}}, + {"a pool match whose other side has no id", settlementComp(CompFormatLeague, CompStatusPools), + []DrawMatch{{ID: "Pool A-0", SideAID: "t"}}, []string{"Pool A-0"}}, + {"a hidden bye", settlementComp(CompFormatKnockout, CompStatusKnockout), + []DrawMatch{{ID: "r0-m0", SideAID: "t", Knockout: true, Hidden: true}, {ID: "r1-m0", SideAID: "t", SideBID: "o", Knockout: true, Round: 1}}, []string{"r1-m0"}}, + {"a pool representative bout and a tie-break", settlementComp(CompFormatLeague, CompStatusPools), + []DrawMatch{decided, {ID: "Pool A-DH-1", SideAID: "t", SideBID: "o"}, {ID: "Pool A-TB-1", SideAID: "t", SideBID: "o"}}, []string{"Pool A-0"}}, + {"a league's vestigial bracket", settlementComp(CompFormatLeague, CompStatusPools), + []DrawMatch{decided, {ID: "r0-m0", SideAID: "t", SideBID: "o", Knockout: true}}, []string{"Pool A-0"}}, + {"a match the team is not in", settlementComp(CompFormatLeague, CompStatusPools), + []DrawMatch{{ID: "Pool A-0", SideAID: "o", SideBID: "x"}}, nil}, + {"the 3rd-place match", settlementComp(CompFormatKnockout, CompStatusKnockout), + []DrawMatch{{ID: "r0-m0", SideAID: "t", SideBID: "o", Knockout: true}, {ID: BronzeMatchID, SideAID: "t", SideBID: "o", Knockout: true, Round: 1}}, []string{"r0-m0", BronzeMatchID}}, + } { + t.Run(tc.name, func(t *testing.T) { + res := settleRoundLineups(tc.comp, players, round1For(), tc.draw, false) + + var want map[string][]string + if len(tc.matches) > 0 { + want = map[string][]string{"t": slices.Sorted(slices.Values(tc.matches))} + } + assert.Equal(t, want, res.given) + for _, id := range tc.matches { + assert.Contains(t, res.lineups, teamLineupMatchKey("t", id)) + } + assert.Len(t, res.lineups, 1+len(tc.matches), "the round lineup and one lineup per match") + }) + } +} + +// A legacy team that has no starting lineup is given, on the load pass, the +// lineup v2.1.1 showed before any round it saved: the highest, which v2.1.1 +// fell back to. A match seated later and read at a round below the team's +// lowest then reads that lineup, as v2.1.1 did. +func TestSettleRoundLineups_SeedsTheStartingLineupWithWhatV211ShowedBeforeAnyRound(t *testing.T) { + comp := settlementComp(CompFormatMixed, CompStatusPools) + players := []domain.Player{{ID: "t", Name: "Tora"}} + for _, tc := range []struct { + name string + rounds []int + onLoad bool + // start is the lineup the team has as its starting lineup afterwards, "" for none. + start string + }{ + {"one later round", []int{1}, true, "round1"}, + {"two later rounds: v2.1.1 fell back to the highest", []int{1, 2}, true, "round2"}, + {"two later rounds, the lower above round 1", []int{2, 3}, true, "round3"}, + {"a starting lineup of its own is kept", []int{0, 2}, true, "round0"}, + {"a write seeds nothing", []int{1, 2}, false, ""}, + } { + t.Run(tc.name, func(t *testing.T) { + lineups := map[string]domain.TeamLineup{} + for _, r := range tc.rounds { + lineups[teamLineupKey("t", r)] = domain.TeamLineup{TeamID: "t", CompetitionID: "c", Round: r, + Positions: map[domain.Position]string{"1": fmt.Sprintf("round%d", r)}} + } + + res := settleRoundLineups(comp, players, lineups, nil, tc.onLoad) + + start, ok := res.lineups[teamLineupKey("t", 0)] + if tc.start == "" { + assert.False(t, ok) + return + } + assert.True(t, ok) + assert.Equal(t, tc.start, start.Positions["1"]) + if slices.Contains(tc.rounds, 0) { + return // the team's own lineup, not a copy + } + start.Positions["1"] = "changed" + for _, r := range tc.rounds { + assert.NotEqual(t, "changed", lineups[teamLineupKey("t", r)].Positions["1"], "a seeded starting lineup carries its own copy of the maps") + } + }) + } +} + +// A pair the competition's record lists as settled is left alone, whatever the +// lineups hold: a lineup the operator removed from a match stays removed. A +// seated pair the record does not list is given the lineup v2.1.1 showed there +// when the team has none, and listed either way. +func TestSettleRoundLineups_APairAlreadyGivenIsLeftAlone(t *testing.T) { + comp := settlementComp(CompFormatLeague, CompStatusPools) + comp.RoundLineupsGiven = map[string][]string{"t": {"Pool A-0"}} + players := []domain.Player{{ID: "t", Name: "Tora"}, {ID: "o", Name: "Other"}} + lineups := round1For() + own := domain.TeamLineup{TeamID: "t", CompetitionID: "c", MatchID: "Pool A-2", Positions: map[domain.Position]string{"1": "Ito"}} + lineups[teamLineupMatchKey("t", "Pool A-2")] = own + draw := []DrawMatch{ + {ID: "Pool A-0", SideAID: "t", SideBID: "o"}, + {ID: "Pool A-1", SideAID: "t", SideBID: "o"}, + {ID: "Pool A-2", SideAID: "t", SideBID: "o"}, + } + + res := settleRoundLineups(comp, players, lineups, draw, false) + + assert.NotContains(t, res.lineups, teamLineupMatchKey("t", "Pool A-0"), "listed, and without a lineup: the operator removed it, and that stands") + assert.Equal(t, "Sato", res.lineups[teamLineupMatchKey("t", "Pool A-1")].Positions["1"], "not listed: given what v2.1.1 showed") + assert.Equal(t, own, res.lineups[teamLineupMatchKey("t", "Pool A-2")], "not listed, with a lineup of its own: that is v2.1.1's reading, and is left as it is") + assert.Equal(t, map[string][]string{"t": {"Pool A-0", "Pool A-1", "Pool A-2"}}, res.given, "all three are settled now") + assert.True(t, res.givenGrew) +} + +// The record keeps each team's list sorted and without duplicates, whatever a +// hand-edited config.md held: a list with its ids out of order or repeated is +// read as the set it is, and a team with no ids is not kept. +func TestSettleRoundLineups_KeepsEachTeamsListSortedWithoutDuplicates(t *testing.T) { + comp := settlementComp(CompFormatLeague, CompStatusPools) + comp.RoundLineupsGiven = map[string][]string{"t": {"Pool A-1", "Pool A-0", "Pool A-1"}, "nobody": {}} + players := []domain.Player{{ID: "t", Name: "Tora"}, {ID: "o", Name: "Other"}} + draw := []DrawMatch{ + {ID: "Pool A-0", SideAID: "t", SideBID: "o"}, + {ID: "Pool A-1", SideAID: "t", SideBID: "o"}, + {ID: "Pool A-2", SideAID: "t", SideBID: "o"}, + } + + res := settleRoundLineups(comp, players, round1For(), draw, false) + + assert.Equal(t, map[string][]string{"t": {"Pool A-0", "Pool A-1", "Pool A-2"}}, res.given) + assert.NotContains(t, res.lineups, teamLineupMatchKey("t", "Pool A-0"), "a pair listed out of order is still listed") + assert.NotContains(t, res.lineups, teamLineupMatchKey("t", "Pool A-1")) + assert.Contains(t, res.lineups, teamLineupMatchKey("t", "Pool A-2")) +} + +// A copy of a competition shares no part of its record of the pairs settled: not +// the map, and not a team's list. +func TestCopyCompetition_DeepCopiesTheRoundLineupsGiven(t *testing.T) { + original := &Competition{ID: "c", RoundLineupsGiven: map[string][]string{"t": {"r0-m0", "r1-m0"}}} + + cp := (&Store{}).copyCompetition(original) + cp.RoundLineupsGiven["t"][0] = "changed" + cp.RoundLineupsGiven["u"] = []string{"r0-m1"} + + assert.Equal(t, map[string][]string{"t": {"r0-m0", "r1-m0"}}, original.RoundLineupsGiven) + assert.Nil(t, (&Store{}).copyCompetition(&Competition{ID: "c"}).RoundLineupsGiven, "no record stays none") +} + +// The list of legacy teams is shared with no copy either, and an empty record +// stays nil: a nil list is what tells the first settlement from a later one. +func TestCopyCompetition_DeepCopiesTheRoundLineupsLegacy(t *testing.T) { + original := &Competition{ID: "c", RoundLineupsLegacy: []string{"t", "u"}} + + cp := (&Store{}).copyCompetition(original) + cp.RoundLineupsLegacy[0] = "changed" + + assert.Equal(t, []string{"t", "u"}, original.RoundLineupsLegacy) + assert.Nil(t, (&Store{}).copyCompetition(&Competition{ID: "c"}).RoundLineupsLegacy, "no record stays none") +} + +// legacyReading answers what v2.1.1 showed from a team's round lineups, and says +// when there were none: v2.1.1 showed a team with no lineup for a round nothing at +// a match it had no lineup entered for, and an empty slice has no highest round to +// fall back to. +func TestLegacyReading_ATeamWithNoRoundLineupWasShownNothing(t *testing.T) { + for name, rounds := range map[string][]domain.TeamLineup{"nil": nil, "empty": {}} { + t.Run(name, func(t *testing.T) { + for _, round := range []int{0, 1, 5} { + got, shown := legacyReading(rounds, round) + + assert.False(t, shown) + assert.Empty(t, got.Positions) + } + }) + } + one := []domain.TeamLineup{{Round: 2, Positions: map[domain.Position]string{"1": "Sato"}}} + for _, round := range []int{0, 2, 7} { + got, shown := legacyReading(one, round) + assert.True(t, shown, "round %d", round) + assert.Equal(t, "Sato", got.Positions["1"]) + } +} + +// A match lineup makes a team legacy at the competition's FIRST settlement only +// (no list recorded yet), and only for a match of the draw that seats the team by +// id; from then on the recorded list and a lineup for a round decide. +func TestSettleRoundLineups_AMatchLineupMakesATeamLegacyOnlyAtTheFirstSettlement(t *testing.T) { + players := []domain.Player{{ID: "t", Name: "Tora"}, {ID: "o", Name: "Other"}, {ID: "x", Name: "Extra"}} + draw := []DrawMatch{ + {ID: "Pool A-0", SideAID: "t", SideBID: "o", PoolRound: 0}, + {ID: "Pool A-1", SideAID: "t", SideBID: "o", PoolRound: 1}, + } + entered := func(team, matchID string) map[string]domain.TeamLineup { + return map[string]domain.TeamLineup{teamLineupMatchKey(team, matchID): { + TeamID: team, CompetitionID: "c", MatchID: matchID, Positions: map[domain.Position]string{"1": "Sato"}, + }} + } + for _, tc := range []struct { + name string + recorded []string + lineups map[string]domain.TeamLineup + wantLegacy []string + wantFound bool + wantPinnedAt []string // the matches Tora is given an empty lineup for + wantWaiting int + }{ + {"first pass, a match it plays", nil, entered("t", "Pool A-0"), []string{"t"}, true, []string{"Pool A-1"}, 1}, + {"first pass, a match it does not play", nil, entered("x", "Pool A-0"), nil, false, nil, 0}, + {"first pass, a match the draw does not hold", nil, entered("t", "Pool Z-9"), nil, false, nil, 0}, + {"a later pass: the list is recorded, and this team is not on it", []string{"o"}, entered("t", "Pool A-0"), nil, false, nil, 1}, + {"a later pass: a team on the list is read from it", []string{"t"}, entered("o", "Pool A-0"), nil, false, []string{"Pool A-0", "Pool A-1"}, 1}, + } { + t.Run(tc.name, func(t *testing.T) { + comp := settlementComp(CompFormatLeague, CompStatusPools) + comp.RoundLineupsLegacy = tc.recorded + + res := settleRoundLineups(comp, players, tc.lineups, draw, false) + + assert.Equal(t, tc.wantLegacy, res.legacy) + assert.Equal(t, tc.wantFound, res.legacyFound) + var pinned []string + for _, m := range draw { + if l, ok := res.lineups[teamLineupMatchKey("t", m.ID)]; ok { + if _, own := tc.lineups[teamLineupMatchKey("t", m.ID)]; !own { + pinned = append(pinned, m.ID) + assert.NotNil(t, l.Positions, "an empty lineup is stored as an empty one, not a missing one") + assert.Empty(t, l.Positions) + } + } + } + assert.Equal(t, tc.wantPinnedAt, pinned) + assert.Equal(t, tc.wantWaiting, res.waiting, "a legacy team waits for the competition to be completed, so the marker stays unset") + }) + } +} + +// The lineups are saved before the competition's record. If saving the record +// failed, the next pass finds each lineup it gave and only lists the pairs. +func TestSettleRoundLineups_LineupsThatLandedBeforeTheRecordOnlyAddThePairs(t *testing.T) { + comp := settlementComp(CompFormatLeague, CompStatusPools) + players := []domain.Player{{ID: "t", Name: "Tora"}, {ID: "o", Name: "Other"}} + draw := []DrawMatch{ + {ID: "Pool A-0", SideAID: "t", SideBID: "o"}, + {ID: "Pool A-1", SideAID: "t", SideBID: "o"}, + } + landed := settleRoundLineups(comp, players, round1For(), draw, false) + require.True(t, landed.changed) + + next := settleRoundLineups(comp, players, landed.lineups, draw, false) // comp still lists nothing + + assert.False(t, next.changed, "nothing to give: the lineups are there") + assert.True(t, next.givenGrew) + assert.Equal(t, landed.given, next.given) + assert.Equal(t, landed.lineups, next.lineups) +} + +// markedTeamStore is a store holding a team competition with a pool draw whose +// round-lineup conversion is done: it carries the marker, as every competition +// this release creates does, so every write of its draw asks the settlement and +// finds nothing to do. +func markedTeamStore(t testing.TB) (*Store, string) { + t.Helper() + store, err := NewStore(t.TempDir()) + require.NoError(t, err) + const compID = "marked" + require.NoError(t, store.SaveCompetition(&Competition{ + ID: compID, Name: "Marked", Kind: "team", TeamSize: 3, Format: CompFormatLeague, Status: CompStatusPools, + Courts: []string{"A", "B"}, StartTime: "09:00", RoundLineupsConverted: true, + })) + players := make([]domain.Player, 8) + for i := range players { + players[i] = domain.Player{ID: fmt.Sprintf("00000000-0000-4000-8000-%012d", i), Name: fmt.Sprintf("Team%d", i), Dojo: fmt.Sprintf("Dojo%d", i)} + } + require.NoError(t, store.SaveParticipants(compID, players)) + var matches []MatchResult + for i := 0; i < len(players); i++ { + for j := i + 1; j < len(players); j++ { + matches = append(matches, MatchResult{ + ID: fmt.Sprintf("Pool A-%d", len(matches)), + SideA: players[i].Name, SideAID: players[i].ID, SideB: players[j].Name, SideBID: players[j].ID, + }) + } + } + require.NoError(t, store.SavePoolMatches(compID, matches)) + return store, compID +} + +// A write of the draw asks the settlement whether anything waits, and for a +// competition that is marked the answer comes from the competition record the +// store already holds: it neither reads config.md from disk nor parses it again. +func TestSettleRoundLineupsAfterWrite_AMarkedCompetitionCostsNothing(t *testing.T) { + store, compID := markedTeamStore(t) + mu := store.getCompLock(compID) + mu.Lock() + defer mu.Unlock() + store.settleRoundLineupsAfterWrite(compID) // warm the competition record + + allocs := testing.AllocsPerRun(50, func() { store.settleRoundLineupsAfterWrite(compID) }) + + t.Logf("allocations per settlement of a marked competition: %.0f", allocs) + assert.Less(t, allocs, 40.0, "a marked competition must not be read from disk and parsed on every write of its draw (that costs about 200)") +} + +// The record the settlement answers from is the store's cached one, which is +// checked against the file on every use: a config.md edited by hand since, to take +// the marker off, is read again and settled, not answered from the old record. +func TestSettleRoundLineupsAfterWrite_AHandEditedMarkerIsNotAnsweredFromTheCache(t *testing.T) { + store, compID := markedTeamStore(t) + require.NoError(t, store.SetTeamLineup(compID, domain.TeamLineup{ + TeamID: "00000000-0000-4000-8000-000000000000", CompetitionID: compID, Round: 1, + Positions: map[domain.Position]string{domain.PositionNumbered(1): "tora-round1"}, + }, 3)) + mu := store.getCompLock(compID) + mu.Lock() + defer mu.Unlock() + store.settleRoundLineupsAfterWrite(compID) // the record is cached, marked + path := store.compPath(compID, "config.md") + raw, err := os.ReadFile(path) // #nosec G304 -- a path under the test store + require.NoError(t, err) + edited := strings.ReplaceAll(string(raw), "round_lineups_converted: true\n", "") + require.NotEqual(t, string(raw), edited, "precondition: the marker was in config.md") + require.NoError(t, os.WriteFile(path, []byte(edited), 0o600)) + later := time.Now().Add(time.Hour) + require.NoError(t, os.Chtimes(path, later, later)) // a coarse clock cannot make the two edits look alike + + store.settleRoundLineupsAfterWrite(compID) + + lineups, err := store.loadTeamLineupsLocked(compID) + require.NoError(t, err) + assert.Contains(t, lineups, teamLineupMatchKey("00000000-0000-4000-8000-000000000000", "Pool A-0"), + "the unmarked competition was settled: its legacy team was given its lineup at a match") +} + +// BenchmarkSettleRoundLineupsAfterWrite_Marked is the cost, per write of the +// draw, of a competition that is marked. +func BenchmarkSettleRoundLineupsAfterWrite_Marked(b *testing.B) { + store, compID := markedTeamStore(b) + mu := store.getCompLock(compID) + mu.Lock() + defer mu.Unlock() + b.ReportAllocs() + b.ResetTimer() + for i := 0; i < b.N; i++ { + store.settleRoundLineupsAfterWrite(compID) + } +} + +// A lineup stored under the name of a player that has no id yet keeps the +// conversion from being marked done; one under an id, a name no player has, or the +// name of a player that has an id does not. +func TestLineupRoster_AnyAwaitsID(t *testing.T) { + roster := newLineupRoster([]domain.Player{{ID: "a", Name: "Alpha"}, {Name: "Bravo"}}) + under := func(team string) map[string]domain.TeamLineup { + return map[string]domain.TeamLineup{teamLineupKey(team, 0): {TeamID: team}} + } + for _, tc := range []struct { + name string + lineups map[string]domain.TeamLineup + want bool + }{ + {"none", nil, false}, + {"under a participant id", under("a"), false}, + {"under the name of a player with an id", under("Alpha"), false}, + {"under a name no player has", under("Nobody"), false}, + {"under the name of a player with no id", under("Bravo"), true}, + {"an unkeyed lineup", under(""), false}, + } { + t.Run(tc.name, func(t *testing.T) { + assert.Equal(t, tc.want, roster.anyAwaitsID(tc.lineups)) + }) + } +} + +// Only a team of the roster is waited for, each once, sorted, and a match lineup or +// the starting lineup makes none. +func TestTeamsWithRoundLineups(t *testing.T) { + roster := newLineupRoster([]domain.Player{{ID: "b", Name: "B"}, {ID: "a", Name: "A"}, {ID: "c", Name: "C"}}) + lineups := map[string]domain.TeamLineup{ + teamLineupKey("b", 1): {TeamID: "b", Round: 1}, + teamLineupKey("b", 2): {TeamID: "b", Round: 2}, + teamLineupKey("a", 1): {TeamID: "a", Round: 1}, + teamLineupKey("c", 0): {TeamID: "c", Round: 0}, + teamLineupMatchKey("c", "Pool A-0"): {TeamID: "c", MatchID: "Pool A-0"}, + teamLineupKey("gone", 3): {TeamID: "gone", Round: 3}, + } + + assert.Equal(t, []string{"a", "b"}, teamsWithRoundLineups(lineups, roster)) + assert.Nil(t, teamsWithRoundLineups(nil, roster)) +} diff --git a/internal/state/round_lineups_upgrade_test.go b/internal/state/round_lineups_upgrade_test.go new file mode 100644 index 000000000..26e4a35e8 --- /dev/null +++ b/internal/state/round_lineups_upgrade_test.go @@ -0,0 +1,2238 @@ +package state_test + +// round_lineups_upgrade_test.go pins the one-time conversion of round lineups +// into match lineups (operator decision 2026-10-06). Releases up to v2.1.1 let +// the Lineups page save a lineup for round r, and read a match's lineup as its +// own, else the round lineup with the highest round at or below the match's +// round, else the highest round there was; a match's own lineup was never +// carried to another match. A team now carries the lineup of its previous match +// instead, so a team with a lineup for a later round is given, for every match +// it is seated in, a lineup of its own for that match equal to what v2.1.1 +// showed there: on load, and then in the write that seats it in a match it was +// not in. The round lineups stay while any match may still seat a team, and go, +// with the marker set, once none can. +// +// The round a match had in v2.1.1 is what it read the lineup by: a knockout +// match's index in bracket.Rounds, the 3rd-place match's len(Rounds), a pool or +// league match's stored Round (the circle-method round; -1 for a pool drawn +// without rounds, read as 0), and 0 for every Swiss match, which never stored +// one. rlV211Reading restates the reading, so the code under test is not its own +// oracle. + +import ( + "errors" + "fmt" + "maps" + "os" + "path/filepath" + "slices" + "strings" + "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" + "gopkg.in/yaml.v3" +) + +const rlComp = "rl" + +// rlTeams are the participant ids of the teams every fixture seats. inu is the +// fifth, for a pool of five; a fixture that does not seat it leaves it unplayed. +type rlTeams struct{ tora, usagi, kuma, saru, inu string } + +func newRLTeams() rlTeams { + return rlTeams{tora: helper.NewUUID4(), usagi: helper.NewUUID4(), kuma: helper.NewUUID4(), saru: helper.NewUUID4(), inu: helper.NewUUID4()} +} + +func (r rlTeams) players() []domain.Player { + return []domain.Player{ + {ID: r.tora, Name: "Tora", Dojo: "Tora Dojo"}, + {ID: r.usagi, Name: "Usagi", Dojo: "Usagi Dojo"}, + {ID: r.kuma, Name: "Kuma", Dojo: "Kuma Dojo"}, + {ID: r.saru, Name: "Saru", Dojo: "Saru Dojo"}, + {ID: r.inu, Name: "Inu", Dojo: "Inu Dojo"}, + } +} + +// names maps each team's id to its name: what a match side calls it, and what a +// tag a failure can be read by is built from. +func (r rlTeams) names() map[string]string { + return map[string]string{r.tora: "Tora", r.usagi: "Usagi", r.kuma: "Kuma", r.saru: "Saru", r.inu: "Inu"} +} + +// rlLineup is a lineup of a team whose first position holds tag, so a test can +// tell which saved lineup it is looking at, and whose member id is derived from +// it, so a copy is told from a rebuild. +func rlLineup(teamID, matchID string, round int, tag string) domain.TeamLineup { + return domain.TeamLineup{ + TeamID: teamID, CompetitionID: rlComp, Round: round, MatchID: matchID, + Positions: map[domain.Position]string{domain.PositionNumbered(1): tag}, + MemberIDs: map[domain.Position]string{domain.PositionNumbered(1): "member-" + tag}, + } +} + +func rlTeamComp(format string) *state.Competition { + return &state.Competition{ID: rlComp, Name: "Round Lineups", Kind: "team", TeamSize: 3, Format: format, Status: state.CompStatusKnockout} +} + +// rlSeed saves a competition as v2.1.1 left it: the roster, the draw, and +// lineups.yaml written as that release's Lineups page wrote it, with no marker. +// It returns the data folder, which a fresh Store then loads. +func rlSeed(t *testing.T, comp *state.Competition, teams rlTeams, pool []state.MatchResult, bracket *state.Bracket, lineups ...domain.TeamLineup) string { + t.Helper() + dir := t.TempDir() + s, err := state.NewStore(dir) + require.NoError(t, err) + require.NoError(t, s.SaveCompetition(comp)) + require.NoError(t, s.SaveParticipantsRestored(comp.ID, teams.players())) + if pool != nil { + require.NoError(t, s.SavePoolMatches(comp.ID, pool)) + } + if bracket != nil { + require.NoError(t, s.SaveBracket(comp.ID, bracket)) + } + // Writing the draw may have recorded that nothing waits on a round; v2.1.1 + // recorded no such thing. + stored, err := s.LoadCompetition(comp.ID) + require.NoError(t, err) + stored.RoundLineupsConverted = false + require.NoError(t, s.SaveCompetition(stored)) + rlWriteLineups(t, dir, comp.ID, lineups...) + return dir +} + +// rlWriteLineups writes lineups.yaml in the shape v2.1.1's saveTeamLineupsLocked +// wrote: a `lineups:` list, in storage-key order. +func rlWriteLineups(t *testing.T, dir, compID string, lineups ...domain.TeamLineup) { + t.Helper() + data, err := yaml.Marshal(struct { + Lineups []domain.TeamLineup `yaml:"lineups"` + }{Lineups: lineups}) + require.NoError(t, err) + require.NoError(t, os.WriteFile(filepath.Join(dir, "competitions", compID, "lineups.yaml"), data, 0o600)) +} + +func rlRaw(t *testing.T, dir, file string) string { + t.Helper() + raw, err := os.ReadFile(filepath.Join(dir, "competitions", rlComp, file)) // #nosec G304 -- a path under t.TempDir + require.NoError(t, err) + return string(raw) +} + +// rlFind is the lineup stored for (teamID, matchID, round), found by fields: +// the map's own keys are the store's. +func rlFind(lineups map[string]domain.TeamLineup, teamID, matchID string, round int) (domain.TeamLineup, bool) { + for _, l := range lineups { + if l.TeamID == teamID && l.MatchID == matchID && l.Round == round { + return l, true + } + } + return domain.TeamLineup{}, false +} + +func rlTag(l domain.TeamLineup) string { return l.Positions[domain.PositionNumbered(1)] } + +// rlHas asserts a lineup is stored for the scope, carrying tag. +func rlHas(t *testing.T, lineups map[string]domain.TeamLineup, teamID, matchID string, round int, tag string) { + t.Helper() + l, ok := rlFind(lineups, teamID, matchID, round) + if assert.Truef(t, ok, "no lineup for team %s match %q round %d; have %v", teamID, matchID, round, lineups) { + assert.Equal(t, tag, rlTag(l)) + } +} + +func rlLacks(t *testing.T, lineups map[string]domain.TeamLineup, teamID, matchID string, round int) { + t.Helper() + _, ok := rlFind(lineups, teamID, matchID, round) + assert.Falsef(t, ok, "a lineup for team %s match %q round %d is stored; have %v", teamID, matchID, round, lineups) +} + +func rlLoad(t *testing.T, store *state.Store) map[string]domain.TeamLineup { + t.Helper() + lineups, err := store.LoadTeamLineups(rlComp) + require.NoError(t, err) + return lineups +} + +func rlMarker(t *testing.T, store *state.Store) bool { + t.Helper() + comp, err := store.LoadCompetition(rlComp) + require.NoError(t, err) + require.NotNil(t, comp) + return comp.RoundLineupsConverted +} + +// rlCompleted is comp in the completed status, the one a legacy team's round +// lineups are removed in. +func rlCompleted(comp *state.Competition) *state.Competition { + comp.Status = state.CompStatusComplete + return comp +} + +// rlComplete completes the competition in a config.md write the draw's write +// hooks do not see. +func rlComplete(t *testing.T, store *state.Store) { + t.Helper() + _, err := store.UpdateCompetitionChanged(rlComp, func(c *state.Competition) (*state.Competition, error) { + c.Status = state.CompStatusComplete + return c, nil + }) + require.NoError(t, err) +} + +// rlGiven is the pairs the competition's record lists as settled: each team's +// participant id, to the ids of the matches settled for it. +func rlGiven(t *testing.T, store *state.Store) map[string][]string { + t.Helper() + comp, err := store.LoadCompetition(rlComp) + require.NoError(t, err) + require.NotNil(t, comp) + return comp.RoundLineupsGiven +} + +// rlLegacy is the teams the competition's record lists as legacy (config.md's +// round_lineups_legacy): the teams whose lineups v2.1.1 read by round or by match, +// found by the first settlement. +func rlLegacy(t *testing.T, store *state.Store) []string { + t.Helper() + comp, err := store.LoadCompetition(rlComp) + require.NoError(t, err) + require.NotNil(t, comp) + return comp.RoundLineupsLegacy +} + +// rlTouchPool is a write of the draw that seats nobody new: a pool match +// started. +func rlTouchPool(t *testing.T, store *state.Store, matchID string) { + t.Helper() + found, err := store.UpdatePoolMatchByID(rlComp, matchID, func(m *state.MatchResult) error { + m.Status = state.MatchStatusRunning + return nil + }) + require.NoError(t, err) + require.True(t, found) +} + +// rlRounds is a lineup for each round from first to last of a team, tagged +// "-round" with r the stored round, so a failure names the round a +// lineup was saved for. +func rlRounds(teamID, name string, first, last int) []domain.TeamLineup { + var out []domain.TeamLineup + for r := first; r <= last; r++ { + out = append(out, rlLineup(teamID, "", r, fmt.Sprintf("%s-round%d", name, r))) + } + return out +} + +// rlMatchRound is the round v2.1.1 read a match's lineup by: a knockout match's +// bracket round index (the 3rd-place match's len(Rounds)), a pool or league +// match's stored round (-1, a pool drawn without rounds, read as 0), and 0 for a +// Swiss match. Written out here rather than asked of the code under test. +func rlMatchRound(m state.DrawMatch) int { + if m.Knockout { + return m.Round + } + return max(m.PoolRound, 0) +} + +// rlV211Reading is the lineup v2.1.1 showed team at a match read at round, +// from the lineups its Lineups page had saved: the team's own lineup for the +// match; else the round lineup with the highest round at or below the match's; +// else the highest round there was. A lineup entered for another match was never +// carried, so it counts for nothing here. This is state.FindBestLineup of v2.1.1, +// restated so the code under test is not its own oracle. +func rlV211Reading(legacy []domain.TeamLineup, team, matchID string, round int) string { + for _, l := range legacy { + if l.TeamID == team && l.MatchID == matchID { + return rlTag(l) + } + } + bestRound, highestRound := -1, -1 + var best, highest string + for _, l := range legacy { + if l.TeamID != team || l.MatchID != "" { + continue + } + if l.Round <= round && l.Round > bestRound { + bestRound, best = l.Round, rlTag(l) + } + if l.Round > highestRound { + highestRound, highest = l.Round, rlTag(l) + } + } + if bestRound >= 0 { + return best + } + return highest +} + +// rlAssertShowsWhatV211Showed asserts that every match of draw that seats one of +// teamIDs by id (a hidden bye aside) holds a lineup of its own for that team, +// and that it is the lineup v2.1.1 showed there. A match the team is not seated +// in is not asked about: it holds no lineup, and gets its own in the write that +// seats the team. +func rlAssertShowsWhatV211Showed(t *testing.T, lineups map[string]domain.TeamLineup, legacy []domain.TeamLineup, draw []state.DrawMatch, teamIDs ...string) { + t.Helper() + for _, team := range teamIDs { + for _, m := range draw { + if m.Hidden || !m.Seats(team) { + continue + } + want := rlV211Reading(legacy, team, m.ID, rlMatchRound(m)) + got, ok := rlFind(lineups, team, m.ID, 0) + if !assert.Truef(t, ok, "team %s has no lineup for match %s (round %d); v2.1.1 showed %q", team, m.ID, rlMatchRound(m), want) { + continue + } + assert.Equalf(t, want, rlTag(got), "team %s at match %s (round %d)", team, m.ID, rlMatchRound(m)) + } + } +} + +// rlKnockout is a four-team knockout part way through: Tora won round 0 and is +// seated in the final, where Usagi is not yet, since r0-m1 is still to play. +func rlKnockout(teams rlTeams) *state.Bracket { + return &state.Bracket{Rounds: [][]state.BracketMatch{ + { + {ID: "r0-m0", SideA: "Tora", SideAID: teams.tora, SideB: "Kuma", SideBID: teams.kuma, + Status: state.MatchStatusCompleted, Winner: "Tora", WinnerID: teams.tora}, + {ID: "r0-m1", SideA: "Usagi", SideAID: teams.usagi, SideB: "Saru", SideBID: teams.saru}, + }, + {{ID: "r1-m0", SideA: "Tora", SideAID: teams.tora, SideB: "Winner of r0-m1"}}, + }} +} + +// rlSeatUsagi is the write that decides r0-m1 and seats Usagi in the final. +func rlSeatUsagi(teams rlTeams) func(*state.Bracket) error { + return func(b *state.Bracket) error { + b.Rounds[0][1].Status = state.MatchStatusCompleted + b.Rounds[0][1].Winner, b.Rounds[0][1].WinnerID = "Usagi", teams.usagi + b.Rounds[1][0].SideB, b.Rounds[1][0].SideBID = "Usagi", teams.usagi + return nil + } +} + +// rlLegacyKnockoutLineups is what v2.1.1's Lineups page left for rlKnockout: +// round lineups by id for Tora and Usagi, one saved under a team's NAME (v2.0.0 +// addressed an id-less team that way), and one lineup entered for a match. +func rlLegacyKnockoutLineups(teams rlTeams) []domain.TeamLineup { + return []domain.TeamLineup{ + rlLineup(teams.tora, "", 1, "tora-round1"), + rlLineup(teams.usagi, "", 1, "usagi-round1"), + rlLineup("Saru", "", 0, "saru-by-name"), + rlLineup(teams.kuma, "r0-m0", 0, "kuma-match"), + } +} + +// TestRoundLineups_LoadGivesEachSeatedMatchItsLineupAndKeepsTheRound: on load a +// team with a lineup for a later round has, for each match it is seated in, the +// lineup v2.1.1 showed there; a lineup saved under a team's name is keyed by the +// team's id; a team with no starting lineup gets what v2.1.1 showed before any +// round as one; the pairs settled are recorded; and the round lineups stay, with +// the marker unset, until the competition is completed. +func TestRoundLineups_LoadGivesEachSeatedMatchItsLineupAndKeepsTheRound(t *testing.T) { + teams := newRLTeams() + dir := rlSeed(t, rlTeamComp(state.CompFormatKnockout), teams, nil, rlKnockout(teams), rlLegacyKnockoutLineups(teams)...) + + store, err := state.NewStore(dir) + require.NoError(t, err) + lineups := rlLoad(t, store) + + t.Run("a seated team has, for each match it is seated in, the lineup v2.1.1 showed there", func(t *testing.T) { + // Tora has a lineup for round 2 (stored 1) and none for round 1, so v2.1.1 + // showed it at the first match as well: it fell back to the highest round. + rlHas(t, lineups, teams.tora, "r0-m0", 0, "tora-round1") + rlHas(t, lineups, teams.tora, "r1-m0", 0, "tora-round1") + moved, _ := rlFind(lineups, teams.tora, "r1-m0", 0) + assert.Equal(t, "member-tora-round1", moved.MemberIDs[domain.PositionNumbered(1)], "the member ids move with it") + assert.Equal(t, rlComp, moved.CompetitionID) + rlHas(t, lineups, teams.usagi, "r0-m1", 0, "usagi-round1") + rlLacks(t, lineups, teams.usagi, "r1-m0", 0) + }) + + t.Run("a team with only a later round gets, as its starting lineup, what v2.1.1 showed before any round", func(t *testing.T) { + rlHas(t, lineups, teams.tora, "", 0, "tora-round1") + rlHas(t, lineups, teams.usagi, "", 0, "usagi-round1") + start, _ := rlFind(lineups, teams.tora, "", 0) + assert.Equal(t, "member-tora-round1", start.MemberIDs[domain.PositionNumbered(1)]) + }) + + t.Run("the round lineups stay until the competition is completed", func(t *testing.T) { + rlHas(t, lineups, teams.tora, "", 1, "tora-round1") + rlHas(t, lineups, teams.usagi, "", 1, "usagi-round1") + }) + + t.Run("a lineup saved under a team's name is keyed by its id", func(t *testing.T) { + rlHas(t, lineups, teams.saru, "", 0, "saru-by-name") + rlLacks(t, lineups, "Saru", "", 0) + rlLacks(t, lineups, teams.saru, "r0-m1", 0) + }) + + t.Run("a lineup entered for a match is left as it is", func(t *testing.T) { + rlHas(t, lineups, teams.kuma, "r0-m0", 0, "kuma-match") + }) + + t.Run("the pairs it settled are recorded in config.md, and the marker stays unset", func(t *testing.T) { + // Kuma has a lineup of its own for r0-m0 and none for a round: v2.1.1's + // reading of the one match it is seated in, which its pair records. + assert.Equal(t, map[string][]string{ + teams.tora: {"r0-m0", "r1-m0"}, + teams.usagi: {"r0-m1"}, + teams.kuma: {"r0-m0"}, + }, rlGiven(t, store)) + assert.Equal(t, slices.Sorted(slices.Values([]string{teams.tora, teams.usagi, teams.kuma})), rlLegacy(t, store), + "the teams that had a lineup for a round, or one entered for a match, are the legacy teams") + assert.Contains(t, rlRaw(t, dir, "config.md"), "round_lineups_given") + assert.False(t, rlMarker(t, store)) + assert.NotContains(t, rlRaw(t, dir, "config.md"), "round_lineups_converted") + }) +} + +// TestRoundLineups_SeatingATeamGivesItItsLineupInThatWrite: the write that seats +// a team in a match gives it its lineup for that match, in the same write, and +// records the pair. The round lineups stay: the competition is not completed, so +// a correction can still seat another team. Through the store's own door and +// through a transaction alike. +func TestRoundLineups_SeatingATeamGivesItItsLineupInThatWrite(t *testing.T) { + loaded := func(t *testing.T) (*state.Store, rlTeams, string) { + teams := newRLTeams() + dir := rlSeed(t, rlTeamComp(state.CompFormatKnockout), teams, nil, rlKnockout(teams), rlLegacyKnockoutLineups(teams)...) + store, err := state.NewStore(dir) + require.NoError(t, err) + require.False(t, rlMarker(t, store), "precondition: the round lineups wait for the competition to be completed") + rlLacks(t, rlLoad(t, store), teams.usagi, "r1-m0", 0) + return store, teams, dir + } + given := func(t *testing.T, store *state.Store, teams rlTeams, dir string) { + t.Helper() + lineups := rlLoad(t, store) + rlHas(t, lineups, teams.usagi, "r1-m0", 0, "usagi-round1") + rlHas(t, lineups, teams.usagi, "", 1, "usagi-round1") + assert.Contains(t, rlGiven(t, store)[teams.usagi], "r1-m0", "the pair is recorded as settled") + assert.Contains(t, rlRaw(t, dir, "lineups.yaml"), "matchId: r1-m0") + assert.False(t, rlMarker(t, store), "every side is decided, but the competition is not over: a correction can still seat another team") + } + + t.Run("a direct write", func(t *testing.T) { + store, teams, dir := loaded(t) + + require.NoError(t, store.UpdateBracket(rlComp, rlSeatUsagi(teams))) + + given(t, store, teams, dir) + }) + + t.Run("a transaction", func(t *testing.T) { + store, teams, dir := loaded(t) + + require.NoError(t, store.WithTransaction(rlComp, func(tx state.StoreTx) error { + return tx.UpdateBracket(rlComp, rlSeatUsagi(teams)) + })) + + given(t, store, teams, dir) + }) + + t.Run("a write that seats nobody new gives nothing and records nothing", func(t *testing.T) { + store, teams, dir := loaded(t) + lineupsBefore, configBefore := rlRaw(t, dir, "lineups.yaml"), rlRaw(t, dir, "config.md") + + require.NoError(t, store.UpdateBracket(rlComp, func(b *state.Bracket) error { + b.Rounds[0][1].Status = state.MatchStatusRunning + return nil + })) + + assert.Equal(t, lineupsBefore, rlRaw(t, dir, "lineups.yaml")) + assert.Equal(t, configBefore, rlRaw(t, dir, "config.md")) + assert.False(t, rlMarker(t, store)) + rlHas(t, rlLoad(t, store), teams.usagi, "", 1, "usagi-round1") + }) +} + +// TestRoundLineups_ATransactionSettlesWhatItSees: the lineups and the +// competition a transaction has staged are the ones the settlement is made +// against, so a lineup staged earlier in the transaction and a status staged +// beside it both land with the move. +func TestRoundLineups_ATransactionSettlesWhatItSees(t *testing.T) { + teams := newRLTeams() + dir := rlSeed(t, rlTeamComp(state.CompFormatKnockout), teams, nil, rlKnockout(teams), rlLegacyKnockoutLineups(teams)...) + store, err := state.NewStore(dir) + require.NoError(t, err) + + require.NoError(t, store.WithTransaction(rlComp, func(tx state.StoreTx) error { + require.NoError(t, tx.SetTeamLineup(rlComp, rlLineup(teams.saru, "", 0, "saru-edited"), 3)) + comp, err := tx.LoadCompetition(rlComp) + require.NoError(t, err) + comp.Status = state.CompStatusComplete + require.NoError(t, tx.SaveCompetition(comp)) + return tx.UpdateBracket(rlComp, rlSeatUsagi(teams)) + })) + + lineups := rlLoad(t, store) + rlHas(t, lineups, teams.usagi, "r1-m0", 0, "usagi-round1") + rlHas(t, lineups, teams.saru, "", 0, "saru-edited") + rlLacks(t, lineups, teams.usagi, "", 1) + rlLacks(t, lineups, teams.tora, "", 1) + comp, err := store.LoadCompetition(rlComp) + require.NoError(t, err) + assert.Equal(t, state.CompStatusComplete, comp.Status, "the staged status is not written over by the marker") + assert.True(t, comp.RoundLineupsConverted, "the transaction completed the competition, so its round lineups went with it") + assert.Empty(t, comp.RoundLineupsGiven, "and the pairs settled are not kept once the marker is set") +} + +// TestRoundLineups_AnAbortedTransactionLeavesLineupsAlone: the move is staged +// with the write that seats the team, so a transaction that does not commit +// moves nothing and records nothing; the next one that does commits both. +func TestRoundLineups_AnAbortedTransactionLeavesLineupsAlone(t *testing.T) { + teams := newRLTeams() + dir := rlSeed(t, rlTeamComp(state.CompFormatKnockout), teams, nil, rlKnockout(teams), rlLegacyKnockoutLineups(teams)...) + store, err := state.NewStore(dir) + require.NoError(t, err) + lineupsBefore := rlRaw(t, dir, "lineups.yaml") + configBefore := rlRaw(t, dir, "config.md") + givenBefore := rlGiven(t, store) + + err = store.WithTransaction(rlComp, func(tx state.StoreTx) error { + require.NoError(t, tx.UpdateBracket(rlComp, rlSeatUsagi(teams))) + return errors.New("the transaction does not commit") + }) + require.Error(t, err) + + assert.Equal(t, lineupsBefore, rlRaw(t, dir, "lineups.yaml")) + assert.Equal(t, configBefore, rlRaw(t, dir, "config.md"), "the pair the transaction would have settled is not recorded either") + assert.Equal(t, givenBefore, rlGiven(t, store)) + rlHas(t, rlLoad(t, store), teams.usagi, "", 1, "usagi-round1") + rlLacks(t, rlLoad(t, store), teams.usagi, "r1-m0", 0) + assert.False(t, rlMarker(t, store)) + + require.NoError(t, store.WithTransaction(rlComp, func(tx state.StoreTx) error { + return tx.UpdateBracket(rlComp, rlSeatUsagi(teams)) + })) + rlHas(t, rlLoad(t, store), teams.usagi, "r1-m0", 0, "usagi-round1") + rlHas(t, rlLoad(t, store), teams.usagi, "", 1, "usagi-round1") + assert.Contains(t, rlGiven(t, store)[teams.usagi], "r1-m0", "the one that commits records its pair with its lineup") + assert.False(t, rlMarker(t, store)) +} + +// TestRoundLineups_AMatchesOwnLineupShadowsTheRoundOne: v2.1.1 read a match's +// own lineup before any round's, so a match the team already has a lineup for +// keeps it, and the round lineup is read for every other match the team is +// seated in; it goes only when no match can seat a team any more. +func TestRoundLineups_AMatchesOwnLineupShadowsTheRoundOne(t *testing.T) { + teams := newRLTeams() + legacy := []domain.TeamLineup{ + rlLineup(teams.tora, "", 1, "tora-round1"), + rlLineup(teams.tora, "r1-m0", 0, "tora-own"), + } + dir := rlSeed(t, rlTeamComp(state.CompFormatKnockout), teams, nil, rlKnockout(teams), legacy...) + + store, err := state.NewStore(dir) + require.NoError(t, err) + lineups := rlLoad(t, store) + + rlHas(t, lineups, teams.tora, "r1-m0", 0, "tora-own") + rlHas(t, lineups, teams.tora, "r0-m0", 0, "tora-round1") + rlHas(t, lineups, teams.tora, "", 0, "tora-round1") + rlHas(t, lineups, teams.tora, "", 1, "tora-round1") + assert.False(t, rlMarker(t, store), "the round lineup is kept until the competition is completed, and the final's own lineup is not touched") + assert.Contains(t, rlGiven(t, store)[teams.tora], "r1-m0", "a pair that already had its own lineup is settled too") + + require.NoError(t, store.UpdateBracket(rlComp, rlSeatUsagi(teams))) + + lineups = rlLoad(t, store) + rlHas(t, lineups, teams.tora, "r1-m0", 0, "tora-own") + rlHas(t, lineups, teams.tora, "", 1, "tora-round1") + assert.False(t, rlMarker(t, store)) +} + +// TestRoundLineups_TwoRoundLineupsAreReadByTheMatchesRound: v2.1.1 read the +// round lineup with the highest round at or below a match's, else the highest +// round there was, so two round lineups of a team are each the lineup of the +// matches it was read at; the starting lineup is the highest of them, which +// v2.1.1 showed before any round. +func TestRoundLineups_TwoRoundLineupsAreReadByTheMatchesRound(t *testing.T) { + teams := newRLTeams() + pool := []state.MatchResult{ + rlBout(teams, "Pool A-0", 0, teams.tora, teams.usagi), + rlBout(teams, "Pool A-1", 3, teams.tora, teams.kuma), + rlBout(teams, "Pool A-2", 1, teams.tora, teams.saru), + } + legacy := []domain.TeamLineup{ + rlLineup(teams.tora, "", 1, "tora-low"), + rlLineup(teams.tora, "", 2, "tora-high"), + } + dir := rlSeed(t, rlTeamComp(state.CompFormatLeague), teams, pool, nil, legacy...) + + store, err := state.NewStore(dir) + require.NoError(t, err) + lineups := rlLoad(t, store) + + // v2.1.1 had nothing at or below round 0 to show, so it fell back to the highest. + rlHas(t, lineups, teams.tora, "Pool A-0", 0, "tora-high") + rlHas(t, lineups, teams.tora, "Pool A-1", 0, "tora-high") + rlHas(t, lineups, teams.tora, "Pool A-2", 0, "tora-low") + rlAssertShowsWhatV211Showed(t, lineups, legacy, state.DrawMatchesFrom(pool, nil), teams.tora) + rlHas(t, lineups, teams.tora, "", 0, "tora-high") // the starting lineup is what v2.1.1 showed before any round + rlHas(t, lineups, teams.tora, "", 1, "tora-low") + rlHas(t, lineups, teams.tora, "", 2, "tora-high") + assert.False(t, rlMarker(t, store), "the league is not completed") +} + +// TestRoundLineups_LoadSettlesACompletedCompetitionInOnePass: a completed +// competition loaded for the first time gives each team the lineup v2.1.1 showed +// at every match it is seated in, removes the round lineups and sets the marker, +// whatever the draw holds. Whatever a team's round, a lineup it never gets +// seated at survives only as one of those. +func TestRoundLineups_LoadSettlesACompletedCompetitionInOnePass(t *testing.T) { + t.Run("a knockout, whose final's other side was never decided", func(t *testing.T) { + teams := newRLTeams() + bracket := rlKnockout(teams) // r0-m1 and the final's second side are still undecided + dir := rlSeed(t, rlCompleted(rlTeamComp(state.CompFormatKnockout)), teams, nil, bracket, + rlLineup(teams.tora, "", 1, "tora-round1"), + rlLineup(teams.usagi, "", 1, "usagi-round1"), + ) + + store, err := state.NewStore(dir) + require.NoError(t, err) + lineups := rlLoad(t, store) + + rlHas(t, lineups, teams.tora, "r0-m0", 0, "tora-round1") // v2.1.1's fallback to the highest round, at the match Tora did play + rlHas(t, lineups, teams.tora, "r1-m0", 0, "tora-round1") + rlHas(t, lineups, teams.usagi, "r0-m1", 0, "usagi-round1") + rlHas(t, lineups, teams.tora, "", 0, "tora-round1") + rlLacks(t, lineups, teams.tora, "", 1) + rlLacks(t, lineups, teams.usagi, "", 1) + assert.True(t, rlMarker(t, store), "given, retired and marked in the one pass") + assert.Empty(t, rlGiven(t, store), "and the pairs settled are not kept") + assert.NotContains(t, rlRaw(t, dir, "config.md"), "round_lineups_given") + }) + + t.Run("a Swiss competition, whose matches all had round 0", func(t *testing.T) { + teams := newRLTeams() + pool := []state.MatchResult{ + {ID: "Swiss-R1-0", SideA: "Tora", SideAID: teams.tora, SideB: "Usagi", SideBID: teams.usagi}, + {ID: "Swiss-R1-1", SideA: "Kuma", SideAID: teams.kuma, SideB: "Saru", SideBID: teams.saru}, + {ID: "Swiss-R2-0", SideA: "Tora", SideAID: teams.tora, SideB: "Kuma", SideBID: teams.kuma}, + } + dir := rlSeed(t, rlCompleted(rlTeamComp(state.CompFormatSwiss)), teams, pool, nil, rlLineup(teams.tora, "", 1, "tora-round1")) + + store, err := state.NewStore(dir) + require.NoError(t, err) + lineups := rlLoad(t, store) + + // No Swiss match was ever read at round 1, so v2.1.1 showed the round 1 + // lineup only as the highest round there was: at every match of the team. + rlLacks(t, lineups, teams.tora, "", 1) + rlHas(t, lineups, teams.tora, "", 0, "tora-round1") + rlHas(t, lineups, teams.tora, "Swiss-R1-0", 0, "tora-round1") + rlHas(t, lineups, teams.tora, "Swiss-R2-0", 0, "tora-round1") + assert.Len(t, lineups, 3) + assert.True(t, rlMarker(t, store)) + }) + + t.Run("a league, whose matches had their round-robin round", func(t *testing.T) { + teams := newRLTeams() + pool := []state.MatchResult{ + {ID: "Pool A-0", SideA: "Tora", SideAID: teams.tora, SideB: "Usagi", SideBID: teams.usagi, Round: 0}, + {ID: "Pool A-1", SideA: "Tora", SideAID: teams.tora, SideB: "Kuma", SideBID: teams.kuma, Round: 1}, + {ID: "Pool A-2", SideA: "Usagi", SideAID: teams.usagi, SideB: "Kuma", SideBID: teams.kuma, Round: 2}, + } + legacy := []domain.TeamLineup{ + rlLineup(teams.tora, "", 1, "tora-round1"), + rlLineup(teams.tora, "", 2, "tora-round2"), + rlLineup(teams.usagi, "", 2, "usagi-round2"), + } + dir := rlSeed(t, rlCompleted(rlTeamComp(state.CompFormatLeague)), teams, pool, nil, legacy...) + + store, err := state.NewStore(dir) + require.NoError(t, err) + lineups := rlLoad(t, store) + + rlHas(t, lineups, teams.tora, "Pool A-1", 0, "tora-round1") + rlHas(t, lineups, teams.tora, "Pool A-0", 0, "tora-round2") // round 0: nothing at or below it, so the highest + rlLacks(t, lineups, teams.tora, "Pool A-2", 0) + rlLacks(t, lineups, teams.tora, "", 2) + rlHas(t, lineups, teams.usagi, "Pool A-2", 0, "usagi-round2") + rlAssertShowsWhatV211Showed(t, lineups, legacy, state.DrawMatchesFrom(pool, nil), teams.tora, teams.usagi) + assert.True(t, rlMarker(t, store)) + }) +} + +// TestRoundLineups_APoolDrawnWithoutRoundsIsRoundZero: v2.1.1 stored -1 for the +// round of a pool match drawn without rounds and read it as 0, so it showed the +// starting lineup there and the round 1 lineup only at the knockout match it +// feeds, once seated. +func TestRoundLineups_APoolDrawnWithoutRoundsIsRoundZero(t *testing.T) { + teams := newRLTeams() + pool := []state.MatchResult{ + {ID: "Pool A-0", SideA: "Tora", SideAID: teams.tora, SideB: "Usagi", SideBID: teams.usagi, Round: -1}, + {ID: "Pool B-0", SideA: "Kuma", SideAID: teams.kuma, SideB: "Saru", SideBID: teams.saru, Round: -1}, + } + bracket := &state.Bracket{Rounds: [][]state.BracketMatch{ + {{ID: "r0-m0", SideA: "Pool A-1st", SideB: "Pool B-1st"}}, + {{ID: "r1-m0", SideA: "Winner of r0-m0", SideB: "Pool C-1st"}}, + }} + dir := rlSeed(t, rlTeamComp(state.CompFormatMixed), teams, pool, bracket, + rlLineup(teams.tora, "", 0, "tora-start"), + rlLineup(teams.tora, "", 1, "tora-round1"), + ) + + store, err := state.NewStore(dir) + require.NoError(t, err) + lineups := rlLoad(t, store) + + rlHas(t, lineups, teams.tora, "Pool A-0", 0, "tora-start") // read at round 0, not at -1, where nothing is at or below it + rlHas(t, lineups, teams.tora, "", 1, "tora-round1") + assert.False(t, rlMarker(t, store), "the knockout's slots are not filled yet: Tora may still be seated there") + + require.NoError(t, store.UpdateBracket(rlComp, func(b *state.Bracket) error { + b.Rounds[0][0].SideA, b.Rounds[0][0].SideAID = "Tora", teams.tora + b.Rounds[0][0].SideB, b.Rounds[0][0].SideBID = "Kuma", teams.kuma + b.Rounds[1][0].SideA, b.Rounds[1][0].SideAID = "Tora", teams.tora + b.Rounds[1][0].SideB, b.Rounds[1][0].SideBID = "Usagi", teams.usagi + return nil + })) + lineups = rlLoad(t, store) + rlHas(t, lineups, teams.tora, "r0-m0", 0, "tora-start") + rlHas(t, lineups, teams.tora, "r1-m0", 0, "tora-round1") + rlHas(t, lineups, teams.tora, "", 1, "tora-round1") + assert.False(t, rlMarker(t, store), "every slot is filled, but the competition is not completed") +} + +// TestRoundLineups_TheThirdPlaceMatchIsAfterTheLastRound: v2.1.1 read the +// 3rd-place match at len(Rounds), so a lineup for the round after the final was +// shown there, and the two semifinal losers, who play only it, are the only +// teams that match can show it for. +func TestRoundLineups_TheThirdPlaceMatchIsAfterTheLastRound(t *testing.T) { + teams := newRLTeams() + bracket := &state.Bracket{ + Rounds: [][]state.BracketMatch{ + { + {ID: "r0-m0", SideA: "Tora", SideAID: teams.tora, SideB: "Kuma", SideBID: teams.kuma, + Status: state.MatchStatusCompleted, Winner: "Tora", WinnerID: teams.tora}, + {ID: "r0-m1", SideA: "Usagi", SideAID: teams.usagi, SideB: "Saru", SideBID: teams.saru, + Status: state.MatchStatusCompleted, Winner: "Usagi", WinnerID: teams.usagi}, + }, + {{ID: "r1-m0", SideA: "Tora", SideAID: teams.tora, SideB: "Usagi", SideBID: teams.usagi}}, + }, + ThirdPlaceMatch: &state.BracketMatch{ID: state.BronzeMatchID, SideA: "Kuma", SideAID: teams.kuma, SideB: "Saru", SideBID: teams.saru}, + } + legacy := []domain.TeamLineup{ + rlLineup(teams.kuma, "", 2, "kuma-bronze"), + rlLineup(teams.kuma, "", 1, "kuma-final"), + rlLineup(teams.tora, "", 2, "tora-round2"), + rlLineup(teams.tora, "", 1, "tora-round1"), + } + dir := rlSeed(t, rlCompleted(rlTeamComp(state.CompFormatKnockout)), teams, nil, bracket, legacy...) + + store, err := state.NewStore(dir) + require.NoError(t, err) + lineups := rlLoad(t, store) + + t.Run("a lineup for the round after the final is read at the 3rd-place match", func(t *testing.T) { + rlHas(t, lineups, teams.kuma, state.BronzeMatchID, 0, "kuma-bronze") + rlLacks(t, lineups, teams.kuma, "", 2) + }) + t.Run("the lower round's lineup is read at no match its team is seated in", func(t *testing.T) { + rlLacks(t, lineups, teams.kuma, "", 1) + rlHas(t, lineups, teams.kuma, "r0-m0", 0, "kuma-bronze") // nothing at or below round 0: the highest round + }) + t.Run("a team's starting lineup is what v2.1.1 showed before any round it saved: the highest", func(t *testing.T) { + rlHas(t, lineups, teams.kuma, "", 0, "kuma-bronze") + rlHas(t, lineups, teams.tora, "", 0, "tora-round2") + }) + t.Run("a finalist is in no match of that round: its lineup for it is read only where v2.1.1 fell back to it", func(t *testing.T) { + rlLacks(t, lineups, teams.tora, "", 2) + rlHas(t, lineups, teams.tora, "r0-m0", 0, "tora-round2") + rlHas(t, lineups, teams.tora, "r1-m0", 0, "tora-round1") + }) + t.Run("every match shows what v2.1.1 showed", func(t *testing.T) { + rlAssertShowsWhatV211Showed(t, lineups, legacy, state.DrawMatchesFrom(nil, bracket), teams.kuma, teams.tora) + }) + t.Run("the competition is completed, so nothing waits", func(t *testing.T) { + assert.True(t, rlMarker(t, store)) + }) +} + +// TestRoundLineups_NothingIsJudgedBeforeTheDraw: a competition not yet drawn has +// no match to give a lineup to and none to say a round lineup can never be read, +// so what a round lineup needs is only done to the starting lineup; each match +// gets its lineup when the draw seats the team. +func TestRoundLineups_NothingIsJudgedBeforeTheDraw(t *testing.T) { + teams := newRLTeams() + league := func(t *testing.T) (*state.Store, []state.MatchResult) { + comp := rlTeamComp(state.CompFormatLeague) + comp.Status = state.CompStatusSetup + dir := rlSeed(t, comp, teams, nil, nil, + rlLineup(teams.tora, "", 2, "tora-round2"), + rlLineup("Usagi", "", 0, "usagi-by-name"), + ) + store, err := state.NewStore(dir) + require.NoError(t, err) + lineups := rlLoad(t, store) + rlHas(t, lineups, teams.tora, "", 2, "tora-round2") + rlHas(t, lineups, teams.tora, "", 0, "tora-round2") + rlHas(t, lineups, teams.usagi, "", 0, "usagi-by-name") + require.False(t, rlMarker(t, store)) + return store, []state.MatchResult{ + {ID: "Pool A-0", SideA: "Tora", SideAID: teams.tora, SideB: "Usagi", SideBID: teams.usagi, Round: 0}, + {ID: "Pool A-1", SideA: "Tora", SideAID: teams.tora, SideB: "Kuma", SideBID: teams.kuma, Round: 2}, + } + } + + t.Run("a draw written directly", func(t *testing.T) { + store, pool := league(t) + + require.NoError(t, store.SavePoolMatches(rlComp, pool)) + + lineups := rlLoad(t, store) + rlHas(t, lineups, teams.tora, "Pool A-1", 0, "tora-round2") + rlHas(t, lineups, teams.tora, "Pool A-0", 0, "tora-round2") + rlHas(t, lineups, teams.tora, "", 2, "tora-round2") + assert.False(t, rlMarker(t, store), "the league is drawn, not completed") + }) + + t.Run("a draw written in a transaction", func(t *testing.T) { + store, pool := league(t) + + require.NoError(t, store.WithTransaction(rlComp, func(tx state.StoreTx) error { + return tx.SavePoolMatches(rlComp, pool) + })) + + lineups := rlLoad(t, store) + rlHas(t, lineups, teams.tora, "Pool A-1", 0, "tora-round2") + rlHas(t, lineups, teams.tora, "Pool A-0", 0, "tora-round2") + rlHas(t, lineups, teams.tora, "", 2, "tora-round2") + assert.False(t, rlMarker(t, store), "the league is drawn, not completed") + }) +} + +// TestRoundLineups_NameKeyedLineups: a lineup whose team id is not a +// participant id is the team's only when it names exactly one team. +func TestRoundLineups_NameKeyedLineups(t *testing.T) { + t.Run("an id-keyed lineup for the same scope is kept and the name-keyed one dropped", func(t *testing.T) { + teams := newRLTeams() + dir := rlSeed(t, rlTeamComp(state.CompFormatKnockout), teams, nil, rlKnockout(teams), + rlLineup("Saru", "", 0, "saru-by-name"), + rlLineup(teams.saru, "", 0, "saru-by-id"), + rlLineup("Saru", "r0-m1", 0, "saru-match-by-name"), + ) + + store, err := state.NewStore(dir) + require.NoError(t, err) + lineups := rlLoad(t, store) + + rlHas(t, lineups, teams.saru, "", 0, "saru-by-id") + rlHas(t, lineups, teams.saru, "r0-m1", 0, "saru-match-by-name") + rlLacks(t, lineups, "Saru", "", 0) + rlLacks(t, lineups, "Saru", "r0-m1", 0) + }) + + t.Run("a name matching no team is left, and a name matching two", func(t *testing.T) { + teams := newRLTeams() + dir := t.TempDir() + s, err := state.NewStore(dir) + require.NoError(t, err) + require.NoError(t, s.SaveCompetition(rlTeamComp(state.CompFormatKnockout))) + twin := []domain.Player{ + {ID: teams.tora, Name: "Twin", Dojo: "North"}, + {ID: teams.usagi, Name: "Twin", Dojo: "South"}, + } + require.NoError(t, s.SaveParticipantsRestored(rlComp, twin)) + rlWriteLineups(t, dir, rlComp, + rlLineup("Twin", "", 0, "twin"), + rlLineup("Nobody", "", 0, "nobody"), + ) + + store, err := state.NewStore(dir) + require.NoError(t, err) + lineups := rlLoad(t, store) + + rlHas(t, lineups, "Twin", "", 0, "twin") + rlHas(t, lineups, "Nobody", "", 0, "nobody") + assert.True(t, rlMarker(t, store), "a lineup no team can claim is not one waiting for a team to be seated") + }) +} + +// TestRoundLineups_ANameKeyedRoundLineupIsKeyedFirstAndThenGivenToItsMatches: a +// round lineup saved under a team's name is the team's once it is keyed by id, +// so the team is a legacy team and has the lineup v2.1.1 showed at each match. +func TestRoundLineups_ANameKeyedRoundLineupIsKeyedFirstAndThenGivenToItsMatches(t *testing.T) { + teams := newRLTeams() + dir := rlSeed(t, rlTeamComp(state.CompFormatKnockout), teams, nil, rlKnockout(teams), + rlLineup("Kuma", "", 1, "kuma-round1-by-name"), + ) + + store, err := state.NewStore(dir) + require.NoError(t, err) + lineups := rlLoad(t, store) + + rlHas(t, lineups, teams.kuma, "", 1, "kuma-round1-by-name") + rlLacks(t, lineups, "Kuma", "", 1) + rlHas(t, lineups, teams.kuma, "r0-m0", 0, "kuma-round1-by-name") + rlHas(t, lineups, teams.kuma, "", 0, "kuma-round1-by-name") +} + +// TestRoundLineups_ALineupKeyedByIdOnLoadAndItsCopiesCarryTheMemberIds: the lineup +// member-id repair resolves a position's name against the squad of the team the +// lineup is stored under, and a lineup stored under a team's NAME has no squad to +// resolve against until the round-lineup step has keyed it by the team's id. The +// repair therefore runs after that step, so the re-keyed lineup, the starting +// lineup seeded from it and the lineup given to each match all carry the member +// ids, in the one load that moved them, and none is left to the next. +func TestRoundLineups_ALineupKeyedByIdOnLoadAndItsCopiesCarryTheMemberIds(t *testing.T) { + teams := newRLTeams() + dir := rlSeed(t, rlTeamComp(state.CompFormatKnockout), teams, nil, rlKnockout(teams)) + seed, err := state.NewStore(dir) + require.NoError(t, err) + sato, err := seed.AddTeamMember(rlComp, teams.kuma, "Sato") + require.NoError(t, err) + // Reading the competition above found no round lineup waiting and marked it, + // which v2.1.1 never did. + _, err = seed.UpdateCompetitionChanged(rlComp, func(c *state.Competition) (*state.Competition, error) { + c.RoundLineupsConverted = false + return c, nil + }) + require.NoError(t, err) + // v2.0.0's shape: the team's name for a key, a position's name and no id. + rlWriteLineups(t, dir, rlComp, domain.TeamLineup{ + TeamID: "Kuma", CompetitionID: rlComp, Round: 1, + Positions: map[domain.Position]string{domain.PositionNumbered(1): "Sato"}, + }) + + store, err := state.NewStore(dir) + require.NoError(t, err) + lineups := rlLoad(t, store) + + for _, scope := range []struct { + name string + matchID string + round int + }{ + {"the round lineup, keyed by id", "", 1}, + {"the starting lineup it seeded", "", 0}, + {"the lineup given to the match it is seated in", "r0-m0", 0}, + } { + got, ok := rlFind(lineups, teams.kuma, scope.matchID, scope.round) + if !assert.Truef(t, ok, "%s is not stored; have %v", scope.name, lineups) { + continue + } + assert.Equal(t, "Sato", got.Positions[domain.PositionNumbered(1)], scope.name) + assert.Equal(t, sato.ID, got.MemberIDs[domain.PositionNumbered(1)], "%s carries the member id of the name it holds", scope.name) + } + assert.NotContains(t, rlRaw(t, dir, "lineups.yaml"), "teamId: Kuma\n", "nothing is left under the name") +} + +// TestRoundLineups_TheMarkerStopsTheRepair: the repair is keyed on the marker, +// not on the lineups' shape, and an individual competition, which has no +// lineups, is neither converted nor marked. +func TestRoundLineups_TheMarkerStopsTheRepair(t *testing.T) { + teams := newRLTeams() + for _, tc := range []struct { + name string + mark bool + kind string + teamSize int + converted bool + }{ + {"unmarked team competition", false, "team", 3, true}, + {"marked team competition", true, "team", 3, false}, + {"individual competition", false, "", 0, false}, + } { + t.Run(tc.name, func(t *testing.T) { + comp := rlTeamComp(state.CompFormatKnockout) + comp.Kind, comp.TeamSize = tc.kind, tc.teamSize + dir := rlSeed(t, comp, teams, nil, rlKnockout(teams), rlLineup(teams.tora, "", 1, "tora-round1")) + if tc.mark { + seed, err := state.NewStore(dir) + require.NoError(t, err) + stored, err := seed.LoadCompetition(rlComp) + require.NoError(t, err) + stored.RoundLineupsConverted = true + require.NoError(t, seed.SaveCompetition(stored)) + rlWriteLineups(t, dir, rlComp, rlLineup(teams.tora, "", 1, "tora-round1")) // the seeding store's sweep may have moved it + } + before := rlRaw(t, dir, "lineups.yaml") + + store, err := state.NewStore(dir) + require.NoError(t, err) + + if tc.converted { + rlHas(t, rlLoad(t, store), teams.tora, "r1-m0", 0, "tora-round1") + assert.NotEqual(t, before, rlRaw(t, dir, "lineups.yaml")) + return + } + assert.Equal(t, before, rlRaw(t, dir, "lineups.yaml"), "left as it was") + assert.Equal(t, tc.mark, rlMarker(t, store), "and its marker is not touched") + }) + } +} + +// TestRoundLineups_SeatingNeverRebuildsAStartingLineup: giving a team without +// one what v2.1.1 showed before any round it saved is the load repair's, once; a +// write that seats a team later does not bring back a starting lineup the +// operator has since removed. +func TestRoundLineups_SeatingNeverRebuildsAStartingLineup(t *testing.T) { + for name, seat := range map[string]func(*state.Store, rlTeams) error{ + "a direct write": func(s *state.Store, teams rlTeams) error { + return s.UpdateBracket(rlComp, rlSeatUsagi(teams)) + }, + "a transaction": func(s *state.Store, teams rlTeams) error { + return s.WithTransaction(rlComp, func(tx state.StoreTx) error { + return tx.UpdateBracket(rlComp, rlSeatUsagi(teams)) + }) + }, + } { + t.Run(name, func(t *testing.T) { + teams := newRLTeams() + dir := rlSeed(t, rlTeamComp(state.CompFormatKnockout), teams, nil, rlKnockout(teams), rlLegacyKnockoutLineups(teams)...) + store, err := state.NewStore(dir) + require.NoError(t, err) + rlHas(t, rlLoad(t, store), teams.usagi, "", 0, "usagi-round1") + require.NoError(t, store.DeleteTeamLineup(rlComp, teams.usagi, 0)) + + require.NoError(t, seat(store, teams)) + + lineups := rlLoad(t, store) + rlHas(t, lineups, teams.usagi, "r1-m0", 0, "usagi-round1") + rlLacks(t, lineups, teams.usagi, "", 0) + }) + } +} + +// TestRoundLineups_AHiddenByeHoldsNoLineup: a structural bye is a match nobody +// plays, and the draw seats its winner at once, so a team that skips rounds is +// seated in one. A lineup is given for the matches the team plays, never for the +// bye, and the bye's empty side is not a side still to be decided. +func TestRoundLineups_AHiddenByeHoldsNoLineup(t *testing.T) { + teams := newRLTeams() + bracket := &state.Bracket{Rounds: [][]state.BracketMatch{ + {{ID: "r0-m0", SideA: "Kuma", SideAID: teams.kuma, SideB: "Saru", SideBID: teams.saru}}, + { + {ID: "r1-m0", SideA: "Tora", SideAID: teams.tora, Hidden: true}, + {ID: "r1-m1", SideA: "Winner of r0-m0", SideB: "Usagi", SideBID: teams.usagi}, + }, + {{ID: "r2-m0", SideA: "Tora", SideAID: teams.tora, SideB: "Winner of r1-m1"}}, + }} + dir := rlSeed(t, rlTeamComp(state.CompFormatKnockout), teams, nil, bracket, rlLineup(teams.tora, "", 1, "tora-round1")) + + store, err := state.NewStore(dir) + require.NoError(t, err) + lineups := rlLoad(t, store) + + rlHas(t, lineups, teams.tora, "r2-m0", 0, "tora-round1") + rlLacks(t, lineups, teams.tora, "r1-m0", 0) + rlHas(t, lineups, teams.tora, "", 1, "tora-round1") + assert.False(t, rlMarker(t, store), "the second semi-final is not decided, so the round lineup is kept; the bye's empty side is not waited for") +} + +// TestRoundLineups_ALeaguesVestigialBracketIsNotItsKnockout: a league and a +// Swiss competition have no knockout, whatever a vestigial bracket.json holds, +// so a match in it seats no one and is given no lineup. +func TestRoundLineups_ALeaguesVestigialBracketIsNotItsKnockout(t *testing.T) { + teams := newRLTeams() + pool := []state.MatchResult{ + {ID: "Pool A-0", SideA: "Tora", SideAID: teams.tora, SideB: "Usagi", SideBID: teams.usagi, Round: 0}, + {ID: "Pool A-1", SideA: "Kuma", SideAID: teams.kuma, SideB: "Saru", SideBID: teams.saru, Round: 1}, + } + vestigial := &state.Bracket{Rounds: [][]state.BracketMatch{ + {{ID: "r0-m0", SideA: "Tora", SideAID: teams.tora, SideB: "Kuma", SideBID: teams.kuma}}, + {{ID: "r1-m0", SideA: "Winner of r0-m0", SideB: "Pool A-3rd"}}, + }} + dir := rlSeed(t, rlTeamComp(state.CompFormatLeague), teams, pool, vestigial, rlLineup(teams.tora, "", 1, "tora-round1")) + + store, err := state.NewStore(dir) + require.NoError(t, err) + + lineups := rlLoad(t, store) + rlHas(t, lineups, teams.tora, "Pool A-0", 0, "tora-round1") + rlLacks(t, lineups, teams.tora, "r0-m0", 0) + rlHas(t, lineups, teams.tora, "", 1, "tora-round1") + assert.NotContains(t, rlGiven(t, store)[teams.tora], "r0-m0", "a match in the vestigial bracket seats no one") + assert.False(t, rlMarker(t, store), "the league is not completed") +} + +// TestRoundLineups_ASwissByeHoldsNoLineup: a Swiss round with an odd number of +// teams stores its odd team out as a completed match against nobody (a side +// with neither a name nor an id). Nobody plays it, so a team is given a lineup +// for the matches it plays and never for the bye, which the Lineups page does +// not list: a lineup there could be neither seen nor removed. +func TestRoundLineups_ASwissByeHoldsNoLineup(t *testing.T) { + teams := newRLTeams() + comp := rlTeamComp(state.CompFormatSwiss) + comp.SwissRounds, comp.SwissCurrentRound = 2, 2 + bye := func(id, team string) state.MatchResult { + return state.MatchResult{ID: id, SideA: teams.names()[team], SideAID: team, Winner: teams.names()[team], WinnerID: team, Status: state.MatchStatusCompleted} + } + pool := []state.MatchResult{ + rlBout(teams, "Swiss-R1-0", 0, teams.tora, teams.usagi), + bye("Swiss-R1-1", teams.kuma), + rlBout(teams, "Swiss-R2-0", 0, teams.kuma, teams.tora), + bye("Swiss-R2-1", teams.usagi), + } + dir := rlSeed(t, comp, teams, pool, nil, rlLineup(teams.kuma, "", 1, "kuma-round1"), rlLineup(teams.usagi, "", 1, "usagi-round1")) + + store, err := state.NewStore(dir) + require.NoError(t, err) + lineups := rlLoad(t, store) + + rlHas(t, lineups, teams.kuma, "Swiss-R2-0", 0, "kuma-round1") + rlLacks(t, lineups, teams.kuma, "Swiss-R1-1", 0) + rlHas(t, lineups, teams.usagi, "Swiss-R1-0", 0, "usagi-round1") + rlLacks(t, lineups, teams.usagi, "Swiss-R2-1", 0) + assert.Equal(t, map[string][]string{ + teams.kuma: {"Swiss-R2-0"}, + teams.usagi: {"Swiss-R1-0"}, + }, rlGiven(t, store), "a bye is no pair to settle") + assert.NotContains(t, rlRaw(t, dir, "lineups.yaml"), "Swiss-R1-1") + assert.NotContains(t, rlRaw(t, dir, "lineups.yaml"), "Swiss-R2-1") +} + +// TestRoundLineups_EveryDoorThatWritesTheDrawSettles: the move is made by the +// write that seats the team, whichever of the store's doors makes it, directly +// or in a transaction. +func TestRoundLineups_EveryDoorThatWritesTheDrawSettles(t *testing.T) { + teams := newRLTeams() + // Usagi is seated in neither Pool A-1 (Round 1) nor the final yet. Its slot + // in Pool A-1 carries no name either: the load repair would stamp the id of + // a side named for a unique team. + pool := func() []state.MatchResult { + return []state.MatchResult{ + {ID: "Pool A-0", SideA: "Kuma", SideAID: teams.kuma, SideB: "Saru", SideBID: teams.saru, Round: 0}, + {ID: "Pool A-1", SideB: "Kuma", SideBID: teams.kuma, Round: 1}, + } + } + seatPool := func(m *state.MatchResult) error { m.SideAID = teams.usagi; return nil } + seatBracketMatch := func(m *state.BracketMatch) { m.SideB, m.SideBID = "Usagi", teams.usagi } + + type door struct { + name string + pool bool + run func(t *testing.T, s *state.Store) + } + doors := []door{ + {"SaveBracket", false, func(t *testing.T, s *state.Store) { + b := rlKnockout(teams) + require.NoError(t, rlSeatUsagi(teams)(b)) + require.NoError(t, s.SaveBracket(rlComp, b)) + }}, + {"UpdateBracket", false, func(t *testing.T, s *state.Store) { + require.NoError(t, s.UpdateBracket(rlComp, rlSeatUsagi(teams))) + }}, + {"UpdateBracketMatchByID", false, func(t *testing.T, s *state.Store) { + found, err := s.UpdateBracketMatchByID(rlComp, "r1-m0", seatBracketMatch) + require.NoError(t, err) + require.True(t, found) + }}, + {"SavePoolMatches", true, func(t *testing.T, s *state.Store) { + p := pool() + p[1].SideAID = teams.usagi + require.NoError(t, s.SavePoolMatches(rlComp, p)) + }}, + {"UpdatePoolMatchByID", true, func(t *testing.T, s *state.Store) { + found, err := s.UpdatePoolMatchByID(rlComp, "Pool A-1", seatPool) + require.NoError(t, err) + require.True(t, found) + }}, + {"a transaction's SaveBracket", false, func(t *testing.T, s *state.Store) { + b := rlKnockout(teams) + require.NoError(t, rlSeatUsagi(teams)(b)) + require.NoError(t, s.WithTransaction(rlComp, func(tx state.StoreTx) error { return tx.SaveBracket(rlComp, b) })) + }}, + {"a transaction's UpdateBracketMatchByID", false, func(t *testing.T, s *state.Store) { + require.NoError(t, s.WithTransaction(rlComp, func(tx state.StoreTx) error { + found, err := tx.UpdateBracketMatchByID(rlComp, "r1-m0", seatBracketMatch) + require.True(t, found) + return err + })) + }}, + {"a transaction's SavePoolMatches", true, func(t *testing.T, s *state.Store) { + p := pool() + p[1].SideAID = teams.usagi + require.NoError(t, s.WithTransaction(rlComp, func(tx state.StoreTx) error { return tx.SavePoolMatches(rlComp, p) })) + }}, + {"a transaction's UpdatePoolMatchByID", true, func(t *testing.T, s *state.Store) { + require.NoError(t, s.WithTransaction(rlComp, func(tx state.StoreTx) error { + found, err := tx.UpdatePoolMatchByID(rlComp, "Pool A-1", seatPool) + require.True(t, found) + return err + })) + }}, + } + for _, d := range doors { + t.Run(d.name, func(t *testing.T) { + var dir string + matchID := "r1-m0" + if d.pool { + matchID = "Pool A-1" + dir = rlSeed(t, rlTeamComp(state.CompFormatLeague), teams, pool(), nil, rlLineup(teams.usagi, "", 1, "usagi-round1")) + } else { + dir = rlSeed(t, rlTeamComp(state.CompFormatKnockout), teams, nil, rlKnockout(teams), rlLineup(teams.usagi, "", 1, "usagi-round1")) + } + store, err := state.NewStore(dir) + require.NoError(t, err) + require.False(t, rlMarker(t, store), "precondition: the round lineup waits for the competition to be completed") + rlLacks(t, rlLoad(t, store), teams.usagi, matchID, 0) + + d.run(t, store) + + lineups := rlLoad(t, store) + rlHas(t, lineups, teams.usagi, matchID, 0, "usagi-round1") + rlHas(t, lineups, teams.usagi, "", 1, "usagi-round1") + assert.Contains(t, rlGiven(t, store)[teams.usagi], matchID) + assert.False(t, rlMarker(t, store)) + }) + } +} + +// TestRoundLineups_ATeamWithAStartingLineupKeepsIt: only a team without a +// starting lineup is given one; one that has it keeps it, and shows it at the +// matches v2.1.1 showed it at, its later rounds at theirs. +func TestRoundLineups_ATeamWithAStartingLineupKeepsIt(t *testing.T) { + teams := newRLTeams() + dir := rlSeed(t, rlTeamComp(state.CompFormatKnockout), teams, nil, rlKnockout(teams), + rlLineup(teams.tora, "", 0, "tora-start"), + rlLineup(teams.tora, "", 1, "tora-round1"), + ) + + store, err := state.NewStore(dir) + require.NoError(t, err) + lineups := rlLoad(t, store) + + rlHas(t, lineups, teams.tora, "", 0, "tora-start") + rlHas(t, lineups, teams.tora, "r0-m0", 0, "tora-start") + rlHas(t, lineups, teams.tora, "r1-m0", 0, "tora-round1") + rlHas(t, lineups, teams.tora, "", 1, "tora-round1") + assert.Len(t, lineups, 4, "the starting lineup and the round lineup, and one for each of the two matches Tora is seated in") +} + +// TestRoundLineups_AFileItCannotReadLeavesTheMarkerUnset: a settlement that +// cannot read what it needs decides nothing and records nothing, so a later +// start retries, and a write of the draw is never failed by it. +func TestRoundLineups_AFileItCannotReadLeavesTheMarkerUnset(t *testing.T) { + for _, tc := range []struct { + name string + file string + // damaged is not a valid file of its kind. + damaged string + // write is a draw write that does not itself read the damaged file. + write func(t *testing.T, s *state.Store, teams rlTeams) + }{ + {"lineups.yaml", "lineups.yaml", "lineups: [this is: not: valid yaml", func(t *testing.T, s *state.Store, teams rlTeams) { + require.NoError(t, s.UpdateBracket(rlComp, rlSeatUsagi(teams))) + }}, + {"pool-matches.csv", "pool-matches.csv", "\"unterminated", func(t *testing.T, s *state.Store, teams rlTeams) { + require.NoError(t, s.UpdateBracket(rlComp, rlSeatUsagi(teams))) + }}, + {"bracket.json", "bracket.json", "{not json", func(t *testing.T, s *state.Store, teams rlTeams) { + require.NoError(t, s.SavePoolMatches(rlComp, []state.MatchResult{{ID: "Pool A-0", SideAID: teams.tora, SideBID: teams.kuma}})) + }}, + } { + t.Run(tc.name, func(t *testing.T) { + teams := newRLTeams() + dir := rlSeed(t, rlTeamComp(state.CompFormatKnockout), teams, nil, rlKnockout(teams), rlLegacyKnockoutLineups(teams)...) + require.NoError(t, os.WriteFile(filepath.Join(dir, "competitions", rlComp, tc.file), []byte(tc.damaged), 0o600)) + + store, err := state.NewStore(dir) + require.NoError(t, err) + assert.False(t, rlMarker(t, store), "the load repair could not read %s, so it decided nothing", tc.file) + if tc.file != "lineups.yaml" { + assert.Contains(t, rlRaw(t, dir, "lineups.yaml"), "round: 1", "and left the lineups as they were") + } + + tc.write(t, store, teams) + assert.False(t, rlMarker(t, store), "a write of the draw cannot settle what it cannot read, and is not failed by it") + }) + } +} + +// TestRoundLineups_ARecordWhoseIdIsNotItsDirectoryIsLeftAlone: the marker is +// saved under the record's own id, so a config.md that names another competition +// is never written to on the strength of this directory's lineups. +func TestRoundLineups_ARecordWhoseIdIsNotItsDirectoryIsLeftAlone(t *testing.T) { + teams := newRLTeams() + dir := rlSeed(t, rlTeamComp(state.CompFormatKnockout), teams, nil, rlKnockout(teams), rlLineup(teams.tora, "", 1, "tora-round1")) + cfg := filepath.Join(dir, "competitions", rlComp, "config.md") + raw, err := os.ReadFile(cfg) // #nosec G304 -- a path under t.TempDir + require.NoError(t, err) + require.Contains(t, string(raw), "id: rl\n") + require.NoError(t, os.WriteFile(cfg, []byte(strings.Replace(string(raw), "id: rl\n", "id: elsewhere\n", 1)), 0o600)) + lineupsBefore := rlRaw(t, dir, "lineups.yaml") + + _, err = state.NewStore(dir) + require.NoError(t, err) + + assert.Equal(t, lineupsBefore, rlRaw(t, dir, "lineups.yaml")) + assert.NotContains(t, rlRaw(t, dir, "config.md"), "round_lineups_converted") + assert.NotContains(t, rlRaw(t, dir, "config.md"), "round_lineups_given") +} + +// TestRoundLineups_ReadsTheFileV211Wrote pins the bytes: lineups.yaml as v2.1.1's +// Lineups page and score sheet left it (a `lineups:` list, a team's round +// lineups, a lineup entered for a match with its `matchId`, a name-keyed lineup), +// written as text rather than through the store's own marshalling. +func TestRoundLineups_ReadsTheFileV211Wrote(t *testing.T) { + teams := newRLTeams() + dir := rlSeed(t, rlTeamComp(state.CompFormatKnockout), teams, nil, rlKnockout(teams)) + legacy := fmt.Sprintf(`lineups: + - teamId: %[1]s + competitionId: rl + round: 1 + positions: + "1": Sato + "2": Ito + memberIds: + "1": member-sato + "2": member-ito + - teamId: %[2]s + competitionId: rl + round: 0 + matchId: r0-m0 + positions: + "1": Endo + - teamId: Saru + competitionId: rl + round: 0 + positions: + "1": Kato +`, teams.tora, teams.kuma) + require.NoError(t, os.WriteFile(filepath.Join(dir, "competitions", rlComp, "lineups.yaml"), []byte(legacy), 0o600)) + + store, err := state.NewStore(dir) + require.NoError(t, err) + lineups := rlLoad(t, store) + + moved, ok := rlFind(lineups, teams.tora, "r1-m0", 0) + require.True(t, ok) + assert.Equal(t, map[domain.Position]string{"1": "Sato", "2": "Ito"}, moved.Positions) + assert.Equal(t, map[domain.Position]string{"1": "member-sato", "2": "member-ito"}, moved.MemberIDs) + start, ok := rlFind(lineups, teams.tora, "", 0) + require.True(t, ok) + assert.Equal(t, moved.Positions, start.Positions, "the starting lineup is a copy of what v2.1.1 showed before any round") + rlHas(t, lineups, teams.tora, "", 1, "Sato") // kept: the competition is not completed + rlHas(t, lineups, teams.kuma, "r0-m0", 0, "Endo") + rlHas(t, lineups, teams.saru, "", 0, "Kato") + assert.Contains(t, rlRaw(t, dir, "lineups.yaml"), "matchId: r1-m0", "and the file on disk says it in the shape the match lineup PUT writes") +} + +// rlBout is a pool bout between two teams, numbered as a drawn pool numbers it. +func rlBout(teams rlTeams, id string, round int, a, b string) state.MatchResult { + n := teams.names() + return state.MatchResult{ID: id, SideA: n[a], SideAID: a, SideB: n[b], SideBID: b, Round: round} +} + +// rlPoolOfFour is a pool of four numbered as a real draw numbers one: the bout +// number is the playing order, and the stored round of a team's bouts in that +// order does not rise. Tora plays rounds 1, 0, 2 (Pool A-0, A-3, A-4). It is +// pool A of a drawn eight-team mixed competition. +func rlPoolOfFour(teams rlTeams) []state.MatchResult { + return []state.MatchResult{ + rlBout(teams, "Pool A-0", 1, teams.tora, teams.usagi), + rlBout(teams, "Pool A-1", 0, teams.usagi, teams.kuma), + rlBout(teams, "Pool A-2", 1, teams.kuma, teams.saru), + rlBout(teams, "Pool A-3", 0, teams.saru, teams.tora), + rlBout(teams, "Pool A-4", 2, teams.tora, teams.kuma), + rlBout(teams, "Pool A-5", 2, teams.usagi, teams.saru), + } +} + +// rlPoolOfFive is the single pool of a drawn five-team league, numbered the same +// way. Tora plays rounds 2, 1, 4, 3 (Pool A-2, A-4, A-7, A-9). +func rlPoolOfFive(teams rlTeams) []state.MatchResult { + return []state.MatchResult{ + rlBout(teams, "Pool A-0", 0, teams.kuma, teams.saru), + rlBout(teams, "Pool A-1", 0, teams.usagi, teams.inu), + rlBout(teams, "Pool A-2", 2, teams.tora, teams.kuma), + rlBout(teams, "Pool A-3", 1, teams.saru, teams.inu), + rlBout(teams, "Pool A-4", 1, teams.tora, teams.usagi), + rlBout(teams, "Pool A-5", 3, teams.kuma, teams.inu), + rlBout(teams, "Pool A-6", 2, teams.usagi, teams.saru), + rlBout(teams, "Pool A-7", 4, teams.tora, teams.inu), + rlBout(teams, "Pool A-8", 4, teams.usagi, teams.kuma), + rlBout(teams, "Pool A-9", 3, teams.tora, teams.saru), + } +} + +// rlEveryRound is the lineups a Lineups page left when it saved a lineup for +// every round from 0 to last for each of the teams. +func rlEveryRound(teams rlTeams, last int, ids ...string) []domain.TeamLineup { + var out []domain.TeamLineup + for _, id := range ids { + out = append(out, rlRounds(id, teams.names()[id], 0, last)...) + } + return out +} + +// rlThreeRoundKnockout is a knockout part way through: Tora has won its first +// two matches and is seated in the final, whose other side waits for the second +// semi-final, which is not decided yet. +func rlThreeRoundKnockout(teams rlTeams) *state.Bracket { + return &state.Bracket{Rounds: [][]state.BracketMatch{ + { + {ID: "r0-m0", SideA: "Tora", SideAID: teams.tora, SideB: "Kuma", SideBID: teams.kuma, + Status: state.MatchStatusCompleted, Winner: "Tora", WinnerID: teams.tora}, + {ID: "r0-m1", SideA: "Usagi", SideAID: teams.usagi, SideB: "Saru", SideBID: teams.saru, + Status: state.MatchStatusCompleted, Winner: "Usagi", WinnerID: teams.usagi}, + }, + { + {ID: "r1-m0", SideA: "Tora", SideAID: teams.tora, SideB: "Usagi", SideBID: teams.usagi, + Status: state.MatchStatusCompleted, Winner: "Tora", WinnerID: teams.tora}, + {ID: "r1-m1", SideA: "Winner of r0-m2", SideB: "Winner of r0-m3"}, + }, + {{ID: "r2-m0", SideA: "Tora", SideAID: teams.tora, SideB: "Winner of r1-m1"}}, + }} +} + +// rlDecideTheOtherSemiFinal is the write that decides the second semi-final, so +// every match of rlThreeRoundKnockout has both its sides. +func rlDecideTheOtherSemiFinal(teams rlTeams) func(*state.Bracket) error { + return func(b *state.Bracket) error { + b.Rounds[1][1].SideA, b.Rounds[1][1].SideAID = "Inu", teams.inu + b.Rounds[1][1].SideB, b.Rounds[1][1].SideBID = "Kuma", teams.kuma + b.Rounds[2][0].SideB, b.Rounds[2][0].SideBID = "Inu", teams.inu + return nil + } +} + +// TestRoundLineups_APoolOfFourInPlayingOrderShowsWhatV211Showed: a pool's bouts +// are numbered in playing order, and a team's stored rounds in that order need +// not rise (Tora plays 1, 0, 2), so a lineup saved for a round cannot be moved +// onto "the team's first match at that round or later" without changing what +// several of its matches show. Every match a team is seated in shows what v2.1.1 +// showed there: the round lineup with the highest round at or below the match's, +// else the highest round there was. +func TestRoundLineups_APoolOfFourInPlayingOrderShowsWhatV211Showed(t *testing.T) { + teams := newRLTeams() + four := []string{teams.tora, teams.usagi, teams.kuma, teams.saru} + legacy := rlEveryRound(teams, 2, four...) + pool := rlPoolOfFour(teams) + round1And2Held := func(t *testing.T, lineups map[string]domain.TeamLineup) { + t.Helper() + for _, team := range four { + for round := 1; round <= 2; round++ { + rlHas(t, lineups, team, "", round, fmt.Sprintf("%s-round%d", teams.names()[team], round)) + } + } + } + + t.Run("a league that is on keeps the round lineups, though every match is held", func(t *testing.T) { + dir := rlSeed(t, rlTeamComp(state.CompFormatLeague), teams, pool, nil, legacy...) + + store, err := state.NewStore(dir) + require.NoError(t, err) + lineups := rlLoad(t, store) + + rlAssertShowsWhatV211Showed(t, lineups, legacy, state.DrawMatchesFrom(pool, nil), four...) + round1And2Held(t, lineups) + assert.False(t, rlMarker(t, store)) + }) + + t.Run("a mixed competition keeps them too", func(t *testing.T) { + bracket := &state.Bracket{Rounds: [][]state.BracketMatch{ + {{ID: "r0-m0", SideA: "Pool A-1st", SideB: "Pool B-1st"}}, + {{ID: "r1-m0", SideA: "Winner of r0-m0", SideB: "Pool B-2nd"}}, + }} + dir := rlSeed(t, rlTeamComp(state.CompFormatMixed), teams, pool, bracket, legacy...) + + store, err := state.NewStore(dir) + require.NoError(t, err) + lineups := rlLoad(t, store) + + rlAssertShowsWhatV211Showed(t, lineups, legacy, state.DrawMatchesFrom(pool, bracket), four...) + round1And2Held(t, lineups) + assert.False(t, rlMarker(t, store)) + }) + + t.Run("a completed league gives up the round lineups with the marker", func(t *testing.T) { + dir := rlSeed(t, rlCompleted(rlTeamComp(state.CompFormatLeague)), teams, pool, nil, legacy...) + + store, err := state.NewStore(dir) + require.NoError(t, err) + lineups := rlLoad(t, store) + + rlAssertShowsWhatV211Showed(t, lineups, legacy, state.DrawMatchesFrom(pool, nil), four...) + for _, team := range four { + rlLacks(t, lineups, team, "", 1) + rlLacks(t, lineups, team, "", 2) + rlHas(t, lineups, team, "", 0, teams.names()[team]+"-round0") + } + assert.True(t, rlMarker(t, store)) + }) +} + +// TestRoundLineups_AFiveTeamLeagueShowsWhatV211Showed: the same for the single +// pool of a drawn five-team league, whose bouts run in an order that is further +// from its rounds still (Tora plays rounds 2, 1, 4, 3). No match shows another +// round's lineup, and no lineup v2.1.1 showed anywhere is lost. +func TestRoundLineups_AFiveTeamLeagueShowsWhatV211Showed(t *testing.T) { + teams := newRLTeams() + five := []string{teams.tora, teams.usagi, teams.kuma, teams.saru, teams.inu} + legacy := rlEveryRound(teams, 4, five...) + pool := rlPoolOfFive(teams) + dir := rlSeed(t, rlTeamComp(state.CompFormatLeague), teams, pool, nil, legacy...) + roundLineupsHeld := func(lineups map[string]domain.TeamLineup) int { + held := 0 + for _, l := range lineups { + if l.MatchID == "" && l.Round >= 1 { + held++ + } + } + return held + } + + store, err := state.NewStore(dir) + require.NoError(t, err) + lineups := rlLoad(t, store) + + rlAssertShowsWhatV211Showed(t, lineups, legacy, state.DrawMatchesFrom(pool, nil), five...) + assert.Equal(t, len(five)*4, roundLineupsHeld(lineups), "no round lineup is gone before the league is completed") + assert.False(t, rlMarker(t, store)) + + rlComplete(t, store) + restarted, err := state.NewStore(dir) + require.NoError(t, err) + + lineups = rlLoad(t, restarted) + rlAssertShowsWhatV211Showed(t, lineups, legacy, state.DrawMatchesFrom(pool, nil), five...) + assert.Zero(t, roundLineupsHeld(lineups), "the next load retires them: the match lineups hold everything v2.1.1 showed") + assert.True(t, rlMarker(t, restarted)) +} + +// TestRoundLineups_ASemiFinalOnlyLineupDoesNotHideTheRoundLineupAtTheFinal: Tora +// has a lineup for round 3 (stored 2, the final) read from its round 2 lineup +// (stored 1) and one entered for its semi-final alone. v2.1.1 showed the semi-final +// lineup at the semi-final only (a match's own lineup was never carried) and the +// round 2 lineup again at the final. +func TestRoundLineups_ASemiFinalOnlyLineupDoesNotHideTheRoundLineupAtTheFinal(t *testing.T) { + teams := newRLTeams() + legacy := []domain.TeamLineup{ + rlLineup(teams.tora, "", 0, "tora-start"), + rlLineup(teams.tora, "", 1, "tora-round2"), + rlLineup(teams.tora, "r1-m0", 0, "tora-semi-final"), + } + bracket := rlThreeRoundKnockout(teams) + dir := rlSeed(t, rlTeamComp(state.CompFormatKnockout), teams, nil, bracket, legacy...) + + store, err := state.NewStore(dir) + require.NoError(t, err) + lineups := rlLoad(t, store) + + t.Run("each match shows what v2.1.1 showed there", func(t *testing.T) { + rlHas(t, lineups, teams.tora, "r0-m0", 0, "tora-start") + rlHas(t, lineups, teams.tora, "r1-m0", 0, "tora-semi-final") + rlHas(t, lineups, teams.tora, "r2-m0", 0, "tora-round2") + rlAssertShowsWhatV211Showed(t, lineups, legacy, state.DrawMatchesFrom(nil, bracket), teams.tora) + }) + + t.Run("nothing is deleted while the competition is on", func(t *testing.T) { + rlHas(t, lineups, teams.tora, "", 1, "tora-round2") + assert.False(t, rlMarker(t, store)) + }) + + t.Run("not when the write that decides the last side is made either", func(t *testing.T) { + require.NoError(t, store.UpdateBracket(rlComp, rlDecideTheOtherSemiFinal(teams))) + + lineups := rlLoad(t, store) + rlHas(t, lineups, teams.tora, "r2-m0", 0, "tora-round2") + rlHas(t, lineups, teams.tora, "r1-m0", 0, "tora-semi-final") + rlHas(t, lineups, teams.tora, "", 1, "tora-round2") + assert.False(t, rlMarker(t, store)) + }) + + t.Run("it goes with the completion, when a write of the draw sees it", func(t *testing.T) { + rlComplete(t, store) + require.NoError(t, store.UpdateBracket(rlComp, func(b *state.Bracket) error { return nil })) + + lineups := rlLoad(t, store) + rlHas(t, lineups, teams.tora, "r2-m0", 0, "tora-round2") + rlHas(t, lineups, teams.tora, "r1-m0", 0, "tora-semi-final") + rlLacks(t, lineups, teams.tora, "", 1) + assert.True(t, rlMarker(t, store)) + }) +} + +// TestRoundLineups_ALegacyTeamSeatedLaterGetsItsReadingInTheWriteThatSeatsIt: a +// team seated in an early match at load has the lineup v2.1.1 showed there at +// once; the write that seats it in the final gives it the one v2.1.1 showed at +// the final, which is not the first match's. Through the store's own door and +// through a transaction alike. +func TestRoundLineups_ALegacyTeamSeatedLaterGetsItsReadingInTheWriteThatSeatsIt(t *testing.T) { + for name, seat := range map[string]func(*state.Store, rlTeams) error{ + "a direct write": func(s *state.Store, teams rlTeams) error { + return s.UpdateBracket(rlComp, rlSeatUsagi(teams)) + }, + "a transaction": func(s *state.Store, teams rlTeams) error { + return s.WithTransaction(rlComp, func(tx state.StoreTx) error { + return tx.UpdateBracket(rlComp, rlSeatUsagi(teams)) + }) + }, + } { + t.Run(name, func(t *testing.T) { + teams := newRLTeams() + dir := rlSeed(t, rlTeamComp(state.CompFormatKnockout), teams, nil, rlKnockout(teams), + rlLineup(teams.usagi, "", 0, "usagi-start"), + rlLineup(teams.usagi, "", 1, "usagi-round1"), + ) + store, err := state.NewStore(dir) + require.NoError(t, err) + lineups := rlLoad(t, store) + rlHas(t, lineups, teams.usagi, "r0-m1", 0, "usagi-start") + rlLacks(t, lineups, teams.usagi, "r1-m0", 0) + rlHas(t, lineups, teams.usagi, "", 1, "usagi-round1") + + require.NoError(t, seat(store, teams)) + + lineups = rlLoad(t, store) + rlHas(t, lineups, teams.usagi, "r1-m0", 0, "usagi-round1") + rlHas(t, lineups, teams.usagi, "r0-m1", 0, "usagi-start") + rlHas(t, lineups, teams.usagi, "", 1, "usagi-round1") + assert.False(t, rlMarker(t, store), "the competition is not completed") + }) + } +} + +// TestRoundLineups_ATeamWithoutALaterRoundIsLeftAsItIs: a team with no round +// lineup for round 2 or later and none entered for a match (Saru, with only a +// starting lineup) was never affected by the change of rule: it keeps exactly the +// lineups it has, and is given none for the matches it is seated in (its starting +// lineup carries under the new rule). Kuma has a lineup entered for r0-m0, the only +// match it is seated in, so it is a legacy team too (match_only_test.go); it keeps +// that lineup and has no other match to be given one for. +func TestRoundLineups_ATeamWithoutALaterRoundIsLeftAsItIs(t *testing.T) { + teams := newRLTeams() + dir := rlSeed(t, rlTeamComp(state.CompFormatKnockout), teams, nil, rlKnockout(teams), + rlLineup(teams.tora, "", 1, "tora-round1"), + rlLineup(teams.kuma, "", 0, "kuma-start"), + rlLineup(teams.kuma, "r0-m0", 0, "kuma-match"), + rlLineup(teams.saru, "", 0, "saru-start"), + ) + + store, err := state.NewStore(dir) + require.NoError(t, err) + lineups := rlLoad(t, store) + + for _, tc := range []struct { + team string + tags map[string]string // match id ("" for the starting lineup) to tag + }{ + {teams.kuma, map[string]string{"": "kuma-start", "r0-m0": "kuma-match"}}, + {teams.saru, map[string]string{"": "saru-start"}}, + } { + var held int + for _, l := range lineups { + if l.TeamID == tc.team { + held++ + } + } + assert.Equal(t, len(tc.tags), held, "team %s holds exactly the lineups it had", tc.team) + for matchID, tag := range tc.tags { + rlHas(t, lineups, tc.team, matchID, 0, tag) + } + } + rlHas(t, lineups, teams.tora, "r0-m0", 0, "tora-round1") +} + +// TestRoundLineups_TheRoundLineupsGoOnlyWhenTheCompetitionIsCompleted: the round +// lineups wait whatever the draw holds, since a correction can seat a team in a +// match until the competition is over, and are removed, with the marker set, +// when it is. +func TestRoundLineups_TheRoundLineupsGoOnlyWhenTheCompetitionIsCompleted(t *testing.T) { + teams := newRLTeams() + roundLineups := func() []domain.TeamLineup { + return []domain.TeamLineup{rlLineup(teams.tora, "", 1, "tora-round1"), rlLineup(teams.usagi, "", 1, "usagi-round1")} + } + inStatus := func(format string, status state.CompetitionStatus) func() *state.Competition { + return func() *state.Competition { + c := rlTeamComp(format) + c.Status = status + return c + } + } + four := rlPoolOfFour(teams) + for _, tc := range []struct { + name string + comp func() *state.Competition + pool []state.MatchResult + bracket *state.Bracket + retired bool + }{ + {"a knockout with a side still to be decided", inStatus(state.CompFormatKnockout, state.CompStatusKnockout), nil, rlKnockout(teams), false}, + {"a mixed competition whose bracket is not drawn yet", inStatus(state.CompFormatMixed, state.CompStatusPools), four, nil, false}, + {"a league whose every match has both sides", inStatus(state.CompFormatLeague, state.CompStatusPools), four, nil, false}, + {"a competition drawn and not started", inStatus(state.CompFormatLeague, state.CompStatusDrawReady), four, nil, false}, + {"a completed knockout, whatever is still undecided", inStatus(state.CompFormatKnockout, state.CompStatusComplete), nil, rlKnockout(teams), true}, + {"a completed league", inStatus(state.CompFormatLeague, state.CompStatusComplete), four, nil, true}, + } { + t.Run(tc.name, func(t *testing.T) { + dir := rlSeed(t, tc.comp(), teams, tc.pool, tc.bracket, roundLineups()...) + + store, err := state.NewStore(dir) + require.NoError(t, err) + lineups := rlLoad(t, store) + + assert.Equal(t, tc.retired, rlMarker(t, store)) + for _, team := range []string{teams.tora, teams.usagi} { + _, held := rlFind(lineups, team, "", 1) + assert.Equal(t, !tc.retired, held, "a round lineup is held exactly until the competition is completed") + } + if tc.retired { + assert.Empty(t, rlGiven(t, store)) + } + }) + } +} + +// TestRoundLineups_ALaterSwissRoundIsGivenWhatV211Showed: every Swiss +// match was read at round 0, and the matches of a later round are not in the draw +// until it is generated, so what v2.1.1 showed there is only known while the +// round lineups are kept: a match entered for an earlier round was never carried. +func TestRoundLineups_ALaterSwissRoundIsGivenWhatV211Showed(t *testing.T) { + teams := newRLTeams() + comp := rlTeamComp(state.CompFormatSwiss) + comp.SwissRounds, comp.SwissCurrentRound = 3, 1 + round := func(n int, pairs ...[2]string) []state.MatchResult { + var out []state.MatchResult + for i, p := range pairs { + out = append(out, rlBout(teams, fmt.Sprintf("Swiss-R%d-%d", n, i), 0, p[0], p[1])) + } + return out + } + one := round(1, [2]string{teams.tora, teams.usagi}, [2]string{teams.kuma, teams.saru}) + legacy := []domain.TeamLineup{ + rlLineup(teams.tora, "", 0, "tora-start"), + rlLineup(teams.tora, "", 1, "tora-round1"), + rlLineup(teams.tora, "Swiss-R1-0", 0, "tora-entered-for-round-1"), + } + dir := rlSeed(t, comp, teams, one, nil, legacy...) + store, err := state.NewStore(dir) + require.NoError(t, err) + require.False(t, rlMarker(t, store), "precondition: the Swiss competition is on") + rlHas(t, rlLoad(t, store), teams.tora, "", 1, "tora-round1") + + generate := func(n int, matches []state.MatchResult) { + t.Helper() + stored, err := store.LoadCompetition(rlComp) + require.NoError(t, err) + stored.SwissCurrentRound = n + require.NoError(t, store.SaveCompetition(stored)) + prior, err := store.LoadPoolMatches(rlComp) + require.NoError(t, err) + require.NoError(t, store.SavePoolMatches(rlComp, append(prior, matches...))) + } + + // Every Swiss match was read at round 0, and the lineup entered for round 1's + // match was never carried to a later round: v2.1.1 showed the starting lineup + // at each of the rounds generated later. + generate(2, round(2, [2]string{teams.tora, teams.kuma}, [2]string{teams.usagi, teams.saru})) + lineups := rlLoad(t, store) + rlHas(t, lineups, teams.tora, "Swiss-R2-0", 0, "tora-start") + rlHas(t, lineups, teams.tora, "Swiss-R1-0", 0, "tora-entered-for-round-1") + assert.False(t, rlMarker(t, store)) + + generate(3, round(3, [2]string{teams.tora, teams.saru}, [2]string{teams.usagi, teams.kuma})) + lineups = rlLoad(t, store) + rlHas(t, lineups, teams.tora, "Swiss-R3-0", 0, "tora-start") + rlHas(t, lineups, teams.tora, "", 1, "tora-round1") + assert.False(t, rlMarker(t, store), "the last round is generated and every side is decided: the competition is not completed") + + rlComplete(t, store) + restarted, err := state.NewStore(dir) + require.NoError(t, err) + rlLacks(t, rlLoad(t, restarted), teams.tora, "", 1) + assert.True(t, rlMarker(t, restarted)) +} + +// TestRoundLineups_SettlingAgainChangesNothing: a write that seats nobody new +// finds every match already holding its lineup, so it neither writes +// lineups.yaml nor sets the marker. +func TestRoundLineups_SettlingAgainChangesNothing(t *testing.T) { + teams := newRLTeams() + four := []string{teams.tora, teams.usagi, teams.kuma, teams.saru} + bracket := &state.Bracket{Rounds: [][]state.BracketMatch{ + {{ID: "r0-m0", SideA: "Pool A-1st", SideB: "Pool B-1st"}}, + {{ID: "r1-m0", SideA: "Winner of r0-m0", SideB: "Pool B-2nd"}}, + }} + dir := rlSeed(t, rlTeamComp(state.CompFormatMixed), teams, rlPoolOfFour(teams), bracket, rlEveryRound(teams, 2, four...)...) + store, err := state.NewStore(dir) + require.NoError(t, err) + settled, settledConfig := rlRaw(t, dir, "lineups.yaml"), rlRaw(t, dir, "config.md") + require.Contains(t, settled, "matchId: Pool A-3", "precondition: the load repair gave every match its lineup") + require.Contains(t, settledConfig, "round_lineups_given", "and recorded the pairs") + + rlTouchPool(t, store, "Pool A-3") + restarted, err := state.NewStore(dir) + require.NoError(t, err) + rlTouchPool(t, restarted, "Pool A-3") + + assert.Equal(t, settled, rlRaw(t, dir, "lineups.yaml"), "a write that seats nobody new, and a reload, give nothing") + assert.Equal(t, settledConfig, rlRaw(t, dir, "config.md"), "and record nothing") + assert.False(t, rlMarker(t, store)) +} + +// TestRoundLineups_AnIdlessRosterKeepsItsNameKeyedLineupsWaitingForIds: a roster +// recorded without ids (the id repair leaves a three-column, non-zekken roster +// alone when no pools file corroborates it) cannot be told apart from "no team has +// that name" until the operator applies the participant list and ids are minted. +// The lineups v2.0.0 saved under a team's name wait for that, with the marker +// unset, instead of being left name-keyed for good. +func TestRoundLineups_AnIdlessRosterKeepsItsNameKeyedLineupsWaitingForIds(t *testing.T) { + dir := t.TempDir() + seed, err := state.NewStore(dir) + require.NoError(t, err) + require.NoError(t, seed.SaveCompetition(&state.Competition{ + ID: rlComp, Name: "Round Lineups", Kind: "team", TeamSize: 3, Format: state.CompFormatKnockout, Status: state.CompStatusKnockout, + })) + roster := "Tora, Tora Dojo, 1dan\nUsagi, Usagi Dojo, 1dan\nKuma, Kuma Dojo, 1dan\nSaru, Saru Dojo, 1dan\n" + require.NoError(t, os.WriteFile(filepath.Join(dir, "competitions", rlComp, "participants.csv"), []byte(roster), 0o600)) + require.NoError(t, seed.SaveBracket(rlComp, &state.Bracket{Rounds: [][]state.BracketMatch{ + {{ID: "r0-m0", SideA: "Tora", SideB: "Kuma"}, {ID: "r0-m1", SideA: "Usagi", SideB: "Saru"}}, + {{ID: "r1-m0", SideA: "Winner of r0-m0", SideB: "Winner of r0-m1"}}, + }})) + stored, err := seed.LoadCompetition(rlComp) + require.NoError(t, err) + stored.RoundLineupsConverted = false + require.NoError(t, seed.SaveCompetition(stored)) + rlWriteLineups(t, dir, rlComp, + rlLineup("Tora", "", 0, "tora-start"), + rlLineup("Tora", "", 1, "tora-round1"), + ) + + store, err := state.NewStore(dir) + require.NoError(t, err) + players, err := store.LoadParticipants(rlComp, false) + require.NoError(t, err) + require.Len(t, players, 4) + require.Empty(t, players[0].ID, "precondition: the roster has no ids yet") + + t.Run("the load repair leaves them keyed by name and the marker unset", func(t *testing.T) { + lineups := rlLoad(t, store) + rlHas(t, lineups, "Tora", "", 0, "tora-start") + rlHas(t, lineups, "Tora", "", 1, "tora-round1") + assert.False(t, rlMarker(t, store), "a team named Tora has no id yet, so its lineups may still be re-keyed") + }) + + require.NoError(t, store.SaveParticipants(rlComp, players)) // the operator applies the participant list: ids are minted + players, err = store.LoadParticipants(rlComp, false) + require.NoError(t, err) + toraID := players[0].ID + require.NotEmpty(t, toraID) + + t.Run("the next write of the draw keys them by id", func(t *testing.T) { + require.NoError(t, store.UpdateBracket(rlComp, func(b *state.Bracket) error { + b.Rounds[0][1].Status = state.MatchStatusRunning + return nil + })) + + lineups := rlLoad(t, store) + rlHas(t, lineups, toraID, "", 0, "tora-start") + rlHas(t, lineups, toraID, "", 1, "tora-round1") + rlLacks(t, lineups, "Tora", "", 0) + rlLacks(t, lineups, "Tora", "", 1) + assert.False(t, rlMarker(t, store), "the draw is not decided, and the team is now one whose round lineup waits") + }) + + t.Run("and a restart gives its first match the lineup v2.1.1 showed there", func(t *testing.T) { + restarted, err := state.NewStore(dir) + require.NoError(t, err) + + lineups, err := restarted.LoadTeamLineups(rlComp) + require.NoError(t, err) + rlHas(t, lineups, toraID, "r0-m0", 0, "tora-start") + rlHas(t, lineups, toraID, "", 1, "tora-round1") + }) +} + +// TestRoundLineups_ASetupRostersNameKeyedLineupsAreKeyedByTheIdsItsWriteMints: a +// team competition recorded by v2.0.0 and still in Setup has a roster with no +// ids and lineups saved under the teams' names (the Lineups page addressed a team +// with no id by its name). The roster write that mints the ids also prunes the +// lineups of teams no longer on the roster, which it read as every name-keyed +// lineup there was: it deleted them all. They are keyed by the ids the write +// mints instead, through whichever door the write came, and a lineup under a name +// no team has is still an orphan. +func TestRoundLineups_ASetupRostersNameKeyedLineupsAreKeyedByTheIdsItsWriteMints(t *testing.T) { + seed := func(t *testing.T) (*state.Store, []domain.Player) { + t.Helper() + dir := t.TempDir() + store, err := state.NewStore(dir) + require.NoError(t, err) + require.NoError(t, store.SaveCompetition(&state.Competition{ + ID: rlComp, Name: "Round Lineups", Kind: "team", TeamSize: 3, Format: state.CompFormatKnockout, Status: state.CompStatusSetup, + })) + roster := "Tora, Tora Dojo, 1dan\nUsagi, Usagi Dojo, 1dan\nKuma, Kuma Dojo, 1dan\n" + require.NoError(t, os.WriteFile(filepath.Join(dir, "competitions", rlComp, "participants.csv"), []byte(roster), 0o600)) + rlWriteLineups(t, dir, rlComp, + rlLineup("Tora", "", 0, "tora-start"), + rlLineup("Tora", "", 1, "tora-round1"), + rlLineup("Usagi", "", 0, "usagi-start"), + rlLineup("Nobody", "", 0, "nobody-start"), + ) + store, err = state.NewStore(dir) + require.NoError(t, err) + players, err := store.LoadParticipants(rlComp, false) + require.NoError(t, err) + require.Len(t, players, 3) + require.Empty(t, players[0].ID, "precondition: the roster has no ids yet") + return store, players + } + idOf := func(t *testing.T, store *state.Store, name string) string { + t.Helper() + players, err := store.LoadParticipants(rlComp, false) + require.NoError(t, err) + for _, p := range players { + if p.Name == name { + require.NotEmpty(t, p.ID) + return p.ID + } + } + require.Failf(t, "no such team", "%s", name) + return "" + } + keyedByIDs := func(t *testing.T, store *state.Store) { + t.Helper() + lineups := rlLoad(t, store) + rlHas(t, lineups, idOf(t, store, "Tora"), "", 0, "tora-start") + rlHas(t, lineups, idOf(t, store, "Tora"), "", 1, "tora-round1") + rlHas(t, lineups, idOf(t, store, "Usagi"), "", 0, "usagi-start") + rlLacks(t, lineups, "Tora", "", 0) + rlLacks(t, lineups, "Tora", "", 1) + rlLacks(t, lineups, "Usagi", "", 0) + rlLacks(t, lineups, "Nobody", "", 0) + assert.Len(t, lineups, 3, "a lineup under a name no team has is an orphan, as it was") + } + + t.Run("a roster saved", func(t *testing.T) { + store, players := seed(t) + + require.NoError(t, store.SaveParticipants(rlComp, players)) + + keyedByIDs(t, store) + }) + + t.Run("a participant added", func(t *testing.T) { + store, _ := seed(t) + + _, err := store.AddParticipant(rlComp, domain.Player{Name: "Saru", Dojo: "Saru Dojo"}, false) + require.NoError(t, err) + + keyedByIDs(t, store) + }) + + t.Run("a roster restored", func(t *testing.T) { + store, players := seed(t) + + require.NoError(t, store.SaveParticipantsRestored(rlComp, players)) + + keyedByIDs(t, store) + }) +} + +// TestRoundLineups_AMatchSeatedAfterLoadReadsWhatV211ShowedAtRoundZero: a team +// with lineups for rounds 1 and 2 and none for round 0 was shown the highest of +// them by v2.1.1 at every match read at round 0 (nothing is at or below it). A +// mixed competition's knockout is read at round 0 for its first round, which a +// team is seated in only once the pools resolve, after the load: the starting +// lineup the load seeded is what that read finds, so it must be the highest. +func TestRoundLineups_AMatchSeatedAfterLoadReadsWhatV211ShowedAtRoundZero(t *testing.T) { + teams := newRLTeams() + bracket := &state.Bracket{Rounds: [][]state.BracketMatch{ + {{ID: "r0-m0", SideA: "Pool A-1st", SideB: "Pool B-1st"}}, + {{ID: "r1-m0", SideA: "Winner of r0-m0", SideB: "Pool B-2nd"}}, + }} + dir := rlSeed(t, rlTeamComp(state.CompFormatMixed), teams, rlPoolOfFour(teams), bracket, + rlLineup(teams.tora, "", 1, "tora-round1"), + rlLineup(teams.tora, "", 2, "tora-round2"), + ) + store, err := state.NewStore(dir) + require.NoError(t, err) + + lineups := rlLoad(t, store) + rlHas(t, lineups, teams.tora, "Pool A-3", 0, "tora-round2") // read at round 0: nothing at or below it + rlHas(t, lineups, teams.tora, "Pool A-0", 0, "tora-round1") // round 1 + rlHas(t, lineups, teams.tora, "Pool A-4", 0, "tora-round2") // round 2 + rlHas(t, lineups, teams.tora, "", 0, "tora-round2") // the starting lineup: what v2.1.1 showed before any round + + // The pools resolve: Tora is seated in the knockout's first round (read at + // round 0), then in its second (read at round 1). + require.NoError(t, store.UpdateBracket(rlComp, func(b *state.Bracket) error { + b.Rounds[0][0].SideA, b.Rounds[0][0].SideAID = "Tora", teams.tora + return nil + })) + rlHas(t, rlLoad(t, store), teams.tora, "r0-m0", 0, "tora-round2") + require.NoError(t, store.UpdateBracket(rlComp, func(b *state.Bracket) error { + b.Rounds[1][0].SideA, b.Rounds[1][0].SideAID = "Tora", teams.tora + return nil + })) + rlHas(t, rlLoad(t, store), teams.tora, "r1-m0", 0, "tora-round1") +} + +// TestRoundLineups_AnOperatorsRemovalOfAMatchLineupStands: "Use the previous +// match's lineup" removes a match's own lineup so the match carries the one +// before it. The pair is recorded as settled, so no later pass gives the lineup +// back: not the next write of the draw, whether it goes straight to disk or +// through a transaction, and not a reload. +func TestRoundLineups_AnOperatorsRemovalOfAMatchLineupStands(t *testing.T) { + removed := func(t *testing.T) (store *state.Store, teams rlTeams, dir string) { + teams = newRLTeams() + dir = rlSeed(t, rlTeamComp(state.CompFormatKnockout), teams, nil, rlKnockout(teams), rlLineup(teams.tora, "", 1, "tora-round1")) + store, err := state.NewStore(dir) + require.NoError(t, err) + rlHas(t, rlLoad(t, store), teams.tora, "r0-m0", 0, "tora-round1") + require.NoError(t, store.DeleteTeamLineupForMatch(rlComp, teams.tora, "r0-m0")) + return store, teams, dir + } + stands := func(t *testing.T, store *state.Store, teams rlTeams) { + t.Helper() + lineups := rlLoad(t, store) + rlLacks(t, lineups, teams.tora, "r0-m0", 0) + rlHas(t, lineups, teams.tora, "r1-m0", 0, "tora-round1") + rlHas(t, lineups, teams.tora, "", 1, "tora-round1") + } + + t.Run("a later write straight to disk", func(t *testing.T) { + store, teams, _ := removed(t) + + require.NoError(t, store.UpdateBracket(rlComp, rlSeatUsagi(teams))) + + stands(t, store, teams) + rlHas(t, rlLoad(t, store), teams.tora, "r1-m0", 0, "tora-round1") + }) + + t.Run("a later write in a transaction", func(t *testing.T) { + store, teams, _ := removed(t) + + require.NoError(t, store.WithTransaction(rlComp, func(tx state.StoreTx) error { + return tx.UpdateBracket(rlComp, rlSeatUsagi(teams)) + })) + + stands(t, store, teams) + }) + + t.Run("a reload", func(t *testing.T) { + _, teams, dir := removed(t) + + reloaded, err := state.NewStore(dir) + require.NoError(t, err) + + stands(t, reloaded, teams) + assert.Contains(t, rlGiven(t, reloaded)[teams.tora], "r0-m0", "the record survives the restart") + }) +} + +// TestRoundLineups_ARemovalOfALineupV211HadEnteredStandsToo: a match that already +// had a lineup of its own when the pass reached it is settled as well, so +// removing that lineup afterwards stands like any other. +func TestRoundLineups_ARemovalOfALineupV211HadEnteredStandsToo(t *testing.T) { + teams := newRLTeams() + dir := rlSeed(t, rlTeamComp(state.CompFormatKnockout), teams, nil, rlKnockout(teams), + rlLineup(teams.tora, "", 1, "tora-round1"), + rlLineup(teams.tora, "r1-m0", 0, "tora-own"), + ) + store, err := state.NewStore(dir) + require.NoError(t, err) + rlHas(t, rlLoad(t, store), teams.tora, "r1-m0", 0, "tora-own") + + require.NoError(t, store.DeleteTeamLineupForMatch(rlComp, teams.tora, "r1-m0")) + require.NoError(t, store.UpdateBracket(rlComp, rlSeatUsagi(teams))) + + rlLacks(t, rlLoad(t, store), teams.tora, "r1-m0", 0) +} + +// TestRoundLineups_TheGivenPairsAreClearedWithTheMarker: the record of the pairs +// settled is only needed while the round lineups are; the pass that sets the +// marker takes it out of config.md. +func TestRoundLineups_TheGivenPairsAreClearedWithTheMarker(t *testing.T) { + teams := newRLTeams() + dir := rlSeed(t, rlTeamComp(state.CompFormatKnockout), teams, nil, rlKnockout(teams), rlLineup(teams.tora, "", 1, "tora-round1")) + store, err := state.NewStore(dir) + require.NoError(t, err) + require.Equal(t, map[string][]string{teams.tora: {"r0-m0", "r1-m0"}}, rlGiven(t, store)) + require.Contains(t, rlRaw(t, dir, "config.md"), "round_lineups_given") + + rlComplete(t, store) + require.NoError(t, store.UpdateBracket(rlComp, rlSeatUsagi(teams))) + + assert.True(t, rlMarker(t, store)) + assert.Empty(t, rlGiven(t, store)) + assert.NotContains(t, rlRaw(t, dir, "config.md"), "round_lineups_given") + assert.Contains(t, rlRaw(t, dir, "config.md"), "round_lineups_converted: true") +} + +// TestRoundLineups_TheGivenRecordIsReadableInConfigMd: config.md is a file people +// read and edit, so the record of the pairs settled is each team's participant id +// with the ids of the matches settled for it, sorted, as plain YAML: no key with a +// NUL byte in it, no "m:" prefix. The teams come out in the order yaml.v3 sorts a +// map's keys in, so the file reads the same on every save, and a fresh Store +// reads it back as it was. +func TestRoundLineups_TheGivenRecordIsReadableInConfigMd(t *testing.T) { + teams := newRLTeams() + // yaml.v3 orders a map's keys by reading each run of digits as a number, which + // is not the order slices.Sorted gives for two random ids that share a first + // digit (6ce3... before 605110...), so ids drawn at random failed this test one + // run in ten. Fixed ids whose two orders agree make the expected text exact. + teams.tora, teams.usagi = "11111111-1111-4111-8111-111111111111", "22222222-2222-4222-8222-222222222222" + dir := rlSeed(t, rlTeamComp(state.CompFormatKnockout), teams, nil, rlKnockout(teams), + rlLineup(teams.tora, "", 1, "tora-round1"), + rlLineup(teams.usagi, "", 1, "usagi-round1"), + ) + store, err := state.NewStore(dir) + require.NoError(t, err) + + given := map[string][]string{teams.tora: {"r0-m0", "r1-m0"}, teams.usagi: {"r0-m1"}} + teamIDs := slices.Sorted(maps.Keys(given)) + want := "round_lineups_given:\n" + for _, id := range teamIDs { + want += " " + id + ":\n" + for _, matchID := range given[id] { + want += " - " + matchID + "\n" + } + } + config := rlRaw(t, dir, "config.md") + assert.Contains(t, config, want) + assert.Contains(t, config, "round_lineups_legacy:\n - "+teams.tora+"\n - "+teams.usagi+"\n", + "the teams found legacy are a plain list of ids, sorted") + assert.NotContains(t, config, "\x00", "no NUL byte") + assert.NotContains(t, config, `\0`, "and no escaped one") + assert.NotContains(t, config, "m:"+teams.tora, "no lineup key") + assert.Equal(t, given, rlGiven(t, store)) + + reloaded, err := state.NewStore(dir) + require.NoError(t, err) + assert.Equal(t, given, rlGiven(t, reloaded), "a fresh Store reads the record back as it was") + assert.Equal(t, config, rlRaw(t, dir, "config.md"), "and settling over it writes nothing") +} + +// TestRoundLineups_AHandWrittenRecordIsHonoured: a record written by hand in the +// shape config.md shows (the indentation is the writer's own) is read like one the +// server wrote: a pair it lists is left alone, and the pass that settles the rest +// writes the whole record back, sorted. +func TestRoundLineups_AHandWrittenRecordIsHonoured(t *testing.T) { + teams := newRLTeams() + dir := rlSeed(t, rlTeamComp(state.CompFormatKnockout), teams, nil, rlKnockout(teams), rlLineup(teams.tora, "", 1, "tora-round1")) + cfg := filepath.Join(dir, "competitions", rlComp, "config.md") + raw, err := os.ReadFile(cfg) // #nosec G304 -- a path under t.TempDir + require.NoError(t, err) + handWritten := strings.TrimSuffix(string(raw), "---\n") + "round_lineups_given:\n " + teams.tora + ":\n - r0-m0\n---\n" + require.NoError(t, os.WriteFile(cfg, []byte(handWritten), 0o600)) + + store, err := state.NewStore(dir) + require.NoError(t, err) + + lineups := rlLoad(t, store) + rlLacks(t, lineups, teams.tora, "r0-m0", 0) // listed: left alone, as an operator's removal would be + rlHas(t, lineups, teams.tora, "r1-m0", 0, "tora-round1") + assert.Equal(t, map[string][]string{teams.tora: {"r0-m0", "r1-m0"}}, rlGiven(t, store)) + assert.Contains(t, rlRaw(t, dir, "config.md"), "round_lineups_given:\n "+teams.tora+":\n - r0-m0\n - r1-m0\n") +} + +// TestRoundLineups_ACorrectionBeforeCompletionSeatsADifferentTeamWithItsReading: +// a result recorded the wrong way round is corrected before the competition is +// over, which seats another team in the final. That team has the lineup v2.1.1 +// showed at the final, though every side was decided before the correction. +func TestRoundLineups_ACorrectionBeforeCompletionSeatsADifferentTeamWithItsReading(t *testing.T) { + teams := newRLTeams() + dir := rlSeed(t, rlTeamComp(state.CompFormatKnockout), teams, nil, rlKnockout(teams), + rlLineup(teams.tora, "", 1, "tora-round1"), + rlLineup(teams.usagi, "", 1, "usagi-round1"), + rlLineup(teams.saru, "", 0, "saru-start"), + rlLineup(teams.saru, "", 1, "saru-round1"), + ) + store, err := state.NewStore(dir) + require.NoError(t, err) + + require.NoError(t, store.UpdateBracket(rlComp, rlSeatUsagi(teams))) // every side of every match is decided + lineups := rlLoad(t, store) + rlHas(t, lineups, teams.usagi, "r1-m0", 0, "usagi-round1") + rlLacks(t, lineups, teams.saru, "r1-m0", 0) + + require.NoError(t, store.UpdateBracket(rlComp, func(b *state.Bracket) error { // Saru won r0-m1 after all + b.Rounds[0][1].Winner, b.Rounds[0][1].WinnerID = "Saru", teams.saru + b.Rounds[1][0].SideB, b.Rounds[1][0].SideBID = "Saru", teams.saru + return nil + })) + + lineups = rlLoad(t, store) + rlHas(t, lineups, teams.saru, "r1-m0", 0, "saru-round1") + rlHas(t, lineups, teams.saru, "r0-m1", 0, "saru-start") + assert.False(t, rlMarker(t, store)) +} + +// TestRoundLineups_ACompletionTheDrawHooksDoNotSeeIsSettledByTheNextLoad: a +// competition completes in a config.md write that none of the draw's write hooks +// sees. Its round lineups wait for the next load, which retires them and sets the +// marker. +func TestRoundLineups_ACompletionTheDrawHooksDoNotSeeIsSettledByTheNextLoad(t *testing.T) { + teams := newRLTeams() + four := []string{teams.tora, teams.usagi, teams.kuma, teams.saru} + bracket := &state.Bracket{Rounds: [][]state.BracketMatch{ + {{ID: "r0-m0", SideA: "Pool A-1st", SideB: "Pool B-1st"}}, + {{ID: "r1-m0", SideA: "Winner of r0-m0", SideB: "Pool B-2nd"}}, + }} + dir := rlSeed(t, rlTeamComp(state.CompFormatMixed), teams, rlPoolOfFour(teams), bracket, rlEveryRound(teams, 2, four...)...) + store, err := state.NewStore(dir) + require.NoError(t, err) + + rlComplete(t, store) + + assert.False(t, rlMarker(t, store), "nothing wrote the draw, so nothing settled") + rlHas(t, rlLoad(t, store), teams.tora, "", 1, "Tora-round1") + reloaded, err := state.NewStore(dir) + require.NoError(t, err) + lineups := rlLoad(t, reloaded) + for _, team := range four { + rlLacks(t, lineups, team, "", 1) + rlLacks(t, lineups, team, "", 2) + } + assert.True(t, rlMarker(t, reloaded)) + assert.Empty(t, rlGiven(t, reloaded)) +} + +// TestRoundLineups_TheLoadStepsAfterTheSettlementDoNotWriteItAway: the load +// repair saves the competition's record from a copy it keeps for its later +// steps, so the copy has to carry what the settlement saved, or the kachinuki +// encounter-encho repair that follows (which saves the record from it) writes the +// pairs settled, or the marker, away. A kachinuki competition has that repair +// pending until it has run. +func TestRoundLineups_TheLoadStepsAfterTheSettlementDoNotWriteItAway(t *testing.T) { + for _, tc := range []struct { + name string + completed bool + }{ + {"the pairs settled of a competition that is on", false}, + {"the marker of a completed competition", true}, + } { + t.Run(tc.name, func(t *testing.T) { + teams := newRLTeams() + comp := rlTeamComp(state.CompFormatKnockout) + comp.TeamMatchType = state.TeamMatchTypeKachinuki + if tc.completed { + rlCompleted(comp) + } + dir := rlSeed(t, comp, teams, nil, rlKnockout(teams), rlLineup(teams.tora, "", 1, "tora-round1")) + cfg := filepath.Join(dir, "competitions", rlComp, "config.md") + raw, err := os.ReadFile(cfg) // #nosec G304 -- a path under t.TempDir + require.NoError(t, err) + // Whatever the seeding wrote, the repair is still pending: the record + // is as a release that never ran it left it. + require.NoError(t, os.WriteFile(cfg, []byte(strings.Replace(string(raw), "kachinuki_encounter_encho_cleared: true\n", "", 1)), 0o600)) + require.NotContains(t, rlRaw(t, dir, "config.md"), "kachinuki_encounter_encho_cleared") + + store, err := state.NewStore(dir) + require.NoError(t, err) + + config := rlRaw(t, dir, "config.md") + assert.Contains(t, config, "kachinuki_encounter_encho_cleared: true", "precondition: the later step ran and saved the record") + if tc.completed { + assert.Contains(t, config, "round_lineups_converted: true", "the marker the settlement set is still there") + assert.NotContains(t, config, "round_lineups_given") + assert.NotContains(t, config, "round_lineups_legacy") + assert.True(t, rlMarker(t, store)) + return + } + assert.Contains(t, config, "round_lineups_given", "the pairs the settlement recorded are still there") + assert.Equal(t, map[string][]string{teams.tora: {"r0-m0", "r1-m0"}}, rlGiven(t, store)) + assert.Equal(t, []string{teams.tora}, rlLegacy(t, store), "and so are the legacy teams it found") + assert.False(t, rlMarker(t, store)) + }) + } +} diff --git a/internal/state/squad.go b/internal/state/squad.go index a93f499c8..8be40ed7f 100644 --- a/internal/state/squad.go +++ b/internal/state/squad.go @@ -185,6 +185,18 @@ func copySquads(in map[string][]domain.TeamMember) map[string][]domain.TeamMembe return out } +// stampMember sets m's ModifiedAt for a write made now: the server's time in +// milliseconds, or one more than m's previous stamp when that is not earlier, so +// the stamps of one member only grow, whatever the clock does between its writes +// (a server whose clock is set back still orders them). Every writer that +// creates a member or changes its name calls it under the competition's lock +// (addTeamMember, renameTeamMemberTx, clearTeamMemberNameTx); the writers that +// only seed or migrate members leave the stamp at 0, as a member no write has +// touched carries. +func (s *Store) stampMember(m *domain.TeamMember) { + m.ModifiedAt = max(s.now().UnixMilli(), m.ModifiedAt+1) +} + // LoadSquads returns every team's squad persisted for compID, keyed by the // team's participant id. A missing file is treated as "no squads recorded // yet" and returns an empty map (consistent with LoadTeamLineups). @@ -540,6 +552,7 @@ func (s *Store) addTeamMember(compID, teamID, name string, limit int, participan } } member := domain.TeamMember{ID: newParticipantID(), Index: nextIndex, Name: name} + s.stampMember(&member) squads[teamID] = append(existing, member) if err := s.saveSquadsLocked(compID, squads, s.directWrite); err != nil { @@ -560,7 +573,11 @@ func (s *Store) addTeamMember(compID, teamID, name string, limit int, participan // sitting beside one already stamped. It also returned the SECOND write's error // as the whole call's, so the operator was told a rename that HAD landed had // failed, and retyping the old name became a second real rename. -func (s *Store) RenameTeamMember(compID, teamID, memberID, newName string) error { +// +// It answers with the member as written, stamped (stampMember): the copy this +// write produced, which a client keeps in place of any it held with a smaller +// stamp. +func (s *Store) RenameTeamMember(compID, teamID, memberID, newName string) (domain.TeamMember, error) { return s.nameTeamMember(compID, teamID, memberID, newName, false) } @@ -570,29 +587,37 @@ func (s *Store) RenameTeamMember(compID, teamID, memberID, newName string) error // Both checks read under the same hold of the competition lock as the write, // the lock a competition's completion also takes, so two callers naming one // blank member cannot both pass (the second finds the first's name), and -// neither can a caller racing the finish. -func (s *Store) NameUnnamedTeamMember(compID, teamID, memberID, newName string) error { +// neither can a caller racing the finish. Like RenameTeamMember it answers with +// the member as written. +func (s *Store) NameUnnamedTeamMember(compID, teamID, memberID, newName string) (domain.TeamMember, error) { return s.nameTeamMember(compID, teamID, memberID, newName, true) } -func (s *Store) nameTeamMember(compID, teamID, memberID, newName string, onlyUnnamed bool) error { +func (s *Store) nameTeamMember(compID, teamID, memberID, newName string, onlyUnnamed bool) (domain.TeamMember, error) { if err := ValidateCompetitionID(compID); err != nil { - return err + return domain.TeamMember{}, err } newName = strings.TrimSpace(newName) - return s.WithTransaction(compID, func(tx StoreTx) error { - return s.renameTeamMemberTx(tx, compID, teamID, memberID, newName, onlyUnnamed) + var member domain.TeamMember + err := s.WithTransaction(compID, func(tx StoreTx) error { + var err error + member, err = s.renameTeamMemberTx(tx, compID, teamID, memberID, newName, onlyUnnamed) + return err }) + if err != nil { + return domain.TeamMember{}, err + } + return member, nil } // renameTeamMemberTx is the body of both renames, staged through the // transaction's writer so the squad file and the lineups file land together -// or not at all. -func (s *Store) renameTeamMemberTx(tx StoreTx, compID, teamID, memberID, newName string, onlyUnnamed bool) error { +// or not at all. It answers with the member it stamped. +func (s *Store) renameTeamMemberTx(tx StoreTx, compID, teamID, memberID, newName string, onlyUnnamed bool) (domain.TeamMember, error) { write := tx.(*storeTx).txWriteFn() squads, err := s.loadSquadsLocked(compID) if err != nil { - return err + return domain.TeamMember{}, err } existing := squads[teamID] @@ -606,26 +631,30 @@ func (s *Store) renameTeamMemberTx(tx StoreTx, compID, teamID, memberID, newName otherNames = append(otherNames, m.Name) } if target == -1 { - return ErrTeamMemberNotFound + return domain.TeamMember{}, ErrTeamMemberNotFound } if onlyUnnamed { if strings.TrimSpace(existing[target].Name) != "" { - return ErrTeamMemberNamed + return domain.TeamMember{}, ErrTeamMemberNamed } if err := s.requireCompetitionOpenLocked(compID); err != nil { - return err + return domain.TeamMember{}, err } } if err := squadDuplicateNameCheck(newName, otherNames); err != nil { - return err + return domain.TeamMember{}, err } existing[target].Name = newName + s.stampMember(&existing[target]) squads[teamID] = existing if err := s.saveSquadsLocked(compID, squads, write); err != nil { - return err + return domain.TeamMember{}, err } - return s.renameMemberInLineupsLocked(compID, memberID, newName, write) + if err := s.renameMemberInLineupsLocked(compID, memberID, newName, write); err != nil { + return domain.TeamMember{}, err + } + return existing[target], nil } // renameMemberInLineupsLocked carries a member's new name into every stored @@ -692,28 +721,37 @@ func (s *Store) renameMemberInLineupsLocked(compID, memberID, name string, write // // Like RenameTeamMember, both writes ride ONE WAL transaction; see that // function's comment for why. -func (s *Store) ClearTeamMemberName(compID, teamID, memberID string) error { +// +// It answers with the member as written, stamped (stampMember), like the renames. +func (s *Store) ClearTeamMemberName(compID, teamID, memberID string) (domain.TeamMember, error) { if err := ValidateCompetitionID(compID); err != nil { - return err + return domain.TeamMember{}, err } - return s.WithTransaction(compID, func(tx StoreTx) error { - return s.clearTeamMemberNameTx(tx, compID, teamID, memberID) + var member domain.TeamMember + err := s.WithTransaction(compID, func(tx StoreTx) error { + var err error + member, err = s.clearTeamMemberNameTx(tx, compID, teamID, memberID) + return err }) + if err != nil { + return domain.TeamMember{}, err + } + return member, nil } -func (s *Store) clearTeamMemberNameTx(tx StoreTx, compID, teamID, memberID string) error { +func (s *Store) clearTeamMemberNameTx(tx StoreTx, compID, teamID, memberID string) (domain.TeamMember, error) { write := tx.(*storeTx).txWriteFn() comp, err := s.loadCompetitionLocked(compID) if err != nil { - return err + return domain.TeamMember{}, err } if comp != nil && !CanStart(comp.Status) { - return ErrTeamMemberClearAfterStart + return domain.TeamMember{}, ErrTeamMemberClearAfterStart } squads, err := s.loadSquadsLocked(compID) if err != nil { - return err + return domain.TeamMember{}, err } existing := squads[teamID] @@ -725,13 +763,17 @@ func (s *Store) clearTeamMemberNameTx(tx StoreTx, compID, teamID, memberID strin } } if target == -1 { - return ErrTeamMemberNotFound + return domain.TeamMember{}, ErrTeamMemberNotFound } existing[target].Name = "" + s.stampMember(&existing[target]) squads[teamID] = existing if err := s.saveSquadsLocked(compID, squads, write); err != nil { - return err + return domain.TeamMember{}, err + } + if err := s.renameMemberInLineupsLocked(compID, memberID, "", write); err != nil { + return domain.TeamMember{}, err } - return s.renameMemberInLineupsLocked(compID, memberID, "", write) + return existing[target], nil } diff --git a/internal/state/squad_test.go b/internal/state/squad_test.go index 31cdcda0e..b7406dfa1 100644 --- a/internal/state/squad_test.go +++ b/internal/state/squad_test.go @@ -39,6 +39,24 @@ func newSquadTestStore(t *testing.T) (store *Store, compID, teamA, teamB string) return s, id, stored[0].ID, stored[1].ID } +// renameMember, nameMember and clearMember are the three member name writers for +// a test that asks only whether the write failed: each answers with the member as +// written (carrying its stamp), which team_member_stamp_test.go reads. +func renameMember(s *Store, compID, teamID, memberID, name string) error { + _, err := s.RenameTeamMember(compID, teamID, memberID, name) + return err +} + +func nameMember(s *Store, compID, teamID, memberID, name string) error { + _, err := s.NameUnnamedTeamMember(compID, teamID, memberID, name) + return err +} + +func clearMember(s *Store, compID, teamID, memberID string) error { + _, err := s.ClearTeamMemberName(compID, teamID, memberID) + return err +} + // --- AddTeamMember / RenameTeamMember --------------------------------------- // A reserve added beyond the 5 seeded slots (TeamSize 3, floor 5) gets @@ -59,7 +77,7 @@ func TestSquad_AddMintsIDAndIndex_RenameKeepsBoth(t *testing.T) { assert.NotEqual(t, m1.ID, m2.ID) assert.Equal(t, 7, m2.Index, "a second reserve must get the next index, not a fresh 1") - require.NoError(t, s.RenameTeamMember(id, teamA, m1.ID, "Alicia")) + require.NoError(t, renameMember(s, id, teamA, m1.ID, "Alicia")) squads, err := s.LoadSquads(id) require.NoError(t, err) @@ -110,19 +128,19 @@ func TestSquad_RenameRefusesDuplicateWithinOneTeam_ButNotSelf(t *testing.T) { require.NoError(t, err) t.Run("renaming to another member's name is refused", func(t *testing.T) { - err := s.RenameTeamMember(id, teamA, alice.ID, "Bob") + err := renameMember(s, id, teamA, alice.ID, "Bob") require.Error(t, err) assert.True(t, errors.Is(err, ErrDuplicateTeamMember)) }) t.Run("renaming to another member's name in a normalized form is refused", func(t *testing.T) { - err := s.RenameTeamMember(id, teamA, alice.ID, " bob ") + err := renameMember(s, id, teamA, alice.ID, " bob ") require.Error(t, err) assert.True(t, errors.Is(err, ErrDuplicateTeamMember)) }) t.Run("renaming a member to their OWN current name is not a self-collision", func(t *testing.T) { - err := s.RenameTeamMember(id, teamA, alice.ID, "Alice") + err := renameMember(s, id, teamA, alice.ID, "Alice") require.NoError(t, err, "a rename to the member's own current name must not be refused as a collision with itself") }) } @@ -133,7 +151,7 @@ func TestSquad_RenameUnknownMemberOrTeam(t *testing.T) { s, id, teamA, _ := newSquadTestStore(t) t.Run("no squad for the team at all", func(t *testing.T) { - err := s.RenameTeamMember(id, "no-such-team", "no-such-member", "X") + err := renameMember(s, id, "no-such-team", "no-such-member", "X") require.Error(t, err) assert.True(t, errors.Is(err, ErrTeamMemberNotFound)) }) @@ -141,7 +159,7 @@ func TestSquad_RenameUnknownMemberOrTeam(t *testing.T) { t.Run("team exists but member id does not", func(t *testing.T) { _, err := s.AddTeamMember(id, teamA, "Alice") require.NoError(t, err) - err = s.RenameTeamMember(id, teamA, "no-such-member", "X") + err = renameMember(s, id, teamA, "no-such-member", "X") require.Error(t, err) assert.True(t, errors.Is(err, ErrTeamMemberNotFound)) }) @@ -156,16 +174,16 @@ func TestSquad_NameUnnamedTeamMember(t *testing.T) { blank := squads[teamA][0] require.Empty(t, blank.Name, "teamA is seeded with blank members") - require.NoError(t, s.NameUnnamedTeamMember(id, teamA, blank.ID, " Mei Ito ")) - err = s.NameUnnamedTeamMember(id, teamA, blank.ID, "Ren Abe") + require.NoError(t, nameMember(s, id, teamA, blank.ID, " Mei Ito ")) + err = nameMember(s, id, teamA, blank.ID, "Ren Abe") assert.ErrorIs(t, err, ErrTeamMemberNamed, "a member who has a name is not renamed") squads, err = s.LoadSquads(id) require.NoError(t, err) assert.Equal(t, "Mei Ito", squads[teamA][0].Name, "the first name stands, trimmed") - assert.ErrorIs(t, s.NameUnnamedTeamMember(id, teamA, "no-such-member", "X"), ErrTeamMemberNotFound) - require.NoError(t, s.RenameTeamMember(id, teamA, blank.ID, "Ren Abe"), "the organiser's rename is unrestricted") + assert.ErrorIs(t, nameMember(s, id, teamA, "no-such-member", "X"), ErrTeamMemberNotFound) + require.NoError(t, renameMember(s, id, teamA, blank.ID, "Ren Abe"), "the organiser's rename is unrestricted") } // A squad may exceed the competition's TeamSize: reserves and replacements @@ -731,7 +749,7 @@ func TestSquad_ClearBlanksNameKeepsIDAndIndex(t *testing.T) { m, err := s.AddTeamMember(id, teamA, "Alice") require.NoError(t, err) - require.NoError(t, s.ClearTeamMemberName(id, teamA, m.ID)) + require.NoError(t, clearMember(s, id, teamA, m.ID)) squads, err := s.LoadSquads(id) require.NoError(t, err) @@ -755,7 +773,7 @@ func TestSquad_ClearBeforeDrawIsAllowed(t *testing.T) { m, err := s.AddTeamMember(id, teamA, "Alice") require.NoError(t, err) - require.NoError(t, s.ClearTeamMemberName(id, teamA, m.ID), "a competition still in setup must allow clearing") + require.NoError(t, clearMember(s, id, teamA, m.ID), "a competition still in setup must allow clearing") } // Clearing is still allowed once a draw exists but the competition has not @@ -771,7 +789,7 @@ func TestSquad_ClearAtDrawReadyIsAllowed(t *testing.T) { comp.Status = CompStatusDrawReady require.NoError(t, s.SaveCompetition(comp)) - require.NoError(t, s.ClearTeamMemberName(id, teamA, m.ID), "draw-ready is still before start; clearing must be allowed") + require.NoError(t, clearMember(s, id, teamA, m.ID), "draw-ready is still before start; clearing must be allowed") } // Clearing is refused once the competition has started, with its own @@ -787,7 +805,7 @@ func TestSquad_ClearRefusedOnceStarted(t *testing.T) { require.NoError(t, s.SaveCompetition(comp)) versionBefore := s.FileVersion(id, teamMembersFilename) - err = s.ClearTeamMemberName(id, teamA, m.ID) + err = clearMember(s, id, teamA, m.ID) require.Error(t, err) assert.True(t, errors.Is(err, ErrTeamMemberClearAfterStart)) @@ -821,7 +839,7 @@ func TestSquad_RenameAndAddRemainAvailableOnceStarted(t *testing.T) { comp.Status = CompStatusPools require.NoError(t, s.SaveCompetition(comp)) - require.NoError(t, s.RenameTeamMember(id, teamA, m.ID, "Alicia"), + require.NoError(t, renameMember(s, id, teamA, m.ID, "Alicia"), "a started competition must still allow a member's name to be corrected") reserve, err := s.AddTeamMember(id, teamA, "Bob") @@ -846,13 +864,13 @@ func TestSquad_ClearUnknownMemberOrTeam(t *testing.T) { s, id, teamA, _ := newSquadTestStore(t) t.Run("no squad for the team at all", func(t *testing.T) { - err := s.ClearTeamMemberName(id, "no-such-team", "no-such-member") + err := clearMember(s, id, "no-such-team", "no-such-member") require.Error(t, err) assert.True(t, errors.Is(err, ErrTeamMemberNotFound)) }) t.Run("team exists but member id does not", func(t *testing.T) { - err := s.ClearTeamMemberName(id, teamA, "no-such-member") + err := clearMember(s, id, teamA, "no-such-member") require.Error(t, err) assert.True(t, errors.Is(err, ErrTeamMemberNotFound)) }) @@ -880,7 +898,7 @@ func TestSquad_RenameAndClearReachStoredLineups(t *testing.T) { MemberIDs: map[domain.Position]string{domain.PositionNumbered(1): sato.ID}, }, 3)) - require.NoError(t, store.RenameTeamMember(compID, teamA, sato.ID, "Sato")) + require.NoError(t, renameMember(store, compID, teamA, sato.ID, "Sato")) lineups, err := store.LoadTeamLineups(compID) require.NoError(t, err) require.Len(t, lineups, 2) @@ -891,7 +909,7 @@ func TestSquad_RenameAndClearReachStoredLineups(t *testing.T) { } } - require.NoError(t, store.ClearTeamMemberName(compID, teamA, sato.ID)) + require.NoError(t, clearMember(store, compID, teamA, sato.ID)) lineups, err = store.LoadTeamLineups(compID) require.NoError(t, err) for _, l := range lineups { @@ -920,7 +938,7 @@ func TestRenameTeamMember_LeavesTheSquadUntouchedWhenTheLineupHalfFails(t *testi lineupPath := filepath.Join(s.GetFolder(), "competitions", compID, teamLineupFilename) require.NoError(t, os.WriteFile(lineupPath, []byte("lineups: [this is not a list\n"), 0o600)) - err = s.RenameTeamMember(compID, teamA, member.ID, "Sato Kenji") + err = renameMember(s, compID, teamA, member.ID, "Sato Kenji") require.Error(t, err, "a rename that cannot reach the lineup half must report it") // THE POINT: the squad write is rolled back with it, so the operator's @@ -946,7 +964,7 @@ func TestClearTeamMemberName_LeavesTheSquadUntouchedWhenTheLineupHalfFails(t *te lineupPath := filepath.Join(s.GetFolder(), "competitions", compID, teamLineupFilename) require.NoError(t, os.WriteFile(lineupPath, []byte("lineups: [this is not a list\n"), 0o600)) - err = s.ClearTeamMemberName(compID, teamA, member.ID) + err = clearMember(s, compID, teamA, member.ID) require.Error(t, err) squads, loadErr := s.LoadSquads(compID) diff --git a/internal/state/store.go b/internal/state/store.go index f777810fd..861915c2f 100644 --- a/internal/state/store.go +++ b/internal/state/store.go @@ -9,6 +9,7 @@ import ( "strings" "sync" "sync/atomic" + "time" "github.com/gitrgoliveira/bracket-creator/internal/state/wal" ) @@ -26,6 +27,10 @@ type Store struct { // silently lose the transaction. walDir string + // clock is the store's wall clock for the stamps it writes (stampMember); + // nil means time.Now. A test sets it to stand still or to step back. + clock func() time.Time + // compMu maps competition ID -> *sync.RWMutex for fine-grained locking. compMu sync.Map @@ -336,6 +341,29 @@ func (s *Store) cachedBracket(compID string) (*Bracket, error) { return bracket, nil } +// now is the store's wall clock for the stamps it writes: time.Now, or the clock +// a test has set. +func (s *Store) now() time.Time { + if s.clock != nil { + return s.clock() + } + return time.Now() +} + +// cachedCompetition is cachedPoolMatches's competition twin: the no-copy +// accessor for config.md, pairing it with parseCompetitionFile in one place. The +// result is nil when the competition has no config.md. Callers MUST NOT mutate +// it: writers replace the cached record and never change it in place, and +// LoadCompetition copies it for a caller that needs an owned one. +func (s *Store) cachedCompetition(compID string) (*Competition, error) { + data, err := s.loadCached(compID, "config.md", parseCompetitionFile) + if err != nil { + return nil, err + } + comp, _ := data.(*Competition) + return comp, nil +} + // invalidCompDir is where a rejected competition id resolves. No legal id can // collide with it: validIDPattern requires a leading alphanumeric. const invalidCompDir = ".invalid" diff --git a/internal/state/team_lineup.go b/internal/state/team_lineup.go index 9d109818f..8f52c684d 100644 --- a/internal/state/team_lineup.go +++ b/internal/state/team_lineup.go @@ -17,8 +17,10 @@ package state import ( "fmt" + "log" "maps" "os" + "slices" "sort" "github.com/gitrgoliveira/bracket-creator/internal/domain" @@ -101,6 +103,24 @@ func (s *Store) LoadTeamLineups(compID string) (map[string]domain.TeamLineup, er return copyTeamLineups(data.(map[string]domain.TeamLineup)), nil } +// ReadTeamLineups calls read with the lineups persisted for compID, keyed as +// LoadTeamLineups keys them, taken from the cache without copying them: a caller +// that answers with one lineup copies that one instead of all of them. read must +// not modify the map or any lineup in it, nor keep either after it returns. A +// write replaces the cached map and never changes one in place, so the map read +// holds stays whole for as long as it holds it, though a write may have replaced +// it by then. A missing file is no lineups yet, an empty map. +func (s *Store) ReadTeamLineups(compID string, read func(lineups map[string]domain.TeamLineup)) error { + data, err := s.loadCached(compID, teamLineupFilename, func(path string) (any, error) { + return parseTeamLineupsFile(path) + }) + if err != nil { + return err + } + read(data.(map[string]domain.TeamLineup)) + return nil +} + // loadTeamLineupsLocked reads the lineup file directly from disk WITHOUT // acquiring the per-competition lock. Caller MUST already hold the lock // (typically via WithTransaction or setTeamLineupLocked). Bypasses the cache: @@ -190,12 +210,18 @@ func (s *Store) saveTeamLineupsLocked(compID string, lineups map[string]domain.T } // pruneOrphanedTeamLineupsLocked drops lineups.yaml entries for teams no -// longer present in keepIDs, the lineup sibling of -// pruneOrphanedTeamMembersLocked in squad.go (bc-tmfn): same trigger (a -// roster write that already landed), same Kind/TeamSize gate, same -// best-effort contract (a failure here is logged by the caller, not -// propagated). Only writes when an entry was actually dropped. -func (s *Store) pruneOrphanedTeamLineupsLocked(compID string, comp *Competition, keepIDs map[string]bool) error { +// longer on the roster that was just written, players, whose ids are already +// minted: the lineup sibling of pruneOrphanedTeamMembersLocked in squad.go +// (bc-tmfn): same trigger (a roster write that already landed), same Kind/TeamSize +// gate, same best-effort contract (a failure here is logged by the caller, not +// propagated). Only writes when an entry was actually dropped or re-keyed. +// +// A lineup saved under a team's NAME, which v2.0.0's Lineups page did for a team +// that had no id yet, belongs to the team this very write has just given an id: +// it is keyed by that id first, by the rule the round-lineup settlement keys it +// by (roundLineupSettlement.keyByTeamID), so the write that mints the ids keeps +// it. One whose name is no team's, or is several teams', is left to the prune. +func (s *Store) pruneOrphanedTeamLineupsLocked(compID string, comp *Competition, players []domain.Player) error { if comp == nil || (comp.Kind != "team" && comp.TeamSize == 0) { return nil } @@ -214,9 +240,15 @@ func (s *Store) pruneOrphanedTeamLineupsLocked(compID string, comp *Competition, if len(lineups) == 0 { return nil } - changed := false + roster := newLineupRoster(players) + keyed := roundLineupSettlement{lineups: lineups} + keyed.keyByTeamID(roster, false) + for _, note := range keyed.notes { + log.Printf("state: roster write for %s: %s", compID, note) + } + changed := keyed.changed for key, l := range lineups { - if !keepIDs[l.TeamID] { + if !roster.has(l.TeamID) { delete(lineups, key) changed = true } @@ -297,81 +329,6 @@ func (s *Store) DeleteTeamLineup(compID, teamID string, round int) error { return s.saveTeamLineupsLocked(compID, current, s.directWrite) } -// FindBestLineup returns the most relevant lineup for teamID from a pre-loaded -// lineups map, following the AMENDMENT 1 priority order: -// 1. Match-scoped entry keyed by matchID (exact match for the specific bout). -// 2. Round-scoped: the highest round <= maxRound (the current match's round, -// e.g. 0 for pool matches, bracket round index for knockout matches). -// 3. Round-scoped: the highest round overall (fallback when no saved lineup -// has round <= maxRound, e.g. operator saved a bracket-phase lineup but the -// current match is a pool match, or vice versa). -// -// Returns the lineup and true when found, the zero value and false otherwise. -// Callers should use LoadTeamLineups to obtain the map before calling this. -func FindBestLineup(lineups map[string]domain.TeamLineup, teamID, matchID string, maxRound int) (domain.TeamLineup, bool) { - return FindBestLineupAny(lineups, []string{teamID}, matchID, maxRound) -} - -// FindBestLineupAny is FindBestLineup for a set of candidate team keys. -// The lineup editor keys lineups by the team PARTICIPANT ID (player.id) -// while match sides carry the team display NAME, so callers resolving a -// lineup for a match side must try both keys ("match on id OR name"). -// The AMENDMENT 1 priority tiers apply ACROSS the whole key set: a -// match-scoped entry under any key beats a round-scoped entry under any -// key. Within a tier, ties between keys resolve to the first teamID in -// the slice that has an entry. -func FindBestLineupAny(lineups map[string]domain.TeamLineup, teamIDs []string, matchID string, maxRound int) (domain.TeamLineup, bool) { - // 1. Match-scoped (exact), first key wins. - if matchID != "" { - for _, teamID := range teamIDs { - key := teamLineupMatchKey(teamID, matchID) - if l, ok := lineups[key]; ok { - return l, true - } - } - } - // 2. Round-scoped: highest round <= maxRound. - // 3. Round-scoped: highest round overall (AMENDMENT 1 fallback). - // Map iteration order is nondeterministic, so ties on Round are broken - // by the teamID's position in teamIDs (the documented "first key wins" - // priority), keeping selection stable across calls. - // Precompute teamID -> rank once (first occurrence wins, matching - // slices.Index) so the per-entry rank lookup below is O(1). - rankByID := make(map[string]int, len(teamIDs)) - for i, id := range teamIDs { - if _, seen := rankByID[id]; !seen { - rankByID[id] = i - } - } - var best, fallback domain.TeamLineup - bestRank, fallbackRank := -1, -1 - hasBest, hasFallback := false, false - for _, l := range lineups { - if l.MatchID != "" { - continue // skip match-scoped entries - } - rank, ok := rankByID[l.TeamID] - if !ok { - continue // wrong team - } - if l.Round <= maxRound { - if !hasBest || l.Round > best.Round || (l.Round == best.Round && rank < bestRank) { - best, bestRank, hasBest = l, rank, true - } - } - if !hasFallback || l.Round > fallback.Round || (l.Round == fallback.Round && rank < fallbackRank) { - fallback, fallbackRank, hasFallback = l, rank, true - } - } - if hasBest { - return best, true - } - if hasFallback { - return fallback, true - } - return domain.TeamLineup{}, false -} - // DeleteTeamLineupForMatch removes the match-scoped lineup for // (teamID, matchID) if present (mp-825). Lineups are always deletable, // including while a match is running. @@ -396,3 +353,82 @@ func (s *Store) DeleteTeamLineupForMatch(compID, teamID, matchID string) error { delete(current, key) return s.saveTeamLineupsLocked(compID, current, s.directWrite) } + +// ClearDrawLineups removes what a draw leaves in the lineup state of compID, in +// one transaction: every match-scoped lineup (the round-scoped ones stay, a +// team's starting lineup is round 0) and the record of the (team, match) pairs +// the round-lineup conversion settled (Competition.RoundLineupsGiven). Generating +// a draw again reuses the match ids, so a lineup left behind for one would become +// a team's own lineup at the reused id and every later match of the team would +// carry it, and a pair left in the record would keep the conversion from giving +// the new draw's match its lineup. A discarded draw calls it, and so does a draw +// generated from Setup, which may be the retry of one that failed after it wrote +// its matches. +// +// Writes only what changed, and never creates the competition directory (see +// saveTeamLineupsLocked). +func (s *Store) ClearDrawLineups(compID string) error { + if err := ValidateCompetitionID(compID); err != nil { + return err + } + return s.WithTransaction(compID, func(tx StoreTx) error { + write := tx.(*storeTx).txWriteFn() + lineups, err := tx.LoadTeamLineups(compID) + if err != nil { + return err + } + dropped := false + for key, l := range lineups { + if l.MatchID != "" { + delete(lineups, key) + dropped = true + } + } + if dropped { + if err := s.saveTeamLineupsLocked(compID, lineups, write); err != nil { + return err + } + } + comp, err := tx.LoadCompetition(compID) + if err != nil || comp == nil { + return err + } + changed := len(comp.RoundLineupsGiven) > 0 + comp.RoundLineupsGiven = nil + if len(comp.RoundLineupsLegacy) > 0 && s.trimLegacyTeams(compID, comp, lineups) { + changed = true + } + if !changed { + return nil + } + return s.saveCompetitionLocked(comp, write) + }) +} + +// trimLegacyTeams drops from comp's legacy teams (Competition.RoundLineupsLegacy) +// those a draw's removal leaves nothing to show: a team that was legacy only for +// its v2.1.1 match lineups has nothing left to be shown what v2.1.1 showed, since +// they went with the draw. One with a lineup for a round keeps waiting, since the +// next draw's matches are given theirs from it. With none left the conversion is +// done and the marker is set, unless a lineup still waits for a player's id (it +// would stay name-keyed for good). Reports whether comp changed. A roster that +// cannot be read leaves the teams as they are: a team that waits for nothing is +// only kept waiting until the competition is completed. Caller holds the +// competition's lock. +func (s *Store) trimLegacyTeams(compID string, comp *Competition, lineups map[string]domain.TeamLineup) bool { + players, err := s.loadParticipantsNoLock(compID, comp.EffectiveWithZekkenName(), LoadParticipantsOpts{HasIDs: comp.ParticipantIDsHint()}) + if err != nil { + log.Printf("state: the legacy teams of %s are left as they are after the draw's removal: the roster could not be read: %v", compID, err) + return false + } + roster := newLineupRoster(players) + kept := teamsWithRoundLineups(lineups, roster) + if slices.Equal(kept, comp.RoundLineupsLegacy) { + return false + } + comp.RoundLineupsLegacy = kept + if len(kept) == 0 && !roster.anyAwaitsID(lineups) { + comp.RoundLineupsConverted = true + } + return true +} diff --git a/internal/state/team_lineup_match_test.go b/internal/state/team_lineup_match_test.go index cd3fd038a..3ccfcc791 100644 --- a/internal/state/team_lineup_match_test.go +++ b/internal/state/team_lineup_match_test.go @@ -6,6 +6,8 @@ package state import ( + "os" + "path/filepath" "testing" "github.com/gitrgoliveira/bracket-creator/internal/domain" @@ -114,3 +116,99 @@ func TestDeleteTeamLineupForMatch(t *testing.T) { require.NoError(t, err) assert.Empty(t, got, "match lineup gone after delete") } + +// TestClearDrawLineups: every match-scoped lineup goes, whichever team it is +// for, and the round-scoped ones stay (a team's starting lineup is its round-0 +// entry), and so does the record of the pairs the round-lineup conversion +// settled. A discarded draw and a draw generated from Setup rely on this: the +// regenerated draw reuses the match ids, so a lineup left behind for one would +// become a team's own at the reused id, and a pair left in the record would keep +// the new draw's match from being given its lineup. +func TestClearDrawLineups(t *testing.T) { + store, cleanup := newTestStore(t) + defer cleanup() + + const compID = "team-match-delete-all" + require.NoError(t, store.SaveCompetition(&Competition{ID: compID})) + + t.Run("nothing saved is not an error and writes no file", func(t *testing.T) { + require.NoError(t, store.ClearDrawLineups(compID)) + + _, err := os.Stat(filepath.Join(store.GetFolder(), "competitions", compID, teamLineupFilename)) + assert.True(t, os.IsNotExist(err), "no lineups.yaml was there to rewrite") + }) + + require.NoError(t, store.SetTeamLineup(compID, fiveStarter("team-alpha", 0), 5)) + require.NoError(t, store.SetTeamLineup(compID, fiveStarter("team-alpha", 1), 5)) + require.NoError(t, store.SetTeamLineup(compID, fiveStarterForMatch("team-alpha", "P1"), 5)) + require.NoError(t, store.SetTeamLineup(compID, fiveStarterForMatch("team-alpha", "P2"), 5)) + require.NoError(t, store.SetTeamLineup(compID, fiveStarterForMatch("team-beta", "P1"), 5)) + + t.Run("drops every match-scoped lineup and keeps the round-scoped ones", func(t *testing.T) { + require.NoError(t, store.ClearDrawLineups(compID)) + + got, err := store.LoadTeamLineups(compID) + require.NoError(t, err) + assert.Len(t, got, 2) + assert.Contains(t, got, teamLineupKey("team-alpha", 0), "the starting lineup stays") + assert.Contains(t, got, teamLineupKey("team-alpha", 1), "a later round's lineup stays") + }) + + t.Run("is idempotent", func(t *testing.T) { + require.NoError(t, store.ClearDrawLineups(compID)) + + got, err := store.LoadTeamLineups(compID) + require.NoError(t, err) + assert.Len(t, got, 2) + }) + + t.Run("clears the record of the pairs settled and nothing else in config.md", func(t *testing.T) { + const settled = "team-match-delete-record" + require.NoError(t, store.SaveCompetition(&Competition{ + ID: settled, Name: "Kept", Kind: "team", TeamSize: 5, + RoundLineupsGiven: map[string][]string{"team-alpha": {"P1", "P2"}, "team-beta": {"P1"}}, + })) + require.NoError(t, store.SetTeamLineup(settled, fiveStarter("team-alpha", 1), 5)) + require.NoError(t, store.SetTeamLineup(settled, fiveStarterForMatch("team-alpha", "P1"), 5)) + + require.NoError(t, store.ClearDrawLineups(settled)) + + comp, err := store.LoadCompetition(settled) + require.NoError(t, err) + assert.Empty(t, comp.RoundLineupsGiven) + assert.Equal(t, "Kept", comp.Name) + assert.Equal(t, 5, comp.TeamSize) + raw, err := os.ReadFile(filepath.Join(store.GetFolder(), "competitions", settled, "config.md")) // #nosec G304 -- a path under the test store + require.NoError(t, err) + assert.NotContains(t, string(raw), "round_lineups_given") + got, err := store.LoadTeamLineups(settled) + require.NoError(t, err) + assert.Len(t, got, 1, "the lineups and the record go in the same call") + assert.Contains(t, got, teamLineupKey("team-alpha", 1)) + }) + + t.Run("a record with nothing in it is left alone, byte for byte", func(t *testing.T) { + const plain = "team-match-delete-plain" + require.NoError(t, store.SaveCompetition(&Competition{ID: plain, Name: "Plain", RoundLineupsConverted: true})) + path := filepath.Join(store.GetFolder(), "competitions", plain, "config.md") + before, err := os.ReadFile(path) // #nosec G304 -- a path under the test store + require.NoError(t, err) + + require.NoError(t, store.ClearDrawLineups(plain)) + + after, err := os.ReadFile(path) // #nosec G304 -- a path under the test store + require.NoError(t, err) + assert.Equal(t, string(before), string(after), "the marker and every other field stay as they were") + }) + + t.Run("never creates the competition directory", func(t *testing.T) { + require.NoError(t, store.ClearDrawLineups("never-created")) + + _, err := os.Stat(filepath.Join(store.GetFolder(), "competitions", "never-created")) + assert.True(t, os.IsNotExist(err), "only competition creation makes the directory") + }) + + t.Run("an invalid competition id is an error", func(t *testing.T) { + assert.Error(t, store.ClearDrawLineups("../traversal")) + }) +} diff --git a/internal/state/team_lineup_test.go b/internal/state/team_lineup_test.go index da3ec9a57..cdae751f7 100644 --- a/internal/state/team_lineup_test.go +++ b/internal/state/team_lineup_test.go @@ -375,106 +375,6 @@ func TestLoadTeamLineups_CacheRefreshedOnSave(t *testing.T) { "newly added lineup must appear in the second load") } -// TestFindBestLineupAny covers the multi-key lookup used when a match -// side name must also be tried as the team's participant ID ("match on -// id OR name"): the priority tiers apply across the whole key set, and -// within a tier the first key in the slice wins. -func TestFindBestLineupAny(t *testing.T) { - pid := "8d5a1b1e-1111-4222-8333-444455556666" - name := "RedTeam" - - matchScoped := domain.TeamLineup{ - TeamID: pid, MatchID: "SF-1", - Positions: map[domain.Position]string{domain.PosSenpo: "MatchScoped"}, - } - roundScopedName := domain.TeamLineup{ - TeamID: name, Round: 0, - Positions: map[domain.Position]string{domain.PosSenpo: "RoundName"}, - } - roundScopedPid := domain.TeamLineup{ - TeamID: pid, Round: 1, - Positions: map[domain.Position]string{domain.PosSenpo: "RoundPid"}, - } - lineups := map[string]domain.TeamLineup{ - lineupStorageKey(matchScoped): matchScoped, - lineupStorageKey(roundScopedName): roundScopedName, - lineupStorageKey(roundScopedPid): roundScopedPid, - } - - t.Run("match-scoped under id beats round-scoped under name", func(t *testing.T) { - got, found := FindBestLineupAny(lineups, []string{name, pid}, "SF-1", 1) - require.True(t, found) - assert.Equal(t, "MatchScoped", got.Positions[domain.PosSenpo]) - }) - - t.Run("round tier picks highest round <= maxRound across keys", func(t *testing.T) { - got, found := FindBestLineupAny(lineups, []string{name, pid}, "other-match", 1) - require.True(t, found) - assert.Equal(t, "RoundPid", got.Positions[domain.PosSenpo], - "round 1 pid-keyed entry outranks round 0 name-keyed entry") - }) - - t.Run("single name key still resolves", func(t *testing.T) { - got, found := FindBestLineupAny(lineups, []string{name}, "", 0) - require.True(t, found) - assert.Equal(t, "RoundName", got.Positions[domain.PosSenpo]) - }) - - t.Run("no candidate keys match", func(t *testing.T) { - _, found := FindBestLineupAny(lineups, []string{"UnknownTeam"}, "", 5) - assert.False(t, found) - }) -} - -// TestFindBestLineupAny_TieBreakDeterminism verifies that when two entries -// have the SAME round, the teamID whose position in the teamIDs slice is -// lower wins deterministically across repeated calls (Fix 1: the previous -// map-iteration implementation was nondeterministic under tie). -// -// Two different TeamID values ("id-key" and "name-key") both appear in the -// teamIDs slice at different positions. Their lineups carry the same Round so -// neither outranks the other by round. The test asserts that: -// - teamIDs=[idKey, nameKey] always returns the idKey lineup. -// - teamIDs=[nameKey, idKey] always returns the nameKey lineup. -// -// Running 20 iterations per order guards against accidentally passing due to -// lucky map iteration. -func TestFindBestLineupAny_TieBreakDeterminism(t *testing.T) { - const idKey = "8d5a1b1e-0000-4000-8000-000000000001" - const nameKey = "RedTeam" - - idLineup := domain.TeamLineup{ - TeamID: idKey, Round: 0, - Positions: map[domain.Position]string{domain.PosSenpo: "by-id"}, - } - nameLineup := domain.TeamLineup{ - TeamID: nameKey, Round: 0, - Positions: map[domain.Position]string{domain.PosSenpo: "by-name"}, - } - lineups := map[string]domain.TeamLineup{ - lineupStorageKey(idLineup): idLineup, - lineupStorageKey(nameLineup): nameLineup, - } - - t.Run("idKey first in slice wins", func(t *testing.T) { - for i := range 20 { - got, found := FindBestLineupAny(lineups, []string{idKey, nameKey}, "", 0) - require.True(t, found, "iteration %d: must find a lineup", i) - assert.Equal(t, "by-id", got.Positions[domain.PosSenpo], - "iteration %d: idKey must win when it comes first in teamIDs", i) - } - }) - - t.Run("nameKey first in slice wins", func(t *testing.T) { - for i := range 20 { - got, found := FindBestLineupAny(lineups, []string{nameKey, idKey}, "", 0) - require.True(t, found, "iteration %d: must find a lineup", i) - assert.Equal(t, "by-name", got.Positions[domain.PosSenpo], - "iteration %d: nameKey must win when it comes first in teamIDs", i) - } - }) -} - // bc-tmfn: a roster replace that drops a team must prune that team's // lineups.yaml entries too, the lineup sibling of // TestSaveParticipants_PrunesOrphanedTeamMembers (squad_test.go). A team diff --git a/internal/state/team_member_stamp_test.go b/internal/state/team_member_stamp_test.go new file mode 100644 index 000000000..1baa55f8b --- /dev/null +++ b/internal/state/team_member_stamp_test.go @@ -0,0 +1,210 @@ +package state + +// team_member_stamp_test.go pins the server stamp a team member carries +// (operator decision 2026-10-07: "Do it in this PR"). Every write that creates a +// member or changes its name stamps it, under the store's lock, with the later of +// the server's time in milliseconds and one more than that member's previous +// stamp, so one member's stamps only grow. A client holding two copies of a member +// keeps the one with the larger stamp. A member no write of this release has +// touched carries 0, and a file written before the field existed round-trips byte +// for byte. + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" + "time" + + "github.com/gitrgoliveira/bracket-creator/internal/domain" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "gopkg.in/yaml.v3" +) + +// memberOf is the stored member of a team, read back from the file. +func memberOf(t *testing.T, s *Store, compID, teamID, memberID string) domain.TeamMember { + t.Helper() + squads, err := s.LoadSquads(compID) + require.NoError(t, err) + for _, m := range squads[teamID] { + if m.ID == memberID { + return m + } + } + require.Failf(t, "no such member", "%s", memberID) + return domain.TeamMember{} +} + +// serverClock stands the store's clock still at t and returns what moves it. +func serverClock(s *Store, t time.Time) (set func(time.Time)) { + now := t + s.clock = func() time.Time { return now } + return func(to time.Time) { now = to } +} + +func TestTeamMemberStamp_GrowsForOneMemberAcrossEveryWrite(t *testing.T) { + s, id, teamA, _ := newSquadTestStore(t) + at := time.UnixMilli(1_760_000_000_000) + serverClock(s, at) + + added, err := s.AddTeamMember(id, teamA, "Alice") + require.NoError(t, err) + assert.Equal(t, at.UnixMilli(), added.ModifiedAt, "a new member is stamped with the server's time") + + renamed, err := s.RenameTeamMember(id, teamA, added.ID, "Alicia") + require.NoError(t, err) + assert.Equal(t, added.ModifiedAt+1, renamed.ModifiedAt, "the clock has not moved, so the stamp grows by one") + assert.Equal(t, "Alicia", renamed.Name) + + cleared, err := s.ClearTeamMemberName(id, teamA, added.ID) + require.NoError(t, err) + assert.Equal(t, renamed.ModifiedAt+1, cleared.ModifiedAt) + assert.Empty(t, cleared.Name) + + again, err := s.RenameTeamMember(id, teamA, added.ID, "Alice B") + require.NoError(t, err) + assert.Equal(t, cleared.ModifiedAt+1, again.ModifiedAt) + + stored := memberOf(t, s, id, teamA, added.ID) + assert.Equal(t, again, stored, "what each write answers is what it stored") +} + +func TestTeamMemberStamp_FollowsTheClockWhenItMovesOn(t *testing.T) { + s, id, teamA, _ := newSquadTestStore(t) + at := time.UnixMilli(1_760_000_000_000) + moveTo := serverClock(s, at) + added, err := s.AddTeamMember(id, teamA, "Alice") + require.NoError(t, err) + + moveTo(at.Add(time.Hour)) + renamed, err := s.RenameTeamMember(id, teamA, added.ID, "Alicia") + require.NoError(t, err) + + assert.Equal(t, at.Add(time.Hour).UnixMilli(), renamed.ModifiedAt) +} + +// A server whose clock is set back between two writes still orders one member's +// writes: the stamp is never lower than the previous one plus one. +func TestTeamMemberStamp_StillGrowsAfterTheClockStepsBack(t *testing.T) { + s, id, teamA, _ := newSquadTestStore(t) + at := time.UnixMilli(1_760_000_000_000) + moveTo := serverClock(s, at) + added, err := s.AddTeamMember(id, teamA, "Alice") + require.NoError(t, err) + + moveTo(at.Add(-10 * time.Minute)) + renamed, err := s.RenameTeamMember(id, teamA, added.ID, "Alicia") + require.NoError(t, err) + cleared, err := s.ClearTeamMemberName(id, teamA, added.ID) + require.NoError(t, err) + + assert.Equal(t, added.ModifiedAt+1, renamed.ModifiedAt) + assert.Equal(t, added.ModifiedAt+2, cleared.ModifiedAt) +} + +func TestTeamMemberStamp_NamingABlankMemberStampsIt(t *testing.T) { + s, id, teamA, _ := newSquadTestStore(t) + at := time.UnixMilli(1_760_000_000_000) + serverClock(s, at) + squads, err := s.LoadSquads(id) + require.NoError(t, err) + blank := squads[teamA][0] + require.Empty(t, blank.Name) + require.Zero(t, blank.ModifiedAt) + + named, err := s.NameUnnamedTeamMember(id, teamA, blank.ID, "Ren Abe") + require.NoError(t, err) + + assert.Equal(t, at.UnixMilli(), named.ModifiedAt) + assert.Equal(t, "Ren Abe", named.Name) + assert.Equal(t, blank.Index, named.Index) + assert.Equal(t, named, memberOf(t, s, id, teamA, blank.ID)) +} + +func TestTeamMemberStamp_AWriteStampsOnlyTheMemberItWrites(t *testing.T) { + s, id, teamA, teamB := newSquadTestStore(t) + serverClock(s, time.UnixMilli(1_760_000_000_000)) + other, err := s.AddTeamMember(id, teamA, "Bob") + require.NoError(t, err) + third, err := s.AddTeamMember(id, teamB, "Carol") + require.NoError(t, err) + squads, err := s.LoadSquads(id) + require.NoError(t, err) + seeded := squads[teamA][0] + + target, err := s.AddTeamMember(id, teamA, "Alice") + require.NoError(t, err) + _, err = s.RenameTeamMember(id, teamA, target.ID, "Alicia") + require.NoError(t, err) + + assert.Equal(t, other, memberOf(t, s, id, teamA, other.ID), "a teammate is untouched") + assert.Equal(t, third, memberOf(t, s, id, teamB, third.ID), "so is a member of another team") + assert.Equal(t, seeded, memberOf(t, s, id, teamA, seeded.ID)) +} + +func TestTeamMemberStamp_SeededSlotsAndMembersNoWriteHasTouchedCarryZero(t *testing.T) { + s, id, teamA, _ := newSquadTestStore(t) + + squads, err := s.LoadSquads(id) + require.NoError(t, err) + + require.NotEmpty(t, squads[teamA]) + for _, m := range squads[teamA] { + assert.Zerof(t, m.ModifiedAt, "seeded slot %d", m.Index) + } +} + +func TestTeamMemberStamp_ShapeOnTheWireAndOnDisk(t *testing.T) { + t.Run("json always sends it, 0 for an untouched member", func(t *testing.T) { + raw, err := json.Marshal(domain.TeamMember{ID: "m", Index: 1}) + + require.NoError(t, err) + assert.JSONEq(t, `{"id":"m","index":1,"name":"","modifiedAt":0}`, string(raw)) + }) + + t.Run("yaml leaves it out at 0 and keeps it otherwise", func(t *testing.T) { + untouched, err := yaml.Marshal(domain.TeamMember{ID: "m", Index: 1, Name: "A"}) + require.NoError(t, err) + stamped, err := yaml.Marshal(domain.TeamMember{ID: "m", Index: 1, Name: "A", ModifiedAt: 1760000000000}) + require.NoError(t, err) + + assert.NotContains(t, string(untouched), "modifiedAt") + assert.Contains(t, string(stamped), "modifiedAt: 1760000000000") + }) +} + +// A team-members.yaml written before the field existed loads, and saves back as +// the same bytes: nothing in it gains a key. +func TestTeamMemberStamp_ALegacyFileRoundTripsByteForByte(t *testing.T) { + s, id, teamA, _ := newSquadTestStore(t) + legacy := "members:\n " + teamA + ":\n - id: 11111111-1111-4111-8111-111111111111\n index: 1\n name: Ito\n - id: 22222222-2222-4222-8222-222222222222\n index: 2\n name: \"\"\n" + path := filepath.Join(s.GetFolder(), "competitions", id, teamMembersFilename) + require.NoError(t, os.WriteFile(path, []byte(legacy), 0o600)) + squads, err := s.loadSquadsLocked(id) + require.NoError(t, err) + + require.NoError(t, s.saveSquadsLocked(id, squads, s.directWrite)) + + raw, err := os.ReadFile(path) // #nosec G304 -- a path under the test store + require.NoError(t, err) + assert.Equal(t, legacy, string(raw)) + for _, m := range squads[teamA] { + assert.Zero(t, m.ModifiedAt) + } +} + +func TestTeamMemberStamp_AWriteLeavesTheOthersOfALegacyFileAsTheyWere(t *testing.T) { + s, id, teamA, _ := newSquadTestStore(t) + legacy := "members:\n " + teamA + ":\n - id: 11111111-1111-4111-8111-111111111111\n index: 1\n name: Ito\n - id: 22222222-2222-4222-8222-222222222222\n index: 2\n name: \"\"\n" + path := filepath.Join(s.GetFolder(), "competitions", id, teamMembersFilename) + require.NoError(t, os.WriteFile(path, []byte(legacy), 0o600)) + serverClock(s, time.UnixMilli(1_760_000_000_000)) + + _, err := s.NameUnnamedTeamMember(id, teamA, "22222222-2222-4222-8222-222222222222", "Ueno") + require.NoError(t, err) + + raw, err := os.ReadFile(path) // #nosec G304 -- a path under the test store + require.NoError(t, err) + assert.Equal(t, "members:\n "+teamA+":\n - id: 11111111-1111-4111-8111-111111111111\n index: 1\n name: Ito\n - id: 22222222-2222-4222-8222-222222222222\n index: 2\n name: Ueno\n modifiedAt: 1760000000000\n", string(raw)) +} diff --git a/internal/state/transactions.go b/internal/state/transactions.go index de7d92321..274abfe09 100644 --- a/internal/state/transactions.go +++ b/internal/state/transactions.go @@ -240,6 +240,12 @@ func (s *Store) WithTransaction(compID string, fn func(tx StoreTx) error) error return ferr } + // A transaction that wrote the draw also moves the round lineups that + // write seated a team for, staged here beside it so they commit together + // (round_lineups.go). After fn returns nil, so a transaction that does not + // commit settles nothing. + tx.settleRoundLineupsAtCommit() + // Fast path: a tx that called nothing through the WAL writer // (e.g., pure read-only or a no-op save like // SaveCompetitionChanged returning false-changed) has no diff --git a/specs/005-per-match-lineup/spec.md b/specs/005-per-match-lineup/spec.md index edc8c78ea..b9dc320dc 100644 --- a/specs/005-per-match-lineup/spec.md +++ b/specs/005-per-match-lineup/spec.md @@ -34,8 +34,9 @@ Add an optional `MatchID string` to `TeamLineup`. - Store key resolves to a **match-scoped** key when `MatchID != ""`, else the existing **round-scoped** key. The two namespaces never collide (match keys are prefixed). -- Bracket and legacy round-only data load unchanged: **no on-disk migration**. A - round-only lineup remains valid and is the fallback when no per-match entry exists. +- Bracket data loads unchanged. A round-scoped lineup is now only the team's starting + lineup (round 0); a lineup saved for a later round by v2.1.1 and earlier is migrated, + never read (see the 2026-10-06 note below). - Per-match entries are independent: pool match 1's lineup and pool match 2's are saved and read apart. @@ -58,8 +59,9 @@ Add an optional `MatchID string` to `TeamLineup`. The `ErrLineupLocked` set-guard this decision first described was removed with the lock. -3. **Migration.** None on disk. The fallback key means existing round-keyed lineups - keep working; the new UI writes match-keyed entries going forward. +3. **Migration.** None on disk when match lineups were added: round-keyed lineups kept + working beside them. Superseded on 2026-10-06 for lineups saved for a later round, and + on 2026-10-07 for match lineups saved by v2.1.1: see the note on migration below. 4. **FIK 5-person rule.** `Validate(teamSize)` is unchanged and runs per set, regardless of keying. 5. **Engine advancement.** `MaybeAdvanceKachinuki` does **not** read lineups (it works @@ -77,18 +79,57 @@ Add an optional `MatchID string` to `TeamLineup`. > heuristic when no lineup exists — and it is append-only: it never auto-finalizes a > kachinuki encounter; completion is an explicit operator score write. +> **Superseded (2026-10-05, operator ruling):** "match-scoped entry preferred, round-scoped +> fallback" is no longer the rule. By default a team carries the lineup of its previous team +> match, and may have a different one in every match; every match has a number and an order. +> The lineup in force for a team at a match is its own match entry, else the latest of its +> earlier matches' entries, else its starting lineup (the Lineups page's lineup, round 0); a +> match the draw does not hold takes its own entry or the starting lineup. Match order is pool +> and league matches by pool-match number, then knockout matches by round and position, then +> the 3rd-place match; `state.TeamMatches` says which matches are team matches (a bye, a pool +> daihyosen or tie-break bout is none). `engine.Engine.LineupInForce` owns the rule: +> `kachinukiRemainingRoster`, the Kachinuki Detail export and `GET .../lineup-in-force/:matchId` +> all ask it. + +> **Migration (operator decisions 2026-10-06 "Migrate old content to new format" and +> 2026-10-07 "Show what v2.1.1 showed"):** lineups saved by v2.1.1 and earlier are migrated, +> never read under the old rule. A team with a lineup for a later round, or with a match +> lineup when the competition is first settled (the list is recorded then, as +> `round_lineups_legacy`, so a match lineup saved afterwards never counts), gets as its own +> lineup, at every match it is seated in, the lineup v2.1.1 showed there: the match's own, +> else the highest round at or below the match's round, else the highest round, or no lineup +> at all (an empty one of its own) when the team had neither. Each match is given its lineup +> once, in the write that seats the team, so an operator's later change stands. A discarded +> or regenerated draw clears what was given and drops the teams with no round lineup from the +> list; round lineups are removed once the competition is completed. `state.settleRoundLineups` +> owns it. + +> **Lineup saves (operator decision 2026-10-07, "Only changed positions"):** a save names the +> positions it changed (`changed`), and the server applies only those, under the competition's +> lock, onto the lineup stored for that key, or, for a match with none, onto the lineup in +> force there. Two devices changing different positions both keep their change in either +> arrival order, a save made offline included; the same position keeps the later arrival. A +> save without `changed` replaces the whole lineup, as before. + ## API -`GET/PUT/DELETE /api/competitions/:id/teams/:tid/lineups/:round`: unchanged (round-scoped). +`GET/PUT/DELETE /api/competitions/:id/teams/:tid/lineups/:round`: round-scoped. A PUT saves +round 0 only, the team's starting lineup; round 1 or later is a 400 (2026-10-05). Both PUTs +take the optional `changed` list (2026-10-07, above). New, match-scoped (added alongside, both live one release): `GET/PUT/DELETE /api/competitions/:id/teams/:tid/match-lineups/:matchId` `GET` answers 200 with an empty lineup marked `saved: false` when no match-scoped -lineup exists; the caller then falls back to the round-scoped endpoint (operator -decision 2026-09-27, bc-k404). +lineup exists (operator decision 2026-09-27, bc-k404); it reads only the lineup saved for +exactly that match. + +`GET /api/competitions/:id/teams/:tid/lineup-in-force/:matchId` (public, 2026-10-05): the +lineup the team fields at the match, with its source (`sourceMatchId` or `sourceRound`). +Same contract: 200 with `saved: false` when nothing applies, 404 only for an unknown +competition. Surfaces read this, not the two routes above, for what a team fields. ## Out of scope (follow-up beads) -- **Phase 5**: Score-editor UI: per-side lineup edit panel + "Copy from previous match". +- **Phase 5**: Score-editor UI: per-side lineup edit panel. Its "Copy from previous match" was removed (2026-10-05): a team carries the lineup of its previous match by default, so the panel offers "Use the previous match's lineup" on a match's own lineup instead. - **Phase 6**: Viewer/TV overlay rendering of the per-match lineup. diff --git a/specs/openapi.yaml b/specs/openapi.yaml index 5ccaa5a71..80c771c24 100644 --- a/specs/openapi.yaml +++ b/specs/openapi.yaml @@ -20,6 +20,7 @@ info: - **`GET /api/competitions/{id}/competitor-status`**: public read; the `POST` write is protected. - **`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}/swiss/standings`**: public read; always public. - **`GET /api/tournament/announcement`**: public read; returns the currently active admin broadcast announcement (204 when none/expired). @@ -39,7 +40,7 @@ info: has a name (409 `member_already_named`), add or name a member once the competition has finished (409 `competition_finished`), or add one to a team that already holds twice its team size plus two members (409 `team_member_limit`). The Lineups page's - other writes (round lineups, deleting a match lineup, clearing a member's name) + other writes (the starting lineup, deleting a match lineup, clearing a member's name) and `GET .../team-members` stay gated. An empty `X-Tournament-Password` also runs the representative bout of a tied knockout team match: `POST`/`DELETE .../matches/{mid}/daihyosen` add and remove @@ -4032,7 +4033,14 @@ paths: required: true schema: type: integer - description: "Tournament round index, 0-based. The admin UI displays round 0 as Round 1." + description: > + Lineups-page round, 0-based. Round 0 is the team's starting lineup, the only + round a PUT saves. Rounds 1 and up were saved by earlier versions of the + Lineups page, which no longer creates them: the server gives the team, at + every match it is seated in, a lineup of its own equal to the one that version + showed there, and removes them once the competition is completed, so a read of + one finds it only until then. What a team fields at a match is read from + lineup-in-force, not from a round. get: tags: [competitions] summary: Get team lineup for round @@ -4040,26 +4048,11 @@ paths: Always answers 200 while the competition exists (bc-k404, operator decision 2026-09-27: a lineup either exists or it doesn't, and empty is a normal state, not a missing resource). The response's `saved` field is the marker: `true` - means the lineup stored at the round asked for (or, with `fallback=best`, the - resolved round); `false` means nothing is stored there, and the body echoes the - requested round with empty `positions`. 404 is reserved for a competition id - that names no competition at all. + means the lineup stored at exactly the round asked for; `false` means nothing is + stored there, and the body echoes the requested round with empty `positions`, + never another round's lineup. 404 is reserved for a competition id that names no + competition at all. security: [] - parameters: - - in: query - name: fallback - required: false - schema: - type: string - enum: [best] - description: > - When fallback=best the server resolves the best available lineup when none - exists for the exact round (the highest round at or below the requested one, - then the team's latest lineup), answering `saved: false` only when the team - has no round-scoped lineups at all. This round-scoped endpoint has no - matchId, so match-scoped lineups are never consulted. Without this parameter - the endpoint keeps exact-round semantics (saved: false when that round has - nothing saved). PUT and DELETE are unaffected. responses: '200': description: Team lineup, saved or not (see `saved`). @@ -4078,6 +4071,12 @@ paths: put: tags: [competitions] summary: Save team lineup for round + description: | + Saves the team's starting lineup (round 0). With `changed` (operator decision + 2026-10-07), only the positions it names are written, onto the starting lineup + stored when the request is handled (an empty one when none is), so a change + another device made to any other position stays; see `TeamLineupSave`. Without + it the body replaces the whole lineup. security: - TournamentPassword: [] requestBody: @@ -4085,18 +4084,22 @@ paths: content: application/json: schema: - $ref: '#/components/schemas/TeamLineup' + $ref: '#/components/schemas/TeamLineupSave' responses: '200': - description: Lineup saved. + description: Lineup saved. The body is the whole lineup as stored after the save. content: application/json: schema: $ref: '#/components/schemas/TeamLineup' '400': description: > - The body is not a JSON object, a position key is not a valid - position, or a position's name is longer than 100 bytes. + The round is 1 or more (a lineup is saved as the team's starting lineup, or + for a match, so the answer is "A lineup is saved as the team's starting + lineup or for a match." before the body is read), the body is not a JSON + object, a position key is not a valid position, a position's name is + longer than 100 bytes, `changed` is empty or names a position `positions` + does not hold, or the saved lineup would place one member at two positions. content: application/json: schema: @@ -4137,9 +4140,9 @@ paths: description: | Match-scoped lineup (mp-825). Always answers 200 while the competition exists (bc-k404): `saved: false` and empty `positions` when no match-scoped entry - exists, never a 404. The server never falls back to the round-scoped lineup on - this route; that tier belongs to the CALLER, which reads `saved: false` and - asks the round-scoped endpoint above itself. 404 is reserved for a competition + exists, never a 404. This route answers only the lineup saved for exactly this + match, never one carried from elsewhere: the lineup a team FIELDS at a match is + read from `GET .../lineup-in-force/{matchId}`. 404 is reserved for a competition id that names no competition at all. security: [] responses: @@ -4166,15 +4169,24 @@ paths: save; a structurally invalid payload (FIK shape rules) is still rejected with a 400. + With `changed` (operator decision 2026-10-07), only the positions it names + are written, under the competition's lock, onto the match's own lineup, or, + when the match has none yet, onto the lineup in force there (the one the + team carries from its previous match, else its starting lineup), so a + change another device made to any other position stays, whichever save + arrives first. Two saves of the same position keep the later arrival. + Without `changed` the body replaces the whole lineup. See `TeamLineupSave`. + In a self-run tournament the request is also accepted with an empty password: the public score sheet saves it when a competitor names a bout's fighter (bc-dhas). Such a request is refused for a match that does not exist (404), for a team the match does not hold by id (404 - `team_not_in_match`), for a member id the team does not hold (400 - `team_member_not_in_team`), or for a match that has finished (409 - `result_finalized`, the score endpoint's own refusal); the organiser, - sending the password, keeps the always-editable rule above, and a - password that is sent but wrong gets 401 before the body is read. + `team_not_in_match`), for a member id the team does not hold among the + positions the save writes (400 `team_member_not_in_team`), or for a match + that has finished (409 `result_finalized`, the score endpoint's own + refusal); the organiser, sending the password, keeps the always-editable + rule above, and a password that is sent but wrong gets 401 before the + body is read. security: - TournamentPassword: [] requestBody: @@ -4182,10 +4194,10 @@ paths: content: application/json: schema: - $ref: '#/components/schemas/TeamLineup' + $ref: '#/components/schemas/TeamLineupSave' responses: '200': - description: Lineup saved. + description: Lineup saved. The body is the whole lineup as stored after the save. content: application/json: schema: @@ -4193,11 +4205,13 @@ paths: '400': description: | The body is not a JSON object, a position key is not a valid - position, or a position's name is longer than 100 bytes. Self-run, - an empty password: also a member id the team does not hold - (`error: team_member_not_in_team`, with a `message` to show, e.g. - "The member chosen for senpo is not on this team. Pick again from - the list."). + position, a position's name is longer than 100 bytes, `changed` is + empty or names a position `positions` does not hold, or the saved + lineup would place one member at two positions (the answer names + both). Self-run, an empty password: also a member id the team does + not hold (`error: team_member_not_in_team`, with a `message` to + show, e.g. "The member chosen for senpo is not on this team. Pick + again from the list."). content: application/json: schema: @@ -4250,6 +4264,73 @@ paths: '500': $ref: '#/components/responses/InternalError' + /api/competitions/{id}/teams/{tid}/lineup-in-force/{matchId}: + parameters: + - $ref: '#/components/parameters/CompetitionId' + - in: path + name: tid + required: true + schema: + type: string + description: Team ID (the team's participant id). + - in: path + name: matchId + required: true + schema: + type: string + description: Match ID the lineup is wanted for. + get: + tags: [competitions] + summary: Get the lineup a team fields at a match + description: | + The lineup in force for the team at the match (operator ruling 2026-10-05: by + default a team carries the lineup of its previous team match, and may have a + different one in every match). In order: + + 1. the lineup saved for the team at this very match; + 2. else the latest lineup of the team placed before the match: one saved for an + earlier match of the team, or, at the very start, the team's starting lineup + (the Lineups-page lineup, round 0); + 3. else, for a match the draw does not hold, the starting lineup. + + A lineup saved for round 1 or later by earlier versions of the Lineups page is + not read: the server gives its team, at every match it is seated in, a lineup of + its own equal to the one that version showed there, when it loads the + competition and then in the write that seats the team, and removes the round + lineups once the competition is completed. A team that had a lineup saved for a + match by those versions is given the same, so a match it had none for shows the + lineup those versions showed there (its starting lineup, or none), not the one + it would carry from that match. + + Match order is fixed: pool and league matches in pool-match number order; + knockout matches follow at their bracket round index; the 3rd-place match is + last. A lineup saved for a match counts only while that match is in the + current draw and the team is seated in it by participant id. + + Always answers 200 while the competition exists (bc-k404): `saved: false` and + empty `positions` when no lineup applies, never a 404. 404 is reserved for a + competition id that names no competition at all. The body is the stored lineup + that is in force, so its own `matchId` and `round` are the source's; the source + is also spelled out in `sourceMatchId` or `sourceRound`. + security: [] + responses: + '200': + description: The lineup in force, or none (see `saved`). + content: + application/json: + schema: + $ref: '#/components/schemas/LineupInForceRead' + '400': + $ref: '#/components/responses/BadRequest' + '404': + description: Competition not found. + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '500': + $ref: '#/components/responses/InternalError' + /api/competitions/{id}/teams/{tid}/members: parameters: - $ref: '#/components/parameters/CompetitionId' @@ -4264,7 +4345,9 @@ paths: summary: Add a team member description: | Adds a member to the team with the next display index; an index is - never reused. Two members of one team may not share a name. + never reused. Two members of one team may not share a name. The answer + carries the member's `modifiedAt` stamp, and the change is announced to + every open page (the `lineup_updated` event, naming the team). In a self-run tournament a request with an empty password may add one too: the public score sheet does when a competitor types a fighter's @@ -4350,6 +4433,7 @@ paths: recorded with in their stored `sideA`/`sideB`, but every surface of the app and the export show the member's current name, looked up by id, so a rename changes what those bouts show too, finished matches included. + The answer is the member as stored, with its new `modifiedAt` stamp. In a self-run tournament a request with an empty password may name a member who has no name yet (the public score sheet does when a @@ -4368,8 +4452,12 @@ paths: schema: $ref: '#/components/schemas/TeamMemberName' responses: - '204': + '200': description: Member named. + content: + application/json: + schema: + $ref: '#/components/schemas/TeamMember' '400': description: The body is not a JSON object, or `name` is empty or longer than 100 bytes. content: @@ -4409,6 +4497,44 @@ paths: message: type: string description: The sentence the public page shows as it is (member_already_named, competition_finished). + delete: + tags: [competitions] + summary: Clear a team member's name + description: | + The operator's removal of a member: clears the name and keeps the member's + id and display index, so a bout already fought that names this position + keeps its meaning (a member is never removed). Refused once the + competition has started. Needs the password in a self-run tournament too. + The answer is the member as stored, with its new `modifiedAt` stamp. + security: + - TournamentPassword: [] + responses: + '200': + description: Name cleared. + content: + application/json: + schema: + $ref: '#/components/schemas/TeamMember' + '400': + $ref: '#/components/responses/BadRequest' + '401': + description: '`invalid tournament password`: missing or wrong.' + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '404': + description: No competition, team or member with this id. + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '409': + description: The competition has started, so a member's name can no longer be cleared. + content: + application/json: + schema: + $ref: '#/components/schemas/Error' /api/competitions/{id}/swiss/generate-round: parameters: @@ -4917,6 +5043,7 @@ components: `GET /api/competitions/{id}/competitor-status`, `GET /api/competitions/{id}/teams/{tid}/lineups/{round}`, `GET /api/competitions/{id}/teams/{tid}/match-lineups/{matchId}`, + `GET /api/competitions/{id}/teams/{tid}/lineup-in-force/{matchId}`, `GET /api/competitions/{id}/swiss/standings`, and `GET /api/tournament/announcement`, and (in file mode only) the bootstrap PUT/POST `/api/tournament` @@ -6843,13 +6970,23 @@ components: name: type: string description: Empty for a numbered member no one has named yet. + modifiedAt: + type: integer + format: int64 + description: > + When the member was last added, named, renamed or cleared (server time, + unix ms). One member's stamps only grow, so of two copies of a member the + one with the larger stamp is the newer; a page keeps that one whatever + order the copies arrive in. 0 for a member no such write has touched yet + (a numbered slot seeded for the team, or one stored by an earlier release). TeamLineup: type: object description: > - Lineup assigning team members to positions. Round-scoped by default; - when matchId is present the lineup is keyed to a specific match/encounter - (mp-825), overriding the round lineup for that bout. + Lineup assigning team members to positions: the team's starting lineup + (round 0, no matchId) or, when matchId is present, the team's own lineup for + that match/encounter (mp-825; its round is 0), which the team carries to its + later matches until another is entered. required: [teamId, competitionId, round, positions] properties: teamId: @@ -6862,7 +6999,7 @@ components: type: string description: > When present, keys this lineup to a specific match/encounter rather - than the whole round. Omitted for round-scoped lineups. + than the team's starting lineup. Omitted for the starting lineup. positions: type: object additionalProperties: @@ -6879,6 +7016,30 @@ components: positions as `positions`. Optional: a position without an entry here is identified by its name alone. + TeamLineupSave: + description: > + The request body of the two lineup PUTs: a TeamLineup plus an optional + `changed` list (operator decision 2026-10-07: a save names the positions it + changed). With `changed`, only the positions it names are written, onto the + lineup stored for that key when the request is handled (for a match with no + lineup of its own, the lineup in force there); every other key of + `positions` and `memberIds` is ignored, so a client may send its whole form. + Without it, the body replaces the whole lineup. + allOf: + - $ref: '#/components/schemas/TeamLineup' + - type: object + properties: + changed: + type: array + minItems: 1 + items: + type: string + description: > + The positions this save changed. Each must be a key of `positions`: + a non-empty name, or an empty name with a `memberIds` entry (a member + with no name yet), places a fighter there; an empty name with no + `memberIds` entry clears the position. + TeamLineupRead: description: > The response body for the two public lineup GETs (bc-k404, operator decision @@ -6895,10 +7056,39 @@ components: saved: type: boolean description: > - true when a lineup is stored at the round/matchId asked for (or, with - fallback=best, the round it resolved to); false when nothing is stored - there, in which case `positions` is empty and the round/matchId in the - body is the one requested, not a fallback. + true when a lineup is stored at exactly the round/matchId asked for; + false when nothing is stored there, in which case `positions` is empty + and the round/matchId in the body is the one requested. + + LineupInForceRead: + description: > + The response body of the lineup-in-force GET: the stored lineup that is in + force for the team at the match, with the same required `saved` marker as + TeamLineupRead. When `saved` is false, `positions` is empty, the matchId in the + body is the one requested, and neither source field is present. + allOf: + - $ref: '#/components/schemas/TeamLineup' + - type: object + required: [saved] + properties: + saved: + type: boolean + description: true when a lineup is in force for the team at the match. + sourceMatchId: + type: string + description: > + Present when the lineup in force was saved for a match: the match asked + about (its own lineup) or an earlier match of the team it is carried + from. Absent for the starting lineup. + sourceRound: + type: integer + minimum: 0 + description: > + Present when the lineup in force is the team's starting lineup, and + then always, round 0 included (0 is a value to send, not an absence). + A lineup for a later round is never read as one (the server converts it + into lineups for its team's matches), so this is always 0. Absent when + `sourceMatchId` is present. ScheduleEstimate: type: object diff --git a/web-mobile/check-imports.mjs b/web-mobile/check-imports.mjs index 9518244d4..6fc960ba0 100644 --- a/web-mobile/check-imports.mjs +++ b/web-mobile/check-imports.mjs @@ -61,6 +61,27 @@ const CHECK_MODULES = [ // admin_scoring_shared.jsx, match_groups.jsx). 'admin_scoring_engi.jsx', 'match_history_view.jsx', + // bc-lpfb: both lineup editors import the helpers they share from + // lineup_resolver.jsx (the source wording moved there from the panel) and + // pool_ids.jsx. + 'admin_lineup.jsx', + 'admin_schedule_lineup.jsx', + // bc-lnul: both lineup editors import the form hook, the source line, the read + // problem and the draft notice from lineup_draft.jsx, which imports the lineup + // read, the position diff and the wording from lineup_resolver.jsx. + 'lineup_draft.jsx', + // lineup_resolver.jsx words a lineup read nobody answered through + // noAnswerSentence, the one owner of that sentence in write_result.jsx. + 'lineup_resolver.jsx', + // bc-cfbd: the layers a tap opens keep the bounce of that tap off their + // backdrop through the tap_guard.jsx leaf: the participant Edit dialog, the + // public match card, the admin sign-in dialog, the shiaijo console's confirm + // and ui.jsx's confirm and Modal each import useOpenedTapGuard. + 'admin_participants.jsx', + 'viewer_match.jsx', + 'app.jsx', + 'ui.jsx', + 'admin_shiaijo.jsx', ]; // --------------------------------------------------------------------------- diff --git a/web-mobile/css/styles.css b/web-mobile/css/styles.css index 29994010b..5e5669e57 100644 --- a/web-mobile/css/styles.css +++ b/web-mobile/css/styles.css @@ -2053,6 +2053,15 @@ a:hover { border: 0; } +/* Holds its space (operator ruling 2026-10-06): out of play for now, but the + box stays where it is, so nothing beside it or under it moves when it comes + and goes. visibility:hidden, not display:none, which would give the space + back; it also takes the element out of the accessibility tree (it is not + announced), the tab order and the pointer. */ +.holds-space { + visibility: hidden; +} + .pool-match-numbered-row__score { font-family: var(--font-mono); font-weight: 600; @@ -2177,18 +2186,30 @@ a:hover { } /* ---------- Modal ---------- */ +/* The modal layer: above the announcement cards (900), which sit over the page's + own chrome, so an open modal is never obscured by one. Every overlay score + editor and the shared Modal are a plain .modal-backdrop, so this one rule lifts + them all; the confirm dialogs go higher still (below). */ .modal-backdrop { position: fixed; inset: 0; background: rgba(15, 20, 35, 0.55); display: grid; place-items: center; - z-index: 100; + z-index: 1000; padding: 20px; } @supports (backdrop-filter: blur(2px)) { .modal-backdrop { backdrop-filter: blur(2px); } } +/* The confirm and prompt dialogs (DialogHost) open on top of whatever asked for + them. The viewer's match modal and the at-court lineup panel are fixed layers + at 1000, the modal layer's own value, so a dialog left on it opened UNDER them, + out of reach. Above every overlay, and under the toast (1200). After + .modal-backdrop: same specificity, so the later rule wins. */ +.modal-backdrop--dialog { + z-index: 1100; +} /* Court-reassignment confirmation, a small, centred confirm card. Moving a match to another shiaijo takes it off this court, so it's gated here. */ @@ -5210,7 +5231,7 @@ button.vsched-item:not([data-clickable]) { reachable — previously the whole modal scrolled and on a 390px phone the primary action sat below the fold behind a nested scroll. Applies to every overlay editor (individual, team, engi); the inline .scoring-panel variant - flows in the page and is unaffected. */ + flows in the page, and only its team sheet pins bars (see below). */ .editor-modal { background: var(--surface); border-radius: var(--r-lg); @@ -5238,6 +5259,102 @@ button.vsched-item:not([data-clickable]) { position: static; } +/* The team sheet pins two bars in the inline hosts (the page scrolls, the + panel does not): the team header and result band under the topbar, the + footer actions at the bottom edge. overflow: clip keeps the rounded + corners without making the panel or its body a scroll container, so both + sticky bars resolve against the document. Must follow the static rule + above (same specificity). */ +.scoring-panel--team { + overflow: clip; +} +.scoring-panel--team .editor-modal__body { + overflow: visible; +} +.scoring-panel--team .editor-modal__foot--nav { + position: sticky; + bottom: 0; + z-index: 20; + background: var(--surface); + border-top: 1px solid var(--line); + /* A correction reason prompt or a pending banner must not grow the dock + over the whole sheet. */ + max-height: 45vh; + overflow-y: auto; +} +/* The action row stays at the dock's bottom edge while a tall prompt, banner + or History disclosure above it scrolls; the key legend after it scrolls + into view only at the end. */ +.scoring-panel--team .editor-modal__foot--nav .score-nav { + position: sticky; + bottom: 0; + background: var(--surface); +} +/* Safari before 16 has no overflow: clip; keep the bars pinned and lose only + the rounded-corner clip. */ +@supports not (overflow: clip) { + .scoring-panel--team { + overflow: visible; + } +} + +/* Team header + result band, one sticky unit (z 9: above the name list at 8 + inside the editor body, under the footer dock at 20 and the topbar at + 30). Overlay hosts pin it at the top of their scrolling body; the inline + hosts sit it under the shell's topbar stack, whose height AdminTopbar + publishes as --topbar-stack-h. The sheet publishes this unit's own height as + --team-pin-h, and the inline dock's as --team-dock-h, for the scroll margin + that keeps a focused control clear of the bars (the content rules below). */ +.team-sheet-pin { + position: sticky; + top: 0; + z-index: 9; + flex: 0 0 auto; + display: flex; + flex-direction: column; + padding-bottom: 6px; + background: var(--surface); +} +.scoring-panel--team .team-sheet-pin { + top: var(--topbar-stack-h, 0px); +} +/* In the overlay the bar sticks to the body's top edge, over the body's top + padding, so a bout scrolled past does not show in a strip above the team + names; the negative margin keeps it where it sits before it sticks. The note + for a match whose stored data cannot be read can come first (the bar is then + the body's second child): the bar's box starts that far up into the space the + note's bottom margin and the body's gap leave, which clears it in both + densities, so it never reaches the note. */ +.editor-modal--team > .editor-modal__body > .team-sheet-pin { + top: calc(-1 * var(--editor-body-pad-top)); + margin-top: calc(-1 * var(--editor-body-pad-top)); + padding-top: var(--editor-body-pad-top); +} +/* A control a scroll brings only just into view (a keyboard Tab onto it, + scrollIntoView) stops clear of the bars the sheet pins over its content: the + topbar and the pinned header above it, the footer dock below it. The sheet's + own content carries that as scroll margin, sized by the heights the sheet + publishes while it is mounted (published_height.jsx). It is margin on the + content's controls and not padding on the scroller: padding claims a strip of + the viewport, a bar's own controls (the dock's Finish, a button of the topbar) + lie inside it, and focusing one scrolled the page by about 300px for nothing. + So only controls in the body's content are reached, never one in the pinned + header or the footer dock. Each rule hangs off its host's own root (a + scoring-panel inline, an editor-modal in an overlay), so no element meets two; + an overlay's footer sits outside the scrolling body and covers nothing, so only + the top is margined there. The inline head (the title row and Close) scrolls + with the page above the pinned header, so only the topbar can cover it. */ +.scoring-panel--team > .editor-modal__body :is(button, a[href], input, select, textarea, summary, [tabindex]):not(.team-sheet-pin *) { + scroll-margin-top: calc(var(--topbar-stack-h, 0px) + var(--team-pin-h, 0px)); + scroll-margin-bottom: var(--team-dock-h, 0px); +} +.scoring-panel--team > .editor-modal__head :is(button, a[href], input, select, textarea, summary, [tabindex]) { + scroll-margin-top: var(--topbar-stack-h, 0px); +} +.editor-modal--team > .editor-modal__body :is(button, a[href], input, select, textarea, summary, [tabindex]):not(.team-sheet-pin *) { + scroll-margin-top: var(--team-pin-h, 0px); +} + .editor-modal__head { padding: 18px 22px; border-bottom: 1px solid var(--line); @@ -5245,7 +5362,9 @@ button.vsched-item:not([data-clickable]) { } .editor-modal__body { - padding: 18px 22px; + /* Named so the team sheet's pinned bar can cover it (.team-sheet-pin). */ + --editor-body-pad-top: 18px; + padding: var(--editor-body-pad-top) 22px; display: flex; flex-direction: column; gap: 14px; @@ -6241,9 +6360,34 @@ button.vsched-item:not([data-clickable]) { margin-bottom: 0; } .lineup-name__bar { - min-height: 36px; - padding: 4px 6px; + min-height: max(36px, var(--tap-floor)); + padding: 0 6px; flex-wrap: nowrap; + align-items: stretch; +} +/* The input fills the bar, so the whole bar is the typeable target (a tap on + the bar's own padding used to focus nothing); the clear button stays centred. */ +.lineup-name__bar .pmf__input { + align-self: stretch; + /* The input is the tap target, so it carries the floor itself: the bar's + own floor counts its 1px borders, which left an empty box's input 42px. */ + min-height: var(--tap-floor); +} +.lineup-name__bar .lineup-name__clear { + align-self: center; +} +/* A box that takes no pick right now (the team sheet disables every name box + while a name it took is being read against and saved) looks disabled the way + the buttons do (.btn:disabled), on the muted surface: a box that looks like + every other one is tapped for nothing. The input is the tap target, so the + cursor is set on it as well as on the bar. */ +.lineup-name__bar:has(.pmf__input:disabled) { + background: var(--surface-2); + opacity: 0.6; + cursor: not-allowed; +} +.lineup-name__bar .pmf__input:disabled { + cursor: not-allowed; } /* An unfilled lineup slot is a "fill this" caution, not an error, and the two sides are already Aka (red) / Shiro per the colour system, so a red @@ -6268,12 +6412,77 @@ button.vsched-item:not([data-clickable]) { .lineup-name__clear:hover { color: var(--ink-1); } +/* The at-court lineup panel's Rename link: compact under a fine pointer, and + floored under a coarse one. Two classes, so it outranks both .btn floors + (an inline style used to, which left it 47x14 on an iPad). */ +.btn.lineup-rename-btn { + padding: 0 2px; + font-size: 11px; + height: auto; + min-height: 0; +} +@media (pointer: coarse) { + .btn.lineup-rename-btn { + min-height: var(--tap-floor); + min-width: var(--tap-floor); + } +} +/* The notice a lineup editor shows when it brings back unsaved changes, or says + it could not: the amber .alert--warn with the Discard beside the words. The + Discard is a plain .btn, so the coarse-pointer floor reaches it. */ +.lineup-draft-note { + display: flex; + align-items: center; + justify-content: space-between; + flex-wrap: wrap; + gap: 8px; + margin-bottom: 8px; +} +/* The line under a lineup editor's title, one for the at-court panel and the + Lineups page: where the lineup shown was saved and, for a match's own lineup, + the button that gives it up. One size and one emphasis for both. */ +.lineup-source { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 8px; + margin-bottom: 8px; + font-size: 12px; + color: var(--ink-3); +} +.lineup-source__label--own { + color: var(--accent); + font-weight: 600; +} +.lineup-source__use { + margin-left: auto; +} +/* Why that button waits for a queued save, as a line: a title never shows on a + touchscreen. */ +.lineup-source__why { + flex: 1 0 100%; +} +/* Why a lineup could not be read, with Try again beside the words. Try again is a + plain .btn, so the coarse-pointer floor reaches it. */ +.lineup-problem { + display: flex; + align-items: center; + justify-content: space-between; + flex-wrap: wrap; + gap: 8px; + margin-bottom: 8px; +} .lineup-name__dropdown { z-index: 60; min-width: 200px; - max-height: 240px; overflow-y: auto; } +/* Inside a score editor the list slides under the pinned header, the dock and + the topbar, exactly as its input does (the body creates no stacking + context, so this competes at the root). */ +.editor-modal__body .lineup-name__dropdown { + z-index: 8; +} .pmf__option--active { background: var(--surface-2); } @@ -6617,7 +6826,10 @@ button.vsched-item:not([data-clickable]) { font-size: 14px; font-weight: 600; box-shadow: 0 8px 24px rgba(0, 0, 0, 0.2); - z-index: 500; + /* The top layer, above the confirm dialogs (1100): a toast raised while one is + open is not dimmed by its backdrop. It takes no pointer events, so it never + blocks the dialog under it. */ + z-index: 1200; pointer-events: none; animation: toast-in 300ms cubic-bezier(0.16, 1, 0.3, 1); } @@ -7570,13 +7782,15 @@ abbr.msb-lab { text-decoration: none; cursor: help; } gap: 3px 6px; } @media (pointer: coarse) { + /* The box bleeds into the row padding and the gap rather than widening the + number gutter, which the team header restates as a margin. */ .tsm-caret-btn { - min-height: 44px; - min-width: 36px; + min-height: var(--tap-floor); + min-width: var(--tap-floor); display: inline-flex; align-items: center; justify-content: center; - margin: -10px 0; + margin: -10px -5px; } } @@ -7818,8 +8032,8 @@ abbr.msb-lab { text-decoration: none; cursor: help; } individual matches (always), team matches with teamSize ≤ 5, and kachinuki team matches (regardless of size, since kachinuki renders the current bout only). Fixed-format team matches with teamSize > 5 keep the - roomy legacy layout but get the `.team-bouts-scroll` rule below for - independent bout-list scrolling. The engi (kata) editor body is compact on + roomy legacy layout, whose bouts scroll with the body as in every other + layout. The engi (kata) editor body is compact on BOTH hosts (operator ruling 2026-09-18): this text used to say engi was compact on neither, while the inline panel carried the class and the overlay did not, so the same editor rendered at two densities and the comment @@ -7914,6 +8128,18 @@ abbr.msb-lab { text-decoration: none; cursor: help; } align-items: center; gap: 6px; font-weight: 600; + min-height: var(--tap-floor); + cursor: pointer; +} +/* The label, not the checkbox, is the tap target: the native box stays small + on a fine pointer and grows with the label under a coarse one. */ +@media (pointer: coarse) { + .encho-row__label { padding-inline: 6px; } + .encho-row__label input[type="checkbox"] { + width: 20px; + height: 20px; + accent-color: var(--accent); + } } .encho-row__stepper { display: flex; @@ -7927,8 +8153,8 @@ abbr.msb-lab { text-decoration: none; cursor: help; } font-weight: 700; } .encho-row__btn { - min-width: 28px; - min-height: 28px; + min-width: max(28px, var(--tap-floor)); + min-height: max(28px, var(--tap-floor)); } /* --- Compact mode: chrome ------------------------------------------------ */ @@ -7951,7 +8177,8 @@ abbr.msb-lab { text-decoration: none; cursor: help; } gap: 12px; } .editor-modal--compact .editor-modal__body { - padding: 6px 14px; + --editor-body-pad-top: 6px; + padding: var(--editor-body-pad-top) 14px; gap: 4px; } .editor-modal--compact .editor-modal__foot { @@ -8385,35 +8612,15 @@ abbr.msb-lab { text-decoration: none; cursor: help; } to { opacity: 1; transform: translateY(0); } } -/* --- Roomy (non-compact) bout list scroll region ----------------------- */ -/* Make the modal frame a flex column so the head/foot stay anchored and - only the body (containing .team-bouts-scroll) participates in vertical - space. The base .editor-modal has overflow:auto so an oversize modal - spawns a single scrollbar on the outer frame; that double-scrolls when - we also scroll the bouts list. Switch to overflow:hidden + body flex - for the roomy team case only, compact modals never overflow. */ -.editor-modal--team:not(.editor-modal--compact) { - display: flex; - flex-direction: column; - overflow: hidden; -} -.editor-modal--team:not(.editor-modal--compact) .editor-modal__body { - flex: 1 1 auto; - min-height: 0; - overflow: hidden; - display: flex; - flex-direction: column; -} -.editor-modal--team:not(.editor-modal--compact) .team-bouts-scroll { - flex: 1 1 auto; - min-height: 0; - overflow-y: auto; - /* The .team-summary is rendered *after* this region in the flex column - (not inside it), so it always stays visible below the bout list in the - roomy layout. The position:sticky on .team-summary is relative to the - .editor-modal__body scroll container, which is most useful if the - compact modal ever overflows on a very small screen. */ -} +/* --- Bout list in the overlay ------------------------------------------- */ +/* The bouts flow in the modal body, which scrolls (.editor-modal__body) while + the team header and result band (.team-sheet-pin) stick at its top and the + footer stays below it, roomy (a team of six or more) and compact alike. The + roomy layout used to hide the body's overflow and scroll the bout list on + its own, to keep the band (then rendered after the bouts) in view; with the + band pinned at the top that list only got the height left over, which a + knockout's tie-breaker panel and the rows below it took almost entirely + (8px for a team of six on a 1180x820 screen). */ /* In compact mode the wrapper is layout-transparent, flow normally. */ .editor-modal--compact .team-bouts-scroll { display: contents; @@ -8483,9 +8690,11 @@ abbr.msb-lab { text-decoration: none; cursor: help; } /* Fixed-position container stacks all active announcement cards in the top-right corner. pointer-events:none on the container means empty space never blocks bracket/score affordances behind it; each card re-enables pointer events. - z-index 900 sits ABOVE normal viewer chrome (max 500) but BELOW the modal - layer (.modal-backdrop dialogs at 1000) so open dialogs stay modal and are - never obscured by announcement cards. */ + z-index 900 sits ABOVE normal viewer chrome (max 200, the tooltips) but BELOW + the modal layer (every .modal-backdrop, the overlay score editors and the shared + Modal among them, and the viewer's match modal and the at-court lineup panel, + at 1000; the confirm dialogs at 1100) so open dialogs stay modal and are never + obscured by announcement cards. */ @keyframes announcement-slide-in { from { opacity: 0; diff --git a/web-mobile/js/__tests__/admin_helpers.test.jsx b/web-mobile/js/__tests__/admin_helpers.test.jsx index 84a757268..14d146a32 100644 --- a/web-mobile/js/__tests__/admin_helpers.test.jsx +++ b/web-mobile/js/__tests__/admin_helpers.test.jsx @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest'; -import { sideName, hasBothSides, hasPoolOriginPlaceholder, isPendingBracketMatch, compMatchStats, bracketFullyComplete, normalizeDate, dmyToIso, isoToDmy, compareDmy, isValidDate, validateAndNormalizeDate, decideNumericUpdate, getScoreBtnClass, deriveTournamentDays, normalizeCourts, courtCount, resolveRoundIndex, DATE_ERR_INVALID_FORMAT, DATE_ERR_YEAR_RANGE, MIN_YEAR, MAX_YEAR, MAX_TEAM_SIZE, MAX_COURTS, MAX_RANK, MAX_TOURNAMENT_DURATION_DAYS } from '../admin_helpers.jsx'; +import { sideName, hasBothSides, hasPoolOriginPlaceholder, isPendingBracketMatch, compMatchStats, bracketFullyComplete, normalizeDate, dmyToIso, isoToDmy, compareDmy, isValidDate, validateAndNormalizeDate, decideNumericUpdate, getScoreBtnClass, deriveTournamentDays, normalizeCourts, courtCount, DATE_ERR_INVALID_FORMAT, DATE_ERR_YEAR_RANGE, MIN_YEAR, MAX_YEAR, MAX_TEAM_SIZE, MAX_COURTS, MAX_RANK, MAX_TOURNAMENT_DURATION_DAYS } from '../admin_helpers.jsx'; describe('sideName', () => { it('returns "" for null / undefined', () => { @@ -860,30 +860,3 @@ describe('courtCount', () => { expect(courtCount("AB")).toBe(1); }); }); - -describe('resolveRoundIndex', () => { - it('returns m.roundIndex when non-negative', () => { - expect(resolveRoundIndex({ roundIndex: 0 })).toBe(0); - expect(resolveRoundIndex({ roundIndex: 3 })).toBe(3); - }); - - it('falls back to numeric m.round when roundIndex is absent', () => { - expect(resolveRoundIndex({ round: 2 })).toBe(2); - }); - - it('clamps negative numeric round to 0 via fallback', () => { - expect(resolveRoundIndex({ round: -1 })).toBe(0); - }); - - it('returns 0 for pool matches with no roundIndex or numeric round', () => { - expect(resolveRoundIndex({ round: 'Pool A', status: 'completed' })).toBe(0); - }); - - it('returns 0 when match has no round fields at all', () => { - expect(resolveRoundIndex({})).toBe(0); - }); - - it('prefers roundIndex over numeric round', () => { - expect(resolveRoundIndex({ roundIndex: 1, round: 5 })).toBe(1); - }); -}); diff --git a/web-mobile/js/__tests__/admin_lineup_form.test.jsx b/web-mobile/js/__tests__/admin_lineup_form.test.jsx index 50f885ad7..877d86eb1 100644 --- a/web-mobile/js/__tests__/admin_lineup_form.test.jsx +++ b/web-mobile/js/__tests__/admin_lineup_form.test.jsx @@ -16,6 +16,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { makeReactive } from './helpers/reactive_react.js'; import { collectText } from './helpers/vdom.js'; +import { answered } from './helpers/team_members.js'; const realReact = global.React; @@ -74,8 +75,9 @@ describe('AdminLineup form (competition-admin Lineups, bc-tmid pass 3)', () => { fetchTeamLineup: vi.fn().mockResolvedValue(null), // nothing saved -> fresh form fetchSquads: vi.fn().mockResolvedValue({}), addTeamMember: vi.fn(), - renameTeamMember: vi.fn().mockResolvedValue(true), - clearTeamMember: vi.fn().mockResolvedValue(true), + // The server answers a member write with the member it holds, stamped. + renameTeamMember: vi.fn(async (_c, _t, id, name) => answered({ id, index: 1 }, { name })), + clearTeamMember: vi.fn(async (_c, _t, id) => answered({ id, index: 1 }, { name: '' })), putTeamLineup: vi.fn().mockResolvedValue({}), }; // bc-pnum: commitAdd (operation 2) now confirms before minting. Default @@ -107,7 +109,7 @@ describe('AdminLineup form (competition-admin Lineups, bc-tmid pass 3)', () => { global.window.API.fetchSquads.mockResolvedValue(opts.squads); } runtime.mount(AdminLineup, { - comp: opts.comp || COMP, team, round: 0, password: 'pw', showToast: vi.fn(), onClose: vi.fn(), + comp: opts.comp || COMP, team, password: 'pw', showToast: vi.fn(), onClose: vi.fn(), }); await flush(); return runtime.currentTree(); @@ -322,6 +324,9 @@ describe('AdminLineup form (competition-admin Lineups, bc-tmid pass 3)', () => { await flush(); expect(global.window.API.renameTeamMember).toHaveBeenCalledWith('comp-1', 'team-1', 'sq-sato', 'Sato-Renamed', 'pw'); + // The member the server answered is what the page shows: the members are not read + // again after the write (the server announces it to every device instead). + expect(global.window.API.fetchSquads).toHaveBeenCalledTimes(1); const tree3 = runtime.currentTree(); // The position's select value (the id) is unchanged... @@ -337,6 +342,22 @@ describe('AdminLineup form (competition-admin Lineups, bc-tmid pass 3)', () => { expect(call[5]['1']).toBe('sq-sato'); }); + // A member no one has named yet is named by its slot in the rename box's label, as + // everywhere else on this page (squadSlotLabel): built from the name alone, the + // label read "Rename " and told a screen reader nothing about which member it was. + it('labels the rename box of a member with no name by its slot', async () => { + const tree = await mountFor({ id: 'team-1', name: 'Tora A', number: 'T10' }, { + squads: { 'team-1': [{ id: 'sq-sato', index: 1, name: 'Sato' }, { id: 'sq-blank', index: 2, name: '' }] }, + }); + buttonNamed(squadRow(tree, 'sq-blank'), 'Rename').props.onClick(); + const blankField = findHosts(squadRow(runtime.currentTree(), 'sq-blank'), 'input')[0]; + expect(blankField.props['aria-label']).toBe('Rename T10.2'); + buttonNamed(squadRow(runtime.currentTree(), 'sq-blank'), 'Cancel').props.onClick(); + buttonNamed(squadRow(runtime.currentTree(), 'sq-sato'), 'Rename').props.onClick(); + const namedField = findHosts(squadRow(runtime.currentTree(), 'sq-sato'), 'input')[0]; + expect(namedField.props['aria-label'], 'a named member keeps its name').toBe('Rename Sato'); + }); + it('there is no member-removal control anywhere in the form', async () => { const tree = await mountFor({ id: 'team-1', name: 'Tora A', number: 'T10' }, { squads: { 'team-1': [{ id: 'sq-sato', index: 1, name: 'Sato' }] }, @@ -375,6 +396,7 @@ describe('AdminLineup form (competition-admin Lineups, bc-tmid pass 3)', () => { await flush(); expect(global.window.API.clearTeamMember).toHaveBeenCalledWith('comp-1', 'team-1', 'sq-sato', 'pw'); + expect(global.window.API.fetchSquads, 'the members are not read again after the write').toHaveBeenCalledTimes(1); const tree3 = runtime.currentTree(); expect(collectText(squadRow(tree3, 'sq-sato'))).not.toContain('Sato'); @@ -454,18 +476,29 @@ describe('AdminLineup form (competition-admin Lineups, bc-tmid pass 3)', () => { await flush(); const call = global.window.API.putTeamLineup.mock.calls.at(-1); - // putTeamLineup(compId, teamId, round, positionsOut, password, memberIdsOut) + // putTeamLineup(compId, teamId, round, positions, password, memberIds, changed): + // the changed position alone, a picked member that has no name yet with its id. expect(call[3]).toEqual({ '2': '' }); expect(call[5]).toEqual({ '2': 'sq-blank-2' }); + expect(call[6]).toEqual(['2']); }); + // A starting lineup is saved only once it differs from what was read (like a + // match's), so these two start from a lineup stored by name alone, before + // members had ids, and empty one position. + const NAMES_ONLY = { teamId: 'team-1', round: 0, positions: { 1: 'Sato', 2: 'Ito' }, saved: true }; + it('Save omits memberIds entirely when no position resolved to a squad member', async () => { - const tree = await mountFor({ id: 'team-1', name: 'Tora A', number: 'T10' }, { squads: {} }); - mainSaveButton(tree).props.onClick(); + const tree = await mountFor({ id: 'team-1', name: 'Tora A', number: 'T10' }, { squads: {}, lineup: NAMES_ONLY }); + positionSelect(tree, '2').props.onChange({ target: { value: '' } }); + mainSaveButton(runtime.currentTree()).props.onClick(); await flush(); const call = global.window.API.putTeamLineup.mock.calls.at(-1); - // putTeamLineup(compId, teamId, round, positionsOut, password, memberIdsOut) + // putTeamLineup(compId, teamId, round, positions, password, memberIds, changed): + // the cleared position goes as its empty name, which the server needs to be there. + expect(call[3]).toEqual({ 2: '' }); expect(call[5]).toBeUndefined(); + expect(call[6]).toEqual(['2']); }); // bc-cse gap closure: make a silent failure visible, without ever blocking. @@ -474,9 +507,10 @@ describe('AdminLineup form (competition-admin Lineups, bc-tmid pass 3)', () => { it('shows the squad-unavailable warning after a save that still succeeded, when the squad failed to load', async () => { global.window.API.fetchSquads.mockRejectedValue(new Error('network error')); - const tree = await mountFor({ id: 'team-1', name: 'Tora A', number: 'T10' }); + const tree = await mountFor({ id: 'team-1', name: 'Tora A', number: 'T10' }, { lineup: NAMES_ONLY }); - mainSaveButton(tree).props.onClick(); + positionSelect(tree, '2').props.onChange({ target: { value: '' } }); + mainSaveButton(runtime.currentTree()).props.onClick(); await flush(); const tree2 = runtime.currentTree(); @@ -553,7 +587,7 @@ describe('AdminLineup form (competition-admin Lineups, bc-tmid pass 3)', () => { expect(global.window.API.renameTeamMember).not.toHaveBeenCalled(); const tree3 = runtime.currentTree(); - expect(collectText(tree3)).toContain('is already at'); + expect(collectText(tree3)).toContain('Yamada is already at Position 2.'); // Position 1 was never assigned Yamada's id: the refusal returns before // selectMember runs, so its add-row stays open rather than switching // back to a select showing a (wrongly) resolved value. diff --git a/web-mobile/js/__tests__/admin_lineup_member_resolver.test.jsx b/web-mobile/js/__tests__/admin_lineup_member_resolver.test.jsx index cad3007c7..f8e477d1c 100644 --- a/web-mobile/js/__tests__/admin_lineup_member_resolver.test.jsx +++ b/web-mobile/js/__tests__/admin_lineup_member_resolver.test.jsx @@ -6,7 +6,11 @@ // mounting anything. import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; -import { resolveMemberIdForName, resolveMemberIdsForPositions, memberIdentityWarning, blankMemberForPosition } from '../admin_lineup.jsx'; +import { resolveMemberIdForName, resolveMemberIdsForPositions, memberIdentityWarning, blankMemberForPosition, typedNameTarget } from '../admin_lineup.jsx'; +import { answered } from './helpers/team_members.js'; + +// A rename as the server answers it: the member, named, with the stamp it gave the write. +const renamingIn = (members) => vi.fn((_comp, _team, id, name) => Promise.resolve(answered(members.find((m) => m.id === id), { name }))); // The server's sentence for a participant's rename of a team member who // already has a name (errMemberAlreadyNamed, internal/mobileapp/handlers_squad.go). @@ -154,7 +158,7 @@ describe('resolveMemberIdsForPositions', () => { it('bc-dnst: a name typed into a named position (senpo) renames the blank member seeded at its index, never mints', async () => { const addTeamMember = vi.fn(); - const renameTeamMember = vi.fn().mockResolvedValue(true); + const renameTeamMember = renamingIn(BLANK_SQUAD); global.window.API = { addTeamMember, renameTeamMember }; const { memberIds, squad } = await resolveMemberIdsForPositions( 'comp1', 'team1', { senpo: 'Sato' }, BLANK_SQUAD, 'pw' @@ -165,9 +169,30 @@ describe('resolveMemberIdsForPositions', () => { expect(squad.find(m => m.id === 'm1').name).toBe('Sato'); }); + // The list an editor merges the squad into orders copies of a member by the stamp the + // server gave them, so the renamed member the resolver hands back must be the one the + // server answered: a copy it built from the typed name has no stamp, and any list + // read before the rename would win over it. + it('the member it renamed is the one the server answered, stamp included, never a copy built from the name typed', async () => { + const renameTeamMember = renamingIn(BLANK_SQUAD); + global.window.API = { addTeamMember: vi.fn(), renameTeamMember }; + const { squad } = await resolveMemberIdsForPositions('comp1', 'team1', { senpo: 'Sato' }, BLANK_SQUAD, 'pw'); + const answeredMember = await renameTeamMember.mock.results[0].value; + expect(squad.find(m => m.id === 'm1')).toBe(answeredMember); + expect(squad.find(m => m.id === 'm1').modifiedAt).toBeGreaterThan(0); + expect(squad.find(m => m.id === 'm2'), 'the member it did not touch is as it was').toBe(BLANK_SQUAD[1]); + }); + + it('the member it minted is the one the server answered, stamp included', async () => { + const minted = answered({ id: 'mem-new', index: 3, name: '' }, { name: 'Yamada' }); + global.window.API = { addTeamMember: vi.fn().mockResolvedValue(minted), renameTeamMember: vi.fn() }; + const { squad } = await resolveMemberIdsForPositions('comp1', 'team1', { senpo: 'Yamada' }, [{ id: 'm1', index: 1, name: 'Ito' }], 'pw'); + expect(squad.find(m => m.id === 'mem-new')).toBe(minted); + }); + it('bc-dnst: a numeric position key ("2") renames the blank member seeded at that index', async () => { const addTeamMember = vi.fn(); - const renameTeamMember = vi.fn().mockResolvedValue(true); + const renameTeamMember = renamingIn(BLANK_SQUAD); global.window.API = { addTeamMember, renameTeamMember }; const { memberIds, squad } = await resolveMemberIdsForPositions( 'comp1', 'team1', { '2': 'Ito' }, BLANK_SQUAD, 'pw' @@ -238,12 +263,12 @@ describe('resolveMemberIdsForPositions', () => { // index-default fallback, not merely as a tie-break when they agree. it('bc-dnst: currentIds naming a blank member (not the index default) renames THAT member instead', async () => { const addTeamMember = vi.fn(); - const renameTeamMember = vi.fn().mockResolvedValue(true); - global.window.API = { addTeamMember, renameTeamMember }; const squad = [ { id: 'm1', index: 1, name: '' }, // senpo's own index default: must NOT be touched { id: 'm6', index: 6, name: '' }, // the reserve actually picked into senpo ]; + const renameTeamMember = renamingIn(squad); + global.window.API = { addTeamMember, renameTeamMember }; const { memberIds, squad: nextSquad } = await resolveMemberIdsForPositions( 'comp1', 'team1', { senpo: 'Picked Name' }, squad, 'pw', { senpo: 'm6' } ); @@ -304,6 +329,35 @@ describe('blankMemberForPosition', () => { }); }); +// typedNameTarget is the ONE order a name typed at a position is placed by: the resolver +// above, the Lineups page's add and the at-court panel's check before a Save (which +// asks it of a copy of the members and writes nothing) all drive it. +describe('typedNameTarget', () => { + const MEMBERS = [ + { id: 'm1', index: 1, name: 'Ito' }, + { id: 'm2', index: 2, name: '' }, + { id: 'm3', index: 3, name: '' }, + ]; + + it('is the member the name belongs to, whatever the position holds: nothing is written', () => { + expect(typedNameTarget(MEMBERS, '2', ' ito ', {})).toEqual({ member: MEMBERS[0], write: 'none' }); + }); + + it('is the unnamed member the position holds, else the one seeded for it, which the name is written to', () => { + expect(typedNameTarget(MEMBERS, '2', 'Mori', {})).toEqual({ member: MEMBERS[1], write: 'rename' }); + expect(typedNameTarget(MEMBERS, '2', 'Mori', { 2: 'm3' })).toEqual({ member: MEMBERS[2], write: 'rename' }); + }); + + it('is a member that does not exist yet when no member is unnamed at the position (the seeded one is fielded elsewhere, or named)', () => { + expect(typedNameTarget(MEMBERS, '2', 'Mori', { 1: 'm2' })).toEqual({ member: null, write: 'add' }); + expect(typedNameTarget(MEMBERS, '1', 'Mori', {})).toEqual({ member: null, write: 'add' }); + }); + + it('tries the name before the position: a name a member has is never written to another', () => { + expect(typedNameTarget(MEMBERS, '2', 'Ito', { 2: 'm2' }).write).toBe('none'); + }); +}); + // bc-cse gap closure: memberIdentityWarning is the ONE composer all three // lineup-writing surfaces (admin_lineup.jsx, admin_schedule_lineup.jsx, // admin_scoring_team.jsx) call to tell the operator a save's squad-member diff --git a/web-mobile/js/__tests__/admin_schedule_surface.test.jsx b/web-mobile/js/__tests__/admin_schedule_surface.test.jsx index d185771a4..7ec263f82 100644 --- a/web-mobile/js/__tests__/admin_schedule_surface.test.jsx +++ b/web-mobile/js/__tests__/admin_schedule_surface.test.jsx @@ -22,7 +22,6 @@ import { timeToMinutes, suggestRebalances, allMatchesCompleted, - pickCopySource, MatchLineupPanel, } from '../admin_schedule.jsx'; @@ -36,7 +35,6 @@ const ES_EXPORTS = { timeToMinutes, suggestRebalances, allMatchesCompleted, - pickCopySource, MatchLineupPanel, }; diff --git a/web-mobile/js/__tests__/api_client_match_lineup.test.jsx b/web-mobile/js/__tests__/api_client_match_lineup.test.jsx index acab64ab2..878431cc2 100644 --- a/web-mobile/js/__tests__/api_client_match_lineup.test.jsx +++ b/web-mobile/js/__tests__/api_client_match_lineup.test.jsx @@ -1,12 +1,13 @@ -// mp-bkg: tests for the three matchId-keyed lineup API helpers -// (fetchMatchLineup, putMatchLineup, deleteMatchLineup) in api_client.jsx. -// These mirror the round-scoped helpers: fetchMatchLineup turns a `saved: -// false` body into null (bc-k404: nothing saved is a 200, not a 404), the -// same rule fetchTeamLineup applies below, just against a different -// endpoint path (/match-lineups/:matchId vs /lineups/:round). +// mp-bkg: tests for the matchId-keyed lineup API helpers (fetchLineupInForce, +// putMatchLineup, deleteMatchLineup) in api_client.jsx. fetchLineupInForce turns +// a `saved: false` body into null (bc-k404: nothing saved is a 200, not a 404), +// the same rule fetchTeamLineup applies below, just against a different endpoint +// path (/lineup-in-force/:matchId vs /lineups/:round). import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { API } from '../api_client.jsx'; +import { FETCH_TIMEOUT_MS } from '../write_result.jsx'; +import { lineupReadFailure, LINEUP_READ_NO_ANSWER } from '../lineup_resolver.jsx'; // Minimal fetch stub that records the most recent call. function mockFetch(status, body) { @@ -19,44 +20,51 @@ function mockFetch(status, body) { ); } -describe('API.fetchMatchLineup', () => { +// The lineup a team fields at a match (operator ruling 2026-10-05): the server +// owns the rule; the client maps `saved: false` to null and hands the body back +// whole, source fields included. +describe('API.fetchLineupInForce', () => { let originalFetch; beforeEach(() => { originalFetch = global.fetch; }); afterEach(() => { global.fetch = originalFetch; }); - it('returns null when the server says nothing is saved (200, saved false)', async () => { + it('calls the correct /lineup-in-force/:matchId URL', async () => { + global.fetch = mockFetch(200, { positions: {}, saved: false }); + await API.fetchLineupInForce('c42', 't99', 'mx7'); + const [url] = global.fetch.mock.calls[0]; + expect(url).toBe('/api/competitions/c42/teams/t99/lineup-in-force/mx7'); + }); + + it('returns null when nothing is in force (200, saved false)', async () => { global.fetch = mockFetch(200, { teamId: 'team1', matchId: 'match1', positions: {}, saved: false }); - const result = await API.fetchMatchLineup('comp1', 'team1', 'match1'); - expect(result).toBeNull(); + expect(await API.fetchLineupInForce('comp1', 'team1', 'match1')).toBeNull(); }); - it('returns parsed body on 200', async () => { - const lineup = { teamId: 'team1', matchId: 'match1', positions: { senpo: 'Alice' } }; - global.fetch = mockFetch(200, lineup); - const result = await API.fetchMatchLineup('comp1', 'team1', 'match1'); - expect(result).toEqual(lineup); + it('returns the body whole, a carried lineup\'s source included', async () => { + const carried = { teamId: 'team1', matchId: 'earlier', round: 0, positions: { senpo: 'Alice' }, sourceMatchId: 'earlier', saved: true }; + global.fetch = mockFetch(200, carried); + expect(await API.fetchLineupInForce('comp1', 'team1', 'match1')).toEqual(carried); }); - it('saved true with empty positions is a lineup', async () => { - const lineup = { teamId: 'team1', matchId: 'match1', positions: {}, saved: true }; - global.fetch = mockFetch(200, lineup); - const result = await API.fetchMatchLineup('comp1', 'team1', 'match1'); - expect(result).toEqual(lineup); + it('keeps a Lineups-page lineup\'s round 0, the starting lineup, as a source', async () => { + const starting = { teamId: 'team1', round: 0, positions: { senpo: 'Alice' }, sourceRound: 0, saved: true }; + global.fetch = mockFetch(200, starting); + const result = await API.fetchLineupInForce('comp1', 'team1', 'match1'); + expect(result.sourceRound).toBe(0); }); - it('calls the correct /match-lineups/:matchId URL', async () => { - global.fetch = mockFetch(200, {}); - await API.fetchMatchLineup('c42', 't99', 'mx7'); - const [url] = global.fetch.mock.calls[0]; - expect(url).toBe('/api/competitions/c42/teams/t99/match-lineups/mx7'); + it('saved true with empty positions is a lineup', async () => { + const lineup = { teamId: 'team1', matchId: 'match1', positions: {}, sourceMatchId: 'match1', saved: true }; + global.fetch = mockFetch(200, lineup); + expect(await API.fetchLineupInForce('comp1', 'team1', 'match1')).toEqual(lineup); }); it('throws on an error answer, 404 included, with the server\'s message', async () => { global.fetch = mockFetch(500, { error: 'internal' }); - await expect(API.fetchMatchLineup('c1', 't1', 'm1')).rejects.toThrow('internal'); + await expect(API.fetchLineupInForce('c1', 't1', 'm1')).rejects.toThrow('internal'); global.fetch = mockFetch(404, { error: 'competition not found' }); - await expect(API.fetchMatchLineup('c1', 't1', 'm1')).rejects.toThrow('competition not found'); + await expect(API.fetchLineupInForce('c1', 't1', 'm1')).rejects.toThrow('competition not found'); }); }); @@ -65,30 +73,29 @@ describe('API.fetchTeamLineup', () => { beforeEach(() => { originalFetch = global.fetch; }); afterEach(() => { global.fetch = originalFetch; }); - it('calls the correct URL without fallback', async () => { + it('calls the correct URL, with no query', async () => { global.fetch = mockFetch(200, { teamId: 't1', round: 1, positions: {}, saved: false }); await API.fetchTeamLineup('c1', 't1', 1); const [url] = global.fetch.mock.calls[0]; expect(url).toBe('/api/competitions/c1/teams/t1/lineups/1'); }); - it('calls the correct URL with ?fallback=best', async () => { - global.fetch = mockFetch(200, { teamId: 't1', round: 1, positions: {}, saved: false }); - await API.fetchTeamLineup('c1', 't1', 1, { fallback: true }); + it('reads round 0, a team\'s starting lineup, from the round route', async () => { + global.fetch = mockFetch(200, { teamId: 't1', round: 0, positions: {}, saved: false }); + await API.fetchTeamLineup('c1', 't1', 0); const [url] = global.fetch.mock.calls[0]; - expect(url).toBe('/api/competitions/c1/teams/t1/lineups/1?fallback=best'); + expect(url).toBe('/api/competitions/c1/teams/t1/lineups/0'); }); - it('returns null when saved is false, with or without fallback', async () => { + it('returns null when saved is false', async () => { global.fetch = mockFetch(200, { teamId: 't1', round: 1, positions: {}, saved: false }); expect(await API.fetchTeamLineup('c1', 't1', 1)).toBeNull(); - expect(await API.fetchTeamLineup('c1', 't1', 1, { fallback: true })).toBeNull(); }); it('returns the body when saved is true', async () => { const lineup = { teamId: 't1', round: 0, positions: { senpo: 'Alice' }, saved: true }; global.fetch = mockFetch(200, lineup); - const result = await API.fetchTeamLineup('c1', 't1', 1, { fallback: true }); + const result = await API.fetchTeamLineup('c1', 't1', 0); expect(result).toEqual(lineup); }); @@ -103,6 +110,81 @@ describe('API.fetchTeamLineup', () => { }); }); +// Both reads are bounded like every sibling request (the deadline covers the +// body too): a read the server never answers must end, with the timed-out error +// lineupReadFailure words, or an editor stays on "Loading lineup…" until the +// browser gives up on the connection. +describe('reading a lineup when the server does not answer', () => { + const reads = [ + ['fetchLineupInForce', () => API.fetchLineupInForce('c1', 't1', 'm1')], + ['fetchTeamLineup', () => API.fetchTeamLineup('c1', 't1', 0)], + ]; + let originalFetch; + beforeEach(() => { originalFetch = global.fetch; }); + afterEach(() => { global.fetch = originalFetch; vi.useRealTimers(); }); + + // What a read has come to so far, without waiting on it. + function watch(read) { + const seen = { outcome: 'waiting' }; + read().then((lineup) => { seen.outcome = lineup; }, (error) => { seen.outcome = error; }); + return seen; + } + + it.each(reads)('%s rejects with the timed-out error when the request is never answered, at the deadline', async (_name, read) => { + vi.useFakeTimers(); + global.fetch = vi.fn(() => new Promise(() => {})); + const seen = watch(read); + await vi.advanceTimersByTimeAsync(FETCH_TIMEOUT_MS - 1); + expect(seen.outcome).toBe('waiting'); + await vi.advanceTimersByTimeAsync(2); + expect(seen.outcome).toBeInstanceOf(Error); + expect(seen.outcome.timedOut).toBe(true); + expect(lineupReadFailure(seen.outcome)).toBe(LINEUP_READ_NO_ANSWER); + }); + + it.each(reads)('%s gives up on an answer whose body never completes, too', async (_name, read) => { + vi.useFakeTimers(); + global.fetch = vi.fn(() => Promise.resolve({ ok: true, status: 200, json: () => new Promise(() => {}) })); + const seen = watch(read); + await vi.advanceTimersByTimeAsync(FETCH_TIMEOUT_MS + 1); + expect(seen.outcome.timedOut).toBe(true); + }); + + it.each(reads)('%s aborts the request it gave up on, which frees its connection', async (_name, read) => { + vi.useFakeTimers(); + global.fetch = vi.fn((_url, { signal }) => new Promise((_resolve, reject) => { + signal.addEventListener('abort', () => reject(new DOMException('aborted', 'AbortError'))); + })); + const seen = watch(read); + await vi.advanceTimersByTimeAsync(FETCH_TIMEOUT_MS + 1); + expect(global.fetch.mock.calls[0][1].signal.aborted).toBe(true); + expect(seen.outcome.timedOut).toBe(true); + }); + + it.each(reads)('%s lets a connection that is down through as the browser\'s own error, which lineupReadFailure words', async (_name, read) => { + global.fetch = vi.fn(() => Promise.reject(new TypeError('Failed to fetch'))); + const error = await read().catch((e) => e); + expect(error).toBeInstanceOf(TypeError); + expect(error.timedOut).toBeUndefined(); + expect(lineupReadFailure(error)).toBe(LINEUP_READ_NO_ANSWER); + }); + + it.each(reads)('%s still throws the server\'s own message, or a plain sentence when it sends none', async (_name, read) => { + global.fetch = mockFetch(404, { error: 'competition not found' }); + await expect(read()).rejects.toThrow('competition not found'); + global.fetch = mockFetch(500, {}); + await expect(read()).rejects.toThrow('Failed to load lineup'); + }); + + // _fetchJson reads an unreadable body as {}. A 200 that is not JSON (a venue's + // sign-in page answering for the server) must stay a failed read, never an + // empty lineup: a Save composed on it would blank the lineup it stands for. + it.each(reads)('%s does not hand an unreadable answer on as a lineup', async (_name, read) => { + global.fetch = vi.fn(() => Promise.resolve({ ok: true, status: 200, json: () => Promise.reject(new SyntaxError('Unexpected token < in JSON')) })); + await expect(read()).rejects.toThrow('The lineup could not be read. Check the connection and try again.'); + }); +}); + describe('API.putMatchLineup', () => { let originalFetch; beforeEach(() => { originalFetch = global.fetch; }); @@ -228,3 +310,142 @@ describe('API.deleteMatchLineup', () => { .rejects.toThrow('internal error'); }); }); + +// A removal is bounded like every sibling write (the deadline covers the body +// too), and answers in a plain sentence when the server cannot be reached: the +// editor shows it as it is, never the browser's "Failed to fetch". +describe('removing a lineup when the server does not answer', () => { + const NOT_REMOVED = 'The lineup was not removed: the server did not answer. Check the connection and try again.'; + const removals = [ + ['deleteMatchLineup', () => API.deleteMatchLineup('c1', 't1', 'm1', 'pw')], + ]; + let originalFetch; + beforeEach(() => { originalFetch = global.fetch; }); + afterEach(() => { global.fetch = originalFetch; vi.useRealTimers(); }); + + it.each(removals)('%s rejects with a plain sentence when the connection is down', async (_name, remove) => { + global.fetch = vi.fn(() => Promise.reject(new TypeError('Failed to fetch'))); + const err = await remove().catch((e) => e); + expect(err.message).toBe(NOT_REMOVED); + expect(err.message).not.toMatch(/Failed to fetch/); + }); + + it.each(removals)('%s sends its request with an abort signal, so it can be given up on', async (_name, remove) => { + global.fetch = vi.fn(() => Promise.resolve({ ok: true, status: 204 })); + await remove(); + const [, opts] = global.fetch.mock.calls[0]; + expect(opts.method).toBe('DELETE'); + expect(opts.signal).toBeInstanceOf(AbortSignal); + }); + + it.each(removals)('%s gives up on a request that is never answered, at the deadline', async (_name, remove) => { + vi.useFakeTimers(); + global.fetch = vi.fn((_url, { signal }) => new Promise((_resolve, reject) => { + signal.addEventListener('abort', () => reject(new DOMException('aborted', 'AbortError'))); + })); + let settled = false; + const pending = remove().catch((e) => e).finally(() => { settled = true; }); + await vi.advanceTimersByTimeAsync(FETCH_TIMEOUT_MS - 1); + expect(settled).toBe(false); + await vi.advanceTimersByTimeAsync(2); + expect((await pending).message).toBe(NOT_REMOVED); + }); + + it.each(removals)('%s gives up on an answer whose body never completes, too', async (_name, remove) => { + vi.useFakeTimers(); + global.fetch = vi.fn(() => Promise.resolve({ ok: false, status: 500, json: () => new Promise(() => {}) })); + const pending = remove().catch((e) => e); + await vi.advanceTimersByTimeAsync(FETCH_TIMEOUT_MS + 1); + expect((await pending).message).toBe(NOT_REMOVED); + }); + + it.each(removals)('%s still says a refusal in the server\'s own words, and a missing lineup is removed already', async (_name, remove) => { + global.fetch = vi.fn(() => Promise.resolve({ + ok: false, status: 409, json: () => Promise.resolve({ error: 'locked', message: 'The competition has finished.' }), + })); + const err = await remove().catch((e) => e); + expect(err.message).toBe('The competition has finished.'); + expect(err.code).toBe('locked'); + + global.fetch = vi.fn(() => Promise.resolve({ ok: false, status: 404, json: () => Promise.resolve({ error: 'not found' }) })); + expect(await remove()).toBe(true); + }); +}); + +// Is a save of this lineup still queued? A queued save replays after anything sent +// now, so a removal made meanwhile would be undone by it: the editors hold "Use the +// previous match's lineup" until it has gone out. That is all the answer is for: +// a new save of the lineup joins the queued one and names what it changed, so +// nothing is composed on what is queued (lineup_save_queue.test.jsx). +describe('API.queuedLineupSave', () => { + let originalFetch; + beforeEach(() => { + originalFetch = global.fetch; + localStorage.removeItem('bc_write_queue'); + API.clearQueue(); + }); + afterEach(async () => { + global.fetch = originalFetch; + // Drain the immediate background retry the enqueue fires (it fails against the + // same rejecting mock), then cancel any backoff timer it left. + await Promise.resolve(); await Promise.resolve(); await Promise.resolve(); + API.clearQueue(); + }); + const offline = () => { global.fetch = vi.fn(() => Promise.reject(new TypeError('network error'))); }; + + it('is false while nothing is queued', () => { + expect(API.queuedLineupSave('c1', 't1', { matchId: 'm1' })).toBe(false); + expect(API.queuedLineupSave('c1', 't1', { round: 0 })).toBe(false); + }); + + it('is true for the match whose own lineup save is queued, and for that match of that team only', async () => { + offline(); + expect(await API.putMatchLineup('c1', 't-q', 'm-q', { senpo: 'Bob' }, 'pw', undefined, ['senpo'])).toEqual({ queued: true }); + expect(API.queuedLineupSave('c1', 't-q', { matchId: 'm-q' })).toBe(true); + expect(API.queuedLineupSave('c1', 't-q', { matchId: 'm-other' })).toBe(false); + expect(API.queuedLineupSave('c1', 't-other', { matchId: 'm-q' })).toBe(false); + expect(API.queuedLineupSave('c2', 't-q', { matchId: 'm-q' })).toBe(false); + // Neither the team's starting lineup nor another round. + expect(API.queuedLineupSave('c1', 't-q', { round: 0 })).toBe(false); + }); + + it('is true for a queued starting lineup (round 0), and for that round only', async () => { + offline(); + await API.putTeamLineup('c1', 't-q', 0, { senpo: 'Bob' }, 'pw', undefined, ['senpo']); + expect(API.queuedLineupSave('c1', 't-q', { round: 0 })).toBe(true); + expect(API.queuedLineupSave('c1', 't-q', { round: 1 })).toBe(false); + expect(API.queuedLineupSave('c1', 't-q', { matchId: 'm-q' })).toBe(false); + }); + + it('stays true when a later save joined the queued one: the lineup still has one save queued', async () => { + offline(); + await API.putMatchLineup('c1', 't-q', 'm-q', { senpo: 'Bob' }, 'pw', { senpo: 'mem-1' }, ['senpo']); + await API.putMatchLineup('c1', 't-q', 'm-q', { jiho: 'Amy' }, 'pw', undefined, ['jiho']); + expect(API.queuedLineupSave('c1', 't-q', { matchId: 'm-q' })).toBe(true); + }); + + it('is true when the save clears every position, a queued save of a lineup being a queued save', async () => { + offline(); + await API.putMatchLineup('c1', 't-q', 'm-q', {}, 'pw'); + expect(API.queuedLineupSave('c1', 't-q', { matchId: 'm-q' })).toBe(true); + }); + + it('asks about the very queue entry a save is queued under: the same key the save used', async () => { + offline(); + await API.putMatchLineup('c1', 't-q', 'm-q', { senpo: 'Bob' }, 'pw'); + const keys = JSON.parse(localStorage.getItem('bc_write_queue')).map(([key]) => key); + expect(keys.some((key) => key.startsWith('lineup:c1:t-q:match:m-q'))).toBe(true); + }); + + it('is false again once the queue is cleared', async () => { + offline(); + await API.putMatchLineup('c1', 't-q', 'm-q', { senpo: 'Bob' }, 'pw'); + expect(API.queuedLineupSave('c1', 't-q', { matchId: 'm-q' })).toBe(true); + API.clearQueue(); + expect(API.queuedLineupSave('c1', 't-q', { matchId: 'm-q' })).toBe(false); + }); + + it('asks about no lineup at all when it is given no target, and does not throw', () => { + expect(API.queuedLineupSave('c1', 't1')).toBe(false); + }); +}); diff --git a/web-mobile/js/__tests__/api_client_squad.test.jsx b/web-mobile/js/__tests__/api_client_squad.test.jsx index 4038d000f..e31c877cc 100644 --- a/web-mobile/js/__tests__/api_client_squad.test.jsx +++ b/web-mobile/js/__tests__/api_client_squad.test.jsx @@ -6,6 +6,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { API } from '../api_client.jsx'; +import { FETCH_TIMEOUT_MS } from '../write_result.jsx'; function mockFetch(status, body) { return vi.fn(() => @@ -54,8 +55,8 @@ describe('API.addTeamMember', () => { beforeEach(() => { originalFetch = global.fetch; }); afterEach(() => { global.fetch = originalFetch; }); - it('POSTs the name and returns the minted member', async () => { - const created = { id: 'm2', index: 2, name: 'Ito' }; + it('POSTs the name and returns the minted member, with the stamp the server gave it', async () => { + const created = { id: 'm2', index: 2, name: 'Ito', modifiedAt: 1700000000123 }; global.fetch = mockFetch(201, created); const result = await API.addTeamMember('c1', 'team-1', 'Ito', 'pw'); const [url, opts] = global.fetch.mock.calls[0]; @@ -77,14 +78,15 @@ describe('API.renameTeamMember', () => { beforeEach(() => { originalFetch = global.fetch; }); afterEach(() => { global.fetch = originalFetch; }); - it('PUTs the new name to the member endpoint and resolves true on 204', async () => { - global.fetch = vi.fn(() => Promise.resolve({ ok: true, status: 204 })); + it('PUTs the new name to the member endpoint and returns the member the server answers, with its stamp', async () => { + const renamed = { id: 'm1', index: 1, name: 'Sato-Renamed', modifiedAt: 1700000000456 }; + global.fetch = mockFetch(200, renamed); const result = await API.renameTeamMember('c1', 'team-1', 'm1', 'Sato-Renamed', 'pw'); const [url, opts] = global.fetch.mock.calls[0]; expect(url).toBe('/api/competitions/c1/teams/team-1/members/m1'); expect(opts.method).toBe('PUT'); expect(JSON.parse(opts.body)).toEqual({ name: 'Sato-Renamed' }); - expect(result).toBe(true); + expect(result).toEqual(renamed); }); it('throws on a 404 (member id does not resolve)', async () => { @@ -104,6 +106,7 @@ describe('the member writes throw a refusal\'s sentence, not its code', () => { it.each([ ['add', (API) => API.addTeamMember('c1', 'team-1', 'Ito', '')], ['rename', (API) => API.renameTeamMember('c1', 'team-1', 'm1', 'Ito', '')], + ['clear', (API) => API.clearTeamMember('c1', 'team-1', 'm1', '')], ])('the %s', async (_name, send) => { global.fetch = mockFetch(409, { error: 'result_finalized', message: 'This match has finished. Contact the tournament organizer.' }); await expect(send(API)).rejects.toThrow('This match has finished. Contact the tournament organizer.'); @@ -115,6 +118,7 @@ describe('the member writes throw a refusal\'s sentence, not its code', () => { it.each([ ['add', (API) => API.addTeamMember('c1', 'team-1', 'Ito', '')], ['rename', (API) => API.renameTeamMember('c1', 'team-1', 'm1', 'Ito', '')], + ['clear', (API) => API.clearTeamMember('c1', 'team-1', 'm1', '')], ])('the %s keeps a refusal\'s code on the error it throws', async (_name, send) => { global.fetch = mockFetch(409, { error: 'member_already_named', message: 'This team member already has a name. Ask the tournament organizer to change it.' }); const err = await send(API).catch((e) => e); @@ -125,6 +129,7 @@ describe('the member writes throw a refusal\'s sentence, not its code', () => { it.each([ ['add', (API) => API.addTeamMember('c1', 'team-1', 'Ito', '')], ['rename', (API) => API.renameTeamMember('c1', 'team-1', 'm1', 'Ito', '')], + ['clear', (API) => API.clearTeamMember('c1', 'team-1', 'm1', '')], ])('the %s leaves no code on a refusal that is only a message', async (_name, send) => { global.fetch = mockFetch(404, { error: 'team member not found' }); const err = await send(API).catch((e) => e); @@ -133,19 +138,93 @@ describe('the member writes throw a refusal\'s sentence, not its code', () => { }); }); +// The team score sheet holds every name box disabled, and the side's lineup +// announcements back, while a member write is out, so one the server never answers +// would keep naming off until the browser gave up on the connection. Every member +// write (an add, a rename, a cleared name) is bounded like every sibling request (the +// deadline covers the body too) and ends in a plain sentence the sheet shows as it is. +describe('the member writes when the server does not answer', () => { + const sends = [ + ['addTeamMember', () => API.addTeamMember('c1', 'team-1', 'Ito', 'pw'), 'The team member was not added'], + ['renameTeamMember', () => API.renameTeamMember('c1', 'team-1', 'm1', 'Ito', 'pw'), 'The team member was not renamed'], + ['clearTeamMember', () => API.clearTeamMember('c1', 'team-1', 'm1', 'pw'), 'The team member\'s name was not cleared'], + ]; + let originalFetch; + beforeEach(() => { originalFetch = global.fetch; }); + afterEach(() => { global.fetch = originalFetch; vi.useRealTimers(); }); + + // What a write has come to so far, without waiting on it. + function watch(send) { + const seen = { outcome: 'waiting' }; + send().then((value) => { seen.outcome = value; }, (error) => { seen.outcome = error; }); + return seen; + } + const sentence = (notDone) => `${notDone}: the server did not answer. Check the connection and try again.`; + + it.each(sends)('%s gives up at the deadline when the request is never answered', async (_name, send, notDone) => { + vi.useFakeTimers(); + global.fetch = vi.fn(() => new Promise(() => {})); + const seen = watch(send); + await vi.advanceTimersByTimeAsync(FETCH_TIMEOUT_MS - 1); + expect(seen.outcome).toBe('waiting'); + await vi.advanceTimersByTimeAsync(2); + expect(seen.outcome).toBeInstanceOf(Error); + expect(seen.outcome.message).toBe(sentence(notDone)); + }); + + it.each(sends)('%s gives up on an answer whose body never completes, too', async (_name, send, notDone) => { + vi.useFakeTimers(); + global.fetch = vi.fn(() => Promise.resolve({ ok: true, status: 201, json: () => new Promise(() => {}) })); + const seen = watch(send); + await vi.advanceTimersByTimeAsync(FETCH_TIMEOUT_MS + 1); + expect(seen.outcome).toBeInstanceOf(Error); + expect(seen.outcome.message).toBe(sentence(notDone)); + }); + + it.each(sends)('%s aborts the request it gave up on, which frees its connection', async (_name, send) => { + vi.useFakeTimers(); + global.fetch = vi.fn((_url, { signal }) => new Promise((_resolve, reject) => { + signal.addEventListener('abort', () => reject(new DOMException('aborted', 'AbortError'))); + })); + const seen = watch(send); + await vi.advanceTimersByTimeAsync(FETCH_TIMEOUT_MS + 1); + expect(global.fetch.mock.calls[0][1].signal.aborted).toBe(true); + expect(seen.outcome).toBeInstanceOf(Error); + }); + + it.each(sends)('%s answers a connection that is down in the same sentence, not the browser\'s own', async (_name, send, notDone) => { + global.fetch = vi.fn(() => Promise.reject(new TypeError('Failed to fetch'))); + await expect(send()).rejects.toThrow(sentence(notDone)); + }); + + // _fetchJson reads an unreadable body as {}, which cannot be told from a member the + // server sent: an answer with no id is no member, so the write is a failed one. + it.each(sends)('%s does not hand an unreadable answer on as a member', async (_name, send, notDone) => { + global.fetch = vi.fn(() => Promise.resolve({ ok: true, status: 200, json: () => Promise.reject(new SyntaxError('Unexpected token < in JSON')) })); + await expect(send()).rejects.toThrow(sentence(notDone)); + }); + + it.each(sends)('%s does not take an answer with no member in it for a success', async (_name, send, notDone) => { + global.fetch = vi.fn(() => Promise.resolve({ ok: true, status: 204 })); + await expect(send()).rejects.toThrow(sentence(notDone)); + }); +}); + describe('API.clearTeamMember', () => { let originalFetch; beforeEach(() => { originalFetch = global.fetch; }); afterEach(() => { global.fetch = originalFetch; }); - it('DELETEs the member endpoint and resolves true on 204', async () => { - global.fetch = vi.fn(() => Promise.resolve({ ok: true, status: 204 })); + it('DELETEs the member endpoint and returns the member the server answers, nameless, with its stamp', async () => { + const cleared = { id: 'm1', index: 1, name: '', modifiedAt: 1700000000789 }; + global.fetch = mockFetch(200, cleared); const result = await API.clearTeamMember('c1', 'team-1', 'm1', 'pw'); const [url, opts] = global.fetch.mock.calls[0]; expect(url).toBe('/api/competitions/c1/teams/team-1/members/m1'); expect(opts.method).toBe('DELETE'); expect(opts.headers['X-Tournament-Password']).toBe('pw'); - expect(result).toBe(true); + expect(opts.body, 'a clear sends no body').toBeUndefined(); + expect(result).toEqual(cleared); }); it('throws with the server message on a 409 once the competition has started', async () => { diff --git a/web-mobile/js/__tests__/bracket_round_label_agreement.test.jsx b/web-mobile/js/__tests__/bracket_round_label_agreement.test.jsx index 3555fbc31..c103794ec 100644 --- a/web-mobile/js/__tests__/bracket_round_label_agreement.test.jsx +++ b/web-mobile/js/__tests__/bracket_round_label_agreement.test.jsx @@ -129,7 +129,7 @@ describe('row labels agree with bracket columns across a collapsed bye round (mp expect(phaseLabel(rounds[0][0], true, 0, 3, 'knockout')).toBe('Semifinals'); }); - it('keeps roundIndex RAW so lineup fetches still key on the backend round', () => { + it('keeps roundIndex RAW: the position in bracket.rounds, not the effective round the label names', () => { const rows = compMatches(comp()).filter((m) => m.phase === 'bracket'); const byId = Object.fromEntries(rows.map((m) => [m.id, m])); // The label moved to the effective round; the index must NOT follow it. diff --git a/web-mobile/js/__tests__/build_inline_lineup_write.test.jsx b/web-mobile/js/__tests__/build_inline_lineup_write.test.jsx index dfab60d29..229140e66 100644 --- a/web-mobile/js/__tests__/build_inline_lineup_write.test.jsx +++ b/web-mobile/js/__tests__/build_inline_lineup_write.test.jsx @@ -8,6 +8,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { buildInlineLineupWrite, mergeLineupIdsForPosition } from '../lineup_resolver.jsx'; +import { changedLineupSave } from '../lineup_save.jsx'; describe('mergeLineupIdsForPosition', () => { it('carries existing ids forward and sets the resolved id for the changed position', () => { @@ -61,6 +62,33 @@ describe('buildInlineLineupWrite', () => { expect(out.memberIds).toEqual({ senpo: 'mem-sato', jiho: 'mem-tanaka' }); }); + // Operator decision 2026-10-07, "Only changed positions": the write names the one + // position it changes and the server puts it on the lineup it holds. The rest of + // what comes back is the lineup as it will be, which the sheet shows. + it('names the one position it changes, and the body built from it carries that position alone', async () => { + global.window.AdminLineupHelpers = { resolveMemberIdsForPositions: vi.fn() }; + const member = { id: 'mem-tanaka', index: 2, name: 'Tanaka' }; + + const out = await buildInlineLineupWrite('comp1', 'team1', lineup, [], 'jiho', 'Tanaka', 'pw', member); + + expect(out.changed).toEqual(['jiho']); + expect(out.positions).toEqual({ senpo: 'Sato', jiho: 'Tanaka' }); + expect(changedLineupSave(out.positions, out.memberIds, out.changed)).toEqual({ + positions: { jiho: 'Tanaka' }, memberIds: { jiho: 'mem-tanaka' }, changed: ['jiho'], + }); + }); + + it('names a cleared position too: the body says it by its empty name, which the server needs to be there', async () => { + global.window.AdminLineupHelpers = { resolveMemberIdsForPositions: vi.fn() }; + + const out = await buildInlineLineupWrite('comp1', 'team1', lineup, [], 'senpo', '', 'pw'); + + expect(out.changed).toEqual(['senpo']); + expect(changedLineupSave(out.positions, out.memberIds, out.changed)).toEqual({ + positions: { senpo: '' }, memberIds: {}, changed: ['senpo'], + }); + }); + it('mints a new member for a name not on the squad and uses the returned id', async () => { const resolveMemberIdsForPositions = vi.fn().mockResolvedValue({ memberIds: { taisho: 'mem-new' }, diff --git a/web-mobile/js/__tests__/helpers/lineup_server.js b/web-mobile/js/__tests__/helpers/lineup_server.js new file mode 100644 index 000000000..e67a71298 --- /dev/null +++ b/web-mobile/js/__tests__/helpers/lineup_server.js @@ -0,0 +1,143 @@ +// lineup_server.js: a server that follows the lineup save contract (operator +// decision 2026-10-07, "Only changed positions"), for the tests that run the +// real API client, or an editor over a stubbed one, against it. +// +// PUT .../match-lineups/:matchId and PUT .../lineups/:round take +// { positions, memberIds?, changed? }: +// - `changed` absent: the body is the whole lineup and replaces the stored one. +// - `changed` present (a non-empty array of position keys): only those keys of +// positions and memberIds are read, whatever else the body carries. positions[p] +// must be there (else 400); name "" with no id clears the position, anything else +// sets it (the id is dropped when memberIds has none for p). The changes land on +// the lineup the server holds when the write arrives: a match's own lineup, else the +// lineup in force there (the carry rule), else an empty one. +// - one member at two positions of the composed lineup is a 400 naming both. +// The answer is the whole lineup as stored after the write. +import { vi } from 'vitest'; + +const has = (object, key) => Object.prototype.hasOwnProperty.call(object || {}, key); + +// applyLineupPut: the contract's composition, on its own: `base` is `{ positions, +// memberIds }` as the server holds it, `body` is what was sent. Answers +// { status, body }: the composed lineup on 200, `{ error }` on 400. +export function applyLineupPut(base, body) { + const positions = { ...(base && base.positions) }; + const memberIds = { ...(base && base.memberIds) }; + if (body.changed === undefined) { + Object.keys(positions).forEach((key) => delete positions[key]); + Object.keys(memberIds).forEach((key) => delete memberIds[key]); + Object.assign(positions, body.positions || {}); + Object.assign(memberIds, body.memberIds || {}); + } else { + if (!Array.isArray(body.changed) || body.changed.length === 0) { + return { status: 400, body: { error: 'a save that names changed positions names at least one' } }; + } + for (const key of body.changed) { + if (!has(body.positions, key)) return { status: 400, body: { error: `positions holds nothing for the changed position ${key}` } }; + } + for (const key of body.changed) { + const name = body.positions[key]; + const id = (body.memberIds && body.memberIds[key]) || ''; + if (name === '' && id === '') { + delete positions[key]; + delete memberIds[key]; + } else { + positions[key] = name; + if (id) memberIds[key] = id; else delete memberIds[key]; + } + } + } + const at = {}; + for (const [key, id] of Object.entries(memberIds)) { + if (!id) continue; + if (at[id]) return { status: 400, body: { error: `member ${id} is at positions ${at[id]} and ${key}` } }; + at[id] = key; + } + return { status: 200, body: { positions, memberIds } }; +} + +// lineupPutStub: a stand-in for API.putMatchLineup / API.putTeamLineup, for the tests +// that drive an editor over a stubbed API. It follows the contract over `held`, the +// lineup the server holds ({ positions, memberIds }): a save with `changed` lands its +// changed positions on it, one without replaces it. `held` is updated by every save +// that lands, which is what the next save then composes on, and a test sets it to what +// another device saved. The answer is the whole lineup the server then holds; a save +// the server refuses (one member at two positions) rejects with its message, as the +// API does. +export function lineupPutStub(held) { + return vi.fn((_comp, _team, _scope, positions, _password, memberIds, changed) => { + const done = applyLineupPut(held, { positions, memberIds, changed }); + if (done.status !== 200) return Promise.reject(new Error(done.body.error)); + held.positions = done.body.positions; + held.memberIds = done.body.memberIds; + return Promise.resolve({ positions: { ...held.positions }, memberIds: { ...held.memberIds } }); + }); +} + +// lineupPutStubByTeam: the same for a sheet that writes the lineups of both its teams. +// `lineups` is a function that answers the map of what the server holds, by team id, +// each `{ positions, memberIds }` (a function, so a test that starts a new map in +// its beforeEach is followed). A team holding nothing yet is empty. +export function lineupPutStubByTeam(lineups) { + return vi.fn((_comp, teamId, _match, positions, _password, memberIds, changed) => { + const map = lineups(); + const done = applyLineupPut(map[teamId], { positions, memberIds, changed }); + if (done.status !== 200) return Promise.reject(new Error(done.body.error)); + map[teamId] = done.body; + return Promise.resolve({ positions: { ...done.body.positions }, memberIds: { ...done.body.memberIds } }); + }); +} + +const reply = (status, body) => ({ ok: status >= 200 && status < 300, status, json: () => Promise.resolve(body) }); + +// makeLineupServer: the routes of one team in one competition over a fetch stub. +// own the lineups saved for a match, by match id (a match's own lineup) +// starting the team's starting lineup (round 0), or null +// carried the lineup in force at a match that has none of its own, by match id +// members the answer of GET team-members +// `offline` makes every request reject like a connection that is down. `puts` lists +// the bodies of the PUTs that reached it, with their urls. +export function makeLineupServer({ comp = 'c1', team = 't1', own = {}, starting = null, carried = {}, members = {} } = {}) { + const server = { + comp, team, own: { ...own }, starting, carried: { ...carried }, members, offline: false, puts: [], + }; + const base = `/api/competitions/${comp}/teams/${team}`; + const withIds = (lineup) => ({ positions: { ...lineup.positions }, memberIds: { ...lineup.memberIds } }); + const whole = (lineup, extra) => ({ teamId: team, competitionId: comp, ...withIds(lineup), saved: true, ...extra }); + const inForce = (matchId) => server.own[matchId] || server.carried[matchId] || null; + + server.fetch = vi.fn((url, opts = {}) => { + if (server.offline) return Promise.reject(new TypeError('Failed to fetch')); + const method = opts.method || 'GET'; + const path = String(url).split('?')[0]; + if (path === '/api/time') return Promise.resolve(reply(200, { nowMs: Date.now() })); + if (path === `/api/competitions/${comp}/team-members`) return Promise.resolve(reply(200, { teamMembers: server.members })); + let m = path.match(new RegExp(`^${base}/lineup-in-force/([^/]+)$`)); + if (m && method === 'GET') { + const lineup = inForce(decodeURIComponent(m[1])); + return Promise.resolve(reply(200, lineup ? whole(lineup, { matchId: m[1] }) : { teamId: team, positions: {}, saved: false })); + } + m = path.match(new RegExp(`^${base}/lineups/(\\d+)$`)); + if (m && method === 'GET') { + return Promise.resolve(reply(200, server.starting ? whole(server.starting, { round: Number(m[1]) }) : { teamId: team, positions: {}, saved: false })); + } + if (m && method === 'PUT') { + const body = JSON.parse(opts.body); + server.puts.push({ url: path, body }); + const done = applyLineupPut(server.starting, body); + if (done.status === 200) server.starting = done.body; + return Promise.resolve(reply(done.status, done.status === 200 ? whole(done.body, { round: Number(m[1]) }) : done.body)); + } + m = path.match(new RegExp(`^${base}/match-lineups/([^/]+)$`)); + if (m && method === 'PUT') { + const matchId = decodeURIComponent(m[1]); + const body = JSON.parse(opts.body); + server.puts.push({ url: path, body }); + const done = applyLineupPut(inForce(matchId), body); + if (done.status === 200) server.own[matchId] = done.body; + return Promise.resolve(reply(done.status, done.status === 200 ? whole(done.body, { matchId }) : done.body)); + } + return Promise.resolve(reply(404, { error: `no route for ${method} ${path}` })); + }); + return server; +} diff --git a/web-mobile/js/__tests__/helpers/team_members.js b/web-mobile/js/__tests__/helpers/team_members.js new file mode 100644 index 000000000..213e46e43 --- /dev/null +++ b/web-mobile/js/__tests__/helpers/team_members.js @@ -0,0 +1,19 @@ +// What the server does to a team member, for the tests that mock the member writes. A +// member carries `modifiedAt`: 0 until it is created or named, then the stamp the server +// gave that write, which only grows for a member (max(now, stored + 1)). Every write +// (an add, a rename, a cleared name) answers the member as the server holds it, stamped, +// and a member an editor builds by hand from what was typed carries no stamp, so a mock +// that answers `true` or an unstamped copy models a server that does not exist. +let clock = 1_700_000_000_000; + +/** A stamp later than every one handed out so far. */ +export const nextStamp = () => { + clock += 1000; + return clock; +}; + +/** `member` as the server holds it once a write changed it by `changes`, stamped. */ +export const answered = (member, changes = {}) => ({ ...member, ...changes, modifiedAt: nextStamp() }); + +/** `member` named later than `than` was, as another device would have named it. */ +export const namedLater = (than, name) => ({ ...than, name, modifiedAt: (than.modifiedAt || 0) + 500 }); diff --git a/web-mobile/js/__tests__/lineup_changed_positions.test.jsx b/web-mobile/js/__tests__/lineup_changed_positions.test.jsx new file mode 100644 index 000000000..79c4379c8 --- /dev/null +++ b/web-mobile/js/__tests__/lineup_changed_positions.test.jsx @@ -0,0 +1,144 @@ +import { describe, it, expect } from 'vitest'; +import { changedLineupPositions, lineupDuplicateNote, alreadyPlacedNote } from '../lineup_resolver.jsx'; + +const KEYS = ['1', '2', '3']; +const side = (positions = {}, memberIds = {}) => ({ positions, memberIds }); + +describe('changedLineupPositions', () => { + it('reports nothing for identical sides', () => { + const s = side({ 1: 'Aoki', 2: 'Sato' }, { 1: 'mem-1' }); + expect(changedLineupPositions(s, side({ 1: 'Aoki', 2: 'Sato' }, { 1: 'mem-1' }), KEYS)).toEqual([]); + }); + + it('treats "" and a missing key as the same, for names and ids', () => { + expect(changedLineupPositions(side({ 1: '' }, { 1: '' }), side({}, {}), KEYS)).toEqual([]); + expect(changedLineupPositions(side(), side({ 2: '' }, { 2: '' }), KEYS)).toEqual([]); + }); + + it('ignores surrounding whitespace in a name', () => { + expect(changedLineupPositions(side({ 1: 'Aoki' }), side({ 1: ' Aoki ' }), KEYS)).toEqual([]); + }); + + it('reports a changed name', () => { + expect(changedLineupPositions(side({ 1: 'Aoki' }), side({ 1: 'Sato' }), KEYS)).toEqual(['1']); + }); + + it('reports a changed member id even when the name is the same', () => { + expect(changedLineupPositions(side({ 1: '' }, { 1: 'mem-1' }), side({ 1: '' }, { 1: 'mem-2' }), KEYS)).toEqual(['1']); + expect(changedLineupPositions(side({ 1: 'Aoki' }), side({ 1: 'Aoki' }, { 1: 'mem-1' }), KEYS)).toEqual(['1']); + }); + + it('reports a position that was emptied', () => { + expect(changedLineupPositions(side({ 1: 'Aoki' }, { 1: 'mem-1' }), side(), KEYS)).toEqual(['1']); + }); + + it('only looks at the given position keys, in their order', () => { + const base = side({ 1: 'A', 2: 'B', 9: 'X' }); + const cur = side({ 1: 'a', 2: 'b', 9: 'Y' }); + expect(changedLineupPositions(base, cur, ['2', '1'])).toEqual(['2', '1']); + }); + + it('tolerates a missing side', () => { + expect(changedLineupPositions(undefined, side({ 1: 'A' }), KEYS)).toEqual(['1']); + expect(changedLineupPositions(side({ 1: 'A' }), undefined, KEYS)).toEqual(['1']); + }); +}); + +// lineupDuplicateNote is asked of the lineup a Save leaves behind (the form with its +// changes, which is what the server composes on the lineup it holds), by both +// editors, so the refusal for one member at two positions reads alike. It names the +// position the operator did NOT change: the one that was already there. Of two +// they changed it names the one they picked the member for, never the box they typed +// a name into, and failing that the earlier. +describe('lineupDuplicateNote', () => { + const label = (key) => `Position ${key}`; + const note = (positions, memberIds, changed = [], typed = []) => lineupDuplicateNote(positions, memberIds, label, KEYS, changed, typed); + + it('is empty for a lineup that fields each member once, and for one with no ids at all', () => { + expect(note({ 1: 'Aoki', 2: 'Sato' }, { 1: 'mem-1', 2: 'mem-2' })).toBe(''); + expect(note({ 1: 'Aoki', 2: 'Sato' }, {})).toBe(''); + expect(note({ 1: 'Aoki' }, undefined)).toBe(''); + }); + + it('names the fighter and the position the operator did not change, whichever of the two comes first', () => { + const positions = { 1: 'Mori', 2: 'Mori' }; + const ids = { 1: 'mem-4', 2: 'mem-4' }; + expect(note(positions, ids, ['2'])).toBe('Mori is already at Position 1.'); + expect(note(positions, ids, ['1'])).toBe('Mori is already at Position 2.'); + }); + + it('does not depend on the order the ids were collected in: an id resolved last is still the operator\'s', () => { + const keys = ['senpo', 'jiho', 'chuken']; + const words = { senpo: 'Senpo', jiho: 'Jiho', chuken: 'Chuken' }; + // Senpo is stored; Jiho's id, resolved from a typed name, is added after the others. + const ids = { senpo: 'mem-4', chuken: 'mem-3' }; + ids.jiho = 'mem-4'; + const positions = { senpo: 'Mori', jiho: 'Mori', chuken: 'Ito' }; + expect(lineupDuplicateNote(positions, ids, (key) => words[key], keys, ['jiho'])).toBe('Mori is already at Senpo.'); + expect(lineupDuplicateNote(positions, ids, (key) => words[key], keys, ['senpo'])).toBe('Mori is already at Jiho.'); + }); + + it('names the earlier position when the operator changed both of them, or neither', () => { + const positions = { 1: 'Mori', 2: 'Mori' }; + const ids = { 1: 'mem-4', 2: 'mem-4' }; + expect(note(positions, ids, ['1', '2'])).toBe('Mori is already at Position 1.'); + expect(note(positions, ids, [])).toBe('Mori is already at Position 1.'); + expect(note(positions, ids, ['3'])).toBe('Mori is already at Position 1.'); + }); + + it('names the position the member was picked for, not the box a name was typed into, when the operator changed both', () => { + const positions = { 1: 'Mori', 2: 'Mori' }; + const ids = { 1: 'mem-4', 2: 'mem-4' }; + expect(note(positions, ids, ['1', '2'], ['1'])).toBe('Mori is already at Position 2.'); + expect(note(positions, ids, ['1', '2'], ['2'])).toBe('Mori is already at Position 1.'); + }); + + it('names the earlier position when the operator typed a name into both of them, or picked the member for both', () => { + const positions = { 1: 'Mori', 2: 'Mori' }; + const ids = { 1: 'mem-4', 2: 'mem-4' }; + expect(note(positions, ids, ['1', '2'], ['1', '2'])).toBe('Mori is already at Position 1.'); + expect(note(positions, ids, ['1', '2'], [])).toBe('Mori is already at Position 1.'); + }); + + it('names a position the operator did not change before it looks at what they typed', () => { + const positions = { 1: 'Mori', 2: 'Mori' }; + const ids = { 1: 'mem-4', 2: 'mem-4' }; + expect(note(positions, ids, ['1'], ['1'])).toBe('Mori is already at Position 2.'); + expect(note(positions, ids, ['2'], ['2'])).toBe('Mori is already at Position 1.'); + expect(note(positions, ids, ['2'], [])).toBe('Mori is already at Position 1.'); + }); + + it('takes the name from the position it names, and from the other when that one has none yet', () => { + expect(note({ 1: 'Mori', 2: 'mori' }, { 1: 'mem-4', 2: 'mem-4' }, ['2'])).toBe('Mori is already at Position 1.'); + expect(note({ 1: '', 2: 'Kato' }, { 1: 'mem-6', 2: 'mem-6' }, ['2'])).toBe('Kato is already at Position 1.'); + }); + + it('trims the name, and says "This fighter" for a placement that has none yet', () => { + expect(note({ 1: ' Aoki ', 2: 'Sato' }, { 1: 'mem-1', 2: 'mem-1' })).toBe('Aoki is already at Position 1.'); + expect(note({ 1: '', 2: '' }, { 1: 'mem-1', 2: 'mem-1' })).toBe('This fighter is already at Position 1.'); + expect(note({ 1: ' ', 2: 'Sato' }, { 1: 'mem-1', 2: 'mem-1' })).toBe('Sato is already at Position 1.'); + }); + + it('is not troubled by positions that hold no member', () => { + expect(note({ 1: 'Aoki', 2: 'Sato', 3: 'Ito' }, { 1: '', 2: '', 3: 'mem-3' })).toBe(''); + }); +}); + +// alreadyPlacedNote words that refusal for every writer: lineupDuplicateNote +// above, the score sheet's row pick and the Lineups page's add path. +describe('alreadyPlacedNote', () => { + it('names the fighter and the position that already holds them', () => { + expect(alreadyPlacedNote('Aoki', 'Senpo')).toBe('Aoki is already at Senpo.'); + }); + + it('trims the name', () => { + expect(alreadyPlacedNote(' Aoki ', 'Position 2')).toBe('Aoki is already at Position 2.'); + }); + + it('says "This fighter" for a name that is empty, blank or missing', () => { + expect(alreadyPlacedNote('', 'Senpo')).toBe('This fighter is already at Senpo.'); + expect(alreadyPlacedNote(' ', 'Senpo')).toBe('This fighter is already at Senpo.'); + expect(alreadyPlacedNote(undefined, 'Senpo')).toBe('This fighter is already at Senpo.'); + expect(alreadyPlacedNote(null, 'Senpo')).toBe('This fighter is already at Senpo.'); + }); +}); diff --git a/web-mobile/js/__tests__/lineup_draft.test.jsx b/web-mobile/js/__tests__/lineup_draft.test.jsx new file mode 100644 index 000000000..970e4a871 --- /dev/null +++ b/web-mobile/js/__tests__/lineup_draft.test.jsx @@ -0,0 +1,535 @@ +// lineup_draft.jsx keeps the position picks a lineup editor has not saved in +// this tab's sessionStorage and offers them back when the same lineup is opened +// again (bc-lnul). The hook reads the React global at call time and this suite's +// global React is a stub whose effects never run, so each test swaps the real +// one in and drives the hook through a small harness that holds `current` in +// state the way the editors do, taking a restore as one more setState. + +import RealReact from 'react'; +import { renderHook, act } from '@testing-library/react'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { useLineupDraft, lineupDraftKey } from '../lineup_draft.jsx'; + +const stubReact = global.React; +beforeEach(() => { global.React = RealReact; }); +afterEach(() => { + global.React = stubReact; + vi.unstubAllGlobals(); +}); + +const HOUR = 60 * 60 * 1000; +const KEYS = ['1', '2', '3']; +const KEY = lineupDraftKey({ compId: 'comp', teamId: 'team', matchId: 'Pool A-0' }); +const OTHER_KEY = lineupDraftKey({ compId: 'comp', teamId: 'team', matchId: 'Pool A-1' }); +const stored = (map, key = KEY) => JSON.parse(map.get(`bc.lineupDraft.v1:${key}`)); +const hasDraft = (map, key = KEY) => map.has(`bc.lineupDraft.v1:${key}`); +const seed = (map, draft, key = KEY) => map.set(`bc.lineupDraft.v1:${key}`, typeof draft === 'string' ? draft : JSON.stringify(draft)); + +const side = (positions = {}, memberIds = {}) => ({ + positions: { 1: '', 2: '', 3: '', ...positions }, + memberIds: { 1: '', 2: '', 3: '', ...memberIds }, +}); +const BLANK = side(); +const NAMED = side({ 1: 'Aoki', 2: 'Sato' }, { 1: 'mem-1', 2: 'mem-2' }); +const OTHER_NAMED = side({ 3: 'Ito' }, { 3: 'mem-3' }); + +function installStorage() { + const map = new Map(); + const storage = { + getItem: vi.fn((k) => (map.has(k) ? map.get(k) : null)), + setItem: vi.fn((k, v) => { map.set(k, String(v)); }), + removeItem: vi.fn((k) => { map.delete(k); }), + }; + vi.stubGlobal('sessionStorage', storage); + return { storage, map }; +} + +function useEditor({ key, ready, baseline, onRestore }) { + const [current, setCurrent] = RealReact.useState(baseline); + const draft = useLineupDraft({ + key, ready, baseline, current, positionKeys: KEYS, + onRestore: (restored) => { onRestore(restored); setCurrent(restored); }, + }); + return { draft, current, setCurrent }; +} + +// A fresh mount of the editor: the lineup loaded is `baseline` (what the server +// holds), and `ready` says the load has finished. +function mount(props = {}) { + const onRestore = vi.fn(); + const view = renderHook((p) => useEditor({ onRestore, ...p }), { + initialProps: { key: KEY, ready: true, baseline: BLANK, ...props }, + }); + return { ...view, onRestore }; +} + +describe('lineupDraftKey', () => { + it('names a match by its id and the starting lineup by "start"', () => { + expect(lineupDraftKey({ compId: 'c', teamId: 't', matchId: 'Pool A-0' })).toBe('c:t:match:Pool A-0'); + expect(lineupDraftKey({ compId: 'c', teamId: 't', matchId: '' })).toBe('c:t:start'); + expect(lineupDraftKey({ compId: 'c', teamId: 't' })).toBe('c:t:start'); + }); +}); + +describe('writing the draft', () => { + it('keeps what differs from the loaded lineup, with the lineup it was made against', () => { + const { map } = installStorage(); + const view = mount(); + expect(hasDraft(map)).toBe(false); + + act(() => view.result.current.setCurrent(NAMED)); + + expect(stored(map)).toMatchObject({ baseline: BLANK, current: NAMED }); + expect(Date.now() - stored(map).savedAt).toBeLessThan(5000); + }); + + it('removes the draft when the lineup is edited back to what was loaded', () => { + const { map } = installStorage(); + const view = mount(); + act(() => view.result.current.setCurrent(NAMED)); + expect(hasDraft(map)).toBe(true); + act(() => view.result.current.setCurrent(BLANK)); + expect(hasDraft(map)).toBe(false); + }); + + it('removes the draft when the loaded lineup becomes the edited one (a save)', () => { + const { map } = installStorage(); + const view = mount(); + act(() => view.result.current.setCurrent(NAMED)); + expect(hasDraft(map)).toBe(true); + view.rerender({ key: KEY, ready: true, baseline: NAMED }); + expect(hasDraft(map)).toBe(false); + }); + + it('writes nothing for a lineup nobody changed', () => { + const { storage } = installStorage(); + const view = mount({ ready: false }); + view.rerender({ key: KEY, ready: true, baseline: BLANK }); + expect(storage.setItem).not.toHaveBeenCalled(); + }); +}); + +describe('restoring the draft', () => { + it('offers an unsaved draft back when the same lineup is opened again', () => { + const { map } = installStorage(); + const first = mount(); + act(() => first.result.current.setCurrent(NAMED)); + first.unmount(); + expect(hasDraft(map)).toBe(true); + + const again = mount(); + + expect(again.onRestore).toHaveBeenCalledTimes(1); + expect(again.onRestore).toHaveBeenCalledWith(NAMED); + expect(again.result.current.current).toEqual(NAMED); + expect(again.result.current.draft).toMatchObject({ restored: true, stale: null }); + // Restoring is not a save: the draft stays until the lineup is saved or discarded. + expect(hasDraft(map)).toBe(true); + }); + + it('touches nothing until the lineup has loaded, then restores and keeps the draft', () => { + const { storage, map } = installStorage(); + seed(map, { savedAt: Date.now(), baseline: BLANK, current: NAMED }); + + // While loading, the editor shows an empty lineup that equals its empty baseline: + // a sync step that ran now would remove the draft before it is read. + const view = mount({ ready: false }); + expect(view.onRestore).not.toHaveBeenCalled(); + expect(storage.setItem).not.toHaveBeenCalled(); + expect(storage.removeItem).not.toHaveBeenCalled(); + expect(hasDraft(map)).toBe(true); + + view.rerender({ key: KEY, ready: true, baseline: BLANK }); + + expect(view.onRestore).toHaveBeenCalledTimes(1); + expect(view.onRestore).toHaveBeenCalledWith(NAMED); + expect(view.result.current.current).toEqual(NAMED); + expect(view.result.current.draft.restored).toBe(true); + expect(stored(map)).toMatchObject({ baseline: BLANK, current: NAMED }); + }); + + it('reads the draft once: later renders do not restore it again', () => { + const { map } = installStorage(); + seed(map, { savedAt: Date.now(), baseline: BLANK, current: NAMED }); + const view = mount(); + act(() => view.result.current.setCurrent(side({ 1: 'Aoki' }, { 1: 'mem-1' }))); + view.rerender({ key: KEY, ready: true, baseline: BLANK }); + expect(view.onRestore).toHaveBeenCalledTimes(1); + }); + + it('stops reporting restored once the lineup is saved', () => { + const { map } = installStorage(); + seed(map, { savedAt: Date.now(), baseline: BLANK, current: NAMED }); + const view = mount(); + expect(view.result.current.draft.restored).toBe(true); + + view.rerender({ key: KEY, ready: true, baseline: NAMED }); + + expect(view.result.current.draft.restored).toBe(false); + expect(hasDraft(map)).toBe(false); + }); + + it('stops reporting restored once the lineup is edited back to what was loaded', () => { + const { map } = installStorage(); + seed(map, { savedAt: Date.now(), baseline: BLANK, current: NAMED }); + const view = mount(); + act(() => view.result.current.setCurrent(BLANK)); + expect(view.result.current.draft.restored).toBe(false); + expect(hasDraft(map)).toBe(false); + }); + + it('restores only the positions of the lineup it is asked about', () => { + const { map } = installStorage(); + seed(map, { + savedAt: Date.now(), + baseline: { positions: { 1: '', 9: 'Gone' }, memberIds: {} }, + current: { positions: { 1: 'Aoki', 9: 'Gone' }, memberIds: { 1: 'mem-1' } }, + }); + const view = mount(); + expect(view.onRestore).toHaveBeenCalledWith(side({ 1: 'Aoki' }, { 1: 'mem-1' })); + }); +}); + +describe('a lineup saved meanwhile', () => { + const SAVED_MEANWHILE = side({ 3: 'Mori' }, { 3: 'mem-4' }); + + it('is not overwritten: the draft is dropped and what was not restored is named', () => { + const { map } = installStorage(); + seed(map, { + savedAt: Date.now(), + baseline: side({ 3: 'Ito' }, { 3: 'mem-3' }), + current: side({ 1: 'Aoki', 2: 'Sato', 3: 'Ito' }, { 1: 'mem-1', 2: 'mem-2', 3: 'mem-3' }), + }); + + const view = mount({ baseline: SAVED_MEANWHILE }); + + expect(view.onRestore).not.toHaveBeenCalled(); + expect(view.result.current.current).toEqual(SAVED_MEANWHILE); + // Position 3 was not changed by the draft, so its name is not listed. + expect(view.result.current.draft).toMatchObject({ restored: false, stale: { names: ['Aoki', 'Sato'] } }); + expect(hasDraft(map)).toBe(false); + }); + + it('still says so when the draft only cleared positions', () => { + const { map } = installStorage(); + seed(map, { savedAt: Date.now(), baseline: side({ 1: 'Aoki' }, { 1: 'mem-1' }), current: BLANK }); + const view = mount({ baseline: SAVED_MEANWHILE }); + expect(view.result.current.draft.stale).toEqual({ names: [] }); + expect(hasDraft(map)).toBe(false); + }); + + it('lists a name once however many positions hold it', () => { + const { map } = installStorage(); + seed(map, { savedAt: Date.now(), baseline: BLANK, current: side({ 1: 'Aoki', 2: 'Aoki' }) }); + const view = mount({ baseline: SAVED_MEANWHILE }); + expect(view.result.current.draft.stale).toEqual({ names: ['Aoki'] }); + }); + + it('is dropped without a word when the saved lineup already holds what the draft held', () => { + const { map } = installStorage(); + seed(map, { savedAt: Date.now(), baseline: BLANK, current: NAMED }); + const view = mount({ baseline: NAMED }); + expect(view.onRestore).not.toHaveBeenCalled(); + expect(view.result.current.draft).toMatchObject({ restored: false, stale: null }); + expect(hasDraft(map)).toBe(false); + }); + + // The operator's own save, sent after they left (or queued, and written later), holds + // what the draft held with the member id the save attached to a name they typed in: + // the draft never had one. That is their save having landed, not another device's change. + describe('that is the operator\'s own save, landed', () => { + const TYPED = side({ 1: 'Mori' }); + const SAVED_WITH_ID = side({ 1: 'Mori' }, { 1: 'mem-4' }); + + it('is dropped without a word: the id a save attached to a name typed in is no change', () => { + const { map } = installStorage(); + seed(map, { savedAt: Date.now(), baseline: BLANK, current: TYPED }); + const view = mount({ baseline: SAVED_WITH_ID }); + expect(view.onRestore).not.toHaveBeenCalled(); + expect(view.result.current.draft).toMatchObject({ restored: false, stale: null }); + expect(hasDraft(map)).toBe(false); + }); + + it('holds whatever member id the name was given, also over the id of the member it replaced', () => { + const { map } = installStorage(); + seed(map, { savedAt: Date.now(), baseline: NAMED, current: side({ 1: 'Mori', 2: 'Sato' }, { 1: 'mem-1', 2: 'mem-2' }) }); + const view = mount({ baseline: side({ 1: 'Mori', 2: 'Sato' }, { 1: 'mem-4', 2: 'mem-2' }) }); + expect(view.result.current.draft.stale).toBeNull(); + expect(hasDraft(map)).toBe(false); + }); + + it('still names what was not restored when the saved lineup holds another name', () => { + const { map } = installStorage(); + seed(map, { savedAt: Date.now(), baseline: BLANK, current: TYPED }); + const view = mount({ baseline: side({ 1: 'Kato' }, { 1: 'mem-5' }) }); + expect(view.result.current.draft.stale).toEqual({ names: ['Mori'] }); + }); + + it('still names what was not restored when a position the draft holds by number alone holds another member', () => { + const { map } = installStorage(); + seed(map, { savedAt: Date.now(), baseline: BLANK, current: side({}, { 1: 'mem-6' }) }); + const view = mount({ baseline: side({}, { 1: 'mem-7' }) }); + expect(view.result.current.draft.stale).toEqual({ names: [] }); + }); + + it('holds a position the draft holds by number alone when the saved lineup holds that member', () => { + const { map } = installStorage(); + seed(map, { savedAt: Date.now(), baseline: BLANK, current: side({}, { 1: 'mem-6' }) }); + const view = mount({ baseline: side({}, { 1: 'mem-6' }) }); + expect(view.result.current.draft.stale).toBeNull(); + expect(hasDraft(map)).toBe(false); + }); + }); +}); + +// "Not restored, the lineup changed since" names the names the draft could not +// restore from the lineup as it was opened. Re-entering one of several is no +// answer to the others, so it stays through every edit and goes when the editor +// says the lineup was saved or given up (`resolved`), or when the lineup is left. +describe('the "not restored" notice', () => { + const SAVED_MEANWHILE = side({ 3: 'Mori' }, { 3: 'mem-4' }); + + function openWithDroppedDraft() { + const { map } = installStorage(); + seed(map, { savedAt: Date.now(), baseline: BLANK, current: NAMED }); + const view = mount({ baseline: SAVED_MEANWHILE }); + expect(view.result.current.draft.stale).toEqual({ names: ['Aoki', 'Sato'] }); + return { view, map }; + } + + it('stays while nothing is done to the lineup', () => { + const { view } = openWithDroppedDraft(); + view.rerender({ key: KEY, ready: true, baseline: SAVED_MEANWHILE }); + view.rerender({ key: KEY, ready: true, baseline: SAVED_MEANWHILE }); + expect(view.result.current.draft.stale).toEqual({ names: ['Aoki', 'Sato'] }); + }); + + it('stays when the operator changes the lineup, and the change is a draft of its own', () => { + const { view, map } = openWithDroppedDraft(); + act(() => view.result.current.setCurrent(side({ 1: 'Aoki', 3: 'Mori' }, { 1: 'mem-1', 3: 'mem-4' }))); + expect(view.result.current.draft.stale).toEqual({ names: ['Aoki', 'Sato'] }); + expect(stored(map).current.positions[1]).toBe('Aoki'); + }); + + it('stays through one name after another being entered, so the rest are still named', () => { + const { view } = openWithDroppedDraft(); + act(() => view.result.current.setCurrent(side({ 1: 'Aoki', 3: 'Mori' }, { 1: 'mem-1', 3: 'mem-4' }))); + act(() => view.result.current.setCurrent(side({ 1: 'Aoki', 2: 'Sato', 3: 'Mori' }, { 1: 'mem-1', 2: 'mem-2', 3: 'mem-4' }))); + expect(view.result.current.draft.stale).toEqual({ names: ['Aoki', 'Sato'] }); + }); + + it('stays when the operator changes it and puts it back: the names are still not in the lineup', () => { + const { view } = openWithDroppedDraft(); + act(() => view.result.current.setCurrent(NAMED)); + act(() => view.result.current.setCurrent(SAVED_MEANWHILE)); + expect(view.result.current.draft.stale).toEqual({ names: ['Aoki', 'Sato'] }); + }); + + it('stays when the lineup it was opened on is read again and has changed once more', () => { + const { view } = openWithDroppedDraft(); + view.rerender({ key: KEY, ready: true, baseline: OTHER_NAMED }); + act(() => view.result.current.setCurrent(OTHER_NAMED)); + expect(view.result.current.draft.stale).toEqual({ names: ['Aoki', 'Sato'] }); + }); + + it('goes once the editor says the lineup was saved', () => { + const { view, map } = openWithDroppedDraft(); + act(() => view.result.current.setCurrent(NAMED)); + act(() => view.result.current.draft.resolved()); + view.rerender({ key: KEY, ready: true, baseline: NAMED }); + + expect(view.result.current.draft.stale).toBeNull(); + expect(hasDraft(map)).toBe(false); + }); + + it('does not come back for the next change', () => { + const { view } = openWithDroppedDraft(); + act(() => view.result.current.draft.resolved()); + act(() => view.result.current.setCurrent(NAMED)); + act(() => view.result.current.setCurrent(SAVED_MEANWHILE)); + act(() => view.result.current.setCurrent(OTHER_NAMED)); + expect(view.result.current.draft.stale).toBeNull(); + }); + + it('is not shown again when the operator leaves the lineup and comes back to it', () => { + const { view } = openWithDroppedDraft(); + view.rerender({ key: OTHER_KEY, ready: true, baseline: SAVED_MEANWHILE }); + expect(view.result.current.draft.stale).toBeNull(); + view.rerender({ key: KEY, ready: true, baseline: SAVED_MEANWHILE }); + expect(view.result.current.draft.stale).toBeNull(); + }); +}); + +describe('an old or unreadable draft', () => { + it.each([ + ['13 hours old', 13 * HOUR], + ['just over 12 hours old', 12 * HOUR + 1000], + ])('is ignored and removed when it is %s', (_name, age) => { + const { map } = installStorage(); + seed(map, { savedAt: Date.now() - age, baseline: BLANK, current: NAMED }); + const view = mount(); + expect(view.onRestore).not.toHaveBeenCalled(); + expect(view.result.current.draft).toMatchObject({ restored: false, stale: null }); + expect(hasDraft(map)).toBe(false); + }); + + it('is still offered when it is just under 12 hours old', () => { + const { map } = installStorage(); + seed(map, { savedAt: Date.now() - (12 * HOUR - 60 * 1000), baseline: BLANK, current: NAMED }); + const view = mount(); + expect(view.onRestore).toHaveBeenCalledWith(NAMED); + }); + + it.each([ + ['not JSON', 'not json'], + ['null', 'null'], + ['a number', '12'], + ['a draft with no time', JSON.stringify({ baseline: BLANK, current: NAMED })], + ['a draft with no lineup', JSON.stringify({ savedAt: Date.now(), baseline: BLANK })], + ['a draft that changed nothing', JSON.stringify({ savedAt: Date.now(), baseline: NAMED, current: NAMED })], + ])('is dropped when it is %s', (_name, raw) => { + const { map } = installStorage(); + seed(map, raw); + const view = mount(); + expect(view.onRestore).not.toHaveBeenCalled(); + expect(view.result.current.draft).toMatchObject({ restored: false, stale: null }); + expect(hasDraft(map)).toBe(false); + }); +}); + +describe('a storage that throws', () => { + const boom = () => { throw new Error('storage refused'); }; + + it('never throws out of the hook when it cannot read', () => { + const { storage } = installStorage(); + storage.getItem.mockImplementation(boom); + let view; + expect(() => { view = mount(); }).not.toThrow(); + expect(view.result.current.draft).toMatchObject({ restored: false, stale: null }); + expect(() => act(() => view.result.current.setCurrent(NAMED))).not.toThrow(); + expect(view.result.current.current).toEqual(NAMED); + }); + + it('never throws out of the hook when it cannot write (private mode, quota)', () => { + const { storage } = installStorage(); + storage.setItem.mockImplementation(boom); + const view = mount(); + expect(() => act(() => view.result.current.setCurrent(NAMED))).not.toThrow(); + expect(view.result.current.current).toEqual(NAMED); + }); + + it('never throws out of the hook when it cannot remove, and discard still restores the lineup', () => { + const { storage, map } = installStorage(); + storage.removeItem.mockImplementation(boom); + seed(map, { savedAt: Date.now(), baseline: BLANK, current: NAMED }); + const view = mount(); + expect(() => act(() => view.result.current.draft.discard())).not.toThrow(); + expect(view.result.current.current).toEqual(BLANK); + }); + + it('never throws out of the hook when reaching sessionStorage itself throws', () => { + const original = Object.getOwnPropertyDescriptor(globalThis, 'sessionStorage'); + Object.defineProperty(globalThis, 'sessionStorage', { configurable: true, get: boom }); + try { + let view; + expect(() => { view = mount(); }).not.toThrow(); + expect(() => act(() => view.result.current.setCurrent(NAMED))).not.toThrow(); + expect(() => act(() => view.result.current.draft.discard())).not.toThrow(); + expect(view.result.current.current).toEqual(BLANK); + } finally { + if (original) Object.defineProperty(globalThis, 'sessionStorage', original); + else delete globalThis.sessionStorage; + } + }); + + it('works with no sessionStorage at all', () => { + vi.stubGlobal('sessionStorage', undefined); + const view = mount(); + expect(() => act(() => view.result.current.setCurrent(NAMED))).not.toThrow(); + expect(view.result.current.draft).toMatchObject({ restored: false, stale: null }); + }); +}); + +describe('discard', () => { + it('puts the loaded lineup back and removes the draft', () => { + const { map } = installStorage(); + seed(map, { savedAt: Date.now(), baseline: BLANK, current: NAMED }); + const view = mount(); + expect(view.result.current.current).toEqual(NAMED); + + act(() => view.result.current.draft.discard()); + + expect(view.onRestore).toHaveBeenLastCalledWith(BLANK); + expect(view.result.current.current).toEqual(BLANK); + expect(view.result.current.draft.restored).toBe(false); + expect(hasDraft(map)).toBe(false); + }); + + it('leaves the next edit a draft of its own, never one that was restored', () => { + const { map } = installStorage(); + seed(map, { savedAt: Date.now(), baseline: BLANK, current: NAMED }); + const view = mount(); + act(() => view.result.current.draft.discard()); + + act(() => view.result.current.setCurrent(side({ 3: 'Ito' }, { 3: 'mem-3' }))); + + expect(view.result.current.draft.restored).toBe(false); + expect(stored(map).current).toEqual(OTHER_NAMED); + }); +}); + +describe('an editor that takes the restore only as a callback', () => { + it('is told it was restored at once, and discard removes the draft by itself', () => { + const { map } = installStorage(); + seed(map, { savedAt: Date.now(), baseline: BLANK, current: NAMED }); + const onRestore = vi.fn(); + const view = renderHook(() => useLineupDraft({ + key: KEY, ready: true, baseline: BLANK, current: BLANK, positionKeys: KEYS, onRestore, + })); + + expect(onRestore).toHaveBeenCalledWith(NAMED); + expect(view.result.current.restored).toBe(true); + + act(() => view.result.current.discard()); + + expect(onRestore).toHaveBeenLastCalledWith(BLANK); + expect(view.result.current.restored).toBe(false); + expect(hasDraft(map)).toBe(false); + }); +}); + +describe('another lineup', () => { + it('does not take the previous key\'s draft', () => { + const { map } = installStorage(); + seed(map, { savedAt: Date.now(), baseline: BLANK, current: NAMED }, KEY); + + const view = mount({ key: OTHER_KEY }); + + expect(view.onRestore).not.toHaveBeenCalled(); + expect(view.result.current.draft).toMatchObject({ restored: false, stale: null }); + expect(hasDraft(map, KEY)).toBe(true); + }); + + it('starts over when the key changes under a mounted hook: the new key\'s draft, and nothing written across', () => { + const { map } = installStorage(); + seed(map, { savedAt: Date.now(), baseline: BLANK, current: NAMED }, KEY); + seed(map, { savedAt: Date.now(), baseline: BLANK, current: OTHER_NAMED }, OTHER_KEY); + const view = mount({ key: KEY }); + expect(view.result.current.draft.restored).toBe(true); + const firstDraft = map.get(`bc.lineupDraft.v1:${KEY}`); + const otherDraft = map.get(`bc.lineupDraft.v1:${OTHER_KEY}`); + + // The other lineup is loading: the editor still shows the first one's picks. + view.rerender({ key: OTHER_KEY, ready: false, baseline: BLANK }); + expect(view.result.current.draft).toMatchObject({ restored: false, stale: null }); + expect(map.get(`bc.lineupDraft.v1:${OTHER_KEY}`)).toBe(otherDraft); + + act(() => view.result.current.setCurrent(BLANK)); + view.rerender({ key: OTHER_KEY, ready: true, baseline: BLANK }); + + expect(view.onRestore).toHaveBeenLastCalledWith(OTHER_NAMED); + expect(view.result.current.current).toEqual(OTHER_NAMED); + expect(view.result.current.draft.restored).toBe(true); + expect(map.get(`bc.lineupDraft.v1:${KEY}`)).toBe(firstDraft); + }); +}); diff --git a/web-mobile/js/__tests__/lineup_draft_members.test.jsx b/web-mobile/js/__tests__/lineup_draft_members.test.jsx new file mode 100644 index 000000000..5e46e1f4e --- /dev/null +++ b/web-mobile/js/__tests__/lineup_draft_members.test.jsx @@ -0,0 +1,119 @@ +// mergeMembers (lineup_draft.jsx) is the ONE rule every list of a team's members goes +// through, in both lineup editors and on the team score sheet: the server stamps a member +// (`modifiedAt`) each time it is created or named, and a list that arrives is merged with +// the one shown by member id, keeping for each member the copy with the larger stamp. Which +// list arrived last, or which read began first, says nothing about which copy is newer. + +import { describe, it, expect } from 'vitest'; +import { mergeMembers } from '../lineup_draft.jsx'; + +const member = (n, name = '', modifiedAt = 0) => ({ id: `m${n}`, index: n, name, modifiedAt }); +const names = (members) => members.map((m) => `${m.index}:${m.name}`); + +describe('mergeMembers: which members are in the list', () => { + it('holds every member of both lists, once, in member number order', () => { + const shown = [member(1), member(2, 'Kai'), member(6, 'Added elsewhere')]; + const arriving = [member(2, 'Kai'), member(1), member(4, 'Newcomer')]; + + expect(names(mergeMembers(shown, arriving))).toEqual(['1:', '2:Kai', '4:Newcomer', '6:Added elsewhere']); + }); + + it('keeps a member shown that the list lacks: no screen removes a member', () => { + expect(names(mergeMembers([member(1, 'Ren'), member(2, 'Ito', 7)], [member(1, 'Ren')]))).toEqual(['1:Ren', '2:Ito']); + }); + + it('brings in a member the list holds that is not shown', () => { + expect(names(mergeMembers([member(1, 'Ren')], [member(1, 'Ren'), member(2, 'Mei', 9)]))).toEqual(['1:Ren', '2:Mei']); + }); + + it('gives an empty list as it is merged with nothing', () => { + expect(mergeMembers([], [])).toEqual([]); + expect(names(mergeMembers([], [member(2, 'Kai'), member(1)]))).toEqual(['1:', '2:Kai']); + expect(names(mergeMembers([member(2, 'Kai'), member(1)], []))).toEqual(['1:', '2:Kai']); + }); + + it('changes neither list it is given', () => { + const shown = [member(2, 'Kai', 3), member(1)]; + const arriving = [member(1, 'Ito', 5)]; + const before = JSON.stringify([shown, arriving]); + + mergeMembers(shown, arriving); + + expect(JSON.stringify([shown, arriving])).toBe(before); + }); +}); + +describe('mergeMembers: the copy of a member that is kept', () => { + it('is the arriving one when its stamp is larger', () => { + const merged = mergeMembers([member(1, 'Ito', 100)], [member(1, 'Itoh', 200)]); + + expect(merged).toEqual([member(1, 'Itoh', 200)]); + }); + + it('is the one shown when its stamp is larger: an older list never undoes a newer rename', () => { + const merged = mergeMembers([member(1, 'Itoh', 200)], [member(1, 'Ito', 100)]); + + expect(merged).toEqual([member(1, 'Itoh', 200)]); + }); + + it('is the arriving one on a tie', () => { + const shown = [member(1, 'Ito', 100)]; + const arriving = [member(1, 'Ito', 100)]; + + const merged = mergeMembers(shown, arriving); + + expect(merged).toEqual([member(1, 'Ito', 100)]); + expect(merged[0]).toBe(arriving[0]); + }); + + it('is the arriving one when neither carries a stamp: members nobody has touched', () => { + const shown = [{ id: 'm1', index: 1, name: 'Old' }]; + const arriving = [{ id: 'm1', index: 1, name: 'New' }]; + + expect(mergeMembers(shown, arriving)).toEqual(arriving); + }); + + it('reads a missing stamp as 0: any stamped copy outranks one with none, either way round', () => { + const stamped = member(1, 'Ito', 5); + const unstamped = { id: 'm1', index: 1, name: 'Before' }; + + expect(mergeMembers([stamped], [unstamped])).toEqual([stamped]); + expect(mergeMembers([unstamped], [stamped])).toEqual([stamped]); + }); + + it('reads a stamp of 0 as no stamp at all', () => { + expect(mergeMembers([member(1, 'Ito', 0)], [member(1, '', 0)])).toEqual([member(1, '', 0)]); + expect(mergeMembers([member(1, 'Ito', 3)], [member(1, '', 0)])).toEqual([member(1, 'Ito', 3)]); + }); + + it('keeps a cleared name when it is the newer copy: a list that still names the member does not bring the name back', () => { + const cleared = member(1, '', 50); + + expect(mergeMembers([cleared], [member(1, 'Ito', 40)])).toEqual([cleared]); + expect(mergeMembers([member(1, 'Ito', 40)], [cleared])).toEqual([cleared]); + }); + + it('is judged member by member', () => { + const shown = [member(1, 'Ito', 10), member(2, 'Mori', 3)]; + const arriving = [member(1, 'Old', 5), member(2, 'Mori Kato', 8)]; + + expect(names(mergeMembers(shown, arriving))).toEqual(['1:Ito', '2:Mori Kato']); + }); + + it('settles three copies the same whichever order they arrive in', () => { + const own = member(1, 'Ito', 120); + const elsewhereLater = member(1, 'Itoh', 150); + const read = member(1, 'Old', 0); + const orders = [ + [own, elsewhereLater, read], + [read, own, elsewhereLater], + [elsewhereLater, read, own], + [own, read, elsewhereLater], + ]; + + orders.forEach((order) => { + const merged = order.reduce((shown, copy) => mergeMembers(shown, [copy]), []); + expect(merged).toEqual([elsewhereLater]); + }); + }); +}); diff --git a/web-mobile/js/__tests__/lineup_editors_read_first.test.jsx b/web-mobile/js/__tests__/lineup_editors_read_first.test.jsx new file mode 100644 index 000000000..00ad021a8 --- /dev/null +++ b/web-mobile/js/__tests__/lineup_editors_read_first.test.jsx @@ -0,0 +1,279 @@ +// Both lineup editors (the at-court panel's MatchLineupSideEditor and the Lineups +// page's AdminLineup) write a lineup only after it was READ. Before, a read that +// failed left an empty form with Save live: on the starting lineup (always +// saveable) one tap PUT {} over round 0 and every match carrying it lost its +// names, and on a match one pick wrote a partial lineup of its own that later +// matches then carried. +// +// This runtime calls the handlers straight off the vnodes, so it checks that +// save() itself refuses, not only that the button is disabled. It cannot see +// inside a child component, so the two the editors hand their source line and +// their read problem to are called by name (both are hook-free). + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { makeReactive } from './helpers/reactive_react.js'; +import { collectText, expandNamed } from './helpers/vdom.js'; + +const realReact = global.React; + +function childrenOf(node) { + const c = node.children; + if (c !== undefined && c !== null && !(Array.isArray(c) && c.length === 0)) return c; + return node.props?.children; +} +function walk(node, visit) { + if (node == null || node === false || node === true) return; + if (Array.isArray(node)) { for (const n of node) walk(n, visit); return; } + if (typeof node !== 'object') return; + visit(node); + walk(childrenOf(node), visit); +} +const findHosts = (tree, type) => { const out = []; walk(tree, (n) => { if (n && n.type === type) out.push(n); }); return out; }; +const findComponents = (tree, name) => { + const out = []; + walk(tree, (n) => { if (n && typeof n.type === 'function' && n.type.name === name) out.push(n); }); + return out; +}; +const allText = (tree) => collectText(tree, expandNamed('LineupSourceLine', 'LineupProblem')); +// A hook-free child component's own output. +const rendered = (node) => node.type(node.props); + +const SQUAD = [ + { id: 'mem-1', index: 1, name: 'Aoki' }, + { id: 'mem-2', index: 2, name: 'Sato' }, + { id: 'mem-3', index: 3, name: 'Ito' }, + { id: 'mem-4', index: 4, name: 'Mori' }, +]; +const NAMES = { positions: { 1: 'Aoki', 2: 'Sato', 3: 'Ito' }, memberIds: { 1: 'mem-1', 2: 'mem-2', 3: 'mem-3' } }; +const OWN = { ...NAMES, sourceMatchId: 'Pool D-1', saved: true }; +const CARRIED = { ...NAMES, sourceMatchId: 'Pool D-0', saved: true }; +const STARTING = { ...NAMES, sourceRound: 0, saved: true }; +const COMP = { id: 'comp-1', name: 'Team Event', kind: 'team', teamSize: 3 }; +const flush = async () => { for (let i = 0; i < 8; i++) await Promise.resolve(); }; + +let runtime; +let saved; + +function install(api) { + saved = { + API: window.API, helpers: window.AdminLineupHelpers, compMatches: window.compMatches, + confirmDialog: window.confirmDialog, + }; + window.compMatches = () => []; + window.confirmDialog = vi.fn().mockResolvedValue(true); + window.AdminLineupHelpers = { + positionsForSize: (n) => Array.from({ length: n }, (_, i) => ({ key: String(i + 1), label: String(i + 1) })), + rosterFor: () => [], + mergeRosterWithAssigned: (base) => (Array.isArray(base) ? base : []), + teamIdOf: (t) => t?.id || t?.name || '', + resolveMemberIdsForPositions: vi.fn().mockResolvedValue({ memberIds: {}, squad: [], failures: [] }), + memberIdentityWarning: () => '', + }; + window.API = api; + runtime = makeReactive(); + global.React = runtime.React; + vi.resetModules(); +} + +function restore() { + runtime.unmount(); + global.React = realReact; + window.API = saved.API; + window.AdminLineupHelpers = saved.helpers; + window.compMatches = saved.compMatches; + window.confirmDialog = saved.confirmDialog; + vi.resetModules(); +} + +describe('MatchLineupSideEditor (the at-court panel)', () => { + const TEAM = { id: 'uuid-grouped', name: 'Grouped Team', number: 'T5' }; + const MATCH = { + id: 'Pool D-1', compId: 'comp-1', phase: 'pool', poolName: 'Pool D', + sideA: { id: 'uuid-grouped', name: 'Grouped Team' }, sideB: { id: 'other', name: 'Other' }, status: 'scheduled', + }; + let MatchLineupSideEditor; + let api; + + beforeEach(async () => { + api = { + fetchLineupInForce: vi.fn().mockResolvedValue(CARRIED), + fetchSquads: vi.fn().mockResolvedValue({ 'uuid-grouped': SQUAD }), + putMatchLineup: vi.fn().mockResolvedValue({ positions: {} }), + deleteMatchLineup: vi.fn().mockResolvedValue(true), + }; + install(api); + ({ MatchLineupSideEditor } = await import('../admin_schedule_lineup.jsx')); + }); + afterEach(restore); + + async function mount() { + runtime.mount(MatchLineupSideEditor, { + comp: COMP, team: TEAM, match: MATCH, allMatches: [MATCH], password: 'pw', showToast: vi.fn(), + }); + await flush(); + return runtime.currentTree(); + } + const saveButton = (tree) => findHosts(tree, 'button').find((b) => /Save lineup/.test(collectText(b))); + const pickers = (tree) => findComponents(tree, 'LineupNameInput'); + const tryAgain = (tree) => { + const problem = findComponents(tree, 'LineupProblem')[0]; + return findHosts(rendered(problem), 'button').find((b) => collectText(b) === 'Try again'); + }; + + it('after a failed read shows why, and Save, the boxes and a direct save all refuse', async () => { + api.fetchLineupInForce.mockRejectedValue(new Error('competition not found')); + let tree = await mount(); + expect(allText(tree)).toContain('competition not found'); + expect(saveButton(tree).props.disabled).toBe(true); + expect(pickers(tree).every((p) => p.props.disabled)).toBe(true); + + // The boxes are off in the page, but the callback is still reachable: what is + // typed there must not make anything saveable. + pickers(tree)[0].props.onSelect('Mori', SQUAD[3]); + tree = runtime.currentTree(); + expect(saveButton(tree).props.disabled).toBe(true); + saveButton(tree).props.onClick(); + await flush(); + expect(api.putMatchLineup).not.toHaveBeenCalled(); + // The problem is still there after the attempt. + expect(allText(runtime.currentTree())).toContain('competition not found'); + }); + + it('shows no source for a lineup that was not read', async () => { + api.fetchLineupInForce.mockRejectedValue(new Error('down')); + const tree = await mount(); + expect(allText(tree)).not.toContain('No lineup saved yet'); + }); + + it('reads again on Try again, and then saves a change', async () => { + api.fetchLineupInForce.mockRejectedValueOnce(new Error('down')).mockResolvedValue(CARRIED); + let tree = await mount(); + tryAgain(tree).props.onClick(); + await flush(); + tree = runtime.currentTree(); + + expect(api.fetchLineupInForce).toHaveBeenCalledTimes(2); + expect(allText(tree)).not.toContain('down'); + expect(allText(tree)).toContain('Same as'); + expect(pickers(tree).some((p) => p.props.disabled)).toBe(false); + pickers(tree)[1].props.onSelect('Mori', SQUAD[3]); + expect(saveButton(runtime.currentTree()).props.disabled).toBe(false); + saveButton(runtime.currentTree()).props.onClick(); + await flush(); + expect(api.putMatchLineup).toHaveBeenCalledTimes(1); + }); + + it('after a removal whose re-read fails, shows an empty form that cannot be saved and says so', async () => { + api.fetchLineupInForce.mockResolvedValueOnce(OWN).mockRejectedValue(new Error('down')); + let tree = await mount(); + expect(allText(tree)).toContain('Lineup for this match'); + const line = findComponents(tree, 'LineupSourceLine')[0]; + + await line.props.form.dropOwnLineup(); + await flush(); + tree = runtime.currentTree(); + + expect(api.deleteMatchLineup).toHaveBeenCalledWith('comp-1', 'uuid-grouped', 'Pool D-1', 'pw'); + expect(allText(tree)).toContain('Removed. The lineup this match now uses could not be read: try again.'); + expect(allText(tree)).not.toContain('Lineup for this match'); + expect(pickers(tree).map((p) => p.props.value)).toEqual(['', '', '']); + expect(saveButton(tree).props.disabled).toBe(true); + saveButton(tree).props.onClick(); + await flush(); + expect(api.putMatchLineup).not.toHaveBeenCalled(); + }); +}); + +describe('AdminLineup (the Lineups page)', () => { + const TEAM = { id: 'team-1', name: 'Tora A', number: 'T10' }; + let AdminLineup; + let api; + + beforeEach(async () => { + api = { + fetchTeamLineup: vi.fn().mockResolvedValue(STARTING), + fetchLineupInForce: vi.fn().mockResolvedValue(CARRIED), + fetchSquads: vi.fn().mockResolvedValue({ 'team-1': SQUAD }), + putTeamLineup: vi.fn().mockResolvedValue({}), + putMatchLineup: vi.fn().mockResolvedValue({}), + deleteMatchLineup: vi.fn().mockResolvedValue(true), + }; + install(api); + ({ AdminLineup } = await import('../admin_lineup.jsx')); + }); + afterEach(restore); + + async function mount(extra = {}) { + runtime.mount(AdminLineup, { + comp: COMP, team: TEAM, password: 'pw', showToast: vi.fn(), onClose: vi.fn(), ...extra, + }); + await flush(); + return runtime.currentTree(); + } + const saveButton = (tree) => findHosts(tree, 'button').find((b) => /^Save lineup$/.test(collectText(b).trim())); + const select = (tree, key) => findHosts(tree, 'select').find((s) => s.props?.['data-testid'] === `lineup-position-${key}`); + const tryAgain = (tree) => { + const problem = findComponents(tree, 'LineupProblem')[0]; + return findHosts(rendered(problem), 'button').find((b) => collectText(b) === 'Try again'); + }; + + it('a starting lineup that could not be read is never written: one tap on Save writes nothing', async () => { + api.fetchTeamLineup.mockRejectedValue(new Error('Failed to load lineup')); + const tree = await mount(); + expect(allText(tree)).toContain('Failed to load lineup'); + expect(saveButton(tree).props.disabled).toBe(true); + expect(select(tree, '1').props.disabled).toBe(true); + + saveButton(tree).props.onClick(); + await flush(); + + expect(api.putTeamLineup).not.toHaveBeenCalled(); + expect(allText(runtime.currentTree())).toContain('Failed to load lineup'); + }); + + it('nor does a pick on it make it saveable', async () => { + api.fetchTeamLineup.mockRejectedValue(new Error('down')); + const tree = await mount(); + select(tree, '1').props.onChange({ target: { value: 'mem-4' } }); + const after = runtime.currentTree(); + expect(saveButton(after).props.disabled).toBe(true); + saveButton(after).props.onClick(); + await flush(); + expect(api.putTeamLineup).not.toHaveBeenCalled(); + }); + + it('a match whose lineup could not be read is never written either', async () => { + api.fetchLineupInForce.mockRejectedValue(new Error('down')); + const tree = await mount({ matchId: 'Pool D-1', matchLabel: 'Pool D · Match 2', allMatches: [] }); + select(tree, '2').props.onChange({ target: { value: 'mem-4' } }); + const after = runtime.currentTree(); + expect(saveButton(after).props.disabled).toBe(true); + saveButton(after).props.onClick(); + await flush(); + expect(api.putMatchLineup).not.toHaveBeenCalled(); + }); + + it('reads again on Try again, and then the form works', async () => { + api.fetchTeamLineup.mockRejectedValueOnce(new Error('down')).mockResolvedValue(STARTING); + let tree = await mount(); + tryAgain(tree).props.onClick(); + await flush(); + tree = runtime.currentTree(); + + expect(api.fetchTeamLineup).toHaveBeenCalledTimes(2); + expect(allText(tree)).not.toContain('down'); + expect(select(tree, '1').props.disabled).toBeFalsy(); + expect(select(tree, '1').props.value).toBe('mem-1'); + select(tree, '1').props.onChange({ target: { value: 'mem-4' } }); + expect(saveButton(runtime.currentTree()).props.disabled).toBe(false); + }); + + it('an untouched starting lineup that WAS read has nothing to save: a direct save writes nothing', async () => { + const tree = await mount(); + expect(saveButton(tree).props.disabled).toBe(true); + expect(saveButton(tree).props.title).toBe('No changes to save'); + saveButton(tree).props.onClick(); + await flush(); + expect(api.putTeamLineup).not.toHaveBeenCalled(); + }); +}); diff --git a/web-mobile/js/__tests__/lineup_form.test.jsx b/web-mobile/js/__tests__/lineup_form.test.jsx new file mode 100644 index 000000000..4cb0400bb --- /dev/null +++ b/web-mobile/js/__tests__/lineup_form.test.jsx @@ -0,0 +1,1679 @@ +// useLineupForm (lineup_draft.jsx) is the state machine both lineup editors run: +// what was read, where it was saved, what is shown, the confirmed save and giving +// a match's own lineup up. These tests drive the hook alone, with the real React +// swapped in for this suite's stub (the hook reads the React global at call +// time), and window.API / window.confirmDialog stubbed the way the editors reach +// them. +// +// The rule they pin most: nothing is saved before the lineup has been READ. An +// editor that could not read a lineup shows an empty one, and a Save over it +// would write that empty lineup over the real one, and over every match that +// carries it. + +import RealReact from 'react'; +import { renderHook, act } from '@testing-library/react'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { useLineupForm, lineupDraftKey } from '../lineup_draft.jsx'; +import { REMOVED_UNREAD_NOTICE, LINEUP_READ_NO_ANSWER } from '../lineup_resolver.jsx'; +import { FETCH_TIMEOUT_MS } from '../write_result.jsx'; +import { API as realApi } from '../api_client.jsx'; +import { answered, namedLater } from './helpers/team_members.js'; + +const stubReact = global.React; +const KEYS = ['1', '2', '3']; +const NAMES = { positions: { 1: 'Aoki', 2: 'Sato', 3: 'Ito' }, memberIds: { 1: 'mem-1', 2: 'mem-2', 3: 'mem-3' } }; +// What API.queuedLineupSave answers while a save of the lineup is queued. +const QUEUED = true; +// The team's members as the API answers them, keyed by the team's id. +const MEMBERS = [ + { id: 'mem-1', index: 1, name: 'Aoki' }, + { id: 'mem-2', index: 2, name: 'Sato' }, + { id: 'mem-3', index: 3, name: 'Ito' }, +]; +const OWN = { ...NAMES, sourceMatchId: 'm1', saved: true }; +const CARRIED = { ...NAMES, sourceMatchId: 'm0', saved: true }; +const STARTING = { ...NAMES, sourceRound: 0, saved: true }; +const DRAFT_KEY = `bc.lineupDraft.v1:${lineupDraftKey({ compId: 'c', teamId: 't', matchId: 'm1' })}`; + +let api; +let saved; + +beforeEach(() => { + global.React = RealReact; + sessionStorage.clear(); + saved = { + API: window.API, confirmDialog: window.confirmDialog, + subscribeSyncStatus: window.subscribeSyncStatus, subscribeUnsentWrites: window.subscribeUnsentWrites, + }; + api = { + fetchTeamLineup: vi.fn().mockResolvedValue(STARTING), + fetchLineupInForce: vi.fn().mockResolvedValue(CARRIED), + deleteMatchLineup: vi.fn().mockResolvedValue(true), + queuedLineupSave: vi.fn().mockReturnValue(false), + fetchSquads: vi.fn().mockResolvedValue({ t: MEMBERS }), + }; + window.API = api; + window.confirmDialog = vi.fn().mockResolvedValue(true); + delete window.subscribeSyncStatus; + delete window.subscribeUnsentWrites; +}); + +afterEach(() => { + global.React = stubReact; + for (const [key, value] of Object.entries(saved)) { + if (value === undefined) delete window[key]; else window[key] = value; + } +}); + +const props = (extra = {}) => ({ + compId: 'c', teamId: 't', matchId: 'm1', positionKeys: KEYS, password: 'pw', + matchLabel: 'Pool A · Match 2', teamName: 'Team A', ...extra, +}); + +// Mounts the hook and lets its first read settle. +async function mount(extra) { + const view = renderHook((p) => useLineupForm(p), { initialProps: props(extra) }); + await act(async () => { await Promise.resolve(); }); + return view; +} + +const edit = (view, positions = { 1: 'Mori' }) => act(() => { + view.result.current.setValues((v) => ({ ...v, ...positions })); +}); + +function deferred() { + let resolve; + let reject; + const promise = new Promise((res, rej) => { resolve = res; reject = rej; }); + return { promise, resolve, reject }; +} + +describe('reading', () => { + it('reads a match through the lineup in force, and the starting lineup exactly, as round 0', async () => { + await mount(); + expect(api.fetchLineupInForce).toHaveBeenCalledWith('c', 't', 'm1'); + expect(api.fetchTeamLineup).not.toHaveBeenCalled(); + + api.fetchLineupInForce.mockClear(); + await mount({ matchId: '' }); + expect(api.fetchTeamLineup).toHaveBeenCalledWith('c', 't', 0); + expect(api.fetchLineupInForce).not.toHaveBeenCalled(); + }); + + it('shows what was read, and where it was saved', async () => { + const view = await mount(); + expect(view.result.current.values).toEqual(NAMES.positions); + expect(view.result.current.memberIds).toEqual(NAMES.memberIds); + expect(view.result.current.source).toEqual({ matchId: 'm0' }); + expect(view.result.current.loading).toBe(false); + expect(view.result.current.read).toBe(true); + }); + + it('shows an empty form for nothing in force, and says nothing is saved', async () => { + api.fetchLineupInForce.mockResolvedValue(null); + const view = await mount(); + expect(view.result.current.values).toEqual({ 1: '', 2: '', 3: '' }); + expect(view.result.current.source).toBeNull(); + expect(view.result.current.read).toBe(true); + }); + + it('is unread, and cannot be saved, until the lineup has been read', async () => { + let answer; + api.fetchLineupInForce.mockReturnValue(new Promise((resolve) => { answer = resolve; })); + const view = renderHook((p) => useLineupForm(p), { initialProps: props() }); + expect(view.result.current.loading).toBe(true); + expect(view.result.current.read).toBe(false); + act(() => view.result.current.setValues({ 1: 'Mori', 2: '', 3: '' })); + expect(view.result.current.canSave).toBe(false); + + await act(async () => { answer(CARRIED); }); + expect(view.result.current.read).toBe(true); + expect(view.result.current.canSave).toBe(false); + }); +}); + +describe('a lineup that could not be read', () => { + beforeEach(() => { api.fetchLineupInForce.mockRejectedValue(new Error('competition not found')); }); + + it('says why, holds nothing read, and can never be saved, whatever is typed', async () => { + const view = await mount(); + expect(view.result.current.loadError).toBe('competition not found'); + expect(view.result.current.read).toBe(false); + expect(view.result.current.loading).toBe(false); + + edit(view); + expect(view.result.current.dirty).toBe(true); + expect(view.result.current.canSave).toBe(false); + expect(view.result.current.saveTitle).toBeUndefined(); + }); + + it('falls back to a plain sentence when the failure carries none', async () => { + api.fetchLineupInForce.mockRejectedValue(new Error('')); + const view = await mount(); + expect(view.result.current.loadError).toBe('Failed to load lineup'); + }); + + it.each([ + ['a match lineup', { matchId: 'm1' }, () => api.fetchLineupInForce], + ['a starting lineup', { matchId: '' }, () => api.fetchTeamLineup], + ])('says a read the server never answered in a plain sentence, never the browser\'s own text (%s)', async (_what, extra, read) => { + read().mockRejectedValue(new TypeError('Failed to fetch')); + const view = await mount(extra); + expect(view.result.current.loadError).toBe(LINEUP_READ_NO_ANSWER); + expect(view.result.current.loadError).not.toMatch(/Failed to fetch/); + expect(view.result.current.read).toBe(false); + }); + + it('says a read given up on at its deadline the same way', async () => { + api.fetchLineupInForce.mockRejectedValue(Object.assign(new Error('the request was not answered in time'), { timedOut: true })); + const view = await mount(); + expect(view.result.current.loadError).toBe(LINEUP_READ_NO_ANSWER); + }); + + it('through the real client: a connection that is down reads as that sentence, an answer in the server\'s own words', async () => { + const originalFetch = global.fetch; + window.API = realApi; + try { + global.fetch = vi.fn(() => Promise.reject(new TypeError('Failed to fetch'))); + const view = await mount(); + expect(view.result.current.loadError).toBe(LINEUP_READ_NO_ANSWER); + + global.fetch = vi.fn(() => Promise.resolve({ + ok: false, status: 404, json: () => Promise.resolve({ error: 'competition not found' }), + })); + await act(async () => { view.result.current.retry(); }); + expect(view.result.current.loadError).toBe('competition not found'); + } finally { + global.fetch = originalFetch; + } + }); + + it('keeps its problem when the editor clears its own errors, until a read succeeds', async () => { + const view = await mount(); + act(() => view.result.current.setError('')); + act(() => view.result.current.setWarning('')); + expect(view.result.current.loadError).toBe('competition not found'); + expect(view.result.current.canSave).toBe(false); + }); + + it('reads again on retry, and then the form can be saved', async () => { + const view = await mount(); + api.fetchLineupInForce.mockResolvedValue(CARRIED); + + await act(async () => { view.result.current.retry(); }); + + expect(api.fetchLineupInForce).toHaveBeenCalledTimes(2); + expect(view.result.current.loadError).toBe(''); + expect(view.result.current.read).toBe(true); + expect(view.result.current.values).toEqual(NAMES.positions); + edit(view); + expect(view.result.current.canSave).toBe(true); + }); + + it('keeps the problem when the retry fails too', async () => { + const view = await mount(); + api.fetchLineupInForce.mockRejectedValue(new Error('still down')); + await act(async () => { view.result.current.retry(); }); + expect(view.result.current.loadError).toBe('still down'); + expect(view.result.current.read).toBe(false); + expect(view.result.current.loading).toBe(false); + }); + + it('leaves the unsaved draft alone', async () => { + sessionStorage.setItem(DRAFT_KEY, JSON.stringify({ savedAt: Date.now(), baseline: NAMES, current: { ...NAMES, positions: { ...NAMES.positions, 1: 'Mori' } } })); + const kept = sessionStorage.getItem(DRAFT_KEY); + const view = await mount(); + expect(sessionStorage.getItem(DRAFT_KEY)).toBe(kept); + expect(view.result.current.draft).toMatchObject({ restored: false, stale: null }); + }); +}); + +describe('what may be saved', () => { + it('is a change to the lineup that was read', async () => { + const view = await mount(); + expect(view.result.current.dirty).toBe(false); + expect(view.result.current.canSave).toBe(false); + expect(view.result.current.saveTitle).toBe('No changes to save'); + + edit(view); + expect(view.result.current.canSave).toBe(true); + expect(view.result.current.saveTitle).toBeUndefined(); + + edit(view, { 1: 'Aoki' }); + expect(view.result.current.canSave).toBe(false); + }); + + it('is the same for the starting lineup: it is no longer always saveable', async () => { + const view = await mount({ matchId: '' }); + expect(view.result.current.canSave).toBe(false); + edit(view); + expect(view.result.current.canSave).toBe(true); + }); + + it('confirmSaved makes what the server answered the baseline, and a match\'s lineup its own', async () => { + const view = await mount(); + edit(view); + act(() => view.result.current.confirmSaved({ + positions: { 1: 'Mori', 2: 'Sato', 3: 'Ito' }, memberIds: { 1: 'mem-4', 2: 'mem-2', 3: 'mem-3' }, + })); + expect(view.result.current.canSave).toBe(false); + expect(view.result.current.values[1]).toBe('Mori'); + expect(view.result.current.memberIds[1]).toBe('mem-4'); + expect(view.result.current.source).toEqual({ matchId: 'm1' }); + }); + + // A save names only the positions the operator changed, and the server answers the + // whole lineup it holds then: a position the operator left alone that another + // device changed is shown after the save, with the id the server holds, not the + // one this form had. + it('confirmSaved shows the whole lineup the server answered: a position left alone that another device changed shows, with its own id', async () => { + const view = await mount(); + edit(view, { 1: 'Mori' }); + await act(async () => { await view.result.current.lineupToSave(); }); + act(() => view.result.current.confirmSaved({ + positions: { 1: 'Mori', 2: 'Kato', 3: 'Ito' }, memberIds: { 1: 'mem-1', 2: 'mem-5', 3: 'mem-3' }, + })); + expect(view.result.current.values).toEqual({ 1: 'Mori', 2: 'Kato', 3: 'Ito' }); + expect(view.result.current.memberIds).toEqual({ 1: 'mem-1', 2: 'mem-5', 3: 'mem-3' }); + expect(view.result.current.baseline).toEqual({ + positions: { 1: 'Mori', 2: 'Kato', 3: 'Ito' }, memberIds: { 1: 'mem-1', 2: 'mem-5', 3: 'mem-3' }, + }); + expect(view.result.current.dirty).toBe(false); + }); + + it('confirmSaved leaves the source of a starting lineup alone, and takes a missing answer as blank', async () => { + const view = await mount({ matchId: '' }); + edit(view); + act(() => view.result.current.confirmSaved({ positions: undefined, memberIds: undefined })); + expect(view.result.current.source).toEqual({ round: 0 }); + expect(view.result.current.values).toEqual({ 1: '', 2: '', 3: '' }); + }); +}); + +describe('giving a match\'s own lineup up', () => { + const drop = (view) => act(async () => { await view.result.current.dropOwnLineup(); }); + + it('asks first, in the words of previousLineupConfirm, and removes nothing when declined', async () => { + window.confirmDialog.mockResolvedValue(false); + const view = await mount(); + await drop(view); + expect(window.confirmDialog).toHaveBeenCalledTimes(1); + expect(window.confirmDialog.mock.calls[0][0].message).toContain('Pool A · Match 2'); + expect(window.confirmDialog.mock.calls[0][0].message).toContain('Team A'); + expect(api.deleteMatchLineup).not.toHaveBeenCalled(); + }); + + it('removes it, then shows the lineup the match now carries, and drops the draft made against the old one', async () => { + api.fetchLineupInForce.mockResolvedValueOnce(OWN).mockResolvedValue(CARRIED); + const view = await mount(); + edit(view); + expect(sessionStorage.getItem(DRAFT_KEY)).not.toBeNull(); + + await drop(view); + + expect(api.deleteMatchLineup).toHaveBeenCalledWith('c', 't', 'm1', 'pw'); + expect(view.result.current.source).toEqual({ matchId: 'm0' }); + expect(view.result.current.values).toEqual(NAMES.positions); + expect(view.result.current.read).toBe(true); + expect(view.result.current.removing).toBe(false); + expect(sessionStorage.getItem(DRAFT_KEY)).toBeNull(); + }); + + it('changes nothing when the server refuses the removal, and says so', async () => { + api.fetchLineupInForce.mockResolvedValue(OWN); + api.deleteMatchLineup.mockRejectedValue(new Error('Failed to delete match lineup')); + const view = await mount(); + edit(view); + + await drop(view); + + expect(view.result.current.error).toBe('Failed to delete match lineup'); + expect(view.result.current.source).toEqual({ matchId: 'm1' }); + expect(view.result.current.values[1]).toBe('Mori'); + expect(view.result.current.read).toBe(true); + expect(view.result.current.loadError).toBe(''); + expect(api.fetchLineupInForce).toHaveBeenCalledTimes(1); + }); + + it('says the removal was not done in a plain sentence of its own when the refusal carries none', async () => { + api.fetchLineupInForce.mockResolvedValue(OWN); + api.deleteMatchLineup.mockRejectedValue(new Error('')); + const view = await mount(); + await drop(view); + expect(view.result.current.error).toBe("Failed to use the previous match's lineup"); + }); + + it('after a removal whose re-read fails, leaves an empty form that cannot be saved, never the removed lineup', async () => { + api.fetchLineupInForce.mockResolvedValueOnce(OWN).mockRejectedValue(new Error('network down')); + const view = await mount(); + edit(view); + + await drop(view); + + const form = view.result.current; + expect(form.loadError).toBe(REMOVED_UNREAD_NOTICE); + expect(form.read).toBe(false); + expect(form.source).toBeNull(); + expect(form.values).toEqual({ 1: '', 2: '', 3: '' }); + expect(form.memberIds).toEqual({}); + expect(form.canSave).toBe(false); + expect(sessionStorage.getItem(DRAFT_KEY)).toBeNull(); + }); + + it('reads the lineup the match carries when it is told to try again', async () => { + api.fetchLineupInForce.mockResolvedValueOnce(OWN).mockRejectedValueOnce(new Error('network down')).mockResolvedValue(CARRIED); + const view = await mount(); + await drop(view); + expect(view.result.current.read).toBe(false); + + await act(async () => { view.result.current.retry(); }); + + expect(view.result.current.loadError).toBe(''); + expect(view.result.current.read).toBe(true); + expect(view.result.current.source).toEqual({ matchId: 'm0' }); + expect(view.result.current.values).toEqual(NAMES.positions); + }); + + it('clears the editor\'s own error and warning when it starts', async () => { + api.fetchLineupInForce.mockResolvedValue(OWN); + const view = await mount(); + act(() => { view.result.current.setError('old'); view.result.current.setWarning('old warning'); }); + await drop(view); + expect(view.result.current.error).toBe(''); + expect(view.result.current.warning).toBe(''); + }); +}); + +describe('a save of the lineup that is still queued', () => { + it('asks the API about this match\'s lineup, and about round 0 for the starting lineup', async () => { + await mount(); + expect(api.queuedLineupSave).toHaveBeenCalledWith('c', 't', { matchId: 'm1' }); + api.queuedLineupSave.mockClear(); + await mount({ matchId: '' }); + expect(api.queuedLineupSave).toHaveBeenCalledWith('c', 't', { round: 0 }); + }); + + it('is false when the API has no such question (a stub), and nothing is queued', async () => { + delete api.queuedLineupSave; + const view = await mount(); + expect(view.result.current.saveQueued).toBe(false); + }); + + it('is true when the API says a save is queued, and false when it says none is', async () => { + api.queuedLineupSave.mockReturnValue(QUEUED); + expect((await mount()).result.current.saveQueued).toBe(true); + + api.queuedLineupSave.mockReturnValue(false); + expect((await mount()).result.current.saveQueued).toBe(false); + }); + + it('keeps the removal from being asked for: nothing is confirmed, nothing removed', async () => { + api.fetchLineupInForce.mockResolvedValue(OWN); + api.queuedLineupSave.mockReturnValue(QUEUED); + const view = await mount(); + expect(view.result.current.saveQueued).toBe(true); + + await act(async () => { await view.result.current.dropOwnLineup(); }); + + expect(window.confirmDialog).not.toHaveBeenCalled(); + expect(api.deleteMatchLineup).not.toHaveBeenCalled(); + }); + + it('follows the queue: it is false again once the save has gone out', async () => { + const listeners = new Set(); + window.subscribeUnsentWrites = (fn) => { listeners.add(fn); fn(); return () => listeners.delete(fn); }; + api.queuedLineupSave.mockReturnValue(QUEUED); + const view = await mount(); + expect(view.result.current.saveQueued).toBe(true); + + api.queuedLineupSave.mockReturnValue(false); + act(() => listeners.forEach((fn) => fn())); + expect(view.result.current.saveQueued).toBe(false); + + api.queuedLineupSave.mockReturnValue(QUEUED); + act(() => listeners.forEach((fn) => fn())); + expect(view.result.current.saveQueued).toBe(true); + + view.unmount(); + expect(listeners.size).toBe(0); + }); +}); + +// Another device saved a lineup of the competition. The editors follow it, as the +// team sheet does, but never over what the operator has done to the form. +describe('a lineup change announced for the competition', () => { + const CHANGED = { + positions: { 1: 'Kato', 2: 'Sato', 3: 'Ito' }, memberIds: { 1: 'mem-5', 2: 'mem-2', 3: 'mem-3' }, + sourceMatchId: 'm1', saved: true, + }; + const announce = (detail = { competitionId: 'c' }) => act(async () => { + window.dispatchEvent(new CustomEvent('lineup-updated', detail === undefined ? {} : { detail })); + }); + const reads = () => api.fetchLineupInForce.mock.calls.length; + const settle = () => act(async () => { await new Promise((resolve) => setTimeout(resolve, 0)); }); + + it('is read again and shown while the form is untouched, with no loading screen in between', async () => { + const loadings = []; + const view = renderHook((p) => { + const form = useLineupForm(p); + loadings.push(form.loading); + return form; + }, { initialProps: props() }); + await act(async () => { await Promise.resolve(); }); + expect(view.result.current.values[1]).toBe('Aoki'); + loadings.length = 0; + + api.fetchLineupInForce.mockResolvedValue(CHANGED); + await announce(); + + expect(reads()).toBe(2); + expect(view.result.current.values[1]).toBe('Kato'); + expect(view.result.current.memberIds[1]).toBe('mem-5'); + expect(view.result.current.baseline.positions[1]).toBe('Kato'); + expect(view.result.current.source).toEqual({ matchId: 'm1' }); + expect(view.result.current.dirty).toBe(false); + expect(loadings.length).toBeGreaterThan(0); + expect(loadings.every((l) => l === false), 'the form stays on screen').toBe(true); + }); + + it('follows the starting lineup the same way', async () => { + const view = await mount({ matchId: '' }); + api.fetchTeamLineup.mockResolvedValue({ ...STARTING, positions: { ...NAMES.positions, 1: 'Kato' } }); + + await announce(); + + expect(api.fetchTeamLineup).toHaveBeenCalledTimes(2); + expect(view.result.current.values[1]).toBe('Kato'); + }); + + it('follows an announcement that names no competition', async () => { + await mount(); + await announce(undefined); + expect(reads()).toBe(2); + }); + + it('leaves a form the operator has edited alone: nothing is read, and the edit stands', async () => { + const view = await mount(); + edit(view); + api.fetchLineupInForce.mockResolvedValue(CHANGED); + + await announce(); + + expect(reads()).toBe(1); + expect(view.result.current.values[1]).toBe('Mori'); + expect(view.result.current.dirty).toBe(true); + }); + + it('does not read over an edit made while the read was out', async () => { + const view = await mount(); + const late = deferred(); + api.fetchLineupInForce.mockReturnValue(late.promise); + await announce(); + expect(reads()).toBe(2); + + edit(view); + await act(async () => { late.resolve(CHANGED); }); + + expect(view.result.current.values[1]).toBe('Mori'); + expect(view.result.current.baseline.positions[1]).toBe('Aoki'); + expect(view.result.current.dirty).toBe(true); + }); + + it('does not put the lineup from before a save back over the saved one', async () => { + const view = await mount(); + const late = deferred(); + api.fetchLineupInForce.mockReturnValue(late.promise); + await announce(); + edit(view); + act(() => view.result.current.confirmSaved({ + positions: { 1: 'Mori', 2: 'Sato', 3: 'Ito' }, memberIds: { 1: 'mem-4', 2: 'mem-2', 3: 'mem-3' }, + })); + + await act(async () => { late.resolve(CARRIED); }); + + expect(view.result.current.values[1]).toBe('Mori'); + expect(view.result.current.baseline.positions[1]).toBe('Mori'); + expect(view.result.current.dirty).toBe(false); + }); + + it('is not read while a removal is out, and the removal\'s own read is what ends up shown', async () => { + api.fetchLineupInForce.mockResolvedValueOnce(OWN).mockResolvedValue(CARRIED); + const view = await mount(); + const gate = deferred(); + api.deleteMatchLineup.mockReturnValue(gate.promise); + let removal; + await act(async () => { + removal = view.result.current.dropOwnLineup(); + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + expect(view.result.current.removing).toBe(true); + const before = reads(); + + await announce(); + expect(reads(), 'nothing is read while the removal is out').toBe(before); + + await act(async () => { gate.resolve(true); await removal; }); + expect(view.result.current.removing).toBe(false); + expect(view.result.current.source).toEqual({ matchId: 'm0' }); + }); + + it('does not put the lineup that a removal took away back, when a read from before it answers', async () => { + api.fetchLineupInForce.mockResolvedValueOnce(OWN); + const view = await mount(); + const late = deferred(); + api.fetchLineupInForce.mockReturnValueOnce(late.promise).mockResolvedValue(CARRIED); + await announce(); + expect(reads(), 'the announcement started a read').toBe(2); + + await act(async () => { await view.result.current.dropOwnLineup(); }); + expect(view.result.current.source).toEqual({ matchId: 'm0' }); + await act(async () => { late.resolve(OWN); }); + + expect(view.result.current.source, 'the lineup the match carries now').toEqual({ matchId: 'm0' }); + }); + + it('keeps what is shown, and raises no problem, when the read that follows fails', async () => { + const view = await mount(); + api.fetchLineupInForce.mockRejectedValue(new Error('offline')); + + await announce(); + + expect(reads()).toBe(2); + expect(view.result.current.values[1]).toBe('Aoki'); + expect(view.result.current.loadError).toBe(''); + expect(view.result.current.read).toBe(true); + }); + + it('reads a form that could not be read again, and keeps its problem when that fails too', async () => { + api.fetchLineupInForce.mockRejectedValue(new Error('competition not found')); + const view = await mount(); + expect(view.result.current.read).toBe(false); + + await announce(); + expect(reads()).toBe(2); + expect(view.result.current.loadError).toBe('competition not found'); + expect(view.result.current.loading).toBe(false); + + api.fetchLineupInForce.mockResolvedValue(CARRIED); + await announce(); + expect(view.result.current.read).toBe(true); + expect(view.result.current.loadError).toBe(''); + expect(view.result.current.values[1]).toBe('Aoki'); + }); + + it('ignores a change announced for another competition', async () => { + await mount(); + await announce({ competitionId: 'another' }); + expect(reads()).toBe(1); + }); + + it('ignores a change announced for another team of the competition: neither its lineup nor its members are read', async () => { + await mount(); + await announce({ competitionId: 'c', teamId: 'u' }); + expect(reads()).toBe(1); + expect(api.fetchSquads).toHaveBeenCalledTimes(1); + }); + + it('follows a change announced for this team, and one that names no team', async () => { + await mount(); + await announce({ competitionId: 'c', teamId: 't' }); + expect(reads(), 'this team\'s').toBe(2); + await announce({ competitionId: 'c', matchId: 'm1' }); + expect(reads(), 'one that names no team is read as every team\'s').toBe(3); + }); + + // A read that replaced the first one could fail where the first would have shown the + // lineup, and the operator would be told it could not be read. So a change announced + // while a read of the lineup is out waits for it. + describe('announced while the lineup is being read', () => { + // Mounts the editor with its first read answered by `first` (and every later one by + // `later`), recording what position 1 showed, in order, once its lineup was read. + const start = (later) => { + const first = deferred(); + api.fetchLineupInForce.mockReturnValueOnce(first.promise); + later(); + const shown = []; + const view = renderHook((p) => { + const form = useLineupForm(p); + const name = form.read ? form.values[1] : ''; + if (shown[shown.length - 1] !== name) shown.push(name); + return form; + }, { initialProps: props() }); + return { first, shown, view }; + }; + + it('does not replace that read: its lineup is shown, and then the change is followed', async () => { + const { first, shown, view } = start(() => api.fetchLineupInForce.mockResolvedValue(CHANGED)); + await act(async () => { await Promise.resolve(); }); + + await announce(); + expect(reads(), 'nothing is read over the first read').toBe(1); + + await act(async () => { first.resolve(CARRIED); }); + await settle(); + + expect(reads(), 'followed once it was shown').toBe(2); + expect(shown, 'the first read\'s lineup was shown before the change').toEqual(['', 'Aoki', 'Kato']); + expect(view.result.current.baseline.positions[1]).toBe('Kato'); + }); + + it('raises no problem when the follow then fails: the first read\'s lineup is still shown', async () => { + const { first, view } = start(() => api.fetchLineupInForce.mockRejectedValue(new Error('offline'))); + await act(async () => { await Promise.resolve(); }); + await announce(); + + await act(async () => { first.resolve(CARRIED); }); + await settle(); + + expect(reads(), 'the follow was tried').toBe(2); + expect(view.result.current.read).toBe(true); + expect(view.result.current.values).toEqual(NAMES.positions); + expect(view.result.current.loadError).toBe(''); + }); + + it('is dropped when that read fails: its failure is shown, nothing else is read, and Try again reads the lineup as it is now', async () => { + const { first, view } = start(() => api.fetchLineupInForce.mockResolvedValue(CHANGED)); + await act(async () => { await Promise.resolve(); }); + await announce(); + + await act(async () => { first.reject(new Error('competition not found')); }); + await settle(); + + expect(view.result.current.loadError).toBe('competition not found'); + expect(view.result.current.read).toBe(false); + expect(reads(), 'the change was dropped, not followed').toBe(1); + + await act(async () => { view.result.current.retry(); }); + await settle(); + + expect(reads()).toBe(2); + expect(view.result.current.read).toBe(true); + expect(view.result.current.values[1]).toBe('Kato'); + }); + + it('is not followed once a later change shows the lineup: the failure of the read dropped it', async () => { + const { first, view } = start(() => api.fetchLineupInForce.mockResolvedValue(CHANGED)); + await act(async () => { await Promise.resolve(); }); + await announce(); + await act(async () => { first.reject(new Error('competition not found')); }); + await settle(); + expect(reads()).toBe(1); + + await announce(); + await settle(); + + expect(view.result.current.read).toBe(true); + expect(reads(), 'one read for the later change, none for the dropped one').toBe(2); + }); + + it('is dropped when the editor is given another lineup meanwhile: that one is read as it is now', async () => { + const { first, view } = start(() => api.fetchLineupInForce.mockResolvedValue(CARRIED)); + await act(async () => { await Promise.resolve(); }); + await announce(); + + view.rerender(props({ matchId: 'm2' })); + await settle(); + await act(async () => { first.resolve(CHANGED); }); + await settle(); + + expect(view.result.current.read).toBe(true); + expect(view.result.current.values[1], 'the late answer of the lineup left is not shown').toBe('Aoki'); + expect(reads(), 'the lineup left and the lineup opened, and no follow').toBe(2); + }); + + it('is held the same way while a Try again read is out', async () => { + api.fetchLineupInForce.mockRejectedValueOnce(new Error('down')); + const view = await mount(); + expect(view.result.current.loadError).toBe('down'); + const again = deferred(); + api.fetchLineupInForce.mockReturnValueOnce(again.promise).mockRejectedValue(new Error('offline')); + await act(async () => { view.result.current.retry(); }); + + await announce(); + expect(reads(), 'nothing is read over the Try again read').toBe(2); + + await act(async () => { again.resolve(CARRIED); }); + await settle(); + + expect(reads()).toBe(3); + expect(view.result.current.read).toBe(true); + expect(view.result.current.loadError).toBe(''); + }); + + it('ignores a change announced for another competition', async () => { + const { first } = start(() => api.fetchLineupInForce.mockResolvedValue(CHANGED)); + await act(async () => { await Promise.resolve(); }); + + await announce({ competitionId: 'another' }); + await act(async () => { first.resolve(CARRIED); }); + await settle(); + + expect(reads()).toBe(1); + }); + }); + + it('stops listening when the editor closes', async () => { + const view = await mount(); + view.unmount(); + await announce(); + await settle(); + expect(reads()).toBe(1); + }); +}); + +// "Not restored, the lineup changed since: Mori, Kato" names the names a draft +// could not restore. Re-entering one is not an answer to the others, so it stays +// through every edit; saving the lineup, or giving it up, answers it. +describe('the "not restored" notice', () => { + const STALE_DRAFT = { + savedAt: Date.now(), + baseline: { positions: { 1: 'Old', 2: 'Sato', 3: 'Ito' }, memberIds: { 1: 'mem-9', 2: 'mem-2', 3: 'mem-3' } }, + current: { positions: { 1: 'Mori', 2: 'Kato', 3: 'Ito' }, memberIds: { 1: 'mem-4', 2: 'mem-5', 3: 'mem-3' } }, + }; + const NAMED = { names: ['Mori', 'Kato'] }; + + async function mountWithStaleDraft() { + sessionStorage.setItem(DRAFT_KEY, JSON.stringify(STALE_DRAFT)); + const view = await mount(); + expect(view.result.current.draft.stale).toEqual(NAMED); + return view; + } + + it('stays through the operator\'s edits, and through putting one back', async () => { + const view = await mountWithStaleDraft(); + + edit(view, { 1: 'Mori' }); + expect(view.result.current.draft.stale).toEqual(NAMED); + edit(view, { 2: 'Kato' }); + expect(view.result.current.draft.stale).toEqual(NAMED); + edit(view, { 1: 'Aoki', 2: 'Sato' }); + expect(view.result.current.dirty).toBe(false); + expect(view.result.current.draft.stale).toEqual(NAMED); + }); + + it('stays when another device\'s change is followed', async () => { + const view = await mountWithStaleDraft(); + api.fetchLineupInForce.mockResolvedValue({ ...CARRIED, positions: { ...NAMES.positions, 3: 'Oda' } }); + + await act(async () => { window.dispatchEvent(new CustomEvent('lineup-updated', { detail: { competitionId: 'c' } })); }); + + expect(view.result.current.values[3]).toBe('Oda'); + expect(view.result.current.draft.stale).toEqual(NAMED); + }); + + it('goes once the lineup is saved', async () => { + const view = await mountWithStaleDraft(); + edit(view, { 1: 'Mori' }); + + act(() => view.result.current.confirmSaved({ + positions: { 1: 'Mori', 2: 'Sato', 3: 'Ito' }, memberIds: { 1: 'mem-4', 2: 'mem-2', 3: 'mem-3' }, + })); + + expect(view.result.current.draft.stale).toBeNull(); + }); + + it('goes once the lineup is given up for the one the match carries', async () => { + api.fetchLineupInForce.mockResolvedValueOnce(OWN).mockResolvedValue(CARRIED); + const view = await mountWithStaleDraft(); + + await act(async () => { await view.result.current.dropOwnLineup(); }); + + expect(view.result.current.draft.stale).toBeNull(); + }); + + it('is not shown again when the operator leaves the lineup and comes back to it', async () => { + const view = await mountWithStaleDraft(); + + view.rerender(props({ matchId: 'm2' })); + await act(async () => { await Promise.resolve(); }); + expect(view.result.current.draft.stale).toBeNull(); + + view.rerender(props({ matchId: 'm1' })); + await act(async () => { await Promise.resolve(); }); + expect(view.result.current.read).toBe(true); + expect(view.result.current.draft.stale).toBeNull(); + }); +}); + +// A Save carries the form as shown and names the positions the operator changed +// (operator decision 2026-10-07, "Only changed positions"): the server puts those on +// the lineup it holds when the save arrives, so a position another device changed +// since the form was read stays, and nothing is asked of the server for it: no lineup +// to compose on, and no members either (a member another device creates is announced, +// and the editor reads the members again for it). That is why a Save with no +// connection is built, and queued, like any other. +describe('what a Save carries', () => { + // A name typed in, as the panel's boxes do: no member id is picked for it. + const typeName = (view, position, name) => act(() => { + view.result.current.setValues((v) => ({ ...v, [position]: name })); + view.result.current.setMemberIds((ids) => ({ ...ids, [position]: '' })); + }); + + it('is the form as shown, and names the positions the operator changed', async () => { + const view = await mount(); + edit(view, { 1: 'Mori' }); + + expect(view.result.current.lineupToSave()).toEqual({ + positions: { 1: 'Mori', 2: 'Sato', 3: 'Ito' }, + memberIds: { 1: 'mem-1', 2: 'mem-2', 3: 'mem-3' }, + changed: ['1'], + }); + }); + + it('asks nothing of the server: no lineup to compose on and no members, a name typed in included', async () => { + const view = await mount(); + edit(view, { 1: 'Mori' }); + typeName(view, 2, 'Zed'); + api.fetchLineupInForce.mockClear(); + api.fetchTeamLineup.mockClear(); + api.fetchSquads.mockClear(); + + expect(view.result.current.lineupToSave().changed).toEqual(['1', '2']); + + expect(api.fetchLineupInForce).not.toHaveBeenCalled(); + expect(api.fetchTeamLineup).not.toHaveBeenCalled(); + expect(api.fetchSquads).not.toHaveBeenCalled(); + }); + + it('is built the same with no connection: the server not answering changes nothing', async () => { + const view = await mount(); + edit(view, { 1: 'Mori' }); + api.fetchLineupInForce.mockRejectedValue(new TypeError('Failed to fetch')); + api.fetchSquads.mockRejectedValue(new TypeError('Failed to fetch')); + + expect(view.result.current.lineupToSave().changed).toEqual(['1']); + }); + + it('shows nothing of the server: the form, its baseline and its source stay as they were', async () => { + const view = await mount(); + edit(view, { 1: 'Mori' }); + const before = view.result.current.baseline; + + view.result.current.lineupToSave(); + + expect(view.result.current.baseline).toBe(before); + expect(view.result.current.values).toEqual({ 1: 'Mori', 2: 'Sato', 3: 'Ito' }); + expect(view.result.current.source).toEqual({ matchId: 'm0' }); + expect(view.result.current.dirty).toBe(true); + }); + + it('names a position put back to what was loaded as no change at all', async () => { + const view = await mount(); + edit(view, { 1: 'Mori' }); + edit(view, { 1: 'Aoki', 3: 'Oda' }); + + expect(view.result.current.lineupToSave().changed).toEqual(['3']); + }); + + it('names a position the operator cleared, with its name and its id empty', async () => { + const view = await mount(); + act(() => { + view.result.current.setValues((v) => ({ ...v, 2: '' })); + view.result.current.setMemberIds((ids) => ({ ...ids, 2: '' })); + }); + + expect(view.result.current.lineupToSave()).toEqual({ + positions: { 1: 'Aoki', 2: '', 3: 'Ito' }, memberIds: { 1: 'mem-1', 2: '', 3: 'mem-3' }, changed: ['2'], + }); + }); + + it('names a member picked before it was named: its name is empty and its id is the placement', async () => { + const view = await mount(); + act(() => { + view.result.current.setValues((v) => ({ ...v, 2: '' })); + view.result.current.setMemberIds((ids) => ({ ...ids, 2: 'mem-blank' })); + }); + + const carried = view.result.current.lineupToSave(); + + expect(carried.changed).toEqual(['2']); + expect(carried.positions[2]).toBe(''); + expect(carried.memberIds[2]).toBe('mem-blank'); + }); +}); + +// The team's members are the other half of what both lineup editors show: a +// position's member is picked from them and named by them. The hook reads them, +// and again whenever it follows another device's lineup, so a member created +// elsewhere meanwhile is in the list the followed lineup names, whenever that read +// is answered. Each member carries the stamp the server gave its last write, and of two +// copies of a member the one with the larger stamp is the one that stands, whichever +// list brought it and whichever read began first. +describe('the team\'s members', () => { + const NEW_MEMBER = { id: 'mem-9', index: 4, name: 'Zed' }; + const FOLLOWED = { + positions: { 1: 'Aoki', 2: 'Zed', 3: 'Ito' }, memberIds: { 1: 'mem-1', 2: 'mem-9', 3: 'mem-3' }, + sourceMatchId: 'm1', saved: true, + }; + const announce = () => act(async () => { + window.dispatchEvent(new CustomEvent('lineup-updated', { detail: { competitionId: 'c' } })); + }); + // An editor's own change to the members, made once the server holds it (an add, a + // rename, a mint): the members as the server answered the write, stamped. + const change = (view, members) => act(async () => { view.result.current.changeMembers(members); }); + + it('are read when the editor opens, for its team, with the password', async () => { + const view = await mount(); + expect(api.fetchSquads).toHaveBeenCalledWith('c', 'pw'); + expect(view.result.current.squad).toEqual(MEMBERS); + expect(view.result.current.squadUnavailable).toBe(false); + }); + + it('are an empty list, not a failure, when the answer holds none for the team', async () => { + api.fetchSquads.mockResolvedValue({ other: MEMBERS }); + const view = await mount(); + expect(view.result.current.squad).toEqual([]); + expect(view.result.current.squadUnavailable).toBe(false); + }); + + it('say they could not be read when the read fails, and the list stays empty', async () => { + api.fetchSquads.mockRejectedValue(new Error('down')); + const view = await mount(); + expect(view.result.current.squad).toEqual([]); + expect(view.result.current.squadUnavailable).toBe(true); + }); + + it('do not hold the lineup back: a lineup that loads is shown whether or not the members could be read', async () => { + api.fetchSquads.mockRejectedValue(new Error('down')); + const view = await mount(); + expect(view.result.current.read).toBe(true); + expect(view.result.current.values).toEqual(NAMES.positions); + }); + + it('are read again when the password changes, and a read that now succeeds clears the earlier failure', async () => { + api.fetchSquads.mockRejectedValueOnce(new Error('401')).mockResolvedValue({ t: MEMBERS }); + const view = await mount(); + expect(view.result.current.squadUnavailable).toBe(true); + + view.rerender(props({ password: 'new' })); + await act(async () => { await Promise.resolve(); }); + + expect(api.fetchSquads).toHaveBeenLastCalledWith('c', 'new'); + expect(view.result.current.squad).toEqual(MEMBERS); + expect(view.result.current.squadUnavailable).toBe(false); + }); + + it('are not replaced by an older copy: the read made when the editor opened, answering after a followed lineup read them again, undoes nothing the later read brought', async () => { + const opening = deferred(); + api.fetchSquads.mockReturnValueOnce(opening.promise); + const view = renderHook((p) => useLineupForm(p), { initialProps: props() }); + await act(async () => { await Promise.resolve(); }); + // Another device named Sato after the opening read began: the followed read holds it, stamped. + const renamed = answered(MEMBERS[1], { name: 'Sato-san' }); + api.fetchSquads.mockResolvedValue({ t: [MEMBERS[0], renamed, MEMBERS[2], NEW_MEMBER] }); + api.fetchLineupInForce.mockResolvedValue(FOLLOWED); + await announce(); + expect(view.result.current.squad, 'the followed read').toContainEqual(NEW_MEMBER); + + await act(async () => { opening.resolve({ t: MEMBERS }); }); + + expect(view.result.current.squad, 'the member the later read brought stands').toContainEqual(NEW_MEMBER); + expect(view.result.current.squad[1], 'and so does the name it gave Sato, which the older list predates').toEqual(renamed); + expect(view.result.current.squadUnavailable).toBe(false); + }); + + it('do not read as unavailable because an older read failed after a newer one was shown', async () => { + const opening = deferred(); + api.fetchSquads.mockReturnValueOnce(opening.promise); + const view = renderHook((p) => useLineupForm(p), { initialProps: props() }); + await act(async () => { await Promise.resolve(); }); + api.fetchSquads.mockResolvedValue({ t: [...MEMBERS, NEW_MEMBER] }); + api.fetchLineupInForce.mockResolvedValue(FOLLOWED); + await announce(); + + await act(async () => { opening.reject(new Error('down')); }); + + expect(view.result.current.squad).toContainEqual(NEW_MEMBER); + expect(view.result.current.squadUnavailable).toBe(false); + }); + + it('are shown when the read made as the editor opened answers after a followed lineup was dropped for an edit: a follow that shows nothing ends nothing', async () => { + const opening = deferred(); + api.fetchSquads.mockReturnValueOnce(opening.promise); + const view = renderHook((p) => useLineupForm(p), { initialProps: props() }); + await act(async () => { await Promise.resolve(); }); + const lineup = deferred(); + api.fetchSquads.mockRejectedValue(new Error('offline')); + api.fetchLineupInForce.mockReturnValue(lineup.promise); + await announce(); + + edit(view, { 1: 'Mori' }); + await act(async () => { lineup.resolve(FOLLOWED); }); + expect(view.result.current.values[1], 'the followed lineup is not shown over an edit').toBe('Mori'); + expect(view.result.current.squad, 'and the follow read no list').toEqual([]); + + await act(async () => { opening.resolve({ t: MEMBERS }); }); + + expect(view.result.current.squad).toEqual(MEMBERS); + expect(view.result.current.squadUnavailable).toBe(false); + }); + + it('are shown with a followed lineup whose read fails, which leaves the form as it was', async () => { + const view = await mount(); + api.fetchSquads.mockResolvedValue({ t: [...MEMBERS, NEW_MEMBER] }); + api.fetchLineupInForce.mockRejectedValue(new Error('offline')); + + await announce(); + + expect(view.result.current.squad).toContainEqual(NEW_MEMBER); + expect(view.result.current.squadUnavailable).toBe(false); + expect(view.result.current.values[1]).toBe('Aoki'); + expect(view.result.current.loadError, 'a lineup that was read raises no problem').toBe(''); + }); + + it('are not shown from a read made for a team the editor has since left, whenever it answers', async () => { + const opening = deferred(); + api.fetchSquads.mockReturnValueOnce(opening.promise); + const view = renderHook((p) => useLineupForm(p), { initialProps: props() }); + await act(async () => { await Promise.resolve(); }); + api.fetchSquads.mockRejectedValue(new Error('offline')); + view.rerender(props({ teamId: 'u' })); + await act(async () => { await Promise.resolve(); }); + + await act(async () => { opening.resolve({ t: MEMBERS }); }); + + expect(view.result.current.squad).toEqual([]); + expect(view.result.current.squadUnavailable, 'the read made for the team it shows failed').toBe(true); + }); + + it('are the editor\'s to change after a rename or a mint: the member the server answered joins the list shown', async () => { + const view = await mount(); + await change(view, [NEW_MEMBER]); + expect(view.result.current.squad).toEqual([...MEMBERS, NEW_MEMBER]); + }); + + // The editors call memberRenamed with the member the server answered once it holds a + // rename, or a cleared name (the name ""): the one place that shows it on the members, + // the positions and the baseline. + describe('a member renamed on the server', () => { + // The server's answer to the write: the member, named, with the stamp it gave it. + const renamed = (view, id, name) => { + const member = answered(MEMBERS.find((m) => m.id === id), { name }); + return act(async () => { view.result.current.memberRenamed(member); }); + }; + + it('shows the new name on the members, the positions that hold the member and the baseline: a rename alone is no edit', async () => { + const view = await mount(); + + await renamed(view, 'mem-2', 'Sato-san'); + + expect(view.result.current.squad.find((m) => m.id === 'mem-2').name).toBe('Sato-san'); + expect(view.result.current.squadRef.current.find((m) => m.id === 'mem-2').name).toBe('Sato-san'); + expect(view.result.current.values).toEqual({ 1: 'Aoki', 2: 'Sato-san', 3: 'Ito' }); + expect(view.result.current.baseline.positions[2]).toBe('Sato-san'); + expect(view.result.current.dirty).toBe(false); + expect(sessionStorage.getItem(DRAFT_KEY), 'and leaves no draft').toBeNull(); + }); + + it('shows a cleared name the same way, with the member still placed', async () => { + const view = await mount(); + + await renamed(view, 'mem-2', ''); + + expect(view.result.current.squad.find((m) => m.id === 'mem-2').name).toBe(''); + expect(view.result.current.values[2]).toBe(''); + expect(view.result.current.memberIds[2], 'the member keeps its place').toBe('mem-2'); + expect(view.result.current.dirty).toBe(false); + }); + + it('reads the positions as they are now: a position the member was moved to meanwhile takes the name, the one it left keeps what the operator made of it', async () => { + const view = await mount(); + // While the rename was out the operator put Sato at Position 1, and cleared Position 2. + act(() => { + view.result.current.setValues((v) => ({ ...v, 1: 'Sato', 2: '' })); + view.result.current.setMemberIds((ids) => ({ ...ids, 1: 'mem-2', 2: '' })); + }); + + await renamed(view, 'mem-2', 'Sato-san'); + + expect(view.result.current.values).toEqual({ 1: 'Sato-san', 2: '', 3: 'Ito' }); + }); + + it('does not read the members again: the change is announced to every device, this one included', async () => { + const view = await mount(); + expect(api.fetchSquads).toHaveBeenCalledTimes(1); + + await renamed(view, 'mem-2', 'Sato-san'); + + expect(api.fetchSquads).toHaveBeenCalledTimes(1); + }); + + it('shows the name the copy that is kept has, when the answer is older than the member shown: the positions follow the list', async () => { + const view = await mount(); + // Another device named Sato after this rename was made, and a read has shown it. + const mine = answered(MEMBERS[1], { name: 'Sato-san' }); + const theirs = namedLater(mine, 'Sato-kun'); + api.fetchSquads.mockResolvedValue({ t: [MEMBERS[0], theirs, MEMBERS[2]] }); + await announce(); + expect(view.result.current.squad[1]).toEqual(theirs); + + await act(async () => { view.result.current.memberRenamed(mine); }); + + expect(view.result.current.squad[1], 'the answer older than the list does not undo it').toEqual(theirs); + expect(view.result.current.values[2], 'nor does it name the position the old way').toBe('Sato-kun'); + expect(view.result.current.baseline.positions[2]).toBe('Sato-kun'); + }); + + it('is nothing for a team the editor has since left', async () => { + const view = await mount(); + const staleRenamed = view.result.current.memberRenamed; + api.fetchSquads.mockResolvedValue({ u: [{ id: 'oth-1', index: 1, name: 'Oda' }] }); + view.rerender(props({ teamId: 'u' })); + await act(async () => { await Promise.resolve(); }); + + act(() => { staleRenamed(answered(MEMBERS[1], { name: 'Sato-san' })); }); + + expect(view.result.current.squad, 'the list of the team it shows now').toEqual([{ id: 'oth-1', index: 1, name: 'Oda' }]); + expect(view.result.current.squadRef.current.map((m) => m.id)).toEqual(['oth-1']); + }); + }); + + // A read begun before an editor's change can answer after it, with the list from + // before: a rename undone by it would also leave the typed new name resolving to + // nobody, and a typed name that finds nobody is named onto a blank slot or minted + // as a second member. The member the server answered carries the stamp of the write, + // and of two copies the larger stamp stands, so no list can undo it and no read is + // made after the change to put it right: the server announces the change to every + // device, which reads then. + describe('an editor\'s own change', () => { + const RENAMED = () => answered(MEMBERS[1], { name: 'Sato-san' }); + + it('is not read again, and flags nothing: the list is the one the change made', async () => { + const view = await mount(); + + await change(view, [NEW_MEMBER]); + + expect(api.fetchSquads, 'no read is made after the change').toHaveBeenCalledTimes(1); + expect(view.result.current.squad).toEqual([...MEMBERS, NEW_MEMBER]); + expect(view.result.current.squadUnavailable, 'a list that was read is not unavailable').toBe(false); + }); + + it('stands over a read begun before it, which answers after it with the list from before', async () => { + const view = await mount(); + // A lineup another device saved starts a read of the members, still out when the rename is answered. + const older = deferred(); + api.fetchSquads.mockReturnValueOnce(older.promise); + api.fetchLineupInForce.mockResolvedValue(FOLLOWED); + await announce(); + const renamed = RENAMED(); + + await change(view, [renamed]); + await act(async () => { older.resolve({ t: MEMBERS }); }); + + expect(view.result.current.squad[1], 'the read begun before the rename does not bring the old name back').toEqual(renamed); + expect(view.result.current.squadRef.current[1].name, 'nor on the ref a handler reads').toBe('Sato-san'); + expect(api.fetchSquads, 'and the change is not followed by a read of its own').toHaveBeenCalledTimes(2); + }); + + // A Save can mint a typed name before the read made as the editor opened has + // answered: nothing shown can be undone then, and that read holds the rest of the + // team, which a name typed next must find rather than mint again. + it('stands over the read made as the editor opened, which answers last with the list from before it, and that read still brings the rest of the team', async () => { + const opening = deferred(); + api.fetchSquads.mockReturnValueOnce(opening.promise); + const view = renderHook((p) => useLineupForm(p), { initialProps: props() }); + await act(async () => { await Promise.resolve(); }); + const renamed = RENAMED(); + + await change(view, [renamed]); + expect(view.result.current.squad, 'the change alone, until a list is read').toEqual([renamed]); + await act(async () => { opening.resolve({ t: MEMBERS }); }); + + expect(view.result.current.squad).toEqual([MEMBERS[0], renamed, MEMBERS[2]]); + expect(view.result.current.squadRef.current[1].name).toBe('Sato-san'); + expect(view.result.current.squadUnavailable).toBe(false); + }); + + // While no list is shown the editor can only have changed members it made itself (a + // mint, then a rename of it), which the read made as the editor opened does not + // hold, and every other read begun before a change holds an older state of them. + it.each([ + ['the read made as the editor opened first', true], + ['the followed lineup\'s read first', false], + ])('stands through a mint and a rename of the minted member, %s', async (_order, openingFirst) => { + const opening = deferred(); + const followed = deferred(); + api.fetchSquads.mockReturnValueOnce(opening.promise).mockReturnValueOnce(followed.promise); + const view = renderHook((p) => useLineupForm(p), { initialProps: props() }); + await act(async () => { await Promise.resolve(); }); + await announce(); + expect(api.fetchSquads, 'the followed lineup reads the members').toHaveBeenCalledTimes(2); + const minted = answered(NEW_MEMBER); + const renamedMinted = answered(minted, { name: 'Zed-san' }); + + await change(view, [minted]); + await change(view, [renamedMinted]); + const answers = [ + () => act(async () => { opening.resolve({ t: MEMBERS }); }), + () => act(async () => { followed.resolve({ t: [...MEMBERS, NEW_MEMBER] }); }), + ]; + if (!openingFirst) answers.reverse(); + await answers[0](); + await answers[1](); + + expect(view.result.current.squad.map((m) => m.id), 'the rest of the team is shown').toEqual(['mem-1', 'mem-2', 'mem-3', 'mem-9']); + expect(view.result.current.squad[3].name, 'under the name the rename gave').toBe('Zed-san'); + expect(view.result.current.squadRef.current[3].name, 'and on the ref a handler reads').toBe('Zed-san'); + expect(view.result.current.squadUnavailable).toBe(false); + }); + + it('is nothing for a team the editor has since left: a handler that resumes after the editor was given another team adds it to no list', async () => { + const OTHER = [{ id: 'oth-1', index: 1, name: 'Oda' }]; + const view = await mount(); + const staleChange = view.result.current.changeMembers; + api.fetchSquads.mockResolvedValue({ u: OTHER }); + view.rerender(props({ teamId: 'u' })); + await act(async () => { await Promise.resolve(); }); + + act(() => { staleChange([NEW_MEMBER]); }); + + expect(view.result.current.squad).toEqual(OTHER); + expect(view.result.current.squadRef.current).toEqual(OTHER); + }); + + it('is not undone by a read of the members a followed lineup made for a team the editor has since left', async () => { + const view = await mount(); + const followed = deferred(); + api.fetchSquads.mockReturnValueOnce(followed.promise); + await announce(); + api.fetchSquads.mockRejectedValue(new Error('offline')); + view.rerender(props({ teamId: 'u' })); + await act(async () => { await Promise.resolve(); }); + + await act(async () => { followed.resolve({ t: [...MEMBERS, NEW_MEMBER] }); }); + + expect(view.result.current.squad, 'the team it shows now has no list').toEqual([]); + }); + }); + + // No screen removes a member, so a list that arrives, from a read or from an editor's + // own write, only ever adds to the one shown: every member of both lists, in member + // number order, and of a member both hold the copy with the larger stamp. + describe('a list that arrives is merged with the one shown', () => { + const MINTED = { id: 'mem-10', index: 5, name: 'Kato' }; + + it('keeps a member the editor added while a read begun before it was out: that read answers without them', async () => { + const view = await mount(); + const members = deferred(); + api.fetchSquads.mockReturnValue(members.promise); + await announce(); + + await change(view, [NEW_MEMBER]); + await act(async () => { members.resolve({ t: MEMBERS }); }); + + expect(view.result.current.squad).toEqual([...MEMBERS, NEW_MEMBER]); + expect(view.result.current.squadRef.current, 'and the ref a handler reads').toEqual([...MEMBERS, NEW_MEMBER]); + }); + + it('keeps a member a read brought in when the editor\'s update was built on the list from before it', async () => { + const view = await mount(); + const before = view.result.current.squad; + api.fetchSquads.mockResolvedValue({ t: [...MEMBERS, NEW_MEMBER] }); + api.fetchLineupInForce.mockResolvedValue(FOLLOWED); + await announce(); + expect(view.result.current.squad).toContainEqual(NEW_MEMBER); + + await change(view, [...before, MINTED]); + + expect(view.result.current.squad).toEqual([...MEMBERS, NEW_MEMBER, MINTED]); + expect(view.result.current.squadRef.current).toEqual([...MEMBERS, NEW_MEMBER, MINTED]); + }); + + it('takes a rename as the server answered it: the member with the new name and its stamp', async () => { + const view = await mount(); + const renamed = answered(MEMBERS[1], { name: 'Sato-san' }); + + await change(view, [renamed]); + + expect(view.result.current.squad).toEqual([MEMBERS[0], renamed, MEMBERS[2]]); + expect(view.result.current.squadRef.current[1].name).toBe('Sato-san'); + }); + + it('takes the member as a read holds it too: a name another device gave it later replaces the one shown', async () => { + const view = await mount(); + const later = answered(MEMBERS[1], { name: 'Sato-san' }); + api.fetchSquads.mockResolvedValue({ t: [MEMBERS[0], later, MEMBERS[2]] }); + + await announce(); + + expect(view.result.current.squad[1]).toEqual(later); + }); + + it('lists the members in member number order, whichever list each came in on', async () => { + const view = await mount(); + await change(view, [NEW_MEMBER]); + // The read answers with a member numbered after the one the editor added, and without it. + api.fetchSquads.mockResolvedValue({ t: [...MEMBERS, MINTED] }); + + await announce(); + + expect(view.result.current.squad.map((m) => m.index)).toEqual([1, 2, 3, 4, 5]); + expect(view.result.current.squadRef.current.map((m) => m.index)).toEqual([1, 2, 3, 4, 5]); + }); + + it('does not carry another team\'s members into the list of the team the editor is given', async () => { + const OTHER = [{ id: 'oth-1', index: 1, name: 'Oda' }]; + const view = await mount(); + expect(view.result.current.squad).toEqual(MEMBERS); + api.fetchSquads.mockResolvedValue({ u: OTHER }); + + view.rerender(props({ teamId: 'u' })); + await act(async () => { await Promise.resolve(); }); + + expect(view.result.current.squad).toEqual(OTHER); + expect(view.result.current.squadRef.current).toEqual(OTHER); + }); + }); + + // Each write of a member is stamped by the server, and the editor keeps the newest + // copy of each member by that stamp, whichever way it came: an own write's answer or a + // read's list, in whatever order they arrive. + describe('the newest copy of a member stands', () => { + it('an older list never undoes a newer rename', async () => { + const view = await mount(); + const renamed = answered(MEMBERS[1], { name: 'Sato-san' }); + await change(view, [renamed]); + // A list read before the rename was made: the member as it was, unstamped. + api.fetchSquads.mockResolvedValue({ t: MEMBERS }); + + await announce(); + + expect(view.result.current.squad[1]).toEqual(renamed); + expect(view.result.current.squadRef.current[1]).toEqual(renamed); + }); + + it('another device\'s later rename shows, even after this device wrote the member', async () => { + const view = await mount(); + const mine = answered(MEMBERS[1], { name: 'Sato-san' }); + await change(view, [mine]); + const theirs = namedLater(mine, 'Sato-kun'); + api.fetchSquads.mockResolvedValue({ t: [MEMBERS[0], theirs, MEMBERS[2]] }); + + await announce(); + + expect(view.result.current.squad[1]).toEqual(theirs); + expect(view.result.current.squadRef.current[1].name).toBe('Sato-kun'); + }); + + it('an own write\'s answer older than a list already shown does not undo the list', async () => { + const view = await mount(); + const mine = answered(MEMBERS[1], { name: 'Sato-san' }); + const theirs = namedLater(mine, 'Sato-kun'); + api.fetchSquads.mockResolvedValue({ t: [MEMBERS[0], theirs, MEMBERS[2]] }); + await announce(); + expect(view.result.current.squad[1]).toEqual(theirs); + + await change(view, [mine]); + + expect(view.result.current.squad[1]).toEqual(theirs); + expect(view.result.current.squadRef.current[1]).toEqual(theirs); + }); + + it('a name cleared by a write stands over a list that still names the member', async () => { + const view = await mount(); + const cleared = answered(MEMBERS[1], { name: '' }); + await change(view, [cleared]); + api.fetchSquads.mockResolvedValue({ t: MEMBERS }); + + await announce(); + + expect(view.result.current.squad[1]).toEqual(cleared); + }); + + it('a member nobody has written yet is taken as the list that arrives holds it', async () => { + const view = await mount(); + api.fetchSquads.mockResolvedValue({ t: [MEMBERS[0], { ...MEMBERS[1], name: 'Sato-sensei' }, MEMBERS[2]] }); + + await announce(); + + expect(view.result.current.squad[1].name).toBe('Sato-sensei'); + }); + }); + + it('are read again with a followed lineup and shown with it: no render shows a lineup naming a member the list lacks', async () => { + const renders = []; + const view = renderHook((p) => { + const form = useLineupForm(p); + renders.push({ squad: form.squad, memberIds: form.memberIds }); + return form; + }, { initialProps: props() }); + await act(async () => { await Promise.resolve(); }); + api.fetchSquads.mockResolvedValue({ t: [...MEMBERS, NEW_MEMBER] }); + api.fetchLineupInForce.mockResolvedValue(FOLLOWED); + renders.length = 0; + + await announce(); + + expect(api.fetchSquads).toHaveBeenCalledTimes(2); + expect(view.result.current.memberIds[2]).toBe('mem-9'); + expect(view.result.current.squad).toContainEqual(NEW_MEMBER); + expect(renders.length).toBeGreaterThan(0); + expect(renders.every(({ squad, memberIds }) => Object.values(memberIds).every((id) => !id || squad.some((m) => m.id === id)))).toBe(true); + }); + + it('keep the list the editor has, and the lineup is still shown, when the read that comes with a followed lineup fails', async () => { + const view = await mount(); + api.fetchSquads.mockRejectedValue(new Error('offline')); + api.fetchLineupInForce.mockResolvedValue(FOLLOWED); + + await announce(); + + expect(view.result.current.memberIds[2]).toBe('mem-9'); + expect(view.result.current.squad).toEqual(MEMBERS); + expect(view.result.current.squadUnavailable, 'a list that was read is not unavailable').toBe(false); + }); + + it('are waited for, with the lineup, only until the deadline: a read never answered does not hold a followed lineup back for good', async () => { + const view = await mount(); + api.fetchSquads.mockReturnValue(new Promise(() => {})); + api.fetchLineupInForce.mockResolvedValue(FOLLOWED); + vi.useFakeTimers(); + try { + await announce(); + expect(view.result.current.memberIds[2], 'shown with the members, so held while they are awaited').toBe('mem-2'); + + await act(async () => { await vi.advanceTimersByTimeAsync(FETCH_TIMEOUT_MS); }); + + expect(view.result.current.memberIds[2]).toBe('mem-9'); + expect(view.result.current.squad).toEqual(MEMBERS); + } finally { + vi.useRealTimers(); + } + }); + + it('are shown over an edit made while they were out, which the followed lineup is not: the list is not the operator\'s edit', async () => { + const view = await mount(); + const members = deferred(); + const lineup = deferred(); + api.fetchSquads.mockReturnValue(members.promise); + api.fetchLineupInForce.mockReturnValue(lineup.promise); + await announce(); + expect(api.fetchSquads).toHaveBeenCalledTimes(2); + + edit(view, { 1: 'Mori' }); + await act(async () => { + members.resolve({ t: [...MEMBERS, NEW_MEMBER] }); + lineup.resolve(FOLLOWED); + }); + + expect(view.result.current.values[1], 'the followed lineup is not shown over an edit').toBe('Mori'); + expect(view.result.current.squad, 'the list is').toEqual([...MEMBERS, NEW_MEMBER]); + + await announce(); + expect(api.fetchLineupInForce, 'the lineup of an edited form is not read again').toHaveBeenCalledTimes(2); + expect(api.fetchSquads, 'its members are').toHaveBeenCalledTimes(3); + }); + + it('are read again while the form holds edits, and the edits stay: the list is not the operator\'s edit', async () => { + const view = await mount(); + edit(view, { 1: 'Mori' }); + api.fetchSquads.mockResolvedValue({ t: [...MEMBERS, NEW_MEMBER] }); + api.fetchLineupInForce.mockResolvedValue(FOLLOWED); + + await announce(); + + expect(api.fetchSquads, 'a member another device added is read').toHaveBeenCalledTimes(2); + expect(view.result.current.squad).toContainEqual(NEW_MEMBER); + expect(view.result.current.squadRef.current, 'and is on the ref a Save resolves a typed name against').toContainEqual(NEW_MEMBER); + expect(api.fetchLineupInForce, 'the lineup is not read over the edit').toHaveBeenCalledTimes(1); + expect(view.result.current.values[1]).toBe('Mori'); + expect(view.result.current.values[2], 'nor is a position the operator left alone replaced').toBe('Sato'); + expect(view.result.current.dirty).toBe(true); + }); + + // A name typed before the first list is shown would be resolved against no members: a + // new name minted where the member's seeded slot is free, and the name of a member the + // team has refused by the server as a second one. The editors that resolve a typed name + // wait for the list first (waitForMembers), bounded by the deadline of any request. + describe('the wait for the first list', () => { + const openedWith = async (opening) => { + api.fetchSquads.mockReturnValueOnce(opening.promise); + const view = renderHook((p) => useLineupForm(p), { initialProps: props() }); + await act(async () => { await Promise.resolve(); }); + return view; + }; + + it('is nothing to wait for once the list the editor opened with is shown', async () => { + const view = await mount(); + expect(view.result.current.waitForMembers()).toBeNull(); + }); + + it('lasts until the read made as the editor opened shows its list, and says so', async () => { + const opening = deferred(); + const view = await openedWith(opening); + let outcome; + const waiting = view.result.current.waitForMembers(); + expect(waiting, 'the read is out').not.toBeNull(); + waiting.then((listed) => { outcome = listed; }); + await act(async () => { await Promise.resolve(); }); + expect(outcome, 'still waiting').toBeUndefined(); + + await act(async () => { opening.resolve({ t: MEMBERS }); }); + + expect(await waiting).toBe(true); + expect(view.result.current.squad).toEqual(MEMBERS); + expect(view.result.current.waitForMembers()).toBeNull(); + }); + + it('is ended by an empty list: a team with no members is a list that was read', async () => { + const opening = deferred(); + const view = await openedWith(opening); + const waiting = view.result.current.waitForMembers(); + + await act(async () => { opening.resolve({ t: [] }); }); + + expect(await waiting).toBe(true); + }); + + it('is ended by the read failing, which says no list was shown, and there is nothing to wait for after it', async () => { + const opening = deferred(); + const view = await openedWith(opening); + const waiting = view.result.current.waitForMembers(); + + await act(async () => { opening.reject(new Error('down')); }); + + expect(await waiting).toBe(false); + expect(view.result.current.squadUnavailable).toBe(true); + expect(view.result.current.waitForMembers(), 'the editor goes on without them, as it always did').toBeNull(); + }); + + it('is ended at the deadline of any bounded request, saying no list was shown', async () => { + vi.useFakeTimers(); + try { + const view = await openedWith(deferred()); + let outcome = 'waiting'; + view.result.current.waitForMembers().then((listed) => { outcome = listed; }); + + await act(async () => { await vi.advanceTimersByTimeAsync(FETCH_TIMEOUT_MS - 1); }); + expect(outcome).toBe('waiting'); + await act(async () => { await vi.advanceTimersByTimeAsync(1); }); + + expect(outcome).toBe(false); + } finally { + vi.useRealTimers(); + } + }); + + // A read that hangs holds up the first typed name for the deadline and no other: after + // it the editor goes on without the members, as it does when the read fails, and says + // so. A list that arrives later is still shown. + it('ends at the deadline for good: nothing is left to wait for, the members read as unavailable, and a list that arrives later is still shown', async () => { + vi.useFakeTimers(); + try { + const opening = deferred(); + const view = await openedWith(opening); + view.result.current.waitForMembers(); + + await act(async () => { await vi.advanceTimersByTimeAsync(FETCH_TIMEOUT_MS); }); + + expect(view.result.current.waitForMembers(), 'a name typed next goes on at once').toBeNull(); + expect(view.result.current.squadUnavailable, 'and the editor says the members could not be read').toBe(true); + + await act(async () => { opening.resolve({ t: MEMBERS }); }); + + expect(view.result.current.squad, 'the list that arrives late is shown').toEqual(MEMBERS); + expect(view.result.current.squadUnavailable, 'and the members are available again').toBe(false); + expect(view.result.current.waitForMembers()).toBeNull(); + } finally { + vi.useRealTimers(); + } + }); + + it('is ended for a second wait begun meanwhile by the first one\'s deadline, not held for a deadline of its own', async () => { + vi.useFakeTimers(); + try { + const view = await openedWith(deferred()); + view.result.current.waitForMembers(); + await act(async () => { await vi.advanceTimersByTimeAsync(5000); }); + let second = 'waiting'; + view.result.current.waitForMembers().then((listed) => { second = listed; }); + + await act(async () => { await vi.advanceTimersByTimeAsync(FETCH_TIMEOUT_MS - 5000); }); + + expect(second).toBe(false); + } finally { + vi.useRealTimers(); + } + }); + + // Only a read's list ends it: the members an editor's own write put on the list are + // not the team's, and a typed name resolved against them alone still misses the rest. + it('is not ended by a member the editor wrote itself, however newly stamped: only a read\'s list ends it', async () => { + const opening = deferred(); + const view = await openedWith(opening); + + await change(view, [answered(NEW_MEMBER)]); + + expect(view.result.current.squad).toEqual([expect.objectContaining({ id: 'mem-9', name: 'Zed' })]); + expect(view.result.current.waitForMembers()).not.toBeNull(); + await act(async () => { opening.resolve({ t: MEMBERS }); }); + expect(view.result.current.waitForMembers()).toBeNull(); + expect(view.result.current.squad.map((m) => m.id)).toEqual(['mem-1', 'mem-2', 'mem-3', 'mem-9']); + }); + + it('is not started again by a read that is out once a list has been shown', async () => { + const view = await mount(); + api.fetchSquads.mockReturnValue(new Promise(() => {})); + + await announce(); + + expect(api.fetchSquads, 'a followed lineup reads the members again').toHaveBeenCalledTimes(2); + expect(view.result.current.waitForMembers()).toBeNull(); + }); + + it('starts again when the editor is given another team, whose members were not read yet', async () => { + const view = await mount(); + expect(view.result.current.waitForMembers()).toBeNull(); + const other = deferred(); + api.fetchSquads.mockReturnValue(other.promise); + + view.rerender(props({ teamId: 'u' })); + await act(async () => { await Promise.resolve(); }); + const waiting = view.result.current.waitForMembers(); + expect(waiting).not.toBeNull(); + + await act(async () => { other.resolve({ u: [{ id: 'oth-1', index: 1, name: 'Oda' }] }); }); + expect(await waiting).toBe(true); + }); + }); +}); diff --git a/web-mobile/js/__tests__/lineup_save.test.jsx b/web-mobile/js/__tests__/lineup_save.test.jsx new file mode 100644 index 000000000..bb10c3c29 --- /dev/null +++ b/web-mobile/js/__tests__/lineup_save.test.jsx @@ -0,0 +1,136 @@ +// What a lineup save carries and how two saves of one lineup join in the offline +// queue (operator decision 2026-10-07, "Only changed positions"): lineup_save.jsx +// is the one owner of both. + +import { describe, it, expect } from 'vitest'; +import { changedLineupSave, joinQueuedLineupSave } from '../lineup_save.jsx'; + +const LINEUP = { teamId: 't1', competitionId: 'c1', matchId: 'm1' }; + +describe('changedLineupSave', () => { + const shown = { senpo: 'Kato', jiho: 'Ito', chuken: 'Mori' }; + const ids = { senpo: 'mem-k', jiho: '', chuken: 'mem-m' }; + + it('carries the name and the id of each changed position, and nothing else', () => { + expect(changedLineupSave(shown, ids, ['senpo', 'chuken'])).toEqual({ + positions: { senpo: 'Kato', chuken: 'Mori' }, + memberIds: { senpo: 'mem-k', chuken: 'mem-m' }, + changed: ['senpo', 'chuken'], + }); + }); + + it('names a changed position that has no id by its name alone', () => { + expect(changedLineupSave(shown, ids, ['jiho'])).toEqual({ + positions: { jiho: 'Ito' }, memberIds: {}, changed: ['jiho'], + }); + }); + + it('says a cleared position by its empty name, which the server needs to be there, with no id', () => { + const body = changedLineupSave({ senpo: '', jiho: 'Ito' }, { senpo: '', jiho: '' }, ['senpo']); + expect(body).toEqual({ positions: { senpo: '' }, memberIds: {}, changed: ['senpo'] }); + expect('senpo' in body.positions).toBe(true); + }); + + it('says a position missing from the lineup as shown the same way: cleared', () => { + expect(changedLineupSave({ jiho: 'Ito' }, {}, ['senpo'])).toEqual({ + positions: { senpo: '' }, memberIds: {}, changed: ['senpo'], + }); + }); + + it('keeps a picked member that has no name yet: the empty name with the id is a placement', () => { + expect(changedLineupSave({ senpo: '' }, { senpo: 'mem-blank' }, ['senpo'])).toEqual({ + positions: { senpo: '' }, memberIds: { senpo: 'mem-blank' }, changed: ['senpo'], + }); + }); + + it('is a copy: the changed list it carries is not the one it was given', () => { + const changed = ['senpo']; + const body = changedLineupSave(shown, ids, changed); + body.changed.push('jiho'); + expect(changed).toEqual(['senpo']); + }); +}); + +describe('joinQueuedLineupSave', () => { + const save = (positions, memberIds, changed) => ({ + ...LINEUP, positions, ...(memberIds ? { memberIds } : {}), ...(changed ? { changed } : {}), + }); + + it('joins two saves into one that changes every position either changed', () => { + const queued = save({ senpo: 'Kato' }, { senpo: 'mem-k' }, ['senpo']); + const incoming = save({ jiho: 'Ito' }, { jiho: 'mem-i' }, ['jiho']); + expect(joinQueuedLineupSave(queued, incoming)).toEqual( + save({ senpo: 'Kato', jiho: 'Ito' }, { senpo: 'mem-k', jiho: 'mem-i' }, ['senpo', 'jiho']), + ); + }); + + it('gives a position both saves changed the value of the later one, name and id', () => { + const queued = save({ senpo: 'Kato', jiho: 'Ito' }, { senpo: 'mem-k', jiho: 'mem-i' }, ['senpo', 'jiho']); + const incoming = save({ senpo: 'Ota' }, { senpo: 'mem-o' }, ['senpo']); + expect(joinQueuedLineupSave(queued, incoming)).toEqual( + save({ senpo: 'Ota', jiho: 'Ito' }, { senpo: 'mem-o', jiho: 'mem-i' }, ['senpo', 'jiho']), + ); + }); + + it('drops the id a position had when the later save names it without one', () => { + const queued = save({ senpo: 'Kato' }, { senpo: 'mem-k' }, ['senpo']); + const incoming = save({ senpo: 'Ota' }, undefined, ['senpo']); + const joined = joinQueuedLineupSave(queued, incoming); + expect(joined.positions).toEqual({ senpo: 'Ota' }); + expect(joined.memberIds).toEqual({}); + }); + + it('makes a position the later save cleared its empty name with no id, which stays a change', () => { + const queued = save({ senpo: 'Kato', jiho: 'Ito' }, { senpo: 'mem-k' }, ['senpo', 'jiho']); + const incoming = save({ senpo: '' }, {}, ['senpo']); + const joined = joinQueuedLineupSave(queued, incoming); + expect(joined.positions).toEqual({ senpo: '', jiho: 'Ito' }); + expect(joined.memberIds).toEqual({}); + expect(joined.changed).toEqual(['senpo', 'jiho']); + }); + + it('lists a changed position once, in the order it was first changed', () => { + const queued = save({ jiho: 'Ito', senpo: 'Kato' }, undefined, ['jiho', 'senpo']); + const incoming = save({ senpo: 'Ota', chuken: 'Mori' }, undefined, ['senpo', 'chuken']); + expect(joinQueuedLineupSave(queued, incoming).changed).toEqual(['jiho', 'senpo', 'chuken']); + }); + + it('takes what names the lineup from the later save', () => { + const queued = { ...save({ senpo: 'Kato' }, undefined, ['senpo']), teamId: 't-old' }; + const joined = joinQueuedLineupSave(queued, save({ jiho: 'Ito' }, undefined, ['jiho'])); + expect(joined.teamId).toBe('t1'); + expect(joined.competitionId).toBe('c1'); + expect(joined.matchId).toBe('m1'); + }); + + it('applies the changes onto a save an earlier build queued, which has no changed list, and stays whole', () => { + const queued = save({ senpo: 'Sato', jiho: 'Tanaka', taisho: 'Ito' }, { senpo: 'mem-s', jiho: 'mem-t' }); + const incoming = save({ senpo: 'Kato', jiho: '' }, { senpo: 'mem-k' }, ['senpo', 'jiho']); + const joined = joinQueuedLineupSave(queued, incoming); + expect(joined).toEqual(save({ senpo: 'Kato', taisho: 'Ito' }, { senpo: 'mem-k' })); + expect('changed' in joined).toBe(false); + // A whole save has no empty names: a cleared position is simply absent, as every + // earlier build sent it. + expect('jiho' in joined.positions).toBe(false); + }); + + it('keeps the ids a save an earlier build queued has for the positions nobody changed', () => { + const queued = save({ senpo: 'Sato', jiho: 'Tanaka' }, { senpo: 'mem-s', jiho: 'mem-t' }); + const joined = joinQueuedLineupSave(queued, save({ jiho: 'Ito' }, { jiho: 'mem-i' }, ['jiho'])); + expect(joined.memberIds).toEqual({ senpo: 'mem-s', jiho: 'mem-i' }); + }); + + it('takes a save that names no changed positions as the whole lineup: it replaces the queued one', () => { + const queued = save({ senpo: 'Kato' }, { senpo: 'mem-k' }, ['senpo']); + const incoming = save({ jiho: 'Ito' }); + expect(joinQueuedLineupSave(queued, incoming)).toEqual(incoming); + }); + + it('changes neither save it is given', () => { + const queued = save({ senpo: 'Kato' }, { senpo: 'mem-k' }, ['senpo']); + const incoming = save({ senpo: 'Ota', jiho: 'Ito' }, { senpo: 'mem-o' }, ['senpo', 'jiho']); + const before = JSON.stringify([queued, incoming]); + joinQueuedLineupSave(queued, incoming); + expect(JSON.stringify([queued, incoming])).toBe(before); + }); +}); diff --git a/web-mobile/js/__tests__/lineup_save_queue.test.jsx b/web-mobile/js/__tests__/lineup_save_queue.test.jsx new file mode 100644 index 000000000..5220c471a --- /dev/null +++ b/web-mobile/js/__tests__/lineup_save_queue.test.jsx @@ -0,0 +1,259 @@ +// A lineup save names the positions it changed, and the offline queue keeps ONE +// entry per lineup (operator decision 2026-10-07, "Only changed positions"): a save +// of a lineup that already has one queued joins that entry, changing the union of +// the positions, the later value standing where both changed one. A save a previous +// build queued names no changed positions: it stays whole and has the new changes +// applied onto it. The server here follows the contract (helpers/lineup_server.js), +// so what survives a replay is judged on the lineup it ends with. +// +// Setup mirrors queue_ordered_writes.test.jsx (functional localStorage, EventSource +// stub, fake timers, the background clock poll never reaching the server). + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { makeLineupServer } from './helpers/lineup_server.js'; + +let API; +let _origFetch; +let _origEventSource; +let _origLocalStorage; +let _lsStore; + +beforeEach(async () => { + vi.useFakeTimers(); + vi.resetModules(); + _origEventSource = global.EventSource; + global.EventSource = class FakeES { constructor() { this.close = () => {}; } }; + global.EventSource.OPEN = 1; + _origFetch = global.fetch; + _origLocalStorage = global.localStorage; + _lsStore = {}; + global.localStorage = { + getItem: (k) => (k in _lsStore ? _lsStore[k] : null), + setItem: (k, v) => { _lsStore[k] = String(v); }, + removeItem: (k) => { delete _lsStore[k]; }, + clear: () => { _lsStore = {}; }, + }; + API = (await import('../api_client.jsx')).API; +}); + +afterEach(() => { + vi.useRealTimers(); + vi.resetModules(); + global.fetch = _origFetch; + if (_origEventSource === undefined) delete global.EventSource; + else global.EventSource = _origEventSource; + if (_origLocalStorage === undefined) delete global.localStorage; + else global.localStorage = _origLocalStorage; +}); + +async function flushMicrotasks() { + for (let i = 0; i < 12; i++) await Promise.resolve(); +} +async function tick(ms = 0) { + await vi.advanceTimersByTimeAsync(ms); + await flushMicrotasks(); +} + +const storedEntries = () => JSON.parse(localStorage.getItem('bc_write_queue') || '[]'); +const queuedPayloads = () => storedEntries().map(([, d]) => d.payload); + +// The contract server behind the fetch the API sees; the background clock poll +// never reaches it. `reconnect` brings the connection back and tells the page. +function connect(options) { + const server = makeLineupServer(options); + global.fetch = vi.fn((url, opts) => (String(url).includes('/api/time') + ? Promise.reject(new TypeError('clock poll: not part of this test')) + : server.fetch(url, opts))); + server.reconnect = async () => { + server.offline = false; + window.dispatchEvent(new Event('online')); + await tick(50); + }; + return server; +} + +const STORED = { + positions: { senpo: 'Sato', jiho: 'Tanaka', taisho: 'Ito' }, + memberIds: { senpo: 'mem-s', jiho: 'mem-t', taisho: 'mem-i' }, +}; + +describe('a save that reaches the server names the positions it changed', () => { + it('sends the changed positions and their ids, and the list that names them, and nothing else', async () => { + const s = connect({ own: { m1: STORED } }); + await API.putMatchLineup('c1', 't1', 'm1', { senpo: 'Kato' }, 'pw', { senpo: 'mem-k' }, ['senpo']); + expect(s.puts).toEqual([{ + url: '/api/competitions/c1/teams/t1/match-lineups/m1', + body: { + teamId: 't1', competitionId: 'c1', matchId: 'm1', + positions: { senpo: 'Kato' }, memberIds: { senpo: 'mem-k' }, changed: ['senpo'], + }, + }]); + }); + + it('does the same for a team\'s starting lineup, a cleared position being its empty name', async () => { + const s = connect({ starting: STORED }); + await API.putTeamLineup('c1', 't1', 0, { jiho: '' }, 'pw', undefined, ['jiho']); + expect(s.puts).toEqual([{ + url: '/api/competitions/c1/teams/t1/lineups/0', + body: { teamId: 't1', competitionId: 'c1', round: 0, positions: { jiho: '' }, changed: ['jiho'] }, + }]); + expect(s.starting.positions).toEqual({ senpo: 'Sato', taisho: 'Ito' }); + }); + + it('answers the whole lineup as the server holds it, so a position another device changed shows', async () => { + connect({ own: { m1: { positions: { ...STORED.positions, taisho: 'Mori' }, memberIds: { ...STORED.memberIds, taisho: 'mem-m' } } } }); + const answer = await API.putMatchLineup('c1', 't1', 'm1', { senpo: 'Kato' }, 'pw', { senpo: 'mem-k' }, ['senpo']); + expect(answer.positions).toEqual({ senpo: 'Kato', jiho: 'Tanaka', taisho: 'Mori' }); + expect(answer.memberIds).toEqual({ senpo: 'mem-k', jiho: 'mem-t', taisho: 'mem-m' }); + }); + + it('a save that names no changed positions is sent as the whole lineup it always was', async () => { + const s = connect({ own: { m1: STORED } }); + await API.putMatchLineup('c1', 't1', 'm1', { senpo: 'Kato' }, 'pw'); + expect(s.puts[0].body).toEqual({ teamId: 't1', competitionId: 'c1', matchId: 'm1', positions: { senpo: 'Kato' } }); + expect('changed' in s.puts[0].body).toBe(false); + }); +}); + +describe('a save made offline replays naming only what it changed', () => { + it('keeps what another device changed meanwhile: the replayed body carries Senpo alone, and Taisho survives', async () => { + const s = connect({ own: { m1: STORED } }); + s.offline = true; + // Device 1, with no connection, changes Senpo and saves. + expect(await API.putMatchLineup('c1', 't1', 'm1', { senpo: 'Kato' }, 'pw', { senpo: 'mem-k' }, ['senpo'])).toEqual({ queued: true }); + await tick(0); + // Device 2 changed Taisho meanwhile. + s.own.m1 = { positions: { ...STORED.positions, taisho: 'Mori' }, memberIds: { ...STORED.memberIds, taisho: 'mem-m' } }; + await s.reconnect(); + expect(s.puts).toHaveLength(1); + expect(s.puts[0].body.changed).toEqual(['senpo']); + expect(s.own.m1.positions).toEqual({ senpo: 'Kato', jiho: 'Tanaka', taisho: 'Mori' }); + expect(s.own.m1.memberIds).toEqual({ senpo: 'mem-k', jiho: 'mem-t', taisho: 'mem-m' }); + expect(API.queuedLineupSave('c1', 't1', { matchId: 'm1' })).toBe(false); + expect(localStorage.getItem('bc_write_queue')).toBeNull(); + }); + + it('keeps the names the lineup in force carried when the match has no lineup of its own', async () => { + const s = connect({ carried: { m1: STORED } }); + s.offline = true; + await API.putMatchLineup('c1', 't1', 'm1', { senpo: 'Kato' }, 'pw', undefined, ['senpo']); + await tick(0); + await s.reconnect(); + expect(s.own.m1.positions).toEqual({ senpo: 'Kato', jiho: 'Tanaka', taisho: 'Ito' }); + }); +}); + +describe('a lineup has one entry in the queue', () => { + it('joins a later save into the queued one: both changes, and the later value of a position both changed', async () => { + const s = connect({ own: { m1: STORED } }); + s.offline = true; + await API.putMatchLineup('c1', 't1', 'm1', { senpo: 'Kato' }, 'pw', { senpo: 'mem-k' }, ['senpo']); + vi.advanceTimersByTime(1000); + const answer = await API.putMatchLineup('c1', 't1', 'm1', { senpo: 'Ota', jiho: 'Hara' }, 'pw', { senpo: 'mem-o', jiho: 'mem-h' }, ['senpo', 'jiho']); + expect(answer).toEqual({ queued: true }); + expect(storedEntries()).toHaveLength(1); + expect(queuedPayloads()[0]).toEqual({ + teamId: 't1', competitionId: 'c1', matchId: 'm1', + positions: { senpo: 'Ota', jiho: 'Hara' }, memberIds: { senpo: 'mem-o', jiho: 'mem-h' }, changed: ['senpo', 'jiho'], + }); + await tick(0); + await s.reconnect(); + expect(s.puts).toHaveLength(1); + expect(s.own.m1.positions).toEqual({ senpo: 'Ota', jiho: 'Hara', taisho: 'Ito' }); + }); + + it('joins a save of another position: the first change is not lost to the second', async () => { + const s = connect({ own: { m1: STORED } }); + s.offline = true; + await API.putMatchLineup('c1', 't1', 'm1', { senpo: 'Kato' }, 'pw', { senpo: 'mem-k' }, ['senpo']); + await API.putMatchLineup('c1', 't1', 'm1', { taisho: 'Mori' }, 'pw', { taisho: 'mem-m' }, ['taisho']); + expect(queuedPayloads()[0].changed).toEqual(['senpo', 'taisho']); + await tick(0); + await s.reconnect(); + expect(s.own.m1.positions).toEqual({ senpo: 'Kato', jiho: 'Tanaka', taisho: 'Mori' }); + }); + + it('makes a position the later save cleared its empty name with no id, and the replay clears it', async () => { + const s = connect({ own: { m1: STORED } }); + s.offline = true; + await API.putMatchLineup('c1', 't1', 'm1', { jiho: 'Hara' }, 'pw', { jiho: 'mem-h' }, ['jiho']); + await API.putMatchLineup('c1', 't1', 'm1', { jiho: '' }, 'pw', undefined, ['jiho']); + expect(queuedPayloads()[0].positions).toEqual({ jiho: '' }); + expect(queuedPayloads()[0].memberIds).toEqual({}); + await tick(0); + await s.reconnect(); + expect(s.own.m1.positions).toEqual({ senpo: 'Sato', taisho: 'Ito' }); + expect(s.own.m1.memberIds).toEqual({ senpo: 'mem-s', taisho: 'mem-i' }); + }); + + it('never sends a save straight to the server while one is queued, even with the connection back: one request carries both', async () => { + const s = connect({ own: { m1: STORED } }); + s.offline = true; + await API.putMatchLineup('c1', 't1', 'm1', { senpo: 'Kato' }, 'pw', { senpo: 'mem-k' }, ['senpo']); + await tick(0); + // The connection is back, but the page has not been told: the queued save is + // still waiting for its backoff timer. + s.offline = false; + expect(API.queuedLineupSave('c1', 't1', { matchId: 'm1' })).toBe(true); + await API.putMatchLineup('c1', 't1', 'm1', { jiho: 'Hara' }, 'pw', { jiho: 'mem-h' }, ['jiho']); + await tick(50); + expect(s.puts).toHaveLength(1); + expect(s.puts[0].body.changed).toEqual(['senpo', 'jiho']); + expect(s.own.m1.positions).toEqual({ senpo: 'Kato', jiho: 'Hara', taisho: 'Ito' }); + }); + + it('keeps a match\'s lineup and a team\'s starting lineup as two entries', async () => { + global.fetch = vi.fn(() => Promise.reject(new TypeError('network error'))); + await API.putMatchLineup('c1', 't1', 'm1', { senpo: 'Kato' }, 'pw', undefined, ['senpo']); + await API.putTeamLineup('c1', 't1', 0, { senpo: 'Ota' }, 'pw', undefined, ['senpo']); + expect(storedEntries()).toHaveLength(2); + expect(API.queuedLineupSave('c1', 't1', { matchId: 'm1' })).toBe(true); + expect(API.queuedLineupSave('c1', 't1', { round: 0 })).toBe(true); + }); +}); + +describe('a save a previous build queued', () => { + // The previous build sent a whole lineup and kept one entry per lineup, keyed by + // the lineup itself. It loads, and replays, as it is. + const oldEntry = [ + 'lineup:c1:t1:match:m1', + { + enqueuedAt: Date.now() - 60_000, password: 'pw', + compID: 'c1', matchID: 'm1', kind: 'lineup', terminal: true, method: 'PUT', + url: '/api/competitions/c1/teams/t1/match-lineups/m1', + payload: { teamId: 't1', competitionId: 'c1', matchId: 'm1', ...STORED }, + }, + ]; + + async function reload() { + localStorage.setItem('bc_write_queue', JSON.stringify([oldEntry])); + vi.resetModules(); + API = (await import('../api_client.jsx')).API; + } + + it('stays whole when a save is made: the new changes are applied onto its lineup, and it names none', async () => { + await reload(); + const s = connect({ own: { m1: STORED } }); + s.offline = true; + expect(API.queuedLineupSave('c1', 't1', { matchId: 'm1' })).toBe(true); + await API.putMatchLineup('c1', 't1', 'm1', { senpo: 'Kato', jiho: '' }, 'pw', { senpo: 'mem-k' }, ['senpo', 'jiho']); + expect(storedEntries()).toHaveLength(1); + expect(queuedPayloads()[0]).toEqual({ + teamId: 't1', competitionId: 'c1', matchId: 'm1', + positions: { senpo: 'Kato', taisho: 'Ito' }, memberIds: { senpo: 'mem-k', taisho: 'mem-i' }, + }); + expect('changed' in queuedPayloads()[0]).toBe(false); + }); + + it('replays as a whole lineup, which replaces what the server holds', async () => { + await reload(); + const s = connect({ own: { m1: { positions: { senpo: 'Z' }, memberIds: {} } } }); + s.offline = true; + await API.putMatchLineup('c1', 't1', 'm1', { senpo: 'Kato' }, 'pw', { senpo: 'mem-k' }, ['senpo']); + await tick(0); + await s.reconnect(); + expect(s.puts).toHaveLength(1); + expect('changed' in s.puts[0].body).toBe(false); + expect(s.own.m1.positions).toEqual({ senpo: 'Kato', jiho: 'Tanaka', taisho: 'Ito' }); + }); +}); diff --git a/web-mobile/js/__tests__/lineup_source_label.test.jsx b/web-mobile/js/__tests__/lineup_source_label.test.jsx new file mode 100644 index 000000000..da5202bfd --- /dev/null +++ b/web-mobile/js/__tests__/lineup_source_label.test.jsx @@ -0,0 +1,174 @@ +// What both lineup editors (the at-court panel and the Lineups page) share, as +// the pure helpers in lineup_resolver.jsx: the fields a lineup read fills in, +// the wording that says where it was saved, and the wording for taking a +// match's own lineup away. + +import { describe, it, expect, vi } from 'vitest'; +// viewer_utils publishes window.poolLabel, which scoreRowMatchLabel reads when +// it names a pool match (in the app viewer.js evaluates it first). +import '../viewer_utils.jsx'; +import { + lineupFields, lineupSourceOf, lineupSourceLabel, isOwnLineup, STARTING_ROUND, PREVIOUS_LINEUP_LABEL, SAVE_QUEUED_REASON, + REMOVED_UNREAD_NOTICE, LINEUP_READ_NO_ANSWER, lineupReadFailure, previousLineupConfirm, +} from '../lineup_resolver.jsx'; + +const ALL = [ + { id: 'Pool D-0', phase: 'pool', poolName: 'Pool D' }, + { id: 'k-r0-m3', phase: 'bracket', matchNumber: 7 }, + { id: 'bronze', phase: 'bracket' }, +]; + +describe('lineupFields', () => { + it('names every position, vacant ones as empty strings', () => { + const lineup = { positions: { 1: 'Aoki', 3: 'Ito' }, memberIds: { 1: 'mem-1', 3: 'mem-3' } }; + expect(lineupFields(lineup, ['1', '2', '3'])).toEqual({ + positions: { 1: 'Aoki', 2: '', 3: 'Ito' }, + memberIds: { 1: 'mem-1', 2: '', 3: 'mem-3' }, + }); + }); + + it('keeps a member fielded by number alone, with no name', () => { + const lineup = { positions: { 1: '' }, memberIds: { 1: 'mem-1' } }; + expect(lineupFields(lineup, ['1'])).toEqual({ positions: { 1: '' }, memberIds: { 1: 'mem-1' } }); + }); + + it('reads a lineup saved before members had ids, and no lineup at all, as blank', () => { + expect(lineupFields({ positions: { 1: 'Aoki' } }, ['1', '2'])).toEqual({ + positions: { 1: 'Aoki', 2: '' }, + memberIds: { 1: '', 2: '' }, + }); + expect(lineupFields(null, ['1'])).toEqual({ positions: { 1: '' }, memberIds: { 1: '' } }); + }); + + it('ignores a position the team has no slot for', () => { + expect(lineupFields({ positions: { 1: 'Aoki', 9: 'Ghost' } }, ['1']).positions).toEqual({ 1: 'Aoki' }); + }); +}); + +describe('lineupSourceOf', () => { + it('reads the source the server names', () => { + expect(lineupSourceOf({ sourceMatchId: 'm0' })).toEqual({ matchId: 'm0' }); + expect(lineupSourceOf({ sourceRound: 0 })).toEqual({ round: 0 }); + expect(lineupSourceOf({ positions: {} })).toBeNull(); + expect(lineupSourceOf(null)).toBeNull(); + }); +}); + +describe('isOwnLineup', () => { + it('is true only for a lineup saved for this very match', () => { + expect(isOwnLineup({ matchId: 'Pool D-1' }, 'Pool D-1')).toBe(true); + expect(isOwnLineup({ matchId: 'Pool D-0' }, 'Pool D-1')).toBe(false); + expect(isOwnLineup({ round: 0 }, 'Pool D-1')).toBe(false); + expect(isOwnLineup(null, 'Pool D-1')).toBe(false); + }); + + it('is false for the starting lineup, which has no match to own it', () => { + expect(isOwnLineup({ matchId: 'Pool D-1' }, '')).toBe(false); + expect(isOwnLineup({ matchId: '' }, '')).toBe(false); + }); + + it('is what the label calls "Lineup for this match"', () => { + for (const [source, matchId] of [[{ matchId: 'a' }, 'a'], [{ matchId: 'b' }, 'a'], [{ round: 0 }, 'a'], [null, 'a'], [{ matchId: 'a' }, '']]) { + expect(lineupSourceLabel(source, matchId, []) === 'Lineup for this match').toBe(isOwnLineup(source, matchId)); + } + }); +}); + +describe('lineupSourceLabel', () => { + it.each([ + ['nothing in force', null, 'No lineup saved yet'], + ['its own', { matchId: 'Pool D-1' }, 'Lineup for this match'], + ['carried from a pool match', { matchId: 'Pool D-0' }, 'Same as Pool D · Match 1'], + ['carried from a knockout match', { matchId: 'k-r0-m3' }, 'Same as Match 7'], + ['carried from a match the list does not hold', { matchId: 'gone-9' }, 'Same as gone-9'], + ['the starting lineup', { round: 0 }, 'Starting lineup'], + ])('%s', (_name, source, label) => { + expect(lineupSourceLabel(source, 'Pool D-1', ALL)).toBe(label); + }); + + it('takes the matches as a function too, called only when the lineup is carried from another match', () => { + const matches = vi.fn(() => ALL); + expect(lineupSourceLabel(null, 'Pool D-1', matches)).toBe('No lineup saved yet'); + expect(lineupSourceLabel({ matchId: 'Pool D-1' }, 'Pool D-1', matches)).toBe('Lineup for this match'); + expect(lineupSourceLabel({ round: 0 }, 'Pool D-1', matches)).toBe('Starting lineup'); + expect(matches).not.toHaveBeenCalled(); + + expect(lineupSourceLabel({ matchId: 'Pool D-0' }, 'Pool D-1', matches)).toBe('Same as Pool D · Match 1'); + expect(matches).toHaveBeenCalledTimes(1); + }); + + it('names the match by its id when there are no matches to look in', () => { + expect(lineupSourceLabel({ matchId: 'gone-9' }, 'Pool D-1')).toBe('Same as gone-9'); + expect(lineupSourceLabel({ matchId: 'gone-9' }, 'Pool D-1', () => undefined)).toBe('Same as gone-9'); + }); +}); + +describe('the wording for taking a match\'s own lineup away', () => { + it('names the button and, in the confirm, the match, the team and what else follows', () => { + expect(PREVIOUS_LINEUP_LABEL).toBe('Use the previous match\'s lineup'); + const confirm = previousLineupConfirm('Pool D · Match 2', 'Team A'); + expect(confirm.message).toContain('Pool D · Match 2'); + expect(confirm.message).toContain('Team A carries the lineup of its previous match'); + expect(confirm.message).toContain('Later matches that have no lineup of their own follow too.'); + }); + + it('says what happens to the team\'s first match, and that unsaved changes are discarded', () => { + const { message, confirmLabel, cancelLabel } = previousLineupConfirm('Pool D · Match 1', 'Team A'); + expect(message).toBe( + 'Use the lineup Team A had before Pool D · Match 1? The lineup entered for Pool D · Match 1 is removed, ' + + 'so Team A carries the lineup of its previous match, or its starting lineup if this is its first match. ' + + 'Later matches that have no lineup of their own follow too. Unsaved changes here are discarded.', + ); + expect(confirmLabel).toBe('Use previous lineup'); + expect(cancelLabel).toBe('Cancel'); + }); + + it('reads on when the match or the team has no name', () => { + expect(previousLineupConfirm('', '').message).toContain('before this match?'); + expect(previousLineupConfirm('', '').message).toContain('the team carries'); + }); +}); + +describe('the other words the lineup editors share', () => { + it('say why the button waits for a queued save, and what a removal whose re-read failed leaves', () => { + expect(SAVE_QUEUED_REASON).toBe('A save of this lineup is still waiting to be sent.'); + expect(REMOVED_UNREAD_NOTICE).toBe('Removed. The lineup this match now uses could not be read: try again.'); + }); + + it('keep the team\'s starting lineup as its round-0 entry', () => { + expect(STARTING_ROUND).toBe(0); + }); +}); + +// What an editor shows when the read of a lineup fails: the server's own words +// when it answered, one plain sentence when it never did. +describe('lineupReadFailure', () => { + it('words a read nobody answered as one sentence that says what to do', () => { + expect(LINEUP_READ_NO_ANSWER).toBe( + 'The lineup could not be read: the server did not answer. Check the connection and try again.', + ); + }); + + it.each([ + ['Chrome', 'Failed to fetch'], + ['Firefox', 'NetworkError when attempting to fetch resource.'], + ['Safari', 'Load failed'], + ])('says a request that never reached the server by that sentence, whatever %s calls it', (_browser, text) => { + expect(lineupReadFailure(new TypeError(text))).toBe(LINEUP_READ_NO_ANSWER); + }); + + it('says a request given up on at its deadline by that sentence too', () => { + const timedOut = Object.assign(new Error('the request was not answered in time'), { timedOut: true }); + expect(lineupReadFailure(timedOut)).toBe(LINEUP_READ_NO_ANSWER); + }); + + it('keeps the server\'s own words when it answered with some', () => { + expect(lineupReadFailure(new Error('competition not found'))).toBe('competition not found'); + }); + + it('falls back to a plain sentence for an answer with no words, or for no error at all', () => { + expect(lineupReadFailure(new Error(''))).toBe('Failed to load lineup'); + expect(lineupReadFailure(undefined)).toBe('Failed to load lineup'); + expect(lineupReadFailure(null)).toBe('Failed to load lineup'); + }); +}); diff --git a/web-mobile/js/__tests__/match_lineup_panel_roster.test.jsx b/web-mobile/js/__tests__/match_lineup_panel_roster.test.jsx index 984171748..282a151ab 100644 --- a/web-mobile/js/__tests__/match_lineup_panel_roster.test.jsx +++ b/web-mobile/js/__tests__/match_lineup_panel_roster.test.jsx @@ -93,8 +93,7 @@ describe('MatchLineupPanel roster resolution (mp-bkg)', () => { // MatchLineupSideEditor fetches its saved lineup on mount: stub to null. global.window.API = { - fetchMatchLineup: vi.fn().mockResolvedValue(null), - fetchTeamLineup: vi.fn().mockResolvedValue(null), + fetchLineupInForce: vi.fn().mockResolvedValue(null), }; runtime = makeReactive(); @@ -161,6 +160,30 @@ describe('MatchLineupPanel roster resolution (mp-bkg)', () => { expect(sideEditors.length).toBe(0); }); + it('hands the side editors a function for the competition\'s matches, which builds them only when asked', async () => { + global.window.compMatches = vi.fn(() => [{ id: 'match-0', phase: 'pool', poolName: 'Pool A' }]); + runtime.mount(MatchLineupPanel, { + match: MATCH, + tournament: TOURNAMENT, + password: 'pw', + showToast: vi.fn(), + onClose: vi.fn(), + }); + await Promise.resolve(); + await Promise.resolve(); + + // A render of the panel builds nothing: only a lineup carried from another + // match needs one match of the list, and the side editor asks for it then. + expect(global.window.compMatches).not.toHaveBeenCalled(); + const sideEditors = findComponents(runtime.currentTree(), n => n === 'MatchLineupSideEditor'); + expect(sideEditors.length).toBe(2); + for (const node of sideEditors) expect(typeof node.props.allMatches).toBe('function'); + + expect(sideEditors[0].props.allMatches()).toEqual([{ id: 'match-0', phase: 'pool', poolName: 'Pool A' }]); + expect(global.window.compMatches).toHaveBeenCalledTimes(1); + expect(global.window.compMatches.mock.calls[0][0].id).toBe(COMP_ID); + }); + it('falls back to the match-side object when no participant matches', async () => { const matchUnknown = { ...MATCH, diff --git a/web-mobile/js/__tests__/match_lineup_side_editor_in_force.test.jsx b/web-mobile/js/__tests__/match_lineup_side_editor_in_force.test.jsx new file mode 100644 index 000000000..003c8727f --- /dev/null +++ b/web-mobile/js/__tests__/match_lineup_side_editor_in_force.test.jsx @@ -0,0 +1,263 @@ +// The at-court lineup panel (MatchLineupSideEditor) shows the lineup the team +// fields at the match, the way the score sheet does (resolveMatchLineup: the +// match's own lineup, else the one the team carries from its previous match or +// round; the server owns the rule), says where that lineup was saved, and never +// writes a side the operator did not change. Same harness as +// match_lineup_side_editor_member_ids.test.jsx. + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { makeReactive } from './helpers/reactive_react.js'; +import { collectText, expandNamed } from './helpers/vdom.js'; +import { answered } from './helpers/team_members.js'; + +const realReact = global.React; + +function childrenOf(node) { + const c = node.children; + if (c !== undefined && c !== null && !(Array.isArray(c) && c.length === 0)) return c; + return node.props?.children; +} +function walk(node, visit) { + if (node == null || node === false || node === true) return; + if (Array.isArray(node)) { for (const n of node) walk(n, visit); return; } + if (typeof node !== 'object') return; + visit(node); + walk(childrenOf(node), visit); +} +function findHosts(tree, typeName) { + const out = []; + walk(tree, n => { if (n && n.type === typeName) out.push(n); }); + return out; +} +function findComponents(tree, name) { + const out = []; + walk(tree, n => { if (n && typeof n.type === 'function' && n.type.name === name) out.push(n); }); + return out; +} +const saveButton = (tree) => + findHosts(tree, 'button').find(b => /Save lineup/.test(collectText(b))); +// This runtime never calls a child component, so the two the editor hands its +// source line and its read problem to are expanded by name (both are hook-free). +const allText = (tree) => collectText(tree, expandNamed('LineupSourceLine', 'LineupProblem')); + +describe('MatchLineupSideEditor shows the lineup in force, where it came from, and writes only a changed side', () => { + let runtime, MatchLineupSideEditor; + let origAPI, origHelpers, origCompMatches; + + const COMP = { id: 'comp-1', name: 'Team Event', kind: 'team', teamSize: 3 }; + const TEAM = { id: 'uuid-grouped', name: 'Grouped Team', number: 'T5' }; + const MATCH = { + id: 'Pool D-1', compId: 'comp-1', phase: 'pool', poolName: 'Pool D', + sideA: { id: 'uuid-grouped', name: 'Grouped Team' }, sideB: { id: 'other', name: 'Other' }, status: 'scheduled', + }; + // The team's earlier match, which the lineup in force is carried from. + const EARLIER = { + id: 'Pool D-0', compId: 'comp-1', phase: 'pool', poolName: 'Pool D', + sideA: { id: 'uuid-grouped', name: 'Grouped Team' }, sideB: { id: 'third', name: 'Third' }, status: 'completed', + }; + const SQUAD = [ + { id: 'mem-1', index: 1, name: 'Aoki' }, + { id: 'mem-2', index: 2, name: 'Sato' }, + { id: 'mem-3', index: 3, name: 'Ito' }, + { id: 'mem-4', index: 4, name: 'Mori' }, + ]; + const NAMES = { + positions: { 1: 'Aoki', 2: 'Sato', 3: 'Ito' }, + memberIds: { 1: 'mem-1', 2: 'mem-2', 3: 'mem-3' }, + }; + // What the server answers, by where the lineup was saved. + const CARRIED = { teamId: 'uuid-grouped', matchId: 'Pool D-0', round: 0, ...NAMES, sourceMatchId: 'Pool D-0', saved: true }; + const OWN = { teamId: 'uuid-grouped', matchId: 'Pool D-1', round: 0, ...NAMES, sourceMatchId: 'Pool D-1', saved: true }; + const STARTING = { teamId: 'uuid-grouped', round: 0, ...NAMES, sourceRound: 0, saved: true }; + + beforeEach(async () => { + origAPI = global.window.API; + origHelpers = global.window.AdminLineupHelpers; + origCompMatches = global.window.compMatches; + + global.window.compMatches = () => []; + global.window.AdminLineupHelpers = { + positionsForSize: (n) => Array.from({ length: n }, (_, i) => ({ key: String(i + 1), label: String(i + 1) })), + rosterFor: () => [], + mergeRosterWithAssigned: (base) => (Array.isArray(base) ? base : []), + teamIdOf: (t) => t?.id || t?.name || '', + resolveMemberIdsForPositions: vi.fn().mockResolvedValue({ memberIds: {}, squad: [], failures: [] }), + memberIdentityWarning: () => '', + }; + global.window.API = { + fetchLineupInForce: vi.fn().mockResolvedValue(CARRIED), + fetchSquads: vi.fn().mockResolvedValue({ 'uuid-grouped': SQUAD }), + putMatchLineup: vi.fn().mockResolvedValue({ positions: {} }), + }; + + runtime = makeReactive(); + global.React = runtime.React; + vi.resetModules(); + ({ MatchLineupSideEditor } = await import('../admin_schedule_lineup.jsx')); + }); + + afterEach(() => { + runtime.unmount(); + global.React = realReact; + global.window.API = origAPI; + global.window.AdminLineupHelpers = origHelpers; + global.window.compMatches = origCompMatches; + vi.resetModules(); + }); + + const flush = async () => { for (let i = 0; i < 8; i++) await Promise.resolve(); }; + + async function mount(match = MATCH, allMatches = [EARLIER, match]) { + runtime.mount(MatchLineupSideEditor, { + comp: COMP, team: TEAM, match, allMatches, password: 'pw', showToast: vi.fn(), + }); + await flush(); + return runtime.currentTree(); + } + const pickerValues = (tree) => findComponents(tree, 'LineupNameInput').map(p => p.props.value); + + it('shows the names the team carries from its earlier match, and names that match', async () => { + const tree = await mount(); + expect(global.window.API.fetchLineupInForce).toHaveBeenCalledWith('comp-1', 'uuid-grouped', 'Pool D-1'); + expect(pickerValues(tree)).toEqual(['Aoki', 'Sato', 'Ito']); + expect(allText(tree)).toContain('Same as Pool D · Match 1'); + expect(allText(tree)).not.toContain('Lineup for this match'); + }); + + it('names a carried-from match the list no longer holds by its id', async () => { + const tree = await mount(MATCH, [MATCH]); + expect(allText(tree)).toContain('Same as Pool D-0'); + }); + + it('marks the team\'s starting lineup as such', async () => { + global.window.API.fetchLineupInForce = vi.fn().mockResolvedValue(STARTING); + const tree = await mount(); + expect(pickerValues(tree)).toEqual(['Aoki', 'Sato', 'Ito']); + expect(allText(tree)).toContain('Starting lineup'); + }); + + it('marks a match\'s own lineup', async () => { + global.window.API.fetchLineupInForce = vi.fn().mockResolvedValue({ + ...OWN, positions: { 1: 'Mori' }, memberIds: { 1: 'mem-4' }, + }); + const tree = await mount(); + expect(pickerValues(tree)).toEqual(['Mori', '', '']); + expect(allText(tree)).toContain('Lineup for this match'); + expect(allText(tree)).not.toContain('Same as'); + }); + + it('says so when no lineup is saved at all', async () => { + global.window.API.fetchLineupInForce = vi.fn().mockResolvedValue(null); + const tree = await mount(); + expect(pickerValues(tree)).toEqual(['', '', '']); + expect(allText(tree)).toContain('No lineup saved yet'); + }); + + it('disables Save on an untouched carried side, and a direct click writes nothing', async () => { + const tree = await mount(); + const save = saveButton(tree); + expect(save.props.disabled).toBe(true); + expect(save.props.title).toBe('No changes to save'); + save.props.onClick(); + await flush(); + expect(global.window.API.putMatchLineup).not.toHaveBeenCalled(); + }); + + // A save names the positions it changed and carries those alone (operator decision + // 2026-10-07): the carried positions are not sent, the server holds them. + it('after one edit Save enables and the write carries the edited position alone, naming it', async () => { + let tree = await mount(); + findComponents(tree, 'LineupNameInput')[1].props.onSelect('Mori', SQUAD[3]); + tree = runtime.currentTree(); + const save = saveButton(tree); + expect(save.props.disabled).toBe(false); + save.props.onClick(); + await flush(); + + expect(global.window.API.putMatchLineup).toHaveBeenCalledTimes(1); + const call = global.window.API.putMatchLineup.mock.calls[0]; + expect(call[3]).toEqual({ 2: 'Mori' }); + expect(call[5]).toEqual({ 2: 'mem-4' }); + expect(call[6]).toEqual(['2']); + }); + + it('an edit put back to what was loaded is not a change', async () => { + let tree = await mount(); + findComponents(tree, 'LineupNameInput')[1].props.onSelect('Mori', SQUAD[3]); + findComponents(runtime.currentTree(), 'LineupNameInput')[1].props.onSelect('Sato', SQUAD[1]); + tree = runtime.currentTree(); + expect(saveButton(tree).props.disabled).toBe(true); + }); + + it('after a confirmed save the side is the baseline again: Save disables and the label turns to the match\'s own', async () => { + global.window.API.putMatchLineup = vi.fn().mockResolvedValue({ + positions: { 1: 'Aoki', 2: 'Mori', 3: 'Ito' }, memberIds: { 1: 'mem-1', 2: 'mem-4', 3: 'mem-3' }, + }); + let tree = await mount(); + findComponents(tree, 'LineupNameInput')[1].props.onSelect('Mori', SQUAD[3]); + saveButton(runtime.currentTree()).props.onClick(); + await flush(); + tree = runtime.currentTree(); + expect(saveButton(tree).props.disabled).toBe(true); + expect(allText(tree)).toContain('Lineup for this match'); + expect(allText(tree)).not.toContain('Same as'); + }); + + it('a queued (offline) write keeps the side dirty', async () => { + global.window.API.putMatchLineup = vi.fn().mockResolvedValue({ queued: true }); + let tree = await mount(); + findComponents(tree, 'LineupNameInput')[1].props.onSelect('Mori', SQUAD[3]); + saveButton(runtime.currentTree()).props.onClick(); + await flush(); + tree = runtime.currentTree(); + expect(saveButton(tree).props.disabled).toBe(false); + }); + + it('a deliberate edit that empties a match\'s own side is dirty and still saves', async () => { + global.window.API.fetchLineupInForce = vi.fn().mockResolvedValue({ + ...OWN, positions: { 1: 'Aoki' }, memberIds: { 1: 'mem-1' }, + }); + let tree = await mount(); + findComponents(tree, 'LineupNameInput')[0].props.onSelect(''); + tree = runtime.currentTree(); + expect(saveButton(tree).props.disabled).toBe(false); + saveButton(tree).props.onClick(); + await flush(); + // The emptied position goes as its empty name, which the server needs to be there. + expect(global.window.API.putMatchLineup.mock.calls[0][3]).toEqual({ 1: '' }); + expect(global.window.API.putMatchLineup.mock.calls[0][6]).toEqual(['1']); + }); + + it('a failed read shows "Failed to load lineup" rather than an empty lineup', async () => { + global.window.API.fetchLineupInForce = vi.fn().mockRejectedValue(new Error('')); + const tree = await mount(); + expect(allText(tree)).toContain('Failed to load lineup'); + expect(pickerValues(tree)).toEqual(['', '', '']); + }); + + it('a read of a missing competition shows the server\'s message', async () => { + global.window.API.fetchLineupInForce = vi.fn().mockRejectedValue(new Error('competition not found')); + const tree = await mount(); + expect(allText(tree)).toContain('competition not found'); + }); + + it('renaming a member on a carried side does not make the side look edited', async () => { + global.window.API.renameTeamMember = vi.fn().mockResolvedValue(answered({ id: 'mem-1', index: 1 }, { name: 'Aoki Jr' })); + let tree = await mount(); + const rename = findHosts(tree, 'button').find(b => b.props?.['aria-label'] === 'Rename 1 player'); + expect(rename).toBeTruthy(); + rename.props.onClick(); + tree = runtime.currentTree(); + findHosts(tree, 'input').find(i => i.props?.['aria-label'] === 'Rename 1 player') + .props.onChange({ target: { value: 'Aoki Jr' } }); + tree = runtime.currentTree(); + findHosts(tree, 'button').find(b => /^Save$/.test(collectText(b).trim())).props.onClick(); + await flush(); + + tree = runtime.currentTree(); + expect(global.window.API.renameTeamMember).toHaveBeenCalledWith('comp-1', 'uuid-grouped', 'mem-1', 'Aoki Jr', 'pw'); + expect(pickerValues(tree)[0]).toBe('Aoki Jr'); + expect(saveButton(tree).props.disabled).toBe(true); + expect(allText(tree)).toContain('Same as Pool D · Match 1'); + }); +}); diff --git a/web-mobile/js/__tests__/match_lineup_side_editor_member_ids.test.jsx b/web-mobile/js/__tests__/match_lineup_side_editor_member_ids.test.jsx index 828ac03af..ce7ceeaa1 100644 --- a/web-mobile/js/__tests__/match_lineup_side_editor_member_ids.test.jsx +++ b/web-mobile/js/__tests__/match_lineup_side_editor_member_ids.test.jsx @@ -8,6 +8,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { makeReactive } from './helpers/reactive_react.js'; import { collectText } from './helpers/vdom.js'; +import { answered } from './helpers/team_members.js'; // bc-cse: the REAL composer, not a stub, so these tests exercise the exact // wording the operator sees (mirrors admin_lineup.jsx's own window bridge). import { memberIdentityWarning } from '../admin_lineup.jsx'; @@ -43,9 +44,13 @@ const memberWarning = (tree) => const errorBanner = (tree) => findHosts(tree, 'div').find(d => collectText(d) && /Failed to (save|load) lineup/.test(collectText(d))); +// A Save reads the lineup again before it writes (operator decision 2026-10-05), +// so the write lands a few microtask hops after the tap. +const flush = async () => { for (let i = 0; i < 12; i++) await Promise.resolve(); }; + describe('MatchLineupSideEditor resolves names to squad member ids (bc-pnum gap closure)', () => { let runtime, MatchLineupSideEditor; - let origAPI, origHelpers, origResolveRound, origCompMatches; + let origAPI, origHelpers, origCompMatches; const COMP = { id: 'comp-1', name: 'Team Event', kind: 'team', teamSize: 3 }; const TEAM = { id: 'uuid-grouped', name: 'Grouped Team', number: 'T5' }; @@ -62,10 +67,8 @@ describe('MatchLineupSideEditor resolves names to squad member ids (bc-pnum gap beforeEach(async () => { origAPI = global.window.API; origHelpers = global.window.AdminLineupHelpers; - origResolveRound = global.window.resolveRoundIndex; origCompMatches = global.window.compMatches; - global.window.resolveRoundIndex = () => 0; global.window.compMatches = () => []; global.window.AdminLineupHelpers = { positionsForSize: (n) => Array.from({ length: n }, (_, i) => ({ key: String(i + 1), label: String(i + 1) })), @@ -76,8 +79,7 @@ describe('MatchLineupSideEditor resolves names to squad member ids (bc-pnum gap memberIdentityWarning, }; global.window.API = { - fetchMatchLineup: vi.fn().mockResolvedValue(null), - fetchTeamLineup: vi.fn().mockResolvedValue(null), + fetchLineupInForce: vi.fn().mockResolvedValue(null), fetchSquads: vi.fn().mockResolvedValue({}), putMatchLineup: vi.fn().mockResolvedValue({ positions: {} }), }; @@ -93,7 +95,6 @@ describe('MatchLineupSideEditor resolves names to squad member ids (bc-pnum gap global.React = realReact; global.window.API = origAPI; global.window.AdminLineupHelpers = origHelpers; - global.window.resolveRoundIndex = origResolveRound; global.window.compMatches = origCompMatches; vi.resetModules(); }); @@ -117,8 +118,7 @@ describe('MatchLineupSideEditor resolves names to squad member ids (bc-pnum gap pickers[0].props.onSelect('Sato'); tree = runtime.currentTree(); saveButton(tree).props.onClick(); - await Promise.resolve(); - await Promise.resolve(); + await flush(); expect(global.window.AdminLineupHelpers.resolveMemberIdsForPositions) // The sixth argument is the panel's own memberIds map (bc-dnst): the @@ -138,8 +138,7 @@ describe('MatchLineupSideEditor resolves names to squad member ids (bc-pnum gap pickers[0].props.onSelect('Sato'); tree = runtime.currentTree(); saveButton(tree).props.onClick(); - await Promise.resolve(); - await Promise.resolve(); + await flush(); expect(global.window.API.putMatchLineup).toHaveBeenCalled(); // putMatchLineup(compId, teamId, matchId, positionsOut, password, memberIdsOut) @@ -154,8 +153,7 @@ describe('MatchLineupSideEditor resolves names to squad member ids (bc-pnum gap pickers[0].props.onSelect('Sato'); tree = runtime.currentTree(); saveButton(tree).props.onClick(); - await Promise.resolve(); - await Promise.resolve(); + await flush(); const call = global.window.API.putMatchLineup.mock.calls.at(-1); expect(call[5]).toBeUndefined(); @@ -171,8 +169,7 @@ describe('MatchLineupSideEditor resolves names to squad member ids (bc-pnum gap pickers[0].props.onSelect('Sato'); tree = runtime.currentTree(); saveButton(tree).props.onClick(); - await Promise.resolve(); - await Promise.resolve(); + await flush(); expect(global.window.API.putMatchLineup).toHaveBeenCalled(); const call = global.window.API.putMatchLineup.mock.calls.at(-1); @@ -194,9 +191,7 @@ describe('MatchLineupSideEditor resolves names to squad member ids (bc-pnum gap pickers[0].props.onSelect('Sato'); tree = runtime.currentTree(); saveButton(tree).props.onClick(); - await Promise.resolve(); - await Promise.resolve(); - await Promise.resolve(); + await flush(); tree = runtime.currentTree(); // The save itself still succeeded (never blocked): putMatchLineup ran. @@ -230,9 +225,7 @@ describe('MatchLineupSideEditor resolves names to squad member ids (bc-pnum gap pickers[0].props.onSelect('Sato'); tree = runtime.currentTree(); saveButton(tree).props.onClick(); - await Promise.resolve(); - await Promise.resolve(); - await Promise.resolve(); + await flush(); tree = runtime.currentTree(); expect(global.window.API.putMatchLineup).toHaveBeenCalled(); // never blocked @@ -287,8 +280,7 @@ describe('MatchLineupSideEditor resolves names to squad member ids (bc-pnum gap pickers[0].props.onSelect('Fighter 1', SQUAD_7[0]); tree = runtime.currentTree(); saveButton(tree).props.onClick(); - await Promise.resolve(); - await Promise.resolve(); + await flush(); // The picked id is already known, so this position never goes through // resolveMemberIdsForPositions at all. @@ -299,49 +291,6 @@ describe('MatchLineupSideEditor resolves names to squad member ids (bc-pnum gap expect(call[5]).toEqual({ 1: 'mem-1' }); }); - // bc-cse: "Copy from previous match" can carry a source position that has - // an id but no name yet (a fighter fielded by number, never typed). The - // copy must still write that position (present, with an empty string) and - // its id, and must not send it through the resolver at all: an empty name - // never enters positionsForResolver, the id already known. - it('copying a source position with an id and an empty name writes it directly, skipping the resolver', async () => { - const MATCH_PREV = { - id: 'match-0', compId: 'comp-1', - sideA: { id: 'uuid-grouped', name: 'Grouped Team' }, sideB: { id: 'other', name: 'Other' }, - status: 'completed', - }; - global.window.API.fetchMatchLineup = vi.fn((_compId, _teamId, matchId) => ( - matchId === 'match-0' - ? Promise.resolve({ positions: { '1': '' }, memberIds: { '1': 'mem-1' } }) - : Promise.resolve(null) - )); - - runtime.mount(MatchLineupSideEditor, { - comp: COMP, team: TEAM, match: MATCH, allMatches: [MATCH, MATCH_PREV], password: 'pw', showToast: vi.fn(), - }); - await Promise.resolve(); - await Promise.resolve(); - await Promise.resolve(); - let tree = runtime.currentTree(); - - const copyBtn = findHosts(tree, 'button').find(b => /Copy from previous match/.test(collectText(b))); - expect(copyBtn).toBeTruthy(); - copyBtn.props.onClick(); - await Promise.resolve(); - await Promise.resolve(); - await Promise.resolve(); - await Promise.resolve(); - - expect(global.window.AdminLineupHelpers.resolveMemberIdsForPositions).not.toHaveBeenCalled(); - expect(global.window.API.putMatchLineup).toHaveBeenCalled(); - const call = global.window.API.putMatchLineup.mock.calls.at(-1); - // positionsOut(3): the position is PRESENT with an empty string, not - // omitted, because its id makes it a real placement (bc-dnst). - expect(call[3]).toEqual({ '1': '' }); - // memberIdsOut(5): the copied id rides along unresolved. - expect(call[5]).toEqual({ '1': 'mem-1' }); - }); - // bc-cse: typing a DIFFERENT name over a previously PICKED entry (its id // already known in the panel's own memberIds) must still resolve that // position through the shared resolver, passing the panel's memberIds @@ -363,8 +312,7 @@ describe('MatchLineupSideEditor resolves names to squad member ids (bc-pnum gap tree = runtime.currentTree(); saveButton(tree).props.onClick(); - await Promise.resolve(); - await Promise.resolve(); + await flush(); expect(global.window.AdminLineupHelpers.resolveMemberIdsForPositions).toHaveBeenCalledWith( 'comp-1', 'uuid-grouped', { '1': 'Yamada' }, SQUAD_7, 'pw', @@ -373,29 +321,32 @@ describe('MatchLineupSideEditor resolves names to squad member ids (bc-pnum gap }); // bc-cse: clearing a picked entry (the roster's clear affordance: an - // empty name, no entry) must remove its id from memberIds AND omit that - // position from the write entirely -- it goes back to vacant, not to an - // empty-string placement. - it('clearing a picked entry removes its id and omits the position from the write', async () => { + // empty name, no entry) must remove its id from memberIds: the position goes + // back to vacant, which a save names by its empty name and no id (the server + // clears a changed position that has neither). + it('clearing a picked entry removes its id and names the position by its empty name', async () => { global.window.API.fetchSquads = vi.fn().mockResolvedValue({ 'uuid-grouped': SQUAD_7 }); + // Based on a loaded override that holds the pick: a pick-then-clear from + // an empty side is a net-zero edit, which Save no longer writes. + global.window.API.fetchLineupInForce = vi.fn().mockResolvedValue({ + matchId: 'match-1', positions: { 1: 'Fighter 1' }, memberIds: { 1: 'mem-1' }, sourceMatchId: 'match-1', + }); let tree = await mount(); - let pickers = findComponents(tree, 'LineupNameInput'); - pickers[0].props.onSelect('Fighter 1', SQUAD_7[0]); - tree = runtime.currentTree(); - pickers = findComponents(tree, 'LineupNameInput'); + const pickers = findComponents(tree, 'LineupNameInput'); + expect(pickers[0].props.value).toBe('Fighter 1'); pickers[0].props.onSelect(''); tree = runtime.currentTree(); saveButton(tree).props.onClick(); - await Promise.resolve(); - await Promise.resolve(); + await flush(); expect(global.window.AdminLineupHelpers.resolveMemberIdsForPositions).not.toHaveBeenCalled(); expect(global.window.API.putMatchLineup).toHaveBeenCalled(); const call = global.window.API.putMatchLineup.mock.calls.at(-1); - expect(call[3]).toEqual({}); + expect(call[3]).toEqual({ 1: '' }); expect(call[5]).toBeUndefined(); + expect(call[6]).toEqual(['1']); }); // bc-dnst (operator decision 2026-09-15): a member's name can be corrected @@ -403,9 +354,52 @@ describe('MatchLineupSideEditor resolves names to squad member ids (bc-pnum gap // same rename the Lineups page offers; typing into the picker over a named // member stays a substitution. A blank pick gets no Rename: it is named by // typing into the box. + // bc-tp44: the Rename link is sized by a class (an inline style outranked the + // coarse .btn floors and left it 47x14 on an iPad), and its row is a div: a + // label with no `for` activates its first labelable descendant, which on a + // named row is this button, so tapping the position name opened Rename. + it('Rename carries a class not an inline style, and its row host is not a label', async () => { + global.window.API.fetchSquads = vi.fn().mockResolvedValue({ 'uuid-grouped': SQUAD_7 }); + + let tree = await mount(); + const pickers = findComponents(tree, 'LineupNameInput'); + pickers[0].props.onSelect('Fighter 1', SQUAD_7[0]); + tree = runtime.currentTree(); + + const rename = findHosts(tree, 'button').find(b => b.props?.['aria-label'] === 'Rename 1 player'); + expect(rename).toBeTruthy(); + expect(rename.props.style).toBeUndefined(); + expect(rename.props.className).toContain('lineup-rename-btn'); + + const rowHosts = (type) => findHosts(tree, type) + .filter(n => (n.props?.['data-testid'] || '').startsWith('match-lineup-pos-')); + expect(rowHosts('label')).toEqual([]); + expect(rowHosts('div').length).toBeGreaterThan(0); + }); + + it('the position name is a label for its own name box, and no label contains the Rename button', async () => { + global.window.API.fetchSquads = vi.fn().mockResolvedValue({ 'uuid-grouped': SQUAD_7 }); + + let tree = await mount(); + findComponents(tree, 'LineupNameInput')[0].props.onSelect('Fighter 1', SQUAD_7[0]); + tree = runtime.currentTree(); + + const pickers = findComponents(tree, 'LineupNameInput'); + const ids = pickers.map(p => p.props.inputId); + expect(ids[0]).toBeTruthy(); + expect(new Set(ids).size).toBe(ids.length); + + const labels = findHosts(tree, 'label'); + const first = labels.find(l => l.props?.htmlFor === ids[0]); + expect(first, 'a label points at the first position\'s name box').toBeTruthy(); + expect(collectText(first)).toBe('1'); + for (const l of labels) expect(findHosts(l, 'button')).toEqual([]); + expect(findHosts(tree, 'button').some(b => b.props?.['aria-label'] === 'Rename 1 player')).toBe(true); + }); + it('Rename under a named pick renames that member and the next save writes the new name with the same id', async () => { global.window.API.fetchSquads = vi.fn().mockResolvedValue({ 'uuid-grouped': SQUAD_7 }); - global.window.API.renameTeamMember = vi.fn().mockResolvedValue({ id: 'mem-1', index: 1, name: 'Fighter One' }); + global.window.API.renameTeamMember = vi.fn().mockResolvedValue(answered({ id: 'mem-1', index: 1 }, { name: 'Fighter One' })); let tree = await mount(); let pickers = findComponents(tree, 'LineupNameInput'); @@ -433,8 +427,7 @@ describe('MatchLineupSideEditor resolves names to squad member ids (bc-pnum gap expect(findComponents(tree, 'LineupNameInput')[0].props.value).toBe('Fighter One'); saveButton(tree).props.onClick(); - await Promise.resolve(); - await Promise.resolve(); + await flush(); expect(global.window.AdminLineupHelpers.resolveMemberIdsForPositions).not.toHaveBeenCalled(); const call = global.window.API.putMatchLineup.mock.calls.at(-1); expect(call[3]).toEqual({ 1: 'Fighter One', 2: '' }); diff --git a/web-mobile/js/__tests__/match_lineup_side_editor_trim.test.jsx b/web-mobile/js/__tests__/match_lineup_side_editor_trim.test.jsx index a7d085900..a49f98dc9 100644 --- a/web-mobile/js/__tests__/match_lineup_side_editor_trim.test.jsx +++ b/web-mobile/js/__tests__/match_lineup_side_editor_trim.test.jsx @@ -36,9 +36,13 @@ function findComponents(tree, name) { const saveButton = (tree) => findHosts(tree, 'button').find(b => /Save lineup/.test(collectText(b))); +// A Save reads the lineup again before it writes (operator decision 2026-10-05), +// so the write lands a few microtask hops after the tap. +const flush = async () => { for (let i = 0; i < 12; i++) await Promise.resolve(); }; + describe('MatchLineupSideEditor trims names before saving', () => { let runtime, MatchLineupSideEditor; - let origAPI, origHelpers, origResolveRound, origCompMatches; + let origAPI, origHelpers, origCompMatches; const COMP = { id: 'comp-1', name: 'Team Event', kind: 'team', teamSize: 3 }; const TEAM = { id: 'uuid-grouped', name: 'Grouped Team' }; // no metadata @@ -47,10 +51,8 @@ describe('MatchLineupSideEditor trims names before saving', () => { beforeEach(async () => { origAPI = global.window.API; origHelpers = global.window.AdminLineupHelpers; - origResolveRound = global.window.resolveRoundIndex; origCompMatches = global.window.compMatches; - global.window.resolveRoundIndex = () => 0; global.window.compMatches = () => []; global.window.AdminLineupHelpers = { positionsForSize: (n) => Array.from({ length: n }, (_, i) => ({ key: String(i + 1), label: String(i + 1) })), @@ -59,8 +61,7 @@ describe('MatchLineupSideEditor trims names before saving', () => { teamIdOf: (t) => t?.id || t?.name || '', }; global.window.API = { - fetchMatchLineup: vi.fn().mockResolvedValue(null), - fetchTeamLineup: vi.fn().mockResolvedValue(null), + fetchLineupInForce: vi.fn().mockResolvedValue(null), putMatchLineup: vi.fn().mockResolvedValue({ positions: {} }), }; @@ -75,7 +76,6 @@ describe('MatchLineupSideEditor trims names before saving', () => { global.React = realReact; global.window.API = origAPI; global.window.AdminLineupHelpers = origHelpers; - global.window.resolveRoundIndex = origResolveRound; global.window.compMatches = origCompMatches; vi.resetModules(); }); @@ -84,8 +84,7 @@ describe('MatchLineupSideEditor trims names before saving', () => { runtime.mount(MatchLineupSideEditor, { comp: COMP, team: TEAM, match: MATCH, allMatches: [MATCH], password: 'pw', showToast: vi.fn(), }); - await Promise.resolve(); - await Promise.resolve(); + for (let i = 0; i < 6; i++) await Promise.resolve(); // resolveMatchLineup adds hops return runtime.currentTree(); } @@ -96,21 +95,27 @@ describe('MatchLineupSideEditor trims names before saving', () => { pickers[0].props.onSelect(' Padded Name '); tree = runtime.currentTree(); saveButton(tree).props.onClick(); - await Promise.resolve(); + await flush(); expect(global.window.API.putMatchLineup).toHaveBeenCalled(); // putMatchLineup(compId, teamId, matchId, positionsOut, ...) const positionsOut = global.window.API.putMatchLineup.mock.calls.at(-1)[3]; expect(positionsOut['1']).toBe('Padded Name'); }); - it('drops a whitespace-only name rather than persisting blanks', async () => { + it('clears a position typed over with whitespace only rather than persisting blanks', async () => { + // Based on a loaded override that holds a name: blanking an already-empty + // side is not a change, and Save writes nothing for it. + global.window.API.fetchLineupInForce = vi.fn().mockResolvedValue({ + matchId: 'match-1', positions: { 1: 'Old Name' }, sourceMatchId: 'match-1', + }); let tree = await mount(); const pickers = findComponents(tree, 'LineupNameInput'); pickers[0].props.onSelect(' '); tree = runtime.currentTree(); saveButton(tree).props.onClick(); - await Promise.resolve(); + await flush(); + // The position goes as its empty name, never as the blanks the operator typed. const positionsOut = global.window.API.putMatchLineup.mock.calls.at(-1)[3]; - expect(positionsOut['1']).toBeUndefined(); + expect(positionsOut['1']).toBe(''); }); }); diff --git a/web-mobile/js/__tests__/operator_tap_floor.test.jsx b/web-mobile/js/__tests__/operator_tap_floor.test.jsx new file mode 100644 index 000000000..f35df8e31 --- /dev/null +++ b/web-mobile/js/__tests__/operator_tap_floor.test.jsx @@ -0,0 +1,116 @@ +// bc-tp44: the operator's score-editor and lineup-panel controls reach the +// 44px floor under a coarse pointer, through --tap-floor in CLASSES (0px on a +// fine pointer, so a laptop keeps its density), never an inline style. +// +// jsdom lays nothing out, so this pins the rules in the stylesheet (the +// pattern of viewer_watchlist_panel.test.jsx); the acceptance is the browser +// measurement at 1180x820 with a coarse pointer. + +import { describe, it, expect } from 'vitest'; +import { cssBlock, readStylesheet } from './helpers/source.js'; + +const css = readStylesheet(); + +const block = (selector) => { + const b = cssBlock(css, selector); + expect(b, `rule ${selector} exists`).not.toBeNull(); + return b; +}; + +// Every `@media (pointer: coarse) { ... }` body, found by brace depth. +function coarseBlocks(source) { + const out = []; + const opener = '@media (pointer: coarse) {'; + let from = 0; + for (;;) { + const at = source.indexOf(opener, from); + if (at < 0) return out; + let depth = 1; + let i = at + opener.length; + while (i < source.length && depth > 0) { + if (source[i] === '{') depth += 1; + else if (source[i] === '}') depth -= 1; + i += 1; + } + out.push(source.slice(at + opener.length, i - 1)); + from = i; + } +} +const coarseRule = (selector) => { + const esc = selector.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + for (const body of coarseBlocks(css)) { + const m = new RegExp(`(?:^|\\s)${esc}\\s*\\{([^}]*)\\}`).exec(body); + if (m) return m[1]; + } + return null; +}; + +describe('operator tap floors (bc-tp44)', () => { + it('floors the encho stepper buttons on both axes', () => { + const b = block('.encho-row__btn'); + expect(b).toMatch(/min-width: max\(28px, var\(--tap-floor\)\)/); + expect(b).toMatch(/min-height: max\(28px, var\(--tap-floor\)\)/); + }); + + it('floors the encho label, which is the checkbox\'s tap target', () => { + expect(block('.encho-row__label')).toMatch(/min-height: var\(--tap-floor\)/); + const box = coarseRule('.encho-row__label input[type="checkbox"]'); + expect(box, 'coarse checkbox rule').not.toBeNull(); + expect(box).toMatch(/width: 20px/); + expect(box).toMatch(/height: 20px/); + }); + + it('floors the lineup name bar and lets the input fill it', () => { + const bar = block('.lineup-name__bar'); + expect(bar).toMatch(/min-height: max\(36px, var\(--tap-floor\)\)/); + expect(bar).toMatch(/padding: 0 6px/); + expect(bar).toMatch(/align-items: stretch/); + const input = block('.lineup-name__bar .pmf__input'); + expect(input).toMatch(/align-self: stretch/); + // The input is the tap target: it floors its own height (border-box, so + // exactly the floor) rather than relying on the bar, whose floor counts + // its borders and left an empty box's input 2px short. + expect(input).toMatch(/min-height: var\(--tap-floor\)/); + expect(block('.lineup-name__bar .lineup-name__clear')).toMatch(/align-self: center/); + }); + + it('floors the lineup panel Rename link under a coarse pointer, above the .btn floors', () => { + expect(block('.btn.lineup-rename-btn')).toMatch(/min-height: 0/); + const coarse = coarseRule('.btn.lineup-rename-btn'); + expect(coarse, 'coarse rename rule').not.toBeNull(); + expect(coarse).toMatch(/min-height: var\(--tap-floor\)/); + expect(coarse).toMatch(/min-width: var\(--tap-floor\)/); + }); + + it('floors the collapse caret button on both axes without widening the gutter', () => { + const coarse = coarseRule('.tsm-caret-btn'); + expect(coarse, 'coarse caret rule').not.toBeNull(); + expect(coarse).toMatch(/min-height: var\(--tap-floor\)/); + expect(coarse).toMatch(/min-width: var\(--tap-floor\)/); + expect(coarse).toMatch(/margin: -10px -5px/); + }); +}); + +// A name box whose lineup is unread, or whose save is out, is disabled: it must +// look it, or the operator taps it and nothing happens. +describe('the lineup name box looks disabled when its input is', () => { + const disabledBar = '.lineup-name__bar:has(.pmf__input:disabled)'; + + it('dims the bar as a disabled button is dimmed, on the muted surface, and says not-allowed', () => { + const bar = block(disabledBar); + expect(bar).toMatch(/opacity: 0\.6/); + expect(bar).toMatch(/background: var\(--surface-2\)/); + expect(bar).toMatch(/cursor: not-allowed/); + expect(block('.btn:disabled'), 'the buttons\' own disabled look').toMatch(/opacity: 0\.6/); + }); + + it('puts the cursor on the input too: it fills the bar, so it is what the pointer is over', () => { + expect(block('.lineup-name__bar .pmf__input:disabled')).toMatch(/cursor: not-allowed/); + }); + + it('leaves the size alone: the 44px floors stand', () => { + expect(block(disabledBar)).not.toMatch(/height|padding|margin/); + expect(block('.lineup-name__bar')).toMatch(/min-height: max\(36px, var\(--tap-floor\)\)/); + expect(block('.lineup-name__bar .pmf__input')).toMatch(/min-height: var\(--tap-floor\)/); + }); +}); diff --git a/web-mobile/js/__tests__/pick_copy_source.test.jsx b/web-mobile/js/__tests__/pick_copy_source.test.jsx deleted file mode 100644 index 40b23740f..000000000 --- a/web-mobile/js/__tests__/pick_copy_source.test.jsx +++ /dev/null @@ -1,176 +0,0 @@ -// mp-bkg: tests for pickCopySource. The "Copy from previous match" -// candidate-selection algorithm exported from admin_schedule.jsx. -// -// Contract: -// pickCopySource(allMatches, currentMatchId, teamId, savedLineups) -// → the best source Match, or null if no candidate. -// -// Sorting rule: -// 1. scheduledAt DESC (nulls last; unscheduled treated as least-recent) -// 2. court ASC -// 3. index in allMatches ASC (queue/sequence order) -// 4. matchId DESC - -import { describe, it, expect } from 'vitest'; -import { pickCopySource } from '../admin_schedule.jsx'; - -// Build a minimal match object. -function mkMatch(id, opts = {}) { - const { sideAId = 'team-a', sideBId = 'team-b', scheduledAt = null, court = '', idx = 0 } = opts; - return { - id, - sideA: { id: sideAId }, - sideB: { id: sideBId }, - scheduledAt, - court, - _idx: idx, // not used by pickCopySource; just for readability - }; -} - -describe('pickCopySource', () => { - const TEAM = 'team-a'; - const OTHER = 'team-b'; - const CURRENT = 'current-match'; - - it('returns null when no candidates exist', () => { - expect(pickCopySource([], CURRENT, TEAM, {})).toBeNull(); - }); - - it('returns null when savedLineups is empty (no lineups saved)', () => { - const m1 = mkMatch('m1', { sideAId: TEAM }); - expect(pickCopySource([m1], CURRENT, TEAM, {})).toBeNull(); - }); - - it('returns null when only current match has a saved lineup', () => { - const cur = mkMatch(CURRENT, { sideAId: TEAM }); - expect(pickCopySource([cur], CURRENT, TEAM, { [CURRENT]: { positions: { senpo: 'x' } } })).toBeNull(); - }); - - it('ignores matches where the team is not involved', () => { - const m1 = mkMatch('m1', { sideAId: 'other-team-1', sideBId: 'other-team-2' }); - expect(pickCopySource([m1], CURRENT, TEAM, { m1: { positions: {} } })).toBeNull(); - }); - - it('picks the only candidate when exactly one exists', () => { - const m1 = mkMatch('m1', { sideAId: TEAM }); - const savedLineups = { m1: { positions: { senpo: 'Alice' } } }; - const result = pickCopySource([m1], CURRENT, TEAM, savedLineups); - expect(result?.id).toBe('m1'); - }); - - it('works when team is sideBId (SHIRO side)', () => { - const m1 = mkMatch('m1', { sideAId: OTHER, sideBId: TEAM }); - const saved = { m1: { positions: {} } }; - expect(pickCopySource([m1], CURRENT, TEAM, saved)?.id).toBe('m1'); - }); - - it('REGRESSION: matches a name-keyed side when teamKeys = [uuid, name]', () => { - // Real data path: a participant's id is a UUID, but the match side is - // keyed by team NAME (api_serializers name-as-id fallback). Matching the - // UUID alone found zero candidates, so copy-from-previous silently - // no-opped. teamKeys carries BOTH keys so the name-keyed side resolves. - const m1 = { - id: 'm1', - sideA: { id: 'Red Dojo', name: 'Red Dojo' }, - sideB: { id: 'Blue Dojo', name: 'Blue Dojo' }, - scheduledAt: '09:00', - court: 'A', - }; - const saved = { m1: { positions: { senpo: 'Aka Ichi' } } }; - expect(pickCopySource([m1], CURRENT, ['uuid-red-123', 'Red Dojo'], saved)?.id).toBe('m1'); - // UUID-only (the pre-fix behaviour) finds nothing; the side is name-keyed. - expect(pickCopySource([m1], CURRENT, 'uuid-red-123', saved)).toBeNull(); - }); - - describe('sort rule 1: scheduledAt DESC (most recent first)', () => { - it('picks the match with the later scheduledAt', () => { - const m1 = mkMatch('m1', { sideAId: TEAM, scheduledAt: '09:00' }); - const m2 = mkMatch('m2', { sideAId: TEAM, scheduledAt: '10:00' }); - const saved = { m1: { positions: {} }, m2: { positions: {} } }; - expect(pickCopySource([m1, m2], CURRENT, TEAM, saved)?.id).toBe('m2'); - }); - - it('picks match with scheduledAt over one without (nulls last)', () => { - const m1 = mkMatch('m1', { sideAId: TEAM, scheduledAt: '08:00' }); - const m2 = mkMatch('m2', { sideAId: TEAM, scheduledAt: null }); - const saved = { m1: { positions: {} }, m2: { positions: {} } }; - // null → "" which is lexicographically less than any time string, so in - // DESC order m1 (08:00) > m2 ("") → m1 wins (nulls sort last). - expect(pickCopySource([m1, m2], CURRENT, TEAM, saved)?.id).toBe('m1'); - }); - - it('both nulls fall through to court tiebreak', () => { - // Both have no scheduledAt → equal on rule 1 → proceed to court - const m1 = mkMatch('m1', { sideAId: TEAM, scheduledAt: null, court: 'B' }); - const m2 = mkMatch('m2', { sideAId: TEAM, scheduledAt: null, court: 'A' }); - const saved = { m1: { positions: {} }, m2: { positions: {} } }; - // court ASC: 'A' < 'B' → m2 (court A) wins - expect(pickCopySource([m1, m2], CURRENT, TEAM, saved)?.id).toBe('m2'); - }); - }); - - describe('sort rule 2: court ASC (tiebreak)', () => { - it('picks the match on the earlier court (A before B)', () => { - const m1 = mkMatch('m1', { sideAId: TEAM, scheduledAt: '10:00', court: 'B' }); - const m2 = mkMatch('m2', { sideAId: TEAM, scheduledAt: '10:00', court: 'A' }); - const saved = { m1: { positions: {} }, m2: { positions: {} } }; - expect(pickCopySource([m1, m2], CURRENT, TEAM, saved)?.id).toBe('m2'); - }); - }); - - describe('sort rule 3: index in allMatches ASC (queue order)', () => { - it('picks the match appearing earlier in the array when time and court are equal', () => { - const m1 = mkMatch('m1', { sideAId: TEAM, scheduledAt: '10:00', court: 'A' }); - const m2 = mkMatch('m2', { sideAId: TEAM, scheduledAt: '10:00', court: 'A' }); - const saved = { m1: { positions: {} }, m2: { positions: {} } }; - // m1 is at index 0, m2 at index 1 → m1 has lower index → m1 wins - expect(pickCopySource([m1, m2], CURRENT, TEAM, saved)?.id).toBe('m1'); - }); - }); - - // Note: sort rule 4 (matchId DESC) is a safety-net tiebreak for cases - // where two distinct JS objects have identical scheduledAt, court, AND the - // same indexOf position in allMatches. Because Array.indexOf returns the - // first occurrence of each reference and each match object is distinct, - // two different matches always have different indices; rule 3 always - // resolves before rule 4. Rule 4 is therefore effectively unreachable in - // practice and has no dedicated test. - - it('only considers matches with a non-empty savedLineups entry', () => { - const m1 = mkMatch('m1', { sideAId: TEAM, scheduledAt: '11:00' }); // no saved lineup - const m2 = mkMatch('m2', { sideAId: TEAM, scheduledAt: '09:00' }); // has saved lineup - const saved = { m2: { positions: { senpo: 'Bob' } } }; - expect(pickCopySource([m1, m2], CURRENT, TEAM, saved)?.id).toBe('m2'); - }); - - describe('"previous match" filter: excludes siblings scheduled after the current match', () => { - // The button is "Copy from previous match": when the current match has a - // time, only siblings at or before that time are candidates. - const CUR_ID = 'cur'; - const mkCur = (scheduledAt) => ({ id: CUR_ID, sideA: { id: TEAM }, sideB: { id: OTHER }, scheduledAt, court: 'A' }); - - it('excludes a later sibling and picks the earlier one', () => { - const cur = mkCur('10:00'); - const earlier = mkMatch('earlier', { sideAId: TEAM, scheduledAt: '09:00' }); - const later = mkMatch('later', { sideAId: TEAM, scheduledAt: '11:00' }); - const saved = { earlier: { positions: { senpo: 'E' } }, later: { positions: { senpo: 'L' } } }; - // Without the filter, DESC sort would pick the later match (11:00). - expect(pickCopySource([cur, earlier, later], CUR_ID, TEAM, saved)?.id).toBe('earlier'); - }); - - it('returns null when the only saved sibling is later than the current match', () => { - const cur = mkCur('10:00'); - const later = mkMatch('later', { sideAId: TEAM, scheduledAt: '11:00' }); - const saved = { later: { positions: { senpo: 'L' } } }; - expect(pickCopySource([cur, later], CUR_ID, TEAM, saved)).toBeNull(); - }); - - it('does not restrict when the current match has no scheduled time', () => { - const cur = mkCur(null); - const later = mkMatch('later', { sideAId: TEAM, scheduledAt: '11:00' }); - const saved = { later: { positions: { senpo: 'L' } } }; - // No current time → any saved sibling is a valid source. - expect(pickCopySource([cur, later], CUR_ID, TEAM, saved)?.id).toBe('later'); - }); - }); -}); diff --git a/web-mobile/js/__tests__/pick_manual_bout_name.test.jsx b/web-mobile/js/__tests__/pick_manual_bout_name.test.jsx new file mode 100644 index 000000000..f117c2fd1 --- /dev/null +++ b/web-mobile/js/__tests__/pick_manual_bout_name.test.jsx @@ -0,0 +1,78 @@ +// pickManualBoutName (admin_scoring_team.jsx) is the write behind a name typed or picked +// on a bout row that has no lineup slot to route through. A rename the server accepted is +// handed to onRenamed as the member the server answered, stamped, so the sheet can merge +// it over a list of the team's members that predates it. A rename the server refused, a +// pick that renames nothing and a substitution hand nothing over. + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { pickManualBoutName } from '../admin_scoring_team.jsx'; +import { answered } from './helpers/team_members.js'; + +const BLANK = { id: 'b3', index: 3, name: '' }; +const NAMED = { id: 'b4', index: 4, name: 'Kai' }; + +let renameTeamMember; +let savedApi; + +beforeEach(() => { + savedApi = window.API; + renameTeamMember = vi.fn((_comp, _team, id, name) => Promise.resolve(answered({ id, index: 3, name: '' }, { name }))); + window.API = { renameTeamMember }; +}); + +afterEach(() => { window.API = savedApi; }); + +// The row's own sub holds the member the operator picked there (`pickedId`). +async function pick({ value = 'Ito', member, pickedId = 'b3' } = {}) { + const spies = { updateSub: vi.fn(), onRenamed: vi.fn(), onRenameFailed: vi.fn() }; + await pickManualBoutName({ + sub: { bMemberIdOverride: pickedId }, + idx: 1, + sideKey: 'bName', + memberIdKey: 'bMemberIdOverride', + squad: [BLANK, NAMED], + compId: 'c1', + teamId: 'team-B', + password: 'pw', + ...spies, + }, value, member); + return spies; +} + +describe('pickManualBoutName hands a rename to onRenamed', () => { + it('as the member the server answered, stamp included, once it accepted the rename', async () => { + const { onRenamed, onRenameFailed } = await pick(); + + expect(renameTeamMember).toHaveBeenCalledWith('c1', 'team-B', 'b3', 'Ito', 'pw'); + const answeredMember = await renameTeamMember.mock.results[0].value; + expect(answeredMember.modifiedAt).toBeGreaterThan(0); + expect(onRenamed).toHaveBeenCalledTimes(1); + expect(onRenamed.mock.calls[0][0]).toEqual([answeredMember]); + expect(onRenameFailed).not.toHaveBeenCalled(); + }); + + it('not when the server refused it', async () => { + const refusal = new Error('refused'); + renameTeamMember.mockRejectedValue(refusal); + + const { onRenamed, onRenameFailed } = await pick(); + + expect(onRenamed).not.toHaveBeenCalled(); + expect(onRenameFailed).toHaveBeenCalledWith('Ito', refusal); + }); + + it('not for a member picked from the list, which renames nobody', async () => { + const { onRenamed, updateSub } = await pick({ value: '', member: BLANK }); + + expect(renameTeamMember).not.toHaveBeenCalled(); + expect(updateSub).toHaveBeenCalledTimes(1); + expect(onRenamed).not.toHaveBeenCalled(); + }); + + it('not for a name typed over a named member, which is a substitution', async () => { + const { onRenamed } = await pick({ pickedId: 'b4' }); + + expect(renameTeamMember).not.toHaveBeenCalled(); + expect(onRenamed).not.toHaveBeenCalled(); + }); +}); diff --git a/web-mobile/js/__tests__/render/admin_lineup_draft.render.test.jsx b/web-mobile/js/__tests__/render/admin_lineup_draft.render.test.jsx new file mode 100644 index 000000000..d3ca0aea7 --- /dev/null +++ b/web-mobile/js/__tests__/render/admin_lineup_draft.render.test.jsx @@ -0,0 +1,395 @@ +// The Lineups page keeps the position picks the operator has not saved as a +// draft in this tab's sessionStorage (bc-lnul, operator decision 2026-10-05) and +// offers them back, with "Unsaved lineup changes restored" and a Discard, when +// the same lineup is opened again: after a reload, after the app's Back, or after +// picking another lineup and coming back. The starting lineup and a match each +// have a draft of their own, and a match's draft is the one the at-court panel +// keeps for it. Naming, renaming and clearing a team member write through the API +// and are not drafts. Nothing is written to the server until Save. + +import React from 'react'; +import { render, act, fireEvent } from '@testing-library/react'; +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { lineupPutStub } from '../helpers/lineup_server.js'; + +const SQUAD = [ + { id: 'mem-1', index: 1, name: 'Aoki' }, + { id: 'mem-2', index: 2, name: 'Sato' }, + { id: 'mem-3', index: 3, name: 'Ito' }, + { id: 'mem-4', index: 4, name: 'Mori' }, + { id: 'mem-5', index: 5, name: 'Kato' }, +]; +const NAMES = { positions: { 1: 'Aoki', 2: 'Sato', 3: 'Ito' }, memberIds: { 1: 'mem-1', 2: 'mem-2', 3: 'mem-3' } }; +const lineupFor = (extra) => ({ teamId: 'team-a', competitionId: 'comp-1', ...NAMES, saved: true, ...extra }); +const STARTING = lineupFor({ round: 0, sourceRound: 0 }); +const CARRIED = lineupFor({ matchId: 'Pool A-0', sourceMatchId: 'Pool A-0' }); +const OWN = lineupFor({ matchId: 'Pool A-2', sourceMatchId: 'Pool A-2' }); +const SAVED_ELSEWHERE = lineupFor({ + matchId: 'Pool A-0', sourceMatchId: 'Pool A-0', + positions: { 1: 'Kato', 2: 'Sato', 3: 'Ito' }, memberIds: { 1: 'mem-5', 2: 'mem-2', 3: 'mem-3' }, +}); + +const A = { id: 'team-a', name: 'Team A' }; +const B = { id: 'team-b', name: 'Team B' }; +const C = { id: 'team-c', name: 'Team C' }; +const match = (id, sideA, sideB) => ({ id, status: 'scheduled', sideA, sideB }); +const POOL_MATCHES = [match('Pool A-0', A, B), match('Pool A-1', B, C), match('Pool A-2', A, C)]; +const COMP = { id: 'comp-1', name: 'Team Event', kind: 'team', format: 'pools', status: 'active', teamSize: 3, players: [A, B, C] }; + +const MATCH_KEY = 'bc.lineupDraft.v1:comp-1:team-a:match:Pool A-2'; +const START_KEY = 'bc.lineupDraft.v1:comp-1:team-a:start'; +const draftAt = (key) => JSON.parse(sessionStorage.getItem(key)); +const NOTICE = 'lineup-draft-notice'; + +let AdminTeamLineupsList; +let MatchLineupSideEditor; +let saved; +let api; + +beforeEach(async () => { + sessionStorage.clear(); + saved = { API: window.API, confirmDialog: window.confirmDialog }; + window.confirmDialog = vi.fn().mockResolvedValue(true); + api = { + fetchTeamLineup: vi.fn().mockResolvedValue(STARTING), + fetchLineupInForce: vi.fn().mockResolvedValue(CARRIED), + fetchSquads: vi.fn().mockResolvedValue({ 'team-a': SQUAD }), + // A save names the positions it changed, and the server answers the lineup it + // holds then (lineupPutStub): the lineups the page read are what it holds. + putTeamLineup: lineupPutStub({ positions: { ...NAMES.positions }, memberIds: { ...NAMES.memberIds } }), + putMatchLineup: lineupPutStub({ positions: { ...NAMES.positions }, memberIds: { ...NAMES.memberIds } }), + deleteMatchLineup: vi.fn().mockResolvedValue(true), + }; + window.API = api; + ({ AdminTeamLineupsList } = await import('../../admin_lineup.jsx')); + ({ MatchLineupSideEditor } = await import('../../admin_schedule_lineup.jsx')); +}); + +afterEach(() => { + window.API = saved.API; + window.confirmDialog = saved.confirmDialog; +}); + +async function mountPage(props = {}) { + let utils; + await act(async () => { + utils = render( + + ); + }); + await act(async () => { await Promise.resolve(); }); + return utils; +} + +async function chooseTarget(utils, value) { + await act(async () => { fireEvent.change(utils.getByLabelText('Lineup for'), { target: { value } }); }); + await act(async () => { await Promise.resolve(); }); +} + +async function pick(utils, position, memberId) { + await act(async () => { + fireEvent.change(utils.getByTestId(`lineup-position-${position}`), { target: { value: memberId } }); + }); +} + +async function click(button) { + await act(async () => { fireEvent.click(button); }); + await act(async () => { await Promise.resolve(); }); +} + +const saveButton = (utils) => utils.getByRole('button', { name: /^Save lineup$/ }); +const discardButton = (utils) => utils.getByRole('button', { name: 'Discard' }); +const positionValue = (utils, position) => utils.getByTestId(`lineup-position-${position}`).value; + +describe('a match\'s unsaved picks', () => { + it('are offered back with a Discard when the page is opened again on that match', async () => { + const first = await mountPage(); + await chooseTarget(first, 'Pool A-2'); + await pick(first, 1, 'mem-4'); + first.unmount(); + + const again = await mountPage(); + await chooseTarget(again, 'Pool A-2'); + + expect(positionValue(again, 1)).toBe('mem-4'); + expect(positionValue(again, 2)).toBe('mem-2'); + const notice = again.getByTestId(NOTICE); + expect(notice.textContent).toContain('Unsaved lineup changes restored'); + expect(notice.getAttribute('role')).toBe('status'); + expect(notice.className).toContain('alert--warn'); + expect(discardButton(again)).toBeTruthy(); + expect(saveButton(again).disabled).toBe(false); + // Restoring is not saving. + expect(api.putMatchLineup).not.toHaveBeenCalled(); + expect(api.putTeamLineup).not.toHaveBeenCalled(); + }); + + it('are kept in this tab\'s sessionStorage under the match\'s key, the one the at-court panel uses', async () => { + const utils = await mountPage(); + await chooseTarget(utils, 'Pool A-2'); + await pick(utils, 1, 'mem-4'); + const draft = draftAt(MATCH_KEY); + expect(draft.current).toEqual({ positions: { 1: 'Mori', 2: 'Sato', 3: 'Ito' }, memberIds: { 1: 'mem-4', 2: 'mem-2', 3: 'mem-3' } }); + expect(draft.baseline).toEqual(NAMES); + expect(sessionStorage.getItem(START_KEY)).toBeNull(); + }); + + it('are Discarded back to the lineup that was loaded, and the notice and the draft go', async () => { + const first = await mountPage(); + await chooseTarget(first, 'Pool A-2'); + await pick(first, 1, 'mem-4'); + first.unmount(); + const again = await mountPage(); + await chooseTarget(again, 'Pool A-2'); + + await click(discardButton(again)); + + expect(positionValue(again, 1)).toBe('mem-1'); + expect(again.queryByTestId(NOTICE)).toBeNull(); + expect(saveButton(again).disabled).toBe(true); + expect(sessionStorage.getItem(MATCH_KEY)).toBeNull(); + }); + + it('are cleared by Save, with the notice, and a later open shows none', async () => { + const first = await mountPage(); + await chooseTarget(first, 'Pool A-2'); + await pick(first, 1, 'mem-4'); + first.unmount(); + const again = await mountPage(); + await chooseTarget(again, 'Pool A-2'); + + await click(saveButton(again)); + + expect(api.putMatchLineup).toHaveBeenCalledWith( + 'comp-1', 'team-a', 'Pool A-2', { 1: 'Mori' }, 'pw', { 1: 'mem-4' }, ['1'], + ); + expect(again.queryByTestId(NOTICE)).toBeNull(); + expect(sessionStorage.getItem(MATCH_KEY)).toBeNull(); + again.unmount(); + + api.fetchLineupInForce.mockResolvedValue({ ...OWN, positions: { 1: 'Mori', 2: 'Sato', 3: 'Ito' }, memberIds: { 1: 'mem-4', 2: 'mem-2', 3: 'mem-3' } }); + const reopened = await mountPage(); + await chooseTarget(reopened, 'Pool A-2'); + expect(reopened.queryByTestId(NOTICE)).toBeNull(); + expect(positionValue(reopened, 1)).toBe('mem-4'); + }); + + it('are kept while another lineup is picked and come back when the match is picked again', async () => { + const utils = await mountPage(); + await chooseTarget(utils, 'Pool A-2'); + await pick(utils, 1, 'mem-4'); + + await chooseTarget(utils, ''); + expect(utils.queryByTestId(NOTICE)).toBeNull(); + expect(positionValue(utils, 1)).toBe('mem-1'); + await chooseTarget(utils, 'Pool A-2'); + + expect(positionValue(utils, 1)).toBe('mem-4'); + expect(utils.getByTestId(NOTICE).textContent).toContain('Unsaved lineup changes restored'); + }); + + it('are not offered on another match, or on another team\'s lineup', async () => { + const first = await mountPage(); + await chooseTarget(first, 'Pool A-2'); + await pick(first, 1, 'mem-4'); + first.unmount(); + + const again = await mountPage(); + await chooseTarget(again, 'Pool A-0'); + expect(positionValue(again, 1)).toBe('mem-1'); + expect(again.queryByTestId(NOTICE)).toBeNull(); + + await act(async () => { fireEvent.change(again.getByLabelText('Team'), { target: { value: 'team-b' } }); }); + await act(async () => { await Promise.resolve(); }); + expect(again.queryByTestId(NOTICE)).toBeNull(); + }); + + it('go when the match is told to use the previous match\'s lineup', async () => { + api.fetchLineupInForce.mockResolvedValueOnce(OWN).mockResolvedValue(CARRIED); + const utils = await mountPage(); + await chooseTarget(utils, 'Pool A-2'); + await pick(utils, 1, 'mem-4'); + expect(draftAt(MATCH_KEY)).not.toBeNull(); + + await click(utils.getByRole('button', { name: "Use the previous match's lineup" })); + + expect(positionValue(utils, 1)).toBe('mem-1'); + expect(sessionStorage.getItem(MATCH_KEY)).toBeNull(); + expect(utils.queryByTestId(NOTICE)).toBeNull(); + }); +}); + +describe('the starting lineup\'s unsaved picks', () => { + it('are offered back with a Discard when the page is opened again', async () => { + const first = await mountPage(); + await pick(first, 2, 'mem-4'); + expect(draftAt(START_KEY).current.positions[2]).toBe('Mori'); + first.unmount(); + + const again = await mountPage(); + + expect(positionValue(again, 2)).toBe('mem-4'); + expect(again.getByTestId(NOTICE).textContent).toContain('Unsaved lineup changes restored'); + expect(discardButton(again)).toBeTruthy(); + expect(api.putTeamLineup).not.toHaveBeenCalled(); + }); + + it('are Discarded back to the starting lineup that was loaded', async () => { + const first = await mountPage(); + await pick(first, 2, 'mem-4'); + first.unmount(); + const again = await mountPage(); + + await click(discardButton(again)); + + expect(positionValue(again, 2)).toBe('mem-2'); + expect(again.queryByTestId(NOTICE)).toBeNull(); + expect(sessionStorage.getItem(START_KEY)).toBeNull(); + }); + + it('are cleared by Save, which waits for a change like a match\'s, and a later open shows no notice', async () => { + const first = await mountPage(); + expect(saveButton(first).disabled).toBe(true); + await pick(first, 2, 'mem-4'); + expect(saveButton(first).disabled).toBe(false); + first.unmount(); + const again = await mountPage(); + expect(again.getByTestId(NOTICE)).toBeTruthy(); + + await click(saveButton(again)); + + expect(api.putTeamLineup).toHaveBeenCalledWith( + 'comp-1', 'team-a', 0, { 2: 'Mori' }, 'pw', { 2: 'mem-4' }, ['2'], + ); + expect(again.queryByTestId(NOTICE)).toBeNull(); + expect(sessionStorage.getItem(START_KEY)).toBeNull(); + again.unmount(); + + api.fetchTeamLineup.mockResolvedValue({ ...STARTING, positions: { 1: 'Aoki', 2: 'Mori', 3: 'Ito' }, memberIds: { 1: 'mem-1', 2: 'mem-4', 3: 'mem-3' } }); + const reopened = await mountPage(); + expect(reopened.queryByTestId(NOTICE)).toBeNull(); + expect(positionValue(reopened, 2)).toBe('mem-4'); + }); + + it('are not cleared by a save that was only queued (offline): the lineup is still unsaved', async () => { + api.putTeamLineup.mockResolvedValue({ queued: true }); + const utils = await mountPage(); + await pick(utils, 2, 'mem-4'); + await click(saveButton(utils)); + expect(draftAt(START_KEY).current.positions[2]).toBe('Mori'); + }); + + it('put no notice and no draft behind a lineup nobody changed', async () => { + const first = await mountPage(); + expect(first.queryByTestId(NOTICE)).toBeNull(); + first.unmount(); + expect(sessionStorage.length).toBe(0); + }); +}); + +describe('a draft started in the at-court panel', () => { + it('is offered on the Lineups page for that match, with the team member it picked', async () => { + let panel; + await act(async () => { + panel = render( + + ); + }); + await act(async () => { await Promise.resolve(); }); + // Pick Mori from the position's list, so the pick carries the member's id. + await act(async () => { fireEvent.focus(panel.getByLabelText('1 player')); }); + await act(async () => { fireEvent.click(panel.getByText('Mori').closest('button')); }); + expect(draftAt(MATCH_KEY).current.memberIds[1]).toBe('mem-4'); + panel.unmount(); + + const page = await mountPage(); + await chooseTarget(page, 'Pool A-2'); + + expect(positionValue(page, 1)).toBe('mem-4'); + expect(page.getByTestId(NOTICE).textContent).toContain('Unsaved lineup changes restored'); + }); + + it('is offered back in the panel when the Lineups page started it', async () => { + const first = await mountPage(); + await chooseTarget(first, 'Pool A-2'); + await pick(first, 1, 'mem-4'); + first.unmount(); + + let panel; + await act(async () => { + panel = render( + + ); + }); + await act(async () => { await Promise.resolve(); }); + + expect(panel.getByLabelText('1 player').value).toBe('Mori'); + expect(panel.getByTestId('match-lineup-draft-team-a').textContent).toContain('Unsaved lineup changes restored'); + }); +}); + +describe('a lineup saved meanwhile', () => { + it('is not overwritten by an older draft: the notice names what was not restored', async () => { + const first = await mountPage(); + await chooseTarget(first, 'Pool A-2'); + await pick(first, 1, 'mem-4'); + first.unmount(); + api.fetchLineupInForce.mockResolvedValue(SAVED_ELSEWHERE); + + const again = await mountPage(); + await chooseTarget(again, 'Pool A-2'); + + const notice = again.getByTestId(NOTICE); + expect(notice.textContent).toBe('Not restored, the lineup changed since: Mori'); + expect(notice.getAttribute('role')).toBe('status'); + expect(again.queryByRole('button', { name: 'Discard' })).toBeNull(); + // What the server holds is what is shown, and Save has nothing to write. + expect(positionValue(again, 1)).toBe('mem-5'); + expect(saveButton(again).disabled).toBe(true); + expect(sessionStorage.getItem(MATCH_KEY)).toBeNull(); + expect(api.putMatchLineup).not.toHaveBeenCalled(); + }); + + it('is found on the starting lineup too', async () => { + const first = await mountPage(); + await pick(first, 2, 'mem-4'); + first.unmount(); + api.fetchTeamLineup.mockResolvedValue({ ...STARTING, positions: { 1: 'Aoki', 2: 'Kato', 3: 'Ito' }, memberIds: { 1: 'mem-1', 2: 'mem-5', 3: 'mem-3' } }); + + const again = await mountPage(); + + expect(again.getByTestId(NOTICE).textContent).toBe('Not restored, the lineup changed since: Mori'); + expect(sessionStorage.getItem(START_KEY)).toBeNull(); + }); +}); + +describe('a lineup that could not be read', () => { + it('keeps the draft and shows no notice', async () => { + const first = await mountPage(); + await chooseTarget(first, 'Pool A-2'); + await pick(first, 1, 'mem-4'); + first.unmount(); + const kept = sessionStorage.getItem(MATCH_KEY); + api.fetchLineupInForce.mockRejectedValue(new Error('competition not found')); + + const failed = await mountPage(); + await chooseTarget(failed, 'Pool A-2'); + + expect(failed.getByText('competition not found')).toBeTruthy(); + expect(failed.queryByTestId(NOTICE)).toBeNull(); + expect(sessionStorage.getItem(MATCH_KEY)).toBe(kept); + }); +}); + +describe('the Discard button', () => { + it('is a plain .btn, so the coarse-pointer floor reaches it through the class, never an inline style', async () => { + const first = await mountPage(); + await pick(first, 2, 'mem-4'); + first.unmount(); + const again = await mountPage(); + + const discard = discardButton(again); + expect(discard.className.split(/\s+/)).toContain('btn'); + expect(discard.hasAttribute('style')).toBe(false); + }); +}); diff --git a/web-mobile/js/__tests__/render/admin_scoring_modal.render.test.jsx b/web-mobile/js/__tests__/render/admin_scoring_modal.render.test.jsx index 2558b8be1..46aabda9b 100644 --- a/web-mobile/js/__tests__/render/admin_scoring_modal.render.test.jsx +++ b/web-mobile/js/__tests__/render/admin_scoring_modal.render.test.jsx @@ -21,7 +21,6 @@ const STUBBED_GLOBALS = { isTextEntry: () => false, isInteractiveTarget: () => false, confirmDialog: vi.fn().mockResolvedValue(true), - resolveRoundIndex: () => 0, API: { fetchCompetitionDetails: vi.fn().mockResolvedValue(null), recordScore: vi.fn(), 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 f02f70bcd..94581898d 100644 --- a/web-mobile/js/__tests__/render/admin_shiaijo.render.test.jsx +++ b/web-mobile/js/__tests__/render/admin_shiaijo.render.test.jsx @@ -1,6 +1,6 @@ import React from 'react'; -import { render, act } from '@testing-library/react'; -import { describe, it, expect, vi, beforeAll, afterAll, afterEach } from 'vitest'; +import { render, act, fireEvent, within } from '@testing-library/react'; +import { describe, it, expect, vi, beforeAll, afterAll, beforeEach, afterEach } from 'vitest'; import { installWindowStubs } from '../helpers/stub_globals.js'; import { DOWNSTREAM_KNOCKOUT_PLAYED_CANCELLED } from '../../write_result.jsx'; // Window globals required by admin_shiaijo.jsx. @@ -668,6 +668,77 @@ describe('AdminShiaijoPage render-smoke', () => { } }); + // The console's own confirms (Send back to queue, the court move) work like the + // app's other dialogs from a keyboard: focus goes into the confirm as it opens, + // Escape cancels one that is not busy, and focus goes back to the control that + // opened it. Found in the browser: Escape did nothing and focus fell to the page. + describe('the console confirms from a keyboard', () => { + const running = { + id: 'm1', compId: 'c1', compName: 'Cup', status: 'running', + phase: 'bracket', matchNumber: 1, court: 'A', compKind: 'team', teamSize: 5, + sideA: { id: 'a', name: 'Team A' }, sideB: { id: 'b', name: 'Team B' }, + }; + const settle = () => act(async () => { await new Promise((r) => setTimeout(r, 5)); }); + let saved; + beforeEach(() => { + saved = { fetch: window.API.fetchCourtMatches, sub: window.API.subscribeToEvents, revert: window.API.revertMatchToQueue }; + window.tournamentMatches = () => [running]; + window.filterMatchesByCourt = (matches) => matches; + window.API.fetchCourtMatches = vi.fn().mockImplementation(() => Promise.resolve([{ id: 'c1', name: 'Cup' }])); + window.API.subscribeToEvents = () => () => {}; + }); + afterEach(() => { + window.API.fetchCourtMatches = saved.fetch; + window.API.subscribeToEvents = saved.sub; + window.API.revertMatchToQueue = saved.revert; + }); + + it('Send back to queue: focus goes in, Escape cancels and gives it back, and a confirm whose request is out stays', async () => { + let answer; + window.API.revertMatchToQueue = vi.fn().mockImplementation(() => new Promise((r) => { answer = r; })); + let utils; + await act(async () => { utils = renderPage(makeMinimalTournament()); }); + const opener = utils.getByRole('button', { name: /^send back to queue$/i }); + opener.focus(); + await act(async () => { opener.click(); }); + await settle(); + const dialog = utils.getByRole('dialog', { name: /send back to queue\?/i }); + expect(dialog.contains(document.activeElement), 'focus is in the confirm').toBe(true); + + await act(async () => { fireEvent.keyDown(document.activeElement, { key: 'Escape' }); }); + await settle(); + expect(utils.queryByRole('dialog'), 'Escape cancels it').toBeNull(); + expect(document.activeElement, 'focus is back on the control that opened it').toBe(utils.getByRole('button', { name: /^send back to queue$/i })); + expect(window.API.revertMatchToQueue).not.toHaveBeenCalled(); + + await act(async () => { utils.getByRole('button', { name: /^send back to queue$/i }).click(); }); + await settle(); + const again = utils.getByRole('dialog', { name: /send back to queue\?/i }); + await act(async () => { within(again).getByRole('button', { name: /^send back to queue$/i }).click(); }); + await act(async () => { fireEvent.keyDown(document.body, { key: 'Escape' }); }); + expect(utils.queryByRole('dialog'), 'a confirm whose request is out stays open').not.toBeNull(); + await act(async () => { answer(true); }); + }); + + it('the court move: focus goes in, Escape cancels and gives it back', async () => { + // A queued row after Up next carries the court control (as in the move + // confirm test below; mountCourt seeds a court feed). + const c = await mountCourt([courtMatch('m1', 'running'), courtMatch('m2', 'scheduled'), courtMatch('m3', 'scheduled')]); + try { + const opener = c.utils.getAllByTestId('move-to-B')[0]; + opener.focus(); + await act(async () => { opener.click(); }); + await settle(); + const dialog = c.utils.getByRole('dialog', { name: /move to shiaijo b\?/i }); + expect(dialog.contains(document.activeElement), 'focus is in the confirm').toBe(true); + await act(async () => { fireEvent.keyDown(document.activeElement, { key: 'Escape' }); }); + await settle(); + expect(c.utils.queryByRole('dialog'), 'Escape cancels it').toBeNull(); + expect(document.activeElement, 'focus is back on the control that opened it').toBe(c.utils.getAllByTestId('move-to-B')[0]); + } finally { c.restore(); } + }); + }); + // UAT (bc-tmfn): the operator corrects a finished match, taps Clear // withdrawal and reopen (the match is RUNNING again), scores the rest and // taps Finish + Start Next. The console kept the old match pinned in diff --git a/web-mobile/js/__tests__/render/admin_topbar_stack_height.render.test.jsx b/web-mobile/js/__tests__/render/admin_topbar_stack_height.render.test.jsx new file mode 100644 index 000000000..8c16b4565 --- /dev/null +++ b/web-mobile/js/__tests__/render/admin_topbar_stack_height.render.test.jsx @@ -0,0 +1,88 @@ +// bc-tmfd: AdminTopbar publishes its stack's height as --topbar-stack-h, which +// the team sheet's pinned header reads to sit directly under the topbar. +// Measured once in a layout effect (a ResizeObserver first fires after paint), +// then kept current by the observer, and removed when the topbar unmounts. + +import React from 'react'; +import { render, act } from '@testing-library/react'; +import { describe, it, expect, beforeAll, beforeEach, afterEach } from 'vitest'; + +const PROP = '--topbar-stack-h'; +const rootValue = () => document.documentElement.style.getPropertyValue(PROP); + +let AdminTopbar; +let height; +let observers; +const realRO = globalThis.ResizeObserver; +const realRect = Element.prototype.getBoundingClientRect; + +beforeAll(async () => { + await import('../../admin_shell.jsx'); + AdminTopbar = window.AdminTopbar; +}); + +beforeEach(() => { + height = 65; + observers = []; + window.subscribeSyncStatus = (fn) => { fn('synced'); return () => {}; }; + window.subscribeUnsentWrites = undefined; + Element.prototype.getBoundingClientRect = function () { + return this.classList && this.classList.contains('topbar-stack') + ? { top: 0, bottom: height, height, left: 0, right: 0, width: 0, x: 0, y: 0 } + : realRect.call(this); + }; +}); + +afterEach(() => { + Element.prototype.getBoundingClientRect = realRect; + globalThis.ResizeObserver = realRO; + document.documentElement.style.removeProperty(PROP); +}); + +const mountBar = () => render( + {}} onViewerMode={() => {}} tournament={null} hideRunningStrip /> +); + +function stubObserver() { + globalThis.ResizeObserver = class { + constructor(cb) { this.cb = cb; this.observed = []; this.disconnected = false; observers.push(this); } + observe(el) { this.observed.push(el); } + disconnect() { this.disconnected = true; } + }; +} + +describe('AdminTopbar publishes --topbar-stack-h', () => { + it('sets the measured border-box height before any observer callback fires', () => { + stubObserver(); + mountBar(); + expect(rootValue()).toBe('65px'); + expect(observers).toHaveLength(1); + expect(observers[0].observed[0].classList.contains('topbar-stack')).toBe(true); + }); + + it('updates when the observer fires with a grown stack', () => { + stubObserver(); + mountBar(); + height = 118; + act(() => { observers[0].cb([]); }); + expect(rootValue()).toBe('118px'); + }); + + it('removes the property and disconnects on unmount', () => { + stubObserver(); + const { unmount } = mountBar(); + expect(rootValue()).toBe('65px'); + unmount(); + expect(rootValue()).toBe(''); + expect(observers[0].disconnected).toBe(true); + }); + + it('still measures once, and does not throw, without ResizeObserver', () => { + globalThis.ResizeObserver = undefined; + let bar; + expect(() => { bar = mountBar(); }).not.toThrow(); + expect(rootValue()).toBe('65px'); + bar.unmount(); + expect(rootValue()).toBe(''); + }); +}); diff --git a/web-mobile/js/__tests__/render/auth_modal_opened_tap.render.test.jsx b/web-mobile/js/__tests__/render/auth_modal_opened_tap.render.test.jsx new file mode 100644 index 000000000..288a48a29 --- /dev/null +++ b/web-mobile/js/__tests__/render/auth_modal_opened_tap.render.test.jsx @@ -0,0 +1,100 @@ +// bc-cfbd: the admin sign-in modal, opened by a tap on the viewer's Admin +// control, survives the bounce of that tap. The second click of a double tap +// lands on the fresh backdrop one render later, and the backdrop's onClick is +// the modal's dismiss, which closed it at once; landing on Sign in it submitted +// an empty password. tap_guard.jsx's useOpenedTapGuard swallows every pointer +// click in the layer for TAP_BOUNCE_MS after it opens; a deliberate tap after +// the window acts as before, and keyboard activation (detail 0) is never +// swallowed. +// +// 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. App mounts once +// per file, with real timers (mountApp settles on one); each test then fakes +// them and leaves the modal closed. +// +// Pointer taps pass detail: 1 (helpers/tap_events.js), or the guard exempts +// them and these tests could never go red. +import React from 'react'; +import { act, screen } from '@testing-library/react'; +import { describe, it, expect, vi, beforeAll, afterAll, beforeEach, afterEach } from 'vitest'; +import { mountApp } from '../helpers/mount_app.js'; +import { TAP_BOUNCE_MS } from '../../tap_guard.jsx'; +import { pointerTap, keyboardClick } from '../helpers/tap_events.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(); }); + +beforeEach(() => { vi.useFakeTimers(); }); + +const wait = (ms) => act(async () => { vi.advanceTimersByTime(ms); }); +const backdrop = () => document.querySelector('.modal-backdrop'); +const signIn = () => screen.getByRole('button', { name: 'Sign in' }); +const EMPTY_PASSWORD = 'Enter a password.'; + +afterEach(async () => { + if (backdrop()) { + await wait(TAP_BOUNCE_MS + 50); + await pointerTap(backdrop()); + } + vi.useRealTimers(); +}); + +async function openSignIn() { + expect(backdrop()).toBeNull(); + await pointerTap(screen.getByRole('button', { name: 'Open admin' })); + expect(backdrop()).not.toBeNull(); +} + +describe('the sign-in modal opened by a tap', () => { + it('survives the bounce landing on its backdrop', async () => { + await openSignIn(); + await wait(30); + await pointerTap(backdrop()); + expect(backdrop()).not.toBeNull(); + }); + + it('survives the bounce landing on Sign in without submitting an empty password', async () => { + await openSignIn(); + await wait(30); + await pointerTap(signIn()); + expect(backdrop()).not.toBeNull(); + expect(screen.queryByText(EMPTY_PASSWORD)).toBeNull(); + }); + + it('is dismissed by a deliberate tap on the backdrop after the window', async () => { + await openSignIn(); + await wait(TAP_BOUNCE_MS + 50); + await pointerTap(backdrop()); + expect(backdrop()).toBeNull(); + }); + + it('submits at once from the keyboard (detail 0 is never a bounce)', async () => { + await openSignIn(); + await wait(30); + await keyboardClick(signIn()); + expect(screen.getByText(EMPTY_PASSWORD)).toBeTruthy(); + }); +}); diff --git a/web-mobile/js/__tests__/render/autosave_debounce.render.test.jsx b/web-mobile/js/__tests__/render/autosave_debounce.render.test.jsx index 677f91d1a..9474de1af 100644 --- a/web-mobile/js/__tests__/render/autosave_debounce.render.test.jsx +++ b/web-mobile/js/__tests__/render/autosave_debounce.render.test.jsx @@ -32,7 +32,6 @@ const STUBBED_GLOBALS = { isTextEntry: () => false, isInteractiveTarget: () => false, confirmDialog: vi.fn().mockResolvedValue(true), - resolveRoundIndex: () => 0, API: { fetchCompetitionDetails: vi.fn().mockResolvedValue(null), // A landed body: saveRunningSheet (admin_scoring_team.jsx) and Start diff --git a/web-mobile/js/__tests__/render/autosave_every_scoring_edit.render.test.jsx b/web-mobile/js/__tests__/render/autosave_every_scoring_edit.render.test.jsx index 63ddbbc7c..bae5b4f0b 100644 --- a/web-mobile/js/__tests__/render/autosave_every_scoring_edit.render.test.jsx +++ b/web-mobile/js/__tests__/render/autosave_every_scoring_edit.render.test.jsx @@ -29,7 +29,6 @@ const STUBBED_GLOBALS = { isTextEntry: () => false, isInteractiveTarget: () => false, confirmDialog: vi.fn().mockResolvedValue(true), - resolveRoundIndex: () => 0, API: { fetchCompetitionDetails: vi.fn().mockResolvedValue(null), recordScore: vi.fn(), diff --git a/web-mobile/js/__tests__/render/autosave_pending_edit.render.test.jsx b/web-mobile/js/__tests__/render/autosave_pending_edit.render.test.jsx index 80e0cb333..7bf3c4ce4 100644 --- a/web-mobile/js/__tests__/render/autosave_pending_edit.render.test.jsx +++ b/web-mobile/js/__tests__/render/autosave_pending_edit.render.test.jsx @@ -24,7 +24,6 @@ const STUBBED_GLOBALS = { isTextEntry: () => false, isInteractiveTarget: () => false, confirmDialog: vi.fn().mockResolvedValue(true), - resolveRoundIndex: () => 0, API: { fetchCompetitionDetails: vi.fn().mockResolvedValue(null), recordScore: vi.fn(), diff --git a/web-mobile/js/__tests__/render/changed_groups_every_editor.render.test.jsx b/web-mobile/js/__tests__/render/changed_groups_every_editor.render.test.jsx index 117c294d7..5cc7fefee 100644 --- a/web-mobile/js/__tests__/render/changed_groups_every_editor.render.test.jsx +++ b/web-mobile/js/__tests__/render/changed_groups_every_editor.render.test.jsx @@ -32,7 +32,6 @@ const STUBBED_GLOBALS = { isTextEntry: () => false, isInteractiveTarget: () => false, confirmDialog: vi.fn().mockResolvedValue(true), - resolveRoundIndex: () => 0, API: { fetchCompetitionDetails: vi.fn().mockResolvedValue(null), recordScore: vi.fn(), @@ -172,6 +171,50 @@ describe('individual editor', () => { expect(lastWrite().changed).toEqual(['points']); }); + it('a reopen moves only the result the editor agrees with: a held edit still names no other group', async () => { + // The server marks the match reopened while a point is unsaved, and another + // device has recorded overtime. The editor takes the reopened result as + // agreed (so ending it again says the result changed) and nothing else: its + // save must not put "no overtime" back. + const { rerender } = await mount(individual()); + await pointerTap(ipponBtn('shiro', 'K')); + await act(async () => { rerender(editorFor(individual({ reopenPending: true, encho: { periodCount: 1 } }))); }); + await settle(); + expect(writes).toHaveLength(1); + expect(lastWrite().encho).toBeUndefined(); + expect(lastWrite().changed).toEqual(['points']); + }); + + // A reopen the server makes WITH a correction's reason (a downstream reopen, a + // requalification, a pool-rank override) leaves the match scheduled with its + // points and no reopenPending stamp. The editor opened on the finished result, so + // that is the result it agrees with: finishing again rebuilds the same result and + // must still say it changed it, or the server, told nothing changed, keeps the + // match as it is and the editor closes as if it had finished. + it('names the result when the match it opened finished is reopened with a reason elsewhere, and finished again', async () => { + const { rerender } = await mount(individual({ status: 'completed', ipponsA: ['M', 'K'], winner: { id: 'p1', name: 'Yamada' } })); + await act(async () => { rerender(editorFor(individual({ status: 'scheduled', ipponsA: ['M', 'K'] }))); }); + await finish(); + expect(lastWrite().status).toBe('completed'); + expect(lastWrite().changed).toContain('result'); + }); + + it('names the result each time the server reopens the match the editor opened finished', async () => { + // The agreement follows the server leaving the finished result every time, not once. + const done = () => individual({ status: 'completed', ipponsA: ['M', 'K'], winner: { id: 'p1', name: 'Yamada' } }); + const reopened = () => individual({ status: 'scheduled', ipponsA: ['M', 'K'] }); + const { rerender } = await mount(done()); + await act(async () => { rerender(editorFor(reopened())); }); + await finish(); + expect(lastWrite().changed).toContain('result'); + await act(async () => { rerender(editorFor(done())); }); + await act(async () => { rerender(editorFor(reopened())); }); + const before = writes.length; + await finish(); + expect(writes.length, 'the second finish is sent').toBeGreaterThan(before); + expect(lastWrite().changed).toContain('result'); + }); + it('an editor that followed a change elsewhere does not name it back', async () => { // Nothing unsaved: the editor re-seeds to the overtime recorded // elsewhere, and its next point names the points alone. diff --git a/web-mobile/js/__tests__/render/confirm_dialog_double_tap.render.test.jsx b/web-mobile/js/__tests__/render/confirm_dialog_double_tap.render.test.jsx new file mode 100644 index 000000000..c3a564752 --- /dev/null +++ b/web-mobile/js/__tests__/render/confirm_dialog_double_tap.render.test.jsx @@ -0,0 +1,110 @@ +// bc-cfbd: a double tap on a button that opens a confirm leaves the confirm +// open. +// +// The trigger's click opens the dialog, Preact renders it on the next +// microtask, and the second tap of the bounce (16-44ms later) lands on the new +// dialog layer: on the backdrop or Cancel it dismissed the confirm, and on +// Confirm it confirmed it without being asked. tap_guard.jsx's swallowBounce +// now covers every pointer click in the layer for TAP_BOUNCE_MS after it +// opens. Keyboard activation (detail 0) is never swallowed. +// +// Pointer taps pass detail: 1 (helpers/tap_events.js), or the guard exempts +// them and these tests could never go red. + +import React from 'react'; +import { render, act, cleanup, screen } from '@testing-library/react'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { DialogHost, confirmDialog, Modal } from '../../ui.jsx'; +import { TAP_BOUNCE_MS } from '../../tap_guard.jsx'; +import { pointerTap, keyboardClick } from '../helpers/tap_events.js'; + +// One holder per test: a dialog a test leaves open is resolved (false) by the +// next test's dialog, and must not write into that test's result. +let outcome; + +function Harness({ sink }) { + return ( +
+ + +
+ ); +} + +const wait = (ms) => act(async () => { vi.advanceTimersByTime(ms); }); +const backdrop = (container) => container.querySelector('.modal-backdrop'); + +beforeEach(() => { outcome = { value: 'unresolved' }; vi.useFakeTimers(); }); +afterEach(() => { cleanup(); vi.useRealTimers(); }); + +describe('a confirm opened by a tap', () => { + it('survives the bounce landing on the backdrop', async () => { + const { container } = render(); + await pointerTap(screen.getByTestId('trigger')); + expect(backdrop(container)).not.toBeNull(); + await wait(30); + await pointerTap(backdrop(container)); + expect(backdrop(container)).not.toBeNull(); + expect(outcome.value).toBe('unresolved'); + }); + + it('survives the bounce landing on Cancel', async () => { + const { container } = render(); + await pointerTap(screen.getByTestId('trigger')); + await wait(30); + await pointerTap(screen.getByText('Cancel')); + expect(backdrop(container)).not.toBeNull(); + expect(outcome.value).toBe('unresolved'); + }); + + it('is not confirmed by the bounce landing on Confirm', async () => { + const { container } = render(); + await pointerTap(screen.getByTestId('trigger')); + await wait(30); + await pointerTap(screen.getByText('Go')); + expect(backdrop(container)).not.toBeNull(); + expect(outcome.value).toBe('unresolved'); + }); + + it('is dismissed by a deliberate tap on the backdrop after the window', async () => { + const { container } = render(); + await pointerTap(screen.getByTestId('trigger')); + await wait(TAP_BOUNCE_MS + 50); + await pointerTap(backdrop(container)); + expect(backdrop(container)).toBeNull(); + expect(outcome.value).toBe(false); + }); + + it('is confirmed by a deliberate tap on Confirm after the window', async () => { + const { container } = render(); + await pointerTap(screen.getByTestId('trigger')); + await wait(TAP_BOUNCE_MS + 50); + await pointerTap(screen.getByText('Go')); + expect(backdrop(container)).toBeNull(); + expect(outcome.value).toBe(true); + }); + + it('is confirmed at once from the keyboard (detail 0 is never a bounce)', async () => { + render(); + await pointerTap(screen.getByTestId('trigger')); + await wait(30); + await keyboardClick(screen.getByText('Go')); + expect(outcome.value).toBe(true); + }); +}); + +describe('a Modal opened by a tap', () => { + it('survives the bounce on its backdrop and closes on a deliberate tap', async () => { + const onClose = vi.fn(); + const { container } = render(

body

); + await wait(30); + await pointerTap(backdrop(container)); + expect(onClose).not.toHaveBeenCalled(); + await wait(TAP_BOUNCE_MS + 50); + await pointerTap(backdrop(container)); + expect(onClose).toHaveBeenCalledTimes(1); + }); +}); diff --git a/web-mobile/js/__tests__/render/court_picker_focus.render.test.jsx b/web-mobile/js/__tests__/render/court_picker_focus.render.test.jsx new file mode 100644 index 000000000..46aabf843 --- /dev/null +++ b/web-mobile/js/__tests__/render/court_picker_focus.render.test.jsx @@ -0,0 +1,106 @@ +// The court picker gives focus back to its button when its list closes, so a +// keyboard is not dropped to the page. It must not take focus as it mounts: +// every queue row and score row carries one, so focus jumped to the last row's +// picker (with its focus ring) on load and whenever a row appeared, and the +// browser scrolled the page to it (found in the browser on the court console). + +import React from 'react'; +import { render, act, fireEvent, cleanup } from '@testing-library/react'; +import { describe, it, expect, vi, beforeAll, afterEach } from 'vitest'; + +let CourtPicker; + +beforeAll(async () => { + await import('../../admin_shell.jsx'); + CourtPicker = window.CourtPicker; +}); + +afterEach(cleanup); + +const COURTS = ['A', 'B', 'C']; + +describe('the court picker and focus', () => { + it('takes no focus as it mounts, so a page of rows keeps the focus it had', () => { + const { getByTestId } = render( +
+ + {}} /> + {}} /> +
+ ); + expect(document.activeElement, 'nothing took focus on mount').toBe(document.body); + getByTestId('elsewhere').focus(); + const { container } = render( {}} />); + expect(container.querySelector('button')).not.toBe(document.activeElement); + expect(document.activeElement, 'a row that appears later takes nothing either').toBe(getByTestId('elsewhere')); + }); + + it('gives focus back to its button when the list closes', async () => { + const { container } = render( {}} />); + const trigger = container.querySelector('button'); + await act(async () => { trigger.click(); }); + const list = container.querySelector('[role="listbox"]'); + expect(list, 'the list is open').not.toBeNull(); + expect(list.contains(document.activeElement), 'focus is in the list').toBe(true); + await act(async () => { fireEvent.keyDown(document.activeElement, { key: 'Escape' }); }); + expect(container.querySelector('[role="listbox"]'), 'the list is closed').toBeNull(); + expect(document.activeElement, 'focus is back on the button').toBe(trigger); + }); + + // Focus goes back only for a close made inside the picker, and without scrolling: + // the button is where the operator was, so the page has no reason to move. + it('gives focus back without scrolling the page, for Escape and for a choice', async () => { + const onChange = vi.fn(); + const { container } = render(); + const trigger = container.querySelector('button'); + const focus = vi.spyOn(trigger, 'focus'); + await act(async () => { trigger.click(); }); + await act(async () => { fireEvent.keyDown(document.activeElement, { key: 'Escape' }); }); + expect(focus).toHaveBeenCalledTimes(1); + expect(focus, 'Escape').toHaveBeenCalledWith({ preventScroll: true }); + + await act(async () => { trigger.click(); }); + const option = [...container.querySelectorAll('[role="option"]')].find((o) => o.textContent === 'B'); + await act(async () => { option.click(); }); + expect(onChange).toHaveBeenCalledWith('B'); + expect(container.querySelector('[role="listbox"]'), 'the choice closed the list').toBeNull(); + expect(focus).toHaveBeenCalledTimes(2); + expect(focus, 'a choice').toHaveBeenLastCalledWith({ preventScroll: true }); + expect(document.activeElement).toBe(trigger); + }); + + // A tap on another control closes the list from outside: its mousedown closes the + // list, then the browser gives the tapped control focus, and only after that does + // the picker's close effect run. Taking focus back there blurred the tapped name + // box (and dropped the iPad keyboard), and without preventScroll it scrolled the + // Scores page back to the button (1682 to 0). + it('leaves focus where an outside tap put it', async () => { + const { container, getByTestId } = render( +
+ + {}} /> +
+ ); + const trigger = container.querySelector('button'); + const focus = vi.spyOn(trigger, 'focus'); + await act(async () => { trigger.click(); }); + expect(container.querySelector('[role="listbox"]'), 'the list is open').not.toBeNull(); + + const other = getByTestId('elsewhere'); + await act(async () => { fireEvent.mouseDown(other); other.focus(); }); + + expect(container.querySelector('[role="listbox"]'), 'the outside tap closed the list').toBeNull(); + expect(document.activeElement, 'the tapped control keeps focus').toBe(other); + expect(focus, 'the picker did not reach for its button').not.toHaveBeenCalled(); + }); + + it('gives focus back when its own button closes the list', async () => { + const { container } = render( {}} />); + const trigger = container.querySelector('button'); + const focus = vi.spyOn(trigger, 'focus'); + await act(async () => { trigger.click(); }); + await act(async () => { trigger.click(); }); + expect(container.querySelector('[role="listbox"]'), 'the list is closed').toBeNull(); + expect(focus).toHaveBeenCalledWith({ preventScroll: true }); + }); +}); diff --git a/web-mobile/js/__tests__/render/decision_form_closes.render.test.jsx b/web-mobile/js/__tests__/render/decision_form_closes.render.test.jsx index 7fd3e94bc..99b18d2ac 100644 --- a/web-mobile/js/__tests__/render/decision_form_closes.render.test.jsx +++ b/web-mobile/js/__tests__/render/decision_form_closes.render.test.jsx @@ -17,7 +17,6 @@ const STUBBED_GLOBALS = { isTextEntry: () => false, isInteractiveTarget: () => false, confirmDialog: vi.fn().mockResolvedValue(true), - resolveRoundIndex: () => 0, API: {}, AdminLineupHelpers: { rosterFor: vi.fn().mockReturnValue([]) }, compMatches: () => [], diff --git a/web-mobile/js/__tests__/render/dialog_stacks_above_overlays.render.test.jsx b/web-mobile/js/__tests__/render/dialog_stacks_above_overlays.render.test.jsx new file mode 100644 index 000000000..c6236a42e --- /dev/null +++ b/web-mobile/js/__tests__/render/dialog_stacks_above_overlays.render.test.jsx @@ -0,0 +1,90 @@ +// A confirm or prompt (DialogHost) must open ABOVE whatever asked for it. The +// at-court lineup panel is a fixed layer at z-index 1000 and the viewer's match +// modal is another, but the dialog sat at the .modal-backdrop default of 100, so +// "Use the previous match's lineup" asked its question UNDER the panel, out of +// reach. The stack, bottom to top: the page and its overlays (1000 at most), the +// dialogs (.modal-backdrop--dialog), the toast, which takes no pointer events and +// so never blocks a dialog it sits over. + +import React from 'react'; +import { render, act, cleanup } from '@testing-library/react'; +import { describe, it, expect, afterEach } from 'vitest'; +import { DialogHost, confirmDialog } from '../../ui.jsx'; +import { readStylesheet, cssBlock, modules, readCode } from '../helpers/source.js'; + +afterEach(cleanup); + +const css = readStylesheet(); +const declared = (text) => [...text.matchAll(/z-index:\s*(\d+)/g)].map((m) => Number(m[1])); +const zOf = (selector) => { + const block = cssBlock(css, selector); + expect(block, `rule ${selector} exists`).not.toBeNull(); + return Number(/z-index:\s*(\d+)/.exec(block)[1]); +}; + +describe('DialogHost', () => { + it('puts its backdrop on the dialog layer, beside the modal backdrop everything keys on', async () => { + const { container } = render(); + act(() => { confirmDialog({ message: 'Sure?' }); }); + const backdrop = container.querySelector('.modal-backdrop'); + expect(backdrop).not.toBeNull(); + expect(backdrop.classList.contains('modal-backdrop--dialog')).toBe(true); + }); +}); + +describe('the stacking order of the overlays', () => { + const dialog = zOf('.modal-backdrop--dialog'); + const toast = zOf('.toast'); + + // Every layer that is neither the dialog nor the toast: the stylesheet's own + // z-indexes, and the ones a module sets inline on a fixed overlay (the lineup + // panel, the viewer's match modal). A new overlay above the dialog turns this red. + const otherLayers = () => { + const rest = css.replace(cssBlock(css, '.modal-backdrop--dialog'), '').replace(cssBlock(css, '.toast'), ''); + const inline = modules().flatMap((file) => [...readCode(file).matchAll(/zIndex:\s*(\d+)/g)].map((m) => Number(m[1]))); + return [...declared(rest), ...inline]; + }; + + it('has the dialog above every other overlay, the lineup panel (1000) included', () => { + const others = otherLayers(); + expect(others).toContain(1000); + expect(dialog).toBeGreaterThan(Math.max(...others)); + }); + + it('has the toast above the dialog, so a toast raised while a dialog is open is not dimmed by its backdrop', () => { + expect(toast).toBeGreaterThan(dialog); + }); + + it('declares the dialog rule after .modal-backdrop, which has the same specificity', () => { + expect(css.indexOf('\n.modal-backdrop--dialog {')).toBeGreaterThan(css.indexOf('\n.modal-backdrop {')); + }); + + it('still lets a toast be click-through, so it never blocks the dialog under it', () => { + expect(cssBlock(css, '.toast')).toMatch(/pointer-events:\s*none/); + }); +}); + +// The announcement cards sit above the page's own chrome and under the modals, so +// an open dialog is never obscured by one: on the self-run public page a +// participant's score editor was covered by a card (the editor's backdrop sat at +// the default 100, under the cards at 900), and a tap on its Close button landed on +// the card. Every overlay editor and the shared Modal are a plain .modal-backdrop, +// so that one rule is the modal layer for all of them. +describe('the announcement cards and the modals', () => { + it('stack under every .modal-backdrop, so an open modal is never obscured by a card', () => { + expect(zOf('.modal-backdrop')).toBeGreaterThan(zOf('.announcement-overlay')); + }); + + it('stay above the viewer\'s own chrome, the tooltips (200) included', () => { + expect(zOf('.announcement-overlay')).toBeGreaterThan(zOf('.tw-tooltip')); + }); + + it.each(['admin_scoring_team.jsx', 'admin_scoring_individual.jsx', 'admin_scoring_engi.jsx'])( + 'the root of the overlay editor in %s is a plain .modal-backdrop, with no lower z-index of its own', + (file) => { + const root = /
]*data-testid="scoring-modal-root"[^>]*>/.exec(readCode(file)); + expect(root, 'the overlay root is found').not.toBeNull(); + expect(root[0]).not.toMatch(/zIndex/); + }, + ); +}); diff --git a/web-mobile/js/__tests__/render/discarded_held_write_disarms.render.test.jsx b/web-mobile/js/__tests__/render/discarded_held_write_disarms.render.test.jsx index 70df8423f..ad3ba92e0 100644 --- a/web-mobile/js/__tests__/render/discarded_held_write_disarms.render.test.jsx +++ b/web-mobile/js/__tests__/render/discarded_held_write_disarms.render.test.jsx @@ -38,7 +38,6 @@ const STUBBED_GLOBALS = { isTextEntry: () => false, isInteractiveTarget: () => false, confirmDialog: vi.fn().mockResolvedValue(true), - resolveRoundIndex: () => 0, AdminLineupHelpers: { rosterFor: vi.fn().mockReturnValue([]) }, compMatches: () => [], compMatchesForCompetition: () => [], diff --git a/web-mobile/js/__tests__/render/editor_barred_match.render.test.jsx b/web-mobile/js/__tests__/render/editor_barred_match.render.test.jsx index 9c6950888..04b4e163d 100644 --- a/web-mobile/js/__tests__/render/editor_barred_match.render.test.jsx +++ b/web-mobile/js/__tests__/render/editor_barred_match.render.test.jsx @@ -17,7 +17,6 @@ const STUBBED_GLOBALS = { isTextEntry: () => false, isInteractiveTarget: () => false, confirmDialog: vi.fn().mockResolvedValue(true), - resolveRoundIndex: () => 0, API: {}, AdminLineupHelpers: { rosterFor: vi.fn().mockReturnValue([]) }, compMatches: () => [], diff --git a/web-mobile/js/__tests__/render/editor_every_way_out.render.test.jsx b/web-mobile/js/__tests__/render/editor_every_way_out.render.test.jsx index d3b76885a..a0a4e1fd5 100644 --- a/web-mobile/js/__tests__/render/editor_every_way_out.render.test.jsx +++ b/web-mobile/js/__tests__/render/editor_every_way_out.render.test.jsx @@ -27,7 +27,6 @@ const STUBBED_GLOBALS = { isTextEntry: () => false, isInteractiveTarget: () => false, confirmDialog: vi.fn().mockResolvedValue(true), - resolveRoundIndex: () => 0, API: { fetchCompetitionDetails: vi.fn().mockResolvedValue(null), recordScore: vi.fn(), diff --git a/web-mobile/js/__tests__/render/fighter_pick_double_tap.render.test.jsx b/web-mobile/js/__tests__/render/fighter_pick_double_tap.render.test.jsx index 30a1cb7ef..23470c408 100644 --- a/web-mobile/js/__tests__/render/fighter_pick_double_tap.render.test.jsx +++ b/web-mobile/js/__tests__/render/fighter_pick_double_tap.render.test.jsx @@ -12,6 +12,7 @@ import React from 'react'; import { render, act, cleanup, fireEvent } from '@testing-library/react'; import { describe, it, expect, vi, beforeAll, afterAll, beforeEach, afterEach } from 'vitest'; import { installWindowStubs } from '../helpers/stub_globals.js'; +import { answered } from '../helpers/team_members.js'; import { TAP_BOUNCE_MS } from '../../tap_guard.jsx'; import { pointerTap, keyboardClick } from '../helpers/tap_events.js'; @@ -22,7 +23,6 @@ const STUBBED_GLOBALS = { isTextEntry: () => false, isInteractiveTarget: () => false, confirmDialog: vi.fn().mockResolvedValue(true), - resolveRoundIndex: () => 0, API: {}, AdminLineupHelpers: { rosterFor: vi.fn().mockReturnValue([]) }, compMatches: () => [], @@ -53,10 +53,10 @@ beforeEach(() => { window.API = { fetchCompetitionDetails: vi.fn().mockResolvedValue({ id: 'c1', config: { format: 'mixed', players: [] } }), fetchSquads: vi.fn().mockResolvedValue(SQUADS), - fetchMatchLineup: vi.fn(async () => null), - fetchTeamLineup: vi.fn(async () => null), + fetchLineupInForce: vi.fn(async () => null), putMatchLineup: vi.fn(async (_c, teamId, matchId, positions, _pw, memberIds) => ({ teamId, matchId, positions, memberIds })), - renameTeamMember: vi.fn(async () => true), + // The server answers a member write with the member it holds, stamped. + renameTeamMember: vi.fn(async (_c, _t, id, name) => answered({ id, index: Number(id.slice(1)) }, { name })), addTeamMember: vi.fn(), recordScore: vi.fn(async () => ({ status: 'running' })), recordDaihyosen: vi.fn(), diff --git a/web-mobile/js/__tests__/render/finish_arm_dwell.render.test.jsx b/web-mobile/js/__tests__/render/finish_arm_dwell.render.test.jsx index 33a16857a..4cbf5e500 100644 --- a/web-mobile/js/__tests__/render/finish_arm_dwell.render.test.jsx +++ b/web-mobile/js/__tests__/render/finish_arm_dwell.render.test.jsx @@ -25,7 +25,6 @@ const STUBBED_GLOBALS = { isTextEntry: () => false, isInteractiveTarget: () => false, confirmDialog: vi.fn().mockResolvedValue(true), - resolveRoundIndex: () => 0, API: { fetchCompetitionDetails: vi.fn().mockResolvedValue(null), recordScore: vi.fn().mockResolvedValue(undefined), diff --git a/web-mobile/js/__tests__/render/individual_editor_config_matrix.render.test.jsx b/web-mobile/js/__tests__/render/individual_editor_config_matrix.render.test.jsx index 325d71ab3..c82332f08 100644 --- a/web-mobile/js/__tests__/render/individual_editor_config_matrix.render.test.jsx +++ b/web-mobile/js/__tests__/render/individual_editor_config_matrix.render.test.jsx @@ -24,7 +24,6 @@ const STUBBED_GLOBALS = { isTextEntry: () => false, isInteractiveTarget: () => false, confirmDialog: vi.fn().mockResolvedValue(true), - resolveRoundIndex: () => 0, API: { fetchCompetitionDetails: vi.fn().mockResolvedValue(null), recordScore: vi.fn().mockResolvedValue(undefined), diff --git a/web-mobile/js/__tests__/render/individual_editor_hantei_correction.render.test.jsx b/web-mobile/js/__tests__/render/individual_editor_hantei_correction.render.test.jsx index a3b6fd99c..ad53bd053 100644 --- a/web-mobile/js/__tests__/render/individual_editor_hantei_correction.render.test.jsx +++ b/web-mobile/js/__tests__/render/individual_editor_hantei_correction.render.test.jsx @@ -19,7 +19,6 @@ const STUBBED_GLOBALS = { isTextEntry: () => false, isInteractiveTarget: () => false, confirmDialog: vi.fn().mockResolvedValue(true), - resolveRoundIndex: () => 0, API: { fetchCompetitionDetails: vi.fn().mockResolvedValue(null), recordScore: vi.fn(), diff --git a/web-mobile/js/__tests__/render/individual_editor_ko_tie_blocked.render.test.jsx b/web-mobile/js/__tests__/render/individual_editor_ko_tie_blocked.render.test.jsx index 96765cef7..252ae8cce 100644 --- a/web-mobile/js/__tests__/render/individual_editor_ko_tie_blocked.render.test.jsx +++ b/web-mobile/js/__tests__/render/individual_editor_ko_tie_blocked.render.test.jsx @@ -23,7 +23,6 @@ const STUBBED_GLOBALS = { isTextEntry: () => false, isInteractiveTarget: () => false, confirmDialog: vi.fn().mockResolvedValue(true), - resolveRoundIndex: () => 0, API: { fetchCompetitionDetails: vi.fn().mockResolvedValue(null), recordScore: vi.fn().mockResolvedValue(undefined), diff --git a/web-mobile/js/__tests__/render/individual_editor_live_hantei.render.test.jsx b/web-mobile/js/__tests__/render/individual_editor_live_hantei.render.test.jsx index 7058326ba..d18c3b1e4 100644 --- a/web-mobile/js/__tests__/render/individual_editor_live_hantei.render.test.jsx +++ b/web-mobile/js/__tests__/render/individual_editor_live_hantei.render.test.jsx @@ -35,7 +35,6 @@ const STUBBED_GLOBALS = { isTextEntry: () => false, isInteractiveTarget: () => false, confirmDialog: vi.fn().mockResolvedValue(true), - resolveRoundIndex: () => 0, API: { fetchCompetitionDetails: vi.fn().mockResolvedValue(null), recordScore: vi.fn(), diff --git a/web-mobile/js/__tests__/render/individual_editor_slot_labels.render.test.jsx b/web-mobile/js/__tests__/render/individual_editor_slot_labels.render.test.jsx index 42f782fac..77d65ec1e 100644 --- a/web-mobile/js/__tests__/render/individual_editor_slot_labels.render.test.jsx +++ b/web-mobile/js/__tests__/render/individual_editor_slot_labels.render.test.jsx @@ -24,7 +24,6 @@ const STUBBED_GLOBALS = { isTextEntry: () => false, isInteractiveTarget: () => false, confirmDialog: viMock.fn().mockResolvedValue(true), - resolveRoundIndex: () => 0, API: { fetchCompetitionDetails: viMock.fn().mockResolvedValue(null), recordScore: viMock.fn(), diff --git a/web-mobile/js/__tests__/render/ippon_double_tap.render.test.jsx b/web-mobile/js/__tests__/render/ippon_double_tap.render.test.jsx index 109150377..b329107e6 100644 --- a/web-mobile/js/__tests__/render/ippon_double_tap.render.test.jsx +++ b/web-mobile/js/__tests__/render/ippon_double_tap.render.test.jsx @@ -24,7 +24,6 @@ const STUBBED_GLOBALS = { isTextEntry: () => false, isInteractiveTarget: () => false, confirmDialog: vi.fn().mockResolvedValue(true), - resolveRoundIndex: () => 0, API: { fetchCompetitionDetails: vi.fn().mockResolvedValue(null), recordScore: vi.fn().mockResolvedValue(undefined), diff --git a/web-mobile/js/__tests__/render/kachinuki_cleared_bout_reaches_server.render.test.jsx b/web-mobile/js/__tests__/render/kachinuki_cleared_bout_reaches_server.render.test.jsx index af8d11571..ff1c9cc4e 100644 --- a/web-mobile/js/__tests__/render/kachinuki_cleared_bout_reaches_server.render.test.jsx +++ b/web-mobile/js/__tests__/render/kachinuki_cleared_bout_reaches_server.render.test.jsx @@ -19,7 +19,6 @@ const STUBBED_GLOBALS = { isTextEntry: () => false, isInteractiveTarget: () => false, confirmDialog: vi.fn().mockResolvedValue(true), - resolveRoundIndex: () => 0, API: { fetchCompetitionDetails: vi.fn().mockResolvedValue({ id: 'comp1', config: { format: 'mixed', teamMatchType: 'kachinuki', naginata: false, players: [] }, diff --git a/web-mobile/js/__tests__/render/kachinuki_done_bout_double_tap.render.test.jsx b/web-mobile/js/__tests__/render/kachinuki_done_bout_double_tap.render.test.jsx index a59c4b62b..bde627cd3 100644 --- a/web-mobile/js/__tests__/render/kachinuki_done_bout_double_tap.render.test.jsx +++ b/web-mobile/js/__tests__/render/kachinuki_done_bout_double_tap.render.test.jsx @@ -20,7 +20,6 @@ const STUBBED_GLOBALS = { isTextEntry: () => false, isInteractiveTarget: () => false, confirmDialog: vi.fn().mockResolvedValue(true), - resolveRoundIndex: () => 0, API: { fetchCompetitionDetails: vi.fn().mockResolvedValue({ id: 'comp1', diff --git a/web-mobile/js/__tests__/render/kachinuki_reopen.render.test.jsx b/web-mobile/js/__tests__/render/kachinuki_reopen.render.test.jsx index 89f1d9269..c086ab849 100644 --- a/web-mobile/js/__tests__/render/kachinuki_reopen.render.test.jsx +++ b/web-mobile/js/__tests__/render/kachinuki_reopen.render.test.jsx @@ -27,7 +27,6 @@ const STUBBED_GLOBALS = { isTextEntry: () => false, isInteractiveTarget: () => false, confirmDialog: vi.fn().mockResolvedValue(true), - resolveRoundIndex: () => 0, API: {}, AdminLineupHelpers: { rosterFor: vi.fn().mockReturnValue([]) }, compMatches: () => [], @@ -279,6 +278,31 @@ describe('kachinuki reopen: ending it again asks for no reason', () => { await waitFor(() => expect(onSubmit).toHaveBeenCalledTimes(1)); expect(onSubmit.mock.calls[0][0].correctionReason).toBeUndefined(); }); + + // The editor opened on the finished encounter, so the result it last agreed + // with is the finished one. The reopen is a write the server makes, not one the + // editor builds: ended again with nothing else changed, End rebuilds that same + // result, and must still say it changed the result, or the server, told that + // nothing changed, keeps the encounter running. + it('names the result when the encounter is ended again from the editor that reopened it', async () => { + const onSubmit = vi.fn().mockResolvedValue(undefined); + const finished = completedKachinukiMatch({ winnerId: 'team-A', decision: 'kachinuki-exhaustion' }); + const utils = await renderEditor({ match: finished, onSubmit }); + await act(async () => { fireEvent.click(screen.getByTestId('kachinuki-reopen-button')); }); + await act(async () => { + utils.rerender( + + ); + }); + + fireEvent.click(screen.getByTestId('kachinuki-end-match-button')); + await act(async () => { fireEvent.click(screen.getByTestId('kachinuki-end-match-button')); }); + + await waitFor(() => expect(onSubmit).toHaveBeenCalledTimes(1)); + const patch = onSubmit.mock.calls[0][0]; + expect(patch.status).toBe('completed'); + expect(patch.changed).toContain('result'); + }); }); describe('kachinuki reopen: a busy court gets a remedy, not a dead end', () => { @@ -692,6 +716,13 @@ describe('kachinuki Encho is offered only on the current tied bout', () => { const tiedBout = (pos, a = 'A1', b = 'B1') => ( { position: pos, sideA: a, sideB: b, ipponsA: ['M'], ipponsB: ['M'] } ); + // Whether a button is offered. While either of the two is, both are in the footer and + // the other is held hidden in its place (the describe below), so "not offered" is + // either not rendered or held. + const offered = (testId) => { + const button = screen.queryByTestId(testId); + return !!button && !button.classList.contains('holds-space'); + }; it('shows the Encho button when the tied bout is the current bout', async () => { await renderEditor({ @@ -731,18 +762,18 @@ describe('kachinuki Encho is offered only on the current tied bout', () => { await renderEditor({ match: completedKachinukiMatch({ status: 'running', winner: null, subResults: [tiedBout(1)] }), }); - expect(screen.queryByTestId('kachinuki-encho-undo-button')).toBeNull(); + expect(offered('kachinuki-encho-undo-button')).toBe(false); await act(async () => { fireEvent.click(screen.getByTestId('kachinuki-encho-button')); }); await act(async () => { fireEvent.click(screen.getByTestId('kachinuki-encho-button')); }); - expect(screen.getByTestId('kachinuki-encho-undo-button')).toBeTruthy(); + expect(offered('kachinuki-encho-undo-button')).toBe(true); const undo = () => act(async () => { fireEvent.click(screen.getByTestId('kachinuki-encho-undo-button')); }); await undo(); - expect(screen.queryByTestId('kachinuki-encho-undo-button'), 'one period still on').not.toBeNull(); + expect(offered('kachinuki-encho-undo-button'), 'one period still on').toBe(true); await undo(); - expect(screen.queryByTestId('kachinuki-encho-undo-button')).toBeNull(); + expect(offered('kachinuki-encho-undo-button')).toBe(false); // Back to the tie it was: Encho is offered again. - expect(screen.getByTestId('kachinuki-encho-button')).toBeTruthy(); + expect(offered('kachinuki-encho-button')).toBe(true); }); it('undoing the only period of a 0-0 tie restores the Tie', async () => { @@ -755,7 +786,7 @@ describe('kachinuki Encho is offered only on the current tied bout', () => { await act(async () => { fireEvent.click(screen.getByTestId('kachinuki-encho-button')); }); await act(async () => { fireEvent.click(screen.getByTestId('kachinuki-encho-undo-button')); }); expect(screen.getByTestId('scoring-modal-tie-button').textContent).toContain('✓'); - expect(screen.getByTestId('kachinuki-encho-button'), 'still a tied bout, so Encho is offered again').toBeTruthy(); + expect(offered('kachinuki-encho-button'), 'still a tied bout, so Encho is offered again').toBe(true); }); it('offers no undo once a point has been struck in encho', async () => { @@ -763,9 +794,9 @@ describe('kachinuki Encho is offered only on the current tied bout', () => { match: completedKachinukiMatch({ status: 'running', winner: null, subResults: [tiedBout(1)] }), }); await act(async () => { fireEvent.click(screen.getByTestId('kachinuki-encho-button')); }); - expect(screen.getByTestId('kachinuki-encho-undo-button')).toBeTruthy(); + expect(offered('kachinuki-encho-undo-button')).toBe(true); await act(async () => { fireEvent.keyDown(window, { key: 'K', shiftKey: true }); }); - expect(screen.queryByTestId('kachinuki-encho-undo-button')).toBeNull(); + expect(offered('kachinuki-encho-undo-button')).toBe(false); }); // Operator ruling 2026-09-26: whether a tied pair fights on is the @@ -774,16 +805,94 @@ describe('kachinuki Encho is offered only on the current tied bout', () => { // between the FIRST fighters still offers Encho, and the tap records it. it('offers Encho on a tie between any pair, lineups in force', async () => { const lineupFor = (p) => ({ positions: { 1: `${p}1`, 2: `${p}2`, 3: `${p}3` } }); - window.API.fetchMatchLineup = vi.fn().mockImplementation(async (_c, teamId) => ( + window.API.fetchLineupInForce = vi.fn().mockImplementation(async (_c, teamId) => ( teamId === 'team-A' ? lineupFor('A') : teamId === 'team-B' ? lineupFor('B') : null )); await renderEditor({ match: completedKachinukiMatch({ status: 'running', winner: null, subResults: [tiedBout(1)] }), }); - await waitFor(() => expect(window.API.fetchMatchLineup).toHaveBeenCalledTimes(2)); + await waitFor(() => expect(window.API.fetchLineupInForce).toHaveBeenCalledTimes(2)); const encho = screen.getByTestId('kachinuki-encho-button'); await act(async () => { fireEvent.click(encho); }); - expect(screen.getByTestId('kachinuki-encho-undo-button'), 'the tap recorded a period').toBeTruthy(); + expect(offered('kachinuki-encho-undo-button'), 'the tap recorded a period').toBe(true); + }); +}); + +// Operator ruling 2026-10-06, "Keep Encho in place": the footer's actions row is centred, so +// a button that comes and goes beside Encho moved it (measured: 55 px left when Undo encho +// appeared, so a double tap on Encho landed on Undo encho and cancelled itself). While +// either of the two is offered both are in the row, in the same order, and the other is +// held hidden in its place: same label, so the same width, out of the accessibility tree, +// the tab order and the pointer. jsdom lays nothing out, so the class and the disabled +// state are what the DOM shows; the stylesheet rule is pinned in team_editor_clear_hint. +describe('kachinuki Encho and Undo encho keep their places in the footer', () => { + const HELD = 'holds-space'; + const ENCHO = 'kachinuki-encho-button'; + const UNDO = 'kachinuki-encho-undo-button'; + const tied = (extra = {}) => ({ position: 1, sideA: 'A1', sideB: 'B1', ipponsA: ['M'], ipponsB: ['M'], ...extra }); + const running = (subResults) => completedKachinukiMatch({ status: 'running', winner: null, subResults }); + const held = (id) => screen.getByTestId(id).classList.contains(HELD); + const labels = () => [...document.querySelector('.score-nav__actions').querySelectorAll('button')] + .map((b) => b.textContent.trim()) + .filter((label) => ['Record bout', 'Encho', 'Undo encho', 'End match'].includes(label)); + + it('renders both, Encho before Undo encho, with Undo encho held while only Encho is offered', async () => { + await renderEditor({ match: running([tied()]) }); + + expect(labels(), 'in a fixed order between Record bout and End match').toEqual(['Record bout', 'Encho', 'Undo encho', 'End match']); + expect(held(ENCHO)).toBe(false); + expect(held(UNDO)).toBe(true); + expect(screen.getByTestId(UNDO).textContent.trim(), 'the same label, so the same width').toBe('Undo encho'); + }); + + it('keeps the same two buttons in the same order when Undo encho becomes offered, and holds nothing back', async () => { + await renderEditor({ match: running([tied()]) }); + const encho = screen.getByTestId(ENCHO); + const undo = screen.getByTestId(UNDO); + + await act(async () => { fireEvent.click(encho); }); + + expect(screen.getByTestId(ENCHO), 'the same element, not one that is added or removed').toBe(encho); + expect(screen.getByTestId(UNDO)).toBe(undo); + expect(labels()).toEqual(['Record bout', 'Encho', 'Undo encho', 'End match']); + expect(held(ENCHO)).toBe(false); + expect(held(UNDO)).toBe(false); + }); + + it('offers both once a 0-0 tie is taken into encho, and takes it back to a tie with both still in place', async () => { + await renderEditor({ match: running([tied({ ipponsA: [], ipponsB: [], decision: 'hikiwake' })]) }); + expect(held(UNDO)).toBe(true); + + await act(async () => { fireEvent.click(screen.getByTestId(ENCHO)); }); + expect(labels()).toEqual(['Record bout', 'Encho', 'Undo encho', 'End match']); + expect(held(ENCHO)).toBe(false); + expect(held(UNDO)).toBe(false); + + await act(async () => { fireEvent.click(screen.getByTestId(UNDO)); }); + expect(labels()).toEqual(['Record bout', 'Encho', 'Undo encho', 'End match']); + expect(held(ENCHO)).toBe(false); + expect(held(UNDO), 'back to the tie: Undo encho goes, its place stays').toBe(true); + }); + + it('is not focusable or clickable while held: it is disabled, and a tap does nothing', async () => { + const onSubmit = vi.fn().mockResolvedValue(undefined); + await renderEditor({ match: running([tied()]), onSubmit }); + const undo = screen.getByTestId(UNDO); + + expect(undo.disabled).toBe(true); + await act(async () => { fireEvent.click(undo); }); + + expect(held(UNDO), 'nothing was taken back, nothing changed').toBe(true); + expect(held(ENCHO)).toBe(false); + expect(onSubmit).not.toHaveBeenCalled(); + }); + + it('renders neither once Record has appended the next pairing: nothing to keep in place', async () => { + await renderEditor({ match: running([tied(), { position: 2, sideA: 'A1', sideB: 'B2', ipponsA: [], ipponsB: [] }]) }); + + expect(labels()).toEqual(['Record bout', 'End match']); + expect(screen.queryByTestId(ENCHO)).toBeNull(); + expect(screen.queryByTestId(UNDO)).toBeNull(); }); }); diff --git a/web-mobile/js/__tests__/render/kachinuki_row_beyond_teamsize.render.test.jsx b/web-mobile/js/__tests__/render/kachinuki_row_beyond_teamsize.render.test.jsx index c33515f16..c58f313b3 100644 --- a/web-mobile/js/__tests__/render/kachinuki_row_beyond_teamsize.render.test.jsx +++ b/web-mobile/js/__tests__/render/kachinuki_row_beyond_teamsize.render.test.jsx @@ -20,6 +20,7 @@ import React from 'react'; import { render, act, fireEvent } from '@testing-library/react'; import { describe, it, expect, vi, beforeAll, afterAll, beforeEach } from 'vitest'; import { installWindowStubs } from '../helpers/stub_globals.js'; +import { answered } from '../helpers/team_members.js'; const STUBBED_GLOBALS = { isHikiwake: () => false, @@ -28,7 +29,6 @@ const STUBBED_GLOBALS = { isTextEntry: () => false, isInteractiveTarget: () => false, confirmDialog: vi.fn().mockResolvedValue(true), - resolveRoundIndex: () => 0, API: {}, AdminLineupHelpers: { rosterFor: vi.fn().mockReturnValue([]) }, compMatches: () => [], @@ -70,7 +70,7 @@ beforeEach(() => { recordDaihyosen: vi.fn(), removeDaihyosen: vi.fn(), putMatchLineup, - renameTeamMember: vi.fn().mockResolvedValue({ id: 'm-blank', index: 7, name: 'Ito' }), + renameTeamMember: vi.fn().mockResolvedValue(answered({ id: 'm-blank', index: 7 }, { name: 'Ito' })), recordDecision: vi.fn(), fetchSquads: vi.fn().mockResolvedValue({ 'team-A': SQUAD_A, 'team-B': SQUAD_B }), }; diff --git a/web-mobile/js/__tests__/render/kachinuki_same_name_winner_id.render.test.jsx b/web-mobile/js/__tests__/render/kachinuki_same_name_winner_id.render.test.jsx index 372e7311f..4a324eb47 100644 --- a/web-mobile/js/__tests__/render/kachinuki_same_name_winner_id.render.test.jsx +++ b/web-mobile/js/__tests__/render/kachinuki_same_name_winner_id.render.test.jsx @@ -30,7 +30,6 @@ const STUBBED_GLOBALS = { isTextEntry: () => false, isInteractiveTarget: () => false, confirmDialog: vi.fn().mockResolvedValue(true), - resolveRoundIndex: () => 0, API: {}, AdminLineupHelpers: { rosterFor: vi.fn().mockReturnValue([]) }, compMatches: () => [], diff --git a/web-mobile/js/__tests__/render/lineup_editors_read_and_remove.render.test.jsx b/web-mobile/js/__tests__/render/lineup_editors_read_and_remove.render.test.jsx new file mode 100644 index 000000000..ba8eb21f8 --- /dev/null +++ b/web-mobile/js/__tests__/render/lineup_editors_read_and_remove.render.test.jsx @@ -0,0 +1,522 @@ +// What both lineup editors (the at-court panel's MatchLineupSideEditor and the +// Lineups page) do about a lineup they could not read, about a removal, and about +// a save of the lineup that is still queued, in a real DOM: +// +// - a lineup that could not be read is never saved: Save and the boxes are off +// and the problem stays, with a Try again that reads it again; +// - "Use the previous match's lineup" waits while a save of that lineup is +// still queued (the save would replay after the removal and bring the lineup +// back), and says why in a line, never in a title alone; +// - a removal whose re-read fails leaves an empty, unread form that cannot be +// saved, never the removed lineup shown as the match's own; +// - "Not restored" stays through the operator's edits and goes once the operator +// saves the lineup; +// - a lineup change announced for the competition is followed while the form is +// untouched, and never over an edit. + +import React from 'react'; +import { render, act, fireEvent } from '@testing-library/react'; +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; + +const SQUAD = [ + { id: 'mem-1', index: 1, name: 'Aoki' }, + { id: 'mem-2', index: 2, name: 'Sato' }, + { id: 'mem-3', index: 3, name: 'Ito' }, + { id: 'mem-4', index: 4, name: 'Mori' }, +]; +const NAMES = { positions: { 1: 'Aoki', 2: 'Sato', 3: 'Ito' }, memberIds: { 1: 'mem-1', 2: 'mem-2', 3: 'mem-3' } }; +const lineupFor = (extra) => ({ teamId: 'team-a', competitionId: 'comp-1', ...NAMES, saved: true, ...extra }); +const STARTING = lineupFor({ round: 0, sourceRound: 0 }); +const CARRIED = lineupFor({ matchId: 'Pool A-0', sourceMatchId: 'Pool A-0' }); +const OWN = lineupFor({ matchId: 'Pool A-2', sourceMatchId: 'Pool A-2' }); +const SAVED_ELSEWHERE = lineupFor({ + matchId: 'Pool A-0', sourceMatchId: 'Pool A-0', + positions: { 1: 'Kato', 2: 'Sato', 3: 'Ito' }, memberIds: { 1: 'mem-5', 2: 'mem-2', 3: 'mem-3' }, +}); + +const A = { id: 'team-a', name: 'Team A' }; +const B = { id: 'team-b', name: 'Team B' }; +const C = { id: 'team-c', name: 'Team C' }; +const match = (id, sideA, sideB) => ({ id, status: 'scheduled', sideA, sideB }); +const POOL_MATCHES = [match('Pool A-0', A, B), match('Pool A-1', B, C), match('Pool A-2', A, C)]; +const COMP = { id: 'comp-1', name: 'Team Event', kind: 'team', format: 'pools', status: 'active', teamSize: 3, players: [A, B, C] }; +// The panel is handed matches shaped as the competition page builds them. +const PANEL_MATCHES = POOL_MATCHES.map((m) => ({ ...m, compId: 'comp-1', phase: 'pool', poolName: 'Pool A' })); +const PANEL_MATCH = PANEL_MATCHES[2]; + +const MATCH_KEY = 'bc.lineupDraft.v1:comp-1:team-a:match:Pool A-2'; +const USE_PREVIOUS = "Use the previous match's lineup"; +const QUEUED_LINE = 'A save of this lineup is still waiting to be sent.'; +const REMOVED_UNREAD = 'Removed. The lineup this match now uses could not be read: try again.'; +const NO_ANSWER = 'The lineup could not be read: the server did not answer. Check the connection and try again.'; + +let AdminTeamLineupsList; +let MatchLineupSideEditor; +let MatchLineupPanel; +let saved; +let api; + +beforeEach(async () => { + sessionStorage.clear(); + saved = { + API: window.API, confirmDialog: window.confirmDialog, + subscribeUnsentWrites: window.subscribeUnsentWrites, subscribeSyncStatus: window.subscribeSyncStatus, + compMatches: window.compMatches, + }; + window.confirmDialog = vi.fn().mockResolvedValue(true); + window.compMatches = () => []; + api = { + fetchTeamLineup: vi.fn().mockResolvedValue(STARTING), + fetchLineupInForce: vi.fn().mockResolvedValue(CARRIED), + fetchSquads: vi.fn().mockResolvedValue({ 'team-a': SQUAD }), + putTeamLineup: vi.fn().mockImplementation((_c, _t, _r, positions, _pw, memberIds) => Promise.resolve({ positions, memberIds })), + putMatchLineup: vi.fn().mockImplementation((_c, _t, _m, positions, _pw, memberIds) => Promise.resolve({ positions, memberIds })), + deleteMatchLineup: vi.fn().mockResolvedValue(true), + queuedLineupSave: vi.fn().mockReturnValue(false), + }; + window.API = api; + delete window.subscribeUnsentWrites; + delete window.subscribeSyncStatus; + ({ AdminTeamLineupsList } = await import('../../admin_lineup.jsx')); + ({ MatchLineupSideEditor, MatchLineupPanel } = await import('../../admin_schedule_lineup.jsx')); +}); + +afterEach(() => { + for (const [key, value] of Object.entries(saved)) { + if (value === undefined) delete window[key]; else window[key] = value; + } +}); + +async function mountPage(props = {}) { + let utils; + await act(async () => { + utils = render(); + }); + await act(async () => { await Promise.resolve(); }); + return utils; +} + +async function mountPanel() { + let utils; + await act(async () => { + utils = render( + + ); + }); + await act(async () => { await Promise.resolve(); }); + return utils; +} + +async function chooseTarget(utils, value) { + await act(async () => { fireEvent.change(utils.getByLabelText('Lineup for'), { target: { value } }); }); + await act(async () => { await Promise.resolve(); }); +} + +async function click(button) { + await act(async () => { fireEvent.click(button); }); + await act(async () => { await Promise.resolve(); }); +} + +async function typeName(utils, position, name) { + await act(async () => { + const input = utils.getByLabelText(`${position} player`); + fireEvent.focus(input); + fireEvent.change(input, { target: { value: name } }); + fireEvent.keyDown(input, { key: 'Enter' }); + }); +} + +const saveButton = (utils) => utils.getByRole('button', { name: /^Save lineup$/ }); +const tryAgain = (utils) => utils.getByRole('button', { name: 'Try again' }); +const useButton = (utils) => utils.queryByRole('button', { name: USE_PREVIOUS }); +const pageSelect = (utils, position) => utils.getByTestId(`lineup-position-${position}`); + +describe('the at-court panel, for a lineup that could not be read', () => { + beforeEach(() => { api.fetchLineupInForce.mockRejectedValue(new Error('competition not found')); }); + + it('shows the problem with a Try again, and turns Save and the boxes off', async () => { + const utils = await mountPanel(); + expect(utils.getByText('competition not found')).toBeTruthy(); + expect(saveButton(utils).disabled).toBe(true); + expect(utils.getByLabelText('1 player').disabled).toBe(true); + // Nothing to say about where the lineup was saved, when it was not read. + expect(utils.queryByText('No lineup saved yet')).toBeNull(); + expect(useButton(utils)).toBeNull(); + await click(saveButton(utils)); + expect(api.putMatchLineup).not.toHaveBeenCalled(); + }); + + it('reads the lineup again on Try again, and the form then works', async () => { + const utils = await mountPanel(); + api.fetchLineupInForce.mockResolvedValue(CARRIED); + + await click(tryAgain(utils)); + + expect(api.fetchLineupInForce).toHaveBeenCalledTimes(2); + expect(utils.queryByText('competition not found')).toBeNull(); + expect(utils.queryByRole('button', { name: 'Try again' })).toBeNull(); + expect(utils.getByLabelText('1 player').value).toBe('Aoki'); + expect(utils.getByLabelText('1 player').disabled).toBe(false); + expect(utils.getByText('Same as Pool A · Match 1')).toBeTruthy(); + await typeName(utils, 1, 'Mori'); + expect(saveButton(utils).disabled).toBe(false); + }); + + it('offers the draft kept for the lineup once Try again has read it, as a fresh open would', async () => { + api.fetchLineupInForce.mockResolvedValue(CARRIED); + const first = await mountPanel(); + await typeName(first, 1, 'Mori'); + first.unmount(); + const kept = sessionStorage.getItem(MATCH_KEY); + expect(kept).not.toBeNull(); + + api.fetchLineupInForce.mockRejectedValue(new Error('competition not found')); + const failed = await mountPanel(); + expect(sessionStorage.getItem(MATCH_KEY)).toBe(kept); + expect(failed.queryByTestId('match-lineup-draft-team-a')).toBeNull(); + + api.fetchLineupInForce.mockResolvedValue(CARRIED); + await click(tryAgain(failed)); + + expect(failed.getByLabelText('1 player').value).toBe('Mori'); + expect(failed.getByTestId('match-lineup-draft-team-a').textContent).toContain('Unsaved lineup changes restored'); + }); + + it('says a read the server never answered in a plain sentence, with a Try again, never the browser\'s "Failed to fetch"', async () => { + api.fetchLineupInForce.mockRejectedValue(new TypeError('Failed to fetch')); + const utils = await mountPanel(); + expect(utils.getByText(NO_ANSWER)).toBeTruthy(); + expect(utils.queryByText('Failed to fetch')).toBeNull(); + expect(tryAgain(utils)).toBeTruthy(); + expect(saveButton(utils).disabled).toBe(true); + }); + + it('keeps the problem when the second read fails too', async () => { + const utils = await mountPanel(); + api.fetchLineupInForce.mockRejectedValue(new Error('still down')); + await click(tryAgain(utils)); + expect(utils.getByText('still down')).toBeTruthy(); + expect(saveButton(utils).disabled).toBe(true); + }); + + it('puts Try again in a plain .btn, so the coarse-pointer floor reaches it through the class', async () => { + const utils = await mountPanel(); + const button = tryAgain(utils); + expect(button.className.split(/\s+/)).toContain('btn'); + expect(button.hasAttribute('style')).toBe(false); + expect(button.getAttribute('type')).toBe('button'); + }); +}); + +describe('the Lineups page, for a lineup that could not be read', () => { + it('a starting lineup: the problem stays, Save and the pickers are off, and Try again reads it', async () => { + api.fetchTeamLineup.mockRejectedValue(new Error('Failed to load lineup')); + const utils = await mountPage(); + expect(utils.getByText('Failed to load lineup')).toBeTruthy(); + expect(saveButton(utils).disabled).toBe(true); + expect(pageSelect(utils, 1).disabled).toBe(true); + await click(saveButton(utils)); + expect(api.putTeamLineup).not.toHaveBeenCalled(); + + api.fetchTeamLineup.mockResolvedValue(STARTING); + await click(tryAgain(utils)); + + expect(utils.queryByText('Failed to load lineup')).toBeNull(); + expect(pageSelect(utils, 1).disabled).toBe(false); + expect(pageSelect(utils, 1).value).toBe('mem-1'); + // Read, and unchanged: nothing to save. + expect(saveButton(utils).disabled).toBe(true); + await act(async () => { fireEvent.change(pageSelect(utils, 2), { target: { value: 'mem-4' } }); }); + expect(saveButton(utils).disabled).toBe(false); + }); + + it('a match: the same', async () => { + api.fetchLineupInForce.mockRejectedValue(new Error('competition not found')); + const utils = await mountPage(); + await chooseTarget(utils, 'Pool A-2'); + expect(utils.getByText('competition not found')).toBeTruthy(); + expect(saveButton(utils).disabled).toBe(true); + expect(utils.queryByTestId('lineup-source')).toBeNull(); + expect(useButton(utils)).toBeNull(); + + api.fetchLineupInForce.mockResolvedValue(CARRIED); + await click(tryAgain(utils)); + expect(utils.getByText('Same as Pool A · Match 1')).toBeTruthy(); + expect(saveButton(utils).disabled).toBe(true); + }); + + it('says a read the server never answered in a plain sentence, with a Try again, never the browser\'s "Failed to fetch"', async () => { + api.fetchTeamLineup.mockRejectedValue(new TypeError('Failed to fetch')); + const utils = await mountPage(); + expect(utils.getByText(NO_ANSWER)).toBeTruthy(); + expect(utils.queryByText('Failed to fetch')).toBeNull(); + expect(tryAgain(utils)).toBeTruthy(); + expect(saveButton(utils).disabled).toBe(true); + }); + + it('an untouched starting lineup that was read has nothing to save', async () => { + const utils = await mountPage(); + expect(saveButton(utils).disabled).toBe(true); + expect(saveButton(utils).getAttribute('title')).toBe('No changes to save'); + }); +}); + +describe('"Use the previous match\'s lineup" while a save of that lineup is still queued', () => { + beforeEach(() => { + api.fetchLineupInForce.mockResolvedValue(OWN); + api.queuedLineupSave.mockReturnValue(true); + }); + + it('is disabled in the panel, with the reason as a line (a title never shows on a touchscreen)', async () => { + const utils = await mountPanel(); + const button = useButton(utils); + expect(button.disabled).toBe(true); + expect(button.hasAttribute('title')).toBe(false); + const line = utils.getByText(QUEUED_LINE); + expect(line.getAttribute('role')).toBe('status'); + expect(api.queuedLineupSave).toHaveBeenCalledWith('comp-1', 'team-a', { matchId: 'Pool A-2' }); + await click(button); + expect(window.confirmDialog).not.toHaveBeenCalled(); + expect(api.deleteMatchLineup).not.toHaveBeenCalled(); + }); + + it('is disabled on the Lineups page too', async () => { + const utils = await mountPage(); + await chooseTarget(utils, 'Pool A-2'); + expect(useButton(utils).disabled).toBe(true); + expect(utils.getByText(QUEUED_LINE)).toBeTruthy(); + await click(useButton(utils)); + expect(window.confirmDialog).not.toHaveBeenCalled(); + }); + + it('says nothing about a queued save on a lineup that is not the match\'s own', async () => { + api.fetchLineupInForce.mockResolvedValue(CARRIED); + const utils = await mountPanel(); + expect(utils.queryByText(QUEUED_LINE)).toBeNull(); + }); + + it('is enabled again, and the line goes, once the save has gone out', async () => { + const listeners = new Set(); + window.subscribeUnsentWrites = (fn) => { listeners.add(fn); fn(); return () => listeners.delete(fn); }; + const utils = await mountPanel(); + expect(useButton(utils).disabled).toBe(true); + + api.queuedLineupSave.mockReturnValue(false); + await act(async () => { listeners.forEach((fn) => fn()); }); + + expect(useButton(utils).disabled).toBe(false); + expect(utils.queryByText(QUEUED_LINE)).toBeNull(); + }); +}); + +describe('a removal whose re-read fails', () => { + async function removeInPanel() { + api.fetchLineupInForce.mockResolvedValueOnce(OWN).mockRejectedValue(new Error('network down')); + const utils = await mountPanel(); + await typeName(utils, 1, 'Mori'); + expect(sessionStorage.getItem(MATCH_KEY)).not.toBeNull(); + await click(useButton(utils)); + return utils; + } + + it('leaves the panel with an empty, unread form: not the removed lineup, and nothing to save', async () => { + const utils = await removeInPanel(); + + expect(api.deleteMatchLineup).toHaveBeenCalledWith('comp-1', 'team-a', 'Pool A-2', 'pw'); + expect(utils.getByText(REMOVED_UNREAD)).toBeTruthy(); + expect(utils.queryByText('Lineup for this match')).toBeNull(); + expect(useButton(utils)).toBeNull(); + expect(utils.getByLabelText('1 player').value).toBe(''); + expect(utils.getByLabelText('1 player').disabled).toBe(true); + expect(saveButton(utils).disabled).toBe(true); + // What was typed was made against the lineup that went. + expect(sessionStorage.getItem(MATCH_KEY)).toBeNull(); + expect(utils.queryByRole('button', { name: 'Discard' })).toBeNull(); + }); + + it('shows what the match now carries once Try again reads it', async () => { + const utils = await removeInPanel(); + api.fetchLineupInForce.mockResolvedValue(CARRIED); + + await click(tryAgain(utils)); + + expect(utils.queryByText(REMOVED_UNREAD)).toBeNull(); + expect(utils.getByText('Same as Pool A · Match 1')).toBeTruthy(); + expect(utils.getByLabelText('1 player').value).toBe('Aoki'); + expect(saveButton(utils).disabled).toBe(true); + }); + + it('does the same on the Lineups page', async () => { + api.fetchLineupInForce.mockResolvedValueOnce(OWN).mockRejectedValue(new Error('network down')); + const utils = await mountPage(); + await chooseTarget(utils, 'Pool A-2'); + await act(async () => { fireEvent.change(pageSelect(utils, 1), { target: { value: 'mem-4' } }); }); + + await click(useButton(utils)); + + expect(utils.getByText(REMOVED_UNREAD)).toBeTruthy(); + expect(utils.queryByText('Lineup for this match')).toBeNull(); + expect(pageSelect(utils, 1).value).toBe(''); + expect(pageSelect(utils, 1).disabled).toBe(true); + expect(saveButton(utils).disabled).toBe(true); + expect(sessionStorage.getItem(MATCH_KEY)).toBeNull(); + }); +}); + +describe('the confirm of "Use the previous match\'s lineup"', () => { + it('names the team and the match, the first-match case and the unsaved changes it discards', async () => { + api.fetchLineupInForce.mockResolvedValue(OWN); + const utils = await mountPage(); + await chooseTarget(utils, 'Pool A-2'); + + await click(useButton(utils)); + + const { message, confirmLabel } = window.confirmDialog.mock.calls[0][0]; + expect(message).toContain('Use the lineup Team A had before Pool A · Match 3?'); + expect(message).toContain('so Team A carries the lineup of its previous match, or its starting lineup if this is its first match.'); + expect(message).toContain('Later matches that have no lineup of their own follow too.'); + expect(message).toContain('Unsaved changes here are discarded.'); + expect(confirmLabel).toBe('Use previous lineup'); + }); +}); + +describe('"Not restored, the lineup changed since"', () => { + async function openWithStaleDraft() { + const first = await mountPanel(); + await typeName(first, 1, 'Mori'); + first.unmount(); + api.fetchLineupInForce.mockResolvedValue(SAVED_ELSEWHERE); + const again = await mountPanel(); + expect(again.getByTestId('match-lineup-draft-team-a').textContent).toBe('Not restored, the lineup changed since: Mori'); + return again; + } + + it('stays when the operator changes the lineup, even to a name it lists', async () => { + const utils = await openWithStaleDraft(); + await typeName(utils, 2, 'Mori'); + expect(utils.getByTestId('match-lineup-draft-team-a').textContent).toBe('Not restored, the lineup changed since: Mori'); + }); + + it('goes once the operator saves it', async () => { + const utils = await openWithStaleDraft(); + await typeName(utils, 2, 'Mori'); + await click(saveButton(utils)); + expect(api.putMatchLineup).toHaveBeenCalledTimes(1); + expect(utils.queryByTestId('match-lineup-draft-team-a')).toBeNull(); + }); + + it('stays while the operator has done nothing to the lineup', async () => { + const utils = await openWithStaleDraft(); + await act(async () => { await Promise.resolve(); }); + expect(utils.getByTestId('match-lineup-draft-team-a')).toBeTruthy(); + }); +}); + +describe('a lineup change announced for the competition', () => { + const CHANGED_ELSEWHERE = lineupFor({ + matchId: 'Pool A-2', sourceMatchId: 'Pool A-2', + positions: { 1: 'Mori', 2: 'Sato', 3: 'Ito' }, memberIds: { 1: 'mem-4', 2: 'mem-2', 3: 'mem-3' }, + }); + const announce = () => act(async () => { + window.dispatchEvent(new CustomEvent('lineup-updated', { detail: { competitionId: 'comp-1' } })); + }); + const nameBox = (utils, position) => utils.getByLabelText(`${position} player`); + + it('shows in the at-court panel while it is untouched', async () => { + const utils = await mountPanel(); + expect(nameBox(utils, 1).value).toBe('Aoki'); + api.fetchLineupInForce.mockResolvedValue(CHANGED_ELSEWHERE); + + await announce(); + + expect(nameBox(utils, 1).value).toBe('Mori'); + expect(saveButton(utils).disabled, 'what was read is not a change to save').toBe(true); + expect(utils.getByTestId('match-lineup-source-team-a').textContent).toBe('Lineup for this match'); + }); + + it('is left out of an at-court panel the operator has edited', async () => { + const utils = await mountPanel(); + await typeName(utils, 2, 'Kato'); + api.fetchLineupInForce.mockResolvedValue(CHANGED_ELSEWHERE); + + await announce(); + + expect(api.fetchLineupInForce).toHaveBeenCalledTimes(1); + expect(nameBox(utils, 2).value).toBe('Kato'); + expect(nameBox(utils, 1).value).toBe('Aoki'); + }); + + it('shows on the Lineups page while it is untouched', async () => { + const utils = await mountPage(); + await chooseTarget(utils, 'Pool A-2'); + expect(pageSelect(utils, 1).value).toBe('mem-1'); + api.fetchLineupInForce.mockResolvedValue(CHANGED_ELSEWHERE); + + await announce(); + + expect(pageSelect(utils, 1).value).toBe('mem-4'); + }); + + it('is left out of a Lineups page the operator has edited', async () => { + const utils = await mountPage(); + await chooseTarget(utils, 'Pool A-2'); + await act(async () => { fireEvent.change(pageSelect(utils, 2), { target: { value: 'mem-4' } }); }); + api.fetchLineupInForce.mockResolvedValue(CHANGED_ELSEWHERE); + + const reads = api.fetchLineupInForce.mock.calls.length; + + await announce(); + + expect(api.fetchLineupInForce, 'nothing is read over an edit').toHaveBeenCalledTimes(reads); + expect(pageSelect(utils, 2).value).toBe('mem-4'); + expect(pageSelect(utils, 1).value).toBe('mem-1'); + }); +}); + +describe('the source line, on both editors', () => { + it('is rendered by the same component: one class for the emphasis, no inline size on either', async () => { + api.fetchLineupInForce.mockResolvedValue(OWN); + const panel = await mountPanel(); + const fromPanel = panel.getByTestId('match-lineup-source-team-a'); + panel.unmount(); + const page = await mountPage(); + await chooseTarget(page, 'Pool A-2'); + const fromPage = page.getByTestId('lineup-source'); + + expect(fromPanel.className).toBe(fromPage.className); + expect(fromPanel.className.split(/\s+/)).toContain('lineup-source__label--own'); + expect(fromPanel.hasAttribute('style')).toBe(false); + expect(fromPage.hasAttribute('style')).toBe(false); + expect(fromPanel.closest('.lineup-source')).not.toBeNull(); + expect(fromPage.closest('.lineup-source')).not.toBeNull(); + }); +}); + +describe('the at-court panel and the matches it names a carried lineup from', () => { + const TOURNAMENT = { competitions: [COMP] }; + async function mountWholePanel() { + let utils; + await act(async () => { + utils = render(); + }); + await act(async () => { await Promise.resolve(); }); + return utils; + } + + it('builds the competition\'s matches to name a lineup carried from another match', async () => { + window.compMatches = vi.fn(() => PANEL_MATCHES); + const utils = await mountWholePanel(); + // Both sides carry the lineup of Team A's earlier match. + expect(utils.getAllByText('Same as Pool A · Match 1').length).toBe(2); + expect(window.compMatches).toHaveBeenCalled(); + expect(window.compMatches.mock.calls.every(([comp]) => comp.id === 'comp-1')).toBe(true); + }); + + it('builds nothing when no side carries a lineup from another match', async () => { + window.compMatches = vi.fn(() => PANEL_MATCHES); + api.fetchLineupInForce.mockResolvedValue(OWN); + const utils = await mountWholePanel(); + expect(utils.getAllByText('Lineup for this match').length).toBeGreaterThan(0); + expect(window.compMatches).not.toHaveBeenCalled(); + }); +}); 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 new file mode 100644 index 000000000..0bc8a6fe0 --- /dev/null +++ b/web-mobile/js/__tests__/render/lineup_editors_save_only_changes.render.test.jsx @@ -0,0 +1,1086 @@ +// What a Save carries in both lineup editors (the at-court panel's +// MatchLineupSideEditor and the Lineups page), in a real DOM: the positions the +// operator changed and nothing else (operator decision 2026-10-07, "Only changed +// positions"). The server puts them on the lineup it holds when the save arrives, so a +// change another device made to a position the operator left alone stays; the stand-in +// for the server here (lineupPutStub) does exactly that. +// +// - the PUT carries the changed positions alone, named in `changed`, a cleared one as +// its empty name; no read of the lineup is made for it, so a Save with no connection is +// built, and queued, like any other; +// - what the server answers, the whole lineup it holds, is what the editor shows after +// the Save, a position left alone that another device changed included; +// - an untouched position is never re-resolved or minted, even when it names a member +// this editor's list does not hold (one another device created); +// - a lineup that would field one member twice is refused before anything is written, +// naming the position that was already there (the one the operator did not change; of +// two they changed, the one they picked the member for, not the box they typed the name +// into), when the form shows it; when another device made it so meanwhile the server +// refuses it, and the editor shows the refusal and keeps the operator's change; +// - the panel refuses such a lineup before its resolver renames or mints for a +// typed name, and hands the resolver the ids of the lineup as the form shows it; for a +// typed name it counts the member the resolver will place (the member the name +// belongs to, else the unnamed member the position holds), so a Save the resolver +// would place without a clash is not refused; +// - while a Save waits for its write nothing that changes the lineup or the team's +// members is usable, and Save waits while one of those is out; +// - a lineup followed from another device is shown with the team's members read +// again, so a member created elsewhere is not shown as an empty slot; a change +// announced makes an edited form read them too, so a name typed for a member created +// elsewhere is placed by its id, not minted a second time. A Save asks the server for +// neither the lineup nor the members. + +import React from 'react'; +import { render, act, fireEvent, within } from '@testing-library/react'; +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { answered, namedLater } from '../helpers/team_members.js'; +import { lineupPutStub } from '../helpers/lineup_server.js'; +import { QUEUED_NOTICE, QUEUED_UNSAVED_NOTICE } from '../../write_result.jsx'; + +const SQUAD = [ + { id: 'mem-1', index: 1, name: 'Aoki' }, + { id: 'mem-2', index: 2, name: 'Sato' }, + { id: 'mem-3', index: 3, name: 'Ito' }, + { id: 'mem-4', index: 4, name: 'Mori' }, +]; +// A member another device created after this editor read the team's members. +const ZED = { id: 'mem-9', index: 5, name: 'Zed' }; +const KATO = { id: 'mem-5', index: 6, name: 'Kato' }; +const NAMES = { positions: { 1: 'Aoki', 2: 'Sato', 3: 'Ito' }, memberIds: { 1: 'mem-1', 2: 'mem-2', 3: 'mem-3' } }; +const lineupFor = (extra) => ({ teamId: 'team-a', competitionId: 'comp-1', ...NAMES, saved: true, ...extra }); +const STARTING = lineupFor({ round: 0, sourceRound: 0 }); +const CARRIED = lineupFor({ matchId: 'Pool A-0', sourceMatchId: 'Pool A-0' }); +// What the server holds once another device has set position 2 to a member this +// editor has never heard of. +const KATO_AT_2 = { positions: { 1: 'Aoki', 2: 'Kato', 3: 'Ito' }, memberIds: { 1: 'mem-1', 2: 'mem-5', 3: 'mem-3' } }; +const ZED_AT_3 = { positions: { 1: 'Aoki', 2: 'Sato', 3: 'Zed' }, memberIds: { 1: 'mem-1', 2: 'mem-2', 3: 'mem-9' } }; +// A member nobody has named yet (a reserve slot), and a lineup that places it unnamed. +const BLANK = { id: 'mem-6', index: 6, name: '' }; +const MORI_AT_1 = { positions: { 1: 'Mori', 2: 'Sato', 3: 'Ito' }, memberIds: { 1: 'mem-4', 2: 'mem-2', 3: 'mem-3' } }; +const BLANK_AT_3 = { positions: { 1: 'Aoki', 2: 'Sato', 3: '' }, memberIds: { 1: 'mem-1', 2: 'mem-2', 3: 'mem-6' } }; +const BLANK_AT_1 = { positions: { 1: '', 2: 'Sato', 3: 'Ito' }, memberIds: { 1: 'mem-6', 2: 'mem-2', 3: 'mem-3' } }; +// A team just drawn: three members nobody has named yet, and nothing in the lineup. +const FRESH = [1, 2, 3].map((index) => ({ id: `mem-${index}`, index, name: '' })); +const EMPTY = { positions: {}, memberIds: {} }; +// The member of a fixture by its id: what the server answers a write of a member with is +// that member, named as asked, with the stamp it gave the write. +const memberById = (id) => [...SQUAD, ZED, KATO, BLANK].find((m) => m.id === id) || { id, index: 0 }; +const renamedAs = (id, name) => answered(memberById(id), { name }); + +const A = { id: 'team-a', name: 'Team A' }; +const B = { id: 'team-b', name: 'Team B' }; +const C = { id: 'team-c', name: 'Team C' }; +const match = (id, sideA, sideB) => ({ id, status: 'scheduled', sideA, sideB }); +const POOL_MATCHES = [match('Pool A-0', A, B), match('Pool A-1', B, C), match('Pool A-2', A, C)]; +const COMP = { id: 'comp-1', name: 'Team Event', kind: 'team', format: 'pools', status: 'active', teamSize: 3, players: [A, B, C] }; +// A team member's name can be cleared until the competition has started; a drawn one has its matches. +const DRAWN_COMP = { ...COMP, status: 'draw-ready' }; +const DRAFT_KEY = 'bc.lineupDraft.v1:comp-1:team-a:'; +const PANEL_MATCHES = POOL_MATCHES.map((m) => ({ ...m, compId: 'comp-1', phase: 'pool', poolName: 'Pool A' })); +const PANEL_MATCH = PANEL_MATCHES[2]; + +let AdminTeamLineupsList; +let MatchLineupSideEditor; +let realResolver; +let resolver; +let saved; +let api; +let showToast; +// What the server holds for the match and for the team's starting lineup. A save lands its +// changed positions on these and the stand-in answers the whole lineup (lineupPutStub); a test +// sets them to what another device saved (serverHolds, startingHolds). +let serverMatch; +let serverStarting; + +beforeEach(async () => { + sessionStorage.clear(); + saved = { + API: window.API, confirmDialog: window.confirmDialog, + subscribeUnsentWrites: window.subscribeUnsentWrites, subscribeSyncStatus: window.subscribeSyncStatus, + compMatches: window.compMatches, + }; + window.confirmDialog = vi.fn().mockResolvedValue(true); + window.compMatches = () => []; + showToast = vi.fn(); + serverMatch = { positions: { ...NAMES.positions }, memberIds: { ...NAMES.memberIds } }; + serverStarting = { positions: { ...NAMES.positions }, memberIds: { ...NAMES.memberIds } }; + api = { + fetchTeamLineup: vi.fn().mockResolvedValue(STARTING), + fetchLineupInForce: vi.fn().mockResolvedValue(CARRIED), + fetchSquads: vi.fn().mockResolvedValue({ 'team-a': SQUAD }), + putTeamLineup: lineupPutStub(serverStarting), + putMatchLineup: lineupPutStub(serverMatch), + addTeamMember: vi.fn().mockImplementation((_c, _t, name) => Promise.resolve(answered({ id: 'mem-minted', index: 6 }, { name }))), + renameTeamMember: vi.fn().mockImplementation((_c, _t, id, name) => Promise.resolve(renamedAs(id, name))), + queuedLineupSave: vi.fn().mockReturnValue(false), + }; + window.API = api; + delete window.subscribeUnsentWrites; + delete window.subscribeSyncStatus; + ({ AdminTeamLineupsList } = await import('../../admin_lineup.jsx')); + ({ MatchLineupSideEditor } = await import('../../admin_schedule_lineup.jsx')); + // The panel resolves a typed name through window.AdminLineupHelpers at call time: + // wrapped, so a test can see which positions it was asked about. + realResolver ||= window.AdminLineupHelpers.resolveMemberIdsForPositions; + resolver = vi.fn(realResolver); + window.AdminLineupHelpers.resolveMemberIdsForPositions = resolver; +}); + +afterEach(() => { + window.AdminLineupHelpers.resolveMemberIdsForPositions = realResolver; + for (const [key, value] of Object.entries(saved)) { + if (value === undefined) delete window[key]; else window[key] = value; + } +}); + +function deferred() { + let resolve; + const promise = new Promise((res) => { resolve = res; }); + return { promise, resolve }; +} + +async function flush() { + for (let i = 0; i < 6; i += 1) { + await act(async () => { await Promise.resolve(); }); + } +} + +async function mountPage(comp = COMP) { + let utils; + await act(async () => { + utils = render(); + }); + await flush(); + return utils; +} + +async function mountPanel(comp = COMP) { + let utils; + await act(async () => { + utils = render( + + ); + }); + await flush(); + return utils; +} + +async function chooseTarget(utils, value) { + await act(async () => { fireEvent.change(utils.getByLabelText('Lineup for'), { target: { value } }); }); + await flush(); +} + +async function click(button) { + await act(async () => { fireEvent.click(button); }); + await flush(); +} + +async function typeName(utils, position, name) { + await act(async () => { + const input = utils.getByLabelText(`${position} player`); + fireEvent.focus(input); + fireEvent.change(input, { target: { value: name } }); + fireEvent.keyDown(input, { key: 'Enter' }); + }); +} + +const pick = (utils, position, memberId) => act(async () => { + fireEvent.change(utils.getByTestId(`lineup-position-${position}`), { target: { value: memberId } }); +}); + +// The panel's name box offers the team's members in a list: opens it and takes one. +async function pickFromList(utils, position, name) { + const row = utils.getByTestId(`match-lineup-pos-team-a-${position}`); + await act(async () => { fireEvent.focus(within(row).getByLabelText(`${position} player`)); }); + await act(async () => { fireEvent.click(within(row).getByText(name)); }); +} + +const panelRow = (utils, position) => within(utils.getByTestId(`match-lineup-pos-team-a-${position}`)); +const memberRow = (utils, id) => within(utils.getByTestId(`squad-member-${id}`)); +// A draft the editor left behind: the lineup as read, and the operator's name at position 1. +const keepDraft = (suffix, baseline) => sessionStorage.setItem(`${DRAFT_KEY}${suffix}`, JSON.stringify({ + savedAt: Date.now(), + baseline, + current: { positions: { ...baseline.positions, 1: 'Mori' }, memberIds: { ...baseline.memberIds, 1: 'mem-4' } }, +})); + +const saveButton = (utils) => utils.getByRole('button', { name: /^Save lineup$/ }); +const savingButton = (utils) => utils.getByRole('button', { name: 'Saving…' }); +const announce = () => act(async () => { + window.dispatchEvent(new CustomEvent('lineup-updated', { detail: { competitionId: 'comp-1' } })); +}); +// What a save sent: the positions and ids it carries, and the list that names the changed ones. +const putOf = (call) => ({ positions: call[3], memberIds: call[5] }); +const changedOf = (call) => call[6]; +// What the stub answers a read of the match's lineup with, and of the starting lineup. +const matchLineup = (stored) => lineupFor({ ...stored, matchId: 'Pool A-2', sourceMatchId: 'Pool A-0' }); +const startingLineup = (stored) => lineupFor({ ...stored, round: 0, sourceRound: 0 }); +// The lineup the server holds, as another device left it: what the editor reads from now +// on and what a save lands on. +const serverHolds = (stored) => { + api.fetchLineupInForce.mockResolvedValue(matchLineup(stored)); + Object.assign(serverMatch, { positions: { ...stored.positions }, memberIds: { ...stored.memberIds } }); +}; +const startingHolds = (stored) => { + api.fetchTeamLineup.mockResolvedValue(startingLineup(stored)); + Object.assign(serverStarting, { positions: { ...stored.positions }, memberIds: { ...stored.memberIds } }); +}; +// Another device changed the lineup after the editor read it: the editor does not know yet. +const changedElsewhere = (stored) => Object.assign(serverMatch, { positions: { ...stored.positions }, memberIds: { ...stored.memberIds } }); +const changedElsewhereStarting = (stored) => Object.assign(serverStarting, { positions: { ...stored.positions }, memberIds: { ...stored.memberIds } }); + +describe('the at-court panel', () => { + it('carries the changed position alone, naming it, and shows what the server answered: a position another device changed meanwhile stays and shows', async () => { + const utils = await mountPanel(); + await typeName(utils, 1, 'Mori'); + // Another device changed position 2 after the panel read the lineup. + changedElsewhere(KATO_AT_2); + + await click(saveButton(utils)); + + expect(api.fetchLineupInForce, 'no read of the lineup is made for the Save').toHaveBeenCalledTimes(1); + expect(api.fetchSquads, 'nor of the members, a typed name included').toHaveBeenCalledTimes(1); + expect(api.putMatchLineup).toHaveBeenCalledTimes(1); + expect(putOf(api.putMatchLineup.mock.calls[0])).toEqual({ positions: { 1: 'Mori' }, memberIds: { 1: 'mem-4' } }); + expect(changedOf(api.putMatchLineup.mock.calls[0])).toEqual(['1']); + expect(api.addTeamMember, 'the position left alone is not minted').not.toHaveBeenCalled(); + // The server's answer is what is shown afterwards: the lineup it holds, Kato included. + expect(utils.getByLabelText('2 player').value).toBe('Kato'); + expect(utils.getByLabelText('1 player').value).toBe('Mori'); + expect(saveButton(utils).disabled, 'saved: nothing left to save').toBe(true); + }); + + it('never resolves or mints a position left alone, even when it names a member this list has never heard of', async () => { + serverHolds(ZED_AT_3); + const utils = await mountPanel(); + expect(utils.getByLabelText('3 player').value).toBe('Zed'); + await typeName(utils, 1, 'Mori'); + + await click(saveButton(utils)); + + expect(putOf(api.putMatchLineup.mock.calls[0])).toEqual({ positions: { 1: 'Mori' }, memberIds: { 1: 'mem-4' } }); + expect(api.addTeamMember).not.toHaveBeenCalled(); + expect(api.renameTeamMember).not.toHaveBeenCalled(); + const asked = resolver.mock.calls.flatMap(([, , positions]) => Object.keys(positions)); + expect(asked, 'only the position the operator typed over was resolved').toEqual(['1']); + expect(utils.getByLabelText('3 player').value, 'and the position left alone is still shown').toBe('Zed'); + }); + + it('still resolves a name typed over a position, with the id that position holds now', async () => { + const utils = await mountPanel(); + await typeName(utils, 2, 'Kato'); + + await click(saveButton(utils)); + + expect(api.addTeamMember).toHaveBeenCalledTimes(1); + expect(api.addTeamMember.mock.calls[0][2]).toBe('Kato'); + const asked = resolver.mock.calls.flatMap(([, , positions]) => Object.keys(positions)); + expect(asked).toEqual(['2']); + expect(putOf(api.putMatchLineup.mock.calls[0])).toEqual({ positions: { 2: 'Kato' }, memberIds: { 2: 'mem-minted' } }); + }); + + it('places a name typed over an edited form by the id of a member another device created: the members are read again when the change is announced, edits or not', async () => { + const utils = await mountPanel(); + await typeName(utils, 1, 'Mori'); + // Meanwhile another device creates Kato and announces a lineup change. + api.fetchSquads.mockResolvedValue({ 'team-a': [...SQUAD, KATO] }); + await announce(); + await typeName(utils, 2, 'Kato'); + + await click(saveButton(utils)); + + expect(api.addTeamMember, 'Kato is the member another device created, not a new one').not.toHaveBeenCalled(); + expect(putOf(api.putMatchLineup.mock.calls[0])).toEqual({ + positions: { 1: 'Mori', 2: 'Kato' }, + memberIds: { 1: 'mem-4', 2: 'mem-5' }, + }); + expect(changedOf(api.putMatchLineup.mock.calls[0])).toEqual(['1', '2']); + }); + + it('does not put the list from before a mint back: the members read of a followed lineup, still out when Save is tapped, is ended with it', async () => { + const utils = await mountPanel(); + // A lineup change from another device starts a follow while the form is untouched. + const followed = deferred(); + api.fetchSquads.mockReturnValue(followed.promise); + await announce(); + await typeName(utils, 1, 'Kato'); + const write = deferred(); + api.putMatchLineup.mockReturnValue(write.promise); + + await click(saveButton(utils)); + + expect(api.addTeamMember, 'the typed name is minted').toHaveBeenCalledTimes(1); + expect(api.putMatchLineup).toHaveBeenCalledTimes(1); + + // The follow's read answers while the write is out, with the list from before the mint. + await act(async () => { followed.resolve({ 'team-a': SQUAD }); }); + await flush(); + await act(async () => { + write.resolve({ positions: { 1: 'Kato', 2: 'Sato', 3: 'Ito' }, memberIds: { 1: 'mem-minted', 2: 'mem-2', 3: 'mem-3' } }); + }); + await flush(); + + expect(panelRow(utils, 1).getByText('Slot 6'), 'the member the Save minted is still listed').toBeTruthy(); + expect(panelRow(utils, 1).getByRole('button', { name: 'Rename 1 player' })).toBeTruthy(); + }); + + it('places the member a rename gave its new name by its id when that name is typed: a read of the team\'s members begun before the rename, answering after it with the old name, does not undo it', async () => { + const utils = await mountPanel(); + // A lineup change from another device starts a read of the team's members, still out + // when the rename lands (the lineup it reads already carries the new spelling). + const older = deferred(); + api.fetchSquads.mockReturnValueOnce(older.promise); + api.fetchLineupInForce.mockResolvedValue(matchLineup({ positions: { ...NAMES.positions, 3: 'Itoh' } })); + await announce(); + // The operator renames Ito (position 3) to Itoh; the server answers the member with the + // stamp of that write, and no read is made after it. + await click(panelRow(utils, 3).getByRole('button', { name: 'Rename 3 player' })); + await act(async () => { fireEvent.change(panelRow(utils, 3).getByLabelText('Rename 3 player'), { target: { value: 'Itoh' } }); }); + await click(panelRow(utils, 3).getByRole('button', { name: 'Save' })); + expect(api.renameTeamMember).toHaveBeenCalledWith('comp-1', 'team-a', 'mem-3', 'Itoh', 'pw'); + // The read begun before it answers now, with the old name. + await act(async () => { older.resolve({ 'team-a': SQUAD }); }); + await flush(); + // The operator takes Itoh off position 3 and types the new name there again. + await act(async () => { fireEvent.click(within(utils.getByTestId('match-lineup-pos-team-a-3')).getByRole('button', { name: 'Clear player' })); }); + await typeName(utils, 3, 'Itoh'); + + await click(saveButton(utils)); + + expect(api.addTeamMember, 'Itoh is the member that exists, not a new one').not.toHaveBeenCalled(); + expect(api.renameTeamMember, 'and no slot is named for it: only the rename the operator made').toHaveBeenCalledTimes(1); + expect(putOf(api.putMatchLineup.mock.calls[0])).toEqual({ positions: { 3: 'Itoh' }, memberIds: { 3: 'mem-3' } }); + }); + + it('clears a position the operator cleared, whatever another device put there meanwhile', async () => { + const utils = await mountPanel(); + await act(async () => { fireEvent.click(within(utils.getByTestId('match-lineup-pos-team-a-1')).getByRole('button', { name: 'Clear player' })); }); + changedElsewhere({ + positions: { 1: 'Oda', 2: 'Kato', 3: 'Ito' }, memberIds: { 1: 'mem-8', 2: 'mem-5', 3: 'mem-3' }, + }); + + await click(saveButton(utils)); + + // The cleared position goes as its empty name, which the server needs to be there. + expect(putOf(api.putMatchLineup.mock.calls[0])).toEqual({ positions: { 1: '' } }); + expect(changedOf(api.putMatchLineup.mock.calls[0])).toEqual(['1']); + expect(serverMatch.positions).toEqual({ 2: 'Kato', 3: 'Ito' }); + expect(utils.getByLabelText('1 player').value).toBe(''); + expect(utils.getByLabelText('2 player').value).toBe('Kato'); + }); + + describe('with no connection', () => { + it('is built as it is online, naming the changed position, and queued, and the operator\'s change stays on screen', async () => { + const utils = await mountPanel(); + await typeName(utils, 1, 'Mori'); + api.fetchLineupInForce.mockRejectedValue(new TypeError('Failed to fetch')); + api.putMatchLineup.mockResolvedValue({ queued: true }); + + await click(saveButton(utils)); + + expect(api.fetchLineupInForce, 'nothing is read for the Save').toHaveBeenCalledTimes(1); + expect(api.putMatchLineup.mock.calls[0][3]).toEqual({ 1: 'Mori' }); + expect(changedOf(api.putMatchLineup.mock.calls[0])).toEqual(['1']); + // The held-write words every editor uses, with the pending icon: not a success. + expect(showToast).toHaveBeenCalledWith(QUEUED_NOTICE, 'pending'); + expect(utils.getByLabelText('1 player').value).toBe('Mori'); + expect(saveButton(utils).disabled, 'still unsaved').toBe(false); + }); + + it('says to keep the page open when the browser could not store the save', async () => { + const utils = await mountPanel(); + await typeName(utils, 1, 'Mori'); + api.putMatchLineup.mockResolvedValue({ queued: true, persisted: false }); + + await click(saveButton(utils)); + + expect(showToast).toHaveBeenCalledWith(QUEUED_UNSAVED_NOTICE, 'pending'); + }); + }); + + it('turns Save and the boxes off while the save is out, and is done once it lands', async () => { + const utils = await mountPanel(); + await typeName(utils, 1, 'Mori'); + const write = deferred(); + api.putMatchLineup.mockReturnValue(write.promise); + + await click(saveButton(utils)); + + expect(savingButton(utils).disabled).toBe(true); + expect(utils.getByLabelText('2 player').disabled).toBe(true); + expect(api.putMatchLineup).toHaveBeenCalledTimes(1); + + await act(async () => { write.resolve({ positions: { 1: 'Mori', 2: 'Sato', 3: 'Ito' }, memberIds: { 1: 'mem-4', 2: 'mem-2', 3: 'mem-3' } }); }); + await flush(); + + expect(saveButton(utils).disabled, 'saved: nothing left to save').toBe(true); + }); + + it('shows the server\'s refusal when another device placed the member meanwhile, and keeps the operator\'s change', async () => { + const utils = await mountPanel(); + await typeName(utils, 1, 'Mori'); + // Meanwhile another device put Mori at position 2: put on that lineup, the change fields him twice. + changedElsewhere({ + positions: { 1: 'Aoki', 2: 'Mori', 3: 'Ito' }, memberIds: { 1: 'mem-1', 2: 'mem-4', 3: 'mem-3' }, + }); + + await click(saveButton(utils)); + + expect(api.putMatchLineup).toHaveBeenCalledTimes(1); + expect(utils.getByText(/is at positions/), 'the server\'s refusal is shown').toBeTruthy(); + expect(saveButton(utils).disabled, 'the operator can still fix it and save').toBe(false); + expect(utils.getByLabelText('1 player').value, 'the operator\'s own change stays').toBe('Mori'); + }); + + it('names the position that was already there, not the one the operator typed into, when a typed name is a member stored elsewhere', async () => { + serverHolds(MORI_AT_1); + const utils = await mountPanel(); + await typeName(utils, 2, 'Mori'); + + await click(saveButton(utils)); + + expect(utils.getByText('Mori is already at Position 1.')).toBeTruthy(); + expect(api.putMatchLineup).not.toHaveBeenCalled(); + expect(api.addTeamMember).not.toHaveBeenCalled(); + }); + + it('names the position the operator picked the member for, not the box they typed the same name into', async () => { + const utils = await mountPanel(); + await pickFromList(utils, 2, 'Mori'); + await typeName(utils, 1, 'Mori'); + + await click(saveButton(utils)); + + expect(utils.getByText('Mori is already at Position 2.')).toBeTruthy(); + expect(api.putMatchLineup).not.toHaveBeenCalled(); + expect(api.addTeamMember).not.toHaveBeenCalled(); + expect(api.renameTeamMember).not.toHaveBeenCalled(); + }); + + it('names Senpo, not the Jiho the operator typed into, on a five-person team whose positions are named', async () => { + api.fetchLineupInForce.mockResolvedValue(matchLineup({ + positions: { senpo: 'Mori', jiho: 'Sato', chuken: 'Ito' }, memberIds: { senpo: 'mem-4', jiho: 'mem-2', chuken: 'mem-3' }, + })); + const utils = await mountPanel({ ...COMP, teamSize: 5 }); + await typeName(utils, 'Jiho', 'Mori'); + + await click(saveButton(utils)); + + expect(utils.getByText('Mori is already at Senpo.')).toBeTruthy(); + expect(api.putMatchLineup).not.toHaveBeenCalled(); + }); + + describe('a duplicate that is there before the typed names are resolved', () => { + it('takes the refusal down once the operator changes a position: the next Save judges the lineup again', async () => { + const utils = await mountPanel(); + await pickFromList(utils, 2, 'Mori'); + await typeName(utils, 3, 'Mori'); + await click(saveButton(utils)); + expect(utils.getByText('Mori is already at Position 2.')).toBeTruthy(); + + await typeName(utils, 3, 'Ito'); + + expect(utils.queryByText('Mori is already at Position 2.')).toBeNull(); + }); + + it('is refused before anything is minted or renamed when a typed name is a member the lineup holds elsewhere', async () => { + serverHolds(MORI_AT_1); + const utils = await mountPanel(); + await typeName(utils, 2, 'Mori'); + await typeName(utils, 3, 'Kato'); + + await click(saveButton(utils)); + + expect(utils.getByText('Mori is already at Position 1.')).toBeTruthy(); + expect(resolver, 'the names are not resolved for a Save that is refused').not.toHaveBeenCalled(); + expect(api.addTeamMember, 'Kato is not minted').not.toHaveBeenCalled(); + expect(api.renameTeamMember).not.toHaveBeenCalled(); + expect(api.putMatchLineup).not.toHaveBeenCalled(); + }); + + it('counts the member a typed name belongs to, not the blank member the position held: another device moved that blank member, and the Save goes through', async () => { + api.fetchSquads.mockResolvedValue({ 'team-a': [...SQUAD, BLANK] }); + serverHolds(BLANK_AT_3); + const utils = await mountPanel(); + // The operator types Mori, who is on the team, over the unnamed member at position 3 ... + await typeName(utils, 3, 'Mori'); + // ... while another device moves that unnamed member to position 1. + changedElsewhere({ positions: { 1: '', 2: 'Sato', 3: '' }, memberIds: { 1: 'mem-6', 2: 'mem-2' } }); + + await click(saveButton(utils)); + + expect(api.renameTeamMember, 'Mori is not a new name for the unnamed member').not.toHaveBeenCalled(); + expect(api.addTeamMember).not.toHaveBeenCalled(); + expect(putOf(api.putMatchLineup.mock.calls[0])).toEqual({ positions: { 3: 'Mori' }, memberIds: { 3: 'mem-4' } }); + expect(serverMatch.positions).toEqual({ 1: '', 2: 'Sato', 3: 'Mori' }); + expect(serverMatch.memberIds).toEqual({ 1: 'mem-6', 2: 'mem-2', 3: 'mem-4' }); + }); + + it('does not take the member a typed-over position held for one it still holds: Sato moved elsewhere, and Kato typed over his old position, saves', async () => { + const utils = await mountPanel(); + await typeName(utils, 2, 'Kato'); + // Another device moved Sato to position 1. + changedElsewhere({ + positions: { 1: 'Sato', 2: 'Ito', 3: 'Aoki' }, memberIds: { 1: 'mem-2', 2: 'mem-3', 3: 'mem-1' }, + }); + + await click(saveButton(utils)); + + expect(api.addTeamMember).toHaveBeenCalledTimes(1); + expect(putOf(api.putMatchLineup.mock.calls[0])).toEqual({ positions: { 2: 'Kato' }, memberIds: { 2: 'mem-minted' } }); + expect(serverMatch.positions).toEqual({ 1: 'Sato', 2: 'Kato', 3: 'Aoki' }); + }); + }); + + // A name nobody on the team has is one member once the resolver has named or minted it + // for the first position, and it finds that member again for the second. Typed at two + // positions it is one member fielded twice, so the Save is refused before the first is + // named or minted, not after. + describe('a name that is new to the team typed at two positions', () => { + const refusedBeforeAnythingIsWritten = () => { + expect(api.addTeamMember, 'nobody is minted for it').not.toHaveBeenCalled(); + expect(api.renameTeamMember, 'no slot is named for it').not.toHaveBeenCalled(); + expect(api.putMatchLineup).not.toHaveBeenCalled(); + expect(resolver, 'the names are not resolved for a Save that is refused').not.toHaveBeenCalled(); + }; + + it('is refused before a member is minted for it', async () => { + const utils = await mountPanel(); + await typeName(utils, 2, 'Kato'); + await typeName(utils, 3, 'Kato'); + + await click(saveButton(utils)); + + expect(utils.getByText('Kato is already at Position 2.')).toBeTruthy(); + refusedBeforeAnythingIsWritten(); + }); + + it('is refused before the unnamed member of a slot is named for it, naming the earlier position', async () => { + api.fetchSquads.mockResolvedValue({ 'team-a': FRESH }); + serverHolds(EMPTY); + const utils = await mountPanel(); + await typeName(utils, 1, 'Ito'); + await typeName(utils, 3, 'Ito'); + + await click(saveButton(utils)); + + expect(utils.getByText('Ito is already at Position 1.')).toBeTruthy(); + refusedBeforeAnythingIsWritten(); + expect(saveButton(utils).disabled, 'the operator can still fix it and save').toBe(false); + }); + + it('is refused when the unnamed member the first position holds is the one that would carry it', async () => { + api.fetchSquads.mockResolvedValue({ 'team-a': [...SQUAD, BLANK] }); + serverHolds(BLANK_AT_1); + const utils = await mountPanel(); + await typeName(utils, 1, 'Kato'); + await typeName(utils, 3, 'Kato'); + + await click(saveButton(utils)); + + expect(utils.getByText('Kato is already at Position 1.')).toBeTruthy(); + refusedBeforeAnythingIsWritten(); + }); + + it('is one name however it is written, as the resolver reads it: case and accents do not make two', async () => { + api.fetchSquads.mockResolvedValue({ 'team-a': FRESH }); + serverHolds(EMPTY); + const utils = await mountPanel(); + await typeName(utils, 1, 'Ito'); + await typeName(utils, 3, 'ITÔ'); + + await click(saveButton(utils)); + + expect(utils.getByText('Ito is already at Position 1.')).toBeTruthy(); + refusedBeforeAnythingIsWritten(); + }); + + it('is not held against two different new names: each is written for its own position', async () => { + api.fetchSquads.mockResolvedValue({ 'team-a': FRESH }); + serverHolds(EMPTY); + const utils = await mountPanel(); + await typeName(utils, 1, 'Ito'); + await typeName(utils, 3, 'Itoh'); + + await click(saveButton(utils)); + + expect(api.renameTeamMember).toHaveBeenCalledTimes(2); + expect(api.addTeamMember).not.toHaveBeenCalled(); + expect(putOf(api.putMatchLineup.mock.calls[0])).toEqual({ + positions: { 1: 'Ito', 3: 'Itoh' }, + memberIds: { 1: 'mem-1', 3: 'mem-3' }, + }); + expect(changedOf(api.putMatchLineup.mock.calls[0])).toEqual(['1', '3']); + }); + }); + + describe('the ids handed to the name resolver', () => { + // The seeded member for position 3 is unnamed; the lineup places it at position 2. + const SEEDED = [{ id: 'mem-1', index: 1, name: 'Aoki' }, { id: 'mem-2', index: 2, name: 'Sato' }, { id: 'mem-3', index: 3, name: '' }]; + + it('are the lineup as the form shows it, so a blank member placed at another position is not renamed for a name typed at its own position: that mints', async () => { + api.fetchSquads.mockResolvedValue({ 'team-a': SEEDED }); + serverHolds({ positions: { 1: 'Aoki', 2: '' }, memberIds: { 1: 'mem-1', 2: 'mem-3' } }); + const utils = await mountPanel(); + await typeName(utils, 3, 'Kato'); + + await click(saveButton(utils)); + + expect(resolver).toHaveBeenCalledTimes(1); + const [, , asked, , , idsGiven] = resolver.mock.calls[0]; + expect(asked).toEqual({ 3: 'Kato' }); + expect(idsGiven, 'what the lineup places counts').toEqual({ 1: 'mem-1', 2: 'mem-3', 3: '' }); + expect(api.renameTeamMember, 'the member at position 2 keeps its place and its blank name').not.toHaveBeenCalled(); + expect(api.addTeamMember).toHaveBeenCalledWith('comp-1', 'team-a', 'Kato', 'pw'); + expect(putOf(api.putMatchLineup.mock.calls[0])).toEqual({ positions: { 3: 'Kato' }, memberIds: { 3: 'mem-minted' } }); + }); + }); + + it('leaves a position that holds a member and no name alone, with its id: it is not sent, and the server keeps it', async () => { + api.fetchSquads.mockResolvedValue({ 'team-a': [...SQUAD, BLANK] }); + serverHolds(BLANK_AT_3); + const utils = await mountPanel(); + await typeName(utils, 1, 'Mori'); + + await click(saveButton(utils)); + + expect(putOf(api.putMatchLineup.mock.calls[0])).toEqual({ positions: { 1: 'Mori' }, memberIds: { 1: 'mem-4' } }); + expect(serverMatch.memberIds[3], 'the placement the Save left alone is still there').toBe('mem-6'); + expect(serverMatch.positions[3]).toBe(''); + }); + + describe('while a Save waits for its write', () => { + it('turns an open rename box and its buttons off, since a rename made then would be overwritten by the Save', async () => { + const utils = await mountPanel(); + await typeName(utils, 2, 'Mori'); + await click(panelRow(utils, 1).getByRole('button', { name: 'Rename 1 player' })); + const write = deferred(); + api.putMatchLineup.mockReturnValue(write.promise); + + await click(saveButton(utils)); + + expect(savingButton(utils).disabled).toBe(true); + expect(panelRow(utils, 1).getByLabelText('Rename 1 player').disabled).toBe(true); + expect(panelRow(utils, 1).getByRole('button', { name: 'Save' }).disabled, 'its own label does not say it is saving').toBe(true); + expect(panelRow(utils, 1).getByRole('button', { name: 'Cancel' }).disabled).toBe(true); + + await act(async () => { write.resolve({ positions: { 1: 'Aoki', 2: 'Mori', 3: 'Ito' }, memberIds: { 1: 'mem-1', 2: 'mem-4', 3: 'mem-3' } }); }); + await flush(); + expect(panelRow(utils, 1).getByLabelText('Rename 1 player').disabled, 'usable again once the Save is done').toBe(false); + }); + + it('turns the draft notice\'s Discard off', async () => { + keepDraft('match:Pool A-2', NAMES); + const utils = await mountPanel(); + expect(utils.getByText('Unsaved lineup changes restored')).toBeTruthy(); + const write = deferred(); + api.putMatchLineup.mockReturnValue(write.promise); + + await click(saveButton(utils)); + + expect(utils.getByRole('button', { name: 'Discard' }).disabled).toBe(true); + await act(async () => { write.resolve({ positions: { 1: 'Mori', 2: 'Sato', 3: 'Ito' }, memberIds: { 1: 'mem-4', 2: 'mem-2', 3: 'mem-3' } }); }); + await flush(); + }); + }); + + it('keeps Save off while a rename is out, and on again once it has landed', async () => { + const utils = await mountPanel(); + await typeName(utils, 2, 'Mori'); + await click(panelRow(utils, 1).getByRole('button', { name: 'Rename 1 player' })); + await act(async () => { fireEvent.change(panelRow(utils, 1).getByLabelText('Rename 1 player'), { target: { value: 'Aoki-san' } }); }); + const renamed = deferred(); + api.renameTeamMember.mockReturnValue(renamed.promise); + + await click(panelRow(utils, 1).getByRole('button', { name: 'Save' })); + + expect(api.renameTeamMember).toHaveBeenCalledTimes(1); + expect(saveButton(utils).disabled, 'a rename is out').toBe(true); + + await act(async () => { renamed.resolve(renamedAs('mem-1', 'Aoki-san')); }); + await flush(); + + expect(saveButton(utils).disabled, 'the rename has landed, and the lineup is still unsaved').toBe(false); + }); +}); + +describe('the Lineups page', () => { + it('a starting lineup: carries the changed position alone and shows what the server answered, a position another device changed meanwhile included', async () => { + api.fetchSquads.mockResolvedValue({ 'team-a': [...SQUAD, KATO] }); + const utils = await mountPage(); + await pick(utils, 1, 'mem-4'); + // Another device changed position 2 after the page read the lineup. + changedElsewhereStarting(KATO_AT_2); + + await click(saveButton(utils)); + + expect(api.fetchTeamLineup, 'the lineup is not read again for the Save').toHaveBeenCalledTimes(1); + expect(api.putTeamLineup).toHaveBeenCalledTimes(1); + const [, , round] = api.putTeamLineup.mock.calls[0]; + expect(round).toBe(0); + expect(putOf(api.putTeamLineup.mock.calls[0])).toEqual({ positions: { 1: 'Mori' }, memberIds: { 1: 'mem-4' } }); + expect(changedOf(api.putTeamLineup.mock.calls[0])).toEqual(['1']); + expect(utils.getByTestId('lineup-position-2').value, 'what the server answered is shown').toBe('mem-5'); + expect(saveButton(utils).disabled, 'saved: nothing left to save').toBe(true); + }); + + it('a match\'s lineup: the same', async () => { + api.fetchSquads.mockResolvedValue({ 'team-a': [...SQUAD, KATO] }); + const utils = await mountPage(); + await chooseTarget(utils, 'Pool A-2'); + await pick(utils, 1, 'mem-4'); + changedElsewhere(KATO_AT_2); + + await click(saveButton(utils)); + + expect(api.fetchLineupInForce, 'the lineup is not read again for the Save').toHaveBeenCalledTimes(1); + expect(api.putMatchLineup).toHaveBeenCalledTimes(1); + expect(putOf(api.putMatchLineup.mock.calls[0])).toEqual({ positions: { 1: 'Mori' }, memberIds: { 1: 'mem-4' } }); + expect(changedOf(api.putMatchLineup.mock.calls[0])).toEqual(['1']); + expect(utils.getByTestId('lineup-position-2').value).toBe('mem-5'); + }); + + it('clears a position the operator cleared, whatever another device put there meanwhile', async () => { + const utils = await mountPage(); + await pick(utils, 1, ''); + changedElsewhereStarting({ + positions: { 1: 'Oda', 2: 'Kato', 3: 'Ito' }, memberIds: { 1: 'mem-8', 2: 'mem-5', 3: 'mem-3' }, + }); + + await click(saveButton(utils)); + + // The cleared position goes as its empty name, which the server needs to be there. + expect(putOf(api.putTeamLineup.mock.calls[0])).toEqual({ positions: { 1: '' } }); + expect(changedOf(api.putTeamLineup.mock.calls[0])).toEqual(['1']); + expect(serverStarting.positions).toEqual({ 2: 'Kato', 3: 'Ito' }); + }); + + describe('with no connection', () => { + it('is built as it is online, naming the changed position, and queued, and the operator\'s change stays on screen', async () => { + const utils = await mountPage(); + await pick(utils, 1, 'mem-4'); + api.fetchTeamLineup.mockRejectedValue(new TypeError('Failed to fetch')); + api.putTeamLineup.mockResolvedValue({ queued: true }); + + await click(saveButton(utils)); + + expect(api.fetchTeamLineup, 'nothing is read for the Save').toHaveBeenCalledTimes(1); + expect(api.putTeamLineup.mock.calls[0][3]).toEqual({ 1: 'Mori' }); + expect(changedOf(api.putTeamLineup.mock.calls[0])).toEqual(['1']); + expect(showToast).toHaveBeenCalledWith(QUEUED_NOTICE, 'pending'); + expect(utils.getByTestId('lineup-position-1').value).toBe('mem-4'); + }); + }); + + it('turns Save and the pickers off while the save is out, and is done once it lands', async () => { + const utils = await mountPage(); + await pick(utils, 1, 'mem-4'); + const write = deferred(); + api.putTeamLineup.mockReturnValue(write.promise); + + await click(saveButton(utils)); + + expect(savingButton(utils).disabled).toBe(true); + expect(utils.getByTestId('lineup-position-2').disabled).toBe(true); + expect(api.putTeamLineup).toHaveBeenCalledTimes(1); + + await act(async () => { write.resolve({ positions: { 1: 'Mori', 2: 'Sato', 3: 'Ito' }, memberIds: { 1: 'mem-4', 2: 'mem-2', 3: 'mem-3' } }); }); + await flush(); + + expect(saveButton(utils).disabled, 'saved: nothing left to save').toBe(true); + expect(utils.getByTestId('lineup-position-2').disabled).toBe(false); + }); + + it('shows the server\'s refusal when another device placed the member meanwhile, and keeps the operator\'s change', async () => { + const utils = await mountPage(); + await pick(utils, 1, 'mem-4'); + // Meanwhile another device put Mori at position 2: put on that lineup, the change fields him twice. + changedElsewhereStarting({ + positions: { 1: 'Aoki', 2: 'Mori', 3: 'Ito' }, memberIds: { 1: 'mem-1', 2: 'mem-4', 3: 'mem-3' }, + }); + + await click(saveButton(utils)); + + expect(api.putTeamLineup).toHaveBeenCalledTimes(1); + expect(utils.getByText(/is at positions/), 'the server\'s refusal is shown').toBeTruthy(); + expect(saveButton(utils).disabled, 'the operator can still fix it and save').toBe(false); + expect(utils.getByTestId('lineup-position-1').value, 'the operator\'s own change stays').toBe('mem-4'); + }); + + it('takes the refusal down once the operator changes a position: the next Save judges the lineup again', async () => { + const utils = await mountPage(); + await pick(utils, 1, 'mem-4'); + changedElsewhereStarting({ + positions: { 1: 'Aoki', 2: 'Mori', 3: 'Ito' }, memberIds: { 1: 'mem-1', 2: 'mem-4', 3: 'mem-3' }, + }); + await click(saveButton(utils)); + expect(utils.getByText(/is at positions/)).toBeTruthy(); + + await pick(utils, 1, 'mem-1'); + + expect(utils.queryByText(/is at positions/)).toBeNull(); + }); + + // The pickers never offer a member the form holds at another position, but a lineup + // saved before that rule can hold one twice: the page refuses it before it asks the + // server, naming the position that was already there. + it('refuses a lineup that shows one member twice, naming the position that was already there, and writes nothing', async () => { + startingHolds({ + positions: { 1: 'Mori', 2: 'Mori', 3: 'Ito' }, memberIds: { 1: 'mem-4', 2: 'mem-4', 3: 'mem-3' }, + }); + const utils = await mountPage(); + await pick(utils, 3, 'mem-1'); + + await click(saveButton(utils)); + + expect(utils.getByText('Mori is already at Position 1.')).toBeTruthy(); + expect(api.putTeamLineup).not.toHaveBeenCalled(); + expect(saveButton(utils).disabled, 'the operator can still fix it and save').toBe(false); + }); + + 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. + const late = deferred(); + api.fetchSquads.mockReturnValue(late.promise); + await announce(); + // Meanwhile the operator adds Kato at position 3. + await pick(utils, 3, '__add__'); + await act(async () => { fireEvent.change(utils.getByLabelText('New member name for 3'), { target: { value: 'Kato' } }); }); + await click(utils.getByRole('button', { name: 'Add' })); + expect(utils.getByTestId('squad-member-mem-minted')).toBeTruthy(); + + await act(async () => { late.resolve({ 'team-a': SQUAD }); }); + await flush(); + + expect(utils.getByTestId('squad-member-mem-minted'), 'the member added is still listed').toBeTruthy(); + expect(utils.getByTestId('lineup-position-3').value).toBe('mem-minted'); + }); + + it('a match\'s lineup: is the match\'s own once saved, where the lineup read came from another match', async () => { + const utils = await mountPage(); + await chooseTarget(utils, 'Pool A-2'); + await pick(utils, 1, ''); + expect(utils.getByTestId('lineup-source').textContent).not.toMatch(/Lineup for this match/); + + await click(saveButton(utils)); + + expect(utils.getByTestId('lineup-source').textContent).toMatch(/Lineup for this match/); + expect(utils.getByTestId('lineup-position-1').value, 'the position the operator cleared stays cleared').toBe(''); + }); +}); + +describe('the Lineups page while a Save waits for its write', () => { + const openAddRow = async (utils, position) => { + await pick(utils, position, '__add__'); + await act(async () => { fireEvent.change(utils.getByLabelText(`New member name for ${position}`), { target: { value: 'Kato' } }); }); + }; + + it('turns off what would change the lineup or its members: Rename, Clear name, an open add row, an open rename box and Discard', async () => { + keepDraft('start', NAMES); + api.clearTeamMember = vi.fn().mockImplementation((_c, _t, id) => Promise.resolve(renamedAs(id, ''))); + const utils = await mountPage(DRAWN_COMP); + expect(utils.getByText('Unsaved lineup changes restored')).toBeTruthy(); + await openAddRow(utils, 3); + await click(memberRow(utils, 'mem-2').getByRole('button', { name: 'Rename' })); + const write = deferred(); + api.putTeamLineup.mockReturnValue(write.promise); + + await click(saveButton(utils)); + + expect(savingButton(utils).disabled).toBe(true); + expect(memberRow(utils, 'mem-1').getByRole('button', { name: 'Rename' }).disabled).toBe(true); + expect(memberRow(utils, 'mem-1').getByRole('button', { name: 'Clear name' }).disabled).toBe(true); + const addInput = utils.getByLabelText('New member name for 3'); + expect(addInput.disabled).toBe(true); + expect(within(addInput.parentElement).getByRole('button', { name: 'Add' }).disabled).toBe(true); + expect(within(addInput.parentElement).getByRole('button', { name: 'Cancel' }).disabled).toBe(true); + expect(utils.getByLabelText('Rename Sato').disabled).toBe(true); + expect(memberRow(utils, 'mem-2').getByRole('button', { name: 'Save' }).disabled, 'its own label does not say it is saving').toBe(true); + expect(memberRow(utils, 'mem-2').getByRole('button', { name: 'Cancel' }).disabled).toBe(true); + expect(utils.getByRole('button', { name: 'Discard' }).disabled).toBe(true); + + await act(async () => { write.resolve({ positions: { 1: 'Mori', 2: 'Sato', 3: 'Ito' }, memberIds: { 1: 'mem-4', 2: 'mem-2', 3: 'mem-3' } }); }); + await flush(); + expect(memberRow(utils, 'mem-1').getByRole('button', { name: 'Rename' }).disabled, 'usable again once the Save is done').toBe(false); + expect(utils.getByLabelText('Rename Sato').disabled).toBe(false); + }); + + it('turns them off for a removal as well: nothing that changes the lineup or its members while it is out', async () => { + api.fetchLineupInForce.mockResolvedValueOnce({ ...CARRIED, sourceMatchId: 'Pool A-2' }).mockResolvedValue(CARRIED); + const removal = deferred(); + api.deleteMatchLineup = vi.fn().mockReturnValue(removal.promise); + const utils = await mountPage(DRAWN_COMP); + await chooseTarget(utils, 'Pool A-2'); + await click(utils.getByRole('button', { name: /Use the previous match/ })); + + expect(api.deleteMatchLineup).toHaveBeenCalledTimes(1); + expect(memberRow(utils, 'mem-1').getByRole('button', { name: 'Rename' }).disabled).toBe(true); + expect(memberRow(utils, 'mem-1').getByRole('button', { name: 'Clear name' }).disabled).toBe(true); + + await act(async () => { removal.resolve(true); }); + await flush(); + expect(memberRow(utils, 'mem-1').getByRole('button', { name: 'Rename' }).disabled).toBe(false); + }); + + it('keeps Save off while a rename is out, and on again once it has landed', async () => { + const utils = await mountPage(); + await pick(utils, 1, 'mem-4'); + await click(memberRow(utils, 'mem-2').getByRole('button', { name: 'Rename' })); + await act(async () => { fireEvent.change(utils.getByLabelText('Rename Sato'), { target: { value: 'Sato-san' } }); }); + const renamed = deferred(); + api.renameTeamMember.mockReturnValue(renamed.promise); + + await click(memberRow(utils, 'mem-2').getByRole('button', { name: 'Save' })); + + expect(api.renameTeamMember).toHaveBeenCalledTimes(1); + expect(saveButton(utils).disabled, 'a rename is out').toBe(true); + + await act(async () => { renamed.resolve(renamedAs('mem-2', 'Sato-san')); }); + await flush(); + + expect(saveButton(utils).disabled, 'the rename has landed, and the lineup is still unsaved').toBe(false); + }); + + it('keeps Save off while a clear is out, and on again once it has landed', async () => { + const cleared = deferred(); + api.clearTeamMember = vi.fn().mockReturnValue(cleared.promise); + const utils = await mountPage(DRAWN_COMP); + await pick(utils, 1, 'mem-4'); + + await click(memberRow(utils, 'mem-2').getByRole('button', { name: 'Clear name' })); + + expect(api.clearTeamMember).toHaveBeenCalledTimes(1); + expect(saveButton(utils).disabled, 'a clear is out').toBe(true); + + await act(async () => { cleared.resolve(renamedAs('mem-2', '')); }); + await flush(); + + expect(saveButton(utils).disabled).toBe(false); + }); + + it('keeps Save off while an add is out, and on again once it has landed', async () => { + const added = deferred(); + api.addTeamMember.mockReturnValue(added.promise); + const utils = await mountPage(); + await pick(utils, 1, 'mem-4'); + await openAddRow(utils, 3); + + await click(utils.getByRole('button', { name: 'Add' })); + + expect(api.addTeamMember).toHaveBeenCalledTimes(1); + expect(saveButton(utils).disabled, 'an add is out').toBe(true); + + await act(async () => { added.resolve(answered({ id: 'mem-minted', index: 6 }, { name: 'Kato' })); }); + await flush(); + + expect(saveButton(utils).disabled).toBe(false); + expect(utils.getByTestId('lineup-position-3').value, 'the member added is the one placed').toBe('mem-minted'); + }); +}); + +// Both editors load the team's members once, when they open. A member another +// device creates afterwards (a name typed on a team score sheet mints one and +// saves the lineup) arrives in a followed lineup with an id the list lacks. +describe('a lineup followed from another device that names a member created there', () => { + const FOLLOWED = { positions: { 1: 'Aoki', 2: 'Zed', 3: 'Ito' }, memberIds: { 1: 'mem-1', 2: 'mem-9', 3: 'mem-3' } }; + + it('the Lineups page shows that member at the position, not an empty slot', async () => { + const utils = await mountPage(); + await chooseTarget(utils, 'Pool A-2'); + api.fetchSquads.mockResolvedValue({ 'team-a': [...SQUAD, ZED] }); + api.fetchLineupInForce.mockResolvedValue(matchLineup(FOLLOWED)); + + await announce(); + + const select = utils.getByTestId('lineup-position-2'); + expect(select.value).toBe('mem-9'); + expect(select.selectedOptions[0].textContent).toContain('Zed'); + expect(utils.getByTestId('squad-member-mem-9'), 'and lists them with the team\'s members').toBeTruthy(); + }); + + it('the at-court panel shows that member\'s number and its Rename', async () => { + const utils = await mountPanel(); + api.fetchSquads.mockResolvedValue({ 'team-a': [...SQUAD, ZED] }); + api.fetchLineupInForce.mockResolvedValue(matchLineup(FOLLOWED)); + + await announce(); + + expect(utils.getByLabelText('2 player').value).toBe('Zed'); + const row = utils.getByTestId('match-lineup-pos-team-a-2'); + expect(within(row).getByText('Slot 5')).toBeTruthy(); + expect(within(row).getByRole('button', { name: 'Rename 2 player' })).toBeTruthy(); + }); + + it('is still shown, with the list as it was, when the members cannot be read again', async () => { + const utils = await mountPage(); + await chooseTarget(utils, 'Pool A-2'); + api.fetchSquads.mockRejectedValue(new Error('offline')); + api.fetchLineupInForce.mockResolvedValue(matchLineup({ + positions: { 1: 'Mori', 2: 'Sato', 3: 'Ito' }, memberIds: { 1: 'mem-4', 2: 'mem-2', 3: 'mem-3' }, + })); + + await announce(); + + expect(utils.getByTestId('lineup-position-1').value, 'the lineup that was read').toBe('mem-4'); + expect(utils.getByTestId('squad-member-mem-1'), 'the list that was read before').toBeTruthy(); + expect(utils.queryByTestId('lineup-members-unavailable')).toBeNull(); + }); +}); + +// Each write of a member is stamped by the server, and the Lineups page, like the panel, +// keeps the newest copy of a member by that stamp: a read begun before a rename that +// answers after it holds the older copy and undoes nothing, and a rename another device +// makes after it shows. No read is made after the page's own write: the server announces it. +describe('the Lineups page: the newest copy of a member stands', () => { + const rowText = (utils, id) => utils.getByTestId(`squad-member-${id}`).textContent; + const renameSato = async (utils) => { + await click(memberRow(utils, 'mem-2').getByRole('button', { name: 'Rename' })); + await act(async () => { fireEvent.change(utils.getByLabelText('Rename Sato'), { target: { value: 'Sato-san' } }); }); + await click(memberRow(utils, 'mem-2').getByRole('button', { name: 'Save' })); + }; + + it('a read begun before a rename, answering after it with the old name, does not undo it', async () => { + const utils = await mountPage(); + const older = deferred(); + api.fetchSquads.mockReturnValueOnce(older.promise); + await announce(); + await renameSato(utils); + expect(rowText(utils, 'mem-2')).toContain('Sato-san'); + + await act(async () => { older.resolve({ 'team-a': SQUAD }); }); + await flush(); + + expect(rowText(utils, 'mem-2')).toContain('Sato-san'); + expect(api.fetchSquads, 'the read made as the page opened and the announced one, none after the rename').toHaveBeenCalledTimes(2); + }); + + it('a rename another device makes after the page\'s own write shows', async () => { + const utils = await mountPage(); + await renameSato(utils); + const mine = await api.renameTeamMember.mock.results[0].value; + api.fetchSquads.mockResolvedValue({ 'team-a': SQUAD.map((m) => (m.id === 'mem-2' ? namedLater(mine, 'Sato-kun') : m)) }); + + await announce(); + + expect(rowText(utils, 'mem-2')).toContain('Sato-kun'); + expect(rowText(utils, 'mem-2')).not.toContain('Sato-san'); + }); + + it('an own rename\'s answer older than the list already shown does not undo the list', async () => { + const utils = await mountPage(); + const answer = deferred(); + const mine = renamedAs('mem-2', 'Sato-san'); + api.renameTeamMember.mockReturnValue(answer.promise); + await renameSato(utils); + api.fetchSquads.mockResolvedValue({ 'team-a': SQUAD.map((m) => (m.id === 'mem-2' ? namedLater(mine, 'Sato-kun') : m)) }); + await announce(); + + // The row is still saving its own rename; once that lands, it shows the list's name. + await act(async () => { answer.resolve(mine); }); + await flush(); + + expect(rowText(utils, 'mem-2')).toContain('Sato-kun'); + expect(rowText(utils, 'mem-2')).not.toContain('Sato-san'); + }); +}); diff --git a/web-mobile/js/__tests__/render/lineup_editors_wait_for_members.render.test.jsx b/web-mobile/js/__tests__/render/lineup_editors_wait_for_members.render.test.jsx new file mode 100644 index 000000000..9c176f544 --- /dev/null +++ b/web-mobile/js/__tests__/render/lineup_editors_wait_for_members.render.test.jsx @@ -0,0 +1,400 @@ +// A name typed in a lineup editor is resolved against the team's members, so the +// editor waits for them to have been read before it resolves one (useLineupForm's +// waitForMembers), in a real DOM. Resolved against none: +// +// - a new name is minted where the member's seeded slot is free, instead of naming +// that slot (the unnamed member whose number the position was shown with), and +// - the name of a member the team already has is refused by the server as a second +// member of that name, so the lineup is saved with the name and no member id. +// +// The wait is bounded by the deadline of any request, and ends when the read fails: +// the editor then goes on without the members, as it always did, and says so. Once a +// list has been shown nothing waits, whatever read is out. +// +// Both surfaces that resolve a typed name are covered: the at-court panel's Save and +// the Lineups page's add row. + +import React from 'react'; +import { render, act, fireEvent } from '@testing-library/react'; +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { FETCH_TIMEOUT_MS } from '../../write_result.jsx'; +import { answered } from '../helpers/team_members.js'; +import { lineupPutStub } from '../helpers/lineup_server.js'; + +const TEAM_MEMBERS = [ + { id: 'mem-1', index: 1, name: 'Aoki' }, + { id: 'mem-2', index: 2, name: 'Sato' }, + { id: 'mem-3', index: 3, name: 'Ito' }, + { id: 'mem-4', index: 4, name: 'Mori' }, +]; +// A team just drawn: three members nobody has named yet, and nothing in the lineup. +const FRESH = [1, 2, 3].map((index) => ({ id: `mem-${index}`, index, name: '' })); +const NAMES = { positions: { 1: 'Aoki', 2: 'Sato', 3: 'Ito' }, memberIds: { 1: 'mem-1', 2: 'mem-2', 3: 'mem-3' } }; +const EMPTY = { positions: {}, memberIds: {} }; +const lineupFor = (extra) => ({ teamId: 'team-a', competitionId: 'comp-1', ...NAMES, saved: true, ...extra }); +const STARTING = lineupFor({ round: 0, sourceRound: 0 }); +const matchLineup = (stored) => lineupFor({ ...stored, matchId: 'Pool A-2', sourceMatchId: 'Pool A-0' }); +const startingLineup = (stored) => lineupFor({ ...stored, round: 0, sourceRound: 0 }); + +const A = { id: 'team-a', name: 'Team A' }; +const B = { id: 'team-b', name: 'Team B' }; +const C = { id: 'team-c', name: 'Team C' }; +const match = (id, sideA, sideB) => ({ id, status: 'scheduled', sideA, sideB }); +const POOL_MATCHES = [match('Pool A-0', A, B), match('Pool A-1', B, C), match('Pool A-2', A, C)]; +const COMP = { id: 'comp-1', name: 'Team Event', kind: 'team', format: 'pools', status: 'active', teamSize: 3, players: [A, B, C] }; +const PANEL_MATCHES = POOL_MATCHES.map((m) => ({ ...m, compId: 'comp-1', phase: 'pool', poolName: 'Pool A' })); +const PANEL_MATCH = PANEL_MATCHES[2]; + +let AdminTeamLineupsList; +let MatchLineupSideEditor; +let saved; +let api; +let showToast; + +beforeEach(async () => { + sessionStorage.clear(); + saved = { + API: window.API, confirmDialog: window.confirmDialog, + subscribeUnsentWrites: window.subscribeUnsentWrites, subscribeSyncStatus: window.subscribeSyncStatus, + compMatches: window.compMatches, + }; + window.confirmDialog = vi.fn().mockResolvedValue(true); + window.compMatches = () => []; + showToast = vi.fn(); + api = { + fetchTeamLineup: vi.fn().mockResolvedValue(STARTING), + fetchLineupInForce: vi.fn().mockResolvedValue(lineupFor({ matchId: 'Pool A-0', sourceMatchId: 'Pool A-0' })), + fetchSquads: vi.fn().mockResolvedValue({ 'team-a': TEAM_MEMBERS }), + // A save names the positions it changed, and the server answers the lineup it holds then. + putTeamLineup: lineupPutStub({ positions: { ...NAMES.positions }, memberIds: { ...NAMES.memberIds } }), + putMatchLineup: lineupPutStub({ positions: { ...NAMES.positions }, memberIds: { ...NAMES.memberIds } }), + // The server answers a member write with the member it holds, stamped. + addTeamMember: vi.fn().mockImplementation((_c, _t, name) => Promise.resolve(answered({ id: 'mem-minted', index: 6 }, { name }))), + renameTeamMember: vi.fn().mockImplementation((_c, _t, id, name) => Promise.resolve(answered({ id, index: Number(id.slice(4)) }, { name }))), + queuedLineupSave: vi.fn().mockReturnValue(false), + }; + window.API = api; + delete window.subscribeUnsentWrites; + delete window.subscribeSyncStatus; + ({ AdminTeamLineupsList } = await import('../../admin_lineup.jsx')); + ({ MatchLineupSideEditor } = await import('../../admin_schedule_lineup.jsx')); +}); + +afterEach(() => { + for (const [key, value] of Object.entries(saved)) { + if (value === undefined) delete window[key]; else window[key] = value; + } +}); + +function deferred() { + let resolve; + let reject; + const promise = new Promise((res, rej) => { resolve = res; reject = rej; }); + return { promise, resolve, reject }; +} + +async function flush() { + for (let i = 0; i < 6; i += 1) { + await act(async () => { await Promise.resolve(); }); + } +} + +async function mountPanel() { + let utils; + await act(async () => { + utils = render( + + ); + }); + await flush(); + return utils; +} + +async function mountPage() { + let utils; + await act(async () => { + utils = render(); + }); + await flush(); + return utils; +} + +async function click(button) { + await act(async () => { fireEvent.click(button); }); + await flush(); +} + +async function typeName(utils, position, name) { + await act(async () => { + const input = utils.getByLabelText(`${position} player`); + fireEvent.focus(input); + fireEvent.change(input, { target: { value: name } }); + fireEvent.keyDown(input, { key: 'Enter' }); + }); +} + +const saveButton = (utils) => utils.getByRole('button', { name: /^Save lineup$/ }); +const savingButton = (utils) => utils.getByRole('button', { name: 'Saving…' }); +const putOf = (call) => ({ positions: call[3], memberIds: call[5] }); +const announce = () => act(async () => { + window.dispatchEvent(new CustomEvent('lineup-updated', { detail: { competitionId: 'comp-1' } })); +}); +const warning = (utils) => utils.getByTestId('match-lineup-warning-team-a').textContent; + +describe('the at-court panel: a Save with a typed name waits for the team\'s members', () => { + it('places a member the team has by its id when the name is typed before the members were read', async () => { + const members = deferred(); + api.fetchSquads.mockReturnValue(members.promise); + const utils = await mountPanel(); + await typeName(utils, 1, 'Mori'); + + await click(saveButton(utils)); + + expect(api.addTeamMember, 'nothing is added while the members are out').not.toHaveBeenCalled(); + expect(api.putMatchLineup, 'nor written').not.toHaveBeenCalled(); + expect(savingButton(utils).disabled, 'the Save is waiting for the members').toBe(true); + + await act(async () => { members.resolve({ 'team-a': TEAM_MEMBERS }); }); + await flush(); + + expect(api.addTeamMember, 'Mori is the member the team has, not a new one').not.toHaveBeenCalled(); + expect(api.renameTeamMember).not.toHaveBeenCalled(); + expect(putOf(api.putMatchLineup.mock.calls[0])).toEqual({ positions: { 1: 'Mori' }, memberIds: { 1: 'mem-4' } }); + }); + + it('names the unnamed member seeded for the position when a new name is typed before the members were read', async () => { + api.fetchLineupInForce.mockResolvedValue(matchLineup(EMPTY)); + const members = deferred(); + api.fetchSquads.mockReturnValue(members.promise); + const utils = await mountPanel(); + await typeName(utils, 1, 'Mori'); + + await click(saveButton(utils)); + expect(api.addTeamMember).not.toHaveBeenCalled(); + expect(api.renameTeamMember).not.toHaveBeenCalled(); + await act(async () => { members.resolve({ 'team-a': FRESH }); }); + await flush(); + + expect(api.renameTeamMember, 'the slot the position was shown with is named').toHaveBeenCalledWith('comp-1', 'team-a', 'mem-1', 'Mori', 'pw'); + expect(api.addTeamMember, 'no member is added beside it').not.toHaveBeenCalled(); + expect(putOf(api.putMatchLineup.mock.calls[0])).toEqual({ positions: { 1: 'Mori' }, memberIds: { 1: 'mem-1' } }); + }); + + // The Save is judged on the members once they were read, before the resolver names or + // mints anything: against none, Kato would be minted for a Save the typed Mori refuses. + it('refuses a lineup that would field a member twice before anything is minted, judged on the members once they were read', async () => { + api.fetchLineupInForce.mockResolvedValue(matchLineup({ + positions: { 1: 'Mori', 2: 'Sato', 3: 'Ito' }, memberIds: { 1: 'mem-4', 2: 'mem-2', 3: 'mem-3' }, + })); + const members = deferred(); + api.fetchSquads.mockReturnValue(members.promise); + const utils = await mountPanel(); + await typeName(utils, 2, 'Mori'); + await typeName(utils, 3, 'Kato'); + await click(saveButton(utils)); + + await act(async () => { members.resolve({ 'team-a': TEAM_MEMBERS }); }); + await flush(); + + expect(utils.getByText('Mori is already at Position 1.')).toBeTruthy(); + expect(api.addTeamMember, 'Kato is not minted for a Save that is refused').not.toHaveBeenCalled(); + expect(api.renameTeamMember).not.toHaveBeenCalled(); + expect(api.putMatchLineup).not.toHaveBeenCalled(); + }); + + it('does not wait for a Save with no typed name: a position that was picked or left alone needs no members', async () => { + const members = deferred(); + api.fetchSquads.mockReturnValue(members.promise); + const utils = await mountPanel(); + await act(async () => { fireEvent.click(utils.getAllByRole('button', { name: 'Clear player' })[0]); }); + + await click(saveButton(utils)); + + // The cleared position goes as its empty name, which the server needs to be there. + expect(putOf(api.putMatchLineup.mock.calls[0])).toEqual({ positions: { 1: '' } }); + }); + + it('goes on without the members when they could not be read, as it always did, and says so', async () => { + api.fetchSquads.mockRejectedValue(new TypeError('Failed to fetch')); + const utils = await mountPanel(); + await typeName(utils, 1, 'Mori'); + + await click(saveButton(utils)); + + expect(api.addTeamMember, 'the name is minted against no list, at once').toHaveBeenCalledTimes(1); + expect(putOf(api.putMatchLineup.mock.calls[0]).memberIds).toEqual({ 1: 'mem-minted' }); + expect(warning(utils)).toContain('team member list could not be loaded'); + }); + + it('goes on, and says so, when the read fails while the Save waits for it', async () => { + const members = deferred(); + api.fetchSquads.mockReturnValue(members.promise); + const utils = await mountPanel(); + await typeName(utils, 1, 'Mori'); + await click(saveButton(utils)); + expect(api.addTeamMember).not.toHaveBeenCalled(); + + await act(async () => { members.reject(new TypeError('Failed to fetch')); }); + await flush(); + + expect(api.addTeamMember).toHaveBeenCalledTimes(1); + expect(putOf(api.putMatchLineup.mock.calls[0]).memberIds).toEqual({ 1: 'mem-minted' }); + expect(warning(utils)).toContain('team member list could not be loaded'); + }); + + it('goes on, and says so, when the read is not answered by its deadline', async () => { + api.fetchSquads.mockReturnValue(new Promise(() => {})); + const utils = await mountPanel(); + await typeName(utils, 1, 'Mori'); + + vi.useFakeTimers(); + try { + await act(async () => { fireEvent.click(saveButton(utils)); }); + await act(async () => { vi.advanceTimersByTime(FETCH_TIMEOUT_MS - 1); }); + expect(api.addTeamMember, 'nothing is minted while the read may still answer').not.toHaveBeenCalled(); + await act(async () => { vi.advanceTimersByTime(2); }); + await flush(); + } finally { + vi.useRealTimers(); + } + + expect(api.addTeamMember).toHaveBeenCalledTimes(1); + expect(putOf(api.putMatchLineup.mock.calls[0]).memberIds).toEqual({ 1: 'mem-minted' }); + expect(warning(utils)).toContain('team member list could not be loaded'); + }); + + it('does not wait once a list has been shown, whatever read is out', async () => { + const utils = await mountPanel(); + // A lineup another device saved makes the editor read the members again; never answered. + api.fetchSquads.mockReturnValue(new Promise(() => {})); + await announce(); + await typeName(utils, 2, 'Kato'); + + await click(saveButton(utils)); + + expect(api.addTeamMember, 'Kato is minted against the list shown, at once').toHaveBeenCalledTimes(1); + expect(putOf(api.putMatchLineup.mock.calls[0]).memberIds).toEqual({ 2: 'mem-minted' }); + }); +}); + +describe('the Lineups page: an add row waits for the team\'s members', () => { + const addRow = async (utils, name) => { + await act(async () => { fireEvent.change(utils.getByTestId('lineup-position-1'), { target: { value: '__add__' } }); }); + await act(async () => { fireEvent.change(utils.getByLabelText('New member name for 1'), { target: { value: name } }); }); + }; + const addButton = (utils) => utils.getByRole('button', { name: /^(Add|Adding…)$/ }); + + it('selects a member the team has, once the members were read, instead of adding the name again', async () => { + const members = deferred(); + api.fetchSquads.mockReturnValue(members.promise); + const utils = await mountPage(); + await addRow(utils, 'Mori'); + + await click(addButton(utils)); + + expect(api.addTeamMember, 'nothing is added while the members are out').not.toHaveBeenCalled(); + expect(window.confirmDialog, 'nor asked').not.toHaveBeenCalled(); + expect(addButton(utils).textContent, 'the add waits for the members').toBe('Adding…'); + expect(addButton(utils).disabled).toBe(true); + + await act(async () => { members.resolve({ 'team-a': TEAM_MEMBERS }); }); + await flush(); + + expect(api.addTeamMember, 'Mori is the member the team has, not a new one').not.toHaveBeenCalled(); + expect(window.confirmDialog, 'selecting a member that exists asks nothing').not.toHaveBeenCalled(); + expect(utils.getByTestId('lineup-position-1').value).toBe('mem-4'); + }); + + it('names the unnamed member seeded for the position, once the members were read, instead of adding one', async () => { + api.fetchTeamLineup.mockResolvedValue(startingLineup(EMPTY)); + const members = deferred(); + api.fetchSquads.mockReturnValue(members.promise); + const utils = await mountPage(); + await addRow(utils, 'Mori'); + + await click(addButton(utils)); + expect(window.confirmDialog).not.toHaveBeenCalled(); + await act(async () => { members.resolve({ 'team-a': FRESH }); }); + await flush(); + + expect(window.confirmDialog).toHaveBeenCalledTimes(1); + expect(window.confirmDialog.mock.calls[0][0].message, 'the slot is named').not.toContain('as a new member of'); + expect(api.renameTeamMember).toHaveBeenCalledWith('comp-1', 'team-a', 'mem-1', 'Mori', 'pw'); + expect(api.addTeamMember).not.toHaveBeenCalled(); + expect(utils.getByTestId('lineup-position-1').value).toBe('mem-1'); + }); + + it('judges the placements as they are once the wait is over: a position cleared meanwhile no longer holds the member', async () => { + const members = deferred(); + api.fetchSquads.mockReturnValue(members.promise); + const utils = await mountPage(); + // Sato holds position 2 of the starting lineup, and is typed for position 1. + await addRow(utils, 'Sato'); + await click(addButton(utils)); + await act(async () => { fireEvent.change(utils.getByTestId('lineup-position-2'), { target: { value: '' } }); }); + + await act(async () => { members.resolve({ 'team-a': TEAM_MEMBERS }); }); + await flush(); + + expect(utils.queryByText(/is already at/), 'Sato is no longer at position 2').toBeNull(); + expect(api.addTeamMember).not.toHaveBeenCalled(); + expect(utils.getByTestId('lineup-position-1').value).toBe('mem-2'); + }); + + it('adds the member when the members could not be read, as it always did', async () => { + api.fetchSquads.mockRejectedValue(new TypeError('Failed to fetch')); + const utils = await mountPage(); + await addRow(utils, 'Mori'); + + await click(addButton(utils)); + + expect(window.confirmDialog.mock.calls[0][0].message).toContain('as a new member of'); + expect(api.addTeamMember).toHaveBeenCalledWith('comp-1', 'team-a', 'Mori', 'pw'); + expect(utils.getByTestId('lineup-position-1').value).toBe('mem-minted'); + }); + + it('adds the member when the read fails while the add waits for it', async () => { + const members = deferred(); + api.fetchSquads.mockReturnValue(members.promise); + const utils = await mountPage(); + await addRow(utils, 'Mori'); + await click(addButton(utils)); + expect(api.addTeamMember).not.toHaveBeenCalled(); + + await act(async () => { members.reject(new TypeError('Failed to fetch')); }); + await flush(); + + expect(api.addTeamMember).toHaveBeenCalledWith('comp-1', 'team-a', 'Mori', 'pw'); + expect(utils.getByTestId('lineup-position-1').value, 'the member is placed and the add row closed').toBe('mem-minted'); + }); + + it('adds the member when the read is not answered by its deadline', async () => { + api.fetchSquads.mockReturnValue(new Promise(() => {})); + const utils = await mountPage(); + await addRow(utils, 'Mori'); + + vi.useFakeTimers(); + try { + await act(async () => { fireEvent.click(addButton(utils)); }); + await act(async () => { vi.advanceTimersByTime(FETCH_TIMEOUT_MS - 1); }); + expect(api.addTeamMember).not.toHaveBeenCalled(); + await act(async () => { vi.advanceTimersByTime(2); }); + await flush(); + } finally { + vi.useRealTimers(); + } + + expect(api.addTeamMember).toHaveBeenCalledWith('comp-1', 'team-a', 'Mori', 'pw'); + }); + + it('does not wait once a list has been shown, whatever read is out', async () => { + const utils = await mountPage(); + api.fetchSquads.mockReturnValue(new Promise(() => {})); + await announce(); + await addRow(utils, 'Kato'); + + await click(addButton(utils)); + + expect(api.addTeamMember, 'Kato is added against the list shown, at once').toHaveBeenCalledWith('comp-1', 'team-a', 'Kato', 'pw'); + }); +}); diff --git a/web-mobile/js/__tests__/render/lineup_name_input_escape.render.test.jsx b/web-mobile/js/__tests__/render/lineup_name_input_escape.render.test.jsx new file mode 100644 index 000000000..31e8e782c --- /dev/null +++ b/web-mobile/js/__tests__/render/lineup_name_input_escape.render.test.jsx @@ -0,0 +1,73 @@ +// One Escape closes one layer. The overlay score editor closes on Escape through a +// window listener; an open fighter list in it takes the key first and keeps it from +// that listener, so an operator closing the list does not close the editor too +// (found in the browser: on the Scores page, Escape on an open list closed both). +// With the list closed, Escape goes on to the editor as before. + +import React from 'react'; +import { render, act, fireEvent, cleanup } from '@testing-library/react'; +import { describe, it, expect, beforeAll, afterEach } from 'vitest'; +import { LineupNameInput } from '../../admin_scoring_shared.jsx'; + +const ROSTER = ['Arai', 'Shiba', 'Tada']; + +beforeAll(() => { + if (typeof window.useClickOutside !== 'function') window.useClickOutside = () => {}; +}); + +let escapesReachingWindow; +const onWindowKeyDown = (e) => { if (e.key === 'Escape') escapesReachingWindow += 1; }; + +afterEach(() => { + window.removeEventListener('keydown', onWindowKeyDown); + cleanup(); +}); + +function mount(roster = ROSTER) { + escapesReachingWindow = 0; + window.addEventListener('keydown', onWindowKeyDown); + const utils = render( {}} ariaLabel="pos" color="shiro" />); + const input = utils.container.querySelector('input'); + return { ...utils, input, list: () => utils.container.querySelector('.lineup-name__dropdown') }; +} + +describe('Escape on a fighter name list', () => { + it('closes an open list and does not reach the editor behind it', () => { + const { input, list } = mount(); + act(() => { fireEvent.focus(input); }); + expect(list(), 'the list is open').not.toBeNull(); + act(() => { fireEvent.keyDown(input, { key: 'Escape' }); }); + expect(list(), 'the list is closed').toBeNull(); + expect(escapesReachingWindow, 'the editor does not see the Escape that closed the list').toBe(0); + }); + + it('goes on to the editor when the list is already closed', () => { + const { input, list } = mount(); + act(() => { fireEvent.focus(input); }); + act(() => { fireEvent.keyDown(input, { key: 'Escape' }); }); + expect(list()).toBeNull(); + act(() => { fireEvent.keyDown(input, { key: 'Escape' }); }); + expect(escapesReachingWindow, 'a second Escape closes the editor, as before').toBe(1); + }); + + // The list is drawn only when it has an option to show, so a box with nothing to + // offer (a team with no members yet, no query) holds no list for Escape to close: + // taking the key anyway made the operator press it twice to close the editor. + it('goes on to the editor when no list is drawn, since there is nothing to close', () => { + const { input, list } = mount([]); + act(() => { fireEvent.focus(input); }); + expect(list(), 'an empty roster draws no list').toBeNull(); + act(() => { fireEvent.keyDown(input, { key: 'Escape' }); }); + expect(escapesReachingWindow, 'the first Escape closes the editor').toBe(1); + }); + + it('still takes the Escape of a typed name that has only its add row to offer', () => { + const { input, list } = mount([]); + act(() => { fireEvent.focus(input); }); + act(() => { fireEvent.change(input, { target: { value: 'Newcomer' } }); }); + expect(list(), 'the add row is a list').not.toBeNull(); + act(() => { fireEvent.keyDown(input, { key: 'Escape' }); }); + expect(list(), 'the list is closed').toBeNull(); + expect(escapesReachingWindow, 'and the editor does not see that Escape').toBe(0); + }); +}); diff --git a/web-mobile/js/__tests__/render/lineup_name_input_placement.render.test.jsx b/web-mobile/js/__tests__/render/lineup_name_input_placement.render.test.jsx new file mode 100644 index 000000000..867238dc0 --- /dev/null +++ b/web-mobile/js/__tests__/render/lineup_name_input_placement.render.test.jsx @@ -0,0 +1,325 @@ +// bc-tmfd: the lineup name list must not open under the team sheet's pinned +// header (top) or footer dock (bottom). On open it measures the room between +// the input and the pinned edges: down when that fits the list, else toward +// the larger side, with its height capped to that room. A host with no pinned +// bar uses the viewport edges. getBoundingClientRect is stubbed (jsdom lays +// nothing out); the browser check is the acceptance. + +import React from 'react'; +import { render, act, fireEvent, cleanup } from '@testing-library/react'; +import { describe, it, expect, beforeAll, beforeEach, afterEach, vi } from 'vitest'; +import { LineupNameInput } from '../../admin_scoring_shared.jsx'; + +const VIEW_H = 820; +const realRect = Element.prototype.getBoundingClientRect; +let rects; +let savedInnerHeight; + +const rect = (top, bottom) => ({ top, bottom, height: bottom - top, left: 0, right: 0, width: 0, x: 0, y: top }); + +beforeAll(() => { + if (typeof window.useClickOutside !== 'function') window.useClickOutside = () => {}; +}); + +beforeEach(() => { + rects = {}; + savedInnerHeight = window.innerHeight; + Object.defineProperty(window, 'innerHeight', { configurable: true, value: VIEW_H }); + Element.prototype.getBoundingClientRect = function () { + if (this.classList) { + for (const cls of Object.keys(rects)) if (this.classList.contains(cls)) return rects[cls]; + } + return realRect.call(this); + }; +}); + +afterEach(() => { + Element.prototype.getBoundingClientRect = realRect; + Object.defineProperty(window, 'innerHeight', { configurable: true, value: savedInnerHeight }); +}); + +const ROSTER = Array.from({ length: 8 }, (_, i) => `Member ${i + 1}`); + +function openList({ pinned = true, foot = true } = {}) { + const utils = render( +
+ {pinned &&
} + {}} ariaLabel="pos" color="shiro" /> + {foot &&
} +
+ ); + act(() => { fireEvent.focus(utils.container.querySelector('input')); }); + return utils.container.querySelector('.lineup-name__dropdown'); +} + +const setVisualViewport = (vv) => Object.defineProperty(window, 'visualViewport', { configurable: true, value: vv }); + +describe('LineupNameInput list placement against the pinned bars', () => { + it('opens downward, uncapped, when the room below the input fits the list', () => { + rects['team-sheet-pin'] = rect(65, 200); + rects['pmf__bar'] = rect(300, 340); + rects['editor-modal__foot--nav'] = rect(740, 820); + const dd = openList(); + expect(dd.style.bottom).toBe(''); + expect(dd.style.top).toBe(''); + expect(dd.style.maxHeight).toBe('240px'); + }); + + it('opens upward when the dock is close and there is more room above', () => { + rects['team-sheet-pin'] = rect(65, 200); + rects['pmf__bar'] = rect(600, 640); + rects['editor-modal__foot--nav'] = rect(700, 820); + const dd = openList(); + expect(dd.style.top).toBe('auto'); + expect(dd.style.bottom).toBe('calc(100% + 4px)'); + expect(dd.style.maxHeight).toBe('240px'); + }); + + it('caps the height to the larger room when neither side fits the list', () => { + rects['team-sheet-pin'] = rect(65, 260); + rects['pmf__bar'] = rect(400, 440); + rects['editor-modal__foot--nav'] = rect(620, 820); + const dd = openList(); + // Room below is 180, above 140: down, capped to 180 less the 8px margin. + expect(dd.style.top).toBe(''); + expect(dd.style.maxHeight).toBe('172px'); + }); + + it('measures against the pin when opening upward, so the list stops under the header', () => { + rects['team-sheet-pin'] = rect(65, 330); + rects['pmf__bar'] = rect(500, 540); + rects['editor-modal__foot--nav'] = rect(540, 820); + const dd = openList(); + // Below: 0. Above: 500 - 330 = 170, less the margin. + expect(dd.style.bottom).toBe('calc(100% + 4px)'); + expect(dd.style.maxHeight).toBe('162px'); + }); + + it('uses the footer as the bottom edge in the overlay too, where it is static but always visible', () => { + rects['team-sheet-pin'] = rect(65, 200); + rects['pmf__bar'] = rect(500, 540); + rects['editor-modal__foot--nav'] = rect(700, 820); + const dd = openList(); + // 700 - 540 = 160 below does not fit 240 and 300 above is larger: up. + expect(dd.style.top).toBe('auto'); + expect(dd.style.bottom).toBe('calc(100% + 4px)'); + }); + + it('clamps the footer edge to the viewport', () => { + rects['pmf__bar'] = rect(100, 140); + rects['editor-modal__foot--nav'] = rect(900, 980); + const dd = openList({ pinned: false }); + // Edge 820 (the viewport), not 900: 680 below, uncapped. + expect(dd.style.maxHeight).toBe('240px'); + expect(dd.style.top).toBe(''); + }); + + it('measures against the visual viewport when the keyboard shrinks it below innerHeight', () => { + setVisualViewport({ offsetTop: 0, height: 560, addEventListener() {}, removeEventListener() {} }); + try { + rects['pmf__bar'] = rect(400, 440); + const dd = openList({ pinned: false, foot: false }); + // 560 - 440 = 120 below, 400 above: up, uncapped. innerHeight (820) would say 380 below. + expect(dd.style.top).toBe('auto'); + expect(dd.style.maxHeight).toBe('240px'); + } finally { + setVisualViewport(undefined); + } + }); + + it('measures again when the page scrolls while the list is open, and stops after it closes', () => { + const raf = vi.spyOn(window, 'requestAnimationFrame').mockImplementation((cb) => { cb(0); return 1; }); + try { + rects['team-sheet-pin'] = rect(65, 200); + rects['pmf__bar'] = rect(300, 340); + rects['editor-modal__foot--nav'] = rect(740, 820); + const dd = openList(); + expect(dd.style.top).toBe(''); + // The page scrolled: the input is now close to the dock. + rects['pmf__bar'] = rect(650, 690); + act(() => { window.dispatchEvent(new Event('scroll')); }); + expect(dd.style.top).toBe('auto'); + expect(dd.style.bottom).toBe('calc(100% + 4px)'); + } finally { + raf.mockRestore(); + } + }); + + it('removes its listeners when the list closes', () => { + const add = vi.spyOn(window, 'addEventListener'); + const remove = vi.spyOn(window, 'removeEventListener'); + try { + rects['pmf__bar'] = rect(300, 340); + const { container } = render( {}} ariaLabel="pos" color="shiro" />); + act(() => { fireEvent.focus(container.querySelector('input')); }); + const added = add.mock.calls.filter(([t]) => t === 'scroll' || t === 'resize').length; + expect(added).toBe(2); + act(() => { fireEvent.keyDown(container.querySelector('input'), { key: 'Escape' }); }); + const removed = remove.mock.calls.filter(([t]) => t === 'scroll' || t === 'resize').length; + expect(removed).toBe(2); + } finally { + add.mockRestore(); + remove.mockRestore(); + } + }); + + it('with no pinned bar uses the viewport edges, as the lineup panel and Lineups page do', () => { + rects['pmf__bar'] = rect(700, 740); + const dd = openList({ pinned: false, foot: false }); + // Below: 80, above: 700, so it opens upward uncapped. + expect(dd.style.top).toBe('auto'); + expect(dd.style.bottom).toBe('calc(100% + 4px)'); + expect(dd.style.maxHeight).toBe('240px'); + }); +}); + +// The room is bounded by the nearest ancestor that cuts off what overflows it +// too (the overlay's scroll body, the inline panel's clip, the at-court panel's +// scroll box), not only by the viewport and the pinned bars: a list taller than +// that room is cut off or stretches the ancestor's scroll. The box is found from +// its computed style, so each wrapper here carries an inline overflow. +describe('LineupNameInput list placement against a clipping ancestor', () => { + const input = {}} ariaLabel="pos" color="shiro" />; + + function openIn(tree) { + const utils = render(tree); + act(() => { fireEvent.focus(utils.container.querySelector('input')); }); + return { utils, dd: utils.container.querySelector('.lineup-name__dropdown') }; + } + + it('opens upward when the room left below by the ancestor is small, though the viewport has plenty', () => { + rects['clip-box'] = rect(100, 500); + rects['pmf__bar'] = rect(400, 440); + const { dd } = openIn(
{input}
); + // 500 - 440 = 60 below, 400 - 100 = 300 above; the viewport alone would say 380 below. + expect(dd.style.top).toBe('auto'); + expect(dd.style.bottom).toBe('calc(100% + 4px)'); + expect(dd.style.maxHeight).toBe('240px'); + }); + + it('opens downward under the same input when there is no clipping ancestor', () => { + rects['pmf__bar'] = rect(400, 440); + const { dd } = openIn(
{input}
); + expect(dd.style.top).toBe(''); + expect(dd.style.maxHeight).toBe('240px'); + }); + + it('caps the height to the ancestor\'s room when neither side fits the list', () => { + rects['clip-box'] = rect(100, 500); + rects['pmf__bar'] = rect(250, 290); + const { dd } = openIn(
{input}
); + // Below 210, above 150: down, capped to 210 less the 8px margin. + expect(dd.style.top).toBe(''); + expect(dd.style.maxHeight).toBe('202px'); + }); + + it('measures the top against the ancestor too, so an upward list stops under its top edge', () => { + rects['clip-box'] = rect(300, 700); + rects['pmf__bar'] = rect(560, 600); + const { dd } = openIn(
{input}
); + // Below 100 does not fit; above 560 - 300 = 260 does: up, uncapped. The + // viewport's top (0) would have said 560. + expect(dd.style.top).toBe('auto'); + expect(dd.style.maxHeight).toBe('240px'); + + cleanup(); + rects['clip-box'] = rect(480, 700); + const second = openIn(
{input}
); + // Above is now 560 - 480 = 80, below 100: down, capped to 92. + expect(second.dd.style.top).toBe(''); + expect(second.dd.style.maxHeight).toBe('92px'); + }); + + it('uses the NEAREST clipping ancestor, and passes over one that does not clip', () => { + rects['outer'] = rect(0, 820); + rects['inner'] = rect(100, 430); + rects['pmf__bar'] = rect(300, 340); + // The inner box clips: 430 - 340 = 90 below, 200 above: up. + const clipped = openIn( +
{input}
+ ); + expect(clipped.dd.style.top).toBe('auto'); + cleanup(); + // The inner box does not clip, so the outer one (820 - 340 = 480 below) decides: down. + const open = openIn( +
{input}
+ ); + expect(open.dd.style.top).toBe(''); + }); + + it.each([ + ['hidden', { overflowY: 'hidden' }], + ['scroll', { overflowY: 'scroll' }], + ['the horizontal axis alone, which makes the vertical axis cut off too', { overflowX: 'auto' }], + ])('counts an ancestor that clips: %s', (_name, style) => { + rects['clip-box'] = rect(100, 500); + rects['pmf__bar'] = rect(400, 440); + const { dd } = openIn(
{input}
); + expect(dd.style.top).toBe('auto'); + }); + + it('does not stop at the page: the body\'s overflow (a dialog locks it) is not an ancestor to bound by', () => { + rects['pmf__bar'] = rect(400, 440); + document.body.style.overflow = 'hidden'; + try { + const { dd } = openIn(input); + expect(dd.style.top).toBe(''); + } finally { + document.body.style.overflow = ''; + } + }); + + it('finds what bounds the room once, when the list opens, and measures only the input again on a scroll', () => { + const frames = []; + const raf = vi.spyOn(window, 'requestAnimationFrame').mockImplementation((cb) => { frames.push(cb); return frames.length; }); + const runFrames = () => act(() => { frames.splice(0).forEach((cb) => cb(0)); }); + const computed = vi.spyOn(window, 'getComputedStyle'); + try { + rects['clip-box'] = rect(100, 500); + rects['pmf__bar'] = rect(150, 190); + const { dd } = openIn(
{input}
); + const lookedUp = computed.mock.calls.length; + expect(lookedUp).toBeGreaterThan(0); + expect(dd.style.top).toBe(''); + + // The page scrolled: the input moved, the box did not. Measured again, twice. + rects['pmf__bar'] = rect(430, 470); + act(() => { window.dispatchEvent(new Event('scroll')); }); + runFrames(); + expect(dd.style.top).toBe('auto'); + rects['pmf__bar'] = rect(150, 190); + act(() => { window.dispatchEvent(new Event('scroll')); }); + runFrames(); + expect(dd.style.top).toBe(''); + + expect(computed.mock.calls.length).toBe(lookedUp); + } finally { + raf.mockRestore(); + computed.mockRestore(); + } + }); + + it('does not measure again for a scroll inside the list itself, and does for any other', () => { + const frames = []; + const raf = vi.spyOn(window, 'requestAnimationFrame').mockImplementation((cb) => { frames.push(cb); return frames.length; }); + try { + rects['pmf__bar'] = rect(300, 340); + const { utils, dd } = openIn( +
{input}
+ ); + expect(raf).not.toHaveBeenCalled(); + + // Scroll does not bubble, but the page-level listener sees it on the way down. + act(() => { dd.dispatchEvent(new Event('scroll')); }); + expect(raf).not.toHaveBeenCalled(); + + act(() => { utils.getByTestId('elsewhere').dispatchEvent(new Event('scroll')); }); + expect(raf).toHaveBeenCalledTimes(1); + act(() => { frames.splice(0).forEach((cb) => cb(0)); }); + act(() => { window.dispatchEvent(new Event('scroll')); }); + expect(raf).toHaveBeenCalledTimes(2); + } finally { + raf.mockRestore(); + } + }); +}); diff --git a/web-mobile/js/__tests__/render/lineup_panel_hosts.render.test.jsx b/web-mobile/js/__tests__/render/lineup_panel_hosts.render.test.jsx new file mode 100644 index 000000000..e1a1519da --- /dev/null +++ b/web-mobile/js/__tests__/render/lineup_panel_hosts.render.test.jsx @@ -0,0 +1,292 @@ +// The at-court lineup panel as its two hosts drive it (the Scores page's Lineup +// button and the court console's Enter lineup). +// +// - It shows the match as the page holds it NOW. A knockout match's side can be given +// another team after the panel opened (its feeding match corrected or decided on +// another device): the panel's editors must then be the new team's, or the operator +// keeps editing the old team and a Save stores a lineup for a team that is no longer +// in the match. Driven through each host's own data, as production does it: the page +// is given new data and its matches change underneath the open panel. No host hands +// the panel a new match prop by hand. +// - Its two editors ask for the team members once between them: one request answers +// for every team. + +import React from 'react'; +import { render, act, fireEvent, within } from '@testing-library/react'; +import { describe, it, expect, vi, beforeAll, afterAll, beforeEach } from 'vitest'; +import { installWindowStubs } from '../helpers/stub_globals.js'; +import { answered } from '../helpers/team_members.js'; + +const A = { id: 'team-a', name: 'Team A' }; +const B = { id: 'team-b', name: 'Team B' }; +const C = { id: 'team-c', name: 'Team C' }; +const COMP = { id: 'comp-1', name: 'Team Event', kind: 'team', format: 'mixed', status: 'active', teamSize: 3, players: [A, B, C] }; +const knockout = (sideB) => ({ + id: 'm-r1-0', compId: 'comp-1', compName: 'Team Event', compKind: 'team', teamSize: 3, status: 'scheduled', + phase: 'bracket', round: 'Final', court: 'A', scheduledAt: '09:00', sideA: A, sideB, +}); + +// What the hosts' match lists answer: the page's matches, which a test changes. +let matches; + +const STUBS = { + ScoreEditorModal: () => null, + AdminTopbar: ({ children }) =>
{children}
, + Breadcrumbs: () => null, + CourtPicker: () => null, + BracketTree: () => null, + Icon: ({ name }) => {name}, + getScoreBtnClass: () => 'test-score-open', + matchScoreStr: () => '', + filterMatchesByCourt: (all) => all, + tournamentMatches: () => matches, + compMatches: () => matches, + startPatch: () => ({ status: 'running', winner: null }), + confirmDialog: vi.fn().mockResolvedValue(true), + pluralize: (n, s, p) => `${n} ${n === 1 ? s : (p || `${s}s`)}`, + API: { + fetchCompetitionDetails: vi.fn().mockResolvedValue(null), + fetchLineupInForce: vi.fn().mockResolvedValue(null), + fetchSquads: vi.fn().mockResolvedValue({}), + queuedLineupSave: vi.fn().mockReturnValue(null), + putMatchLineup: vi.fn(), + addTeamMember: vi.fn(), + // The server answers a member write with the member it holds, stamped. + renameTeamMember: vi.fn((_comp, _team, id, name) => Promise.resolve(answered({ id, index: 1 }, { name }))), + }, +}; + +let restore; +let restorePanel; +let AdminScoreEditor; +let AdminShiaijoPage; + +beforeAll(async () => { + window.scrollTo = vi.fn(); + restore = installWindowStubs(STUBS); + await import('../../admin_lineup.jsx'); + const { MatchLineupPanel } = await import('../../admin_schedule_lineup.jsx'); + // admin_schedule.jsx publishes it; the console reads it off window. + restorePanel = installWindowStubs({ MatchLineupPanel }); + ({ AdminScoreEditor } = await import('../../admin_schedule_score_editor.jsx')); + await import('../../admin_shiaijo.jsx'); + AdminShiaijoPage = window.AdminShiaijoPage; +}); +afterAll(() => { restorePanel(); restore(); }); + +beforeEach(() => { + // A draft an earlier test left behind would be restored into the editor it opens. + sessionStorage.clear(); + window.API.fetchLineupInForce.mockClear(); + window.API.fetchSquads.mockClear(); +}); + +const flush = async () => { + for (let i = 0; i < 6; i += 1) { + await act(async () => { await Promise.resolve(); }); + } +}; + +// The sides editors that were asked for their team's lineup, in the order asked. +const teamsRead = () => window.API.fetchLineupInForce.mock.calls.map((call) => call[1]).sort(); + +// A new tournament object each time, as the admin app hands the page after a refresh. +const scoresPage = () => ( + +); +const courtConsole = () => ( + +); + +describe.each([ + ['the Scores page', scoresPage, 'Lineup'], + ['the court console', courtConsole, 'Enter lineup'], +])('%s lineup panel when a side of its match is given another team', (_host, page, openLabel) => { + const open = async () => { + matches = [knockout(B)]; + let utils; + await act(async () => { utils = render(page()); }); + await act(async () => { fireEvent.click(utils.getByRole('button', { name: openLabel })); }); + await flush(); + return utils; + }; + + it('shows an editor for the new team, and none for the old one', async () => { + const utils = await open(); + expect(utils.getByTestId('match-lineup-side-team-b'), 'team B is on the Shiro side').toBeTruthy(); + expect(utils.getByTestId('match-lineup-side-team-a')).toBeTruthy(); + + // Another device corrects the feeding match: team C takes team B's place. + matches = [knockout(C)]; + await act(async () => { utils.rerender(page()); }); + await flush(); + + expect(utils.queryByTestId('match-lineup-side-team-b'), 'the old team is gone').toBeNull(); + expect(utils.getByTestId('match-lineup-side-team-c'), 'the new team is on the side').toBeTruthy(); + expect(utils.getByTestId('match-lineup-side-team-a'), 'the other side is not touched').toBeTruthy(); + expect(teamsRead(), 'the new team\'s lineup is read').toEqual(['team-a', 'team-b', 'team-c']); + }); + + it('closes when the match leaves the page\'s data', async () => { + const utils = await open(); + expect(utils.getByTestId('match-lineup-side-team-b')).toBeTruthy(); + + matches = []; + await act(async () => { utils.rerender(page()); }); + + expect(utils.queryByTestId('match-lineup-side-team-b')).toBeNull(); + expect(utils.queryByText('Lineup for this match')).toBeNull(); + }); +}); + +// A write the old team's editor still has out lands nowhere once its side is given +// another team: the new team's editor is a new one. +describe('the Scores page lineup panel when the old team still has a save out', () => { + const NAMES = { positions: { 1: 'Aoki', 2: 'Sato', 3: 'Ito' }, memberIds: { 1: 'mem-1', 2: 'mem-2', 3: 'mem-3' } }; + const LINEUPS = { + 'team-a': NAMES, + 'team-b': { positions: { 1: 'Baba', 2: 'Bando', 3: 'Bessho' }, memberIds: { 1: 'b-1', 2: 'b-2', 3: 'b-3' } }, + 'team-c': { positions: { 1: 'Chiba', 2: 'Cho', 3: 'Date' }, memberIds: { 1: 'c-1', 2: 'c-2', 3: 'c-3' } }, + }; + const MEMBERS = { + 'team-a': [{ id: 'mem-1', index: 1, name: 'Aoki' }, { id: 'mem-2', index: 2, name: 'Sato' }, { id: 'mem-3', index: 3, name: 'Ito' }, { id: 'mem-4', index: 4, name: 'Mori' }], + 'team-b': [{ id: 'b-1', index: 1, name: 'Baba' }, { id: 'b-2', index: 2, name: 'Bando' }, { id: 'b-3', index: 3, name: 'Bessho' }, { id: 'b-4', index: 4, name: 'Bito' }], + 'team-c': [{ id: 'c-1', index: 1, name: 'Chiba' }, { id: 'c-2', index: 2, name: 'Cho' }, { id: 'c-3', index: 3, name: 'Date' }, { id: 'c-4', index: 4, name: 'Endo' }], + }; + + beforeEach(() => { + window.API.fetchSquads.mockResolvedValue(MEMBERS); + window.API.fetchLineupInForce.mockImplementation((_comp, teamId) => Promise.resolve({ + teamId, competitionId: 'comp-1', ...LINEUPS[teamId], matchId: 'm-r1-0', sourceMatchId: 'm-r1-0', saved: true, + })); + window.API.putMatchLineup.mockReset(); + window.API.addTeamMember.mockReset().mockImplementation((_comp, _team, name) => Promise.resolve(answered({ id: 'mem-minted', index: 6 }, { name }))); + }); + + const deferred = () => { + let resolve; + const promise = new Promise((res) => { resolve = res; }); + return { promise, resolve }; + }; + const side = (utils, teamId) => within(utils.getByTestId(`match-lineup-side-${teamId}`)); + + async function open() { + matches = [knockout(B)]; + let utils; + await act(async () => { utils = render(scoresPage()); }); + await act(async () => { fireEvent.click(utils.getByRole('button', { name: 'Lineup' })); }); + await flush(); + return utils; + } + + // The operator types a name nobody has at team B's position 1 and saves. + async function saveNewNameForTeamB(utils) { + await act(async () => { + const input = side(utils, 'team-b').getByLabelText('1 player'); + fireEvent.focus(input); + fireEvent.change(input, { target: { value: 'Kobayashi' } }); + fireEvent.keyDown(input, { key: 'Enter' }); + }); + await act(async () => { fireEvent.click(side(utils, 'team-b').getByRole('button', { name: /^Save lineup$/ })); }); + await flush(); + } + + async function giveTeamCTheSide(utils) { + matches = [knockout(C)]; + await act(async () => { utils.rerender(scoresPage()); }); + await flush(); + } + + // The new team's editor shows its own lineup and offers its own members, none of team B's. + async function expectTeamCAlone(utils) { + const c = side(utils, 'team-c'); + expect(c.getByLabelText('1 player').value).toBe('Chiba'); + expect(c.getByLabelText('2 player').value).toBe('Cho'); + expect(c.getByLabelText('3 player').value).toBe('Date'); + // The open list shows the query, not the value: look, then close it again. + await act(async () => { fireEvent.focus(c.getByLabelText('1 player')); }); + expect(c.getByText('Endo'), 'its own unplaced member').toBeTruthy(); + expect(c.queryByText('Bito'), 'team B\'s unplaced member').toBeNull(); + expect(c.queryByText('Kobayashi'), 'the member team B\'s Save minted').toBeNull(); + await act(async () => { fireEvent.keyDown(c.getByLabelText('1 player'), { key: 'Escape' }); }); + } + + it('lands nothing of a write the old team still has out in the new team\'s lineup or members', async () => { + const utils = await open(); + const write = deferred(); + window.API.putMatchLineup.mockReturnValue(write.promise); + await saveNewNameForTeamB(utils); + expect(window.API.putMatchLineup, 'team B\'s write is out').toHaveBeenCalledTimes(1); + + await giveTeamCTheSide(utils); + await expectTeamCAlone(utils); + await act(async () => { + write.resolve({ positions: { 1: 'Kobayashi', 2: 'Bando', 3: 'Bessho' }, memberIds: { 1: 'mem-minted', 2: 'b-2', 3: 'b-3' } }); + }); + await flush(); + + await expectTeamCAlone(utils); + }); + + it('lands nothing either of the member the old team\'s Save was still minting', async () => { + const utils = await open(); + const mint = deferred(); + window.API.addTeamMember.mockReturnValue(mint.promise); + await saveNewNameForTeamB(utils); + expect(window.API.addTeamMember, 'the member is being minted for team B').toHaveBeenCalledTimes(1); + + await giveTeamCTheSide(utils); + await expectTeamCAlone(utils); + await act(async () => { mint.resolve(answered({ id: 'mem-minted', index: 6 }, { name: 'Kobayashi' })); }); + await flush(); + + await expectTeamCAlone(utils); + }); +}); + +// The API answers every team's members in one request, and the panel mounts two +// editors, each of which needs its own team's: they share the request. +describe('the lineup panel asks for the team members once', () => { + const announce = (detail) => act(async () => { + window.dispatchEvent(new CustomEvent('lineup-updated', { detail })); + }); + + async function open() { + matches = [knockout(B)]; + let utils; + await act(async () => { utils = render(scoresPage()); }); + await act(async () => { fireEvent.click(utils.getByRole('button', { name: 'Lineup' })); }); + await flush(); + return utils; + } + + it('when it opens', async () => { + const utils = await open(); + expect(utils.getByTestId('match-lineup-side-team-a')).toBeTruthy(); + expect(utils.getByTestId('match-lineup-side-team-b')).toBeTruthy(); + expect(window.API.fetchSquads).toHaveBeenCalledTimes(1); + }); + + it('when a lineup change is announced that names no team', async () => { + await open(); + await announce({ competitionId: 'comp-1' }); + await flush(); + + expect(window.API.fetchLineupInForce, 'both sides read their lineup again').toHaveBeenCalledTimes(4); + expect(window.API.fetchSquads, 'for one more request of the members').toHaveBeenCalledTimes(2); + }); + + it('when one is announced for one of its teams: only that side reads again', async () => { + await open(); + window.API.fetchLineupInForce.mockClear(); + await announce({ competitionId: 'comp-1', teamId: 'team-b' }); + await flush(); + + expect(window.API.fetchLineupInForce.mock.calls.map((call) => call[1])).toEqual(['team-b']); + expect(window.API.fetchSquads).toHaveBeenCalledTimes(2); + }); +}); diff --git a/web-mobile/js/__tests__/render/lineup_panel_overlay.render.test.jsx b/web-mobile/js/__tests__/render/lineup_panel_overlay.render.test.jsx new file mode 100644 index 000000000..d0d9dbf26 --- /dev/null +++ b/web-mobile/js/__tests__/render/lineup_panel_overlay.render.test.jsx @@ -0,0 +1,236 @@ +// The at-court lineup panel is a layer over the page, and an editor like the overlay +// score editors: its controls work at once, so it guards only its backdrop against the +// bounce of the tap that opened it (operator ruling 2026-10-06 for the editor overlays: +// the second tap of a double tap lands on the control under the finger, and that is +// accepted). Swallowing every tap for the bounce window made a Close tapped at once do +// nothing (found by PR #442's journeys). Escape closes it unless a write is out, focus +// goes into it and back to the control that opened it, and it is a dialog with a name. +// One Escape closes one layer: an open name list, or an open rename box, takes it first. + +import React from 'react'; +import { render, act, fireEvent, within, cleanup } from '@testing-library/react'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { TAP_BOUNCE_MS } from '../../tap_guard.jsx'; +import { pointerTap, keyboardClick } from '../helpers/tap_events.js'; +import { answered } from '../helpers/team_members.js'; + +const MEMBERS = [ + { id: 'mem-1', index: 1, name: 'Aoki' }, + { id: 'mem-2', index: 2, name: 'Sato' }, + { id: 'mem-3', index: 3, name: 'Ito' }, + { id: 'mem-4', index: 4, name: 'Mori' }, +]; +const NAMES = { positions: { 1: 'Aoki', 2: 'Sato', 3: 'Ito' }, memberIds: { 1: 'mem-1', 2: 'mem-2', 3: 'mem-3' } }; +const A = { id: 'team-a', name: 'Team A' }; +const B = { id: 'team-b', name: 'Team B' }; +const COMP = { id: 'comp-1', name: 'Team Event', kind: 'team', format: 'mixed', status: 'active', teamSize: 3, players: [A, B] }; +const MATCH = { id: 'm-r1-0', compId: 'comp-1', status: 'scheduled', phase: 'bracket', sideA: A, sideB: B }; + +let MatchLineupPanel; +let saved; +let api; + +beforeEach(async () => { + sessionStorage.clear(); + saved = { API: window.API, confirmDialog: window.confirmDialog, compMatches: window.compMatches }; + window.confirmDialog = vi.fn().mockResolvedValue(true); + window.compMatches = () => []; + api = { + fetchLineupInForce: vi.fn().mockImplementation((_c, teamId) => Promise.resolve({ + teamId, competitionId: 'comp-1', ...NAMES, matchId: 'm-r1-0', sourceMatchId: 'm-r1-0', saved: true, + })), + fetchSquads: vi.fn().mockResolvedValue({ 'team-a': MEMBERS, 'team-b': MEMBERS }), + putMatchLineup: vi.fn().mockImplementation((_c, _t, _m, positions, _pw, memberIds) => Promise.resolve({ positions, memberIds })), + // The server answers a member write with the member it holds, stamped. + addTeamMember: vi.fn().mockImplementation((_c, _t, name) => Promise.resolve(answered({ id: 'mem-minted', index: 6 }, { name }))), + renameTeamMember: vi.fn().mockImplementation((_c, _t, id, name) => Promise.resolve(answered({ id, index: 1 }, { name }))), + queuedLineupSave: vi.fn().mockReturnValue(null), + }; + window.API = api; + await import('../../admin_lineup.jsx'); + ({ MatchLineupPanel } = await import('../../admin_schedule_lineup.jsx')); +}); + +afterEach(() => { + cleanup(); + vi.useRealTimers(); + for (const [key, value] of Object.entries(saved)) { + if (value === undefined) delete window[key]; else window[key] = value; + } +}); + +const panelFor = (onClose, extra = {}) => ( + +); + +async function mount(extra) { + const onClose = vi.fn(); + let utils; + await act(async () => { utils = render(panelFor(onClose, extra)); }); + return { ...utils, onClose }; +} + +const settle = () => act(async () => { await new Promise((resolve) => setTimeout(resolve, 5)); }); +const escape = (target = document.body) => act(async () => { fireEvent.keyDown(target, { key: 'Escape' }); }); +const closeButton = (utils) => utils.getByRole('button', { name: '✕ Close' }); +const sideA = (utils) => within(utils.getByTestId('match-lineup-side-team-a')); + +describe('the panel as a dialog', () => { + it('is a modal dialog named for what it is', async () => { + const utils = await mount(); + const dialog = utils.getByRole('dialog', { name: 'Lineup for this match' }); + expect(dialog.getAttribute('aria-modal')).toBe('true'); + expect(dialog.contains(closeButton(utils))).toBe(true); + }); + + it('is not a layer when it sits in the page (the inline variant): no dialog, and Escape leaves it', async () => { + const utils = await mount({ variant: 'inline' }); + expect(utils.queryByRole('dialog')).toBeNull(); + await escape(); + expect(utils.onClose).not.toHaveBeenCalled(); + }); +}); + +describe('Escape', () => { + it('closes the panel', async () => { + const utils = await mount(); + await escape(); + expect(utils.onClose).toHaveBeenCalledTimes(1); + }); + + it('closes only an open name list first, and the panel on the next one', async () => { + const utils = await mount(); + await settle(); + const input = sideA(utils).getByLabelText('1 player'); + await act(async () => { fireEvent.focus(input); }); + expect(sideA(utils).getByText('Mori'), 'the list is open, with the member nobody holds').toBeTruthy(); + + await escape(input); + expect(utils.onClose, 'the list took it').not.toHaveBeenCalled(); + expect(sideA(utils).queryByText('Mori')).toBeNull(); + + await escape(input); + expect(utils.onClose).toHaveBeenCalledTimes(1); + }); + + it('cancels an open rename box only, and closes the panel on the next one', async () => { + const utils = await mount(); + await settle(); + await act(async () => { fireEvent.click(sideA(utils).getByRole('button', { name: 'Rename 1 player' })); }); + const box = sideA(utils).getByRole('textbox', { name: 'Rename 1 player' }); + + await escape(box); + expect(sideA(utils).queryByRole('textbox', { name: 'Rename 1 player' }), 'the rename box is gone').toBeNull(); + expect(utils.onClose, 'the rename took it').not.toHaveBeenCalled(); + + await escape(); + expect(utils.onClose).toHaveBeenCalledTimes(1); + }); + + it('does not close the panel while a Save is out, and does once it has landed', async () => { + let answer; + api.putMatchLineup.mockImplementation(() => new Promise((resolve) => { answer = resolve; })); + const utils = await mount(); + await settle(); + const input = sideA(utils).getByLabelText('1 player'); + await act(async () => { + fireEvent.focus(input); + fireEvent.change(input, { target: { value: 'Mori' } }); + fireEvent.keyDown(input, { key: 'Enter' }); + }); + await act(async () => { fireEvent.click(sideA(utils).getByRole('button', { name: /^Save lineup$/ })); }); + await settle(); + expect(api.putMatchLineup, 'the write is out').toHaveBeenCalledTimes(1); + + await escape(); + expect(utils.onClose).not.toHaveBeenCalled(); + + await act(async () => { answer({ positions: { 1: 'Mori', 2: 'Sato', 3: 'Ito' }, memberIds: { 1: 'mem-4', 2: 'mem-2', 3: 'mem-3' } }); }); + await settle(); + await escape(); + expect(utils.onClose).toHaveBeenCalledTimes(1); + }); +}); + +describe('the tap that opened the panel', () => { + const wait = (ms) => act(async () => { await vi.advanceTimersByTimeAsync(ms); }); + + beforeEach(() => { vi.useFakeTimers(); }); + + it('leaves the panel\'s controls working at once, as the overlay score editors do', async () => { + const utils = await mount(); + await wait(30); + await pointerTap(closeButton(utils)); + expect(utils.onClose, 'a Close tapped straight after opening closes the panel').toHaveBeenCalledTimes(1); + }); + + it('is followed by a tap that works after the window too', async () => { + const utils = await mount(); + await wait(TAP_BOUNCE_MS + 50); + await pointerTap(closeButton(utils)); + expect(utils.onClose).toHaveBeenCalledTimes(1); + }); + + it('is followed by a keyboard activation that works at once (detail 0 is never a bounce)', async () => { + const utils = await mount(); + await wait(30); + await keyboardClick(closeButton(utils)); + expect(utils.onClose).toHaveBeenCalledTimes(1); + }); +}); + +describe('focus', () => { + // The host that opens the panel from a button, as the Scores page and the court + // console do, and closes it by unmounting it. + function Host() { + const [open, setOpen] = React.useState(false); + return ( +
+ + {open && ( + setOpen(false)} /> + )} +
+ ); + } + + it('goes into the panel as it opens and back to the control that opened it, without scrolling', async () => { + const focus = vi.spyOn(HTMLElement.prototype, 'focus'); + try { + let utils; + await act(async () => { utils = render(); }); + const opener = utils.getByRole('button', { name: 'Open lineup' }); + opener.focus(); + await act(async () => { opener.click(); }); + await settle(); + const dialog = utils.getByRole('dialog', { name: 'Lineup for this match' }); + expect(dialog.contains(document.activeElement), 'focus is in the panel').toBe(true); + expect(document.activeElement).toBe(closeButton(utils)); + + await act(async () => { fireEvent.click(closeButton(utils), { detail: 0 }); }); + await settle(); + expect(utils.queryByRole('dialog')).toBeNull(); + expect(document.activeElement, 'back on the control that opened it').toBe(utils.getByRole('button', { name: 'Open lineup' })); + const moves = focus.mock.calls.slice(1).map(([options]) => options); + expect(moves, 'both moves leave the page where it is').toEqual([{ preventScroll: true }, { preventScroll: true }]); + } finally { + focus.mockRestore(); + } + }); + + it('is not given back to a control that is gone', async () => { + let utils; + await act(async () => { utils = render(); }); + const opener = utils.getByRole('button', { name: 'Open lineup' }); + opener.focus(); + await act(async () => { opener.click(); }); + await settle(); + + // The opener leaves the page while the panel is open (the match it belongs to was started). + opener.remove(); + await act(async () => { fireEvent.click(closeButton(utils), { detail: 0 }); }); + await settle(); + + expect(document.activeElement).toBe(document.body); + }); +}); diff --git a/web-mobile/js/__tests__/render/lineup_source_line.render.test.jsx b/web-mobile/js/__tests__/render/lineup_source_line.render.test.jsx new file mode 100644 index 000000000..46cc3be3a --- /dev/null +++ b/web-mobile/js/__tests__/render/lineup_source_line.render.test.jsx @@ -0,0 +1,156 @@ +// LineupSourceLine and LineupProblem (lineup_draft.jsx) are the ONE rendering of +// where a lineup editor's lineup was saved, of the button that gives a match's +// own lineup up, and of why a lineup could not be read. The at-court panel and +// the Lineups page both render them, so the two can no longer drift (they had: +// the page's label was 12px and the panel's 11px, each with its own inline style). + +import React from 'react'; +import { render, fireEvent } from '@testing-library/react'; +import { describe, it, expect, vi } from 'vitest'; +import { LineupSourceLine, LineupProblem } from '../../lineup_draft.jsx'; +import { readStylesheet, cssBlock } from '../helpers/source.js'; + +const MATCHES = [{ id: 'Pool D-0', phase: 'pool', poolName: 'Pool D' }]; +const formOf = (extra = {}) => ({ + read: true, source: { matchId: 'Pool D-1' }, saveQueued: false, removing: false, dropOwnLineup: vi.fn(), ...extra, +}); +const line = (form, props = {}) => render( + +); +const USE = "Use the previous match's lineup"; + +describe('LineupSourceLine', () => { + it('shows nothing before the lineup was read: it would name a source the form does not hold', () => { + const { container } = line(formOf({ read: false })); + expect(container.firstChild).toBeNull(); + }); + + it('names a match\'s own lineup in the one emphasis, with the button that gives it up', () => { + const form = formOf(); + const utils = line(form); + const label = utils.getByTestId('src'); + expect(label.textContent).toBe('Lineup for this match'); + expect(label.className.split(/\s+/)).toContain('lineup-source__label--own'); + expect(label.hasAttribute('style')).toBe(false); + + const button = utils.getByRole('button', { name: USE }); + expect(button.disabled).toBe(false); + fireEvent.click(button); + expect(form.dropOwnLineup).toHaveBeenCalledTimes(1); + }); + + it('puts the button in a plain .btn, so the coarse-pointer floor reaches it through the class', () => { + const button = line(formOf()).getByRole('button', { name: USE }); + expect(button.className.split(/\s+/)).toEqual(expect.arrayContaining(['btn', 'btn--sm', 'lineup-source__use'])); + expect(button.hasAttribute('style')).toBe(false); + expect(button.getAttribute('type')).toBe('button'); + }); + + it('offers no button, and no emphasis, on a lineup the match carries', () => { + const utils = line(formOf({ source: { matchId: 'Pool D-0' } })); + expect(utils.getByTestId('src').textContent).toBe('Same as Pool D · Match 1'); + expect(utils.getByTestId('src').className.split(/\s+/)).not.toContain('lineup-source__label--own'); + expect(utils.queryByRole('button')).toBeNull(); + }); + + it.each([ + ['the starting lineup', { round: 0 }, 'Starting lineup'], + ['nothing saved', null, 'No lineup saved yet'], + ])('offers no button on %s', (_name, source, text) => { + const utils = line(formOf({ source })); + expect(utils.getByTestId('src').textContent).toBe(text); + expect(utils.queryByRole('button')).toBeNull(); + }); + + it('asks for the matches only when the lineup is carried from another match', () => { + const matches = vi.fn(() => MATCHES); + line(formOf(), { allMatches: matches }); + line(formOf({ source: null }), { allMatches: matches }); + line(formOf({ source: { round: 0 } }), { allMatches: matches }); + expect(matches).not.toHaveBeenCalled(); + line(formOf({ source: { matchId: 'Pool D-0' } }), { allMatches: matches }); + expect(matches).toHaveBeenCalledTimes(1); + }); + + describe('while a save of the lineup is still queued', () => { + it('disables the button and says why in a line, never in a title alone', () => { + const form = formOf({ saveQueued: true }); + const utils = line(form); + const button = utils.getByRole('button', { name: USE }); + expect(button.disabled).toBe(true); + expect(button.hasAttribute('title')).toBe(false); + const why = utils.getByText('A save of this lineup is still waiting to be sent.'); + expect(why.getAttribute('role')).toBe('status'); + fireEvent.click(button); + expect(form.dropOwnLineup).not.toHaveBeenCalled(); + }); + + it('says nothing about it where there is no button to wait', () => { + const utils = line(formOf({ saveQueued: true, source: { matchId: 'Pool D-0' } })); + expect(utils.queryByText('A save of this lineup is still waiting to be sent.')).toBeNull(); + }); + }); + + it('disables the button while a save or a removal is out, with no reason line', () => { + const saving = line(formOf(), { busy: true }); + expect(saving.getByRole('button', { name: USE }).disabled).toBe(true); + expect(saving.queryByRole('status')).toBeNull(); + saving.unmount(); + const removing = line(formOf({ removing: true })); + expect(removing.getByRole('button', { name: USE }).disabled).toBe(true); + }); +}); + +describe('LineupProblem', () => { + it('shows nothing when the lineup was read', () => { + const { container } = render(); + expect(container.firstChild).toBeNull(); + }); + + it('says why, with a Try again that reads again', () => { + const retry = vi.fn(); + const utils = render(); + const box = utils.getByTestId('problem'); + expect(box.getAttribute('role')).toBe('alert'); + expect(box.className).toContain('alert--error'); + expect(utils.getByText('competition not found')).toBeTruthy(); + fireEvent.click(utils.getByRole('button', { name: 'Try again' })); + expect(retry).toHaveBeenCalledTimes(1); + }); + + it('puts Try again in a plain .btn, and holds it while a removal is out', () => { + const utils = render(); + const button = utils.getByRole('button', { name: 'Try again' }); + expect(button.className.split(/\s+/)).toEqual(expect.arrayContaining(['btn', 'btn--sm'])); + expect(button.hasAttribute('style')).toBe(false); + expect(button.disabled).toBe(true); + }); +}); + +describe('the stylesheet gives the source line ONE size and ONE emphasis', () => { + const css = readStylesheet(); + const block = (selector) => { + const b = cssBlock(css, selector); + expect(b, `rule ${selector} exists`).not.toBeNull(); + return b; + }; + + it('sizes the line once, at 12px, and emphasises an own lineup by colour and weight alone', () => { + expect(block('.lineup-source')).toMatch(/font-size:\s*12px/); + expect(block('.lineup-source__label--own')).not.toMatch(/font-size/); + expect(block('.lineup-source__label--own')).toMatch(/color:\s*var\(--accent\)/); + expect(block('.lineup-source__label--own')).toMatch(/font-weight:\s*600/); + }); + + it('pushes the button to the end of the line, and wraps the queued reason onto a line of its own', () => { + expect(block('.lineup-source__use')).toMatch(/margin-left:\s*auto/); + expect(block('.lineup-source')).toMatch(/flex-wrap:\s*wrap/); + expect(block('.lineup-source__why')).toMatch(/flex:\s*1 0 100%/); + }); + + it('leaves the buttons their .btn floor: no min-height or height of their own', () => { + for (const selector of ['.lineup-source__use', '.lineup-problem']) { + expect(block(selector)).not.toMatch(/(^|[\s;])(min-)?height:/); + } + }); +}); diff --git a/web-mobile/js/__tests__/render/lineups_page_targets.render.test.jsx b/web-mobile/js/__tests__/render/lineups_page_targets.render.test.jsx new file mode 100644 index 000000000..80a6bab63 --- /dev/null +++ b/web-mobile/js/__tests__/render/lineups_page_targets.render.test.jsx @@ -0,0 +1,503 @@ +// The Lineups page picks WHICH lineup it edits (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"): the team's starting lineup, or +// the lineup of any one of its team matches. The matches come from the match +// data the competition page holds beside the competition's config (real +// compMatchesForCompetition, real match shapes), never from a stub that hands +// the list over, so a page that read the config alone would list none. + +import React from 'react'; +import { render, act, fireEvent, within } from '@testing-library/react'; +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { bracketRoundLabel } from '../../bracket.jsx'; +import { lineupPutStub } from '../helpers/lineup_server.js'; + +const SQUAD = [ + { id: 'mem-1', index: 1, name: 'Aoki' }, + { id: 'mem-2', index: 2, name: 'Sato' }, + { id: 'mem-3', index: 3, name: 'Ito' }, + { id: 'mem-4', index: 4, name: 'Mori' }, +]; +const NAMES = { positions: { 1: 'Aoki', 2: 'Sato', 3: 'Ito' }, memberIds: { 1: 'mem-1', 2: 'mem-2', 3: 'mem-3' } }; +const lineupFor = (extra) => ({ teamId: 'team-a', competitionId: 'comp-1', ...NAMES, saved: true, ...extra }); +const STARTING = lineupFor({ round: 0, sourceRound: 0 }); +const CARRIED = lineupFor({ matchId: 'Pool A-0', sourceMatchId: 'Pool A-0' }); +const OWN = lineupFor({ matchId: 'Pool A-2', sourceMatchId: 'Pool A-2' }); + +const A = { id: 'team-a', name: 'Team A' }; +const B = { id: 'team-b', name: 'Team B' }; +const C = { id: 'team-c', name: 'Team C' }; +const D = { id: 'team-d', name: 'Team D' }; +const E = { id: 'team-e', name: 'Team E' }; +const NOBODY = { id: '', name: '' }; +const match = (id, sideA, sideB, extra = {}) => ({ id, status: 'scheduled', sideA, sideB, ...extra }); + +// Stored out of order on purpose: the page orders them, the draw's file order +// is not its contract. Team A also holds a tiebreaker and a pool daihyosen, +// which are individual bouts and no team match. +const POOL_MATCHES = [ + match('Pool A-2', A, C), + match('Pool A-1', B, C), + match('Pool A-0', A, B), + match('Pool A-TB-0', A, B), + match('Pool A-DH-0', A, C), +]; +const BRACKET = { + rounds: [ + [ + match('m-r0-0', A, D, { matchNumber: 1 }), + match('m-r0-1', B, C, { matchNumber: 2 }), + match('m-r0-2', E, NOBODY, { hidden: true }), + ], + [match('m-r1-0', A, { id: '', name: 'Winner of r0-1' }, { matchNumber: 3 })], + ], + thirdPlaceMatch: match('m-bronze', A, B), +}; +const PLAYERS = [A, B, C, D, E].map((t) => ({ ...t, number: '' })); +const COMP = { + id: 'comp-1', name: 'Team Event', kind: 'team', format: 'mixed', status: 'active', teamSize: 3, players: PLAYERS, +}; + +const A_MATCH_LABELS = ['Pool A · Match 1', 'Pool A · Match 3', 'Match 1', 'Match 3', 'the 3rd-place match']; + +let AdminTeamLineupsList; +let saved; +let api; + +beforeEach(async () => { + saved = { API: window.API, confirmDialog: window.confirmDialog, bracketRoundLabel: window.bracketRoundLabel }; + window.bracketRoundLabel = bracketRoundLabel; + window.confirmDialog = vi.fn().mockResolvedValue(true); + api = { + fetchTeamLineup: vi.fn().mockResolvedValue(null), + fetchLineupInForce: vi.fn().mockResolvedValue(null), + fetchSquads: vi.fn().mockResolvedValue({ 'team-a': SQUAD }), + // A save names the positions it changed, and the server answers the lineup it + // holds then (lineupPutStub): the starting lineup is empty, a match carries NAMES. + putTeamLineup: lineupPutStub({ positions: {}, memberIds: {} }), + putMatchLineup: lineupPutStub({ positions: { ...NAMES.positions }, memberIds: { ...NAMES.memberIds } }), + deleteMatchLineup: vi.fn().mockResolvedValue(true), + }; + window.API = api; + ({ AdminTeamLineupsList } = await import('../../admin_lineup.jsx')); +}); + +afterEach(() => { + window.API = saved.API; + window.confirmDialog = saved.confirmDialog; + window.bracketRoundLabel = saved.bracketRoundLabel; +}); + +// The competition page holds the config beside the match data, as props. +const pageFor = (props = {}) => ( + +); + +async function mountPage(props = {}) { + let utils; + await act(async () => { utils = render(pageFor(props)); }); + await act(async () => { await Promise.resolve(); }); + return utils; +} + +const targetSelect = (utils) => utils.getByLabelText('Lineup for'); +const optionTexts = (select) => within(select).getAllByRole('option').map((o) => o.textContent); +const saveButton = (utils) => utils.getByRole('button', { name: /^Save lineup$/ }); + +async function chooseTarget(utils, value) { + await act(async () => { fireEvent.change(targetSelect(utils), { target: { value } }); }); + await act(async () => { await Promise.resolve(); }); +} + +async function pick(utils, position, memberId) { + await act(async () => { + fireEvent.change(utils.getByTestId(`lineup-position-${position}`), { target: { value: memberId } }); + }); +} + +async function click(button) { + await act(async () => { fireEvent.click(button); }); + await act(async () => { await Promise.resolve(); }); +} + +describe('the "Lineup for" select', () => { + it('lists the starting lineup, then the team\'s own team matches in match order', async () => { + const utils = await mountPage(); + expect(optionTexts(targetSelect(utils))).toEqual(['Starting lineup', ...A_MATCH_LABELS]); + expect(targetSelect(utils).value).toBe(''); + // The Round number input is gone. + expect(utils.queryByLabelText('Round')).toBeNull(); + }); + + it('leaves out a pool tiebreaker, a pool daihyosen, a bye and other teams\' matches', async () => { + const utils = await mountPage(); + const options = within(targetSelect(utils)).getAllByRole('option'); + const values = options.map((o) => o.value); + expect(values).not.toContain('Pool A-TB-0'); + expect(values).not.toContain('Pool A-DH-0'); + expect(values).not.toContain('Pool A-1'); + expect(values).not.toContain('m-r0-1'); + // A team whose only knockout match is a bye has no match to field a lineup in. + await act(async () => { fireEvent.change(utils.getByLabelText('Team'), { target: { value: 'team-e' } }); }); + expect(optionTexts(targetSelect(utils))).toEqual(['Starting lineup']); + }); + + it('lists a knockout match whose opponent is not decided yet: that side carries a placeholder name', async () => { + const utils = await mountPage({ + poolMatches: undefined, + bracket: { rounds: [[match('m-r0-0', A, { id: '', name: 'Winner of Match 2' }, { matchNumber: 1 })]] }, + }); + expect(optionTexts(targetSelect(utils))).toEqual(['Starting lineup', 'Match 1']); + }); + + it('leaves out a bye: a match with a side left empty, hidden or not', async () => { + // A knockout bye the draw did not hide, and a Swiss bye (sideB "") alongside a real round. + const knockout = await mountPage({ + poolMatches: undefined, + bracket: { rounds: [[match('m-r0-0', A, D, { matchNumber: 1 }), match('m-r0-1', A, NOBODY, { matchNumber: 2 })]] }, + }); + expect(optionTexts(targetSelect(knockout))).toEqual(['Starting lineup', 'Match 1']); + knockout.unmount(); + + const swiss = await mountPage({ + comp: { ...COMP, format: 'swiss' }, + poolMatches: [match('Swiss-R1-0', A, C), match('Swiss-R2-0', A, NOBODY)], + bracket: undefined, + }); + expect(optionTexts(targetSelect(swiss))).toEqual(['Starting lineup', 'Round 1 · Match 1']); + }); + + it('leaves out a hidden match the team is seated in, whatever its opponent is named', async () => { + const utils = await mountPage({ + poolMatches: undefined, + bracket: { rounds: [[match('m-r0-0', A, D, { matchNumber: 1 }), match('m-r0-1', A, B, { hidden: true })]] }, + }); + expect(optionTexts(targetSelect(utils))).toEqual(['Starting lineup', 'Match 1']); + }); + + it('keeps a Swiss team\'s rounds in the order they were drawn, not by match number alone', async () => { + // Round 2's match is number 1 of its round and Round 1's is number 4, so + // ordering by number alone would put Round 2 first. + const utils = await mountPage({ + comp: { ...COMP, format: 'swiss' }, + poolMatches: [match('Swiss-R1-3', A, C), match('Swiss-R2-0', A, B)], + bracket: undefined, + }); + expect(optionTexts(targetSelect(utils))).toEqual(['Starting lineup', 'Round 1 · Match 4', 'Round 2 · Match 1']); + }); + + it('offers only the starting lineup before the draw', async () => { + const utils = await mountPage({ comp: { ...COMP, status: 'setup' }, poolMatches: undefined, bracket: undefined }); + expect(optionTexts(targetSelect(utils))).toEqual(['Starting lineup']); + }); + + it('reads the match data off the competition itself when it carries it', async () => { + const utils = await mountPage({ + comp: { ...COMP, poolMatches: POOL_MATCHES, bracket: BRACKET }, + poolMatches: undefined, + bracket: undefined, + }); + expect(optionTexts(targetSelect(utils))).toEqual(['Starting lineup', ...A_MATCH_LABELS]); + }); + + it('goes back to the starting lineup when another team is picked', async () => { + api.fetchLineupInForce.mockResolvedValue(CARRIED); + const utils = await mountPage(); + await chooseTarget(utils, 'Pool A-2'); + expect(targetSelect(utils).value).toBe('Pool A-2'); + await act(async () => { fireEvent.change(utils.getByLabelText('Team'), { target: { value: 'team-b' } }); }); + expect(targetSelect(utils).value).toBe(''); + }); +}); + +describe('the starting lineup', () => { + it('reads round 0 exactly, with no fallback and no lineup in force', async () => { + const utils = await mountPage(); + expect(api.fetchTeamLineup).toHaveBeenCalledTimes(1); + expect(api.fetchTeamLineup).toHaveBeenCalledWith('comp-1', 'team-a', 0); + expect(api.fetchLineupInForce).not.toHaveBeenCalled(); + expect(utils.getByText('Starting lineup', { selector: '.overline' })).toBeTruthy(); + }); + + it('saves to round 0', async () => { + const utils = await mountPage(); + await pick(utils, 1, 'mem-1'); + await click(saveButton(utils)); + expect(api.putTeamLineup).toHaveBeenCalledTimes(1); + expect(api.putTeamLineup).toHaveBeenCalledWith('comp-1', 'team-a', 0, { 1: 'Aoki' }, 'pw', { 1: 'mem-1' }, ['1']); + expect(api.putMatchLineup).not.toHaveBeenCalled(); + }); +}); + +describe('a match', () => { + it('loads the lineup in force and says where it comes from', async () => { + api.fetchLineupInForce.mockResolvedValue(CARRIED); + const utils = await mountPage(); + await chooseTarget(utils, 'Pool A-2'); + + expect(api.fetchLineupInForce).toHaveBeenCalledWith('comp-1', 'team-a', 'Pool A-2'); + expect(utils.getByText('Same as Pool A · Match 1')).toBeTruthy(); + expect(utils.getByTestId('lineup-position-1').value).toBe('mem-1'); + expect(utils.getByTestId('lineup-position-2').value).toBe('mem-2'); + // The starting lineup's own read is not the match's. + expect(api.fetchTeamLineup).toHaveBeenCalledTimes(1); + }); + + it('keeps Save disabled until something changes, then writes the match\'s own lineup', async () => { + api.fetchLineupInForce.mockResolvedValue(CARRIED); + const utils = await mountPage(); + await chooseTarget(utils, 'Pool A-2'); + + expect(saveButton(utils).disabled).toBe(true); + expect(saveButton(utils).getAttribute('title')).toBe('No changes to save'); + await click(saveButton(utils)); + expect(api.putMatchLineup).not.toHaveBeenCalled(); + + await pick(utils, 2, 'mem-4'); + expect(saveButton(utils).disabled).toBe(false); + await click(saveButton(utils)); + + expect(api.putMatchLineup).toHaveBeenCalledTimes(1); + expect(api.putMatchLineup).toHaveBeenCalledWith( + 'comp-1', 'team-a', 'Pool A-2', { 2: 'Mori' }, 'pw', { 2: 'mem-4' }, ['2'], + ); + expect(api.putTeamLineup).not.toHaveBeenCalled(); + }); + + it('is the match\'s own lineup once saved, and Save disables again', async () => { + api.fetchLineupInForce.mockResolvedValue(CARRIED); + const utils = await mountPage(); + await chooseTarget(utils, 'Pool A-2'); + await pick(utils, 2, 'mem-4'); + await click(saveButton(utils)); + + expect(utils.getByText('Lineup for this match')).toBeTruthy(); + expect(utils.queryByText('Same as Pool A · Match 1')).toBeNull(); + expect(saveButton(utils).disabled).toBe(true); + expect(utils.getByRole('button', { name: "Use the previous match's lineup" })).toBeTruthy(); + }); + + it('an edit put back to what was loaded is not a change', async () => { + api.fetchLineupInForce.mockResolvedValue(CARRIED); + const utils = await mountPage(); + await chooseTarget(utils, 'Pool A-2'); + await pick(utils, 2, 'mem-4'); + await pick(utils, 2, 'mem-2'); + expect(saveButton(utils).disabled).toBe(true); + }); + + it('a queued (offline) save keeps the lineup unsaved', async () => { + api.fetchLineupInForce.mockResolvedValue(CARRIED); + api.putMatchLineup.mockResolvedValue({ queued: true }); + const utils = await mountPage(); + await chooseTarget(utils, 'Pool A-2'); + await pick(utils, 2, 'mem-4'); + await click(saveButton(utils)); + expect(saveButton(utils).disabled).toBe(false); + expect(utils.queryByText('Lineup for this match')).toBeNull(); + }); + + it('shows the starting lineup as the source of a match that carries it', async () => { + api.fetchLineupInForce.mockResolvedValue(STARTING); + const utils = await mountPage(); + await chooseTarget(utils, 'Pool A-0'); + expect(utils.getByText('Starting lineup', { selector: '[data-testid="lineup-source"]' })).toBeTruthy(); + expect(utils.queryByRole('button', { name: "Use the previous match's lineup" })).toBeNull(); + }); + + it('reads a failed load as an error, never as an empty lineup', async () => { + api.fetchLineupInForce.mockRejectedValue(new Error('competition not found')); + const utils = await mountPage(); + await chooseTarget(utils, 'Pool A-2'); + expect(utils.getByText('competition not found')).toBeTruthy(); + }); +}); + +describe('Use the previous match\'s lineup', () => { + it('is offered only on a match\'s own lineup', async () => { + api.fetchLineupInForce.mockResolvedValue(CARRIED); + const utils = await mountPage(); + expect(utils.queryByRole('button', { name: "Use the previous match's lineup" })).toBeNull(); + await chooseTarget(utils, 'Pool A-2'); + expect(utils.queryByRole('button', { name: "Use the previous match's lineup" })).toBeNull(); + }); + + it('removes the match\'s own lineup and shows the one it carries again', async () => { + api.fetchLineupInForce.mockResolvedValueOnce(OWN).mockResolvedValue(CARRIED); + const utils = await mountPage(); + await chooseTarget(utils, 'Pool A-2'); + expect(utils.getByText('Lineup for this match')).toBeTruthy(); + + await click(utils.getByRole('button', { name: "Use the previous match's lineup" })); + + // The confirm says what else follows. + expect(window.confirmDialog).toHaveBeenCalledTimes(1); + expect(window.confirmDialog.mock.calls[0][0].message).toMatch(/later match.*no lineup of their own/i); + expect(api.deleteMatchLineup).toHaveBeenCalledWith('comp-1', 'team-a', 'Pool A-2', 'pw'); + expect(api.fetchLineupInForce).toHaveBeenCalledTimes(2); + expect(utils.getByText('Same as Pool A · Match 1')).toBeTruthy(); + expect(utils.queryByRole('button', { name: "Use the previous match's lineup" })).toBeNull(); + }); + + it('shows an empty lineup when nothing is left for the match to carry', async () => { + api.fetchLineupInForce.mockResolvedValueOnce(OWN).mockResolvedValue(null); + const utils = await mountPage(); + await chooseTarget(utils, 'Pool A-2'); + await click(utils.getByRole('button', { name: "Use the previous match's lineup" })); + expect(utils.getByText('No lineup saved yet')).toBeTruthy(); + expect(utils.getByTestId('lineup-position-1').value).toBe(''); + }); + + it('removes nothing when the operator declines', async () => { + api.fetchLineupInForce.mockResolvedValue(OWN); + window.confirmDialog.mockResolvedValue(false); + const utils = await mountPage(); + await chooseTarget(utils, 'Pool A-2'); + await click(utils.getByRole('button', { name: "Use the previous match's lineup" })); + expect(api.deleteMatchLineup).not.toHaveBeenCalled(); + expect(utils.getByText('Lineup for this match')).toBeTruthy(); + }); + + it('reports a refused removal and keeps the lineup shown', async () => { + api.fetchLineupInForce.mockResolvedValue(OWN); + api.deleteMatchLineup.mockRejectedValue(new Error('Failed to delete match lineup')); + const utils = await mountPage(); + await chooseTarget(utils, 'Pool A-2'); + await click(utils.getByRole('button', { name: "Use the previous match's lineup" })); + expect(utils.getByText('Failed to delete match lineup')).toBeTruthy(); + expect(utils.getByText('Lineup for this match')).toBeTruthy(); + }); +}); + +// The match chosen in "Lineup for" can stop seating the team while the page is open: +// a feeding match is reopened or corrected on another device, or the draw is +// discarded. The page never falls back to the starting lineup by itself: the next edit +// and Save there would rewrite a lineup that every later match without its own carries. +describe('a match the team is no longer in', () => { + // Team C plays the final until the match feeding it is corrected and team B plays it. + const finalWith = (sideB) => ({ + rounds: [ + [match('m-r0-0', A, D, { matchNumber: 1 }), match('m-r0-1', B, C, { matchNumber: 2 })], + [match('m-r1-0', A, sideB, { matchNumber: 3 })], + ], + }); + const pageWith = (bracket) => pageFor({ poolMatches: undefined, bracket }); + const notInMatch = (utils) => utils.getByTestId('lineup-not-in-match'); + + // Team C's lineup for the final, chosen and edited: an unsaved change for that match. + async function editFinalAsTeamC() { + api.fetchSquads.mockResolvedValue({ 'team-a': SQUAD, 'team-c': SQUAD }); + api.fetchLineupInForce.mockResolvedValue(lineupFor({ teamId: 'team-c', matchId: 'm-r1-0', sourceMatchId: 'm-r0-1' })); + api.fetchTeamLineup.mockResolvedValue(lineupFor({ teamId: 'team-c', round: 0, sourceRound: 0 })); + const utils = await mountPage({ poolMatches: undefined, bracket: finalWith(C) }); + await act(async () => { fireEvent.change(utils.getByLabelText('Team'), { target: { value: 'team-c' } }); }); + await chooseTarget(utils, 'm-r1-0'); + await pick(utils, 2, 'mem-4'); + return utils; + } + + async function giveTheFinalTo(utils, bracket) { + await act(async () => { utils.rerender(pageWith(bracket)); }); + await act(async () => { await Promise.resolve(); }); + } + + it('keeps the match chosen and says plainly that the team is no longer in it', async () => { + const utils = await editFinalAsTeamC(); + expect(targetSelect(utils).value).toBe('m-r1-0'); + + await giveTheFinalTo(utils, finalWith(B)); + + expect(targetSelect(utils).value, 'the chosen match is still chosen').toBe('m-r1-0'); + expect(optionTexts(targetSelect(utils)), 'and still named').toEqual(['Starting lineup', 'Match 2', 'Match 3']); + expect(notInMatch(utils).textContent).toBe( + 'Team C is no longer in Match 3. Choose another match, or the starting lineup, in Lineup for.', + ); + expect(utils.queryByText('Starting lineup', { selector: '.overline' }), 'the starting lineup is not shown').toBeNull(); + }); + + it('keeps Save off for it, and the operator\'s edit stays on the form', async () => { + const utils = await editFinalAsTeamC(); + expect(saveButton(utils).disabled).toBe(false); + + await giveTheFinalTo(utils, finalWith(B)); + await click(saveButton(utils)); + + expect(saveButton(utils).disabled).toBe(true); + expect(api.putMatchLineup, 'nothing is written for a match the team is not in').not.toHaveBeenCalled(); + expect(api.putTeamLineup, 'and nothing is written over the starting lineup').not.toHaveBeenCalled(); + expect(utils.getByTestId('lineup-position-2').value, 'the edit is not lost from view').toBe('mem-4'); + expect(utils.getByTestId('lineup-position-2').disabled, 'but cannot be taken further').toBe(true); + }); + + // Removing the lineup of a match the team is not in is no way on either: the only way + // on is choosing another match, or the starting lineup. + it('locks "Use the previous match\'s lineup" too, so the lineup it names is not removed', async () => { + api.fetchSquads.mockResolvedValue({ 'team-a': SQUAD, 'team-c': SQUAD }); + api.fetchLineupInForce.mockResolvedValue(lineupFor({ teamId: 'team-c', matchId: 'm-r1-0', sourceMatchId: 'm-r1-0' })); + api.fetchTeamLineup.mockResolvedValue(lineupFor({ teamId: 'team-c', round: 0, sourceRound: 0 })); + const utils = await mountPage({ poolMatches: undefined, bracket: finalWith(C) }); + await act(async () => { fireEvent.change(utils.getByLabelText('Team'), { target: { value: 'team-c' } }); }); + await chooseTarget(utils, 'm-r1-0'); + const useButton = () => utils.getByRole('button', { name: "Use the previous match's lineup" }); + expect(useButton().disabled, 'offered while the team plays the match').toBe(false); + + await giveTheFinalTo(utils, finalWith(B)); + + expect(notInMatch(utils)).toBeTruthy(); + expect(useButton().disabled).toBe(true); + await click(useButton()); + expect(window.confirmDialog).not.toHaveBeenCalled(); + expect(api.deleteMatchLineup).not.toHaveBeenCalled(); + }); + + it('is the same for a match that has left the draw altogether', async () => { + const utils = await editFinalAsTeamC(); + + // The final is gone from the data, not re-seated. + await giveTheFinalTo(utils, { rounds: [finalWith(B).rounds[0]] }); + + expect(targetSelect(utils).value).toBe('m-r1-0'); + expect(notInMatch(utils).textContent).toContain('Team C is no longer in Match 3.'); + expect(saveButton(utils).disabled).toBe(true); + }); + + it('lets the operator choose another lineup, and the notice goes with the match', async () => { + const utils = await editFinalAsTeamC(); + await giveTheFinalTo(utils, finalWith(B)); + expect(targetSelect(utils).value, 'the chosen match waits for the operator').toBe('m-r1-0'); + + await chooseTarget(utils, ''); + expect(targetSelect(utils).value).toBe(''); + expect(utils.queryByTestId('lineup-not-in-match')).toBeNull(); + expect(utils.getByText('Starting lineup', { selector: '.overline' })).toBeTruthy(); + expect(optionTexts(targetSelect(utils)), 'the match that was kept is not offered again').toEqual(['Starting lineup', 'Match 2']); + + await pick(utils, 1, 'mem-4'); + await click(saveButton(utils)); + expect(api.putTeamLineup, 'the starting lineup is saved because the operator chose it').toHaveBeenCalledTimes(1); + + await chooseTarget(utils, 'm-r0-1'); + expect(utils.queryByTestId('lineup-not-in-match')).toBeNull(); + expect(utils.getByText('Match 2', { selector: '.overline' })).toBeTruthy(); + }); + + it('goes when the team is seated in the match again, with the edit still there to save', async () => { + const utils = await editFinalAsTeamC(); + await giveTheFinalTo(utils, finalWith(B)); + expect(utils.queryByTestId('lineup-not-in-match')).not.toBeNull(); + + await giveTheFinalTo(utils, finalWith(C)); + + expect(utils.queryByTestId('lineup-not-in-match')).toBeNull(); + expect(utils.getByTestId('lineup-position-2').value).toBe('mem-4'); + expect(saveButton(utils).disabled).toBe(false); + }); +}); + diff --git a/web-mobile/js/__tests__/render/match_history_view.render.test.jsx b/web-mobile/js/__tests__/render/match_history_view.render.test.jsx index 0ec3abf20..8c11895d5 100644 --- a/web-mobile/js/__tests__/render/match_history_view.render.test.jsx +++ b/web-mobile/js/__tests__/render/match_history_view.render.test.jsx @@ -34,7 +34,6 @@ const STUBBED_GLOBALS = { isTextEntry: () => false, isInteractiveTarget: () => false, confirmDialog: vi.fn().mockResolvedValue(true), - resolveRoundIndex: () => 0, API: { fetchCompetitionDetails: vi.fn().mockResolvedValue(null), recordScore: vi.fn().mockResolvedValue(undefined), diff --git a/web-mobile/js/__tests__/render/match_lineup_side_editor_draft.render.test.jsx b/web-mobile/js/__tests__/render/match_lineup_side_editor_draft.render.test.jsx new file mode 100644 index 000000000..3dcf0d4ab --- /dev/null +++ b/web-mobile/js/__tests__/render/match_lineup_side_editor_draft.render.test.jsx @@ -0,0 +1,319 @@ +// A lineup the operator has changed but not saved survives a reload, the app's +// Back and closing the at-court panel (bc-lnul, operator decision 2026-10-05): +// each side keeps its unsaved picks as a draft in this tab's sessionStorage and +// offers them back when the same match's lineup is opened again, with "Unsaved +// lineup changes restored" and a Discard. Nothing reaches the server until the +// operator presses Save: a restored draft is never written on its own. + +import React from 'react'; +import { render, act, fireEvent } from '@testing-library/react'; +import { describe, it, expect, beforeAll, beforeEach, afterAll, vi } from 'vitest'; +import { installWindowStubs } from '../helpers/stub_globals.js'; +import { lineupPutStub } from '../helpers/lineup_server.js'; + +const NAMES = { positions: { 1: 'Aoki', 2: 'Sato', 3: 'Ito' }, memberIds: { 1: 'mem-1', 2: 'mem-2', 3: 'mem-3' } }; +const CARRIED = { teamId: 'uuid-grouped', matchId: 'Pool D-0', ...NAMES, sourceMatchId: 'Pool D-0', saved: true }; +const OWN = { teamId: 'uuid-grouped', matchId: 'Pool D-1', ...NAMES, sourceMatchId: 'Pool D-1', saved: true }; +const SAVED_ELSEWHERE = { ...CARRIED, positions: { 1: 'Kato', 2: 'Sato', 3: 'Ito' } }; + +const SQUAD = [ + { id: 'mem-1', index: 1, name: 'Aoki' }, + { id: 'mem-2', index: 2, name: 'Sato' }, + { id: 'mem-3', index: 3, name: 'Ito' }, +]; +const COMP = { id: 'comp-1', name: 'Team Event', kind: 'team', teamSize: 3 }; +const TEAM = { id: 'uuid-grouped', name: 'Grouped Team', number: 'T5' }; +const side = (id, other) => ({ id, compId: 'comp-1', phase: 'pool', poolName: 'Pool D', sideA: { id: 'uuid-grouped', name: 'Grouped Team' }, sideB: { id: other, name: other }, status: 'scheduled' }); +const MATCH = side('Pool D-1', 'other'); +const NEXT_MATCH = side('Pool D-2', 'third'); +const EARLIER = side('Pool D-0', 'second'); + +const STORAGE_KEY = 'bc.lineupDraft.v1:comp-1:uuid-grouped:match:Pool D-1'; +const draftOf = () => JSON.parse(sessionStorage.getItem(STORAGE_KEY)); +const NOTICE = 'match-lineup-draft-uuid-grouped'; + +const api = { + fetchLineupInForce: vi.fn(), + fetchSquads: vi.fn().mockResolvedValue({ 'uuid-grouped': SQUAD }), + putMatchLineup: vi.fn(), + deleteMatchLineup: vi.fn(), +}; +const STUBBED_GLOBALS = { + compMatches: () => [], + confirmDialog: vi.fn(), + AdminLineupHelpers: { + positionsForSize: (n) => Array.from({ length: n }, (_, i) => ({ key: String(i + 1), label: String(i + 1) })), + rosterFor: () => [], + mergeRosterWithAssigned: (base) => (Array.isArray(base) ? base : []), + teamIdOf: (t) => t?.id || t?.name || '', + resolveMemberIdsForPositions: vi.fn().mockResolvedValue({ memberIds: {}, squad: [], failures: [] }), + memberIdentityWarning: () => '', + }, + API: api, +}; + +let restoreGlobals; +let MatchLineupSideEditor; + +beforeAll(async () => { + restoreGlobals = installWindowStubs(STUBBED_GLOBALS); + ({ MatchLineupSideEditor } = await import('../../admin_schedule_lineup.jsx')); +}); +afterAll(() => restoreGlobals()); + +beforeEach(() => { + sessionStorage.clear(); + api.fetchLineupInForce.mockReset().mockResolvedValue(CARRIED); + // A save names the positions it changed, and the server answers the lineup it holds + // then (lineupPutStub): the lineup in force is what it holds. + api.putMatchLineup.mockReset().mockImplementation( + lineupPutStub({ positions: { ...NAMES.positions }, memberIds: { ...NAMES.memberIds } }), + ); + api.deleteMatchLineup.mockReset().mockResolvedValue(true); + window.confirmDialog.mockReset().mockResolvedValue(true); +}); + +async function mountPanel(match = MATCH) { + let utils; + await act(async () => { + utils = render( + + ); + }); + await act(async () => { await Promise.resolve(); }); + return utils; +} + +async function typeName(utils, position, name) { + await act(async () => { + const input = utils.getByLabelText(`${position} player`); + fireEvent.focus(input); + fireEvent.change(input, { target: { value: name } }); + fireEvent.keyDown(input, { key: 'Enter' }); + }); +} + +async function click(button) { + await act(async () => { fireEvent.click(button); }); + await act(async () => { await Promise.resolve(); }); +} + +const saveButton = (utils) => utils.getByRole('button', { name: /Save lineup/ }); +const discardButton = (utils) => utils.getByRole('button', { name: 'Discard' }); + +describe('unsaved picks in the at-court lineup panel', () => { + it('are kept when the panel goes away, and offered back with a Discard when it is opened again', async () => { + const first = await mountPanel(); + await typeName(first, 1, 'Mori'); + expect(first.getByLabelText('1 player').value).toBe('Mori'); + first.unmount(); + + const again = await mountPanel(); + + expect(again.getByLabelText('1 player').value).toBe('Mori'); + expect(again.getByLabelText('2 player').value).toBe('Sato'); + const notice = again.getByTestId(NOTICE); + expect(notice.textContent).toContain('Unsaved lineup changes restored'); + expect(notice.getAttribute('role')).toBe('status'); + expect(notice.className).toContain('alert--warn'); + expect(discardButton(again)).toBeTruthy(); + // Restoring is not saving: the operator's Save is the only write. + expect(saveButton(again).disabled).toBe(false); + expect(api.putMatchLineup).not.toHaveBeenCalled(); + }); + + it('are kept in this tab\'s sessionStorage, against the lineup they were made on', async () => { + const utils = await mountPanel(); + await typeName(utils, 1, 'Mori'); + const draft = draftOf(); + expect(draft.current.positions[1]).toBe('Mori'); + expect(draft.baseline.positions[1]).toBe('Aoki'); + expect(Number.isFinite(draft.savedAt)).toBe(true); + }); + + it('put no notice and no draft behind a lineup nobody changed', async () => { + const first = await mountPanel(); + expect(first.queryByTestId(NOTICE)).toBeNull(); + first.unmount(); + expect(sessionStorage.length).toBe(0); + const again = await mountPanel(); + expect(again.queryByTestId(NOTICE)).toBeNull(); + }); + + it('are Discarded back to the lineup that was loaded, and the notice and the draft go', async () => { + const first = await mountPanel(); + await typeName(first, 1, 'Mori'); + first.unmount(); + const again = await mountPanel(); + + await click(discardButton(again)); + + expect(again.getByLabelText('1 player').value).toBe('Aoki'); + expect(again.queryByTestId(NOTICE)).toBeNull(); + expect(saveButton(again).disabled).toBe(true); + expect(sessionStorage.getItem(STORAGE_KEY)).toBeNull(); + expect(api.putMatchLineup).not.toHaveBeenCalled(); + again.unmount(); + expect((await mountPanel()).queryByTestId(NOTICE)).toBeNull(); + }); + + it('are cleared by Save, with the notice, and a later open shows none', async () => { + const first = await mountPanel(); + await typeName(first, 1, 'Mori'); + first.unmount(); + const again = await mountPanel(); + expect(again.getByTestId(NOTICE)).toBeTruthy(); + + await click(saveButton(again)); + + expect(api.putMatchLineup).toHaveBeenCalledTimes(1); + expect(api.putMatchLineup.mock.calls[0].slice(0, 4)).toEqual(['comp-1', 'uuid-grouped', 'Pool D-1', { 1: 'Mori' }]); + expect(api.putMatchLineup.mock.calls[0][6], 'the save names the position it changed').toEqual(['1']); + expect(again.queryByTestId(NOTICE)).toBeNull(); + expect(sessionStorage.getItem(STORAGE_KEY)).toBeNull(); + again.unmount(); + + api.fetchLineupInForce.mockResolvedValue({ ...OWN, positions: { 1: 'Mori', 2: 'Sato', 3: 'Ito' } }); + const reopened = await mountPanel(); + expect(reopened.queryByTestId(NOTICE)).toBeNull(); + expect(reopened.getByLabelText('1 player').value).toBe('Mori'); + }); + + it('are not cleared by a save that was only queued (offline): the lineup is still unsaved', async () => { + api.putMatchLineup.mockResolvedValue({ queued: true }); + const utils = await mountPanel(); + await typeName(utils, 1, 'Mori'); + await click(saveButton(utils)); + expect(draftOf().current.positions[1]).toBe('Mori'); + }); + + it('belong to their own match: another match of the team opens without them', async () => { + const first = await mountPanel(); + await typeName(first, 1, 'Mori'); + first.unmount(); + + const other = await mountPanel(NEXT_MATCH); + + expect(other.getByLabelText('1 player').value).toBe('Aoki'); + expect(other.queryByTestId(NOTICE)).toBeNull(); + expect(draftOf().current.positions[1]).toBe('Mori'); + }); + + it('go when the match is told to use the previous match\'s lineup, which replaces what the panel shows', async () => { + api.fetchLineupInForce.mockResolvedValueOnce(OWN).mockResolvedValue(CARRIED); + const utils = await mountPanel(); + await typeName(utils, 1, 'Mori'); + expect(draftOf()).not.toBeNull(); + + await click(utils.getByRole('button', { name: "Use the previous match's lineup" })); + + expect(utils.getByLabelText('1 player').value).toBe('Aoki'); + expect(sessionStorage.getItem(STORAGE_KEY)).toBeNull(); + expect(utils.queryByTestId(NOTICE)).toBeNull(); + }); +}); + +describe('a lineup saved meanwhile', () => { + it('is not overwritten by an older draft: the notice names what was not restored', async () => { + const first = await mountPanel(); + await typeName(first, 1, 'Mori'); + first.unmount(); + api.fetchLineupInForce.mockResolvedValue(SAVED_ELSEWHERE); + + const again = await mountPanel(); + + expect(again.getByLabelText('1 player').value).toBe('Kato'); + const notice = again.getByTestId(NOTICE); + expect(notice.textContent).toBe('Not restored, the lineup changed since: Mori'); + expect(notice.getAttribute('role')).toBe('status'); + expect(notice.className).toContain('alert--warn'); + expect(again.queryByRole('button', { name: 'Discard' })).toBeNull(); + expect(saveButton(again).disabled).toBe(true); + expect(sessionStorage.getItem(STORAGE_KEY)).toBeNull(); + expect(api.putMatchLineup).not.toHaveBeenCalled(); + }); +}); + +// The operator's own save can land after the panel was closed: it was queued (offline), +// or still out when they closed it. A name typed in is given its member by the save, so +// the lineup the server then holds carries an id the draft never had. That is their +// save having landed, not another device's change, and it is never reported as one. +describe('the operator\'s own save, landed after the panel was closed', () => { + const LANDED = { ...OWN, positions: { 1: 'Mori', 2: 'Sato', 3: 'Ito' }, memberIds: { 1: 'mem-9', 2: 'mem-2', 3: 'mem-3' } }; + + beforeEach(() => { + window.AdminLineupHelpers.resolveMemberIdsForPositions.mockResolvedValueOnce({ + memberIds: { 1: 'mem-9' }, squad: [...SQUAD, { id: 'mem-9', index: 4, name: 'Mori' }], failures: [], + }); + }); + + it('is no conflict when the save was queued and has since been written', async () => { + api.putMatchLineup.mockResolvedValue({ queued: true }); + const first = await mountPanel(); + await typeName(first, 1, 'Mori'); + await click(saveButton(first)); + expect(api.putMatchLineup.mock.calls[0][5], 'the save carried the id of the member it named').toEqual({ 1: 'mem-9' }); + first.unmount(); + api.fetchLineupInForce.mockResolvedValue(LANDED); + + const again = await mountPanel(); + + expect(again.queryByTestId(NOTICE), 'no "Not restored" and no restored draft').toBeNull(); + expect(again.getByLabelText('1 player').value).toBe('Mori'); + expect(saveButton(again).disabled).toBe(true); + expect(sessionStorage.getItem(STORAGE_KEY)).toBeNull(); + }); + + it('is no conflict either when the panel was closed while the save was still out', async () => { + let answer; + api.putMatchLineup.mockImplementation(() => new Promise((resolve) => { answer = resolve; })); + const first = await mountPanel(); + await typeName(first, 1, 'Mori'); + await click(saveButton(first)); + first.unmount(); + await act(async () => { answer({ positions: LANDED.positions, memberIds: LANDED.memberIds }); }); + api.fetchLineupInForce.mockResolvedValue(LANDED); + + const again = await mountPanel(); + + expect(again.queryByTestId(NOTICE)).toBeNull(); + expect(again.getByLabelText('1 player').value).toBe('Mori'); + expect(sessionStorage.getItem(STORAGE_KEY)).toBeNull(); + }); +}); + +describe('a lineup that could not be read', () => { + it('keeps the draft and shows no notice, and offers the draft once the lineup loads', async () => { + const first = await mountPanel(); + await typeName(first, 1, 'Mori'); + first.unmount(); + const kept = sessionStorage.getItem(STORAGE_KEY); + api.fetchLineupInForce.mockRejectedValue(new Error('competition not found')); + + const failed = await mountPanel(); + + expect(failed.getByText('competition not found')).toBeTruthy(); + expect(failed.queryByTestId(NOTICE)).toBeNull(); + expect(sessionStorage.getItem(STORAGE_KEY)).toBe(kept); + failed.unmount(); + + api.fetchLineupInForce.mockResolvedValue(CARRIED); + const recovered = await mountPanel(); + expect(recovered.getByLabelText('1 player').value).toBe('Mori'); + expect(recovered.getByTestId(NOTICE).textContent).toContain('Unsaved lineup changes restored'); + }); +}); + +describe('the Discard button', () => { + it('is a plain .btn, so the coarse-pointer floor reaches it through the class, never an inline style', async () => { + const first = await mountPanel(); + await typeName(first, 1, 'Mori'); + first.unmount(); + const again = await mountPanel(); + + const discard = discardButton(again); + expect(discard.className.split(/\s+/)).toContain('btn'); + expect(discard.hasAttribute('style')).toBe(false); + expect(discard.getAttribute('type')).toBe('button'); + }); +}); diff --git a/web-mobile/js/__tests__/render/match_lineup_side_editor_order.render.test.jsx b/web-mobile/js/__tests__/render/match_lineup_side_editor_order.render.test.jsx deleted file mode 100644 index 9469cf1b3..000000000 --- a/web-mobile/js/__tests__/render/match_lineup_side_editor_order.render.test.jsx +++ /dev/null @@ -1,84 +0,0 @@ -// bc-cpdc: "Copy from previous match" moved from the footer (beside "Save -// lineup") to the header of each side's editor, matching the docs' -// walkthrough (docs/user-guide/organisers/team-tournaments.md: "use Copy -// from previous match at the top of the lineup panel"). The docs define -// behaviour, so the control moved rather than the sentence. -// -// This pins the ORDER an operator actually sees in the DOM: Copy above the -// position rows, Save below them. Asserting mere presence of the button -// would pass whether it sits in the header or the footer; only ordering -// (via Node.compareDocumentPosition, real DOM -- this suite mounts with -// real React via @testing-library/react, unlike the unit suite's vdom -// stub) catches a regression back to the old placement. - -import React from 'react'; -import { render, act } from '@testing-library/react'; -import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest'; -import { installWindowStubs } from '../helpers/stub_globals.js'; - -const STUBBED_GLOBALS = { - resolveRoundIndex: () => 0, - compMatches: () => [], - AdminLineupHelpers: { - positionsForSize: (n) => Array.from({ length: n }, (_, i) => ({ key: String(i + 1), label: String(i + 1) })), - rosterFor: () => [], - mergeRosterWithAssigned: (base) => (Array.isArray(base) ? base : []), - teamIdOf: (t) => t?.id || t?.name || '', - resolveMemberIdsForPositions: vi.fn().mockResolvedValue({ memberIds: {}, squad: [], failures: [] }), - memberIdentityWarning: () => '', - }, - API: { - fetchMatchLineup: vi.fn().mockResolvedValue(null), - fetchTeamLineup: vi.fn().mockResolvedValue(null), - fetchSquads: vi.fn().mockResolvedValue({}), - putMatchLineup: vi.fn().mockResolvedValue({ positions: {} }), - }, -}; - -let restoreGlobals; -let MatchLineupSideEditor; - -beforeAll(async () => { - restoreGlobals = installWindowStubs(STUBBED_GLOBALS); - ({ MatchLineupSideEditor } = await import('../../admin_schedule_lineup.jsx')); -}); - -afterAll(() => restoreGlobals()); - -const COMP = { id: 'comp-1', name: 'Team Event', kind: 'team', teamSize: 3 }; -const TEAM = { id: 'uuid-grouped', name: 'Grouped Team', number: 'T5' }; -const MATCH = { - id: 'match-1', - compId: 'comp-1', - sideA: { id: 'uuid-grouped', name: 'Grouped Team' }, - sideB: { id: 'other', name: 'Other' }, - status: 'scheduled', -}; - -describe('MatchLineupSideEditor places Copy above the positions and Save below them (bc-cpdc)', () => { - it('renders Copy from previous match before the first position row, and Save lineup after it', async () => { - let utils; - await act(async () => { - utils = render( - - ); - }); - const { container, findByRole } = utils; - - const saveBtn = await findByRole('button', { name: /Save lineup/ }); - const copyBtn = await findByRole('button', { name: /Copy from previous match/ }); - const firstPos = container.querySelector('[data-testid^="match-lineup-pos-"]'); - expect(firstPos).toBeTruthy(); - - // Node.compareDocumentPosition: DOCUMENT_POSITION_FOLLOWING === 4. - expect(copyBtn.compareDocumentPosition(firstPos) & 4).toBeTruthy(); - expect(firstPos.compareDocumentPosition(saveBtn) & 4).toBeTruthy(); - }); -}); diff --git a/web-mobile/js/__tests__/render/match_lineup_side_editor_use_previous.render.test.jsx b/web-mobile/js/__tests__/render/match_lineup_side_editor_use_previous.render.test.jsx new file mode 100644 index 000000000..5c0650fcc --- /dev/null +++ b/web-mobile/js/__tests__/render/match_lineup_side_editor_use_previous.render.test.jsx @@ -0,0 +1,172 @@ +// A team carries the lineup of its previous team match by default (operator +// ruling 2026-10-05), so the at-court lineup panel no longer offers "Copy from +// previous match". What it offers instead, and only on a match's OWN lineup, is +// "Use the previous match's lineup": it removes the match's own lineup and +// shows the one the team carries again. + +import React from 'react'; +import { render, act, fireEvent } from '@testing-library/react'; +import { describe, it, expect, beforeAll, beforeEach, afterAll, vi } from 'vitest'; +import { installWindowStubs } from '../helpers/stub_globals.js'; + +const SQUAD = [ + { id: 'mem-1', index: 1, name: 'Aoki' }, + { id: 'mem-2', index: 2, name: 'Sato' }, + { id: 'mem-3', index: 3, name: 'Ito' }, +]; +const NAMES = { positions: { 1: 'Aoki', 2: 'Sato', 3: 'Ito' }, memberIds: { 1: 'mem-1', 2: 'mem-2', 3: 'mem-3' } }; +const OWN = { teamId: 'uuid-grouped', matchId: 'Pool D-1', ...NAMES, sourceMatchId: 'Pool D-1', saved: true }; +const OWN_OTHER_NAMES = { ...OWN, positions: { 1: 'Mori' }, memberIds: {} }; +const CARRIED = { teamId: 'uuid-grouped', matchId: 'Pool D-0', ...NAMES, sourceMatchId: 'Pool D-0', saved: true }; +const STARTING = { teamId: 'uuid-grouped', round: 0, ...NAMES, sourceRound: 0, saved: true }; + +const COMP = { id: 'comp-1', name: 'Team Event', kind: 'team', teamSize: 3 }; +const TEAM = { id: 'uuid-grouped', name: 'Grouped Team', number: 'T5' }; +const MATCH = { + id: 'Pool D-1', compId: 'comp-1', phase: 'pool', poolName: 'Pool D', + sideA: { id: 'uuid-grouped', name: 'Grouped Team' }, sideB: { id: 'other', name: 'Other' }, status: 'scheduled', +}; +const EARLIER = { + id: 'Pool D-0', compId: 'comp-1', phase: 'pool', poolName: 'Pool D', + sideA: { id: 'uuid-grouped', name: 'Grouped Team' }, sideB: { id: 'third', name: 'Third' }, status: 'completed', +}; + +const api = { + fetchLineupInForce: vi.fn(), + fetchSquads: vi.fn().mockResolvedValue({ 'uuid-grouped': SQUAD }), + putMatchLineup: vi.fn().mockResolvedValue({ positions: {} }), + deleteMatchLineup: vi.fn().mockResolvedValue(true), +}; +const STUBBED_GLOBALS = { + compMatches: () => [], + confirmDialog: vi.fn(), + AdminLineupHelpers: { + positionsForSize: (n) => Array.from({ length: n }, (_, i) => ({ key: String(i + 1), label: String(i + 1) })), + rosterFor: () => [], + mergeRosterWithAssigned: (base) => (Array.isArray(base) ? base : []), + teamIdOf: (t) => t?.id || t?.name || '', + resolveMemberIdsForPositions: vi.fn().mockResolvedValue({ memberIds: {}, squad: [], failures: [] }), + memberIdentityWarning: () => '', + }, + API: api, +}; + +let restoreGlobals; +let MatchLineupSideEditor; + +beforeAll(async () => { + restoreGlobals = installWindowStubs(STUBBED_GLOBALS); + ({ MatchLineupSideEditor } = await import('../../admin_schedule_lineup.jsx')); +}); +afterAll(() => restoreGlobals()); + +beforeEach(() => { + api.fetchLineupInForce.mockReset().mockResolvedValue(CARRIED); + api.deleteMatchLineup.mockReset().mockResolvedValue(true); + api.putMatchLineup.mockClear(); + window.confirmDialog.mockReset().mockResolvedValue(true); +}); + +async function mountPanel() { + let utils; + await act(async () => { + utils = render( + + ); + }); + await act(async () => { await Promise.resolve(); }); + return utils; +} + +const useButton = (utils) => utils.queryByRole('button', { name: "Use the previous match's lineup" }); + +describe('the at-court lineup panel no longer copies from a previous match', () => { + it.each([ + ['a carried lineup', CARRIED], + ['a match\'s own lineup', OWN], + ['the starting lineup', STARTING], + ['no lineup at all', null], + ])('offers no "Copy from previous match" on %s', async (_name, lineup) => { + api.fetchLineupInForce.mockResolvedValue(lineup); + const utils = await mountPanel(); + expect(utils.queryByRole('button', { name: /Copy from previous match/ })).toBeNull(); + expect(utils.queryByText(/Copy from previous/)).toBeNull(); + }); +}); + +describe('Use the previous match\'s lineup', () => { + it.each([ + ['a carried lineup', CARRIED], + ['the starting lineup', STARTING], + ['no lineup at all', null], + ])('is not offered on %s', async (_name, lineup) => { + api.fetchLineupInForce.mockResolvedValue(lineup); + const utils = await mountPanel(); + expect(useButton(utils)).toBeNull(); + }); + + it('is offered on the match\'s own lineup, above the positions and Save', async () => { + api.fetchLineupInForce.mockResolvedValue(OWN); + const utils = await mountPanel(); + const button = useButton(utils); + expect(button).toBeTruthy(); + const firstPosition = utils.container.querySelector('[data-testid^="match-lineup-pos-"]'); + const save = utils.getByRole('button', { name: /Save lineup/ }); + // Node.compareDocumentPosition: DOCUMENT_POSITION_FOLLOWING === 4. + expect(button.compareDocumentPosition(firstPosition) & 4).toBeTruthy(); + expect(firstPosition.compareDocumentPosition(save) & 4).toBeTruthy(); + }); + + it('asks first, removes the match\'s own lineup, and shows the one it carries again', async () => { + api.fetchLineupInForce.mockResolvedValueOnce(OWN_OTHER_NAMES).mockResolvedValue(CARRIED); + const utils = await mountPanel(); + expect(utils.getByText('Lineup for this match')).toBeTruthy(); + expect(utils.getByLabelText('1 player').value).toBe('Mori'); + + await act(async () => { fireEvent.click(useButton(utils)); }); + await act(async () => { await Promise.resolve(); }); + + expect(window.confirmDialog).toHaveBeenCalledTimes(1); + const confirm = window.confirmDialog.mock.calls[0][0]; + expect(confirm.message).toContain('Pool D · Match 2'); + expect(confirm.message).toContain('Later matches that have no lineup of their own follow too.'); + expect(api.deleteMatchLineup).toHaveBeenCalledWith('comp-1', 'uuid-grouped', 'Pool D-1', 'pw'); + expect(api.fetchLineupInForce).toHaveBeenCalledTimes(2); + expect(utils.getByText('Same as Pool D · Match 1')).toBeTruthy(); + expect(utils.getByLabelText('1 player').value).toBe('Aoki'); + expect(useButton(utils)).toBeNull(); + // What it now shows is what it loaded: nothing to save. + expect(utils.getByRole('button', { name: /Save lineup/ }).disabled).toBe(true); + }); + + it('leaves the side empty when nothing is left for the match to carry', async () => { + api.fetchLineupInForce.mockResolvedValueOnce(OWN).mockResolvedValue(null); + const utils = await mountPanel(); + await act(async () => { fireEvent.click(useButton(utils)); }); + await act(async () => { await Promise.resolve(); }); + expect(utils.getByText('No lineup saved yet')).toBeTruthy(); + expect(utils.getByLabelText('1 player').value).toBe(''); + }); + + it('removes nothing when the operator declines', async () => { + api.fetchLineupInForce.mockResolvedValue(OWN); + window.confirmDialog.mockResolvedValue(false); + const utils = await mountPanel(); + await act(async () => { fireEvent.click(useButton(utils)); }); + await act(async () => { await Promise.resolve(); }); + expect(api.deleteMatchLineup).not.toHaveBeenCalled(); + expect(api.fetchLineupInForce).toHaveBeenCalledTimes(1); + expect(utils.getByText('Lineup for this match')).toBeTruthy(); + }); + + it('says so when the removal is refused, and keeps the lineup shown', async () => { + api.fetchLineupInForce.mockResolvedValue(OWN); + api.deleteMatchLineup.mockRejectedValue(new Error('Failed to delete match lineup')); + const utils = await mountPanel(); + await act(async () => { fireEvent.click(useButton(utils)); }); + await act(async () => { await Promise.resolve(); }); + expect(utils.getByText('Failed to delete match lineup')).toBeTruthy(); + expect(utils.getByText('Lineup for this match')).toBeTruthy(); + expect(utils.getByLabelText('1 player').value).toBe('Aoki'); + }); +}); diff --git a/web-mobile/js/__tests__/render/match_viewer_modal_opened_tap.render.test.jsx b/web-mobile/js/__tests__/render/match_viewer_modal_opened_tap.render.test.jsx new file mode 100644 index 000000000..9791a39ff --- /dev/null +++ b/web-mobile/js/__tests__/render/match_viewer_modal_opened_tap.render.test.jsx @@ -0,0 +1,147 @@ +// bc-cfbd: the public MatchViewerModal, opened by a tap on a bracket card, +// survives the bounce of that tap. The second click of a double tap lands on +// the fresh backdrop one render later, and the backdrop's onClick is the +// modal's dismiss, which closed the card at once. tap_guard.jsx's +// useOpenedTapGuard swallows every pointer click in the layer for +// TAP_BOUNCE_MS after it opens; a deliberate tap after the window acts as +// before, and keyboard activation (detail 0) is never swallowed. +// +// Mounted through the real door, the competition page's Bracket tab. The card +// is a layer of its own when the self-run score editor closes (the editor +// replaces it while open), so the tap that closes the editor has a bounce of +// its own to survive. +// +// Pointer taps pass detail: 1 (helpers/tap_events.js), or the guard exempts +// them and these tests could never go red. +import React from 'react'; +import { render, act, cleanup, screen, within } from '@testing-library/react'; +import { describe, it, expect, vi, beforeAll, afterAll, beforeEach, afterEach } from 'vitest'; +import { installWindowStubs } from '../helpers/stub_globals.js'; +import { TAP_BOUNCE_MS } from '../../tap_guard.jsx'; +import { pointerTap, keyboardClick } from '../helpers/tap_events.js'; + +function ProbeScoreEditor({ onClose }) { + return ( +
+ +
+ ); +} + +// The tree stub hands back the row it was given, as BracketTree does. +function StubBracketTree({ rounds, onMatchClick }) { + return ( +
+ {rounds.flat().map((m) => ( + + ))} +
+ ); +} + +const STUBBED_GLOBALS = { + ScoreEditorModal: ProbeScoreEditor, + BracketTree: StubBracketTree, + bracketRoundLabel: () => 'Final', + matchStateCell: () => 'vs', + API: { fetchCompetitionDetails: vi.fn().mockResolvedValue(null), recordScore: vi.fn() }, + Term: ({ children }) => {children}, + GlossaryHint: ({ name }) => , + // jsdom has no ResizeObserver; the Bracket tab watches its canvas with one. + ResizeObserver: class { observe() {} disconnect() {} }, +}; + +let restoreGlobals; +let ViewerCompetition; + +beforeAll(async () => { + restoreGlobals = installWindowStubs(STUBBED_GLOBALS); + ({ ViewerCompetition } = await import('../../viewer_competition.jsx')); +}); + +afterAll(() => restoreGlobals()); + +beforeEach(() => { vi.useFakeTimers(); }); +afterEach(() => { cleanup(); vi.useRealTimers(); }); + +const final = { + id: 'k1', round: 0, status: 'running', court: 'A', + sideA: { id: 'p1', name: 'Yamada' }, sideB: { id: 'p2', name: 'Tanaka' }, + modifiedAt: 100, ipponsA: [], ipponsB: [], +}; + +const competition = { + id: 'c1', name: 'Open', kind: 'individual', teamSize: 0, format: 'knockout', + status: 'knockout', startTime: '09:00', courts: ['A'], players: [], +}; + +const wait = (ms) => act(async () => { vi.advanceTimersByTime(ms); }); +const dialog = () => screen.queryByRole('dialog'); +const backdrop = () => document.querySelector('.modal-backdrop'); +const closeCard = () => within(screen.getByRole('dialog')).getByRole('button', { name: 'Close' }); + +async function openTheMatch() { + await act(async () => { + render( + , + ); + }); + await pointerTap(screen.getByRole('button', { name: 'open k1' })); + expect(dialog()).not.toBeNull(); +} + +describe('the match modal opened by a tap', () => { + it('survives the bounce landing on its backdrop', async () => { + await openTheMatch(); + await wait(30); + await pointerTap(backdrop()); + expect(dialog()).not.toBeNull(); + }); + + it('survives the bounce landing on a control inside it', async () => { + await openTheMatch(); + await wait(30); + await pointerTap(closeCard()); + expect(dialog()).not.toBeNull(); + }); + + it('is dismissed by a deliberate tap on the backdrop after the window', async () => { + await openTheMatch(); + await wait(TAP_BOUNCE_MS + 50); + await pointerTap(backdrop()); + expect(dialog()).toBeNull(); + }); + + it('is dismissed at once from the keyboard (detail 0 is never a bounce)', async () => { + await openTheMatch(); + await wait(30); + await keyboardClick(closeCard()); + expect(dialog()).toBeNull(); + }); + + it('survives the bounce of the tap that closes the score editor', async () => { + await openTheMatch(); + await wait(TAP_BOUNCE_MS + 50); + await pointerTap(screen.getByRole('button', { name: 'Report result' })); + expect(screen.getByTestId('probe-score-editor')).toBeTruthy(); + expect(dialog()).toBeNull(); + + await pointerTap(screen.getByRole('button', { name: 'Close editor' })); + expect(dialog()).not.toBeNull(); + await wait(30); + await pointerTap(backdrop()); + expect(dialog()).not.toBeNull(); + }); +}); diff --git a/web-mobile/js/__tests__/render/not_landed_banner_call_sites.render.test.jsx b/web-mobile/js/__tests__/render/not_landed_banner_call_sites.render.test.jsx index 84ff359c2..040742e36 100644 --- a/web-mobile/js/__tests__/render/not_landed_banner_call_sites.render.test.jsx +++ b/web-mobile/js/__tests__/render/not_landed_banner_call_sites.render.test.jsx @@ -27,7 +27,6 @@ const STUBBED_GLOBALS = { isTextEntry: () => false, isInteractiveTarget: () => false, confirmDialog: vi.fn().mockResolvedValue(true), - resolveRoundIndex: () => 0, API: {}, AdminLineupHelpers: { rosterFor: vi.fn().mockReturnValue([]) }, compMatches: () => [], diff --git a/web-mobile/js/__tests__/render/not_saved_banner_no_retry.render.test.jsx b/web-mobile/js/__tests__/render/not_saved_banner_no_retry.render.test.jsx index c944a5a7a..1cd2eab01 100644 --- a/web-mobile/js/__tests__/render/not_saved_banner_no_retry.render.test.jsx +++ b/web-mobile/js/__tests__/render/not_saved_banner_no_retry.render.test.jsx @@ -31,7 +31,6 @@ const STUBBED_GLOBALS = { isTextEntry: () => false, isInteractiveTarget: () => false, confirmDialog: vi.fn().mockResolvedValue(true), - resolveRoundIndex: () => 0, API: {}, AdminLineupHelpers: { rosterFor: vi.fn().mockReturnValue([]) }, compMatches: () => [], diff --git a/web-mobile/js/__tests__/render/overlay_editor_opened_tap.render.test.jsx b/web-mobile/js/__tests__/render/overlay_editor_opened_tap.render.test.jsx new file mode 100644 index 000000000..da70bcb76 --- /dev/null +++ b/web-mobile/js/__tests__/render/overlay_editor_opened_tap.render.test.jsx @@ -0,0 +1,127 @@ +// bc-cfbd: an overlay score editor opened by a tap survives the bounce of that +// tap. The second click of a double tap lands on the fresh backdrop one render +// later, and the backdrop's onClick is the editor's dismiss, which closed an +// untouched editor silently. tap_guard.jsx's useOpenedTapGuard swallows it for +// TAP_BOUNCE_MS; a deliberate tap after the window dismisses as before. The +// inline court-console panel has no backdrop and is not covered. +// +// Pointer taps pass detail: 1 (helpers/tap_events.js), or the guard exempts +// them and these tests could never go red. +import React from 'react'; +import { render, act, cleanup } from '@testing-library/react'; +import { describe, it, expect, vi, beforeAll, afterAll, beforeEach, afterEach } from 'vitest'; +import { installWindowStubs } from '../helpers/stub_globals.js'; +import { TAP_BOUNCE_MS } from '../../tap_guard.jsx'; +import { pointerTap, keyboardClick } from '../helpers/tap_events.js'; + +const STUBBED_GLOBALS = { + isHikiwake: () => false, + arraysEqual: (a, b) => a.length === b.length && a.every((v, i) => v === b[i]), + isKikenDecision: () => false, + isTextEntry: () => false, + isInteractiveTarget: () => false, + confirmDialog: vi.fn().mockResolvedValue(true), + API: { + fetchCompetitionDetails: vi.fn().mockResolvedValue(null), + recordScore: vi.fn().mockResolvedValue(undefined), + recordDaihyosen: vi.fn(), + removeDaihyosen: vi.fn(), + putMatchLineup: vi.fn(), + recordDecision: vi.fn(), + }, + AdminLineupHelpers: { rosterFor: vi.fn().mockReturnValue([]) }, + compMatches: () => [], + Term: ({ children }) => {children}, + GlossaryHint: ({ name }) => , +}; + +let restoreGlobals; +let ScoreEditorModal; + +beforeAll(async () => { + restoreGlobals = installWindowStubs(STUBBED_GLOBALS); + await import('../../admin_scoring_modal.jsx'); + ScoreEditorModal = window.ScoreEditorModal; +}); + +afterAll(() => restoreGlobals()); + +beforeEach(() => { vi.useFakeTimers(); }); +afterEach(() => { cleanup(); vi.useRealTimers(); }); + +const base = { + status: 'running', + phase: 'pool', + poolName: 'Pool 1', + court: 'A', +}; + +const EDITORS = { + individual: { + id: 'm-ind', + ...base, + sideA: { id: 'p1', name: 'Yamada' }, + sideB: { id: 'p2', name: 'Tanaka' }, + }, + team: { + id: 'm-team', + ...base, + compKind: 'team', + teamSize: 3, + sideA: { id: 'teamA', name: 'Team A' }, + sideB: { id: 'teamB', name: 'Team B' }, + }, + engi: { + id: 'm-engi', + ...base, + compEngi: true, + sideA: { id: 'p1', name: 'Aka One - Aka Two' }, + sideB: { id: 'p2', name: 'Shiro One - Shiro Two' }, + }, +}; + +const wait = (ms) => act(async () => { vi.advanceTimersByTime(ms); }); +const closeButton = (container) => ( + container.querySelector('[data-testid="engi-close-btn"]') + || [...container.querySelectorAll('button')].find((b) => b.textContent.includes('Close')) +); +const backdrop = (container) => container.querySelector('[data-testid="scoring-modal-root"]'); + +function mount(match, props = {}) { + const onClose = vi.fn(); + const utils = render( + , + ); + return { ...utils, onClose }; +} + +describe.each(Object.entries(EDITORS))('the %s overlay editor opened by a tap', (_kind, match) => { + it('survives the bounce landing on its backdrop', async () => { + const { container, onClose } = mount(match); + await wait(30); + await pointerTap(backdrop(container)); + expect(onClose).not.toHaveBeenCalled(); + expect(backdrop(container)).not.toBeNull(); + }); + + it('does not swallow a tap on a control inside the editor', async () => { + const { container, onClose } = mount(match); + await wait(30); + await pointerTap(closeButton(container)); + expect(onClose).toHaveBeenCalledTimes(1); + }); + + it('is dismissed by a deliberate tap on the backdrop after the window', async () => { + const { container, onClose } = mount(match); + await wait(TAP_BOUNCE_MS + 50); + await pointerTap(backdrop(container)); + expect(onClose).toHaveBeenCalledTimes(1); + }); + + it('is dismissed at once from the keyboard (detail 0 is never a bounce)', async () => { + const { container, onClose } = mount(match); + await wait(30); + await keyboardClick(backdrop(container)); + expect(onClose).toHaveBeenCalledTimes(1); + }); +}); diff --git a/web-mobile/js/__tests__/render/participant_edit_dialog_opened_tap.render.test.jsx b/web-mobile/js/__tests__/render/participant_edit_dialog_opened_tap.render.test.jsx new file mode 100644 index 000000000..0f2aa0dbf --- /dev/null +++ b/web-mobile/js/__tests__/render/participant_edit_dialog_opened_tap.render.test.jsx @@ -0,0 +1,77 @@ +// bc-cfbd: the participant Edit dialog, opened by a tap on a row's pencil, +// survives the bounce of that tap. The second click of a double tap lands on +// the fresh backdrop one render later, and the backdrop's onClick is the +// dialog's dismiss, which closed an untouched dialog at once. tap_guard.jsx's +// useOpenedTapGuard swallows every pointer click in the layer for +// TAP_BOUNCE_MS after it opens; a deliberate tap after the window acts as +// before, and keyboard activation (detail 0) is never swallowed. +// +// The hook lives in AdminParticipants, which outlives the dialog, so the stamp +// has to be taken each time the dialog mounts, not once with the page. +// +// Pointer taps pass detail: 1 (helpers/tap_events.js), or the guard exempts +// them and these tests could never go red. +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { act, cleanup, screen, within } from '@testing-library/react'; +import { TAP_BOUNCE_MS } from '../../tap_guard.jsx'; +import { pointerTap, keyboardClick } from '../helpers/tap_events.js'; +import { installParticipantsHarness, makeParticipantsCompetition, mountParticipants } from './admin_participants_mount_harness.jsx'; + +installParticipantsHarness(); + +beforeEach(() => { vi.useFakeTimers(); }); +afterEach(() => { cleanup(); vi.useRealTimers(); }); + +const wait = (ms) => act(async () => { vi.advanceTimersByTime(ms); }); +const dialog = () => screen.queryByRole('dialog'); +const cancel = () => within(screen.getByRole('dialog')).getByText('Cancel'); + +async function mountAndOpen() { + const { container } = await mountParticipants(makeParticipantsCompetition()); + const open = () => pointerTap(container.querySelector('button[aria-label="Edit Alice"]')); + await open(); + expect(dialog()).not.toBeNull(); + return open; +} + +describe('the participant Edit dialog opened by a tap', () => { + it('survives the bounce landing on its backdrop', async () => { + await mountAndOpen(); + await wait(30); + await pointerTap(dialog()); + expect(dialog()).not.toBeNull(); + }); + + it('survives the bounce landing on a control inside it', async () => { + await mountAndOpen(); + await wait(30); + await pointerTap(cancel()); + expect(dialog()).not.toBeNull(); + }); + + it('is dismissed by a deliberate tap on the backdrop after the window', async () => { + await mountAndOpen(); + await wait(TAP_BOUNCE_MS + 50); + await pointerTap(dialog()); + expect(dialog()).toBeNull(); + }); + + it('is dismissed at once from the keyboard (detail 0 is never a bounce)', async () => { + await mountAndOpen(); + await wait(30); + await keyboardClick(cancel()); + expect(dialog()).toBeNull(); + }); + + it('takes a fresh window each time it opens', async () => { + const open = await mountAndOpen(); + await wait(TAP_BOUNCE_MS + 50); + await pointerTap(cancel()); + expect(dialog()).toBeNull(); + + await open(); + await wait(30); + await pointerTap(dialog()); + expect(dialog()).not.toBeNull(); + }); +}); diff --git a/web-mobile/js/__tests__/render/queued_write_banner.render.test.jsx b/web-mobile/js/__tests__/render/queued_write_banner.render.test.jsx index 48c8144cb..350f29f67 100644 --- a/web-mobile/js/__tests__/render/queued_write_banner.render.test.jsx +++ b/web-mobile/js/__tests__/render/queued_write_banner.render.test.jsx @@ -25,7 +25,6 @@ const STUBBED_GLOBALS = { isTextEntry: () => false, isInteractiveTarget: () => false, confirmDialog: vi.fn().mockResolvedValue(true), - resolveRoundIndex: () => 0, API: {}, AdminLineupHelpers: { rosterFor: vi.fn().mockReturnValue([]) }, compMatches: () => [], diff --git a/web-mobile/js/__tests__/render/recorded_withdrawal_reopen.render.test.jsx b/web-mobile/js/__tests__/render/recorded_withdrawal_reopen.render.test.jsx index f278caa78..ea95bc1de 100644 --- a/web-mobile/js/__tests__/render/recorded_withdrawal_reopen.render.test.jsx +++ b/web-mobile/js/__tests__/render/recorded_withdrawal_reopen.render.test.jsx @@ -26,7 +26,6 @@ const STUBBED_GLOBALS = { isTextEntry: () => false, isInteractiveTarget: () => false, confirmDialog: vi.fn().mockResolvedValue(true), - resolveRoundIndex: () => 0, API: {}, AdminLineupHelpers: { rosterFor: vi.fn().mockReturnValue([]) }, compMatches: () => [], diff --git a/web-mobile/js/__tests__/render/remove_withdrawal_correction.render.test.jsx b/web-mobile/js/__tests__/render/remove_withdrawal_correction.render.test.jsx index 5cf8cec45..9be885867 100644 --- a/web-mobile/js/__tests__/render/remove_withdrawal_correction.render.test.jsx +++ b/web-mobile/js/__tests__/render/remove_withdrawal_correction.render.test.jsx @@ -22,7 +22,6 @@ const STUBBED_GLOBALS = { isTextEntry: () => false, isInteractiveTarget: () => false, confirmDialog: vi.fn().mockResolvedValue(true), - resolveRoundIndex: () => 0, poolLabel: (m) => m.poolName || 'Pool', API: {}, AdminLineupHelpers: { rosterFor: vi.fn().mockReturnValue([]) }, diff --git a/web-mobile/js/__tests__/render/score_editor_dismiss_prompt.render.test.jsx b/web-mobile/js/__tests__/render/score_editor_dismiss_prompt.render.test.jsx index 8bd800850..44565e1c5 100644 --- a/web-mobile/js/__tests__/render/score_editor_dismiss_prompt.render.test.jsx +++ b/web-mobile/js/__tests__/render/score_editor_dismiss_prompt.render.test.jsx @@ -29,7 +29,6 @@ const STUBBED_GLOBALS = { isTextEntry: () => false, isInteractiveTarget: () => false, confirmDialog: vi.fn().mockResolvedValue(true), - resolveRoundIndex: () => 0, API: { fetchCompetitionDetails: vi.fn().mockResolvedValue(null), recordScore: vi.fn().mockResolvedValue(undefined), diff --git a/web-mobile/js/__tests__/render/score_editor_dispatch.render.test.jsx b/web-mobile/js/__tests__/render/score_editor_dispatch.render.test.jsx index 8d86d0036..57ac8fa30 100644 --- a/web-mobile/js/__tests__/render/score_editor_dispatch.render.test.jsx +++ b/web-mobile/js/__tests__/render/score_editor_dispatch.render.test.jsx @@ -61,7 +61,6 @@ const STUBBED_GLOBALS = { isTextEntry: () => false, isInteractiveTarget: () => false, confirmDialog: vi.fn().mockResolvedValue(true), - resolveRoundIndex: () => 0, API: { fetchCompetitionDetails: vi.fn().mockResolvedValue(null), recordScore: vi.fn(), diff --git a/web-mobile/js/__tests__/render/score_editor_mount_sites.render.test.jsx b/web-mobile/js/__tests__/render/score_editor_mount_sites.render.test.jsx index 73186f172..9570b7d2b 100644 --- a/web-mobile/js/__tests__/render/score_editor_mount_sites.render.test.jsx +++ b/web-mobile/js/__tests__/render/score_editor_mount_sites.render.test.jsx @@ -78,7 +78,6 @@ const STUBBED_GLOBALS = { compMatches: () => [], startPatch: () => ({ status: 'running', winner: null }), confirmDialog: vi.fn().mockResolvedValue(true), - resolveRoundIndex: () => 0, PoolsViewer: () => null, // per-test override drives pools open LeagueStandingsViewer: () => null, API: { diff --git a/web-mobile/js/__tests__/render/score_editor_seed_from_score_string.render.test.jsx b/web-mobile/js/__tests__/render/score_editor_seed_from_score_string.render.test.jsx index 0230b0d4d..187e4eba9 100644 --- a/web-mobile/js/__tests__/render/score_editor_seed_from_score_string.render.test.jsx +++ b/web-mobile/js/__tests__/render/score_editor_seed_from_score_string.render.test.jsx @@ -40,7 +40,6 @@ const STUBBED_GLOBALS = { isTextEntry: () => false, isInteractiveTarget: () => false, confirmDialog: vi.fn().mockResolvedValue(true), - resolveRoundIndex: () => 0, API: { fetchCompetitionDetails: vi.fn().mockResolvedValue(null), recordScore: vi.fn(), diff --git a/web-mobile/js/__tests__/render/self_run_daihyosen.render.test.jsx b/web-mobile/js/__tests__/render/self_run_daihyosen.render.test.jsx index f01b23db1..3e07fd9c4 100644 --- a/web-mobile/js/__tests__/render/self_run_daihyosen.render.test.jsx +++ b/web-mobile/js/__tests__/render/self_run_daihyosen.render.test.jsx @@ -25,7 +25,6 @@ const STUBBED_GLOBALS = { isTextEntry: () => false, isInteractiveTarget: () => false, confirmDialog: vi.fn().mockResolvedValue(true), - resolveRoundIndex: () => 0, API: {}, compMatches: () => [], compMatchesForCompetition: () => [], @@ -67,8 +66,7 @@ beforeEach(() => { window.API = { fetchCompetitionDetails: vi.fn().mockResolvedValue({ id: 'c1', config: { format: 'knockout', players: [] } }), fetchSquads: vi.fn(), - fetchMatchLineup: vi.fn(async () => null), - fetchTeamLineup: vi.fn(async () => null), + fetchLineupInForce: vi.fn(async () => null), putMatchLineup: vi.fn(), // Every write lands, as the server now answers the public page. recordScore: vi.fn(async (_c, _id, patch) => ({ status: patch.status })), diff --git a/web-mobile/js/__tests__/render/self_run_team_members.render.test.jsx b/web-mobile/js/__tests__/render/self_run_team_members.render.test.jsx index d83f2bf81..6ce9eee4d 100644 --- a/web-mobile/js/__tests__/render/self_run_team_members.render.test.jsx +++ b/web-mobile/js/__tests__/render/self_run_team_members.render.test.jsx @@ -20,7 +20,6 @@ const STUBBED_GLOBALS = { isTextEntry: () => false, isInteractiveTarget: () => false, confirmDialog: vi.fn().mockResolvedValue(true), - resolveRoundIndex: () => 0, API: {}, AdminLineupHelpers: { rosterFor: vi.fn().mockReturnValue([]) }, compMatches: () => [], diff --git a/web-mobile/js/__tests__/render/self_run_team_names.render.test.jsx b/web-mobile/js/__tests__/render/self_run_team_names.render.test.jsx index 1021c813f..7b0569407 100644 --- a/web-mobile/js/__tests__/render/self_run_team_names.render.test.jsx +++ b/web-mobile/js/__tests__/render/self_run_team_names.render.test.jsx @@ -11,6 +11,8 @@ import React from 'react'; import { render, act, fireEvent, screen, waitFor } from '@testing-library/react'; import { describe, it, expect, vi, beforeAll, afterAll, beforeEach } from 'vitest'; import { installWindowStubs } from '../helpers/stub_globals.js'; +import { answered } from '../helpers/team_members.js'; +import { lineupPutStubByTeam } from '../helpers/lineup_server.js'; const STUBBED_GLOBALS = { isHikiwake: () => false, @@ -19,7 +21,6 @@ const STUBBED_GLOBALS = { isTextEntry: () => false, isInteractiveTarget: () => false, confirmDialog: vi.fn().mockResolvedValue(true), - resolveRoundIndex: () => 0, API: {}, compMatches: () => [], compMatchesForCompetition: () => [], @@ -42,16 +43,20 @@ afterAll(() => restoreGlobals()); const blank = (p) => [1, 2, 3, 4, 5].map((i) => ({ id: `${p}${i}`, index: i, name: '' })); const named = (p, names) => names.map((name, i) => ({ id: `${p}${i + 1}`, index: i + 1, name })); +// What the server holds, by team: a save names the position it changed and the server +// answers the lineup it holds then. +let lineups; + beforeEach(() => { + lineups = {}; window.API = { fetchCompetitionDetails: vi.fn().mockResolvedValue({ id: 'c1', config: { format: 'mixed', players: [] } }), fetchSquads: vi.fn(), - fetchMatchLineup: vi.fn(async () => null), - fetchTeamLineup: vi.fn(async () => null), - // What the server now answers the public page. - putMatchLineup: vi.fn(async (_c, teamId, matchId, positions, _pw, memberIds) => ({ teamId, matchId, positions, memberIds })), - renameTeamMember: vi.fn(async () => true), - addTeamMember: vi.fn(async (_c, _t, name) => ({ id: 'new-1', index: 6, name })), + fetchLineupInForce: vi.fn(async () => null), + putMatchLineup: lineupPutStubByTeam(() => lineups), + // The server answers a member write with the member it holds, stamped. + renameTeamMember: vi.fn(async (_c, _t, id, name) => answered({ id, index: Number(id.slice(1)) }, { name })), + addTeamMember: vi.fn(async (_c, _t, name) => answered({ id: 'new-1', index: 6 }, { name })), recordScore: vi.fn(async () => ({ status: 'running' })), hasPendingTerminalWrite: () => false, notePendingEdit: () => () => {}, @@ -96,7 +101,7 @@ describe.each(['fixed', 'kachinuki'])('a participant names bout 1 of a %s team m await typeName(akaBout1(), 'Mei Ito'); expect(window.API.renameTeamMember).toHaveBeenCalledWith('c1', 'team-A', 'a1', 'Mei Ito', ''); - expect(window.API.putMatchLineup).toHaveBeenCalledWith('c1', 'team-A', 'm1', { senpo: 'Mei Ito' }, '', { senpo: 'a1' }); + expect(window.API.putMatchLineup).toHaveBeenCalledWith('c1', 'team-A', 'm1', { senpo: 'Mei Ito' }, '', { senpo: 'a1' }, ['senpo']); noError(); expect(akaBout1().value).toBe('Mei Ito'); }); @@ -116,7 +121,7 @@ describe('a participant fills in a fixed-order row from the list (bc-dhas)', () await act(async () => { fireEvent.click(kai); }); await act(async () => { await new Promise((r) => setTimeout(r, 0)); }); - expect(window.API.putMatchLineup).toHaveBeenCalledWith('c1', 'team-A', 'm1', { senpo: 'Kai Mori' }, '', { senpo: 'a2' }); + expect(window.API.putMatchLineup).toHaveBeenCalledWith('c1', 'team-A', 'm1', { senpo: 'Kai Mori' }, '', { senpo: 'a2' }, ['senpo']); expect(window.API.renameTeamMember).not.toHaveBeenCalled(); noError(); expect(akaBout1().value).toBe('Kai Mori'); @@ -135,7 +140,7 @@ describe('a participant fills in a fixed-order row from the list (bc-dhas)', () await act(async () => { await new Promise((r) => setTimeout(r, 0)); }); expect(window.API.addTeamMember).toHaveBeenCalledWith('c1', 'team-A', 'Jun Oda', ''); - expect(window.API.putMatchLineup).toHaveBeenCalledWith('c1', 'team-A', 'm1', { senpo: 'Jun Oda' }, '', { senpo: 'new-1' }); + expect(window.API.putMatchLineup).toHaveBeenCalledWith('c1', 'team-A', 'm1', { senpo: 'Jun Oda' }, '', { senpo: 'new-1' }, ['senpo']); noError(); expect(akaBout1().value).toBe('Jun Oda'); }); diff --git a/web-mobile/js/__tests__/render/start_then_strike_saves.render.test.jsx b/web-mobile/js/__tests__/render/start_then_strike_saves.render.test.jsx index 20895b352..c63f66a12 100644 --- a/web-mobile/js/__tests__/render/start_then_strike_saves.render.test.jsx +++ b/web-mobile/js/__tests__/render/start_then_strike_saves.render.test.jsx @@ -18,7 +18,6 @@ const STUBBED_GLOBALS = { isTextEntry: () => false, isInteractiveTarget: () => false, confirmDialog: vi.fn().mockResolvedValue(true), - resolveRoundIndex: () => 0, API: { fetchCompetitionDetails: vi.fn().mockResolvedValue({ id: 'comp1', config: { format: 'mixed', teamMatchType: 'kachinuki', naginata: false, players: [] }, diff --git a/web-mobile/js/__tests__/render/team_bout_row_records_no_team_name.render.test.jsx b/web-mobile/js/__tests__/render/team_bout_row_records_no_team_name.render.test.jsx index 90d3e27c2..acdfbf579 100644 --- a/web-mobile/js/__tests__/render/team_bout_row_records_no_team_name.render.test.jsx +++ b/web-mobile/js/__tests__/render/team_bout_row_records_no_team_name.render.test.jsx @@ -29,7 +29,6 @@ const STUBBED_GLOBALS = { isTextEntry: () => false, isInteractiveTarget: () => false, confirmDialog: vi.fn().mockResolvedValue(true), - resolveRoundIndex: () => 0, API: { fetchCompetitionDetails: vi.fn().mockResolvedValue(null), recordScore: vi.fn().mockResolvedValue(undefined), diff --git a/web-mobile/js/__tests__/render/team_daihyosen_silence.render.test.jsx b/web-mobile/js/__tests__/render/team_daihyosen_silence.render.test.jsx index c657e0238..24fd64844 100644 --- a/web-mobile/js/__tests__/render/team_daihyosen_silence.render.test.jsx +++ b/web-mobile/js/__tests__/render/team_daihyosen_silence.render.test.jsx @@ -42,7 +42,6 @@ const STUBBED_GLOBALS = { isTextEntry: () => false, isInteractiveTarget: () => false, confirmDialog: vi.fn().mockResolvedValue(true), - resolveRoundIndex: () => 0, API: { fetchCompetitionDetails: vi.fn().mockResolvedValue(null), recordScore: vi.fn().mockResolvedValue(undefined), diff --git a/web-mobile/js/__tests__/render/team_editor_clear_hint.render.test.jsx b/web-mobile/js/__tests__/render/team_editor_clear_hint.render.test.jsx index 91e68ee32..0b2d596e9 100644 --- a/web-mobile/js/__tests__/render/team_editor_clear_hint.render.test.jsx +++ b/web-mobile/js/__tests__/render/team_editor_clear_hint.render.test.jsx @@ -3,13 +3,16 @@ // Operator ruling 2026-09-16: a team encounter carries ONE hint, placed after // the team names and before the first bout. The individual board states the // same sentence under its own marks, but the team sheet repeats a row per -// bout, so repeating it there would be noise. It shows on the SAME condition -// as the individual board's: only while there is a mark to clear. +// bout, so repeating it there would be noise. It is SHOWN on the same condition +// as the individual board's: only while there is a mark to clear. Operator +// ruling 2026-10-06, "Hold its space": otherwise it is not removed but held +// hidden, so the bout rows never move when it comes and goes. // // What this pins, and why each part would rot silently otherwise: -// 1. Absent with no marks. Without this the hint could become unconditional -// and nothing would fail. -// 2. Present once a bout carries a mark. +// 1. Held hidden with no marks: still in the layout, not announced. Without +// this the hint could become unconditional, or go back to being added +// and removed, and nothing would fail. +// 2. Shown once a bout carries a mark. // 3. ORDER: after the team header, before the first bout row. The ruling is // about placement, so asserting mere presence would pass on a hint // rendered at the bottom of the sheet. @@ -18,9 +21,10 @@ // carries its own "click to undo" title, which is a different sentence. import React from 'react'; -import { render, act } from '@testing-library/react'; +import { render, act, fireEvent } from '@testing-library/react'; import { describe, it, expect, vi, beforeAll, afterAll } from 'vitest'; import { installWindowStubs } from '../helpers/stub_globals.js'; +import { cssBlock, readStylesheet } from '../helpers/source.js'; const STUBBED_GLOBALS = { isHikiwake: () => false, @@ -29,7 +33,6 @@ const STUBBED_GLOBALS = { isTextEntry: () => false, isInteractiveTarget: () => false, confirmDialog: vi.fn().mockResolvedValue(true), - resolveRoundIndex: () => 0, API: { fetchCompetitionDetails: vi.fn().mockResolvedValue(null), recordScore: vi.fn().mockResolvedValue(undefined), @@ -105,21 +108,61 @@ const THREE_MARKS = [ { position: 3, sideA: 'A3', sideB: 'B3', ipponsA: ['D'], ipponsB: ['M'] }, ]; +// Operator ruling 2026-10-06, "Hold its space": the hint is always in the layout and is +// hidden while no mark exists to clear, so the bout rows never move when it comes and +// goes. Hidden is visibility:hidden (the .holds-space utility), which keeps the box and +// takes the element out of the accessibility tree: it is not announced. jsdom loads no +// stylesheet, so the class is what the DOM shows, and the rule is pinned below. +const HELD = 'holds-space'; +const heldBy = (hint) => hint.classList.contains(HELD); + describe('team editor: the clear-a-mark hint', () => { - it('does not render while no bout carries a mark', async () => { + it('is rendered but held hidden while no bout carries a mark, so it keeps its space', async () => { const { container } = await mount(null); - expect(container.querySelectorAll(HINT)).toHaveLength(0); + const hint = container.querySelector(HINT); + expect(hint, 'always in the layout').not.toBeNull(); + expect(heldBy(hint), 'hidden, not announced').toBe(true); + expect(hint.textContent, 'the same words').toBe('Tap a scored mark to clear it'); }); - it('renders once a bout carries a mark, with the same words as the individual board', async () => { + it('is shown once a bout carries a mark, with the same words as the individual board', async () => { const { container } = await mount(ONE_MARK); const hint = container.querySelector(HINT); expect(hint).not.toBeNull(); expect(hint.textContent).toBe('Tap a scored mark to clear it'); + expect(heldBy(hint)).toBe(false); // Same owner as the individual board's line, not a second style. expect(hint.classList.contains('sb-hint')).toBe(true); }); + it('is the same element before and after the first mark, in the same place: nothing under it moves', async () => { + const { container } = await mount(null); + const before = container.querySelector(HINT); + const row = container.querySelector('.team-sub-match'); + expect(before.nextElementSibling, 'the bouts follow it').toBe(container.querySelector('.team-bouts-scroll')); + + const shiroM = [...row.querySelectorAll('.team-sub-match__side--shiro button.ipt-btn')].find((b) => b.textContent === 'M'); + await act(async () => { fireEvent.click(shiroM); }); + + const after = container.querySelector(HINT); + expect(after, 'one element throughout, not one that is added').toBe(before); + expect(heldBy(after), 'now shown').toBe(false); + expect(after.nextElementSibling).toBe(container.querySelector('.team-bouts-scroll')); + }); + + it('is held again when the last mark is taken back', async () => { + const { container } = await mount(null); + const row = container.querySelector('.team-sub-match'); + const shiroM = () => [...row.querySelectorAll('.team-sub-match__side--shiro button.ipt-btn')].find((b) => b.textContent === 'M'); + await act(async () => { fireEvent.click(shiroM()); }); + expect(heldBy(container.querySelector(HINT))).toBe(false); + + // The scored mark is the button a tap takes back. + await act(async () => { fireEvent.click(row.querySelector('button[title="Click to remove"]')); }); + + expect(heldBy(container.querySelector(HINT))).toBe(true); + }); + it('sits after the team names and before the first bout', async () => { const { container } = await mount(ONE_MARK); const hint = container.querySelector(HINT); @@ -143,6 +186,13 @@ describe('team editor: the clear-a-mark hint', () => { const { container } = await mount([ { position: -1, sideA: 'A1', sideB: 'B1', ipponsA: [], ipponsB: ['Ht'] }, ]); - expect(container.querySelectorAll(HINT)).toHaveLength(0); + expect(heldBy(container.querySelector(HINT))).toBe(true); + }); + + it('holds its space through visibility, which keeps the box and leaves the accessibility tree', () => { + const rule = cssBlock(readStylesheet(), `.${HELD}`); + expect(rule, `rule .${HELD} exists`).not.toBeNull(); + expect(rule).toMatch(/visibility:\s*hidden/); + expect(rule, 'display:none would give the space back').not.toMatch(/display:\s*none/); }); }); diff --git a/web-mobile/js/__tests__/render/team_editor_config_matrix.render.test.jsx b/web-mobile/js/__tests__/render/team_editor_config_matrix.render.test.jsx index 1703d2d63..bf149bc1a 100644 --- a/web-mobile/js/__tests__/render/team_editor_config_matrix.render.test.jsx +++ b/web-mobile/js/__tests__/render/team_editor_config_matrix.render.test.jsx @@ -31,7 +31,6 @@ const STUBBED_GLOBALS = { isTextEntry: () => false, isInteractiveTarget: () => false, confirmDialog: vi.fn().mockResolvedValue(true), - resolveRoundIndex: () => 0, API: { fetchCompetitionDetails: vi.fn().mockResolvedValue(null), recordScore: vi.fn().mockResolvedValue(undefined), @@ -369,7 +368,13 @@ describe('TeamScoreEditorModal kachinuki bout navigation', () => { expect(rows[1].querySelector('.team-sub-match__btns')).not.toBeNull(); // The collapse caret (rotated, aria-expanded=true) is the close control. const caret = screen.getByTestId('kachinuki-done-collapse-0'); - expect(caret.classList.contains('tsm-caret--open')).toBe(true); + // bc-tp44: only the GLYPH is rotated. Rotating the button itself made its + // 44x36 tap box hit-test as 36x44. + expect(caret.classList.contains('tsm-caret--open')).toBe(false); + expect(caret.classList.contains('tsm-caret-btn')).toBe(true); + const glyph = caret.querySelector('.tsm-caret.tsm-caret--open'); + expect(glyph).not.toBeNull(); + expect(glyph.getAttribute('aria-hidden')).toBe('true'); expect(caret.getAttribute('aria-expanded')).toBe('true'); }); @@ -515,7 +520,7 @@ describe('TeamScoreEditorModal: a fixed-order row with no roster metadata still // number of the blank squad member seeded for that position. afterEach(() => { delete window.API.fetchSquads; - delete window.API.fetchMatchLineup; + delete window.API.fetchLineupInForce; }); it('every numbered row renders a name input on both sides, labelled with each blank member\'s own number', async () => { @@ -609,7 +614,7 @@ describe('TeamScoreEditorModal: a fixed-order row with no roster metadata still }); // Kenji Tanaka (member b2, seeded to position 2) is placed at Chuken // (position 3) in the saved Shiro lineup. - window.API.fetchMatchLineup = vi.fn().mockImplementation(async (_compId, teamId) => ( + window.API.fetchLineupInForce = vi.fn().mockImplementation(async (_compId, teamId) => ( teamId === 'team-B' ? { positions: { chuken: 'Kenji Tanaka' }, memberIds: { chuken: 'b2' } } : null @@ -653,7 +658,7 @@ describe('TeamScoreEditorModal: a fixed-order row with no roster metadata still 'team-A': blankSquad('a'), 'team-B': squadB, }); - window.API.fetchMatchLineup = vi.fn().mockImplementation(async (_compId, teamId) => ( + window.API.fetchLineupInForce = vi.fn().mockImplementation(async (_compId, teamId) => ( teamId === 'team-B' ? { positions: { chuken: 'Kenji Tanaka' }, memberIds: { chuken: 'b2' } } : null diff --git a/web-mobile/js/__tests__/render/team_editor_data_issue.render.test.jsx b/web-mobile/js/__tests__/render/team_editor_data_issue.render.test.jsx index 1b00eb21d..3cb8a7d57 100644 --- a/web-mobile/js/__tests__/render/team_editor_data_issue.render.test.jsx +++ b/web-mobile/js/__tests__/render/team_editor_data_issue.render.test.jsx @@ -18,7 +18,6 @@ const STUBBED_GLOBALS = { isTextEntry: () => false, isInteractiveTarget: () => false, confirmDialog: vi.fn().mockResolvedValue(true), - resolveRoundIndex: () => 0, API: { fetchCompetitionDetails: vi.fn().mockResolvedValue(null), recordScore: vi.fn(), diff --git a/web-mobile/js/__tests__/render/team_editor_error_surface.render.test.jsx b/web-mobile/js/__tests__/render/team_editor_error_surface.render.test.jsx index 2ba98e203..0b904bf07 100644 --- a/web-mobile/js/__tests__/render/team_editor_error_surface.render.test.jsx +++ b/web-mobile/js/__tests__/render/team_editor_error_surface.render.test.jsx @@ -1,13 +1,16 @@ -// editorErr is the team editor's ONE inline error surface: the daihyosen -// add/remove POSTs and the inline lineup-position PUT all report through it. +// editorErr is the team editor's inline error surface for the daihyosen +// add/remove POSTs. An inline lineup-position save that is refused or fails +// reports in the bout row it was made in instead (see +// team_editor_lineup_refusal_at_row.render.test.jsx), not here. // // It used to be rendered INSIDE the "add a representative bout" block, whose // guard is `if (hasDaihyosen || !isKnockoutPhase || isKachinuki) return null`. -// Every state that can SET the error from somewhere else therefore hid it: +// Every state that could SET the error from somewhere else therefore hid it: // removing an existing daihyosen (hasDaihyosen is true, so the block that -// would have shown the failure is gone) and a failed lineup save in the -// kachinuki flow (isKachinuki is true). The operator saw the button return to -// rest and nothing else — a silent failure on a write they believe landed. +// would have shown the failure is gone) and, while a failed lineup save still +// reported here, the kachinuki flow (isKachinuki is true). The operator saw +// the button return to rest and nothing else — a silent failure on a write +// they believe landed. // // Pinned here on the remove-daihyosen path because it needs no lineup wiring: // the assertion is simply that the failure REACHES THE SCREEN. @@ -25,7 +28,6 @@ const STUBBED_GLOBALS = { isTextEntry: () => false, isInteractiveTarget: () => false, confirmDialog: vi.fn().mockResolvedValue(true), - resolveRoundIndex: () => 0, API: {}, AdminLineupHelpers: { rosterFor: vi.fn().mockReturnValue([]) }, compMatches: () => [], diff --git a/web-mobile/js/__tests__/render/team_editor_lineup_read_state.render.test.jsx b/web-mobile/js/__tests__/render/team_editor_lineup_read_state.render.test.jsx new file mode 100644 index 000000000..0e09e7d2c --- /dev/null +++ b/web-mobile/js/__tests__/render/team_editor_lineup_read_state.render.test.jsx @@ -0,0 +1,857 @@ +// A name picked in a bout row saves the one position it names, and the server puts +// it on the lineup it holds when the save arrives (operator decision 2026-10-07, +// "Only changed positions"); every later match of the team inherits that lineup (the +// carry rule). The sheet composes nothing and re-reads nothing for the write. What it +// holds for a side is what a pick is checked against (one member at one position) and +// what the rows show, so: +// +// - both teams' lineups are read together, and a team whose lineup could not +// be read is told apart from one with nothing saved (null is a read); +// - a pick on a side whose lineup was never read (its read is still out, or +// failed) reads it first and saves; it is refused, in the row, with nothing +// sent, only when that read fails, and a side never read is read again when +// the connection returns; +// - a pick on a side that was read is written at once, naming its position: no read +// of the lineup is made for it, a position set elsewhere since opening stays, and +// the lineup the server answers (the whole lineup it holds) is what the side shows; +// - when the server cannot be reached, or a save of the lineup is already waiting in +// the outbox, the pick is queued and shown on the lineup held; the API joins it to +// the queued save; +// - a read that began before a confirmed write never puts the old lineup back; +// - a lineup change announced for this competition is followed, but never in +// the middle of this sheet's own write for that side; +// - a notice for a kachinuki bout still shows, right after its row, once that +// bout is read-only. + +import React from 'react'; +import { render, act, fireEvent } from '@testing-library/react'; +import { describe, it, expect, vi, beforeAll, afterAll, beforeEach } from 'vitest'; +import { installWindowStubs } from '../helpers/stub_globals.js'; +import { answered } from '../helpers/team_members.js'; +import { lineupPutStubByTeam } from '../helpers/lineup_server.js'; +import { FETCH_TIMEOUT_MS, QUEUED_NOTICE, QUEUED_UNSAVED_NOTICE } from '../../write_result.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: {}, + compMatches: () => [], + compMatchesForCompetition: () => [], + Term: ({ children }) => {children}, + GlossaryHint: ({ name }) => , +}; + +const NOT_READ = 'The lineup could not be read, so this name was not saved. Check the connection and try again.'; + +let restoreGlobals; +let ScoreEditorModal; + +beforeAll(async () => { + restoreGlobals = installWindowStubs(STUBBED_GLOBALS); + await import('../../admin_lineup.jsx'); + await import('../../admin_scoring_modal.jsx'); + ScoreEditorModal = window.ScoreEditorModal; +}); + +afterAll(() => restoreGlobals()); + +const members = (p, names) => names.map((name, i) => ({ id: `${p}${i + 1}`, index: i + 1, name })); +const SHIRO_NAMES = ['Ren Abe', 'Kai Mori', 'Yui Sato', 'Rin Ota', 'Sho Ueda']; + +function deferred() { + let resolve; + let reject; + const promise = new Promise((res, rej) => { resolve = res; reject = rej; }); + return { promise, resolve, reject }; +} + +const flush = () => act(async () => { await new Promise((r) => setTimeout(r, 0)); }); + +// What the server holds by team, which a save lands its position on and answers whole +// (lineupPutStubByTeam); a test sets it to the lineup another device left. +let lineups; + +beforeEach(() => { + lineups = {}; + window.compMatches = () => []; + window.compMatchesForCompetition = () => []; + window.API = { + fetchCompetitionDetails: vi.fn().mockResolvedValue({ + id: 'comp1', + config: { format: 'knockout', teamMatchType: 'fixed', naginata: false, players: [] }, + }), + fetchSquads: vi.fn().mockResolvedValue({ + 'team-A': members('a', ['A One', 'A Two', 'A Three', 'A Four', 'A Five']), + 'team-B': members('b', SHIRO_NAMES), + }), + fetchLineupInForce: vi.fn(async () => null), + putMatchLineup: lineupPutStubByTeam(() => lineups), + // The server answers a member write with the member it holds, stamped. + renameTeamMember: vi.fn(async (_c, _t, id, name) => answered({ id, index: Number(id.slice(1)) || 1 }, { name })), + addTeamMember: vi.fn(async (_c, _t, name) => answered({ id: 'new-1', index: 6 }, { name })), + recordScore: vi.fn().mockResolvedValue(undefined), + recordDaihyosen: vi.fn(), + removeDaihyosen: vi.fn(), + recordDecision: vi.fn(), + hasPendingTerminalWrite: () => false, + notePendingEdit: () => () => {}, + }; +}); + +const fivePersonMatch = () => ({ + id: 'm1', + compId: 'comp1', + status: 'running', + phase: 'bracket', + court: 'A', + compKind: 'team', + teamSize: 5, + compFormat: 'knockout', + teamMatchType: 'fixed', + round: 'Semi-final', + matchNumber: 1, + sideA: { id: 'team-A', name: 'Team A' }, + sideB: { id: 'team-B', name: 'Team B' }, +}); + +async function mountFivePerson() { + let utils; + await act(async () => { + utils = render( + + ); + }); + await flush(); + return utils; +} + +const jihoRow = (container) => container.querySelectorAll('.team-sub-match')[1]; +const jihoInput = (container, color) => jihoRow(container).querySelector(`.team-sub-match__side--${color} input`); +const notices = (container) => container.querySelectorAll('[data-testid="team-editor-lineup-warning"]'); +const readsOf = (teamId) => window.API.fetchLineupInForce.mock.calls.filter((c) => c[1] === teamId).length; + +async function typeName(input, name) { + await act(async () => { + fireEvent.focus(input); + fireEvent.change(input, { target: { value: name } }); + fireEvent.keyDown(input, { key: 'Enter' }); + }); + await flush(); +} + +const announceLineupChange = (detail) => act(async () => { + window.dispatchEvent(new CustomEvent('lineup-updated', detail === undefined ? {} : { detail })); +}); + +describe('team editor: both teams\' lineups are read together', () => { + it('asks for the Aka and the Shiro lineup before either answers', async () => { + const gates = {}; + window.API.fetchLineupInForce = vi.fn((_c, teamId) => new Promise((resolve) => { gates[teamId] = resolve; })); + await mountFivePerson(); + + expect(window.API.fetchLineupInForce.mock.calls.map((c) => c[1]).sort()).toEqual(['team-A', 'team-B']); + + await act(async () => { gates['team-A'](null); gates['team-B'](null); }); + }); + + it('keeps the side that could be read when the other cannot', async () => { + window.API.fetchLineupInForce = vi.fn(async (_c, teamId) => { + if (teamId === 'team-B') throw new Error('offline'); + return null; + }); + const { container } = await mountFivePerson(); + + await typeName(jihoInput(container, 'aka'), 'A Two'); + expect(window.API.putMatchLineup, 'the readable side still saves').toHaveBeenCalledTimes(1); + expect(window.API.putMatchLineup.mock.calls[0][1]).toBe('team-A'); + expect(notices(container)).toHaveLength(0); + + await typeName(jihoInput(container, 'shiro'), 'Kai Mori'); + expect(window.API.putMatchLineup, 'the unreadable side does not').toHaveBeenCalledTimes(1); + expect(notices(container)).toHaveLength(1); + }); +}); + +describe('team editor: a side whose lineup was not read never writes one', () => { + it('refuses the pick in its row and sends nothing', async () => { + window.API.fetchLineupInForce = vi.fn().mockRejectedValue(new Error('offline')); + const { container } = await mountFivePerson(); + + await typeName(jihoInput(container, 'shiro'), 'Kai Mori'); + + expect(window.API.putMatchLineup).not.toHaveBeenCalled(); + expect(notices(container)).toHaveLength(1); + const notice = notices(container)[0]; + expect(notice.textContent).toBe(NOT_READ); + expect(notice.getAttribute('role')).toBe('alert'); + expect(notice.getAttribute('data-tone')).toBe('error'); + expect(notice.classList.contains('alert--error')).toBe(true); + expect(notice.closest('.team-sub-match')).toBe(jihoRow(container)); + expect(notice.closest('.team-sub-match__side--shiro')).not.toBeNull(); + }); + + it('treats "nothing saved" as a read: the pick is written on an empty lineup', async () => { + const { container } = await mountFivePerson(); + + await typeName(jihoInput(container, 'shiro'), 'Kai Mori'); + + expect(notices(container)).toHaveLength(0); + expect(window.API.putMatchLineup).toHaveBeenCalledTimes(1); + expect(window.API.putMatchLineup.mock.calls[0][3]).toEqual({ jiho: 'Kai Mori' }); + }); + + it('words the refusal as something to do, not as a reopen the editor may not offer', async () => { + window.API.fetchLineupInForce = vi.fn().mockRejectedValue(new Error('offline')); + const { container } = await mountFivePerson(); + + await typeName(jihoInput(container, 'shiro'), 'Kai Mori'); + + expect(notices(container)[0].textContent).toMatch(/Check the connection and try again\.$/); + expect(notices(container)[0].textContent).not.toMatch(/reopen/i); + }); + + it('refuses the pick, and frees the boxes, when the read it makes is not answered in time', async () => { + window.API.fetchLineupInForce = vi.fn(() => new Promise(() => {})); + const { container } = await mountFivePerson(); + + vi.useFakeTimers(); + try { + const input = jihoInput(container, 'shiro'); + await act(async () => { + fireEvent.focus(input); + fireEvent.change(input, { target: { value: 'Kai Mori' } }); + fireEvent.keyDown(input, { key: 'Enter' }); + }); + expect(input.disabled, 'the boxes wait while the lineup is read').toBe(true); + await act(async () => { vi.advanceTimersByTime(FETCH_TIMEOUT_MS); }); + await act(async () => { await Promise.resolve(); }); + } finally { + vi.useRealTimers(); + } + + expect(window.API.putMatchLineup).not.toHaveBeenCalled(); + expect(notices(container)[0].textContent).toBe(NOT_READ); + expect(jihoInput(container, 'shiro').disabled).toBe(false); + }); +}); + +// The reads start only once the competition has been fetched, so on a slow link +// the first pick on a side can come while its first read is still out, and after +// a read that really failed nothing but a pick (or the connection returning, or +// a change announced) reads the side again. +describe('team editor: a pick on a side not yet read reads it first', () => { + const OPEN = { positions: { senpo: 'Ren Abe' }, memberIds: { senpo: 'b1' } }; + const senpoInput = (container) => container.querySelectorAll('.team-sub-match')[0].querySelector('.team-sub-match__side--shiro input'); + + it('reads the lineup itself while the first read is still out, writes the one position, and refuses nothing', async () => { + lineups['team-B'] = OPEN; + let unanswered = 2; + window.API.fetchLineupInForce = vi.fn((_c, teamId) => { + if (unanswered > 0) { + unanswered -= 1; + return new Promise(() => {}); + } + return Promise.resolve(teamId === 'team-B' ? OPEN : null); + }); + const { container } = await mountFivePerson(); + expect(readsOf('team-B'), 'opening asked once').toBe(1); + + await typeName(jihoInput(container, 'shiro'), 'Kai Mori'); + + expect(notices(container)).toHaveLength(0); + expect(readsOf('team-B'), 'and the pick asked for itself').toBe(2); + expect(window.API.putMatchLineup).toHaveBeenCalledTimes(1); + const [, teamId, matchId, positions, , memberIds, changed] = window.API.putMatchLineup.mock.calls[0]; + expect([teamId, matchId]).toEqual(['team-B', 'm1']); + expect(positions, 'names the one position it changes').toEqual({ jiho: 'Kai Mori' }); + expect(memberIds).toEqual({ jiho: 'b2' }); + expect(changed).toEqual(['jiho']); + expect(senpoInput(container).value, 'the side shows the lineup the server answered').toBe('Ren Abe'); + }); + + it('reads the side again at the next pick after a read that failed, and saves once it can', async () => { + lineups['team-B'] = OPEN; + let healthy = false; + window.API.fetchLineupInForce = vi.fn(async (_c, teamId) => { + if (!healthy) throw new Error('offline'); + return teamId === 'team-B' ? OPEN : null; + }); + const { container } = await mountFivePerson(); + await typeName(jihoInput(container, 'shiro'), 'Kai Mori'); + expect(window.API.putMatchLineup).not.toHaveBeenCalled(); + expect(notices(container)[0].textContent).toBe(NOT_READ); + + healthy = true; + await typeName(jihoInput(container, 'shiro'), 'Kai Mori'); + + expect(notices(container)).toHaveLength(0); + expect(window.API.putMatchLineup).toHaveBeenCalledTimes(1); + expect(window.API.putMatchLineup.mock.calls[0][3]).toEqual({ jiho: 'Kai Mori' }); + expect(senpoInput(container).value, 'and the side shows the lineup the server answered').toBe('Ren Abe'); + }); + + it('shows a side that was never read once a change is announced for it', async () => { + let healthy = false; + window.API.fetchLineupInForce = vi.fn(async (_c, teamId) => { + if (!healthy) throw new Error('offline'); + return teamId === 'team-B' ? OPEN : null; + }); + const { container } = await mountFivePerson(); + expect(senpoInput(container).value).toBe(''); + + healthy = true; + await announceLineupChange({ competitionId: 'comp1' }); + + expect(senpoInput(container).value).toBe('Ren Abe'); + }); + +}); + +describe('team editor: a side never read is read again when the connection returns', () => { + const OPEN = { positions: { senpo: 'Ren Abe' }, memberIds: { senpo: 'b1' } }; + const senpoInput = (container) => container.querySelectorAll('.team-sub-match')[0].querySelector('.team-sub-match__side--shiro input'); + const connectionReturns = () => act(async () => { window.dispatchEvent(new Event('online')); }); + + it('reads each side that was never read, and shows what it reads', async () => { + let healthy = false; + window.API.fetchLineupInForce = vi.fn(async (_c, teamId) => { + if (!healthy) throw new Error('offline'); + return teamId === 'team-B' ? OPEN : null; + }); + const { container } = await mountFivePerson(); + expect([readsOf('team-A'), readsOf('team-B')]).toEqual([1, 1]); + expect(senpoInput(container).value).toBe(''); + + healthy = true; + await connectionReturns(); + + expect([readsOf('team-A'), readsOf('team-B')]).toEqual([2, 2]); + expect(senpoInput(container).value).toBe('Ren Abe'); + expect(notices(container)).toHaveLength(0); + }); + + it('reads only the side that was never read', async () => { + window.API.fetchLineupInForce = vi.fn(async (_c, teamId) => { + if (teamId === 'team-B') throw new Error('offline'); + return null; + }); + await mountFivePerson(); + + await connectionReturns(); + + expect([readsOf('team-A'), readsOf('team-B')]).toEqual([1, 2]); + }); + + it('does not read a side again once it was read', async () => { + await mountFivePerson(); + await connectionReturns(); + expect([readsOf('team-A'), readsOf('team-B')]).toEqual([1, 1]); + }); + + it('reads nothing when the connection returns after the editor closed', async () => { + window.API.fetchLineupInForce = vi.fn().mockRejectedValue(new Error('offline')); + const { unmount } = await mountFivePerson(); + unmount(); + await connectionReturns(); + expect([readsOf('team-A'), readsOf('team-B')]).toEqual([1, 1]); + }); +}); + +// A read begun by an announcement before the pick can still be out when the pick +// writes, and answers with the lineup from before the write. +describe('team editor: a confirmed write is not undone by a read that began before it', () => { + const OPEN = { positions: { senpo: 'Ren Abe' }, memberIds: { senpo: 'b1' } }; + + it('keeps what was written when a read begun by an announcement before it answers afterwards', async () => { + const reads = []; + window.API.fetchLineupInForce = vi.fn((_c, teamId) => { + if (teamId !== 'team-B') return Promise.resolve(null); + if (reads.length === 0) { reads.push(null); return Promise.resolve(OPEN); } + const gate = deferred(); + reads.push(gate); + return gate.promise; + }); + const { container } = await mountFivePerson(); + // Announced before the pick: its read is still out when the pick writes. + await announceLineupChange({ competitionId: 'comp1' }); + expect(reads).toHaveLength(2); + + await typeName(jihoInput(container, 'shiro'), 'Kai Mori'); + expect(window.API.putMatchLineup).toHaveBeenCalledTimes(1); + expect(jihoInput(container, 'shiro').value).toBe('Kai Mori'); + + await act(async () => { reads[1].resolve(OPEN); }); + await flush(); + + expect(jihoInput(container, 'shiro').value, 'the lineup from before the write does not come back').toBe('Kai Mori'); + }); +}); + +describe('team editor: a pick names the one position it changes', () => { + const OPEN = { positions: { senpo: 'Ren Abe' }, memberIds: { senpo: 'b1' } }; + const NOW = { positions: { senpo: 'Ren Abe', fukusho: 'Rin Ota' }, memberIds: { senpo: 'b1', fukusho: 'b4' } }; + const shiroInputAt = (container, row) => container.querySelectorAll('.team-sub-match')[row].querySelector('.team-sub-match__side--shiro input'); + + it('writes the one position and reads nothing for it, so a position set elsewhere since opening stays', async () => { + window.API.fetchLineupInForce = vi.fn(async (_c, teamId) => (teamId === 'team-B' ? OPEN : null)); + const { container } = await mountFivePerson(); + // Another device set fukusho after this sheet read the lineup. + lineups['team-B'] = NOW; + expect(shiroInputAt(container, 3).value, 'this sheet has not heard of it').toBe(''); + + await typeName(jihoInput(container, 'shiro'), 'Kai Mori'); + + expect(readsOf('team-B'), 'the lineup is not read for the pick').toBe(1); + expect(window.API.putMatchLineup).toHaveBeenCalledTimes(1); + const [, teamId, matchId, positions, , memberIds, changed] = window.API.putMatchLineup.mock.calls[0]; + expect([teamId, matchId]).toEqual(['team-B', 'm1']); + expect(positions).toEqual({ jiho: 'Kai Mori' }); + expect(memberIds).toEqual({ jiho: 'b2' }); + expect(changed).toEqual(['jiho']); + // The server put it on the lineup it holds, and what it answered is what the side shows. + expect(lineups['team-B'].positions).toEqual({ senpo: 'Ren Abe', fukusho: 'Rin Ota', jiho: 'Kai Mori' }); + expect(shiroInputAt(container, 3).value, 'the position another device set shows after the write').toBe('Rin Ota'); + expect(notices(container)).toHaveLength(0); + }); + + it('writes a cleared name as that position\'s empty name, which the server needs to be there', async () => { + window.API.fetchLineupInForce = vi.fn(async (_c, teamId) => (teamId === 'team-B' ? OPEN : null)); + lineups['team-B'] = OPEN; + const { container } = await mountFivePerson(); + + const clear = container.querySelectorAll('.team-sub-match')[0].querySelector('.team-sub-match__side--shiro .lineup-name__clear'); + await act(async () => { fireEvent.click(clear); }); + await flush(); + + expect(window.API.putMatchLineup).toHaveBeenCalledTimes(1); + const [, , , positions, , memberIds, changed] = window.API.putMatchLineup.mock.calls[0]; + expect(positions).toEqual({ senpo: '' }); + expect(memberIds).toBeUndefined(); + expect(changed).toEqual(['senpo']); + expect(lineups['team-B'].positions, 'the server cleared it').toEqual({}); + }); + + it('keeps writing, pick after pick, while the server cannot be reached: each is queued with its own position and shown', async () => { + let reachable = true; + window.API.fetchLineupInForce = vi.fn(async (_c, teamId) => { + if (!reachable) throw new Error('offline'); + return teamId === 'team-B' ? OPEN : null; + }); + window.API.putMatchLineup = vi.fn(async () => ({ queued: true })); + const { container } = await mountFivePerson(); + reachable = false; + + await typeName(jihoInput(container, 'shiro'), 'Kai Mori'); + const chuken = shiroInputAt(container, 2); + await typeName(chuken, 'Yui Sato'); + + // Each pick only reached the outbox, and says so in its row (the latest pick's: a + // pick clears the notice before it); a notice that said it was saved, or none, + // would have the operator believe it was. + expect([...notices(container)].map((n) => n.textContent)).toEqual([QUEUED_NOTICE]); + expect(window.API.putMatchLineup).toHaveBeenCalledTimes(2); + expect(window.API.putMatchLineup.mock.calls[0][3]).toEqual({ jiho: 'Kai Mori' }); + expect(window.API.putMatchLineup.mock.calls[1][3], 'its own position, not the lineup').toEqual({ chuken: 'Yui Sato' }); + expect(window.API.putMatchLineup.mock.calls[1][6]).toEqual(['chuken']); + // Neither reached the server, and both are shown on the lineup held. + expect(shiroInputAt(container, 0).value).toBe('Ren Abe'); + expect(jihoInput(container, 'shiro').value).toBe('Kai Mori'); + expect(chuken.value).toBe('Yui Sato'); + }); + + describe('a pick that only reached the outbox is not reported as saved', () => { + const noticeTexts = (container) => [...notices(container)].map((n) => n.textContent); + + it('says it is not sent yet, in the row, the way every held write is worded', async () => { + window.API.putMatchLineup = vi.fn(async () => ({ queued: true })); + const { container } = await mountFivePerson(); + + await typeName(jihoInput(container, 'shiro'), 'Kai Mori'); + + expect(noticeTexts(container)).toEqual([QUEUED_NOTICE]); + expect(notices(container)[0].getAttribute('data-tone'), 'a held write is amber, not an error').toBe('warn'); + expect(notices(container)[0].textContent, 'it leads with what is true: not sent yet').toMatch(/^Not sent yet/); + expect(notices(container)[0].textContent).not.toMatch(/Lineup saved/); + }); + + it('says to keep the page open when the browser could not store it either', async () => { + window.API.putMatchLineup = vi.fn(async () => ({ queued: true, persisted: false })); + const { container } = await mountFivePerson(); + + await typeName(jihoInput(container, 'shiro'), 'Kai Mori'); + + expect(noticeTexts(container)).toEqual([QUEUED_UNSAVED_NOTICE]); + }); + + it('does not say "Lineup saved" over a name that could not be linked to a team member either', async () => { + window.API.putMatchLineup = vi.fn(async () => ({ queued: true })); + window.API.addTeamMember = vi.fn().mockRejectedValue(new Error('offline')); + const { container } = await mountFivePerson(); + + await typeName(jihoInput(container, 'shiro'), 'Newcomer'); + + expect(window.API.addTeamMember).toHaveBeenCalled(); + expect(noticeTexts(container)).toEqual([QUEUED_NOTICE]); + }); + + it('still says nothing for a pick the server took', async () => { + const { container } = await mountFivePerson(); + + await typeName(jihoInput(container, 'shiro'), 'Kai Mori'); + + expect(notices(container)).toHaveLength(0); + }); + }); + + // The pick is checked against the lineup the sheet holds: the one it read, kept by + // what it announces and by its own writes. The server asks the same of the lineup it + // composes, and refuses naming both positions when another device placed the member. + describe('is checked against the lineup the sheet holds', () => { + const PLACED = { positions: { senpo: 'Kai Mori', chuken: 'Ren Abe' }, memberIds: { senpo: 'b2', chuken: 'b1' } }; + + it('refuses a member that lineup already places elsewhere, and sends nothing', async () => { + window.API.fetchLineupInForce = vi.fn(async (_c, teamId) => (teamId === 'team-B' ? PLACED : null)); + const { container } = await mountFivePerson(); + + // Ren Abe is at chuken, so this pick is refused. + await typeName(jihoInput(container, 'shiro'), 'Ren Abe'); + + expect(window.API.putMatchLineup).not.toHaveBeenCalled(); + expect(notices(container)[0].textContent).toBe('Ren Abe is already at Chuken.'); + expect(shiroInputAt(container, 0).value).toBe('Kai Mori'); + }); + + it('judges a pick against what a change announced since opening brought', async () => { + let placed = false; + window.API.fetchLineupInForce = vi.fn(async (_c, teamId) => (teamId === 'team-B' ? (placed ? PLACED : OPEN) : null)); + const { container } = await mountFivePerson(); + placed = true; + await announceLineupChange({ competitionId: 'comp1' }); + expect(shiroInputAt(container, 2).value).toBe('Ren Abe'); + + await typeName(jihoInput(container, 'shiro'), 'Ren Abe'); + + expect(window.API.putMatchLineup).not.toHaveBeenCalled(); + expect(notices(container)[0].textContent).toBe('Ren Abe is already at Chuken.'); + }); + }); +}); + +// A save of the lineup still waiting in the outbox is not on the server yet. The API +// joins a new save to it (one entry per lineup, the union of the positions), so the +// sheet names its own position and composes nothing on what is queued, whichever +// surface queued it (this sheet, the at-court panel or the Lineups page). +// API.queuedLineupSave only says whether one is queued. +describe('team editor: a save of this lineup is still waiting in the outbox', () => { + const OPEN = { positions: { senpo: 'Ren Abe' }, memberIds: { senpo: 'b1' } }; + const SERVER_COPY = { positions: { senpo: 'Ren Abe', fukusho: 'Rin Ota' }, memberIds: { senpo: 'b1', fukusho: 'b4' } }; + const senpoInput = (container) => container.querySelectorAll('.team-sub-match')[0].querySelector('.team-sub-match__side--shiro input'); + + function serverAnswering(copy) { + let shiroReads = 0; + window.API.fetchLineupInForce = vi.fn(async (_c, teamId) => { + if (teamId !== 'team-B') return null; + shiroReads += 1; + return shiroReads === 1 ? OPEN : copy; + }); + } + + it('writes its own position alone and reads nothing for it', async () => { + window.API.queuedLineupSave = vi.fn(() => true); + serverAnswering(SERVER_COPY); + const { container } = await mountFivePerson(); + + await typeName(jihoInput(container, 'shiro'), 'Kai Mori'); + + expect(readsOf('team-B'), 'only the read at opening').toBe(1); + expect(window.API.putMatchLineup).toHaveBeenCalledTimes(1); + const [, teamId, , positions, , memberIds, changed] = window.API.putMatchLineup.mock.calls[0]; + expect(teamId).toBe('team-B'); + expect(positions).toEqual({ jiho: 'Kai Mori' }); + expect(memberIds).toEqual({ jiho: 'b2' }); + expect(changed).toEqual(['jiho']); + }); + + it('keeps showing the lineup it holds when a read answers with the server\'s copy meanwhile', async () => { + let queued = false; + let serverCopy = OPEN; + window.API.queuedLineupSave = vi.fn(() => queued); + window.API.fetchLineupInForce = vi.fn(async (_c, teamId) => (teamId === 'team-B' ? serverCopy : null)); + const { container } = await mountFivePerson(); + expect(senpoInput(container).value).toBe('Ren Abe'); + + queued = true; + serverCopy = { positions: { senpo: 'Kai Mori' }, memberIds: { senpo: 'b2' } }; + await announceLineupChange({ competitionId: 'comp1' }); + expect(readsOf('team-B'), 'it still reads').toBe(2); + expect(senpoInput(container).value, 'but the server\'s copy lacks the queued edit').toBe('Ren Abe'); + + queued = false; + await announceLineupChange({ competitionId: 'comp1' }); + expect(senpoInput(container).value, 'and follows the server again once the save is sent').toBe('Kai Mori'); + }); + + it('still shows the lineup read at opening when a save was already queued then', async () => { + window.API.queuedLineupSave = vi.fn(() => true); + serverAnswering(SERVER_COPY); + const { container } = await mountFivePerson(); + + expect(senpoInput(container).value).toBe('Ren Abe'); + expect(notices(container)).toHaveLength(0); + }); +}); + +describe('team editor: it follows a lineup change announced for this competition', () => { + const OPEN = { positions: { senpo: 'Ren Abe' }, memberIds: { senpo: 'b1' } }; + const CHANGED = { positions: { senpo: 'Kai Mori' }, memberIds: { senpo: 'b2' } }; + const senpoInput = (container) => container.querySelectorAll('.team-sub-match')[0].querySelector('.team-sub-match__side--shiro input'); + + it('reads both teams again and shows what it reads', async () => { + let announced = false; + window.API.fetchLineupInForce = vi.fn(async (_c, teamId) => ( + teamId === 'team-B' ? (announced ? CHANGED : OPEN) : null + )); + const { container } = await mountFivePerson(); + expect(senpoInput(container).value).toBe('Ren Abe'); + expect([readsOf('team-A'), readsOf('team-B')]).toEqual([1, 1]); + + announced = true; + await announceLineupChange({ competitionId: 'comp1' }); + + expect([readsOf('team-A'), readsOf('team-B')]).toEqual([2, 2]); + expect(senpoInput(container).value).toBe('Kai Mori'); + }); + + it('keeps the lineup it holds when the read that follows an announcement fails', async () => { + let healthy = true; + window.API.fetchLineupInForce = vi.fn(async (_c, teamId) => { + if (!healthy) throw new Error('offline'); + return teamId === 'team-B' ? OPEN : null; + }); + const { container } = await mountFivePerson(); + + healthy = false; + await announceLineupChange({ competitionId: 'comp1' }); + + expect([readsOf('team-A'), readsOf('team-B')]).toEqual([2, 2]); + expect(senpoInput(container).value).toBe('Ren Abe'); + expect(notices(container)).toHaveLength(0); + }); + + it('keeps the read that began last when an earlier one answers after it', async () => { + const reads = []; + window.API.fetchLineupInForce = vi.fn((_c, teamId) => { + const gate = deferred(); + reads.push({ teamId, ...gate }); + return gate.promise; + }); + const { container } = await mountFivePerson(); + await announceLineupChange({ competitionId: 'comp1' }); + expect(reads.map((r) => r.teamId)).toEqual(['team-A', 'team-B', 'team-A', 'team-B']); + + await act(async () => { reads[3].resolve(CHANGED); reads[2].resolve(null); }); + await act(async () => { reads[1].resolve(OPEN); reads[0].resolve(null); }); + + expect(senpoInput(container).value).toBe('Kai Mori'); + }); + + it('ignores a change announced for another competition', async () => { + await mountFivePerson(); + await announceLineupChange({ competitionId: 'another-comp' }); + expect([readsOf('team-A'), readsOf('team-B')]).toEqual([1, 1]); + }); + + it('reads again when the announcement names no competition', async () => { + await mountFivePerson(); + await announceLineupChange(undefined); + expect([readsOf('team-A'), readsOf('team-B')]).toEqual([2, 2]); + }); + + // The announcement names the team it changed (teamId): only that team's lineup is read + // again, by the side that has it, and nothing for a team that plays neither side. + it('reads only the side whose team the announcement names', async () => { + await mountFivePerson(); + + await announceLineupChange({ competitionId: 'comp1', teamId: 'team-B' }); + expect([readsOf('team-A'), readsOf('team-B')], 'the Shiro team alone').toEqual([1, 2]); + + await announceLineupChange({ competitionId: 'comp1', teamId: 'team-A' }); + expect([readsOf('team-A'), readsOf('team-B')], 'the Aka team alone').toEqual([2, 2]); + }); + + it('reads nothing for an announcement that names a team playing neither side', async () => { + await mountFivePerson(); + await announceLineupChange({ competitionId: 'comp1', teamId: 'team-elsewhere' }); + expect([readsOf('team-A'), readsOf('team-B')]).toEqual([1, 1]); + }); + + it('reads nothing when a change is announced after the editor closed', async () => { + const { unmount } = await mountFivePerson(); + unmount(); + await announceLineupChange({ competitionId: 'comp1' }); + expect([readsOf('team-A'), readsOf('team-B')]).toEqual([1, 1]); + }); + + it('leaves a side alone while this sheet is writing its lineup, reads it after, and does not wait to read the other', async () => { + const put = deferred(); + window.API.putMatchLineup = vi.fn(() => put.promise); + const { container } = await mountFivePerson(); + + await typeName(jihoInput(container, 'shiro'), 'Kai Mori'); + expect(window.API.putMatchLineup).toHaveBeenCalledTimes(1); + // Mount read each team once; the pick read nothing. + expect([readsOf('team-A'), readsOf('team-B')]).toEqual([1, 1]); + + await announceLineupChange({ competitionId: 'comp1' }); + expect(readsOf('team-A'), 'the side nobody is writing is read at once').toBe(2); + expect(readsOf('team-B'), 'the side being written waits').toBe(1); + + await act(async () => { put.resolve({ positions: { jiho: 'Kai Mori' }, memberIds: { jiho: 'b2' } }); }); + await flush(); + expect(readsOf('team-B'), 'and is read once its write has settled').toBe(2); + expect(readsOf('team-A')).toBe(2); + }); + + it('does not read a side again after a write when nothing was announced meanwhile', async () => { + const { container } = await mountFivePerson(); + await typeName(jihoInput(container, 'shiro'), 'Kai Mori'); + expect([readsOf('team-A'), readsOf('team-B')]).toEqual([1, 1]); + }); +}); + +// A kachinuki encounter whose bouts 1-5 are fought and whose current bout is +// position 6, beyond the 5-person team, so both its pickers take the manual +// path and a typed name over a picked blank member renames that member. +describe('team editor: a notice for a kachinuki bout shows once that bout is read-only', () => { + const SQUAD_A = [ + { id: 'm-kept', index: 1, name: 'Kept Winner' }, + { id: 'm-blank', index: 7, name: '' }, + ]; + const SQUAD_B = [{ id: 'm-shiro', index: 1, name: 'Shiro One' }]; + + const fought = [1, 2, 3, 4, 5].map((position) => ({ + position, sideA: `Aka ${position}`, sideB: `Shiro ${position}`, ipponsA: ['M'], ipponsB: [], + })); + const kachinukiMatch = (subResults) => ({ + id: 'm1', + compId: 'comp1', + status: 'running', + phase: 'pool', + poolName: 'Pool 1', + court: 'A', + compKind: 'team', + teamSize: 5, + compFormat: 'mixed', + teamMatchType: 'kachinuki', + sideA: { id: 'team-A', name: 'Team A', number: 'T5' }, + sideB: { id: 'team-B', name: 'Team B', number: 'T9' }, + subResults, + }); + const bout6 = { position: 6, sideA: 'Aka 5', sideB: '', ipponsA: [], ipponsB: [] }; + const bout6Fought = { ...bout6, sideB: 'Shiro 6', ipponsA: ['M'] }; + const bout7 = { position: 7, sideA: 'Aka 5', sideB: '', ipponsA: [], ipponsB: [] }; + + const editorProps = { onClose: vi.fn(), onSubmit: vi.fn().mockResolvedValue(undefined), password: 'secret' }; + + beforeEach(() => { + window.API.fetchCompetitionDetails = vi.fn().mockResolvedValue({ + id: 'comp1', + config: { format: 'mixed', teamMatchType: 'kachinuki', naginata: false, players: [] }, + }); + window.API.fetchSquads = vi.fn().mockResolvedValue({ 'team-A': SQUAD_A, 'team-B': SQUAD_B }); + }); + + async function mountKachinuki(subResults) { + let utils; + await act(async () => { utils = render(); }); + await flush(); + return utils; + } + const currentRow = () => document.querySelector('.team-sub-match:not(.team-sub-match--readonly)'); + // A read-only row is a role="button", whose children are presentational to + // assistive tech: its notice follows it as a sibling, so it is announced. + const noticeAfter = (rowTestId) => { + const row = document.querySelector(`[data-testid="${rowTestId}"]`); + const next = row && row.nextElementSibling; + return next && next.getAttribute('data-testid') === 'team-editor-lineup-warning' ? next : null; + }; + const expectOutsideTheButton = (rowTestId, notice) => { + expect(document.querySelector(`[data-testid="${rowTestId}"]`).getAttribute('role')).toBe('button'); + expect(document.querySelector(`[data-testid="${rowTestId}"]`).contains(notice)).toBe(false); + expect(notice.closest('[role="button"]')).toBeNull(); + }; + + it('a failed rename that answers after the bout was recorded shows right after that bout\'s row', async () => { + const rename = deferred(); + window.API.renameTeamMember = vi.fn(() => rename.promise); + const { rerender } = await mountKachinuki([...fought, bout6]); + + // Pick the blank member for Aka, then type its name: the member is renamed. + const akaInput = () => currentRow().querySelector('.team-sub-match__side--aka input'); + await act(async () => { fireEvent.focus(akaInput()); }); + const blank = [...document.querySelectorAll('.team-sub-match__side--aka .pmf__option')].find((b) => b.textContent.includes('T5.7')); + await act(async () => { fireEvent.click(blank); }); + await act(async () => { + fireEvent.focus(akaInput()); + fireEvent.change(akaInput(), { target: { value: 'Ito' } }); + fireEvent.keyDown(akaInput(), { key: 'Enter' }); + }); + expect(window.API.renameTeamMember).toHaveBeenCalledTimes(1); + + // The bout is scored and recorded before the rename answers: it is read-only. + const aka = [...currentRow().querySelectorAll('.team-sub-match__side--aka button.ipt-btn')].find((b) => b.textContent === 'M'); + await act(async () => { fireEvent.click(aka); }); + await act(async () => { + rerender(); + }); + expect(document.querySelector('[data-testid="kachinuki-done-bout-5"]'), 'bout 6 is read-only now').not.toBeNull(); + expect(document.querySelectorAll('[data-testid="team-editor-lineup-warning"]')).toHaveLength(0); + + await act(async () => { rename.reject(new Error('could not rename')); }); + await flush(); + + const notice = noticeAfter('kachinuki-done-bout-5'); + expect(notice, 'the notice follows the read-only row').not.toBeNull(); + expect(notice.textContent).toMatch(/^"Ito" was used for this bout, but the team member could not be renamed\./); + expect(notice.getAttribute('role')).toBe('status'); + expect(notice.getAttribute('data-tone')).toBe('warn'); + expect(notice.classList.contains('alert--warn')).toBe(true); + expectOutsideTheButton('kachinuki-done-bout-5', notice); + expect(document.querySelectorAll('[data-testid="team-editor-lineup-warning"]')).toHaveLength(1); + }); + + it('a refusal on the first bout still shows, right after its row, when that bout is recorded', async () => { + window.API.fetchLineupInForce = vi.fn().mockRejectedValue(new Error('offline')); + const first = { position: 1, sideA: '', sideB: '', ipponsA: [], ipponsB: [] }; + const { rerender } = await mountKachinuki([first]); + + const shiroInput = () => currentRow().querySelector('.team-sub-match__side--shiro input'); + await act(async () => { fireEvent.focus(shiroInput()); }); + const pick = [...document.querySelectorAll('.team-sub-match__side--shiro .pmf__option')][0]; + expect(pick, 'the list offers a team member').toBeTruthy(); + await act(async () => { fireEvent.click(pick); }); + expect(window.API.putMatchLineup).not.toHaveBeenCalled(); + expect(document.querySelector('[data-testid="team-editor-lineup-warning"]').textContent).toBe(NOT_READ); + + const aka = [...currentRow().querySelectorAll('.team-sub-match__side--aka button.ipt-btn')].find((b) => b.textContent === 'M'); + await act(async () => { fireEvent.click(aka); }); + await act(async () => { + rerender(); + }); + + expect(document.querySelector('[data-testid="kachinuki-done-bout-0"]'), 'bout 1 is read-only now').not.toBeNull(); + const notice = noticeAfter('kachinuki-done-bout-0'); + expect(notice, 'the refusal is still shown, after the bout it was made on').not.toBeNull(); + expect(notice.textContent).toBe(NOT_READ); + expect(notice.getAttribute('role')).toBe('alert'); + expect(notice.getAttribute('data-tone')).toBe('error'); + expectOutsideTheButton('kachinuki-done-bout-0', notice); + }); +}); diff --git a/web-mobile/js/__tests__/render/team_editor_lineup_refusal_at_row.render.test.jsx b/web-mobile/js/__tests__/render/team_editor_lineup_refusal_at_row.render.test.jsx new file mode 100644 index 000000000..2df7ce846 --- /dev/null +++ b/web-mobile/js/__tests__/render/team_editor_lineup_refusal_at_row.render.test.jsx @@ -0,0 +1,168 @@ +// bc-lnrf: what a bout row's name box produces is shown IN that row. +// +// A name typed into a row can be refused (that member already holds another +// position), saved with a team-member identity warning, or fail to save. Those +// messages used to share one sheet-level slot rendered after every bout row and +// the IV/PW band, about 1359px down on an iPad, so the operator saw the typed +// name simply vanish. The notice now sits under the name box it answers. + +import React from 'react'; +import { render, act, fireEvent } from '@testing-library/react'; +import { describe, it, expect, vi, beforeAll, afterAll, beforeEach } from 'vitest'; +import { installWindowStubs } from '../helpers/stub_globals.js'; +import { answered } from '../helpers/team_members.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: {}, + compMatches: () => [], + Term: ({ children }) => {children}, + GlossaryHint: ({ name }) => , +}; + +let restoreGlobals; +let ScoreEditorModal; + +beforeAll(async () => { + restoreGlobals = installWindowStubs(STUBBED_GLOBALS); + await import('../../admin_lineup.jsx'); + await import('../../admin_scoring_modal.jsx'); + ScoreEditorModal = window.ScoreEditorModal; +}); + +afterAll(() => restoreGlobals()); + +const members = (p, names) => names.map((name, i) => ({ id: `${p}${i + 1}`, index: i + 1, name })); +const SHIRO_NAMES = ['Ren Abe', 'Kai Mori', 'Yui Sato', 'Rin Ota', 'Sho Ueda']; + +beforeEach(() => { + window.API = { + fetchCompetitionDetails: vi.fn().mockResolvedValue({ + id: 'comp1', + config: { format: 'knockout', teamMatchType: 'fixed', naginata: false, players: [] }, + }), + fetchSquads: vi.fn().mockResolvedValue({ + 'team-A': members('a', ['A One', 'A Two', 'A Three', 'A Four', 'A Five']), + 'team-B': members('b', SHIRO_NAMES), + }), + // Shiro (side B) already has Ren Abe at senpo. + fetchLineupInForce: vi.fn(async (_c, teamId) => ( + teamId === 'team-B' ? { positions: { senpo: 'Ren Abe' }, memberIds: { senpo: 'b1' } } : null + )), + putMatchLineup: vi.fn(async () => ({})), + // The server answers a member write with the member it holds, stamped. + renameTeamMember: vi.fn(async (_c, _t, id, name) => answered({ id, index: Number(id.slice(1)) }, { name })), + addTeamMember: vi.fn(async (_c, _t, name) => answered({ id: 'new-1', index: 6 }, { name })), + recordScore: vi.fn().mockResolvedValue(undefined), + recordDaihyosen: vi.fn(), + removeDaihyosen: vi.fn(), + recordDecision: vi.fn(), + hasPendingTerminalWrite: () => false, + notePendingEdit: () => () => {}, + }; +}); + +async function mountFivePerson() { + let utils; + await act(async () => { + utils = render( + + ); + }); + await act(async () => { await new Promise((r) => setTimeout(r, 0)); }); + return utils; +} + +const jihoRow = (container) => container.querySelectorAll('.team-sub-match')[1]; +const jihoShiroInput = (container) => jihoRow(container).querySelector('.team-sub-match__side--shiro input'); + +async function typeName(input, name) { + await act(async () => { + fireEvent.focus(input); + fireEvent.change(input, { target: { value: name } }); + fireEvent.keyDown(input, { key: 'Enter' }); + }); + await act(async () => { await new Promise((r) => setTimeout(r, 0)); }); +} + +const notices = (container) => container.querySelectorAll('[data-testid="team-editor-lineup-warning"]'); + +describe('team editor: a lineup refusal shows in the row the operator typed in (bc-lnrf)', () => { + it('refuses a member already placed at another position, in the jiho row', async () => { + const { container } = await mountFivePerson(); + await typeName(jihoShiroInput(container), 'Ren Abe'); + + expect(window.API.putMatchLineup, 'nothing is written for a refusal').not.toHaveBeenCalled(); + expect(notices(container)).toHaveLength(1); + const notice = notices(container)[0]; + expect(notice.textContent).toBe('Ren Abe is already at Senpo.'); + expect(notice.getAttribute('role')).toBe('alert'); + expect(notice.classList.contains('alert--error')).toBe(true); + expect(notice.getAttribute('data-tone')).toBe('error'); + expect(notice.closest('.team-sub-match')).toBe(jihoRow(container)); + expect(notice.closest('.team-sub-match__side--shiro')).not.toBeNull(); + expect(container.querySelector('[data-testid="team-editor-error"]')).toBeNull(); + }); + + it('shows the identity warning of a saved lineup in the row, as a status', async () => { + window.API.addTeamMember = vi.fn(async () => { throw new Error('offline'); }); + const { container } = await mountFivePerson(); + await typeName(jihoShiroInput(container), 'Newcomer'); + + expect(window.API.putMatchLineup, 'the lineup itself is saved').toHaveBeenCalled(); + expect(notices(container)).toHaveLength(1); + const notice = notices(container)[0]; + expect(notice.textContent).toMatch(/^Lineup saved, but/); + expect(notice.getAttribute('role')).toBe('status'); + expect(notice.classList.contains('alert--warn')).toBe(true); + expect(notice.getAttribute('data-tone')).toBe('warn'); + expect(notice.closest('.team-sub-match')).toBe(jihoRow(container)); + expect(notice.closest('.team-sub-match__side--shiro')).not.toBeNull(); + }); + + it('shows a failed lineup save in the row and not in the sheet error', async () => { + window.API.putMatchLineup = vi.fn(async () => { throw new Error(''); }); + const { container } = await mountFivePerson(); + await typeName(jihoShiroInput(container), 'Kai Mori'); + + expect(notices(container)).toHaveLength(1); + const notice = notices(container)[0]; + expect(notice.textContent).toBe('Failed to update lineup'); + expect(notice.getAttribute('role')).toBe('alert'); + expect(notice.closest('.team-sub-match')).toBe(jihoRow(container)); + expect(container.querySelector('[data-testid="team-editor-error"]')).toBeNull(); + }); + + it('clears the notice when the next lineup write starts', async () => { + const { container } = await mountFivePerson(); + await typeName(jihoShiroInput(container), 'Ren Abe'); + expect(notices(container)).toHaveLength(1); + + await typeName(jihoShiroInput(container), 'Kai Mori'); + expect(notices(container)).toHaveLength(0); + }); +}); 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 new file mode 100644 index 000000000..36d21ed17 --- /dev/null +++ b/web-mobile/js/__tests__/render/team_editor_member_writes_stand.render.test.jsx @@ -0,0 +1,681 @@ +// A name typed in a bout row names or adds a team member on the server, and the +// sheet shows that member from then on. The server stamps every member write +// (`modifiedAt`) and answers the member with its stamp, and of two copies of a member +// the sheet keeps the one with the larger stamp, whichever way a list reaches it: +// +// - an admin host leaves the sheet to read the members itself, once as it opens and +// again when the competition arrives, and the second read can answer after a name +// was typed; +// - the public self-run page hands the members in (teamMembers), and its copy can be +// older than the write. +// +// Undone, a member the sheet had just named shows as unnamed again, so the same name +// typed for another bout finds nobody and names a second member instead of being +// refused by the duplicate guard, and a member the sheet had just added vanishes with +// its number. A list read before the write holds the member with an older stamp, so it +// undoes nothing; a list that holds a larger stamp was written after, by another device, +// and shows at once. The admin sheet reads its members again whenever a lineup change is +// announced for its competition, so such a change shows without reopening the sheet. + +import React from 'react'; +import { render, act, fireEvent, screen } from '@testing-library/react'; +import { describe, it, expect, vi, beforeAll, afterAll, beforeEach } from 'vitest'; +import { installWindowStubs } from '../helpers/stub_globals.js'; +import { answered, namedLater } from '../helpers/team_members.js'; +import { lineupPutStubByTeam } from '../helpers/lineup_server.js'; +import { FETCH_TIMEOUT_MS } from '../../write_result.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: {}, + compMatches: () => [], + compMatchesForCompetition: () => [], + Term: ({ children }) => {children}, + GlossaryHint: ({ name }) => , +}; + +let restoreGlobals; +let ScoreEditorModal; +let MatchViewerModal; + +beforeAll(async () => { + restoreGlobals = installWindowStubs(STUBBED_GLOBALS); + await import('../../admin_lineup.jsx'); + await import('../../admin_scoring_modal.jsx'); + ScoreEditorModal = window.ScoreEditorModal; + ({ MatchViewerModal } = await import('../../viewer_match.jsx')); +}); + +afterAll(() => restoreGlobals()); + +const members = (p, names) => names.map((name, i) => ({ id: `${p}${i + 1}`, index: i + 1, name })); +const AKA = members('a', ['A One', 'A Two', 'A Three', 'A Four', 'A Five']); +// The first slot has no name yet: a name typed for the first bout names that member. +const SHIRO_FIRST_BLANK = members('b', ['', 'Kai Mori', 'Yui Sato', 'Rin Ota', 'Sho Ueda']); +const SHIRO_NAMED = members('b', ['Ren Abe', 'Kai Mori', 'Yui Sato', 'Rin Ota', 'Sho Ueda']); +const blank = (p) => members(p, ['', '', '', '', '']); +const teams = (shiro) => ({ 'team-A': AKA, 'team-B': shiro }); + +const DETAIL = { id: 'comp1', config: { format: 'knockout', teamMatchType: 'fixed', naginata: false, players: [] } }; + +function deferred() { + let resolve; + const promise = new Promise((res) => { resolve = res; }); + return { promise, resolve }; +} + +const flush = () => act(async () => { await new Promise((r) => setTimeout(r, 0)); }); + +// What the server holds, so a lineup the sheet reads is the one it saved. +let lineups; +// The answers of the members reads the sheet has asked for, in the order it asked, and +// what makes each of them fail. +let memberReads; +let memberFailures; +// Held until the test lets the competition arrive, which makes the sheet read the +// members again. +let competition; + +beforeEach(() => { + lineups = {}; + memberReads = []; + memberFailures = []; + competition = deferred(); + window.API = { + fetchCompetitionDetails: vi.fn(() => competition.promise), + fetchSquads: vi.fn(() => new Promise((resolve, reject) => { memberReads.push(resolve); memberFailures.push(reject); })), + fetchLineupInForce: vi.fn(async (_c, teamId) => lineups[teamId] || null), + // A save names the position it changed, and the server answers the lineup it holds then. + putMatchLineup: lineupPutStubByTeam(() => lineups), + // The server answers a member write with the member it holds, stamped. The ids here + // are the letter of the team and the member's number. + renameTeamMember: vi.fn(async (_c, _t, id, name) => answered({ id, index: Number(id.slice(1)) }, { name })), + addTeamMember: vi.fn(async (_c, _t, name) => answered({ id: 'new-1', index: 6 }, { name })), + recordScore: vi.fn().mockResolvedValue(undefined), + recordDaihyosen: vi.fn(), + removeDaihyosen: vi.fn(), + recordDecision: vi.fn(), + hasPendingTerminalWrite: () => false, + notePendingEdit: () => () => {}, + }; +}); + +const bout = (root, n, color) => root.querySelectorAll('.team-sub-match')[n].querySelector(`.team-sub-match__side--${color} input`); +const label = (root, n, color) => { + const el = root.querySelectorAll('.team-sub-match')[n].querySelector(`[data-testid="team-sub-match-member-label-${color}"]`); + return el ? el.textContent : ''; +}; +const notices = (root) => root.querySelectorAll('[data-testid="team-editor-lineup-warning"]'); +const offered = (root, n, color) => [...root.querySelectorAll('.team-sub-match')[n].querySelectorAll(`.team-sub-match__side--${color} .pmf__option`)] + .map((o) => o.textContent); + +async function typeName(input, name) { + await act(async () => { + fireEvent.focus(input); + fireEvent.change(input, { target: { value: name } }); + fireEvent.keyDown(input, { key: 'Enter' }); + }); + await flush(); +} + +const teamMatch = (extra = {}) => ({ + id: 'm1', + compId: 'comp1', + status: 'running', + phase: 'bracket', + court: 'A', + compKind: 'team', + teamSize: 5, + compFormat: 'knockout', + teamMatchType: 'fixed', + round: 'Semi-final', + matchNumber: 1, + sideA: { id: 'team-A', name: 'Team A', number: 'T1' }, + sideB: { id: 'team-B', name: 'Team B', number: 'T2' }, + ...extra, +}); + +// The sheet as an admin host opens it: the members read it makes as it opens is +// answered with `firstRead`, then the competition arrives and it reads them again, +// and that second read stays out until the test answers it. +async function mountWithSecondReadOut(firstRead, match = teamMatch()) { + let utils; + await act(async () => { + utils = render(); + }); + await act(async () => { memberReads[0](firstRead); }); + await flush(); + await act(async () => { competition.resolve(DETAIL); }); + await flush(); + expect(memberReads, 'the competition arriving reads the members again').toHaveLength(2); + return utils; +} + +describe('team editor: a members read that predates the sheet\'s own write does not undo it', () => { + it('keeps the member the sheet named, so the same name typed for another bout is refused', async () => { + const before = teams(SHIRO_FIRST_BLANK); + const { container } = await mountWithSecondReadOut(before); + + await typeName(bout(container, 0, 'shiro'), 'Ito'); + expect(window.API.renameTeamMember).toHaveBeenCalledWith('comp1', 'team-B', 'b1', 'Ito', ''); + + // The read begun before the rename answers with the list from before it. + await act(async () => { memberReads[1](before); }); + await flush(); + await typeName(bout(container, 1, 'shiro'), 'Ito'); + + expect(window.API.addTeamMember, 'no second member named Ito is added').not.toHaveBeenCalled(); + expect(window.API.renameTeamMember, 'no other member is named Ito').toHaveBeenCalledTimes(1); + expect(window.API.putMatchLineup, 'the refused name saves no lineup').toHaveBeenCalledTimes(1); + expect(notices(container)).toHaveLength(1); + expect(notices(container)[0].textContent).toBe('Ito is already at Senpo.'); + }); + + it('keeps the member the sheet added, with its number', async () => { + const before = teams(SHIRO_NAMED); + const { container } = await mountWithSecondReadOut(before); + + await typeName(bout(container, 0, 'shiro'), 'Newcomer'); + expect(window.API.addTeamMember).toHaveBeenCalledWith('comp1', 'team-B', 'Newcomer', ''); + expect(label(container, 0, 'shiro')).toBe('T2.6'); + + await act(async () => { memberReads[1](before); }); + await flush(); + + expect(label(container, 0, 'shiro'), 'the row still carries the added member').toBe('T2.6'); + }); + + // A name typed before the first list arrives waits for it, until the deadline of any + // request (team_editor_members_read_first.render.test.jsx). Once it has given up, the + // sheet knows no member and adds one, and the list answering late lacks it. + it('keeps a member added before the first list arrived, which lacks it', async () => { + vi.useFakeTimers(); + try { + await act(async () => { + render(); + }); + const input = bout(document, 0, 'shiro'); + await act(async () => { + fireEvent.focus(input); + fireEvent.change(input, { target: { value: 'Newcomer' } }); + fireEvent.keyDown(input, { key: 'Enter' }); + }); + await act(async () => { await vi.advanceTimersByTimeAsync(FETCH_TIMEOUT_MS); }); + expect(window.API.addTeamMember).toHaveBeenCalledTimes(1); + } finally { + vi.useRealTimers(); + } + + await act(async () => { memberReads[0](teams(SHIRO_NAMED)); }); + await flush(); + + expect(label(document, 0, 'shiro'), 'the row still carries the added member').toBe('T2.6'); + await act(async () => { fireEvent.focus(bout(document, 2, 'shiro')); }); + expect(offered(document, 2, 'shiro').some((o) => o.includes('Sho Ueda')), 'the list brings the rest of the team').toBe(true); + }); + + it('still shows a member another device added, beside the write', async () => { + const before = teams(SHIRO_FIRST_BLANK); + const { container } = await mountWithSecondReadOut(before); + await typeName(bout(container, 0, 'shiro'), 'Ito'); + + const another = { id: 'b6', index: 6, name: 'Mei Endo' }; + await act(async () => { memberReads[1](teams([...SHIRO_FIRST_BLANK, another])); }); + await flush(); + + await act(async () => { fireEvent.focus(bout(container, 2, 'shiro')); }); + expect(offered(container, 2, 'shiro').some((o) => o.includes('Mei Endo')), 'the member another device added is offered').toBe(true); + await typeName(bout(container, 1, 'shiro'), 'Ito'); + expect(window.API.addTeamMember).not.toHaveBeenCalled(); + expect(notices(container)[0].textContent).toBe('Ito is already at Senpo.'); + }); + + // A kachinuki row past the first is a pairing, not a lineup position: a name typed + // over a member picked from the list names that member by its own write. + it('keeps the member a kachinuki row named', async () => { + const played = [ + { position: 1, sideA: 'A One', sideB: 'Mei Ito', ipponsA: ['M', 'M'], ipponsB: [], winner: 'A One' }, + { position: 2, sideA: 'A One', sideB: '', ipponsA: [], ipponsB: [] }, + ]; + const before = { 'team-A': blank('a'), 'team-B': blank('b') }; + const { container } = await mountWithSecondReadOut(before, teamMatch({ phase: 'pool', compFormat: 'mixed', teamMatchType: 'kachinuki', subResults: played })); + const shiroNow = () => [...container.querySelectorAll('.team-sub-match__side--shiro input')].pop(); + const shiroOptions = () => [...container.querySelectorAll('.team-sub-match__side--shiro .pmf__option')].map((o) => o.textContent); + + await act(async () => { fireEvent.focus(shiroNow()); }); + const slot = [...container.querySelectorAll('.team-sub-match__side--shiro .pmf__option')].find((o) => o.textContent.includes('T2.3')); + expect(slot, 'the numbered member T2.3 is offered').toBeTruthy(); + await act(async () => { fireEvent.click(slot); }); + await typeName(shiroNow(), 'Ito'); + expect(window.API.renameTeamMember).toHaveBeenCalledWith('comp1', 'team-B', 'b3', 'Ito', ''); + + await act(async () => { memberReads[1](before); }); + await flush(); + await act(async () => { fireEvent.focus(shiroNow()); }); + + expect(shiroOptions().find((o) => o.includes('T2.3')), 'T2.3 keeps its name').toContain('Ito'); + }); +}); + +describe('team editor: the host\'s copy of the members that predates the sheet\'s own write does not undo it (public page)', () => { + const selfRun = (list) => ({ mode: 'self-run', competitions: [{ id: 'c1', squads: list }] }); + const publicMatch = () => teamMatch({ + compId: 'c1', compName: 'Teams', phase: 'pool', poolName: 'Pool 1', compFormat: 'mixed', + }); + const aka = (n) => bout(document, n, 'aka'); + let view; + + const host = (list) => ; + const hostHolds = (list) => act(async () => { view.rerender(host(list)); }); + + beforeEach(async () => { + competition.resolve({ id: 'c1', config: { format: 'mixed', teamMatchType: 'fixed', naginata: false, players: [] } }); + await act(async () => { view = render(host({ 'team-A': blank('a'), 'team-B': blank('b') })); }); + await act(async () => { fireEvent.click(screen.getByRole('button', { name: 'Report result' })); }); + await flush(); + }); + + // The member as the server answered the sheet's write, and the host's copy of team A + // with that member in place of the unnamed a1. + const written = () => window.API.renameTeamMember.mock.results[0].value; + const copyWith = (member) => ({ 'team-A': [member, ...blank('a').slice(1)], 'team-B': blank('b') }); + + it('keeps the member the sheet named over a copy that predates the write, and a copy that holds the write changes nothing', async () => { + await typeName(aka(0), 'Mei Ito'); + expect(window.API.renameTeamMember).toHaveBeenCalledWith('c1', 'team-A', 'a1', 'Mei Ito', ''); + expect(aka(0).value).toBe('Mei Ito'); + + // A copy refreshed before the write reached the server: a1 has no name in it, and no + // stamp. The other team's list changed too, which is what makes the sheet take the + // copy in. + await hostHolds({ 'team-A': blank('a'), 'team-B': [...blank('b'), { id: 'b6', index: 6, name: '' }] }); + await typeName(aka(1), 'Mei Ito'); + + expect(window.API.renameTeamMember, 'no other member is named Mei Ito').toHaveBeenCalledTimes(1); + expect(window.API.addTeamMember).not.toHaveBeenCalled(); + expect(notices(document)).toHaveLength(1); + expect(notices(document)[0].textContent).toBe('Mei Ito is already at Senpo.'); + + // The copy that has the write: the server has caught up. + await hostHolds(copyWith(await written())); + expect(aka(0).value).toBe('Mei Ito'); + }); + + it('shows a rename another device made after the sheet\'s own write, and follows the copies after it', async () => { + await typeName(aka(0), 'Mei Ito'); + const mine = await written(); + await hostHolds(copyWith(mine)); + expect(aka(0).value).toBe('Mei Ito'); + + // Another device renames the member: its stamp is the larger, so that shows now. + const theirs = namedLater(mine, 'Mei Ito-Kato'); + await hostHolds(copyWith(theirs)); + expect(aka(0).value).toBe('Mei Ito-Kato'); + + await hostHolds(copyWith(namedLater(theirs, 'Mei Ito-Kato Jr'))); + expect(aka(0).value).toBe('Mei Ito-Kato Jr'); + }); + + it('keeps the name the sheet wrote over a copy that still holds the name the member had before the write', async () => { + await typeName(aka(0), 'Mei Ito'); + expect(aka(0).value).toBe('Mei Ito'); + + // A copy refreshed before the write reached the server: a1 is unnamed in it, as it was. + await hostHolds({ 'team-A': blank('a'), 'team-B': [...blank('b'), { id: 'b6', index: 6, name: '' }] }); + + expect(aka(0).value).toBe('Mei Ito'); + }); + + // The stamp, not the name, says which copy is newer: a copy that holds a name the member + // never had before the write but carries an OLDER stamp was read before it, so it + // undoes nothing; the same name with a larger stamp was given after the write, and shows. + it('tells another device\'s name from a stale copy by the stamp: an older stamp changes nothing, a larger one shows', async () => { + await typeName(aka(0), 'Mei Ito'); + const mine = await written(); + expect(aka(0).value).toBe('Mei Ito'); + + await hostHolds(copyWith({ ...mine, name: 'Mei Endo', modifiedAt: mine.modifiedAt - 1 })); + expect(aka(0).value, 'a name with an older stamp was read before the write').toBe('Mei Ito'); + + await hostHolds(copyWith(namedLater(mine, 'Mei Endo'))); + expect(aka(0).value, 'a name with a larger stamp was given after it').toBe('Mei Endo'); + }); + + // The public page's copy follows lineup changes through the host's own refresh, so the + // sheet reads nothing for them. + it('is not read again on a lineup change: the host\'s copy is what it follows', async () => { + await act(async () => { window.dispatchEvent(new CustomEvent('lineup-updated', { detail: { competitionId: 'c1' } })); }); + await flush(); + + expect(window.API.fetchSquads).not.toHaveBeenCalled(); + }); +}); + +// The admin sheet reads its own members, and reads them again whenever a lineup change is +// announced for its competition (the event the server sends for a rename, a cleared name +// and every lineup save), so a member renamed, cleared or added on another device shows +// without reopening the sheet, as the lineup editors' members do. +describe('team editor: the admin sheet follows the members another device changes', () => { + const announce = (competitionId = 'comp1') => act(async () => { + window.dispatchEvent(new CustomEvent('lineup-updated', { detail: { competitionId } })); + }); + const shiro = (...names) => teams(members('b', names)); + const OTHERS = ['Kai Mori', 'Yui Sato', 'Rin Ota', 'Sho Ueda']; + // Opens the sheet with its two reads (as it opens, and when the competition arrives) both answered. + async function open(firstNames) { + const utils = await mountWithSecondReadOut(shiro(...firstNames)); + await act(async () => { memberReads[1](shiro(...firstNames)); }); + await flush(); + return utils; + } + const options = async (container) => { + await act(async () => { fireEvent.focus(bout(container, 2, 'shiro')); }); + return offered(container, 2, 'shiro'); + }; + + it('reads the members again and shows a rename made elsewhere', async () => { + const { container } = await open(['Ren Abe', ...OTHERS]); + expect((await options(container)).some((o) => o.includes('Ren Abe'))).toBe(true); + + await announce(); + expect(memberReads, 'a read for the announcement').toHaveLength(3); + await act(async () => { memberReads[2](shiro('Ren Abe-Kato', ...OTHERS)); }); + await flush(); + + const after = await options(container); + expect(after.some((o) => o.includes('Ren Abe-Kato')), 'the new name is offered').toBe(true); + expect(after.some((o) => /Ren Abe(?!-)/.test(o)), 'and the old one no longer').toBe(false); + }); + + it('shows a member added or cleared elsewhere in the same way', async () => { + const { container } = await open(['Ren Abe', ...OTHERS]); + + await announce(); + await act(async () => { memberReads[2](teams([...members('b', ['', ...OTHERS]), { id: 'b6', index: 6, name: 'Newcomer' }])); }); + await flush(); + + const after = await options(container); + expect(after.some((o) => o.includes('Newcomer')), 'the member another device added').toBe(true); + expect(after.some((o) => o.includes('Ren Abe')), 'the name another device cleared').toBe(false); + }); + + // The list a read answers once the server holds `first` as the first member, as the + // write's own answer stamped it. + const shiroWith = (first, ...rest) => ({ 'team-A': AKA, 'team-B': [first, ...members('b', ['', ...rest]).slice(1)] }); + + it('shows a rename made elsewhere of a member the sheet named, however the sheet\'s own write was read', async () => { + const { container } = await open(['', ...OTHERS]); + await typeName(bout(container, 0, 'shiro'), 'Ito'); + expect(window.API.renameTeamMember).toHaveBeenCalledWith('comp1', 'team-B', 'b1', 'Ito', ''); + expect(bout(container, 0, 'shiro').value).toBe('Ito'); + const mine = await window.API.renameTeamMember.mock.results[0].value; + + // The server announces the rename, and the read holds it, with the stamp of the write. + await announce(); + await act(async () => { memberReads[2](shiroWith(mine, ...OTHERS)); }); + await flush(); + expect(bout(container, 0, 'shiro').value).toBe('Ito'); + + // Another device renames the member: its stamp is the larger, so that shows. + await announce(); + await act(async () => { memberReads[3](shiroWith(namedLater(mine, 'Itoh'), ...OTHERS)); }); + await flush(); + expect(bout(container, 0, 'shiro').value).toBe('Itoh'); + }); + + it('shows another device\'s later rename of a member the sheet named even when no read has shown the sheet\'s own name first', async () => { + const { container } = await open(['', ...OTHERS]); + await typeName(bout(container, 0, 'shiro'), 'Ito'); + const mine = await window.API.renameTeamMember.mock.results[0].value; + + await announce(); + await act(async () => { memberReads[2](shiroWith(namedLater(mine, 'Itoh'), ...OTHERS)); }); + await flush(); + + expect(bout(container, 0, 'shiro').value).toBe('Itoh'); + }); + + it('keeps the member the sheet named over a read begun before the write that answers after it, and over one the write\'s own answer is older than', async () => { + const { container } = await open(['', ...OTHERS]); + await announce(); + await typeName(bout(container, 0, 'shiro'), 'Ito'); + const mine = await window.API.renameTeamMember.mock.results[0].value; + // A read of what the server held before the write answers after it. + await act(async () => { memberReads[2](shiro('', ...OTHERS)); }); + await flush(); + expect(bout(container, 0, 'shiro').value, 'the older list does not undo the write').toBe('Ito'); + + // A list another device's later rename made, then the answer of this sheet's own write + // arriving once more (a retry of the same write is the same stamp): the larger stays. + await announce(); + await act(async () => { memberReads[3](shiroWith(namedLater(mine, 'Itoh'), ...OTHERS)); }); + await flush(); + expect(bout(container, 0, 'shiro').value).toBe('Itoh'); + await announce(); + await act(async () => { memberReads[4](shiroWith(mine, ...OTHERS)); }); + await flush(); + expect(bout(container, 0, 'shiro').value, 'a list holding only this sheet\'s own, older copy does not undo the later rename').toBe('Itoh'); + }); + + // Only the read made as the sheet opens says the members could not be read: a later one + // that fails changes nothing, and the list that was read stays. + it('says nothing when a later read fails: the members that were read stay, and a name typed next carries no warning', async () => { + const { container } = await open(['', ...OTHERS]); + await announce(); + await act(async () => { memberFailures[2](new TypeError('Failed to fetch')); }); + await flush(); + + await typeName(bout(container, 0, 'shiro'), 'Ito'); + + expect(window.API.renameTeamMember, 'resolved against the list that was read').toHaveBeenCalledWith('comp1', 'team-B', 'b1', 'Ito', ''); + expect(notices(container), 'no warning that the members could not be read').toHaveLength(0); + }); + + it('keeps the name the sheet wrote over a read that predates the write, as before', async () => { + const { container } = await open(['', ...OTHERS]); + await announce(); + await typeName(bout(container, 0, 'shiro'), 'Ito'); + + // The read begun by the announcement, before the write, answers with the member unnamed. + await act(async () => { memberReads[2](shiro('', ...OTHERS)); }); + await flush(); + + expect(bout(container, 0, 'shiro').value).toBe('Ito'); + }); + + // Which copy of a member is newer is read off the stamp the server gave it, never off + // which read began first or which answer arrived last. + describe('keeps the newest copy of a member, whichever order the answers arrive in', () => { + const older = answered({ id: 'b1', index: 1 }, { name: 'Ren Older' }); + const newer = namedLater(older, 'Ren Newer'); + const holding = (first) => shiroWith(first, ...OTHERS); + + it('an older copy arriving after a newer one is ignored', async () => { + const { container } = await open(['Ren Abe', ...OTHERS]); + await announce(); + await announce(); + expect(memberReads, 'one read for each announcement').toHaveLength(4); + + await act(async () => { memberReads[3](holding(newer)); }); + await flush(); + await act(async () => { memberReads[2](holding(older)); }); + await flush(); + + const after = await options(container); + expect(after.some((o) => o.includes('Ren Newer')), 'the newer copy stays').toBe(true); + expect(after.some((o) => o.includes('Ren Older')), 'the older one does not replace it').toBe(false); + }); + + // The sheet's write is answered after a list that holds a later rename of the same member + // has been shown: the answer carries the older stamp, so it does not undo that list. + it('an own write\'s answer older than a list already shown does not undo the list', async () => { + const { container } = await open(['', ...OTHERS]); + const answer = deferred(); + const mine = answered({ id: 'b1', index: 1 }, { name: 'Ito' }); + window.API.renameTeamMember = vi.fn(() => answer.promise); + await typeName(bout(container, 0, 'shiro'), 'Ito'); + expect(window.API.renameTeamMember, 'the write is out').toHaveBeenCalledTimes(1); + + // Another device renames the member after this write was stamped, and a read shows it. + await announce(); + await act(async () => { memberReads[2](holding(namedLater(mine, 'Itoh'))); }); + await flush(); + await act(async () => { answer.resolve(mine); }); + await flush(); + + expect(bout(container, 0, 'shiro').value).toBe('Itoh'); + }); + + it('a newer copy shows even when the read that holds it began first', async () => { + const { container } = await open(['Ren Abe', ...OTHERS]); + await announce(); + await announce(); + + await act(async () => { memberReads[3](holding(older)); }); + await flush(); + await act(async () => { memberReads[2](holding(newer)); }); + await flush(); + + const after = await options(container); + expect(after.some((o) => o.includes('Ren Newer')), 'the newer copy shows').toBe(true); + expect(after.some((o) => o.includes('Ren Older')), 'and the older one does not stand beside it').toBe(false); + }); + }); + + it('still takes an older answer while no newer one has been, and ignores an announcement for another competition', async () => { + const { container } = await open(['Ren Abe', ...OTHERS]); + await announce('another-competition'); + expect(memberReads, 'nothing is read for another competition').toHaveLength(2); + + await announce(); + await announce(); + await act(async () => { memberReads[2](shiro('Ren Older', ...OTHERS)); }); + await flush(); + + expect((await options(container)).some((o) => o.includes('Ren Older')), 'the only answer so far is taken').toBe(true); + }); + + // The announcement names the team it changed (teamId): the sheet reads the members + // again only when that is one of its two teams, and still reads on one that names none. + it('reads again for an announcement that names one of its teams, and not for another team\'s', async () => { + const announceFor = (teamId, competitionId = 'comp1') => act(async () => { + window.dispatchEvent(new CustomEvent('lineup-updated', { detail: { competitionId, teamId } })); + }); + await open(['Ren Abe', ...OTHERS]); + + await announceFor('team-elsewhere'); + expect(memberReads, 'nothing is read for another team').toHaveLength(2); + await announceFor('team-B'); + expect(memberReads, 'its Shiro team is read').toHaveLength(3); + await announceFor('team-A'); + expect(memberReads, 'and its Aka team').toHaveLength(4); + await announce(); + expect(memberReads, 'an announcement that names no team is read, as before').toHaveLength(5); + }); + + // The warning that the members could not be read speaks for the LATEST read: the opening + // read failing after a later read already showed the members is stale news. + it('says nothing when the opening read fails after a later read showed the members', async () => { + let utils; + await act(async () => { + utils = render(); + }); + expect(memberReads, 'the opening read is out').toHaveLength(1); + await announce(); + expect(memberReads, 'and a later one begins').toHaveLength(2); + await act(async () => { memberReads[1](shiro('', ...OTHERS)); }); + await flush(); + + await act(async () => { memberFailures[0](new TypeError('Failed to fetch')); }); + await flush(); + await typeName(bout(utils.container, 0, 'shiro'), 'Ito'); + + expect(window.API.renameTeamMember, 'resolved against the list the later read showed').toHaveBeenCalledWith('comp1', 'team-B', 'b1', 'Ito', ''); + expect(notices(utils.container), 'no warning that the members could not be read').toHaveLength(0); + }); + + it('still says so when the opening read fails and no later read showed the members', async () => { + let utils; + await act(async () => { + utils = render(); + }); + await act(async () => { memberFailures[0](new TypeError('Failed to fetch')); }); + await flush(); + + await typeName(bout(utils.container, 0, 'shiro'), 'Ito'); + + expect(notices(utils.container)).toHaveLength(1); + expect(notices(utils.container)[0].textContent).toContain('team member list could not be loaded'); + }); +}); + +// A kachinuki row past the first names its fighters on the bout itself, and it can be +// the removable one: its notice goes with the bout it was about, and a rename that is +// still out when another team is given the side does not reach the new team's list. +describe('team editor: a kachinuki row\'s name box', () => { + const PLAYED = [ + { position: 1, sideA: 'A One', sideB: 'Mei Ito', ipponsA: ['M', 'M'], ipponsB: [], winner: 'A One' }, + { position: 2, sideA: 'A One', sideB: '', ipponsA: [], ipponsB: [] }, + ]; + const BEFORE = { 'team-A': blank('a'), 'team-B': blank('b') }; + const kachinuki = (sideB) => teamMatch({ phase: 'pool', compFormat: 'mixed', teamMatchType: 'kachinuki', subResults: PLAYED, ...(sideB ? { sideB } : {}) }); + const shiroNow = (container) => [...container.querySelectorAll('.team-sub-match__side--shiro input')].pop(); + const shiroOptions = (container) => [...container.querySelectorAll('.team-sub-match__side--shiro .pmf__option')].map((o) => o.textContent); + + // Picks the numbered member T2.3 from the current bout's list and types a name over it. + async function nameTheThirdMember(container) { + await act(async () => { fireEvent.focus(shiroNow(container)); }); + const slot = [...container.querySelectorAll('.team-sub-match__side--shiro .pmf__option')].find((o) => o.textContent.includes('T2.3')); + await act(async () => { fireEvent.click(slot); }); + await typeName(shiroNow(container), 'Ito'); + } + + it('shows no warning about the old pairing on a bout added at the place of a removed one', async () => { + window.API.renameTeamMember = vi.fn().mockRejectedValue(new Error('offline')); + window.API.removeKachinukiBout = vi.fn().mockResolvedValue({ id: 'm1', subResults: [PLAYED[0]] }); + const { container } = await mountWithSecondReadOut(BEFORE, kachinuki()); + + await nameTheThirdMember(container); + expect(notices(container), 'the rename that failed is reported in the bout row').toHaveLength(1); + + await act(async () => { fireEvent.click(screen.getByTestId('kachinuki-remove-bout-button')); }); + await flush(); + expect(window.API.removeKachinukiBout).toHaveBeenCalledTimes(1); + // The next pairing is added by hand, at the same place. + await act(async () => { fireEvent.click(screen.getByTestId('kachinuki-add-bout-button')); }); + await flush(); + + expect(container.querySelectorAll('.team-sub-match').length, 'the new bout is a row of its own').toBeGreaterThan(1); + expect(notices(container), 'nothing is said about the pairing that was removed').toHaveLength(0); + }); + + it('does not put the old team\'s renamed member in the list of the team the side was given while the rename was out', async () => { + const rename = deferred(); + const { container, rerender } = await mountWithSecondReadOut(BEFORE, kachinuki()); + window.API.renameTeamMember = vi.fn(() => rename.promise); + + await nameTheThirdMember(container); + expect(window.API.renameTeamMember).toHaveBeenCalledWith('comp1', 'team-B', 'b3', 'Ito', ''); + + // Another team is given the Shiro side, and its members are read. + await act(async () => { + rerender(); + }); + await flush(); + await act(async () => { memberReads[memberReads.length - 1]({ 'team-A': blank('a'), 'team-C': blank('c') }); }); + await flush(); + + // The rename the old team's member was waiting on lands, answered as the server does. + await act(async () => { rename.resolve(answered({ id: 'b3', index: 3 }, { name: 'Ito' })); }); + await flush(); + + await act(async () => { fireEvent.focus(shiroNow(container)); }); + expect(shiroOptions(container).some((o) => o.includes('Ito')), 'the old team\'s member is not offered to the new team').toBe(false); + expect(notices(container)[0].textContent).toBe('Nothing was saved because another team is now on this side. Type the name again.'); + }); +}); diff --git a/web-mobile/js/__tests__/render/team_editor_members_read_first.render.test.jsx b/web-mobile/js/__tests__/render/team_editor_members_read_first.render.test.jsx new file mode 100644 index 000000000..e7ada51ba --- /dev/null +++ b/web-mobile/js/__tests__/render/team_editor_members_read_first.render.test.jsx @@ -0,0 +1,785 @@ +// The team score sheet keeps its own lists of each side's team members, and a name +// typed in a bout row is resolved against the list of that side's team. Three rules, in +// a real DOM: +// +// 1. A name typed before the team's members were read waits for them (bounded by the +// deadline of any request). Resolved against none, a new name is minted where the +// member's seeded slot is free instead of naming that slot, and the name of a +// member the team has is refused by the server as a second member of that name, +// so the lineup is saved with the name and no member id. The wait ends when the +// read fails, and the sheet then goes on without them, as it always did, and says +// so. The deadline ends it for good: while a read hangs, only the first name +// waits, and the next goes on at once with the same warning. +// +// 2. A side given another team (the feeding match of a knockout corrected on another +// device seats another team on it) holds nothing of the old team's members: they +// are not merged into the new team's list, where they would be offered and +// refused by the server (400 team_member_not_in_team), and nothing the sheet wrote +// for them is applied to the new team's. Both ways the members reach the sheet: +// handed in by the host (the public self-run page) and read by the sheet itself. +// +// 3. The same side shows the new team's lineup, and a pick made for the old team while +// it was out writes nothing, and says so at the row: the organiser is not judged +// by the server for putting one team's member in another team's lineup. One whose +// member write was out keeps that write (it is the old team's own member) but saves +// no lineup and records the member nowhere on the side. The old team's lineup goes +// with its members until the new team's is read, and nothing still out for the old +// team lands on the side, whenever it answers. +// +// 4. A side is given another team only when it really is one: its own key in the match +// and the team it resolves to both change. The team's id arriving for a side that +// carried only its name, or the match starting to carry the id of the team it named, +// is not. +// +// 5. A side whose team is not known yet (it carries only a name, and the roster has not +// named a participant for it) takes no members list: its wait stays open, and a pick +// that waited writes for the team as it is known when the wait is over. + +import React from 'react'; +import { render, act, fireEvent } from '@testing-library/react'; +import { describe, it, expect, vi, beforeAll, afterAll, beforeEach } from 'vitest'; +import { installWindowStubs } from '../helpers/stub_globals.js'; +import { readCode } from '../helpers/source.js'; +import { lineupPutStubByTeam } from '../helpers/lineup_server.js'; +import { FETCH_TIMEOUT_MS } from '../../write_result.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: {}, + compMatches: () => [], + compMatchesForCompetition: () => [], + Term: ({ children }) => {children}, + GlossaryHint: ({ name }) => , +}; + +let restoreGlobals; +let ScoreEditorModal; + +beforeAll(async () => { + restoreGlobals = installWindowStubs(STUBBED_GLOBALS); + await import('../../admin_lineup.jsx'); + await import('../../admin_scoring_modal.jsx'); + ScoreEditorModal = window.ScoreEditorModal; +}); + +afterAll(() => restoreGlobals()); + +const members = (p, names) => names.map((name, i) => ({ id: `${p}${i + 1}`, index: i + 1, name })); +const AKA = members('a', ['A One', 'A Two', 'A Three', 'A Four', 'A Five']); +const SHIRO_NAMED = members('b', ['Ren Abe', 'Kai Mori', 'Yui Sato', 'Rin Ota', 'Sho Ueda']); +// The first slot has no name yet: a name typed for the first bout names that member. +const SHIRO_FIRST_BLANK = members('b', ['', 'Kai Mori', 'Yui Sato', 'Rin Ota', 'Sho Ueda']); +const teams = (shiro) => ({ 'team-A': AKA, 'team-B': shiro }); + +function deferred() { + let resolve; + const promise = new Promise((res) => { resolve = res; }); + return { promise, resolve }; +} + +const flush = () => act(async () => { await new Promise((r) => setTimeout(r, 0)); }); + +// What the server holds, so a lineup the sheet reads is the one it saved. +let lineups; +// The members reads the sheet has asked for, in the order it asked: each answered by the test. +let memberReads; +// Held until a test lets the competition arrive, which makes the sheet read the members again. +let competition; + +beforeEach(() => { + lineups = {}; + memberReads = []; + competition = deferred(); + window.API = { + fetchCompetitionDetails: vi.fn(() => competition.promise), + fetchSquads: vi.fn(() => new Promise((resolve, reject) => { memberReads.push({ resolve, reject }); })), + fetchLineupInForce: vi.fn(async (_c, teamId) => lineups[teamId] || null), + // A save names the position it changed, and the server answers the lineup it holds then. + putMatchLineup: lineupPutStubByTeam(() => lineups), + renameTeamMember: vi.fn(async () => true), + addTeamMember: vi.fn(async (_c, _t, name) => ({ id: 'new-1', index: 6, name })), + recordScore: vi.fn().mockResolvedValue(undefined), + recordDaihyosen: vi.fn(), + removeDaihyosen: vi.fn(), + recordDecision: vi.fn(), + hasPendingTerminalWrite: () => false, + notePendingEdit: () => () => {}, + }; +}); + +const bout = (root, n, color) => root.querySelectorAll('.team-sub-match')[n].querySelector(`.team-sub-match__side--${color} input`); +const label = (root, n, color) => { + const el = root.querySelectorAll('.team-sub-match')[n].querySelector(`[data-testid="team-sub-match-member-label-${color}"]`); + return el ? el.textContent : ''; +}; +const notices = (root) => [...root.querySelectorAll('[data-testid="team-editor-lineup-warning"]')].map((n) => n.textContent); +const offered = (root, n, color) => [...root.querySelectorAll('.team-sub-match')[n].querySelectorAll(`.team-sub-match__side--${color} .pmf__option`)] + .map((o) => o.textContent); +// The names a bout row's list offers for a side once it is opened. +async function offers(root, n, color) { + await act(async () => { fireEvent.focus(bout(root, n, color)); }); + return offered(root, n, color); +} + +// Types a name into a bout row and takes it, without waiting for what it starts. +const type = (input, name) => act(async () => { + fireEvent.focus(input); + fireEvent.change(input, { target: { value: name } }); + fireEvent.keyDown(input, { key: 'Enter' }); +}); +async function typeName(input, name) { + await type(input, name); + await flush(); +} + +const side = (id, number) => ({ id, name: `Team ${id.slice(5)}`, number }); +const teamMatch = (extra = {}) => ({ + id: 'm1', + compId: 'comp1', + status: 'running', + phase: 'bracket', + court: 'A', + compKind: 'team', + teamSize: 5, + compFormat: 'knockout', + teamMatchType: 'fixed', + round: 'Semi-final', + matchNumber: 1, + sideA: side('team-A', 'T1'), + sideB: side('team-B', 'T2'), + ...extra, +}); + +async function open(match = teamMatch(), extra = {}) { + let view; + await act(async () => { + view = render(); + }); + return view; +} + +describe('team editor: a name typed before the team\'s members were read waits for them', () => { + it('places the member the team has by its id, instead of adding the name again', async () => { + const { container } = await open(); + + await typeName(bout(container, 0, 'shiro'), 'Kai Mori'); + expect(window.API.addTeamMember, 'nothing is added while the members are out').not.toHaveBeenCalled(); + expect(window.API.putMatchLineup, 'nor written').not.toHaveBeenCalled(); + + await act(async () => { memberReads[0].resolve(teams(SHIRO_NAMED)); }); + await flush(); + + expect(window.API.addTeamMember, 'Kai Mori is the member the team has, not a new one').not.toHaveBeenCalled(); + expect(window.API.renameTeamMember).not.toHaveBeenCalled(); + const [, teamId, , positions, , memberIds] = window.API.putMatchLineup.mock.calls[0]; + expect(teamId).toBe('team-B'); + expect(positions).toEqual({ senpo: 'Kai Mori' }); + expect(memberIds).toEqual({ senpo: 'b2' }); + expect(notices(container)).toEqual([]); + }); + + it('names the unnamed member seeded for the position, instead of adding one', async () => { + const { container } = await open(); + + await typeName(bout(container, 0, 'shiro'), 'Ito'); + expect(window.API.addTeamMember).not.toHaveBeenCalled(); + expect(window.API.renameTeamMember).not.toHaveBeenCalled(); + + await act(async () => { memberReads[0].resolve(teams(SHIRO_FIRST_BLANK)); }); + await flush(); + + expect(window.API.renameTeamMember, 'the slot the row was shown with is named').toHaveBeenCalledWith('comp1', 'team-B', 'b1', 'Ito', ''); + expect(window.API.addTeamMember, 'no member is added beside it').not.toHaveBeenCalled(); + expect(window.API.putMatchLineup.mock.calls[0][5]).toEqual({ senpo: 'b1' }); + expect(label(container, 0, 'shiro')).toBe('T2.1'); + }); + + it('goes on without the members when they could not be read, as it always did, and says so', async () => { + const { container } = await open(); + await act(async () => { memberReads[0].reject(new TypeError('Failed to fetch')); }); + await flush(); + + await typeName(bout(container, 0, 'shiro'), 'Newcomer'); + + expect(window.API.addTeamMember, 'the name is added against no list, at once').toHaveBeenCalledWith('comp1', 'team-B', 'Newcomer', ''); + expect(notices(container)).toHaveLength(1); + expect(notices(container)[0]).toContain('team member list could not be loaded'); + }); + + it('goes on, and says so, when the read fails while the name waits for it', async () => { + const { container } = await open(); + await typeName(bout(container, 0, 'shiro'), 'Newcomer'); + expect(window.API.addTeamMember).not.toHaveBeenCalled(); + + await act(async () => { memberReads[0].reject(new TypeError('Failed to fetch')); }); + await flush(); + + expect(window.API.addTeamMember).toHaveBeenCalledWith('comp1', 'team-B', 'Newcomer', ''); + expect(notices(container)).toHaveLength(1); + expect(notices(container)[0]).toContain('team member list could not be loaded'); + }); + + it('does not wait once a list has been shown, whatever read is out', async () => { + const { container } = await open(); + await act(async () => { memberReads[0].resolve(teams(SHIRO_NAMED)); }); + await flush(); + // The competition arriving makes the sheet read the members again, and that read is never answered. + await act(async () => { competition.resolve({ id: 'comp1', config: { format: 'knockout', teamMatchType: 'fixed', naginata: false, players: [] } }); }); + await flush(); + expect(memberReads, 'the second read is out').toHaveLength(2); + + await typeName(bout(container, 0, 'shiro'), 'Newcomer'); + + expect(window.API.addTeamMember, 'the name is added against the list shown, at once').toHaveBeenCalledWith('comp1', 'team-B', 'Newcomer', ''); + }); + + // The member added then is one the list that arrives late lacks, which the sheet keeps + // (team_editor_member_writes_stand.render.test.jsx). + it('goes on at the deadline, and says so', async () => { + vi.useFakeTimers(); + let container; + try { + ({ container } = await open()); + await type(bout(container, 0, 'shiro'), 'Newcomer'); + await act(async () => { await vi.advanceTimersByTimeAsync(FETCH_TIMEOUT_MS - 1); }); + expect(window.API.addTeamMember, 'nothing is added while the read may still answer').not.toHaveBeenCalled(); + + await act(async () => { await vi.advanceTimersByTimeAsync(1); }); + await act(async () => { await vi.advanceTimersByTimeAsync(0); }); + + expect(window.API.addTeamMember).toHaveBeenCalledWith('comp1', 'team-B', 'Newcomer', ''); + } finally { + vi.useRealTimers(); + } + expect(notices(container)).toHaveLength(1); + expect(notices(container)[0]).toContain('team member list could not be loaded'); + }); + + // The deadline ends the wait for good (lineup_form.test.jsx): while the read hangs, only + // the first name waits for it. The next goes on at once, with the same warning, and a + // list that arrives late is still shown. + it('waits once for a read that hangs: the next name goes on at once, with the warning, and the late list is still shown', async () => { + let added = 0; + window.API.addTeamMember.mockImplementation(async (_c, _t, name) => ({ id: `new-${++added}`, index: 5 + added, name })); + vi.useFakeTimers(); + let container; + try { + ({ container } = await open()); + await type(bout(container, 0, 'shiro'), 'Newcomer'); + await act(async () => { await vi.advanceTimersByTimeAsync(FETCH_TIMEOUT_MS); }); + expect(window.API.addTeamMember).toHaveBeenCalledTimes(1); + + await type(bout(container, 1, 'shiro'), 'Another'); + await act(async () => { await vi.advanceTimersByTimeAsync(0); }); + + expect(window.API.addTeamMember, 'the second name does not wait for a deadline of its own').toHaveBeenCalledTimes(2); + const second = container.querySelectorAll('.team-sub-match')[1].querySelector('[data-testid="team-editor-lineup-warning"]'); + expect(second, 'and says the members could not be read, as the first did').not.toBeNull(); + expect(second.textContent).toContain('team member list could not be loaded'); + } finally { + vi.useRealTimers(); + } + + await act(async () => { memberReads[0].resolve(teams(SHIRO_NAMED)); }); + await flush(); + + expect((await offers(container, 3, 'shiro')).some((o) => o.includes('Sho Ueda')), 'the list that arrives late is shown').toBe(true); + }); +}); + +describe('team editor: a side given another team holds nothing of the old team\'s members', () => { + const X = members('x', ['Xavier One', 'Xavier Two', 'Xavier Three', 'Xavier Four', 'Xavier Five']); + const Y = members('y', ['Yuri One', 'Yuri Two', 'Yuri Three', 'Yuri Four', 'Yuri Five']); + const everyTeam = { 'team-X': X, 'team-Y': Y, 'team-B': SHIRO_NAMED }; + const holds = (list, who) => list.some((o) => o.includes(who)); + const withSideA = (id, number) => teamMatch({ sideA: side(id, number) }); + + describe('handed in by the host (the public self-run page)', () => { + const host = (id, number, lists = everyTeam) => ( + + ); + + it('offers only the new team\'s members, not the old team\'s beside them', async () => { + let view; + await act(async () => { view = render(host('team-X', 'T3')); }); + await flush(); + const before = await offers(document, 2, 'aka'); + expect(holds(before, 'Xavier One'), 'the old team\'s members are offered while it plays the side').toBe(true); + + await act(async () => { view.rerender(host('team-Y', 'T4')); }); + await flush(); + const after = await offers(document, 2, 'aka'); + + expect(holds(after, 'Yuri One'), 'the new team\'s members are offered').toBe(true); + expect(after.filter((o) => o.includes('Xavier')), 'and none of the old team\'s').toEqual([]); + expect(after, 'five members, not ten').toHaveLength(5); + expect(holds(await offers(document, 2, 'shiro'), 'Ren Abe'), 'the other side is left as it was').toBe(true); + }); + + it('applies no name the sheet wrote for the old team\'s member to the new team\'s', async () => { + // Two teams whose members carry the same ids, which only a test can arrange: what + // the sheet wrote is kept by member id until a list shows it. + const oldTeam = members('m', ['', 'Xavier Two', 'Xavier Three', 'Xavier Four', 'Xavier Five']); + const newTeam = members('m', ['Yuri One', 'Yuri Two', 'Yuri Three', 'Yuri Four', 'Yuri Five']); + const lists = { 'team-X': oldTeam, 'team-Y': newTeam, 'team-B': SHIRO_NAMED }; + let view; + await act(async () => { view = render(host('team-X', 'T3', lists)); }); + await flush(); + await typeName(bout(document, 0, 'aka'), 'Ito'); + expect(window.API.renameTeamMember, 'the first slot of the old team is named').toHaveBeenCalledWith('comp1', 'team-X', 'm1', 'Ito', ''); + + await act(async () => { view.rerender(host('team-Y', 'T4', lists)); }); + await flush(); + // The first row's list: the lineup the sheet wrote still names the member that shares + // the id at that position, so it is that row's own member and is offered there. + const first = (await offers(document, 0, 'aka')).find((o) => o.includes('T4.1')); + + expect(first, 'the new team\'s first member is offered').toBeTruthy(); + expect(first, 'under its own name, not the one written for the old team\'s').toContain('Yuri One'); + expect(first).not.toContain('Ito'); + }); + }); + + describe('read by the sheet itself (an admin host)', () => { + const host = (id, number) => ( + + ); + + it('drops the old team\'s members at once, reads the new team\'s, and offers only those', async () => { + let view; + await act(async () => { view = render(host('team-X', 'T3')); }); + await act(async () => { memberReads[0].resolve(everyTeam); }); + await flush(); + expect(holds(await offers(document, 2, 'aka'), 'Xavier One')).toBe(true); + + await act(async () => { view.rerender(host('team-Y', 'T4')); }); + await flush(); + + expect(memberReads, 'the new team\'s members are read').toHaveLength(2); + expect(await offers(document, 2, 'aka'), 'nothing of the old team is offered while they are out').toEqual([]); + + await act(async () => { memberReads[1].resolve(everyTeam); }); + await flush(); + const after = await offers(document, 2, 'aka'); + + expect(holds(after, 'Yuri One')).toBe(true); + expect(after.filter((o) => o.includes('Xavier'))).toEqual([]); + expect(after).toHaveLength(5); + }); + + it('waits for the new team\'s members before a name typed for it is resolved', async () => { + let view; + await act(async () => { view = render(host('team-X', 'T3')); }); + await act(async () => { memberReads[0].resolve(everyTeam); }); + await flush(); + await act(async () => { view.rerender(host('team-Y', 'T4')); }); + await flush(); + + await typeName(bout(document, 0, 'aka'), 'Yuri Two'); + expect(window.API.addTeamMember, 'the new team\'s members were not read yet').not.toHaveBeenCalled(); + + await act(async () => { memberReads[1].resolve(everyTeam); }); + await flush(); + + expect(window.API.addTeamMember, 'Yuri Two is the member the new team has').not.toHaveBeenCalled(); + expect(window.API.putMatchLineup.mock.calls[0][1]).toBe('team-Y'); + expect(window.API.putMatchLineup.mock.calls[0][5]).toEqual({ senpo: 'y2' }); + }); + + // A name is picked for the team that plays the side when it is typed. When another + // team takes the side while the pick is still out (the feeding match corrected on + // another device), nothing of it is written: the member it would name or add is the old + // team's, and the lineup it would save is one the new team does not carry. The organiser + // is not judged by the server for either, so the sheet stops it, and says why at the row. + const OTHER_TEAM = 'Nothing was saved because another team is now on this side. Type the name again.'; + const noWrite = () => { + expect(window.API.addTeamMember, 'no member is added').not.toHaveBeenCalled(); + expect(window.API.renameTeamMember, 'none is named').not.toHaveBeenCalled(); + expect(window.API.putMatchLineup, 'and no lineup is written').not.toHaveBeenCalled(); + }; + + it('stops a pick that waits for the members when the side is given another team', async () => { + let view; + await act(async () => { view = render(host('team-X', 'T3')); }); + await type(bout(document, 0, 'aka'), 'Newcomer'); + expect(window.API.addTeamMember, 'the members are out, so the pick waits').not.toHaveBeenCalled(); + + await act(async () => { view.rerender(host('team-Y', 'T4')); }); + await flush(); + await act(async () => { memberReads.forEach((read) => read.resolve(everyTeam)); }); + await flush(); + + noWrite(); + expect(window.API.fetchLineupInForce, 'the old team\'s lineup is not read back into the side').not.toHaveBeenCalledWith('comp1', 'team-X', 'm1'); + expect(notices(document)).toEqual([OTHER_TEAM]); + }); + + it('stops a pick that waits for the lineup when the side is given another team', async () => { + let view; + await act(async () => { view = render(host('team-X', 'T3')); }); + await act(async () => { memberReads[0].resolve(everyTeam); }); + await flush(); + const lineupRead = deferred(); + window.API.fetchLineupInForce = vi.fn(() => lineupRead.promise); + await typeName(bout(document, 0, 'aka'), 'Newcomer'); + expect(window.API.fetchLineupInForce, 'the lineup is being read for the pick').toHaveBeenCalledTimes(1); + + await act(async () => { view.rerender(host('team-Y', 'T4')); }); + await flush(); + await act(async () => { lineupRead.resolve(null); }); + await flush(); + + noWrite(); + expect(notices(document)).toEqual([OTHER_TEAM]); + }); + + it('lets a pick through when the side keeps its team', async () => { + await act(async () => { render(host('team-X', 'T3')); }); + await act(async () => { memberReads[0].resolve(everyTeam); }); + await flush(); + + await typeName(bout(document, 0, 'aka'), 'Newcomer'); + + expect(window.API.addTeamMember).toHaveBeenCalledWith('comp1', 'team-X', 'Newcomer', 'pw'); + expect(notices(document)).toEqual([]); + }); + + // The sheet reads each side's lineup for the team that plays it, so a side given + // another team shows that team's lineup and not the old team's beside its members. + it('reads the lineup of the team a side is given, and shows it instead of the old team\'s', async () => { + lineups = { + 'team-X': { positions: { senpo: 'Xavier One' }, memberIds: { senpo: 'x1' } }, + 'team-Y': { positions: { senpo: 'Yuri One' }, memberIds: { senpo: 'y1' } }, + }; + let view; + await act(async () => { view = render(host('team-X', 'T3')); }); + await act(async () => { competition.resolve({ id: 'comp1', config: { format: 'knockout', teamMatchType: 'fixed', naginata: false, players: [] } }); }); + await flush(); + expect(bout(document, 0, 'aka').value, 'the lineup of the team on the side').toBe('Xavier One'); + + await act(async () => { view.rerender(host('team-Y', 'T4')); }); + await flush(); + + expect(window.API.fetchLineupInForce).toHaveBeenCalledWith('comp1', 'team-Y', 'm1'); + expect(bout(document, 0, 'aka').value, 'the new team\'s lineup, not the old team\'s').toBe('Yuri One'); + }); + + // A pick whose member write is out when the side is given another team: the write was + // typed for the old team and stays (it is the team's own member), but the lineup is + // not saved, and the member is not recorded on the side, whose list is the new team's. + it('stops a pick whose member write is still out when the side is given another team', async () => { + let view; + await act(async () => { view = render(host('team-X', 'T3')); }); + await act(async () => { memberReads[0].resolve(everyTeam); }); + await flush(); + const mint = deferred(); + window.API.addTeamMember = vi.fn(() => mint.promise); + await typeName(bout(document, 0, 'aka'), 'Newcomer'); + expect(window.API.addTeamMember, 'the member is being added for the old team').toHaveBeenCalledWith('comp1', 'team-X', 'Newcomer', 'pw'); + + await act(async () => { view.rerender(host('team-Y', 'T4')); }); + await flush(); + await act(async () => { mint.resolve({ id: 'new-1', index: 6, name: 'Newcomer' }); }); + await flush(); + await act(async () => { memberReads[1].resolve(everyTeam); }); + await flush(); + + expect(window.API.addTeamMember, 'the member that was added stays').toHaveBeenCalledTimes(1); + expect(window.API.putMatchLineup, 'but no lineup is written').not.toHaveBeenCalled(); + expect(notices(document)).toEqual([OTHER_TEAM]); + const offeredNow = await offers(document, 2, 'aka'); + expect(holds(offeredNow, 'Yuri One'), 'the side lists the new team\'s members').toBe(true); + expect(holds(offeredNow, 'Newcomer'), 'and not the member added for the old team').toBe(false); + }); + + // The lineup each side shows is dropped when it is given another team, before the new + // team's is read: a read that fails leaves the side with no lineup, and a read still out + // for the old team never lands on it, whenever it answers. + const ROSTER = { id: 'comp1', config: { format: 'knockout', teamMatchType: 'fixed', naginata: false, players: [] } }; + const lineupOf = (name, id) => ({ positions: { senpo: name }, memberIds: { senpo: id } }); + + it('leaves a side with no lineup when the new team\'s lineup cannot be read, never the old team\'s', async () => { + lineups = { 'team-X': lineupOf('Xavier One', 'x1') }; + window.API.fetchLineupInForce = vi.fn(async (_c, teamId) => { + if (teamId === 'team-Y') throw new TypeError('Failed to fetch'); + return lineups[teamId] || null; + }); + let view; + await act(async () => { view = render(host('team-X', 'T3')); }); + await act(async () => { competition.resolve(ROSTER); }); + await flush(); + expect(bout(document, 0, 'aka').value).toBe('Xavier One'); + + await act(async () => { view.rerender(host('team-Y', 'T4')); }); + await flush(); + + expect(window.API.fetchLineupInForce, 'the new team\'s lineup was asked for').toHaveBeenCalledWith('comp1', 'team-Y', 'm1'); + expect(bout(document, 0, 'aka').value, 'and the side shows none, not the old team\'s').toBe(''); + }); + + it('refuses a pick on a side whose new team\'s lineup was not read, rather than composing it on the old team\'s', async () => { + lineups = { 'team-X': lineupOf('Xavier One', 'x1') }; + window.API.fetchLineupInForce = vi.fn(async (_c, teamId) => { + if (teamId === 'team-Y') throw new TypeError('Failed to fetch'); + return lineups[teamId] || null; + }); + let view; + await act(async () => { view = render(host('team-X', 'T3')); }); + await act(async () => { competition.resolve(ROSTER); }); + await flush(); + await act(async () => { view.rerender(host('team-Y', 'T4')); }); + await flush(); + await act(async () => { memberReads[memberReads.length - 1].resolve(everyTeam); }); + await flush(); + + await typeName(bout(document, 0, 'aka'), 'Yuri Two'); + + expect(window.API.putMatchLineup, 'nothing is written for the new team').not.toHaveBeenCalled(); + expect(notices(document)).toHaveLength(1); + expect(notices(document)[0]).toContain('lineup could not be read'); + }); + + it('never shows the old team\'s lineup when its read answers after the side was given another team', async () => { + const lateRead = deferred(); + window.API.fetchLineupInForce = vi.fn(async (_c, teamId) => { + if (teamId === 'team-X') return lateRead.promise; + if (teamId === 'team-Y') throw new TypeError('Failed to fetch'); + return null; + }); + let view; + await act(async () => { view = render(host('team-X', 'T3')); }); + await act(async () => { competition.resolve(ROSTER); }); + await flush(); + await act(async () => { view.rerender(host('team-Y', 'T4')); }); + await flush(); + + await act(async () => { lateRead.resolve(lineupOf('Xavier One', 'x1')); }); + await flush(); + + expect(bout(document, 0, 'aka').value, 'a late answer for the old team is not shown').toBe(''); + }); + + it('never shows a lineup the old team had written when it lands after the side was given another team', async () => { + lineups = { 'team-X': lineupOf('Xavier One', 'x1') }; + let view; + await act(async () => { view = render(host('team-X', 'T3')); }); + await act(async () => { memberReads[0].resolve(everyTeam); }); + await act(async () => { competition.resolve(ROSTER); }); + await flush(); + const put = deferred(); + window.API.putMatchLineup = vi.fn(() => put.promise); + window.API.fetchLineupInForce = vi.fn(async (_c, teamId) => (teamId === 'team-Y' ? null : lineups[teamId] || null)); + await typeName(bout(document, 1, 'aka'), 'Newcomer'); + expect(window.API.putMatchLineup, 'the lineup is being written for the old team').toHaveBeenCalledTimes(1); + + await act(async () => { view.rerender(host('team-Y', 'T4')); }); + await flush(); + await act(async () => { put.resolve({}); }); + await flush(); + + expect(bout(document, 1, 'aka').value, 'the side shows what the new team has, not what was written for the old team').toBe(''); + }); + }); +}); + +// A side that carries only its name has no team the sheet can name until the competition's +// roster resolves it to a participant: its key in the match is the name, and so is its +// team, until then. Two rules, one for each half of that. +describe('team editor: a side that carries only its name', () => { + const NAMED = members('n', ['Nao One', 'Nao Two', 'Nao Three', 'Nao Four', 'Nao Five']); + const OTHERS = members('o', ['Omi One', 'Omi Two', 'Omi Three', 'Omi Four', 'Omi Five']); + // What the members route answers: lists keyed by participant id, never by name. + const lists = { 'team-N': NAMED, 'team-O': OTHERS, 'team-B': SHIRO_NAMED }; + const roster = (...players) => ({ id: 'comp1', config: { format: 'knockout', teamMatchType: 'fixed', naginata: false, players } }); + const ROSTER = roster({ id: 'team-N', name: 'Team N' }, { id: 'team-O', name: 'Team O' }); + const onlyName = (name) => ({ name, number: 'T5' }); + const host = (sideA) => ( + + ); + const holds = (list, who) => list.some((o) => o.includes(who)); + const lineupOf = (name, id) => ({ positions: { senpo: name }, memberIds: { senpo: id } }); + const OTHER_TEAM = 'Nothing was saved because another team is now on this side. Type the name again.'; + + // A side is given another team only when it really is one. The team's id arriving for the + // side is not, and nor is the match starting to carry the id of the team it named: nothing + // is dropped, and a pick made meanwhile is not stopped. + describe('is given another team only when its key in the match and its team both change', () => { + it('is not one when the team\'s id arrives for a side that carried only its name: the lineup read for the team stays', async () => { + lineups = { 'team-N': lineupOf('Nao One', 'n1') }; + await act(async () => { render(host(onlyName('Team N'))); }); + + await act(async () => { competition.resolve(ROSTER); }); + await flush(); + + expect(bout(document, 0, 'aka').value, 'the team\'s lineup, read once the roster named it, is not dropped').toBe('Nao One'); + }); + + it('is not one when the match starts to carry the id of the team it named', async () => { + let view; + await act(async () => { view = render(host(onlyName('Team N'))); }); + await act(async () => { competition.resolve(ROSTER); }); + await act(async () => { memberReads.forEach((read) => read.resolve(lists)); }); + await flush(); + // The pick is out while the member it names is being added. + const mint = deferred(); + window.API.addTeamMember = vi.fn(() => mint.promise); + await typeName(bout(document, 0, 'aka'), 'Newcomer'); + + await act(async () => { view.rerender(host({ id: 'team-N', name: 'Team N', number: 'T5' })); }); + await flush(); + expect(holds(await offers(document, 2, 'aka'), 'Nao One'), 'the list is kept').toBe(true); + await act(async () => { mint.resolve({ id: 'new-1', index: 6, name: 'Newcomer' }); }); + await flush(); + + expect(window.API.putMatchLineup, 'the pick is written').toHaveBeenCalledTimes(1); + expect(notices(document)).toEqual([]); + }); + + it('is one when a side that carries only its name is given another team by name', async () => { + let view; + await act(async () => { view = render(host(onlyName('Team N'))); }); + await act(async () => { competition.resolve(ROSTER); }); + await act(async () => { memberReads.forEach((read) => read.resolve(lists)); }); + await flush(); + expect(holds(await offers(document, 2, 'aka'), 'Nao One')).toBe(true); + // The pick is out while the member it names is being added. + const mint = deferred(); + window.API.addTeamMember = vi.fn(() => mint.promise); + await typeName(bout(document, 0, 'aka'), 'Newcomer'); + + await act(async () => { view.rerender(host(onlyName('Team O'))); }); + await flush(); + await act(async () => { mint.resolve({ id: 'new-1', index: 6, name: 'Newcomer' }); }); + await act(async () => { memberReads.forEach((read) => read.resolve(lists)); }); + await flush(); + + expect(window.API.putMatchLineup, 'nothing is written for the old team').not.toHaveBeenCalled(); + expect(notices(document)).toEqual([OTHER_TEAM]); + const after = await offers(document, 2, 'aka'); + expect(holds(after, 'Omi One'), 'the side lists the new team\'s members').toBe(true); + expect(holds(after, 'Nao'), 'and none of the old team\'s').toBe(false); + }); + }); + + // The members route answers by participant id, so for the name it has nothing: the sheet + // would show an empty list, which a name typed meanwhile is resolved against, and its member + // add is refused by the server (the team id is checked against the participants) while the + // name is saved without a member. A side takes its list only once its team is known: it + // carries an id, or the roster named a participant for it. Until then its wait stays open + // (bounded as ever), and a pick that waited writes for the team as it is known when the wait + // is over, not as it was when the name was typed. + describe('takes no members list until its team is known, and a pick writes for the team as it is known then', () => { + it('takes none, so a name typed meanwhile waits, and is written under the participant id with the member found by id', async () => { + await act(async () => { render(host(onlyName('Team N'))); }); + await act(async () => { memberReads[0].resolve(lists); }); + await flush(); + expect(await offers(document, 2, 'aka'), 'no list is taken for a side whose team is not known').toEqual([]); + expect(holds(await offers(document, 2, 'shiro'), 'Ren Abe'), 'the other side carries its id and takes its list').toBe(true); + + await typeName(bout(document, 0, 'aka'), 'Nao Two'); + expect(window.API.addTeamMember, 'the pick waits for the roster to name the team').not.toHaveBeenCalled(); + expect(window.API.putMatchLineup).not.toHaveBeenCalled(); + + await act(async () => { competition.resolve(ROSTER); }); + await flush(); + await act(async () => { memberReads.slice(1).forEach((read) => read.resolve(lists)); }); + await flush(); + + expect(window.API.addTeamMember, 'Nao Two is the team\'s member, found in the list that was read').not.toHaveBeenCalled(); + expect(window.API.renameTeamMember).not.toHaveBeenCalled(); + const [, teamId, , positions, , memberIds] = window.API.putMatchLineup.mock.calls[0]; + expect(teamId, 'written under the participant id, not the name').toBe('team-N'); + expect(positions).toEqual({ senpo: 'Nao Two' }); + expect(memberIds).toEqual({ senpo: 'n2' }); + expect(notices(document)).toEqual([]); + }); + + it('takes no copy of the host\'s either, and takes it once the roster names the team', async () => { + const copy = { 'team-N': NAMED, 'team-B': SHIRO_NAMED }; + await act(async () => { + render(); + }); + await flush(); + expect(await offers(document, 2, 'aka'), 'no list for a side whose team is not known').toEqual([]); + await typeName(bout(document, 0, 'aka'), 'Nao Two'); + expect(window.API.addTeamMember, 'the pick waits').not.toHaveBeenCalled(); + expect(window.API.putMatchLineup).not.toHaveBeenCalled(); + + await act(async () => { competition.resolve(ROSTER); }); + await flush(); + + expect(window.API.addTeamMember).not.toHaveBeenCalled(); + const [, teamId, , , , memberIds] = window.API.putMatchLineup.mock.calls[0]; + expect(teamId).toBe('team-N'); + expect(memberIds).toEqual({ senpo: 'n2' }); + expect(holds(await offers(document, 2, 'aka'), 'Nao One'), 'and the side lists the copy\'s members').toBe(true); + }); + + it('is not held back for a side that carries its id: it takes its list before the roster names the team, and its picks do not wait', async () => { + await act(async () => { render(host({ id: 'team-N', name: 'Team N', number: 'T5' })); }); + await act(async () => { memberReads[0].resolve(lists); }); + await flush(); + expect(holds(await offers(document, 2, 'aka'), 'Nao One')).toBe(true); + + await typeName(bout(document, 0, 'aka'), 'Nao Two'); + + expect(window.API.addTeamMember).not.toHaveBeenCalled(); + expect(window.API.putMatchLineup.mock.calls[0][1]).toBe('team-N'); + expect(window.API.putMatchLineup.mock.calls[0][5]).toEqual({ senpo: 'n2' }); + }); + + it('goes on at the deadline when the team is never known, as it did, and says the members could not be read', async () => { + vi.useFakeTimers(); + try { + await act(async () => { render(host(onlyName('Team N'))); }); + await act(async () => { memberReads[0].resolve(lists); }); + await type(bout(document, 0, 'aka'), 'Nao Two'); + await act(async () => { await vi.advanceTimersByTimeAsync(FETCH_TIMEOUT_MS - 1); }); + expect(window.API.addTeamMember, 'nothing is added while the roster may still name the team').not.toHaveBeenCalled(); + + await act(async () => { await vi.advanceTimersByTimeAsync(1); }); + await act(async () => { await vi.advanceTimersByTimeAsync(0); }); + + expect(window.API.addTeamMember).toHaveBeenCalledWith('comp1', 'Team N', 'Nao Two', 'pw'); + expect(notices(document)).toHaveLength(1); + expect(notices(document)[0]).toContain('team member list could not be loaded'); + } finally { + vi.useRealTimers(); + } + }); + }); +}); + +// The old team's lineup is dropped before the new team's is read: the effect that notices a +// side given another team is declared before the one that reads the lineups, so that holds +// whatever the read awaits. The read starts after `await fetchCompetitionDetails`, so within +// one commit the drop runs first whatever the order, and no render test can go red for a +// reversed one: the read would have to begin synchronously. The order is pinned here, so a +// change that does start the read synchronously cannot bring the race back unseen. +describe('team editor: the effect that notices a side given another team is declared before the one that reads the lineups', () => { + it('has the side identity effect ahead of the lineup reads in the source', () => { + const code = readCode('admin_scoring_team.jsx'); + const noticesSide = code.indexOf('sideSeen.current = {'); + const readsLineups = code.indexOf('startLineupReads({'); + + expect(noticesSide, 'the effect that notices a side given another team is in the source').toBeGreaterThan(-1); + expect(readsLineups, 'and so is the call that starts the lineup reads').toBeGreaterThan(-1); + expect(noticesSide, 'declared first').toBeLessThan(readsLineups); + }); +}); + +// A side is given another team once, and the lineup sync's epoch (raised when it drops +// the side's lineup) is the one counter that says so: a pick asks the epoch it began on, +// not a second count kept beside it. The team of a side is read through sideTeam alone. +describe('team editor: one counter says a side was given another team', () => { + it('keeps no second generation counter, and no wrapper over sideTeam', () => { + const code = readCode('admin_scoring_team.jsx'); + expect(code).not.toMatch(/sideGeneration/); + expect(code).not.toMatch(/teamIdForSide/); + }); +}); diff --git a/web-mobile/js/__tests__/render/team_editor_pinned_bars.render.test.jsx b/web-mobile/js/__tests__/render/team_editor_pinned_bars.render.test.jsx new file mode 100644 index 000000000..e235e14e6 --- /dev/null +++ b/web-mobile/js/__tests__/render/team_editor_pinned_bars.render.test.jsx @@ -0,0 +1,526 @@ +// bc-tmfd: the team sheet pins its running total and its actions. +// +// At the operator's iPad size the IV/PW band used to sit after every bout row +// and the footer actions below the fold. The team header and the band are now +// one sticky unit (.team-sheet-pin) ahead of the bouts, and the footer is the +// pinned dock. jsdom lays nothing out, so this pins the DOM ORDER and the +// stylesheet rules; the acceptance is the browser measurement at 1180x820. + +import React from 'react'; +import { render, act, screen, within } from '@testing-library/react'; +import { describe, it, expect, vi, beforeAll, afterAll, beforeEach, afterEach } from 'vitest'; +import { installWindowStubs } from '../helpers/stub_globals.js'; +import { cssBlock, readStylesheet } from '../helpers/source.js'; + +const STUBBED_GLOBALS = { + isHikiwake: () => false, + arraysEqual: (a, b) => a.length === b.length && a.every((v, i) => v === b[i]), + isKikenDecision: () => false, + isTextEntry: () => false, + isInteractiveTarget: () => false, + confirmDialog: vi.fn().mockResolvedValue(true), + API: { + fetchCompetitionDetails: vi.fn().mockResolvedValue(null), + recordScore: vi.fn().mockResolvedValue(undefined), + recordDaihyosen: vi.fn(), + removeDaihyosen: vi.fn(), + putMatchLineup: vi.fn(), + recordDecision: vi.fn(), + }, + AdminLineupHelpers: { rosterFor: vi.fn().mockReturnValue([]) }, + compMatches: () => [], + Term: ({ children }) => {children}, + GlossaryHint: ({ name }) => , +}; + +let restoreGlobals; +let ScoreEditorModal; + +beforeAll(async () => { + restoreGlobals = installWindowStubs(STUBBED_GLOBALS); + await import('../../admin_scoring_modal.jsx'); + ScoreEditorModal = window.ScoreEditorModal; +}); + +afterAll(() => restoreGlobals()); + +const ONE_MARK = [{ position: 1, sideA: 'A1', sideB: 'B1', ipponsA: [], ipponsB: ['M'] }]; + +async function mount({ teamSize = 5, teamMatchType = 'fixed', variant, unreadable = false } = {}) { + window.API.fetchCompetitionDetails = vi.fn().mockResolvedValue({ + id: 'comp1', + config: { format: 'knockout', teamMatchType, naginata: false, players: [] }, + }); + let utils; + await act(async () => { + utils = render( + + ); + }); + return utils; +} + +const follows = (a, b) => Boolean(a.compareDocumentPosition(b) & Node.DOCUMENT_POSITION_FOLLOWING); +const PIN = '.team-sheet-pin'; +const HINT = '[data-testid="team-scoring-clear-hint"]'; + +function expectPinnedOrder(container, { band }) { + const pin = container.querySelector(PIN); + expect(pin, 'the pinned wrapper renders').not.toBeNull(); + expect(pin.querySelector('.sb-match'), 'the team header is in the wrapper').not.toBeNull(); + if (band) { + expect(pin.querySelector('[data-testid="team-summary-result"]'), 'the band is in the wrapper').not.toBeNull(); + expect(pin.querySelector('.team-summary')).not.toBeNull(); + expect(follows(pin.querySelector('.sb-match'), pin.querySelector('.team-summary'))).toBe(true); + } else { + expect(pin.querySelector('.team-summary'), 'no band in kachinuki bout mode').toBeNull(); + } + const firstBout = container.querySelector('.team-sub-match'); + expect(firstBout).not.toBeNull(); + expect(follows(pin, firstBout), 'the wrapper precedes the first bout').toBe(true); + expect(pin.contains(firstBout)).toBe(false); + + const hint = container.querySelector(HINT); + expect(hint, 'the clear-a-mark hint renders').not.toBeNull(); + expect(pin.contains(hint), 'the hint stays out of the wrapper').toBe(false); + expect(follows(pin, hint)).toBe(true); + expect(follows(hint, firstBout)).toBe(true); + + // The band, when it renders at all, no longer follows the bouts. + const band_ = container.querySelector('.team-summary'); + if (band_) expect(follows(firstBout, band_)).toBe(false); +} + +describe('team editor: the header and the result band are one pinned unit ahead of the bouts', () => { + it('inline, five-person team match: header and band in the wrapper, which precedes the bouts', async () => { + const { container } = await mount({ variant: 'inline' }); + expect(container.querySelector('.scoring-panel--team')).not.toBeNull(); + expectPinnedOrder(container, { band: true }); + const foot = container.querySelector('.editor-modal__foot--nav'); + expect(foot.querySelector('.score-nav'), '.score-nav is in the footer').not.toBeNull(); + expect(container.querySelector(PIN).contains(foot)).toBe(false); + }); + + it('the head, with its sync pill and correction pill, stays out of the wrapper', async () => { + const { container } = await mount({ variant: 'inline' }); + expect(container.querySelector(PIN).contains(container.querySelector('.editor-modal__head'))).toBe(false); + }); + + it('inline, kachinuki bout mode: the wrapper holds the header only and the footer keeps its actions', async () => { + const { container } = await mount({ variant: 'inline', teamMatchType: 'kachinuki' }); + expectPinnedOrder(container, { band: false }); + const foot = container.querySelector('.editor-modal__foot--nav'); + const inFoot = within(foot); + expect(inFoot.getByRole('button', { name: 'Record bout' })).not.toBeNull(); + expect(inFoot.getByRole('button', { name: /End match/ })).not.toBeNull(); + expect(screen.queryByTestId('team-summary-result')).toBeNull(); + }); + + it('overlay, compact (five-person): same order', async () => { + const { container } = await mount(); + expect(container.querySelector('.editor-modal--team.editor-modal--compact')).not.toBeNull(); + expectPinnedOrder(container, { band: true }); + expect(container.querySelector('.editor-modal__foot--nav .score-nav')).not.toBeNull(); + }); + + it('overlay, roomy (team of six): same order, ahead of the bout list', async () => { + const { container } = await mount({ teamSize: 6 }); + expect(container.querySelector('.editor-modal--team')).not.toBeNull(); + expect(container.querySelector('.editor-modal--compact')).toBeNull(); + expectPinnedOrder(container, { band: true }); + const scroll = container.querySelector('.team-bouts-scroll'); + expect(follows(container.querySelector(PIN), scroll)).toBe(true); + expect(container.querySelector(PIN).contains(scroll)).toBe(false); + }); +}); + +const css = readStylesheet(); +const block = (selector) => { + const b = cssBlock(css, selector); + expect(b, `rule ${selector} exists`).not.toBeNull(); + return b; +}; +// One entry per selector of every rule, comments dropped (they carry braces), +// wherever an @media block nests it. A selector's last compound is what the +// rule styles, so a rule for something INSIDE a class is not a rule for it. A +// list splits at its top-level commas only: the commas inside :is(...) and +// [...] belong to one selector. +const splitSelectors = (list) => { + const out = []; + let depth = 0; + let current = ''; + for (const ch of list) { + if (ch === '(' || ch === '[') depth += 1; + if (ch === ')' || ch === ']') depth -= 1; + if (ch === ',' && depth === 0) { + out.push(current.trim()); + current = ''; + } else { + current += ch; + } + } + if (current.trim()) out.push(current.trim()); + return out; +}; +const rules = [...css.replace(/\/\*[\s\S]*?\*\//g, '').matchAll(/([^{}]+)\{([^{}]*)\}/g)].flatMap( + ([, selectors, decls]) => splitSelectors(selectors).map((selector) => ({ selector, decls })), +); + +describe('the stylesheet pins the two bars in the inline team panel (bc-tmfd)', () => { + it('clips the panel instead of making it a scroll container, so both bars stick to the page', () => { + expect(block('.scoring-panel--team')).toMatch(/overflow:\s*clip/); + expect(block('.scoring-panel--team .editor-modal__body')).toMatch(/overflow:\s*visible/); + }); + + it('makes the team footer a bottom dock, after the static rule it overrides', () => { + const dock = block('.scoring-panel--team .editor-modal__foot--nav'); + expect(dock).toMatch(/position:\s*sticky/); + expect(dock).toMatch(/bottom:\s*0/); + expect(dock).toMatch(/z-index:\s*20\b/); + expect(dock).toMatch(/max-height:\s*45vh/); + const staticAt = css.indexOf('\n.scoring-panel .editor-modal__foot--nav {'); + expect(staticAt).toBeGreaterThan(-1); + expect(css.indexOf('\n.scoring-panel--team .editor-modal__foot--nav {')).toBeGreaterThan(staticAt); + }); + + it('keeps the dock under the topbar (30) and above the pin (9) and the name list (8)', () => { + const z = (sel) => Number(/z-index:\s*(\d+)/.exec(block(sel))[1]); + expect(z('.scoring-panel--team .editor-modal__foot--nav')).toBeLessThan(z('.topbar-stack')); + expect(z('.scoring-panel--team .editor-modal__foot--nav')).toBeGreaterThan(z('.team-sheet-pin')); + expect(z('.editor-modal__body .lineup-name__dropdown')).toBeLessThan(z('.team-sheet-pin')); + }); + + it('stacks the name list below the pinned header inside a score editor, and keeps 60 elsewhere', () => { + expect(block('.editor-modal__body .lineup-name__dropdown')).toMatch(/z-index:\s*8\b/); + expect(block('.lineup-name__dropdown')).toMatch(/z-index:\s*60/); + }); + + it('leaves the name list height to LineupNameInput: no CSS max-height on it', () => { + expect(block('.lineup-name__dropdown')).not.toMatch(/max-height/); + }); + + it('keeps the action row visible at the dock bottom while the content above scrolls', () => { + const nav = block('.scoring-panel--team .editor-modal__foot--nav .score-nav'); + expect(nav).toMatch(/position:\s*sticky/); + expect(nav).toMatch(/bottom:\s*0/); + expect(nav).toMatch(/background:\s*var\(--surface\)/); + }); + + it('falls back to overflow: visible where overflow: clip is unsupported, so the bars still pin', () => { + expect(css).toMatch(/@supports not \(overflow: clip\)\s*\{\s*\.scoring-panel--team\s*\{\s*overflow:\s*visible;/); + }); + + it('pins the header unit at the top, and the inline scope sits it under the shell topbar', () => { + const pin = block('.team-sheet-pin'); + expect(pin).toMatch(/position:\s*sticky/); + expect(pin).toMatch(/top:\s*0/); + expect(pin).toMatch(/z-index:\s*9\b/); + expect(block('.scoring-panel--team .team-sheet-pin')).toMatch(/top:\s*var\(--topbar-stack-h/); + }); + + it('leaves the individual and engi inline panels alone: no sticky or clip on the shared panel rules', () => { + const shared = block('.scoring-panel'); + expect(shared).toMatch(/overflow:\s*hidden/); + expect(block('.scoring-panel .editor-modal__foot--nav')).toMatch(/position:\s*static/); + }); +}); + +// The overlay's bout rows flow in .editor-modal__body, a modal's one scroll +// region, under the pinned bar. A rule that hid that body's overflow and made +// the bout list a scroll area of its own left the list only the height the +// pinned bar, a knockout's tie-breaker panel and the rows below the bouts did +// not take: 8px for a team of six at 1180x820, so no bout could be seen. jsdom +// lays nothing out, so these read the rules; the acceptance is the browser +// measurement. +describe('the stylesheet keeps the overlay bouts in the body, under the pinned bar', () => { + const styling = (cls) => + rules.filter(({ selector }) => new RegExp(`\\.${cls}(?![\\w-])[^\\s>+~]*$`).test(selector)); + // Anchored on the property name, so text-overflow is not read as overflow. + const CLIPS = /(?:^|[\s;])overflow(?:-y)?:[^;]*\b(?:hidden|clip)\b/; + const OWN_OVERFLOW = /(?:^|[\s;])overflow(?:-y)?:[^;]*\b(?:auto|scroll|hidden|clip)\b/; + + it('leaves the body the scroll region: no rule on an editor body hides its overflow', () => { + expect(block('.editor-modal__body')).toMatch(/overflow-y:\s*auto/); + const bodies = styling('editor-modal__body'); + expect(bodies.map((r) => r.selector), 'the sweep finds the body rules').toContain('.editor-modal__body'); + for (const { selector, decls } of bodies) { + expect(decls, `${selector} hides the body overflow`).not.toMatch(CLIPS); + } + }); + + it('never makes the bout list a scroll region of its own, which would leave it only the height that remains', () => { + const lists = styling('team-bouts-scroll'); + expect(lists.length, 'the sweep finds a rule for the wrapper itself, or it proves nothing').toBeGreaterThan(0); + for (const { selector, decls } of lists) { + expect(decls, `${selector} gives the bout list its own overflow`).not.toMatch(OWN_OVERFLOW); + } + }); + + it('names the body top padding in both densities and pads with it, so the bar can cover exactly that much', () => { + for (const selector of ['.editor-modal__body', '.editor-modal--compact .editor-modal__body']) { + const body = block(selector); + expect(body, `${selector} names the top padding`).toMatch(/--editor-body-pad-top:\s*\d+px/); + expect(body, `${selector} pads with it`).toMatch(/padding:\s*var\(--editor-body-pad-top\)\s/); + } + }); + + it('sticks the overlay bar over the body padding: top and margin-top pull it up by that much, padding-top keeps its content in place', () => { + const bar = block('.editor-modal--team > .editor-modal__body > .team-sheet-pin'); + expect(bar).toMatch(/(?:^|[\s;])top:\s*calc\(-1 \* var\(--editor-body-pad-top\)\)/); + expect(bar).toMatch(/margin-top:\s*calc\(-1 \* var\(--editor-body-pad-top\)\)/); + expect(bar).toMatch(/padding-top:\s*var\(--editor-body-pad-top\)/); + }); + + // The note shown for a match whose stored data cannot be read is the one thing that + // can come before the bar in the body. The bar is then the body's second child, and a + // rule that only reached a first child left it stuck below the body's top padding, so a + // bout scrolled past showed in a strip above the team names. + it('reaches the bar when the unreadable-data note comes first, so the strip above the team names is covered then as well', async () => { + const { container } = await mount({ unreadable: true }); + const body = container.querySelector('.editor-modal--team > .editor-modal__body'); + const children = Array.from(body.children); + const pin = body.querySelector('.team-sheet-pin'); + expect(children[0].classList.contains('data-issue--editor'), 'the note opens the body').toBe(true); + expect(children[1], 'and the bar follows it').toBe(pin); + const pullingUp = rules.filter(({ decls }) => /margin-top:\s*calc\(-1 \* var\(--editor-body-pad-top\)\)/.test(decls)); + expect(pullingUp.length, 'the rule that pulls the bar over the body padding exists').toBeGreaterThan(0); + for (const { selector } of pullingUp) { + expect(pin.matches(selector), `${selector} reaches the bar that follows the note`).toBe(true); + } + }); + + // The bar's box starts that far above its content (the padding it covers), so what + // comes before it must leave that much clear, or the box would hide the bottom edge of + // the note. The note carries a bottom margin of its own and the body a gap, in each + // density; this is the premise the rule above rests on. + it('leaves the note room above the bar: its bottom margin and the body gap clear the padding the bar covers, in both densities', () => { + const px = (decls, prop) => Number(new RegExp(`(?:^|[\\s;])${prop}:\\s*(\\d+)px`).exec(decls)?.[1]); + // The bottom of a margin shorthand: the third value when there are three or four, else the first. + const marginBottom = (decls) => { + const values = /(?:^|[\s;])margin:\s*([^;}]+)/.exec(decls)?.[1].trim().split(/\s+/) ?? []; + return parseFloat(values.length >= 3 ? values[2] : values[0]); + }; + const noteMargin = marginBottom(block('.data-issue--editor')); + expect(noteMargin, 'the note has a bottom margin the sweep can read').toBeGreaterThan(0); + for (const selector of ['.editor-modal__body', '.editor-modal--compact .editor-modal__body']) { + const body = block(selector); + const pad = px(body, '--editor-body-pad-top'); + const gap = px(body, 'gap'); + expect(gap, `${selector} has a gap the sweep can read`).toBeGreaterThan(0); + expect(noteMargin + gap, `${selector}: ${pad}px is covered above the bar, and the note's ${noteMargin}px margin plus the ${gap}px gap must leave it clear`).toBeGreaterThanOrEqual(pad); + } + }); +}); + +// A scroll that brings a control only just into view (a keyboard Tab onto it, +// scrollIntoView) leaves it exactly under whichever bar it came from: measured on +// the court console, "Remove this bout" scrolled to the bottom edge had the dock's +// hint text across its centre, so a tap on it landed on the dock. The sheet's own +// content carries scroll margin sized to the bars to keep such a target clear of +// them, and CSS cannot read an element's height, so the sheet publishes the +// heights while it is mounted (published_height.jsx, the mechanism AdminTopbar's +// --topbar-stack-h uses too): on the element whose scroll they affect. The inline +// hosts scroll the DOCUMENT under both bars; an overlay scrolls its body under the +// pinned header alone, its footer sitting outside the scrolling body and covering +// nothing. jsdom lays nothing out, so the heights are stubbed; the acceptance is +// the browser. +describe('the team sheet publishes the heights of the bars the page scrolls under', () => { + const PIN_H = '--team-pin-h'; + const DOCK_H = '--team-dock-h'; + const ROOT = document.documentElement; + const DOCK = '.editor-modal__foot--nav'; + const realRect = Element.prototype.getBoundingClientRect; + const realRO = globalThis.ResizeObserver; + let heights; + let observers; + + beforeEach(() => { + heights = { 'team-sheet-pin': 88, 'editor-modal__foot--nav': 140 }; + observers = []; + Element.prototype.getBoundingClientRect = function () { + const cls = Object.keys(heights).find((c) => this.classList && this.classList.contains(c)); + return cls ? { top: 0, bottom: heights[cls], height: heights[cls], left: 0, right: 0, width: 0, x: 0, y: 0 } : realRect.call(this); + }; + globalThis.ResizeObserver = class { + constructor(cb) { this.cb = cb; this.observed = []; this.disconnected = false; observers.push(this); } + observe(el) { this.observed.push(el); } + disconnect() { this.disconnected = true; } + }; + }); + + afterEach(() => { + Element.prototype.getBoundingClientRect = realRect; + globalThis.ResizeObserver = realRO; + ROOT.style.removeProperty(PIN_H); + ROOT.style.removeProperty(DOCK_H); + }); + + const rootValue = (prop) => ROOT.style.getPropertyValue(prop); + const valueOn = (el, prop) => el.style.getPropertyValue(prop); + const watching = (el) => observers.find((o) => o.observed.includes(el)); + + it('inline: publishes both heights on the document root as measured, before any observer fires', async () => { + const { container } = await mount({ variant: 'inline' }); + expect(rootValue(PIN_H)).toBe('88px'); + expect(rootValue(DOCK_H)).toBe('140px'); + expect(watching(container.querySelector(PIN)), 'the header unit is observed').toBeDefined(); + expect(watching(container.querySelector(DOCK)), 'and so is the dock').toBeDefined(); + // The page scrolls, not the panel: nothing is published on the panel's body. + const body = container.querySelector('.editor-modal__body'); + expect(valueOn(body, PIN_H)).toBe(''); + expect(valueOn(body, DOCK_H)).toBe(''); + }); + + it('inline: follows each bar as it grows and shrinks (a correction prompt opens in the dock)', async () => { + const { container } = await mount({ variant: 'inline' }); + heights['editor-modal__foot--nav'] = 210; + act(() => { watching(container.querySelector(DOCK)).cb([]); }); + expect(rootValue(DOCK_H)).toBe('210px'); + expect(rootValue(PIN_H), 'the header unit is unchanged').toBe('88px'); + heights['team-sheet-pin'] = 64; + act(() => { watching(container.querySelector(PIN)).cb([]); }); + expect(rootValue(PIN_H)).toBe('64px'); + expect(rootValue(DOCK_H)).toBe('210px'); + }); + + it('inline: removes both from the root and stops observing when the sheet unmounts', async () => { + const { container, unmount } = await mount({ variant: 'inline' }); + const pinObserver = watching(container.querySelector(PIN)); + const dockObserver = watching(container.querySelector(DOCK)); + unmount(); + expect(rootValue(PIN_H)).toBe(''); + expect(rootValue(DOCK_H)).toBe(''); + expect(pinObserver.disconnected).toBe(true); + expect(dockObserver.disconnected).toBe(true); + }); + + it('overlay: publishes the header unit on the scrolling body, and nothing on the root or for the footer', async () => { + const { container } = await mount(); + const body = container.querySelector('.editor-modal--team > .editor-modal__body'); + expect(valueOn(body, PIN_H)).toBe('88px'); + expect(rootValue(PIN_H), 'the document is not scrolled under the overlay bar').toBe(''); + expect(rootValue(DOCK_H)).toBe(''); + expect(valueOn(body, DOCK_H), 'the footer sits outside the scrolling body and covers nothing').toBe(''); + expect(watching(container.querySelector(DOCK)), 'so it is not measured').toBeUndefined(); + }); + + it('overlay: follows the header unit, and removes it when the sheet unmounts', async () => { + const { container, unmount } = await mount(); + const body = container.querySelector('.editor-modal--team > .editor-modal__body'); + const observer = watching(container.querySelector(PIN)); + heights['team-sheet-pin'] = 120; + act(() => { observer.cb([]); }); + expect(valueOn(body, PIN_H)).toBe('120px'); + unmount(); + expect(valueOn(body, PIN_H)).toBe(''); + expect(observer.disconnected).toBe(true); + }); +}); + +// The clearance is margin on the sheet's CONTENT, never padding on the scroller. +// Padding claims a strip of the viewport, and a bar's own controls (the dock's +// Finish, a button of the topbar) lie inside it, so focusing one scrolled the page +// by about 300px for nothing (measured at 1180x820 with the root padded by the +// bars). Margin on the content's controls leaves those alone. jsdom lays nothing +// out, so these read the rules and run their selectors against the mounted sheets; +// the acceptance is the browser. +describe('the stylesheet keeps the sheet\'s own content clear of the bars with scroll margin', () => { + const BAR_HEIGHT = /--(?:topbar-stack|team-pin|team-dock)-h/; + const declares = (decls, prop) => new RegExp(`(?:^|[\\s;])${prop}:`).test(decls); + const margins = () => rules.filter((r) => declares(r.decls, 'scroll-margin-top') && BAR_HEIGHT.test(r.decls)); + const inlineRule = () => margins().find((r) => r.selector.startsWith('.scoring-panel--team > .editor-modal__body')); + const overlayRule = () => margins().find((r) => r.selector.startsWith('.editor-modal--team > .editor-modal__body')); + const reach = (el) => ({ + inline: Boolean(inlineRule()) && el.matches(inlineRule().selector), + overlay: Boolean(overlayRule()) && el.matches(overlayRule().selector), + }); + const CONTROLS = 'button, a[href], input, select, textarea, summary, [tabindex]'; + const controlsIn = (container, scope) => Array.from(container.querySelectorAll(scope)).flatMap((el) => ( + el.matches(CONTROLS) ? [el] : Array.from(el.querySelectorAll(CONTROLS)) + )); + const named = (el) => el.outerHTML.slice(0, 90); + + it('pads no scroll by a bar height: a bar\'s own control would sit inside the padded strip', () => { + const padding = rules.filter((r) => declares(r.decls, 'scroll-padding(?:-[a-z]+)?') && BAR_HEIGHT.test(r.decls)); + expect(padding.map((r) => r.selector)).toEqual([]); + }); + + it('inline hosts: the content clears the topbar and the pinned header above it and the dock below it', () => { + const rule = inlineRule(); + expect(rule, 'a rule for the inline body exists').toBeDefined(); + expect(rule.decls).toMatch(/scroll-margin-top:\s*calc\(var\(--topbar-stack-h,\s*0px\)\s*\+\s*var\(--team-pin-h,\s*0px\)\)/); + expect(rule.decls).toMatch(/scroll-margin-bottom:\s*var\(--team-dock-h,\s*0px\)/); + }); + + it('overlay hosts: the content clears the pinned header alone, and nothing at the foot', () => { + const rule = overlayRule(); + expect(rule, 'a rule for the overlay body exists').toBeDefined(); + expect(rule.decls).toMatch(/scroll-margin-top:\s*var\(--team-pin-h,\s*0px\)/); + expect(rule.decls, 'the footer sits outside the scrolling body and covers nothing').not.toMatch(/scroll-margin-bottom/); + }); + + it('inline sheet: every control in the body is reached by the inline rule alone, and none in the pin, the dock or the head', async () => { + const { container } = await mount({ variant: 'inline', teamMatchType: 'kachinuki' }); + const content = controlsIn(container, '.editor-modal__body').filter((el) => !el.closest(PIN)); + expect(content.length, 'the sheet has content controls').toBeGreaterThan(0); + expect(container.querySelector('[data-testid="kachinuki-add-bout-button"]'), 'the button below the bouts is one of them').not.toBeNull(); + for (const el of content) expect(reach(el), named(el)).toEqual({ inline: true, overlay: false }); + + const bars = [...controlsIn(container, '.editor-modal__foot--nav'), ...controlsIn(container, '.editor-modal__head')]; + expect(bars.length, 'the dock and the head have controls').toBeGreaterThan(0); + // The pinned header holds no control today; one added to it must not be reached either. + const probe = container.querySelector(PIN).appendChild(document.createElement('button')); + for (const el of [...bars, probe]) expect(reach(el), named(el)).toEqual({ inline: false, overlay: false }); + }); + + // The inline sheet's head (its title row and Close) scrolls with the page above + // the pinned header, so only the topbar can cover it: its controls clear the + // topbar alone. The overlay's head sits outside its scrolling body. + const headRule = () => margins().find((r) => r.selector.startsWith('.scoring-panel--team > .editor-modal__head')); + it('inline sheet: the head\'s controls clear the topbar, and nothing else is reached by that rule', async () => { + const rule = headRule(); + expect(rule, 'a rule for the inline head exists').toBeDefined(); + expect(rule.decls).toMatch(/scroll-margin-top:\s*var\(--topbar-stack-h,\s*0px\)/); + expect(rule.decls, 'the head is above the dock, which never covers it').not.toMatch(/scroll-margin-bottom/); + const { container } = await mount({ variant: 'inline', teamMatchType: 'kachinuki' }); + const head = controlsIn(container, '.editor-modal__head'); + expect(head.length, 'the head has controls').toBeGreaterThan(0); + for (const el of head) expect(el.matches(rule.selector), named(el)).toBe(true); + const others = [...controlsIn(container, '.editor-modal__body'), ...controlsIn(container, '.editor-modal__foot--nav')]; + for (const el of others) expect(el.matches(rule.selector), named(el)).toBe(false); + }); + + it('overlay sheet: every control in the body is reached by the overlay rule alone, and none in the pin, the dock or the head', async () => { + const { container } = await mount({ teamMatchType: 'kachinuki' }); + const content = controlsIn(container, '.editor-modal__body').filter((el) => !el.closest(PIN)); + expect(content.length, 'the sheet has content controls').toBeGreaterThan(0); + for (const el of content) expect(reach(el), named(el)).toEqual({ inline: false, overlay: true }); + + const bars = [...controlsIn(container, '.editor-modal__foot--nav'), ...controlsIn(container, '.editor-modal__head')]; + expect(bars.length, 'the dock and the head have controls').toBeGreaterThan(0); + const probe = container.querySelector(PIN).appendChild(document.createElement('button')); + for (const el of [...bars, probe]) expect(reach(el), named(el)).toEqual({ inline: false, overlay: false }); + }); +}); diff --git a/web-mobile/js/__tests__/render/team_editor_superseded.render.test.jsx b/web-mobile/js/__tests__/render/team_editor_superseded.render.test.jsx index b17600fb8..ea83f9658 100644 --- a/web-mobile/js/__tests__/render/team_editor_superseded.render.test.jsx +++ b/web-mobile/js/__tests__/render/team_editor_superseded.render.test.jsx @@ -28,7 +28,6 @@ const STUBBED_GLOBALS = { isTextEntry: () => false, isInteractiveTarget: () => false, confirmDialog: vi.fn().mockResolvedValue(true), - resolveRoundIndex: () => 0, API: {}, AdminLineupHelpers: { rosterFor: vi.fn().mockReturnValue([]) }, compMatches: () => [], diff --git a/web-mobile/js/__tests__/render/team_editor_vacancy_not_flagged.render.test.jsx b/web-mobile/js/__tests__/render/team_editor_vacancy_not_flagged.render.test.jsx index 7adb860f0..3cd3c1294 100644 --- a/web-mobile/js/__tests__/render/team_editor_vacancy_not_flagged.render.test.jsx +++ b/web-mobile/js/__tests__/render/team_editor_vacancy_not_flagged.render.test.jsx @@ -24,7 +24,6 @@ const STUBBED_GLOBALS = { isTextEntry: () => false, isInteractiveTarget: () => false, confirmDialog: vi.fn().mockResolvedValue(true), - resolveRoundIndex: () => 0, API: { fetchCompetitionDetails: vi.fn().mockResolvedValue(null), recordScore: vi.fn().mockResolvedValue(undefined), diff --git a/web-mobile/js/__tests__/render/team_finish_refuses_unfought_bouts.render.test.jsx b/web-mobile/js/__tests__/render/team_finish_refuses_unfought_bouts.render.test.jsx index 31b1949ea..de2296b57 100644 --- a/web-mobile/js/__tests__/render/team_finish_refuses_unfought_bouts.render.test.jsx +++ b/web-mobile/js/__tests__/render/team_finish_refuses_unfought_bouts.render.test.jsx @@ -18,7 +18,6 @@ const STUBBED_GLOBALS = { isTextEntry: () => false, isInteractiveTarget: () => false, confirmDialog: vi.fn().mockResolvedValue(true), - resolveRoundIndex: () => 0, API: { fetchCompetitionDetails: vi.fn().mockResolvedValue(null), recordScore: vi.fn().mockResolvedValue(undefined), diff --git a/web-mobile/js/__tests__/render/team_fusensho_keeps_points.render.test.jsx b/web-mobile/js/__tests__/render/team_fusensho_keeps_points.render.test.jsx index b4161b44f..164d0ca3f 100644 --- a/web-mobile/js/__tests__/render/team_fusensho_keeps_points.render.test.jsx +++ b/web-mobile/js/__tests__/render/team_fusensho_keeps_points.render.test.jsx @@ -21,7 +21,6 @@ const STUBBED_GLOBALS = { isTextEntry: () => false, isInteractiveTarget: () => false, confirmDialog: vi.fn().mockResolvedValue(true), - resolveRoundIndex: () => 0, API: { fetchCompetitionDetails: vi.fn().mockResolvedValue(null), recordScore: vi.fn().mockResolvedValue(undefined), diff --git a/web-mobile/js/__tests__/render/team_fusensho_refusal_clears.render.test.jsx b/web-mobile/js/__tests__/render/team_fusensho_refusal_clears.render.test.jsx index 2561fb6ba..50158dde4 100644 --- a/web-mobile/js/__tests__/render/team_fusensho_refusal_clears.render.test.jsx +++ b/web-mobile/js/__tests__/render/team_fusensho_refusal_clears.render.test.jsx @@ -20,7 +20,6 @@ const STUBBED_GLOBALS = { isTextEntry: () => false, isInteractiveTarget: () => false, confirmDialog: vi.fn().mockResolvedValue(true), - resolveRoundIndex: () => 0, API: { fetchCompetitionDetails: vi.fn().mockResolvedValue(null), recordScore: vi.fn().mockResolvedValue(undefined), diff --git a/web-mobile/js/__tests__/render/toast_pending.render.test.jsx b/web-mobile/js/__tests__/render/toast_pending.render.test.jsx new file mode 100644 index 000000000..c7debaf69 --- /dev/null +++ b/web-mobile/js/__tests__/render/toast_pending.render.test.jsx @@ -0,0 +1,30 @@ +// A toast says what happened in its icon as well as its words. A write the device holds +// until the connection returns is neither saved nor refused: it takes the pending icon, +// not the success check that "not saved yet" used to sit beside (operator decision +// 2026-10-07), and it is a status, polite, with no dismiss button, like a success. + +import React from 'react'; +import { render, cleanup } from '@testing-library/react'; +import { describe, it, expect, afterEach } from 'vitest'; +import { Toast } from '../../ui.jsx'; + +afterEach(cleanup); + +const icon = (container) => container.querySelector('.toast__icon').textContent; + +describe('the toast icon', () => { + it('is the pending icon for a write held on the device', () => { + const { container, getByRole } = render( {}} />); + expect(icon(container)).toBe('⏳'); + expect(getByRole('status')).toBeTruthy(); + expect(container.querySelector('.toast__dismiss'), 'nothing to dismiss').toBeNull(); + }); + + it('stays the success check for a success and the warning for an error', () => { + const ok = render( {}} />); + expect(icon(ok.container)).toBe('✅'); + cleanup(); + const err = render( {}} />); + expect(icon(err.container)).toBe('⚠️'); + }); +}); diff --git a/web-mobile/js/__tests__/render/use_team_lineups_squads.render.test.jsx b/web-mobile/js/__tests__/render/use_team_lineups_squads.render.test.jsx index f29ab4e03..d04f65669 100644 --- a/web-mobile/js/__tests__/render/use_team_lineups_squads.render.test.jsx +++ b/web-mobile/js/__tests__/render/use_team_lineups_squads.render.test.jsx @@ -21,15 +21,14 @@ const MATCH = { id: 'Pool A-0', compId: 'comp1', sideA: { id: 'team-a', name: 'T let latest; function Probe({ competition }) { - latest = useTeamLineups(MATCH, competition, 0); + latest = useTeamLineups(MATCH, competition); return null; } beforeEach(async () => { origAPI = window.API; window.API = { - fetchMatchLineup: vi.fn().mockResolvedValue(null), - fetchTeamLineup: vi.fn().mockResolvedValue(null), + fetchLineupInForce: vi.fn().mockResolvedValue(null), fetchCompetitionDetails: vi.fn().mockResolvedValue({}), }; ({ useTeamLineups } = await import('../../match_scoreboard.jsx')); @@ -49,16 +48,19 @@ describe('useTeamLineups follows a changed squads map (bc-dnst)', () => { }); }); -// A pool match stores Round -1 ("no round"). A board handed the stored -// match (no roundIndex) must read it as round 0, as resolveRoundIndex does: -// the server refuses a negative round with a 400. -describe('useTeamLineups reads a pool match stored Round -1 as round 0', () => { - it('never asks the server for round -1', async () => { +// A team fields the lineup in force at THIS match (the one it carries from its +// previous match, unless one is entered for the match): each side is asked for +// by team and match alone, never by a round the board would have to derive, so +// a pool match stored Round -1 ("no round") needs no special reading. +describe('useTeamLineups asks for the lineup in force at the match', () => { + it('asks for each side by team and match, and for no round', async () => { const POOL_MATCH = { ...MATCH, round: -1 }; function PoolProbe() { useTeamLineups(POOL_MATCH, COMP('Sato')); return null; } await act(async () => { render(); }); await act(async () => { await Promise.resolve(); await Promise.resolve(); }); - expect(window.API.fetchTeamLineup).toHaveBeenCalled(); - for (const call of window.API.fetchTeamLineup.mock.calls) expect(call[2]).toBe(0); + expect(window.API.fetchLineupInForce.mock.calls).toEqual([ + ['comp1', 'team-a', 'Pool A-0'], + ['comp1', 'team-b', 'Pool A-0'], + ]); }); }); diff --git a/web-mobile/js/__tests__/render/viewer_competition_live_match.render.test.jsx b/web-mobile/js/__tests__/render/viewer_competition_live_match.render.test.jsx index 17cc44942..5f6a39415 100644 --- a/web-mobile/js/__tests__/render/viewer_competition_live_match.render.test.jsx +++ b/web-mobile/js/__tests__/render/viewer_competition_live_match.render.test.jsx @@ -100,7 +100,7 @@ describe('ViewerCompetition: a match opened from a tab follows the live data (bc let view; await act(async () => { view = render(page()); }); await openEditorOn(view, 'k1'); - expect(probe.props.match).toMatchObject({ id: 'k1', modifiedAt: 100, phase: 'bracket', round: 'Final', roundIndex: 0, compId: 'c1', compName: 'Open' }); + expect(probe.props.match).toMatchObject({ id: 'k1', modifiedAt: 100, phase: 'bracket', round: 'Final', compId: 'c1', compName: 'Open' }); // A point recorded on another device reaches the page as a new row. await act(async () => { diff --git a/web-mobile/js/__tests__/render/withdrawal_correction_shows_result.render.test.jsx b/web-mobile/js/__tests__/render/withdrawal_correction_shows_result.render.test.jsx index 5ee0e2fe9..9fca130dd 100644 --- a/web-mobile/js/__tests__/render/withdrawal_correction_shows_result.render.test.jsx +++ b/web-mobile/js/__tests__/render/withdrawal_correction_shows_result.render.test.jsx @@ -25,7 +25,6 @@ const STUBBED_GLOBALS = { isTextEntry: () => false, isInteractiveTarget: () => false, confirmDialog: vi.fn().mockResolvedValue(true), - resolveRoundIndex: () => 0, API: {}, AdminLineupHelpers: { rosterFor: vi.fn().mockReturnValue([]) }, compMatches: () => [], diff --git a/web-mobile/js/__tests__/scoring_modal_match_lineup.test.jsx b/web-mobile/js/__tests__/scoring_modal_match_lineup.test.jsx index 90e7f98e9..4e2b8044f 100644 --- a/web-mobile/js/__tests__/scoring_modal_match_lineup.test.jsx +++ b/web-mobile/js/__tests__/scoring_modal_match_lineup.test.jsx @@ -1,11 +1,12 @@ -// mp-bkg regression guard: resolveMatchLineup must prefer the per-match -// lineup endpoint (match-lineups/:matchId) over the round lineup, and fall -// back to the round lineup only when the per-match GET returns null -// (nothing saved, bc-k404). +// mp-bkg regression guard: resolveMatchLineup reads the lineup a team fields at +// a match from ONE endpoint (lineup-in-force/:matchId), and a match's own +// lineup always wins there (the whole point of the per-match API). Which lineup +// a team carries into a match is the server's rule (engine/lineup_in_force.go), +// so this client only has to hand back what the server names, source included, +// and treat nothing in force (saved: false -> null) as nothing. // -// Without this test the "preferred match lineup" change in -// TeamScoreEditorModal is invisible. The component mounts, fires the -// useEffect, but since vitest stubs hooks we test the pure helper directly. +// The component mounts and fires its useEffect, but since vitest stubs hooks we +// test the pure helper directly. import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { resolveMatchLineup, resolveLineupTeamId } from '../admin_scoring_modal.jsx'; @@ -15,84 +16,80 @@ describe('resolveMatchLineup (mp-bkg regression guard)', () => { const COMP_ID = 'comp1'; const TEAM_ID = 'team1'; const MATCH_ID = 'match-xyz'; - const ROUND = 1; - const matchLineup = { teamId: TEAM_ID, matchId: MATCH_ID, positions: { senpo: 'Alice (match)' } }; - const roundLineup = { teamId: TEAM_ID, round: ROUND, positions: { senpo: 'Alice (round)' } }; + const ownLineup = { teamId: TEAM_ID, matchId: MATCH_ID, positions: { senpo: 'Alice (own)' }, sourceMatchId: MATCH_ID, saved: true }; + const carriedLineup = { teamId: TEAM_ID, matchId: 'earlier-match', positions: { senpo: 'Alice (carried)' }, sourceMatchId: 'earlier-match', saved: true }; + const startingLineup = { teamId: TEAM_ID, round: 0, positions: { senpo: 'Alice (starting)' }, sourceRound: 0, saved: true }; // Inject API as a plain object of mocked async functions. - function makeAPI({ matchResult, roundResult, matchThrows = false, roundThrows = false } = {}) { + function makeAPI({ result, throws = false } = {}) { return { - fetchMatchLineup: matchThrows + fetchLineupInForce: throws ? vi.fn().mockRejectedValue(new Error('network')) - : vi.fn().mockResolvedValue(matchResult ?? null), - fetchTeamLineup: roundThrows - ? vi.fn().mockRejectedValue(new Error('network')) - : vi.fn().mockResolvedValue(roundResult ?? null), + : vi.fn().mockResolvedValue(result ?? null), }; } - it('returns the per-match lineup when it exists (non-null)', async () => { - const api = makeAPI({ matchResult: matchLineup, roundResult: roundLineup }); - const result = await resolveMatchLineup(COMP_ID, TEAM_ID, MATCH_ID, ROUND, api); - expect(result).toEqual(matchLineup); - // fetchTeamLineup should NOT be called when per-match entry exists - expect(api.fetchTeamLineup).not.toHaveBeenCalled(); + it('returns the lineup in force as the server names it, its source included', async () => { + for (const lineup of [ownLineup, carriedLineup, startingLineup]) { + const api = makeAPI({ result: lineup }); + expect(await resolveMatchLineup(COMP_ID, TEAM_ID, MATCH_ID, api)).toEqual(lineup); + } }); - it('falls back to round lineup when per-match GET returns null (nothing saved)', async () => { - const api = makeAPI({ matchResult: null, roundResult: roundLineup }); - const result = await resolveMatchLineup(COMP_ID, TEAM_ID, MATCH_ID, ROUND, api); - expect(result).toEqual(roundLineup); - expect(api.fetchMatchLineup).toHaveBeenCalledWith(COMP_ID, TEAM_ID, MATCH_ID); - // The round step asks the server for best-effort resolution: operators - // typically save one round-0 lineup for the whole day, so an exact-only - // GET would answer nothing saved for every round after the first (UAT: - // the final's kachinuki bout 1 was submitted with empty side names). - expect(api.fetchTeamLineup).toHaveBeenCalledWith(COMP_ID, TEAM_ID, ROUND, { fallback: true }); + it('returns null when nothing is in force (no lineup saved)', async () => { + const api = makeAPI({ result: null }); + expect(await resolveMatchLineup(COMP_ID, TEAM_ID, MATCH_ID, api)).toBeNull(); }); - it('falls back to round lineup when per-match GET throws (network error)', async () => { - const api = makeAPI({ matchThrows: true, roundResult: roundLineup }); - const result = await resolveMatchLineup(COMP_ID, TEAM_ID, MATCH_ID, ROUND, api); - expect(result).toEqual(roundLineup); + it('returns null when the read fails (a display degrades gracefully)', async () => { + const api = makeAPI({ throws: true }); + expect(await resolveMatchLineup(COMP_ID, TEAM_ID, MATCH_ID, api)).toBeNull(); }); - it('returns null when both per-match and round lineups are null (no lineup submitted)', async () => { - const api = makeAPI({ matchResult: null, roundResult: null }); - const result = await resolveMatchLineup(COMP_ID, TEAM_ID, MATCH_ID, ROUND, api); - expect(result).toBeNull(); + it('asks for the team and the match, and for no round', async () => { + const api = makeAPI({ result: null }); + await resolveMatchLineup('cX', 'tY', 'mZ', api); + expect(api.fetchLineupInForce).toHaveBeenCalledTimes(1); + expect(api.fetchLineupInForce).toHaveBeenCalledWith('cX', 'tY', 'mZ'); }); - it('returns null when per-match is null and round throws', async () => { - const api = makeAPI({ matchResult: null, roundThrows: true }); - const result = await resolveMatchLineup(COMP_ID, TEAM_ID, MATCH_ID, ROUND, api); - expect(result).toBeNull(); + it('REGRESSION: the match\'s own lineup is what comes back when one is saved (the whole point of mp-bkg)', async () => { + // The server answers the match's own lineup first; the client must not + // swap it for anything it reads elsewhere. + const api = makeAPI({ result: ownLineup }); + const result = await resolveMatchLineup(COMP_ID, TEAM_ID, MATCH_ID, api); + expect(result?.positions?.senpo).toBe('Alice (own)'); + expect(result?.sourceMatchId).toBe(MATCH_ID); }); - it('returns null when both throw (full network failure)', async () => { - const api = makeAPI({ matchThrows: true, roundThrows: true }); - const result = await resolveMatchLineup(COMP_ID, TEAM_ID, MATCH_ID, ROUND, api); - expect(result).toBeNull(); - }); + describe('throwOnError', () => { + const opts = { throwOnError: true }; - it('passes the correct arguments to each API call', async () => { - const api = makeAPI({ matchResult: null, roundResult: null }); - await resolveMatchLineup('cX', 'tY', 'mZ', 3, api); - expect(api.fetchMatchLineup).toHaveBeenCalledWith('cX', 'tY', 'mZ'); - expect(api.fetchTeamLineup).toHaveBeenCalledWith('cX', 'tY', 3, { fallback: true }); - }); + it('the default still swallows a failed read', async () => { + const api = makeAPI({ throws: true }); + await expect(resolveMatchLineup(COMP_ID, TEAM_ID, MATCH_ID, api)).resolves.toBeNull(); + }); + + it('rethrows a network error', async () => { + const api = makeAPI({ throws: true }); + await expect(resolveMatchLineup(COMP_ID, TEAM_ID, MATCH_ID, api, opts)).rejects.toThrow('network'); + }); + + it('rethrows a thrown 404 (competition not found)', async () => { + const api = { fetchLineupInForce: vi.fn().mockRejectedValue(new Error('competition not found')) }; + await expect(resolveMatchLineup(COMP_ID, TEAM_ID, MATCH_ID, api, opts)).rejects.toThrow('competition not found'); + }); - it('REGRESSION: per-match entry is preferred over round (the whole point of mp-bkg)', async () => { - // This is the load-bearing test: if fetchMatchLineup returns a non-null - // lineup it must win over the round lineup. Previously, the modal always - // used fetchTeamLineup (round-only), so per-match edits were cosmetic. - const matchSpecific = { positions: { senpo: 'Match-specific player' } }; - const roundDefault = { positions: { senpo: 'Round-default player' } }; - const api = makeAPI({ matchResult: matchSpecific, roundResult: roundDefault }); - const result = await resolveMatchLineup(COMP_ID, TEAM_ID, MATCH_ID, ROUND, api); - expect(result?.positions?.senpo).toBe('Match-specific player'); - expect(result).not.toEqual(roundDefault); + it('returns the lineup in force', async () => { + const api = makeAPI({ result: carriedLineup }); + await expect(resolveMatchLineup(COMP_ID, TEAM_ID, MATCH_ID, api, opts)).resolves.toEqual(carriedLineup); + }); + + it('nothing in force is null, not an error', async () => { + const api = makeAPI({ result: null }); + await expect(resolveMatchLineup(COMP_ID, TEAM_ID, MATCH_ID, api, opts)).resolves.toBeNull(); + }); }); }); @@ -131,16 +128,18 @@ describe('resolveLineupTeamId (mp-bkg: name-keyed side → participant UUID)', ( }); // resolveMatchLineup through the REAL api_client (bc-k404): the mocked-API -// tests above prove the fall-through logic in isolation, but fetchMatchLineup -// and fetchTeamLineup are what actually turn a `saved: false` body into null -// (and now throw on a genuine 404). This exercises the real boundary against -// a fetch stub routed by URL, so a regression in either function's mapping -// shows up here even if resolveMatchLineup's own logic is untouched. +// tests above prove the hand-back logic in isolation, but fetchLineupInForce is +// what actually turns a `saved: false` body into null (and throws on a genuine +// 404). This exercises the real boundary against a fetch stub routed by URL, so +// a regression in its mapping shows up here even if resolveMatchLineup's own +// logic is untouched. describe('resolveMatchLineup through the real api_client (bc-k404)', () => { let originalFetch; beforeEach(() => { originalFetch = global.fetch; }); afterEach(() => { global.fetch = originalFetch; }); + const IN_FORCE_URL = '/api/competitions/c1/teams/t1/lineup-in-force/m1'; + // Routes a URL to a 200 JSON body; anything unmatched answers 404 with // "competition not found", the real server's shape for a route this test // never stubs. @@ -158,43 +157,33 @@ describe('resolveMatchLineup through the real api_client (bc-k404)', () => { }); } - it('(a) match route saved false, round route (fallback=best) saved true: returns the round lineup', async () => { - const roundLineup = { teamId: 't1', round: 0, positions: { senpo: 'Alice' }, saved: true }; - global.fetch = routeFetch([ - ['/api/competitions/c1/teams/t1/match-lineups/m1', { teamId: 't1', matchId: 'm1', positions: {}, saved: false }], - ['/api/competitions/c1/teams/t1/lineups/1?fallback=best', roundLineup], - ]); - const result = await resolveMatchLineup('c1', 't1', 'm1', 1, API); - expect(result).toEqual(roundLineup); - // The round step must ask with ?fallback=best, not a bare exact GET. - const roundCall = global.fetch.mock.calls.map(([u]) => u).find((u) => u.includes('/lineups/1')); - expect(roundCall).toBe('/api/competitions/c1/teams/t1/lineups/1?fallback=best'); + it('(a) a lineup in force comes back whole, with the source the server named', async () => { + const carried = { teamId: 't1', matchId: 'earlier', round: 0, positions: { senpo: 'Alice' }, sourceMatchId: 'earlier', saved: true }; + global.fetch = routeFetch([[IN_FORCE_URL, carried]]); + const result = await resolveMatchLineup('c1', 't1', 'm1', API); + expect(result).toEqual(carried); }); - it('(b) both routes saved false: returns null', async () => { + it('(b) saved false: returns null', async () => { global.fetch = routeFetch([ - ['/api/competitions/c1/teams/t1/match-lineups/m1', { teamId: 't1', matchId: 'm1', positions: {}, saved: false }], - ['/api/competitions/c1/teams/t1/lineups/1?fallback=best', { teamId: 't1', round: 1, positions: {}, saved: false }], + [IN_FORCE_URL, { teamId: 't1', matchId: 'm1', positions: {}, saved: false }], ]); - const result = await resolveMatchLineup('c1', 't1', 'm1', 1, API); + const result = await resolveMatchLineup('c1', 't1', 'm1', API); expect(result).toBeNull(); }); - it('(c) match route saved true with EMPTY positions wins; the round URL is never fetched (mp-bkg guard)', async () => { - const matchLineup = { teamId: 't1', matchId: 'm1', positions: {}, saved: true }; - global.fetch = routeFetch([ - ['/api/competitions/c1/teams/t1/match-lineups/m1', matchLineup], - ]); - const result = await resolveMatchLineup('c1', 't1', 'm1', 1, API); - expect(result).toEqual(matchLineup); - expect(global.fetch.mock.calls.some(([u]) => u.includes('/lineups/1'))).toBe(false); + it('(c) one read, of the in-force route alone: the round and match routes are never fetched', async () => { + const own = { teamId: 't1', matchId: 'm1', positions: {}, sourceMatchId: 'm1', saved: true }; + global.fetch = routeFetch([[IN_FORCE_URL, own]]); + const result = await resolveMatchLineup('c1', 't1', 'm1', API); + expect(result).toEqual(own); + expect(global.fetch.mock.calls.map(([u]) => u)).toEqual([IN_FORCE_URL]); }); - it('(d) match route 404 (competition missing): falls through; round route saved false: returns null', async () => { - global.fetch = routeFetch([ - ['/api/competitions/c1/teams/t1/lineups/1?fallback=best', { teamId: 't1', round: 1, positions: {}, saved: false }], - ]); - const result = await resolveMatchLineup('c1', 't1', 'm1', 1, API); - expect(result).toBeNull(); + it('(d) 404 (competition missing): null by default, thrown for the panel that must not guess', async () => { + global.fetch = routeFetch([]); + expect(await resolveMatchLineup('c1', 't1', 'm1', API)).toBeNull(); + await expect(resolveMatchLineup('c1', 't1', 'm1', API, { throwOnError: true })) + .rejects.toThrow('competition not found'); }); }); diff --git a/web-mobile/js/__tests__/streaming_overlay_score.test.jsx b/web-mobile/js/__tests__/streaming_overlay_score.test.jsx index dc0539a74..7fab0ee67 100644 --- a/web-mobile/js/__tests__/streaming_overlay_score.test.jsx +++ b/web-mobile/js/__tests__/streaming_overlay_score.test.jsx @@ -244,8 +244,7 @@ describe('StreamingOverlay team bout name: a rename reaches the current bout (bc // stub only needs to resolve without throwing. global.window.API = { fetchCompetitionDetails: async () => ({}), - fetchMatchLineup: async () => null, - fetchTeamLineup: async () => null, + fetchLineupInForce: async () => null, }; vi.resetModules(); ({ StreamingOverlay } = await import('../streaming_overlay.jsx')); @@ -310,8 +309,7 @@ describe('StreamingOverlay team bout: Aka squad label sits after the name, not b global.window.Term = function Term(props) { return { type: 'span', props, children: props?.children }; }; global.window.API = { fetchCompetitionDetails: async () => ({}), - fetchMatchLineup: async () => null, - fetchTeamLineup: async () => null, + fetchLineupInForce: async () => null, }; vi.resetModules(); ({ StreamingOverlay } = await import('../streaming_overlay.jsx')); diff --git a/web-mobile/js/__tests__/tap_guard.test.jsx b/web-mobile/js/__tests__/tap_guard.test.jsx index e4fd70504..a47c8a123 100644 --- a/web-mobile/js/__tests__/tap_guard.test.jsx +++ b/web-mobile/js/__tests__/tap_guard.test.jsx @@ -2,7 +2,7 @@ // (bc-dtfn). The hook (useArmedConfirm) is pinned through the editors in // render/finish_arm_dwell.render.test.jsx; this pins the pure helpers. import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; -import { TAP_BOUNCE_MS, isPointerTap, stampTap, clearTap, tapIsBounce, acceptTap, swallowBounce } from '../tap_guard.jsx'; +import { TAP_BOUNCE_MS, isPointerTap, stampTap, clearTap, tapIsBounce, acceptTap, swallowBounce, useOpenedTapGuard } from '../tap_guard.jsx'; const pointer = { detail: 1 }; const keyboard = { detail: 0 }; @@ -107,3 +107,60 @@ describe('swallowBounce', () => { expect(late.stopPropagation).not.toHaveBeenCalled(); }); }); + +describe('useOpenedTapGuard', () => { + const ev = (detail) => ({ detail, stopPropagation: vi.fn(), preventDefault: vi.fn() }); + + it('stamps when a node mounts and ignores the unmount call', () => { + const { openedRef, onClickCapture } = useOpenedTapGuard(); + openedRef(null); + const unmounted = ev(1); + onClickCapture(unmounted); + expect(unmounted.stopPropagation).not.toHaveBeenCalled(); + + openedRef({}); + const bounce = ev(1); + onClickCapture(bounce); + expect(bounce.stopPropagation).toHaveBeenCalled(); + expect(bounce.preventDefault).toHaveBeenCalled(); + }); + + it('lets a pointer click through once the window has passed', () => { + const { openedRef, onClickCapture } = useOpenedTapGuard(); + openedRef({}); + vi.advanceTimersByTime(TAP_BOUNCE_MS); + const late = ev(1); + onClickCapture(late); + expect(late.stopPropagation).not.toHaveBeenCalled(); + }); + + it('never swallows a keyboard click (detail 0)', () => { + const { openedRef, onClickCapture } = useOpenedTapGuard(); + openedRef({}); + const key = ev(0); + onClickCapture(key); + expect(key.stopPropagation).not.toHaveBeenCalled(); + }); + + it('a new mount re-stamps (a request replacing another)', () => { + const { openedRef, onClickCapture } = useOpenedTapGuard(); + openedRef({}); + vi.advanceTimersByTime(TAP_BOUNCE_MS + 10); + openedRef({}); + const bounce = ev(1); + onClickCapture(bounce); + expect(bounce.stopPropagation).toHaveBeenCalled(); + }); + + it('with backdropOnly, swallows a bounce on the backdrop but not on a control inside it', () => { + const { openedRef, onClickCapture } = useOpenedTapGuard({ backdropOnly: true }); + openedRef({}); + const backdrop = {}; + const onBackdrop = { ...ev(1), target: backdrop, currentTarget: backdrop }; + onClickCapture(onBackdrop); + expect(onBackdrop.stopPropagation).toHaveBeenCalled(); + const onControl = { ...ev(1), target: {}, currentTarget: backdrop }; + onClickCapture(onControl); + expect(onControl.stopPropagation).not.toHaveBeenCalled(); + }); +}); diff --git a/web-mobile/js/__tests__/viewer_bronze_match.test.jsx b/web-mobile/js/__tests__/viewer_bronze_match.test.jsx index 33fae46c6..d5e94bdb0 100644 --- a/web-mobile/js/__tests__/viewer_bronze_match.test.jsx +++ b/web-mobile/js/__tests__/viewer_bronze_match.test.jsx @@ -214,13 +214,11 @@ describe('ViewerCompetition bronze / 3rd-place match rendering (mp-gy6g)', () => expect(text).not.toContain('3rd Place'); }); - // mp-gy6g review fix: the bronze card's onClick used to stamp roundIndex: - // -1, but resolveRoundIndex (admin_helpers.jsx) treats any negative - // roundIndex as absent and falls back to 0, mis-grouping the bronze match - // into round 0 for round-indexed consumers (team lineup fetch, displays, - // queue grouping). It must match compMatches' convention of "one past the - // final" (rounds.length) instead. - it('stamps the bronze match roundIndex as rounds.length (one past the final), not -1', () => { + // The bronze card opens the modal on the live bronze row, headed "3rd Place". + // The opener declares only its round label and the competition's fields; the + // row's own roundIndex (one past the final) is compMatches' business and is + // pinned in bronze_shiaijo.test.jsx, since nothing in the modal reads it. + it('opens the modal on the bronze match, headed "3rd Place"', () => { const bracket = mkBracket(true); const tree = runtime.mount(ViewerCompetition, { tournament: { competitions: [] }, @@ -243,11 +241,19 @@ describe('ViewerCompetition bronze / 3rd-place match rendering (mp-gy6g)', () => // The click sets selectedMatch, which mounts MatchViewerModal with the // clicked match as a prop. Find it by name (real import, not stubbed) - // and read the roundIndex without invoking the component. + // and read the match without invoking the component. const updated = runtime.currentTree(); const modalVnode = findAll(updated, n => n && typeof n.type === 'function' && n.type.name === 'MatchViewerModal')[0]; expect(modalVnode).toBeTruthy(); - expect(modalVnode.props.match.roundIndex).toBe(bracket.rounds.length); - expect(modalVnode.props.match.roundIndex).not.toBe(-1); + expect(modalVnode.props.match).toMatchObject({ + id: 'm-bronze', + phase: 'bracket', + round: '3rd Place', + phaseName: '3rd Place', + compId: 'nagi-1', + compName: 'Naginata Cup', + }); + expect(modalVnode.props.match.sideA.name).toBe('Bob'); + expect(modalVnode.props.match.sideB.name).toBe('Dave'); }); }); diff --git a/web-mobile/js/__tests__/viewer_competition_round_labels.test.jsx b/web-mobile/js/__tests__/viewer_competition_round_labels.test.jsx index a802ef9a6..909c63c7a 100644 --- a/web-mobile/js/__tests__/viewer_competition_round_labels.test.jsx +++ b/web-mobile/js/__tests__/viewer_competition_round_labels.test.jsx @@ -181,7 +181,7 @@ describe('ViewerCompetition stamps bracket rows with the EFFECTIVE round (mp-u37 expect(recent[0].phaseName).toBe('Quarterfinals'); }); - it('keeps roundIndex RAW so lineup fetches still key on the backend round', () => { + it('keeps roundIndex RAW: the position in bracket.rounds, not the effective round the label names', () => { const props = overviewProps(normalizeCompetitionDetail(rawDetail())); // The label moved to the effective round; the index must NOT follow it. expect(props.runningMatches[0].roundIndex).toBe(0); diff --git a/web-mobile/js/__tests__/viewer_team_lineup.test.jsx b/web-mobile/js/__tests__/viewer_team_lineup.test.jsx index fe623ecc2..df4dbfdb9 100644 --- a/web-mobile/js/__tests__/viewer_team_lineup.test.jsx +++ b/web-mobile/js/__tests__/viewer_team_lineup.test.jsx @@ -124,34 +124,23 @@ describe('lineup_resolver: resolveMatchLineup', () => { ({ resolveMatchLineup } = await import('../lineup_resolver.jsx')); }); - it('returns match-specific lineup when fetchMatchLineup succeeds', async () => { - const matchLineup = { positions: { senpo: 'A' } }; - const fetchers = { - fetchMatchLineup: vi.fn().mockResolvedValue(matchLineup), - fetchTeamLineup: vi.fn().mockResolvedValue({ positions: { senpo: 'B' } }), - }; - const result = await resolveMatchLineup('c1', 't1', 'm1', 0, fetchers); - expect(result).toBe(matchLineup); - expect(fetchers.fetchTeamLineup).not.toHaveBeenCalled(); + it('returns the lineup in force for the team at the match', async () => { + const inForce = { positions: { senpo: 'A' }, sourceMatchId: 'm0' }; + const fetchers = { fetchLineupInForce: vi.fn().mockResolvedValue(inForce) }; + const result = await resolveMatchLineup('c1', 't1', 'm1', fetchers); + expect(result).toBe(inForce); + expect(fetchers.fetchLineupInForce).toHaveBeenCalledWith('c1', 't1', 'm1'); }); - it('falls through to round-based lineup when fetchMatchLineup returns null', async () => { - const roundLineup = { positions: { '1': 'C' } }; - const fetchers = { - fetchMatchLineup: vi.fn().mockResolvedValue(null), - fetchTeamLineup: vi.fn().mockResolvedValue(roundLineup), - }; - const result = await resolveMatchLineup('c1', 't1', 'm1', 2, fetchers); - expect(result).toBe(roundLineup); - expect(fetchers.fetchTeamLineup).toHaveBeenCalledWith('c1', 't1', 2, { fallback: true }); + it('returns null when nothing is in force', async () => { + const fetchers = { fetchLineupInForce: vi.fn().mockResolvedValue(null) }; + const result = await resolveMatchLineup('c1', 't1', 'm1', fetchers); + expect(result).toBeNull(); }); - it('returns null when both fetchers throw/reject', async () => { - const fetchers = { - fetchMatchLineup: vi.fn().mockRejectedValue(new Error('network')), - fetchTeamLineup: vi.fn().mockRejectedValue(new Error('competition not found')), - }; - const result = await resolveMatchLineup('c1', 't1', 'm1', 0, fetchers); + it('returns null when the read throws/rejects', async () => { + const fetchers = { fetchLineupInForce: vi.fn().mockRejectedValue(new Error('network')) }; + const result = await resolveMatchLineup('c1', 't1', 'm1', fetchers); expect(result).toBeNull(); }); }); diff --git a/web-mobile/js/__tests__/write_result_deadline.test.jsx b/web-mobile/js/__tests__/write_result_deadline.test.jsx new file mode 100644 index 000000000..d78e9dd26 --- /dev/null +++ b/web-mobile/js/__tests__/write_result_deadline.test.jsx @@ -0,0 +1,49 @@ +// withinDeadline (write_result.jsx) gives a wait that is not a request of its own +// the deadline every bounded request has: the team editor's save before a +// representative-bout change, and the lineup read a pick or a Save is composed on. + +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { withinDeadline, TIMED_OUT, FETCH_TIMEOUT_MS } from '../write_result.jsx'; + +describe('withinDeadline', () => { + afterEach(() => { vi.useRealTimers(); }); + + it('is the promise\'s own outcome when it settles first, and leaves no timer behind', async () => { + vi.useFakeTimers(); + expect(await withinDeadline(Promise.resolve('answer'), FETCH_TIMEOUT_MS)).toBe('answer'); + expect(vi.getTimerCount()).toBe(0); + }); + + it('is TIMED_OUT once the deadline has passed with no outcome, and not before', async () => { + vi.useFakeTimers(); + let outcome = 'waiting'; + withinDeadline(new Promise(() => {}), FETCH_TIMEOUT_MS).then((value) => { outcome = value; }); + + await vi.advanceTimersByTimeAsync(FETCH_TIMEOUT_MS - 1); + expect(outcome).toBe('waiting'); + await vi.advanceTimersByTimeAsync(1); + expect(outcome).toBe(TIMED_OUT); + }); + + it('ends only the wait: the promise runs on and its late outcome reaches whoever else holds it', async () => { + vi.useFakeTimers(); + let answer; + const slow = new Promise((resolve) => { answer = resolve; }); + const late = vi.fn(); + slow.then(late); + + const waited = withinDeadline(slow, 100); + await vi.advanceTimersByTimeAsync(100); + expect(await waited).toBe(TIMED_OUT); + + answer('late'); + await vi.advanceTimersByTimeAsync(0); + expect(late).toHaveBeenCalledWith('late'); + }); + + it('rejects with the promise\'s own rejection, before the deadline', async () => { + vi.useFakeTimers(); + await expect(withinDeadline(Promise.reject(new Error('offline')), FETCH_TIMEOUT_MS)).rejects.toThrow('offline'); + expect(vi.getTimerCount()).toBe(0); + }); +}); diff --git a/web-mobile/js/admin_competition.jsx b/web-mobile/js/admin_competition.jsx index 4bcc260ee..a3aba9541 100644 --- a/web-mobile/js/admin_competition.jsx +++ b/web-mobile/js/admin_competition.jsx @@ -486,7 +486,7 @@ function AdminCompetition({ tournament, competition, pools, poolMatches, standin
{section === "overview" && } {section === "participants" && } - {section === "lineups" && window.AdminTeamLineupsList && } + {section === "lineups" && window.AdminTeamLineupsList && } {section === "settings" && } {section === "awards" && } {/* T191/T193 (FR-050d/e): Swiss-round management. */} diff --git a/web-mobile/js/admin_helpers.jsx b/web-mobile/js/admin_helpers.jsx index d6d5fd81f..8de88a46d 100644 --- a/web-mobile/js/admin_helpers.jsx +++ b/web-mobile/js/admin_helpers.jsx @@ -979,20 +979,9 @@ function partitionStartableCompetitions(competitions, tournamentCourts) { return { startable, blocked }; } -// Resolves the 0-based round index from a match object. Bracket matches -// carry m.roundIndex (stamped by compMatches/viewer.jsx); fall back to a -// non-negative numeric m.round for any older shapes. -// Returns 0 for pool matches (no per-round lineup). -function resolveRoundIndex(match) { - if (typeof match.roundIndex === "number" && match.roundIndex >= 0) return match.roundIndex; - if (typeof match.round === "number" && match.round >= 0) return match.round; - return 0; -} - // Guard window assignments so this file stays safely importable in // non-browser test environments (matches the pattern in data.jsx / ui.jsx). if (typeof window !== "undefined") { - window.resolveRoundIndex = resolveRoundIndex; window.sideName = sideName; window.hasBothSides = hasBothSides; window.hasPoolOriginPlaceholder = hasPoolOriginPlaceholder; @@ -1150,5 +1139,4 @@ export { seedGapDiagnosis, seededRanks, partitionStartableCompetitions, - resolveRoundIndex, }; diff --git a/web-mobile/js/admin_lineup.jsx b/web-mobile/js/admin_lineup.jsx index 93a41422d..cde2c3166 100644 --- a/web-mobile/js/admin_lineup.jsx +++ b/web-mobile/js/admin_lineup.jsx @@ -5,7 +5,14 @@ // other sizes). Lineups are always editable; operators can change them // at any time before or during a match. // -// Wire shape (matches domain.TeamLineup): +// The page edits one lineup at a time (operator ruling 2026-10-05: a team +// carries the lineup of its previous team match unless one is entered for a +// match): the team's STARTING lineup, stored as its round-0 entry, or the +// lineup of one of its team matches. A match shows the lineup in force there +// and says where it was saved (lineup_resolver.jsx). +// +// Wire shape (matches domain.TeamLineup; a match's own lineup carries +// `matchId` where this one has `round`): // { // teamId: "team-1", // competitionId: "...", @@ -13,6 +20,9 @@ // positions: { senpo: "Sato", ... }, // memberIds: { senpo: "member-uuid", ... } // } +// A save does not send the lineup: it names the positions it changed +// (`changed`, with those positions' names and ids), and the server puts them on +// the lineup it holds (lineup_save.jsx, operator decision 2026-10-07). // // bc-tmid pass 3: a position now carries the squad MEMBER's stable id // (memberIds) alongside the display NAME (positions) it always carried. @@ -38,11 +48,15 @@ import { idOf, nameOf } from './competitor_identity.jsx'; import { squadSlotLabel } from './squad_member_label.jsx'; -import { rosterWithoutPlacedElsewhere, memberPlacedElsewhere, memberRefusalNote } from './lineup_resolver.jsx'; +import { rosterWithoutPlacedElsewhere, memberPlacedElsewhere, memberRefusalNote, lineupDuplicateNote, alreadyPlacedNote, lineupPositionLabel, STARTING_ROUND } from './lineup_resolver.jsx'; +import { poolMatchNumberOf, isSupplementaryBout, scoreRowMatchLabel } from './pool_ids.jsx'; import { normalizeParticipantName } from './data.jsx'; import { renameMemberFields } from './lineup_rename.jsx'; +import { changedLineupSave } from './lineup_save.jsx'; +import { queuedNotice } from './write_result.jsx'; +import { useLineupForm, LineupSourceLine, LineupProblem, LineupDraftNotice } from './lineup_draft.jsx'; -const { useState: useStateA, useEffect: useEffectA, useMemo: useMemoA, useRef: useRefA } = React; +const { useState: useStateA, useMemo: useMemoA } = React; // Term: kendo-glossary tooltip wrapper. Lazy lookup so the script // load order between glossary.jsx and this module doesn't matter (both @@ -73,21 +87,11 @@ function positionsForSize(teamSize) { })); } -// lineupPositionLabel: the operator-facing name for a position KEY, reusing -// POS_LABELS_5's own labels (Senpo, Jiho, ...) rather than a second copy of -// them, so the two can never drift. A numeric-size key (positionsForSize's -// "1".."N") has no FIK name, so it reads as "Position N": unambiguous on -// its own outside the table context memberIdentityWarning's callers render -// it in. -const POS_LABEL_BY_KEY = POS_LABELS_5.reduce((acc, p) => { acc[p.key] = p.label; return acc; }, {}); -function lineupPositionLabel(posKey) { - return POS_LABEL_BY_KEY[posKey] || `Position ${posKey}`; -} // Pull the member roster off the team Player object. Retained for the // OTHER lineup surface (admin_schedule_lineup.jsx's match-scoped panel, // reached via window.AdminLineupHelpers) which has not moved off -// team.metadata; the round-scoped editor below (AdminLineup) no longer +// team.metadata; the Lineups page (AdminLineup, below) no longer // calls this itself, it reads the squad store instead (see module header). // The CSV parser stores member names in Metadata; fall back to the team // name itself so callers that DO use this never see an empty array crash. @@ -108,7 +112,7 @@ function rosterFor(team) { // whitespace assignments are ignored. The base array is never mutated. // // Retained for admin_schedule_lineup.jsx (see rosterFor's own comment); the -// round-scoped AdminLineup below no longer calls it. +// Lineups page (AdminLineup, below) no longer calls it. function mergeRosterWithAssigned(baseRoster, lineup) { const base = Array.isArray(baseRoster) ? baseRoster : []; const positions = lineup && lineup.positions ? lineup.positions : null; @@ -226,6 +230,21 @@ function blankMemberForPosition(squad, posKey, currentIds) { : null; } +// typedNameTarget: what a name typed at a position goes on, in the ONE order every +// surface that places a typed name goes by (the resolver below, the add on the Lineups +// page, and the at-court panel's check before a Save, which asks it of a copy of the +// members and writes nothing): the member the name already belongs to (`write` is +// "none"), else the unnamed member it names in place, which is the one the position +// holds, else the position's seeded slot while that slot is free (`write` is "rename"), +// else a member that does not exist yet (`write` is "add", `member` null). `ids` is the +// lineup's member ids by position. +function typedNameTarget(members, posKey, name, ids) { + const existing = resolveMemberIdForName(members, name); + if (existing) return { member: existing, write: "none" }; + const blank = blankMemberForPosition(members, posKey, ids); + return blank ? { member: blank, write: "rename" } : { member: null, write: "add" }; +} + // resolveMemberIdsForPositions resolves a WHOLE positions map (posKey → // name) to its memberIds counterpart against an already-loaded `squad`. // Shared by BOTH match-scoped lineup writers (admin_schedule_lineup.jsx's @@ -296,17 +315,18 @@ async function resolveMemberIdsForPositions(compId, teamId, positions, squad, pa for (const [posKey, rawName] of Object.entries(positions || {})) { const name = (rawName || "").trim(); if (!name) continue; - const existing = resolveMemberIdForName(currentSquad, name); - if (existing) { - memberIds[posKey] = existing.id; + const { member: target, write } = typedNameTarget(currentSquad, posKey, name, ids); + if (write === "none") { + memberIds[posKey] = target.id; continue; } - const blankMember = blankMemberForPosition(currentSquad, posKey, ids); - if (blankMember) { + if (write === "rename") { try { - await window.API.renameTeamMember(compId, teamId, blankMember.id, name, password); - currentSquad = currentSquad.map(mem => (mem === blankMember ? { ...mem, name } : mem)); - memberIds[posKey] = blankMember.id; + // The member as the server answered it, stamped: what the lists merge by. A copy + // built from the name typed carries no stamp and would lose to every list. + const renamed = await window.API.renameTeamMember(compId, teamId, target.id, name, password); + currentSquad = currentSquad.map(mem => (mem === target ? renamed : mem)); + memberIds[posKey] = target.id; } catch (e) { failures.push(memberWriteFailure(posKey, name, e)); } @@ -372,9 +392,13 @@ function memberIdentityWarning(failures, squadUnavailable) { return `Lineup saved, but ${parts.join(" ")} Scores will still record normally.`; } -function AdminLineup({ comp, team, round, password, showToast, onClose }) { +// AdminLineup edits one lineup of one team: its starting lineup (no matchId), +// or the lineup of the team match matchId names. matchLabel is how that match +// is named, and allMatches names the earlier match a carried lineup comes from. +function AdminLineup({ comp, team, matchId = "", matchLabel = "", notInMatch = false, allMatches, password, showToast, onClose }) { const teamSize = comp?.teamSize || 5; const positions = useMemoA(() => positionsForSize(teamSize), [teamSize]); + const positionKeys = positions.map(p => p.key); const teamId = teamServerIdOf(team); const compId = comp?.id || ""; // The team's OWN competitor number (e.g. "T10"), the input to @@ -392,29 +416,37 @@ function AdminLineup({ comp, team, round, password, showToast, onClose }) { // squad member ids in `memberIds`, keyed by the SAME position key so a // partial edit to one position never disturbs another's already-resolved // id (bc-tmid pass 3's OrderedMembers reasoning, restated client-side). - const [values, setValues] = useStateA(() => { - const init = {}; - positions.forEach(p => { init[p.key] = ""; }); - return init; + // They are useLineupForm's, with the lineup as it was read, where it was + // saved, the unsaved-picks draft and giving a match's own lineup up: shared + // with the at-court panel. `saveWarning` is the composed warning shown after + // a SUCCESSFUL save (see save() below): deliberately a separate channel from + // `error`, since the save did not fail, so it must never read like the red + // error banner. + const form = useLineupForm({ + compId, teamId, matchId, positionKeys, password, matchLabel, teamName: team?.name || team?.Name, }); - const [memberIds, setMemberIds] = useStateA({}); - // The team's squad (the actual people on it), loaded from its own store - // rather than team.metadata (module header). Independent of teamSize: a - // squad may hold reserves beyond however many positions exist. - const [squad, setSquad] = useStateA([]); - const [loading, setLoading] = useStateA(true); + // The team's squad (the actual people on it), read from its own store rather than + // team.metadata (module header), by the hook: it reads them again when it follows + // a lineup another device saved, so a member created there is not shown as an + // empty slot. Independent of teamSize: a squad may hold reserves beyond however + // many positions exist. bc-cse gap closure: `squadUnavailable` is whether the + // squad failed to load this session. Fed into memberIdentityWarning below, + // alongside a save's own per-position `failures`, so a squad fetch failure -- + // which otherwise silently leaves every existing member looking "new" -- is + // disclosed rather than discarded. A save is never blocked on it. + const { + values, setValues, memberIds, setMemberIds, memberIdsRef, + error, setError, warning: saveWarning, setWarning: setSaveWarning, + squad, changeMembers, squadUnavailable, + } = form; const [saving, setSaving] = useStateA(false); - const [error, setError] = useStateA(""); - // bc-cse gap closure: did the team's squad fail to load this session? Fed - // into memberIdentityWarning below, alongside a save's own per-position - // `failures`, so a squad fetch failure -- which otherwise silently leaves - // every existing member looking "new" -- is disclosed rather than - // discarded. A save is never blocked on it. - const [squadUnavailable, setSquadUnavailable] = useStateA(false); - // The composed warning shown after a SUCCESSFUL save (see save() below). - // Deliberately a separate channel from `error`: the save did not fail, - // so it must never read like the red error banner above. - const [saveWarning, setSaveWarning] = useStateA(""); + // A removal of the match's own lineup is in flight. + const busy = saving || form.removing; + // The pickers are for a lineup that was read: until then Save is off too, and + // the problem line says why. A match the team is no longer seated in + // (`notInMatch`) has no lineup to edit, and the notice below says so. + const locked = busy || !form.read || notInMatch; + const canSave = form.canSave && !notInMatch; // Operation 2 (ADD): which position is mid-add, and the name typed so // far. Only one position can be mid-add at a time, an operator works one @@ -435,79 +467,10 @@ function AdminLineup({ comp, team, round, password, showToast, onClose }) { // alongside Rename rather than being duplicated on both. const [clearingId, setClearingId] = useStateA(null); - // memberIdsRef mirrors memberIds for the two handlers that AWAIT a round - // trip before touching it. The position pickers stay interactive while a - // rename or a clear is in flight (they are gated on `saving` alone), so the - // memberIds captured in a handler's closure can be stale by the time it - // resolves. An operator who re-picks the position mid-flight otherwise has - // that position blanked by the resolving clear, and save() then writes it - // with a member id and no name, which the occupancy rule reads as a real - // placement fielding a nameless fighter. - const memberIdsRef = useRefA(memberIds); - memberIdsRef.current = memberIds; - - // Load the existing lineup (positions/memberIds); null (nothing saved) -> a fresh form. - useEffectA(() => { - let cancelled = false; - if (!compId || !teamId) { - setLoading(false); - return; - } - (async () => { - try { - const lineup = await window.API.fetchTeamLineup(compId, teamId, round); - if (cancelled) return; - if (lineup) { - const nextValues = {}; - const nextIds = {}; - positions.forEach(p => { - nextValues[p.key] = (lineup.positions || {})[p.key] || ""; - nextIds[p.key] = (lineup.memberIds || {})[p.key] || ""; - }); - setValues(nextValues); - setMemberIds(nextIds); - } - } catch (e) { - if (!cancelled) setError(e?.message || "Failed to load lineup"); - } finally { - if (!cancelled) setLoading(false); - } - })(); - return () => { cancelled = true; }; - }, [compId, teamId, round]); - - // Load the team's squad: the pickable member list operation 1 (SELECT) - // needs. Independent of the lineup load above -- a squad fetch failure - // must never block the lineup from loading or the form from being usable - // (bc-pnum gap closure); an empty/missing squad is simply []. It DOES mean - // the picker shows no existing members this session, so anything typed - // through "+ Add new member..." looks new to the resolver even when it - // is not; squadUnavailable carries that fact to memberIdentityWarning. - // `password` is a dependency, not just a closure read, and squadUnavailable - // is cleared on every attempt. The operator can reach this screen before - // entering the password (or with a rotated one), and the 401 that follows - // used to strand the section in its error state until the whole route - // remounted, because compId never changed: requestReauth renders the modal - // as a SIBLING of the admin app, so nothing unmounts and setPassword only - // changes a closure value. That rationale came with the deleted Settings - // section and was lost when this effect moved here; without it every picker - // stays empty, "+ Add new member" mints instead of resolving, and every save - // shows a false "the team member list could not be loaded" (bc-dnst). - useEffectA(() => { - let cancelled = false; - if (!compId || !teamId) return; - (async () => { - try { - const squads = await window.API.fetchSquads(compId, password); - if (cancelled) return; - setSquad((squads && squads[teamId]) || []); - setSquadUnavailable(false); - } catch (_e) { - if (!cancelled) setSquadUnavailable(true); - } - })(); - return () => { cancelled = true; }; - }, [compId, teamId, password]); + // A change to a team member is out. The save waits for it, and while a save (or a + // removal) is out the controls that make one are off: a rename made then would be + // written over by the save, and an add finished then would not be placed. + const memberBusy = addBusy || renameBusy || clearingId !== null; const squadSorted = useMemoA(() => squadMemberOptions(squad), [squad]); @@ -519,18 +482,28 @@ function AdminLineup({ comp, team, round, password, showToast, onClose }) { // squad nor the lineup (e.g. typing into the Rename box) was pure waste. const rostersByPosition = useMemoA(() => { const byKey = {}; - positions.forEach(p => { byKey[p.key] = rosterWithoutPlacedElsewhere(squadSorted, { positions: values, memberIds }, p.key); }); + positions.forEach(p => { + const offered = rosterWithoutPlacedElsewhere(squadSorted, { positions: values, memberIds }, p.key); + // A position always lists the member it holds: a lineup another device saved can + // hold one member at two positions, and a picker without its own value would + // show "none" where the form holds a member. + byKey[p.key] = squadSorted.filter(m => m.id === memberIds[p.key] || offered.includes(m)); + }); return byKey; }, [positions, squadSorted, values, memberIds]); // Operation 1 (SELECT): put an existing squad member's (name, id) pair // into a position, both keyed together so they can never drift apart. + // A change to a position ends the refusal shown for the lineup as it was: the + // next Save judges the lineup again. const selectMember = (posKey, member) => { + setError(""); setValues(v => ({ ...v, [posKey]: member.name })); setMemberIds(ids => ({ ...ids, [posKey]: member.id })); }; const clearPosition = (posKey) => { + setError(""); setValues(v => ({ ...v, [posKey]: "" })); setMemberIds(ids => ({ ...ids, [posKey]: "" })); }; @@ -571,39 +544,53 @@ function AdminLineup({ comp, team, round, password, showToast, onClose }) { const posKey = addingPos; const name = addingName.trim(); if (!posKey || !name) { setAddingPos(null); setAddingName(""); return; } - // The three outcomes the resolver itself has, in ITS order, so the copy - // can never describe a branch the action will not take. First: the name - // is an EXISTING member's (same normalisation as the resolver): nothing - // is created or renamed, that member is simply selected here, with no - // confirmation because nothing permanent happens; unless the lineup - // already fields them elsewhere, which is refused where the list would - // not have offered them. - const existingMember = resolveMemberIdForName(squad, name); - if (existingMember) { - const elsewhere = memberPlacedElsewhere(memberIds, posKey, existingMember.id); + // The name is judged against the team's members, so they must have been + // read: against none, a member the team has would be added again (and the + // server would refuse it as a second member of that name), and a position + // whose seeded slot is free would mint rather than name it. Members that + // cannot be read, or not in time, go without, as they always did. What is + // judged is the list and the placements as they are once the wait is over, + // not the ones this handler closed over. + const waiting = form.waitForMembers(); + if (waiting) { + setAddBusy(true); + await waiting; + setAddBusy(false); + } + const members = form.squadRef.current; + const placed = memberIdsRef.current; + // The three outcomes the resolver itself has, in ITS order (typedNameTarget + // is the one owner of it), so the copy can never describe a branch the action + // will not take. First: the name is an EXISTING member's (same normalisation + // as the resolver): nothing is created or renamed, that member is simply + // selected here, with no confirmation because nothing permanent happens; + // unless the lineup already fields them elsewhere, which is refused where the + // list would not have offered them. + const { member: target, write } = typedNameTarget(members, posKey, name, placed); + if (write === "none") { + const elsewhere = memberPlacedElsewhere(placed, posKey, target.id); if (elsewhere) { - setError(`${existingMember.name} is already at ${lineupPositionLabel(elsewhere)}.`); + setError(alreadyPlacedNote(target.name, lineupPositionLabel(elsewhere))); return; } - selectMember(posKey, existingMember); + selectMember(posKey, target); setAddingPos(null); setAddingName(""); return; } - const blankMember = blankMemberForPosition(squad, posKey, memberIds); - const message = blankMember - ? `Name ${squadSlotLabel(teamNumber, blankMember.index)} as "${name}"?` + const message = write === "rename" + ? `Name ${squadSlotLabel(teamNumber, target.index)} as "${name}"?` : `Add "${name}" as a new member of ${team?.name || team?.Name || "this team"}? This adds a new position to the team. Once added, it can be cleared but never removed.`; const ok = await window.confirmDialog({ message, - confirmLabel: blankMember ? "Name slot" : "Add member", + confirmLabel: write === "rename" ? "Name slot" : "Add member", cancelLabel: "Cancel", }); if (!ok) return; setAddBusy(true); setError(""); try { - const resolved = await resolveMemberIdsForPositions(compId, teamId, { [posKey]: name }, squad, password, memberIds); + const resolved = await resolveMemberIdsForPositions(compId, teamId, { [posKey]: name }, members, password, placed); const failure = (resolved.failures || []).find(f => f.position === posKey); if (failure) { setError(failure.reason || "Failed to add team member"); @@ -611,7 +598,7 @@ function AdminLineup({ comp, team, round, password, showToast, onClose }) { } const resolvedId = resolved.memberIds[posKey]; const resolvedMember = resolved.squad.find(m => m.id === resolvedId); - setSquad(resolved.squad); + changeMembers(resolved.squad); if (resolvedMember) selectMember(posKey, resolvedMember); setAddingPos(null); setAddingName(""); @@ -641,16 +628,7 @@ function AdminLineup({ comp, team, round, password, showToast, onClose }) { setRenameBusy(true); setError(""); try { - await window.API.renameTeamMember(compId, teamId, id, name, password); - setSquad(s => s.map(m => (m.id === id ? { ...m, name } : m))); - setValues(v => { - const next = { ...v }; - const ids = memberIdsRef.current; - Object.keys(ids).forEach(posKey => { - if (ids[posKey] === id) next[posKey] = name; - }); - return next; - }); + form.memberRenamed(await window.API.renameTeamMember(compId, teamId, id, name, password)); setRenamingId(null); setRenamingName(""); } catch (e) { @@ -674,16 +652,7 @@ function AdminLineup({ comp, team, round, password, showToast, onClose }) { setClearingId(member.id); setError(""); try { - await window.API.clearTeamMember(compId, teamId, member.id, password); - setSquad(s => s.map(m => (m.id === member.id ? { ...m, name: "" } : m))); - setValues(v => { - const next = { ...v }; - const ids = memberIdsRef.current; - Object.keys(ids).forEach(posKey => { - if (ids[posKey] === member.id) next[posKey] = ""; - }); - return next; - }); + form.memberRenamed(await window.API.clearTeamMember(compId, teamId, member.id, password)); } catch (e) { setError(e?.message || "Failed to clear the name"); } finally { @@ -692,40 +661,51 @@ function AdminLineup({ comp, team, round, password, showToast, onClose }) { }; const save = async () => { + // Nothing is written before the lineup was read, or while it holds no change: + // the starting lineup as much as a match's, so a Save that changed nothing + // never writes the lineup it carries over as a lineup of its own. + if (!canSave) return; setError(""); setSaveWarning(""); setSaving(true); try { - // Strip vacant positions before sending: an omitted key reads as - // "vacant" the same way an explicit empty string would (the server's - // ValidatePositions only checks that submitted KEYS are valid for the - // team size, not whether values are filled), so this is a storage- - // hygiene choice, an omitted key, not a stored empty string. A - // position holding a picked squad slot with no name yet is NOT vacant - // (bc-dnst): the id is the placement, so it is written with an empty - // name, exactly as the match panel and the score sheet write it. - const positionsOut = {}; - const memberIdsOut = {}; - Object.entries(values).forEach(([k, v]) => { - // Trim here too (not just on commit), so a Save never persists - // leading/trailing or whitespace-only names. - const trimmed = (v || "").trim(); - if (trimmed || memberIds[k]) { - positionsOut[k] = trimmed; - if (memberIds[k]) memberIdsOut[k] = memberIds[k]; - } - }); - const hasMemberIds = Object.keys(memberIdsOut).length > 0; - const updated = await window.API.putTeamLineup( - compId, teamId, round, positionsOut, password, hasMemberIds ? memberIdsOut : undefined - ); + // The save names the positions the operator changed and carries those alone + // (form.lineupToSave, operator decision 2026-10-07): the server puts them on the + // lineup it holds when the save arrives, so a change another device made to a + // position left alone since this page read the lineup stays. + const { positions: shown, memberIds: shownIds, changed } = form.lineupToSave(); + // Trim here too (not just on commit), so a Save never persists leading/trailing + // or whitespace-only names. A cleared position goes as its empty name, which the + // server needs to be there, and a position holding a picked team member with no + // name yet is not vacant (bc-dnst): the id is the placement, so it goes with an + // empty name, exactly as the match panel and the score sheet write it. + const named = {}; + positionKeys.forEach((k) => { named[k] = (shown[k] || "").trim(); }); + // One position per member (the shared predicate, bc-dnst), asked of the lineup + // as the form shows it: the pickers never offer a member this form holds at + // another position, but a lineup another device saved can hold one twice. The + // server asks the same of the lineup it composes, and refuses naming both. + const duplicate = lineupDuplicateNote(named, shownIds, lineupPositionLabel, positionKeys, changed); + if (duplicate) { + setError(duplicate); + return; + } + const body = changedLineupSave(named, shownIds, changed); + const idsOut = Object.keys(body.memberIds).length > 0 ? body.memberIds : undefined; + const updated = matchId + ? await window.API.putMatchLineup(compId, teamId, matchId, body.positions, password, idsOut, body.changed) + : await window.API.putTeamLineup(compId, teamId, STARTING_ROUND, body.positions, password, idsOut, body.changed); // F5: a queued (offline/transient) write is NOT a confirmed save: don't // clear the revising state or show "saved"; the write is durable and will // retry. Keep the form editable and tell the operator it's pending. if (updated && updated.queued) { - if (typeof showToast === "function") showToast("Offline: lineup not saved yet, will retry"); + if (typeof showToast === "function") showToast(queuedNotice(updated), "pending"); return; } + // What the server answered is the whole lineup it holds now, the positions this + // save left alone included: the new baseline, for the starting lineup as for a + // match, so a saved lineup leaves no draft behind. + form.confirmSaved(updated); if (typeof showToast === "function") showToast("Lineup saved"); // bc-cse gap closure: this surface's own SELECT/ADD/RENAME operations // already surface a mint/rename failure immediately (see commitAdd's @@ -741,7 +721,7 @@ function AdminLineup({ comp, team, round, password, showToast, onClose }) { } }; - if (loading) { + if (form.loading) { return
Loading lineup…
; } @@ -768,7 +748,7 @@ function AdminLineup({ comp, team, round, password, showToast, onClose }) {
- {comp?.name} · Round {round + 1} + {matchId ? matchLabel : "Starting lineup"}

{team?.name || team?.Name || "Team"}: Lineup @@ -785,6 +765,18 @@ function AdminLineup({ comp, team, round, password, showToast, onClose }) {

+ + {notInMatch && ( +
+ {`${team?.name || team?.Name || "The team"} is no longer in ${matchLabel}. Choose another match, or the starting lineup, in Lineup for.`} +
+ )} + {matchId && ( + + )} + + + {error && (
{error} @@ -819,7 +811,7 @@ function AdminLineup({ comp, team, round, password, showToast, onClose }) { className="input" data-testid={`lineup-position-${p.key}`} aria-label={`${p.label} player`} - disabled={saving} + disabled={locked} value={memberId} onChange={(e) => onPickerChange(p.key, e.target.value)} > @@ -844,17 +836,17 @@ function AdminLineup({ comp, team, round, password, showToast, onClose }) { className="input" aria-label={`New member name for ${p.label}`} value={addingName} - disabled={addBusy} + disabled={addBusy || busy} onChange={(e) => setAddingName(e.target.value)} onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); commitAdd(); } else if (e.key === "Escape") { e.preventDefault(); cancelAdd(); } }} /> - - +
)} @@ -900,16 +892,16 @@ function AdminLineup({ comp, team, round, password, showToast, onClose }) { onCommit: commitRename, onCancel: cancelRename, busy: renameBusy, - ariaLabel: `Rename ${m.name}`, + ariaLabel: `Rename ${m.name || squadSlotLabel(teamNumber, m.index)}`, inputStyle: { flex: 1 }, - disabled: renameBusy, + disabled: renameBusy || busy, }) : ( <> {squadSlotLabel(teamNumber, m.index)} {m.name} - + {/* Nothing to clear on an already-blank slot: the row shows no Clear button at all rather than one that would refuse itself (a blank candidate never @@ -920,7 +912,7 @@ function AdminLineup({ comp, team, round, password, showToast, onClose }) { type="button" className="btn btn--ghost btn--sm" onClick={() => clearMember(m)} - disabled={renameBusy || clearingId !== null || started} + disabled={busy || renameBusy || clearingId !== null || started} title={started ? "Names cannot be cleared once the competition has started" : undefined} > {clearingId === m.id ? "Clearing…" : "Clear name"} @@ -938,7 +930,8 @@ function AdminLineup({ comp, team, round, password, showToast, onClose }) { @@ -948,14 +941,65 @@ function AdminLineup({ comp, team, round, password, showToast, onClose }) { ); } -// AdminTeamLineupsList: a small selector that picks a team from the -// competition's player list and renders AdminLineup for it. Mounted by -// the "Lineups" sidebar entry in admin_competition.jsx (T136 nav hook). -function AdminTeamLineupsList({ comp, password, showToast }) { +// teamMatchOptions: the team matches `teamId` is seated in, as the { id, label } +// choices of the "Lineup for" select. The order follows the server's match +// order (engine/lineup_in_force.go) and is presentation only: the server alone +// decides which lineup a match carries. Pool and league matches come first, by +// their number in the pool (a Swiss team's rounds in the order they were +// drawn), then the knockout by round and position, the 3rd-place match last. +// A team is seated by participant id, as the server seats it. A pool tiebreaker +// or daihyosen is an individual bout, and a bye (a hidden match, or one with a +// side left empty, a Swiss bye included) is a match nobody fights. A knockout +// match whose opponent is not decided yet is no bye: that side carries a +// placeholder name ("Winner of ..."), so the match is listed and its lineup can +// be set before then. +function teamMatchOptions(allMatches, teamId) { + if (!teamId) return []; + const mine = (allMatches || []).filter(m => m && m.id && !m.hidden && !isSupplementaryBout(m.id) + && nameOf(m.sideA) && nameOf(m.sideB) + && (idOf(m.sideA) === teamId || idOf(m.sideB) === teamId)); + const pool = mine.filter(m => m.phase === "pool"); + const drawn = [...new Set(pool.map(m => m.poolName))]; + pool.sort((a, b) => drawn.indexOf(a.poolName) - drawn.indexOf(b.poolName) || poolMatchNumberOf(a.id) - poolMatchNumberOf(b.id)); + // Array.sort is stable, so a round's matches keep their position order. + const knockout = mine.filter(m => m.phase === "bracket").sort((a, b) => a.roundIndex - b.roundIndex); + return [...pool, ...knockout].map(m => ({ id: m.id, label: scoreRowMatchLabel(m) || m.id })); +} + +// The "Lineup for" choice that names no match: the team's starting lineup. +const STARTING_TARGET = { id: "", label: "" }; + +// AdminTeamLineupsList: selectors that pick a team from the competition's +// player list and which of its lineups to edit (its starting lineup, or one of +// its team matches), and render AdminLineup for that pair. Mounted by the +// "Lineups" sidebar entry in admin_competition.jsx (T136 nav hook). +function AdminTeamLineupsList({ comp, pools, poolMatches, bracket, password, showToast }) { const teams = (comp?.players || []); const [teamId, setTeamId] = useStateA(teams[0] ? teamIdOf(teams[0]) : ""); - const [round, setRound] = useStateA(0); + // The starting lineup has no id; otherwise one of the team's matches, with the + // label it was offered under, which the page keeps for a match that stops being + // offered. + const [chosen, setChosen] = useStateA(STARTING_TARGET); const selectedTeam = teams.find(t => teamIdOf(t) === teamId) || teams[0]; + // The competition page holds the match data beside the competition's config + // (pools, poolMatches and bracket are its own props), so the matches are read + // from both. A competition that carries the data itself is read as it stands. + const allMatches = useMemoA(() => { + if (typeof window.compMatchesForCompetition !== "function") return []; + const data = (pools || poolMatches || bracket) ? { pools, poolMatches, bracket } : undefined; + return window.compMatchesForCompetition(comp, data); + }, [comp, pools, poolMatches, bracket]); + const matchOptions = useMemoA( + () => teamMatchOptions(allMatches, teamServerIdOf(selectedTeam)), + [allMatches, selectedTeam] + ); + // A match the team is no longer seated in (a feeding match reopened or corrected + // elsewhere, a draw discarded and drawn again) is no longer offered, but it stays + // chosen: going back to the starting lineup by itself would put the operator's + // next edit and Save on the lineup that every later match without its own carries. + // The editor says the team is not in it, and cannot save for it. + const offered = matchOptions.find(o => o.id === chosen.id) || null; + const target = offered || (chosen.id ? chosen : null); if ((comp?.kind || "") !== "team") { return ( @@ -975,7 +1019,7 @@ function AdminTeamLineupsList({ comp, password, showToast }) {
{selectedTeam ? ( ) : (
@@ -1027,13 +1075,13 @@ if (typeof window !== "undefined") { // at runtime in the browser and in the esbuild bundle). window.AdminLineupHelpers = { positionsForSize, rosterFor, mergeRosterWithAssigned, teamIdOf, - resolveMemberIdForName, resolveMemberIdsForPositions, memberIdentityWarning, - lineupPositionLabel, blankMemberForPosition, + resolveMemberIdsForPositions, memberIdentityWarning, + lineupPositionLabel, typedNameTarget, }; } export { AdminLineup, AdminTeamLineupsList, positionsForSize, rosterFor, mergeRosterWithAssigned, teamIdOf, - lineupPositionLabel, blankMemberForPosition, + lineupPositionLabel, blankMemberForPosition, typedNameTarget, resolveMemberIdForName, resolveMemberIdsForPositions, memberIdentityWarning, }; diff --git a/web-mobile/js/admin_participants.jsx b/web-mobile/js/admin_participants.jsx index 8df2b8f0b..78fa4a43e 100644 --- a/web-mobile/js/admin_participants.jsx +++ b/web-mobile/js/admin_participants.jsx @@ -7,6 +7,7 @@ import { seededRanks } from './admin_helpers.jsx'; import { NO_ID_HINT } from './data_integrity.jsx'; import { NumberedName } from './numbered_name.jsx'; +import { useOpenedTapGuard } from './tap_guard.jsx'; const { useState: useStateA, useMemo: useMemoA, useEffect: useEffectA, useRef: useRefA } = React; @@ -285,6 +286,9 @@ function AdminParticipants({ c, tournament: _tournament, onUpdate, password, sho const [replaceDojo, setReplaceDojo] = useStateA(""); const [replaceDanGrade, setReplaceDanGrade] = useStateA(""); const [replaceZekken, setReplaceZekken] = useStateA(""); + // The Edit dialog opens under the finger that tapped a row's pencil, so the + // bounce of that tap must not reach its backdrop, which dismisses it (bc-cfbd). + const { openedRef: editOpenedRef, onClickCapture: editOnClickCapture } = useOpenedTapGuard(); // nearDupPending holds {pairs} when the server returned Tier-2 near-dup // warnings on the roster save. The save has already committed (warnings are // non-blocking); this drives a post-save informational banner. @@ -1016,7 +1020,7 @@ function AdminParticipants({ c, tournament: _tournament, onUpdate, password, sho
)} {replaceTarget && ( -
{ if (e.target === e.currentTarget) setReplaceTarget(null); }}> +
{ if (e.target === e.currentTarget) setReplaceTarget(null); }}> setReplaceTarget(null)} />
Edit {replaceTarget.name}
diff --git a/web-mobile/js/admin_schedule.jsx b/web-mobile/js/admin_schedule.jsx index eb9669a44..0b24f1fe5 100644 --- a/web-mobile/js/admin_schedule.jsx +++ b/web-mobile/js/admin_schedule.jsx @@ -19,7 +19,7 @@ import { filterMatchesByCourt, computeCourtPaceStats, CourtPacePanel, suggestRebalances, } from './admin_schedule_pacing.jsx'; -import { pickCopySource, MatchLineupPanel } from './admin_schedule_lineup.jsx'; +import { MatchLineupPanel } from './admin_schedule_lineup.jsx'; import { AdminExport } from './admin_schedule_export.jsx'; import { startPatch, AdminScoreEditor, AdminScoreEditorPage } from './admin_schedule_score_editor.jsx'; import { AdminSchedulePage } from './admin_schedule_page.jsx'; @@ -31,7 +31,7 @@ export { timeEdited, timeToMinutes, allMatchesCompleted, clampDurationSeconds, filterMatchesByCourt, computeCourtPaceStats, CourtPacePanel, - suggestRebalances, pickCopySource, MatchLineupPanel, + suggestRebalances, MatchLineupPanel, }; window.AdminSchedulePage = AdminSchedulePage; diff --git a/web-mobile/js/admin_schedule_lineup.jsx b/web-mobile/js/admin_schedule_lineup.jsx index 9755dd9a9..5aae0221e 100644 --- a/web-mobile/js/admin_schedule_lineup.jsx +++ b/web-mobile/js/admin_schedule_lineup.jsx @@ -1,83 +1,34 @@ // Per-match lineup components extracted from admin_schedule.jsx (mp-d7tl). -// pickCopySource, MatchLineupSideEditor (local), MatchLineupPanel. +// MatchLineupSideEditor (local), MatchLineupPanel. import { LineupNameInput } from './admin_scoring_shared.jsx'; import { sideLookupKey } from './competitor_identity.jsx'; -import { squadRosterEntries, rosterWithoutPlacedElsewhere, memberPlacedElsewhere } from './lineup_resolver.jsx'; +import { scoreRowMatchLabel } from './pool_ids.jsx'; +import { squadRosterEntries, rosterWithoutPlacedElsewhere, lineupDuplicateNote, lineupPositionLabel } from './lineup_resolver.jsx'; import { renameMemberFields } from './lineup_rename.jsx'; +import { changedLineupSave } from './lineup_save.jsx'; +import { queuedNotice } from './write_result.jsx'; +import { useLineupForm, LineupSourceLine, LineupProblem, LineupDraftNotice } from './lineup_draft.jsx'; +import { useOpenedTapGuard } from './tap_guard.jsx'; +import { useDialogFocus } from './dialog_focus.jsx'; -const { useState: useStateA, useEffect: useEffectA, useRef: useRefA, useMemo: useMemoA } = React; +const { useState: useStateA, useMemo: useMemoA, useEffect: useEffectA, useRef: useRefA, useCallback: useCallbackA } = React; -// pickCopySource: pure helper that selects the most recent saved lineup -// among this team's *earlier* matches ("Copy from previous match"). -// Exported for unit testing. -// Candidate filter: this team's matches, not the current match, with a saved -// lineup, scheduled at or before the current match's time (when it has one). -// Sort order: scheduledAt DESC (nulls last: unscheduled matches treated as -// least-recent), then court ASC, then queue-position (index in allMatches) -// ASC, then matchId DESC. -export function pickCopySource(allMatches, currentMatchId, teamId, savedLineups) { - // savedLineups is a map of matchId → lineup (non-null only when a lineup - // has been saved). Candidate = match for this team, not the current match, - // with a saved lineup. - // - // teamId may be a single key or an array of keys ([id, name]). A match - // side may be keyed by the team NAME (api_serializers name-as-id fallback) - // while the team's real id is a UUID, so we match a side against ANY of - // the provided keys by either its id OR its name: comparing only one key - // space would silently find zero candidates (the original copy-from- - // previous bug). - const keys = (Array.isArray(teamId) ? teamId : [teamId]).filter(Boolean); - const sideMatches = (side) => { - if (side == null) return false; - const sid = typeof side === "object" ? (side.id ?? side.ID) : side; - const sname = typeof side === "object" ? (side.name ?? side.Name) : side; - return keys.includes(sid) || keys.includes(sname); - }; - // "Previous match": only consider siblings scheduled at or before the - // current match. When the current match has no time, don't restrict (any - // saved sibling is a valid source). An unscheduled sibling (no time) is - // always allowed: it sorts last anyway. - const current = allMatches.find(m => m.id === currentMatchId); - const currentTime = current && current.scheduledAt ? current.scheduledAt : ""; - const candidates = allMatches.filter(m => { - if (m.id === currentMatchId) return false; - if (!savedLineups[m.id]) return false; - if (!sideMatches(m.sideA) && !sideMatches(m.sideB)) return false; - if (currentTime && m.scheduledAt && m.scheduledAt > currentTime) return false; - return true; - }); - if (candidates.length === 0) return null; - candidates.sort((a, b) => { - // scheduledAt DESC; null/missing treated as "" so they sort after any real - // time string in a DESC comparison (unscheduled = least recent). - const aT = a.scheduledAt || ""; - const bT = b.scheduledAt || ""; - if (aT !== bT) return bT.localeCompare(aT); - // court ASC - const aC = a.court || ""; - const bC = b.court || ""; - if (aC !== bC) return aC.localeCompare(bC); - // queue/sequence: original index in allMatches (lower = earlier) - const aIdx = allMatches.indexOf(a); - const bIdx = allMatches.indexOf(b); - if (aIdx !== bIdx) return aIdx - bIdx; - // matchId DESC: a defensive final tiebreak. In practice distinct match - // objects always have distinct indices above, so this is effectively - // unreachable: kept only so the comparator is total. - return (b.id || "").localeCompare(a.id || ""); - }); - return candidates[0]; -} +// What an editor standing alone holds open: nothing. +const holdsNothing = () => undefined; // MatchLineupSideEditor: inline lineup editor for one team side within -// the per-match lineup panel. Handles load/save/copy-from-previous for a -// single (compId, teamId, matchId) triple. +// the per-match lineup panel, for a single (compId, teamId, matchId) triple. The +// lineup's state (read, draft, confirmed save, use-the-previous-match's-lineup) +// is useLineupForm's, shared with the Lineups page; the save body is this +// editor's own. `allMatches` is the competition's matches, or a function +// returning them (lineupSourceLabel calls it only for a carried lineup). // Reuses admin_lineup.jsx's exported helpers (positionsForSize, rosterFor, // teamIdOf) so there is no duplication of position-label / roster logic. // The helpers are read lazily on each render so module evaluation order -// does not matter (safe in test/bundler contexts too). -export function MatchLineupSideEditor({ comp, team, match, allMatches, password, showToast }) { +// does not matter (safe in test/bundler contexts too). `holdOpen` is the panel's: +// called while a write of this editor's own is out, it returns what ends the hold. +export function MatchLineupSideEditor({ comp, team, match, allMatches, password, showToast, holdOpen = holdsNothing }) { const teamSize = comp?.teamSize || 5; const { positionsForSize: lineupPositionsForSize, rosterFor: lineupRosterFor, teamIdOf: lineupTeamIdOf } = window.AdminLineupHelpers || {}; const positions = (typeof lineupPositionsForSize === "function") @@ -94,75 +45,44 @@ export function MatchLineupSideEditor({ comp, team, match, allMatches, password, : (team?.id || team?.name || ""); const compId = comp?.id || ""; const matchId = match?.id || ""; + const positionKeys = positions.map(p => p.key); - // A match "involves" this team when either side resolves to it by id OR by - // name. Match sides may be keyed by team NAME (api_serializers name-as-id - // fallback) while teamId is the participant UUID, so we compare against - // both keys: the same id-vs-name pitfall the roster resolver hits. - const teamKeys = [team?.id, team?.ID, team?.name, team?.Name].filter(Boolean); - const sideMatchesTeam = (side) => { - if (side == null) return false; - const sid = typeof side === "object" ? (side.id ?? side.ID) : side; - const sname = typeof side === "object" ? (side.name ?? side.Name) : side; - return teamKeys.includes(sid) || teamKeys.includes(sname); - }; - const matchInvolvesTeam = (mm) => sideMatchesTeam(mm.sideA) || sideMatchesTeam(mm.sideB); - - const [values, setValues] = useStateA(() => { - const init = {}; - positions.forEach(p => { init[p.key] = ""; }); - return init; + // The lineup as read and as shown, where it was saved, the unsaved-picks draft, + // and giving the match's own lineup up: useLineupForm, shared with the Lineups + // page. `values` is a name per position, `memberIds` the squad member id backing + // each one (set directly when the operator picks one of the roster's numbered + // squad entries; cleared when a position is cleared; a free name typed over it + // is resolved through the shared resolver by save()). `lineupWarning` is the + // composed operator-facing warning shown after a SUCCESSFUL save whose + // squad-member attachment fell short (see doSave): a separate channel from + // `error`, since the save did not fail. `squad` is the team's members, read by + // the hook (bc-pnum gap closure) and again when it follows a lineup another + // device saved: this panel's picker (LineupNameInput) stays typeable, unlike the + // Lineups page's strict select-by-id dropdown (AdminLineup), so doSave resolves a + // typed name to its member id against it. A squad that could not be read must + // not block loading OR saving the lineup: the resolver simply mints for every + // name it cannot find against an empty list, and bc-cse's `squadUnavailable` + // records that this happened, so doSave's warning names the real root cause + // instead of reporting every position the resolver then "failed" to match. + const form = useLineupForm({ + compId, teamId, matchId, positionKeys, password, + matchLabel: match ? scoreRowMatchLabel(match) : "", teamName: team?.name || team?.Name, }); - // bc-dnst: the squad member id backing each position, keyed the same as - // `values`. Set directly (no resolver round trip) when the operator picks - // one of the roster's numbered squad entries; cleared when a position is - // cleared or a free name is typed over it, in which case save() falls - // back to resolving that position's name through the shared resolver, as - // it always has. - const [memberIds, setMemberIds] = useStateA({}); - // memberIdsRef mirrors memberIds for commitRename below, which awaits a - // round trip before touching it while the pickers stay interactive. Same - // rule and same reason as the Lineups page's copy. - const memberIdsRef = useRefA(memberIds); - memberIdsRef.current = memberIds; + const { + values, setValues, memberIds, setMemberIds, + error, setError, warning: lineupWarning, setWarning: setLineupWarning, + squad, changeMembers, squadUnavailable, + } = form; // RENAME (bc-dnst, operator decision 2026-09-15): a member's name can be - // corrected from this panel as well as from the Lineups page (the score - // sheet stays add-only). `renamingKey` is the position whose member is - // being renamed; the row swaps its picker for a plain input while it is - // set. Typing into the picker itself stays a SUBSTITUTION over a named - // member (a different person), so a correction needs this explicit door. + // corrected from this panel as well as from the Lineups page (the score sheet + // stays add-only). `renamingKey` is the position whose member is being renamed; + // the row swaps its picker for a plain input while it is set. Typing into the + // picker itself stays a SUBSTITUTION over a named member (a different person), + // so a correction needs this explicit door. const [renamingKey, setRenamingKey] = useStateA(null); const [renamingName, setRenamingName] = useStateA(""); const [renameBusy, setRenameBusy] = useStateA(false); - const [loading, setLoading] = useStateA(true); const [saving, setSaving] = useStateA(false); - const [copying, setCopying] = useStateA(false); - const [error, setError] = useStateA(""); - // Track whether the current match's lineup was loaded from a per-match - // entry (true) or is inheriting the round default (false). - const [isMatchOverride, setIsMatchOverride] = useStateA(false); - // bc-cse gap closure: the composed operator-facing warning shown after a - // SUCCESSFUL save whose squad-member attachment fell short (see doSave - // below). Deliberately a separate channel from `error`: the save did not - // fail, so it must never look like the red error banner above it. - const [lineupWarning, setLineupWarning] = useStateA(""); - - // bc-pnum gap closure: this team's squad, loaded once so save() can - // resolve a typed/picked name to its member id (see doSave below) -- - // this panel's picker (LineupNameInput) stays typeable, unlike the - // round-scoped AdminLineup form's strict select-by-id dropdown: it now - // ALSO offers every numbered squad entry as a pickable suggestion - // (bc-dnst, see `suggestions` below), but a freely typed name is still - // accepted, so a name→id lookup is still needed on that path. Independent - // of the lineup-load effect below: a squad - // fetch failure must not block loading OR saving the lineup itself, so - // it is swallowed and the resolver (window.AdminLineupHelpers. - // resolveMemberIdsForPositions) simply mints for every name it cannot - // find against an empty list. bc-cse: `squadUnavailable` records that this - // happened, so doSave's warning names the real root cause instead of - // reporting every position the resolver then "failed" to match. - const [squad, setSquad] = useStateA([]); - const [squadUnavailable, setSquadUnavailable] = useStateA(false); // bc-pnum: the suggestion list reads the SQUAD, because that is where a // team's members now live. rosterFor reads the roster row's metadata array, @@ -243,18 +163,7 @@ export function MatchLineupSideEditor({ comp, team, match, allMatches, password, setRenameBusy(true); setError(""); try { - await window.API.renameTeamMember(compId, teamId, id, name, password); - setSquad(sq => sq.map(m => (m && m.id === id ? { ...m, name } : m))); - setValues(v => { - const next = { ...v }; - // Read the CURRENT placements, not the ones this handler closed over - // before its round trip: the pickers stay interactive while a rename - // is in flight, so a position moved meanwhile would otherwise take the - // new name while the position it moved to kept the old spelling. - const ids = memberIdsRef.current; - Object.keys(ids).forEach(key => { if (ids[key] === id) next[key] = name; }); - return next; - }); + form.memberRenamed(await window.API.renameTeamMember(compId, teamId, id, name, password)); cancelRename(); } catch (e) { setError(e?.message || "Failed to rename team member"); @@ -262,91 +171,110 @@ export function MatchLineupSideEditor({ comp, team, match, allMatches, password, setRenameBusy(false); } }; - useEffectA(() => { - let cancelled = false; - if (!compId || !teamId) return; - (async () => { - try { - const squads = await window.API.fetchSquads(compId, password); - if (cancelled) return; - setSquad((squads && squads[teamId]) || []); - setSquadUnavailable(false); - } catch (_e) { - if (!cancelled) setSquadUnavailable(true); - } - })(); - return () => { cancelled = true; }; - // `password` is a DEPENDENCY, not just a closure read, and the success - // path CLEARS squadUnavailable. Same rule as the Lineups page's copy of - // this effect, which carries the full rationale: the re-auth modal is a - // SIBLING of the admin app, so a 401 here unmounts nothing and neither - // compId nor teamId ever changes. Without both halves one 401 leaves this - // panel's pickers empty for its whole lifetime and every typed name mints - // a new member instead of resolving to the one already on the team. - }, [compId, teamId, password]); - - // Load per-match lineup on mount; record whether it was a real hit. - useEffectA(() => { - let cancelled = false; - if (!compId || !teamId || !matchId) { - setLoading(false); - return; - } - (async () => { - try { - const matchLineup = await window.API.fetchMatchLineup(compId, teamId, matchId); - if (cancelled) return; - if (matchLineup) { - const next = {}; - const nextIds = {}; - positions.forEach(p => { - next[p.key] = (matchLineup.positions || {})[p.key] || ""; - nextIds[p.key] = (matchLineup.memberIds || {})[p.key] || ""; - }); - setValues(next); - setMemberIds(nextIds); - setIsMatchOverride(true); - } else { - // No per-match entry: reflect the round default (fetch-and-show, - // but do NOT set isMatchOverride so the label says "inheriting"). - const round = window.resolveRoundIndex(match); - try { - const roundLineup = await window.API.fetchTeamLineup(compId, teamId, round); - if (cancelled) return; - if (roundLineup) { - const next = {}; - const nextIds = {}; - positions.forEach(p => { - next[p.key] = (roundLineup.positions || {})[p.key] || ""; - nextIds[p.key] = (roundLineup.memberIds || {})[p.key] || ""; - }); - setValues(next); - setMemberIds(nextIds); - } - } catch (_e) { /* no round lineup: leave blank */ } - setIsMatchOverride(false); - } - } catch (e) { - if (!cancelled) setError(e?.message || "Failed to load lineup"); - } finally { - if (!cancelled) setLoading(false); - } - })(); - return () => { cancelled = true; }; - }, [compId, teamId, matchId]); - - // knownMemberIds (bc-dnst): the subset of positionsOut whose member id is - // already known -- picked directly off the roster's numbered entries - // (see onSelect above), never resolved by name. Those positions skip the - // resolver entirely; only positions with NO known id still go through it. - const doSave = async (positionsOut, successMsg = "Match lineup saved", knownMemberIds = {}) => { + // What a Save carries is the positions the operator changed and nothing else + // (form.lineupToSave, operator decision 2026-10-07): the server puts them on the + // lineup it holds when the save arrives, so a position another device changed + // since this panel read the lineup stays. A position left alone is never + // resolved, never minted, even when it names a member this panel's list has not + // heard of (one another device created). Of the changed positions, those whose + // member id is already known (bc-dnst) -- picked directly off the roster's + // numbered entries (see onSelect above), never resolved by name -- skip the + // resolver entirely; only a changed position with NO known id goes through it, + // against the team's members as the Save's read of them left them (form.squadRef), + // not the list this closure held when Save was tapped: a typed name resolves to a + // member another device created, never mints it a second time. + const doSave = async () => { setError(""); setLineupWarning(""); setSaving(true); try { - // bc-pnum gap closure: resolve each occupied-but-unresolved position's - // name to a squad member id before writing. A name not on the squad is - // a substitute typed straight into the slot; per operator ruling, + const { positions: composed, memberIds: composedIds, changed } = form.lineupToSave(); + // The lineup as the form shows it once the typed names are resolved, vacant + // positions left out: the lineup the duplicate guard below is asked of. What is + // sent is the positions the operator changed (changedLineupSave, below). + const positionsOut = {}; + const memberIdsOut = {}; + const positionsForResolver = {}; + positions.forEach(p => { + const key = p.key; + const pickedId = composedIds[key]; + if (!changed.includes(key)) { + // A picked-but-unnamed slot is a placement too: kept with its id. + if (composed[key] || pickedId) { + positionsOut[key] = composed[key]; + if (pickedId) memberIdsOut[key] = pickedId; + } + return; + } + // Trim here too (not only at the picker's onSelect) so a Save can never + // persist leading/trailing or whitespace-only names: matches AdminLineup. + const v = (composed[key] || "").trim(); + // A picked squad entry is a real placement even when its member is + // still unnamed (bc-dnst): keep the position so the id survives, + // exactly like buildInlineLineupWrite (lineup_resolver.jsx). The + // id is KNOWN (no resolver) while the box still reads the picked + // member's own name, or nothing; a DIFFERENT name typed over the pick + // goes through the resolver with this id as the position's current + // member, so it renames that member (a blank one) rather than the + // slot seeded for the position, and never the wrong fighter. + const picked = pickedId ? squadEntries.find(e => e.id === pickedId) : null; + const unchanged = !!picked && (!v || v.toLowerCase() === picked.name.toLowerCase()); + if (pickedId && unchanged) { + positionsOut[key] = v; + memberIdsOut[key] = pickedId; + } else if (v) { + positionsOut[key] = v; + positionsForResolver[key] = v; + } + }); + // A typed name is resolved against the team's members, so they must have been + // read: against none, a new name is minted where the position's seeded slot is + // free, and the name of a member the team has is refused by the server as a + // second one. Members that cannot be read, or not in time, go without, as they + // always did, and the warning below says so. + let membersUnavailable = squadUnavailable; + if (Object.keys(positionsForResolver).length > 0) { + const waiting = form.waitForMembers(); + if (waiting && !(await waiting)) membersUnavailable = true; + } + // The member the resolver will put each typed name on, asked of the list the + // resolver is given (the team's members as the Save's read of them left them) through + // the resolver's own decision (typedNameTarget). A minted member collides with + // no member that exists. The next typed position can: the resolver goes through + // the names one after another, and a name it has just given a member is found + // again for the next position that carries it, so a new name typed at two + // positions is one member at both, which it finds out only once it has named or + // minted the first. The same walk is made here on a copy of the list, a member + // of its own standing in for a mint, and nothing is written. + const { typedNameTarget: targetOf } = window.AdminLineupHelpers || {}; + const walked = form.squadRef.current.map(m => ({ ...m })); + const placedByResolver = {}; + Object.entries(positionsForResolver).forEach(([key, name]) => { + let member = typeof targetOf === "function" ? targetOf(walked, key, name, composedIds).member : null; + if (!member) { + member = { id: `new-at-${key}` }; + walked.push(member); + } + if (!member.name) member.name = name; + placedByResolver[key] = member.id; + }); + // One position per member (the shared predicate, bc-dnst), asked of the lineup + // as the form shows it (a member another device placed meanwhile is for the + // server to refuse, naming both positions). Asked before + // the resolver as well as after it: the resolver renames a blank member or mints + // one, and nothing may be written for a Save that is then refused. The positions + // sent to the resolver are the ones whose name the operator typed, so a refusal + // names where they picked the member rather than the box they typed it into. + const typed = Object.keys(positionsForResolver); + const duplicateIn = (ids) => lineupDuplicateNote(positionsOut, ids, lineupPositionLabel, positionKeys, changed, typed); + const refusedEarly = duplicateIn({ ...memberIdsOut, ...placedByResolver }); + if (refusedEarly) { + setError(refusedEarly); + return; + } + // bc-pnum gap closure: resolve each changed, occupied-but-unresolved + // position's name to a squad member id before writing. A name not on the + // squad is a substitute typed straight into the slot; per operator ruling, // adding a new name in a position MINTS the member in that one step // (see resolveMemberIdsForPositions, admin_lineup.jsx -- the ONE // place this resolve/mint contract lives, shared with the inline @@ -357,21 +285,15 @@ export function MatchLineupSideEditor({ comp, team, match, allMatches, password, // behaves today. bc-cse: the failure is no longer discarded either -- // `memberFailures` carries it through to the warning shown below on // a successful save. - let memberIdsOut = { ...knownMemberIds }; let memberFailures = []; - const positionsForResolver = {}; - Object.keys(positionsOut).forEach(key => { - const name = (positionsOut[key] || "").trim(); - if (name && !knownMemberIds[key]) positionsForResolver[key] = name; - }); if (Object.keys(positionsForResolver).length > 0) { try { const resolver = window.AdminLineupHelpers?.resolveMemberIdsForPositions; if (typeof resolver === "function") { - const resolved = await resolver(compId, teamId, positionsForResolver, squad, password, memberIds); - memberIdsOut = { ...memberIdsOut, ...resolved.memberIds }; + const resolved = await resolver(compId, teamId, positionsForResolver, form.squadRef.current, password, composedIds); + Object.assign(memberIdsOut, resolved.memberIds); memberFailures = resolved.failures || []; - setSquad(resolved.squad); + changeMembers(resolved.squad); } } catch (_e) { // Defense in depth on top of the helper's own per-position mint @@ -379,40 +301,36 @@ export function MatchLineupSideEditor({ comp, team, match, allMatches, password, // not block the save. Proceed with the names alone. } } - // One position per member (the shared predicate, bc-dnst): a typed - // name can resolve onto a member the list never offered because it is - // already fielded elsewhere. Refuse before writing and say where. - for (const [key, id] of Object.entries(memberIdsOut)) { - const otherKey = memberPlacedElsewhere(memberIdsOut, key, id); - if (otherKey) { - const who = (positionsOut[key] || "").trim() || "This fighter"; - const where = positions.find(p => p.key === otherKey)?.label || otherKey; - setError(`${who} is already at ${where}.`); - return; - } + // Again, now that the typed names have ids: a typed name can resolve onto a + // member the list never offered because it is already fielded elsewhere. + // Refuse before writing and say where. + const duplicate = duplicateIn(memberIdsOut); + if (duplicate) { + setError(duplicate); + return; } - const hasMemberIds = Object.keys(memberIdsOut).length > 0; - const updated = await window.API.putMatchLineup( - compId, teamId, matchId, positionsOut, password, hasMemberIds ? memberIdsOut : undefined - ); + // The save names the positions the operator changed, and carries those alone: + // the server puts them on the lineup it holds when the save arrives (operator + // decision 2026-10-07), so a position left alone keeps whatever another device + // made of it, and a cleared one goes as its empty name. + const body = changedLineupSave(positionsOut, memberIdsOut, changed); + const idsOut = Object.keys(body.memberIds).length > 0 ? body.memberIds : undefined; + const updated = await window.API.putMatchLineup(compId, teamId, matchId, body.positions, password, idsOut, body.changed); // F5: a queued (offline/transient) write is NOT confirmed. Do NOT rebuild // the form from updated.positions (which is absent, would clear every // field) or show success; keep the operator's entered values and report // pending. The write is durable and will retry. if (updated && updated.queued) { - if (typeof showToast === "function") showToast("Offline: match lineup not saved yet, will retry"); + if (typeof showToast === "function") showToast(queuedNotice(updated), "pending"); return; } - // Reflect exactly what was persisted. - const next = {}; - positions.forEach(p => { next[p.key] = (updated.positions || {})[p.key] || ""; }); - setValues(next); - setMemberIds({ ...memberIdsOut, ...updated.memberIds }); - setIsMatchOverride(true); - if (typeof showToast === "function") showToast(successMsg); + // Reflect exactly what was persisted: the whole lineup the server holds now, + // the positions left alone included. + form.confirmSaved(updated); + if (typeof showToast === "function") showToast("Match lineup saved"); const composer = window.AdminLineupHelpers?.memberIdentityWarning; if (typeof composer === "function") { - setLineupWarning(composer(memberFailures, squadUnavailable)); + setLineupWarning(composer(memberFailures, membersUnavailable)); } } catch (e) { setError(e?.message || "Failed to save lineup"); @@ -422,115 +340,30 @@ export function MatchLineupSideEditor({ comp, team, match, allMatches, password, }; const save = () => { - // Strip empty positions before PUT. The handler replaces the whole - // positions map (TeamLineup{Positions: req.Positions}), and the domain - // validator treats an absent key the same as an explicit "": both - // "missing". Sending explicit empties only bloats the persisted YAML. - const positionsOut = {}; - const knownMemberIds = {}; - positions.forEach(p => { - // Trim here too (not only at the picker's onSelect) so a Save can never - // persist leading/trailing or whitespace-only names: matches AdminLineup. - const v = (values[p.key] || "").trim(); - const pickedId = memberIds[p.key]; - // A picked squad entry is a real placement even when its member is - // still unnamed (bc-dnst): keep the position so the id survives, - // exactly like buildInlineLineupWrite (lineup_resolver.jsx). The - // id is KNOWN (no resolver) while the box still reads the picked - // member's own name, or nothing; a DIFFERENT name typed over the pick - // goes through the resolver with this id as the position's current - // member, so it renames that member (a blank one) rather than the - // slot seeded for the position, and never the wrong fighter. - const picked = pickedId ? squadEntries.find(e => e.id === pickedId) : null; - const unchanged = !!picked && (!v || v.toLowerCase() === picked.name.toLowerCase()); - if (pickedId && unchanged) { - positionsOut[p.key] = v; - knownMemberIds[p.key] = pickedId; - } else if (v) { - positionsOut[p.key] = v; - } - }); - doSave(positionsOut, undefined, knownMemberIds); + // Nothing is written before the lineup was read, or while it holds no change. + if (!form.canSave) return; + doSave(); }; - const hasSiblings = allMatches.some(m => m.id !== matchId && matchInvolvesTeam(m)); - - const copyFromPrevious = async () => { - setCopying(true); - setError(""); - try { - // There is no bulk "lineup headers" endpoint, so probe each sibling match - // for this team in parallel. A null result means that sibling has no saved - // lineup; we keep the fetched lineup objects so the chosen source needs no - // second round-trip. - const siblings = allMatches.filter(m => m.id !== matchId && matchInvolvesTeam(m)); - const results = await Promise.all( - siblings.map(s => - window.API.fetchMatchLineup(compId, teamId, s.id) - .then(l => ({ matchId: s.id, lineup: l })) - .catch(() => ({ matchId: s.id, lineup: null })) - ) - ); - const savedLineups = {}; - results.forEach(({ matchId: mid, lineup }) => { if (lineup) savedLineups[mid] = lineup; }); - - const source = pickCopySource(allMatches, matchId, [teamId, ...teamKeys], savedLineups); - if (!source) { - setError("No previous match found to copy from."); - return; - } - const sourceLineup = savedLineups[source.id]; - if (!sourceLineup) { - setError("Previous lineup is empty or unavailable."); - return; - } - // Strip vacant positions (see save()): copy only the slots that are - // set, where a slot fielded by number alone (an id with no name yet, - // bc-dnst) IS set and travels with its id, so the copy is by identity - // and never re-resolves a name the source had already pinned. - const next = {}; - const known = {}; - positions.forEach(p => { - const v = (sourceLineup.positions || {})[p.key] || ""; - const id = (sourceLineup.memberIds || {})[p.key] || ""; - if (v || id) { next[p.key] = v; if (id) known[p.key] = id; } - }); - - // doSave applies the copied values from the persisted server response on - // success, so we deliberately do NOT setValues eagerly here: a failed - // save must not leave unpersisted copied values on screen. - await doSave(next, "Lineup copied from previous match", known); - } catch (e) { - setError(e?.message || "Failed to copy lineup"); - } finally { - setCopying(false); - } - }; + const busy = saving || form.removing; + // The boxes are for a lineup that was read: until then Save is off too, and the + // problem line below says why. + const locked = busy || !form.read; + // A write of this editor's own that is out keeps Escape from closing the panel. + const writing = busy || renameBusy; + useEffectA(() => (writing ? holdOpen() : undefined), [writing]); - if (loading) return
Loading lineup…
; + if (form.loading) return
Loading lineup…
; const teamName = team?.name || team?.Name || "Team"; return (
-
- {teamName} - {isMatchOverride - ? Override for this match - : Inheriting round default - } - -
+
{teamName}
+ + + + {error && (
@@ -548,7 +381,10 @@ export function MatchLineupSideEditor({ comp, team, match, allMatches, password,
{positions.map(p => { const pickedLabel = pickedLabelFor(p.key); return ( -
{teamB ? ( ) : (
Team not found in roster.
@@ -709,13 +576,14 @@ export function MatchLineupPanel({ match, tournament, password, showToast, onClo
{teamA ? ( ) : (
Team not found in roster.
@@ -728,17 +596,17 @@ export function MatchLineupPanel({ match, tournament, password, showToast, onClo // Inline (mp-c2yr): render in-flow inside the operator console's main // column: no fixed overlay, no backdrop. The shiaijo page owns the // surrounding card; here we just provide padding + scroll. - if (variant === "inline") { + if (!overlay) { return
{inner}
; } return ( -
-
`${m.compId}:${m.id}`; const openMatch = openKey ? allMatches.find((m) => scoreKeyOf(m) === openKey) || null : null; + const lineupMatch = lineupKey ? allMatches.find((m) => scoreKeyOf(m) === lineupKey) || null : null; const f = filter.trim().toLowerCase(); const filtered = allMatches.filter((m) => { @@ -318,7 +322,7 @@ export function AdminScoreEditor({ t, c, onEditScore, onMoveCourt, restrictToCom @@ -346,7 +350,7 @@ export function AdminScoreEditor({ t, c, onEditScore, onMoveCourt, restrictToCom tournament={tournament} password={password} showToast={showToast} - onClose={() => setLineupMatch(null)} + onClose={() => setLineupKey(null)} /> )} diff --git a/web-mobile/js/admin_scoring_autosave.jsx b/web-mobile/js/admin_scoring_autosave.jsx index f3096b521..9a497be10 100644 --- a/web-mobile/js/admin_scoring_autosave.jsx +++ b/web-mobile/js/admin_scoring_autosave.jsx @@ -3,7 +3,7 @@ // Extracted from admin_scoring_modal.jsx (mp-zac3). import { toBackendMatchResult, matchWire } from './api_serializers.jsx'; -import { changedGroups, statedGroupsOf, groupMatches, keptInHistoryNote } from './match_groups.jsx'; +import { changedGroups, statedGroupsOf, groupMatches, keptInHistoryNote, GROUP_RESULT } from './match_groups.jsx'; import { writeDidNotLand } from './write_result.jsx'; const { useState: useStateA, useEffect: useEffectA, useRef: useRefA } = React; @@ -40,10 +40,22 @@ export function useChangedGroups(match) { matchRef.current = match; const stateRef = useRefA(null); const keyOf = (m) => `${(m && m.compId) || ""}\u0000${(m && m.id) || ""}`; + const finished = !!(match && match.status === "completed"); // The editor moving to another match starts over from that match. if (!stateRef.current || stateRef.current.key !== keyOf(match)) { - stateRef.current = { key: keyOf(match), seed: matchWire(match), agreed: {}, last: null }; + stateRef.current = { key: keyOf(match), seed: matchWire(match), agreed: {}, last: null, finished }; } + // A reopen is a write the server makes, not one the editor builds, so no build + // moves the result the editor agrees with off the finished one. Once the server + // no longer holds the match finished the editor agrees with the result as it now + // stands, so ending it again with the same verdict still says the result changed. + // That is the status leaving "completed", not the reopenPending stamp: a reopen + // without a reason carries the stamp, but one a correction's reason comes with (a + // downstream reopen, a requalification, a pool-rank override) leaves the match + // scheduled with its points and no stamp, and an editor that only watched the stamp + // named no result for it: the server kept the match as it was, with no error. + if (!finished && stateRef.current.finished) stateRef.current.agreed[GROUP_RESULT] = matchWire(match); + stateRef.current.finished = finished; return (patch) => { if (!patch) return patch; const m = matchRef.current; diff --git a/web-mobile/js/admin_scoring_engi.jsx b/web-mobile/js/admin_scoring_engi.jsx index 67f5423eb..ac110dbb1 100644 --- a/web-mobile/js/admin_scoring_engi.jsx +++ b/web-mobile/js/admin_scoring_engi.jsx @@ -28,7 +28,7 @@ import { useEscapeToClose, confirmDialog } from './ui.jsx'; // NumberedName: single owner of the number-chip-on-the-outer-side rule. import { NumberedName } from './numbered_name.jsx'; import { SideCell } from './side_cell.jsx'; -import { useArmedConfirm } from './tap_guard.jsx'; +import { useArmedConfirm, useOpenedTapGuard } from './tap_guard.jsx'; import { terminalFailureBanner, notSavedText, writeWasRefused, writeRetryable } from './write_result.jsx'; const MAX_FLAGS = 5; @@ -200,6 +200,8 @@ export function EngiScoreEditorModal({ match, onClose, onSubmit, onSubmitAndNext }; const handleDismiss = () => leaveEditor(onClose); useEscapeToClose(canClose ? handleDismiss : undefined); + // bc-cfbd: the bounce of the tap that opened the overlay must not dismiss it. + const { openedRef, onClickCapture } = useOpenedTapGuard({ backdropOnly: true }); const goPrev = () => leaveEditor(onPrev); const goNext = () => leaveEditor(onNext); @@ -624,7 +626,7 @@ export function EngiScoreEditorModal({ match, onClose, onSubmit, onSubmitAndNext } return ( -
+
e.stopPropagation()} data-testid="engi-score-editor"> {inner}
diff --git a/web-mobile/js/admin_scoring_individual.jsx b/web-mobile/js/admin_scoring_individual.jsx index 8a2b5307f..0d1ddf241 100644 --- a/web-mobile/js/admin_scoring_individual.jsx +++ b/web-mobile/js/admin_scoring_individual.jsx @@ -16,7 +16,7 @@ import { sameCompetitor } from './competitor_identity.jsx'; // its host and by unit tests that never load api_client, and write_result.jsx // is import-only so it can be reached directly (see its header). import { notLandedBanner, terminalFailureBanner, notSavedText, writeWasRefused, writeRetryable, decisionWord } from './write_result.jsx'; -import { useArmedConfirm, acceptTap, clearTap } from './tap_guard.jsx'; +import { useArmedConfirm, useOpenedTapGuard, acceptTap, clearTap } from './tap_guard.jsx'; import { MAX_IPPONS_PER_SIDE, @@ -885,6 +885,8 @@ export function ScoreEditorModal({ match, onClose, onSubmit, onSubmitAndNext, on // bc-dscn: a host that cannot close (the inline court console) has nothing // to discard INTO, so it never prompts either. const handleDismiss = () => (canClose ? leaveEditor(onClose) : undefined); + // bc-cfbd: the bounce of the tap that opened the overlay must not dismiss it. + const { openedRef, onClickCapture } = useOpenedTapGuard({ backdropOnly: true }); const goPrev = () => leaveEditor(onPrev); const goNext = () => leaveEditor(onNext); @@ -1503,7 +1505,7 @@ export function ScoreEditorModal({ match, onClose, onSubmit, onSubmitAndNext, on } return ( -
+
e.stopPropagation()}> {inner}
diff --git a/web-mobile/js/admin_scoring_shared.jsx b/web-mobile/js/admin_scoring_shared.jsx index d26aeccb6..9e7e9402c 100644 --- a/web-mobile/js/admin_scoring_shared.jsx +++ b/web-mobile/js/admin_scoring_shared.jsx @@ -3,7 +3,7 @@ // out so the foundation can be reused and the modal file stays focused on the // two stateful editors. See web-mobile/admin_split_plan.md. -const { useState: useStateA, useEffect: useEffectA, useRef: useRefA, useMemo: useMemoA } = React; +const { useState: useStateA, useEffect: useEffectA, useRef: useRefA, useMemo: useMemoA, useLayoutEffect: useLayoutEffectA } = React; const Icon = window.Icon; import { DAIHYOSEN_POSITION, scoreRowMatchLabel } from './pool_ids.jsx'; @@ -950,11 +950,108 @@ function FoulCounter({ fouls, setFouls, onIncrement, color, disabled }) { // `onListPick` is told of a pick made by tapping a row of the open list (an // option or "+ Add"), never of a typed commit, Enter, the clear button or a // click outside: only that pick closes the list under the finger. -function LineupNameInput({ value, roster, onSelect, onListPick, disabled, ariaLabel, color, clearable }) { +// The name list is absolutely placed under its input. It opens down when the room +// below fits the list, else toward the larger room, and caps its height to that +// room. The room is bounded by the viewport (the visual one, which the iPad +// keyboard shrinks), by the team sheet's pinned header (top) and footer (bottom: +// the inline dock, the overlay's foot), and by the nearest ancestor that cuts off +// what overflows it (the overlay's scroll body, the inline panel's clip, the +// at-court panel's scroll box): a list taller than that room would be cut off or +// stretch the ancestor's scroll. A host with none of these (the Lineups page) +// uses the viewport edges. (bc-tmfd) +const LINEUP_LIST_MAX_H = 240; +const LINEUP_LIST_GAP = 8; +const LINEUP_LIST_MIN_H = 72; + +// Whether an element cuts off what overflows it vertically: hidden, auto, scroll +// or clip on the vertical axis, or hidden/auto/scroll on the horizontal one, +// which makes the vertical axis cut off too. +function clipsVertically(el) { + const { overflowX, overflowY } = getComputedStyle(el); + const cuts = (v) => v === "hidden" || v === "auto" || v === "scroll"; + return cuts(overflowY) || overflowY === "clip" || cuts(overflowX); +} + +// The nearest ancestor that clips. The page itself is no ancestor of this kind: +// the viewport edges stand for it (and a dialog locks the body's scroll). +function clippingAncestor(el) { + for (let a = el.parentElement; a && a !== document.body && a !== document.documentElement; a = a.parentElement) { + if (clipsVertically(a)) return a; + } + return null; +} + +// The elements that bound a name list's room, found once when the list opens: the +// page scrolling and the keyboard rising move the input, never these. +function lineupListEdges(wrapper) { + const scope = wrapper.closest(".scoring-panel, .editor-modal"); + return { + pin: scope && scope.querySelector(".team-sheet-pin"), + foot: scope && scope.querySelector(".editor-modal__foot--nav"), + clip: clippingAncestor(wrapper), + }; +} + +function lineupListPlacement({ pin, foot, clip }, bar) { + // The visual viewport shrinks under the iPad keyboard where innerHeight does not. + const vv = window.visualViewport; + const viewTop = vv ? vv.offsetTop : 0; + const viewH = vv ? vv.offsetTop + vv.height : window.innerHeight; + const clipRect = clip ? clip.getBoundingClientRect() : null; + const topEdge = Math.max(viewTop, pin ? pin.getBoundingClientRect().bottom : 0, clipRect ? clipRect.top : 0, 0); + const r = bar.getBoundingClientRect(); + // The footer is the bottom edge wherever it sits below the input: the inline + // dock is sticky and the overlay's is always visible under the scroll body. + const footTop = foot ? foot.getBoundingClientRect().top : viewH; + const dockEdge = foot && footTop >= r.bottom ? Math.min(viewH, footTop) : viewH; + const bottomEdge = clipRect ? Math.min(dockEdge, clipRect.bottom) : dockEdge; + const roomBelow = bottomEdge - r.bottom; + const roomAbove = r.top - topEdge; + const up = roomBelow < LINEUP_LIST_MAX_H && roomAbove > roomBelow; + const room = (up ? roomAbove : roomBelow) - LINEUP_LIST_GAP; + const maxHeight = room < LINEUP_LIST_MAX_H ? Math.max(LINEUP_LIST_MIN_H, Math.floor(room)) : undefined; + return { up, maxHeight }; +} + +function LineupNameInput({ value, roster, onSelect, onListPick, disabled, ariaLabel, color, clearable, inputId }) { const [query, setQuery] = useStateA(""); const [open, setOpen] = useStateA(false); const [active, setActive] = useStateA(-1); // -1 = no explicit selection yet const ref = useRefA(null); + const barRef = useRefA(null); + const listRef = useRefA(null); + const [placement, setPlacement] = useStateA({ up: false, maxHeight: undefined }); + // Measured on open, then again whenever the page scrolls or the viewport + // changes (the keyboard rising), one frame at a time. What bounds the room (the + // pinned bars, the clipping ancestor) is found once, on open. + useLayoutEffectA(() => { + if (!open || !ref.current) return; + const edges = lineupListEdges(ref.current); + let frame = 0; + const measure = () => { + frame = 0; + if (!ref.current || !barRef.current) return; + const next = lineupListPlacement(edges, barRef.current); + setPlacement(p => (p.up === next.up && p.maxHeight === next.maxHeight ? p : next)); + }; + const schedule = (e) => { + // The list scrolling itself moves nothing it is placed against. + const target = e && e.target; + if (target instanceof Node && listRef.current && listRef.current.contains(target)) return; + if (!frame) frame = requestAnimationFrame(measure); + }; + measure(); + const vv = window.visualViewport; + window.addEventListener("scroll", schedule, { capture: true, passive: true }); + window.addEventListener("resize", schedule); + if (vv) { vv.addEventListener("resize", schedule); vv.addEventListener("scroll", schedule); } + return () => { + window.removeEventListener("scroll", schedule, { capture: true }); + window.removeEventListener("resize", schedule); + if (vv) { vv.removeEventListener("resize", schedule); vv.removeEventListener("scroll", schedule); } + if (frame) cancelAnimationFrame(frame); + }; + }, [open]); // Guards against double-commit when click-outside fires first and the blur // event arrives immediately after (mousedown precedes blur in browser order). const skipBlurRef = useRefA(false); @@ -1057,13 +1154,24 @@ function LineupNameInput({ value, roster, onSelect, onListPick, disabled, ariaLa if (active >= 0 && active < matches.length) commitEntry(matches[active]); else if (active === matches.length && canAddNew) commit(q); else if (q) commit(q); - } else if (e.key === "Escape") { e.preventDefault(); setOpen(false); setQuery(""); } + } else if (e.key === "Escape") { + // One Escape closes one layer: a DRAWN list takes it, and keeps it from the + // overlay score editor's own Escape (a window listener that closes the + // editor). No list drawn (it is drawn only with an option to show: a closed + // box, or an open one with nothing to offer) leaves it to the editor. + if (!open || optionCount === 0) return; + e.preventDefault(); + e.stopPropagation(); + setOpen(false); + setQuery(""); + } }; return (
-
+
{open && optionCount > 0 && ( -
+
{matches.map((entry, i) => (
); + // A role="button" has presentational children, so a notice inside the row + // would not be announced: it follows the row instead. + return ( + + {row} + {renderLineupNotice(lineupNoticeKey("b", idx))} + {renderLineupNotice(lineupNoticeKey("a", idx))} + + ); }; // opts.kachinukiBoutFinal: attach the transient bout-final flag ONLY for @@ -3144,6 +3532,8 @@ export function TeamScoreEditorModal({ match, teamSize, onClose, onSubmit, onSub // bc-dscn: a host that cannot close (the inline court console) has nothing // to discard INTO, so it never prompts either. const handleDismiss = () => (canClose ? leaveEditor(onClose) : undefined); + // bc-cfbd: the bounce of the tap that opened the overlay must not dismiss it. + const { openedRef, onClickCapture } = useOpenedTapGuard({ backdropOnly: true }); const goPrev = () => leaveEditor(onPrev); const goNext = () => leaveEditor(onNext); @@ -3197,6 +3587,26 @@ export function TeamScoreEditorModal({ match, teamSize, onClose, onSubmit, onSub // individual ScoreEditorModal). const dialogLabel = `Team score editor: ${m.sideB?.name || m.sideB || "Shiro"} vs ${m.sideA?.name || m.sideA || "Aka"}${m.court ? ` · Shiaijo ${m.court}` : ""}`; + // The bars the sheet pins cover whatever the page scrolls under them: the team + // header and result band at the top (.team-sheet-pin) and, on the inline hosts, + // the footer dock at the bottom. A control a scroll brings only just into view + // (a keyboard Tab onto it, scrollIntoView) would stop under one, so their + // heights are published (published_height.jsx) for the scroll margin the + // stylesheet gives the sheet's content, on the element whose scroll they + // affect. The inline hosts scroll the document under both bars. An overlay + // scrolls its body under the header alone: its footer sits outside the + // scrolling body and covers nothing. + const bodyRef = useRefA(null); + const pinRef = useRefA(null); + const dockRef = useRefA(null); + const isInline = variant === "inline"; + useLayoutEffectA(() => { + const host = isInline ? document.documentElement : bodyRef.current; + const stops = [publishHeight(pinRef.current, host, "--team-pin-h")]; + if (isInline) stops.push(publishHeight(dockRef.current, host, "--team-dock-h")); + return () => stops.forEach((stop) => stop()); + }, []); + const inner = ( <>
@@ -3227,12 +3637,15 @@ export function TeamScoreEditorModal({ match, teamSize, onClose, onSubmit, onSub
-
+
{/* Inside `inner`, so the wide overlay and the narrow shiaijo inline panel get it from ONE placement and cannot diverge. */} {matchDataUnreadable(m) ? : null} - {/* Team header */} -
+ {/* Team header and result band, pinned together (bc-tmfd): the + running total and the match identity stay in view while the + bout rows scroll under them. */} +
+
{teamSides.map((s, idx) => (
@@ -3265,25 +3678,100 @@ export function TeamScoreEditorModal({ match, teamSize, onClose, onSubmit, onSub ))}
+ {/* mp-gmcg band redesign (user-confirmed brief): in kachinuki the + centre cell carries BOUT-LOG FACTS while running (bout number + + last bout result; never a verdict: the old IV/PW-derived + "AKA WIN" contradicted the End gate) and the verdict only on + completion, derived from the match winner. IV/PW demote to the + side cells (they still feed standings tie-breaks). Non-kachinuki + team matches keep RESULT + teamVerdictText: fixed formats ARE + decided by IV/PW. The running kachinuki scorer drops the band: + its fought bouts render as read-only rows (renderReadOnlyBout). + Totals are the editor's LOCAL tally, never teamIVPWFrom. */} + {!kachinukiBoutMode && (() => { + const kb = isKachinuki && !hasDaihyosen + ? kachinukiBandModel({ + subs, daihyosenIdx, isComplete, + namesAt: playerNamesForBout, + // RAW winner/sides (not pre-flattened to names): winnerSideLR + // inside kachinukiBandModel needs the {id,name} shape to prefer + // id equality over name (review: two teams CAN share a name). + matchWinner: m.winner, + matchDecision: m.decision, + sideA: m.sideA, sideB: m.sideB, + currentBout: parseInt(visiblePositions[visiblePositions.length - 1], 10) || undefined, + }) + : null; + return ( +
+ {teamSides.map((ts, idx) => ( + + {/* idx 0 = SHIRO (left, default left-align); idx 1 = AKA, which + sits in the right 1fr grid cell and must right-align to mirror + SHIRO. */} +
+
{ts.label}
+
IV: {ts.iv} · PW: {ts.pw}
+
+ {idx === 0 && ( +
+ {kb ? ( + <> +
{kb.headline}
+ {kb.verdict + ?
{kb.verdict}
+ : (kb.fact ?
{kb.fact}
: null)} + + ) : ( + <> +
RESULT
+
{teamVerdictText}
+ + )} + {/* bc-kcsh: the recorded decision under the verdict + while a withdrawal is in force (see + teamVerdictText). */} + {rulingShown && ( +
{withdrawalLabel(m.decision)}
+ )} +
+ )} +
+ ))} +
+ ); + })()} +
{/* One clear-a-mark hint for the whole encounter, after the team names and before the first bout (operator ruling 2026-09-16, bc-dnst). The team sheet repeats a row per bout, so the line that sits under the marks on the individual board would be noise here; it is stated once for the sheet instead. Same words and same .sb-hint owner as that board, and shown on the - same condition: only while there is a mark to clear. */} - {anyMarkScored && ( -
Tap a scored mark to clear it
- )} + same condition: only while there is a mark to clear. Otherwise it + is held, not removed (operator ruling 2026-10-06, "Hold its + space"): .holds-space keeps its box and takes it out of the + accessibility tree, so the bout rows below never move when it + comes and goes (in kachinuki it goes at every Record bout and + returns with the next bout's first point, which shifted them + 28.5 px under a quick second tap). */} +
+ Tap a scored mark to clear it +
{/* Individual match rows. T136: in kachinuki mode only the current bout is rendered (see visiblePositions / kachinukiVisiblePositions above). The server appends new bouts via engine.MaybeAdvanceKachinuki after each score record, so the operator re-opens the modal to score the next bout. - The .team-bouts-scroll wrapper gives the roomy (non-compact) - layout an independent scroll region for the bout list so the - team header / summary / decision / footer stay anchored. */} + The .team-bouts-scroll wrapper scopes the bout rows' layout + rules and is not a scroll region: the rows scroll with the + body, under the team header and result band (.team-sheet-pin) + and above the footer, which are pinned in every host and both + layouts. */}
{[ // mp-gmcg: operator-led completion. The banner reads "ended" @@ -3369,8 +3857,6 @@ export function TeamScoreEditorModal({ match, teamSize, onClose, onSubmit, onSub // not in the position column. Compute the per-side name props here // so they can ride on the rowSides entries below. const lineupPosKey = posKey5 || posKeyN; - const teamIdB = teamIdForSide(m.sideB); // SHIRO = left - const teamIdA = teamIdForSide(m.sideA); // AKA = right // A fixed-order lineup fields each fighter once, so a member already // placed at ANOTHER position of this side's lineup is not offered // for this row (bc-dnst click test: the list offered a fighter twice @@ -3395,12 +3881,16 @@ export function TeamScoreEditorModal({ match, teamSize, onClose, onSubmit, onSub ); const rosterB = withoutPlacedElsewhere(rosterForSide(m.sideB, lineupB, squadB), lineupB); const rosterA = withoutPlacedElsewhere(rosterForSide(m.sideA, lineupA, squadA), lineupA); + // Where this row's pick on each side files its notice, and where the + // row looks for it (rowSides below). + const noticeKeyB = lineupNoticeKey("b", idx); + const noticeKeyA = lineupNoticeKey("a", idx); // member (LineupNameInput's second onSelect argument) is the // picked squad-member object when the operator chose one of the // row's numbered entries; it is undefined for a typed/"+ Add" // name, exactly like buildInlineLineupWrite's own optional arg. - const pickPlayer = (teamId, lineup, squad, setSquad) => (value, member) => { - submitInlineLineup(teamId, lineup, squad, setSquad, lineupPosKey, value, member); + const pickPlayer = (side, noticeKey) => (value, member) => { + submitInlineLineup({ side, noticeKey, posKey: lineupPosKey, value, member }); }; // mp-gmcg: a manually-added bout has no lineup key (positions // beyond teamSize are not valid lineup keys) and no server @@ -3422,14 +3912,33 @@ export function TeamScoreEditorModal({ match, teamSize, onClose, onSubmit, onSub // hoisted out of this row closure; this factory just binds it to // the row's own sub/index and this side's key, mirroring // pickPlayer above. - const pickManual = (sideKey, memberIdKey, squad, setSquad, teamId) => (value, member) => - pickManualBoutName({ - sub: s, idx, sideKey, memberIdKey, squad, setSquad, teamId, + const pickManual = (side, noticeKey, squad, teamId) => (value, member) => { + setLineupNotice(null); + // A rename waits on the server, and the side can be given another team + // meanwhile: the member it names is then the old team's, which is not put in + // the new team's list nor reported in its row (submitInlineLineup's rule). + const sideChanged = watchLineupSide(side); + const sideChangedNotice = () => { + if (!sideChanged()) return false; + setLineupNotice({ key: noticeKey, tone: "error", text: SIDE_TEAM_CHANGED_NOTICE }); + return true; + }; + return pickManualBoutName({ + sub: s, idx, sideKey: `${side}Name`, memberIdKey: `${side}MemberIdOverride`, squad, teamId, compId: m.compId, password, updateSub, + onRenamed: (written) => { if (!sideChangedNotice()) wroteSideMembers(side, written); }, // A self-run competitor cannot open the Lineups page, so they are // pointed at the organizer, as the server's own refusals do. - onRenameFailed: (typed, e) => setEditorWarning(`"${typed}" was used for this bout, but the team member could not be renamed. ${memberRefusalNote({ code: e && e.code, reason: e && e.message }, selfReport ? "Ask the tournament organizer to rename them." : "Rename them on the Lineups page.")}`), + onRenameFailed: (typed, e) => { + if (sideChangedNotice()) return; + setLineupNotice({ + key: noticeKey, + tone: "warn", + text: `"${typed}" was used for this bout, but the team member could not be renamed. ${memberRefusalNote({ code: e && e.code, reason: e && e.message }, selfReport ? "Ask the tournament organizer to rename them." : "Rename them on the Lineups page.")}`, + }); + }, }, value, member); + }; // mp-gmcg: a kachinuki side with NO resolved name AND no lineup // route gets a free-typed name input riding the sub (like a // manual row). This covers the one-sided WALKOVER SLOT the engine @@ -3511,8 +4020,9 @@ export function TeamScoreEditorModal({ match, teamSize, onClose, onSubmit, onSub playerName: resolveBoutSideDisplayName({ squad: squadB, memberId: playerBMemberId, storedName: playerBName }), memberId: playerBMemberId, memberLabel: playerBLabel, roster: isDaihyoRow ? [] : rosterB, forceInput: manualPathB, + noticeKey: noticeKeyB, lineupSlot: !isDaihyoRow && idx + 1 <= teamSize, - onSelectName: manualPathB ? pickManual("bName", "bMemberIdOverride", squadB, setSquadB, teamIdB) : pickPlayer(teamIdB, lineupB, squadB, setSquadB), + onSelectName: manualPathB ? pickManual("b", noticeKeyB, squadB, teamBId) : pickPlayer("b", noticeKeyB), }, { key: "a", tapKey: `${idx}:a`, pts: s.aPts, fouls: s.aFouls, @@ -3528,8 +4038,9 @@ export function TeamScoreEditorModal({ match, teamSize, onClose, onSubmit, onSub playerName: resolveBoutSideDisplayName({ squad: squadA, memberId: playerAMemberId, storedName: playerAName }), memberId: playerAMemberId, memberLabel: playerALabel, roster: isDaihyoRow ? [] : rosterA, forceInput: manualPathA, + noticeKey: noticeKeyA, lineupSlot: !isDaihyoRow && idx + 1 <= teamSize, - onSelectName: manualPathA ? pickManual("aName", "aMemberIdOverride", squadA, setSquadA, teamIdA) : pickPlayer(teamIdA, lineupA, squadA, setSquadA), + onSelectName: manualPathA ? pickManual("a", noticeKeyA, squadA, teamAId) : pickPlayer("a", noticeKeyA), }, ]; @@ -3604,8 +4115,12 @@ export function TeamScoreEditorModal({ match, teamSize, onClose, onSubmit, onSub corrected past bout carries it; the live current bout does not (it is never collapsible). */} {idx === editingDoneBoutIdx && ( - + )} {/* Bout number only: the FIK position abbreviations (Sen/Ji/Chu/Fuk/Tai) that used to ride under it were @@ -3675,6 +4190,7 @@ export function TeamScoreEditorModal({ match, teamSize, onClose, onSubmit, onSub : - )}
+ {renderLineupNotice(rs.noticeKey)} {/* Row 1: the ippon mark buttons and the per-bout Fusensho button (layout: the .tsm-row-1 compact rules in styles.css). T096/FR-031: Fusensho awards the bout @@ -3840,77 +4356,6 @@ export function TeamScoreEditorModal({ match, teamSize, onClose, onSubmit, onSub
)} - {/* Team summary: T138: sticky to the top of the modal body so - the totals stay visible as the operator scrolls through many - bout rows. zIndex: 5 keeps it under the modal head (10) but - above the bout cells. - mp-gmcg band redesign (user-confirmed brief): in kachinuki the - centre cell carries BOUT-LOG FACTS while running (bout number + - last bout result; never a verdict — the old IV/PW-derived - "AKA WIN" contradicted the End gate) and the verdict only on - completion, derived from the match winner. IV/PW demote to the - side cells (they still feed standings tie-breaks). Non-kachinuki - team matches keep RESULT + teamVerdictText: fixed formats ARE - decided by IV/PW. */} - {/* mp-gmcg: in the RUNNING kachinuki scorer the fought bouts render as - read-only rows above (renderReadOnlyBout), so the IV/PW summary band - is redundant and is dropped. Completed kachinuki (correction verdict) - and fixed-format team matches keep it. */} - {!kachinukiBoutMode && (() => { - const kb = isKachinuki && !hasDaihyosen - ? kachinukiBandModel({ - subs, daihyosenIdx, isComplete, - namesAt: playerNamesForBout, - // RAW winner/sides (not pre-flattened to names): winnerSideLR - // inside kachinukiBandModel needs the {id,name} shape to prefer - // id equality over name (review: two teams CAN share a name). - matchWinner: m.winner, - matchDecision: m.decision, - sideA: m.sideA, sideB: m.sideB, - currentBout: parseInt(visiblePositions[visiblePositions.length - 1], 10) || undefined, - }) - : null; - return ( -
- {teamSides.map((ts, idx) => ( - - {/* idx 0 = SHIRO (left, default left-align); idx 1 = AKA, which - sits in the right 1fr grid cell and must right-align to mirror - SHIRO — the --right class existed but was never wired up, so - AKA's IV/PW floated mid-panel. */} -
-
{ts.label}
-
IV: {ts.iv} · PW: {ts.pw}
-
- {idx === 0 && ( -
- {kb ? ( - <> -
{kb.headline}
- {kb.verdict - ?
{kb.verdict}
- : (kb.fact ?
{kb.fact}
: null)} - - ) : ( - <> -
RESULT
-
{teamVerdictText}
- - )} - {/* bc-kcsh: the recorded decision under the verdict - while a withdrawal is in force (see - teamVerdictText). */} - {rulingShown && ( -
{withdrawalLabel(m.decision)}
- )} -
- )} -
- ))} -
- ); - })()} - {/* mp-4pc: hantei affordance for the daihyosen: the rep bout is the only team sub-bout that may be decided by judges (FIK 7-5 / 29-6). Encho is optional: a tied daihyosen may be taken straight @@ -4043,26 +4488,16 @@ export function TeamScoreEditorModal({ match, teamSize, onClose, onSubmit, onSub ); })()} - {/* The modal's ONE inline error surface: the daihyosen add/remove - POSTs AND the inline lineup-position PUT all report here. It - used to live INSIDE the add-daihyosen block above, which returns - null for kachinuki / pool / already-has-a-daihyosen matches — so - a failed lineup save (reachable exactly in the kachinuki flow) - set a message the operator never saw. Rendered here, a sibling - of decisionErr, it shows wherever it is set. */} + {/* The daihyosen add/remove POSTs report here. It used to live + INSIDE the add-daihyosen block above, which returns null for + kachinuki / pool / already-has-a-daihyosen matches, so a failure + there set a message the operator never saw. Rendered here, a + sibling of decisionErr, it shows wherever it is set. A bout + row's name box reports in its own row instead (lineupNotice). */} {editorErr && (
{editorErr}
)} - {/* Non-blocking: the inline lineup save above already succeeded. - Amber .alert--warn, a separate channel from editorErr, so it - can never be mistaken for that failed-save message. */} - {editorWarning && ( -
- {editorWarning} -
- )} - {/* Ippon-type letter legend: same affordance as the individual editor; the per-bout buttons use the same M/K/D/T/H letters. */} @@ -4173,7 +4608,7 @@ export function TeamScoreEditorModal({ match, teamSize, onClose, onSubmit, onSub
-
+
{/* Audit reason prompt for team-match corrections: same contract as ScoreEditorModal: operator must confirm before the patch fires. */} {correctionPromptOpen && ( @@ -4410,30 +4845,38 @@ export function TeamScoreEditorModal({ match, teamSize, onClose, onSubmit, onSub bout End would judge is still the CURRENT editable bout (kachinukiEnchoOffered): once Record has advanced the encounter, overtime on the past bout is meaningless and the - button would silently edit a read-only row (mp-gmcg review). */} - {kachinukiEnchoOffered && ( - - )} - {kachinukiEnchoUndoable && ( - + button would silently edit a read-only row (mp-gmcg review). + While either of Encho and Undo encho is offered both are in the + row, in this order, and the other is HELD (.holds-space) in its + place (operator ruling 2026-10-06, "Keep Encho in place"): the + row is centred, so a button added or removed beside Encho moved + it (55 px left when Undo encho appeared), and a double tap on + Encho landed on Undo encho and cancelled itself. A held button + has the same label, so the same width, and is disabled besides: + out of the tab order and the pointer, and no tap reaches it. */} + {(kachinukiEnchoOffered || kachinukiEnchoUndoable) && ( + <> + + + )} {isTeamMatch(upNext) && ( - @@ -1607,7 +1631,7 @@ function AdminShiaijoPage({ tournament, court: routeCourt, onBack, onEditScore, setLineupKey(matchKey(m))} onPick={pickMatch} onCall={callToCourt} callingKey={callingKey} calledKey={calledKey} startingKey={startingKey} scheduled={filteredScheduled} @@ -1874,8 +1898,9 @@ function AdminShiaijoPage({ tournament, court: routeCourt, onBack, onEditScore,
{pendingMove && ( -
!movingCourt && setPendingMove(null)}> -
!movingCourt && setPendingMove(null)}> +
e.stopPropagation()}>

Move to Shiaijo {pendingMove.to}? @@ -1897,8 +1922,9 @@ function AdminShiaijoPage({ tournament, court: routeCourt, onBack, onEditScore, )} {pendingRevert && ( -
!reverting && setPendingRevert(null)}> -
!reverting && setPendingRevert(null)}> +
e.stopPropagation()}>

Send back to queue? @@ -1930,7 +1956,7 @@ function AdminShiaijoPage({ tournament, court: routeCourt, onBack, onEditScore, tournament={{ competitions: courtCompetitions }} password={password} showToast={typeof showToast === "function" ? showToast : undefined} - onClose={() => setLineupMatch(null)} + onClose={() => setLineupKey(null)} /> )} {resolveMatch && Modal && ( diff --git a/web-mobile/js/api_client.jsx b/web-mobile/js/api_client.jsx index d8783950a..d69954882 100644 --- a/web-mobile/js/api_client.jsx +++ b/web-mobile/js/api_client.jsx @@ -36,6 +36,7 @@ import { normalizeCompetitionDetail, normalizePlayer, toBackendMatchResult, buildPlayerMetadata } from './api_serializers.jsx'; import { unionChanged } from './match_groups.jsx'; +import { joinQueuedLineupSave } from './lineup_save.jsx'; import { bridge as _bridge } from './court_bridge.jsx'; // The offset lives in a leaf so a score editor can read the same clock (server_clock.jsx). import { serverNowMs, serverClockOffsetMs, setServerClockOffsetMs } from './server_clock.jsx'; @@ -566,14 +567,15 @@ const _revSession = (typeof crypto !== 'undefined' && crypto.randomUUID) // coalescing rule (_coalesceTarget): a score write takes the place of the // match's LAST entry only when that entry is a score write no newer than it, // and a running write only of a running one, claiming the groups both changed -// (union, as bc-mrgc phase 2 does); a lineup save takes the place of a queued -// save of the same lineup, because a lineup PUT restates the whole lineup and -// carries no stamp the server could order it by. Nothing else replaces -// anything: a running write never replaces a queued Finish or a decision, and -// a decision and a score for the same match are two entries. A key persisted -// by an earlier build (one entry per match, keyed by the base itself) is a -// valid entry of this shape: its base is the key, so an old queue loads and -// replays as it is. +// (union, as bc-mrgc phase 2 does); a lineup save joins the queued save of the +// same lineup, so a lineup has ONE entry (lineup_save.jsx: the union of the +// positions the two changed, the later value standing where both changed one), +// because a lineup PUT carries no stamp the server could order it by. Nothing +// else replaces anything: a running write never replaces a queued Finish or +// a decision, and a decision and a score for the same match are two entries. +// A key persisted by an earlier build (one entry per match, keyed by the base +// itself) is a valid entry of this shape: its base is the key, so an old queue +// loads and replays as it is. // // F4: The queue is persisted to localStorage on every change so that a page // reload or crash during a wifi gap does not lose unsaved scores/decisions. @@ -950,20 +952,18 @@ function _entriesFor(base) { // RUNNING write takes the place of a running one only: never of a queued // Finish or correction. A Finish or correction may take the place of either // (a later Finish, or the editor's Retry re-sending the queued one, is the -// same result restated, not a second one). A LINEUP save takes the place of a -// queued save of the same lineup (the base names the lineup): the PUT replaces -// the whole lineup, so the later save says everything the earlier one did. A -// decision or an override is never replaced, and nothing coalesces across -// one. The page-hide copy of a write still being sent (_keepInflightRunning) -// is its own entry until its fetch settles. +// same result restated, not a second one). A LINEUP save joins the queued save +// of the same lineup (the base names the lineup): _placeWrite makes the one +// entry say what both changed. A decision or an override is never replaced, +// and nothing coalesces across one. The page-hide copy of a write still being +// sent (_keepInflightRunning) is its own entry until its fetch settles. function _coalesceTarget(base, descriptor) { if (!descriptor) return null; const list = _entriesFor(base); const last = list.length ? list[list.length - 1] : null; if (!last) return null; const [key, prev] = last; - // A lineup save restates the whole lineup (the PUT replaces it), so a - // later save of the same lineup carries everything a queued one said. + // A lineup has one entry in the queue: a later save of it joins the queued one. if (descriptor.kind === 'lineup') return prev && prev.kind === 'lineup' ? last : null; if (descriptor.kind !== 'score') return null; if (!prev || prev.kind !== 'score' || _heldByOpenFetch(key, prev)) return null; @@ -972,18 +972,27 @@ function _coalesceTarget(base, descriptor) { return last; } -// _queuedLineupSave: is a save of this lineup (`base`) still queued? A new -// save then takes its place in the queue (_coalesceTarget) rather than go -// straight to the server: a lineup PUT carries no stamp the server could order -// it by, so a direct save landing while the older one is still being replayed -// could be overwritten by it. That is not waiting behind it: the queued save is -// replaced by the newer one and the flush the enqueue kicks sends it at once. -// Every other write goes straight to the server whatever is queued (bc-mrgc: -// the server orders a match write by its stamp). +// _queuedLineupSave: whether a save of this lineup (`base`) is still queued. A new +// save of the lineup then joins that entry (_coalesceTarget, _placeWrite) rather +// than go straight to the server: a lineup PUT carries no stamp the server could +// order it by, so a direct save landing while the older one is still being +// replayed could be overwritten by it. That is not waiting behind it: the joined +// entry is the one flush the enqueue kicks sends at once. It carries both saves' +// changes, so nothing is composed on what is queued. It is also what holds back +// removing the lineup, which the replay would undo. Every other write goes +// straight to the server whatever is queued (bc-mrgc: the server orders a match +// write by its stamp). function _queuedLineupSave(base) { return _coalesceTarget(base, { kind: 'lineup' }) !== null; } +// _lineupKey: the queue BASE of a lineup: a match's own lineup (`matchId`), or a +// team's Lineups-page round (round 0 is its starting lineup). The one owner of +// the key: both lineup saves and queuedLineupSave name a lineup by it. +function _lineupKey(compID, teamId, { matchId, round }) { + return matchId ? `lineup:${compID}:${teamId}:match:${matchId}` : `lineup:${compID}:${teamId}:${round}`; +} + // _storedEntryProblem: why a stored queue entry cannot be replayed // ('unreadable'), or null when it can. The checks a rehydrate applies, stated // once so taking up another tab's entries (_onStoredQueueChanged) applies them @@ -1961,10 +1970,11 @@ function _queuedAnswer() { } // _placeWrite: the key a new write about `base` goes under. When it coalesces -// (_coalesceTarget), it takes the last running score write's key and carries -// what that write said that a later write must not lose: the groups it +// (_coalesceTarget), it takes the queued entry's key and carries what that +// write said that a later write must not lose: the groups a score write // changed (_claimingQueuedGroups) and, for a running write, a pending -// kachinukiBoutFinal (enqueueRunningWrite's comment says why). Otherwise it is +// kachinukiBoutFinal (enqueueRunningWrite's comment says why); a lineup save +// the positions the queued save changed (joinQueuedLineupSave). Otherwise it is // a new entry, after the others in replay order. function _placeWrite(base, descriptor) { const target = _coalesceTarget(base, descriptor); @@ -1975,7 +1985,9 @@ function _placeWrite(base, descriptor) { if (Number(prev.seenModifiedAt) > (Number(descriptor.seenModifiedAt) || 0)) { descriptor.seenModifiedAt = prev.seenModifiedAt; } - let payload = _claimingQueuedGroups(prev, descriptor.payload); + let payload = descriptor.kind === 'lineup' + ? joinQueuedLineupSave(prev.payload, descriptor.payload) + : _claimingQueuedGroups(prev, descriptor.payload); if (!descriptor.terminal && prev.payload && prev.payload.kachinukiBoutFinal && payload && !payload.kachinukiBoutFinal) { payload = { ...payload, kachinukiBoutFinal: true }; @@ -2057,9 +2069,9 @@ function _withoutDownstreamConfirmation(payload) { * A terminal SCORE write (a Finish, a correction) takes the place of the * match's last queued write when that is a score write (running, or a Finish * the editor's Retry is re-sending), claiming its groups (_coalesceTarget, - * _placeWrite); every other terminal write (a - * decision, a lineup, an override) is queued after what is there and replays - * in order. Nothing queued is ever replaced by it otherwise. + * _placeWrite); a lineup save joins the queued save of its lineup; every + * other terminal write (a decision, an override) is queued after what is there + * and replays in order. Nothing queued is ever replaced by it otherwise. * @param {string} key - Queue BASE: the match the write is about (use * _revKey, or the lineup / override base) * @param {WriteKind} kind - 'score' | 'decision' | 'lineup' @@ -2554,7 +2566,7 @@ function parseSkippedCompetitionsHeader(headerValue) { // Strict === false: a body that does not SAY it is unsaved is a lineup, so // a stub/older-shape response with no `saved` field at all still resolves // as a lineup rather than being swallowed. Shared by fetchTeamLineup and -// fetchMatchLineup. +// fetchLineupInForce. const lineupOrNull = (body) => (body.saved === false ? null : body); // What the daihyosen add or remove came back with. A refusal (superseded, @@ -2650,6 +2662,62 @@ async function _daihyosenRequest(method, compID, matchID, password, notDone, see return _daihyosenOutcome(body); } +// What a lineup removal leaves undone when it does not land. +const LINEUP_NOT_REMOVED = 'The lineup was not removed'; + +// _readLineup: a lineup's GET, bounded like every sibling request (_fetchJson: +// the deadline covers the body), so a read the server never answers rejects with +// _requestTimedOut's error, which lineupReadFailure words, instead of leaving an +// editor on "Loading lineup…" until the browser gives up on the connection. +// `saved: false` reads as null (lineupOrNull). Shared by fetchTeamLineup and +// fetchLineupInForce. +async function _readLineup(url) { + const { res, body } = await _fetchJson(url, {}); + if (!res.ok) throw new Error(body.error || "Failed to load lineup"); + // An answer whose body could not be read comes back from _fetchJson as an empty + // object, which cannot be told from one the server sent. A body with no keys is + // refused here rather than read as no lineup, since an editor would then show an + // empty lineup as the one the team fields. + if (Object.keys(body).length === 0) throw new Error("The lineup could not be read. Check the connection and try again."); + return lineupOrNull(body); +} + +// What a team member write leaves undone when the server does not answer it. +const TEAM_MEMBER_NOT_ADDED = 'The team member was not added'; +const TEAM_MEMBER_NOT_RENAMED = 'The team member was not renamed'; +const TEAM_MEMBER_NOT_CLEARED = 'The team member\'s name was not cleared'; + +// _memberWrite: a team member's POST (add), PUT (rename) or DELETE (cleared name). The +// team score sheet holds every name box disabled, and the side's lineup announcements +// back, until the write settles, so it is bounded like every sibling request +// (_fetchJson: the deadline covers the body) and answered in a plain sentence when the +// server cannot be reached or does not answer, never the browser's "Failed to fetch". +// `payload` is the JSON body, none for a clear. Resolves to { res, body }. +async function _memberWrite(url, method, password, notDone, payload) { + const headers = { 'X-Tournament-Password': password }; + const opts = { method, headers }; + if (payload !== undefined) { + headers['Content-Type'] = 'application/json'; + opts.body = JSON.stringify(payload); + } + try { + return await _fetchJson(url, opts); + } catch (_e) { + throw new Error(noAnswerSentence(notDone)); + } +} + +// _answeredMember: the member a write answers, which the server has stamped +// (`modifiedAt`): what an editor merges in by that stamp, so it is the answer and not a +// copy built from what was typed, which carries no stamp and would lose to every list. +// _fetchJson reads an unreadable body as {}, which cannot be told from a member the +// server sent: an answer with no id is no member, and the write is a failed one. +function _answeredMember(res, body, failure, notDone) { + if (!res.ok) throw _refusalError(body, failure); + if (!body.id) throw new Error(noAnswerSentence(notDone)); + return body; +} + const API = { async fetchTournament() { const res = await fetch('/api/viewer/tournament'); @@ -4029,35 +4097,38 @@ const API = { }, // The GET answers saved: false when nothing is stored; lineupOrNull // makes that null ("blank, editable"). A 404 means the competition does - // not exist and throws. PUT replaces; DELETE clears. - // opts.fallback: best-effort resolution for match-scoring surfaces: when - // the exact round has nothing saved the server falls back to the - // closest saved round (highest <= requested, else highest overall). The - // lineup EDITOR must NOT pass this: it reads the exact round with no - // fallback, so nothing saved there is null ("blank, editable"). - async fetchTeamLineup(compID, teamId, round, opts) { - const qs = opts && opts.fallback ? "?fallback=best" : ""; - const res = await fetch(`/api/competitions/${compID}/teams/${teamId}/lineups/${round}${qs}`); - if (!res.ok) { - const err = await res.json().catch(() => ({})); - throw new Error(err.error || "Failed to load lineup"); - } - const body = await res.json(); - return lineupOrNull(body); + // not exist and throws. PUT replaces; DELETE clears. The read is of the + // exact round: the Lineups page reads a team's starting lineup (round 0) + // this way, and the match surfaces (score sheet, displays, the at-court + // lineup panel) do not read rounds at all: they read fetchLineupInForce + // through resolveMatchLineup. + async fetchTeamLineup(compID, teamId, round) { + return _readLineup(`/api/competitions/${compID}/teams/${teamId}/lineups/${round}`); }, // memberIds (bc-tmid pass 3) is optional and keyed by the same position // as positions: the squad member id half of a lineup, sent alongside // the name so the server can persist both together. Omitted entirely // when the caller passes nothing, so a caller that never adopted squad // members (or an older bundle) round-trips exactly as before. - async putTeamLineup(compID, teamId, round, positions, password, memberIds) { + // + // changed (operator decision 2026-10-07, "Only changed positions") names the + // positions this save changes: the server reads only those and lands them + // on the lineup it holds when the save arrives, so a position another device + // changed meanwhile stays, and the answer is the whole lineup it then holds + // (lineup_save.jsx builds the body: positions and memberIds for the changed + // positions only). Omitted, the body is a whole lineup that replaces the + // stored one. + async putTeamLineup(compID, teamId, round, positions, password, memberIds, changed) { const lineupUrl = `/api/competitions/${compID}/teams/${teamId}/lineups/${round}`; - const lineupBody = { teamId, competitionId: compID, round, positions, ...(memberIds ? { memberIds } : {}) }; + const lineupBody = { + teamId, competitionId: compID, round, positions, + ...(memberIds ? { memberIds } : {}), ...(changed ? { changed } : {}), + }; // F5: lineup queue key is distinct from score/decision keys so a lineup // write doesn't collide with a concurrent score write for the same match. - const lineupKey = `lineup:${compID}:${teamId}:${round}`; - // A save of this lineup still queued is replaced by this one, which - // the flush sends at once (_queuedLineupSave): sent straight, it could + const lineupKey = _lineupKey(compID, teamId, { round }); + // A save of this lineup still queued is joined by this one, which the + // flush sends at once (_queuedLineupSave): sent straight, it could // land before the queued one's replay and be overwritten by it. if (_queuedLineupSave(lineupKey)) { _enqueueTerminalWrite(lineupKey, 'lineup', 'PUT', lineupUrl, lineupBody, password, compID, ''); @@ -4098,17 +4169,6 @@ const API = { } return body; }, - async deleteTeamLineup(compID, teamId, round, password) { - const res = await fetch(`/api/competitions/${compID}/teams/${teamId}/lineups/${round}`, { - method: 'DELETE', - headers: { 'X-Tournament-Password': password } - }); - if (!res.ok && res.status !== 404) { - const err = await res.json().catch(() => ({})); - throw new Error(err.error || "Failed to delete lineup"); - } - return true; - }, // bc-tmid pass 3: a team's squad, the actual people on it, lives in its // own per-competition store (team-members.yaml), keyed by the team's // participant id -- see internal/state/squad.go. Returns the whole @@ -4143,74 +4203,47 @@ const API = { }, // Mints the new member's id and display index server-side in one step // (operator ruling: assigned automatically as members are added) and - // returns the created {id, index, name}. clearTeamMember below is the - // nearest counterpart and only blanks a name: an index, once minted, is - // never freed. + // returns the created {id, index, name, modifiedAt}, 201. clearTeamMember + // below is the nearest counterpart and only blanks a name: an index, once + // minted, is never freed. Every member write answers the member with the + // stamp the server gave it (_answeredMember), and the server announces the + // change (lineup_updated, with the team) to every device. async addTeamMember(compID, teamId, name, password) { - const res = await fetch(`/api/competitions/${compID}/teams/${teamId}/members`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'X-Tournament-Password': password - }, - body: JSON.stringify({ name }) - }); - if (!res.ok) { - const err = await res.json().catch(() => ({})); - throw _refusalError(err, "Failed to add team member"); - } - return res.json(); + const { res, body } = await _memberWrite(`/api/competitions/${compID}/teams/${teamId}/members`, 'POST', password, TEAM_MEMBER_NOT_ADDED, { name }); + return _answeredMember(res, body, "Failed to add team member", TEAM_MEMBER_NOT_ADDED); }, - // Keeps memberId's id and index; only the display name changes. - // 204 No Content on success. + // Keeps memberId's id and index; only the display name changes. Answers 200 + // with the member, stamped. async renameTeamMember(compID, teamId, memberId, name, password) { - const res = await fetch(`/api/competitions/${compID}/teams/${teamId}/members/${memberId}`, { - method: 'PUT', - headers: { - 'Content-Type': 'application/json', - 'X-Tournament-Password': password - }, - body: JSON.stringify({ name }) - }); - if (!res.ok) { - const err = await res.json().catch(() => ({})); - throw _refusalError(err, "Failed to rename team member"); - } - return true; + const { res, body } = await _memberWrite(`/api/competitions/${compID}/teams/${teamId}/members/${memberId}`, 'PUT', password, TEAM_MEMBER_NOT_RENAMED, { name }); + return _answeredMember(res, body, "Failed to rename team member", TEAM_MEMBER_NOT_RENAMED); }, // The operator's "removal": blanks memberId's Name back to "" and // leaves the id and display index untouched, so a bout already fought // that names this position keeps meaning the same person (bc-pnum). - // 204 No Content on success. Refused with a 409 once the competition - // has started (state.ErrTeamMemberClearAfterStart); the server's own - // message is operator-facing, so it is surfaced verbatim rather than - // remapped here. + // Answers 200 with the member, nameless and stamped. Refused with a 409 + // once the competition has started (state.ErrTeamMemberClearAfterStart); + // the server's own message is operator-facing, so it is surfaced verbatim + // rather than remapped here. async clearTeamMember(compID, teamId, memberId, password) { - const res = await fetch(`/api/competitions/${compID}/teams/${teamId}/members/${memberId}`, { - method: 'DELETE', - headers: { 'X-Tournament-Password': password } - }); - if (!res.ok) { - const err = await res.json().catch(() => ({})); - throw new Error(err.error || "Failed to clear team member"); - } - return true; + const { res, body } = await _memberWrite(`/api/competitions/${compID}/teams/${teamId}/members/${memberId}`, 'DELETE', password, TEAM_MEMBER_NOT_CLEARED); + return _answeredMember(res, body, "Failed to clear team member", TEAM_MEMBER_NOT_CLEARED); }, // mp-825 / mp-bkg: per-match lineup endpoints. Match ID takes the // place of the round key: successive encounters between the same - // two teams each carry an independent lineup entry. - // `saved: false` -> null (bc-k404: nothing saved for this match is a - // 200, not a 404; resolveMatchLineup falls back to the round-scoped GET - // above on null, never the server). A 404 here means the competition - // does not exist. - async fetchMatchLineup(compID, teamId, matchId) { - const res = await fetch(`/api/competitions/${compID}/teams/${teamId}/match-lineups/${matchId}`); - if (!res.ok) { - const err = await res.json().catch(() => ({})); - throw new Error(err.error || "Failed to load match lineup"); - } - const body = await res.json(); - return lineupOrNull(body); + // two teams each carry an independent lineup entry. Nothing here reads + // the lineup saved for exactly one match: what a team fields at a match + // is fetchLineupInForce. + // The lineup a team fields at a match (operator ruling 2026-10-05: a team + // carries the lineup of its previous match unless one is entered for the + // match). The server owns the rule (engine/lineup_in_force.go): the + // match's own lineup, else the latest of the team's earlier matches' + // lineups, else its starting lineup. `saved: false` -> null (nothing + // applies). A lineup carries where it was saved: `sourceMatchId` (this + // match, or an earlier one it is carried from) or `sourceRound` (0, the + // starting lineup). A 404 means the competition does not exist. + async fetchLineupInForce(compID, teamId, matchId) { + return _readLineup(`/api/competitions/${compID}/teams/${teamId}/lineup-in-force/${matchId}`); }, // memberIds (bc-pnum gap closure) is optional and keyed by the same // position as positions, exactly like putTeamLineup's own memberIds @@ -4220,13 +4253,18 @@ const API = { // (or an older bundle) round-trips exactly as before -- including into // the offline queue: matchLineupBody (built once, below) is the SAME // object fed to both the live fetch and _enqueueTerminalWrite, so a - // replayed lineup write carries the ids too. - async putMatchLineup(compID, teamId, matchId, positions, password, memberIds) { - const matchLineupBody = { teamId, competitionId: compID, matchId, positions, ...(memberIds ? { memberIds } : {}) }; + // replayed lineup write carries the ids too. `changed` names the positions + // the save changes, as for putTeamLineup above. + async putMatchLineup(compID, teamId, matchId, positions, password, memberIds, changed) { + const matchLineupBody = { + teamId, competitionId: compID, matchId, positions, + ...(memberIds ? { memberIds } : {}), ...(changed ? { changed } : {}), + }; const matchLineupUrl = `/api/competitions/${compID}/teams/${teamId}/match-lineups/${matchId}`; - // F5: per-match lineup key: distinct from round-scoped lineups. - const matchLineupKey = `lineup:${compID}:${teamId}:match:${matchId}`; - // Takes the place of a queued save of it, as above. + // F5: a match's own lineup is queued under its own key (_lineupKey), + // distinct from a Lineups-page round's. + const matchLineupKey = _lineupKey(compID, teamId, { matchId }); + // Joins a queued save of it, as above. if (_queuedLineupSave(matchLineupKey)) { _enqueueTerminalWrite(matchLineupKey, 'lineup', 'PUT', matchLineupUrl, matchLineupBody, password, compID, matchId); return _queuedAnswer(); @@ -4270,17 +4308,35 @@ const API = { } return body; }, + // Removes the match's own lineup. Bounded like every sibling write + // (_fetchJson: the deadline covers the body), and answered in a plain sentence + // when the server cannot be reached, never the browser's "Failed to fetch". It + // is not queued: the editor shows what the match carries without the lineup + // only once the server has confirmed the removal. A lineup that is not there + // is removed already (404). async deleteMatchLineup(compID, teamId, matchId, password) { - const res = await fetch(`/api/competitions/${compID}/teams/${teamId}/match-lineups/${matchId}`, { - method: 'DELETE', - headers: { 'X-Tournament-Password': password } - }); - if (!res.ok && res.status !== 404) { - const err = await res.json().catch(() => ({})); - throw new Error(err.error || "Failed to delete match lineup"); + let res; + let body; + try { + ({ res, body } = await _fetchJson(`/api/competitions/${compID}/teams/${teamId}/match-lineups/${matchId}`, { + method: 'DELETE', + headers: { 'X-Tournament-Password': password }, + })); + } catch (_e) { + throw new Error(noAnswerSentence(LINEUP_NOT_REMOVED)); } + if (!res.ok && res.status !== 404) throw _refusalError(body, "Failed to delete match lineup"); return true; }, + // Whether a save of this lineup is still queued. `target` is { matchId } for a + // match's own lineup or { round } for a team's Lineups-page round (0: its + // starting lineup). A queued save replays after anything sent now, so a + // removal made meanwhile would be undone by it: the lineup editors hold their + // "Use the previous match's lineup" while this is true. Nothing is composed + // on a queued save: a new save of the lineup joins it and names what it changed. + queuedLineupSave(compID, teamId, target) { + return _queuedLineupSave(_lineupKey(compID, teamId, target || {})); + }, // T141: daihyosen (representative bout) appended after a knockout team // match ties on IV and PW. Server validates the tie + eligibility and // returns the updated MatchResult with a new SubMatchResult whose diff --git a/web-mobile/js/app.jsx b/web-mobile/js/app.jsx index cb259b010..78dd6a8f8 100644 --- a/web-mobile/js/app.jsx +++ b/web-mobile/js/app.jsx @@ -8,6 +8,7 @@ import { LS_NOTIFICATIONS_ENABLED } from './notification_keys.jsx'; import { bridge, setSnapshotProvider, setDisplayCourt, getLastBroadcastAt, applyPatchToTree, mergeSnapshotIntoTree, deriveLinkState, freshnessMs } from './court_bridge.jsx'; import { BRANDING_DEFAULTS } from './admin_branding.jsx'; import { queuedWritesNoun, supersededAlertText, displacedAlertText } from './write_result.jsx'; +import { useOpenedTapGuard } from './tap_guard.jsx'; const { useState: useS, useEffect: useE, useRef: useR, useCallback: useC } = React; @@ -1692,6 +1693,9 @@ function AuthModal({ onClose, onSuccess, onForgotPassword, resetEnabled, reauth // as the admin-side mountedRef pattern. const mountedRef = useR(true); useE(() => () => { mountedRef.current = false; }, []); + // bc-cfbd: the bounce of the tap that opened the modal must not reach its + // backdrop or its buttons. + const { openedRef, onClickCapture } = useOpenedTapGuard(); const submit = async (e) => { e.preventDefault(); @@ -1723,7 +1727,7 @@ function AuthModal({ onClose, onSuccess, onForgotPassword, resetEnabled, reauth // .modal-backdrop default of 100 sits below viewer chrome at 200/500 and // below the mp-udb announcement overlay at 900); without this the sign-in // dialog could be obscured by chrome or by announcement cards. -
+
e.stopPropagation()}> { e.target.onerror = null; e.target.src = "/logo.jpeg"; }} alt="Tournament logo" className="auth__logo" decoding="async" />
{reauth ? "Sign in to save" : "Admin sign in"}
diff --git a/web-mobile/js/competitor_identity.jsx b/web-mobile/js/competitor_identity.jsx index b0215e26a..168e716bd 100644 --- a/web-mobile/js/competitor_identity.jsx +++ b/web-mobile/js/competitor_identity.jsx @@ -24,7 +24,7 @@ // the intentional recovery path for resolveSide's own "no id at all" // fallback (api_serializers.jsx), and is why callers like // match_scoreboard.jsx's useTeamLineups, admin_scoring_team.jsx's -// sideAKey/rosterForSide/teamIdForSide, and admin_schedule_lineup.jsx's +// sideAKey/rosterForSide, and admin_schedule_lineup.jsx's // sideKey/matchesKey call sideLookupKey (below) rather than sameCompetitor. // admin_lineup.jsx's teamIdOf is the one exception: its legacy ID/Name // fallback has a precedence sideLookupKey's simple shape can't reproduce, diff --git a/web-mobile/js/dialog_focus.jsx b/web-mobile/js/dialog_focus.jsx new file mode 100644 index 000000000..420e96358 --- /dev/null +++ b/web-mobile/js/dialog_focus.jsx @@ -0,0 +1,34 @@ +// dialog_focus.jsx: the ONE owner of focus going into a layer that opens over the +// page and back to the control that opened it (WCAG 2.4.3, the ARIA dialog pattern). +// The court console's confirms and the at-court lineup panel take it from here; +// DialogHost (ui.jsx), which also traps Tab and locks the page's scroll, keeps its +// own. +// +// A leaf with no imports: every consumer ES-imports it directly, and there is no +// window mirror (the tap_guard.jsx pattern). The hook reads the React global at CALL +// time, so importing this module needs no React. + +// useDialogFocus: while `open`, focus goes into the layer (its first button, whose +// Enter is a safe cancel or close), and when the layer closes or goes away it goes +// back to the control that had it as the layer opened. `boxRef` is the layer's node. +// const boxRef = React.useRef(null); +// useDialogFocus(boxRef, open); +//
+// Neither move scrolls the page (preventScroll): the layer opens over the control that +// was pressed, which is in view. The move in runs on a 0ms timer, as DialogHost's: a +// focus moved during the commit is reset. Focus is not given back to the page itself +// (a tap on a touchscreen focuses nothing) or to a control that has left the page. +export function useDialogFocus(boxRef, open = true) { + React.useEffect(() => { + if (!open) return undefined; + const opener = document.activeElement; + const timer = setTimeout(() => { + const first = boxRef.current && boxRef.current.querySelector("button"); + if (first) first.focus({ preventScroll: true }); + }, 0); + return () => { + clearTimeout(timer); + if (opener && opener !== document.body && document.contains(opener)) opener.focus({ preventScroll: true }); + }; + }, [boxRef, open]); +} diff --git a/web-mobile/js/display_scoreboard.jsx b/web-mobile/js/display_scoreboard.jsx index 250acd454..d6c284da6 100644 --- a/web-mobile/js/display_scoreboard.jsx +++ b/web-mobile/js/display_scoreboard.jsx @@ -721,8 +721,7 @@ function TvDisplay({ court, tournament, competitions, withZekkenName, linkState // normalizeViewerCompItem). const { lineupA, lineupB, squadA, squadB } = useTeamLineups( isTeamMatch && promoted && promoted.match ? promoted.match : null, - isTeamMatch && promoted ? promoted.competition : null, - promoted ? promoted.roundIndex : undefined + isTeamMatch && promoted ? promoted.competition : null ); // mp-13y: DH (Daihyosen) row gating: shown when: diff --git a/web-mobile/js/lineup_draft.jsx b/web-mobile/js/lineup_draft.jsx new file mode 100644 index 000000000..deb737fad --- /dev/null +++ b/web-mobile/js/lineup_draft.jsx @@ -0,0 +1,836 @@ +// lineup_draft.jsx: unsaved lineup picks survive a reload, the app's Back and +// closing a panel (bc-lnul, operator decision 2026-10-05). An editor hands +// useLineupDraft the lineup it loaded (baseline) and the one it shows (current); +// while they differ, the difference is kept as a draft in this tab's +// sessionStorage (per tab: it survives a reload and Back, and never reaches +// another tab) and handed back through onRestore when the same lineup is opened +// again. +// +// Nothing here writes to the server: a restored draft is saved only by the +// operator's Save, and an offline write stays the queue's job (api_client.jsx). +// +// useLineupForm, below, is the state machine of ONE lineup editor, shared by the +// at-court panel and the Lineups page: what was read, where it was saved, what +// is shown, whether it differs, the draft, the confirmed save and giving a +// match's own lineup up. The editors keep their layouts and their save bodies. +// +// The rules for the team's members (mergeMembers, the one rule a list that arrives is +// merged by, and newMembersWait, the wait for a team's first list) are exported too: +// the team score sheet writes members from its bout rows and keeps its own lists, and +// asks the same owner what a list that arrives does to them. +// +// Its imports are lineup_resolver.jsx, the owner of the lineup read and of which +// positions differ, and write_result.jsx, the leaf that owns the deadline a bounded +// wait is given. What a Save carries is lineup_save.jsx's. The hooks read the React +// global at call time, as tap_guard.jsx does. +import { + changedLineupPositions, lineupFields, lineupSourceOf, lineupSourceLabel, isOwnLineup, resolveMatchLineup, + previousLineupConfirm, PREVIOUS_LINEUP_LABEL, SAVE_QUEUED_REASON, REMOVED_UNREAD_NOTICE, STARTING_ROUND, + lineupReadFailure, +} from './lineup_resolver.jsx'; +import { FETCH_TIMEOUT_MS, TIMED_OUT, withinDeadline } from './write_result.jsx'; + +const STORAGE_PREFIX = 'bc.lineupDraft.v1:'; +// A draft older than this is never offered: the operator has moved on. +const MAX_AGE_MS = 12 * 60 * 60 * 1000; + +const RESTORED_NOTICE = 'Unsaved lineup changes restored'; +const NOT_RESTORED_NOTICE = 'Not restored, the lineup changed since'; + +// The ONE key of a lineup's draft, so the panel and the Lineups page offer each +// other's drafts: a match's lineup, or a team's starting lineup (no match). +export const lineupDraftKey = ({ compId, teamId, matchId }) => ( + matchId ? `${compId}:${teamId}:match:${matchId}` : `${compId}:${teamId}:start` +); + +// Every storage access is guarded: reaching sessionStorage throws when the +// browser blocks it, and a write throws on a full or private-mode store. A draft +// is a convenience, so a storage that refuses it must never reach the editor. +function store() { + try { + return typeof sessionStorage === 'undefined' ? null : sessionStorage; + } catch (_e) { + return null; + } +} + +const isSide = (v) => !!v && typeof v === 'object' && !!v.positions && typeof v.positions === 'object'; + +function readDraft(key) { + try { + const raw = store()?.getItem(STORAGE_PREFIX + key); + if (!raw) return null; + const draft = JSON.parse(raw); + return draft && Number.isFinite(draft.savedAt) && isSide(draft.baseline) && isSide(draft.current) ? draft : null; + } catch (_e) { + return null; + } +} + +function writeDraft(key, draft) { + try { + store()?.setItem(STORAGE_PREFIX + key, JSON.stringify(draft)); + } catch (_e) { + // Quota or private mode: the draft is simply not kept. + } +} + +function removeDraft(key) { + try { + store()?.removeItem(STORAGE_PREFIX + key); + } catch (_e) { + // Nothing to remove from a storage that cannot be reached. + } +} + +// The names the draft held at the positions it had changed, once each: what a +// notice lists when the draft could not be restored. +function draftNames(draft, keys) { + const names = []; + changedLineupPositions(draft.baseline, draft.current, keys).forEach((key) => { + const name = String(draft.current.positions[key] ?? '').trim(); + if (name && !names.includes(name)) names.push(name); + }); + return names; +} + +// Whether `loaded` already holds everything a draft shows (`shown`): the same name at +// every position, and at one with no name the same member. A name is held by the +// name alone: a name typed in has no member id in the draft, and the save that wrote +// it attached one, so the operator's own landed save differs from their draft by ids +// the draft never had. +function holdsDraft(loaded, shown, keys) { + const held = lineupFields(loaded, keys); + const wanted = lineupFields(shown, keys); + const name = (side, key) => String(side.positions[key]).trim(); + return keys.every((key) => (name(wanted, key) + ? name(held, key) === name(wanted, key) + : !name(held, key) && held.memberIds[key] === wanted.memberIds[key])); +} + +const NOTHING = { restored: false, stale: null }; + +// useLineupDraft: keeps and restores one lineup's draft. +// key the lineup's lineupDraftKey +// ready true once baseline and current show what the server holds for THIS +// key (the load succeeded): until then the hook touches nothing, +// since the empty form of a lineup still loading equals its empty +// baseline and would read as "nothing to keep" +// baseline the lineup as loaded or last saved, { positions, memberIds } +// current the lineup as shown, in the same shape +// positionKeys the lineup's position keys +// onRestore called with the { positions, memberIds } to show, for a restored +// draft and for discard() +// Returns { restored, stale, discard, resolved }: `restored` while a restored draft +// is still unsaved; `stale` is { names } when the lineup changed meanwhile and the +// draft was dropped rather than overwrite it, and it stays until the editor calls +// `resolved()` (the lineup was saved, or given up) or the lineup is left. +export function useLineupDraft({ key, ready, baseline, current, positionKeys, onRestore }) { + const { useState, useRef, useCallback, useLayoutEffect } = React; + const [outcome, setOutcome] = useState({ key: '', ...NOTHING }); + // The key whose draft was last read: a draft is read once, when its lineup has + // loaded, and the sync that follows must not remove it before that. + const readKey = useRef(''); + const baseFields = lineupFields(baseline, positionKeys); + const nowFields = lineupFields(current, positionKeys); + const latest = useRef(null); + latest.current = { baseFields, nowFields, positionKeys, onRestore }; + // `current` and `baseline` are new objects every render: the effect runs on + // what they hold. + const signature = JSON.stringify([positionKeys, baseFields, nowFields]); + + // A layout effect, so a draft is on disk the moment an edit commits (a reload + // right after it still finds it) and a restore lands before the first paint. + useLayoutEffect(() => { + if (!ready || !key) return; + const { baseFields: loaded, nowFields: shown, positionKeys: keys, onRestore: restore } = latest.current; + if (readKey.current !== key) { + readKey.current = key; + // A notice left from another lineup, or from this one's last visit, is not + // this visit's. + const settle = (next) => setOutcome((o) => ( + !o.restored && !o.stale && !next.restored && !next.stale ? o : { key, ...next } + )); + const draft = readDraft(key); + if (!draft) { + // None stored (a no-op), or one that cannot be read: it goes. + removeDraft(key); + settle(NOTHING); + return; + } + const expired = Date.now() - draft.savedAt > MAX_AGE_MS; + if (expired || changedLineupPositions(draft.baseline, draft.current, keys).length === 0 + || holdsDraft(loaded, draft.current, keys)) { + // Too old, nothing in it, or the saved lineup already holds all of it (the + // operator's own save, written after they left, included). + removeDraft(key); + settle(NOTHING); + } else if (changedLineupPositions(draft.baseline, loaded, keys).length > 0) { + // The lineup changed meanwhile (another device): never overwrite it. + removeDraft(key); + settle({ restored: false, stale: { names: draftNames(draft, keys) } }); + } else { + restore(lineupFields(draft.current, keys)); + settle({ restored: true, stale: null }); + } + return; + } + if (changedLineupPositions(loaded, shown, keys).length > 0) { + writeDraft(key, { savedAt: Date.now(), baseline: loaded, current: shown }); + } else { + removeDraft(key); + setOutcome((o) => (o.restored ? { ...o, restored: false } : o)); + } + }, [key, ready, signature]); + + const discard = useCallback(() => { + const { baseFields: loaded, positionKeys: keys, onRestore: restore } = latest.current; + removeDraft(key); + restore(lineupFields(loaded, keys)); + setOutcome((o) => (o.restored ? { ...o, restored: false } : o)); + }, [key]); + + // "Not restored" says which names are missing from the lineup as it was opened, + // so editing does not answer it, not even putting the edit back: only the lineup + // being saved, or given up, does. + const resolved = useCallback(() => { + setOutcome((o) => (o.stale ? { ...o, stale: null } : o)); + }, []); + + const mine = outcome.key === key ? outcome : NOTHING; + return { restored: mine.restored, stale: mine.stale, discard, resolved }; +} + +// LineupDraftNotice: the amber notice under an editor's source label, from what +// useLineupDraft returned. A restored draft always offers Discard; a draft dropped +// because the lineup changed meanwhile names what was not restored. Discard is a +// plain .btn, so the coarse-pointer floor reaches it through the class. It waits +// while `busy` (the editor's own: a save is out): the save writes the changes +// Discard would take back. +export function LineupDraftNotice({ draft, busy, testId }) { + const { restored, stale, discard } = draft; + if (!restored && !stale) return null; + const names = stale ? stale.names.join(', ') : ''; + return ( +
+ {restored ? RESTORED_NOTICE : (names ? `${NOT_RESTORED_NOTICE}: ${names}` : NOT_RESTORED_NOTICE)} + {restored && } +
+ ); +} + +const EMPTY_SIDE = { positions: {}, memberIds: {} }; +const NO_CHANGES_TITLE = 'No changes to save'; + +// A name box per position, all empty: the form of a lineup nothing is known of. +const blankNames = (positionKeys) => Object.fromEntries(positionKeys.map((key) => [key, ''])); + +// The request for every team's members of a competition, shared by the reads begun in +// the same tick: the at-court panel's two editors each ask for their own team's as they +// open and when a lineup change is announced, and one answer holds both. A read begun +// any later is a request of its own, so it holds whatever the server has by then: a +// read made for a change announced must not be handed an answer that began before it. +const requestsOfThisTick = new Map(); +function requestMembers(compId, password) { + const key = `${compId}\n${password}`; + if (!requestsOfThisTick.has(key)) { + requestsOfThisTick.set(key, window.API.fetchSquads(compId, password)); + Promise.resolve().then(() => requestsOfThisTick.delete(key)); + } + return requestsOfThisTick.get(key); +} + +// The team's members (the API answers every team's, keyed by id), or null when +// they cannot be read: whoever asks keeps the list it has. +async function readMembers(compId, teamId, password) { + try { + const squads = await requestMembers(compId, password); + return (squads && squads[teamId]) || []; + } catch (_e) { + return null; + } +} + +// The team's members once `arriving` is shown beside `shown`: every member of both +// lists, once, in member number order, the order the pickers list them in. No screen +// removes a member, so one a list lacks was added after that list was read, or after +// the list an editor built its update on, and was not taken away. For a member both +// lists hold, the copy with the larger `modifiedAt` is kept, the arriving one on a tie. +// The server stamps a member each time it is created or named (a member nobody has +// touched carries 0, and a missing stamp reads as 0), so which copy is newer is read off +// the member itself, never off which list arrived last or which read began first: a +// list read before a rename never undoes it, and a rename made on another device after +// this one's own shows over it. This is the ONE door every list of members passes, an +// editor's own write included, which is merged as the server answered it, stamp and all: +// a copy built from what was typed carries no stamp and would lose to every list. +export function mergeMembers(shown, arriving) { + const kept = new Map(shown.map((m) => [m.id, m])); + arriving.forEach((m) => { + const held = kept.get(m.id); + if (!held || (m.modifiedAt || 0) >= (held.modifiedAt || 0)) kept.set(m.id, m); + }); + return [...kept.values()].sort((a, b) => (a.index || 0) - (b.index || 0)); +} + +// The first list of a team's members that a read shows, and the wait for it. A name +// typed before one is shown is resolved against no members: a new name is minted where +// the member's seeded slot is free, and the name of a member the team already has is +// refused by the server as a second member of that name. `listed` says a READ's list has +// been shown for the team (a change an editor made itself is not one). `pending()` is +// null when there is nothing out to wait for (a list is shown, or the read failed: the +// editor goes on without one, as it always did), else a promise that ends when a list +// is shown, when the read fails or at the deadline of any bounded request, and says +// whether a list was shown by then: when none was, the editor owns up to going on +// without (the members-unavailable warning). The deadline ends the wait for good: a read +// that hangs holds up the first name typed and no other, which goes on at once, and +// `onTimeout` lets the editor flag the members as unavailable for it as it does when +// the read fails. A list that arrives later is still shown (`show`). Another team starts +// a new wait. +export function newMembersWait(onTimeout) { + let settle; + const done = new Promise((resolve) => { settle = resolve; }); + const end = () => { + wait.ended = true; + settle(); + }; + const wait = { + listed: false, + ended: false, + show: () => { + wait.listed = true; + end(); + }, + fail: end, + pending: () => (wait.ended ? null : withinDeadline(done, FETCH_TIMEOUT_MS).then((outcome) => { + if (outcome === TIMED_OUT) { + end(); + if (onTimeout) onTimeout(); + } + return wait.listed; + })), + }; + return wait; +} + +// useLineupForm: the state of one lineup editor, for a team's match (`matchId`) +// or for its starting lineup (no matchId). It reads the lineup (a match's is the +// one in force there, which the server works out; the starting lineup is read +// exactly, as the team's round-0 entry) and holds +// values, memberIds what is shown, keyed by position +// baseline, source what the server held when it was read or last saved, and +// where that lineup was saved +// loading, loadError the first read is out; why a lineup could not be read +// read the lineup was read for THIS key. Nothing may be written +// before: an editor that could not read a lineup shows an +// empty one, and saving it would write that over the real +// lineup and over every match that carries it +// dirty, canSave the lineup differs from what was read; canSave is +// read && dirty, and the editors gate Save on it +// draft useLineupDraft's answer for it +// error, warning the editor's own refusal and its after-save notice, here +// so that giving a lineup up clears both +// removing a removal is out +// saveQueued a save of this lineup is still waiting to be sent +// squad the team's members as the API answers them: every list +// that arrives, a read's or an editor's own write's, is +// merged with the one shown by member id and the server's +// stamp (mergeMembers), so that none is lost and none is +// undone by an older copy; squadRef, the same list for a +// handler that resumes after a wait, and +// squadUnavailable, that they could not be read +// and gives the editor +// lineupToSave() what a Save carries: the form as shown and `changed`, the +// positions the operator changed, `{ positions, memberIds, +// changed }`. The server puts those on the lineup it holds +// when the save arrives (operator decision 2026-10-07), so +// nothing is asked of the server for it, and an offline +// save is built, and queued, like any other +// changeMembers(members) after the server answered a write of members (an add, a +// mint): the members as it answered them, stamped, merged in +// memberRenamed(member) after the server renamed a member, or cleared the name +// (""): the member as it answered, merged in, and the name of +// the copy kept shown on the positions that hold it and on the +// baseline, so the rename makes no edit of the lineup +// confirmSaved(lineup) after the server confirmed a save: the whole lineup it +// answered, the positions this save left alone as another +// device changed them among them, is now shown and the +// baseline, and a match's own lineup, and what a draft +// could not restore no longer applies +// dropOwnLineup() confirm, then remove a match's own lineup, so the match +// carries its team's previous one, and show that one +// retry() read again ("Try again") +// waitForMembers() what an editor awaits before it resolves a typed name: +// null when nothing is out to wait for (a list of the team's +// members that was read is shown, or the read failed), else +// a promise that ends when one is shown, when the read fails +// or at the deadline of any bounded request, and says whether +// one was shown (newMembersWait). Against no list the name +// would be minted where the member's seeded slot is free, or +// where the team already has the member +// A lineup change announced for the competition (the lineup-updated event) is +// followed: the lineup is read again and shown, unless the form has edits or a +// removal is out (one announced while a read of the lineup is out waits for that +// read, and is followed once it has shown the lineup, or dropped when it fails), +// and the team's members are read again with it, so a member another device +// created is in the list when the lineup names them. They are shown whether or +// not the lineup is (the list is not the operator's edit): a form with edits reads +// them on their own, and leaves its positions as they are. A members read that +// fails or is not answered shows nothing and keeps the list as it was. A change +// announced for another team is not followed (one that names no team is). An +// editor's own change of the members is not followed by a read of its own: the +// server announces it that way too, for the team, to every device, this one among +// them. +// The editors keep their layouts and their save bodies. +export function useLineupForm({ compId, teamId, matchId = '', positionKeys, password, matchLabel, teamName }) { + const { useState, useRef, useEffect } = React; + const draftKey = lineupDraftKey({ compId, teamId, matchId }); + const [values, setValues] = useState(() => blankNames(positionKeys)); + const [memberIds, setMemberIds] = useState({}); + // memberIdsRef mirrors memberIds for the editors' handlers that AWAIT a round + // trip (a rename, a clear) before touching it. The pickers stay interactive + // meanwhile, so the memberIds a handler closed over can be stale by the time it + // resolves: an operator who re-picks the position mid-flight would otherwise + // have it blanked by the resolving clear, and Save would then write it with a + // member id and no name, which the occupancy rule reads as a real placement + // fielding a nameless fighter. + const memberIdsRef = useRef(memberIds); + memberIdsRef.current = memberIds; + const [baseline, setBaseline] = useState(EMPTY_SIDE); + const [source, setSource] = useState(null); + // The draft key whose lineup was read into the form: a lineup is only judged, + // restored over or saved once it was read, never the empty form of a failed read. + const [loadedKey, setLoadedKey] = useState(''); + const [loading, setLoading] = useState(true); + const [loadError, setLoadError] = useState(''); + const [error, setError] = useState(''); + const [warning, setWarning] = useState(''); + const [removing, setRemoving] = useState(false); + // The team's members. The read made when the editor opens that fails leaves the + // list as it is and flags that it could not be read, unless a read's list is shown + // for the team by then: a lineup is still read and saved without it. A read made + // later that fails shows nothing and changes no flag. + const [squad, setSquadList] = useState([]); + const [squadUnavailable, setSquadUnavailable] = useState(false); + // squadRef mirrors squad for the editor's save, which resolves a typed name after + // it awaits (the first list, a member write). A read of the team's members that + // lands meanwhile (a change announced) may bring in a member another device + // created, which the list the save closed over when Save was tapped lacks: the + // resolver would mint the member a second time, and a member can never be removed. + // A list that arrives (setSquad, below) is put on it as it is shown, so it holds + // that list whenever the handler resumes, whether or not the render that shows it + // has run. + const squadRef = useRef(squad); + squadRef.current = squad; + // The read whose answer may still be shown: another lineup, or leaving, ends it. + const attempt = useRef(0); + // The number, in `attempt`, of the read of the lineup that is out (the first, a Try + // again, or the one of another lineup), null when none is. A lineup change announced + // meanwhile is not read over it: a read that replaced it could fail where it would + // have shown the lineup. `followDue` keeps the change until that read has shown the + // lineup, and drops it when the read fails. + const reading = useRef(null); + const followDue = useRef(false); + // The team's members are asked for by two reads (the one made when the editor + // opens, and the one a change announced for the team makes, followed or not), which + // need not answer in the order they began, and changed by the editor's own writes. None of that is ordered here: every list is + // merged by the stamp each member carries (mergeMembers), so a read answering late + // cannot put an older copy over a newer one. What is tied down is the team: `teamKey` + // is the team of the render a read, or a write's handler, was made in, and + // `squadTeam` the one the editor shows (set by the effect below), so a read begun for + // a team the editor has been given since, or an answer a handler of that team brings, + // is merged into no list of another team. + const teamKey = `${compId}/${teamId}`; + const squadTeam = useRef(teamKey); + // Whether a read's list is shown for this team, and the wait for it (see + // newMembersWait): what an editor's typed name and the failure of the read made as + // the editor opened both ask. Another team starts it again. A wait the deadline ended + // flags the members as unavailable, as a read that failed does, so the names typed + // after it say so too. + const firstMembers = useRef(null); + const newWait = () => newMembersWait(() => setSquadUnavailable(true)); + if (firstMembers.current === null) firstMembers.current = newWait(); + + // The ONE door every list of the team's members comes through: showMembers, below, + // for a read's answer, and changeMembers for an editor's own write. The list is + // merged with the one shown (see mergeMembers): of a member both hold the copy with + // the larger stamp stands, and no member is lost to a read begun before it was added + // or to a list built on an older one. It is on squadRef at once. It is not handed to + // the editors: what an editor writes goes through changeMembers. + const setSquad = (members) => { + const merged = mergeMembers(squadRef.current, members); + squadRef.current = merged; + setSquadList(merged); + }; + + // Shows what a members read for `team` answered: a list, not a failure (null) or the + // deadline (TIMED_OUT), and not one read for a team the editor has been given since. + const showMembers = (team, members) => { + if (team !== squadTeam.current || !Array.isArray(members)) return; + setSquad(members); + setSquadUnavailable(false); + firstMembers.current.show(); + }; + + // The ONE door for an editor's own write of the team's members, made once the server + // holds it (an add, a rename, a cleared name, a mint): `members` are the members as + // the server answered the write, with the stamp it gave them. A copy built from what + // was typed carries none and would lose to every list. The write is not followed by a + // read of its own: the server announces it to every device, this one included, which + // reads then, and the stamp keeps a read that began before the write from undoing it. + // It is not a list that was read, so the wait for the first list is not ended by it: + // a name typed next still needs the rest of the team. An answer a handler of another + // team brings, after the editor was given this one, is merged into no list. + const changeMembers = (members) => { + if (teamKey !== squadTeam.current) return; + setSquad(members); + }; + + // The server renamed a team member (a cleared name is the name ""): `member` is the + // member as it answered. It is merged in, and the name of the copy that stands (a + // list already shown may be newer than this answer, and then it is that list's) is + // shown on every position that holds the member and on the baseline, so a rename + // alone makes no edit of the lineup (the server rewrote the names of every stored + // lineup by id). The positions are read as they are now, not as the handler that asks + // closed over before its round trip: the pickers stay interactive meanwhile, and a + // position moved meanwhile would otherwise take the new name while the position it + // moved to kept the old spelling. + const memberRenamed = (member) => { + if (teamKey !== squadTeam.current) return; + setSquad([member]); + const { id } = member; + const name = (squadRef.current.find((m) => m.id === id) || member).name || ''; + setValues((v) => { + const next = { ...v }; + const ids = memberIdsRef.current; + Object.keys(ids).forEach((key) => { if (ids[key] === id) next[key] = name; }); + return next; + }); + setBaseline((b) => { + const positions = { ...b.positions }; + Object.keys(b.memberIds).forEach((key) => { if (b.memberIds[key] === id) positions[key] = name; }); + return { ...b, positions }; + }); + }; + + const readLineup = () => (matchId + ? resolveMatchLineup(compId, teamId, matchId, window.API, { throwOnError: true }) + : window.API.fetchTeamLineup(compId, teamId, STARTING_ROUND)); + + // Shows what was read (null when nothing applies: a fresh form) as the loaded + // state of the form. + const adopt = (lineup) => { + const loaded = lineupFields(lineup, positionKeys); + setValues(loaded.positions); + setMemberIds(loaded.memberIds); + setBaseline(loaded); + setSource(lineupSourceOf(lineup)); + setLoadError(''); + // Last: the draft sees the lineup as loaded only once everything above is set. + setLoadedKey(draftKey); + }; + + // Nothing is known of the lineup: an empty form that cannot be saved. + const forget = () => { + setValues(blankNames(positionKeys)); + setMemberIds({}); + setBaseline(EMPTY_SIDE); + setSource(null); + setLoadedKey(''); + }; + + const load = async () => { + const mine = ++attempt.current; + reading.current = mine; + // This read is of the lineup as it is now: a change announced before it began + // has nothing left to add. + followDue.current = false; + try { + const lineup = await readLineup(); + if (attempt.current === mine) adopt(lineup); + } catch (e) { + if (attempt.current === mine) { + setLoadError(lineupReadFailure(e)); + followDue.current = false; + } + } finally { + if (attempt.current === mine) { + reading.current = null; + setLoading(false); + } + } + }; + + // A save of this lineup that is still queued (offline, or refused for now) + // replays after anything sent now: removing the lineup would be undone by it. + // The API answers whether one is queued, which is all that matters here. + const queuedNow = () => { + const api = window.API; + return !!(api && typeof api.queuedLineupSave === 'function' && compId && teamId + && api.queuedLineupSave(compId, teamId, matchId ? { matchId } : { round: STARTING_ROUND })); + }; + + const read = loadedKey === draftKey; + const current = { positions: values, memberIds }; + const dirty = changedLineupPositions(baseline, current, positionKeys).length > 0; + + // Another device changed a lineup of this competition: read this one again and + // show it, unless the operator has edits on it or a removal is out, which are + // theirs and stay (the draft covers a reopen), or a read of the lineup is still + // out, which it waits for (see `reading`). This editor's own save is held off + // the same way, since the form stays dirty until confirmSaved. A read of a + // lineup already shown that fails leaves it as it is, raising no problem over a + // form that can still be used. The team's members are read with it, so a member + // another device created since is in the list when the lineup names them. The + // list is not the operator's edit: it is shown with the lineup, and on its own + // when the lineup is not shown (the form was edited meanwhile, or its read + // failed). Their read has the deadline of any bounded request, and one that fails + // or is not answered shows nothing and changes no flag. Both are dropped when + // this lineup read was ended meanwhile (see attempt). + const follow = async () => { + const shown = read; + const mine = ++attempt.current; + const [lineup, members] = await Promise.allSettled([ + readLineup(), + withinDeadline(readMembers(compId, teamId, password), FETCH_TIMEOUT_MS), + ]); + if (attempt.current !== mine) return; + showMembers(teamKey, members.value); + if (lineup.status === 'rejected') { + if (!shown) setLoadError(lineupReadFailure(lineup.reason)); + } else if (!live.current.touched) { + adopt(lineup.value); + } + setLoading(false); + }; + + // The team's members, read again on their own: the list is not the operator's edit, + // so a form that holds edits or a removal still shows a member another device + // created, and a typed name finds that member instead of minting it a second time. + // The read begins now, so it holds what the server holds now. It settles once the list + // is merged, or when the read failed or was not answered by the deadline of any + // bounded request, which shows nothing and keeps the list as it was. + const readMembersAgain = async () => { + showMembers(teamKey, await withinDeadline(readMembers(compId, teamId, password), FETCH_TIMEOUT_MS)); + }; + + // The effects below key on the lineup alone and call these as of the render they + // run in. + const live = useRef(null); + live.current = { load, follow, readMembersAgain, queuedNow, touched: dirty || removing }; + + useEffect(() => { + if (!compId || !teamId) { + setLoading(false); + return undefined; + } + live.current.load(); + return () => { attempt.current += 1; }; + }, [compId, teamId, matchId]); + + useEffect(() => { + if (!compId || !teamId) return undefined; + const onUpdated = (e) => { + if (e.detail && e.detail.competitionId !== compId) return; + // A change to another team's lineup or members is none of this team's: an + // announcement that names no team is read as a change to every team's. + if (e.detail && e.detail.teamId && e.detail.teamId !== teamId) return; + if (reading.current === attempt.current) followDue.current = true; + else if (live.current.touched) live.current.readMembersAgain(); + else live.current.follow(); + }; + window.addEventListener('lineup-updated', onUpdated); + return () => window.removeEventListener('lineup-updated', onUpdated); + }, [compId, teamId, matchId]); + + // A change announced while the lineup was being read is followed once that read has + // shown it. It starts here, not inside the read: `live` holds the render that shows + // the lineup, so the follow knows it is shown and a failure of its own raises no + // problem over it. + useEffect(() => { + if (!read || !followDue.current) return; + followDue.current = false; + if (!live.current.touched) live.current.follow(); + }, [read]); + + // The list shown is the one of the team it was read for: giving the editor another + // team drops it, so its members are not merged into the new team's list. + useEffect(() => { + if (squadTeam.current === teamKey) return; + squadTeam.current = teamKey; + squadRef.current = []; + setSquadList([]); + firstMembers.current = newWait(); + }, [teamKey]); + + // The team's members, independent of the lineup read: one that fails must not + // block loading or saving the lineup. `password` is a dependency, not just a + // closure read, and a read that succeeds clears the flag a failed one raised: + // the re-auth modal is a SIBLING of the admin app, so a 401 here unmounts + // nothing and neither compId nor teamId ever changes. Without both halves one + // 401 would leave the pickers empty for the editor's whole life, and every typed + // name would mint a new member instead of resolving to the one already on the + // team. Changing the team or the password, or leaving, ends this read: it was + // made for what no longer applies. Its failure flags the members as unavailable + // unless a read's list is shown for the team by then. + useEffect(() => { + if (!compId || !teamId) return undefined; + let ended = false; + readMembers(compId, teamId, password).then((members) => { + if (ended) return; + if (members) { + showMembers(teamKey, members); + } else if (!firstMembers.current.listed) { + setSquadUnavailable(true); + firstMembers.current.fail(); + } + }); + return () => { ended = true; }; + }, [compId, teamId, password, teamKey]); + + const retry = () => { + setLoadError(''); + setLoading(true); + load(); + }; + + const [saveQueued, setSaveQueued] = useState(queuedNow); + useEffect(() => { + const refresh = () => { + const now = live.current.queuedNow(); + setSaveQueued((was) => (was === now ? was : now)); + }; + refresh(); + const offs = []; + if (typeof window.subscribeSyncStatus === 'function') offs.push(window.subscribeSyncStatus(refresh)); + if (typeof window.subscribeUnsentWrites === 'function') offs.push(window.subscribeUnsentWrites(refresh)); + return () => offs.forEach((off) => { if (typeof off === 'function') off(); }); + }, [compId, teamId, matchId]); + + // Unsaved picks survive a reload, the app's Back and closing the editor; a + // restored draft is only shown, never saved. + const draft = useLineupDraft({ + key: draftKey, + ready: read, + baseline, + current, + positionKeys, + onRestore: (side) => { setValues(side.positions); setMemberIds(side.memberIds); }, + }); + + // What a Save carries (operator decision 2026-10-07, "Only changed positions"): the + // form as shown and `changed`, the positions the operator changed since the lineup + // was read or last confirmed. The server puts those on the lineup it holds when the + // save arrives, so a position the operator left alone keeps whatever another device + // made of it, and nothing is asked of the server here: a Save with no connection is + // built, and queued, like any other, and a position put back to what it was loaded + // with is no change at all. + // + // The team's members are not read for it either. A member another device creates is + // announced to this one (lineup-updated, which names the team), and the editor reads + // the members again for it, edits or not, so a typed name is resolved against a list + // that holds them; and a name the list missed is refused by the server as a second + // member of that name, which the editor's warning says. A read made here would hold a + // Save up to the deadline of any request on a link that does not answer. The server's + // answer to a save that does go through is shown after it (confirmSaved). + const lineupToSave = () => ({ + positions: values, memberIds, changed: changedLineupPositions(baseline, current, positionKeys), + }); + + // What the server answered to a save is what it holds now, the whole lineup: a + // position this save left alone shows what another device made of it, with its + // own member id. + const confirmSaved = ({ positions, memberIds: ids }) => { + // A read begun before the save would answer with the lineup from before it. + attempt.current += 1; + const stored = lineupFields({ positions, memberIds: ids }, positionKeys); + setValues(stored.positions); + setMemberIds(stored.memberIds); + setBaseline(stored); + if (matchId) setSource({ matchId }); + draft.resolved(); + }; + + // Removes the match's own lineup, then shows what the match carries without it. A + // removal the server refuses changes nothing. Once it is done the draft goes + // (it was made against the lineup that has just gone), and a read of what is + // carried now that fails leaves an empty form that cannot be saved, never the + // removed lineup shown as the match's own. + const dropOwnLineup = async () => { + if (saveQueued) return; + const ok = await window.confirmDialog(previousLineupConfirm(matchLabel, teamName)); + if (!ok) return; + setRemoving(true); + setError(''); + setWarning(''); + // A read begun before the removal would answer with the lineup that is going. + attempt.current += 1; + try { + try { + await window.API.deleteMatchLineup(compId, teamId, matchId, password); + } catch (e) { + setError(e?.message || "Failed to use the previous match's lineup"); + return; + } + removeDraft(draftKey); + draft.resolved(); + try { + adopt(await readLineup()); + } catch (_e) { + forget(); + setLoadError(REMOVED_UNREAD_NOTICE); + } + } finally { + setRemoving(false); + } + }; + + return { + values, setValues, memberIds, setMemberIds, memberIdsRef, baseline, source, + loading, loadError, retry, read, dirty, canSave: read && dirty, + saveTitle: read && !dirty ? NO_CHANGES_TITLE : undefined, + // A form that holds nothing read has no draft to offer back or discard. + draft: read ? draft : { ...draft, restored: false, stale: null }, + error, setError, warning, setWarning, + squad, changeMembers, memberRenamed, squadRef, squadUnavailable, waitForMembers: () => firstMembers.current.pending(), + removing, dropOwnLineup, saveQueued, lineupToSave, confirmSaved, + }; +} + +// LineupSourceLine: where the lineup shown was saved, and for a match's own +// lineup the button that gives it up (the match then carries its team's previous +// lineup). The button waits while a save of that lineup is still queued, and +// says why in a line, since a title never shows on a touchscreen. Shows nothing +// before the lineup was read: it would name a source the form does not hold. +// `form` is useLineupForm's answer; `busy` is the editor's own (a save is out). +export function LineupSourceLine({ form, matchId, allMatches, busy, testId }) { + if (!form.read) return null; + const own = isOwnLineup(form.source, matchId); + return ( +
+ + {lineupSourceLabel(form.source, matchId, allMatches)} + + {own && ( + + )} + {own && form.saveQueued && ( +
{SAVE_QUEUED_REASON}
+ )} +
+ ); +} + +// LineupProblem: why the lineup could not be read, kept until a read succeeds, +// with the way to try again. Nothing is saved meanwhile (useLineupForm's `read`). +export function LineupProblem({ form, testId }) { + if (!form.loadError) return null; + return ( +
+ {form.loadError} + +
+ ); +} diff --git a/web-mobile/js/lineup_rename.jsx b/web-mobile/js/lineup_rename.jsx index f189e000d..f5bf76e14 100644 --- a/web-mobile/js/lineup_rename.jsx +++ b/web-mobile/js/lineup_rename.jsx @@ -26,11 +26,13 @@ // documented reason: admin_schedule_lineup.jsx's per-position row is a fixed // two-column layout that must not grow WIDTH, so its Save/Cancel row sits // BELOW the input rather than beside it (see the comment on that branch -// below). `disabled` is the input's own disabled attribute, kept separate -// from `busy` (which always governs the two buttons and the Save label) -// because admin_schedule_lineup.jsx's input was never disabled while a -// rename was in flight -- a pre-existing difference between the two hosts, -// preserved here rather than smoothed away. +// below). `busy` is this rename being saved: it turns the two buttons off and +// puts "Saving…" on Save. `disabled` turns the input and both buttons off +// without that label, for a host whose own save is out (a rename made then +// would be overwritten by it). It is kept separate from `busy` because +// admin_schedule_lineup.jsx's input was never disabled while a rename was in +// flight -- a pre-existing difference between the two hosts, preserved here +// rather than smoothed away. export function renameMemberFields({ value, onChange, @@ -54,17 +56,23 @@ export function renameMemberFields({ onChange={(e) => onChange(e.target.value)} onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); onCommit(); } - else if (e.key === "Escape") { e.preventDefault(); onCancel(); } + else if (e.key === "Escape") { + // One Escape closes one layer: cancelling the rename must not also close + // the at-court panel the box is in (its Escape is a window listener). + e.preventDefault(); + e.stopPropagation(); + onCancel(); + } }} /> ); const saveButton = ( - ); const cancelButton = ( - + ); if (stacked) { // Input on top, Save and Cancel below: adds height, never width (a diff --git a/web-mobile/js/lineup_resolver.jsx b/web-mobile/js/lineup_resolver.jsx index 41a98762f..1c136bb93 100644 --- a/web-mobile/js/lineup_resolver.jsx +++ b/web-mobile/js/lineup_resolver.jsx @@ -1,13 +1,12 @@ -// lineup_resolver.js: shared helpers for resolving per-match and round-scoped -// team lineups across all consumer surfaces (admin scoring modal, viewer, +// lineup_resolver.js: shared helpers for resolving the lineup a team fields at +// a match across all consumer surfaces (admin scoring modal, viewer, // TvDisplay, StreamingOverlay). // // Do NOT import from admin_lineup.jsx; that module is an admin input panel // and may not be loaded on public/viewer surfaces. // // API shape expected: -// API.fetchMatchLineup(compId, teamId, matchId) → lineup | null -// API.fetchTeamLineup(compId, teamId, round) → lineup | null +// API.fetchLineupInForce(compId, teamId, matchId) → lineup | null // // Lineup shape: // { teamId, positions: { [posKey]: playerName }, memberIds: { [posKey]: memberId } } @@ -17,6 +16,8 @@ // posKey as `positions`; a lineup saved before squads existed simply omits it. import { squadMemberLabel, squadSlotLabel } from './squad_member_label.jsx'; +import { scoreRowMatchLabel } from './pool_ids.jsx'; +import { noAnswerSentence } from './write_result.jsx'; // squadRosterEntries: the ONE builder of a lineup picker's list for a team // (bc-dnst), shared by the score sheet's per-row picker (admin_scoring_team @@ -119,6 +120,51 @@ export function memberPlacedElsewhere(memberIds, posKey, id) { return hit ? hit[0] : ""; } +// alreadyPlacedNote: the ONE wording of the refusal memberPlacedElsewhere backs. +// `name` is the fighter (blank reads "This fighter") and `positionLabel` the +// position that already holds them, labelled the way the asking editor labels it. +export function alreadyPlacedNote(name, positionLabel) { + return `${String(name ?? "").trim() || "This fighter"} is already at ${positionLabel}.`; +} + +// lineupDuplicateNote: the sentence for a lineup that would field one member at +// two positions, or "" when it does not. Both editors ask it of the lineup a Save +// is about to write, so the refusal reads alike and never has to come from the +// server's own check. `labelOf` words a position key the way the editor labels it; +// `positionKeys` is the lineup's positions in order, `changed` the ones the operator +// changed (changedLineupPositions) and `typed` the ones among them that hold a name +// they typed (the panel's; the Lineups page types none): the note names a position +// they did not change, else one they picked the member for, else the earlier. +export function lineupDuplicateNote(positions, memberIds, labelOf, positionKeys, changed, typed = []) { + const nameAt = (key) => String(positions?.[key] ?? "").trim(); + for (const key of positionKeys) { + const otherKey = memberPlacedElsewhere(memberIds, key, memberIds?.[key]); + if (!otherKey) continue; + // `key` is the earlier of the two in position order. The position the operator + // changed is the one being refused, so the note names the one of the pair they did + // not change, which was already there (the earlier one when neither is theirs). + // When both are theirs it names the one they picked the member for, not the box + // they typed that member's name into; when both were typed, or both picked, the + // earlier one. The fighter is named from the position named, else from the other. + const pair = [key, otherKey]; + const there = pair.find((k) => !changed.includes(k)) ?? pair.find((k) => !typed.includes(k)) ?? key; + const here = there === key ? otherKey : key; + return alreadyPlacedNote(nameAt(there) || nameAt(here), labelOf(there)); + } + return ""; +} + +// changedLineupPositions: the position keys, among `positionKeys`, where +// `current` differs from `baseline` (both `{ positions, memberIds }` maps keyed +// by position). A key is changed when its trimmed name or its member id +// differs; "" and undefined are the same. The one answer to "did the operator +// edit this side", so a panel writes only a side that changed. +export function changedLineupPositions(baseline, current, positionKeys) { + const name = (side, key) => String(side?.positions?.[key] ?? "").trim(); + const id = (side, key) => String(side?.memberIds?.[key] ?? ""); + return positionKeys.filter(key => name(baseline, key) !== name(current, key) || id(baseline, key) !== id(current, key)); +} + export function rosterWithoutPlacedElsewhere(roster, lineup, posKey) { const otherNames = new Set(Object.entries(lineup?.positions || {}) .filter(([key, name]) => key !== posKey && String(name || "").trim()) @@ -161,9 +207,10 @@ export function memberRefusalNote(refusal, fallback) { return refusal && refusal.code === MEMBER_ALREADY_NAMED && refusal.reason ? refusal.reason : fallback; } -// mergeLineupIdsForPosition composes the WHOLE memberIds map an inline -// lineup write sends: carries `existingIds` forward untouched, then either -// sets `posKey` to `resolvedId` or CLEARS it -- clearing happens both when +// mergeLineupIdsForPosition composes the WHOLE memberIds map of the lineup an +// inline lineup write leaves behind (the sheet shows it; the save itself +// carries the one position it changes): carries `existingIds` forward +// untouched, then either sets `posKey` to `resolvedId` or CLEARS it -- clearing happens both when // the operator cleared the position (no name, so nothing to resolve) and // when a name was typed/picked but resolution/minting failed (offline venue // wifi). Either way a stale id must never survive under a position it no @@ -180,11 +227,15 @@ export function mergeLineupIdsForPosition(existingIds, posKey, resolvedId) { // buildInlineLineupWrite computes exactly what the inline lineup picker // (submitInlineLineup, inside TeamScoreEditorModal in admin_scoring_team.jsx) -// sends to putMatchLineup: the WHOLE positions map (existing + the one -// changed position) and its memberIds counterpart, merged via -// mergeLineupIdsForPosition above. Exported (and pulled out of the -// component) so this exact value-in/body-out contract -- including "a mint -// failure never blocks the write" -- is pinned directly, without mounting +// writes with putMatchLineup: the one position it changes, `changed: [posKey]` +// (operator decision 2026-10-07, "Only changed positions": the server puts it on +// the lineup it holds), and the lineup as it will be, `lineup` (what the sheet +// holds) with that position changed: the WHOLE positions map and its memberIds +// counterpart, merged via mergeLineupIdsForPosition above, which the sheet +// shows while the save is only queued. changedLineupSave (lineup_save.jsx) +// builds the body from the three. Exported (and pulled out of the component) so +// this exact value-in/body-out contract -- including "a mint failure never +// blocks the write" -- is pinned directly, without mounting // TeamScoreEditorModal, which vitest's hook stubs cannot drive through a // full interaction (see tie_button_no_term.test.jsx). It lives here, not in // the scoring module, because it IS the lineup-write authority every other @@ -272,52 +323,135 @@ export async function buildInlineLineupWrite(compId, teamId, lineup, squad, posK } const memberIds = mergeLineupIdsForPosition(lineup?.memberIds, posKey, resolvedId); - return { positions, memberIds, squad: nextSquad, failures }; + return { positions, memberIds, changed: [posKey], squad: nextSquad, failures }; } -// resolveMatchLineup: prefer the per-match lineup endpoint (GET -// match-lineups/:matchId); fall back to the round lineup when no per-match -// entry exists (saved: false -> null -> round lookup). Network errors on -// either endpoint are swallowed so the caller degrades gracefully. +// resolveMatchLineup: the lineup a team fields at a match, from ONE read (GET +// lineup-in-force/:matchId): the match's own lineup, else the one the team +// carries from its previous match, else its starting lineup. The server owns +// that rule (engine/lineup_in_force.go); no surface restates it. Nothing in +// force is null. The lineup says where it was saved: `sourceMatchId` (this +// match, or an earlier one it is carried from) or `sourceRound` (0, the team's +// starting lineup). // -// The round step passes { fallback: true }: match-scoring surfaces are the -// client-side twin of AMENDMENT 1, so when the match's own round has -// nothing saved the server resolves the closest saved round instead of -// answering unsaved (operators typically save one round-0 lineup for the -// whole day; without this, a knockout final at round index 1 got no names -// and kachinuki bout 1 was submitted with empty sides). The lineup EDITOR -// calls fetchTeamLineup directly without the flag, so its exact-round, -// nothing-saved-is-null semantics are unchanged. +// A failed read is swallowed so a display degrades gracefully (null), unless +// the caller passes { throwOnError: true }: the at-court lineup panel and the +// Lineups page do, because an editor that failed to read a lineup must not +// show an empty one and let Save overwrite it. // -// mp-bkg regression guard: the per-match endpoint must win when it returns a -// non-null result (the whole point of the per-match API). This function is -// tested directly in scoring_modal_match_lineup.test.jsx. -export async function resolveMatchLineup(compId, teamId, matchId, round, { fetchMatchLineup, fetchTeamLineup }) { - try { - const matchLineup = await fetchMatchLineup(compId, teamId, matchId); - if (matchLineup !== null) return matchLineup; - } catch (_e) { /* network: fall through */ } +// mp-bkg regression guard: a match's own lineup always wins (the server +// answers it first). This function is tested directly in +// scoring_modal_match_lineup.test.jsx. +export async function resolveMatchLineup(compId, teamId, matchId, { fetchLineupInForce }, { throwOnError = false } = {}) { try { - return await fetchTeamLineup(compId, teamId, round, { fallback: true }); - } catch (_e) { /* competition missing / network: ignore */ } + return await fetchLineupInForce(compId, teamId, matchId); + } catch (e) { + if (throwOnError) throw e; + return null; + } +} + +// lineupFields: what a lineup read holds, as the form fields of a lineup editor: +// the name and the member id of every position in `positionKeys` ("" for a +// position the lineup leaves vacant, or for no lineup at all). +export function lineupFields(lineup, positionKeys) { + const positions = {}; + const memberIds = {}; + positionKeys.forEach(key => { + positions[key] = ((lineup && lineup.positions) || {})[key] || ""; + memberIds[key] = ((lineup && lineup.memberIds) || {})[key] || ""; + }); + return { positions, memberIds }; +} + +// lineupSourceOf: where a lineup in force was saved, from the fields the +// server names it by: the match it was saved for (`sourceMatchId`: the match +// asked about, or an earlier one of the team it is carried from) or the team's +// starting lineup (`sourceRound`, 0). +export function lineupSourceOf(lineup) { + if (lineup && lineup.sourceMatchId) return { matchId: lineup.sourceMatchId }; + if (lineup && Number.isInteger(lineup.sourceRound)) return { round: lineup.sourceRound }; return null; } +// isOwnLineup: the lineup shown was saved for THIS match, as against one the +// match carries from an earlier match or from the starting lineup. The one +// answer to that question: the label below and the editors' "Use the previous +// match's lineup" button both ask it. +export function isOwnLineup(source, matchId) { + return !!source && !!matchId && source.matchId === matchId; +} + +// lineupSourceLabel: how a lineup editor says where the lineup it shows comes +// from (a team carries the lineup of its previous match unless one is entered +// for the match). A carried lineup names the earlier match as the scores list +// does (scoreRowMatchLabel), so the operator can find it. `allMatches` is the +// competition's matches, or a function returning them that is called only when +// the lineup is carried from another match: a caller that has to build the list +// pays for it then, not on every render. +export function lineupSourceLabel(source, matchId, allMatches) { + if (!source) return "No lineup saved yet"; + if (isOwnLineup(source, matchId)) return "Lineup for this match"; + if (source.matchId) { + const list = typeof allMatches === "function" ? allMatches() : allMatches; + const from = (list || []).find(m => m.id === source.matchId); + return `Same as ${(from && scoreRowMatchLabel(from)) || source.matchId}`; + } + return "Starting lineup"; +} + +// A team's starting lineup is stored as its round-0 entry. +export const STARTING_ROUND = 0; + +// The words both lineup editors (the at-court panel and the Lineups page) use +// to take a match's own lineup away so the match carries its team's previous +// one again: the button, the confirm that says what else follows, why the +// button waits while a save of that lineup is still queued (the save would +// replay after the removal and bring the lineup back), and what is said when the +// lineup the match now carries could not be read. +export const PREVIOUS_LINEUP_LABEL = "Use the previous match's lineup"; +export const SAVE_QUEUED_REASON = "A save of this lineup is still waiting to be sent."; +export const REMOVED_UNREAD_NOTICE = "Removed. The lineup this match now uses could not be read: try again."; + +// What a lineup editor says when the read of a lineup fails. A request that never +// reached the server rejects with a TypeError, whatever the browser's own text for +// it ("Failed to fetch" in one, "Load failed" in another), and a bounded request +// given up on is marked `timedOut`: both are said by one sentence that tells the +// operator what to do. An answer the server did send is said in its own words, +// or in the fallback when it carries none. +export const LINEUP_READ_NO_ANSWER = noAnswerSentence("The lineup could not be read"); +export function lineupReadFailure(error) { + if (error instanceof TypeError || (error && error.timedOut)) return LINEUP_READ_NO_ANSWER; + return (error && error.message) || "Failed to load lineup"; +} + +export function previousLineupConfirm(matchLabel, teamName) { + const match = matchLabel || "this match"; + const team = teamName || "the team"; + return { + message: `Use the lineup ${team} had before ${match}? The lineup entered for ${match} is removed, so ${team} carries the lineup of its previous match, or its starting lineup if this is its first match. Later matches that have no lineup of their own follow too. Unsaved changes here are discarded.`, + confirmLabel: "Use previous lineup", + cancelLabel: "Cancel", + }; +} + // resolveLineupTeamId maps a match-side key to the participant id that -// lineups are stored under. A match side's `id`, once resolved -// (api_serializers.resolveSide), is EITHER the participant's real id (a -// UUID) or "" -- resolveSide never invents an id from the display name. -// Callers build `sideKey` via sideLookupKey(side) (competitor_identity.jsx), -// so an unresolved side (id "") still falls through to its NAME here, and -// TeamLineups are keyed server-side by whatever team key was used when the -// lineup was saved; in practice, that's the participant's real id. Passing -// a bare name straight through can make the lineup GET read nothing saved, -// and the per-match (and round) lineup never reaches the scoring grid. -// We look the side up in the competition's participant list by id OR name -// and return its real id, falling back to the original key when unmatched. +// lineups are stored under. A lineup belongs to a team's participant id and the +// server matches it by that id alone: a side with no id has no lineup. A match +// side's `id`, once resolved (api_serializers.resolveSide), is EITHER the +// participant's real id (a UUID) or "" -- resolveSide never invents an id from +// the display name. Callers build `sideKey` via sideLookupKey(side) +// (competitor_identity.jsx), so an unresolved side (id "") still falls through +// to its NAME here, and the name is only a way to FIND the participant: the +// competition's participant list is searched by id OR name (the client's lookup +// of a participant row, not how the server matches a lineup) and the +// participant's real id is returned, which is what the lineup reads then +// address. When no participant matches, the key comes back as it was: an id the +// list does not hold still reads its lineup, a bare name reads nothing saved. // // bc-pnum: callers pass sideLookupKey(side) deliberately -- the name arm -// recovers a real id for an id-less side. No object overload (YAGNI). +// recovers the real id of a side the match carries by name alone. No object +// overload (YAGNI). export function resolveLineupTeamId(sideKey, players) { if (!sideKey) return ""; const list = Array.isArray(players) ? players : []; @@ -333,6 +467,15 @@ export const POS_KEYS_5 = ["senpo", "jiho", "chuken", "fukusho", "taisho"]; // Consumed by admin_scoring_team.jsx and streaming_overlay.jsx (single source). export const POS_LABELS_5 = POS_KEYS_5.map((s) => s.charAt(0).toUpperCase() + s.slice(1)); +// lineupPositionLabel: how a sentence names a lineup position, by its key: the +// FIK name for a five-person team's (Senpo, Jiho, ...), else "Position N", which +// reads on its own outside the rows that show the bare number. The one label every +// lineup editor and the score sheet put into alreadyPlacedNote. +export function lineupPositionLabel(posKey) { + const i = POS_KEYS_5.indexOf(posKey); + return i >= 0 ? POS_LABELS_5[i] : `Position ${posKey}`; +} + // resolveBoutSideName: which name identifies one side of a sub-bout row. // KACHINUKI numbered bouts are SERVER-FIRST: the engine appended the // pairing via winner-stays advancement (bout 5 is "winner of bout 4 vs diff --git a/web-mobile/js/lineup_save.jsx b/web-mobile/js/lineup_save.jsx new file mode 100644 index 000000000..e850dafac --- /dev/null +++ b/web-mobile/js/lineup_save.jsx @@ -0,0 +1,58 @@ +// lineup_save.jsx: what a lineup save carries, and how two saves of one lineup join in +// the offline queue (operator decision 2026-10-07, "Only changed positions"). +// +// A save names the positions it changed: { positions, memberIds?, changed }. The +// server reads only those positions and lands them on the lineup it holds when the +// save arrives, so two devices changing different positions of one lineup both keep +// their change, and a save made with no connection says no more than one made with +// it. A body with no `changed` is a whole lineup and replaces the stored one: what a +// save queued by an earlier build replays as. +// +// A leaf with no imports, ES-imported by api_client.jsx (the queue) and the editors +// (the body); there is no window mirror. + +// changedLineupSave: the body of a save of the positions `changed`, read off the +// lineup as the form shows it. A name is there for every changed position, "" for +// one the operator cleared (the server takes a changed position with no name for an +// error), and memberIds holds the ids of the positions that have one: a name "" with +// an id is a member picked before it was named, a placement of its own. +export function changedLineupSave(positions, memberIds, changed) { + const body = { positions: {}, memberIds: {}, changed: [...changed] }; + changed.forEach((key) => { + body.positions[key] = positions[key] || ''; + if (memberIds[key]) body.memberIds[key] = memberIds[key]; + }); + return body; +} + +// joinQueuedLineupSave: the save that takes the place of a queued save of the same +// lineup (`queued`) when `incoming` is made, so the lineup has ONE entry in the +// queue. It changes every position either changed, and where both did the later +// save's value stands, name and id (a position it cleared is "" with no id). +// +// A queued save that names no changed positions is a whole lineup, queued by an +// earlier build, and stays whole: the later save's changes are applied onto that +// lineup (a position it cleared is left out, as such a build sent it), and the join +// names none either. A later save that names none is a whole lineup itself and takes +// the queued one's place as it is. +// +// A copy, never a mutation: the request in flight and the broadcast share the objects. +export function joinQueuedLineupSave(queued, incoming) { + if (!Array.isArray(incoming.changed)) return incoming; + const whole = !Array.isArray(queued.changed); + const positions = { ...queued.positions }; + const memberIds = { ...queued.memberIds }; + incoming.changed.forEach((key) => { + const name = incoming.positions[key]; + const id = (incoming.memberIds && incoming.memberIds[key]) || ''; + if (whole && !name && !id) delete positions[key]; + else positions[key] = name; + if (id) memberIds[key] = id; + else delete memberIds[key]; + }); + const joined = { ...incoming, positions }; + if (queued.memberIds || incoming.memberIds) joined.memberIds = memberIds; + if (whole) delete joined.changed; + else joined.changed = [...queued.changed, ...incoming.changed.filter((key) => !queued.changed.includes(key))]; + return joined; +} diff --git a/web-mobile/js/match_scoreboard.jsx b/web-mobile/js/match_scoreboard.jsx index c3792dd1f..db3e36e3e 100644 --- a/web-mobile/js/match_scoreboard.jsx +++ b/web-mobile/js/match_scoreboard.jsx @@ -74,12 +74,11 @@ export function boutHansokuMark(foulCount) { // competition (or a payload predating this field) simply yields {} and // every lookup below degrades to []. // -// `roundIndex` (optional, 0-based) is the authoritative round for the -// round-scoped lineup fallback. Callers that know the bracket round (the TV -// display / overlay carry it as promoted.roundIndex) MUST pass it: do not -// rely on parsing match.round, which now holds a bracket-size display label -// ("Round 16"/"Round 32") in some surfaces and would misderive the round. -export function useTeamLineups(match, competition, roundIndex) { +// Each side's lineup is the one in force for its team at THIS match (a team +// carries the lineup of its previous match unless one is entered for the +// match; resolveMatchLineup reads it from the server, which owns the rule), so +// no round has to be derived here. +export function useTeamLineups(match, competition) { const [lineupA, setLineupA] = useSB(null); const [lineupB, setLineupB] = useSB(null); const [squadA, setSquadA] = useSB([]); @@ -161,29 +160,13 @@ export function useTeamLineups(match, competition, roundIndex) { console.warn("useTeamLineups: competition fetch failed", _e); } } - // Prefer the explicit 0-based round index. Only when it is absent do we - // fall back to match.round: a raw numeric index, or the legacy engine - // label "Round " (1-based round NUMBER → 0-based). We deliberately - // do NOT trust a bracket-size label here; callers with a real round pass - // roundIndex so this parse is never reached on those surfaces. - let round = 0; - if (typeof roundIndex === "number" && roundIndex >= 0) { - round = roundIndex; - } else if (typeof match.round === "number" && match.round >= 0) { - // A pool match stores Round -1 ("no round"); like resolveRoundIndex, - // it reads as round 0 rather than asking the server for round -1. - round = match.round; - } else if (typeof match.round === "string") { - const mr = /^Round\s+(\d+)$/.exec(match.round); - if (mr) round = parseInt(mr[1], 10) - 1; - } const teamAId = resolveLineupTeamId(sideAId, players); const teamBId = resolveLineupTeamId(sideBId, players); // Both sides are independent GETs: fetch them in parallel to halve the // time-to-render (the promoted match changes often on TV/overlay). const [la, lb] = await Promise.all([ - teamAId ? resolveMatchLineup(compId, teamAId, matchId, round, window.API) : null, - teamBId ? resolveMatchLineup(compId, teamBId, matchId, round, window.API) : null, + teamAId ? resolveMatchLineup(compId, teamAId, matchId, window.API) : null, + teamBId ? resolveMatchLineup(compId, teamBId, matchId, window.API) : null, ]); if (cancelled) return; if (teamAId) setLineupA(la); @@ -193,9 +176,7 @@ export function useTeamLineups(match, competition, roundIndex) { if (teamBId) setSquadB(squads[teamBId] || []); })(); return () => { cancelled = true; }; - // match?.round participates in the fallback-round lineup fetch, so a round - // change on a reused match id must re-run the effect. - }, [compId, matchId, sideAId, sideBId, roundIndex, match?.round, lineupVersion, squadsSig]); + }, [compId, matchId, sideAId, sideBId, lineupVersion, squadsSig]); return { lineupA, lineupB, squadA, squadB }; } diff --git a/web-mobile/js/published_height.jsx b/web-mobile/js/published_height.jsx new file mode 100644 index 000000000..8dbeb55ba --- /dev/null +++ b/web-mobile/js/published_height.jsx @@ -0,0 +1,29 @@ +// published_height.jsx: the ONE owner of "an element's height, published as a +// CSS custom property while it is mounted" (bc-tmfd). +// +// A sticky bar covers whatever the page scrolls under it, and CSS cannot read an +// element's height, so anything sized to a bar (the pinned team header sitting +// under the topbar, the scroll margin that keeps a focused control clear of the +// bars) is told the height: --topbar-stack-h by AdminTopbar, --team-pin-h and +// --team-dock-h by the team score sheet. +// +// Measured at once (a ResizeObserver first fires after paint, which would leave +// the first frame unsized), then kept current by the observer, because a bar +// grows with a connection alert, the running strip, a correction prompt or a +// banner. The property is removed when the bar goes. +// +// A leaf with no imports: every consumer ES-imports it directly. + +// Publishes el's border-box height as the custom property `prop` on `host` (the +// element whose scroll or layout the bar affects) and returns the function that +// stops it, which also removes the property. +export function publishHeight(el, host, prop) { + const publish = () => host.style.setProperty(prop, `${el.getBoundingClientRect().height}px`); + publish(); + const ro = typeof ResizeObserver === 'function' ? new ResizeObserver(publish) : null; + if (ro) ro.observe(el); + return () => { + if (ro) ro.disconnect(); + host.style.removeProperty(prop); + }; +} diff --git a/web-mobile/js/streaming_overlay.jsx b/web-mobile/js/streaming_overlay.jsx index 3c4302a0d..8c0bb6c2d 100644 --- a/web-mobile/js/streaming_overlay.jsx +++ b/web-mobile/js/streaming_overlay.jsx @@ -100,8 +100,7 @@ function StreamingOverlay({ court, position, competitions }) { // now hoists squads onto). const { lineupA: ovlLineupA, lineupB: ovlLineupB, squadA: ovlSquadA, squadB: ovlSquadB } = useTeamLineups( isTeamMatch && hasRunning ? running.match : null, - isTeamMatch && hasRunning ? comp : null, - hasRunning ? running.roundIndex : undefined + isTeamMatch && hasRunning ? comp : null ); // Current bout for the overlay. findCurrentBoutIndex returns an index diff --git a/web-mobile/js/tap_guard.jsx b/web-mobile/js/tap_guard.jsx index 3905b1434..ababdb56e 100644 --- a/web-mobile/js/tap_guard.jsx +++ b/web-mobile/js/tap_guard.jsx @@ -12,9 +12,9 @@ // Space carries detail === 0, and keyboard input is never swallowed (bc-kbrw). // // A leaf with no imports: every consumer ES-imports it directly, and there is -// no window mirror (the write_result.jsx pattern). useArmedConfirm reads the -// React global at CALL time, as ui.jsx's hooks do, so importing this module -// needs no React. +// no window mirror (the write_result.jsx pattern). useArmedConfirm and +// useOpenedTapGuard read the React global at CALL time, as ui.jsx's hooks +// do, so importing this module needs no React. // Longer than a double tap's gap, shorter than a deliberate second tap. Two // real ippon calls can never arrive this close: the shushin stops play for @@ -82,6 +82,26 @@ export const swallowBounce = (ref, key = KEYLESS) => (ev) => { ev.preventDefault(); }; +// useOpenedTapGuard: a layer opened by a tap (a confirm, a modal, an overlay +// editor) must not take the bounce of that tap, which lands on the fresh +// backdrop one render later (bc-cfbd). `openedRef` goes on the layer's node +// (or is called from a callback ref that already exists) and stamps when it +// mounts; `onClickCapture` goes on the backdrop and swallows a pointer bounce +// within TAP_BOUNCE_MS of that stamp. A confirm swallows every click in its +// layer; `{ backdropOnly: true }` swallows only a click on the backdrop itself, +// for an editor whose controls the operator may use at once. +// const { openedRef, onClickCapture } = useOpenedTapGuard(); +//
+export function useOpenedTapGuard({ backdropOnly = false } = {}) { + const stampRef = React.useRef(null); + const openedRef = React.useCallback((node) => { if (node) stampTap(stampRef); }, []); + const onClickCapture = React.useMemo(() => { + const swallow = swallowBounce(stampRef); + return backdropOnly ? (ev) => { if (ev.target === ev.currentTarget) swallow(ev); } : swallow; + }, [backdropOnly]); + return { openedRef, onClickCapture }; +} + // useArmedConfirm: the two-tap commit (Finish, Finish + Start Next, End // match). The first tap arms; a second tap commits only once the button has // been armed for TAP_BOUNCE_MS, so the bounce of the arming tap cannot commit. diff --git a/web-mobile/js/ui.jsx b/web-mobile/js/ui.jsx index 3cf6bd830..56c951bf3 100644 --- a/web-mobile/js/ui.jsx +++ b/web-mobile/js/ui.jsx @@ -1,5 +1,7 @@ // Shared UI primitives used by both admin and viewer modules. +import { useOpenedTapGuard } from './tap_guard.jsx'; + // Formats that run their matches through the pool pipeline but are not pools. // See the label note inside StatusBadge. const POOLS_PHASE_FORMAT_LABEL = { league: "League", swiss: "Swiss" }; @@ -72,6 +74,9 @@ const TOAST_ERROR_DWELL_MS = 8000; // windows. // // - success/info: role=status + aria-live=polite, short auto-dismiss. +// - pending: the same, with the pending icon instead of the success check: a +// write the device holds until the connection returns is not saved yet +// (worded by write_result.jsx's queuedNotice). // - error: role=alert + aria-live=assertive, long dwell (>=8s) plus a manual // dismiss control; cannot be clobbered by an incoming non-error toast. function Toast({ message, type, onClose }) { @@ -122,7 +127,7 @@ function Toast({ message, type, onClose }) { role={role} aria-live={ariaLive} > - +
{shown.message}
{shownIsError && (