diff --git a/.claude/rules/match-results-and-marks.md b/.claude/rules/match-results-and-marks.md index 4f262d1d3..aac218657 100644 --- a/.claude/rules/match-results-and-marks.md +++ b/.claude/rules/match-results-and-marks.md @@ -93,6 +93,8 @@ It projects `ModifiedAt` and `GroupStamps` too (bc-lww1, bc-mrgc). `mergeMatchWr Because an editor can now never rule on something it did not display, an explicit `false` is always the operator's decision and always reaches the wire as what it now IS, a markless scoreline: `daihyosenEnchoFields` still states the local boolean unconditionally, and the serializer chokepoint converts it (true → mark placed on the winner's side, false → arrays stay markless; `realIppons` strips echoed marks first so placement never doubles). Two earlier designs are recorded in `web-mobile/js/__tests__/render/individual_editor_live_hantei.render.test.jsx` and must not be reintroduced: recomputing the baseline from the live prop while freezing the local armed state (which erased the verdict on the next write), and freezing at mount with a `hanteiKnown` silence guard (which stopped the erase by accepting the divergence; the guard was deleted with it). +**The edit-time adopt rule.** A score editor holds its board against a server copy stamped before the operator's last unsent edit, because comparing values alone reads a tap taken back during a save as untouched, and that save's echo then wipes it. Its three owners are the team sheet's `rowTakenFromServer`/`repRowTakes` (bc-kclr), the individual editor's `adoptServerScore` (bc-ltap) and the engi editor's adopt (`flagsEditedAtRef`, bc-evts). `landedAutosave` (admin_scoring_autosave.jsx) clears a stamp once a save carrying the last edit has landed, so every later server copy is followed. All three editors take the stamp only while the match is running, because only then is the edit autosaved: on a scheduled match or a correction of a completed one nothing is sent, no landed save would ever clear the stamp, and a net-zero fiddle would make the editor refuse another device's copy it would otherwise have adopted. All three also clear it when the match leaves running (an effect keyed on the status): another device finishing the match or sending it back to the queue inside the debounce makes `fireRunningWrite` drop the write with no callback, so no landed save would clear it. A REFUSED autosave (`writeWasRefused`: nothing handed back, or `applied:false`) stores and broadcasts nothing, so no later copy would re-run the adopt and the stamp would hold the operator's value against the server's for good. `landedAutosave` therefore takes an optional `onRefused(seq)`; each editor, when no edit was made since that save was built (the `seq` still matches), clears the stamp and takes the server's CURRENT copy through its own adopt path (the engi counts, the individual `applyServerScore`, the team sheet's rows and pick parts the save carried, composed by `composeRepRow`), reading the latest render's closure through a ref. An edit made since keeps the stamp and the save that carries it decides; the individual and engi editors also require the stamp still to be set, because their counter counts only running edits, so a correction tapped on a match that finished elsewhere while the save was out is not wiped by a late refusal. A QUEUED answer calls neither callback and keeps the stamp: that write will land. A queued write that the server later refuses on replay is not seen by the editor (no promise reaches it), so its stamp stays until the match leaves running or a later save lands. A `clock_skew` answer (`writeWasRefusedForClock`) calls neither callback either and keeps the stamp: `recordScore` keeps a running one silent, relearns the clock and leaves the tap on the board for the NEXT save to carry with a corrected stamp (nothing re-sends it by itself; `_handleOkBody` only relearns). Known limits: the team sheet's refusal adopt (`takeServerRowsRef`) restores bout rows and representative picks only, so the representative-bout verdict and the encounter overtime stay local after a refused save; and a save applied IN PART (`heldGroups`) counts as landed and clears the stamp, and no new broadcast follows when the server's value for a held group equals the copy the adopt already skipped; that stays the genuine disagreement `keepLocalEdits` leaves to the operator, with the held values in the match's history. + ## Silence on the wire Silence still means "no opinion" on the wire. It is now OMITTED IPPONS (nil, not `[]`) rather than an omitted flag, and the distinction is load-bearing: an explicit `[]` is the operator saying ZERO (a 0-0 withdrawal), a missing key is a writer with nothing to say, whose stored verdict `preserveSubHantei` restores. The team editor IS one of those writers: `buildPatch` (admin_scoring_team.jsx) has a `daihyosenSilent` branch that OMITS the daihyosen row's ippon arrays when the operator has not touched that row this session AND nothing about it is known locally. That branch is the only reason the nil case is reachable from a real client at all, so do NOT "fix" it into an unconditional write. Any touch, or any locally-known verdict or scoreline, falls through to the explicit-array path, so a deliberate withdrawal still withdraws. Scope note: the restore protects a stored HANTEI verdict only; `preserveSubHantei` bails when the stored row carries no mark, so a rep bout decided by a strike is not restored by this mechanism. diff --git a/Makefile b/Makefile index f372d102a..12795d66b 100644 --- a/Makefile +++ b/Makefile @@ -378,9 +378,10 @@ docs/media: go/build $(SHOTS_BROWSER_STAMP) ## Regenerate every captured screens @node $(SHOTS_DIR)/run.mjs $(if $(KIND),'KIND=$(KIND)',) $(SHOTS_ARGS) # Local e2e journeys (scripts/e2e, see its README.md). Like the capture harness -# above, Playwright lives in its own package.json rather than web-mobile's, and -# both pin the same Playwright release so they share one downloaded Chromium -# under ~/.cache/ms-playwright. Deliberately NOT part of go/test and not run in +# above, Playwright lives in its own package.json rather than web-mobile's. Both +# lockfiles currently resolve the same Playwright release (1.63.0; the capture +# harness's package.json only asks for ^1.56.0), so they share one downloaded +# Chromium under ~/.cache/ms-playwright. Deliberately NOT part of go/test and not run in # CI: a journey drives the real app through a browser, setup wizard and all. E2E_DIR := scripts/e2e E2E_DEPS_STAMP := $(E2E_DIR)/node_modules/.package-lock.json diff --git a/docs/user-guide/organisers/run-tournament.md b/docs/user-guide/organisers/run-tournament.md index 6e0b3b4c0..0fdc3e066 100644 --- a/docs/user-guide/organisers/run-tournament.md +++ b/docs/user-guide/organisers/run-tournament.md @@ -231,6 +231,8 @@ A draw-ready competition is already listed on the shiai-jo operator views, since The **Pools** tab shows standings for every pool. Ranks are computed automatically from match results. Operators do not edit them by hand, with one exception: chusen (drawing lots), the last-resort tie-break for a consequential team-pool tie that a daihyosen cannot settle. Refer to [Recording decisions](../court-operators/recording-decisions.md). When a daihyosen settles a tie that determines pool advancement, the winning side carries a **DH** badge in the standings. +When two or more competitors are level on every ranking criterion, the app schedules tie-break bouts between them if the best-placed of them finishes within the pool's qualifying places, because finishing place decides where a qualifier lands in the bracket. Those places are **Winners per pool**, plus the extra place an oversized pool sends under **Oversized send +1**, plus the second place of each pool that **Fit the knockout** drafts to fill the bracket, so a tie over such an extra place gets a bout too. A tie wholly below the qualifying places gets no bout and shares its place. The pool's qualifiers are seated in the bracket only once the bouts are scored. Until the bouts are scored, the tied competitors share one place, shown as **=2nd** for a tie over second place. A tie that runs across the advancing line leaves none of them with the advancing mark, while a tie inside the advancing places, such as two competitors level for first, reads **=1st** and keeps it. Once the bouts are scored, each takes their own place. A team pool settles a tie with a daihyosen instead, and if that round still leaves teams level, the pool waits for chusen. In an individual pool, each tied pair fights once, and no second round is scheduled if that leaves them level. + Pool finishers move into the knockout bracket on their own: as soon as a pool's last match is scored, its qualifiers are seeded into their bracket slots. Those knockout matches can be scored without waiting for the other pools. Once the last pool is seeded, the competition moves to the knockout phase. The bracket updates in real time as results come in. A pool result corrected later still moves its qualifiers in the bracket; refer to [Correct a pool result after the knockout has started](../court-operators/scoring-a-match.md#correct-a-pool-result-after-the-knockout-has-started). ![The Pools tab: each pool as a card with its standings in draw order, every competitor carrying their number and a rank badge, the pool's matches with their results, and the head-to-head grid beneath.](../../screenshots/mobile-pool-standings.png) diff --git a/internal/engine/chusen.go b/internal/engine/chusen.go index 6543efdcb..7e92f1bc4 100644 --- a/internal/engine/chusen.go +++ b/internal/engine/chusen.go @@ -198,7 +198,14 @@ func (e *Engine) ChusenStatus(compID string) (ChusenReport, error) { if overridesObj != nil { poolRanks = overridesObj.PoolRanks } - poolWinners := comp.EffectivePoolWinners() + pools, err := e.store.LoadPools(compID) + if err != nil { + return report, err + } + bracket, err := e.store.LoadBracket(compID) + if err != nil { + return report, err + } poolNames := make([]string, 0, len(standings)) for name := range standings { @@ -209,9 +216,10 @@ func (e *Engine) ChusenStatus(compID string) (ChusenReport, error) { for _, poolName := range poolNames { poolStandings := standings[poolName] groupOverrides := poolRanks[poolName] + cut := poolTieBreakCut(comp, pools, bracket, poolName) for _, positions := range detectPoolTies(poolStandings) { // Only a tie that affects advancement/seed warrants a decider at all. - if !tieAffectsAdvancement(positions, poolWinners) { + if !tieAffectsAdvancement(positions, cut) { continue } group := standingsAt(poolStandings, positions) diff --git a/internal/engine/daihyosen.go b/internal/engine/daihyosen.go index 1e74bd58b..c5afd3095 100644 --- a/internal/engine/daihyosen.go +++ b/internal/engine/daihyosen.go @@ -238,6 +238,14 @@ func (e *Engine) InjectPoolDaihyosenMatches(compID string) ([]state.MatchResult, return err } e.noteMatchRead(compID) + pools, err := tx.LoadPools(compID) + if err != nil { + return err + } + bracket, err := tx.LoadBracket(compID) + if err != nil { + return err + } // Scan existing DH matches per pool for idempotency and ID sequencing. // poolNameFromMatchID strips the trailing suffix deterministically so @@ -281,7 +289,7 @@ func (e *Engine) InjectPoolDaihyosenMatches(compID string) ([]state.MatchResult, if regularIncomplete[poolName] { continue } - for _, p := range pendingTieBreaks(comp, poolName, poolStandings, poolDH[poolName], poolCourt[poolName], true) { + for _, p := range pendingTieBreaks(comp, poolName, poolTieBreakCut(comp, pools, bracket, poolName), poolStandings, poolDH[poolName], poolCourt[poolName], true) { injected = append(injected, p.bouts...) } } diff --git a/internal/engine/pool_daihyosen_bandaware_test.go b/internal/engine/pool_daihyosen_bandaware_test.go index 29c9d0885..fb41df20d 100644 --- a/internal/engine/pool_daihyosen_bandaware_test.go +++ b/internal/engine/pool_daihyosen_bandaware_test.go @@ -13,7 +13,9 @@ import ( // These tests pin the band-aware rule for the pool daihyosen: a supplementary // bout is played only where the tie affects who advances (or their seed). The -// top `poolWinners` of each pool advance and 1st place gets a bracket bye, so a +// top `poolWinners` of each pool advance (the pool's own qualifier count, which +// is larger for an oversized pool under larger-pools; see +// tiebreak_extra_qualifier_test.go) and 1st place gets a bracket bye, so a // tie whose best position is within [1..poolWinners] is consequential, while a // tie sitting entirely below the cut shares its rank with no bout // (running_a_kendo_tournament.md:441 - a daihyosen is played "to determine @@ -21,15 +23,19 @@ import ( // setupTeamPoolWinners is setupTeamPool with an explicit advancement cutoff so a // tie can be placed above, across, or below the qualifying band. -func setupTeamPoolWinners(t *testing.T, compID string, teams []string, poolWinners int, matches []state.MatchResult) (*Engine, *state.Store) { +func setupTeamPoolWinners(t *testing.T, compID string, teams []string, poolWinners int, matches []state.MatchResult, opts ...func(*state.Competition)) (*Engine, *state.Store) { t.Helper() eng, store, _ := setupTestEngine(t) - require.NoError(t, store.SaveCompetition(&state.Competition{ + comp := &state.Competition{ ID: compID, Name: "Band-aware DH Test", Format: state.CompFormatLeague, Status: state.CompStatusPools, Courts: []string{"A"}, Kind: "team", TeamSize: 2, PoolWinners: poolWinners, - })) + } + for _, o := range opts { + o(comp) + } + require.NoError(t, store.SaveCompetition(comp)) players := make([]helper.Player, len(teams)) for i, n := range teams { players[i] = helper.Player{Name: n} diff --git a/internal/engine/pool_requalify.go b/internal/engine/pool_requalify.go index 2e720117a..1426656e3 100644 --- a/internal/engine/pool_requalify.go +++ b/internal/engine/pool_requalify.go @@ -149,7 +149,7 @@ func (e *Engine) planRequalification(tx state.StoreTx, compID string, comp *stat // label. Engi places are decided by the standings' own order. tied := map[string]bool{} if !comp.Engi { - tied, err = e.tiedQualifyingLabels(tx, compID, comp, poolName, standings[poolName], ranksNeeded) + tied, err = e.tiedQualifyingLabels(tx, compID, comp, poolName, poolTieBreakCut(comp, pools, bracket, poolName), standings[poolName], ranksNeeded) if err != nil { return nil, err } @@ -253,7 +253,7 @@ func rankOfLabel(poolName, label string, ranksNeeded int) int { // tiedQualifyingLabels returns the pool's qualifying labels whose rank sits in // a tied group that still owes a supplementary bout (pendingTieBreaks, the // injectors' own rule), read through the transaction. -func (e *Engine) tiedQualifyingLabels(tx state.StoreTx, compID string, comp *state.Competition, poolName string, poolStandings []state.PlayerStanding, ranksNeeded int) (map[string]bool, error) { +func (e *Engine) tiedQualifyingLabels(tx state.StoreTx, compID string, comp *state.Competition, poolName string, cut int, poolStandings []state.PlayerStanding, ranksNeeded int) (map[string]bool, error) { rows, err := tx.LoadPoolMatches(compID) if err != nil { return nil, fmt.Errorf("requalification: load pool matches for %s: %w", compID, err) @@ -273,7 +273,7 @@ func (e *Engine) tiedQualifyingLabels(tx state.StoreTx, compID string, comp *sta } } tied := map[string]bool{} - for _, p := range pendingTieBreaks(comp, poolName, poolStandings, existing, court, daihyosen) { + for _, p := range pendingTieBreaks(comp, poolName, cut, poolStandings, existing, court, daihyosen) { for _, pos := range p.positions { if pos+1 <= ranksNeeded { tied[qualifierLabel(poolName, pos+1)] = true diff --git a/internal/engine/tiebreak_extra_qualifier_test.go b/internal/engine/tiebreak_extra_qualifier_test.go new file mode 100644 index 000000000..e179bcc29 --- /dev/null +++ b/internal/engine/tiebreak_extra_qualifier_test.go @@ -0,0 +1,660 @@ +package engine + +import ( + "fmt" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/gitrgoliveira/bracket-creator/internal/domain" + "github.com/gitrgoliveira/bracket-creator/internal/helper" + "github.com/gitrgoliveira/bracket-creator/internal/state" + bctest "github.com/gitrgoliveira/bracket-creator/internal/test/idstamp" +) + +// bc-xqtb: with extraQualifiers larger-pools an oversized pool sends one more +// qualifier than Winners per pool, so a tie over THAT place decides who goes +// through exactly as a tie over any other qualifying place does. The cut a tie +// is judged against is the pool's own qualifier count (poolTieBreakCut, which is +// Competition.QualifiersForPool), never the uniform Winners per pool. + +// largerPools makes c a minimum-size competition whose pools above poolSize +// players send one extra qualifier. +func largerPools(poolSize int) func(*state.Competition) { + return func(c *state.Competition) { + c.PoolSize = poolSize + c.ExtraQualifiers = state.ExtraQualifiersLargerPools + } +} + +// poolBouts numbers a pool's completed bouts in the order given; a winner of "" +// is a hikiwake. +func poolBouts(pool string, pairs ...[3]string) []state.MatchResult { + out := make([]state.MatchResult, len(pairs)) + for i, p := range pairs { + out[i] = state.MatchResult{ + ID: fmt.Sprintf("%s-%d", pool, i), SideA: p[0], SideB: p[1], + Status: state.MatchStatusCompleted, Winner: p[2], Court: "A", + } + if p[2] == "" { + out[i].Decision = string(domain.DecisionHikiwake) + } + } + return out +} + +// Four players: Alice unbeaten, Bob and Carol level for 2nd (each beat Dave and +// drew each other), Dave last. +func fourPlayerTieForSecond() []state.MatchResult { + return poolBouts("Pool A", + [3]string{"Alice", "Bob", "Alice"}, [3]string{"Alice", "Carol", "Alice"}, + [3]string{"Alice", "Dave", "Alice"}, [3]string{"Bob", "Carol", ""}, + [3]string{"Bob", "Dave", "Bob"}, [3]string{"Carol", "Dave", "Carol"}) +} + +// Five players, Alice and Bob on top in that order. Carol and Dave are level +// for 3rd (one win over Erin each, a hikiwake between them); Erin is last. +func fivePlayerTieForThird() []state.MatchResult { + return poolBouts("Pool A", + [3]string{"Alice", "Bob", "Alice"}, [3]string{"Alice", "Carol", "Alice"}, + [3]string{"Alice", "Dave", "Alice"}, [3]string{"Alice", "Erin", "Alice"}, + [3]string{"Bob", "Carol", "Bob"}, [3]string{"Bob", "Dave", "Bob"}, + [3]string{"Bob", "Erin", "Bob"}, [3]string{"Carol", "Dave", ""}, + [3]string{"Carol", "Erin", "Carol"}, [3]string{"Dave", "Erin", "Dave"}) +} + +// Five players with a strict top three; Dave and Erin are level for 4th. +func fivePlayerTieForFourth() []state.MatchResult { + return poolBouts("Pool A", + [3]string{"Alice", "Bob", "Alice"}, [3]string{"Alice", "Carol", "Alice"}, + [3]string{"Alice", "Dave", "Alice"}, [3]string{"Alice", "Erin", "Alice"}, + [3]string{"Bob", "Carol", "Bob"}, [3]string{"Bob", "Dave", "Bob"}, + [3]string{"Bob", "Erin", "Bob"}, [3]string{"Carol", "Dave", "Carol"}, + [3]string{"Carol", "Erin", "Carol"}, [3]string{"Dave", "Erin", ""}) +} + +func TestInjectTiebreaker_CutIsThePoolsOwnQualifierCount(t *testing.T) { + four := []string{"Alice", "Bob", "Carol", "Dave"} + five := []string{"Alice", "Bob", "Carol", "Dave", "Erin"} + tests := []struct { + name string + players []string + matches []state.MatchResult + opts []func(*state.Competition) + want [][2]string // the injected bouts' sides + }{ + { + // The shape the wizard allows: larger-pools locks Winners per pool at + // 1, so the oversized pool's extra place is its 2nd. + name: "larger-pools, 1 winner, 4 players, 2nd and 3rd level: the extra 2nd place is tie-broken", + players: four, matches: fourPlayerTieForSecond(), + opts: []func(*state.Competition){largerPools(3), func(c *state.Competition) { c.PoolWinners = 1 }}, + want: [][2]string{{"Bob", "Carol"}}, + }, + { + name: "1 winner, no extra qualifiers: a tie for 2nd shares its place with no bout", + players: four, matches: fourPlayerTieForSecond(), + opts: []func(*state.Competition){func(c *state.Competition) { c.PoolWinners = 1 }}, + }, + { + name: "larger-pools, 4 players, 3rd and 4th level: the extra place is tie-broken", + players: four, matches: fourPlayerOneTiedPairTB(), + opts: []func(*state.Competition){largerPools(3)}, + want: [][2]string{{"Carol", "Dave"}}, + }, + { + name: "no extra qualifiers: the same tie shares its place with no bout", + players: four, matches: fourPlayerOneTiedPairTB(), + }, + { + name: "larger-pools but the pool is not oversized: no extra place to break", + players: four, matches: fourPlayerOneTiedPairTB(), + opts: []func(*state.Competition){largerPools(4)}, + }, + { + name: "larger-pools, 5 players, 3rd and 4th level: the extra place is tie-broken", + players: five, matches: fivePlayerTieForThird(), + opts: []func(*state.Competition){largerPools(3)}, + want: [][2]string{{"Carol", "Dave"}}, + }, + { + name: "larger-pools, a tie wholly below the extra place gets no bout", + players: five, matches: fivePlayerTieForFourth(), + opts: []func(*state.Competition){largerPools(3)}, + }, + { + name: "no extra qualifiers: a tie for 3rd shares its place with no bout", + players: five, matches: fivePlayerTieForThird(), + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + eng, store, _ := setupTestEngine(t) + comp := &state.Competition{ + ID: "xqtb", Name: "xqtb", Format: state.CompFormatMixed, + Status: state.CompStatusPools, Courts: []string{"A"}, PoolWinners: 2, + } + for _, o := range tc.opts { + o(comp) + } + require.NoError(t, store.SaveCompetition(comp)) + players := make([]helper.Player, len(tc.players)) + for i, n := range tc.players { + players[i] = helper.Player{Name: n, Dojo: "Dojo " + n} + } + bctest.StampIDs(players, tc.matches) + require.NoError(t, store.SavePools("xqtb", []helper.Pool{{PoolName: "Pool A", Players: players}})) + require.NoError(t, store.SavePoolMatches("xqtb", tc.matches)) + + injected, err := eng.InjectTiebreakerMatches("xqtb") + require.NoError(t, err) + got := make([][2]string, len(injected)) + for i, m := range injected { + got[i] = [2]string{m.SideA, m.SideB} + } + if len(tc.want) == 0 { + assert.Empty(t, got) + return + } + assert.Equal(t, tc.want, got) + }) + } +} + +func TestInjectPoolDaihyosen_CutIsThePoolsOwnQualifierCount(t *testing.T) { + tests := []struct { + name string + opts []func(*state.Competition) + want int + }{ + {"larger-pools, 4 teams, 3rd and 4th level: the extra place gets a representative bout", []func(*state.Competition){largerPools(3)}, 1}, + {"no extra qualifiers: the same tie shares its place", nil, 0}, + {"larger-pools but the pool is not oversized", []func(*state.Competition){largerPools(4)}, 0}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + eng, _ := setupTeamPoolWinners(t, "dh-xqtb", []string{"Alpha", "Beta", "Gamma", "Delta"}, 2, fourTeamOneTiedPair(), tc.opts...) + injected, err := eng.InjectPoolDaihyosenMatches("dh-xqtb") + require.NoError(t, err) + require.Len(t, injected, tc.want) + if tc.want == 1 { + got := map[string]bool{injected[0].SideA: true, injected[0].SideB: true} + assert.True(t, got["Gamma"] && got["Delta"], "the representative bout is between the level teams") + } + }) + } +} + +// A representative bout that leaves the extra-place tie level is a chusen, so the +// operator is offered the lots-draw for it. +func TestChusenStatus_CutIsThePoolsOwnQualifierCount(t *testing.T) { + for _, tc := range []struct { + name string + opts []func(*state.Competition) + want int + }{ + {"larger-pools: a level extra place needs a chusen", []func(*state.Competition){largerPools(3)}, 1}, + {"no extra qualifiers: nothing to draw", nil, 0}, + } { + t.Run(tc.name, func(t *testing.T) { + compID := "chusen-xqtb" + eng, store := setupTeamPoolWinners(t, compID, []string{"Alpha", "Beta", "Gamma", "Delta"}, 2, fourTeamOneTiedPair(), tc.opts...) + _, err := eng.InjectPoolDaihyosenMatches(compID) + require.NoError(t, err) + all, err := store.LoadPoolMatches(compID) + require.NoError(t, err) + for i := range all { + if IsPoolDaihyosenMatchID(all[i].ID) { + all[i].Status = state.MatchStatusCompleted + all[i].Winner = "" // hikiwake: the round leaves them level + } + } + require.NoError(t, store.SavePoolMatches(compID, all)) + eng.standingsCache.Delete(compID) + eng.standingsFlight.Delete(compID) + + report, err := eng.ChusenStatus(compID) + require.NoError(t, err) + assert.Len(t, report.Pending, tc.want) + }) + } +} + +// The whole path: the extra place's tie holds the pool open, the bout is +// scored, and its winner is seated in the knockout slot of that place. +func TestLargerPoolsExtraPlaceTie_QualificationWaitsForTheBout(t *testing.T) { + f := newRQFixture(t, "rq-xqtb", 2, [][]string{{"A1", "A2", "A3", "A4"}, {"B1", "B2", "B3"}}, largerPools(3)) + require.NoError(t, f.store.SaveBracket(f.compID, &state.Bracket{Rounds: [][]state.BracketMatch{{ + {ID: "m-r1-0", MatchNumber: 1, SideA: "Pool A-1st", SideB: "Pool B-2nd", PlaceholderA: "Pool A-1st", PlaceholderB: "Pool B-2nd"}, + {ID: "m-r1-1", MatchNumber: 2, SideA: "Pool B-1st", SideB: "Pool A-2nd", PlaceholderA: "Pool B-1st", PlaceholderB: "Pool A-2nd"}, + {ID: "m-r1-2", MatchNumber: 3, SideA: "Pool A-3rd", SideB: "Pool B-3rd", PlaceholderA: "Pool A-3rd", PlaceholderB: "Pool B-3rd"}, + }}})) + // Pool A: A1 beats all, A2 beats A3 and A4, A3 and A4 draw: level for 3rd, + // the place only an oversized pool sends. + for _, w := range [][2]string{{"Pool A-0", "A1"}, {"Pool A-1", "A1"}, {"Pool A-2", "A1"}, {"Pool A-3", "A2"}, {"Pool A-4", "A2"}} { + f.scorePool(w[0], w[1]) + } + require.NoError(t, f.write("Pool A-5", &state.MatchResult{ + SideA: "A3", SideB: "A4", SideAID: rqID("A3"), SideBID: rqID("A4"), + Status: state.MatchStatusCompleted, Decision: string(domain.DecisionHikiwake), + })) + for _, w := range [][2]string{{"Pool B-0", "B1"}, {"Pool B-1", "B1"}, {"Pool B-2", "B2"}} { + f.scorePool(w[0], w[1]) + } + + _, err := f.eng.MaybeAutoCompletePools(f.compID) + require.NoError(t, err) + var tbs []state.MatchResult + matches, err := f.store.LoadPoolMatches(f.compID) + require.NoError(t, err) + for _, m := range matches { + if IsTiebreakerMatchID(m.ID) { + tbs = append(tbs, m) + } + } + require.Len(t, tbs, 1, "the tie over the extra qualifier place is owed a tie-break bout") + assert.ElementsMatch(t, []string{"A3", "A4"}, []string{tbs[0].SideA, tbs[0].SideB}) + + m, side := f.slot("Pool A-3rd") + name, _ := sideOf(m, side) + assert.Equal(t, "Pool A-3rd", name, "the place stays open while the bout is unplayed") + + f.scorePool(tbs[0].ID, "A4") + _, err = f.eng.MaybeAutoCompletePools(f.compID) + require.NoError(t, err) + m, side = f.slot("Pool A-3rd") + name, id := sideOf(m, side) + assert.Equal(t, "A4", name, "the bout's winner takes the extra place") + assert.Equal(t, rqID("A4"), id) + m, side = f.slot("Pool A-2nd") + name, _ = sideOf(m, side) + assert.Equal(t, "A2", name, "the rest of the pool is seated once the bout is played") +} + +// bc-fbtb: under fill-bracket some pools are DRAFTED to send their 2nd as well +// (helper.SelectFillBracketDraftIndices), and Competition.QualifiersForPool +// deliberately cannot say which. A tie over a drafted pool's 2nd decides who +// takes the "Pool X-2nd" knockout place, so it is owed a bout exactly like a +// tie over any other qualifying place; the same tie in a pool that was not +// drafted shares its place. The tests draw through the real pipeline and read +// the drafted pool off the stored bracket, so they fail if the cut stops +// following the draw. + +// startFillBracket draws an 11-entrant fill-bracket competition (Winners per +// pool 1, min 3: pools of 3, 4 and 4, one 2nd drafted from an oversized pool) +// and returns the drafted pool and the oversized pool left undrafted. +func startFillBracket(t *testing.T, compID string, team bool) (eng *Engine, store *state.Store, drafted, undrafted string) { + t.Helper() + eng, store, _ = setupTestEngine(t) + createTestCompetition(t, store, compID, state.CompFormatMixed, 3, func(c *state.Competition) { + c.PoolWinners = 1 + c.ExtraQualifiers = state.ExtraQualifiersFillBracket + if team { + c.Kind = "team" + c.TeamSize = 2 + } + }) + names := make([]string, 11) + for i := range names { + names[i] = fmt.Sprintf("Player%02d", i) + } + saveTestParticipants(t, store, compID, names) + require.NoError(t, eng.StartCompetition(compID)) + + bracket, err := store.LoadBracket(compID) + require.NoError(t, err) + for _, m := range bracket.Rounds[0] { + for _, label := range []string{m.PlaceholderA, m.PlaceholderB} { + if strings.HasSuffix(label, "-2nd") { + require.Empty(t, drafted, "exactly one 2nd is drafted") + drafted = qualifierLabelPool(label) + } + } + } + require.NotEmpty(t, drafted, "the draw drafts one pool's 2nd") + pools, err := store.LoadPools(compID) + require.NoError(t, err) + for _, p := range pools { + if p.PoolName != drafted && len(p.Players) == 4 { + undrafted = p.PoolName + } + } + require.NotEmpty(t, undrafted, "an oversized pool is left undrafted") + return eng, store, drafted, undrafted +} + +// scoreFillBracketPools completes the pool matches: the lower-seated player of +// each pair wins, except that in tiePool the 2nd and 3rd players draw each +// other (level for 2nd). Pools other than tiePool are scored only when all is +// set, so a test can leave them incomplete. +func scoreFillBracketPools(t *testing.T, eng *Engine, store *state.Store, compID, tiePool string, team, all bool) { + t.Helper() + pools, err := store.LoadPools(compID) + require.NoError(t, err) + order, poolOf := map[string]int{}, map[string]string{} + for _, p := range pools { + for i, pl := range p.Players { + order[pl.Name], poolOf[pl.Name] = i, p.PoolName + } + } + matches, err := store.LoadPoolMatches(compID) + require.NoError(t, err) + for i := range matches { + m := &matches[i] + pn := poolOf[m.SideA] + if pn != tiePool && !all { + continue + } + a, b := order[m.SideA], order[m.SideB] + winner, winnerID := m.SideA, m.SideAID + if b < a { + winner, winnerID = m.SideB, m.SideBID + } + if pn == tiePool && a*b == 2 && a+b == 3 { + winner, winnerID = "", "" + } + m.Status = state.MatchStatusCompleted + m.Winner, m.WinnerID = winner, winnerID + if winner == "" { + m.Decision = string(domain.DecisionHikiwake) + } + if team { + m.SubResults = []state.SubMatchResult{ + {Position: 1, SideA: m.SideA, SideB: m.SideB, Winner: winner, Decision: m.Decision}, + {Position: 2, SideA: m.SideA, SideB: m.SideB, Winner: winner, Decision: m.Decision}, + } + } + } + require.NoError(t, store.SavePoolMatches(compID, matches)) + eng.standingsCache.Delete(compID) + eng.standingsFlight.Delete(compID) +} + +// drawnPoolNames returns pool's player names in draw order. +func drawnPoolNames(t *testing.T, store *state.Store, compID, pool string) []string { + t.Helper() + pools, err := store.LoadPools(compID) + require.NoError(t, err) + for _, p := range pools { + if p.PoolName == pool { + names := make([]string, len(p.Players)) + for i, pl := range p.Players { + names[i] = pl.Name + } + return names + } + } + require.Failf(t, "pool not found", "%s", pool) + return nil +} + +func TestFillBracketDraftedSecondTie_OwesATieBreakBout(t *testing.T) { + for _, tc := range []struct { + name string + drafted bool + }{ + {"a tie for 2nd in the drafted pool is tie-broken", true}, + {"the same tie in an oversized pool that was not drafted shares its place", false}, + } { + t.Run(tc.name, func(t *testing.T) { + eng, store, drafted, undrafted := startFillBracket(t, "fbtb-ind", false) + pool := undrafted + if tc.drafted { + pool = drafted + } + scoreFillBracketPools(t, eng, store, "fbtb-ind", pool, false, false) + + injected, err := eng.InjectTiebreakerMatches("fbtb-ind") + require.NoError(t, err) + if !tc.drafted { + assert.Empty(t, injected) + return + } + require.Len(t, injected, 1) + names := drawnPoolNames(t, store, "fbtb-ind", pool) + assert.ElementsMatch(t, []string{names[1], names[2]}, []string{injected[0].SideA, injected[0].SideB}) + assert.True(t, IsTiebreakerMatchID(injected[0].ID) && strings.HasPrefix(injected[0].ID, pool+"-TB-"), "got %q", injected[0].ID) + }) + } +} + +func TestFillBracketDraftedSecondTie_TeamPoolGetsADaihyosen(t *testing.T) { + for _, tc := range []struct { + name string + drafted bool + want int + }{ + {"drafted pool: the level teams play a daihyosen", true, 1}, + {"undrafted pool: the level teams share the place", false, 0}, + } { + t.Run(tc.name, func(t *testing.T) { + eng, store, drafted, undrafted := startFillBracket(t, "fbtb-team", true) + pool := undrafted + if tc.drafted { + pool = drafted + } + scoreFillBracketPools(t, eng, store, "fbtb-team", pool, true, false) + + injected, err := eng.InjectPoolDaihyosenMatches("fbtb-team") + require.NoError(t, err) + require.Len(t, injected, tc.want) + }) + } +} + +func TestFillBracketDraftedSecondTie_ChusenWaitsOnlyInTheDraftedPool(t *testing.T) { + for _, tc := range []struct { + name string + drafted bool + want int + }{ + {"drafted pool: a daihyosen that leaves them level needs a chusen", true, 1}, + {"undrafted pool: nothing to draw", false, 0}, + } { + t.Run(tc.name, func(t *testing.T) { + eng, store, drafted, undrafted := startFillBracket(t, "fbtb-chusen", true) + pool := undrafted + if tc.drafted { + pool = drafted + } + scoreFillBracketPools(t, eng, store, "fbtb-chusen", pool, true, false) + _, err := eng.InjectPoolDaihyosenMatches("fbtb-chusen") + require.NoError(t, err) + all, err := store.LoadPoolMatches("fbtb-chusen") + require.NoError(t, err) + for i := range all { + if IsPoolDaihyosenMatchID(all[i].ID) { + all[i].Status = state.MatchStatusCompleted + all[i].Winner = "" // hikiwake: the round leaves them level + } + } + require.NoError(t, store.SavePoolMatches("fbtb-chusen", all)) + eng.standingsCache.Delete("fbtb-chusen") + eng.standingsFlight.Delete("fbtb-chusen") + + report, err := eng.ChusenStatus("fbtb-chusen") + require.NoError(t, err) + assert.Len(t, report.Pending, tc.want) + }) + } +} + +func TestFillBracketDraftedSecondTie_QualificationWaitsForTheBout(t *testing.T) { + eng, store, drafted, _ := startFillBracket(t, "fbtb-wait", false) + scoreFillBracketPools(t, eng, store, "fbtb-wait", drafted, false, true) + names := drawnPoolNames(t, store, "fbtb-wait", drafted) + label := drafted + "-2nd" + + // slot reads who sits in the knockout place the drafted pool's 2nd fills. + slot := func() string { + bracket, err := store.LoadBracket("fbtb-wait") + require.NoError(t, err) + for _, m := range bracket.Rounds[0] { + if m.PlaceholderA == label { + return m.SideA + } + if m.PlaceholderB == label { + return m.SideB + } + } + require.Failf(t, "slot not found", "%s", label) + return "" + } + + _, err := eng.MaybeAutoCompletePools("fbtb-wait") + require.NoError(t, err) + matches, err := store.LoadPoolMatches("fbtb-wait") + require.NoError(t, err) + var tbs []state.MatchResult + for _, m := range matches { + if IsTiebreakerMatchID(m.ID) { + tbs = append(tbs, m) + } + } + require.Len(t, tbs, 1, "the tie over the drafted 2nd is owed a tie-break bout") + assert.ElementsMatch(t, []string{names[1], names[2]}, []string{tbs[0].SideA, tbs[0].SideB}) + assert.Equal(t, label, slot(), "the place stays open while the bout is unplayed") + + tbs[0].Status = state.MatchStatusCompleted + tbs[0].Winner, tbs[0].WinnerID = names[2], tbs[0].SideAID + if tbs[0].SideB == names[2] { + tbs[0].WinnerID = tbs[0].SideBID + } + for i := range matches { + if matches[i].ID == tbs[0].ID { + matches[i] = tbs[0] + } + } + require.NoError(t, store.SavePoolMatches("fbtb-wait", matches)) + eng.standingsCache.Delete("fbtb-wait") + eng.standingsFlight.Delete("fbtb-wait") + _, err = eng.MaybeAutoCompletePools("fbtb-wait") + require.NoError(t, err) + assert.Equal(t, names[2], slot(), "the bout's winner takes the drafted 2nd place") +} + +// tiebreakBoutsOf lists the tie-break bouts a competition's pool matches hold. +func tiebreakBoutsOf(t *testing.T, store *state.Store, compID string) []state.MatchResult { + t.Helper() + matches, err := store.LoadPoolMatches(compID) + require.NoError(t, err) + var tbs []state.MatchResult + for _, m := range matches { + if IsTiebreakerMatchID(m.ID) { + tbs = append(tbs, m) + } + } + return tbs +} + +// bothPlayed finds the stored pool match whose sides are a and b. +func bothPlayed(t *testing.T, store *state.Store, compID, a, b string) string { + t.Helper() + matches, err := store.LoadPoolMatches(compID) + require.NoError(t, err) + for _, m := range matches { + if (m.SideA == a && m.SideB == b) || (m.SideA == b && m.SideB == a) { + return m.ID + } + } + require.Failf(t, "match not found", "%s v %s", a, b) + return "" +} + +// bc-fbtb / bc-xqtb review: a CORRECTION that leaves two players level on the +// pool's extra place goes through the requalification, which judges the tie +// against the pool's own cut (poolTieBreakCut), not the uniform Winners per +// pool. A pool whose extra place is cut by Winners per pool alone would seat +// one of the two by sort order. +func TestRequalify_CorrectionTieOnLargerPoolsExtraPlace_WaitsForTheBout(t *testing.T) { + f := newRQFixture(t, "rq-xqtb-fix", 1, [][]string{ + {"A1", "A2", "A3", "A4"}, {"B1", "B2", "B3"}, {"C1", "C2", "C3"}, + }, largerPools(3)) + require.NoError(t, f.store.SaveBracket(f.compID, &state.Bracket{Rounds: [][]state.BracketMatch{{ + {ID: "m-r1-0", MatchNumber: 1, SideA: "Pool A-1st", SideB: "Pool C-1st", PlaceholderA: "Pool A-1st", PlaceholderB: "Pool C-1st"}, + {ID: "m-r1-1", MatchNumber: 2, SideA: "Pool B-1st", SideB: "Pool A-2nd", PlaceholderA: "Pool B-1st", PlaceholderB: "Pool A-2nd"}, + }}})) + for _, w := range [][2]string{ + {"Pool A-0", "A1"}, {"Pool A-1", "A1"}, {"Pool A-2", "A1"}, + {"Pool A-3", "A2"}, {"Pool A-4", "A2"}, {"Pool A-5", "A3"}, + {"Pool B-0", "B1"}, {"Pool B-1", "B1"}, {"Pool B-2", "B2"}, + {"Pool C-0", "C1"}, {"Pool C-1", "C1"}, {"Pool C-2", "C2"}, + } { + f.scorePool(w[0], w[1]) + } + f.resolve() + setCompStatus(t, f.store, f.compID, state.CompStatusKnockout) + m, side := f.slot("Pool A-2nd") + name, _ := sideOf(m, side) + require.Equal(t, "A2", name, "before the correction A2 is the clear 2nd") + + // Correct A2 v A3 to a hikiwake: A2 and A3 are level for the extra place. + require.NoError(t, f.write("Pool A-3", &state.MatchResult{ + SideA: "A2", SideB: "A3", SideAID: rqID("A2"), SideBID: rqID("A3"), + Status: state.MatchStatusCompleted, Decision: string(domain.DecisionHikiwake), + })) + m, side = f.slot("Pool A-2nd") + name, id := sideOf(m, side) + assert.Equal(t, "Pool A-2nd", name, "a tied extra place is not seated by sort order") + assert.Empty(t, id) + + _, err := f.eng.MaybeAutoCompletePools(f.compID) + require.NoError(t, err) + tbs := tiebreakBoutsOf(t, f.store, f.compID) + require.Len(t, tbs, 1, "the tie over the extra place is owed a tie-break bout") + assert.ElementsMatch(t, []string{"A2", "A3"}, []string{tbs[0].SideA, tbs[0].SideB}) + + f.scorePool(tbs[0].ID, "A3") + _, err = f.eng.MaybeAutoCompletePools(f.compID) + require.NoError(t, err) + m, side = f.slot("Pool A-2nd") + name, id = sideOf(m, side) + assert.Equal(t, "A3", name, "the bout's winner takes the extra place") + assert.Equal(t, rqID("A3"), id) +} + +// The same correction over a fill-bracket drafted pool's 2nd, drawn through the +// real pipeline. +func TestRequalify_CorrectionTieOnFillBracketDraftedSecond_WaitsForTheBout(t *testing.T) { + const compID = "fbtb-fix" + eng, store, drafted, _ := startFillBracket(t, compID, false) + // A clean result everywhere: the drafted pool's order is the draw order. + scoreFillBracketPools(t, eng, store, compID, "", false, true) + names := drawnPoolNames(t, store, compID, drafted) + f := &rqFix{t: t, eng: eng, store: store, compID: compID} + label := drafted + "-2nd" + + _, err := eng.MaybeAutoCompletePools(compID) + require.NoError(t, err) + m, side := f.slot(label) + name, _ := sideOf(m, side) + require.Equal(t, names[1], name, "before the correction the draw's second seed holds the drafted 2nd") + + // Correct the 2nd v 3rd result to a hikiwake: they are level for 2nd. + matchID := bothPlayed(t, store, compID, names[1], names[2]) + prior := loadPoolMatchByID(t, store, compID, matchID) + require.NotNil(t, prior) + require.NoError(t, f.write(matchID, &state.MatchResult{ + SideA: prior.SideA, SideB: prior.SideB, SideAID: prior.SideAID, SideBID: prior.SideBID, + Status: state.MatchStatusCompleted, Decision: string(domain.DecisionHikiwake), + })) + m, side = f.slot(label) + name, id := sideOf(m, side) + assert.Equal(t, label, name, "a tied drafted 2nd is not seated by sort order") + assert.Empty(t, id) + + _, err = eng.MaybeAutoCompletePools(compID) + require.NoError(t, err) + tbs := tiebreakBoutsOf(t, store, compID) + require.Len(t, tbs, 1, "the tie over the drafted 2nd is owed a tie-break bout") + assert.ElementsMatch(t, []string{names[1], names[2]}, []string{tbs[0].SideA, tbs[0].SideB}) + + f.scorePool(tbs[0].ID, names[2]) + _, err = eng.MaybeAutoCompletePools(compID) + require.NoError(t, err) + m, side = f.slot(label) + name, _ = sideOf(m, side) + assert.Equal(t, names[2], name, "the bout's winner takes the drafted 2nd place") +} diff --git a/internal/engine/tiebreaker.go b/internal/engine/tiebreaker.go index 2d2174720..c36f715cf 100644 --- a/internal/engine/tiebreaker.go +++ b/internal/engine/tiebreaker.go @@ -4,6 +4,7 @@ import ( "fmt" "sort" + "github.com/gitrgoliveira/bracket-creator/internal/helper" "github.com/gitrgoliveira/bracket-creator/internal/state" ) @@ -217,28 +218,109 @@ func tieSettled(group []state.PlayerStanding, matches []state.MatchResult, isTea // positions from detectPoolTies over the sorted standings) can change the pool // outcome and therefore warrants a supplementary bout. // -// A pool seeds its knockout from the top EffectivePoolWinners of each pool, and +// A pool seeds its knockout from its top qualifying places (poolTieBreakCut), and // finishing position decides where in the bracket a qualifier lands: the 1st // stays in its own block while lower finishers cross to a partner block half the // bracket away (R4), and a block's structural bye goes to its highest-precedence // occupant (R6, helper.BuildKnockoutDraw). That occupant is NOT necessarily a // pool winner -- a block may hold only crossed-in qualifiers, and R5's separation // can take a bye off a winner -- but the ordering always favours the better -// finish, so every position in [1..poolWinners] is a distinct, consequential +// finish, so every position in [1..cut] is a distinct, consequential // seed. That is all this predicate needs; it does not depend on who byes. A tied -// group whose BEST position is already past the cutoff (positions[0]+1 > poolWinners) +// group whose BEST position is already past the cutoff (positions[0]+1 > cut) // sits entirely among eliminated ranks: those teams share that rank and no // supplementary ippon-shobu / daihyosen is played. This mirrors the rule that a // supplementary bout is held only "to determine their relative ranking" where // that ranking matters (running_a_kendo_tournament.md:405/441), and the // band-aware LeagueTiebreakCandidates gate used for team leagues. -func tieAffectsAdvancement(positions []int, poolWinners int) bool { +func tieAffectsAdvancement(positions []int, cut int) bool { if len(positions) == 0 { return false } // positions[0] is 0-based into the descending-sorted standings; +1 makes it // the 1-based finishing rank of the best-placed member of the tied group. - return positions[0]+1 <= poolWinners + return positions[0]+1 <= cut +} + +// poolTieBreakCut is the ONE statement of the cut a pool's ties are judged +// against: how many of its places send a qualifier. Under larger-pools an +// oversized pool sends one more than Winners per pool, and under fill-bracket a +// DRAFTED pool sends its 2nd too, so a tie over such a place is owed a bout like +// any other qualifying place. +// +// The count is read off the stored draw (drawnQualifierPlaces), the truth the +// qualifier seating uses and the only one that knows which pools fill-bracket +// drafted. Where the bracket names none of the pool's places (a v1 bracket +// predating the draw placeholders, which carries only the uniform Winners per +// pool layout, or no bracket yet) the pool's own Competition.QualifiersForPool +// answers. A pool absent from pools (not yet saved, or hand-edited data) falls +// back to EffectivePoolWinners, the count every pool sends without extra +// qualifiers. +func poolTieBreakCut(comp *state.Competition, pools []helper.Pool, bracket *state.Bracket, poolName string) int { + for _, p := range pools { + if p.PoolName != poolName { + continue + } + if n := drawnQualifierPlaces(bracket, p); n > 0 { + return n + } + return comp.QualifiersForPool(p) + } + return comp.EffectivePoolWinners() +} + +// drawnQualifierPlaces is the highest place of pool that the STORED draw seats a +// qualifier from: the largest rank r for which the bracket's draw-time +// placeholders (recordDrawPlaceholders) name qualifierLabel(pool, r). It is 0 +// when the bracket names none of the pool's places (no bracket, a knockout +// seeded without pool placeholders, or a v1 bracket whose placeholders +// ResolveQualifiedPools has not backfilled yet). +// +// The stored draw, not the competition's config, is the truth the qualifier +// seating reads: qualifierResolver fills exactly the labels the bracket holds. +// Which pools a fill-bracket draw DRAFTED to send their 2nd is a whole-field +// selection (helper.SelectFillBracketDraftIndices, which depends on the court +// count and on every pool's seeds) that Competition.QualifiersForPool +// deliberately cannot express, and re-running it here would drift from the +// bracket on disk the moment courts, seeds or pool size changed after the draw. +// Reading the placeholders cannot disagree with the seating, and covers +// larger-pools and standard mode by the same rule. +func drawnQualifierPlaces(bracket *state.Bracket, pool helper.Pool) int { + if bracket == nil { + return 0 + } + labels := map[string]bool{} + note := func(m *state.BracketMatch) { + labels[m.PlaceholderA], labels[m.PlaceholderB], labels[m.PlaceholderWinner] = true, true, true + } + for ri := range bracket.Rounds { + for mi := range bracket.Rounds[ri] { + note(&bracket.Rounds[ri][mi]) + } + } + if bracket.ThirdPlaceMatch != nil { + note(bracket.ThirdPlaceMatch) + } + for rank := len(pool.Players); rank >= 1; rank-- { + if labels[qualifierLabel(pool.PoolName, rank)] { + return rank + } + } + return 0 +} + +// PoolQualifierCuts maps each pool's name to how many of its places send a +// qualifier: poolTieBreakCut for every pool. The viewer payload carries it so a +// standings table marks the advancing places and the tie badge reads a tie over +// the last of them against the SAME count the server holds a tie-break bout to, +// instead of re-deriving it (the client cannot: which fill-bracket pools were +// drafted is a property of the stored draw). +func PoolQualifierCuts(comp *state.Competition, pools []helper.Pool, bracket *state.Bracket) map[string]int { + out := make(map[string]int, len(pools)) + for _, p := range pools { + out[p.PoolName] = poolTieBreakCut(comp, pools, bracket, p.PoolName) + } + return out } // tieNeedsIndividualBreak decides whether InjectTiebreakerMatches should hold an @@ -254,7 +336,7 @@ func tieAffectsAdvancement(positions []int, poolWinners int) bool { // - Non-league (mixed pools feeding a knockout): only ties that change who // advances or their seed warrant a bout; a tie entirely below the qualifying // cut shares its rank with no bout. -func tieNeedsIndividualBreak(comp *state.Competition, positions []int, group []state.PlayerStanding, poolWinners int) bool { +func tieNeedsIndividualBreak(comp *state.Competition, positions []int, group []state.PlayerStanding, cut int) bool { if len(positions) == 0 { return false } @@ -266,7 +348,7 @@ func tieNeedsIndividualBreak(comp *state.Competition, positions []int, group []s } return isConsequentialTie(g, comp) } - return tieAffectsAdvancement(positions, poolWinners) + return tieAffectsAdvancement(positions, cut) } // pendingTieBreak is one tied group of a pool that still owes supplementary @@ -293,26 +375,28 @@ type pendingTieBreak struct { // // The band each kind breaks is the injector's own: individual bouts follow // tieNeedsIndividualBreak, team daihyosen follows tieAffectsAdvancement, both -// against EffectivePoolWinners (not MatchWinnerRanksNeeded: an -// extra-qualifier "-2nd" slot below that band is never given a bout, so -// treating it as owing one would leave it unresolved forever). +// against the pool's OWN qualifier count, cut (poolTieBreakCut, which is +// Competition.QualifiersForPool), so a larger-pools extra qualifier's place +// is tie-broken like any other qualifying place. Not MatchWinnerRanksNeeded: +// that is the one global bound for the whole competition, so a pool that +// sends no extra qualifier would owe a bout for a place that qualifies +// nobody and leave it unresolved forever. // // existingRows are the pool's rows of the matching kind (TB rows for // individual, DH rows for team); their count seeds the new bouts' ids. -func pendingTieBreaks(comp *state.Competition, poolName string, poolStandings []state.PlayerStanding, existingRows []state.MatchResult, court string, daihyosen bool) []pendingTieBreak { - poolWinners := comp.EffectivePoolWinners() +func pendingTieBreaks(comp *state.Competition, poolName string, cut int, poolStandings []state.PlayerStanding, existingRows []state.MatchResult, court string, daihyosen bool) []pendingTieBreak { existingCount := len(existingRows) var out []pendingTieBreak for _, positions := range detectPoolTies(poolStandings) { group := standingsAt(poolStandings, positions) var bouts []state.MatchResult if daihyosen { - if !tieAffectsAdvancement(positions, poolWinners) { + if !tieAffectsAdvancement(positions, cut) { continue } bouts = generatePoolDaihyosenMatches(poolName, group, existingCount, court, existingRows) } else { - if !tieNeedsIndividualBreak(comp, positions, group, poolWinners) { + if !tieNeedsIndividualBreak(comp, positions, group, cut) { continue } bouts = generateTiebreakerMatches(poolName, group, existingCount, court, existingRows) @@ -496,6 +580,14 @@ func (e *Engine) InjectTiebreakerMatches(compID string) ([]state.MatchResult, er return err } e.noteMatchRead(compID) + pools, err := tx.LoadPools(compID) + if err != nil { + return err + } + bracket, err := tx.LoadBracket(compID) + if err != nil { + return err + } // Scan existing TB matches per pool for idempotency and ID sequencing. // existingRows are handed to generateTiebreakerMatches raw (not reduced to @@ -536,7 +628,7 @@ func (e *Engine) InjectTiebreakerMatches(compID string) ([]state.MatchResult, er if regularIncomplete[poolName] { continue } - for _, p := range pendingTieBreaks(comp, poolName, poolStandings, poolTB[poolName], poolCourt[poolName], false) { + for _, p := range pendingTieBreaks(comp, poolName, poolTieBreakCut(comp, pools, bracket, poolName), poolStandings, poolTB[poolName], poolCourt[poolName], false) { injected = append(injected, p.bouts...) } } diff --git a/internal/mobileapp/handlers_branding.go b/internal/mobileapp/handlers_branding.go index 07f669f0f..021c44804 100644 --- a/internal/mobileapp/handlers_branding.go +++ b/internal/mobileapp/handlers_branding.go @@ -27,9 +27,10 @@ var validBrandingContentTypes = map[string]string{ // RegisterPublicBrandingHandlers wires the unauthenticated GET and HEAD routes // that serve the tournament logo bytes. When no custom logo is configured, GET // redirects to the bundled default (/logo.jpeg) so image consumers never see a -// 404, while HEAD returns 404 so BrandingManager can probe whether a custom -// logo is set. HEAD is registered explicitly because Gin does not auto-route -// HEAD to GET. +// 404. HEAD answers 404 while none is set, as it always has, but the app no +// longer asks: BrandingManager reads theme.hasLogo off the tournament instead +// (bc-lgnf), so a console never logs this 404. HEAD is registered explicitly +// because Gin does not auto-route HEAD to GET. func RegisterPublicBrandingHandlers(r *gin.RouterGroup, store *state.Store) { // serveLogo serves the custom logo when one is configured; otherwise it // calls onMissing, which differs by method (GET redirects to the default, @@ -77,9 +78,10 @@ func RegisterPublicBrandingHandlers(r *gin.RouterGroup, store *state.Store) { r.GET("/branding/logo", func(c *gin.Context) { serveLogo(c, func(c *gin.Context) { c.Redirect(http.StatusFound, "/logo.jpeg") }) }) - // HEAD is used by BrandingManager to probe custom-logo existence without - // fetching the payload, so it must keep returning 404 when none is set. - // Gin does not auto-route HEAD to GET, so register it explicitly. + // HEAD reports custom-logo existence without the payload: 404 when none is + // set. The app reads theme.hasLogo instead of probing, so this is for + // outside callers only; keep the answer. Gin does not auto-route HEAD to + // GET, so register it explicitly. r.HEAD("/branding/logo", func(c *gin.Context) { serveLogo(c, func(c *gin.Context) { c.JSON(http.StatusNotFound, gin.H{"error": "no logo configured"}) }) }) diff --git a/internal/mobileapp/handlers_branding_haslogo_test.go b/internal/mobileapp/handlers_branding_haslogo_test.go new file mode 100644 index 000000000..3f4be07dc --- /dev/null +++ b/internal/mobileapp/handlers_branding_haslogo_test.go @@ -0,0 +1,110 @@ +package mobileapp + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// bc-lgnf: the admin page learns whether a custom logo exists from the +// tournament it already reads (theme.hasLogo), instead of probing +// HEAD /api/branding/logo, which answers 404 while none is set and made the +// browser log a console error. +func TestTournament_ThemeHasLogoFollowsTheUploadedLogo(t *testing.T) { + h, _, cleanup := brandingTestSetup(t) + defer cleanup() + + getTheme := func() map[string]any { + t.Helper() + req := httptest.NewRequest(http.MethodGet, "/api/tournament", nil) + req.Header.Set("X-Tournament-Password", "secret") + w := httptest.NewRecorder() + h.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)) + theme, _ := body["theme"].(map[string]any) + return theme + } + write := func(method string) { + t.Helper() + var req *http.Request + if method == http.MethodPost { + body, ct := buildBrandingUpload(t, "logo.png", tinyPNG) + req = httptest.NewRequest(method, "/api/branding/logo", body) + req.Header.Set("Content-Type", ct) + } else { + req = httptest.NewRequest(method, "/api/branding/logo", nil) + } + req.Header.Set("X-Tournament-Password", "secret") + w := httptest.NewRecorder() + h.ServeHTTP(w, req) + require.Equal(t, http.StatusOK, w.Code, w.Body.String()) + } + + assert.NotContains(t, getTheme(), "hasLogo", "no logo: the field is absent") + + write(http.MethodPost) + theme := getTheme() + assert.Equal(t, true, theme["hasLogo"], "an uploaded logo is reported") + assert.NotContains(t, theme, "logoPath", "the disk filename stays off the wire") + + write(http.MethodDelete) + assert.NotContains(t, getTheme(), "hasLogo", "a removed logo is no longer reported") +} + +// A client that sends the theme back with hasLogo (it read it from GET) must +// neither lose the logo nor be able to set one this way. +func TestPutTournament_HasLogoOnTheWireIsIgnored(t *testing.T) { + h, _, cleanup := brandingTestSetup(t) + defer cleanup() + + put := func(body string) { + t.Helper() + req := httptest.NewRequest(http.MethodPut, "/api/tournament", strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("X-Tournament-Password", "secret") + w := httptest.NewRecorder() + h.ServeHTTP(w, req) + require.Equal(t, http.StatusOK, w.Code, w.Body.String()) + } + getTheme := func() map[string]any { + t.Helper() + req := httptest.NewRequest(http.MethodGet, "/api/tournament", nil) + req.Header.Set("X-Tournament-Password", "secret") + w := httptest.NewRecorder() + h.ServeHTTP(w, req) + require.Equal(t, http.StatusOK, w.Code) + var body map[string]any + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body)) + theme, _ := body["theme"].(map[string]any) + return theme + } + + // No logo stored: a client cannot set one by sending hasLogo. + put(`{"name":"Branding Test Cup","password":"secret","courts":["A"],"theme":{"primaryColor":"#112233","hasLogo":true}}`) + theme := getTheme() + assert.Equal(t, "#112233", theme["primaryColor"]) + assert.NotContains(t, theme, "hasLogo", "hasLogo is derived from the stored logo, never set by a client") + + // A logo stored: a client cannot lose it by sending hasLogo false, or by omitting it. + body, ct := buildBrandingUpload(t, "logo.png", tinyPNG) + req := httptest.NewRequest(http.MethodPost, "/api/branding/logo", body) + req.Header.Set("Content-Type", ct) + req.Header.Set("X-Tournament-Password", "secret") + w := httptest.NewRecorder() + h.ServeHTTP(w, req) + require.Equal(t, http.StatusOK, w.Code, w.Body.String()) + require.Equal(t, true, getTheme()["hasLogo"], "precondition: the uploaded logo is reported") + + put(`{"name":"Branding Test Cup","password":"secret","courts":["A"],"theme":{"primaryColor":"#112233","hasLogo":false}}`) + assert.Equal(t, true, getTheme()["hasLogo"], "a PUT carrying hasLogo false does not lose the logo") + + put(`{"name":"Branding Test Cup","password":"secret","courts":["A"],"theme":{"primaryColor":"#112233"}}`) + assert.Equal(t, true, getTheme()["hasLogo"], "a PUT that omits hasLogo does not lose the logo") +} diff --git a/internal/mobileapp/handlers_competition.go b/internal/mobileapp/handlers_competition.go index 7c44c7566..cfef763b4 100644 --- a/internal/mobileapp/handlers_competition.go +++ b/internal/mobileapp/handlers_competition.go @@ -138,6 +138,19 @@ func extractSeeds(players []domain.Player) []domain.SeedAssignment { return out } +// defaultCompetitionDate gives a competition that states no date the +// tournament's Day 1, the one rule every door that creates a competition +// shares (POST /competitions and the folder import). It leaves an explicit +// date alone and reports whether the date is set afterwards, so a door that +// cannot accept a dateless competition can refuse it (a tournament with no +// date gives it nothing to take). +func defaultCompetitionDate(comp *state.Competition, tourn *state.Tournament) bool { + if comp.Date == "" && tourn != nil && tourn.Date != "" { + comp.Date = tourn.Date + } + return comp.Date != "" +} + // validateCompetitionDateInTournament checks that the competition's Date // falls within the tournament's day range [Day 1 .. Day N]. When: // - comp.Date is empty (optional field) → skip, return nil. @@ -866,9 +879,7 @@ func RegisterCompetitionHandlers(r *gin.RouterGroup, store *state.Store, eng *en // from tournament.date directly (JS), but the backend defaulted to // today when it was empty, using Day 1 is the correct multi-day // behaviour and also keeps server and client in sync. - if comp.Date == "" && createTourn != nil && createTourn.Date != "" { - comp.Date = createTourn.Date - } + defaultCompetitionDate(&comp, createTourn) // Reject a competition date that falls outside the tournament's day // range. Skipped when tournament has no date configured. diff --git a/internal/mobileapp/handlers_import.go b/internal/mobileapp/handlers_import.go index 799412ca2..bd1370039 100644 --- a/internal/mobileapp/handlers_import.go +++ b/internal/mobileapp/handlers_import.go @@ -265,6 +265,17 @@ func importCompetition(store *state.Store, eng *engine.Engine, entry ImportManif return res } + // A row with no date takes the tournament's Day 1, the rule POST + // /competitions applies (defaultCompetitionDate). It has to be settled + // here: a competition with no date can never be drawn ("Cannot start: + // invalid date in Settings tab"), so a row that still has none (the + // tournament has no date to give it) is refused rather than imported + // as a competition the organiser cannot use (bc-imdt). + if !defaultCompetitionDate(comp, importTourn) { + res.Error = "date: the row has no date and the tournament has no date to give it; set the tournament date or add a date to the row" + return res + } + // Cross-file guard symmetry with POST/PUT /competitions: reject a // competition date that falls outside the tournament's day range. if err := validateCompetitionDateInTournament(comp, importTourn); err != nil { diff --git a/internal/mobileapp/handlers_import_date_test.go b/internal/mobileapp/handlers_import_date_test.go new file mode 100644 index 000000000..96c8c9dae --- /dev/null +++ b/internal/mobileapp/handlers_import_date_test.go @@ -0,0 +1,68 @@ +package mobileapp + +import ( + "testing" + + "github.com/gitrgoliveira/bracket-creator/internal/engine" + "github.com/gitrgoliveira/bracket-creator/internal/state" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// bc-imdt: a manifest row with no date used to import as a success, yet the +// competition could never be drawn ("Cannot start: invalid date in Settings +// tab"). The importer now gives it the tournament's Day 1, the rule POST +// /competitions applies (defaultCompetitionDate), and refuses a row whose date +// still cannot be resolved. +func TestImportCompetition_DateDefaulting(t *testing.T) { + newStore := func(t *testing.T, tournDate string) *state.Store { + t.Helper() + store, err := state.NewStore(t.TempDir()) + require.NoError(t, err) + require.NoError(t, store.SaveTournament(&state.Tournament{ + Name: "T", Date: tournDate, DurationDays: 2, Courts: []string{"A"}, + })) + return store + } + + t.Run("a row with no date takes the tournament date", func(t *testing.T) { + store := newStore(t, "11-06-2026") + res := importCompetition(store, engine.New(store), ImportManifestComp{ID: "no-date", Name: "No Date"}, map[string][]byte{}) + require.Emptyf(t, res.Error, "import should succeed: %s", res.Error) + comp, err := store.LoadCompetition("no-date") + require.NoError(t, err) + assert.Equal(t, "11-06-2026", comp.Date) + }) + + t.Run("an explicit date is left unchanged", func(t *testing.T) { + store := newStore(t, "11-06-2026") + res := importCompetition(store, engine.New(store), ImportManifestComp{ID: "day-two", Name: "Day Two", Date: "12-06-2026"}, map[string][]byte{}) + require.Emptyf(t, res.Error, "import should succeed: %s", res.Error) + comp, err := store.LoadCompetition("day-two") + require.NoError(t, err) + assert.Equal(t, "12-06-2026", comp.Date) + }) + + t.Run("a row with no date under a tournament with no date is refused", func(t *testing.T) { + store := newStore(t, "") + res := importCompetition(store, engine.New(store), ImportManifestComp{ID: "stuck", Name: "Stuck"}, map[string][]byte{}) + assert.Contains(t, res.Error, "date:") + assert.Contains(t, res.Error, "tournament has no date") + stored, _ := store.LoadCompetition("stuck") + assert.Nil(t, stored, "a refused row must not be persisted") + }) + + t.Run("a dated row under a tournament with no date still imports", func(t *testing.T) { + store := newStore(t, "") + res := importCompetition(store, engine.New(store), ImportManifestComp{ID: "dated", Name: "Dated", Date: "11-06-2026"}, map[string][]byte{}) + require.Emptyf(t, res.Error, "import should succeed: %s", res.Error) + }) +} + +// seedImportTournamentDate gives a test's tournament a date, which a manifest +// row that states none takes (bc-imdt). The date is the one the importer tests +// that state a date use, so those rows stay inside the tournament's day range. +func seedImportTournamentDate(t testing.TB, store *state.Store) { + t.Helper() + require.NoError(t, store.SaveTournament(&state.Tournament{Name: "T", Date: "12-05-2026", Courts: []string{"A", "B", "C", "D"}})) +} diff --git a/internal/mobileapp/handlers_import_extra_qualifiers_test.go b/internal/mobileapp/handlers_import_extra_qualifiers_test.go index fa72b9889..9e163fe49 100644 --- a/internal/mobileapp/handlers_import_extra_qualifiers_test.go +++ b/internal/mobileapp/handlers_import_extra_qualifiers_test.go @@ -63,6 +63,7 @@ func importOneComp(t *testing.T, r http.Handler, manifest string) ImportResult { func TestImport_ExtraQualifiers_RoundTripsAValidSetting(t *testing.T) { r, store, _, _, tempDir := setupTestRouter(t) + seedImportTournamentDate(t, store) defer os.RemoveAll(tempDir) res := importOneComp(t, r, ` @@ -89,6 +90,7 @@ competitions: func TestImport_ExtraQualifiers_RejectsAnUnknownValue(t *testing.T) { r, store, _, _, tempDir := setupTestRouter(t) + seedImportTournamentDate(t, store) defer os.RemoveAll(tempDir) res := importOneComp(t, r, ` @@ -114,6 +116,7 @@ competitions: func TestImport_ExtraQualifiers_RejectsMaximumSizingPairing(t *testing.T) { r, store, _, _, tempDir := setupTestRouter(t) + seedImportTournamentDate(t, store) defer os.RemoveAll(tempDir) res := importOneComp(t, r, ` @@ -143,6 +146,7 @@ competitions: // the mode, and store the pair the rule forbids. func TestImport_ExtraQualifiers_ValidatedAgainstTheDefaultedSizingMode(t *testing.T) { r, store, _, _, tempDir := setupTestRouter(t) + seedImportTournamentDate(t, store) defer os.RemoveAll(tempDir) res := importOneComp(t, r, ` @@ -171,6 +175,7 @@ competitions: // two cannot answer differently for the same record. func TestImport_ExtraQualifiers_ZeroedForAFormatWithNoPoolFedKnockout(t *testing.T) { r, store, _, _, tempDir := setupTestRouter(t) + seedImportTournamentDate(t, store) defer os.RemoveAll(tempDir) res := importOneComp(t, r, ` diff --git a/internal/mobileapp/handlers_import_format_test.go b/internal/mobileapp/handlers_import_format_test.go index 303aa42e5..4b181e190 100644 --- a/internal/mobileapp/handlers_import_format_test.go +++ b/internal/mobileapp/handlers_import_format_test.go @@ -51,6 +51,7 @@ func TestImport_FormatNormalizesToKnockout(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { r, store, _, _, tempDir := setupTestRouter(t) + seedImportTournamentDate(t, store) defer os.RemoveAll(tempDir) res := importOneComp(t, r, "competitions:\n"+ diff --git a/internal/mobileapp/handlers_import_kind_test.go b/internal/mobileapp/handlers_import_kind_test.go index 32795e321..d09b0f169 100644 --- a/internal/mobileapp/handlers_import_kind_test.go +++ b/internal/mobileapp/handlers_import_kind_test.go @@ -34,6 +34,7 @@ import ( func TestImport_RejectsAnUnrecognisedKind(t *testing.T) { r, store, _, _, tempDir := setupTestRouter(t) + seedImportTournamentDate(t, store) defer os.RemoveAll(tempDir) res := importOneComp(t, r, ` @@ -68,6 +69,7 @@ func TestImport_AcceptsTheKindsTheServerRecognises(t *testing.T) { } { t.Run(tc.name, func(t *testing.T) { r, store, _, _, tempDir := setupTestRouter(t) + seedImportTournamentDate(t, store) defer os.RemoveAll(tempDir) res := importOneComp(t, r, ` @@ -106,6 +108,7 @@ func TestImport_RejectsATeamSizeItsKindForbids(t *testing.T) { } { t.Run(tc.name, func(t *testing.T) { r, store, _, _, tempDir := setupTestRouter(t) + seedImportTournamentDate(t, store) defer os.RemoveAll(tempDir) res := importOneComp(t, r, ` @@ -152,6 +155,7 @@ competitions: // specs/openapi.yaml rather than by weakening the guard. func TestImport_RequiresATeamCompetitionToStateItsSize(t *testing.T) { r, store, _, _, tempDir := setupTestRouter(t) + seedImportTournamentDate(t, store) defer os.RemoveAll(tempDir) // No `team_size:` key at all -- the shape the default was proposed for. diff --git a/internal/mobileapp/handlers_import_test.go b/internal/mobileapp/handlers_import_test.go index 0b8a7e8db..0b678750d 100644 --- a/internal/mobileapp/handlers_import_test.go +++ b/internal/mobileapp/handlers_import_test.go @@ -21,6 +21,7 @@ import ( func TestRegisterImportHandlers(t *testing.T) { r, store, _, _, tempDir := setupTestRouter(t) + seedImportTournamentDate(t, store) defer os.RemoveAll(tempDir) t.Run("Import Successful", func(t *testing.T) { diff --git a/internal/mobileapp/handlers_viewer.go b/internal/mobileapp/handlers_viewer.go index e29fd6f64..4288a48cd 100644 --- a/internal/mobileapp/handlers_viewer.go +++ b/internal/mobileapp/handlers_viewer.go @@ -842,6 +842,13 @@ func RegisterViewerHandlers(r *gin.RouterGroup, store *state.Store, eng *engine. "standings": standings, "bracket": bracket, } + // How many of each pool's places send a qualifier, by pool name, + // from the same owner the tie-break bouts are held to + // (engine.PoolQualifierCuts). Absent before a draw, when there are + // no pools to count. + if len(pools) > 0 { + payload["poolQualifiers"] = engine.PoolQualifierCuts(comp, pools, bracket) + } // "teamMembers" is present only for a team competition (isTeamComp, // above) and omitted entirely for an individual one, rather than // carrying an always-empty {} -- a client can treat the key's diff --git a/internal/mobileapp/handlers_viewer_test.go b/internal/mobileapp/handlers_viewer_test.go index 1c961a252..e342d61c9 100644 --- a/internal/mobileapp/handlers_viewer_test.go +++ b/internal/mobileapp/handlers_viewer_test.go @@ -421,6 +421,51 @@ func TestViewerCompetitionDetail_NumbersBeforeAndAfterTheDraw(t *testing.T) { assert.Equal(t, []string{"K2", "K1"}, numbers, "post-draw: pool-order numbers from pools.csv") } +// bc-fbtb: the standings page cannot tell which pools a fill-bracket draw +// drafted to send their 2nd, so the payload says how many places each pool +// sends, from the stored draw (engine.PoolQualifierCuts). +func TestViewerCompetitionDetail_CarriesPoolQualifierCuts(t *testing.T) { + r, store, _, _, tempDir := setupTestRouter(t) + defer os.RemoveAll(tempDir) + + const cid = "viewer-cuts" + require.NoError(t, store.SaveCompetition(&state.Competition{ + ID: cid, Name: "Viewer Cuts", Format: state.CompFormatMixed, Kind: "individual", + Courts: []string{"A"}, PoolSize: 3, PoolWinners: 1, PoolSizeMode: "min", + ExtraQualifiers: state.ExtraQualifiersFillBracket, Status: state.CompStatusPools, + })) + get := func(t *testing.T) map[string]any { + t.Helper() + w := httptest.NewRecorder() + req, _ := http.NewRequest("GET", "/api/viewer/competitions/"+cid, nil) + r.ServeHTTP(w, req) + require.Equalf(t, http.StatusOK, w.Code, "response: %s", w.Body.String()) + var body map[string]any + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body)) + return body + } + _, drawn := get(t)["poolQualifiers"] + assert.False(t, drawn, "before a draw there are no pools to count") + + pool := func(name string, n int) helper.Pool { + p := helper.Pool{PoolName: name} + for i := 0; i < n; i++ { + p.Players = append(p.Players, helper.Player{Name: fmt.Sprintf("%s-%d", name, i), Dojo: "Dojo"}) + } + return p + } + require.NoError(t, store.SavePools(cid, []helper.Pool{pool("Pool A", 3), pool("Pool B", 4), pool("Pool C", 4)})) + // Pool B was drafted: the stored draw seats its 2nd. + require.NoError(t, store.SaveBracket(cid, &state.Bracket{Rounds: [][]state.BracketMatch{{ + {ID: "m1", SideA: "Pool A-1st", SideB: "Pool B-2nd", PlaceholderA: "Pool A-1st", PlaceholderB: "Pool B-2nd"}, + {ID: "m2", SideA: "Pool B-1st", SideB: "Pool C-1st", PlaceholderA: "Pool B-1st", PlaceholderB: "Pool C-1st"}, + }}})) + + cuts, ok := get(t)["poolQualifiers"].(map[string]any) + require.True(t, ok, "the payload carries poolQualifiers") + assert.Equal(t, map[string]any{"Pool A": float64(1), "Pool B": float64(2), "Pool C": float64(1)}, cuts) +} + // TestViewerCompetitionDetail_KnockoutNumbersFollowBracketPosition pins // bc-pnum ruling 2 end to end, through the REAL draw pipeline // (eng.GenerateDraw / eng.DiscardDraw) and the real HTTP viewer detail diff --git a/internal/state/models.go b/internal/state/models.go index c8477b615..e643ae175 100644 --- a/internal/state/models.go +++ b/internal/state/models.go @@ -1,6 +1,7 @@ package state import ( + "encoding/json" "errors" "fmt" "net/url" @@ -177,6 +178,19 @@ type Theme struct { LogoPath string `yaml:"logo_path,omitempty" json:"-"` // disk filename; served via /api/branding/logo } +// MarshalJSON adds hasLogo (true when a custom logo is uploaded) to the wire +// shape, derived from LogoPath, which stays off the wire. A client learns +// whether a logo exists from the tournament it already holds, so it never +// probes /api/branding/logo and meets a 404 while none is set (bc-lgnf). +// Absent when false, so a theme without a logo serialises exactly as before. +func (t Theme) MarshalJSON() ([]byte, error) { + type plain Theme + return json.Marshal(struct { + plain + HasLogo bool `json:"hasLogo,omitempty"` + }{plain(t), t.LogoPath != ""}) +} + const maxWindowTitleLen = 100 var hexColorRE = regexp.MustCompile(`^#[0-9a-fA-F]{6}$`) diff --git a/scripts/e2e/README.md b/scripts/e2e/README.md index bedbe587a..8cdab19bc 100644 --- a/scripts/e2e/README.md +++ b/scripts/e2e/README.md @@ -25,10 +25,13 @@ failed: the `test.fixme` steps of open beads (see [When a journey finds a bug](#when-a-journey-finds-a-bug)) are reported as skipped. -The Playwright version here and the one `scripts/screenshots` resolves are the -same release, so the two share one Chromium under `~/.cache/ms-playwright`. If -that cache is removed the stamp outlives it; run `npx playwright install -chromium` here. +This directory pins `@playwright/test` at 1.63.0, and `scripts/screenshots` +resolves `playwright-core` 1.63.0 from its lockfile (its `package.json` only +asks for `^1.56.0`, so the match holds because both lockfiles are current, not +because anything enforces it). While the two agree they share one Chromium under +`~/.cache/ms-playwright`; if either lockfile moves to another release, each +downloads its own. If that cache is removed the stamp outlives it; run +`npx playwright install chromium` here. To debug, pass Playwright's own flags through `E2E_ARGS`: `--headed` shows the browser, `--debug` opens the inspector, and a failing test leaves a trace @@ -36,38 +39,126 @@ browser, `--debug` opens the inspector, and a failing test leaves a trace ## Layout + package.json Playwright, pinned; package-lock.json is its + lockfile playwright.config.mjs projects, timeouts, output fixtures/test.mjs the `test` every journey imports (server, baseURL, page) and the console policy (below) - fixtures/devices.mjs the operator iPad and the spectator phone - fixtures/setup.mjs first-run tournament form, sign in, sign out - fixtures/pace.mjs the dwell the app's tap-bounce guard needs (below) + fixtures/devices.mjs the operator iPad and the spectator phone, and + EXPECT_TIMEOUT_MS, the one expect timeout + fixtures/setup.mjs first-run tournament form, sign in, sign out, and + enterTournament (sign in, or create the tournament + when the file's server has none): the one owner + fixtures/pace.mjs the dwell the app's tap-bounce guard needs (below), + and the two settle helpers, rendered and + untilStable (Waiting for the app) fixtures/wizard.mjs the create-competition wizard fixtures/competition.mjs roster paste box, Generate / Discard draw, Start + fixtures/roster.mjs the Registration desk: check-in, search, + Group by dojo; the seed boxes; the participant Edit + dialog; reads of pool membership and bracket slots. + fixtures/pools.mjs the pool standings tables, admin Pools tab and the + public viewer's Pools tab: rows, figures, rank + badge, advancing; the individual pool row + (playerRowFigures). fixtures/shiaijo.mjs /admin/shiaijo/:court (queue, inline editor, which match an editor holds) fixtures/scores.mjs the Scores tab and its overlay editor - fixtures/scoring.mjs scoring an individual bout on either editor, and - the Finish check every editor shares + fixtures/scoring.mjs scoring an individual bout on either editor, Start + match (startMatch), clearMarks, Mark draw + (markDraw), the Finish check every editor shares + (finishMatch, which the team sheet's finishTeam + is), and the wait for a match write's answer + (nextMatchWrite, writeLanded) fixtures/individual.mjs individual rosters, the decision panel, playing and - correcting a bout on the court console + correcting a bout on the court console, and a bout + played from a script (playScriptedBout), such as a + pool bout fixtures/team.mjs team members, the three lineup entry points (the Lineups page, the lineup panel, the score sheet - row) and the team score sheet's bout rows + row) and the team score sheet's bout rows (the + strike on a row, ipponOnRow, is shared with the + kachinuki sheet) fixtures/kachinuki.mjs the kachinuki sheet: bouts, Record bout, Encho, End match, the correction doors + fixtures/engi.mjs the engi (kata pairs) flag editor: flag counters, + entering flags, the two-tap Save result + fixtures/held-write.mjs holding a running match's score write in flight, to + observe a race with its echo (below) fixtures/shared-court.mjs two competitions on one shiaijo and a moved match: seeding, the competition picker, the nudges - fixtures/public.mjs the public viewer and the TV board + fixtures/public.mjs the public viewer and the TV board; the readers of + the watch picker and the schedule filter's options + (pickerEntries); schedule rows; the watchlist hero; + the doors to a match card (homeNowRow, poolRow, + upNextRow); the card's marks (cardMarks) + fixtures/organiser.mjs the Excel download, folder import, /reset, logo and + sponsor uploads; runtime PNG and import-bundle + makers; a zip reader for workbooks. fixtures/clumsy.mjs the clumsy operator (below) - fixtures/pointer.mjs the coarse pointer: check, restore, tap-target size + fixtures/pointer.mjs the coarse pointer: check, restore, ensureCoarse + (the one "if not coarse, restore"), tap-target size fixtures/shots.mjs per-step screenshots, and each journey's folder fixtures/audit.mjs audit rows (AUDIT lines and audit.json) - journeys/*.spec.mjs the journeys - -Each fixture module owns the selectors for one surface, so a UI change breaks -one file. The wizard is the one most likely to move; `fixtures/wizard.mjs` is -the only file that knows its copy. + journeys/*.spec.mjs the journeys, one line each: + smoke the harness end to end: seeding through the + interface, scoring on both editors, every + clumsy helper once, a spectator's phone + knockout-mixed-individual the court operator's review of individual + competitions (pools and knockout), and the + bead guards (bc-ltap and others) + knockout-mixed-team team lineups from the three entry points, + scoring a team encounter, one result on every + surface + knockout-mixed-kachinuki kachinuki (winner stays on) played eight bouts + deep, every correction door, the final + kachinuki-pools kachinuki in pools: winner stays on, a + hikiwake, a team exhausted, a level pool draw + with no representative bout, the pool tables + on admin and phone, the winners in the final + pools-tiebreak pools and knockout at a minimum pool size (7 + entrants make pools of 4 and 3); a pool tie + level on every criterion adds a tie-break + bout, whose winner is ranked and seated in the + knockout on admin and phone; a tie split on + points lost adds none; a second competition + with Oversized send +1 where the tie is over + the extra qualifier place, owed a bout too; a + third with Fit the knockout where the tie is + over the 2nd the draw drafted, owed a bout + while the same tie in an undrafted pool is not + moved-match a waiting match moved to another shiaijo + reaches that court's console without a reload + shared-shiaijo two competitions on one shiaijo: the picker, + the nudge lines, correcting across them + engi an engi (kata pairs) knockout scored in flags, + and the bc-evts tap-back guard + organiser-roster check-in decides who is drawn, and the desk + finds a number; seeds keep two seeds out of + one pool; an edited or replaced competitor + reaches the bracket, court console and public + viewer; a duplicate name and dojo is refused. + organiser-admin file-mode organiser jobs: the results + workbook, folder import, including a row with + no date, logo and sponsor, forgotten-password + reset. + organiser-locked locked mode: sign-in with the hashed password, + no Forgot password, and /reset shows it is + disabled. + spectator.public a spectator's phone (public project): the + home, competition tabs, schedule; competitor + search by number, name and dojo; the watchlist + hero and the match card following an operator + live; the match card from each door + output/ screenshots, audit.json and traces (below); + gitignored + +Each fixture module owns the selectors for one surface, so a UI change +usually breaks one fixture file. Not only one: `fixtures/shared-court.mjs` +reads the create wizard page itself (`/admin/create-competition`, its "Add +competition" heading, `#create-start-time`) beside `fixtures/wizard.mjs`, and +a spec holds raw locators for what it checks once. The wizard is the surface +most likely to move; look in both files when it does. ## Projects and devices @@ -79,11 +170,19 @@ Two projects, both Chromium: emulation and the test carries a `coarse-pointer` annotation saying so. Every journey runs here unless its file is named `*.public.spec.mjs`. - `public`: a phone in portrait (390x844), for journeys about a public surface - alone (`*.public.spec.mjs`). No journey uses this project yet; the public - surfaces are checked from `operator` journeys through a second context. - -240 s is the default test timeout (`expect` waits 15 s); some journeys raise it -for a long test with `test.setTimeout`. `retries` is 0 and `forbidOnly` is on, so a stray `test.only` fails the run. + alone (`*.public.spec.mjs`). `spectator.public.spec.mjs` is the one journey + on this project: it seeds and scores from a second context opened with + `OPERATOR_DEVICE`. Other public checks stay on the `operator` project, + through a second context. + +240 s is the default test timeout; some journeys raise it for a long test with +`test.setTimeout`. `expect` waits 15 s (`EXPECT_TIMEOUT_MS` in +`fixtures/devices.mjs`, which the fixtures use for their own waits), an action +such as a tap waits 15 s (`actionTimeout`) and a navigation 30 s +(`navigationTimeout`). `retries` is 0 and `forbidOnly` is on, so a stray +`test.only` fails the run. Chromium runs with `--no-sandbox` only when the +process is root, which it refuses to sandbox; anywhere else the sandbox stays +on (the same rule as `scripts/screenshots/run.mjs`). An operator journey that needs a second device opens another context with one of the two devices from `fixtures/devices.mjs`: `PUBLIC_DEVICE` for a public @@ -101,11 +200,31 @@ server; on a slow or loaded machine pass `E2E_ARGS='--workers=1'`. A worker runs its files one after another (all of them with `--workers=1`), and each file starts with a fresh server, never the previous file's tournament. A worker stops a file's server when it starts the next file's server, or when -the worker ends. Tests in one file share its server, so a spec file -that seeds once and then walks several tests declares +the worker ends. Tests in one file share its server, so a spec file that seeds +once and then walks several tests declares `test.describe.configure({ mode: 'serial' })`. A `beforeAll` hook asks for it with `serverForFile(testInfo.file)`. Journeys navigate by path -(`page.goto('/admin')`); the host is the file's server. +(`page.goto('/admin')`); the host is the file's server. A spec file that +needs its server started with extra arguments or environment declares them with +`serverOptionsFor(fileURLToPath(import.meta.url), () => ({ args, env }))` from +`fixtures/test.mjs`, as `organiser-locked.spec.mjs` does for `--lock-password` +and its hash in `TOURNAMENT_PASSWORD_HASH`. + +When a test fails, Playwright discards its worker. In a serial file the rest of +that file is skipped, because the later tests depend on what the earlier ones +seeded. A file that is not serial (`knockout-mixed-team.spec.mjs`, and the bead +guards at the end of `knockout-mixed-individual.spec.mjs`) goes on in a fresh +worker, with a fresh server and an empty data folder, so each of its tests must +seed or sign in again itself (`enterTournament` creates the tournament when the +server has none). + +`knockout-mixed-kachinuki.spec.mjs` is serial but cannot let a guard skip the +rest. Its four regression guards (bc-crpn, bc-emsl, bc-kheb, bc-sync) need the +final's live console, so they run as steps inside the final's tests, wrapped in +`guarded()`. A guard that fails is not thrown. It is recorded as a +`guard-failure` annotation on the test and as an audit row, the console is put +back, and the final's own checks still run; the played-out test then fails at +its end naming every guard that failed. ## Seeding goes through the interface @@ -120,10 +239,14 @@ about one screen, not the path to it) and its `api.mjs`, `seed.mjs` and the `authAdmin` sign-in shortcut are not used here. Scoring goes through the score editors too. `scripts/screenshots/lib/editor.mjs` -owns the editor selectors and Start, which `fixtures/scoring.mjs` re-exports -beside the ippon helpers. Finish is `fixtures/scoring.mjs`'s own: a journey -taps it, on the inline editor as well as the overlay, and `finishMatch` (and -`finishTeam`, through the same check) returns only once the write landed. It +owns the editor selectors, which `fixtures/scoring.mjs` re-exports beside the +ippon helpers. Start and Finish are `fixtures/scoring.mjs`'s own, not that +library's, which clicks and can return without having acted. `startMatch` taps +the overlay editor's Start match, fails when the button never appears, and +returns once the button has left. A journey taps Finish, on the inline editor as +well as the overlay, and `finishMatch` (the team sheet's `finishTeam` is the +same function, given the sheet as its root) returns only once the write +landed. It fails at once when the server does not answer the completing write within the expect timeout, answers it with an error status, or answers `applied: false` (the refusal a disarmed Finish follows). It then waits for the editor to let @@ -140,8 +263,8 @@ variants as real input: |---|---| | `tapNeighbour(locator, direction)` | taps the control next to the intended one, found by layout, and returns which it hit | | `doubleTap(locator)` | two taps inside the app's bounce window less 100 ms; throws when the machine was too slow to land them that close, so a caller may assert on the outcome unconditionally | -| `retapWhileSaving(locator)` | a second tap while the button reads "Saving…" (writes are held briefly so the state is observable); throws when "Saving…" is never seen | -| `interrupt(page, kind, { awayMs, during })` | `reload`, `back` (then forward, only if Back moved), `hidden` (tab to background and back), `offline` (network down and back); `awayMs` (600 by default) is the time away and `during(page)` runs while the operator is away | +| `retapWhileSaving(locator)` | a second tap while the button reads "Saving…" (writes are held briefly so the state is observable, and every write the taps caused is waited for before it returns, so a duplicate one is on the page when the caller reads it); throws when "Saving…" is never seen | +| `interrupt(page, kind, { awayMs, during })` | `reload`, `back` (then forward, only if Back moved), `hidden` (tab to background and back), `offline` (network down and back); `awayMs` (600 by default) is the time away and `during(page)` runs while the operator is away, for every kind but `reload`, which reloads at once and uses neither | | `hastyConfirm(page)` | answers the open confirm dialog with its most prominent button and returns its label | | `hastyShiaijoConfirm(page)` | the same for the shiaijo page's own confirms (Send back to queue, Move court) | | `loudestButton(scope)` | the ranking both confirm helpers use, for any other panel | @@ -150,10 +273,38 @@ Each returns what happened, and a journey records that as an audit row. Where the docs or an operator ruling say what must happen (a double tap on an ippon button lands one mark, an interruption leaves the operator on the same court), the journey also asserts it, so a regression fails the suite; the rest is -judged by a person from the screenshots. `hidden` needs a note: headless Chromium never changes page -visibility on its own, so the helper overrides `document.hidden` and -`document.visibilityState` and dispatches a real `visibilitychange` for each -edge, which is what the app's listeners read. +judged by a person from the screenshots. + +A helper asserts nothing about the app, but it throws when it could not perform +its variant: `tapNeighbour` finds no neighbour, `doubleTap` lands its taps too +far apart, `retapWhileSaving` never sees "Saving…", and the confirm helpers find +no dialog. + +`hidden` needs a note: headless Chromium never changes page visibility on its +own, so the helper overrides `document.hidden` and `document.visibilityState` +and dispatches a real `visibilitychange` for each edge, which is what the app's +listeners read. + +## Holding a save in flight + +On localhost the answer to an autosave comes back within milliseconds of the save +going out, so a tap made "while the save is out" never lands in that window by +itself. `fixtures/held-write.mjs` (`holdScoreWrites(page)`) intercepts the match +score PUTs: `holdNext()` holds the next one, its `sent` promise resolves once that +PUT is out (so the autosave debounce has run), and `release()` lets it through +after the journey has made the next tap, as a slow uplink would. `bodies` lists +every score PUT the page sent. `answeredBeforeNextWrite(held)` waits for the held +write's response, then for the write that follows it (each up to `safetyMs`), and +throws if no follow-up write was ever sent or if it was sent before the response +reached the page, so a journey cannot pass without exercising the race. A hold +nobody releases is let go after a safety timeout (`safetyMs`, 10 s by default); +`stop()` only cleans up (release, unroute, remove the listener) and is called in a +`finally`, and `assertNoSafetyRelease()` throws when the safety timeout had to +fire, so a journey calls it after its body succeeded, outside the `finally`, where +a throw would hide the body's own assertion error. The bc-ltap step of +`knockout-mixed-individual.spec.mjs` (individual editor) and the bc-evts step of +`engi.spec.mjs` (engi editor) use it to guard a flag or mark taken back while its +add is still saving. ## The pace of a tap @@ -164,15 +315,55 @@ that window, whichever button it lands on (`web-mobile/js/tap_guard.jsx`; a tap on the other side counts at once). Playwright's second tap is always inside it, so `fixtures/pace.mjs` holds the dwell (the screenshot harness's `TAP_DWELL_MS`, 500 ms) and every helper that taps into a fresh layer, confirms an arm -or strikes for a side waits it out: `login`, `hastyConfirm`, -`hastyShiaijoConfirm`, `lineupsNameSlot`, `finishMatch`, `finishTeam`, -`endMatch`, `awardIppon`, `boutIppon`, `awardBoutIppon` (`tapOnSide` keeps the -time of the last tap on each side). A journey that taps such a thing by hand -does the same. `doubleTap` is the one helper that deliberately does not: it is -the bounce, and the app is meant to ignore the second tap. `pace.mjs` reads the +or strikes for a side waits it out. Into a fresh layer: `login`, +`hastyConfirm`, `hastyShiaijoConfirm`, `lineupsNameSlot`. Onto an armed button, +through `armTwoTap` (tap, wait for the armed state, out the dwell): `armFinish` +(so `finishMatch` and `finishTeam`), `endMatch`, `saveEngiResult`. Per side of +a bout, through `tapOnSide`, which keeps the time of the last tap on each side: +`awardIppon` and `ipponOnRow` (the strike `boutIppon` makes on the team sheet +and `awardBoutIppon` on the kachinuki sheet, which both wait for the board and +the autosave). In a fighter name list, through `tapNameOption`, which keeps the +time of the last option tap: a pick stamps the team sheet's bout list, which +ignores every click inside it for that window, so `pickFromNameBox` and any +option tapped by hand go through it. A journey that taps such a thing by hand +does the same. `doubleTap` is the one +helper that deliberately does not: it is the bounce, and the app is meant to +ignore the second tap. `pace.mjs` reads the app's window from `web-mobile/js/tap_guard.jsx` (`TAP_BOUNCE_MS`) rather than copying it, and fails to load if the dwell is not longer than it. +## Waiting for the app + +A journey waits for what it is about to read, and never for a guessed time. +In order of preference: a state (`expect(...).toHave...`, `expect.poll`); the +server's answer to a write (`nextMatchWrite(page)` before the tap that causes +it, `writeLanded(answered)` after: the sync pill is process-wide, not per +match, so it cannot say that one match's edit was saved); and, when the +outcome is the thing under audit and no single state names it, one of two +settle helpers in `fixtures/pace.mjs`: + +- `rendered(page)` waits two animation frames, for the read after a tap whose + effect varies by design and is local to the page (the control next to the + intended one, a double tap). +- `untilStable(read)` polls `read()` until two reads 400 ms apart agree and + returns that value, for a screen that refetches after the action (a reload, + Back and Forward, a write's echo) and ends in a state the journey records + rather than expects. A read taken after a tap can agree with itself on the + state from BEFORE the tap, so a probe passes `from` (the settled read taken + before the tap): the helper then waits up to 3 s for the read to leave it, + and settles on it only if nothing ever changed. Where the end state IS known, + wait for that instead (`expect.poll(...)`); where the tap causes one match + write, wait for its answer (`nextMatchWrite`). + +`page.waitForTimeout` stays in four places, each marked `Deliberate:` or +named for what it is: the dwell of a tap's pace (above); the time away of an +`interrupt` (`awayMs`) and the operator's own delay a journey names ("3 s +later", "300 ms after Finish"); and a NEGATIVE, where the journey asserts that +nothing happens (no second bout appended by a bounced tap, no start after a +decision, a taken-back mark staying taken back after a late echo). An absence +has no state to wait for, so the time is the evidence, and a single read +straight after the action would pass whatever came later. + ## The console policy `fixtures/test.mjs` listens to every page of every browser context a test @@ -187,25 +378,36 @@ fixture is wrapped so none is missed). When the test ends: `CONSOLE {json}` line otherwise, and is listed on the test as a `console-warning` annotation. -Everything a test's pages logged is attached to it as `console.json` (kept in a failing test's trace), with the -page it came from, whether the device was offline at the time, and, for a -failed request, the status and body of the `/api` answer. What a `beforeAll` -hook's pages log, or what the previous test's pages still had open, is checked -with the first test of the file and marked "before this test (a beforeAll hook or -the previous test's open pages)"; what an `afterAll` hook's pages log is reported under its own file when -the next file's server starts or the worker stops, and an error there fails -the run. +Everything a test's pages logged is attached to it as `console.json`, but only +when there is something to attach. A failing test keeps it in its trace. Each +entry carries the page it came from and whether the device was offline at the +time. For a failed request it also carries the status and body of the `/api` +answer. + +What a `beforeAll` hook's pages log, or what the previous test's pages still had +open, is checked with the first test of the file and marked "before this test (a +beforeAll hook or the previous test's open pages)". What an `afterAll` hook's +pages log is reported under its own file when the next file's server starts or +the worker stops, and an error there fails the run. -The allowlist holds only noise a journey's scenario makes, in three entries: +The allowlist holds only noise a journey's scenario makes, in four entries: requests the browser could not send while a journey has the device offline; the app's own "Failed to fetch court matches" log from the court console -while the device is offline; and the 409s of a start (a `/score` write whose +while the device is offline; the 409s of a start (a `/score` write whose body carries `status: "running"`) or a reopen the app sends through the server's gate and shows refused (the court busy, a competitor fighting elsewhere or barred by a withdrawal, or the knockout match it feeds being -fought). Any other write refused that way is an error. A browser "Failed to -load resource" line is paired with the `/api` answer it is about one to one, -in arrival order per URL. +fought). Any other write refused that way is an error. The fourth is the 409 +refusing an organiser's participant edit to the same name and dojo as another +competitor (organiser-roster journey); the same name in another dojo is +accepted. A browser "Failed to load resource" line is paired with the `/api` +answer it is about one to one, in arrival order per URL. Only when the page was +closed while the 409's body was being read is the refusal code unreadable, and +the entry then stands on the answer's status, URL and request alone; the +`CONSOLE` line and a `console-allowed-unread` annotation say "allowed: page +closed before the refusal's code could be read". A body that is missing for any +other reason (the read timed out, or failed on a page still open, as a reload +does) is not allowed: the test fails. Each entry matches as narrowly as it can (the message, the URL, the refusal code, offline only) and says why in one line. An error that looks like the app's own fault does not go on it: the test stays red until a person decides. @@ -228,19 +430,22 @@ from this directory. The trace's Attachments tab holds the test's `console.json` (see [The console policy](#the-console-policy)); the list reporter does not write it to a file of its own, and a passing test keeps no trace, so its console errors are only in the run log as `CONSOLE {json}` lines. A failing -test also carries the server's output as `server.log`. +test also carries the server's output as `server.log`, when a server is running +for its file. Every run empties its whole results folder (`output/test-results/`, or -`E2E_RESULTS_DIR` when set) when it starts, and with `E2E_RESULTS_DIR` set the -journey folders live inside it, so pointing it at a folder that holds anything -else deletes that content. A journey empties -its own `output//` in `beforeAll`. Two runs at the same time in one -checkout would delete each other's traces mid-test, and two runs without -`E2E_RESULTS_DIR` share every `output//`, so give the second one its -own folder: `E2E_RESULTS_DIR=output/test-results-2 make e2e ...`. -With `E2E_RESULTS_DIR` set, the journey folders move inside it too -(`/journeys//`), so neither run empties the other's. -A relative `E2E_RESULTS_DIR` is relative to this directory. +`E2E_RESULTS_DIR` when set) when it starts, so pointing `E2E_RESULTS_DIR` at a +folder that holds anything else deletes that content. A journey empties its own +folder when it calls `journeyShots`, which is once per spec file or once per +test (see below). + +Two runs at the same time in one checkout would delete each other's traces +mid-test, and two runs without `E2E_RESULTS_DIR` share every `output//`, +so give the second one its own folder: +`E2E_RESULTS_DIR=output/test-results-2 make e2e ...`. With `E2E_RESULTS_DIR` set, +the journey folders live inside it, as `/journeys//`, +so neither run empties the other's. A relative `E2E_RESULTS_DIR` is relative to +this directory. ## When a journey finds a bug @@ -262,13 +467,20 @@ removes the `fixme`, and the step then guards the fix. 3. Seed through the interface with the fixtures (`createTournament`, `login`, `createCompetition`, `pasteRoster`, `generateDraw`, `startCompetition`). 4. Take screenshots and keep an audit: `const shot = journeyShots('')` - in `beforeAll` (it empties the folder), `const audit = auditLog('')` - at describe level with `audit.flush()` in `afterAll`, so a journey that fails - part way still leaves its rows. `record` from the audit prints each row as an + (it empties the folder) either in `beforeAll`, for a file whose tests share + one folder, or inside a test, for a file that gives each test a folder of its + own (`knockout-mixed-individual` and `knockout-mixed-team` do); + `const audit = auditLog('')` at describe level with `audit.flush()` + in `afterAll`, so a journey that fails part way still leaves its rows. `record` from the audit prints each row as an `AUDIT` line. 5. When tests share the seeded server, declare `test.describe.configure({ mode: 'serial' })`. 6. A defect the journey finds becomes a `test.fixme(': ...')` step (see below). -`smoke.spec.mjs` is the smallest complete example. +`smoke.spec.mjs` is the smallest complete example. `engi.spec.mjs` is the one +journey on an engi (kata pairs) competition: it seeds four pairs through the +wizard's "Engi (kata competition)" box (`engi: true` in `createCompetition`) and +the roster paste box (`Name 1 - Name 2, Dojo`), scores a match in flags on the +court console and checks the result on a spectator's phone, and guards the engi +editor's tap-back race. diff --git a/scripts/e2e/fixtures/audit.mjs b/scripts/e2e/fixtures/audit.mjs index 18e703457..dd2d4e7e9 100644 --- a/scripts/e2e/fixtures/audit.mjs +++ b/scripts/e2e/fixtures/audit.mjs @@ -13,11 +13,16 @@ import { fileURLToPath } from 'node:url'; import { journeyDir } from './shots.mjs'; // spec file -> the audits it made, so the console policy (fixtures/test.mjs) -// can add its warning rows to the audit of the test's own file. The file is -// read off the call stack: auditLog is always called while a spec file loads. +// can add its warning rows to the audit of the test's own file, looked up by +// the testInfo.file Playwright reports. auditLog runs while a spec file loads, +// before any testInfo exists, so the file is read off the call stack: the first +// frame in a `.spec.mjs` followed by its `:line:column`, taken to that suffix +// (not to the next space, parenthesis or colon), so a checkout path holding any +// of those still resolves. An ESM frame is a file URL (percent-encoded), which +// fileURLToPath decodes to the path testInfo.file carries. const bySpec = new Map(); function callingSpec() { - const m = (new Error().stack || '').match(/((?:file:\/\/)?\/[^\s():]+\.spec\.mjs)/); + const m = /(file:\/\/\/[^\n]*?\.spec\.mjs|\/[^\n]*?\.spec\.mjs)(?=:\d+:\d+)/.exec(new Error().stack || ''); if (!m) return null; return m[1].startsWith('file://') ? fileURLToPath(m[1]) : m[1]; } diff --git a/scripts/e2e/fixtures/clumsy.mjs b/scripts/e2e/fixtures/clumsy.mjs index 495c142c3..26a161df3 100644 --- a/scripts/e2e/fixtures/clumsy.mjs +++ b/scripts/e2e/fixtures/clumsy.mjs @@ -13,12 +13,17 @@ // happened (which element was hit, how far apart the taps were, what the // dialog said), so the journey can record it as an audit row. None of them // asserts the app's behaviour: whether the outcome is correct is the journey's -// call, and judgement columns are formed by eye, never asserted. +// call, and judgement columns are formed by eye, never asserted. They throw +// only when the variant itself could not be performed, so the row would not +// have tested what it says: tapNeighbour finds no neighbour in reach, +// doubleTap's taps land too far apart, retapWhileSaving never sees "Saving…", +// and a helper that needs a dialog or a button finds none. // // Taps go through page.touchscreen at the element's centre: the browser's own // hit-testing decides what is tapped, as it does for a thumb. Every project in // playwright.config.mjs has a touchscreen. import { errors } from '@playwright/test'; +import { EXPECT_TIMEOUT_MS } from './devices.mjs'; import { dwell, TAP_BOUNCE_MS } from './pace.mjs'; // The longest gap that still counts as a double tap: the app's bounce window @@ -118,7 +123,7 @@ export async function doubleTap(locator) { // "Saving…". A local server answers in milliseconds, far too fast for any // thumb, so the write is held for `holdWritesMs` first (every non-GET /api // request), which is what a venue's wifi does anyway. Returns -// { savingSeen, retapped, labelAfter }, both flags always true: a button that +// { savingSeen, retapped, labelAfter, writesSent }, both flags always true: a button that // never shows "Saving…" within the hold THROWS (the second tap was not made, so // a check built on this helper would pass without testing anything). Any other // failure propagates as it is. @@ -136,6 +141,21 @@ export async function retapWhileSaving(locator, { holdWritesMs = 1200, savingTex return route.continue().catch(() => {}); }; await page.route('**/api/**', hold); + // Every write the two taps cause is counted and waited for: a second write + // from the bounced tap is held on its own clock and can land after the first + // one's answer has cleared "Saving…", so the caller reads the page only once + // none is in flight, whatever it then finds. + const inFlight = new Set(); + let writesSent = 0; + const started = (req) => { + if (req.method() === 'GET' || !new URL(req.url()).pathname.startsWith('/api/')) return; + inFlight.add(req); + writesSent += 1; + }; + const ended = (req) => inFlight.delete(req); + page.on('request', started); + page.on('requestfinished', ended); + page.on('requestfailed', ended); try { await page.touchscreen.tap(x, y); try { @@ -156,11 +176,19 @@ export async function retapWhileSaving(locator, { holdWritesMs = 1200, savingTex await page.waitForFunction( ([el, src, flags]) => !el.isConnected || !new RegExp(src, flags).test(el.textContent || ''), [handle, savingText.source, savingText.flags], - { timeout: holdWritesMs + 15000 }, + { timeout: holdWritesMs + EXPECT_TIMEOUT_MS }, ); + const drainBy = Date.now() + holdWritesMs + EXPECT_TIMEOUT_MS; + while (inFlight.size > 0) { + if (Date.now() > drainBy) throw new Error(`clumsy.retapWhileSaving: ${inFlight.size} write(s) still unanswered after the retap`); + await new Promise((r) => setTimeout(r, 50)); + } const labelAfter = await handle.evaluate((el) => (el.isConnected ? el.textContent.trim() : null)); - return { savingSeen, retapped: savingSeen, labelAfter }; + return { savingSeen, retapped: savingSeen, labelAfter, writesSent }; } finally { + page.off('request', started); + page.off('requestfinished', ended); + page.off('requestfailed', ended); await page.unroute('**/api/**', hold); await handle.dispose(); } @@ -280,7 +308,7 @@ export async function loudestButton(scope) { // answer opens would be waited on instead of the one that was answered. async function waitAnswered(handle) { try { - await handle.waitForElementState('hidden', { timeout: 15_000 }); + await handle.waitForElementState('hidden', { timeout: EXPECT_TIMEOUT_MS }); } finally { await handle.dispose(); } diff --git a/scripts/e2e/fixtures/competition.mjs b/scripts/e2e/fixtures/competition.mjs index 9894db26a..3ab471a6e 100644 --- a/scripts/e2e/fixtures/competition.mjs +++ b/scripts/e2e/fixtures/competition.mjs @@ -1,6 +1,7 @@ // A competition's own admin pages: the roster paste box (Participants & // seeds), Generate draw, Discard draw and Start competition. import { expect } from '@playwright/test'; +import { dwell } from './pace.mjs'; const compPath = (id, section = '') => `/admin/competition/${id}${section ? `/${section}` : ''}`; @@ -39,6 +40,17 @@ export async function requestDiscardDraw(page) { await expect(page.locator('.modal[role="dialog"] .modal__foot')).toBeVisible(); } +// Discard the draw and answer its confirm, after the confirm's own bounce +// window, with the button that discards. Returns once the header offers +// Generate draw again. +export async function discardDraw(page) { + await requestDiscardDraw(page); + const dlg = page.locator('.modal[role="dialog"]').filter({ has: page.locator('.modal__foot') }).last(); + await dwell(page, Date.now()); + await dlg.getByRole('button', { name: 'Discard draw' }).tap(); + await expect(page.getByRole('button', { name: 'Generate draw' })).toBeVisible(); +} + // Start the competition. There is no confirm: the tap starts it and lands on // the Scores tab. export async function startCompetition(page, id) { diff --git a/scripts/e2e/fixtures/engi.mjs b/scripts/e2e/fixtures/engi.mjs new file mode 100644 index 000000000..18b011325 --- /dev/null +++ b/scripts/e2e/fixtures/engi.mjs @@ -0,0 +1,80 @@ +// Scoring an ENGI (kata pairs) match on the flag editor +// (admin_scoring_engi.jsx). An engi competitor is a pair, rostered as one +// participant ("Name 1 - Name 2, Dojo"), and a match is scored in flags, not +// ippon: each side has a counter, and the total must be 1, 3 or 5. The editor +// is the same whether the shiaijo page mounts it inline or the Scores tab +// opens it as an overlay; the helpers default to the inline one. +import { expect } from '@playwright/test'; +import { INLINE_EDITOR } from '../../screenshots/lib/editor.mjs'; +import { beforeFinish, finishLanded } from './scoring.mjs'; +import { armTwoTap } from './pace.mjs'; +import { sides, upNextCard } from './shiaijo.mjs'; + +// The editor panel itself (inline) or its dialog (overlay). +export const engiEditor = (page, root = INLINE_EDITOR) => page.locator(root).filter({ has: page.getByTestId('engi-submit') }).first(); + +const SIDE_KEY = { aka: 'aka', shiro: 'shiro' }; + +// A side's flag count as the board shows it, and its + / - buttons. +export const flagCount = (page, side, root = INLINE_EDITOR) => engiEditor(page, root).getByTestId(`engi-${SIDE_KEY[side]}-count`); +export const flagButton = (page, side, change, root = INLINE_EDITOR) => engiEditor(page, root) + .getByTestId(`engi-${SIDE_KEY[side]}-${change === '+' ? 'inc' : 'dec'}`); +export const engiTotal = (page, root = INLINE_EDITOR) => engiEditor(page, root).getByTestId('engi-total'); +export const engiSubmit = (page, root = INLINE_EDITOR) => engiEditor(page, root).getByTestId('engi-submit'); + +// The autosave pill of the open engi editor ("Synced", "Syncing…", ...). +export const engiSync = (page, root = INLINE_EDITOR) => engiEditor(page, root).locator('.sync-pill__label').first(); + +// Wait for the autosave to land. +export const engiSettle = (page, root = INLINE_EDITOR) => expect(engiSync(page, root)).toHaveText('Synced'); + +// The pair each side of the open editor holds, by the first member's line. +export async function engiPair(page, root = INLINE_EDITOR) { + const ed = engiEditor(page, root); + const first = async (side) => (await ed.getByTestId(`engi-side-${side}`).locator('.numbered-name__text').first().textContent()).trim(); + return { shiro: await first('shiro'), aka: await first('aka') }; +} + +// Start the Up next match from its card and return its pair once the engi +// editor holds it. +export async function startUpNextEngi(page) { + const card = upNextCard(page); + const pair = await sides(card); + await card.getByRole('button', { name: 'Start match' }).tap(); + await expect(engiEditor(page)).toBeVisible(); + const held = await engiPair(page); + // Each card holds its own pair: Shiro's the one the Up next card showed on + // Shiro, Aka's the one it showed on Aka. + const firstMember = (name) => name.split(' - ')[0].trim(); + expect(held.shiro, 'the Shiro card holds the Shiro pair').toBe(firstMember(pair.shiro)); + expect(held.aka, 'the Aka card holds the Aka pair').toBe(firstMember(pair.aka)); + return held; +} + +// Enter flags: tap + the given number of times on each side. Each tap is +// checked against the count it should show next, so a dropped tap fails at +// once. No dwell between taps: engi counters are deliberately not tap-guarded. +export async function enterFlags(page, { aka = 0, shiro = 0 }, root = INLINE_EDITOR) { + const expected = {}; + for (const [side, taps] of [['aka', aka], ['shiro', shiro]]) { + const count = flagCount(page, side, root); + const start = Number(await count.innerText()); + expected[side] = start + taps; + for (let i = 0; i < taps; i += 1) { + await flagButton(page, side, '+', root).tap(); + await expect(count, `the ${side} count shows the tap`).toHaveText(String(start + i + 1)); + } + } + await expect(flagCount(page, 'aka', root)).toHaveText(String(expected.aka)); + await expect(flagCount(page, 'shiro', root)).toHaveText(String(expected.shiro)); +} + +// Save the result in two taps ("Save result" / "Finish + Start Next →", then +// "Tap again to ..."), and return once the write has landed (finishLanded). +export async function saveEngiResult(page, root = INLINE_EDITOR) { + const ed = page.locator(root).first(); + const armed = await armTwoTap(engiSubmit(page, root), engiSubmit(page, root).filter({ hasText: /^Tap again to/ })); + const before = await beforeFinish(ed); + await armed.tap(); + return finishLanded(ed, before); +} diff --git a/scripts/e2e/fixtures/held-write.mjs b/scripts/e2e/fixtures/held-write.mjs new file mode 100644 index 000000000..4b7aafa38 --- /dev/null +++ b/scripts/e2e/fixtures/held-write.mjs @@ -0,0 +1,110 @@ +// Hold a running match's score write in flight, so a race with its echo can be +// observed. On localhost the answer to an autosave arrives within milliseconds +// of the save going out, so a tap made "while the save is out" never lands in +// that window by itself; the write is held until the test releases it, and it +// does so only after the next tap has been dispatched, as a slow uplink would +// hold it. A safety timeout lets go of a hold nobody released, and +// assertNoSafetyRelease() throws when one had to. +const SCORE_ROUTE = '**/api/competitions/*/matches/*/score'; +const isScorePut = (r) => r.method() === 'PUT' && /\/score$/.test(r.url()); + +// Start holding on `page`. Returns: +// holdNext() the next score PUT is held; returns { release, sent, answered } +// where `sent` resolves once that PUT has been sent (so the +// autosave debounce has run), `release()` lets it through and +// `answered` resolves once its response has reached the page +// answeredBeforeNextWrite(held) +// waits for the held write's response and then for the write that +// follows it (up to safetyMs each), and throws unless that write +// was sent AFTER the response reached the page +// (the echo then met a board whose next edit was still unsent, +// which is the race a journey holds the write for) +// bodies the JSON body of every score PUT the page sent, in send order +// stop() release any hold still pending and remove the route and the +// listener; cleanup only, never throws (call it in a finally, +// where a throw would replace the body's real assertion error) +// assertNoSafetyRelease() +// throws when the safety timeout had to release a hold; call it +// AFTER the body succeeded, outside the finally +export async function holdScoreWrites(page, { safetyMs = 10_000 } = {}) { + let hold = null; // armed by holdNext(), taken by the next PUT + let safetyFired = 0; + const bodies = []; + const sends = []; // { body, at } in send order, parallel to `bodies` + const holds = []; // every hold made, taken or not + const handler = async (route) => { + const req = route.request(); + if (isScorePut(req)) { + let body = null; + try { body = req.postDataJSON(); } catch { /* not JSON */ } + bodies.push(body); + sends.push({ body, at: Date.now() }); + if (hold) { + const h = hold; + hold = null; + h.request = req; + h.sendIndex = sends.length - 1; + let timer; + const timedOut = new Promise((resolve) => { + timer = setTimeout(() => { if (!h.released) safetyFired += 1; resolve(); }, safetyMs); + }); + await Promise.race([h.promise, timedOut]); + clearTimeout(timer); + } + } + await route.continue(); + }; + const onResponse = (res) => { + const h = holds.find((x) => x.request && x.request === res.request()); + if (h && h.answeredAt === undefined) { + h.answeredAt = Date.now(); + h.resolveAnswered(); + } + }; + await page.route(SCORE_ROUTE, handler); + page.on('response', onResponse); + return { + bodies, + holdNext() { + const h = { released: false, request: null, sendIndex: -1, answeredAt: undefined }; + h.promise = new Promise((resolve) => { h.release = () => { h.released = true; resolve(); }; }); + h.answered = new Promise((resolve) => { h.resolveAnswered = resolve; }); + holds.push(h); + hold = h; + return { release: h.release, sent: page.waitForRequest(isScorePut), answered: h.answered, hold: h }; + }, + async answeredBeforeNextWrite(held) { + const h = held.hold; + const timers = new Set(); + const wait = (ms) => new Promise((resolve) => { const t = setTimeout(resolve, ms); timers.add(t); }); + try { + let timedOut = false; + const expire = wait(safetyMs).then(() => { timedOut = true; }); + await Promise.race([h.answered, expire]); + if (timedOut) throw new Error('held score write: its response never reached the page'); + const deadline = Date.now() + safetyMs; + // The answer is in; the write that follows it may not have been sent yet. + while (!sends[h.sendIndex + 1] && Date.now() < deadline) await wait(25); + const next = sends[h.sendIndex + 1]; + if (!next) throw new Error('held score write: the follow-up write was never sent'); + if (next.at <= h.answeredAt) { + throw new Error(`held score write: the next write was sent ${h.answeredAt - next.at} ms before the held one's response reached the page, so the race was not exercised`); + } + } finally { + for (const t of timers) clearTimeout(t); + } + }, + async stop() { + // Release first: unrouting does not wait for a handler (Playwright + // 1.63.0), so a PUT still blocked in it would stay held until the safety + // timer instead of going through now. + hold = null; + for (const h of holds) h.release(); + page.off('response', onResponse); + await page.unroute(SCORE_ROUTE, handler); + }, + assertNoSafetyRelease() { + if (safetyFired) throw new Error(`held score write: the ${safetyMs} ms safety timeout released ${safetyFired} hold(s) nobody released`); + }, + }; +} diff --git a/scripts/e2e/fixtures/individual.mjs b/scripts/e2e/fixtures/individual.mjs index fe2b785f5..1e15de7fa 100644 --- a/scripts/e2e/fixtures/individual.mjs +++ b/scripts/e2e/fixtures/individual.mjs @@ -6,11 +6,12 @@ // tap-target measurement (fixtures/pointer.mjs) and the hasty confirms // (fixtures/clumsy.mjs) are shared with the other journeys. import { expect } from '@playwright/test'; -import { INLINE_EDITOR, awardIppon, finishMatch } from './scoring.mjs'; +import { INLINE_EDITOR, awardIppon, finishMatch, markDraw } from './scoring.mjs'; import { UP_NEXT, WAITING_ROWS, completedRowFor, courtDone, courtIdle, inlineEditor, upNextCard, } from './shiaijo.mjs'; import { hastyShiaijoConfirm } from './clumsy.mjs'; +import { EXPECT_TIMEOUT_MS } from './devices.mjs'; // Twelve entrants for F1 (pools of 3, two through): six dojos, two members // each, (name, dojo) unique. Names start with distinct letters so the @@ -32,11 +33,25 @@ export const ROSTER_6B = [ ['Bo Grant', 'Gyokusen'], ['Cy Novak', 'Thames'], ['Di Weller', 'Musashi'], ['Ed Frost', 'Seishinkan'], ['Flo Adler', 'Mumeishi'], ['Gil Varga', 'Hizen'], ]; +// Seven for the minimum-pool-size journey (pools-tiebreak): three pools (3, 2 +// and 2) at a maximum of 3, but two pools (4 and 3) at a minimum of 3. Seven dojos, so the +// draw has no dojo to keep apart and the pools are whatever the minimum rule cuts. +export const ROSTER_7 = [ + ['Hiro Sasaki', 'Kyoto Kai'], ['Ines Ferrer', 'Osaka Kan'], ['Joan Mercer', 'Nagoya Dojo'], + ['Kei Takagi', 'Sendai Kan'], ['Lars Nilsen', 'Sapporo Kai'], ['Mina Abara', 'Fukuoka Dojo'], + ['Noel Garnier', 'Kobe Kan'], +]; + // Small rosters for the ': ...' tests, which seed their own // competitions so each runs alone. -export const rosterOf = (prefix, n) => Array.from({ length: n }, (_, i) => [ - `${prefix} ${['Aki', 'Bea', 'Cal', 'Dee', 'Eli', 'Fay'][i]}`, ['Gyokusen', 'Thames', 'Musashi', 'Seishinkan', 'Mumeishi', 'Hizen'][i], -]); +const ROSTER_FIRST_NAMES = ['Aki', 'Bea', 'Cal', 'Dee', 'Eli', 'Fay']; +const ROSTER_DOJOS = ['Gyokusen', 'Thames', 'Musashi', 'Seishinkan', 'Mumeishi', 'Hizen']; +export const rosterOf = (prefix, n) => { + if (n > ROSTER_FIRST_NAMES.length) { + throw new Error(`rosterOf: ${n} entrants asked for, only ${ROSTER_FIRST_NAMES.length} names to give them`); + } + return Array.from({ length: n }, (_, i) => [`${prefix} ${ROSTER_FIRST_NAMES[i]}`, ROSTER_DOJOS[i]]); +}; // Four for each knockout-only fixture: two semi-finals, then the final (and // the bronze match when joint 3rd places are off). export const ROSTER_4_BRONZE = [ @@ -51,10 +66,10 @@ export const ROSTER_4_JOINT = [ export const winnerOf = ({ shiro, aka }) => (shiro.localeCompare(aka) <= 0 ? 'shiro' : 'aka'); // The two names an editor shows, Shiro first. -export async function editorSides(page, root = INLINE_EDITOR) { +export async function editorSides(page, root = INLINE_EDITOR, { timeout } = {}) { const ed = page.locator(root).first(); - const shiro = (await ed.locator('.sb-side--shiro .numbered-name__text').first().innerText()).trim(); - const aka = (await ed.locator('.sb-side--aka .numbered-name__text').first().innerText()).trim(); + const shiro = (await ed.locator('.sb-side--shiro .numbered-name__text').first().innerText({ timeout })).trim(); + const aka = (await ed.locator('.sb-side--aka .numbered-name__text').first().innerText({ timeout })).trim(); return { shiro, aka }; } @@ -116,7 +131,7 @@ export async function inlineRunning(page) { // renders is not taken for one with nothing left. Throws when Start was tapped // and no bout runs by the deadline, or the console never settles. const NOTHING_HELD_MS = 1_500; -export async function ensureRunning(page, { timeout = 15_000 } = {}) { +export async function ensureRunning(page, { timeout = EXPECT_TIMEOUT_MS } = {}) { const start = upNextCard(page).getByRole('button', { name: 'Start match' }); let tappedAt = null; let nothingSince = null; @@ -159,15 +174,49 @@ export async function playRunningBout(page, { waza = 'M' } = {}) { return { ...pair, winner: side }; } +// Play the running bout (or start the Up next one) as a script says, and Finish. +// `script({ shiro, aka })` returns what to record for that pairing, or throws for +// a pairing the journey did not plan: +// { draw: true } Mark draw (hikiwake, no points) +// { shiro: ['M'], aka: ['K', 'D'] } the waza each side is awarded, in order +// Marks level on both sides finish as hikiwake too (the board reads equal totals +// as a draw). Returns the pairing and what was recorded. +export async function playScriptedBout(page, script) { + const pair = await ensureRunning(page); + if (!pair) throw new Error('playScriptedBout: nothing running or startable on this court'); + const plan = script(pair); + if (plan.draw) await markDraw(page); + for (const side of ['shiro', 'aka']) { + for (const waza of plan[side] || []) await awardIppon(page, side, waza); + } + await finishMatch(page); + await expect(completedRowFor(page, pair)).toBeVisible(); + return { ...pair, plan }; +} + +// The bout the console's inline editor is running on the individual board, as +// its pairing, or null when no bout runs there. With `quick` (the poll for the +// bout to be gone) the editor can leave between the two reads, which is a bout +// no longer running: the pairing read then gives up in a second rather than +// waiting for a gone editor. A read that expects a bout waits as long as any +// other. +const liveIndividualBout = async (page, { quick = false } = {}) => ( + (await inlineRunning(page)) + ? editorSides(page, INLINE_EDITOR, quick ? { timeout: 1_000 } : {}).catch(() => null) + : null); + // Open the correction of a completed bout on the shiaijo page, the way the // operator now must. The console never holds a live bout and an open // correction at once (bc-crpn): while a bout runs, Correct is refused with a // notice at the row and a toast, and the notice says what to do: send the live // bout back to the queue (its score is kept), then correct. Returns the bout // that was live (null when the court was idle) and what the refusal said. -export async function correctCompleted(page, pair) { +// `readLive(page, { quick })` reads the bout the console's editor is running (null when +// none): the individual board by default; fixtures/shared-court.mjs passes +// liveBout, which reads a team sheet as well (correctAcross). +export async function correctCompleted(page, pair, { readLive = liveIndividualBout } = {}) { const done = completedRowFor(page, pair); - const live = (await inlineRunning(page)) ? await editorSides(page) : null; + const live = await readLive(page); await done.getByRole('button', { name: 'Correct' }).tap(); let refusal = null; if (live) { @@ -176,12 +225,12 @@ export async function correctCompleted(page, pair) { const toast = (await page.locator('.toast').allInnerTexts().catch(() => [])).join(' | '); refusal = { notice: (await notice.innerText()).trim(), toast, - editorStillOnLiveBout: (await inlineRunning(page)) && JSON.stringify(await editorSides(page)) === JSON.stringify(live), + editorStillOnLiveBout: JSON.stringify(await readLive(page)) === JSON.stringify(live), }; expect(refusal.notice).toMatch(/^Shiaijo \S+ is running .+\. Finish it or send it back to the queue first\. Then correct this match\.$/); await page.getByRole('button', { name: 'Send back to queue' }).tap(); refusal.sendBack = await hastyShiaijoConfirm(page); - await expect.poll(() => inlineRunning(page), { timeout: 10_000 }).toBe(false); + await expect.poll(() => readLive(page, { quick: true }), { timeout: 10_000 }).toBeNull(); await done.getByRole('button', { name: 'Correct' }).tap(); } await expect(inlineEditor(page).first().locator('.editor-head-pill')).toHaveText('CORRECTION'); diff --git a/scripts/e2e/fixtures/kachinuki.mjs b/scripts/e2e/fixtures/kachinuki.mjs index ccc596841..85c29c58f 100644 --- a/scripts/e2e/fixtures/kachinuki.mjs +++ b/scripts/e2e/fixtures/kachinuki.mjs @@ -13,22 +13,12 @@ // Locator.tap(), so the coarse-pointer hit test decides what is hit, as it // does for a thumb. import { expect } from '@playwright/test'; -import { dwell, settled } from './pace.mjs'; -import { isCoarse, restoreCoarse } from './pointer.mjs'; -import { inlineEditor } from './shiaijo.mjs'; +import { armTwoTap } from './pace.mjs'; +import { EXPECT_TIMEOUT_MS } from './devices.mjs'; +import { clearMarks } from './scoring.mjs'; +import { inlineEditor, sides, upNextCard } from './shiaijo.mjs'; import * as T from './team.mjs'; -// Does the page still match (pointer: coarse)? If an interruption or a -// fullPage screenshot lost it, every tap-floor rule in styles.css would be off -// for the rest of the test, so put it back in place (fixtures/pointer.mjs, no -// reload, so the bout is not interrupted a second time) and report whether it -// had been lost, so the audit can say which pointer a measurement was made at. -export async function ensureCoarse(page) { - if (await isCoarse(page)) return { coarseLost: false }; - await restoreCoarse(page); - return { coarseLost: true, restoredBy: 'CDP Emulation.setTouchEmulationEnabled (in place)' }; -} - export const editor = (page) => inlineEditor(page).filter({ has: page.locator('.team-bouts-scroll') }).first(); // ---------------------------------------------------------------- lineup -- @@ -126,24 +116,22 @@ export async function doneRowWinner(row) { // The centre mark of a row (vs / X / (E)). export const boutMiddle = async (row) => (await T.rowMiddle(row).first().innerText()).trim(); -// Award one ippon on the current bout and wait for the board to show it. A second -// ippon on the same side only counts once the first one's bounce window has -// passed (fixtures/pace.mjs), so the helper returns after it. -export async function awardBoutIppon(page, side, waza = 'M') { - const row = currentBout(page); - const before = await boutFilled(row, side).count(); - const tappedAt = Date.now(); - await boutIpponButton(row, side, waza).tap(); - await expect(boutFilled(currentBout(page), side)).toHaveCount(before + 1); - await dwell(page, tappedAt); -} +// Tap one side's filled marks off a bout row until `keep` are left (the +// bounded clearMarks, fixtures/scoring.mjs); returns the taps made. +export const clearBoutMarks = (row, side, opts) => clearMarks(boutFilled(row, side), opts); + +// Award one ippon on the current bout and return once the board shows it and +// the autosave has landed (fixtures/team.mjs ipponOnRow, the one strike the +// team sheet shares). A second ippon on the same side waits out the bounce +// window of the first (fixtures/pace.mjs). +export const awardBoutIppon = (page, side, waza = 'M') => T.ipponOnRow(editor(page), () => currentBout(page), 'current bout', side, waza); // Record bout: one tap. Returns once the bout has become a read-only row (the // server appended the next pairing, or the tie retired both). export async function recordBout(page) { const before = await doneRows(page).count(); await recordBoutButton(page).tap(); - await expect(doneRows(page)).toHaveCount(before + 1, { timeout: 15_000 }); + await expect(doneRows(page)).toHaveCount(before + 1, { timeout: EXPECT_TIMEOUT_MS }); await expect(recordBoutButton(page)).not.toHaveText('Saving…'); } @@ -157,11 +145,32 @@ export async function winBout(page, side) { // End match: the two-tap guard. Returns the armed label the first tap showed. export async function endMatch(page) { const btn = endMatchButton(page); - await btn.tap(); - await expect(btn).toHaveText(/^Tap again/); - const armed = (await btn.innerText()).trim(); - // The second tap counts once the arming tap's bounce window has passed. - await settled(btn); - await btn.tap(); - return armed; + const armed = await armTwoTap(btn, btn.filter({ hasText: /^Tap again/ })); + const label = (await armed.innerText()).trim(); + await armed.tap(); + return label; } + +// ------------------------------------------------------------ pool play -- + +// Start the Up next kachinuki encounter from its card and return the pairing +// (Shiro, Aka team names) once the sheet is running. The individual board's +// startUpNext (fixtures/shiaijo.mjs) waits for `.sb-side--*`, which this sheet +// does not have. +export async function startUpNextKachinuki(page) { + const pair = await sides(upNextCard(page)); + await upNextCard(page).getByRole('button', { name: 'Start match' }).tap(); + await expect(recordBoutButton(page)).toBeVisible(); + return pair; +} + +// The line that stands where the current bout was once Record bout took a +// team's last fighter off ("Aka has no fighters left. End match finishes the +// encounter."). +export const noPairingLine = (page) => editor(page).getByTestId('kachinuki-no-pairing'); + +// The representative bout (daihyosen) offer. A kachinuki sheet never carries +// it (admin_scoring_team.jsx hides the add affordance under isKachinuki), so a +// journey asserts both are absent. +export const daihyosenOffer = (page) => editor(page).getByTestId('scoring-modal-daihyosen-button'); +export const daihyosenControls = (page) => editor(page).locator('.daihyosen-controls'); diff --git a/scripts/e2e/fixtures/organiser.mjs b/scripts/e2e/fixtures/organiser.mjs new file mode 100644 index 000000000..fade9597d --- /dev/null +++ b/scripts/e2e/fixtures/organiser.mjs @@ -0,0 +1,240 @@ +// The organiser's own pages: the Excel download on a competition's Export +// section, the folder import, the forgotten-password reset page, and the +// branding and sponsor uploads on Edit tournament details. Every selector for +// those lives here, with the runtime file makers they need (a PNG, an +// import bundle) and the small zip reader that looks inside a workbook. +import { expect } from '@playwright/test'; +import fs from 'node:fs'; +import path from 'node:path'; +import zlib from 'node:zlib'; +import { dwell } from './pace.mjs'; +import { EXPECT_TIMEOUT_MS } from './devices.mjs'; + +// ------------------------------------------------------------- run files -- + +// A valid PNG of w x h in one flat colour, built here at run time: the +// journeys commit no binaries. A size nothing else in the app has (3 x 2) is +// what lets a journey tell an uploaded image from the bundled default. +export function pngBytes(w, h, [r, g, b] = [200, 30, 30]) { + const chunk = (type, data) => { + const body = Buffer.concat([Buffer.from(type, 'ascii'), data]); + const len = Buffer.alloc(4); + len.writeUInt32BE(data.length); + const crc = Buffer.alloc(4); + crc.writeUInt32BE(zlib.crc32(body)); + return Buffer.concat([len, body, crc]); + }; + const head = Buffer.alloc(13); + head.writeUInt32BE(w, 0); + head.writeUInt32BE(h, 4); + head.writeUInt8(8, 8); // bit depth + head.writeUInt8(2, 9); // colour type: RGB + const row = Buffer.concat([Buffer.from([0]), Buffer.from(Array.from({ length: w }, () => [r, g, b]).flat())]); + const raw = Buffer.concat(Array.from({ length: h }, () => row)); + return Buffer.concat([ + Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]), + chunk('IHDR', head), + chunk('IDAT', zlib.deflateSync(raw)), + chunk('IEND', Buffer.alloc(0)), + ]); +} + +// ---------------------------------------------------------------- workbook -- + +// Every entry of a zip, by name, read from its central directory and inflated +// with node:zlib. Enough for an .xlsx, which is a plain deflated zip. +export function readZip(buf) { + let eocd = -1; + for (let i = buf.length - 22; i >= 0; i -= 1) { + if (buf.readUInt32LE(i) === 0x06054b50) { eocd = i; break; } + } + if (eocd < 0) throw new Error('readZip: no end-of-central-directory record: not a zip'); + const count = buf.readUInt16LE(eocd + 10); + let p = buf.readUInt32LE(eocd + 16); + const entries = new Map(); + for (let n = 0; n < count; n += 1) { + if (buf.readUInt32LE(p) !== 0x02014b50) throw new Error('readZip: bad central directory entry'); + const method = buf.readUInt16LE(p + 10); + const size = buf.readUInt32LE(p + 20); + const nameLen = buf.readUInt16LE(p + 28); + const extraLen = buf.readUInt16LE(p + 30); + const commentLen = buf.readUInt16LE(p + 32); + const local = buf.readUInt32LE(p + 42); + const name = buf.toString('utf8', p + 46, p + 46 + nameLen); + const dataAt = local + 30 + buf.readUInt16LE(local + 26) + buf.readUInt16LE(local + 28); + const packed = buf.subarray(dataAt, dataAt + size); + entries.set(name, method === 0 ? packed : zlib.inflateRawSync(packed)); + p += 46 + nameLen + extraLen + commentLen; + } + return entries; +} + +// One pass, so a decoded '&' is never decoded again ("&lt;" reads "<"). +const XML_ENTITIES = { amp: '&', lt: '<', gt: '>', quot: '"', apos: "'" }; +const xmlText = (s) => s.replace(/&(amp|lt|gt|quot|apos);/g, (_, e) => XML_ENTITIES[e]); + +// A shared or inline string's text: its runs joined (a rich-text string +// splits it across several), read rather than left over from stripping tags. +const runText = (s) => [...s.matchAll(/]*>([\s\S]*?)<\/t>/g)].map((m) => xmlText(m[1])).join(''); + +// What a downloaded workbook holds: its sheet names, in order, and all of its +// cell text (shared strings and inline strings, joined), unescaped. +export function readWorkbook(buf) { + const zip = readZip(buf); + const xml = (name) => (zip.get(name) ? zip.get(name).toString('utf8') : ''); + if (!zip.has('xl/workbook.xml')) throw new Error(`not a workbook: parts are ${[...zip.keys()].join(', ')}`); + const sheets = [...xml('xl/workbook.xml').matchAll(/]*\bname="([^"]*)"/g)].map((m) => xmlText(m[1])); + const parts = [...zip.keys()].filter((k) => k === 'xl/sharedStrings.xml' || /^xl\/worksheets\/[^/]+\.xml$/.test(k)); + const text = xmlText(parts.map((k) => xml(k).replace(/<[^>]+>/g, ' ')).join(' ')); + return { sheets, text, parts: [...zip.keys()] }; +} + +// Every non-empty cell of one sheet, as { "F4": "M", ... }: shared and inline +// strings resolved, numbers as written. A cell's place is what tells a played +// bout's mark from the same letter in a heading, which the flat text of +// readWorkbook cannot. +export function sheetCells(buf, sheetName) { + const zip = readZip(buf); + const xml = (name) => (zip.get(name) ? zip.get(name).toString('utf8') : ''); + const shared = [...xml('xl/sharedStrings.xml').matchAll(/([\s\S]*?)<\/si>/g)] + .map((m) => runText(m[1])); + // Each tag's attributes are read one by one, so their order does not matter. + const entry = [...xml('xl/workbook.xml').matchAll(/]*>/g)] + .map((m) => ({ name: /\bname="([^"]*)"/.exec(m[0]), rid: /\br:id="([^"]*)"/.exec(m[0]) })) + .find((s) => s.name && s.rid && xmlText(s.name[1]) === sheetName); + if (!entry) throw new Error(`sheetCells: no sheet named ${sheetName}`); + const rel = [...xml('xl/_rels/workbook.xml.rels').matchAll(/Id="([^"]*)"[^>]*Target="([^"]*)"|Target="([^"]*)"[^>]*Id="([^"]*)"/g)] + .map((m) => ({ id: m[1] || m[4], target: m[2] || m[3] })).find((r) => r.id === entry.rid[1]); + if (!rel) throw new Error(`sheetCells: no part for ${sheetName}`); + const part = xml(`xl/${rel.target.replace(/^\/?(xl\/)?/, '')}`); + const out = {}; + for (const m of part.matchAll(/]*?)(?:\/>|>([\s\S]*?)<\/c>)/g)) { + const ref = /\br="([A-Z]+\d+)"/.exec(m[1]); + if (!ref || !m[2]) continue; + const type = /\bt="(\w+)"/.exec(m[1]); + const v = /([\s\S]*?)<\/v>/.exec(m[2]); + const inline = /([\s\S]*?)<\/is>/.exec(m[2]); + let text = null; + if (inline) text = runText(inline[1]); + else if (v) text = type && type[1] === 's' ? shared[Number(v[1])] : xmlText(v[1]); + if (text !== null && text !== '') out[ref[1]] = text; + } + return out; +} + +// Open a competition's Export section and download its results workbook. +// Resolves to { file, name, bytes } once Playwright's download event has fired. +export async function downloadResultsWorkbook(page, id) { + await page.goto(`/admin/competition/${id}/export`); + const button = page.getByRole('button', { name: 'Download results (.xlsx)' }); + await expect(button).toBeVisible(); + const [download] = await Promise.all([page.waitForEvent('download'), button.tap()]); + const file = await download.path(); + if (!file) throw new Error(`the download failed: ${await download.failure()}`); + return { file, name: download.suggestedFilename(), bytes: fs.readFileSync(file) }; +} + +// ------------------------------------------------------------------ import -- + +// Write an import bundle to `dir`: manifest.yaml plus one participants CSV per +// competition. `comps` is [{ id, name, kind, format, date, roster: [[name, dojo]] }] +// (`date` is DD-MM-YYYY and may be left out). +// The app has no exporter that writes a manifest, so a bundle is assembled by +// hand, as the import page describes ("a folder containing manifest.yaml and +// participant CSV files"). Returns the file paths, manifest first. +export function writeImportBundle(dir, comps) { + fs.mkdirSync(dir, { recursive: true }); + const files = []; + const lines = ['competitions:']; + for (const c of comps) { + const csv = `${c.id}.csv`; + fs.writeFileSync(path.join(dir, csv), c.roster.map((r) => r.join(', ')).join('\n') + '\n'); + lines.push(` - id: ${c.id}`, ` name: ${c.name}`, ` kind: ${c.kind || 'individual'}`, + ` format: ${c.format || 'mixed'}`, ` participants: ${csv}`); + if (c.date) lines.push(` date: ${c.date}`); + files.push(path.join(dir, csv)); + } + const manifest = path.join(dir, 'manifest.yaml'); + fs.writeFileSync(manifest, lines.join('\n') + '\n'); + return [manifest, ...files]; +} + +// Pick `files` on the import page ("Select files individually" opens the +// browser's file chooser), tap Import, answer the confirm, and return once the +// results list is up. +export async function importFiles(page, files) { + await page.goto('/admin/import'); + await expect(page.getByRole('heading', { level: 1, name: 'Import competitions' })).toBeVisible(); + const [chooser] = await Promise.all([ + page.waitForEvent('filechooser'), + page.getByRole('button', { name: 'Select files individually' }).tap(), + ]); + await chooser.setFiles(files); + await expect(page.getByText('manifest found: manifest.yaml')).toBeVisible(); + await page.getByRole('button', { name: 'Import', exact: true }).tap(); + const dialog = page.locator('.modal[role="dialog"]').filter({ has: page.locator('.modal__foot') }).last(); + await dialog.waitFor({ state: 'visible' }); + await dwell(page, Date.now()); + await dialog.getByRole('button', { name: 'Import competitions' }).tap(); + await expect(page.getByText('Import results')).toBeVisible(); +} + +export const importResultRows = (page) => page.getByTestId('import-result-row'); + +// ------------------------------------------------------------------- reset -- + +// Choose a new admin password on the /reset page (file mode, from the machine +// running the server). The page signs the browser straight in on success. +export async function resetPassword(page, password) { + await page.getByLabel('New password', { exact: true }).fill(password); + await page.getByLabel('Confirm new password').fill(password); + await page.getByRole('button', { name: 'Reset password', exact: true }).tap(); +} + +// ------------------------------------------------------ branding, sponsors -- + +export async function openEditTournament(page) { + await page.goto('/admin/edit-tournament'); + await expect(page.getByRole('heading', { level: 1, name: 'Edit tournament details' })).toBeVisible(); +} + +// Upload the tournament logo from `file` (a path). Returns once the page shows +// the logo as the current one (its Remove button is there). +export async function uploadLogo(page, file) { + await page.locator('#branding-logo-file').setInputFiles(file); + await page.getByRole('button', { name: 'Upload logo', exact: true }).tap(); + await expect(page.getByRole('button', { name: 'Remove' }).first()).toBeVisible(); +} + +// Add a sponsor by name and image. Returns once the sponsor is listed. +export async function addSponsor(page, { name, file }) { + await page.locator('#sponsor-name').fill(name); + await page.locator('#sponsor-logo').setInputFiles(file); + await page.getByRole('button', { name: 'Add sponsor' }).tap(); + await expect(page.locator(`img[alt="${name}"]`)).toBeVisible(); +} + +// What an really holds once loaded: its size in pixels and the request +// URL it was fetched from. The tournament logo falls back to the bundled +// /logo.jpeg when none is uploaded, so "an image is on screen" proves nothing; +// the uploaded image's own size does. +export async function loadedImage(locator) { + await expect.poll(() => locator.evaluate((i) => i.complete && i.naturalWidth > 0), { timeout: EXPECT_TIMEOUT_MS }).toBe(true); + return locator.evaluate((i) => ({ w: i.naturalWidth, h: i.naturalHeight, src: i.getAttribute('src') })); +} + +// Today as the manifest's DD-MM-YYYY date (the machine's own day, which is the +// day the first-run form offers the tournament). +export function todayDMY(now = new Date()) { + const pad = (n) => String(n).padStart(2, '0'); + return `${pad(now.getDate())}-${pad(now.getMonth() + 1)}-${now.getFullYear()}`; +} + +// The day `days` from now, as the date input's YYYY-MM-DD and as the +// manifest's DD-MM-YYYY: a tournament day that is not today. +export function dayFromNow(days, now = new Date()) { + const d = new Date(now.getFullYear(), now.getMonth(), now.getDate() + days); + const pad = (n) => String(n).padStart(2, '0'); + const [y, m, dd] = [d.getFullYear(), pad(d.getMonth() + 1), pad(d.getDate())]; + return { iso: `${y}-${m}-${dd}`, dmy: `${dd}-${m}-${y}` }; +} diff --git a/scripts/e2e/fixtures/pace.mjs b/scripts/e2e/fixtures/pace.mjs index d64241dab..2861cd9ce 100644 --- a/scripts/e2e/fixtures/pace.mjs +++ b/scripts/e2e/fixtures/pace.mjs @@ -20,6 +20,7 @@ import fs from 'node:fs'; import { fileURLToPath } from 'node:url'; import { TAP_DWELL_MS } from '../../screenshots/lib/editor.mjs'; +import { EXPECT_TIMEOUT_MS } from './devices.mjs'; // The app's own window, read from its one source (tap_guard.jsx is JSX, so it // cannot be imported here). Failing to find it is an error: a harness that @@ -48,6 +49,63 @@ export async function settled(locator) { await dwell(locator.page(), Date.now()); } +// The first tap of a two-tap commit (Finish, End match, Save result): tap +// `button`, wait for `armed` (the same control in its armed state) to show, and +// out the dwell, so the second tap, which the caller makes on the returned +// `armed`, is a deliberate one. The one owner of that arm-settle-tap sequence. +export async function armTwoTap(button, armed) { + await button.tap(); + await settled(armed); + return armed; +} + +// Settle before a read that no single state names. A journey that taps the +// control NEXT to the intended one (or taps twice) cannot say what it will see: +// the outcome varies by design and is what the audit row records, so there is +// no state to wait for. Two real frames later the page has drawn whatever the +// tap changed locally (a mark, a panel, a dialog, a list), which is what the +// read that follows is of. A change that needs the server's answer is not +// covered; wait for that answer (scoring.mjs nextMatchWrite) or use +// untilStable. +export function rendered(page) { + return page.evaluate(() => new Promise((done) => { + requestAnimationFrame(() => requestAnimationFrame(() => done())); + })); +} + +// Settle before a read that follows the server's data: poll `read()` until two +// reads `quietMs` apart agree, then return that value. For a screen that +// refetches after an action (a reload, Back and Forward, a write's echo) whose +// end state the journey cannot name, such as a probe of what the app does. The +// answer is the last read, so the caller need not read again. +// +// A probe that taps and then reads can settle on the state from BEFORE the tap +// when the tap's effect (a refetch, a flushed save) has not arrived yet, and +// two reads of an unchanged page agree. Pass `from`, the read taken (settled) +// before the tap: the helper first waits up to `changeWithinMs` for the read to +// leave that value. A tap that really changes nothing costs that wait, a +// Deliberate: negative one, and settles on the unchanged value. +export async function untilStable(read, { quietMs = 400, timeout = EXPECT_TIMEOUT_MS, changeWithinMs = 3_000, ...rest } = {}) { + if ('from' in rest) { + const was = JSON.stringify(rest.from); + const changeDeadline = Date.now() + changeWithinMs; + while (JSON.stringify(await read()) === was && Date.now() < changeDeadline) { + await new Promise((done) => setTimeout(done, 100)); + } + } + const deadline = Date.now() + timeout; + let last = JSON.stringify(await read()); + for (;;) { + await new Promise((done) => setTimeout(done, quietMs)); + const now = JSON.stringify(await read()); + // JSON.stringify(undefined) is undefined, which JSON.parse cannot take: + // a read of undefined settles as undefined, not as null. + if (now === last) return now === undefined ? undefined : JSON.parse(now); + if (Date.now() > deadline) throw new Error(`untilStable: the read kept changing for ${timeout} ms (last: ${now})`); + last = now; + } +} + // page -> Map(side key -> when the last tap on that side finished). const sideTaps = new WeakMap(); diff --git a/scripts/e2e/fixtures/pointer.mjs b/scripts/e2e/fixtures/pointer.mjs index 07c033ab9..8776a2b2f 100644 --- a/scripts/e2e/fixtures/pointer.mjs +++ b/scripts/e2e/fixtures/pointer.mjs @@ -30,6 +30,19 @@ export async function restoreCoarse(page) { } } +// Is the page still (pointer: coarse)? If an interruption or a fullPage +// screenshot lost it, every tap-floor rule in styles.css is off for the rest of +// the test, so put it back in place (restoreCoarse, no reload, so the bout is +// not interrupted a second time) and say whether it had been lost, so an audit +// row can name the pointer a measurement was made at. The one owner of "if not +// coarse, restore": the page fixture, the second devices, the screenshot +// camera and the kachinuki interruptions all ask it. +export async function ensureCoarse(page) { + if (await isCoarse(page)) return { coarseLost: false }; + await restoreCoarse(page); + return { coarseLost: true, restoredBy: 'CDP Emulation.setTouchEmulationEnabled (in place)' }; +} + // A control's rendered size, for the 44px coarse-pointer floor. The page must // still match (pointer: coarse) at the moment of measuring, or the 44px floor // rules are off and the size reads low, so that is asserted first. diff --git a/scripts/e2e/fixtures/pools.mjs b/scripts/e2e/fixtures/pools.mjs new file mode 100644 index 000000000..37d7e57eb --- /dev/null +++ b/scripts/e2e/fixtures/pools.mjs @@ -0,0 +1,73 @@ +// The pool standings tables: the admin Pools tab (/admin/competition/:id/pools, +// admin_pools.jsx) and the public viewer's Pools tab (/competition/:id, +// viewer_competition.jsx) both render PoolsViewer (viewer_standings.jsx), so +// one set of selectors reads either page. A team pool's row lists the team in +// draw order with a rank badge, then eight figures: W L T (team matches won, +// lost, tied), IV IL IT (individual victories, losses, ties) and PW PL +// (points won, lost). +import { expect } from '@playwright/test'; +import { openViewer } from './public.mjs'; + +export const FIGURES = ['W', 'L', 'T', 'IV', 'IL', 'IT', 'PW', 'PL']; + +// Pool blocks on the page; a standings table sits inside each. +export const poolBlocks = (page) => page.locator('.pool').filter({ has: page.locator('table.pool__table') }); + +// The admin Pools tab, loaded once at least one pool is drawn. +export async function openAdminPools(page, id) { + await page.goto(`/admin/competition/${encodeURIComponent(id)}/pools`); + await expect(poolBlocks(page).first()).toBeVisible(); +} + +// The public viewer's Pools tab. +export async function openViewerPools(page, id) { + await openViewer(page, id); + await page.locator('.viewer__tab', { hasText: /^Pools$/ }).tap(); + await expect(poolBlocks(page).first()).toBeVisible(); +} + +// A team's standings row, found by the team name its cell shows. +export const teamRow = (page, team) => page.locator('table.pool__table tbody tr') + .filter({ has: page.locator('.pool__player-name', { hasText: team }) }); + +// Everything a row says: its eight figures by name, the rank badge ("1st"), +// (lower-cased: the page upper-cases it with CSS), and whether the row is marked as advancing to the knockout. +export async function rowFigures(page, team) { + const row = teamRow(page, team); + await expect(row).toHaveCount(1); + const cells = (await row.locator('td.num').allInnerTexts()).map((t) => t.trim()); + const figures = Object.fromEntries(FIGURES.map((f, i) => [f, cells[i] === undefined ? NaN : Number(cells[i])])); + const badge = row.locator('.rank-badge'); + return { + ...figures, + rank: (await badge.count()) ? (await badge.first().innerText()).trim().toLowerCase() : '', + advancing: ((await row.getAttribute('class')) || '').split(/\s+/).includes('advancing'), + }; +} + +// An INDIVIDUAL pool's row carries five figures, not the team table's eight: +// W L D (fights won, lost, drawn: hikiwake) and PW PL (points won, lost: ippons +// scored and conceded). Beside them it reads the rank badge, whether the row is +// marked as advancing, and whether it is marked tied (the amber row: a tie at a +// place still to be broken; a group a tie-break bout already ordered is not). +export const INDIVIDUAL_FIGURES = ['W', 'L', 'D', 'PW', 'PL']; + +export async function playerRowFigures(page, name) { + const row = teamRow(page, name); + await expect(row).toHaveCount(1); + const cells = (await row.locator('td.num').allInnerTexts()).map((t) => t.trim()); + const badge = row.locator('.rank-badge'); + const classes = ((await row.getAttribute('class')) || '').split(/\s+/); + return { + ...Object.fromEntries(INDIVIDUAL_FIGURES.map((f, i) => [f, cells[i] === undefined ? NaN : Number(cells[i])])), + rank: (await badge.count()) ? (await badge.first().innerText()).trim().toLowerCase() : '', + advancing: classes.includes('advancing'), + tied: classes.includes('pool__row--tied'), + }; +} + +// The names of the teams in a pool, in the table's (draw) order. +export async function poolTeams(block) { + return (await block.locator('table.pool__table tbody .pool__player-name').allInnerTexts()) + .map((t) => t.replace(/\s+/g, ' ').trim()); +} diff --git a/scripts/e2e/fixtures/public.mjs b/scripts/e2e/fixtures/public.mjs index 955d1a41f..b885bede6 100644 --- a/scripts/e2e/fixtures/public.mjs +++ b/scripts/e2e/fixtures/public.mjs @@ -24,3 +24,112 @@ export async function openTvBoard(page, court) { await page.goto(`/display?court=${encodeURIComponent(court)}`); await expect(tvBoard(page)).toContainText(`SHIAIJO ${court}`); } + +// ------------------------------------------------- search, watchlist, cards -- +// +// The home's watchlist picker (viewer_watchlist.jsx WatchPicker) and the +// schedule page's filter (viewer_schedule.jsx PlayerMultiFilter, with the text +// chip that filters the rows) both apply the competitor-number rule of +// web-mobile/js/competitor_search.jsx (bc-nsrc): the whole number, or the +// competition's prefix. They share the `.pmf` markup. + +// The home's picker box, whether it reads "Add a name, dojo or number to +// watch…" (empty watchlist) or "Add another name, dojo or number…". +export const watchInput = (page) => page.locator('.pmf__input[aria-label^="Add a"]'); +// The schedule page's filter box. +export const scheduleFilterInput = (page) => page.getByLabel('Filter by player, team, dojo or number', { exact: true }); + +// A competitor option of an open picker: not a dojo row (WatchPicker) and not +// the "Match "x" in any name, dojo or number" row (PlayerMultiFilter). +const PLAYER_OPTION = '.pmf__dropdown .pmf__option:not(.pmf__option--dojo):not(.pmf__option--text)'; +export const NATURAL = (a, b) => a.localeCompare(b, undefined, { numeric: true }); + +// What the open picker lists, competitors only: { numbers, names }, each sorted +// (numbers naturally, so K2 precedes K10). WatchPicker draws a number chip +// (`.num-prefix`) and the name in `.numbered-name__text`; PlayerMultiFilter +// writes "K1 · Akira Tanaka" in one string. +export async function pickerEntries(page) { + const rows = await pickerPairs(page); + return { + numbers: rows.map((r) => r.number).filter(Boolean).sort(NATURAL), + names: rows.map((r) => r.name).sort(NATURAL), + }; +} + +// The same options as [{ number, name }], in the order they are listed. +export async function pickerPairs(page) { + return page.locator(PLAYER_OPTION).evaluateAll((els) => els.map((el) => { + const chip = el.querySelector('.num-prefix'); + const text = el.querySelector('.numbered-name__text'); + if (text) return { number: chip ? chip.textContent.trim() : '', name: text.textContent.trim() }; + const joined = el.querySelector('.pmf__opt-name').textContent.trim(); + const m = /^(\S+) · (.+)$/.exec(joined); + return m ? { number: m[1], name: m[2] } : { number: '', name: joined }; + })); +} + +// The competitor options of an open picker, to tap one. +export const pickerOption = (page, name) => page.locator(PLAYER_OPTION).filter({ hasText: name }); + +// K1..K: the numbers a draw with this prefix and `n` competitors hands out. +export const numbersOf = (prefix, n) => Array.from({ length: n }, (_, i) => `${prefix}${i + 1}`).sort(NATURAL); + +// The schedule page's own count line ("18 matches of 18") and its rows. +export const scheduleCount = (page) => page.getByText(/^\d+ match(es)? of \d+$/); +export const scheduleRows = (page) => page.locator('button.tw-match'); +// A schedule row naming both competitors. +export const scheduleRow = (page, { shiro, aka }) => scheduleRows(page).filter({ hasText: shiro }).filter({ hasText: aka }); + +// Type a query in the schedule's filter and press Enter: it becomes a text chip +// that filters the match rows by name, dojo or number (matchMentions). Returns +// once the chip shows; clearScheduleText takes it off again. +export async function applyScheduleText(page, text) { + const box = scheduleFilterInput(page); + await box.fill(text); + await box.press('Enter'); + await expect(page.locator('.pmf__chip--text')).toContainText(`"${text}"`); +} +export async function clearScheduleText(page) { + await page.locator('.pmf__chip--text').getByRole('button', { name: 'Remove' }).tap(); + await expect(page.locator('.pmf__chip--text')).toHaveCount(0); +} + +// The watchlist hero (WatchHeroCard) and the button inside it that opens the +// match card. +export const watchHero = (page) => page.getByTestId('watch-hero'); +export const watchHeroMatch = (page) => watchHero(page).locator('.wl-hero__sides--btn'); + +// A competition page's tab (Overview, Pools, Bracket). +export const competitionTab = (page, label) => page.locator('.viewer__tab', { hasText: new RegExp(`^${label}$`) }); + +// The doors a spectator opens a match card through, besides the schedule's own +// rows: Home's NOW list, a pool's match list, and an Up next row (Home's or a +// competition's Overview). Where one list holds several matches the row is +// named by both competitors. +export const homeNowRow = (page) => page.locator('.hero-running .vsched-item'); +export const poolRow = (page, { shiro, aka }) => page.locator('button.pool-match-numbered-row').filter({ hasText: shiro }).filter({ hasText: aka }); +export const upNextRow = (page, { shiro, aka }) => page.locator('button.vsched-item').filter({ hasText: shiro }).filter({ hasText: aka }); + +// The Overview's inline cards (officiated tournaments, where no dialog opens +// there): the one an Up next row expanded (it has a Close button) and the ON NOW +// card of the running match (it has none). +export const expandedCard = (page) => page.locator('.match-detail-card').filter({ has: page.getByRole('button', { name: 'Close' }) }); +export const nowCard = (page) => page.locator('.match-detail-card').filter({ hasNot: page.getByRole('button', { name: 'Close' }) }); + +// The match card (MatchViewerModal): a dialog named "Match: vs ". +export const matchCard = (page) => page.getByRole('dialog', { name: /^Match:/ }); + +// A card's ippon slots: two per side, each holding a letter once scored and +// nothing before. Wait for all four before a single read that expects none, +// since a slot not yet rendered also reads as nothing. +export const cardSlots = (card) => card.getByTestId('sub-marks').locator('.msb-slot'); + +// The ippon letters the card shows per side, sorted ("DM", "K"). +export async function cardMarks(card) { + const marks = card.getByTestId('sub-marks'); + const letters = async (loc) => (await loc.allTextContents()).map((t) => t.trim()).filter(Boolean).sort().join(''); + return { + shiro: await letters(marks.locator('.msb-slots:not(.msb-slots--aka) .msb-slot')), + aka: await letters(marks.locator('.msb-slots--aka .msb-slot')), + }; +} diff --git a/scripts/e2e/fixtures/roster.mjs b/scripts/e2e/fixtures/roster.mjs new file mode 100644 index 000000000..71fdd5829 --- /dev/null +++ b/scripts/e2e/fixtures/roster.mjs @@ -0,0 +1,202 @@ +// The roster surfaces an organiser works before and just after the draw: the +// Registration desk (check-in, its search, Group by dojo), the seed rank boxes +// and the participant Edit dialog on the Participants & seeds page, and a read +// of the draw as it stands (pool membership, bracket slots) on the admin +// preview or on the public viewer. Every selector for those lives here. +import { expect } from '@playwright/test'; +import { dwell, rendered } from './pace.mjs'; +import { EXPECT_TIMEOUT_MS } from './devices.mjs'; + +// ---- the Registration desk (admin_registration_desk.jsx) ------------------- + +// Open the desk from the dashboard card (it is a view of /admin, not a URL) +// and pick one competition on its rail, so each row carries that competition's +// own check-in box, number tag and Edit pencil. +export async function openDesk(page, compName) { + await page.goto('/admin'); + await page.getByRole('button', { name: /^Registration desk/ }).tap(); + await expect(page.getByRole('heading', { level: 1, name: 'Registration desk' })).toBeVisible(); + const rail = page.getByRole('navigation', { name: 'Competitions' }); + const item = rail.getByRole('button').filter({ hasText: compName }); + await item.tap(); + await expect(item).toHaveAttribute('aria-current', 'true'); +} + +export const deskSearch = (page) => page.getByRole('textbox', { name: 'Search competitors' }); +export const deskRows = (page) => page.locator('.rd-roster .rd-row'); +export const deskRow = (page, name) => deskRows(page).filter({ has: page.locator('.rd-row__name', { hasText: new RegExp(`^${name}$`) }) }); +export const deskCheck = (page, name) => deskRow(page, name).getByRole('checkbox'); +// " of " and the noun beside it, as one line. +export const deskHeadline = (page) => page.locator('.rd-headline'); + +// Tap one row's check-in box and wait for the box to read the new state. +export async function toggleCheckIn(page, name, on = true) { + const box = deskCheck(page, name); + await box.tap(); + await expect(box).toHaveAttribute('aria-checked', String(on)); +} + +// Type into the desk's search box and wait for the list to draw the filter. +// The filter is local React state (no debounce, no request), so the box +// holding the text and two frames later is the list drawn from it; a caller +// that knows the result still asserts it (a row count, a row visible). +export async function searchDesk(page, text) { + const box = deskSearch(page); + await box.fill(text); + await expect(box).toHaveValue(text); + await rendered(page); +} + +export async function groupByDojo(page, on = true) { + const toggle = page.getByRole('button', { name: 'Group by dojo' }); + if ((await toggle.getAttribute('aria-pressed')) !== String(on)) await toggle.tap(); + await expect(toggle).toHaveAttribute('aria-pressed', String(on)); +} + +// "Check in all N" on one dojo's group header (Group by dojo must be on). +export async function bulkCheckInDojo(page, dojo) { + const group = page.locator('.rd-group').filter({ has: page.locator('.rd-group__name', { hasText: new RegExp(`^${dojo}$`) }) }); + await group.getByRole('button', { name: /^Check in all \d+$/ }).tap(); + await expect(group.getByRole('button', { name: /^Check in all/ })).toHaveCount(0); +} + +// ---- seed rank boxes (admin_participants.jsx) ------------------------------ + +export async function openParticipants(page, id) { + await page.goto(`/admin/competition/${id}/participants`); + await expect(page.getByRole('button', { name: 'Clear seeds' })).toBeVisible(); +} + +const seedBox = (page, name) => page.getByLabel(`Seed rank for ${name}`); + +// Type a rank into a competitor's seed box. Each rank is saved as it is typed +// (the docs say so), so the box is read back after the page is reloaded. +export async function setSeed(page, name, rank) { + const box = seedBox(page, name); + // The box saves 200 ms after the last keystroke (or at once when it loses + // focus), and a person has gone on to the next box long after that. + // Playwright goes on at once, and a reload then drops the save still + // pending: wait for the save that carries this rank to be answered. + const saved = page.waitForResponse((r) => { + const req = r.request(); + if (req.method() !== 'PUT' || !r.ok()) return false; + let body; + try { body = req.postDataJSON(); } catch { return false; } + return Array.isArray(body && body.players) && body.players.some((p) => p.name === name && p.seed === Number(rank)); + }, { timeout: EXPECT_TIMEOUT_MS }); + await box.fill(String(rank)); + await expect(box).toHaveValue(String(rank)); + await saved; +} + +export const seedRankOf = (page, name) => seedBox(page, name).inputValue(); + +// ---- the participant Edit dialog ------------------------------------------- + +export const editDialog = (page) => page.locator('[role="dialog"][aria-labelledby="edit-modal-title"]'); + +export async function openEdit(page, name) { + await page.getByRole('button', { name: `Edit ${name}` }).tap(); + const dlg = editDialog(page); + await expect(dlg).toBeVisible(); + await expect(dlg.locator('#edit-modal-title')).toHaveText(`Edit ${name}`); + return dlg; +} + +// Fill the dialog and tap Save once the dialog's own bounce window has passed. +// Returns the dialog so a caller can see whether it closed (saved) or stayed +// (refused, with a toast). +export async function saveEdit(page, { name, dojo }) { + const dlg = editDialog(page); + const shown = Date.now(); + if (name !== undefined) await dlg.getByLabel(/^Name/).fill(name); + if (dojo !== undefined) await dlg.getByLabel(/^Dojo/).fill(dojo); + await dwell(page, shown); + await dlg.getByRole('button', { name: 'Save', exact: true }).tap(); + return dlg; +} + +// The toast's text, once it shows a message matching `re`. +export async function toastText(page, re) { + const msg = page.locator('.toast.is-visible .toast__msg').filter({ hasText: re }); + await expect(msg).toBeVisible({ timeout: EXPECT_TIMEOUT_MS }); + return (await msg.innerText()).trim(); +} + +// ---- the draw as it stands ------------------------------------------------- + +// Pool name -> the names in it, read from the pools preview (admin or public +// viewer: both draw `.pool` cards). +export async function poolMembers(page) { + await expect(page.locator('.pool').first()).toBeVisible(); + return page.locator('.pool').evaluateAll((pools) => Object.fromEntries(pools.map((p) => [ + p.querySelector('.pool__name').textContent.trim(), + [...p.querySelectorAll('.pool__player-name')].map((e) => (e.querySelector('.numbered-name__text') || e).textContent.trim()), + ]))); +} + +// Bracket slot -> { name, num, dojo }, read from the bracket (admin preview or +// public viewer). A slot is ":a|b"; the match id (m-r1-3) is the +// place in the tree, which a replaced competitor must keep. +// +// `until(slots)`, when given, is the state the caller is about to assert: the +// bracket is re-read until it holds (up to the expect timeout) instead of once +// after the first match shows, which a late refetch could still change. On a +// timeout the last read is returned, so the caller's own assertion names what +// is missing. +export async function bracketSlots(page, until) { + await expect(page.locator('.bc-match').first()).toBeVisible(); + if (!until) return readBracketSlots(page); + let slots = await readBracketSlots(page); + try { + await expect.poll(async () => { + slots = await readBracketSlots(page); + return until(slots); + }, { timeout: EXPECT_TIMEOUT_MS }).toBe(true); + } catch { /* the caller's assertion on `slots` reports the difference */ } + return slots; +} + +function readBracketSlots(page) { + return page.locator('.bc-match').evaluateAll((matches) => { + const out = {}; + matches.forEach((m) => { + ['a', 'b'].forEach((side) => { + const el = m.querySelector(`.bc-side--${side}`); + const name = el && el.querySelector('.numbered-name__text'); + // A later round shows "Winner of M1" until that match is played. + if (!name || /^(Winner|Loser) of /.test(name.textContent.trim())) return; + const num = el.querySelector('.num-prefix'); + const dojo = el.querySelector('.bc-dojo'); + out[`${m.getAttribute('data-match-id')}:${side}`] = { + name: name.textContent.trim(), + num: num ? num.textContent.trim() : '', + dojo: dojo ? dojo.textContent.trim() : '', + }; + }); + }); + return out; + }); +} + +export const bracketNames = async (page) => Object.values(await bracketSlots(page)).map((s) => s.name); + +export async function openCompetitionTab(page, id, tab) { + await page.goto(`/admin/competition/${id}/${tab}`); +} + +// Generate the draw of a competition whose check-in leaves some competitors +// out: the app asks first, naming how many are excluded. Answer Confirm after +// the dialog's own bounce window and return the sentence it asked. +export async function generateDrawExcluding(page, id, excluded) { + if (!(await page.getByRole('button', { name: 'Generate draw' }).isVisible())) await page.goto(`/admin/competition/${id}`); + await page.getByRole('button', { name: 'Generate draw' }).tap(); + const dlg = page.locator('.modal[role="dialog"]'); + await expect(dlg).toContainText(`${excluded} participants are not checked in and will be excluded from the draw`); + const shown = Date.now(); + const asked = (await dlg.innerText()).replace(/\s+/g, ' ').trim(); + await dwell(page, shown); + await dlg.getByRole('button', { name: 'Confirm' }).tap(); + await expect(page.getByRole('button', { name: 'Discard draw' })).toBeVisible(); + return asked; +} diff --git a/scripts/e2e/fixtures/scoring.mjs b/scripts/e2e/fixtures/scoring.mjs index 2c4246df3..60aae76c2 100644 --- a/scripts/e2e/fixtures/scoring.mjs +++ b/scripts/e2e/fixtures/scoring.mjs @@ -6,18 +6,70 @@ // (fixtures/team.mjs, fixtures/kachinuki.mjs); they share the Finish check // below. // -// The editor selectors and Start come from scripts/screenshots/lib/editor.mjs, -// re-exported here so a journey imports one scoring module. Finish is this -// file's own, not that library's: a journey TAPS, as the operator does on the -// iPad, takes the inline editor as well as the overlay, and must know the -// write LANDED (the library's finishMatch clicks, on the overlay only, and -// returns once the button stops saving). +// The editor selectors come from scripts/screenshots/lib/editor.mjs, +// re-exported here so a journey imports one scoring module. Start and Finish +// are this file's own, not that library's: a journey TAPS, as the operator does +// on the iPad, and must know the action LANDED (the library's startMatch clicks +// and returns silently when the button has not rendered yet; its finishMatch +// clicks, on the overlay only, and returns once the button stops saving). +// Finish also takes the inline editor as well as the overlay. import { expect } from '@playwright/test'; -import { EDITOR, INLINE_EDITOR, startMatch } from '../../screenshots/lib/editor.mjs'; -import { settled, tapOnSide } from './pace.mjs'; +import { EDITOR, INLINE_EDITOR, TAP_DWELL_MS } from '../../screenshots/lib/editor.mjs'; +import { EXPECT_TIMEOUT_MS } from './devices.mjs'; +import { armTwoTap, tapOnSide } from './pace.mjs'; import { courtIdle, editorIdentity } from './shiaijo.mjs'; -export { EDITOR, INLINE_EDITOR, startMatch }; +export { EDITOR, INLINE_EDITOR }; + +// Start the match the OVERLAY editor holds and return once the start has +// landed: "Start match" is offered only while the match is scheduled, so the +// button leaving is the server having it running. Fails when the button never +// appears (the editor did not open on a scheduled match) or never leaves. +export async function startMatch(page) { + const btn = page.locator(EDITOR).locator('button').filter({ hasText: /^Start match$/ }).first(); + await expect(btn, 'the overlay editor offers Start match').toBeVisible(); + await btn.tap(); + await expect(btn, 'Start match left the editor once the start landed').toBeHidden(); +} + +// The most taps clearMarks makes on one side before it gives up. A side holds a +// couple of marks at most, so six leaves room for a tap or two that did not count. +export const MAX_CLEAR_TAPS = 6; + +// Tap filled mark slots off until `slots` (a Locator of the FILLED slots, e.g. +// filledSlots or fixtures/team.mjs rowSlots) holds `keep` of them. Bounded: a +// mark that does not go away fails with a message instead of looping until the +// test timeout. A tap the app does not count (one inside the bounce window of +// the layer it just opened, such as a correction row, tap_guard.jsx) removes +// nothing, so after each tap the count is given TAP_DWELL_MS to drop, which +// outlasts that window, before the next tap is made; a tap that lands is not +// waited on. A count that goes UP between taps is a failure, never tapped +// away: a removed mark coming back is the bc-ltap class of defect. `from` is +// the end of the list the next tap takes ('last' or 'first'); `pauseMs` is the +// operator's beat between two removals. Returns the taps made. +export async function clearMarks(slots, { keep = 0, from = 'last', pauseMs = 0 } = {}) { + let taps = 0; + let last = null; // the count after the previous tap's wait + const cameBack = (was, now) => new Error(`clearMarks: ${slots} went UP from ${was} to ${now} mark(s) after ${taps} tap(s) (wanted ${keep}): a removed mark came back`); + for (let before = await slots.count(); before > keep; before = await slots.count()) { + if (last !== null && before > last) throw cameBack(last, before); + if (taps >= MAX_CLEAR_TAPS) { + throw new Error(`clearMarks: ${slots} still holds ${before} mark(s) (wanted ${keep}) after ${MAX_CLEAR_TAPS} taps`); + } + await (from === 'first' ? slots.first() : slots.last()).tap(); + taps += 1; + const tappedAt = Date.now(); + let now = await slots.count(); + while (now === before && Date.now() - tappedAt < TAP_DWELL_MS) { + await slots.page().waitForTimeout(50); + now = await slots.count(); + } + if (now > before) throw cameBack(before, now); + last = now; + if (pauseMs) await slots.page().waitForTimeout(pauseMs); + } + return taps; +} const SIDE_NAME = { shiro: 'Shiro', aka: 'Aka' }; @@ -49,19 +101,52 @@ export async function awardIppon(page, side, waza = 'M', root = INLINE_EDITOR) { await expect(syncState(page, root)).toHaveText('Synced'); } +// The hikiwake toggle of the individual board ("Mark draw", "Cancel draw" while +// on). It is offered only while neither side holds a point. Returns once the +// board shows the draw. +export async function markDraw(page, root = INLINE_EDITOR) { + const toggle = within(page, root).first().getByTestId('scoring-modal-mark-draw'); + await toggle.tap(); + await expect(toggle).toHaveText('Cancel draw'); +} + +// `root` is an editor's selector or its Locator (the team sheet's callers hold one). +const within = (page, root) => (typeof root === 'string' ? page.locator(root) : root); + // The Finish button in its first state ("Finish" or "Finish + Start Next →"), // and the armed state the first tap turns it into. -export const finishButton = (page, root = INLINE_EDITOR) => page.locator(root) +export const finishButton = (page, root = INLINE_EDITOR) => within(page, root) .locator('button').filter({ hasText: /^Finish( \+ Start Next|$)/ }).first(); -export const armedFinishButton = (page, root = INLINE_EDITOR) => page.locator(root) +export const armedFinishButton = (page, root = INLINE_EDITOR) => within(page, root) .locator('button').filter({ hasText: /^Tap again to finish/ }).first(); +// The server's answer to the next write a match gets, for a journey that must +// not move on (reload, read the court) before an edit has been saved. The sync +// pill is process-wide, not per match: it says whether anything is still being +// written, not that THIS match's edit was, so it cannot stand in for the +// answer to the write itself. Call this BEFORE the tap that +// causes the write, then `await writeLanded(...)` after it. `tail` is the +// write's route below /matches/:id: 'score' (PUT, the default: marks, fusensho, +// Tie, Start, Finish) or 'daihyosen' (POST, the representative bout's add). +// A timeout the caller never reached is not an unhandled rejection. +export function nextMatchWrite(page, { method = 'PUT', tail = 'score' } = {}) { + const answered = page.waitForResponse((r) => r.request().method() === method + && new RegExp(`/matches/[^/]+/${tail}$`).test(new URL(r.url()).pathname), { timeout: EXPECT_TIMEOUT_MS }); + answered.catch(() => {}); + return answered; +} + +// Return once that write has been answered with a success status. +export async function writeLanded(answered, what = 'the write') { + const res = await answered; + expect(res.ok(), `${what} was answered ${res.status()} ${res.url()}`).toBe(true); + return res; +} + // Tap Finish once: the two-tap guard arms and nothing is submitted yet. Returns // the armed button once the arm is old enough for a second tap to count. export async function armFinish(page, root = INLINE_EDITOR) { - await finishButton(page, root).tap(); - await settled(armedFinishButton(page, root)); - return armedFinishButton(page, root); + return armTwoTap(finishButton(page, root), armedFinishButton(page, root)); } // What an editor shows when a write did not save: its not-saved banner @@ -81,7 +166,7 @@ const visibleTexts = (loc) => loc.evaluateAll((els) => els export async function beforeFinish(ed) { const page = ed.page(); const answer = page.waitForResponse((r) => r.request().method() === 'PUT' && /\/matches\/[^/]+\/score$/.test(r.url()) - && /"status":"completed"/.test(r.request().postData() || ''), { timeout: 15_000 }).then((r) => r, () => null); + && /"status":"completed"/.test(r.request().postData() || ''), { timeout: EXPECT_TIMEOUT_MS }).then((r) => r, () => null); return { ...(await editorIdentity(page, ed)), notSaved: await visibleTexts(ed.locator(NOT_SAVED)), answer }; } @@ -137,7 +222,7 @@ export async function finishLanded(ed, before) { // engi editor ("Save result" arms too, but is its own button) or a correction // ("Save correction" does not arm). export async function finishMatch(page, root = INLINE_EDITOR) { - const ed = page.locator(root).first(); + const ed = within(page, root).first(); const armed = await armFinish(page, root); const before = await beforeFinish(ed); await armed.tap(); diff --git a/scripts/e2e/fixtures/setup.mjs b/scripts/e2e/fixtures/setup.mjs index c45d66ea8..252d0a399 100644 --- a/scripts/e2e/fixtures/setup.mjs +++ b/scripts/e2e/fixtures/setup.mjs @@ -1,5 +1,6 @@ -// The first-run tournament form, the admin sign-in form and Sign out. -// Every selector for those three lives here. +// The first-run tournament form, the admin sign-in form, Sign out, and the +// sign-in-or-create step the journeys start from. Every selector for those +// lives here. import { expect } from '@playwright/test'; import { dwell } from './pace.mjs'; @@ -14,12 +15,14 @@ const field = (page, label) => page.getByLabel(new RegExp(`^${label}`)); // server with no tournament yet. Creating one signs this page in, so a // journey that wants the login form next calls signOut() then login(). export async function createTournament(page, { - name = 'E2E Cup', venue = 'Test Hall', courts = 2, mode = 'Officiated', password = PASSWORD, + name = 'E2E Cup', venue = 'Test Hall', courts = 2, mode = 'Officiated', password = PASSWORD, date, } = {}) { await page.goto('/'); await expect(page.getByRole('heading', { name: 'Welcome to Bracket Creator' })).toBeVisible(); await field(page, 'Tournament Name').fill(name); await field(page, 'Venue').fill(venue); + // The form offers today; `date` (YYYY-MM-DD) sets another day. + if (date) await page.locator('#first-run-date').fill(date); await field(page, 'Number of Shiaijo').fill(String(courts)); await page.getByRole('group', { name: 'Tournament type' }).getByRole('button', { name: mode }).tap(); await field(page, 'Admin Password').fill(password); @@ -28,6 +31,24 @@ export async function createTournament(page, { await expect(page.getByRole('heading', { level: 1, name })).toBeVisible(); } +// Sign in, creating the tournament first when this file's server has none yet. +// Every test in a file can call it, whichever runs first or in which worker. +// Returns { created } so a caller can tell the first run from a later one. +export async function enterTournament(page, { name = 'E2E Cup', courts = 2, date } = {}) { + await page.goto('/'); + // The first-run form, or the public home of a tournament that exists: + // whichever the first paint shows, however long it takes. + const welcome = page.getByRole('heading', { name: 'Welcome to Bracket Creator' }); + const home = page.locator('.viewer__body'); + await welcome.or(home).first().waitFor({ state: 'visible' }); + if (await welcome.isVisible()) { + await createTournament(page, { name, courts, date }); + return { created: true }; + } + await login(page); + return { created: false }; +} + export async function signOut(page) { await page.getByRole('button', { name: 'Sign out' }).tap(); await expect(page.getByRole('button', { name: 'Sign out' })).toHaveCount(0); diff --git a/scripts/e2e/fixtures/shared-court.mjs b/scripts/e2e/fixtures/shared-court.mjs index 8acc762fc..82b0cff34 100644 --- a/scripts/e2e/fixtures/shared-court.mjs +++ b/scripts/e2e/fixtures/shared-court.mjs @@ -10,14 +10,14 @@ // Generate draw and Start for each. What the draw produced is read back from // the Scores tab, never assumed. import { expect } from '@playwright/test'; -import { createTournament, login } from './setup.mjs'; import { createCompetition } from './wizard.mjs'; import { generateDraw, pasteRoster, startCompetition } from './competition.mjs'; -import { hastyShiaijoConfirm } from './clumsy.mjs'; -import { OPERATOR_DEVICE, PUBLIC_DEVICE } from './devices.mjs'; -import { isCoarse, restoreCoarse } from './pointer.mjs'; +import { correctCompleted } from './individual.mjs'; +import { EXPECT_TIMEOUT_MS, OPERATOR_DEVICE, PUBLIC_DEVICE } from './devices.mjs'; +import { rendered } from './pace.mjs'; +import { ensureCoarse } from './pointer.mjs'; import { INLINE_EDITOR } from './scoring.mjs'; -import { UP_NEXT, WAITING_ROWS, completedRowFor, inlineEditor, waitingRows } from './shiaijo.mjs'; +import { UP_NEXT, WAITING_ROWS, inlineEditor, waitingRows } from './shiaijo.mjs'; import * as T from './team.mjs'; // A device of its own: an operator iPad (pointer verified coarse, as the @@ -28,7 +28,7 @@ import * as T from './team.mjs'; export async function operatorDevice(browser, base) { const ctx = await browser.newContext({ ...OPERATOR_DEVICE, baseURL: base }); const page = await ctx.newPage(); - if (!(await isCoarse(page))) await restoreCoarse(page); + await ensureCoarse(page); return page; } export async function publicDevice(browser, base) { @@ -41,22 +41,6 @@ export async function publicDevice(browser, base) { export const markPage = (page, mark) => page.evaluate((m) => { window.__e2eMark = m; }, mark); export const pageMark = (page) => page.evaluate(() => window.__e2eMark || null); -// Sign in, creating the tournament first when this file's server has none. -export async function enterTournament(page, { name = 'Shared Court Cup', courts = 2 } = {}) { - await page.goto('/'); - // The first-run form, or the public home of a tournament that exists: - // whichever the first paint shows, however long it takes. - const welcome = page.getByRole('heading', { name: 'Welcome to Bracket Creator' }); - const home = page.locator('.viewer__body'); - await welcome.or(home).first().waitFor({ state: 'visible' }); - if (await welcome.isVisible()) { - await createTournament(page, { name, courts }); - return { created: true }; - } - await login(page); - return { created: false }; -} - // What the create wizard offers before a competition is created: its default // Start time (stacked after the latest same-day competition, admin_setup.jsx) // and the hint under the shiaijo choice. Read on the wizard page itself, the @@ -82,7 +66,7 @@ export async function wizardDefaults(page, { expectEstimate = false } = {}) { page.on('request', onRequest); try { const answered = expectEstimate - ? page.waitForResponse((r) => isEstimate(r.request()) && r.ok(), { timeout: 15_000 }) + ? page.waitForResponse((r) => isEstimate(r.request()) && r.ok(), { timeout: EXPECT_TIMEOUT_MS }) : null; await page.goto('/admin/create-competition'); await expect(page.getByRole('heading', { level: 1, name: 'Add competition' })).toBeVisible(); @@ -91,7 +75,7 @@ export async function wizardDefaults(page, { expectEstimate = false } = {}) { if (answered) { const res = await answered; await res.finished(); - await page.evaluate(() => new Promise((done) => requestAnimationFrame(() => requestAnimationFrame(done)))); + await rendered(page); let prev = null; await expect.poll(async () => { const v = await input.inputValue(); @@ -133,6 +117,12 @@ export async function seedCompetitions(page, specs) { // courts the draw produced come from here. export async function readScoresTab(page, id) { await page.goto(`/admin/competition/${encodeURIComponent(id)}/scores`); + return readScoresRows(page); +} + +// The same rows of a Scores tab the page is already on (no navigation), for a +// poll that waits on what the page shows as the app follows the server. +export async function readScoresRows(page) { await expect(page.locator('.score-edit-row').first()).toBeVisible(); return page.locator('.score-edit-row').evaluateAll((rows) => rows.map((r) => { const txt = (sel) => { @@ -240,29 +230,9 @@ export async function liveBout(page) { } // Correct a completed bout on the court console while a bout of EITHER -// competition is live there (individual.mjs correctCompleted reads the live -// bout off the individual board only). Correct is refused while a bout is live -// (bc-crpn): the notice at the row says so, the operator sends the live bout -// back to the queue (its score is kept) and taps Correct again. Returns the -// live bout and what the refusal said. -export async function correctAcross(page, pair) { - const done = completedRowFor(page, pair); - const live = await liveBout(page); - await done.getByRole('button', { name: 'Correct' }).tap(); - let refusal = null; - if (live) { - const notice = done.locator('.shiaijo-upnext__error[role="alert"]'); - await expect(notice).toBeVisible(); - refusal = { notice: (await notice.innerText()).trim(), toast: (await page.locator('.toast').allInnerTexts().catch(() => [])).join(' | ') }; - expect(refusal.notice).toMatch(/^Shiaijo \S+ is running .+\. Finish it or send it back to the queue first\. Then correct this match\.$/); - await page.getByRole('button', { name: 'Send back to queue' }).tap(); - refusal.sendBack = await hastyShiaijoConfirm(page); - await expect.poll(() => liveBout(page), { timeout: 10_000 }).toBeNull(); - await done.getByRole('button', { name: 'Correct' }).tap(); - } - await expect(inlineEditor(page).first().locator('.editor-head-pill')).toHaveText('CORRECTION'); - return { live, refusal }; -} +// competition is live there: individual.mjs correctCompleted, reading the live +// bout with liveBout, since the default reader sees the individual board only. +export const correctAcross = (page, pair) => correctCompleted(page, pair, { readLive: liveBout }); // A waiting row of the queue (Upcoming, not the Up next card) naming both // competitors of a pairing. diff --git a/scripts/e2e/fixtures/shots.mjs b/scripts/e2e/fixtures/shots.mjs index 1e2eb8537..817181487 100644 --- a/scripts/e2e/fixtures/shots.mjs +++ b/scripts/e2e/fixtures/shots.mjs @@ -4,7 +4,7 @@ import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; -import { isCoarse, restoreCoarse } from './pointer.mjs'; +import { ensureCoarse, isCoarse } from './pointer.mjs'; // scripts/e2e, the directory playwright.config.mjs sits in. const E2E_DIR = path.resolve(fileURLToPath(new URL('..', import.meta.url))); @@ -22,7 +22,16 @@ const JOURNEYS_ROOT = process.env.E2E_RESULTS_DIR // The one owner of a journey's output folder: its screenshots and its // audit.json (fixtures/audit.mjs). `journey` may name a subfolder // ('knockout-mixed-individual/J1'). -export const journeyDir = (journey) => path.join(JOURNEYS_ROOT, journey); +// A name that is empty, absolute or climbs out of the root is refused: this +// path is what journeyShots empties. +export function journeyDir(journey) { + const dir = path.resolve(JOURNEYS_ROOT, String(journey)); + const inside = path.relative(JOURNEYS_ROOT, dir); + if (!journey || path.isAbsolute(journey) || !inside || inside.startsWith('..') || path.isAbsolute(inside)) { + throw new Error(`journeyDir: "${journey}" is not a folder inside ${JOURNEYS_ROOT}`); + } + return dir; +} // Empty the journey's folder and return its camera. Call once per spec file // (in beforeAll), so a folder never mixes two runs. @@ -40,7 +49,7 @@ export function journeyShots(journey) { // emulation: measured, (pointer: coarse) reads false afterwards, so every // tap-floor rule switches off for the rest of the test. Put it back in // place (fixtures/pointer.mjs). - if (coarse) await restoreCoarse(page); + if (coarse) await ensureCoarse(page); return file; }; } diff --git a/scripts/e2e/fixtures/team.mjs b/scripts/e2e/fixtures/team.mjs index 4918a1077..19d3e689f 100644 --- a/scripts/e2e/fixtures/team.mjs +++ b/scripts/e2e/fixtures/team.mjs @@ -17,9 +17,9 @@ // them asserts app behaviour beyond "the tap landed", except Finish, which // checks the write landed (fixtures/scoring.mjs finishLanded). import { expect } from '@playwright/test'; -import { createTournament, login } from './setup.mjs'; -import { settled, tapOnSide } from './pace.mjs'; -import { beforeFinish, finishLanded } from './scoring.mjs'; +import { EXPECT_TIMEOUT_MS } from './devices.mjs'; +import { dwell, settled, tapOnSide } from './pace.mjs'; +import { finishMatch } from './scoring.mjs'; import { inlineEditor, upNextCard } from './shiaijo.mjs'; // FIK position names for a five-person team, in order; any other size uses @@ -27,22 +27,6 @@ import { inlineEditor, upNextCard } from './shiaijo.mjs'; export const FIK5 = ['Senpo', 'Jiho', 'Chuken', 'Fukusho', 'Taisho']; export const positionLabels = (teamSize) => (teamSize === 5 ? FIK5 : Array.from({ length: teamSize }, (_, i) => String(i + 1))); -// Sign in, creating the tournament first if this server has none yet. Every -// test in a file can call it, whichever runs first or in which worker. -export async function enterAdmin(page, { courts = 16 } = {}) { - await page.goto('/'); - // The first-run form, or the public home of a tournament that exists: - // whichever the first paint shows, however long it takes. - const welcome = page.getByRole('heading', { name: 'Welcome to Bracket Creator' }); - const home = page.locator('.viewer__body'); - await welcome.or(home).first().waitFor({ state: 'visible' }); - if (await welcome.isVisible()) { - await createTournament(page, { name: 'Team Cup', courts }); - return; - } - await login(page); -} - // ---------------------------------------------------------------- Lineups page const overlineField = (page, text) => page.locator('label') @@ -116,7 +100,7 @@ async function answerDialog(page, label) { const handle = await dialog.elementHandle(); try { await dialog.getByRole('button', { name: label }).tap(); - await handle.waitForElementState('hidden', { timeout: 15_000 }); + await handle.waitForElementState('hidden', { timeout: EXPECT_TIMEOUT_MS }); } finally { await handle.dispose(); } @@ -130,9 +114,40 @@ export async function lineupsNameSlot(page, label, name) { await expect(lineupsSelect(page, label).locator('option:checked')).toContainText(name); } +// The PUT every lineup save sends: a team's lineup of a round (the Lineups +// page) or of one match (the lineup panel, the score sheet row), api_client.jsx +// putTeamLineup / putMatchLineup. Armed BEFORE the tap that sends it. A timeout +// the caller never reached (the tap threw first) is not an unhandled rejection. +// `teamId` names the team whose lineup is being saved, and `matchId` the match +// whose own lineup it is (`null`: the team's starting lineup, round 0); a PUT +// for another team or lineup is not this save's answer. `matchId` undefined +// matches any match lineup of the team (the lineup panel does not show the id). +const LINEUP_PUT = /\/teams\/([^/]+)\/(lineups|match-lineups)\/([^/?]+)(\?|$)/; +function lineupPut(r, { teamId, matchId }) { + if (r.method() !== 'PUT') return false; + const m = LINEUP_PUT.exec(r.url()); + if (!m || decodeURIComponent(m[1]) !== teamId) return false; + if (matchId === undefined) return m[2] === 'match-lineups'; + if (matchId === null) return m[2] === 'lineups' && decodeURIComponent(m[3]) === '0'; + return m[2] === 'match-lineups' && decodeURIComponent(m[3]) === matchId; +} +function lineupSaveAnswer(page, target) { + const answered = page.waitForResponse((r) => lineupPut(r.request(), target), { timeout: EXPECT_TIMEOUT_MS }); + answered.catch(() => {}); + return answered; +} + +// Tap Save lineup on the Lineups page and return once the server has answered +// the save with a success status. The "Lineup saved" toast is not the signal: it +// also opens "Lineup saved, but ..." warnings, and an older toast outlives it. export async function lineupsSave(page) { + // The save is of the team and lineup the two selects show right now. + const teamId = await overlineField(page, 'Team').locator('select').inputValue(); + const matchId = (await overlineField(page, 'Lineup for').locator('select').inputValue()) || null; + const answered = lineupSaveAnswer(page, { teamId, matchId }); await lineupsForm(page).getByRole('button', { name: 'Save lineup' }).tap(); - await expect(page.getByText('Lineup saved').first()).toBeVisible(); + const res = await answered; + expect(res.ok(), `the lineup save was answered ${res.status()} ${res.url()}`).toBe(true); } // The option text currently selected at a position (e.g. "T1.2 Ito"). @@ -164,6 +179,26 @@ export async function openPanelFromUpNext(page) { await expect(panelSide(page, 1)).toBeVisible(); } +// page -> when the last name-list option was tapped. A pick from the list stamps +// the score sheet's bout list (stampTap(boutListTapRef), admin_scoring_team.jsx), +// and that list swallows every click inside it for TAP_BOUNCE_MS afterwards: the +// next option tap is a bounce of the pick's tap to the app, and a pick made that +// soon does nothing (the list stays open, no write is sent). A person's next pick +// is never that close, Playwright's is, so every option tap waits out the dwell +// since the previous one, timed from that tap (nothing in the page says the +// window has passed). +const optionTaps = new WeakMap(); + +// Tap one option of an open name list, at the operator's pace. The one owner of +// "tap a name-list option"; a journey that taps an option itself calls this. +export async function tapNameOption(option) { + const page = option.page(); + const last = optionTaps.get(page); + if (last !== undefined) await dwell(page, last); + await option.tap(); + optionTaps.set(page, Date.now()); +} + // Tap a LineupNameInput and choose the option containing `text`. Works on the // panel and on the score sheet row alike (both render LineupNameInput). export async function pickFromNameBox(input, text) { @@ -171,22 +206,24 @@ export async function pickFromNameBox(input, text) { const box = input.locator('xpath=ancestor::div[contains(concat(" ", normalize-space(@class), " "), " lineup-name ")][1]'); const option = box.locator('.pmf__option').filter({ hasText: text }).first(); await expect(option).toBeVisible(); - await option.tap(); + await tapNameOption(option); } // A name list left open (by the operator, or by a tap that landed on a name -// box). Close it the way a thumb does, with a tap on the empty page margin. A -// box left focused that way still reopens its list on the next tap (bc-flst), -// so Escape would do as well while the list is open, but it is not what a -// touch operator does, and an Escape that arrives just after the list closed -// closes the layer behind it instead (the panel, or the editor). Returns -// whether one was open. Any LineupNameInput: the panel, the team sheet, the -// kachinuki sheet. +// box). Close it with Escape on the box that owns the list: an open list takes +// the key and keeps it from the layer behind (LineupNameInput stops it there), +// and pressing on the owning input puts the key where the list is, whatever +// the focus was. A fixed tap on the page margin is not used: where it lands +// depends on the surface (on the shiaijo page it can be a queue row). Escape +// is pressed only while a list is drawn, since one that arrives with none +// closes the layer behind instead (the panel, or the editor). Returns whether +// one was open. Any LineupNameInput: the panel, the team sheet, the kachinuki +// sheet. export async function closeNameList(page) { const options = page.locator('.pmf__option'); const wasOpen = (await options.count()) > 0; if (wasOpen) { - await page.touchscreen.tap(6, 500); + await page.locator('.lineup-name').filter({ has: options }).first().locator('input.pmf__input').press('Escape'); await expect(options).toHaveCount(0); } return wasOpen; @@ -199,12 +236,38 @@ export async function typeIntoNameBox(input, name) { await input.press('Enter'); } -// Tap one side's Save lineup and return once the button is back from "Saving…". -// It comes back disabled when the side was saved (nothing is left to save) and -// enabled when the save was refused, so the caller asserts which it meant. -export async function panelSave(page, i) { - await panelSaveButton(page, i).tap(); - await expect(panelSide(page, i).getByRole('button', { name: 'Save lineup' })).toBeVisible(); +// Tap one side's Save lineup and return once the save has been answered, then +// once the button is back from "Saving…" (disabled when the side was saved: +// nothing is left to save). By default the server must answer the save PUT with +// a success status. `refused: true` is for a save the panel refuses: it waits +// for the side's inline error instead (a refusal the panel makes itself sends +// no request), and the caller asserts its text. +export async function panelSave(page, i, { refused = false, matchId } = {}) { + const side = panelSide(page, i); + // The side's testid ends in its team's id (admin_schedule_lineup.jsx). + const teamId = (await side.getAttribute('data-testid')).replace(/^match-lineup-side-/, ''); + const answered = refused ? null : lineupSaveAnswer(page, { teamId, matchId }); + if (refused) { + // An alert from an earlier save would satisfy the wait below on its own. + await expect(side.locator('.alert--error'), 'no refusal is showing before the tap').toHaveCount(0); + const sent = []; + const onRequest = (r) => { if (lineupPut(r, { teamId, matchId })) sent.push(r.url()); }; + page.on('request', onRequest); + try { + await panelSaveButton(page, i).tap(); + await expect(side.locator('.alert--error')).toBeVisible(); + // A refusal the panel makes itself sends nothing; give a stray write time to go out. + await page.waitForTimeout(500); + expect(sent, 'the refused save sent no lineup write').toEqual([]); + } finally { + page.off('request', onRequest); + } + } else { + await panelSaveButton(page, i).tap(); + const res = await answered; + expect(res.ok(), `the lineup save was answered ${res.status()} ${res.url()}`).toBe(true); + } + await expect(side.getByRole('button', { name: 'Save lineup' })).toBeVisible(); } export async function closePanel(page) { @@ -275,32 +338,27 @@ export async function startUpNextTeam(page) { return ed; } -// Award one strike on a bout row and wait for the autosave to land. A second -// strike for the same side of the same bout waits out the bounce window of the -// first (fixtures/pace.mjs). -export async function boutIppon(root, n, side, waza = 'M') { - const row = boutRow(root, n); - const before = await rowSlots(row, side).count(); - await tapOnSide(root.page(), `bout ${n} ${side}`, rowIppon(row, side, waza)); - await expect(rowSlots(row, side)).toHaveCount(before + 1); +// Award one strike on a bout row and return once the board shows it and the +// autosave has landed. `getRow` returns the row (a function, because the +// kachinuki sheet's live row is a different element after each Record bout); +// `key` names the bout for pacing, so a second strike for the same side of +// the same bout waits out the bounce window of the first (fixtures/pace.mjs). +// The one strike, for the team sheet (boutIppon) and the kachinuki sheet +// (fixtures/kachinuki.mjs awardBoutIppon) alike. +export async function ipponOnRow(root, getRow, key, side, waza = 'M') { + const before = await rowSlots(getRow(), side).count(); + await tapOnSide(root.page(), `${key} ${side}`, rowIppon(getRow(), side, waza)); + await expect(rowSlots(getRow(), side)).toHaveCount(before + 1); await expect(syncPill(root)).toHaveText('Synced'); } +// One strike on bout `n` of the team sheet. +export const boutIppon = (root, n, side, waza = 'M') => ipponOnRow(root, () => boutRow(root, n), `bout ${n}`, side, waza); + // The primary footer button in whatever state it is in. export const finishBtn = (root) => root.locator('.score-nav__actions button.btn--primary').first(); // Finish in two taps and return once the write has landed, failing if it did -// not (fixtures/scoring.mjs finishLanded, which reads the same signs on the -// team sheet as on the individual board). -export async function finishTeam(root) { - const ed = root.first(); - const btn = finishBtn(root); - await expect(btn).toHaveText(/^Finish( \+ Start Next →)?$/); - await btn.tap(); - await expect(btn).toHaveText(/^Tap again to finish/); - // The second tap counts once the arming tap's bounce window has passed. - await settled(btn); - const before = await beforeFinish(ed); - await btn.tap(); - return finishLanded(ed, before); -} +// not: fixtures/scoring.mjs finishMatch, which reads the same signs on the +// team sheet as on the individual board. +export const finishTeam = (root) => finishMatch(root.page(), root); diff --git a/scripts/e2e/fixtures/test.mjs b/scripts/e2e/fixtures/test.mjs index 46c498303..91e1964e8 100644 --- a/scripts/e2e/fixtures/test.mjs +++ b/scripts/e2e/fixtures/test.mjs @@ -6,7 +6,7 @@ import fs from 'node:fs'; import { start, BIN } from '../../screenshots/lib/server.mjs'; import { auditsFor } from './audit.mjs'; import { EXPECT_TIMEOUT_MS } from './devices.mjs'; -import { isCoarse, restoreCoarse } from './pointer.mjs'; +import { ensureCoarse, isCoarse } from './pointer.mjs'; export { isCoarse }; @@ -29,7 +29,11 @@ export { isCoarse }; // // text the message, as the browser or the app logs it // url where it came from (a failed request's URL, or the script) -// code the app's refusal code in the failed /api answer's body +// code the app's refusal code in the failed /api answer's body; with +// codeUnreadable, an answer whose body could not be read because +// its page closed first stands in for the code, the rest still +// matching (a body missing for any other reason, a timeout or a +// failed read on a page still open, never does) // request which request was refused (method and body), for an entry that // is about one kind of write only // whileOffline only while the page's context is offline (or just back) @@ -51,8 +55,16 @@ const CONSOLE_ALLOWLIST = [ text: /^Failed to load resource: the server responded with a status of 409 \(Conflict\)$/, url: /\/api\/competitions\/[^/]+\/matches\/[^/]+\/(score|reopen)$/, code: /^(court_busy|ineligible_competitor|downstream_knockout_running)$/, + codeUnreadable: true, request: (r) => !!r && (/\/reopen$/.test(r.url) || writeStatus(r.postData) === 'running'), }, + { + reason: 'The organiser edits a competitor to the same name AND dojo as another one on the roster (organiser-roster journey): the server refuses the participant PUT and the Edit dialog shows its message. Same name in another dojo is not refused.', + text: /^Failed to load resource: the server responded with a status of 409 \(Conflict\)$/, + url: /\/api\/competitions\/[^/]+\/participants\/[^/]+$/, + code: /^a participant with the same name and dojo already exists$/, + request: (r) => !!r && r.method === 'PUT', + }, ]; // The `error` field of a refusal body; read with a pattern, since the body is @@ -66,9 +78,13 @@ const writeStatus = (postData) => { return null; } }; +// The page was closed when the answer's body was read: the one reason a body may +// be missing and an entry with codeUnreadable still stand on the answer's status, +// URL and request. +const closedBeforeBody = (e) => e.body === null && e.bodyMissing === 'page-closed' && !!e.request; const allowed = (e) => e.kind === 'console' && CONSOLE_ALLOWLIST.find((r) => r.text.test(e.text) && (!r.url || r.url.test(e.url || '')) - && (!r.code || r.code.test(refusalCode(e.body) || '')) + && (!r.code || r.code.test(refusalCode(e.body) || '') || (r.codeUnreadable && closedBeforeBody(e))) && (!r.request || r.request(e.request)) && (!r.whileOffline || e.offline)); @@ -85,6 +101,7 @@ const OFFLINE_GRACE_MS = 3_000; // meanwhile never delivers it; the entry then carries no body). The expect // timeout, so a body slow under load is waited for as long as an assertion is. const BODY_WAIT_MS = EXPECT_TIMEOUT_MS; +const ALLOWED_UNREAD_REASON = 'allowed: page closed before the refusal\'s code could be read'; // How far apart a "Failed to load resource" line and its answer may be. const PAIR_WINDOW_MS = 5_000; function consoleCollector() { @@ -101,6 +118,10 @@ function consoleCollector() { seen.add(page); const pageUrl = () => { try { return page.url(); } catch { return null; } }; const ctx = page.context(); + // page.isClosed() can still read false while the close is in progress, so + // the close event is recorded too. + let closed = false; + page.on('close', () => { closed = true; }); page.on('console', (msg) => { const type = msg.type(); if (type !== 'error' && type !== 'warning') return; @@ -118,12 +139,20 @@ function consoleCollector() { // this answer can reach drain() before the body has been read, and an // answer that is not in the list yet would read as "no answer". const at = Date.now(); + // Why the body is missing, when it is: the page was closed when the read + // failed, the read failed on a page still open (a reload, a navigation), + // or it did not arrive in BODY_WAIT_MS. + const entry = { url: res.url(), status: res.status(), method: res.request().method(), postData: res.request().postData(), bodyMissing: null, at }; let timer; - const body = Promise.race([ - res.text().then((t) => t.slice(0, 1000), () => null), - new Promise((done) => { timer = setTimeout(done, BODY_WAIT_MS, null); }), + entry.body = Promise.race([ + res.text().then((t) => t.slice(0, 1000), (err) => { + const gone = closed || page.isClosed() || /(?:target|page|context|browser).*closed/i.test(String(err && err.message)); + entry.bodyMissing = gone ? 'page-closed' : 'read-failed'; + return null; + }), + new Promise((done) => { timer = setTimeout(() => { entry.bodyMissing = 'timeout'; done(null); }, BODY_WAIT_MS); }), ]).finally(() => clearTimeout(timer)); - failed.push({ url: res.url(), status: res.status(), method: res.request().method(), postData: res.request().postData(), body, at }); + failed.push(entry); }); }; const attachContext = (ctx) => { @@ -155,7 +184,7 @@ function consoleCollector() { const i = unclaimed.findIndex((f) => f.url === e.url && Math.abs(f.at - e.at) < PAIR_WINDOW_MS); if (i < 0) return e; const [answer] = unclaimed.splice(i, 1); - return { ...e, status: answer.status, body: answer.body, request: { url: answer.url, method: answer.method, postData: answer.postData } }; + return { ...e, status: answer.status, body: answer.body, bodyMissing: answer.bodyMissing, request: { url: answer.url, method: answer.method, postData: answer.postData } }; }); // An answer no line claimed may still be claimed by one that has not // arrived: the 409 landing at the end of one test, its console line in the @@ -181,6 +210,15 @@ async function settleLate(c) { } } +// A spec file whose server needs extra command-line arguments or environment +// (the locked-mode spec) declares them once, at load, for its own file: +// `serverOptionsFor(fileURLToPath(import.meta.url), () => ({ args, env }))`. +// A function is called when the file's server is started, so what it needs (a +// hash made by the built binary) is made then and not while the suite loads. +// A file that declares nothing starts the server it always did. +const fileServerOptions = new Map(); +export const serverOptionsFor = (file, options) => { fileServerOptions.set(file, options); }; + export const test = base.extend({ // One mobile-app server per SPEC FILE, on a free port and a throwaway // TOURNAMENT_DATA_DIR, started and stopped by scripts/screenshots' @@ -206,7 +244,8 @@ export const test = base.extend({ c.file = file; let server; try { - server = await start('mobile'); + const declared = fileServerOptions.get(file); + server = await start('mobile', typeof declared === 'function' ? declared() : declared); } catch (err) { throw new Error(`${err.message}${fs.existsSync(BIN) ? '' : '\n(make e2e builds the binary before it runs the suite)'}`); } @@ -279,7 +318,11 @@ export const test = base.extend({ const errors = entries.filter((e) => e.type === 'error'); for (const e of errors) { const rule = allowed(e); - console.log(`CONSOLE ${JSON.stringify({ test: testInfo.title, allowed: rule ? rule.reason : null, ...e })}`); + // An entry let through for want of a body says so, in place of the + // matched-code reason: nobody checked the code. + const unread = rule && rule.code && !rule.code.test(refusalCode(e.body) || ''); + if (unread) testInfo.annotations.push({ type: 'console-allowed-unread', description: describeEntry(e) }); + console.log(`CONSOLE ${JSON.stringify({ test: testInfo.title, allowed: rule ? (unread ? ALLOWED_UNREAD_REASON : rule.reason) : null, ...e })}`); } const bad = errors.filter((e) => !allowed(e)); if (bad.length) { @@ -313,12 +356,12 @@ export const test = base.extend({ // The operator project's page must really have a coarse pointer. hasTouch + // isMobile (fixtures/devices.mjs) is what provides it today, measured. If // that ever stops holding, turn on CDP touch emulation in place - // (restoreCoarse, which fails loudly if even that does not do it). + // (ensureCoarse, which fails loudly if even that does not do it). page: async ({ page, consoleCollector: c }, use, testInfo) => { c.attachPage(page); - if (testInfo.project.name === 'operator' && !(await isCoarse(page))) { - await restoreCoarse(page); - testInfo.annotations.push({ type: 'coarse-pointer', description: 'CDP Emulation.setTouchEmulationEnabled' }); + if (testInfo.project.name === 'operator') { + const { coarseLost, restoredBy } = await ensureCoarse(page); + if (coarseLost) testInfo.annotations.push({ type: 'coarse-pointer', description: restoredBy }); } await use(page); }, diff --git a/scripts/e2e/fixtures/wizard.mjs b/scripts/e2e/fixtures/wizard.mjs index 52b7847a1..cb685d645 100644 --- a/scripts/e2e/fixtures/wizard.mjs +++ b/scripts/e2e/fixtures/wizard.mjs @@ -15,6 +15,9 @@ const FORMAT = { }; const TEAM_MATCH_TYPE = { fixed: 'Regular', kachinuki: 'Kachinuki (winner stays on)' }; const POOL_SIZE_MODE = { maximum: 'maximum', minimum: 'minimum' }; +// The "Knockout qualifiers" pills (competition_shape.jsx extraQualifiersLabel), +// which only a minimum pool size offers. +const EXTRA_QUALIFIERS = { standard: 'Standard', 'larger-pools': 'Oversized send +1', 'fill-bracket': 'Fit the knockout' }; // The wizard's controls by their accessible names (competition_fields.jsx): a // NumberField or TextField input is named by its label through htmlFor, and a @@ -46,18 +49,25 @@ async function setNumber(page, label, value) { // // kind 'individual' | 'team' // format 'knockout' | 'mixed' | 'league' | 'swiss' +// engi boolean: "Engi (kata competition)" (individual only): the +// roster is pairs and a match is scored in flags // teamSize number (team only) // teamMatchType 'fixed' | 'kachinuki' (team only) // poolSizeMode 'maximum' | 'minimum' (mixed only) // poolSize, poolWinners numbers (mixed only) +// extraQualifiers 'standard' | 'larger-pools' | 'fill-bracket': the "Knockout +// qualifiers" pills (mixed with a minimum pool size only). +// Either non-standard choice sets Winners per pool to 1 and +// locks it, so give poolWinners with 'standard' only // courts e.g. ['A', 'B']: exactly these shiaijo end up selected // numberPrefix up to 3 characters // twoThirdPlaces boolean: "Award two joint 3rd places" (on by default; // untick it for a bronze match) +// checkIn boolean: "Check-in tracking" (officiated tournaments only) export async function createCompetition(page, opts = {}) { const { - name, kind, format, teamSize, teamMatchType, poolSizeMode, poolSize, poolWinners, - courts, numberPrefix, twoThirdPlaces, + name, kind, format, teamSize, teamMatchType, poolSizeMode, poolSize, poolWinners, extraQualifiers, + courts, numberPrefix, twoThirdPlaces, engi, checkIn, } = opts; await page.goto('/admin/create-competition'); await expect(page.getByRole('heading', { level: 1, name: 'Add competition' })).toBeVisible(); @@ -65,14 +75,24 @@ export async function createCompetition(page, opts = {}) { if (name !== undefined) await input(page, 'Display name').fill(name); if (kind) await choose(page, 'Competition type', KIND[kind]); if (format) await choose(page, 'Format', FORMAT[format]); + if (engi !== undefined) await page.getByRole('checkbox', { name: /^Engi \(kata competition\)/ }).setChecked(engi); if (teamSize !== undefined) await setNumber(page, 'Team size', teamSize); if (teamMatchType) await choose(page, 'Team match format', TEAM_MATCH_TYPE[teamMatchType]); if (poolSizeMode) await choose(page, 'Pool size is a', POOL_SIZE_MODE[poolSizeMode]); if (poolSize !== undefined) await setNumber(page, 'Players per pool', poolSize); if (poolWinners !== undefined) await setNumber(page, 'Winners per pool', poolWinners); + if (extraQualifiers) { + await choose(page, 'Knockout qualifiers', EXTRA_QUALIFIERS[extraQualifiers]); + if (extraQualifiers !== 'standard') { + // The wizard locks the field at 1: oversized pools send one more than that. + await expect(input(page, 'Winners per pool')).toHaveValue('1'); + await expect(input(page, 'Winners per pool')).toBeDisabled(); + } + } if (twoThirdPlaces !== undefined) { await page.getByRole('checkbox', { name: 'Award two joint 3rd places' }).setChecked(twoThirdPlaces); } + if (checkIn !== undefined) await page.getByRole('checkbox', { name: /^Check-in tracking/ }).setChecked(checkIn); if (courts) { const grp = group(page, 'Assigned shiaijo'); const pills = grp.getByRole('button'); diff --git a/scripts/e2e/journeys/engi.spec.mjs b/scripts/e2e/journeys/engi.spec.mjs new file mode 100644 index 000000000..5f0395a20 --- /dev/null +++ b/scripts/e2e/journeys/engi.spec.mjs @@ -0,0 +1,166 @@ +// Engi (kata pairs) journey: a knockout of four pairs, scored in flags. +// +// The tournament, the engi competition (the wizard's "Engi (kata competition)" +// box), the roster of pairs ("Name 1 - Name 2, Dojo"), the draw and the start +// are all seeded through the interface, once, in beforeAll. The tests then +// walk the court console: one scores a match and checks the result on a +// spectator's phone, the other guards bc-evts, the engi editor's counterpart +// of bc-ltap (a flag taken back while the save of the one before is still out +// must stay taken back). +import { test, expect } from '../fixtures/test.mjs'; +import { OPERATOR_DEVICE, PUBLIC_DEVICE, EXPECT_TIMEOUT_MS } from '../fixtures/devices.mjs'; +import { journeyShots } from '../fixtures/shots.mjs'; +import { auditLog } from '../fixtures/audit.mjs'; +import { createTournament, login } from '../fixtures/setup.mjs'; +import { createCompetition } from '../fixtures/wizard.mjs'; +import { generateDraw, pasteRoster, startCompetition } from '../fixtures/competition.mjs'; +import { openShiaijo, completedRows, upNextCard } from '../fixtures/shiaijo.mjs'; +import { openViewer } from '../fixtures/public.mjs'; +import { holdScoreWrites } from '../fixtures/held-write.mjs'; +import { + engiEditor, engiSettle, engiTotal, enterFlags, flagCount, flagButton, saveEngiResult, startUpNextEngi, +} from '../fixtures/engi.mjs'; + +// Four pairs, one dojo each: (name, dojo) is unique per pair. +const PAIRS = [ + 'Akira Tanaka - Ben Carter, Gyokusen', + 'Chie Mori - Dan Evans, Musashi', + 'Eri Kato - Finn Hale, Seishinkan', + 'Goro Abe - Hana Ito, Thames', +]; + +let compId; +const audit = auditLog('engi'); +const { record } = audit; +let shot; + +test.describe.configure({ mode: 'serial' }); + +test.describe('engi', () => { + test.beforeAll(async ({ browser, serverForFile }, testInfo) => { + shot = journeyShots('engi'); + const server = await serverForFile(testInfo.file); + const ctx = await browser.newContext({ ...OPERATOR_DEVICE, baseURL: server.base }); + try { + const page = await ctx.newPage(); + await createTournament(page, { name: 'Engi Cup', courts: 2 }); + compId = await createCompetition(page, { + name: 'Engi Pairs', kind: 'individual', format: 'knockout', engi: true, courts: ['A'], numberPrefix: 'EN', + }); + await pasteRoster(page, compId, PAIRS); + await generateDraw(page, compId); + await startCompetition(page, compId); + } finally { + await ctx.close(); + } + }); + + test.afterAll(() => audit.flush()); + + test('score an engi match on the court console', async ({ page, browser, baseURL }) => { + await login(page); + await openShiaijo(page, 'A'); + await shot(page, 'court-before-start'); + + const pair = await startUpNextEngi(page); + await expect(engiTotal(page)).toContainText('Enter flags'); + await shot(page, 'engi-editor-started'); + + await enterFlags(page, { aka: 2, shiro: 1 }); + await expect(engiTotal(page)).toHaveText('Total: 3, Aka wins'); + await engiSettle(page); + await shot(page, 'flags-entered'); + + const landed = await saveEngiResult(page); + record({ journey: 'engi', action: 'Save result', pair, landed: landed.detail }); + await expect(completedRows(page)).toHaveCount(1); + const row = completedRows(page).first(); + await expect(row).toContainText(pair.aka); + // Aka's side carries the winner mark (the row's text names the side it is on). + const rowText = (await row.innerText()).replace(/\s+/g, ' '); + expect(rowText, 'Aka, who held the two flags, is marked the winner').toContain(`Aka: ✓ Winner: ${pair.aka}`); + expect(rowText.match(/✓/g), 'and only Aka').toHaveLength(1); + const result = (await row.locator('.shiaijo-qrow__result').innerText()).trim(); + record({ journey: 'engi', action: 'console result row', result }); + expect(result, 'the result reads in flags, Shiro 1 and Aka 2').toBe('Flags1–2'); + await shot(page, 'console-after-save'); + + const spectator = await browser.newContext({ ...PUBLIC_DEVICE, baseURL }); + try { + const phone = await spectator.newPage(); + await openViewer(phone, compId); + const resultCard = phone.locator('button.vsched-item').filter({ hasText: pair.aka }).filter({ hasText: pair.shiro }).first(); + await expect(resultCard).toBeVisible(); + const cardText = (await resultCard.innerText()).replace(/\s+/g, ' '); + record({ journey: 'engi', action: 'public viewer row', text: cardText }); + expect(cardText, 'the phone shows the flags, Shiro 1 and Aka 2').toContain('1–2'); + expect(cardText, 'Aka is marked the winner').toMatch(new RegExp(`Aka: ${pair.aka}[^✓]*✓`)); + expect(cardText.match(/✓/g), 'and only Aka').toHaveLength(1); + await shot(phone, 'viewer-engi-result', { fullPage: true }); + } finally { + await spectator.close(); + } + }); + + test('bc-evts: a flag taken back while its add is saving stays taken back, and is saved', async ({ page }) => { + await login(page); + await openShiaijo(page, 'A'); + // The previous test's Finish + Start Next left the second semi-final running; + // when this test runs alone the Up next card shows instead. Wait for either, + // and start the Up next match only when the editor is not the one showing. + const upNextStart = upNextCard(page).getByRole('button', { name: 'Start match' }); + await expect(engiEditor(page).or(upNextStart)).toBeVisible({ timeout: EXPECT_TIMEOUT_MS }); + if (!(await engiEditor(page).isVisible())) await startUpNextEngi(page); + await expect(engiEditor(page)).toBeVisible(); + await expect(flagCount(page, 'aka')).toHaveText('0'); + const writes = await holdScoreWrites(page); + const lastBody = () => writes.bodies[writes.bodies.length - 1]; + const reloaded = async () => { + await page.reload(); + await expect(engiEditor(page)).toBeVisible({ timeout: EXPECT_TIMEOUT_MS }); + return Number(await flagCount(page, 'aka').innerText()); + }; + try { + // Add one, and take it back while the add's save is still out. + let held = writes.holdNext(); + await flagButton(page, 'aka', '+').tap(); + await held.sent; // past the autosave debounce: the add's PUT is out, held + await flagButton(page, 'aka', '-').tap(); + held.release(); + // The add's response reached the page before the removal's own save went out. + await writes.answeredBeforeNextWrite(held); + await engiSettle(page); + // Deliberate: a late echo that undoes the tap-back must be given the time to land, so the time is the evidence. + await page.waitForTimeout(1000); + await expect(flagCount(page, 'aka'), 'the taken-back flag stays taken back').toHaveText('0'); + await shot(page, 'flag-taken-back-kept'); + expect(await reloaded(), 'and the server holds no flag').toBe(0); + expect(lastBody(), 'the last save sent was the removal').toMatchObject({ flagsA: 0 }); + expect(lastBody().changed, 'and named the flags as changed').toContain('flags'); + await shot(page, 'flag-taken-back-after-reload'); + + // The mirror: from one flag saved, take it off, and put it back while + // the removal's save is out. + await flagButton(page, 'aka', '+').tap(); + await engiSettle(page); + await expect(flagCount(page, 'aka')).toHaveText('1'); + held = writes.holdNext(); + await flagButton(page, 'aka', '-').tap(); + await held.sent; + await flagButton(page, 'aka', '+').tap(); + held.release(); + await writes.answeredBeforeNextWrite(held); + await engiSettle(page); + // Deliberate: a late echo that undoes the tap-back must be given the time to land, so the time is the evidence. + await page.waitForTimeout(1000); + await expect(flagCount(page, 'aka'), 'the flag put back stays').toHaveText('1'); + await shot(page, 'flag-put-back-kept'); + expect(await reloaded(), 'and the server holds it').toBe(1); + expect(lastBody(), 'the last save sent was the add').toMatchObject({ flagsA: 1 }); + expect(lastBody().changed, 'and named the flags as changed').toContain('flags'); + } finally { + await writes.stop(); + } + writes.assertNoSafetyRelease(); // after the body succeeded: a throw in the finally would hide its error + }); +}); diff --git a/scripts/e2e/journeys/kachinuki-pools.spec.mjs b/scripts/e2e/journeys/kachinuki-pools.spec.mjs new file mode 100644 index 000000000..bd5847a2a --- /dev/null +++ b/scripts/e2e/journeys/kachinuki-pools.spec.mjs @@ -0,0 +1,349 @@ +// Kachinuki (winner stays on) in POOLS. +// +// Fixture: a team competition, kachinuki, team size 3, format "Pools + +// Knockout", five teams in pools of at most three, one qualifier per pool. The +// draw makes a pool of three and a pool of two; the knockout is the final +// between the two pool winners. Everything is seeded through the interface +// (wizard, roster paste, Generate draw, Start). The members of the teams of the +// three-team pool are named at the court through the lineup panel before their +// first encounter. +// +// The draw decides who meets whom and in which order, so the three-team pool +// is scripted by the ROLE a team plays, not by its name: +// 1 its first encounter: Shiro's team (A) beats B, a hikiwake retiring both +// on the way, B's last fighter beaten: winner stays on, a tie removes both, +// a team exhausted (Record bout takes the last fighter off and no pairing +// follows). +// 2 one of A/B against the third team (C): ends LEVEL on individual victories +// and points, both lineups used up on a double hikiwake. A pool encounter +// may end in a draw, so it is recorded as a draw (T) with no winner +// marked. (Kachinuki has no representative bout anywhere, in a pool or a +// knockout, so the sheet offering none here says nothing about pools.) +// 3 the remaining pair: a clean win (End match straight after the win). +// Encounter 3's winner is picked so the pool has a strict order on W, L and T +// alone (no tie-break flow). The two-team pool's only encounter is one bout won +// by Shiro's team, ended with End match. Expected standings are tallied from the +// scripted bouts, never read back from the app. +// +// Asserted: the pairing the winner-stays-on rule brings up after each bout, the +// recorded result of each encounter, the pool tables' eight figures and rank +// badges for every team on the admin Pools tab and on a spectator's phone, and +// the two pool winners seated in the final. +import { test, expect } from '../fixtures/test.mjs'; +import { journeyShots } from '../fixtures/shots.mjs'; +import { auditLog } from '../fixtures/audit.mjs'; +import { createTournament, login } from '../fixtures/setup.mjs'; +import { createCompetition } from '../fixtures/wizard.mjs'; +import { generateDraw, pasteRoster, startCompetition } from '../fixtures/competition.mjs'; +import { completedRowFor, openShiaijo, sides, upNextCard } from '../fixtures/shiaijo.mjs'; +import { EXPECT_TIMEOUT_MS, PUBLIC_DEVICE } from '../fixtures/devices.mjs'; +import { FIGURES, openAdminPools, openViewerPools, poolBlocks, poolTeams, rowFigures } from '../fixtures/pools.mjs'; +import { + awardBoutIppon, boutNames, closeLineupPanel, currentBout, daihyosenControls, daihyosenOffer, doneRows, endMatch, + endMatchButton, noPairingLine, openLineupFromUpNext, recordBout, recordBoutButton, startUpNextKachinuki, tieButton, + typeLineup, +} from '../fixtures/kachinuki.mjs'; + +const JOURNEY = 'kachinuki-pools'; + +const TEAMS = [ + ['Team Hayabusa', 'Kita Dojo'], ['Team Kawasemi', 'Minami Dojo'], ['Team Tsubame', 'Higashi Dojo'], + ['Team Washi', 'Nishi Dojo'], ['Team Taka', 'Chuo Dojo'], +]; +const NAMES = TEAMS.map(([name]) => name); +// Senpo to Taisho. +const MEMBERS = { + 'Team Hayabusa': ['Aoki', 'Baba', 'Chiba'], + 'Team Kawasemi': ['Fujii', 'Goto', 'Hara'], + 'Team Tsubame': ['Kudo', 'Maeda', 'Noda'], + 'Team Washi': ['Taki', 'Ueno', 'Wada'], + 'Team Taka': ['Yano', 'Zaitsu', 'Ando'], +}; +const TEAM_SIZE = 3; +const ORDINAL = ['1st', '2nd', '3rd']; + +const zero = () => ({ W: 0, L: 0, T: 0, IV: 0, IL: 0, IT: 0, PW: 0, PL: 0 }); + +test.describe.configure({ mode: 'serial' }); + +test.describe('kachinuki in pools', () => { + let shot; + let compId; + const audit = auditLog(JOURNEY); + const { record } = audit; + + // What the scripted bouts add up to, per team (the pool tables' figures). + const tally = Object.fromEntries(NAMES.map((n) => [n, zero()])); + // The pools as the draw made them (team names), the three-team one first. + let pools; + // Teams whose members have been named at the court (a lineup carries to the + // team's later matches). + const lineupDone = new Set(); + + test.beforeAll(() => { + shot = journeyShots(JOURNEY); + }); + + test.afterAll(() => audit.flush()); + + // Name the members of every team on the Up next card that has none yet. + async function ensureLineups(page, pair) { + const needing = [pair.shiro, pair.aka].filter((t) => !lineupDone.has(t)); + if (!needing.length) return; + await openLineupFromUpNext(page); + for (const team of needing) { + await typeLineup(page, team, MEMBERS[team]); + lineupDone.add(team); + } + await shot(page, `lineups-${needing.length}-teams-named`); + await closeLineupPanel(page); + } + + // Play `bouts` on the running sheet. Each entry is the winning team's name or + // null for a hikiwake. The winner-stays-on pairing is tracked here and checked + // against the sheet before every bout. A bout is scored M then K (2 points), + // and recorded with Record bout unless it is the last one and `recordLast` is + // false. Returns the encounter's per-team IV and PW for the Completed row. + async function playBouts(page, pair, bouts, { recordLast, label }) { + const next = { [pair.shiro]: 0, [pair.aka]: 0 }; + const other = (team) => (team === pair.shiro ? pair.aka : pair.shiro); + const line = { [pair.shiro]: { IV: 0, PW: 0 }, [pair.aka]: { IV: 0, PW: 0 } }; + for (let n = 0; n < bouts.length; n += 1) { + const winner = bouts[n]; + const last = n === bouts.length - 1; + await expect.poll(() => boutNames(currentBout(page)), { message: `${label}: bout ${n + 1} pairing` }).toEqual({ + shiro: MEMBERS[pair.shiro][next[pair.shiro]], aka: MEMBERS[pair.aka][next[pair.aka]], + }); + expect(await doneRows(page).count(), `${label}: fought bouts above the live one`).toBe(n); + if (winner) { + const side = winner === pair.shiro ? 'shiro' : 'aka'; + await awardBoutIppon(page, side, 'M'); + await awardBoutIppon(page, side, 'K'); + const loser = other(winner); + tally[winner].IV += 1; + tally[winner].PW += 2; + tally[loser].IL += 1; + tally[loser].PL += 2; + line[winner].IV += 1; + line[winner].PW += 2; + next[loser] += 1; + } else { + const tie = tieButton(currentBout(page)); + await tie.tap(); + await expect(tie).toHaveText(/✓ Tie/); + tally[pair.shiro].IT += 1; + tally[pair.aka].IT += 1; + next[pair.shiro] += 1; + next[pair.aka] += 1; + } + await shot(page, `${label}-bout-${n + 1}-scored`); + if (!last || recordLast) await recordBout(page); + } + return line; + } + + // The Completed row's result string for a finished encounter, by side. + const resultText = (pair, line) => new RegExp( + `IV ${line[pair.shiro].IV}–${line[pair.aka].IV}\\s+PW ${line[pair.shiro].PW}–${line[pair.aka].PW}`); + + async function expectCompleted(page, pair, line) { + const row = completedRowFor(page, pair); + await expect(row).toBeVisible(); + await expect(row.locator('.shiaijo-qrow__result')).toHaveText(resultText(pair, line)); + return (await row.innerText()).replace(/\s+/g, ' '); + } + + // The ranking the scripted results give within one pool: W high, L low, T high. + const rankedIn = (teams) => [...teams].sort((a, b) => (tally[b].W - tally[a].W) || (tally[a].L - tally[b].L) + || (tally[b].T - tally[a].T)); + + // Roles of the three-team pool, fixed by its first two encounters. + const role = {}; + + // Encounter 1: winner stays on, a hikiwake retires both, a team is exhausted. + async function encounter1(page, pair) { + role.A = pair.shiro; + role.B = pair.aka; + await ensureLineups(page, pair); + await startUpNextKachinuki(page); + await shot(page, 'enc1-started'); + // A beats B's first, A stays on and ties B's second (both retire), A's + // second beats B's third: B has nobody left. + const line = await playBouts(page, pair, [role.A, null, role.A], { recordLast: true, label: 'enc1' }); + // Record bout took B's last fighter off: no pairing follows, and the sheet says so. + await expect(noPairingLine(page)).toContainText('has no fighters left'); + await expect(recordBoutButton(page)).toBeDisabled(); + await expect(daihyosenOffer(page)).toHaveCount(0); + await shot(page, 'enc1-b-exhausted'); + await endMatch(page); + const rowText = await expectCompleted(page, pair, line); + tally[role.A].W += 1; + tally[role.B].L += 1; + record({ journey: JOURNEY, step: 'enc1', action: 'End match', pair, rowText, screenshot: await shot(page, 'enc1-completed') }); + } + + // Encounter 2: level on victories and points, so a draw (T) with no winner marked. + async function encounter2(page, pair) { + role.C = pools[0].find((n) => n !== role.A && n !== role.B); + const p = pair.shiro === role.C ? pair.aka : pair.shiro; + expect([role.A, role.B], 'one of the first encounter\'s teams meets the third').toContain(p); + await ensureLineups(page, pair); + await startUpNextKachinuki(page); + await shot(page, 'enc2-started'); + // P beats C's first; C's second beats P's first; P's second ties C's second + // (both retire); P's third ties C's third. Both lineups are used up: the last + // bout stays the current bout, Record bout is off, and the encounter is level + // at 1 victory and 2 points each. + const line = await playBouts(page, pair, [p, role.C, null, null], { recordLast: false, label: 'enc2' }); + // Record bout records the tie and finds nobody to bring up: the bout stays the + // current one and Record bout is greyed out (team-tournaments.md, "A tie + // leaves both teams with nobody"). + await expect(recordBoutButton(page)).toBeEnabled(); + await recordBoutButton(page).tap(); + await expect(recordBoutButton(page)).toBeDisabled(); + expect(await doneRows(page).count(), 'the tied last bout stays the current bout').toBe(3); + // Kachinuki has no representative bout in any round, so none is offered on + // this tie. That holds in a knockout too; it is not a claim about pools. + await expect(daihyosenOffer(page)).toHaveCount(0); + await expect(daihyosenControls(page)).toHaveCount(0); + await expect(endMatchButton(page)).toBeEnabled(); + await shot(page, 'enc2-level-no-daihyosen'); + await endMatch(page); + const rowText = await expectCompleted(page, pair, line); + // A draw names no winner. + expect(rowText.match(/✓/g) || [], `a drawn encounter marks no winner: ${rowText}`).toHaveLength(0); + tally[p].T += 1; + tally[role.C].T += 1; + record({ journey: JOURNEY, step: 'enc2', action: 'End match', pair, rowText, screenshot: await shot(page, 'enc2-completed') }); + } + + // Encounter 3: the remaining pair, a clean win ended with End match. + async function encounter3(page, pair) { + const [x, y] = [pair.shiro, pair.aka]; + // The winner that leaves the pool with a strict order on W, L, T. + const order = (a, b) => (b.W - a.W) || (a.L - b.L) || (b.T - a.T); + const strict = (winner) => { + const after = Object.fromEntries(pools[0].map((n) => [n, { ...tally[n] }])); + after[winner].W += 1; + after[winner === x ? y : x].L += 1; + const sorted = [...pools[0]].sort((a, b) => order(after[a], after[b])); + return sorted.every((n, i) => i === 0 || order(after[sorted[i - 1]], after[n]) !== 0); + }; + const winner = [x, y].find(strict); + expect(winner, 'one winner leaves a strict order').toBeTruthy(); + await ensureLineups(page, pair); + await startUpNextKachinuki(page); + // Three wins in a row: the winner stays on against all three. The last win + // is not recorded; End match finishes on the last scored bout. + const line = await playBouts(page, pair, [winner, winner, winner], { recordLast: false, label: 'enc3' }); + await shot(page, 'enc3-last-bout-scored'); + await endMatch(page); + const rowText = await expectCompleted(page, pair, line); + tally[winner].W += 1; + tally[winner === x ? y : x].L += 1; + record({ journey: JOURNEY, step: 'enc3', action: 'End match', pair, winner, rowText, screenshot: await shot(page, 'enc3-completed') }); + } + + // The two-team pool's only encounter: one bout, Shiro's team wins it. + async function quickEncounter(page, pair) { + await startUpNextKachinuki(page); + const line = { [pair.shiro]: { IV: 1, PW: 2 }, [pair.aka]: { IV: 0, PW: 0 } }; + await awardBoutIppon(page, 'shiro', 'M'); + await awardBoutIppon(page, 'shiro', 'K'); + await endMatch(page); + const rowText = await expectCompleted(page, pair, line); + Object.assign(tally[pair.shiro], { W: 1, IV: 1, PW: 2 }); + Object.assign(tally[pair.aka], { L: 1, IL: 1, PL: 2 }); + record({ journey: JOURNEY, step: 'pair pool', action: 'End match', pair, rowText, screenshot: await shot(page, 'pair-pool-completed') }); + } + + test('seed: five teams, a pool of three and a pool of two, one qualifier each', async ({ page }) => { + await createTournament(page, { name: 'Kachinuki Pools Cup', courts: 2 }); + compId = await createCompetition(page, { + name: 'Team Kachinuki Pools', kind: 'team', format: 'mixed', teamSize: TEAM_SIZE, teamMatchType: 'kachinuki', + poolSizeMode: 'maximum', poolSize: 3, poolWinners: 1, courts: ['A'], + }); + await pasteRoster(page, compId, TEAMS); + await generateDraw(page, compId); + await startCompetition(page, compId); + + // The pools as the draw made them, read back. + await openAdminPools(page, compId); + await expect(poolBlocks(page)).toHaveCount(2); + const read = []; + for (let i = 0; i < 2; i += 1) { + const names = await poolTeams(poolBlocks(page).nth(i)); + read.push(NAMES.filter((n) => names.some((t) => t.includes(n)))); + } + pools = read.sort((a, b) => b.length - a.length); + record({ journey: JOURNEY, step: 'draw', action: 'pool composition', pools, screenshot: await shot(page, 'pools-drawn') }); + expect(pools.map((t) => t.length), 'a pool of three and a pool of two').toEqual([3, 2]); + }); + + test('play the pools: winner stays on, hikiwake, exhaustion, a level draw', async ({ page }) => { + await login(page); + const poolOf = (team) => pools.findIndex((t) => t.includes(team)); + let threeTeamPlayed = 0; + for (let n = 1; n <= 4; n += 1) { + await openShiaijo(page, 'A'); + await expect(upNextCard(page)).toBeVisible(); + const pair = await sides(upNextCard(page)); + expect(poolOf(pair.shiro), 'an encounter is within one pool').toBe(poolOf(pair.aka)); + const isThree = poolOf(pair.shiro) === 0; + const title = !isThree ? `encounter ${n}: the pool of two` + : ['winner stays on, a hikiwake retires both, a team is exhausted', 'level on victories and points is a draw (T)', + 'a clean win ended with End match'][threeTeamPlayed]; + await test.step(`encounter ${n} (${pair.shiro} v ${pair.aka}): ${title}`, async () => { + if (!isThree) await quickEncounter(page, pair); + else await [encounter1, encounter2, encounter3][threeTeamPlayed](page, pair); + }); + if (isThree) threeTeamPlayed += 1; + } + expect(threeTeamPlayed).toBe(3); + }); + + async function expectTables(page, where) { + for (const team of pools) { + for (const [i, name] of rankedIn(team).entries()) { + const want = { ...tally[name], rank: ORDINAL[i], advancing: i < 1 }; + // The table reads the server's standings: wait for the last write to show. + await expect.poll(() => rowFigures(page, name), { message: `${where}: ${name}` }).toEqual(want); + } + } + } + + test('the pool standings: team and individual figures, on the admin Pools tab and a spectator\'s phone', async ({ page, browser, baseURL }) => { + await login(page); + await openAdminPools(page, compId); + await expectTables(page, 'admin Pools tab'); + record({ journey: JOURNEY, step: 'standings', action: 'admin Pools tab', columns: FIGURES, tally, + screenshot: await shot(page, 'admin-pools-standings', { fullPage: true }) }); + + const spectator = await browser.newContext({ ...PUBLIC_DEVICE, baseURL }); + try { + const phone = await spectator.newPage(); + await openViewerPools(phone, compId); + await expectTables(phone, 'public viewer Pools tab'); + await shot(phone, 'viewer-pools-standings', { fullPage: true }); + } finally { + await spectator.close(); + } + }); + + test('the winner of each pool is seated in the final', async ({ page }) => { + await login(page); + const winners = pools.map((t) => rankedIn(t)[0]).sort(); + // One navigation; the poll then reads the console as it follows the data + // (a goto per poll interval would re-load the page under every read). + await openShiaijo(page, 'A'); + await expect.poll(async () => { + const card = upNextCard(page); + if (!(await card.count())) return null; + const pair = await sides(card); + return [pair.shiro, pair.aka].sort(); + }, { message: 'the final seats the pool winners', timeout: EXPECT_TIMEOUT_MS }).toEqual(winners); + const text = (await upNextCard(page).innerText()).replace(/\s+/g, ' '); + record({ journey: JOURNEY, step: 'knockout', action: 'Up next after the pools', winners, text, + screenshot: await shot(page, 'final-seated') }); + }); +}); diff --git a/scripts/e2e/journeys/knockout-mixed-individual.spec.mjs b/scripts/e2e/journeys/knockout-mixed-individual.spec.mjs index 3cc1878c6..39e0f3c4a 100644 --- a/scripts/e2e/journeys/knockout-mixed-individual.spec.mjs +++ b/scripts/e2e/journeys/knockout-mixed-individual.spec.mjs @@ -23,7 +23,7 @@ // which gets a fresh server (fixtures/test.mjs serverForFile), and the // file-level beforeAll below creates the tournament again there. import { test, expect, isCoarse } from '../fixtures/test.mjs'; -import { OPERATOR_DEVICE } from '../fixtures/devices.mjs'; +import { OPERATOR_DEVICE, EXPECT_TIMEOUT_MS } from '../fixtures/devices.mjs'; import { journeyShots } from '../fixtures/shots.mjs'; import { createTournament, login } from '../fixtures/setup.mjs'; import { createCompetition } from '../fixtures/wizard.mjs'; @@ -33,12 +33,15 @@ import { editorIdentity, UP_NEXT, } from '../fixtures/shiaijo.mjs'; import { - INLINE_EDITOR, EDITOR, ipponButton, syncState, filledSlots, finishButton, armedFinishButton, armFinish, finishMatch, + INLINE_EDITOR, EDITOR, clearMarks, ipponButton, syncState, filledSlots, finishButton, armedFinishButton, armFinish, finishMatch, + startMatch, awardIppon, nextMatchWrite, writeLanded, } from '../fixtures/scoring.mjs'; +import { dwell, rendered, untilStable } from '../fixtures/pace.mjs'; import { doubleTap, hastyConfirm, hastyShiaijoConfirm, interrupt, retapWhileSaving, tapNeighbour, } from '../fixtures/clumsy.mjs'; import { auditLog } from '../fixtures/audit.mjs'; +import { holdScoreWrites } from '../fixtures/held-write.mjs'; import { tapSize } from '../fixtures/pointer.mjs'; import { ROSTER_12, ROSTER_6, ROSTER_4_BRONZE, ROSTER_4_JOINT, ROSTER_6B, rosterOf, @@ -125,10 +128,10 @@ test.describe('knockout-mixed-individual', () => { const startBtn = card.getByRole('button', { name: /Start match|Starting…/ }); row({ step: 'start bout', action: 'Start match (Up next card)', variant: 'size', ...(await tapSize(startBtn)) }); const dbl = await doubleTap(startBtn); - await expect.poll(() => inlineRunning(page), { timeout: 15_000 }).toBe(true); + await expect.poll(() => inlineRunning(page), { timeout: EXPECT_TIMEOUT_MS }).toBe(true); const running1 = await editorSides(page); const inProgress = (await page.locator('.admin-live-strip, .live-strip').allInnerTexts()).join(' '); - await page.waitForTimeout(800); + await expect.poll(async () => JSON.stringify(await sides(upNextCard(page)).catch(() => null))).not.toBe(JSON.stringify(running1)); const upNextAfter = await sides(upNextCard(page)).catch(() => null); row({ step: 'start bout', action: 'Start match (Up next card)', variant: 'V2 doubleTap', ...dbl, intended: firstPair, editorHolds: running1, upNextAfter, liveStrip: inProgress, @@ -174,7 +177,7 @@ test.describe('knockout-mixed-individual', () => { row({ step: 'clear mark', action: 'tap a scored mark', variant: 'V2 doubleTap', ...dblClear, marksBefore: afterDbl, marksAfter: afterDblClear, shot: await shot(page, 'mark-double-tapped') }); // Put the bout back to one men for the winner. - while ((await filledSlots(page, w1).count()) > 1) await filledSlots(page, w1).first().tap(); + await clearMarks(filledSlots(page, w1), { keep: 1, from: 'first' }); if ((await filledSlots(page, w1).count()) === 0) { await settle(page); // the same reset race as below: let a removal land first await ipponButton(page, w1, 'M').tap(); @@ -188,18 +191,13 @@ test.describe('knockout-mixed-individual', () => { await ipponButton(page, other, 'K').tap(); const pillAtTap = (await syncState(page).innerText()).trim(); await interrupt(page, 'reload'); - await expect.poll(() => inlineRunning(page), { timeout: 15_000 }).toBe(true); - await page.waitForTimeout(1000); + await expect.poll(() => inlineRunning(page), { timeout: EXPECT_TIMEOUT_MS }).toBe(true); + await expect(syncState(page)).toHaveText('Synced'); const otherAfterFastReload = await slotMarks(page, other); row({ step: 'running bout', action: `${other} K then reload within 300ms`, variant: 'V3 interrupt reload (inside autosave debounce)', pillAtTap, marksAfterReload: otherAfterFastReload, shot: await shot(page, 'fast-reload-after-tap') }); // Whatever survived, go back to one men for the winner and nothing else. - for (const sd of [other, w1]) { - while ((await filledSlots(page, sd).count()) > 0) { - await filledSlots(page, sd).first().tap(); - await page.waitForTimeout(150); - } - } + for (const sd of [other, w1]) await clearMarks(filledSlots(page, sd), { from: 'first', pauseMs: 150 }); // Let the removals land before the M. A fresh-clone run lost this tap to // the removal's echo (bc-ltap, fixed in the same PR and guarded by its own // test below); this step only resets the bout, so it does not race. @@ -212,7 +210,7 @@ test.describe('knockout-mixed-individual', () => { // V3 interruptions mid-bout, and V5: same court, same match, marks kept? for (const kind of ['reload', 'hidden', 'back', 'offline']) { const r = await interrupt(page, kind); - const back = await expect.poll(() => inlineRunning(page), { timeout: 15_000 }).toBe(true).then(() => true, () => false); + const back = await expect.poll(() => inlineRunning(page), { timeout: EXPECT_TIMEOUT_MS }).toBe(true).then(() => true, () => false); const id = back ? await editorIdentity(page) : null; const marks = back ? await slotMarks(page, w1) : null; row({ step: 'running bout, one ippon', action: 'interruption', variant: `V3 interrupt ${kind}`, ...r, @@ -225,7 +223,9 @@ test.describe('knockout-mixed-individual', () => { const finish = finishButton(page); row({ step: 'finish', action: 'Finish + Start Next', variant: 'size', label: (await finish.innerText()).trim(), ...(await tapSize(finish)) }); const dblFinish = await doubleTap(finish); - await page.waitForTimeout(1500); + await expect(completedRowFor(page, running1).or(armedFinishButton(page))).toBeVisible(); + // A tap inside the arm's bounce window is ignored: out it, so the tap below counts. + await dwell(page, Date.now()); const finishedByDouble = await completedRowFor(page, running1).isVisible().catch(() => false); const armedLeft = await armedFinishButton(page).isVisible().catch(() => false); row({ step: 'finish', action: 'Finish + Start Next', variant: 'V2 doubleTap', ...dblFinish, @@ -239,7 +239,7 @@ test.describe('knockout-mixed-individual', () => { expect(r1.result).toMatch(/M/); // Finish + Start Next started the next bout on THIS court. - await expect.poll(() => inlineRunning(page), { timeout: 15_000 }).toBe(true); + await expect.poll(() => inlineRunning(page), { timeout: EXPECT_TIMEOUT_MS }).toBe(true); const id2 = await editorIdentity(page); const running2 = await editorSides(page); expect(id2.court).toBe('A'); @@ -256,14 +256,14 @@ test.describe('knockout-mixed-individual', () => { } catch (e) { nbFinish = { hit: null, note: String(e.message).slice(0, 120) }; } - await page.waitForTimeout(500); + await rendered(page); row({ step: 'finish', action: 'armed Finish', variant: 'V1 tapNeighbour left', hit: nbFinish.hit ? nbFinish.hit.label : null, note: nbFinish.note, stillArmed: await armedFinishButton(page).isVisible().catch(() => false), editorCourt: (await editorIdentity(page)).court, shot: await shot(page, 'finish-neighbour-tapped') }); if (!(await armedFinishButton(page).isVisible().catch(() => false))) await armFinish(page); const retap = await retapWhileSaving(armedFinishButton(page)); await expect(completedRowFor(page, running2)).toBeVisible(); - await expect.poll(() => inlineRunning(page), { timeout: 15_000 }).toBe(true); + await expect.poll(() => inlineRunning(page), { timeout: EXPECT_TIMEOUT_MS }).toBe(true); const running3 = await editorSides(page); const completedCount = await completedRows(page).count(); row({ step: 'finish', action: 'armed Finish', variant: 'V2 retapWhileSaving', ...retap, @@ -281,7 +281,7 @@ test.describe('knockout-mixed-individual', () => { await page.locator('.shiaijo-move-confirm[role="dialog"]').waitFor({ state: 'visible' }); const sendBackDialogShot = await shot(page, 'send-back-dialog'); const hasty = await hastyShiaijoConfirm(page); - await page.waitForTimeout(800); + await expect.poll(() => inlineRunning(page)).toBe(false); const idleAfter = await page.locator('.shiaijo__placeholder').isVisible().catch(() => false); const upNextAfterRevert = await sides(upNextCard(page)).catch(() => null); row({ step: 'send back', action: 'Send back to queue confirm', variant: 'V4 hastyConfirm', ...hasty, @@ -296,7 +296,7 @@ test.describe('knockout-mixed-individual', () => { // Play out court A's pool bouts but the last one. for (let i = 0; i < 8 && (await queuedPoolBouts(page)) > 0; i += 1) await playRunningBout(page); - await expect.poll(() => inlineRunning(page), { timeout: 15_000 }).toBe(true); + await expect.poll(() => inlineRunning(page), { timeout: EXPECT_TIMEOUT_MS }).toBe(true); expect((await editorIdentity(page)).eyebrow).toMatch(/POOL/); await shot(page, 'court-A-last-pool-bout-running'); @@ -321,7 +321,7 @@ test.describe('knockout-mixed-individual', () => { await expect(reason).toBeVisible(); await shot(page, 'correction-reason'); await reason.locator('button.btn--primary').tap(); - await page.waitForTimeout(800); + await expect(reason).toBeHidden(); const backToCourt = page.getByRole('button', { name: /Back to court/ }); const stillCorrecting = await backToCourt.isVisible().catch(() => false); row({ step: 'correct', action: 'Save correction (reason prompt, loud button)', variant: 'V4 hasty (inline prompt)', @@ -348,8 +348,9 @@ test.describe('knockout-mixed-individual', () => { const played = await playRunningBout(page); bOrder.push({ ...played, eyebrow: eyebrow || '(started from Up next)' }); // Did Finish + Start Next leave the court idle with pool bouts queued? - await page.waitForTimeout(1500); - if (!(await inlineRunning(page)) && (await queuedPoolBouts(page)) > 0) { + // Give it the time a start takes; an idle court is what the row records. + const restarted = await expect.poll(() => inlineRunning(page), { timeout: 1500 }).toBe(true).then(() => true, () => false); + if (!restarted && (await queuedPoolBouts(page)) > 0) { const toasts = (await page.locator('.toast, [role="status"]').allInnerTexts()).join(' | '); row({ step: 'court B pools', action: `Finish + Start Next after ${eyebrow || 'a bout'}`, variant: 'correct path', happened: 'court left idle although pool bouts wait in Up next', upNext: await sides(upNextCard(page)).catch(() => null), @@ -362,13 +363,14 @@ test.describe('knockout-mixed-individual', () => { // Back on A: the last pool bout, finished with Finish + Start Next. await openShiaijo(page, 'A'); - await expect.poll(() => inlineRunning(page), { timeout: 15_000 }).toBe(true); + await expect.poll(() => inlineRunning(page), { timeout: EXPECT_TIMEOUT_MS }).toBe(true); expect(await editorSides(page)).toEqual(lastPoolA); const labelAtBoundary = (await finishButton(page).innerText()).trim(); await ipponButton(page, winnerOf(lastPoolA), 'M').tap(); await expect(syncState(page)).toHaveText('Synced'); await finishMatch(page, INLINE_EDITOR); await expect(completedRowFor(page, lastPoolA)).toBeVisible(); + // Deliberate: a settle before an audit row only; nothing is asserted on it. await page.waitForTimeout(2500); const afterBoundary = { editorRunning: await inlineRunning(page), @@ -404,7 +406,7 @@ test.describe('knockout-mixed-individual', () => { const seenK = []; for (const key of ['ArrowRight', 'ArrowLeft', 'Escape']) { await page.keyboard.press(key); - await page.waitForTimeout(300); + await rendered(page); seenK.push({ key, eyebrow: (await editorIdentity(page).catch(() => ({ eyebrow: null }))).eyebrow, editor: await inlineEditor(page).isVisible().catch(() => false) }); } @@ -481,7 +483,7 @@ test.describe('knockout-mixed-individual', () => { // V1: the thumb meant Kiken - Voluntary and landed on its neighbour. const nbKiken = await tapNeighbour(decisionButton(page, 'kiken-voluntary'), 'right'); - await page.waitForTimeout(400); + await rendered(page); const promptOpen = await decisionPrompt(page).isVisible().catch(() => false); const popover = await page.locator('[role="tooltip"], .glossary-pop, .term-pop').first().isVisible().catch(() => false); row({ step: 'kiken', action: 'Kiken - Voluntary', variant: 'V1 tapNeighbour right', hit: nbKiken.hit.label, gapPx: nbKiken.gapPx, @@ -504,6 +506,8 @@ test.describe('knockout-mixed-individual', () => { const recordLabelBefore = (await record.innerText()).trim(); const hint = (await decisionPrompt(page).getByTestId('decision-prompt-hint').innerText()).trim(); await record.tap({ force: true }); + // Deliberate: the prompt must STAY open and nothing may be recorded, and + // an absence has no state to wait for, so the time is the evidence. await page.waitForTimeout(500); const stillOpen = await decisionPrompt(page).isVisible(); const recordedByHastyTap = await completedRowFor(page, b1).isVisible().catch(() => false); @@ -604,8 +608,10 @@ test.describe('knockout-mixed-individual', () => { ...undo, confirmShown, result: undoneResult, shot: await shot(page, 'kiken-undo-recorded') }); expect(confirmShown).toBe(false); const backToCourt = page.getByRole('button', { name: /Back to court/ }); - if (await backToCourt.isVisible().catch(() => false)) await backToCourt.tap(); - await page.waitForTimeout(1000); + if (await backToCourt.isVisible().catch(() => false)) { + await backToCourt.tap(); + await expect(backToCourt).toBeHidden(); + } const liveAfter = await inlineRunning(page) ? await editorSides(page) : null; row({ step: 'kiken undo', action: 'back to the court after the undo', variant: 'V5 lost place', running: liveAfter, runningHasNowIneligible: !!liveAfter && (liveAfter.shiro === b1.aka || liveAfter.aka === b1.aka), @@ -621,11 +627,14 @@ test.describe('knockout-mixed-individual', () => { // operator scores it for the other side and Finishes. const live = await editorSides(page); const opp = live.aka === b1.aka ? 'shiro' : 'aka'; + // A toast left by an earlier step must not stand for this Finish's answer: + // let it go, so what shows next is this tap's. + await expect(page.locator('.toast.is-visible')).toHaveCount(0); await ipponButton(page, opp, 'M').tap(); await settle(page); await (await armFinish(page)).tap(); - await page.waitForTimeout(1500); - const msg = (await page.locator('.toast, .pending-write-banner').allInnerTexts().catch(() => [])).join(' | '); + await expect(completedRowFor(page, live).or(page.locator('.toast.is-visible, .pending-write-banner').first())).toBeVisible(); + const msg = (await page.locator('.toast.is-visible, .pending-write-banner').allInnerTexts().catch(() => [])).join(' | '); const done = await completedRowFor(page, live).isVisible().catch(() => false); row({ step: 'kiken undo', action: 'Finish the running bout of the now-withdrawn competitor (M for the opponent)', variant: 'recovery attempt', pair: live, completed: done, message: msg.replace(/\s+/g, ' ').slice(0, 240), shot: await shot(page, 'finish-bout-of-withdrawn') }); @@ -638,15 +647,17 @@ test.describe('knockout-mixed-individual', () => { .filter({ hasText: /Match 1 of 3/ }).filter({ hasNotText: withdrawnName }).last(); const b2 = await sides(poolBCard); await poolBCard.getByRole('button', { name: 'Start match' }).tap(); - await expect.poll(() => inlineRunning(page), { timeout: 15_000 }).toBe(true); + await expect.poll(() => inlineRunning(page), { timeout: EXPECT_TIMEOUT_MS }).toBe(true); expect(await editorSides(page)).toEqual(b2); const id2 = await editorIdentity(page); await decisionButton(page, 'fusenpai').tap(); await expect(decisionPrompt(page)).toBeVisible(); await decisionPrompt(page).locator('input[name="decision-side"][value="aka"]').check(); const dblRecord = await doubleTap(decisionPrompt(page).getByRole('button', { name: 'Record' })); - await page.waitForTimeout(2000); - const dialogAfterDbl = await page.locator('.modal[role="dialog"]').isVisible().catch(() => false); + // Either a confirm asks first, or the decision is recorded. + const confirmDialog = page.locator('.modal[role="dialog"]'); + await expect(confirmDialog.first().or(completedRowFor(page, b2))).toBeVisible(); + const dialogAfterDbl = await confirmDialog.first().isVisible().catch(() => false); let dblDialog = null; if (dialogAfterDbl) { await shot(page, 'fusenpai-double-record-dialog'); @@ -716,8 +727,8 @@ test.describe('knockout-mixed-individual', () => { // Docs: "open the Overtime control and tick Encho started". The pill is // tapped where a thumb lands, its centre. await pill.tap(); - await page.waitForTimeout(500); const encho = inlineEditor(page).getByTestId('scoring-modal-encho-checkbox'); + await expect(encho).toBeVisible(); const openedByCentre = await encho.isVisible().catch(() => false); const enchoPopover = await page.getByText('Overtime, when a knockout match').isVisible().catch(() => false); row({ step: 'encho', action: 'tap the Overtime pill (centre)', variant: 'correct path', counterOpened: openedByCentre, @@ -822,7 +833,7 @@ test.describe('knockout-mixed-individual', () => { await login(page); await openShiaijo(page, 'C'); // Semi-final 1 (M1) online first unless J2 already played it. - await expect.poll(async () => (await inlineRunning(page)) || (await upNextCard(page).isVisible().catch(() => false)), { timeout: 15_000 }).toBe(true); + await expect.poll(async () => (await inlineRunning(page)) || (await upNextCard(page).isVisible().catch(() => false)), { timeout: EXPECT_TIMEOUT_MS }).toBe(true); let played = null; const liveNow = (await inlineRunning(page)) ? (await editorIdentity(page)).eyebrow : ''; const upNow = ((await page.locator('.shiaijo-upnext__time').allInnerTexts())[0] || ''); @@ -836,11 +847,11 @@ test.describe('knockout-mixed-individual', () => { } if (await page.locator('.shiaijo__placeholder').isVisible().catch(() => false)) return 'idle'; return 'waiting'; - }, { timeout: 15_000 }).not.toBe('waiting'); + }, { timeout: EXPECT_TIMEOUT_MS }).not.toBe('waiting'); if (!semi) { semi = await sides(upNextCard(page)); await upNextCard(page).getByRole('button', { name: 'Start match' }).tap(); - await expect.poll(async () => ((await inlineRunning(page)) ? JSON.stringify(await editorSides(page)) : ''), { timeout: 15_000 }) + await expect.poll(async () => ((await inlineRunning(page)) ? JSON.stringify(await editorSides(page)) : ''), { timeout: EXPECT_TIMEOUT_MS }) .toBe(JSON.stringify(semi)); } row({ step: 'finish offline', action: 'the last semi-final is running', variant: 'correct path', semi }); @@ -863,6 +874,9 @@ test.describe('knockout-mixed-individual', () => { }; row({ step: 'finish offline', action: 'the screen 300ms after Finish, offline', variant: 'V5 lost place', ...at300, shot: await shot(page, 'offline-finish-300ms') }); + // The operator watches the screen for the rest of two seconds with the + // network down: what it shows by then (a banner, or the court moved on) is + // what the row records, so the time is the scenario, not a wait for the app. await page.waitForTimeout(1700); const banner = inlineEditor(page).locator('.pending-write-banner'); const bannerText = (await banner.allInnerTexts()).join(' | '); @@ -884,6 +898,7 @@ test.describe('knockout-mixed-individual', () => { let retryRow = { present: await retry.isVisible().catch(() => false) }; if (retryRow.present) { retryRow = { ...retryRow, size: await tapSize(retry), ...(await doubleTap(retry)) }; + // Deliberate: a settle before an audit row only; nothing is asserted on it. await page.waitForTimeout(1500); retryRow.bannerAfter = (await banner.allInnerTexts()).join(' | '); } @@ -896,6 +911,7 @@ test.describe('knockout-mixed-individual', () => { let restart = null; if (upAgain && upAgain.shiro === semi.shiro && upAgain.aka === semi.aka) { await upNextCard(page).getByRole('button', { name: 'Start match' }).tap(); + // Deliberate: a settle before an audit row only; nothing is asserted on it. await page.waitForTimeout(1500); restart = { editor: await inlineEditor(page).isVisible().catch(() => false), running: await inlineRunning(page) ? await editorSides(page) : null, @@ -937,7 +953,7 @@ test.describe('knockout-mixed-individual', () => { // The server's bracket seats the semi-final's loser in the 3rd-place match. const bracketText = (await other.locator('.page').first().innerText()).replace(/\s+/g, ' '); await expect.poll(async () => ((await other.locator('.page').first().innerText()).replace(/\s+/g, ' ').split('3RD PLACE MATCH')[1] || ''), - { timeout: 15_000 }).toContain(loserName); + { timeout: EXPECT_TIMEOUT_MS }).toContain(loserName); row({ step: 'reconnect', action: 'second device reads the bracket', variant: 'correct path', finalHasWinner: bracketText.includes(winnerName), bronzeHasLoser: (bracketText.split('3RD PLACE MATCH')[1] || '').includes(loserName), shot: await shot(other, 'server-bracket-after-reconnect', { fullPage: true }) }); @@ -969,8 +985,7 @@ test.describe('knockout-mixed-individual', () => { await shot(page, 'pools-tab-editor-open'); const start = page.locator(EDITOR).getByRole('button', { name: 'Start match' }); const startOffered = await start.isVisible().catch(() => false); - if (startOffered) await start.tap(); - await page.waitForTimeout(1500); + if (startOffered) await startMatch(page); const openAfterStart = await page.locator(EDITOR).isVisible().catch(() => false); row({ step: 'pools tab', action: 'Start match in the Pools-tab editor', variant: 'correct path', row: rowText.slice(0, 80), startOffered, editorStillOpen: openAfterStart, shot: await shot(page, 'pools-tab-after-start') }); @@ -979,8 +994,9 @@ test.describe('knockout-mixed-individual', () => { await expect(page.locator(EDITOR)).toBeVisible(); } const w = winnerOf(pair); + const ipponSaved = nextMatchWrite(page); await ipponButton(page, w, 'M', EDITOR).tap(); - await page.waitForTimeout(1500); + await writeLanded(ipponSaved, 'the ippon save'); const openAfterIppon = await page.locator(EDITOR).isVisible().catch(() => false); row({ step: 'pools tab', action: 'award one ippon in the Pools-tab editor', variant: 'correct path', editorStillOpen: openAfterIppon, shot: await shot(page, 'pools-tab-after-ippon') }); @@ -992,13 +1008,13 @@ test.describe('knockout-mixed-individual', () => { } const finishLabel = (await finishButton(page, EDITOR).innerText()).trim(); await finishMatch(page, EDITOR); - await page.waitForTimeout(800); - const after = { + // Where the screen ends up once the list has refetched after the finish. + const after = await untilStable(async () => ({ editorOpen: await page.locator(EDITOR).isVisible().catch(() => false), url: new URL(page.url()).pathname, rowNow: (await firstRow.innerText()).replace(/\s+/g, ' ').slice(0, 120), toast: (await page.locator('.toast').allInnerTexts().catch(() => [])).join(' | '), - }; + })); row({ step: 'pools tab', action: 'Finish (Pools tab: no Start Next)', variant: 'V5 lost place', finishLabel, court: idP.court, ...after, shot: await shot(page, 'pools-tab-after-finish') }); expect(after.editorOpen).toBe(false); @@ -1010,16 +1026,19 @@ test.describe('knockout-mixed-individual', () => { await expect(page.locator(EDITOR)).toBeVisible(); const pair2 = await editorSides(page, EDITOR); if (await page.locator(EDITOR).getByRole('button', { name: 'Start match' }).isVisible().catch(() => false)) { - await page.locator(EDITOR).getByRole('button', { name: 'Start match' }).tap(); - await page.waitForTimeout(1000); + await startMatch(page); if (!(await page.locator(EDITOR).isVisible().catch(() => false))) await secondRow.tap(); } await ipponButton(page, winnerOf(pair2), 'M', EDITOR).tap(); // Close straight away (inside the autosave's 300ms), as a hasty operator would. const closedByTap = await page.locator(EDITOR).getByRole('button', { name: /✕ Close/ }).tap({ timeout: 2000 }).then(() => true, () => false); const discardAsked = await page.locator('.modal[role="dialog"] .modal__foot').isVisible().catch(() => false); + // `from`: the editor as it stands before the answer (still open behind the + // confirm), so the settle cannot agree with the page as it was before. + const editorBeforeAnswer = await page.locator(EDITOR).isVisible().catch(() => false); const hasty = discardAsked ? await hastyConfirm(page) : null; - await page.waitForTimeout(800); + // Whether the editor closed (and the unsaved ippon was kept) settles after the answer. + await untilStable(() => page.locator(EDITOR).isVisible().catch(() => false), { from: editorBeforeAnswer }); await secondRow.tap(); await expect(page.locator(EDITOR)).toBeVisible(); const kept = await slotMarks(page, winnerOf(pair2), EDITOR); @@ -1042,7 +1061,7 @@ test.describe('knockout-mixed-individual', () => { await ipponButton(page, winnerOf(bpair), 'M').tap(); await settle(page); await finishMatch(page, INLINE_EDITOR); - await page.waitForTimeout(1000); + await expect(panel.locator('.running-panel__result')).toBeVisible(); const bAfter = { resultCard: await panel.locator('.running-panel__result').isVisible().catch(() => false), editRes: await panel.getByRole('button', { name: 'Edit result' }).isVisible().catch(() => false), @@ -1056,9 +1075,11 @@ test.describe('knockout-mixed-individual', () => { row({ step: 'bracket tab', action: 'Edit result', variant: 'size', ...(await tapSize(panel.getByRole('button', { name: 'Edit result' }))) }); await panel.getByRole('button', { name: 'Edit result' }).tap(); const editDialog = await hastyConfirm(page); - await page.waitForTimeout(500); + // The reopen is a server write: the editor is read once it has left the + // result card's state (not shown), or settled on not shown if it never did. + const editorReopened = await untilStable(() => panel.locator(INLINE_EDITOR).isVisible().catch(() => false), { from: false }); row({ step: 'bracket tab', action: 'Edit result confirm', variant: 'V4 hastyConfirm', ...editDialog, - editorReopened: await panel.locator(INLINE_EDITOR).isVisible().catch(() => false), + editorReopened, shot: await shot(page, 'bracket-tab-edit-result') }); const closeEdit = panel.getByRole('button', { name: /✕ Close|^Cancel$/ }).first(); if (await closeEdit.isVisible().catch(() => false)) await closeEdit.tap(); @@ -1075,9 +1096,15 @@ test.describe('knockout-mixed-individual', () => { const bpair2 = await editorSides(page); await ipponButton(page, winnerOf(bpair2), 'M').tap(); await settle(page); + const readBracket = async () => (await page.locator('.page').first().innerText()).replace(/\s+/g, ' '); await finishMatch(page, INLINE_EDITOR); - await page.waitForTimeout(1000); - const bracketText = (await page.locator('.page').first().innerText()).replace(/\s+/g, ' '); + // The bracket redraws once the finish has refetched. Both semi-finals are + // played now, so the final is seated (no "Winner of ..." left): that is + // the state the absence below is read against, not the tree from before + // the finish, which has no 3rd place match either. Then the text it + // settles on. + await expect.poll(readBracket, { message: 'the final is seated once both semi-finals are played' }).not.toMatch(/Winner of /); + const bracketText = await untilStable(readBracket); row({ step: 'bracket tab', action: 'second semi-final, default joint 3rd places', variant: 'correct path', pair: bpair2, bronzeMatchShown: /3RD PLACE MATCH/i.test(bracketText), shot: await shot(page, 'bracket-tab-semis-done', { fullPage: true }) }); expect(bracketText).not.toMatch(/3RD PLACE MATCH/i); @@ -1186,11 +1213,9 @@ test.describe('knockout-mixed-individual bead guards', () => { await first.tap(); await expect(page.locator(EDITOR)).toBeVisible(); const pair = await editorSides(page, EDITOR); - await page.locator(EDITOR).getByRole('button', { name: 'Start match' }).tap(); - await page.waitForTimeout(1500); + await startMatch(page); await expect(page.locator(EDITOR)).toBeVisible(); - await ipponButton(page, winnerOf(pair), 'M', EDITOR).tap(); - await page.waitForTimeout(1500); + await awardIppon(page, winnerOf(pair), 'M', EDITOR); await expect(page.locator(EDITOR)).toBeVisible(); }); test('bc-sbq: Send back to queue says the score is kept, and starting the bout again carries it on', async ({ page }) => { @@ -1246,7 +1271,6 @@ test.describe('knockout-mixed-individual bead guards', () => { await context.setOffline(true); try { await (await armFinish(page)).tap(); - await page.waitForTimeout(1000); await expect(page.getByText('Offline: 1 result not sent')).toBeVisible(); } finally { await context.setOffline(false); @@ -1266,58 +1290,50 @@ test.describe('knockout-mixed-individual bead guards', () => { await seedOwn(page, { name: 'Finding A9', format: 'knockout', courts: ['O'], numberPrefix: 'FI' }, rosterOf('Anine', 4)); await openShiaijo(page, 'O'); expect(await ensureRunning(page), 'a semi-final starts on O').not.toBeNull(); - let hold = null; // { release } while the next score write is to be held - const holdNextWrite = () => { - const h = {}; - h.promise = new Promise((resolve) => { h.release = resolve; }); - hold = h; - return h; - }; - await page.route('**/api/competitions/*/matches/*/score', async (route) => { - if (route.request().method() === 'PUT' && hold) { - const { promise } = hold; - hold = null; - await Promise.race([promise, new Promise((r) => setTimeout(r, 10_000))]); - } - await route.continue(); - }); - const scoreSent = () => page.waitForRequest((r) => r.method() === 'PUT' && /\/score$/.test(r.url())); - const akaMark = () => inlineEditor(page).locator('[aria-label^="Aka slot "][aria-label*=": remove "]').first(); - const marksAfterReload = async () => { - await page.reload(); - await expect.poll(() => inlineRunning(page), { timeout: 15_000 }).toBe(true); - return filledSlots(page, 'aka').count(); - }; + const writes = await holdScoreWrites(page); + try { + const akaMark = () => inlineEditor(page).locator('[aria-label^="Aka slot "][aria-label*=": remove "]').first(); + const marksAfterReload = async () => { + await page.reload(); + await expect.poll(() => inlineRunning(page), { timeout: EXPECT_TIMEOUT_MS }).toBe(true); + return filledSlots(page, 'aka').count(); + }; - await ipponButton(page, 'aka', 'M').tap(); - await settle(page); - // Remove, then the same mark back while the removal is out. - let held = holdNextWrite(); - let sent = scoreSent(); - await akaMark().tap(); - await sent; - await ipponButton(page, 'aka', 'M').tap(); - held.release(); - await settle(page); - await page.waitForTimeout(1000); - await expect(filledSlots(page, 'aka'), 'the tapped-back mark stays').toHaveCount(1); - await shot(page, 'tapped-back-mark-kept'); - expect(await marksAfterReload(), 'and the server holds it').toBe(1); - await shot(page, 'tapped-back-mark-after-reload'); + await ipponButton(page, 'aka', 'M').tap(); + await settle(page); + // Remove, then the same mark back while the removal is out. + let held = writes.holdNext(); + await akaMark().tap(); + await held.sent; + await ipponButton(page, 'aka', 'M').tap(); + held.release(); + // The removal's response reached the page before the tap-back's own save went out. + await writes.answeredBeforeNextWrite(held); + await settle(page); + // Deliberate: a late echo that undoes the tap-back must be given the time to land, so the time is the evidence. + await page.waitForTimeout(1000); + await expect(filledSlots(page, 'aka'), 'the tapped-back mark stays').toHaveCount(1); + await shot(page, 'tapped-back-mark-kept'); + expect(await marksAfterReload(), 'and the server holds it').toBe(1); + await shot(page, 'tapped-back-mark-after-reload'); - // The reverse: take it off, add it, and take it off again while the add is out. - await akaMark().tap(); - await settle(page); - held = holdNextWrite(); - sent = scoreSent(); - await ipponButton(page, 'aka', 'M').tap(); - await sent; - await akaMark().tap(); - held.release(); - await settle(page); - await page.waitForTimeout(1000); - await expect(filledSlots(page, 'aka'), 'the removal stays').toHaveCount(0); - expect(await marksAfterReload(), 'and the server holds no mark').toBe(0); - await page.unroute('**/api/competitions/*/matches/*/score'); + // The reverse: take it off, add it, and take it off again while the add is out. + await akaMark().tap(); + await settle(page); + held = writes.holdNext(); + await ipponButton(page, 'aka', 'M').tap(); + await held.sent; + await akaMark().tap(); + held.release(); + await writes.answeredBeforeNextWrite(held); + await settle(page); + // Deliberate: a late echo that undoes the tap-back must be given the time to land, so the time is the evidence. + await page.waitForTimeout(1000); + await expect(filledSlots(page, 'aka'), 'the removal stays').toHaveCount(0); + expect(await marksAfterReload(), 'and the server holds no mark').toBe(0); + } finally { + await writes.stop(); + } + writes.assertNoSafetyRelease(); // after the body succeeded: a throw in the finally would hide its error }); }); diff --git a/scripts/e2e/journeys/knockout-mixed-kachinuki.spec.mjs b/scripts/e2e/journeys/knockout-mixed-kachinuki.spec.mjs index dc72eb446..894f6d21b 100644 --- a/scripts/e2e/journeys/knockout-mixed-kachinuki.spec.mjs +++ b/scripts/e2e/journeys/knockout-mixed-kachinuki.spec.mjs @@ -38,11 +38,13 @@ import { createTournament, login } from '../fixtures/setup.mjs'; import { createCompetition } from '../fixtures/wizard.mjs'; import { generateDraw, pasteRoster, startCompetition } from '../fixtures/competition.mjs'; import { completedRowFor, openShiaijo, sides, upNextCard } from '../fixtures/shiaijo.mjs'; -import { PUBLIC_DEVICE } from '../fixtures/devices.mjs'; +import { PUBLIC_DEVICE, EXPECT_TIMEOUT_MS } from '../fixtures/devices.mjs'; import { openViewer, recentResult } from '../fixtures/public.mjs'; import { scoreRows } from '../fixtures/scores.mjs'; +import { nextMatchWrite, writeLanded } from '../fixtures/scoring.mjs'; import { EDITOR } from '../../screenshots/lib/editor.mjs'; -import { dwell, settled } from '../fixtures/pace.mjs'; +import { dwell, rendered, settled, tapOnSide, untilStable } from '../fixtures/pace.mjs'; +import { ensureCoarse } from '../fixtures/pointer.mjs'; // clumsy.hastyConfirm answers confirmDialog (ui.jsx) and hastyShiaijoConfirm // the shiaijo page's own `.shiaijo-move-confirm`. The inline reason prompt and // the inline court-busy panel are neither; the local helpers at the end of @@ -52,7 +54,7 @@ import { } from '../fixtures/clumsy.mjs'; import { closeNameList } from '../fixtures/team.mjs'; import { - awardBoutIppon, boutFilled, ensureCoarse, boutIpponButton, boutMiddle, boutNames, closeLineupPanel, correctingBout, + awardBoutIppon, boutFilled, clearBoutMarks, boutIpponButton, boutMiddle, boutNames, closeLineupPanel, correctingBout, currentBout, doneRowWinner, doneRows, editor, enchoButton, enchoUndoButton, endMatch, endMatchButton, lineupSide, lineupSource, openLineupFromUpNext, recordBout, recordBoutButton, removeBoutButton, reopenButton, syncPill, tieButton, typeLineup, winBout, @@ -104,18 +106,6 @@ test.describe('J5 kachinuki from the court console', () => { // (In a serial file a failing test skips the rest, and a restarted worker // loses the shared state, so a guard of its own could not stand before them.) const guardFailures = []; - const MAX_CLEAR_TAPS = 6; - // Taps a side's filled marks off, at most MAX_CLEAR_TAPS times: a mark that - // does not go away fails with a message instead of looping until the timeout. - async function clearBoutMarks(live, side) { - for (let i = 0; i < MAX_CLEAR_TAPS; i += 1) { - if ((await boutFilled(live, side).count()) === 0) return; - await boutFilled(live, side).last().tap(); - } - if ((await boutFilled(live, side).count()) > 0) { - throw new Error(`the ${side} marks did not clear after ${MAX_CLEAR_TAPS} taps`); - } - } async function guarded(page, title, fn) { try { await test.step(title, () => fn(page)); @@ -279,7 +269,7 @@ test.describe('J5 kachinuki from the court console', () => { // The first tap saves; the second finds the button busy or, once saved, off // (nothing is left to save). Saved: the side shows the lineup as this match's own. await expect(lineupSource(lineupSide(page, pair.shiro))).toHaveText('Lineup for this match'); - await page.waitForTimeout(800); + await rendered(page); record({ step: 'M2 lineup', action: 'Save lineup', variant: 'V2 doubleTap', ...v2, toasts: await page.locator('.toast, [role="status"]').allInnerTexts().catch(() => []), saved: await lineupSide(page, pair.shiro).locator('input.pmf__input').evaluateAll((els) => els.map((e) => e.value)), @@ -339,29 +329,25 @@ test.describe('J5 kachinuki from the court console', () => { // row moves, so the second tap can land on a name box instead and open its list. const mBefore = await boutIpponButton(row, 'shiro', 'M').boundingBox(); const v2 = await doubleTap(boutIpponButton(row, 'shiro', 'M')); - await page.waitForTimeout(300); + await expect(boutFilled(currentBout(page), 'shiro')).toHaveCount(1); const mAfter = await boutIpponButton(currentBout(page), 'shiro', 'M').boundingBox(); const marks = await boutFilled(currentBout(page), 'shiro').allInnerTexts(); const v2Shot = await shot(page, 'v2-ippon-double-tap'); // Recover: close a fighter list the second tap opened, then tap the second // mark if there is one (the editor's "Tap a scored mark to clear it"). const fighterListOpened = await closeNameList(page); - let recoverTaps = 0; - while ((await boutFilled(currentBout(page), 'shiro').count()) > 1) { - await boutFilled(currentBout(page), 'shiro').last().tap(); - recoverTaps += 1; - } + const recoverTaps = await clearBoutMarks(currentBout(page), 'shiro', { keep: 1 }); record({ step: 'M1 bout 1', action: 'Shiro M', variant: 'V2 doubleTap', ...v2, marksAfter: marks, fighterListOpened, mMovedByPx: mBefore && mAfter ? Math.round(mAfter.y - mBefore.y) : null, recoverTaps, hintShown: await editor(page).getByTestId('team-scoring-clear-hint').isVisible(), screenshot: v2Shot }); // V1: the thumb lands on the button beside M. const v1 = await tapNeighbour(boutIpponButton(currentBout(page), 'shiro', 'M'), 'right'); - await page.waitForTimeout(300); + await rendered(page); const after = await boutFilled(currentBout(page), 'shiro').allInnerTexts(); record({ step: 'M1 bout 1', action: 'Shiro M (second point)', variant: 'V1 tapNeighbour right', ...v1, marksAfter: after, screenshot: await shot(page, 'v1-ippon-neighbour') }); // Put the board where the operator meant it: M K for Shiro. - while ((await boutFilled(currentBout(page), 'shiro').count()) > 1) await boutFilled(currentBout(page), 'shiro').last().tap(); + await clearBoutMarks(currentBout(page), 'shiro', { keep: 1 }); if ((await boutFilled(currentBout(page), 'shiro').count()) === 0) await awardBoutIppon(page, 'shiro', 'M'); await awardBoutIppon(page, 'shiro', 'K'); // V3: the iPad reloads the tab before Record bout was tapped. The wait @@ -378,7 +364,8 @@ test.describe('J5 kachinuki from the court console', () => { screenshot: await shot(page, 'v3-reload-bout-1') }); // V2 on Record bout: a re-tap while it reads "Saving…". const retap = await retapWhileSaving(recordBoutButton(page)); - await page.waitForTimeout(800); + await expect(doneRows(page)).not.toHaveCount(0); + await expect(recordBoutButton(page)).not.toHaveText('Saving…'); record({ step: 'M1 bout 1', action: 'Record bout', variant: 'V2 retapWhileSaving', ...retap, doneRows: await doneRows(page).count(), current: await boutNames(currentBout(page)), screenshot: await shot(page, 'v2-record-retap') }); @@ -390,7 +377,7 @@ test.describe('J5 kachinuki from the court console', () => { await awardBoutIppon(page, 'aka', 'M'); await awardBoutIppon(page, 'aka', 'D'); const v1 = await tapNeighbour(recordBoutButton(page), 'right'); - await page.waitForTimeout(300); + await rendered(page); const endLabel = (await endMatchButton(page).innerText()).trim(); record({ step: 'M1 bout 2', action: 'Record bout', variant: 'V1 tapNeighbour right', ...v1, endLabel, screenshot: await shot(page, 'v1-record-neighbour-end-armed') }); @@ -406,7 +393,8 @@ test.describe('J5 kachinuki from the court console', () => { await expect(remove).toBeVisible(); const removeBox = await remove.boundingBox(); const v2 = await doubleTap(remove); - await page.waitForTimeout(1200); + // The appended pairing is gone once the removal has been answered. + await expect(remove).toBeHidden(); const afterRemove = { doneRows: await doneRows(page).count(), current: await boutNames(currentBout(page)), currentShiroMarks: await boutFilled(currentBout(page), 'shiro').count(), currentAkaMarks: await boutFilled(currentBout(page), 'aka').count() }; const removeShot = await shot(page, 'v2-remove-bout-double-tap'); @@ -417,15 +405,17 @@ test.describe('J5 kachinuki from the court console', () => { await expectPairing(3, S[1], A[1]); const tie = tieButton(currentBout(page)); + // `from`: the label before the taps, so the settle cannot agree with the + // page as it was before they were handled. + const tieBefore = await tie.innerText(); const v2tie = await doubleTap(tie); - await page.waitForTimeout(300); - const tieLabel = (await tie.innerText()).trim(); + const tieLabel = (await untilStable(() => tie.innerText(), { from: tieBefore })).trim(); record({ step: 'M1 bout 3', action: 'Tie (hikiwake)', variant: 'V2 doubleTap', ...v2tie, tieLabel, middle: await boutMiddle(currentBout(page)), screenshot: await shot(page, 'v2-tie-double-tap') }); if (!/✓/.test(tieLabel)) await tie.tap(); await expect(tie).toHaveText(/✓ Tie/); const v1tie = await tapNeighbour(tie, 'up'); - await page.waitForTimeout(300); + await rendered(page); record({ step: 'M1 bout 3', action: 'Tie (hikiwake)', variant: 'V1 tapNeighbour up', ...v1tie, tieStill: (await tie.innerText()).trim(), middle: await boutMiddle(currentBout(page)), screenshot: await shot(page, 'v1-tie-neighbour') }); @@ -441,12 +431,12 @@ test.describe('J5 kachinuki from the court console', () => { await tieButton(currentBout(page)).tap(); await expect(enchoButton(page)).toBeVisible(); await shot(page, 'bout-4-tied-encho-offered'); + const overtimeBefore = await enchoUndoButton(page).isVisible(); const v2 = await doubleTap(enchoButton(page)); - await page.waitForTimeout(300); const eyebrow = (await editor(page).locator('.editor-modal__eyebrow').first().innerText()).trim(); // Undo encho appears beside Encho with the first tap and the row re-centres, so // the second tap can land on it and take the period straight back. - const inOvertime = await enchoUndoButton(page).isVisible(); + const inOvertime = await untilStable(() => enchoUndoButton(page).isVisible(), { from: overtimeBefore }); record({ step: 'M1 bout 4', action: 'Encho', variant: 'V2 doubleTap', ...v2, eyebrow, inOvertimeAfterDoubleTap: inOvertime, enchoStillOffered: await enchoButton(page).isVisible(), middle: await boutMiddle(currentBout(page)), screenshot: await shot(page, 'v2-encho-double-tap') }); @@ -488,7 +478,7 @@ test.describe('J5 kachinuki from the court console', () => { const offline = await interruptC(page, 'offline', { during: async (p) => { await boutIpponButton(currentBout(p), 'shiro', 'M').tap(); - await p.waitForTimeout(1500); + await expect(syncPill(p)).not.toHaveText('Synced'); whileOffline = (await syncPill(p).innerText().catch(() => '?')).trim(); }, }); @@ -561,9 +551,13 @@ test.describe('J5 kachinuki from the court console', () => { shiro: await editor(page).locator('.team-sub-match').nth(i).locator('.tsm-center-pts--shiro .editor-side__pt--filled').allInnerTexts(), aka: await editor(page).locator('.team-sub-match').nth(i).locator('.tsm-center-pts--aka .editor-side__pt--filled').allInnerTexts(), }); + // Opening or closing a correction flushes the live bout's save first, so + // each tap below is read against the settled state before it (`from`): a + // read straight after the tap can still be the pre-tap page. + const openBeforeV1 = await untilStable(() => correctingBout(page).count()); // V1: the thumb meant bout 5 and landed on the row below it. const v1 = await tapNeighbour(row5, 'down'); - await page.waitForTimeout(400); + await untilStable(() => correctingBout(page).count(), { from: openBeforeV1 }); const openedWrong = await correctingBout(page).count() ? await boutNames(correctingBout(page)) : null; const v1Shot = await shot(page, 'v1-fought-row-neighbour'); let v1Recover = 0; @@ -576,8 +570,9 @@ test.describe('J5 kachinuki from the court console', () => { // V2: two taps inside 300ms on the fought row. The first expands it; where // does the second land? const before = await marksOf(4); + const openBeforeV2 = await untilStable(() => correctingBout(page).count()); const v2 = await doubleTap(doneRows(page).nth(4)); - await page.waitForTimeout(600); + await untilStable(() => correctingBout(page).count(), { from: openBeforeV2 }); // Which bout did the double tap leave open? const openCaret = editor(page).locator('[data-testid^="kachinuki-done-collapse-"]'); const openIdx = await openCaret.count() @@ -611,21 +606,35 @@ test.describe('J5 kachinuki from the court console', () => { screenshot: await shot(page, 'bout-5-and-live-bout-both-editable', { fullPage: true }) }); // V3: the Back button while bout 5 is open for correction. await expect(syncPill(page)).toHaveText('Synced'); + // The sheet as the correction leaves it: whether the correction is open, + // the bouts fought and bout 5's marks. A Back that reloads the court's + // data changes one of them unless the correction survives it. + const sheetNow = async () => ({ + open: await correctingBout(page).count(), done: await doneRows(page).count(), marks: await marksOf(4).catch(() => null), + }); + const sheetBeforeBack = await sheetNow(); const back = await interruptC(page, 'back'); await expect(editor(page)).toBeVisible(); - await page.waitForTimeout(500); + // Whether the correction survives is what the row records: wait for the + // sheet to leave its pre-Back state and stop changing (a correction that + // survives changes nothing, which costs the helper's wait for a change). + await untilStable(sheetNow, { from: sheetBeforeBack }); record({ step: 'M1 correction', action: 'bout 5 open for correction', variant: 'V3 interrupt back', ...back, stillOpen: await correctingBout(page).count(), bout5: await boutNames(doneRows(page).nth(4)).catch(() => null), bout5Marks: await marksOf(4), live: await boutNames(currentBout(page)), screenshot: await shot(page, 'v3-back-mid-correction') }); if (!(await correctingBout(page).count())) await doneRows(page).nth(4).tap(); // V2 on the collapse triangle. const caretBtn = editor(page).getByTestId('kachinuki-done-collapse-4'); + const openBeforeV2c = await untilStable(() => correctingBout(page).count()); const v2c = await doubleTap(caretBtn); - await page.waitForTimeout(400); - const reopened = await correctingBout(page).count(); + const reopened = await untilStable(() => correctingBout(page).count(), { from: openBeforeV2c }); + const openedByV2c = reopened + ? await editor(page).locator('[data-testid^="kachinuki-done-collapse-"]').first().getAttribute('data-testid') : null; record({ step: 'M1 correction', action: 'collapse triangle', variant: 'V2 doubleTap', ...v2c, - openAfter: reopened, screenshot: await shot(page, 'v2-collapse-double-tap') }); - if (reopened) await editor(page).getByTestId('kachinuki-done-collapse-4').tap(); + openAfter: reopened, openedByV2c, screenshot: await shot(page, 'v2-collapse-double-tap') }); + // Collapse whatever the double tap left open: the second tap may have + // landed on another bout's row once the first had collapsed bout 5. + if (reopened) await editor(page).locator('[data-testid^="kachinuki-done-collapse-"]').first().tap(); await expect(correctingBout(page)).toHaveCount(0); await expect(syncPill(page)).toHaveText('Synced'); expect(await boutFilled(doneRows(page).nth(4), 'shiro').allInnerTexts()).toEqual(['M', 'K']); @@ -639,18 +648,19 @@ test.describe('J5 kachinuki from the court console', () => { const corr = correctingBout(page); await expect(corr).toBeVisible(); // Clear Shiro's two marks, give Aka two: bout 1 now reads as Aka's. - while ((await boutFilled(corr, 'shiro').count()) > 0) await boutFilled(corr, 'shiro').last().tap(); - await boutIpponButton(corr, 'aka', 'M').tap(); - await boutIpponButton(corr, 'aka', 'M').tap(); + await clearBoutMarks(corr, 'shiro'); + // Two strikes for one side: the second waits out the first's bounce window. + await tapOnSide(page, 'correction bout 1 aka', boutIpponButton(corr, 'aka', 'M')); + await tapOnSide(page, 'correction bout 1 aka', boutIpponButton(corr, 'aka', 'M')); const warning = editor(page).getByTestId('kachinuki-done-edit-warn'); const warned = await warning.isVisible(); const warningText = warned ? (await warning.innerText()).trim() : ''; const flipShot = await shot(page, 'bout-1-winner-flipped-warning'); const laterUnchanged = await boutNames(currentBout(page)); // Put it back as it was fought. - while ((await boutFilled(corr, 'aka').count()) > 0) await boutFilled(corr, 'aka').last().tap(); - await boutIpponButton(corr, 'shiro', 'M').tap(); - await boutIpponButton(corr, 'shiro', 'K').tap(); + await clearBoutMarks(corr, 'aka'); + await tapOnSide(page, 'correction bout 1 shiro', boutIpponButton(corr, 'shiro', 'M')); + await tapOnSide(page, 'correction bout 1 shiro', boutIpponButton(corr, 'shiro', 'K')); await editor(page).getByTestId('kachinuki-done-collapse-0').tap(); await expect(syncPill(page)).toHaveText('Synced'); record({ step: 'M1 correction', action: 'flip bout 1 winner by correction, then restore', variant: 'correct path', @@ -672,20 +682,16 @@ test.describe('J5 kachinuki from the court console', () => { const fus = currentBout(page).locator('.team-sub-match__side--shiro').getByTestId('scoring-modal-fusensho-button'); // V1: the thumb meant Fusensho and landed on the button beside it. const v1 = await tapNeighbour(fus, 'left'); - await page.waitForTimeout(300); + await rendered(page); const marks = await boutFilled(currentBout(page), 'shiro').allInnerTexts(); const v1Shot = await shot(page, 'v1-fusensho-neighbour'); - let recoverTaps = 0; - while ((await boutFilled(currentBout(page), 'shiro').count()) > 0) { - await boutFilled(currentBout(page), 'shiro').last().tap(); - recoverTaps += 1; - } + const recoverTaps = await clearBoutMarks(currentBout(page), 'shiro'); record({ step: 'M1 bout 8', action: 'Fusensho (Shiro)', variant: 'V1 tapNeighbour left', ...v1, shiroMarks: marks, recoverTaps, screenshot: v1Shot }); // V2: Fusensho double-tapped (it is a toggle). + const fusBefore = await fus.innerText(); const v2 = await doubleTap(fus); - await page.waitForTimeout(300); - const label = (await fus.innerText()).trim(); + const label = (await untilStable(() => fus.innerText(), { from: fusBefore })).trim(); record({ step: 'M1 bout 8', action: 'Fusensho (Shiro)', variant: 'V2 doubleTap', ...v2, labelAfter: label, shiroMarks: await boutFilled(currentBout(page), 'shiro').allInnerTexts(), screenshot: await shot(page, 'v2-fusensho-double-tap') }); if (!/✓/.test(label)) await fus.tap(); @@ -706,16 +712,21 @@ test.describe('J5 kachinuki from the court console', () => { const row = completedRowFor(page, m1); await expect(row).toBeVisible(); // Shiro won bouts 1, 5, 6 and 8 (the walkover's two maru), Aka 2 and 4: - // IV 4-2, PW 7-3 (bout 5's double tap scores one M since bc-dtip; before - // it the bounce added a second). Does the court's own Completed record say so? - const want = /IV 4–2\s+PW 7–3/; + // IV 4-2, PW 8-3: bout 1 M K, bout 5 M K (corrected from M M), bout 6 M K + // and the walkover's two maru for Shiro, bout 2 D M and bout 4 K for Aka + // (bout 5's double tap scores one M since bc-dtip; before it the bounce added + // a second). Bout 1 is put back to M K by the correction step above, each + // strike paced past its side's bounce window; an unpaced pair left it at M + // (the second strike swallowed) and this line read 7-3. Does the court's + // own Completed record say so? + const want = /IV 4–2\s+PW 8–3/; const result = row.locator('.shiaijo-qrow__result'); const settledWithoutRefresh = await expect(result).toHaveText(want, { timeout: 6000 }).then(() => true, () => false); const shownAtFirst = (await result.innerText()).replace(/\s+/g, ' '); const staleShot = await shot(page, 'm1-completed'); await page.getByRole('button', { name: 'Refresh' }).tap(); - await page.waitForTimeout(1500); - record({ step: 'M1 end', action: 'Completed row after End match', variant: 'result', expected: 'IV 4–2 PW 7–3', + await expect(completedRowFor(page, m1).locator('.shiaijo-qrow__result')).toHaveText(want); + record({ step: 'M1 end', action: 'Completed row after End match', variant: 'result', expected: 'IV 4–2 PW 8–3', shownFor6s: shownAtFirst, settledWithoutRefresh, afterRefresh: (await completedRowFor(page, m1).locator('.shiaijo-qrow__result').innerText()).replace(/\s+/g, ' '), screenshot: staleShot }); @@ -738,7 +749,7 @@ test.describe('J5 kachinuki from the court console', () => { // V1: is there anything beside Correct for the thumb to land on? const v1 = await tapNeighbour(completedRowFor(page, m1).locator('.shiaijo-row__correct'), 'left') .catch((e) => ({ none: e.message.split('\n')[0] })); - await page.waitForTimeout(300); + await rendered(page); const cb = await completedRowFor(page, m1).locator('.shiaijo-row__correct').boundingBox(); record({ step: 'M1 reopen', action: 'Correct (Completed row)', variant: 'V1 tapNeighbour left', ...v1, correctBox: cb && `${Math.round(cb.width)}x${Math.round(cb.height)}`, screenshot: await shot(page, 'v1-correct-neighbour') }); @@ -756,7 +767,7 @@ test.describe('J5 kachinuki from the court console', () => { const btn = boutIpponButton(firstRow, 'aka', 'M'); const enabled = await btn.isEnabled().catch(() => false); if (enabled) await btn.tap(); - await page.waitForTimeout(400); + await rendered(page); record({ step: 'M1 reopen', action: 'tap an ippon on the completed sheet', variant: 'V1 stray tap', buttonEnabled: enabled, akaMarksBefore: before, akaMarksAfter: await boutFilled(firstRow, 'aka').count(), saveButton: await editor(page).getByRole('button', { name: /Save correction/ }).count(), @@ -767,8 +778,8 @@ test.describe('J5 kachinuki from the court console', () => { const reopen = reopenButton(page); const box = await reopen.boundingBox(); const v2 = await doubleTap(reopen); - await expect(recordBoutButton(page)).toBeVisible({ timeout: 15_000 }); - await page.waitForTimeout(500); + await expect(recordBoutButton(page)).toBeVisible({ timeout: EXPECT_TIMEOUT_MS }); + await rendered(page); record({ step: 'M1 reopen', action: 'Reopen match', variant: 'V2 doubleTap', ...v2, reopenBox: box, error: (await page.getByTestId('kachinuki-reopen-error').innerText().catch(() => '')).trim(), doneRows: await doneRows(page).count(), current: await boutNames(currentBout(page)), @@ -777,7 +788,7 @@ test.describe('J5 kachinuki from the court console', () => { await test.step('reload while reopened: back on the same encounter?', async () => { const reload = await interruptC(page, 'reload'); - const back = await editor(page).waitFor({ state: 'visible', timeout: 15_000 }).then(() => true, () => false); + const back = await editor(page).waitFor({ state: 'visible', timeout: EXPECT_TIMEOUT_MS }).then(() => true, () => false); record({ step: 'M1 reopen', action: 'reopened encounter', variant: 'V3 interrupt reload', ...reload, editorBack: back, eyebrow: back ? (await editor(page).locator('.editor-modal__eyebrow').first().innerText()).trim() : null, doneRows: back ? await doneRows(page).count() : null, current: back ? await boutNames(currentBout(page)) : null, @@ -791,7 +802,7 @@ test.describe('J5 kachinuki from the court console', () => { const wasFusensho = /✓/.test(await fus.innerText()); if (wasFusensho) await fus.tap(); // re-tap undoes the walkover await expect(fus).toHaveText(/^Fusensho$/); - while ((await boutFilled(currentBout(page), 'shiro').count()) > 0) await boutFilled(currentBout(page), 'shiro').last().tap(); + await clearBoutMarks(currentBout(page), 'shiro'); await awardBoutIppon(page, 'shiro', 'M'); return wasFusensho; }; @@ -804,8 +815,7 @@ test.describe('J5 kachinuki from the court console', () => { await expect(syncPill(page)).toHaveText('Synced'); record({ step: 'M1 reopen', action: 'undo fusensho, award M on bout 8', variant: 'correct path', wasFusensho, pill: (await syncPill(page).innerText()).trim(), screenshot: await shot(page, 'm1-bout-8-rescored') }); - // V3: two seconds later (well past the autosave's 300ms), the tab reloads. - await page.waitForTimeout(2000); + // V3: the edit has landed (Synced above), then the tab reloads. const reload = await interruptC(page, 'reload'); await expect(recordBoutButton(page)).toBeVisible(); const kept = await bout8Marks(); @@ -857,8 +867,8 @@ test.describe('J5 kachinuki from the court console', () => { const immediately = (await row.locator('.shiaijo-qrow__result').innerText()).replace(/\s+/g, ' '); const immediateShot = await shot(page, 'm1-ended-again'); // Bout 8 is now a men (1 point) instead of the walkover (2 maru). - await expect(row.locator('.shiaijo-qrow__result')).toHaveText(/IV 4–2\s+PW 6–3/, { timeout: 8000 }); - record({ step: 'M1 reopen', action: 'ended again: Completed row', variant: 'result', expected: 'IV 4–2 PW 6–3', + await expect(row.locator('.shiaijo-qrow__result')).toHaveText(/IV 4–2\s+PW 7–3/, { timeout: 8000 }); + record({ step: 'M1 reopen', action: 'ended again: Completed row', variant: 'result', expected: 'IV 4–2 PW 7–3', immediately, settledWithin8s: true, after: (await row.locator('.shiaijo-qrow__result').innerText()).replace(/\s+/g, ' '), screenshot: immediateShot }); // Still Kawasemi's (Shiro's) win: the final keeps its side. @@ -919,7 +929,7 @@ test.describe('J5 kachinuki from the court console', () => { await test.step('V1: the thumb meant the danger button and hit Leave it running', async () => { const v1 = await tapNeighbour(page.getByTestId('kachinuki-reopen-requeue-button'), 'right'); - await page.waitForTimeout(300); + await rendered(page); const panelStill = await conflict.isVisible(); record({ step: 'M1 reopen, court busy', action: 'Queue it and reopen (Scores tab)', variant: 'V1 tapNeighbour right', ...v1, panelStill, screenshot: await shot(page, 'v1-requeue-neighbour') }); @@ -931,14 +941,14 @@ test.describe('J5 kachinuki from the court console', () => { await test.step('V4: the panel answered by its loudest button without reading', async () => { const loud = await hastyPanel(conflict); - await expect(conflict).toHaveCount(0, { timeout: 15_000 }); + await expect(conflict).toHaveCount(0, { timeout: EXPECT_TIMEOUT_MS }); // The overlay follows the reopened match into bout mode. - await expect(page.locator(EDITOR).getByRole('button', { name: 'Record bout' })).toBeVisible({ timeout: 15_000 }); + await expect(page.locator(EDITOR).getByRole('button', { name: 'Record bout' })).toBeVisible({ timeout: EXPECT_TIMEOUT_MS }); const overlayShot = await shot(page, 'v4-requeue-hasty'); // M1 back in play is the court console's to show: it is A's live bout // again, with its bouts, and M2 is back in A's queue. await openShiaijo(page, 'A'); - await expect(recordBoutButton(page)).toBeVisible({ timeout: 15_000 }); + await expect(recordBoutButton(page)).toBeVisible({ timeout: EXPECT_TIMEOUT_MS }); const upNext = await upNextCard(page).isVisible() ? await sides(upNextCard(page)) : null; record({ step: 'M1 reopen, court busy', action: 'court-busy panel (Scores tab)', variant: 'V4 hasty (loudest button)', ...loud, m1Reopened: await boutNames(currentBout(page)), doneRows: await doneRows(page).count(), upNext, @@ -959,7 +969,7 @@ test.describe('J5 kachinuki from the court console', () => { await expect(upNextCard(page)).toBeVisible(); expect(await sides(upNextCard(page))).toEqual(m2); await upNextCard(page).getByRole('button', { name: 'Start match' }).tap(); - await page.waitForTimeout(1500); + await expect(recordBoutButton(page)).toBeVisible(); // The reopen came from the Scores tab, so the console holds no // correction: M2 is the panel's live bout at once (V5's old lost place, // a started match hidden behind a pinned correction, is gone, bc-crpn). @@ -1022,20 +1032,16 @@ test.describe('J5 kachinuki from the court console', () => { await expect(option(S[1])).toBeVisible(); return taps; }; - const clearGhostMarks = async () => { - let taps = 0; - while ((await boutFilled(currentBout(page), 'shiro').count()) > 0) { - await boutFilled(currentBout(page), 'shiro').last().tap(); - taps += 1; - } - return taps; - }; + const clearGhostMarks = () => clearBoutMarks(currentBout(page), 'shiro'); // Correct path: one tap on the right fighter. await openList(); await shot(page, 'm2-fighter-pick-open'); const optBox = await option(S[1]).boundingBox(); await option(S[1]).tap(); - await page.waitForTimeout(600); + await expect(option(S[1])).toBeHidden(); + // The pick is saved and the row redrawn: a tap made meanwhile is lost with the old row. + await expect.poll(() => boutNames(currentBout(page))).toEqual({ shiro: S[1], aka: A[0] }); + await untilStable(() => boutNames(currentBout(page))); const afterPick = await shiroMarks(); const pickShot = await shot(page, 'm2-fighter-picked-one-tap'); record({ step: 'M2 bout 2', action: 'pick the Shiro fighter (one tap)', variant: 'correct path', @@ -1046,7 +1052,9 @@ test.describe('J5 kachinuki from the court console', () => { // V2: the option double-tapped. const reopenTaps = await openList(); const v2 = await doubleTap(option(S[1])); - await page.waitForTimeout(600); + await expect(option(S[1])).toBeHidden(); + // The pick is saved and the row redrawn: a tap made meanwhile is lost with the old row. + await untilStable(() => boutNames(currentBout(page))); const v2Marks = await shiroMarks(); const v2Shot = await shot(page, 'm2-v2-fighter-pick-double-tap'); record({ step: 'M2 bout 2', action: 'pick the Shiro fighter', variant: 'V2 doubleTap', ...v2, tapsToReopenList: reopenTaps, @@ -1055,14 +1063,14 @@ test.describe('J5 kachinuki from the court console', () => { // own shortcut line says "Esc close"). await openList(); await page.keyboard.press('Escape'); - await page.waitForTimeout(400); + await rendered(page); const discard = page.locator('.modal[role="dialog"]').filter({ hasText: /Discard unsaved/ }); const dialogShown = await discard.isVisible(); const escShot = await shot(page, 'm2-esc-discard-dialog'); let hasty = {}; if (dialogShown) { hasty = await hastyConfirm(page); - await page.waitForTimeout(800); + await expect(discard).toBeHidden(); } record({ step: 'M2 bout 2', action: 'Esc to close the fighter list', variant: 'V4 hastyConfirm', dialogShown, ...hasty, editorStill: await recordBoutButton(page).isVisible(), pairing: await boutNames(currentBout(page)).catch(() => null), @@ -1072,7 +1080,7 @@ test.describe('J5 kachinuki from the court console', () => { // V1: the option below is hit instead. await openList(); const v1 = await tapNeighbour(option(S[1]), 'down'); - await page.waitForTimeout(600); + await rendered(page); const wrong = await boutNames(currentBout(page)); const v1Marks = await shiroMarks(); const v1Shot = await shot(page, 'm2-v1-fighter-pick-neighbour'); @@ -1082,7 +1090,10 @@ test.describe('J5 kachinuki from the court console', () => { recoverTaps += await openList(); await option(S[1]).tap(); recoverTaps += 1; - await page.waitForTimeout(600); + await expect(option(S[1])).toBeHidden(); + // The pick is saved and the row redrawn: a tap made meanwhile is lost with the old row. + await expect.poll(() => boutNames(currentBout(page))).toEqual({ shiro: S[1], aka: A[0] }); + await untilStable(() => boutNames(currentBout(page))); recoverTaps += await clearGhostMarks(); } await expect.poll(() => boutNames(currentBout(page))).toEqual({ shiro: S[1], aka: A[0] }); @@ -1106,6 +1117,8 @@ test.describe('J5 kachinuki from the court console', () => { const before = await doneRows(page).count(); const v2 = await doubleTap(recordBoutButton(page)); await expect(doneRows(page)).toHaveCount(before + 1); + // Deliberate: the bounced second tap must NOT append another bout, and + // an absence has no state to wait for, so the time is the evidence. await page.waitForTimeout(1200); record({ step: 'M2 bout 4', action: 'Record bout', variant: 'V2 doubleTap', ...v2, doneRows: await doneRows(page).count(), current: await boutNames(currentBout(page)), @@ -1150,22 +1163,28 @@ test.describe('J5 kachinuki from the court console', () => { await test.step('the hurried operator reopens M2 and taps Send back to queue to get out', async () => { await completedRowFor(page, m2).locator('.shiaijo-row__correct').tap(); await reopenButton(page).tap(); - await expect(recordBoutButton(page)).toBeVisible({ timeout: 15_000 }); + await expect(recordBoutButton(page)).toBeVisible({ timeout: EXPECT_TIMEOUT_MS }); const boutsBefore = await doneRows(page).count(); const revert = page.getByRole('button', { name: 'Send back to queue' }); await expect(revert).toBeVisible(); await revert.tap(); await shot(page, 'm2-send-back-confirm'); const hasty = await hastyShiaijoConfirm(page); - await page.waitForTimeout(1500); + await expect(recordBoutButton(page)).toBeHidden(); // Where did the encounter go, and did its bout log survive? const upNext = await upNextCard(page).isVisible() ? await sides(upNextCard(page)).catch(() => null) : null; let boutsAfter = null; if (upNext) { + // Start is one match write; the sheet shows the bout log only once that + // answer is in, so a count read before it is the empty sheet. + const startAnswered = nextMatchWrite(page); await upNextCard(page).getByRole('button', { name: 'Start match' }).tap(); + await writeLanded(startAnswered, 'Start match'); await expect(recordBoutButton(page)).toBeVisible(); - await page.waitForTimeout(800); - boutsAfter = await doneRows(page).count(); + // Did the bout log survive the send back: the count once the sheet has loaded it. + // The empty sheet (0) is the read before the log loads; `from` makes + // the settle wait for it to leave that. + boutsAfter = await untilStable(() => doneRows(page).count(), { from: 0 }); } record({ step: 'M2 reopened', action: 'Send back to queue (reopened encounter)', variant: 'V4 hasty confirm', ...hasty, boutsBefore, upNext, boutsAfterRestart: boutsAfter, current: upNext ? await boutNames(currentBout(page)) : null, @@ -1302,7 +1321,7 @@ test.describe('J5 kachinuki from the court console', () => { await shot(p, 'final-v3-record-offline'); }, }); - const appended = await expect(doneRows(page)).toHaveCount(before + 1, { timeout: 15_000 }).then(() => true, () => false); + const appended = await expect(doneRows(page)).toHaveCount(before + 1, { timeout: EXPECT_TIMEOUT_MS }).then(() => true, () => false); record({ step: 'final bout 2', action: 'Record bout', variant: 'V3 interrupt offline', ...offline, whileOffline, appendedAfterReconnect: appended, doneRows: await doneRows(page).count(), live: await boutNames(currentBout(page)), screenshot: await shot(page, 'final-after-reconnect') }); @@ -1354,7 +1373,7 @@ async function completedOnServer(page, pair, court = 'A') { await expect.poll(async () => { await openShiaijo(fresh, court); return completedRowFor(fresh, pair).locator('.shiaijo-qrow__result').count(); - }, { timeout: 15_000, message: `${pair.shiro} v ${pair.aka} is completed on the server` }).toBeGreaterThan(0); + }, { timeout: EXPECT_TIMEOUT_MS, message: `${pair.shiro} v ${pair.aka} is completed on the server` }).toBeGreaterThan(0); } finally { await fresh.close(); } @@ -1393,7 +1412,7 @@ async function hastyReasonPrompt(page) { const preset = await prompt.locator('select').first().inputValue().catch(() => ''); const { button, ...loud } = await loudestButton(prompt); await button.tap(); - await prompt.waitFor({ state: 'hidden', timeout: 15_000 }); + await prompt.waitFor({ state: 'hidden', timeout: EXPECT_TIMEOUT_MS }); return { ...loud, defaultReason: preset }; } diff --git a/scripts/e2e/journeys/knockout-mixed-team.spec.mjs b/scripts/e2e/journeys/knockout-mixed-team.spec.mjs index 637298f31..aff3d659f 100644 --- a/scripts/e2e/journeys/knockout-mixed-team.spec.mjs +++ b/scripts/e2e/journeys/knockout-mixed-team.spec.mjs @@ -15,17 +15,17 @@ // first as test.fixme so this file stayed green, and, once fixed, as a plain // test that guards the fix. import { test, expect } from '../fixtures/test.mjs'; -import { OPERATOR_DEVICE, PUBLIC_DEVICE } from '../fixtures/devices.mjs'; +import { OPERATOR_DEVICE, PUBLIC_DEVICE, EXPECT_TIMEOUT_MS } from '../fixtures/devices.mjs'; import { journeyShots } from '../fixtures/shots.mjs'; -import { login } from '../fixtures/setup.mjs'; +import { enterTournament, login } from '../fixtures/setup.mjs'; import { createCompetition } from '../fixtures/wizard.mjs'; import { generateDraw, pasteRoster, startCompetition } from '../fixtures/competition.mjs'; import { inlineEditor, openShiaijo, sides, upNextCard, completedRows, completedRowFor } from '../fixtures/shiaijo.mjs'; -import { EDITOR } from '../fixtures/scoring.mjs'; +import { EDITOR, clearMarks, nextMatchWrite, writeLanded } from '../fixtures/scoring.mjs'; import { openScoreEditor } from '../fixtures/scores.mjs'; import { openTvBoard, openViewer, tvBoard } from '../fixtures/public.mjs'; import { doubleTap, hastyConfirm, interrupt, retapWhileSaving, tapNeighbour } from '../fixtures/clumsy.mjs'; -import { settled } from '../fixtures/pace.mjs'; +import { dwell, rendered, settled, untilStable } from '../fixtures/pace.mjs'; import { auditLog } from '../fixtures/audit.mjs'; import { tapSize } from '../fixtures/pointer.mjs'; import * as T from '../fixtures/team.mjs'; @@ -72,6 +72,10 @@ async function seedF4(page, { name, court, prefix, teams }) { return id; } +// The tournament every test here signs in to, or creates when its server has +// none: sixteen shiaijo, so each test seeds its own competition on a court of its own. +const TEAM_CUP = { name: 'Team Cup', courts: 16 }; + // Name every position of `team`'s starting lineup on the Lineups page. async function nameStartingLineup(page, id, team, names, teamSize) { await T.openLineups(page, id, { team }); @@ -81,10 +85,10 @@ async function nameStartingLineup(page, id, team, names, teamSize) { } // NOT serial: every test here seeds its own competitions on its own shiaijo -// (T.enterAdmin creates the tournament when the worker's server has none), so +// (enterTournament creates the tournament when the worker's server has none), so // a failing journey skips none of the others. After a failure Playwright runs // the rest in a fresh worker, which gets a fresh server (fixtures/test.mjs -// serverForFile) and enterAdmin creates the tournament there again. +// serverForFile) and enterTournament creates the tournament there again. test.describe('knockout-mixed-team', () => { test.afterAll(() => { for (const log of audits) log.flush(); @@ -95,7 +99,7 @@ test.describe('knockout-mixed-team', () => { test.setTimeout(600_000); const shot = journeyShots('J3-lineups'); const { record } = recorder('J3', 'J3-lineups'); - await T.enterAdmin(page); + await enterTournament(page, TEAM_CUP); let f3; let pair3; @@ -119,7 +123,7 @@ test.describe('knockout-mixed-team', () => { const addBtn = T.lineupsForm(page).getByRole('button', { name: /^Add$/ }); const addBox = await tapSize(addBtn); const dbl = await doubleTap(addBtn); - await page.waitForTimeout(400); + await rendered(page); const dialogs = await page.locator('.modal[role="dialog"]').count(); await shot(page, 'f3-lineups-add-doubletap-dialog'); // The second tap may have landed on the dialog itself; if it closed it, @@ -220,7 +224,7 @@ test.describe('knockout-mixed-team', () => { if (!/no control within/.test(e.message)) throw e; return { hazardGone: true }; }); - await page.waitForTimeout(800); + await rendered(page); const shiroState = (await T.panelSide(page, 0).innerText()).replace(/\s+/g, ' '); await shot(page, 'f3-panel-v1-neighbour-of-aka-save'); record({ step: 'EP2 save Aka', action: 'Save lineup (Aka)', variant: 'V1 tapNeighbour left', ...nb, shiroSideAfter: shiroState.slice(0, 200), @@ -236,7 +240,7 @@ test.describe('knockout-mixed-team', () => { // One member at two positions: type Dai (at 1) into 2 and save. await T.typeIntoNameBox(T.panelInput(page, 1, '2'), 'Dai'); - await T.panelSave(page, 1); + await T.panelSave(page, 1, { refused: true }); const err = T.panelSide(page, 1).locator('.alert--error'); await expect(err).toBeVisible(); const copy = (await err.innerText()).trim(); @@ -315,8 +319,8 @@ test.describe('knockout-mixed-team', () => { const aokiIdx = texts.findIndex((t) => t.includes('Aoki')); const wrongOpt = opts.nth(Math.min(aokiIdx + 1, texts.length - 1)); const wrongText = (await wrongOpt.innerText()).trim(); - await wrongOpt.tap(); - await page.waitForTimeout(600); + await T.tapNameOption(wrongOpt); + await rendered(page); const afterWrong = await box.inputValue(); const labelAfterWrong = (await T.rowMemberLabel(row1, 'shiro').innerText().catch(() => '')).trim(); await shot(page, 'f3-sheet-wrong-fighter'); @@ -332,8 +336,8 @@ test.describe('knockout-mixed-team', () => { await expect(T.rowNameBox(T.boutRow(ed, 3), 'shiro')).toHaveValue('Chiba'); // One member at two rows: type Baba (row 2) into row 1. await T.typeIntoNameBox(box, 'Baba'); - await page.waitForTimeout(800); const err = ed.getByTestId('team-editor-lineup-warning'); + await expect(err).toBeVisible(); const errText = (await err.count()) ? (await err.innerText()).trim() : ''; await shot(page, 'f3-sheet-duplicate'); record({ step: 'EP3 duplicate', action: 'type Baba into row 1 (Baba at row 2)', variant: 'refusal copy', copy: errText, @@ -381,8 +385,9 @@ test.describe('knockout-mixed-team', () => { await shot(page, 'f3-lineups-add-member-confirm'); hasty = await hastyConfirm(page); } - await page.waitForTimeout(800); - const after = await T.lineupsForm(page).locator('[data-testid^="squad-member-"]').count(); + // The add is a write and the list refetches: the count the answer left, + // read against the count before the add (`from`). + const after = await untilStable(() => T.lineupsForm(page).locator('[data-testid^="squad-member-"]').count(), { from: before }); await shot(page, 'f3-lineups-after-add-member', { fullPage: true }); record({ step: 'EP1 add a substitute', action: '+ Add new member… at 3 (reserves .4/.5 blank)', variant: 'V4 hastyConfirm', ...(hasty || {}), membersBefore: before, membersAfter: after, alert: r.alert }); @@ -394,9 +399,7 @@ test.describe('knockout-mixed-team', () => { await expect(T.boutRow(ed, 1)).toBeVisible(); const pairNow = await sides(ed); for (const n of [1, 2, 3]) { - for (const side of ['shiro', 'aka']) { - while (await T.rowSlots(T.boutRow(ed, n), side).count()) await T.rowSlots(T.boutRow(ed, n), side).first().tap(); - } + for (const side of ['shiro', 'aka']) await clearMarks(T.rowSlots(T.boutRow(ed, n), side), { from: 'first' }); } await T.boutIppon(ed, 1, 'shiro', 'M'); await T.ensureTie(ed, 2); @@ -404,12 +407,18 @@ test.describe('knockout-mixed-team', () => { // a result; neither team fields bout 3, so it is recorded as a Tie // rather than left unfought, and Finish now refuses until it is. await T.ensureTie(ed, 3); - await page.waitForTimeout(1200); const sheet = await T.sheetState(ed, 3); await shot(page, 'f3-pool-encounter-bout3-tied'); await T.finishTeam(ed); await page.goto(`/admin/competition/${f3}/pools`); - await page.waitForTimeout(1500); + // The row is there before the standings have counted the encounter: wait + // for its team matches (W, L, T, the first three figures) to add up to the + // one just played, for both teams, before the rows are read. + const teamMatches = async (team) => (await page.locator('tr').filter({ hasText: team }).first().locator('td.num').allInnerTexts()) + .slice(0, 3).reduce((n, t) => n + (Number(t.trim()) || 0), 0); + for (const team of [pairNow.shiro, pairNow.aka]) { + await expect.poll(() => teamMatches(team), { message: `${team}'s standings row counts the finished encounter` }).toBeGreaterThan(0); + } await shot(page, 'f3-pools-standings', { fullPage: true }); const standings = await page.locator('tr').filter({ hasText: pairNow.shiro }).first().innerText().catch(() => ''); const standingsAka = await page.locator('tr').filter({ hasText: pairNow.aka }).first().innerText().catch(() => ''); @@ -446,7 +455,7 @@ test.describe('knockout-mixed-team', () => { record({ step: 'pool kiken chain', action: `Kiken – Voluntary, Shiro (${pair.shiro}) withdraws`, variant: 'correct', kikenResult, editorNow: await sides(ed).catch(() => null), upNext: await sides(upNextCard(page)).catch(() => null) }); await page.goto(`/admin/competition/${f3}/pools`); - await page.waitForTimeout(1500); + await expect(page.locator('body')).toContainText('Kiken'); await shot(page, 'f3-pools-after-kiken-chain', { fullPage: true }); const matchesText = (await page.locator('body').innerText()).replace(/\s+/g, ' '); expect(matchesText).toContain('Kiken'); @@ -458,8 +467,9 @@ test.describe('knockout-mixed-team', () => { await openShiaijo(page, 'A'); const upNext = await sides(upNextCard(page)).catch(() => null); await upNextCard(page).getByRole('button', { name: 'Start match' }).tap().catch(() => {}); - await page.waitForTimeout(1500); - const msgs = (await page.locator('.alert, [role="alert"], .toast, [class*="toast"]').allInnerTexts().catch(() => [])).join(' | ').replace(/\s+/g, ' '); + const messages = page.locator('.alert, [role="alert"], .toast, [class*="toast"]'); + await expect(T.boutRow(inlineEditor(page), 1).or(messages.first())).toBeVisible(); + const msgs = (await messages.allInnerTexts().catch(() => [])).join(' | ').replace(/\s+/g, ' '); await shot(page, 'f3-start-withdrawn-team-match'); record({ step: 'pool kiken chain', action: 'Start match on the withdrawn team\'s remaining match', variant: 'V5 lost place', upNext, messages: msgs.slice(0, 300), editorOpen: await T.boutRow(inlineEditor(page), 1).isVisible().catch(() => false) }); @@ -508,7 +518,7 @@ test.describe('knockout-mixed-team', () => { await expect(row1.getByRole('textbox', { name: 'Senpo AKA player' })).toBeVisible(); const shiroRow1 = await T.rowNameBox(row1, 'shiro').inputValue(); await T.typeIntoNameBox(T.rowNameBox(row1, 'aka'), 'Nakamura'); - await page.waitForTimeout(800); + await expect(T.rowMemberLabel(row1, 'aka')).toHaveText(/\.1$/); const akaLabel = (await T.rowMemberLabel(row1, 'aka').innerText()).trim(); await shot(page, 'f4-sheet-row1'); record({ step: 'F4 EP3 sheet', action: 'type Nakamura into Aka Senpo (picked slot .1)', variant: 'correct', shiroRow1, akaLabel, @@ -526,7 +536,7 @@ test.describe('knockout-mixed-team', () => { test.setTimeout(600_000); const shot = journeyShots('J4-team-scoring'); const { record } = recorder('J4', 'J4-team-scoring'); - await T.enterAdmin(page); + await enterTournament(page, TEAM_CUP); const id = await seedF4(page, { name: 'Team KO J4', court: 'D', prefix: 'S', teams: FOUR_TEAMS('J4') }); await openShiaijo(page, 'D'); const ed = inlineEditor(page); @@ -538,9 +548,15 @@ test.describe('knockout-mixed-team', () => { const startBox = await tapSize(start); const dbl = await doubleTap(start); await expect(T.boutRow(ed, 1)).toBeVisible(); - await page.waitForTimeout(800); + await expect(T.syncPill(ed)).toBeVisible(); const identity = (await ed.locator('.editor-modal__eyebrow').first().innerText()).trim(); - const upNextNow = await sides(upNextCard(page)).catch(() => null); + // Up next moves on once the list has refetched after the start: wait for + // it to leave the pairing just started before reading where it settled. + await expect.poll(async () => { + const now = await sides(upNextCard(page)).catch(() => null); + return now !== null && JSON.stringify(now) !== JSON.stringify(sf1); + }).toBe(true); + const upNextNow = await untilStable(() => sides(upNextCard(page)).catch(() => null)); await shot(page, 'sf1-started'); record({ step: 'start SF1', action: 'Start match (Up next)', variant: 'V2 doubleTap', ...dbl, startTapBox: startBox, editorHolds: identity, upNextAfter: upNextNow, errorsShown: await page.locator('.alert--error, [role="alert"]').allInnerTexts() }); @@ -575,7 +591,7 @@ test.describe('knockout-mixed-team', () => { const row = T.boutRow(ed, 2); const intended = T.rowIppon(row, 'aka', 'M'); const nb = await tapNeighbour(intended, 'left'); - await page.waitForTimeout(600); + await rendered(page); const tieOn = /✓/.test((await T.rowTie(row).innerText().catch(() => '')) || ''); const middle = (await T.rowMiddle(row).innerText()).trim(); await shot(page, 'bout2-neighbour-tap'); @@ -625,12 +641,14 @@ test.describe('knockout-mixed-team', () => { // V3: fusensho, the operator looks away (the write lands), the page // reloads, then the operator untoggles "from memory". + const fusenshoSaved = nextMatchWrite(page); await T.rowFusensho(row, 'aka').tap(); - await page.waitForTimeout(1500); + await expect(T.rowSlots(row, 'aka')).toHaveCount(2); + await writeLanded(fusenshoSaved, 'the fusensho save'); await expect(T.syncPill(ed)).toHaveText('Synced'); const r = await interrupt(page, 'reload'); await expect(T.boutRow(ed, 3)).toBeVisible(); - await page.waitForTimeout(1500); + await expect(T.rowFusensho(T.boutRow(ed, 3), 'aka')).toHaveText('✓ Fusensho'); const afterReload = { shiro: await T.rowSlots(T.boutRow(ed, 3), 'shiro').allInnerTexts(), aka: await T.rowSlots(T.boutRow(ed, 3), 'aka').allInnerTexts(), @@ -640,15 +658,14 @@ test.describe('knockout-mixed-team', () => { await T.boutRow(ed, 3).scrollIntoViewIfNeeded(); await shot(page, 'bout3-after-reload-before-untoggle'); await T.rowFusensho(T.boutRow(ed, 3), 'aka').tap(); - await page.waitForTimeout(1200); + await expect(T.rowSlots(T.boutRow(ed, 3), 'aka')).toHaveCount(0); + await expect(T.syncPill(ed)).toHaveText('Synced'); const afterUntoggle = { shiro: await T.rowSlots(T.boutRow(ed, 3), 'shiro').allInnerTexts(), aka: await T.rowSlots(T.boutRow(ed, 3), 'aka').allInnerTexts() }; await shot(page, 'bout3-untoggle-after-reload'); record({ step: 'bout 3', action: 'Fusensho Aka, reload, untoggle', variant: 'V3 interrupt reload', ...r, afterReload, afterUntoggle, sheet: await T.sheetState(ed), expected: 'Shiro K restored (the control title promises the previous score)', note: 'bc-fsnp when Shiro is empty' }); // Make bout 3 a clean hikiwake for the tie that follows. - for (const side of ['shiro', 'aka']) { - while (await T.rowSlots(T.boutRow(ed, 3), side).count()) await T.rowSlots(T.boutRow(ed, 3), side).first().tap(); - } + for (const side of ['shiro', 'aka']) await clearMarks(T.rowSlots(T.boutRow(ed, 3), side), { from: 'first' }); if (/✓/.test(await T.rowFusensho(T.boutRow(ed, 3), 'aka').innerText())) await T.rowFusensho(T.boutRow(ed, 3), 'aka').tap(); await T.ensureTie(ed, 3); }); @@ -665,7 +682,8 @@ test.describe('knockout-mixed-team', () => { const off = await interrupt(page, 'offline', { during: async (p) => { await T.rowIppon(T.boutRow(inlineEditor(p), 4), 'aka', 'D').tap(); - await expect(T.syncPill(inlineEditor(p))).not.toHaveText('Synced', { timeout: 3000 }).catch(() => {}); + // The pill must stop claiming "Synced" while the point cannot be saved. + await expect(T.syncPill(inlineEditor(p))).not.toHaveText('Synced'); offlinePill = (await T.syncPill(inlineEditor(p)).innerText()).trim(); await shot(p, 'bout4-offline'); }, @@ -678,7 +696,7 @@ test.describe('knockout-mixed-team', () => { const pillAtReload = (await T.syncPill(ed).innerText()).trim(); const quick = await interrupt(page, 'reload'); await expect(T.boutRow(ed, 4)).toBeVisible(); - await page.waitForTimeout(1500); + await expect(T.syncPill(ed)).toHaveText('Synced'); record({ step: 'bout 4', action: 'Aka kote, reload within 300ms', variant: 'V3 interrupt reload (instant)', ...quick, pillAtReload, akaAfter: await T.rowSlots(T.boutRow(ed, 4), 'aka').allInnerTexts(), expected: ['D', 'K'], note: 'bc-sync when K is gone' }); const hidden = await interrupt(page, 'hidden'); @@ -686,20 +704,21 @@ test.describe('knockout-mixed-team', () => { sameMatch: (await ed.locator('.editor-modal__eyebrow').first().innerText()).trim() === identity, akaKept: await T.rowSlots(T.boutRow(ed, 4), 'aka').allInnerTexts() }); const back = await interrupt(page, 'back'); - const edBack = await T.boutRow(ed, 4).waitFor({ state: 'visible', timeout: 15000 }).then(() => true, () => false); + const edBack = await T.boutRow(ed, 4).waitFor({ state: 'visible', timeout: EXPECT_TIMEOUT_MS }).then(() => true, () => false); await shot(page, 'bout4-after-back'); record({ step: 'bout 4', action: 'Back then Forward', variant: 'V3 interrupt back + V5 lost place', ...back, editorBack: edBack, sameMatch: edBack && (await ed.locator('.editor-modal__eyebrow').first().innerText()).trim() === identity, akaKept: edBack ? await T.rowSlots(T.boutRow(ed, 4), 'aka').allInnerTexts() : null, sheet: edBack ? await T.sheetState(ed) : null }); await expect(T.boutRow(ed, 4)).toBeVisible(); - while (await T.rowSlots(T.boutRow(ed, 4), 'aka').count()) await T.rowSlots(T.boutRow(ed, 4), 'aka').first().tap(); + await clearMarks(T.rowSlots(T.boutRow(ed, 4), 'aka'), { from: 'first' }); await T.ensureTie(ed, 4); // Bout 5, not yet fought: the operator taps its Tie for a real draw. const row5 = T.boutRow(ed, 5); await row5.scrollIntoViewIfNeeded(); const before5 = { middle: (await T.rowMiddle(row5).innerText()).trim(), tieLabel: (await T.rowTie(row5).innerText()).trim() }; + const tieSaved = nextMatchWrite(page); await T.rowTie(row5).tap(); - await page.waitForTimeout(1500); + await writeLanded(tieSaved, 'the Tie save'); const after5 = { middle: (await T.rowMiddle(row5).innerText()).trim(), tieLabel: (await T.rowTie(row5).innerText()).trim() }; await shot(page, 'bout5-tie-tapped'); record({ step: 'bout 5', action: 'Tie (hikiwake) on the unfought bout 5', variant: 'correct', before5, after5, @@ -755,7 +774,7 @@ test.describe('knockout-mixed-team', () => { await ed.getByTestId('team-daihyosen-hantei-arm').tap(); const shiroWins = ed.getByTestId('team-daihyosen-hantei-shiro'); const nb = await tapNeighbour(shiroWins, 'right'); - await page.waitForTimeout(300); + await rendered(page); const akaHt = await ed.getByTestId('team-daihyosen-ht-aka').count(); await shot(page, 'hantei-wrong-side'); await shiroWins.tap(); @@ -776,7 +795,10 @@ test.describe('knockout-mixed-team', () => { await finish.scrollIntoViewIfNeeded(); const labelBefore = (await finish.innerText()).trim(); const dbl = await doubleTap(finish); - await page.waitForTimeout(1500); + await expect(completedRows(page).first().or(T.finishBtn(ed).filter({ hasText: /^Tap again/ }))).toBeVisible(); + // The app ignores a tap inside the arm's bounce window; out it, so the + // operator's own tap below (when the double tap only armed) counts. + await dwell(page, Date.now()); const done = await completedRows(page).count(); let extraTaps = 0; if (!done) { @@ -833,6 +855,8 @@ test.describe('knockout-mixed-team', () => { const completedBefore = await completedRows(page).count(); const lb = await loud.boundingBox(); await page.touchscreen.tap(lb.x + lb.width / 2, lb.y + lb.height / 2); + // Deliberate: the prompt must STAY open and nothing may be recorded, and + // an absence has no state to wait for, so the time is the evidence. await page.waitForTimeout(800); const promptStillOpen = await prompt.isVisible(); const completedAfterHasty = await completedRows(page).count(); @@ -850,10 +874,9 @@ test.describe('knockout-mixed-team', () => { await shot(page, 'sf2-kiken-side-picked'); // Not reading the button, the operator taps Record. await loud.tap(); - await page.waitForTimeout(1500); - await shot(page, 'sf2-kiken-recorded'); // The withdrawal is marked Kiken beside the team on the bracket. await expect(page.getByText('Kiken', { exact: true }).first()).toBeVisible(); + await shot(page, 'sf2-kiken-recorded'); record({ step: 'SF2 kiken', action: 'pick the side (aimed at Aka), then Record', variant: 'V1 tapNeighbour left', ...nbPill, labelAfterPick, consequence, recordedAgainst: picked, completedRowResult: (await completedRows(page).last().locator('.shiaijo-qrow__result').innerText().catch(() => '')).trim(), @@ -909,6 +932,8 @@ test.describe('knockout-mixed-team', () => { await prompt.locator('button.btn--primary').tap(); // bc-dlck: no "A subsequent match ... Proceed anyway?" confirm follows; the // final it feeds was sent back unscored, so nothing else asks either. + // Deliberate: an absence (no confirm) has no state to wait for, so the + // time is the evidence. await page.waitForTimeout(1500); const confirmShown = await page.locator('.modal[role="dialog"]').filter({ has: page.locator('.modal__foot') }).count(); const errs = (await ed.locator('[style*="danger"], .alert--error').allInnerTexts()).join(' | '); @@ -917,7 +942,7 @@ test.describe('knockout-mixed-team', () => { confirmShown: confirmShown > 0, errorsAfter: errs, correctionStillOpen: await ed.getByText('CORRECTION', { exact: true }).count() }); expect(confirmShown).toBe(0); await openShiaijo(page, 'D'); - await page.waitForTimeout(1000); + await expect(page.locator('.shiaijo__queue')).toBeVisible(); await shot(page, 'court-after-kiken-recovery'); const court = (await page.locator('body').innerText()).replace(/\s+/g, ' '); record({ step: 'SF2 kiken recovery', action: 'court after the recovery', variant: 'V5 lost place', @@ -942,7 +967,7 @@ test.describe('knockout-mixed-team', () => { await prompt.locator('input[value="aka"]').check(); await shot(page, 'final-fusenpai-prompt'); await prompt.locator('button.btn--primary').tap(); - await page.waitForTimeout(1500); + await expect(prompt).toHaveCount(0); await shot(page, 'final-fusenpai-recorded'); record({ step: 'final fusenpai', action: 'Fusenpai (Aka did not show)', variant: 'correct', pair, errors: (await ed.locator('[style*="danger"], .alert--error').allInnerTexts().catch(() => [])).join(' | '), @@ -959,7 +984,7 @@ test.describe('knockout-mixed-team', () => { test.setTimeout(600_000); const shot = journeyShots('J7-one-result'); const { record } = recorder('J7', 'J7-one-result'); - await T.enterAdmin(page); + await enterTournament(page, TEAM_CUP); const id = await seedF4(page, { name: 'Team KO J7', court: 'E', prefix: 'F', teams: [['J7 Asahi', 'Kita Dojo'], ['J7 Yuhi', 'Minami Dojo']] }); await openShiaijo(page, 'E'); const pair = await sides(upNextCard(page)); @@ -1012,7 +1037,7 @@ test.describe('knockout-mixed-team', () => { await shot(hp, 'head-table-hantei-shiro'); await T.finishTeam(hed); // The court console, left open, follows the result. - await expect(completedRows(page)).toHaveCount(1, { timeout: 15000 }); + await expect(completedRows(page)).toHaveCount(1, { timeout: EXPECT_TIMEOUT_MS }); await shot(page, 'court-after-head-table-finish'); const courtRow = (await completedRows(page).first().innerText()).replace(/\s+/g, ' '); record({ step: 'finish elsewhere', action: 'head table Finish with hantei Shiro', variant: 'two devices', courtCompletedRow: courtRow, @@ -1042,7 +1067,7 @@ test.describe('knockout-mixed-team', () => { await shot(hp, 'head-table-correction-reason'); await reason.locator('input, textarea').first().fill('Hantei was for Aka'); await reason.getByRole('button', { name: /Save|Confirm|Record/ }).last().tap(); - await expect(hp.locator(EDITOR)).toHaveCount(0, { timeout: 15000 }).catch(() => {}); + await expect(hp.locator(EDITOR)).toHaveCount(0); await shot(hp, 'head-table-after-correction'); // The court's open correction editor adopts the new verdict, untouched. const adopted = await ed.getByTestId('team-daihyosen-ht-aka').waitFor({ state: 'visible', timeout: 10000 }).then(() => true, () => false); @@ -1075,7 +1100,7 @@ test.describe('knockout-mixed-team', () => { // For a team encounter the marks appear on the bracket // (recording-decisions.md): the viewer's Bracket tab. await phone.getByRole('tab', { name: 'Bracket' }).or(phone.getByRole('button', { name: 'Bracket' })).first().tap(); - await phone.waitForTimeout(800); + await expect(phone.locator('.viewer__tab.is-active')).toHaveText('Bracket'); await shot(phone, 'viewer-bracket-final', { fullPage: true }); const bracketText = (await phone.locator('.viewer__body').innerText()).replace(/\s+/g, ' '); record({ step: 'every surface', action: 'viewer Bracket tab after the correction', variant: 'correct', bracket: bracketText.slice(0, 400), @@ -1101,7 +1126,7 @@ test.describe('knockout-mixed-team', () => { // its previous team match (its starting lineup before its first), and the // panel shows the lineup in force with the line saying where it comes from. test('bc-lpfb: the match lineup panel shows the lineup the score sheet shows, and Save lineup waits for a change', async ({ page }) => { - await T.enterAdmin(page); + await enterTournament(page, TEAM_CUP); const id = await createCompetition(page, { name: 'B1 Pools', kind: 'team', format: 'mixed', teamSize: 3, teamMatchType: 'fixed', courts: ['G', 'H'], numberPrefix: 'G', }); @@ -1128,7 +1153,7 @@ test.describe('knockout-mixed-team', () => { // The refusal used to name the position being typed (Jiho), not the one the // member holds; this guards the fix. test('bc-lprf: the match lineup panel\'s "already at" refusal names the position the member holds', async ({ page }) => { - await T.enterAdmin(page); + await enterTournament(page, TEAM_CUP); await seedF4(page, { name: 'B2 KO', court: 'I', prefix: 'I', teams: FOUR_TEAMS('B2') }); await openShiaijo(page, 'I'); await T.openPanelFromUpNext(page); @@ -1139,19 +1164,20 @@ test.describe('knockout-mixed-team', () => { // Dai holds Senpo; typing Dai at Jiho is refused naming where Dai IS // (team-tournaments.md: "refused with the position they hold"). await T.typeIntoNameBox(T.panelInput(page, 1, 'Jiho'), 'Dai'); - await T.panelSave(page, 1); + await T.panelSave(page, 1, { refused: true }); await expect(T.panelSide(page, 1).locator('.alert--error')).toHaveText('Dai is already at Senpo.'); }); // A bout not yet fought used to read as a draw (X, "✓ Tie"); this guards // the fix on the sheet and on the TV board. test('bc-unfx: a bout not yet fought reads "vs" with Tie off, on the sheet and the TV board', async ({ page, browser, baseURL }) => { - await T.enterAdmin(page); + await enterTournament(page, TEAM_CUP); await seedF4(page, { name: 'B3 KO', court: 'J', prefix: 'J', teams: [['B3 Ume', 'Kita Dojo'], ['B3 Sakura', 'Minami Dojo']] }); await openShiaijo(page, 'J'); const ed = await T.startUpNextTeam(page); + const ipponSaved = nextMatchWrite(page); await T.boutIppon(ed, 1, 'shiro', 'M'); - await page.waitForTimeout(1500); + await writeLanded(ipponSaved, 'the ippon save'); await page.reload(); await expect(T.boutRow(ed, 2)).toBeVisible(); // Bouts 2-5 have not been fought: no draw mark, and Tie is not pressed. @@ -1173,7 +1199,7 @@ test.describe('knockout-mixed-team', () => { // A fusensho used to wipe the points the other side had already scored, so // its undo could not bring them back after a reload; this guards the fix. test('bc-fsnp: a fusensho keeps the points the other side already scored, and its undo after a reload brings the score back', async ({ page }) => { - await T.enterAdmin(page); + await enterTournament(page, TEAM_CUP); await seedF4(page, { name: 'B4 KO', court: 'K', prefix: 'L', teams: [['B4 Ume', 'Kita Dojo'], ['B4 Sakura', 'Minami Dojo']] }); await openShiaijo(page, 'K'); const ed = await T.startUpNextTeam(page); @@ -1181,12 +1207,13 @@ test.describe('knockout-mixed-team', () => { // The kote is saved before the fusensho. await expect(T.syncPill(ed)).toHaveText('Synced'); // Shiro's fighter cannot continue; the bout goes to Aka as a fusensho. + const fusenshoSaved = nextMatchWrite(page); await T.rowFusensho(T.boutRow(ed, 1), 'aka').tap(); await expect(T.rowSlots(T.boutRow(ed, 1), 'aka')).toHaveText(['○', '○']); // recording-decisions.md: "Any point the withdrawing side had already // scored stays valid and is kept on the sheet." await expect(T.rowSlots(T.boutRow(ed, 1), 'shiro')).toHaveText(['K']); - await page.waitForTimeout(1500); + await writeLanded(fusenshoSaved, 'the fusensho save'); await page.reload(); await expect(T.rowFusensho(T.boutRow(ed, 1), 'aka')).toHaveText('✓ Fusensho'); // "Click to undo fusensho: restores the previous score". @@ -1199,7 +1226,7 @@ test.describe('knockout-mixed-team', () => { // are no unfinished team matches. So Finish must refuse while a numbered // bout has no result, and an unfought bout never reaches the standings. test('bc-tmfn: a team encounter cannot be finished while a bout has no result', async ({ page }) => { - await T.enterAdmin(page); + await enterTournament(page, TEAM_CUP); const id = await createCompetition(page, { name: 'B3 Pools', kind: 'team', format: 'mixed', teamSize: 3, teamMatchType: 'fixed', courts: ['M', 'N'], numberPrefix: 'Q', }); @@ -1231,7 +1258,7 @@ test.describe('knockout-mixed-team', () => { }); test('bc-dhrp: the daihyosen row lets each team pick its representative', async ({ page }) => { - await T.enterAdmin(page); + await enterTournament(page, TEAM_CUP); await seedF4(page, { name: 'B6 KO', court: 'F', prefix: 'R', teams: [['B6 Ume', 'Kita Dojo'], ['B6 Sakura', 'Minami Dojo']] }); await openShiaijo(page, 'F'); const ed = await T.startUpNextTeam(page); @@ -1248,16 +1275,17 @@ test.describe('knockout-mixed-team', () => { // A representative bout added right after the last Tie used to be erased by // that Tie's pending autosave; this guards the fix. test('bc-dhas: a representative bout added right after the last Tie survives that Tie\'s autosave and a reload', async ({ page }) => { - await T.enterAdmin(page); + await enterTournament(page, TEAM_CUP); await seedF4(page, { name: 'B9 KO', court: 'L', prefix: 'T', teams: [['B9 Ume', 'Kita Dojo'], ['B9 Sakura', 'Minami Dojo']] }); await openShiaijo(page, 'L'); const ed = await T.startUpNextTeam(page); for (const n of [1, 2, 3, 4, 5]) await T.ensureTie(ed, n); // ensureTie saw the last Tie's write land (the pill back at Synced); the // operator goes straight on. + const daihyosenAdded = nextMatchWrite(page, { method: 'POST', tail: 'daihyosen' }); await ed.getByTestId('scoring-modal-daihyosen-button').tap(); await expect(T.boutRow(ed, 'DH')).toBeVisible(); - await page.waitForTimeout(1500); + await writeLanded(daihyosenAdded, 'the daihyosen add'); await expect(T.boutRow(ed, 'DH')).toBeVisible(); await page.reload(); await expect(T.boutRow(ed, 'DH')).toBeVisible(); diff --git a/scripts/e2e/journeys/moved-match.spec.mjs b/scripts/e2e/journeys/moved-match.spec.mjs index 7e644535f..bc4792774 100644 --- a/scripts/e2e/journeys/moved-match.spec.mjs +++ b/scripts/e2e/journeys/moved-match.spec.mjs @@ -27,16 +27,17 @@ import { test, expect } from '../fixtures/test.mjs'; import { journeyShots } from '../fixtures/shots.mjs'; import { auditLog } from '../fixtures/audit.mjs'; -import { login } from '../fixtures/setup.mjs'; +import { enterTournament, login } from '../fixtures/setup.mjs'; import { openShiaijo, upNextCard, sides, startUpNext } from '../fixtures/shiaijo.mjs'; import { EDITOR, awardIppon, finishButton, finishMatch } from '../fixtures/scoring.mjs'; import { hastyShiaijoConfirm } from '../fixtures/clumsy.mjs'; -import { settled } from '../fixtures/pace.mjs'; +import { rendered, settled, untilStable } from '../fixtures/pace.mjs'; +import { EXPECT_TIMEOUT_MS } from '../fixtures/devices.mjs'; import { tapSize } from '../fixtures/pointer.mjs'; import { ROSTER_6, recordDecision, slotMarks } from '../fixtures/individual.mjs'; import * as T from '../fixtures/team.mjs'; import { - enterTournament, seedCompetitions, readScoresTab, operatorDevice, markPage, pageMark, + seedCompetitions, readScoresTab, readScoresRows, operatorDevice, markPage, pageMark, competitionPicker, officiatingName, pickCompetition, nudges, nudgeLines, courtState, upcomingRow, chooseCourt, runningOnCourt, playTeamMatch, liveBout, } from '../fixtures/shared-court.mjs'; @@ -191,7 +192,7 @@ test.describe('moved-match', () => { // 5b. The console's own Start match: the Teams bout goes back to the queue // with its score (bc-sbq) and the moved match starts. One bout runs on B. await upNextCard(B).getByRole('button', { name: 'Start match' }).tap(); - await expect.poll(async () => ((await courtState(B)).editor || {}).names, { timeout: 15_000 }).toEqual([moved.shiro, moved.aka]); + await expect.poll(async () => ((await courtState(B)).editor || {}).names, { timeout: EXPECT_TIMEOUT_MS }).toEqual([moved.shiro, moved.aka]); const oneRunning = await runningOnCourt(H, 'B'); const bLive = await courtState(B); row({ step: '5b console start', action: 'Start match (Up next) while the Teams bout is fought', variant: 'correct path (bc-sbq: the running bout is sent back)', @@ -224,7 +225,7 @@ test.describe('moved-match', () => { await T.finishTeam(hed); // Court B has seen the finish (its Completed list holds it) before it is // asked whether it stayed on Teams: read earlier, it would prove nothing. - await expect.poll(async () => (await courtState(B)).completed, { timeout: 15_000 }).toBeGreaterThan(completedOnB); + await expect.poll(async () => (await courtState(B)).completed, { timeout: EXPECT_TIMEOUT_MS }).toBeGreaterThan(completedOnB); const bHeld = await courtState(B); row({ step: '6 finish elsewhere', action: 'head table finishes the Teams semi-final', variant: 'two devices', headFinishLabel, courtB: bHeld, shot: await shot(B, 'court-B-after-head-table-finish') }); @@ -238,7 +239,7 @@ test.describe('moved-match', () => { await expect.poll(async () => { const s = await courtState(B); return !!(s.upNext || (s.editor && !s.editor.pill)); - }, { timeout: 15_000 }).toBe(true); + }, { timeout: EXPECT_TIMEOUT_MS }).toBe(true); const sf2 = await playTeamMatch(B); row({ step: '6 finish elsewhere', action: 'court B plays the other semi-final', variant: 'correct path', pair: sf2 }); @@ -261,7 +262,8 @@ test.describe('moved-match', () => { // Teams has nothing left on B: B still shows Teams, now with the amber // line, and switches only on the tap. - await expect.poll(async () => (await nudgeLines(B)).map((l) => l.amber), { timeout: 15_000 }).toEqual([true]); + await expect.poll(async () => (await nudgeLines(B)).map((l) => l.amber), { timeout: EXPECT_TIMEOUT_MS }).toEqual([true]); + // Deliberate: the court must NOT switch by itself, so the time is the evidence. await B.waitForTimeout(3000); const bDone = await courtState(B); const amber = await nudgeLines(B); @@ -288,7 +290,7 @@ test.describe('moved-match', () => { await H.goto(`/admin/competition/${ids.IND}/scores`); const hRow = H.locator('.score-edit-row').filter({ hasText: secondPair.shiro }).filter({ hasText: secondPair.aka }).first(); await chooseCourt(hRow, 'B'); - await H.waitForTimeout(400); + await rendered(H); const confirmAsked = await H.locator('.modal[role="dialog"], .shiaijo-move-confirm').isVisible().catch(() => false); await expect.poll(async () => queueNames(await courtState(A)).includes(pairText(secondPair)), { timeout: 10_000 }).toBe(false); await expect.poll(async () => queueNames(await courtState(B)).includes(pairText(secondPair)), { timeout: 10_000 }).toBe(true); @@ -309,11 +311,17 @@ test.describe('moved-match', () => { const startOf = async (pair) => upNextCard(B).filter({ hasText: pair.shiro }).filter({ hasText: pair.aka }) .or(upcomingRow(B, pair)).first().getByRole('button', { name: 'Start match' }); await (await startOf(moved)).tap(); - await expect.poll(async () => ((await courtState(B)).editor || {}).names, { timeout: 15_000 }).toEqual([moved.shiro, moved.aka]); + await expect.poll(async () => ((await courtState(B)).editor || {}).names, { timeout: EXPECT_TIMEOUT_MS }).toEqual([moved.shiro, moved.aka]); // 8a. At once, before the court's list has caught up with the start just made. - await (await startOf(secondPair)).tap(); - await B.waitForTimeout(2000); const notice = async () => (await B.locator('.shiaijo-upnext__error[role="alert"]').allInnerTexts()).join(' | '); + const courtB = async () => [await notice(), await runningOnCourt(H, 'B')]; + // One plain read, not a settled one: the tap must still come at once. + const beforeSecondStart = await courtB(); + await (await startOf(secondPair)).tap(); + // A start the server refuses shows its notice once answered; one it accepts + // shows the bout running: the row records whichever, so wait for the court + // to leave the state before the tap, and then to settle. + await untilStable(courtB, { from: beforeSecondStart }); row({ step: '8 second start', action: `Start match on ${pairText(secondPair)} at once after starting ${pairText(moved)}`, variant: 'V2 hurried second start (inside the list refresh)', notice: await notice(), courtB: await courtState(B), runningOnB: await runningOnCourt(H, 'B'), shot: await shot(B, 'court-B-second-start-at-once') }); @@ -325,9 +333,10 @@ test.describe('moved-match', () => { row({ step: '8 competitor busy', action: '8a already sent the first moved match back', variant: 'note' }); return; } - await expect.poll(async () => ((await courtState(B)).upNext || {}).names, { timeout: 15_000 }).toEqual([secondPair.shiro, secondPair.aka]); + await expect.poll(async () => ((await courtState(B)).upNext || {}).names, { timeout: EXPECT_TIMEOUT_MS }).toEqual([secondPair.shiro, secondPair.aka]); + const beforeBusyStart = await untilStable(courtB); await (await startOf(secondPair)).tap(); - await B.waitForTimeout(2000); + await untilStable(courtB, { from: beforeBusyStart }); row({ step: '8 competitor busy', action: `Start match on ${pairText(secondPair)} (one of them fights on A) while ${pairText(moved)} runs on B`, variant: 'V5 lost place (the moved match\'s competitor is elsewhere)', notice: await notice(), courtB: await courtState(B), runningOnB: await runningOnCourt(H, 'B'), shot: await shot(B, 'court-B-start-refused-competitor-busy') }); @@ -353,7 +362,7 @@ test.describe('moved-match', () => { const runs = async (pair) => samePair(await liveBout(B), pair); if (!(await runs(moved))) { await startButton(B, moved).tap(); - await expect.poll(() => runs(moved), { timeout: 15_000 }).toBe(true); + await expect.poll(() => runs(moved), { timeout: EXPECT_TIMEOUT_MS }).toBe(true); } await awardIppon(B, 'shiro', 'M'); const marks = await slotMarks(B, 'shiro'); @@ -364,10 +373,12 @@ test.describe('moved-match', () => { const r = [s.upNext, ...s.upcoming].filter(Boolean).find((x) => x.names.some((n) => busy.includes(n))); blocked = r ? { shiro: r.names[0], aka: r.names[1] } : null; return !!blocked; - }, { timeout: 15_000 }).toBe(true); + }, { timeout: EXPECT_TIMEOUT_MS }).toBe(true); await startButton(B, blocked).tap(); const refusal = B.locator('[role="alert"], .toast').filter({ hasText: /is fighting now in .+ on Shiaijo A\. Finish that match first\./ }).first(); await expect(refusal).toBeVisible({ timeout: 10_000 }); + // Deliberate: the running bout must STAY running, and a late send back would + // pass a single read, so the time is the evidence. await B.waitForTimeout(1500); expect(await runs(moved), 'the moved match is still running on B').toBe(true); expect(await slotMarks(B, 'shiro')).toEqual(marks); @@ -380,7 +391,7 @@ test.describe('moved-match', () => { await hastyShiaijoConfirm(A); await expect.poll(() => liveBout(A), { timeout: 10_000 }).toBeNull(); await startButton(B, blocked).tap(); - await expect.poll(() => runs(blocked), { timeout: 15_000 }).toBe(true); + await expect.poll(() => runs(blocked), { timeout: EXPECT_TIMEOUT_MS }).toBe(true); const onBafter = await runningOnCourt(H, 'B'); expect(onBafter).toHaveLength(1); expect(onBafter[0]).toMatchObject({ shiro: blocked.shiro, aka: blocked.aka }); @@ -389,54 +400,91 @@ test.describe('moved-match', () => { // bc-adna (operator ruling 2026-10-12: "The operator's view is where scoring // is done and has a queue. In Admin view, changes are not tied/blocked by // the court status, there's no queue or advancing"). On the competition's - // Scores tab, with other Individual matches waiting on A: Finish reads plain - // Finish and finishing starts nothing, and a fusenpai recorded there starts - // nothing either, so no court_busy refusal follows it. The other half of the - // acceptance (an admin write is never refused for court status) is not - // exercised: reaching it needs a second bout running on the court, which - // bc-mvrq's fix rules out, and what an admin Start on a busy court then does - // is for the bead's plan to settle. The same fix retires shared-shiaijo's S9 - // assertions ("Finish + Start Next →" and the start after a fusenpai on the - // Scores page) and the Scores tab refusal in step 5a above. Runs after + // Scores tab, with other Individual matches waiting on A, the bead's fixed + // behaviour is three things, each asserted on its own (soft, so one failing + // does not hide the others, and the network ones sit in the finally so they + // are read whatever happened before): Finish reads plain Finish; the tab + // sends no /score start by itself (a start is a /score write whose request + // carries startOnly), after Finish or after a fusenpai, so nothing starts on + // A; and no start it sends is refused for the court's status (court_busy), + // whoever asked for it. The refusal half is read from + // the network log, because the console policy would otherwise let the toast + // of the bead's report pass. The operator's own Start match in the editor + // sends no startOnly (it writes the editor's own board), so it never counts + // as a start by itself. The same fix retires shared-shiaijo's + // S9 assertions ("Finish + Start Next →" and the start after a fusenpai on + // the Scores page) and the Scores tab refusal in step 5a above. Runs after // bc-stpf, but takes court A as it finds it: the running Individual bout // there, or else one it starts from the Scores tab whose competitors are not // fighting on B. test.fixme('bc-adna: the Scores tab never starts a match by itself', async () => { - const onB = (await runningOnCourt(H, 'B')).flatMap((m) => [m.shiro, m.aka]); - const rows = await readScoresTab(H, ids.IND); - const waitingOnA = rows.filter((m) => m.court === 'A' && m.status === 'scheduled' && !onB.includes(m.shiro) && !onB.includes(m.aka)); - let first = rows.find((m) => m.court === 'A' && m.status === 'running'); - const startsHere = !first; - if (startsHere) first = waitingOnA.shift(); - expect(first, 'an Individual match on A to score').toBeTruthy(); - expect(waitingOnA.length, 'two more Individual matches wait on A after it').toBeGreaterThan(1); - const ed = H.locator(EDITOR); - const open = async (m) => { - await H.goto(`/admin/competition/${ids.IND}/scores`); - await H.locator('.score-edit-row').filter({ hasText: m.shiro }).filter({ hasText: m.aka }).first().getByRole('button', { name: /^Score$/ }).tap(); - await expect(ed).toBeVisible(); + const scoreWrites = []; + const pending = []; + const onResponse = async (res) => { + const req = res.request(); + if (req.method() !== 'PUT' || !/\/matches\/[^/]+\/score$/.test(new URL(res.url()).pathname)) return; + const sent = req.postData() || ''; + if (!/"status"\s*:\s*"running"/.test(sent)) return; + // A start is the write whose request carries startOnly (startPatch). + let isStart = false; + try { isStart = JSON.parse(sent).startOnly === true; } catch { /* not JSON: not a start */ } + const body = res.status() === 409 ? await res.text().catch(() => '') : ''; + scoreWrites.push({ + url: res.url(), + status: res.status(), + courtBusy: /"error"\s*:\s*"court_busy"/.test(body), + isStart, + }); }; - const start = async () => { - const btn = ed.getByRole('button', { name: 'Start match' }); - await settled(btn); - await btn.tap(); - await expect(finishButton(H, EDITOR)).toBeVisible(); - }; - await open(first); - if (startsHere) await start(); - await expect(finishButton(H, EDITOR)).toHaveText('Finish'); - await awardIppon(H, 'shiro', 'M', EDITOR); - await finishMatch(H, EDITOR); - await H.waitForTimeout(1500); - expect(await runningOnCourt(H, 'A'), 'nothing started on A after Finish').toHaveLength(0); - // A fusenpai on the next one, started by the operator. - const second = waitingOnA.find((m) => !samePair(m, first)); - await open(second); - await start(); - await recordDecision(H, 'fusenpai', { side: 'aka', root: EDITOR }); - await H.waitForTimeout(2000); - await expect(H.locator('.toast').filter({ hasText: /is running/ })).toHaveCount(0); - await expect.poll(async () => ((await readScoresTab(H, ids.IND)).find((m) => samePair(m, second)) || {}).status, { timeout: 10_000 }).toBe('completed'); - expect(await runningOnCourt(H, 'A'), 'nothing started on A after the fusenpai').toHaveLength(0); + const listener = (res) => { pending.push(onResponse(res)); }; + H.on('response', listener); + try { + const onB = (await runningOnCourt(H, 'B')).flatMap((m) => [m.shiro, m.aka]); + const rows = await readScoresTab(H, ids.IND); + const waitingOnA = rows.filter((m) => m.court === 'A' && m.status === 'scheduled' && !onB.includes(m.shiro) && !onB.includes(m.aka)); + let first = rows.find((m) => m.court === 'A' && m.status === 'running'); + const startsHere = !first; + if (startsHere) first = waitingOnA.shift(); + expect(first, 'an Individual match on A to score').toBeTruthy(); + expect(waitingOnA.length, 'two more Individual matches wait on A after it').toBeGreaterThan(1); + const ed = H.locator(EDITOR); + const open = async (m) => { + await H.goto(`/admin/competition/${ids.IND}/scores`); + await H.locator('.score-edit-row').filter({ hasText: m.shiro }).filter({ hasText: m.aka }).first().getByRole('button', { name: /^Score$/ }).tap(); + await expect(ed).toBeVisible(); + }; + // The operator's own Start match: the editor's write carries no startOnly. + const start = async () => { + const btn = ed.getByRole('button', { name: 'Start match' }); + await settled(btn); + await btn.tap(); + await expect(finishButton(H, EDITOR)).toBeVisible(); + }; + await open(first); + if (startsHere) await start(); + await expect.soft(finishButton(H, EDITOR), 'the Scores tab editor offers plain Finish').toHaveText('Finish'); + await awardIppon(H, 'shiro', 'M', EDITOR); + await finishMatch(H, EDITOR); + // Deliberate: nothing may start by itself, so the time is the evidence. + await H.waitForTimeout(1500); + expect.soft(await runningOnCourt(H, 'A'), 'nothing started on A after Finish').toHaveLength(0); + // A fusenpai on the next one, started by the operator. + const second = waitingOnA.find((m) => !samePair(m, first)); + await open(second); + await start(); + await recordDecision(H, 'fusenpai', { side: 'aka', root: EDITOR }); + // Deliberate: no start may follow (no toast, nothing running), so the time is the evidence. + await H.waitForTimeout(2000); + await expect.soft(H.locator('.toast').filter({ hasText: /is running/ })).toHaveCount(0); + // One navigation; the poll reads the tab as the app follows the fusenpai. + await H.goto(`/admin/competition/${encodeURIComponent(ids.IND)}/scores`); + await expect.poll(async () => ((await readScoresRows(H)).find((m) => samePair(m, second)) || {}).status, { timeout: 10_000 }).toBe('completed'); + expect.soft(await runningOnCourt(H, 'A'), 'nothing started on A after the fusenpai').toHaveLength(0); + } finally { + await Promise.all(pending); + H.off('response', listener); + expect.soft(scoreWrites.filter((w) => w.courtBusy), 'no start sent from the Scores tab was refused for the court').toEqual([]); + expect.soft(scoreWrites.filter((w) => w.isStart), 'the Scores tab sent no start by itself').toEqual([]); + } }); }); diff --git a/scripts/e2e/journeys/organiser-admin.spec.mjs b/scripts/e2e/journeys/organiser-admin.spec.mjs new file mode 100644 index 000000000..7b03e686a --- /dev/null +++ b/scripts/e2e/journeys/organiser-admin.spec.mjs @@ -0,0 +1,309 @@ +// The organiser's side jobs, in file mode: the Excel download of a competition +// with a result, importing competitions from a folder, the tournament logo and +// a sponsor, and the forgotten-password reset. Locked mode is its own spec +// file (organiser-locked.spec.mjs), because it is a different server. +// +// One server for the file; the tests run in order and each leaves the server +// for the next. The reset goes LAST: it changes the admin password every +// earlier test signs in with. +// +// Everything is seeded through the interface. The files the journey feeds to +// the app's file inputs (an import bundle, two PNGs) are written to a +// throwaway folder at run time; nothing binary is committed. +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { test, expect } from '../fixtures/test.mjs'; +import { OPERATOR_DEVICE, PUBLIC_DEVICE, EXPECT_TIMEOUT_MS } from '../fixtures/devices.mjs'; +import { journeyShots } from '../fixtures/shots.mjs'; +import { auditLog } from '../fixtures/audit.mjs'; +import { enterTournament, login, signOut } from '../fixtures/setup.mjs'; +import { createCompetition } from '../fixtures/wizard.mjs'; +import { generateDraw, pasteRoster, startCompetition } from '../fixtures/competition.mjs'; +import { openShiaijo } from '../fixtures/shiaijo.mjs'; +import { playRunningBout } from '../fixtures/individual.mjs'; +import { openViewer, openTvBoard, tvBoard } from '../fixtures/public.mjs'; +import { dwell } from '../fixtures/pace.mjs'; +import { + addSponsor, dayFromNow, downloadResultsWorkbook, importFiles, importResultRows, loadedImage, openEditTournament, + pngBytes, readWorkbook, resetPassword, sheetCells, todayDMY, uploadLogo, writeImportBundle, +} from '../fixtures/organiser.mjs'; + +const ROSTER = [ + ['Akira Tanaka', 'Gyokusen'], ['Ben Carter', 'Thames'], ['Chie Mori', 'Musashi'], ['Dan Evans', 'Seishinkan'], +]; +const IMPORTED = [ + ['Emi Hoshino', 'Kenshikan'], ['Fumi Yamada', 'Shudokan'], ['Gen Ota', 'Kenshikan'], +]; +const IMPORTED_B = [ + ['Hiro Sasaki', 'Seiwakan'], ['Ino Mizuno', 'Tenshin'], +]; +const NEW_PASSWORD = 'e2e-after-reset'; +const tournamentDay = dayFromNow(45); + +test.describe.configure({ mode: 'serial' }); + +test.describe('organiser admin (file mode)', () => { + let shot; + let work; // throwaway folder for the files fed to file inputs + let compId; // the competition with a result + const audit = auditLog('organiser-admin'); + const { record } = audit; + + test.beforeAll(() => { + shot = journeyShots('organiser-admin'); + work = fs.mkdtempSync(path.join(os.tmpdir(), 'bc-e2e-organiser-')); + }); + test.afterAll(() => { + audit.flush(); + if (work) fs.rmSync(work, { recursive: true, force: true }); + }); + + test('Excel export: a competition with a result downloads as a workbook', async ({ page }) => { + let pair; + let unplayed; + await test.step('seed a knockout of four and play one bout', async () => { + // Not today: a row the import gives no date must take THIS day. + await enterTournament(page, { name: 'Organiser Cup', courts: 2, date: tournamentDay.iso }); + compId = await createCompetition(page, { + name: 'Export Men', kind: 'individual', format: 'knockout', courts: ['A'], numberPrefix: 'X', + }); + await pasteRoster(page, compId, ROSTER); + await generateDraw(page, compId); + await startCompetition(page, compId); + // The export before any bout is played: the control the played one is + // measured against. + unplayed = (await downloadResultsWorkbook(page, compId)).bytes; + await openShiaijo(page, 'A'); + pair = await playRunningBout(page); + await shot(page, 'bout-played'); + }); + + await test.step('download the results workbook from the Export section', async () => { + const dl = await downloadResultsWorkbook(page, compId); + await shot(page, 'export-section'); + const book = readWorkbook(dl.bytes); + record({ journey: 'organiser-admin', step: 'excel export', file: dl.name, bytes: dl.bytes.length, sheets: book.sheets }); + expect(dl.name).toBe(`results-${compId}.xlsx`); + expect(book.sheets.length).toBeGreaterThan(0); + expect(book.sheets).toContain('Elimination Matches'); + for (const [name] of ROSTER) expect(book.text, `${name} is in the workbook`).toContain(name); + expect(book.text).toContain(pair.shiro); + expect(book.text).toContain(pair.aka); + // What only a recorded result writes (the names alone are on the blank + // draw too): the winner's name carried into the next round's slot, and + // the waza letter the bout was won with in a score cell. Both are + // counted against the export taken before the bout was played. + const winner = pair[pair.winner]; + const count = (bytes, text) => Object.values(sheetCells(bytes, 'Elimination Matches')).filter((v) => v === text).length; + expect(count(dl.bytes, winner), `${winner} is carried into the next round`).toBeGreaterThan(count(unplayed, winner)); + expect(count(dl.bytes, 'M'), 'the men ippon is written in a score cell').toBeGreaterThan(count(unplayed, 'M')); + }); + }); + + test('Import competitions: a folder of CSVs lands on the dashboard and the public page', async ({ page, browser, baseURL }) => { + // Two rows: one with a date (it must be a tournament day: the import refuses + // any other), and one without (the manifest's date is optional and a row + // without one takes the tournament's). + const files = writeImportBundle(path.join(work, 'bundle'), [ + { id: 'imp-kyu', name: 'Imported Kyu', kind: 'individual', format: 'knockout', date: tournamentDay.dmy, roster: IMPORTED }, + { id: 'imp-nodate', name: 'Imported No Date', kind: 'individual', format: 'mixed', roster: IMPORTED_B }, + ]); + await login(page, 'e2e-operator'); + await test.step('import through the page', async () => { + await importFiles(page, files); + await shot(page, 'import-results'); + const rows = importResultRows(page); + await expect(rows).toHaveCount(2); + for (const name of ['Imported Kyu', 'Imported No Date']) { + const row = rows.filter({ hasText: name }); + await expect(row).toContainText('imported'); + await expect(row).not.toContainText('not imported'); + } + }); + + await test.step('the dashboard lists it and its roster is there', async () => { + await expect(page).toHaveURL(/\/admin$/, { timeout: EXPECT_TIMEOUT_MS }); + await expect(page.getByText('Imported Kyu').first()).toBeVisible(); + await expect(page.getByText('Export Men').first()).toBeVisible(); + await page.goto('/admin/competition/imp-kyu/participants'); + for (const [name] of IMPORTED) await expect(page.getByText(name).first()).toBeVisible(); + await shot(page, 'imported-roster'); + }); + + await test.step('the row with no date took the tournament date and can be drawn (bc-imdt)', async () => { + await page.goto('/admin/competition/imp-nodate'); + await expect(page.getByRole('button', { name: 'Generate draw' })).toBeEnabled(); + await expect(page.getByText(/Cannot start: invalid date/)).toHaveCount(0); + // The tournament's day, written by the page's own formatter, which is + // not today's: only taking the tournament's date can put it there. + const [day, today] = await page.evaluate(([a, b]) => [window.formatDate(a), window.formatDate(b)], [tournamentDay.dmy, todayDMY()]); + expect(day).not.toBe(today); + await expect(page.locator('.page-head__sub')).toContainText(day); + await shot(page, 'imported-no-date'); + }); + + await test.step('a spectator sees it listed, and its roster once the imported roster is drawn and started', async () => { + const spectator = await browser.newContext({ ...PUBLIC_DEVICE, baseURL }); + try { + const phone = await spectator.newPage(); + await phone.goto('/'); + await expect(phone.locator('.viewer__body')).toBeVisible(); + await expect(phone.getByText('Imported Kyu').first()).toBeVisible(); + await openViewer(phone, 'imp-kyu'); + await expect(phone.locator('.viewer__body')).toContainText('Not started yet'); + + // The imported roster is a real roster: it draws and starts. + await page.goto('/admin/competition/imp-kyu'); + await generateDraw(page, 'imp-kyu'); + await startCompetition(page, 'imp-kyu'); + await shot(page, 'imported-started'); + await openViewer(phone, 'imp-kyu'); + await phone.getByRole('tab', { name: 'Bracket' }).or(phone.getByRole('button', { name: 'Bracket', exact: true })).first().tap(); + for (const [name] of IMPORTED) await expect(phone.locator('.viewer__body')).toContainText(name); + await shot(phone, 'viewer-imported', { fullPage: true }); + } finally { + await spectator.close(); + } + }); + }); + + test('Branding and sponsors: the logo and a sponsor show on the public page', async ({ page, browser, baseURL }) => { + const logo = path.join(work, 'logo.png'); + const sponsor = path.join(work, 'sponsor.png'); + fs.writeFileSync(logo, pngBytes(3, 2)); + fs.writeFileSync(sponsor, pngBytes(5, 4, [20, 90, 200])); + await login(page, 'e2e-operator'); + + await test.step('upload a logo and add a sponsor on Edit details', async () => { + await openEditTournament(page); + await uploadLogo(page, logo); + await addSponsor(page, { name: 'Acme Dojo Supplies', file: sponsor }); + await shot(page, 'edit-details-uploaded', { fullPage: true }); + }); + + await test.step('the admin top bar shows the uploaded logo', async () => { + await page.goto('/admin'); + const img = page.locator('.topbar__logo').first(); + const size = await loadedImage(img); + expect(size).toMatchObject({ w: 3, h: 2 }); + }); + + await test.step('a spectator phone shows the logo and the sponsor strip', async () => { + const spectator = await browser.newContext({ ...PUBLIC_DEVICE, baseURL }); + try { + const phone = await spectator.newPage(); + await phone.goto('/'); + await expect(phone.locator('.viewer__body')).toBeVisible(); + expect(await loadedImage(phone.locator('.viewer__logo').first())).toMatchObject({ w: 3, h: 2 }); + const strip = phone.getByRole('complementary', { name: 'Sponsors' }); + await strip.scrollIntoViewIfNeeded(); + expect(await loadedImage(strip.getByRole('img', { name: 'Acme Dojo Supplies' }))).toMatchObject({ w: 5, h: 4 }); + await shot(phone, 'viewer-logo-and-sponsor', { fullPage: true }); + + // The docs: sponsor images appear on the public viewer only, never on + // the TV board. + // The board gets the sponsors with the tournament it loads: wait for that + // answer, and see the sponsor in it, before its absence means anything. + const tournamentLoaded = phone.waitForResponse((r) => new URL(r.url()).pathname === '/api/viewer/tournament' && r.ok()); + await openTvBoard(phone, 'A'); + expect(JSON.stringify(await (await tournamentLoaded).json()), 'the board was sent the sponsor').toContain('Acme Dojo Supplies'); + await expect(tvBoard(phone).getByRole('img', { name: 'Acme Dojo Supplies' })).toHaveCount(0); + await expect(phone.getByRole('complementary', { name: 'Sponsors' })).toHaveCount(0); + await shot(phone, 'tv-board-no-sponsor'); + + // The docs also list the lobby display for the logo. Recorded, and held + // by the fixme below. + await phone.goto('/display?court=all'); + await expect(phone.getByTestId('lobby-root')).toBeVisible(); + record({ journey: 'organiser-admin', step: 'logo on the lobby display', logoImages: await phone.locator('img[alt="Tournament logo"]').count() }); + await shot(phone, 'lobby-display'); + } finally { + await spectator.close(); + } + }); + }); + + test.fixme('bc-lblg: the lobby display shows the tournament logo only when the organiser turns that setting on', async ({ page, browser, baseURL }) => { + // bc-lblg (operator decision 2026-10-13): the logo on the lobby display is an + // OPTIONAL organiser setting. run-tournament.md (Branding and sponsors) says + // the logo is "shown on the viewer, the lobby displays, and the admin + // screens"; today /display?court=all (display_lobby.jsx) draws no logo at + // all. The setting does not exist yet, so the step is written against the + // most likely control: a checkbox on Edit tournament details whose label + // contains "logo on the lobby display", saved by Save changes. Change the + // label here to the real one when bc-lblg lands. Runs after the upload test, + // so a logo is uploaded. + const setLogoOnLobby = async (show) => { + await login(page, 'e2e-operator'); + await openEditTournament(page); + const toggle = page.getByLabel(/logo on the lobby display/i); + if (show) await toggle.check(); else await toggle.uncheck(); + await page.getByRole('button', { name: 'Save changes' }).tap(); + await expect(toggle).toBeChecked({ checked: show }); + }; + const spectator = await browser.newContext({ ...PUBLIC_DEVICE, baseURL }); + try { + const phone = await spectator.newPage(); + const lobbyLogo = async () => { + await phone.goto('/display?court=all'); + await expect(phone.getByTestId('lobby-root')).toBeVisible(); + return phone.getByRole('img', { name: 'Tournament logo' }); + }; + await setLogoOnLobby(true); + await expect(await lobbyLogo()).toBeVisible(); + await setLogoOnLobby(false); + await expect(await lobbyLogo()).toHaveCount(0); + } finally { + await spectator.close(); + } + }); + + test('Reset in file mode: Forgot password sets a new one from the loopback browser', async ({ page, browser, baseURL }) => { + // A second organiser signed in on another device: the reset signs it out. + const other = await browser.newContext({ ...OPERATOR_DEVICE, baseURL }); + try { + const otherPage = await other.newPage(); + await login(otherPage, 'e2e-operator'); + + await test.step('from the sign-in dialog, Forgot password? leads to the reset page', async () => { + await page.goto('/admin'); + const dialog = page.locator('.modal.auth'); + await expect(dialog.getByText('Admin sign in')).toBeVisible(); + // The sign-in dialog ignores a tap inside the bounce window of its opening. + await dwell(page, Date.now()); + const forgot = dialog.getByRole('button', { name: 'Forgot password?' }); + await expect(forgot).toBeVisible(); + await forgot.tap(); + const confirm = page.locator('.modal[role="dialog"]').filter({ has: page.locator('.modal__foot') }).last(); + await confirm.waitFor({ state: 'visible' }); + const confirmShown = Date.now(); + await expect(confirm).toContainText('sign out all other admins'); + await dwell(page, confirmShown); + await confirm.getByRole('button', { name: 'Reset password' }).tap(); + await expect(page).toHaveURL(/\/reset$/); + await expect(page.getByRole('heading', { name: 'Reset admin password' })).toBeVisible(); + await shot(page, 'reset-page'); + }); + + await test.step('choose a new password: no old one is asked for, and the browser is signed in', async () => { + await resetPassword(page, NEW_PASSWORD); + await expect(page.getByRole('button', { name: 'Sign out' })).toBeVisible(); + await shot(page, 'after-reset'); + // The tournament and its competitions are untouched: only the password changed. + await page.goto('/admin'); + await expect(page.getByText('Export Men').first()).toBeVisible(); + await expect(page.getByText('Imported Kyu').first()).toBeVisible(); + }); + + await test.step('the new password signs in; the other organiser is asked to sign in again', async () => { + await signOut(page); + await login(page, NEW_PASSWORD); + await expect(otherPage.locator('.modal.auth')).toBeVisible({ timeout: EXPECT_TIMEOUT_MS }); + await shot(otherPage, 'other-admin-signed-out'); + }); + } finally { + await other.close(); + } + }); +}); diff --git a/scripts/e2e/journeys/organiser-locked.spec.mjs b/scripts/e2e/journeys/organiser-locked.spec.mjs new file mode 100644 index 000000000..39eff12df --- /dev/null +++ b/scripts/e2e/journeys/organiser-locked.spec.mjs @@ -0,0 +1,87 @@ +// Locked mode: the server is started with --lock-password and a bcrypt hash in +// TOURNAMENT_PASSWORD_HASH (operating-modes.md, "Locked mode"), so this is its +// own spec file: a file's server is started once, and the file-mode journeys +// (organiser-admin.spec.mjs) need a different one. +// +// The hash is made by the app's own `hash-password` command, which reads the +// password from standard input, exactly as the docs tell an organiser to. +// The server is started with these options through serverOptionsFor +// (fixtures/test.mjs). +// +// What is asserted: the first-run form says the password is the one behind the +// hash, the sign-in dialog accepts it, offers no "Forgot password?", and the +// /reset page shows the operator-disabled message instead of a form. A wrong +// password is not tried: the refusal is a 401 the browser logs as a console +// error, and the console policy lets none through. +import { execFileSync } from 'node:child_process'; +import { BIN } from '../../screenshots/lib/server.mjs'; +import { fileURLToPath } from 'node:url'; +import { test, expect, serverOptionsFor } from '../fixtures/test.mjs'; +import { journeyShots } from '../fixtures/shots.mjs'; +import { auditLog } from '../fixtures/audit.mjs'; +import { createTournament, login } from '../fixtures/setup.mjs'; +import { dwell } from '../fixtures/pace.mjs'; + +const PASSWORD = 'e2e-locked-secret'; + +// Started with --lock-password and the hash of PASSWORD, made when the file's +// server starts (see serverOptionsFor in fixtures/test.mjs). +serverOptionsFor(fileURLToPath(import.meta.url), () => { + const hash = execFileSync(BIN, ['hash-password'], { input: PASSWORD, encoding: 'utf8' }).trim(); + if (!/^\$2[aby]\$/.test(hash)) throw new Error('hash-password did not print a bcrypt hash'); + return { args: ['--lock-password'], env: { TOURNAMENT_PASSWORD_HASH: hash } }; +}); + +test.describe.configure({ mode: 'serial' }); + +test.describe('organiser locked mode', () => { + let shot; + const audit = auditLog('organiser-locked'); + const { record } = audit; + + test.beforeAll(() => { + shot = journeyShots('organiser-locked'); + }); + test.afterAll(() => audit.flush()); + + test('sign-in works with the hashed password and offers no Forgot password', async ({ page }) => { + await test.step('the first-run form names the password behind the hash', async () => { + await page.goto('/'); + await expect(page.getByRole('heading', { name: 'Welcome to Bracket Creator' })).toBeVisible(); + await expect(page.getByLabel('Admin Password (from TOURNAMENT_PASSWORD_HASH)')).toBeVisible(); + await expect(page.getByText('This server is running in locked mode.')).toBeVisible(); + await shot(page, 'first-run-locked'); + }); + + await test.step('create the tournament with that password', async () => { + await createTournament(page, { name: 'Locked Cup', courts: 1, password: PASSWORD }); + await shot(page, 'created'); + }); + + await test.step('the sign-in dialog has no Forgot password?, and the password signs in', async () => { + await page.getByRole('button', { name: 'Sign out' }).tap(); + // The dialog decides on Forgot password? from the auth config: wait for + // that answer, so its absence is not just the config not having come yet. + const authConfig = page.waitForResponse((r) => new URL(r.url()).pathname === '/api/auth-config' && r.ok()); + await page.goto('/admin'); + const dialog = page.locator('.modal.auth'); + await expect(dialog.getByText('Admin sign in')).toBeVisible(); + await authConfig; + await dwell(page, Date.now()); + await expect(dialog.getByRole('button', { name: 'Forgot password?' })).toHaveCount(0); + await shot(page, 'sign-in-no-forgot'); + await login(page, PASSWORD); + await expect(page.getByRole('heading', { level: 1, name: 'Locked Cup' })).toBeVisible(); + record({ journey: 'organiser-locked', step: 'sign in', signedIn: true }); + }); + }); + + test('the /reset page says the operator disabled it', async ({ page }) => { + await page.goto('/reset'); + await expect(page.getByRole('heading', { name: 'Password reset disabled' })).toBeVisible(); + await expect(page.getByText('password reset disabled by the operator', { exact: false })).toBeVisible(); + await expect(page.locator('#reset-password')).toHaveCount(0); + await expect(page.getByRole('button', { name: 'Reset password' })).toHaveCount(0); + await shot(page, 'reset-disabled'); + }); +}); diff --git a/scripts/e2e/journeys/organiser-roster.spec.mjs b/scripts/e2e/journeys/organiser-roster.spec.mjs new file mode 100644 index 000000000..f9aac8ec5 --- /dev/null +++ b/scripts/e2e/journeys/organiser-roster.spec.mjs @@ -0,0 +1,297 @@ +// Organiser roster journey: what an organiser does with the roster around the +// draw, in an officiated tournament, all through the interface. +// +// 1. Check-in on the Registration desk (one by one, by search, by dojo) and +// the draw that follows: only checked-in competitors are drawn, and the +// desk's search finds a competitor by number. +// 2. Seeds typed before the draw: two competitors the unseeded draw puts in +// one pool are seeded, and the draw then keeps them in different pools. +// 3. The participant Edit dialog after the draw: a new name and dojo reach +// the court console, the bracket and the public viewer; the same name and +// dojo as another competitor is refused, the same name in another dojo is +// accepted. +// 4. Replacing a drawn competitor who has not fought with someone new: the +// newcomer takes the same slot, the old name is nowhere. +// +// One server for the file (serial); the tournament is made once, in beforeAll, +// and every test seeds its own competition so it reads as a story of its own. +import { test, expect } from '../fixtures/test.mjs'; +import { OPERATOR_DEVICE, PUBLIC_DEVICE } from '../fixtures/devices.mjs'; +import { journeyShots } from '../fixtures/shots.mjs'; +import { auditLog } from '../fixtures/audit.mjs'; +import { createTournament, login } from '../fixtures/setup.mjs'; +import { createCompetition } from '../fixtures/wizard.mjs'; +import { discardDraw, generateDraw, pasteRoster } from '../fixtures/competition.mjs'; +import { openShiaijo } from '../fixtures/shiaijo.mjs'; +import { openViewer } from '../fixtures/public.mjs'; +import { + openDesk, deskRows, deskRow, deskCheck, deskHeadline, deskSearch, toggleCheckIn, searchDesk, + groupByDojo, bulkCheckInDojo, openParticipants, setSeed, seedRankOf, openEdit, saveEdit, + editDialog, toastText, poolMembers, bracketSlots, bracketNames, openCompetitionTab, generateDrawExcluding, +} from '../fixtures/roster.mjs'; + +// Twelve entrants, four dojos of three, (name, dojo) unique. +const ROSTER_12 = [ + ['Aoi Sato', 'Gyokusen'], ['Gen Ota', 'Gyokusen'], ['Goro Abe', 'Gyokusen'], + ['Ben Carter', 'Thames'], ['Tom Hill', 'Thames'], ['Tia Moss', 'Thames'], + ['Chie Mori', 'Musashi'], ['Mei Ono', 'Musashi'], ['Max Reed', 'Musashi'], + ['Dan Evans', 'Seishinkan'], ['Sho Ito', 'Seishinkan'], ['Sia Lowe', 'Seishinkan'], +]; +// Eight entrants, four dojos of two. +const ROSTER_8 = [ + ['Akira Tanaka', 'Gyokusen'], ['Ben Carter', 'Thames'], ['Chie Mori', 'Musashi'], + ['Dan Evans', 'Seishinkan'], ['Eri Kato', 'Gyokusen'], ['Finn Hale', 'Thames'], + ['Goro Abe', 'Musashi'], ['Hana Ito', 'Seishinkan'], +]; + +const audit = auditLog('organiser-roster'); +const { record } = audit; +let shot; +const ids = {}; + +test.describe.configure({ mode: 'serial' }); + +test.describe('organiser roster', () => { + test.beforeAll(async ({ browser, serverForFile }, testInfo) => { + shot = journeyShots('organiser-roster'); + const server = await serverForFile(testInfo.file); + const ctx = await browser.newContext({ ...OPERATOR_DEVICE, baseURL: server.base }); + try { + const page = await ctx.newPage(); + await createTournament(page, { name: 'Roster Cup', courts: 2 }); + } finally { + await ctx.close(); + } + }); + + test.afterAll(() => audit.flush()); + + test('check-in decides who is drawn, and the desk finds a number', async ({ page }) => { + await login(page); + ids.checkIn = await createCompetition(page, { + name: 'Check-in Cup', kind: 'individual', format: 'knockout', courts: ['A'], numberPrefix: 'K', checkIn: true, + }); + await pasteRoster(page, ids.checkIn, ROSTER_12); + + await openDesk(page, 'Check-in Cup'); + await expect(deskRows(page)).toHaveCount(12); + await expect(deskHeadline(page)).toContainText('0 of 12'); + await shot(page, 'desk-nobody-in'); + + // One at a time, from the row's own box. + await toggleCheckIn(page, 'Aoi Sato'); + // By search: Enter checks in the top match. + await searchDesk(page, 'Tom Hill'); + await expect(deskRows(page)).toHaveCount(1); + await deskSearch(page).press('Enter'); + await expect(deskSearch(page)).toHaveValue(''); + await expect(deskCheck(page, 'Tom Hill')).toHaveAttribute('aria-checked', 'true'); + await expect(deskHeadline(page)).toContainText('2 of 12'); + + // In bulk: a whole dojo from its group header. Thames is left as it is, so + // two Thames competitors (Ben Carter, Tia Moss) never arrive. + await groupByDojo(page, true); + for (const dojo of ['Gyokusen', 'Musashi', 'Seishinkan']) await bulkCheckInDojo(page, dojo); + await expect(deskHeadline(page)).toContainText('10 of 12'); + await shot(page, 'desk-ten-in'); + + // "Unchecked only" leaves exactly the two who have not arrived. + await page.getByRole('button', { name: 'Unchecked only' }).tap(); + await expect(deskRows(page)).toHaveCount(2); + await expect(deskRow(page, 'Ben Carter')).toHaveCount(1); + await expect(deskRow(page, 'Tia Moss')).toHaveCount(1); + record({ journey: 'organiser-roster', action: 'check-in', checkedIn: 10, absent: ['Ben Carter', 'Tia Moss'] }); + + // The draw takes only the ten who are checked in. + const asked = await generateDrawExcluding(page, ids.checkIn, 2); + record({ journey: 'organiser-roster', action: 'generate draw confirm', asked }); + await openCompetitionTab(page, ids.checkIn, 'bracket'); + const names = await bracketNames(page); + const absent = ['Ben Carter', 'Tia Moss']; + const drawn = ROSTER_12.map(([n]) => n).filter((n) => !absent.includes(n)); + expect([...names].sort(), 'the bracket holds exactly the checked-in competitors').toEqual([...drawn].sort()); + await shot(page, 'bracket-checked-in-only'); + + // The desk's number search: bare digits find the competitor whose number + // contains them. Ten were drawn, so K1..K10; "10" is K10 alone. + await openDesk(page, 'Check-in Cup'); + await searchDesk(page, '10'); + await expect(deskRows(page)).toHaveCount(1); + await expect(deskRows(page).first().locator('.rd-tag--number .rd-tag__value')).toHaveText('K10'); + const found = (await deskRows(page).first().locator('.rd-row__name').innerText()).trim(); + expect(drawn, 'the K10 row is one of the drawn').toContain(found); + record({ journey: 'organiser-roster', action: 'desk number search', query: '10', found }); + await shot(page, 'desk-search-number'); + // The two who were left out carry no number. + await searchDesk(page, ''); + // The row is drawn before its tag is counted absent: a row not drawn yet + // has no tag either. + await expect(deskRow(page, 'Ben Carter')).toBeVisible(); + await expect(deskRow(page, 'Ben Carter').locator('.rd-tag--number')).toHaveCount(0); + await expect(deskRows(page).locator('.rd-tag--number')).toHaveCount(10); + }); + + test('seeds typed before the draw never share a pool', async ({ page }) => { + await login(page); + ids.seeds = await createCompetition(page, { + name: 'Seed Cup', kind: 'individual', format: 'mixed', poolSize: 4, poolWinners: 2, courts: ['A'], numberPrefix: 'S', + }); + await pasteRoster(page, ids.seeds, ROSTER_8); + + // The draw with no seeds typed is read first. It is what the seeds must + // change: two competitors it puts in the SAME pool are the pair to seed, + // so a rule that held anyway cannot pass for the seeds working. + await generateDraw(page, ids.seeds); + await openCompetitionTab(page, ids.seeds, 'pools'); + const unseeded = await poolMembers(page); + record({ journey: 'organiser-roster', action: 'unseeded draw', pools: unseeded }); + expect(Object.keys(unseeded), 'two pools of four').toHaveLength(2); + const [firstSeed, secondSeed] = Object.values(unseeded).find((members) => members.length >= 2); + await shot(page, 'pools-unseeded'); + await page.goto(`/admin/competition/${ids.seeds}`); + await discardDraw(page); + + await openParticipants(page, ids.seeds); + await setSeed(page, firstSeed, 1); + await setSeed(page, secondSeed, 2); + // Each rank is saved as it is typed: they survive a reload. + await page.reload(); + await expect(page.getByRole('button', { name: 'Clear seeds' })).toBeVisible(); + expect(await seedRankOf(page, firstSeed)).toBe('1'); + expect(await seedRankOf(page, secondSeed)).toBe('2'); + await shot(page, 'seeds-typed'); + + await generateDraw(page, ids.seeds); + await openCompetitionTab(page, ids.seeds, 'pools'); + const pools = await poolMembers(page); + const poolOf = (n) => Object.keys(pools).find((p) => pools[p].includes(n)); + record({ journey: 'organiser-roster', action: 'seeds placed', seeds: [firstSeed, secondSeed], pools }); + expect(Object.keys(pools), 'two pools of four').toHaveLength(2); + expect(poolOf(firstSeed), 'seed 1 is drawn').toBeTruthy(); + expect(poolOf(secondSeed), 'seed 2 is drawn').toBeTruthy(); + expect(poolOf(firstSeed), 'two seeds never share a pool, though the unseeded draw put them together').not.toBe(poolOf(secondSeed)); + await shot(page, 'pools-seeds-apart'); + }); + + test('an edited name and dojo reach the court, the bracket and the public viewer; duplicates are refused', async ({ page, browser, baseURL }) => { + await login(page); + ids.edit = await createCompetition(page, { + name: 'Edit Cup', kind: 'individual', format: 'knockout', courts: ['B'], numberPrefix: 'E', + }); + await pasteRoster(page, ids.edit, ROSTER_8); + await generateDraw(page, ids.edit); + await openCompetitionTab(page, ids.edit, 'bracket'); + const before = await bracketSlots(page); + const slotOf = (slots, n) => Object.keys(slots).find((k) => slots[k].name === n); + const danSlot = slotOf(before, 'Dan Evans'); + expect(danSlot, 'Dan Evans is drawn').toBeTruthy(); + expect(before[danSlot].num, 'Dan Evans carries a number').not.toBe(''); + + // A new name and a new dojo, after the draw. + await openParticipants(page, ids.edit); + await openEdit(page, 'Dan Evans'); + await saveEdit(page, { name: 'Daniel Evans', dojo: 'Seishinkan UK' }); + await expect(editDialog(page)).toHaveCount(0); + await shot(page, 'participants-after-edit'); + + // The state expectEdited asserts, for bracketSlots to wait for. + const editedSlot = (slots) => slots[danSlot]?.name === 'Daniel Evans'; + const expectEdited = (slots, where) => { + expect(slots[danSlot], `${where}: the same slot now holds Daniel Evans`).toMatchObject({ + name: 'Daniel Evans', dojo: 'Seishinkan UK', num: before[danSlot].num, + }); + expect(Object.values(slots).map((s) => s.name), `${where}: Dan Evans is nowhere`).not.toContain('Dan Evans'); + }; + await openCompetitionTab(page, ids.edit, 'bracket'); + expectEdited(await bracketSlots(page, editedSlot), 'admin bracket'); + await shot(page, 'bracket-after-edit'); + + await openShiaijo(page, 'B'); + const queue = page.locator('.shiaijo__queue'); + await expect(queue.locator('.numbered-name__text', { hasText: 'Daniel Evans' }).first()).toBeVisible(); + const consoleNames = await queue.locator('.numbered-name__text').allInnerTexts(); + expect(consoleNames.map((n) => n.trim()), 'court console: Dan Evans is nowhere').not.toContain('Dan Evans'); + await shot(page, 'court-after-edit'); + + const spectator = await browser.newContext({ ...PUBLIC_DEVICE, baseURL }); + try { + const phone = await spectator.newPage(); + await openViewer(phone, ids.edit); + await phone.getByRole('button', { name: 'Bracket' }).tap(); + expectEdited(await bracketSlots(phone, editedSlot), 'public viewer'); + await shot(phone, 'viewer-after-edit', { fullPage: true }); + } finally { + await spectator.close(); + } + + // Same name AND same dojo as another competitor: refused, with its reason. + await openParticipants(page, ids.edit); + await openEdit(page, 'Finn Hale'); + await saveEdit(page, { name: 'Akira Tanaka', dojo: 'Gyokusen' }); + const refusal = await toastText(page, /same name and dojo/i); + record({ journey: 'organiser-roster', action: 'duplicate refused', message: refusal }); + await expect(editDialog(page), 'the dialog stays open on a refusal').toBeVisible(); + await shot(page, 'edit-duplicate-refused'); + + // The same name in a different dojo is two different people: accepted. + await saveEdit(page, { dojo: 'Thames' }); + await expect(editDialog(page)).toHaveCount(0); + await openCompetitionTab(page, ids.edit, 'bracket'); + const after = await bracketSlots(page, (slots) => Object.values(slots).filter((s) => s.name === 'Akira Tanaka').length === 2); + const akiras = Object.values(after).filter((s) => s.name === 'Akira Tanaka').map((s) => s.dojo).sort(); + expect(akiras, 'two Akira Tanaka, told apart by dojo').toEqual(['Gyokusen', 'Thames']); + expect(Object.values(after).map((s) => s.name), 'Finn Hale was renamed, not duplicated').not.toContain('Finn Hale'); + await shot(page, 'bracket-namesakes'); + }); + + test('replacing a drawn competitor who has not fought puts the newcomer in the same slot', async ({ page, browser, baseURL }) => { + await login(page); + await openCompetitionTab(page, ids.edit, 'bracket'); + const before = await bracketSlots(page); + const slot = Object.keys(before).find((k) => before[k].name === 'Hana Ito'); + expect(slot, 'Hana Ito is drawn and has not fought').toBeTruthy(); + expect(before[slot].num, 'Hana Ito carries a number').not.toBe(''); + + await openParticipants(page, ids.edit); + await openEdit(page, 'Hana Ito'); + await saveEdit(page, { name: 'Zed Newman', dojo: 'Shudokan' }); + await expect(editDialog(page)).toHaveCount(0); + await expect(page.getByRole('button', { name: 'Edit Zed Newman' })).toBeVisible(); + await expect(page.getByRole('button', { name: 'Edit Hana Ito' })).toHaveCount(0); + + const replacedSlot = (slots) => slots[slot]?.name === 'Zed Newman'; + const expectReplaced = (slots, where) => { + expect(slots[slot], `${where}: the newcomer holds the old slot, with its number`).toMatchObject({ + name: 'Zed Newman', dojo: 'Shudokan', num: before[slot].num, + }); + expect(Object.values(slots).map((s) => s.name), `${where}: Hana Ito is nowhere`).not.toContain('Hana Ito'); + // Nobody else moved. + Object.keys(before).filter((k) => k !== slot).forEach((k) => { + expect(before[k].num, `${k} carries a number`).not.toBe(''); + expect(slots[k]?.num, `${where}: ${k} keeps its number`).toBe(before[k].num); + }); + }; + await openCompetitionTab(page, ids.edit, 'bracket'); + expectReplaced(await bracketSlots(page, replacedSlot), 'admin bracket'); + await shot(page, 'bracket-after-replace'); + + await openShiaijo(page, 'B'); + // The queue is drawn (the newcomer is in it) before its names are read. + await expect(page.locator('.shiaijo__queue .numbered-name__text', { hasText: 'Zed Newman' }).first()).toBeVisible(); + const consoleNames = (await page.locator('.shiaijo__queue .numbered-name__text').allInnerTexts()).map((n) => n.trim()); + expect(consoleNames, 'court console shows Zed Newman').toContain('Zed Newman'); + expect(consoleNames, 'court console: Hana Ito is nowhere').not.toContain('Hana Ito'); + await shot(page, 'court-after-replace'); + + const spectator = await browser.newContext({ ...PUBLIC_DEVICE, baseURL }); + try { + const phone = await spectator.newPage(); + await openViewer(phone, ids.edit); + await phone.getByRole('button', { name: 'Bracket' }).tap(); + expectReplaced(await bracketSlots(phone, replacedSlot), 'public viewer'); + } finally { + await spectator.close(); + } + record({ journey: 'organiser-roster', action: 'replace', slot, replaced: before[slot] }); + }); +}); diff --git a/scripts/e2e/journeys/pools-tiebreak.spec.mjs b/scripts/e2e/journeys/pools-tiebreak.spec.mjs new file mode 100644 index 000000000..4ff2f404a --- /dev/null +++ b/scripts/e2e/journeys/pools-tiebreak.spec.mjs @@ -0,0 +1,792 @@ +// Pools + knockout with a MINIMUM pool size, and a pool tie that needs a +// tie-break bout (bc-e2tb). A second competition below (bc-xqtb) does the same +// with a tie over the extra place an oversized pool sends, and a third (bc-fbtb) +// with a tie over the 2nd place that "Fit the knockout" drafts one pool to send. +// +// Fixture: an individual "Pools + Knockout" competition, seven entrants, pool +// size a MINIMUM of 3, two qualifiers per pool, court A (the tournament has a +// second court, B, for the extra-place competition below). The minimum rule +// divides the entrant count down (docs/user-guide/organisers/pool-draw.md: +// "every pool reaches the minimum and no extra pool opens for the remainder; the +// players left over join existing pools, one each"), so seven entrants make two +// pools, of 4 and of 3. A MAXIMUM of 3 would open three pools (3, 2, 2): seven +// is the smallest roster on which the two modes differ. +// +// The draw decides who is in which pool and who meets whom, so the bouts are +// scripted by the ROLE a competitor plays (their place in the pool's draw +// order), never by name, and a bout's script does not depend on which side the +// draw gives it: +// pool of 3, players A B C A beats B and C 1-0 (A is first, W2). +// B and C fight a 1-1 hikiwake. B and C end level on +// EVERY criterion (W0 L1 D1, PW1, PL2) for the 2nd +// place, which advances: the engine adds a +// tie-break bout between them (internal/engine/ +// tiebreaker.go, id "Pool X-TB-N"). +// pool of 4, players P Q R S P beats Q, R and S (R by two points), Q and R beat +// S, and Q and R draw (Mark draw, no points). Q and R +// end level on W1 L1 D1 and PW1 and differ only on +// points lost (Q 1, R 2): the LATER criterion +// orders them, Q above R, so NO tie-break bout is +// added (CLAUDE.md, Key Algorithms: wins, losses, +// hikiwake, points scored, points lost). +// Expected figures and orders are tallied from these scripts, never read back +// from the app. +// +// Asserted: the pool sizes the minimum rule makes; the tie-break bout appearing +// on the court console and the Scores tab as the only bout left to score, with +// no "Match n of m" label, while the pool table (admin and public viewer) shows +// B and C level on every figure, marked tied, both at "=2nd" with no advancing +// mark; no tie-break bout for the pool settled on points lost; after the +// tie-break bout, the winner ranked above the loser and seated in the knockout +// (the loser out), on the admin pages and the public viewer. +import { test, expect } from '../fixtures/test.mjs'; +import { journeyShots } from '../fixtures/shots.mjs'; +import { auditLog } from '../fixtures/audit.mjs'; +import { createTournament, enterTournament, login } from '../fixtures/setup.mjs'; +import { createCompetition } from '../fixtures/wizard.mjs'; +import { generateDraw, pasteRoster, startCompetition } from '../fixtures/competition.mjs'; +import { completedRowFor, openShiaijo, sides, upNextCard } from '../fixtures/shiaijo.mjs'; +import { EXPECT_TIMEOUT_MS, PUBLIC_DEVICE } from '../fixtures/devices.mjs'; +import { INDIVIDUAL_FIGURES, openAdminPools, openViewerPools, playerRowFigures, poolBlocks, poolTeams, teamRow } from '../fixtures/pools.mjs'; +import { ROSTER_7, editorSides, ensureRunning, inlineRunning, playScriptedBout } from '../fixtures/individual.mjs'; +import { awardIppon, finishMatch, nextMatchWrite, writeLanded } from '../fixtures/scoring.mjs'; +import { scoreRows } from '../fixtures/scores.mjs'; +import { bracketSlots, openCompetitionTab, openParticipants, setSeed } from '../fixtures/roster.mjs'; +import { openViewer } from '../fixtures/public.mjs'; + +const JOURNEY = 'pools-tiebreak'; +const MIN_POOL = 3; +const POOL_WINNERS = 2; +const NAMES = ROSTER_7.map(([name]) => name); +const ORDINAL = ['1st', '2nd', '3rd', '4th']; +// A pool bout reads "Match n of m"; a tie-break bout is the bare pairing. +const POOL_MATCH_LABEL = /Match \d+ of \d+/i; + +// The pool sizes the minimum rule makes: floor(n / min) pools, the remainder +// joining existing pools one each. +function minimumRuleSizes(entrants, min) { + const pools = Math.floor(entrants / min); + const leftover = entrants - pools * min; + return Array.from({ length: pools }, (_, i) => min + (i < leftover ? 1 : 0)).sort((a, b) => b - a); +} + +const zero = () => ({ W: 0, L: 0, D: 0, PW: 0, PL: 0 }); +// The pool ranking: wins, then fewer losses, then more hikiwake, then more +// points scored, then fewer points lost. 0 means level on every criterion. +const order = (a, b) => (b.W - a.W) || (a.L - b.L) || (b.D - a.D) || (b.PW - a.PW) || (a.PL - b.PL); + +// The scripted bouts of one competition and the figures they must produce: +// plan maps a sorted-pair key to { marks: { name: [waza] } } | { draw }, and +// tally is each competitor's expected W, L, D, PW, PL, tallied from the scripts. +function scripted(names = NAMES) { + const plan = new Map(); + const tally = Object.fromEntries(names.map((n) => [n, zero()])); + const key = (a, b) => [a, b].sort().join(' | '); + + // Script one bout and add it to the expected tally. + function script(a, b, entry) { + plan.set(key(a, b), entry); + if (entry.draw) { + tally[a].D += 1; + tally[b].D += 1; + return; + } + const pa = (entry.marks[a] || []).length; + const pb = (entry.marks[b] || []).length; + tally[a].PW += pa; + tally[a].PL += pb; + tally[b].PW += pb; + tally[b].PL += pa; + if (pa === pb) { + tally[a].D += 1; + tally[b].D += 1; + } else { + const [win, lose] = pa > pb ? [a, b] : [b, a]; + tally[win].W += 1; + tally[lose].L += 1; + } + } + + const planFor = ({ shiro, aka }) => { + const entry = plan.get(key(shiro, aka)); + if (!entry) throw new Error(`no scripted result for ${shiro} v ${aka}`); + return entry.draw ? { draw: true } : { shiro: entry.marks[shiro] || [], aka: entry.marks[aka] || [] }; + }; + return { tally, script, planFor }; +} + +// The pools the draw made, as the competitor names in each pool's draw order, +// the largest pool first; asserts the sizes the draw was meant to make. +async function readDrawnPools(page, compId, want, names = NAMES) { + await openAdminPools(page, compId); + await expect(poolBlocks(page)).toHaveCount(want.length); + const read = []; + for (let i = 0; i < want.length; i += 1) { + const drawn = await poolTeams(poolBlocks(page).nth(i)); + read.push(drawn.map((t) => names.find((n) => t.includes(n)))); + } + const pools = read.sort((a, b) => b.length - a.length); + expect(pools.map((p) => p.length), 'the draw cut the pools the minimum rule describes').toEqual(want); + expect(pools.flat().sort(), 'every entrant is in exactly one pool').toEqual([...names].sort()); + return pools; +} + +// The competitor the tie-break bout is between: the pairing on court `court`'s +// console, whether the bout waits as Up next or already runs in the editor. +async function tieBreakPairingOn(page, court, between) { + await openShiaijo(page, court); + let pair = null; + await expect.poll(async () => { + pair = (await inlineRunning(page)) + ? await editorSides(page).catch(() => null) + : await sides(upNextCard(page)).catch(() => null); + return pair && [pair.shiro, pair.aka].sort(); + }, { message: 'the court holds the tie-break bout', timeout: EXPECT_TIMEOUT_MS }).toEqual([...between].sort()); + return pair; +} + +async function expectTable(page, where, names, want) { + for (const name of names) { + await expect.poll(() => playerRowFigures(page, name), { message: `${where}: ${name}` }) + .toMatchObject(want(name)); + } +} + +test.describe.configure({ mode: 'serial' }); + +test.describe('pools: minimum pool size and a tie-break bout', () => { + let shot; + let compId; + const audit = auditLog(JOURNEY); + const { record } = audit; + + // Draw order of each pool as the draw made it, the pool of 4 first. + let big; + let small; + const { tally, script, planFor } = scripted(); + // Set once the tie-break bout is on the console. + let tb; + // Who won the tie-break bout, who lost it. + let tbWinner; + let tbLoser; + + test.beforeAll(() => { + shot = journeyShots(JOURNEY); + }); + + test.afterAll(() => audit.flush()); + + const tieBreakPairing = (page) => tieBreakPairingOn(page, 'A', [tb.b, tb.c]); + + test('seed: seven entrants, pool size a minimum of 3, the draw makes a pool of 4 and a pool of 3', async ({ page }) => { + await createTournament(page, { name: 'Pools Tiebreak Cup', courts: 3 }); + compId = await createCompetition(page, { + name: 'Minimum Pools', kind: 'individual', format: 'mixed', poolSizeMode: 'minimum', poolSize: MIN_POOL, + poolWinners: POOL_WINNERS, courts: ['A'], + }); + await pasteRoster(page, compId, ROSTER_7); + await generateDraw(page, compId); + await startCompetition(page, compId); + + const want = minimumRuleSizes(NAMES.length, MIN_POOL); + expect(want, 'the rule makes two pools, 4 and 3 (a maximum of 3 would make 3, 2 and 2)').toEqual([4, 3]); + const pools = await readDrawnPools(page, compId, want); + [big, small] = pools; + record({ journey: JOURNEY, step: 'draw', action: 'pool sizes', rule: `floor(7 / ${MIN_POOL}) pools, leftover joins one each`, pools, + screenshot: await shot(page, 'pools-drawn') }); + + // The scripts, by role (draw order within the pool). + const [A, B, C] = small; + const [P, Q, R, S] = big; + script(A, B, { marks: { [A]: ['M'] } }); + script(A, C, { marks: { [A]: ['M'] } }); + script(B, C, { marks: { [B]: ['M'], [C]: ['M'] } }); + script(P, Q, { marks: { [P]: ['M'] } }); + script(P, R, { marks: { [P]: ['M', 'K'] } }); + script(P, S, { marks: { [P]: ['M'] } }); + script(Q, S, { marks: { [Q]: ['M'] } }); + script(R, S, { marks: { [R]: ['M'] } }); + script(Q, R, { draw: true }); + tb = { a: A, b: B, c: C }; + // Level on every criterion, by construction. + expect(order(tally[B], tally[C]), 'B and C are level on every criterion').toBe(0); + // Q and R are level on W, L, D, PW and apart on PL only. + expect(['W', 'L', 'D', 'PW'].map((f) => tally[Q][f]), 'Q and R agree up to points scored') + .toEqual(['W', 'L', 'D', 'PW'].map((f) => tally[R][f])); + expect(tally[Q].PL < tally[R].PL, 'points lost alone orders Q above R').toBe(true); + }); + + test('play the nine regular bouts on the court console', async ({ page }) => { + await login(page); + await openShiaijo(page, 'A'); + for (let n = 1; n <= 9; n += 1) { + const played = await test.step(`regular bout ${n}`, () => playScriptedBout(page, planFor)); + record({ journey: JOURNEY, step: `bout ${n}`, action: 'Finish', shiro: played.shiro, aka: played.aka, plan: played.plan }); + } + await shot(page, 'regular-bouts-done'); + }); + + test('the level pair: a tie-break bout is added, and the table shows them level until it is fought', async ({ page, browser, baseURL }) => { + await login(page); + const pair = await tieBreakPairing(page); + record({ journey: JOURNEY, step: 'tie-break', action: 'on the court console', pair, screenshot: await shot(page, 'tie-break-on-console') }); + const card = (await inlineRunning(page)) ? page.locator('.editor-modal__eyebrow').first() : upNextCard(page); + expect((await card.innerText()), 'a tie-break bout carries no "Match n of m"').not.toMatch(POOL_MATCH_LABEL); + + // The Scores tab: it is the one bout left to score. + await openCompetitionTab(page, compId, 'scores'); + await expect(scoreRows(page).first()).toBeVisible(); + const toScore = scoreRows(page).filter({ has: page.getByRole('button', { name: /^Score$/ }) }); + await expect(toScore, 'the tie-break bout is the only bout left to score').toHaveCount(1); + await expect(toScore.first()).toContainText(tb.b); + await expect(toScore.first()).toContainText(tb.c); + expect(await toScore.first().innerText()).not.toMatch(POOL_MATCH_LABEL); + await shot(page, 'scores-tab-tie-break'); + + // The pool table: B and C level on every figure, marked tied, sharing ONE + // place (=2nd) with no advancing mark on either, since the tie-break bout + // decides which of them goes through; A first and advancing. The public + // viewer's table reads the same. + const level = (name) => ({ ...tally[name], rank: '=2nd', advancing: false, tied: true }); + await openAdminPools(page, compId); + await expectTable(page, 'admin Pools tab', [tb.b, tb.c], level); + const rows = { b: await playerRowFigures(page, tb.b), c: await playerRowFigures(page, tb.c) }; + record({ journey: JOURNEY, step: 'tie-break', action: 'table before the bout', columns: INDIVIDUAL_FIGURES, rows, screenshot: await shot(page, 'admin-pools-level', { fullPage: true }) }); + await expectTable(page, 'admin Pools tab', [tb.a], (n) => ({ ...tally[n], rank: '1st', advancing: true, tied: false })); + const spectator = await browser.newContext({ ...PUBLIC_DEVICE, baseURL }); + try { + const phone = await spectator.newPage(); + await openViewerPools(phone, compId); + await expectTable(phone, 'public viewer Pools tab', [tb.b, tb.c], level); + await expectTable(phone, 'public viewer Pools tab', [tb.a], (n) => ({ ...tally[n], rank: '1st', advancing: true, tied: false })); + } finally { + await spectator.close(); + } + + // The pool of 4: Q above R on points lost alone, no tie, no bout for them. + const [P, Q, R, S] = big; + const ranked = [P, Q, R, S]; + expect([...big].sort((x, y) => order(tally[x], tally[y])), 'the tally orders P, Q, R, S').toEqual(ranked); + await expectTable(page, 'admin Pools tab', big, (n) => ({ + ...tally[n], rank: ORDINAL[ranked.indexOf(n)], advancing: ranked.indexOf(n) < POOL_WINNERS, tied: false, + })); + await shot(page, 'admin-pools-points-lost-order', { fullPage: true }); + + // The bracket: the tied place is not seated yet (it waits for the tie-break). + await openCompetitionTab(page, compId, 'bracket'); + // Read until the settled pool's two qualifiers are seated, so a refetch + // still to land cannot make the tied pool's absence a false pass. + const settled = (slots) => { + const names = Object.values(slots).map((s) => s.name); + return names.includes(ranked[0]) && names.includes(ranked[1]); + }; + const seated = Object.values(await bracketSlots(page, settled)).map((s) => s.name); + record({ journey: JOURNEY, step: 'tie-break', action: 'bracket before the bout', seated, screenshot: await shot(page, 'admin-bracket-before') }); + expect(seated, 'the pool of 4 is settled: its two qualifiers are seated').toEqual(expect.arrayContaining([ranked[0], ranked[1]])); + for (const name of [tb.b, tb.c]) { + expect(seated, `${name} is not seated while the tie is open`).not.toContain(name); + } + }); + + test('score the tie-break bout: the winner ranks above the loser', async ({ page }) => { + await login(page); + const pair = await tieBreakPairing(page); + // The bout starts from its Up next card unless a Finish + Start Next already did. + const running = await ensureRunning(page); + expect(running, 'the tie-break bout runs on the court').toEqual(pair); + // The last of the pair in the draw (role C) wins, one men: the table + // read them level, so the bout, not the draw order, decides the place. + const side = pair.shiro === tb.c ? 'shiro' : 'aka'; + tbWinner = pair[side]; + tbLoser = pair[side === 'shiro' ? 'aka' : 'shiro']; + expect(tbWinner, 'role C wins the tie-break bout').toBe(tb.c); + const wrote = nextMatchWrite(page); + await awardIppon(page, side, 'M'); + const res = await writeLanded(wrote, 'the tie-break bout\'s first mark'); + const id = decodeURIComponent(new URL(res.url()).pathname.split('/matches/')[1].replace(/\/score$/, '')); + expect(id, 'the bout is the pool\'s supplementary bout').toMatch(/^Pool [A-Z]-TB-\d+$/); + await shot(page, 'tie-break-scored'); + await finishMatch(page); + await expect(completedRowFor(page, pair)).toBeVisible(); + record({ journey: JOURNEY, step: 'tie-break', action: 'Finish', id, winner: tbWinner, loser: tbLoser, + screenshot: await shot(page, 'tie-break-finished') }); + + await openAdminPools(page, compId); + await expectOrdered(page, 'admin Pools tab'); + await shot(page, 'admin-pools-after', { fullPage: true }); + }); + + // After the tie-break bout: the pool of 3 is A, the winner, the loser; the + // level pair is no longer marked tied; the pool of 4 is unchanged. + async function expectOrdered(page, where) { + const [A] = [tb.a]; + const first = [A, tbWinner, tbLoser]; + await expectTable(page, where, first, (n) => ({ + rank: ORDINAL[first.indexOf(n)], advancing: first.indexOf(n) < POOL_WINNERS, tied: false, + })); + const [P, Q, R, S] = big; + const ranked = [P, Q, R, S]; + await expectTable(page, where, ranked, (n) => ({ + ...tally[n], rank: ORDINAL[ranked.indexOf(n)], advancing: ranked.indexOf(n) < POOL_WINNERS, tied: false, + })); + record({ journey: JOURNEY, step: 'tie-break', action: `table after the bout (${where})`, + rows: Object.fromEntries(await Promise.all(first.map(async (n) => [n, await playerRowFigures(page, n)]))) }); + } + + test('the winner, not the loser, is seated in the knockout: admin and public viewer', async ({ page, browser, baseURL }) => { + await login(page); + const qualifiers = [big[0], big[1], tb.a, tbWinner].sort(); + // Roster names only, so an unresolved placeholder never counts as seated. + const seatedIn = async (p) => Object.values(await bracketSlots(p, (slots) => Object.values(slots).filter((s) => NAMES.includes(s.name)).length === 4)) + .map((s) => s.name).filter((n) => NAMES.includes(n)).sort(); + await openCompetitionTab(page, compId, 'bracket'); + expect(await seatedIn(page), 'the admin bracket seats the four qualifiers').toEqual(qualifiers); + await shot(page, 'admin-bracket-after'); + + const spectator = await browser.newContext({ ...PUBLIC_DEVICE, baseURL }); + try { + const phone = await spectator.newPage(); + await openViewerPools(phone, compId); + await expectOrdered(phone, 'public viewer Pools tab'); + await shot(phone, 'viewer-pools-after', { fullPage: true }); + await openViewer(phone, compId); + await phone.getByRole('button', { name: 'Bracket' }).tap(); + expect(await seatedIn(phone), 'the public bracket seats the same four').toEqual(qualifiers); + await shot(phone, 'viewer-bracket-after'); + } finally { + await spectator.close(); + } + expect(qualifiers, 'the tie-break loser is out').not.toContain(tbLoser); + record({ journey: JOURNEY, step: 'knockout', action: 'seated', qualifiers, out: [tbLoser, big[2], big[3]] }); + }); +}); + +// bc-xqtb: Knockout qualifiers "Oversized send +1" (larger-pools). The pool of 4 +// is over the minimum of 3, so it sends one more than the 1 Winners per pool +// (the wizard locks that field at 1): its 1st and its 2nd. The 2nd is the extra +// place, and a tie over it decides who goes through exactly as any other +// qualifying place does, so it is owed a tie-break bout. It runs on court B, +// beside the competition above, in the same tournament. +// +// pool of 3, players A B C A beats B and C, B beats C: no tie. A is first +// and the pool's one qualifier. +// pool of 4, players P Q R S P beats Q, R and S, Q and R beat S 1-0, and Q and +// R draw. Q and R end level on every criterion +// (W1 L1 D1, PW1, PL1) for 2nd, the extra place. +// Asserted: the tie-break bout appearing as the only bout left to score; Q and +// R both at "=2nd" with no advancing mark on admin and phone, and neither +// seated in the knockout meanwhile; after the bout, the winner ranked 2nd, +// marked advancing and seated, the loser out. +const JOURNEY_EXTRA = 'pools-tiebreak-extra-place'; +const EXTRA_COURT = 'B'; + +test.describe('pools: a tie over the extra qualifier place of an oversized pool', () => { + let shot; + let compId; + const audit = auditLog(JOURNEY_EXTRA); + const { record } = audit; + let big; + let small; + const { tally, script, planFor } = scripted(); + let tb; + let tbWinner; + let tbLoser; + + test.beforeAll(() => { + shot = journeyShots(JOURNEY_EXTRA); + }); + + test.afterAll(() => audit.flush()); + + const tieBreakPairing = (page) => tieBreakPairingOn(page, EXTRA_COURT, [tb.q, tb.r]); + + test('seed: Oversized send +1, seven entrants, the draw makes a pool of 4 and a pool of 3', async ({ page }) => { + await enterTournament(page, { name: 'Pools Tiebreak Cup', courts: 3 }); + compId = await createCompetition(page, { + name: 'Extra Place Pools', kind: 'individual', format: 'mixed', poolSizeMode: 'minimum', poolSize: MIN_POOL, + extraQualifiers: 'larger-pools', courts: [EXTRA_COURT], + }); + await pasteRoster(page, compId, ROSTER_7); + await generateDraw(page, compId); + await startCompetition(page, compId); + + const pools = await readDrawnPools(page, compId, minimumRuleSizes(NAMES.length, MIN_POOL)); + [big, small] = pools; + record({ journey: JOURNEY_EXTRA, step: 'draw', action: 'pool sizes', pools, screenshot: await shot(page, 'pools-drawn') }); + + const [A, B, C] = small; + const [P, Q, R, S] = big; + script(A, B, { marks: { [A]: ['M'] } }); + script(A, C, { marks: { [A]: ['M'] } }); + script(B, C, { marks: { [B]: ['M'] } }); + script(P, Q, { marks: { [P]: ['M'] } }); + script(P, R, { marks: { [P]: ['M'] } }); + script(P, S, { marks: { [P]: ['M'] } }); + script(Q, S, { marks: { [Q]: ['M'] } }); + script(R, S, { marks: { [R]: ['M'] } }); + script(Q, R, { draw: true }); + tb = { a: A, p: P, q: Q, r: R, s: S }; + expect(order(tally[Q], tally[R]), 'Q and R are level on every criterion').toBe(0); + expect(order(tally[P], tally[Q]) < 0 && order(tally[Q], tally[S]) < 0, 'P is above them and S below').toBe(true); + }); + + test('play the nine regular bouts on court B', async ({ page }) => { + await login(page); + await openShiaijo(page, EXTRA_COURT); + for (let n = 1; n <= 9; n += 1) { + const played = await test.step(`regular bout ${n}`, () => playScriptedBout(page, planFor)); + record({ journey: JOURNEY_EXTRA, step: `bout ${n}`, action: 'Finish', shiro: played.shiro, aka: played.aka, plan: played.plan }); + } + await shot(page, 'regular-bouts-done'); + }); + + test('the level pair over the extra place: a tie-break bout is added, and both read =2nd until it is fought', async ({ page, browser, baseURL }) => { + await login(page); + const pair = await tieBreakPairing(page); + record({ journey: JOURNEY_EXTRA, step: 'tie-break', action: 'on the court console', pair, screenshot: await shot(page, 'tie-break-on-console') }); + const card = (await inlineRunning(page)) ? page.locator('.editor-modal__eyebrow').first() : upNextCard(page); + expect((await card.innerText()), 'a tie-break bout carries no "Match n of m"').not.toMatch(POOL_MATCH_LABEL); + + await openCompetitionTab(page, compId, 'scores'); + await expect(scoreRows(page).first()).toBeVisible(); + const toScore = scoreRows(page).filter({ has: page.getByRole('button', { name: /^Score$/ }) }); + await expect(toScore, 'the tie-break bout is the only bout left to score').toHaveCount(1); + await expect(toScore.first()).toContainText(tb.q); + await expect(toScore.first()).toContainText(tb.r); + expect(await toScore.first().innerText()).not.toMatch(POOL_MATCH_LABEL); + + // Q and R share 2nd, the extra place, with no advancing mark on either; P + // first and advancing, S last. The pool of 3 is settled: one qualifier. + const level = (name) => ({ ...tally[name], rank: '=2nd', advancing: false, tied: true }); + const settled = (name) => ({ ...tally[name], rank: '4th', advancing: false, tied: false }); + const first = (name) => ({ ...tally[name], rank: '1st', advancing: true, tied: false }); + const smallRanked = small; + const smallRank = (name) => ({ + ...tally[name], rank: ORDINAL[smallRanked.indexOf(name)], advancing: smallRanked.indexOf(name) < 1, tied: false, + }); + const expectPools = async (p, where) => { + await expectTable(p, where, [tb.q, tb.r], level); + await expectTable(p, where, [tb.p], first); + await expectTable(p, where, [tb.s], settled); + await expectTable(p, where, small, smallRank); + }; + await openAdminPools(page, compId); + await expectPools(page, 'admin Pools tab'); + record({ journey: JOURNEY_EXTRA, step: 'tie-break', action: 'table before the bout', columns: INDIVIDUAL_FIGURES, + rows: { q: await playerRowFigures(page, tb.q), r: await playerRowFigures(page, tb.r) }, + screenshot: await shot(page, 'admin-pools-level', { fullPage: true }) }); + const spectator = await browser.newContext({ ...PUBLIC_DEVICE, baseURL }); + try { + const phone = await spectator.newPage(); + await openViewerPools(phone, compId); + await expectPools(phone, 'public viewer Pools tab'); + await shot(phone, 'viewer-pools-level', { fullPage: true }); + } finally { + await spectator.close(); + } + + // The bracket: the pool of 3 is seated, the pool of 4 waits for the bout. + await openCompetitionTab(page, compId, 'bracket'); + const seatedNames = async (until) => Object.values(await bracketSlots(page, until)).map((s) => s.name); + const seated = await seatedNames((slots) => Object.values(slots).some((s) => s.name === small[0])); + record({ journey: JOURNEY_EXTRA, step: 'tie-break', action: 'bracket before the bout', seated, screenshot: await shot(page, 'admin-bracket-before') }); + expect(seated, 'the pool of 3 is settled: its qualifier is seated').toContain(small[0]); + for (const name of [tb.q, tb.r]) { + expect(seated, `${name} is not seated while the tie over the extra place is open`).not.toContain(name); + } + }); + + test('score the tie-break bout: the winner takes the extra place', async ({ page }) => { + await login(page); + const pair = await tieBreakPairing(page); + const running = await ensureRunning(page); + expect(running, 'the tie-break bout runs on the court').toEqual(pair); + // Role R wins, one men: the table read them level, so the bout, not the + // draw order, decides the place. + const side = pair.shiro === tb.r ? 'shiro' : 'aka'; + tbWinner = pair[side]; + tbLoser = pair[side === 'shiro' ? 'aka' : 'shiro']; + expect(tbWinner, 'role R wins the tie-break bout').toBe(tb.r); + const wrote = nextMatchWrite(page); + await awardIppon(page, side, 'M'); + const res = await writeLanded(wrote, 'the tie-break bout\'s first mark'); + const id = decodeURIComponent(new URL(res.url()).pathname.split('/matches/')[1].replace(/\/score$/, '')); + expect(id, 'the bout is the pool\'s supplementary bout').toMatch(/^Pool [A-Z]-TB-\d+$/); + await finishMatch(page); + await expect(completedRowFor(page, pair)).toBeVisible(); + record({ journey: JOURNEY_EXTRA, step: 'tie-break', action: 'Finish', id, winner: tbWinner, loser: tbLoser, + screenshot: await shot(page, 'tie-break-finished') }); + + await openAdminPools(page, compId); + await expectOrdered(page, 'admin Pools tab'); + await shot(page, 'admin-pools-after', { fullPage: true }); + }); + + // After the bout: P, the winner, the loser, S; only P and the winner advance + // from the pool of 4, and the pool of 3 sends its first alone. + async function expectOrdered(page, where) { + const ranked = [tb.p, tbWinner, tbLoser, tb.s]; + await expectTable(page, where, ranked, (n) => ({ + rank: ORDINAL[ranked.indexOf(n)], advancing: ranked.indexOf(n) < 2, tied: false, + })); + await expectTable(page, where, small, (n) => ({ + ...tally[n], rank: ORDINAL[small.indexOf(n)], advancing: small.indexOf(n) < 1, tied: false, + })); + } + + test('the winner, not the loser, is seated in the knockout: admin and public viewer', async ({ page, browser, baseURL }) => { + await login(page); + const qualifiers = [tb.p, tbWinner, small[0]].sort(); + const seatedIn = async (p) => { + const slots = await bracketSlots(p, (read) => { + const names = Object.values(read).map((s) => s.name); + return qualifiers.every((q) => names.includes(q)); + }); + return Object.values(slots).map((s) => s.name).filter((n) => NAMES.includes(n)).sort(); + }; + await openCompetitionTab(page, compId, 'bracket'); + expect(await seatedIn(page), 'the admin bracket seats the three qualifiers').toEqual(qualifiers); + await shot(page, 'admin-bracket-after'); + + const spectator = await browser.newContext({ ...PUBLIC_DEVICE, baseURL }); + try { + const phone = await spectator.newPage(); + await openViewerPools(phone, compId); + await expectOrdered(phone, 'public viewer Pools tab'); + await openViewer(phone, compId); + await phone.getByRole('button', { name: 'Bracket' }).tap(); + expect(await seatedIn(phone), 'the public bracket seats the same three').toEqual(qualifiers); + await shot(phone, 'viewer-bracket-after'); + } finally { + await spectator.close(); + } + expect(qualifiers, 'the tie-break loser is out').not.toContain(tbLoser); + record({ journey: JOURNEY_EXTRA, step: 'knockout', action: 'seated', qualifiers, out: [tbLoser, tb.s, small[1], small[2]] }); + }); +}); + +// bc-fbtb: Knockout qualifiers "Fit the knockout" (fill-bracket). Nine entrants +// at a minimum of 3 make three pools of 3, which would send three qualifiers +// into a four-slot bracket with a bye. The draw fills the gap by DRAFTING one +// pool to send its 2nd as well, and it drafts a SEEDED pool first, so seeding +// one entrant says which pool: the one holding them. A tie over that pool's 2nd +// decides who takes the drafted place, so it is owed a tie-break bout; the same +// tie in a pool that was not drafted shares its place with no bout. It runs on +// court C, beside the competitions above, in the same tournament. +// +// drafted pool, A B C A beats B and C; B and C draw 1-1: level on every +// criterion for 2nd, the drafted place. +// undrafted pool, D E F D beats E and F; E and F draw 1-1: the same tie, +// but 2nd sends nobody here. +// undrafted pool, G H I G beats H and I, H beats I: no tie. +// Asserted: the drafted pool's tie-break bout as the only bout left to score (so +// the undrafted pool's identical tie got none); B and C at "=2nd" with a title +// naming the bout, E and F at "=2nd" with a title naming no bout, none of the +// four marked advancing; neither B nor C seated in the knockout while the bout +// is open; after it, the winner ranked 2nd, marked advancing and seated beside +// the three pool winners in the four-slot bracket, the loser out. +const JOURNEY_FILL = 'pools-tiebreak-fit-the-knockout'; +const FILL_COURT = 'C'; +const ROSTER_9 = [ + ['Akio Tanabe', 'Tokyo Kai'], ['Bruno Lopes', 'Lisbon Kan'], ['Chika Moriya', 'Nara Dojo'], + ['Daigo Hirano', 'Chiba Kai'], ['Emi Kashima', 'Himeji Kan'], ['Farid Nasser', 'Cairo Dojo'], + ['Gen Yamada', 'Akita Kai'], ['Hugo Brandt', 'Bonn Kan'], ['Iris Okada', 'Gifu Dojo'], +]; +const NAMES_9 = ROSTER_9.map(([name]) => name); +const SEEDED = NAMES_9[0]; + +test.describe('pools: a tie over the 2nd place a Fit the knockout draw drafted', () => { + let shot; + let compId; + const audit = auditLog(JOURNEY_FILL); + const { record } = audit; + let drafted; + let tied; + let clean; + const { tally, script, planFor } = scripted(NAMES_9); + let tb; + let tbWinner; + let tbLoser; + + test.beforeAll(() => { + shot = journeyShots(JOURNEY_FILL); + }); + + test.afterAll(() => audit.flush()); + + const tieBreakPairing = (page) => tieBreakPairingOn(page, FILL_COURT, [tb.b, tb.c]); + const badgeTitle = (page, name) => teamRow(page, name).locator('.rank-badge').getAttribute('title'); + + test('seed: Fit the knockout, nine entrants with one seed, the draw makes three pools of 3', async ({ page }) => { + await enterTournament(page, { name: 'Pools Tiebreak Cup', courts: 3 }); + compId = await createCompetition(page, { + name: 'Fit Knockout Pools', kind: 'individual', format: 'mixed', poolSizeMode: 'minimum', poolSize: MIN_POOL, + extraQualifiers: 'fill-bracket', courts: [FILL_COURT], + }); + await pasteRoster(page, compId, ROSTER_9); + await openParticipants(page, compId); + await setSeed(page, SEEDED, 1); + await generateDraw(page, compId); + await startCompetition(page, compId); + + const pools = await readDrawnPools(page, compId, [3, 3, 3], NAMES_9); + drafted = pools.find((p) => p.includes(SEEDED)); + expect(drafted, 'the seeded entrant is in one of the pools').toBeTruthy(); + [tied, clean] = pools.filter((p) => p !== drafted); + record({ journey: JOURNEY_FILL, step: 'draw', action: 'pools', pools, seeded: SEEDED, drafted, + screenshot: await shot(page, 'pools-drawn') }); + + const [A, B, C] = drafted; + const [D, E, F] = tied; + const [G, H, I] = clean; + script(A, B, { marks: { [A]: ['M'] } }); + script(A, C, { marks: { [A]: ['M'] } }); + script(B, C, { marks: { [B]: ['M'], [C]: ['M'] } }); + script(D, E, { marks: { [D]: ['M'] } }); + script(D, F, { marks: { [D]: ['M'] } }); + script(E, F, { marks: { [E]: ['M'], [F]: ['M'] } }); + script(G, H, { marks: { [G]: ['M'] } }); + script(G, I, { marks: { [G]: ['M'] } }); + script(H, I, { marks: { [H]: ['M'] } }); + tb = { a: A, b: B, c: C, d: D, e: E, f: F, g: G, h: H, i: I }; + expect(order(tally[B], tally[C]), 'B and C are level on every criterion').toBe(0); + expect(order(tally[E], tally[F]), 'E and F are level on every criterion').toBe(0); + }); + + test('play the nine regular bouts on court C', async ({ page }) => { + await login(page); + await openShiaijo(page, FILL_COURT); + for (let n = 1; n <= 9; n += 1) { + const played = await test.step(`regular bout ${n}`, () => playScriptedBout(page, planFor)); + record({ journey: JOURNEY_FILL, step: `bout ${n}`, action: 'Finish', shiro: played.shiro, aka: played.aka, plan: played.plan }); + } + await shot(page, 'regular-bouts-done'); + }); + + test('the drafted pool\'s level pair is owed a bout; the same tie in an undrafted pool is not', async ({ page, browser, baseURL }) => { + await login(page); + const pair = await tieBreakPairing(page); + record({ journey: JOURNEY_FILL, step: 'tie-break', action: 'on the console of court C', pair, screenshot: await shot(page, 'tie-break-on-console') }); + + // The Scores tab: the drafted pool's bout is the one bout left to score, + // which also says the undrafted pool's level pair was given none. + await openCompetitionTab(page, compId, 'scores'); + await expect(scoreRows(page).first()).toBeVisible(); + const toScore = scoreRows(page).filter({ has: page.getByRole('button', { name: /^Score$/ }) }); + await expect(toScore, 'the drafted pool\'s tie-break bout is the only bout left to score').toHaveCount(1); + await expect(toScore.first()).toContainText(tb.b); + await expect(toScore.first()).toContainText(tb.c); + + const level = (name) => ({ ...tally[name], rank: '=2nd', advancing: false, tied: true }); + const ranked = (list) => (name) => ({ + ...tally[name], rank: ORDINAL[list.indexOf(name)], advancing: list.indexOf(name) < 1, tied: false, + }); + const expectPools = async (p, where) => { + await expectTable(p, where, [tb.b, tb.c], level); + await expectTable(p, where, [tb.e, tb.f], level); + await expectTable(p, where, [tb.a], ranked([tb.a])); + await expectTable(p, where, [tb.d], ranked([tb.d])); + await expectTable(p, where, clean, ranked(clean)); + // The title says whether a bout decides it: only the drafted pool's tie. + expect(await badgeTitle(p, tb.b), `${where}: the drafted pool's level pair names the bout`).toMatch(/tie-break bout decides the order/); + expect(await badgeTitle(p, tb.e), `${where}: the undrafted pool's level pair names no bout`).toBe('Level on every criterion'); + }; + await openAdminPools(page, compId); + await expectPools(page, 'admin Pools tab'); + record({ journey: JOURNEY_FILL, step: 'tie-break', action: 'table before the bout', columns: INDIVIDUAL_FIGURES, + rows: { b: await playerRowFigures(page, tb.b), e: await playerRowFigures(page, tb.e) }, + screenshot: await shot(page, 'admin-pools-level', { fullPage: true }) }); + const spectator = await browser.newContext({ ...PUBLIC_DEVICE, baseURL }); + try { + const phone = await spectator.newPage(); + await openViewerPools(phone, compId); + await expectPools(phone, 'public viewer Pools tab'); + await shot(phone, 'viewer-pools-level', { fullPage: true }); + } finally { + await spectator.close(); + } + + // The bracket: the two undrafted pools' winners are seated; the drafted + // pool waits for the bout, so neither of its level pair is seated. + await openCompetitionTab(page, compId, 'bracket'); + const seated = Object.values(await bracketSlots(page, (slots) => { + const names = Object.values(slots).map((x) => x.name); + return names.includes(tb.d) && names.includes(tb.g); + })).map((x) => x.name); + record({ journey: JOURNEY_FILL, step: 'tie-break', action: 'bracket before the bout', seated, screenshot: await shot(page, 'admin-bracket-before') }); + expect(seated, 'the undrafted pools are settled: their winners are seated').toEqual(expect.arrayContaining([tb.d, tb.g])); + for (const name of [tb.b, tb.c]) { + expect(seated, `${name} is not seated while the tie over the drafted 2nd is open`).not.toContain(name); + } + }); + + test('score the tie-break bout: the winner takes the drafted place', async ({ page }) => { + await login(page); + const pair = await tieBreakPairing(page); + const running = await ensureRunning(page); + expect(running, 'the tie-break bout runs on the court').toEqual(pair); + // Role C wins, one men: the table read them level, so the bout, not the + // draw order, decides the place. + const side = pair.shiro === tb.c ? 'shiro' : 'aka'; + tbWinner = pair[side]; + tbLoser = pair[side === 'shiro' ? 'aka' : 'shiro']; + expect(tbWinner, 'role C wins the tie-break bout').toBe(tb.c); + const wrote = nextMatchWrite(page); + await awardIppon(page, side, 'M'); + const res = await writeLanded(wrote, 'the tie-break bout\'s first mark'); + const id = decodeURIComponent(new URL(res.url()).pathname.split('/matches/')[1].replace(/\/score$/, '')); + expect(id, 'the bout is the pool\'s supplementary bout').toMatch(/^Pool [A-Z]-TB-\d+$/); + await finishMatch(page); + await expect(completedRowFor(page, pair)).toBeVisible(); + record({ journey: JOURNEY_FILL, step: 'tie-break', action: 'Finish', id, winner: tbWinner, loser: tbLoser, + screenshot: await shot(page, 'tie-break-finished') }); + + await openAdminPools(page, compId); + await expectOrdered(page, 'admin Pools tab'); + await shot(page, 'admin-pools-after', { fullPage: true }); + }); + + // After the bout: the drafted pool is A, the winner, the loser, with the + // first two advancing; the undrafted pools are as they were. + async function expectOrdered(page, where) { + const first = [tb.a, tbWinner, tbLoser]; + await expectTable(page, where, first, (n) => ({ + rank: ORDINAL[first.indexOf(n)], advancing: first.indexOf(n) < 2, tied: false, + })); + await expectTable(page, where, [tb.d], (n) => ({ ...tally[n], rank: '1st', advancing: true, tied: false })); + await expectTable(page, where, [tb.e, tb.f], (n) => ({ ...tally[n], rank: '=2nd', advancing: false, tied: true })); + await expectTable(page, where, clean, (n) => ({ + ...tally[n], rank: ORDINAL[clean.indexOf(n)], advancing: clean.indexOf(n) < 1, tied: false, + })); + } + + test('the winner, not the loser, is seated in the knockout: admin and public viewer', async ({ page, browser, baseURL }) => { + await login(page); + const qualifiers = [tb.a, tbWinner, tb.d, tb.g].sort(); + const seatedIn = async (p) => { + const slots = await bracketSlots(p, (read) => { + const names = Object.values(read).map((x) => x.name); + return qualifiers.every((q) => names.includes(q)); + }); + return Object.values(slots).map((x) => x.name).filter((n) => NAMES_9.includes(n)).sort(); + }; + await openCompetitionTab(page, compId, 'bracket'); + expect(await seatedIn(page), 'the admin bracket seats the four qualifiers').toEqual(qualifiers); + await shot(page, 'admin-bracket-after'); + + const spectator = await browser.newContext({ ...PUBLIC_DEVICE, baseURL }); + try { + const phone = await spectator.newPage(); + await openViewerPools(phone, compId); + await expectOrdered(phone, 'public viewer Pools tab'); + await openViewer(phone, compId); + await phone.getByRole('button', { name: 'Bracket' }).tap(); + expect(await seatedIn(phone), 'the public bracket seats the same four').toEqual(qualifiers); + await shot(phone, 'viewer-bracket-after'); + } finally { + await spectator.close(); + } + expect(qualifiers, 'the tie-break loser is out').not.toContain(tbLoser); + record({ journey: JOURNEY_FILL, step: 'knockout', action: 'seated', qualifiers, out: [tbLoser, tb.e, tb.f, tb.h, tb.i] }); + }); +}); diff --git a/scripts/e2e/journeys/shared-shiaijo.spec.mjs b/scripts/e2e/journeys/shared-shiaijo.spec.mjs index aedd005e9..23bcd47f8 100644 --- a/scripts/e2e/journeys/shared-shiaijo.spec.mjs +++ b/scripts/e2e/journeys/shared-shiaijo.spec.mjs @@ -31,7 +31,7 @@ import { test, expect } from '../fixtures/test.mjs'; import { journeyShots } from '../fixtures/shots.mjs'; import { auditLog } from '../fixtures/audit.mjs'; -import { login } from '../fixtures/setup.mjs'; +import { enterTournament, login } from '../fixtures/setup.mjs'; import { openShiaijo, upNextCard, sides, startUpNext, completedRowFor, inlineEditor, editorIdentity, } from '../fixtures/shiaijo.mjs'; @@ -41,7 +41,8 @@ import { import { doubleTap, hastyShiaijoConfirm, interrupt, loudestButton, retapWhileSaving, tapNeighbour, } from '../fixtures/clumsy.mjs'; -import { dwell, settled } from '../fixtures/pace.mjs'; +import { dwell, rendered, settled, untilStable } from '../fixtures/pace.mjs'; +import { EXPECT_TIMEOUT_MS } from '../fixtures/devices.mjs'; import { tapSize } from '../fixtures/pointer.mjs'; import { openTvBoard, tvBoard } from '../fixtures/public.mjs'; import { @@ -50,7 +51,7 @@ import { } from '../fixtures/individual.mjs'; import * as T from '../fixtures/team.mjs'; import { - enterTournament, seedCompetitions, readScoresTab, bracketSlots, operatorDevice, publicDevice, + seedCompetitions, readScoresTab, readScoresRows, bracketSlots, operatorDevice, publicDevice, competitionPicker, officiatingName, pickCompetition, nudges, nudgeLines, courtState, runningOnCourt, playTeamMatch, liveBout, correctAcross, chooseCourt, } from '../fixtures/shared-court.mjs'; @@ -127,7 +128,7 @@ async function nextLive(prev) { await expect.poll(async () => { live = await liveOnConsole(); return !!live && !same(live, prev); - }, { timeout: 15_000 }).toBe(true); + }, { timeout: EXPECT_TIMEOUT_MS }).toBe(true); return live; } @@ -182,10 +183,12 @@ test.describe('shared-shiaijo', () => { // should be one after the other and the start-time will need to be adjusted // depending on how the day is going"). ST was created after SI and shares // Shiaijo A with it, so by default every ST match on A comes after SI's last - // match there. First in the file because S1 re-times ST with Auto-schedule, - // after which the defaults can no longer be read; it only reads the Scores - // and Bracket tabs, so S1 starts from the same state. The wizard hint's new - // wording is the fix's to write: it is recorded here, not asserted. + // match there, and ends. First in the file because S1 re-times ST with + // Auto-schedule, after which the defaults can no longer be read; it only reads + // the Scores and Bracket tabs and the wizard's form (nothing is created), so + // S1 starts from the same state. The wizard still offers its start time to the + // operator, who can change it as the day goes. The hint's new wording is the + // fix's to write: it is recorded here, and only its presence is asserted. test.fixme('bc-seqc: a competition sharing a shiaijo with an earlier one is scheduled after it by default', async () => { const clock = (t) => /^\d\d:\d\d$/.test(t); const siOnA = [ @@ -197,8 +200,27 @@ test.describe('shared-shiaijo', () => { hint: draw.defaults.ST.sharedCourtHint, startTimes: { si: draw.defaults.SI.startTime, st: draw.defaults.ST.startTime } }); expect(siOnA.length).toBeGreaterThan(0); expect(stOnA.length).toBeGreaterThan(0); - const siLast = siOnA[siOnA.length - 1]; - expect(stOnA[0] > siLast, `${ST}'s first match on A (${stOnA[0]}) comes after ${SI}'s last there (${siLast})`).toBe(true); + // After SI's last match there ENDS, not merely starts: its slot is as long as + // the gap SI's own slots keep (an SI match of a single slot has no gap to read). + const minutes = (t) => Number(t.slice(0, 2)) * 60 + Number(t.slice(3)); + const siStarts = [...new Set(siOnA.map(minutes))]; + const slot = Math.min(...siStarts.slice(1).map((m, i) => m - siStarts[i]).filter((g) => g > 0), Infinity); + const siEnds = siStarts[siStarts.length - 1] + (Number.isFinite(slot) ? slot : 1); + expect(minutes(stOnA[0]), `${ST}'s first match on A (${stOnA[0]}) comes after ${SI}'s last there ends (${siEnds} min)`).toBeGreaterThanOrEqual(siEnds); + // The wizard still tells the operator something about sharing a shiaijo, and + // still lets them set the start time themselves. + expect(draw.defaults.ST.sharedCourtHint, 'the wizard hint about sharing a shiaijo').toBeTruthy(); + // H is shared with the tests after this one: the wizard it opens (a typed + // start time in it) is left again whatever the checks find. + try { + await H.goto('/admin/create-competition'); + const startInput = H.locator('#create-start-time'); + await expect(startInput).toBeEditable(); + await startInput.fill('13:45'); + await expect(startInput).toHaveValue('13:45'); + } finally { + await H.goto('/admin'); + } }); // S1 the picker, S8 the slot times (P6), S7 the public queue before play. @@ -243,7 +265,7 @@ test.describe('shared-shiaijo', () => { nb.note.push(String(e.message).slice(0, 120)); } } - await C.waitForTimeout(600); + await rendered(C); r({ step: 'S1 picker', action: 'competition picker', variant: 'V1 tapNeighbour (left, else down)', direction: nb.direction, hit: nb.hit, gapPx: nb.gapPx, note: nb.note, url: new URL(C.url()).pathname, officiating: await officiatingName(C).catch(() => null), shot: await shot(C, 's1-picker-neighbour-tapped') }); r({ step: 'S1 picker', action: 'competition picker', variant: 'size', ...(await tapSize(competitionPicker(C))) }); @@ -261,8 +283,12 @@ test.describe('shared-shiaijo', () => { await H.locator('#auto-schedule-start').fill(ST_RESTART); const spacing = await H.locator('#auto-match-duration').inputValue().catch(() => null); await H.getByRole('button', { name: 'Auto-schedule competition' }).tap(); - await expect(H.getByRole('button', { name: 'Auto-schedule competition' })).toBeEnabled({ timeout: 15_000 }); - await H.waitForTimeout(800); + await expect(H.getByRole('button', { name: 'Auto-schedule competition' })).toBeEnabled({ timeout: EXPECT_TIMEOUT_MS }); + // The button is enabled again before it has visibly been busy, so the saved + // times are what says the re-timing is done: ST's earliest slot is the restart. + // One navigation; the poll reads the tab as the app follows the saved times. + await H.goto(`/admin/competition/${encodeURIComponent(ids.ST)}/scores`); + await expect.poll(async () => (await readScoresRows(H)).map((m) => m.time).filter(Boolean).sort()[0]).toBe(ST_RESTART); const siAfter = await readScoresTab(H, ids.SI); draw.st = await readScoresTab(H, ids.ST); draw.stKnockout = await bracketSlots(H, ids.ST); @@ -273,9 +299,9 @@ test.describe('shared-shiaijo', () => { // S7 / P8 / D2: the public surfaces for A before play. await openTvBoard(TV, 'A'); - await TV.waitForTimeout(1000); await P.goto('/schedule'); - await P.waitForTimeout(1000); + // Both screens fill from the server after they load: the text they settle on. + await untilStable(async () => [await tvBoard(TV).innerText(), await P.locator('body').innerText()]); r({ step: 'S7 public', action: 'TV board A and the public schedule before play', variant: 'P8 record (D2)', tv: (await tvBoard(TV).innerText()).replace(/\s+/g, ' ').slice(0, 500), schedule: (await P.locator('body').innerText()).replace(/\s+/g, ' ').slice(0, 900), @@ -299,8 +325,22 @@ test.describe('shared-shiaijo', () => { const [shiro, aka] = s.upNext.names; const otherNames = s.officiating === SI ? TEAMS.map((t) => t[0]) : ROSTER_12.map((p) => p[0]); await openTvBoard(TV, 'A'); - await expect(tvBoard(TV)).toContainText(shiro); - await expect(tvBoard(TV)).toContainText(aka); + // The board's next-match slots (display_scoreboard.jsx), each on its own. The + // promoted slot is the match the board puts first before play (its NOW row on an + // individual board, the headline on a team board) and must be the console's Up + // next; the NEXT line, drawn when a queued match is not already on the board, + // must not be the other competition's. The UP NEXT strip lists the next POOL and + // is not the next match, so it is not read here. + const promoted = s.officiating === SI ? TV.getByTestId('tvd-indiv-row-now').first() : tvBoard(TV); + await expect(promoted, 'the TV board promotes a match').toBeVisible(); + await expect(promoted).toContainText(shiro); + await expect(promoted).toContainText(aka); + const nextLine = TV.locator('div:has(> span:text-is("NEXT"))'); + // The NEXT line is drawn only when a queued match is not already on the + // board, so its absence is allowed; the board has to have stopped drawing + // before it is taken to be absent. + if (await untilStable(() => nextLine.count())) for (const name of otherNames) await expect(nextLine).not.toContainText(name); + // And the other competition appears nowhere else on the board either. for (const name of otherNames) await expect(tvBoard(TV)).not.toContainText(name); await P.goto('/schedule'); const nextUp = scheduleColumn(P, 'A').locator('.tw-match') @@ -329,7 +369,10 @@ test.describe('shared-shiaijo', () => { const fin = finishButton(C); const finLabel = (await fin.innerText()).trim(); const dbl = await doubleTap(fin); - await C.waitForTimeout(1000); + await expect(completedRowFor(C, bouts.b1).or(armedFinishButton(C))).toBeVisible(); + // Out the arm's bounce window before reading, so a second tap that was + // wrongly counted would have committed by now, and the tap below counts. + await dwell(C, Date.now()); const committedByDouble = await completedRowFor(C, bouts.b1).isVisible().catch(() => false); const armedAfter = await armedFinishButton(C).isVisible().catch(() => false); r({ step: 'S2 advance', action: finLabel, variant: 'V2 doubleTap', ...dbl, committedByDouble, stillArmed: armedAfter, @@ -338,7 +381,6 @@ test.describe('shared-shiaijo', () => { expect(finLabel).toBe('Finish + Start Next →'); expect(committedByDouble).toBe(false); expect(armedAfter).toBe(true); - await dwell(C, Date.now() - 1000); await armedFinishButton(C).tap(); await expect(completedRowFor(C, bouts.b1)).toBeVisible(); live = await nextLive(bouts.b1); @@ -360,7 +402,7 @@ test.describe('shared-shiaijo', () => { liveBout: corr.live, ...corr.refusal, shot: await shot(C, 's4a-correction-open') }); expect(same(corr.live, bouts.b2)).toBe(true); await C.getByRole('button', { name: /Back to court/ }).tap(); - await C.waitForTimeout(800); + await expect(C.getByRole('button', { name: /Back to court/ })).toBeHidden(); const landed = await courtState(C); r({ step: 'S4a correct', action: 'Back to court', variant: 'V5 lost place', officiating: landed.officiating, upNext: landed.upNext, editor: landed.editor, idle: landed.idle, shot: await shot(C, 's4a-back-to-court') }); @@ -391,9 +433,8 @@ test.describe('shared-shiaijo', () => { r({ step: 'S2b advance', action: 'clock check', variant: 'note', stEarlierThanNextSi: !!nextSiTime && draw.st[0].time < nextSiTime }); // S7 again (D2): what the public sees while the console runs SI. - await TV.waitForTimeout(800); await P.reload(); - await P.waitForTimeout(1000); + await untilStable(async () => [await tvBoard(TV).innerText(), await P.locator('body').innerText()]); r({ step: 'S7 public', action: 'TV board A and the public schedule while SI runs on A', variant: 'P8 record (D2)', consoleRunsNext: 'the next SI bout on A (the console stays on SI)', tv: (await tvBoard(TV).innerText()).replace(/\s+/g, ' ').slice(0, 500), @@ -417,7 +458,7 @@ test.describe('shared-shiaijo', () => { await expect.poll(async () => { ov = { ...(await editorIdentity(C, EDITOR)), ...(await editorSides(C, EDITOR)) }; return !same(ov, bouts.b3) && (await C.locator(EDITOR).locator('.editor-head-pill').count()) === 0; - }, { timeout: 15_000 }).toBe(true); + }, { timeout: EXPECT_TIMEOUT_MS }).toBe(true); bouts.b4 = pairOf(ov); const expectedB4 = siA.find((m) => m.time > nextSiTime && ![m.shiro, m.aka].includes(withdrawn)); r({ step: 'S9 scores page', action: s9Label, variant: 'correct path', listOnA, next: ov, expected: expectedB4, @@ -431,8 +472,9 @@ test.describe('shared-shiaijo', () => { const nav = (cls) => C.locator(EDITOR).locator(cls); for (const cls of ['.score-nav__next', '.score-nav__next', '.score-nav__prev', '.score-nav__prev']) { if (!(await nav(cls).isEnabled().catch(() => false))) continue; + const heldBefore = await C.locator(EDITOR).innerText(); await nav(cls).tap(); - await C.waitForTimeout(300); + await expect.poll(() => C.locator(EDITOR).innerText()).not.toBe(heldBefore); walked.push((await editorIdentity(C, EDITOR)).eyebrow); } r({ step: 'S9 scores page', action: 'Next x2, Prev x2', variant: 'correct path (allowed to walk the court)', walked, @@ -445,7 +487,7 @@ test.describe('shared-shiaijo', () => { await expect.poll(async () => { ov = { ...(await editorIdentity(C, EDITOR)), ...(await editorSides(C, EDITOR)) }; return !same(ov, bouts.b4) && (await C.locator(EDITOR).locator('.editor-head-pill').count()) === 0; - }, { timeout: 15_000 }).toBe(true); + }, { timeout: EXPECT_TIMEOUT_MS }).toBe(true); bouts.b5 = pairOf(ov); r({ step: 'S9 scores page', action: 'Fusenpai (Aka) in the overlay', variant: 'correct path (after-decision advance)', ...fus, next: ov, shot: await shot(C, 's9-after-fusenpai') }); @@ -464,7 +506,7 @@ test.describe('shared-shiaijo', () => { // S3: back on the console, SI's bout running with a point. The operator // switches the picker to ST and starts ST's Up next. await openShiaijo(C, 'A'); - await expect.poll(async () => (await liveOnConsole()) && same(await liveOnConsole(), bouts.b5), { timeout: 15_000 }).toBe(true); + await expect.poll(async () => (await liveOnConsole()) && same(await liveOnConsole(), bouts.b5), { timeout: EXPECT_TIMEOUT_MS }).toBe(true); const marksB5 = await slotMarks(C, winnerOf(bouts.b5)); await pickCompetition(C, ST); const s3 = await courtState(C); @@ -474,7 +516,7 @@ test.describe('shared-shiaijo', () => { // P1: a reload drops the pick. await interrupt(C, 'reload'); await expect(C.getByRole('heading', { level: 1, name: 'Shiaijo A' })).toBeVisible(); - await C.waitForTimeout(1000); + await expect(C.locator('.shiaijo-officiating__name').first()).toBeVisible(); r({ step: 'S3 switch', action: `reload with ${ST} picked and an SI bout running`, variant: 'V3 interrupt reload (P1)', officiatingAfter: await officiatingName(C), shot: await shot(C, 's3-reload-after-pick') }); if ((await officiatingName(C)) !== ST) await pickCompetition(C, ST); @@ -487,8 +529,11 @@ test.describe('shared-shiaijo', () => { } catch (e) { nbStart = { hit: null, note: String(e.message).slice(0, 120) }; } - await C.waitForTimeout(800); - const panelOpen = await C.locator('[data-testid^="match-lineup-side-"]').first().isVisible().catch(() => false); + const lineupPanel = C.locator('[data-testid^="match-lineup-side-"]').first(); + // A neighbour that is the lineup button opens the panel; any other control + // opens nothing, which the read below takes as it finds it. + if (/lineup/i.test((nbStart.hit && nbStart.hit.label) || '')) await expect(lineupPanel).toBeVisible(); + const panelOpen = await lineupPanel.isVisible().catch(() => false); r({ step: 'S3 switch', action: "Start match (ST's Up next)", variant: 'V1 tapNeighbour right', hit: nbStart.hit && nbStart.hit.label, note: nbStart.note, lineupPanelOpened: panelOpen, stillRunning: await liveOnConsole(), shot: await shot(C, 's3-start-neighbour-tapped') }); if (panelOpen) await T.closePanel(C); @@ -505,13 +550,18 @@ test.describe('shared-shiaijo', () => { } }; C.on('response', onRes); - const dblStart = await doubleTap(stCard.getByRole('button', { name: 'Start match' })); - await expect.poll(async () => { - const l = await liveOnConsole(); - return !!l && l.comp === ST; - }, { timeout: 15_000 }).toBe(true); - await C.waitForTimeout(1500); - C.off('response', onRes); + let dblStart; + try { + dblStart = await doubleTap(stCard.getByRole('button', { name: 'Start match' })); + await expect.poll(async () => { + const l = await liveOnConsole(); + return !!l && l.comp === ST; + }, { timeout: EXPECT_TIMEOUT_MS }).toBe(true); + await expect.poll(() => writes.length).toBeGreaterThan(0); + } finally { + // A failed poll must not leave the listener on the shared page. + C.off('response', onRes); + } const onAafterSwitch = await runningOnCourt(H, 'A'); const s3b = await courtState(C); r({ step: 'S3 switch', action: "Start match (ST's Up next) while the SI bout runs", variant: 'V2 doubleTap', ...dblStart, writes, @@ -534,7 +584,7 @@ test.describe('shared-shiaijo', () => { await pickCompetition(C, SI); expect(same(await sides(upNextCard(C)), bouts.b5)).toBe(true); await upNextCard(C).getByRole('button', { name: 'Start match' }).tap(); - await expect.poll(async () => same(await liveOnConsole(), bouts.b5), { timeout: 15_000 }).toBe(true); + await expect.poll(async () => same(await liveOnConsole(), bouts.b5), { timeout: EXPECT_TIMEOUT_MS }).toBe(true); const onAback = await runningOnCourt(H, 'A'); const marksBack = await slotMarks(C, winnerOf(bouts.b5)); r({ step: 'S3 switch', action: 'Start the sent-back SI bout (ST semi-final running)', variant: 'correct path (bc-sbq)', @@ -601,9 +651,9 @@ test.describe('shared-shiaijo', () => { await startBtn.waitFor({ state: 'visible' }); await dwell(H, Date.now()); await startBtn.tap(); - await expect(T.syncPill(hed)).toBeVisible({ timeout: 15_000 }); + await expect(T.syncPill(hed)).toBeVisible({ timeout: EXPECT_TIMEOUT_MS }); const alert = C.locator('.shiaijo-also-running'); - await expect(alert).toBeVisible({ timeout: 15_000 }); + await expect(alert).toBeVisible({ timeout: EXPECT_TIMEOUT_MS }); r({ step: 'S6d reopen', action: `head table starts ${ST}'s semi-final while the console holds an SI correction`, variant: 'two devices', alert: (await alert.innerText()).replace(/\s+/g, ' ').trim(), shot: await shot(C, 's6d-also-running-during-correction') }); await hed.getByRole('button', { name: /✕ Close/ }).tap().catch(() => {}); @@ -617,7 +667,7 @@ test.describe('shared-shiaijo', () => { // V4: the hurried operator taps the loudest button. const loud = await loudestButton(conflict); await loud.button.tap(); - await expect.poll(async () => same(await liveOnConsole(), bouts.b2), { timeout: 15_000 }).toBe(true); + await expect.poll(async () => same(await liveOnConsole(), bouts.b2), { timeout: EXPECT_TIMEOUT_MS }).toBe(true); const onA = await runningOnCourt(H, 'A'); const stRows = await readScoresTab(H, ids.ST); r({ step: 'S6d reopen', action: 'Clear kiken and reopen while ST runs', variant: 'V4 loudest button in the court_busy panel', conflictHead, @@ -628,7 +678,7 @@ test.describe('shared-shiaijo', () => { // V5 / P1 / P7: the iPad reloads the tab now. await interrupt(C, 'reload'); await expect(C.getByRole('heading', { level: 1, name: 'Shiaijo A' })).toBeVisible(); - await C.waitForTimeout(1000); + await expect(C.locator('.shiaijo-officiating__name').first()).toBeVisible(); r({ step: 'S6d reopen', action: 'reload right after the reopen', variant: 'V3/V5 interrupt reload (P1, P7)', court: await courtState(C), shot: await shot(C, 's6d-reload') }); if ((await officiatingName(C)) !== SI) await pickCompetition(C, SI); @@ -645,6 +695,7 @@ test.describe('shared-shiaijo', () => { // S5 (ii): SI's pools on A are done; its quarter-finals here still wait on // pool placeholders from B (hidden rows), and nothing runs on A. + // Deliberate: nothing may start, so the time is the evidence. await C.waitForTimeout(2500); const ii = await courtState(C); r({ step: 'S5 SI done on A', action: 'the console with SI pools done on A, B pools still to play', variant: 'P3 / P7 record', court: ii, @@ -653,7 +704,7 @@ test.describe('shared-shiaijo', () => { // P1 / P7: a reload with no pick now. await interrupt(C, 'reload'); await expect(C.getByRole('heading', { level: 1, name: 'Shiaijo A' })).toBeVisible(); - await C.waitForTimeout(1200); + await expect(C.locator('.shiaijo-officiating__name').first()).toBeVisible(); r({ step: 'S5 SI done on A', action: 'reload (the explicit pick is dropped)', variant: 'V3 interrupt reload (P1)', officiatingAfter: await officiatingName(C), shot: await shot(C, 's5-ii-after-reload') }); if ((await officiatingName(C)) !== SI) await pickCompetition(C, SI); @@ -694,6 +745,7 @@ test.describe('shared-shiaijo', () => { // S5 (i): SI has nothing running or scheduled on A, its final waits on // M6 (running on B): a Later row. 3 s later the console still shows SI. + // Deliberate: the console must NOT switch by itself, so the time is the evidence. await C.waitForTimeout(3000); const i5 = await courtState(C); const lines = await nudgeLines(C); @@ -768,7 +820,7 @@ test.describe('shared-shiaijo', () => { // bead says. test.fixme('bc-sypl: score editors show no sync pill; the header shows what is not sent yet', async () => { if (!/\/admin\/shiaijo\/B$/.test(Bc.url())) await openShiaijo(Bc, 'B'); - await expect.poll(async () => same(await liveBout(Bc), bouts.m6), { timeout: 15_000 }).toBe(true); + await expect.poll(async () => same(await liveBout(Bc), bouts.m6), { timeout: EXPECT_TIMEOUT_MS }).toBe(true); const ed = inlineEditor(Bc); await expect(ed.getByText(SYNC_PILL)).toHaveCount(0); const conn = Bc.locator('.topbar').getByRole('status').filter({ hasText: 'Connected' }); @@ -812,7 +864,7 @@ test.describe('shared-shiaijo', () => { await expect(m6Row).toBeVisible(); await m6Row.locator('button[aria-haspopup="listbox"]').tap(); await H.getByRole('listbox', { name: 'Choose shiaijo' }).getByRole('option', { name: 'A', exact: true }).tap(); - await H.waitForTimeout(500); + await rendered(H); const askedFirst = await H.locator('.modal[role="dialog"]').isVisible().catch(() => false); const alert = C.locator('.shiaijo-also-running'); const alertShown = await alert.waitFor({ state: 'visible', timeout: 10_000 }).then(() => true, () => false); @@ -824,6 +876,8 @@ test.describe('shared-shiaijo', () => { const held = await liveOnConsole(); const ed = inlineEditor(C); let pointTried = null; + // The waits below are the time a refusal (or none) takes to show: whether + // the point is refused is what the row records, so there is no state to wait for. if (held && held.comp === ST) { const before = await T.rowSlots(T.boutRow(ed, 2), 'shiro').count(); await T.rowIppon(T.boutRow(ed, 2), 'shiro', 'M').tap(); @@ -841,7 +895,7 @@ test.describe('shared-shiaijo', () => { // Recovery the alert points at: send the shown bout back to the queue. await C.getByRole('button', { name: 'Send back to queue' }).tap(); const sent = await hastyShiaijoConfirm(C); - await C.waitForTimeout(1500); + await expect.poll(async () => !same(await liveOnConsole(), held)).toBe(true); const onAafter = await runningOnCourt(H, 'A'); r({ step: 'S6a move', action: 'Send back to queue (the shown bout)', variant: 'V4 hastyShiaijoConfirm (recovery)', ...sent, runningOnA: onAafter, court: await courtState(C), shot: await shot(C, 's6a-after-send-back') }); @@ -878,14 +932,14 @@ test.describe('shared-shiaijo', () => { // ST's semi-final runs on A; starting it sends a running M6 back to A's queue. await pickCompetition(C, ST); if (((await liveOnConsole()) || {}).comp !== ST) await T.startUpNextTeam(C); - await expect.poll(async () => ((await liveOnConsole()) || {}).comp, { timeout: 15_000 }).toBe(ST); + await expect.poll(async () => ((await liveOnConsole()) || {}).comp, { timeout: EXPECT_TIMEOUT_MS }).toBe(ST); const stLive = await liveOnConsole(); // M6, waiting on A, moves to B (a waiting move asks nothing) and starts there. const m6ScoresRow = () => H.locator('.score-edit-row').filter({ hasText: bouts.m6.shiro }).filter({ hasText: bouts.m6.aka }).first(); await H.goto(`/admin/competition/${ids.SI}/scores`); await chooseCourt(m6ScoresRow(), 'B'); await openShiaijo(Bc, 'B'); - await expect.poll(async () => same(await sides(upNextCard(Bc)).catch(() => null), bouts.m6), { timeout: 15_000 }).toBe(true); + await expect.poll(async () => same(await sides(upNextCard(Bc)).catch(() => null), bouts.m6), { timeout: EXPECT_TIMEOUT_MS }).toBe(true); expect(same(await ensureRunning(Bc), bouts.m6)).toBe(true); const marks = { shiro: await slotMarks(Bc, 'shiro'), aka: await slotMarks(Bc, 'aka') }; // The move: the running M6, from B's column of the schedule page onto A. @@ -908,7 +962,7 @@ test.describe('shared-shiaijo', () => { await expect(C.locator('.shiaijo-also-running')).toHaveCount(0); // M6 is SI's next match on A, with the score it had. await pickCompetition(C, SI); - await expect.poll(async () => same(await sides(upNextCard(C)).catch(() => null), bouts.m6), { timeout: 15_000 }).toBe(true); + await expect.poll(async () => same(await sides(upNextCard(C)).catch(() => null), bouts.m6), { timeout: EXPECT_TIMEOUT_MS }).toBe(true); await H.goto(`/admin/competition/${ids.SI}/scores`); await m6ScoresRow().getByRole('button', { name: /^Score$/ }).tap(); await expect(H.locator(EDITOR)).toBeVisible(); diff --git a/scripts/e2e/journeys/smoke.spec.mjs b/scripts/e2e/journeys/smoke.spec.mjs index 2908233cb..abdf4d9fb 100644 --- a/scripts/e2e/journeys/smoke.spec.mjs +++ b/scripts/e2e/journeys/smoke.spec.mjs @@ -11,7 +11,7 @@ // cross-surface result need. The knockout-mixed journeys are where variants // get judged. import { test, expect, isCoarse } from '../fixtures/test.mjs'; -import { PUBLIC_DEVICE } from '../fixtures/devices.mjs'; +import { PUBLIC_DEVICE, EXPECT_TIMEOUT_MS } from '../fixtures/devices.mjs'; import { journeyShots } from '../fixtures/shots.mjs'; import { auditLog } from '../fixtures/audit.mjs'; import { createTournament, login, signOut } from '../fixtures/setup.mjs'; @@ -101,7 +101,7 @@ test.describe('smoke', () => { const identity = await editorIdentity(page); const reload = await interrupt(page, 'reload'); - const backAfterReload = await inlineEditor(page).waitFor({ state: 'visible', timeout: 15_000 }) + const backAfterReload = await inlineEditor(page).waitFor({ state: 'visible', timeout: EXPECT_TIMEOUT_MS }) .then(() => true, () => false); record({ variant: 'V3 interrupt', action: 'running bout', ...reload, editorBack: backAfterReload, sameMatch: backAfterReload && (await editorIdentity(page)).key === identity.key }); @@ -113,7 +113,7 @@ test.describe('smoke', () => { sameMatch: (await editorIdentity(page)).key === identity.key }); const back = await interrupt(page, 'back'); - const backAfterBack = await inlineEditor(page).waitFor({ state: 'visible', timeout: 15_000 }) + const backAfterBack = await inlineEditor(page).waitFor({ state: 'visible', timeout: EXPECT_TIMEOUT_MS }) .then(() => true, () => false); const shiroPoints = backAfterBack ? await inlineEditor(page).locator('[aria-label^="Shiro slot "][aria-label*=": remove "]').count() : null; @@ -142,9 +142,9 @@ test.describe('smoke', () => { const offline = await interrupt(page, 'offline', { during: async (p) => { await ipponButton(p, 'aka', 'M').tap(); - // What the autosave pill settles on while the network is down: give - // it a moment to leave "Synced", and record whatever it shows. - await expect(syncState(p)).not.toHaveText('Synced', { timeout: 3000 }).catch(() => {}); + // The pill must stop claiming "Synced" while the point cannot be + // saved; what it settles on instead is recorded, not asserted. + await expect(syncState(p)).not.toHaveText('Synced'); offlineState = (await syncState(p).innerText()).trim(); }, }); diff --git a/scripts/e2e/journeys/spectator.public.spec.mjs b/scripts/e2e/journeys/spectator.public.spec.mjs new file mode 100644 index 000000000..5330fdf98 --- /dev/null +++ b/scripts/e2e/journeys/spectator.public.spec.mjs @@ -0,0 +1,419 @@ +// The spectator's journey, on the PUBLIC project (a 390x844 phone, no sign-in): +// the public home, a competition page and its tabs, the schedule, competitor +// search by number, name and dojo (the bc-nsrc rule), the watchlist, and the +// match card opened from every door while an operator scores. +// +// The test's own `page` is the phone. The tournament is seeded and scored from +// a SECOND context opened with the operator's iPad (OPERATOR_DEVICE), through +// the interface, so the tournament is OFFICIATED: a spectator reads, never +// scores (self-run public scoring is a different journey, mp-yqxn.5). +// +// Two individual competitions with number prefixes chosen so the search rule +// can be seen: +// K Kendo Open 12 competitors, K1..K12 (so K1 and K12 both exist), court A +// M Women Open 6 competitors, M1..M6, court B; no name or dojo of the M +// draw holds a "k" or a digit, so "k" and "1" can only find a K competitor +// by number, or nobody at all. +// Pools of 3 (two through), so every match has both sides at once. Court A +// plays Pool A first, three bouts among the same three competitors. The tests +// read each pairing off the operator's court console and each number off the +// public pages, and assume neither: only the prefixes and the counts are fixed. +import { test, expect } from '../fixtures/test.mjs'; +import { OPERATOR_DEVICE } from '../fixtures/devices.mjs'; +import { journeyShots } from '../fixtures/shots.mjs'; +import { auditLog } from '../fixtures/audit.mjs'; +import { dwell } from '../fixtures/pace.mjs'; +import { createTournament } from '../fixtures/setup.mjs'; +import { createCompetition } from '../fixtures/wizard.mjs'; +import { generateDraw, pasteRoster, startCompetition } from '../fixtures/competition.mjs'; +import { openShiaijo, sides, startUpNext, upNextCard, waitingRows } from '../fixtures/shiaijo.mjs'; +import { awardIppon, clearMarks, filledSlots, finishMatch, syncState } from '../fixtures/scoring.mjs'; +import { editorSides } from '../fixtures/individual.mjs'; +import { + NATURAL, applyScheduleText, cardMarks, cardSlots, clearScheduleText, competitionTab, homeNowRow, expandedCard, matchCard, nowCard, numbersOf, pickerEntries, + pickerOption, pickerPairs, poolRow, scheduleCount, scheduleFilterInput, scheduleRow, scheduleRows, upNextRow, watchHero, + watchHeroMatch, watchInput, +} from '../fixtures/public.mjs'; + +// Twelve K competitors, six dojos plus one with a single member (Shudokan). +const KENDO = [ + ['Akira Tanaka', 'Gyokusen'], ['Ben Carter', 'Thames'], ['Chie Mori', 'Musashi'], ['Dan Evans', 'Seishinkan'], + ['Eri Kato', 'Gyokusen'], ['Finn Hale', 'Thames'], ['Goro Abe', 'Musashi'], ['Hana Ito', 'Seishinkan'], + ['Ivo Nunes', 'Mumeishi'], ['Jun Oda', 'Hizen'], ['Lea Roth', 'Mumeishi'], ['Sora Endo', 'Shudokan'], +]; +// Six M competitors: no "k" and no digit in any name or dojo. +const WOMEN = [ + ['Ana Rossi', 'Thames'], ['Bea Lopez', 'Musashi'], ['Cleo Dane', 'Hizen'], + ['Dora Pine', 'Mumeishi'], ['Ella Quinn', 'Bristol'], ['Fay Stone', 'Nottingham'], +]; + +test.describe.configure({ mode: 'serial' }); + +test.describe('spectator on a phone', () => { + const audit = auditLog('spectator'); + const { record } = audit; + let shot; + let opCtx; + let op; // the operator's page, signed in, kept for the whole file + const ids = {}; + + test.beforeAll(async ({ browser, serverForFile }, testInfo) => { + shot = journeyShots('spectator'); + const server = await serverForFile(testInfo.file); + opCtx = await browser.newContext({ ...OPERATOR_DEVICE, baseURL: server.base }); + op = await opCtx.newPage(); + await createTournament(op, { name: 'Spectator Cup', courts: 2 }); + ids.kendo = await createCompetition(op, { + name: 'Kendo Open', kind: 'individual', format: 'mixed', poolSize: 3, poolWinners: 2, courts: ['A'], numberPrefix: 'K', + }); + await pasteRoster(op, ids.kendo, KENDO); + await generateDraw(op, ids.kendo); + await startCompetition(op, ids.kendo); + ids.women = await createCompetition(op, { + name: 'Women Open', kind: 'individual', format: 'mixed', poolSize: 3, poolWinners: 2, courts: ['B'], numberPrefix: 'M', + }); + await pasteRoster(op, ids.women, WOMEN); + await generateDraw(op, ids.women); + await startCompetition(op, ids.women); + // The bout court A plays first, read from the operator's console. + await openShiaijo(op, 'A'); + ids.first = await sides(upNextCard(op)); + }); + + test.afterAll(async () => { + audit.flush(); + await opCtx?.close(); + }); + + test('the public home, a competition page and its tabs, and the schedule show the competitions and matches', async ({ page }) => { + await test.step('the home lists both competitions, the schedule link and what plays next', async () => { + await page.goto('/'); + await expect(page.locator('.viewer__title')).toHaveText('Spectator Cup'); + const kendo = page.locator('.vlist-item--comp', { hasText: 'Kendo Open' }); + const women = page.locator('.vlist-item--comp', { hasText: 'Women Open' }); + await expect(kendo).toContainText('12 players'); + await expect(women).toContainText('6 players'); + await expect(page.locator('.viewer-nav-card')).toContainText('18 matches · 2 courts'); + // Up next: the first three bouts of court A, K numbers beside the names. + await expect(page.locator('.viewer__upnext-title')).toHaveText(/^Up next · 3$/); + await expect(page.locator('button.vsched-item')).toHaveCount(3); + await expect(page.locator('button.vsched-item').first()).toContainText(ids.first.shiro); + await expect(page.locator('button.vsched-item').first()).toContainText(ids.first.aka); + await expect(page.locator('button.vsched-item').first()).toContainText(/K\d+/); + await shot(page, 'home', { fullPage: true }); + }); + + await test.step('a competition card opens its page: Overview, Pools and Bracket tabs', async () => { + await page.locator('.vlist-item--comp', { hasText: 'Kendo Open' }).tap(); + await expect(page).toHaveURL(new RegExp(`/competition/${ids.kendo}$`)); + for (const tab of ['Overview', 'Pools', 'Bracket']) await expect(competitionTab(page, tab)).toBeVisible(); + // Overview: the next matches. + await expect(page.locator('.section-title', { hasText: /^Up next/ })).toBeVisible(); + await expect(upNextRow(page, ids.first)).toBeVisible(); + await shot(page, 'competition-overview'); + + // Pools: four pools of three, every match listed once. + await competitionTab(page, 'Pools').tap(); + await expect(page.locator('.pool-match-numbered-row')).toHaveCount(12); + await expect(page.getByText(/^Pool [A-D]$/)).toHaveCount(4); + await expect(poolRow(page, ids.first)).toBeVisible(); + await shot(page, 'competition-pools'); + + // Bracket: the knockout tree, drawn before the pools are played. + await competitionTab(page, 'Bracket').tap(); + await expect(competitionTab(page, 'Bracket')).toHaveClass(/is-active/); + await expect(page.getByText('Pool A-1st').first()).toBeVisible(); + await shot(page, 'competition-bracket'); + }); + + await test.step('the second competition has its own page, with its pools', async () => { + await page.goto(`/competition/${ids.women}`); + await competitionTab(page, 'Pools').tap(); + await expect(page.locator('.pool-match-numbered-row')).toHaveCount(6); + await expect(page.getByText(/^Pool [A-B]$/)).toHaveCount(2); + }); + + await test.step('the schedule lists every match on both courts', async () => { + await page.goto('/'); + await page.locator('.viewer-nav-card').tap(); + await expect(page).toHaveURL(/\/schedule$/); + await expect(scheduleRows(page)).toHaveCount(18); + await expect(scheduleCount(page)).toHaveText('18 matches of 18'); + await expect(page.locator('.tw-court', { hasText: 'SHIAIJO A' }).locator('.tw-court__sub')).toHaveText('12 matches'); + await expect(page.locator('.tw-court', { hasText: 'SHIAIJO B' }).locator('.tw-court__sub')).toHaveText('6 matches'); + await expect(scheduleRow(page, ids.first)).toContainText('Kendo Open'); + await shot(page, 'schedule', { fullPage: true }); + }); + }); + + test('competitor search: the number rule (bc-nsrc), names and dojos, on the watch picker and the schedule', async ({ page }) => { + const KENDO_NUMBERS = numbersOf('K', 12); + const WOMEN_NUMBERS = numbersOf('M', 6); + // name -> number, learned from the lists themselves (the draw hands the + // numbers out; only the prefixes and the counts are fixed by the seeding). + const numberOf = {}; + const learn = async (page_) => { for (const p of await pickerPairs(page_)) numberOf[p.name] = p.number; }; + const numbersFor = (...names) => names.map((n) => numberOf[n]).sort(NATURAL); + + // One surface's expectations, run against both pickers: the same rule, so + // the same answers. `type` puts a query in the box; `empty` checks the + // surface's own words for "nobody". + const checkRule = async (surface, type, empty) => { + // The prefix on its own lists the whole draw, and no other prefix. + await type('k'); + await expect.poll(() => pickerEntries(page), { message: `${surface}: "k" lists every K and no M` }) + .toMatchObject({ numbers: KENDO_NUMBERS }); + await learn(page); + expect(Object.values(numberOf).filter((n) => n.startsWith('K')), `${surface}: twelve K competitors`).toHaveLength(12); + expect((await pickerEntries(page)).numbers.filter((n) => !n.startsWith('K')), `${surface}: "k" lists no other prefix`).toEqual([]); + await shot(page, `${surface}-k`); + record({ journey: 'spectator', surface, query: 'k', ...(await pickerEntries(page)) }); + + // The prefix and the digits find one competitor: K1 is not K12 (nor K10, K11). + const k1 = Object.keys(numberOf).find((n) => numberOf[n] === 'K1'); + const k12 = Object.keys(numberOf).find((n) => numberOf[n] === 'K12'); + await type('k1'); + await expect.poll(() => pickerEntries(page), { message: `${surface}: "k1" lists K1 alone` }) + .toEqual({ numbers: ['K1'], names: [k1] }); + await shot(page, `${surface}-k1`); + record({ journey: 'spectator', surface, query: 'k1', ...(await pickerEntries(page)) }); + await type('K12'); + await expect.poll(() => pickerEntries(page), { message: `${surface}: "K12" lists K12 alone` }) + .toEqual({ numbers: ['K12'], names: [k12] }); + + // The second prefix, the same way: the whole M draw is listed (with any K + // competitor whose name or dojo holds an "m", as Musashi does), and M3 alone. + await type('m'); + await expect.poll(async () => (await pickerEntries(page)).numbers, { message: `${surface}: "m" lists the whole M draw` }) + .toEqual(expect.arrayContaining(WOMEN_NUMBERS)); + await learn(page); + await type('m3'); + await expect.poll(async () => (await pickerEntries(page)).numbers, { message: `${surface}: "m3" lists M3 alone` }) + .toEqual(['M3']); + + // Digits on their own are not a competitor number: nobody, there being no + // digit in any name or dojo. + await type('1'); + await expect.poll(() => pickerEntries(page), { message: `${surface}: "1" finds nobody` }) + .toEqual({ numbers: [], names: [] }); + await empty(); + await shot(page, `${surface}-1`); + record({ journey: 'spectator', surface, query: '1', ...(await pickerEntries(page)) }); + + // Names and dojos match on any part of the text. + await type('tanak'); + await expect.poll(() => pickerEntries(page), { message: `${surface}: a name fragment` }) + .toEqual({ numbers: numbersFor('Akira Tanaka'), names: ['Akira Tanaka'] }); + await type('bristol'); + await expect.poll(() => pickerEntries(page), { message: `${surface}: a dojo fragment` }) + .toEqual({ numbers: numbersFor('Ella Quinn'), names: ['Ella Quinn'] }); + await type('thames'); + await expect.poll(() => pickerEntries(page), { message: `${surface}: a dojo shared by two draws` }) + .toEqual({ numbers: numbersFor('Ana Rossi', 'Ben Carter', 'Finn Hale'), names: ['Ana Rossi', 'Ben Carter', 'Finn Hale'] }); + record({ journey: 'spectator', surface, query: 'thames', ...(await pickerEntries(page)) }); + }; + + await test.step('the home\'s watch picker', async () => { + await page.goto('/'); + const box = watchInput(page); + await box.tap(); + await checkRule('watch-picker', (q) => box.fill(q), () => expect(page.getByTestId('watchpicker-empty')).toContainText('No one here matches')); + }); + + await test.step('the schedule\'s filter picker', async () => { + await page.goto('/schedule'); + const box = scheduleFilterInput(page); + await box.tap(); + await checkRule('schedule-picker', (q) => box.fill(q), () => expect(page.locator('.pmf__dropdown-head')).toContainText('0 participants')); + }); + + await test.step('the schedule\'s text filter keeps the matches that mention the query', async () => { + await page.goto('/schedule'); + await expect(scheduleRows(page)).toHaveCount(18); + const k1 = Object.keys(numberOf).find((n) => numberOf[n] === 'K1'); + + // "k": the whole K draw, so every Kendo Open match and not one of Women Open. + await applyScheduleText(page, 'k'); + await expect(scheduleRows(page)).toHaveCount(12); + await expect(scheduleCount(page)).toHaveText('12 matches of 18'); + for (const row of await scheduleRows(page).all()) await expect(row).toContainText('Kendo Open'); + record({ journey: 'spectator', surface: 'schedule-rows', query: 'k', rows: await scheduleRows(page).count() }); + await shot(page, 'schedule-rows-k'); + await clearScheduleText(page); + + // "k1": K1's two matches, and none of K10, K11 or K12's. + await applyScheduleText(page, 'k1'); + await expect(scheduleRows(page)).toHaveCount(2); + for (const row of await scheduleRows(page).all()) { + await expect(row).toContainText(`K1 ${k1}`); + await expect(row).not.toContainText(/\bK1[0-2]\b/); + } + record({ journey: 'spectator', surface: 'schedule-rows', query: 'k1', rows: await scheduleRows(page).count() }); + await shot(page, 'schedule-rows-k1'); + await clearScheduleText(page); + + // "1": no match mentions a competitor by a bare digit. + await applyScheduleText(page, '1'); + await expect(scheduleRows(page)).toHaveCount(0); + record({ journey: 'spectator', surface: 'schedule-rows', query: '1', rows: await scheduleRows(page).count() }); + await clearScheduleText(page); + + // A dojo fragment: the two matches Ella Quinn (Bristol) fights. + await applyScheduleText(page, 'bristol'); + await expect(scheduleRows(page)).toHaveCount(2); + for (const row of await scheduleRows(page).all()) await expect(row).toContainText('Ella Quinn'); + await clearScheduleText(page); + await expect(scheduleRows(page)).toHaveCount(18); + }); + }); + + test('watchlist: follow a competitor, and the hero and the match card follow the court live', async ({ page }) => { + await page.goto('/'); + // The app is never reloaded in this test: this marker is lost if it is. + await page.evaluate(() => { window.__spectatorNoReload = 'kept'; }); + // Court A's first bout, and the one after it (read off the operator's + // console). The followed competitor is the one in BOTH: named from the + // queue, not assumed to be a given side of the first bout. + await openShiaijo(op, 'A'); + const first = await sides(upNextCard(op)); + expect(first).toEqual(ids.first); + const queued = []; + for (const row of await waitingRows(op).all()) queued.push(await sides(row)); + const next = queued.find((q) => q.shiro !== first.shiro || q.aka !== first.aka); + expect(next, 'a second bout is queued on court A behind the first').toBeTruthy(); + const followed = [first.shiro, first.aka].find((n) => n === next.shiro || n === next.aka); + expect(followed, `a competitor is in both court A's first bout (${first.shiro} v ${first.aka}) and its next (${next.shiro} v ${next.aka})`).toBeTruthy(); + + await test.step('follow the competitor who fights both of court A\'s first two bouts: the hero shows the match waiting', async () => { + await watchInput(page).fill(followed.toLowerCase().slice(0, 5)); + await pickerOption(page, followed).tap(); + await expect(watchHero(page)).toContainText(followed); + await expect(watchHero(page)).toContainText(first.shiro); + await expect(watchHero(page)).not.toHaveClass(/wl-hero--running/); + await shot(page, 'watching-waiting'); + }); + + await test.step('the operator starts the match: the hero turns to "On court now"', async () => { + expect(await startUpNext(op)).toEqual(first); + await expect(watchHero(page)).toHaveClass(/wl-hero--running/); + await expect(watchHero(page)).toContainText('On court now'); + await shot(page, 'watching-on-court'); + }); + + const card = matchCard(page); + await test.step('the match card opened from the hero follows the operator\'s marks', async () => { + await watchHeroMatch(page).tap(); + await expect(card).toBeVisible(); + await expect(card).toContainText('NOW'); + await expect(cardSlots(card)).toHaveCount(4); + expect(await cardMarks(card)).toEqual({ shiro: '', aka: '' }); + await awardIppon(op, 'shiro', 'M'); + await expect.poll(() => cardMarks(card)).toEqual({ shiro: 'M', aka: '' }); + await shot(page, 'watching-card-live'); + record({ journey: 'spectator', step: 'watchlist card live', marks: await cardMarks(card) }); + }); + + await test.step('the operator finishes the match: the open card reads FINAL, the hero moves to the next bout', async () => { + const started = await sides(upNextCard(op)); + expect(started, 'the next bout is the one read off the queue').toEqual(next); + const opponent = started.shiro === followed ? started.aka : started.shiro; + await finishMatch(op); + await expect(card).toContainText('FINAL'); + expect(await cardMarks(card)).toEqual({ shiro: 'M', aka: '' }); + await shot(page, 'watching-card-final'); + // Close the card (the tap that opened it was a while ago). + await card.getByRole('button', { name: 'Close' }).tap(); + await expect(card).toHaveCount(0); + // Finish + Start Next started the next bout, also the followed one's: the hero follows. + await expect(watchHero(page)).toContainText(opponent); + await expect(watchHero(page)).toHaveClass(/wl-hero--running/); + await shot(page, 'watching-next-bout'); + }); + + expect(await page.evaluate(() => window.__spectatorNoReload), 'the page was never reloaded').toBe('kept'); + }); + + test('the match card reads the live row from every door: Home, Schedule, a competition\'s Pools tab and its Overview', async ({ page }) => { + // The second bout is running on court A since the last test's Finish + + // Start Next; the third waits as Up next. + const match2 = await editorSides(op); + const match3 = await sides(upNextCard(op)); + expect(match3, 'the third bout is another pairing').not.toEqual(match2); + const card = matchCard(page); + + // Open the card with `open()`, check what it shows now, let the operator + // change the score, and check the OPEN card shows the change at once. + const door = async (name, open, before, change, after) => { + await test.step(name, async () => { + const opened = Date.now(); + await open(); + await expect(card).toBeVisible(); + await expect.poll(() => cardMarks(card), { message: `${name}: the card opens on the live score` }).toEqual(before); + await change(); + await expect.poll(() => cardMarks(card), { message: `${name}: the open card follows the operator` }).toEqual(after); + await shot(page, `door-${name.split(' ')[0].toLowerCase().replace(':', '')}`); + record({ journey: 'spectator', door: name, before, after }); + await dwell(page, opened); + await card.getByRole('button', { name: 'Close' }).tap(); + await expect(card).toHaveCount(0); + }); + }; + + await door('Home: the NOW list', + async () => { await page.goto('/'); await homeNowRow(page).tap(); }, + { shiro: '', aka: '' }, + () => awardIppon(op, 'shiro', 'M'), + { shiro: 'M', aka: '' }); + + await door('Schedule: a row', + async () => { await page.goto('/schedule'); await scheduleRow(page, match2).tap(); }, + { shiro: 'M', aka: '' }, + () => awardIppon(op, 'aka', 'K'), + { shiro: 'M', aka: 'K' }); + + await door('Pools: a match row on the competition page', + async () => { + await page.goto(`/competition/${ids.kendo}`); + await competitionTab(page, 'Pools').tap(); + await poolRow(page, match2).tap(); + }, + { shiro: 'M', aka: 'K' }, + () => awardIppon(op, 'shiro', 'D'), + { shiro: 'DM', aka: 'K' }); + + // The Overview is the one door that is no dialog while the tournament is + // officiated: an Up next row expands a card in place (the dialog is the + // self-run page's), and the running match is its own inline ON NOW card. + // Both read the page's live rows. The operator scores on, then finishes the + // running bout so the waiting one (the expanded row) starts. + await test.step('Overview: an Up next row expands a card; the ON NOW card follows the operator', async () => { + await page.goto(`/competition/${ids.kendo}`); + await upNextRow(page, match3).tap(); + const expanded = expandedCard(page); + await expect(expanded).toBeVisible(); + await expect(expanded).not.toContainText('NOW'); + await expect(cardSlots(expanded)).toHaveCount(4); + expect(await cardMarks(expanded)).toEqual({ shiro: '', aka: '' }); + await expect.poll(() => cardMarks(nowCard(page)), { message: 'Overview: the ON NOW card opens on the live score' }) + .toEqual({ shiro: 'DM', aka: 'K' }); + // The operator takes Aka's mark back (Shiro's two ippon already decide the bout). + await clearMarks(filledSlots(op, 'aka')); + await expect(syncState(op)).toHaveText('Synced'); + await expect.poll(() => cardMarks(nowCard(page)), { message: 'Overview: the ON NOW card follows the operator' }) + .toEqual({ shiro: 'DM', aka: '' }); + await shot(page, 'door-overview-now'); + + await finishMatch(op); + await expect.poll(() => editorSides(op), { message: 'the operator\'s editor holds the third bout' }).toEqual(match3); + await expect(nowCard(page), 'the ON NOW card moves to the started bout').toContainText(match3.aka); + await expect(nowCard(page), 'and shows both of its sides, not the finished bout\'s').toContainText(match3.shiro); + await expect(expanded, 'the waiting bout is no longer in Up next').toHaveCount(0); + expect(await cardMarks(nowCard(page))).toEqual({ shiro: '', aka: '' }); + await awardIppon(op, 'shiro', 'M'); + await expect.poll(() => cardMarks(nowCard(page)), { message: 'Overview: the ON NOW card follows the next bout' }) + .toEqual({ shiro: 'M', aka: '' }); + await shot(page, 'door-overview-next-bout'); + record({ journey: 'spectator', door: 'Overview: Up next row, ON NOW card', after: await cardMarks(nowCard(page)) }); + }); + }); +}); diff --git a/scripts/screenshots/lib/server.mjs b/scripts/screenshots/lib/server.mjs index 5cc518076..46ab3222d 100644 --- a/scripts/screenshots/lib/server.mjs +++ b/scripts/screenshots/lib/server.mjs @@ -71,8 +71,13 @@ function disarmSignals() { signalHandlers.clear(); } -// start("mobile" | "web") -> { base, dataDir, stop() } -export async function start(kind) { +// start("mobile" | "web", { args, env }?) -> { base, dataDir, stop() } +// `args` (extra command-line arguments after the command) and `env` (extra +// environment, applied last) are optional and default to none, so a caller +// that passes neither starts exactly the server it always did. The e2e harness +// uses them to start a locked-mode server (--lock-password and +// TOURNAMENT_PASSWORD_HASH). +export async function start(kind, { args = [], env = {} } = {}) { if (!fs.existsSync(BIN)) { throw new Error(`missing ${BIN} - run "make go/build" first`); } @@ -86,8 +91,8 @@ export async function start(kind) { // var alone is enough and works for both commands. `serve` keeps no state. const command = kind === 'web' ? 'serve' : 'mobile-app'; - const child = spawn(BIN, [command], { - env: { ...process.env, PORT: String(port), TOURNAMENT_DATA_DIR: dataDir }, + const child = spawn(BIN, [command, ...args], { + env: { ...process.env, PORT: String(port), TOURNAMENT_DATA_DIR: dataDir, ...env }, stdio: ['ignore', 'pipe', 'pipe'], }); // Only the startup failure path reads this, so keep a bounded tail rather diff --git a/specs/openapi.yaml b/specs/openapi.yaml index 594341d5e..b6eed13f9 100644 --- a/specs/openapi.yaml +++ b/specs/openapi.yaml @@ -655,6 +655,13 @@ paths: `pool_size` and `pool_size_mode`, by contrast, ARE defaulted when omitted (to 4 and `"max"`). + `date` is defaulted too: a row without one takes the tournament's + date (Day 1), as `POST /api/competitions` does, because a + competition with no date can never be drawn. If the tournament + has no date either, the row fails with + `date: the row has no date and the tournament has no date to give it; ...` + while the rest of the import proceeds. + In self-run mode this is an organiser action: it needs `X-Tournament-Password`. security: @@ -6028,6 +6035,30 @@ components: `password`, which is redacted in locked mode). Defaults to `officiated` when omitted on POST (backward-compatible with pre-mp-7h7 tournaments whose `tournament.md` lacks the field). + theme: + type: object + description: > + Optional branding. Absent when nothing is set (a theme holding + only a logo is returned as `{"hasLogo": true}`). + properties: + primaryColor: + type: string + description: Accent colour as `#rrggbb`. + accentSoftColor: + type: string + description: Soft accent (background tint) as `#rrggbb`. + windowTitle: + type: string + maxLength: 100 + description: Browser tab / window title. + hasLogo: + type: boolean + readOnly: true + description: > + True when a custom logo has been uploaded; absent otherwise. + Derived by the server, so a value sent on PUT is ignored. A + client reads it instead of probing `HEAD /api/branding/logo`, + which answers 404 while no logo is set. TournamentWithCompetitions: description: > The response shape for POST and PUT /api/tournament: every Tournament @@ -7267,6 +7298,20 @@ components: items: $ref: '#/components/schemas/PlayerStanding' description: Map of pool name to ordered standings. + poolQualifiers: + type: object + additionalProperties: + type: integer + minimum: 1 + description: > + Map of pool name to how many of that pool's places send a qualifier + to the knockout. Present once pools are drawn. It is read off the + stored draw, so a pool whose 2nd is drafted under extra qualifiers + "fill-bracket", or an oversized pool under "larger-pools", counts one + more than `config.poolWinners`, and a pool too small to fill every + place counts only the places the draw seats. A tie over any of these places is + owed a tie-break bout (a daihyosen for a team pool), and the + standings mark exactly these places as advancing. bracket: $ref: '#/components/schemas/Bracket' diff --git a/web-mobile/js/__tests__/api.test.jsx b/web-mobile/js/__tests__/api.test.jsx index 2102e2172..613ae3066 100644 --- a/web-mobile/js/__tests__/api.test.jsx +++ b/web-mobile/js/__tests__/api.test.jsx @@ -931,6 +931,23 @@ describe('API Utils', () => { expect(out.config.extraQualifiers).toBe('fill-bracket'); }); + // bc-fbtb: the payload's poolQualifiers map (pool name -> places that send a + // qualifier) lands on each pool as `qualifiers`, which is what + // qualifiersForPool reads. + it('puts each pool\'s server-counted qualifiers on the pool', () => { + const out = normalizeCompetitionDetail({ + config: { id: 'x', name: 'X' }, + pools: [{ poolName: 'Pool A', players: [] }, { poolName: 'Pool B', players: [] }], + poolQualifiers: { 'Pool A': 1, 'Pool B': 2 }, + }); + expect(out.pools.map(p => p.qualifiers)).toEqual([1, 2]); + }); + + it('leaves qualifiers unset when the payload carries no poolQualifiers', () => { + const out = normalizeCompetitionDetail({ config: { id: 'x', name: 'X' }, pools: [{ poolName: 'Pool A', players: [] }] }); + expect(out.pools[0].qualifiers).toBeUndefined(); + }); + it('returns falsy input unchanged (no crash)', () => { expect(normalizeCompetitionDetail(null)).toBeNull(); expect(normalizeCompetitionDetail(undefined)).toBeUndefined(); diff --git a/web-mobile/js/__tests__/render/admin_branding_no_logo_probe.render.test.jsx b/web-mobile/js/__tests__/render/admin_branding_no_logo_probe.render.test.jsx new file mode 100644 index 000000000..12a50ec79 --- /dev/null +++ b/web-mobile/js/__tests__/render/admin_branding_no_logo_probe.render.test.jsx @@ -0,0 +1,89 @@ +// bc-lgnf: Edit tournament details learns whether a custom logo is set from the +// tournament it already holds (theme.hasLogo), so it sends no request that can +// answer 404 for "no logo". It used to probe HEAD /api/branding/logo, which the +// server answers 404 on purpose while none is uploaded, and the browser logged +// a console error on every load. +import React from 'react'; +import { render, screen, act, fireEvent } from '@testing-library/react'; +import { describe, it, expect, vi, beforeAll, afterAll, beforeEach, afterEach } from 'vitest'; +import { installWindowStubs } from '../helpers/stub_globals.js'; + +const API = { + uploadBrandingLogo: vi.fn().mockResolvedValue({ logoPath: 'logo.png' }), + deleteBrandingLogo: vi.fn().mockResolvedValue({}), +}; + +let restoreGlobals; +let BrandingManager; + +beforeAll(async () => { + restoreGlobals = installWindowStubs({ API, confirmDialog: vi.fn().mockResolvedValue(true) }); + ({ BrandingManager } = await import('../../admin_branding.jsx')); +}); +afterAll(() => restoreGlobals()); + +let fetchSpy; +beforeEach(() => { + fetchSpy = vi.fn().mockResolvedValue({ ok: false, status: 404 }); + vi.stubGlobal('fetch', fetchSpy); +}); +afterEach(() => vi.unstubAllGlobals()); + +const logoRequests = () => fetchSpy.mock.calls.filter(([url]) => String(url).includes('/api/branding/logo')); + +const mount = async (theme) => { + await act(async () => { + render( {}} onThemeChange={() => {}} />); + }); +}; + +describe('BrandingManager and the custom logo', () => { + it('with no logo: sends no logo request, offers Upload logo', async () => { + await mount({}); + expect(logoRequests()).toHaveLength(0); + expect(fetchSpy).not.toHaveBeenCalled(); + expect(screen.getByRole('button', { name: 'Upload logo' })).toBeTruthy(); + expect(screen.queryByText('Current logo')).toBeNull(); + }); + + it('with no theme at all: sends no request either', async () => { + await mount(undefined); + expect(fetchSpy).not.toHaveBeenCalled(); + expect(screen.queryByText('Current logo')).toBeNull(); + }); + + it('with a logo set: shows it without asking the server', async () => { + await mount({ hasLogo: true }); + expect(fetchSpy).not.toHaveBeenCalled(); + expect(screen.getByText('Current logo')).toBeTruthy(); + expect(screen.getByRole('button', { name: 'Replace logo' })).toBeTruthy(); + }); + + it('uploading then removing a logo flips the preview with no probe', async () => { + await mount({}); + const input = screen.getByLabelText(/Upload logo/); + const file = new File([new Uint8Array([1, 2, 3])], 'logo.png', { type: 'image/png' }); + await act(async () => { fireEvent.change(input, { target: { files: [file] } }); }); + await act(async () => { fireEvent.click(screen.getByRole('button', { name: 'Upload logo' })); }); + expect(API.uploadBrandingLogo).toHaveBeenCalledTimes(1); + expect(screen.getByText('Current logo')).toBeTruthy(); + + await act(async () => { fireEvent.click(screen.getByRole('button', { name: 'Remove' })); }); + expect(API.deleteBrandingLogo).toHaveBeenCalledWith('pw'); + expect(screen.queryByText('Current logo')).toBeNull(); + expect(fetchSpy).not.toHaveBeenCalled(); + }); + + it('follows the tournament when another organiser changes the logo', async () => { + let view; + await act(async () => { + view = render( {}} onThemeChange={() => {}} />); + }); + expect(screen.queryByText('Current logo')).toBeNull(); + await act(async () => { + view.rerender( {}} onThemeChange={() => {}} />); + }); + expect(screen.getByText('Current logo')).toBeTruthy(); + expect(fetchSpy).not.toHaveBeenCalled(); + }); +}); 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 e7486426d..02818c074 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 @@ -62,7 +62,7 @@ beforeEach(() => { window.API.recordScore.mockReset(); window.API.recordScore.mockImplementation((_c, _m, patch) => { writes.push(patch); - return Promise.resolve(undefined); + return Promise.resolve({ applied: true }); }); window.API.notePendingEdit.mockReset(); vi.useFakeTimers(); 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 f4dc3ba67..5381cf8a1 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 @@ -65,7 +65,7 @@ beforeEach(() => { window.API.recordScore.mockReset(); window.API.recordScore.mockImplementation((_c, _m, patch) => { writes.push(patch); - return Promise.resolve(undefined); + return Promise.resolve({ applied: true }); }); window.API.notePendingEdit.mockReset(); vi.useFakeTimers(); @@ -203,6 +203,9 @@ describe('individual editor', () => { // 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'] }); + // The host hands nothing back for a finish (a landed one closes the editor), so the + // editor stays armed for the second finish. + window.API.recordScore.mockImplementation((_c, _m, patch) => { writes.push(patch); return Promise.resolve(undefined); }); const { rerender } = await mount(done()); await act(async () => { rerender(editorFor(reopened())); }); await finish(); @@ -610,6 +613,8 @@ describe('an adopted value is agreed, never re-sent', () => { }); it('a bout whose point was struck and taken back here stays the other device\'s (held, not agreed)', async () => { + // The saves are queued (offline): none has landed, so the row's edit stamp stands. + window.API.recordScore.mockImplementation((_c, _m, patch) => { writes.push(patch); return Promise.resolve({ queued: true }); }); const rerenderWith = await mountTeam(team()); await pointerTap(boutBtn(0, 'shiro', 'K')); await settle(); diff --git a/web-mobile/js/__tests__/render/engi_editor_tap_back_kept.render.test.jsx b/web-mobile/js/__tests__/render/engi_editor_tap_back_kept.render.test.jsx new file mode 100644 index 000000000..a7aad6aa4 --- /dev/null +++ b/web-mobile/js/__tests__/render/engi_editor_tap_back_kept.render.test.jsx @@ -0,0 +1,199 @@ +// bc-evts: on the engi editor, taking a flag off and tapping it back (or the +// reverse) while the earlier tap is still saving returned the count to the +// value the server still showed, so the editor read it as untouched, and the +// earlier save's echo then wiped the second tap; its own save named no change. +// The counts now keep every server copy stamped before the operator's last +// edit, the rule the individual editor (bc-ltap) and the team sheet (bc-kclr) +// follow. + +import React from 'react'; +import { render, act, fireEvent } from '@testing-library/react'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { EngiScoreEditorModal } from '../../admin_scoring_engi.jsx'; + +const NOW = 1_800_000_000_000; +beforeEach(() => { vi.useFakeTimers(); vi.setSystemTime(NOW); }); +afterEach(() => { vi.useRealTimers(); }); + +// A running engi pool match. No compId: the history fetch stays out. +const engi = (overrides = {}) => ({ + id: 'm1', status: 'running', phase: 'pool', poolName: 'Pool 1', court: 'A', + sideA: { id: 'p-aka', name: 'Aka Pair 1 - Aka Pair 2', dojo: 'Aka Dojo' }, + sideB: { id: 'p-shiro', name: 'Shiro Pair 1 - Shiro Pair 2', dojo: 'Shiro Dojo' }, + flagsA: 0, flagsB: 0, modifiedAt: NOW - 5000, + ...overrides, +}); + +// A save the server took: the answer a landed write comes back with. +const LANDED = { id: 'm1', status: 'running' }; + +const el = (match, onSubmit) => ( + +); + +const count = (r, side) => r.getByTestId(`engi-${side}-count`).textContent; +const tap = async (r, id) => { await act(async () => { fireEvent.click(r.getByTestId(id)); }); }; +const wait = async (ms) => { await act(async () => { vi.advanceTimersByTime(ms); }); }; + +describe('a flag tap made while the previous save is out is never wiped by that save\'s echo (bc-evts)', () => { + it('add a flag, take it off before the add\'s echo: the count stays at the server\'s and the removal is saved', async () => { + const onSubmit = vi.fn().mockResolvedValue(LANDED); + let r; + await act(async () => { r = render(el(engi(), onSubmit)); }); + await tap(r, 'engi-aka-inc'); + await wait(350); // the add's autosave goes out + expect(onSubmit.mock.calls.at(-1)?.[0]?.flagsA, 'precondition: the add was saved').toBe(1); + + vi.setSystemTime(NOW + 400); + await tap(r, 'engi-aka-dec'); // back to the value the server still shows + expect(count(r, 'aka'), 'the tap shows').toBe('0'); + + // The add's echo: the server now holds the flag, stamped at the add. + await act(async () => { r.rerender(el(engi({ flagsA: 1, modifiedAt: NOW + 50 }), onSubmit)); }); + expect(count(r, 'aka'), 'the echo of the earlier add does not bring the flag back').toBe('0'); + + await wait(350); // the removal's own autosave + const save = onSubmit.mock.calls.at(-1)?.[0]; + expect(save?.flagsA, 'the removal is sent').toBe(0); + expect(save?.changed, 'and named as a change').toContain('flags'); + }); + + it('take a flag off, tap it back before the removal\'s echo: the flag stays and is saved', async () => { + const onSubmit = vi.fn().mockResolvedValue(LANDED); + let r; + await act(async () => { r = render(el(engi({ flagsA: 1 }), onSubmit)); }); + await tap(r, 'engi-aka-dec'); + await wait(350); // the removal's autosave goes out + expect(onSubmit.mock.calls.at(-1)?.[0]?.flagsA, 'precondition: the removal was saved').toBe(0); + + vi.setSystemTime(NOW + 400); + await tap(r, 'engi-aka-inc'); // back to the value the server still shows + expect(count(r, 'aka'), 'the tap shows').toBe('1'); + + // The removal's echo: the server now holds no flag, stamped at the removal. + await act(async () => { r.rerender(el(engi({ flagsA: 0, modifiedAt: NOW + 50 }), onSubmit)); }); + expect(count(r, 'aka'), 'the echo of the earlier removal does not wipe the newer tap').toBe('1'); + + await wait(350); // the tap's own autosave + const save = onSubmit.mock.calls.at(-1)?.[0]; + expect(save?.flagsA, 'the tap is sent').toBe(1); + expect(save?.changed, 'and named as a change').toContain('flags'); + }); + + it('a change made elsewhere after the last tap is still followed', async () => { + const onSubmit = vi.fn().mockResolvedValue(LANDED); + let r; + await act(async () => { r = render(el(engi(), onSubmit)); }); + await tap(r, 'engi-aka-inc'); + await wait(350); + // The server copy carries this tap, then another device's Shiro flag, + // stamped after the tap. + await act(async () => { r.rerender(el(engi({ flagsA: 1, modifiedAt: NOW + 50 }), onSubmit)); }); + await act(async () => { r.rerender(el(engi({ flagsA: 1, flagsB: 1, modifiedAt: NOW + 5000 }), onSubmit)); }); + expect(count(r, 'shiro'), 'the later change shows').toBe('1'); + expect(count(r, 'aka')).toBe('1'); + }); + + it('once the tap\'s own save has landed, a server copy stamped just before the tap is followed', async () => { + // The write's stamp is worked out at send, less the edit's age, so it can + // read a millisecond before the tap that made it. Once that save has + // landed the board holds nothing unsent, and a change the server applied + // under such a stamp is the match as it stands. + const onSubmit = vi.fn().mockResolvedValue(LANDED); + let r; + await act(async () => { r = render(el(engi(), onSubmit)); }); + await tap(r, 'engi-aka-inc'); + await wait(350); // the tap's save goes out and lands + await act(async () => {}); + const echo = engi({ flagsA: 1, modifiedAt: NOW - 1 }); + await act(async () => { r.rerender(el(echo, onSubmit)); }); + await act(async () => { r.rerender(el({ ...echo, flagsB: 1 }, onSubmit)); }); + expect(count(r, 'shiro'), 'the board took the Shiro flag stamped just before the tap').toBe('1'); + }); + + it('a save built before the latest tap clears nothing when it lands', async () => { + let landFirst; + const first = new Promise((resolve) => { landFirst = resolve; }); + const onSubmit = vi.fn().mockReturnValueOnce(first).mockResolvedValue(LANDED); + let r; + await act(async () => { r = render(el(engi(), onSubmit)); }); + await tap(r, 'engi-aka-inc'); + await wait(350); // the add's save is out and unanswered + expect(onSubmit.mock.calls.at(-1)?.[0]?.flagsA, 'precondition: the add was saved').toBe(1); + + vi.setSystemTime(NOW + 400); + await tap(r, 'engi-aka-dec'); // a tap the save in flight was not built with + await act(async () => { landFirst(LANDED); }); + + // The add's echo, stamped before the removal. + await act(async () => { r.rerender(el(engi({ flagsA: 1, modifiedAt: NOW + 50 }), onSubmit)); }); + expect(count(r, 'aka'), 'the add landing does not release the board from the removal it never carried').toBe('0'); + + await wait(350); // the removal's own autosave + expect(onSubmit.mock.calls.at(-1)?.[0]?.flagsA, 'the removal is sent').toBe(0); + }); + + // A REFUSED save is not here: it takes the server's copy instead + // (individual_engi_refused_autosave_takes_server.render.test.jsx). + it('a queued save keeps the board against an older copy', async () => { + const onSubmit = vi.fn().mockResolvedValue({ queued: true }); + let r; + await act(async () => { r = render(el(engi(), onSubmit)); }); + await tap(r, 'engi-aka-inc'); + await wait(350); + vi.setSystemTime(NOW + 400); + await tap(r, 'engi-aka-dec'); + await wait(350); + expect(onSubmit.mock.calls.length, 'precondition: both taps were saved').toBe(2); + + await act(async () => { r.rerender(el(engi({ flagsA: 1, modifiedAt: NOW + 50 }), onSubmit)); }); + expect(count(r, 'aka'), 'a write that did not land released nothing').toBe('0'); + }); + + it('a scheduled match is not autosaved, so a net-zero fiddle holds nothing against another device', async () => { + const onSubmit = vi.fn().mockResolvedValue(LANDED); + let r; + await act(async () => { r = render(el(engi({ status: 'scheduled' }), onSubmit)); }); + await tap(r, 'engi-aka-inc'); + await tap(r, 'engi-aka-dec'); + await wait(350); + expect(onSubmit, 'precondition: nothing was autosaved').not.toHaveBeenCalled(); + + // Another device's flag, stamped before the taps. + await act(async () => { r.rerender(el(engi({ status: 'scheduled', flagsB: 1, modifiedAt: NOW - 1000 }), onSubmit)); }); + expect(count(r, 'shiro'), 'the copy from the other device is followed').toBe('1'); + }); + + it.each([ + ['one millisecond before the tap\'s stamp is held', -1, '0'], + ['at the tap\'s stamp is followed', 0, '1'], + ])('a copy stamped %s', async (_name, offset, shiro) => { + const onSubmit = vi.fn().mockResolvedValue({ queued: true }); + let r; + await act(async () => { r = render(el(engi(), onSubmit)); }); + await tap(r, 'engi-aka-inc'); + await wait(350); + vi.setSystemTime(NOW + 400); + await tap(r, 'engi-aka-dec'); // the board is back at the server's value; the stamp is NOW + 400 + await act(async () => { r.rerender(el(engi({ flagsB: 1, modifiedAt: NOW + 400 + offset }), onSubmit)); }); + expect(count(r, 'shiro')).toBe(shiro); + }); +}); + +describe('a stamp left by a write the match\'s leaving running dropped is cleared (bc-evts)', () => { + it('sent back to the queue inside the debounce: a later scheduled copy stamped before the tap is adopted', async () => { + const onSubmit = vi.fn().mockResolvedValue(LANDED); + let r; + await act(async () => { r = render(el(engi(), onSubmit)); }); + await tap(r, 'engi-aka-inc'); + await tap(r, 'engi-aka-dec'); // net zero: the board reads as untouched again + // Another device sends the match back to the queue, same score, inside the + // debounce: the autosave is dropped at fire time, so no landed save clears the stamp. + await act(async () => { r.rerender(el(engi({ status: 'scheduled' }), onSubmit)); }); + await wait(350); + expect(onSubmit, 'the autosave was dropped').not.toHaveBeenCalled(); + // A later-arriving copy, stamped before the tap (and before the match left running). + await act(async () => { r.rerender(el(engi({ status: 'scheduled', flagsB: 1, modifiedAt: NOW - 4000 }), onSubmit)); }); + expect(count(r, 'shiro'), 'the copy is adopted, not held against a stamp nothing can clear').toBe('1'); + }); +}); diff --git a/web-mobile/js/__tests__/render/individual_editor_tap_back_kept.render.test.jsx b/web-mobile/js/__tests__/render/individual_editor_tap_back_kept.render.test.jsx index b6fdc783c..14240e1ac 100644 --- a/web-mobile/js/__tests__/render/individual_editor_tap_back_kept.render.test.jsx +++ b/web-mobile/js/__tests__/render/individual_editor_tap_back_kept.render.test.jsx @@ -54,6 +54,9 @@ const bout = (overrides = {}) => ({ ...overrides, }); +// A save the server took: the answer a landed write comes back with. +const LANDED = { id: 'm1', status: 'running' }; + const el = (match, onSubmit) => ( ); @@ -67,7 +70,7 @@ const wait = async (ms) => { await act(async () => { vi.advanceTimersByTime(ms); describe('a tap made while the previous save is out is never wiped by that save\'s echo (bc-ltap)', () => { it('remove a mark, tap it back before the removal\'s echo: the mark stays and is saved', async () => { - const onSubmit = vi.fn().mockResolvedValue(undefined); + const onSubmit = vi.fn().mockResolvedValue(LANDED); const stored = bout({ ipponsA: ['M'] }); let r; await act(async () => { r = render(el(stored, onSubmit)); }); @@ -95,7 +98,7 @@ describe('a tap made while the previous save is out is never wiped by that save\ }); it('add a mark, take it off before the add\'s echo: the board stays empty', async () => { - const onSubmit = vi.fn().mockResolvedValue(undefined); + const onSubmit = vi.fn().mockResolvedValue(LANDED); let r; await act(async () => { r = render(el(bout(), onSubmit)); }); const aka = r.container.querySelector('.sb-side--aka'); @@ -114,7 +117,7 @@ describe('a tap made while the previous save is out is never wiped by that save\ }); it('a change made elsewhere after the last tap is still followed', async () => { - const onSubmit = vi.fn().mockResolvedValue(undefined); + const onSubmit = vi.fn().mockResolvedValue(LANDED); let r; await act(async () => { r = render(el(bout(), onSubmit)); }); await tap(menButton(r.container.querySelector('.sb-side--aka'))); @@ -137,7 +140,7 @@ describe('a tap made while the previous save is out is never wiped by that save\ // landed the board holds nothing unsent, and a change the server applied // under such a stamp (another device's overtime, made before the tap and // arriving after it) is the match as it stands. - const onSubmit = vi.fn().mockResolvedValue({ id: 'm1', status: 'running' }); + const onSubmit = vi.fn().mockResolvedValue(LANDED); let r; await act(async () => { r = render(el(bout(), onSubmit)); }); await tap(menButton(r.container.querySelector('.sb-side--aka'))); @@ -153,4 +156,87 @@ describe('a tap made while the previous save is out is never wiped by that save\ await wait(350); expect(onSubmit.mock.calls.at(-1)[0].encho, 'the board took the overtime, so the next save keeps it').toEqual({ periodCount: 1 }); }); + + it('a save built before the latest tap clears nothing when it lands', async () => { + let landFirst; + const first = new Promise((resolve) => { landFirst = resolve; }); + const onSubmit = vi.fn().mockReturnValueOnce(first).mockResolvedValue(LANDED); + let r; + await act(async () => { r = render(el(bout(), onSubmit)); }); + await tap(menButton(r.container.querySelector('.sb-side--aka'))); + await wait(350); // the add's save is out and unanswered + const added = onSubmit.mock.calls.at(-1)[0]; + expect(added.ipponsA, 'precondition: the add was saved').toEqual(['M']); + + vi.setSystemTime(NOW + 400); + const [label] = marks(r.container); + await tap(r.container.querySelector(`[aria-label="${label}"]`)); // a tap the save in flight was not built with + expect(marks(r.container)).toHaveLength(0); + await act(async () => { landFirst(LANDED); }); + + await act(async () => { + r.rerender(el(bout({ ipponsA: added.ipponsA, ipponsB: added.ipponsB, modifiedAt: NOW + 50 }), onSubmit)); + }); + expect(marks(r.container), 'the add landing does not release the board from the removal it never carried').toHaveLength(0); + + await wait(350); // the removal's own autosave + expect(onSubmit.mock.calls.at(-1)[0].ipponsA, 'the removal is sent').toEqual([]); + }); + + // A REFUSED save is not here: it takes the server's copy instead + // (individual_engi_refused_autosave_takes_server.render.test.jsx). + it('a queued save keeps the board against an older copy', async () => { + const onSubmit = vi.fn().mockResolvedValue({ queued: true }); + let r; + await act(async () => { r = render(el(bout(), onSubmit)); }); + await tap(menButton(r.container.querySelector('.sb-side--aka'))); + await wait(350); + const added = onSubmit.mock.calls.at(-1)[0]; + vi.setSystemTime(NOW + 400); + const [label] = marks(r.container); + await tap(r.container.querySelector(`[aria-label="${label}"]`)); + await wait(350); + expect(onSubmit.mock.calls.length, 'precondition: both taps were saved').toBe(2); + + await act(async () => { + r.rerender(el(bout({ ipponsA: added.ipponsA, ipponsB: added.ipponsB, modifiedAt: NOW + 50 }), onSubmit)); + }); + expect(marks(r.container), 'a write that did not land released nothing').toHaveLength(0); + }); + + it('a scheduled match is not autosaved, so a net-zero fiddle holds nothing against another device', async () => { + const onSubmit = vi.fn().mockResolvedValue(LANDED); + let r; + await act(async () => { r = render(el(bout({ status: 'scheduled' }), onSubmit)); }); + await tap(menButton(r.container.querySelector('.sb-side--aka'))); + const [label] = marks(r.container); + expect(label, 'precondition: the scheduled board takes the tap').toBeTruthy(); + await tap(r.container.querySelector(`[aria-label="${label}"]`)); + expect(marks(r.container)).toHaveLength(0); + await wait(350); + expect(onSubmit, 'precondition: nothing was autosaved').not.toHaveBeenCalled(); + + // Another device's K, stamped before the taps. + await act(async () => { r.rerender(el(bout({ status: 'scheduled', ipponsB: ['K'], modifiedAt: NOW - 1000 }), onSubmit)); }); + expect(marks(r.container).some((l) => l.endsWith('remove K')), 'the copy from the other device is followed').toBe(true); + }); +}); + +describe('a stamp left by a write the match\'s leaving running dropped is cleared (bc-ltap)', () => { + it('sent back to the queue inside the debounce: a later scheduled copy stamped before the tap is adopted', async () => { + const onSubmit = vi.fn().mockResolvedValue(LANDED); + let r; + await act(async () => { r = render(el(bout(), onSubmit)); }); + await tap(menButton(r.container.querySelector('.sb-side--aka'))); + await tap(r.container.querySelector(`[aria-label="${marks(r.container)[0]}"]`)); // net zero + expect(marks(r.container), 'precondition: the board is empty again').toHaveLength(0); + // Another device sends the match back to the queue, same score, inside the + // debounce: the autosave is dropped at fire time, so no landed save clears the stamp. + await act(async () => { r.rerender(el(bout({ status: 'scheduled' }), onSubmit)); }); + await wait(350); + expect(onSubmit, 'the autosave was dropped').not.toHaveBeenCalled(); + // A later-arriving copy, stamped before the tap (and before the match left running). + await act(async () => { r.rerender(el(bout({ status: 'scheduled', ipponsB: ['M'], modifiedAt: NOW - 4000 }), onSubmit)); }); + expect(marks(r.container), 'the copy is adopted, not held against a stamp nothing can clear').toHaveLength(1); + }); }); diff --git a/web-mobile/js/__tests__/render/individual_engi_refused_autosave_takes_server.render.test.jsx b/web-mobile/js/__tests__/render/individual_engi_refused_autosave_takes_server.render.test.jsx new file mode 100644 index 000000000..cd66412e8 --- /dev/null +++ b/web-mobile/js/__tests__/render/individual_engi_refused_autosave_takes_server.render.test.jsx @@ -0,0 +1,240 @@ +// A REFUSED autosave leaves the editor's edit stamp set (individual board stamp, engi flags +// stamp), so the board kept the operator's value while the server and every other surface +// showed the server's, and nothing re-adopted: a refusal broadcasts nothing. The editor now +// takes the server's CURRENT copy when the refusal arrives (landedAutosave's onRefused) and +// clears the stamp; a QUEUED answer keeps the board and the stamp, since that write will +// land later. See "The edit-time adopt rule" in .claude/rules/match-results-and-marks.md. + +import React from 'react'; +import { render, act, fireEvent } from '@testing-library/react'; +import { describe, it, expect, vi, beforeAll, afterAll, beforeEach, afterEach } from 'vitest'; +import { installWindowStubs } from '../helpers/stub_globals.js'; +import { EngiScoreEditorModal } from '../../admin_scoring_engi.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: { + fetchCompetitionDetails: vi.fn(), + 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 NOW = 1_800_000_000_000; +beforeEach(() => { vi.useFakeTimers(); vi.setSystemTime(NOW); }); +afterEach(() => { vi.useRealTimers(); }); + +const REFUSED = { applied: false, reason: 'superseded' }; +const QUEUED = { queued: true }; +// recordScore answers a running clock_skew refusal silently: it relearns the clock and the +// tap stays on the board for the NEXT save to carry with a corrected stamp. +const CLOCK_SKEW = { applied: false, reason: 'clock_skew' }; + +const tap = async (node) => { await act(async () => { fireEvent.click(node); }); }; +const wait = async (ms) => { await act(async () => { vi.advanceTimersByTime(ms); }); }; +// The save's answer is read in a microtask after the timer fires. +const settle = async () => { for (let i = 0; i < 6; i++) await act(async () => { await Promise.resolve(); }); }; + +describe('individual editor: a refused autosave takes the server\'s copy', () => { + const bout = (overrides = {}) => ({ + id: 'm1', status: 'running', phase: 'pool', poolName: 'Pool 1', court: 'A', + sideA: { id: 'p1', name: 'Yamada' }, sideB: { id: 'p2', name: 'Tanaka' }, + ipponsA: [], ipponsB: [], modifiedAt: NOW - 5000, + ...overrides, + }); + const el = (match, onSubmit) => ( + + ); + const marks = (c) => [...c.querySelectorAll('[aria-label*=" slot "]')] + .map((s) => s.getAttribute('aria-label')) + .filter((l) => /: remove /.test(l)); + const menButton = (side) => [...side.querySelectorAll('.ipt-btn')].find((b) => b.textContent.trim() === 'M'); + + // Aka taps M; the server's copy (another device's Shiro M, stamped before the tap) is held + // by the stamp; the autosave then comes back with `answer`. + async function tapThenAnswer(answer) { + const onSubmit = vi.fn().mockResolvedValue(answer); + let r; + await act(async () => { r = render(el(bout(), onSubmit)); }); + await tap(menButton(r.container.querySelector('.sb-side--aka'))); + expect(marks(r.container), 'precondition: the tap shows').toHaveLength(1); + await act(async () => { r.rerender(el(bout({ ipponsB: ['M'], modifiedAt: NOW - 1 }), onSubmit)); }); + expect(marks(r.container).map((l) => l.split(' ')[0]), 'precondition: the older copy is held').toEqual(['Aka']); + await wait(350); + await settle(); + expect(onSubmit, 'precondition: the autosave went out').toHaveBeenCalled(); + return r; + } + + it('a superseded answer puts the server\'s mark on the board and drops the operator\'s', async () => { + const r = await tapThenAnswer(REFUSED); + expect(marks(r.container).map((l) => l.split(' ')[0])).toEqual(['Shiro']); + }); + + it('a nothing-handed-back answer (a thrown refusal the host reported) does the same', async () => { + const r = await tapThenAnswer(undefined); + expect(marks(r.container).map((l) => l.split(' ')[0])).toEqual(['Shiro']); + }); + + it('a queued answer keeps the board: that write will land', async () => { + const r = await tapThenAnswer(QUEUED); + expect(marks(r.container).map((l) => l.split(' ')[0])).toEqual(['Aka']); + }); + + it('a tap made after the refused save was built is kept', async () => { + let settleFirst; + const onSubmit = vi.fn().mockImplementationOnce(() => new Promise((res) => { settleFirst = res; })) + .mockResolvedValue({ id: 'm1', status: 'running' }); + let r; + await act(async () => { r = render(el(bout(), onSubmit)); }); + await tap(menButton(r.container.querySelector('.sb-side--aka'))); + await act(async () => { r.rerender(el(bout({ ipponsB: ['M'], modifiedAt: NOW - 1 }), onSubmit)); }); + await wait(350); // the first save is out and unanswered + vi.setSystemTime(NOW + 400); + await tap(menButton(r.container.querySelector('.sb-side--aka'))); // a second Aka mark, after it was built + await act(async () => { settleFirst(REFUSED); }); + await settle(); + expect(marks(r.container).filter((l) => l.startsWith('Aka')), 'the newer edit is still owed').not.toHaveLength(0); + }); + + it('a clock_skew answer keeps the board: the tap is owed to the next save, not wiped', async () => { + const r = await tapThenAnswer(CLOCK_SKEW); + expect(marks(r.container).map((l) => l.split(' ')[0])).toEqual(['Aka']); + }); + + it('a late refusal does not wipe a correction tapped after the match finished elsewhere', async () => { + let settleFirst; + const onSubmit = vi.fn().mockImplementationOnce(() => new Promise((res) => { settleFirst = res; })) + .mockResolvedValue({ id: 'm1', status: 'completed' }); + let r; + await act(async () => { r = render(el(bout(), onSubmit)); }); + await tap(menButton(r.container.querySelector('.sb-side--aka'))); + await wait(350); // the autosave is out and unanswered + expect(onSubmit, 'precondition: the autosave went out').toHaveBeenCalledTimes(1); + // Another device finishes the match: Shiro won by M, stamped after the tap. + await act(async () => { + r.rerender(el(bout({ + status: 'completed', ipponsB: ['M'], winner: { id: 'p2', name: 'Tanaka' }, modifiedAt: NOW + 100, + }), onSubmit)); + }); + vi.setSystemTime(NOW + 600); + await tap(menButton(r.container.querySelector('.sb-side--aka'))); // a correction on the finished match + const before = marks(r.container).map((l) => l.split(' ')[0]); + expect(before, 'precondition: the correction shows').toContain('Aka'); + await act(async () => { settleFirst(REFUSED); }); // the autosave is refused late + await settle(); + expect(marks(r.container).map((l) => l.split(' ')[0]), 'the correction stays').toEqual(before); + }); +}); + +describe('engi editor: a refused autosave takes the server\'s copy', () => { + const engi = (overrides = {}) => ({ + id: 'm1', status: 'running', phase: 'pool', poolName: 'Pool 1', court: 'A', + sideA: { id: 'p-aka', name: 'Aka Pair 1 - Aka Pair 2', dojo: 'Aka Dojo' }, + sideB: { id: 'p-shiro', name: 'Shiro Pair 1 - Shiro Pair 2', dojo: 'Shiro Dojo' }, + flagsA: 0, flagsB: 0, modifiedAt: NOW - 5000, + ...overrides, + }); + const el = (match, onSubmit) => ( + + ); + const count = (r, side) => r.getByTestId(`engi-${side}-count`).textContent; + + async function tapThenAnswer(answer) { + const onSubmit = vi.fn().mockResolvedValue(answer); + let r; + await act(async () => { r = render(el(engi(), onSubmit)); }); + await tap(r.getByTestId('engi-aka-inc')); + expect(count(r, 'aka'), 'precondition: the tap shows').toBe('1'); + // Another device's count, stamped before the tap: held by the stamp. + await act(async () => { r.rerender(el(engi({ flagsB: 1, modifiedAt: NOW - 1 }), onSubmit)); }); + expect([count(r, 'aka'), count(r, 'shiro')], 'precondition: the older copy is held').toEqual(['1', '0']); + await wait(350); + await settle(); + expect(onSubmit, 'precondition: the autosave went out').toHaveBeenCalled(); + return r; + } + + it('a superseded answer puts the server\'s counts on the board', async () => { + const r = await tapThenAnswer(REFUSED); + expect([count(r, 'aka'), count(r, 'shiro')]).toEqual(['0', '1']); + }); + + it('a nothing-handed-back answer does the same', async () => { + const r = await tapThenAnswer(undefined); + expect([count(r, 'aka'), count(r, 'shiro')]).toEqual(['0', '1']); + }); + + it('a queued answer keeps the board', async () => { + const r = await tapThenAnswer(QUEUED); + expect([count(r, 'aka'), count(r, 'shiro')]).toEqual(['1', '0']); + }); + + it('a tap made after the refused save was built is kept', async () => { + let settleFirst; + const onSubmit = vi.fn().mockImplementationOnce(() => new Promise((res) => { settleFirst = res; })) + .mockResolvedValue({ id: 'm1', status: 'running' }); + let r; + await act(async () => { r = render(el(engi(), onSubmit)); }); + await tap(r.getByTestId('engi-aka-inc')); + await act(async () => { r.rerender(el(engi({ flagsB: 1, modifiedAt: NOW - 1 }), onSubmit)); }); + await wait(350); + vi.setSystemTime(NOW + 400); + await tap(r.getByTestId('engi-aka-inc')); // after the first save was built + await act(async () => { settleFirst(REFUSED); }); + await settle(); + expect(count(r, 'aka'), 'the newer edit is still owed').toBe('2'); + }); + + it('a clock_skew answer keeps the board: the tap is owed to the next save, not wiped', async () => { + const r = await tapThenAnswer(CLOCK_SKEW); + expect([count(r, 'aka'), count(r, 'shiro')]).toEqual(['1', '0']); + }); + + it('a late refusal does not wipe a correction tapped after the match finished elsewhere', async () => { + let settleFirst; + const onSubmit = vi.fn().mockImplementationOnce(() => new Promise((res) => { settleFirst = res; })) + .mockResolvedValue({ id: 'm1', status: 'completed' }); + let r; + await act(async () => { r = render(el(engi(), onSubmit)); }); + await tap(r.getByTestId('engi-aka-inc')); + await wait(350); // the autosave is out and unanswered + expect(onSubmit, 'precondition: the autosave went out').toHaveBeenCalledTimes(1); + // Another device finishes the match: Shiro 1 flag, stamped after the tap. + await act(async () => { + r.rerender(el(engi({ + status: 'completed', flagsB: 1, winner: { id: 'p-shiro', name: 'Shiro Pair 1 - Shiro Pair 2' }, modifiedAt: NOW + 100, + }), onSubmit)); + }); + vi.setSystemTime(NOW + 600); + await tap(r.getByTestId('engi-aka-inc')); // a correction on the finished match + const before = [count(r, 'aka'), count(r, 'shiro')]; + await act(async () => { settleFirst(REFUSED); }); // the autosave is refused late + await settle(); + expect([count(r, 'aka'), count(r, 'shiro')], 'the correction stays').toEqual(before); + expect(Number(before[0]), 'precondition: the correction was taken').toBeGreaterThan(0); + }); +}); 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 b329107e6..988a5a449 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 @@ -26,7 +26,7 @@ const STUBBED_GLOBALS = { confirmDialog: vi.fn().mockResolvedValue(true), API: { fetchCompetitionDetails: vi.fn().mockResolvedValue(null), - recordScore: vi.fn().mockResolvedValue(undefined), + recordScore: vi.fn().mockResolvedValue({ applied: true }), recordDaihyosen: vi.fn(), removeDaihyosen: vi.fn(), putMatchLineup: vi.fn(), 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 ff1c9cc4e..aae9df9c4 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 @@ -23,7 +23,7 @@ const STUBBED_GLOBALS = { fetchCompetitionDetails: vi.fn().mockResolvedValue({ id: 'comp1', config: { format: 'mixed', teamMatchType: 'kachinuki', naginata: false, players: [] }, }), - recordScore: vi.fn().mockResolvedValue(undefined), + recordScore: vi.fn().mockResolvedValue({ applied: true }), recordDaihyosen: vi.fn(), removeDaihyosen: vi.fn(), putMatchLineup: vi.fn(), @@ -58,7 +58,7 @@ const kachinukiMatch = (subResults, modifiedAt = 0) => ({ }); async function mount(match) { - const onSubmit = vi.fn().mockResolvedValue(undefined); + const onSubmit = vi.fn().mockResolvedValue({ applied: true }); await act(async () => { render(); }); @@ -121,7 +121,7 @@ describe('a mark taken back on a kachinuki bout reaches the server (bc-kclr)', ( // Each write is stamped when it is sent (server_clock.jsx's frame; the // offset is 0 here), and a snapshot of it carries that stamp. const sentAt = []; - const onSubmit = vi.fn().mockImplementation(() => { sentAt.push(Date.now()); return Promise.resolve(undefined); }); + const onSubmit = vi.fn().mockImplementation(() => { sentAt.push(Date.now()); return Promise.resolve({ applied: true }); }); let utils; const el = (match) => ; await act(async () => { utils = render(el(kachinukiMatch([]))); }); @@ -145,7 +145,7 @@ describe('a mark taken back on a kachinuki bout reaches the server (bc-kclr)', ( // adopted. It is keyed on the snapshot's stamp now: written after the edit, // it is news, and a row the operator has put back where it was follows it. it('a change saved on another device after the edit is adopted', async () => { - const onSubmit = vi.fn().mockResolvedValue(undefined); + const onSubmit = vi.fn().mockResolvedValue({ applied: true }); let utils; const el = (match) => ; await act(async () => { utils = render(el(kachinukiMatch([]))); }); @@ -166,7 +166,7 @@ describe('a mark taken back on a kachinuki bout reaches the server (bc-kclr)', ( // nothing. it('closing right after the clear flushes it with no discard prompt', async () => { window.confirmDialog = vi.fn().mockResolvedValue(false); - const onSubmit = vi.fn().mockResolvedValue(undefined); + const onSubmit = vi.fn().mockResolvedValue({ applied: true }); // Closing unmounts the editor, as every host does; the unmount writes it. let view; const onClose = vi.fn(() => view.unmount()); diff --git a/web-mobile/js/__tests__/render/pools_tied_place.render.test.jsx b/web-mobile/js/__tests__/render/pools_tied_place.render.test.jsx new file mode 100644 index 000000000..e76a91a6b --- /dev/null +++ b/web-mobile/js/__tests__/render/pools_tied_place.render.test.jsx @@ -0,0 +1,230 @@ +import React from 'react'; +import { render } from '@testing-library/react'; +import { describe, it, expect, beforeAll } from 'vitest'; +import { PoolsViewer, tiedPlaces } from '../../viewer_standings.jsx'; + +// A pool whose second place is level on EVERY criterion between two +// competitors, with the tie-break bout still to be fought. The admin Pools tab +// and the public viewer render this same component, so one test covers both. +// The server sorts the level pair by draw order (ranks 2 and 3) and marks both +// `tied`; the table must not badge one of them 2nd and advancing. + +beforeAll(async () => { + await import('../../bracket.jsx'); + // api_serializers publishes it for the round-robin matrix under the table. + await import('../../api_serializers.jsx'); +}); + +const player = (id, name) => ({ id, name, dojo: `${name} Dojo` }); +const A = player('a', 'Aoki'); +const B = player('b', 'Baba'); +const C = player('c', 'Cho'); +const D = player('d', 'Dan'); +const pools = [{ poolName: 'Pool A', players: [A, B, C] }]; +const competition = { id: 'c', name: 'C', format: 'mixed', kind: 'individual', poolWinners: 2 }; +const tweaks = { showDojo: false }; + +const row = (p, rank, f) => ({ + player: p, rank, wins: 0, losses: 1, draws: 1, ipponsGiven: 1, ipponsTaken: 2, points: 1, ...f, +}); +const winner = row(A, 1, { wins: 2, losses: 0, draws: 0, ipponsGiven: 2, ipponsTaken: 0, points: 9 }); + +const bout = (id, sideA, sideB, status = 'completed') => ({ id, sideA, sideB, status }); +const regular = [bout('Pool A-0', A, B), bout('Pool A-1', A, C), bout('Pool A-2', B, C)]; + +function renderPool(poolStandings, poolMatches, { comp = competition, poolList = pools } = {}) { + const { container } = render( + , + ); + const byName = {}; + container.querySelectorAll('table.pool__table tbody tr').forEach((tr) => { + const badge = tr.querySelector('.rank-badge'); + const text = tr.querySelector('.pool__player-name').textContent; + byName[[A, B, C, D].find((p) => text.includes(p.name)).name] = { + badge: badge ? badge.textContent : '', + badgeAdv: !!badge && badge.classList.contains('rank-badge--adv'), + title: badge ? badge.getAttribute('title') : null, + advancing: tr.classList.contains('advancing'), + tied: tr.classList.contains('pool__row--tied'), + }; + }); + return byName; +} + +describe('a tie awaiting its tie-break bout', () => { + it('shows both level rows at the same tied place, with no advancing mark', () => { + const tbPending = [...regular, bout('Pool A-TB-1', B, C, 'scheduled')]; + const rows = renderPool([ + winner, + row(B, 2, { tied: true }), + row(C, 3, { tied: true }), + ], tbPending); + + expect(rows.Aoki).toMatchObject({ badge: '1st', badgeAdv: true, advancing: true, tied: false }); + expect(rows.Baba).toMatchObject({ badge: '=2nd', badgeAdv: false, advancing: false, tied: true }); + expect(rows.Cho).toMatchObject({ badge: '=2nd', badgeAdv: false, advancing: false, tied: true }); + }); + + it('brings the ordinary badges and the advancing mark back once the bout is scored', () => { + const tbDone = [...regular, bout('Pool A-TB-1', B, C, 'completed')]; + const rows = renderPool([ + winner, + row(C, 2, { points: 2 }), + row(B, 3, { points: 1 }), + ], tbDone); + + expect(rows.Aoki).toMatchObject({ badge: '1st', advancing: true, tied: false }); + expect(rows.Cho).toMatchObject({ badge: '2nd', badgeAdv: true, advancing: true, tied: false }); + expect(rows.Baba).toMatchObject({ badge: '3rd', badgeAdv: false, advancing: false, tied: false }); + }); +}); + +describe('a tie over the larger-pools extra qualifier place', () => { + // poolWinners 2 plus one extra place for an oversized pool (4 players over a + // minimum size of 3) sends three (`qualifiers`, as the server counts it). The server owes a tie-break bout for a tie + // over any of those places (engine.poolTieBreakCut is the pool's own qualifier + // count), the extra one included, so 3rd and 4th level read as tied and neither + // is marked advancing until the bout is scored. + const oversized = [{ poolName: 'Pool A', players: [A, B, C, D], qualifiers: 3 }]; + const extra = { ...competition, poolSize: 3, extraQualifiers: 'larger-pools' }; + const all4 = [ + bout('Pool A-0', A, B), bout('Pool A-1', A, C), bout('Pool A-2', A, D), + bout('Pool A-3', B, C), bout('Pool A-4', B, D), bout('Pool A-5', C, D), + ]; + + const level = [ + winner, + row(B, 2, { points: 6 }), + row(C, 3, { tied: true }), + row(D, 4, { tied: true }), + ]; + + it('shows both at the same place with no advancing mark, naming the bout that decides it', () => { + const rows = renderPool(level, [...all4, bout('Pool A-TB-1', C, D, 'scheduled')], { comp: extra, poolList: oversized }); + + expect(rows.Baba).toMatchObject({ badge: '2nd', badgeAdv: true, advancing: true }); + expect(rows.Cho).toMatchObject({ badge: '=3rd', badgeAdv: false, advancing: false, tied: true }); + expect(rows.Dan).toMatchObject({ badge: '=3rd', badgeAdv: false, advancing: false, tied: true }); + expect(rows.Cho.title).toMatch(/tie-break bout decides the order/); + }); + + it('brings the ordinary badges and the advancing mark back once the bout is scored', () => { + const rows = renderPool([ + winner, + row(B, 2, { points: 6 }), + row(D, 3, { points: 2 }), + row(C, 4, { points: 1 }), + ], [...all4, bout('Pool A-TB-1', C, D, 'completed')], { comp: extra, poolList: oversized }); + + expect(rows.Dan).toMatchObject({ badge: '3rd', badgeAdv: true, advancing: true, tied: false }); + expect(rows.Cho).toMatchObject({ badge: '4th', badgeAdv: false, advancing: false, tied: false }); + }); + + it('reads 4th and 5th level as an ordinary shared place, since no bout is owed there', () => { + const E = player('e', 'Eri'); + const five = [{ poolName: 'Pool A', players: [A, B, C, D, E], qualifiers: 3 }]; + const { container } = render( + , + ); + const badges = [...container.querySelectorAll('table.pool__table tbody tr .rank-badge')]; + const dan = badges[3]; + expect(dan.textContent).toBe('=4th'); + expect(dan.getAttribute('title')).toBe('Level on every criterion'); + }); +}); + +describe('a tie over a fill-bracket drafted 2nd', () => { + // Winners per pool 1, and the draw drafted this pool to send its 2nd as well: + // the server's count (`qualifiers`, engine.PoolQualifierCuts, read off the + // stored draw) is 2 for a drafted pool and 1 for one that was not. The page + // cannot tell them apart without it. + const fill = { ...competition, poolWinners: 1, poolSize: 3, extraQualifiers: 'fill-bracket' }; + const poolsOf = (qualifiers) => [{ poolName: 'Pool A', players: [A, B, C], qualifiers }]; + const level = [winner, row(B, 2, { tied: true }), row(C, 3, { tied: true })]; + const tbPending = [...regular, bout('Pool A-TB-1', B, C, 'scheduled')]; + + it('reads both as =2nd with no advancing mark, naming the bout that decides it', () => { + const rows = renderPool(level, tbPending, { comp: fill, poolList: poolsOf(2) }); + + expect(rows.Aoki).toMatchObject({ badge: '1st', badgeAdv: true, advancing: true }); + expect(rows.Baba).toMatchObject({ badge: '=2nd', badgeAdv: false, advancing: false, tied: true }); + expect(rows.Cho).toMatchObject({ badge: '=2nd', badgeAdv: false, advancing: false, tied: true }); + expect(rows.Baba.title).toMatch(/tie-break bout decides the order/); + }); + + it('marks the winner of the bout as advancing once it is scored', () => { + const rows = renderPool([ + winner, row(C, 2, { points: 2 }), row(B, 3, { points: 1 }), + ], [...regular, bout('Pool A-TB-1', B, C, 'completed')], { comp: fill, poolList: poolsOf(2) }); + + expect(rows.Cho).toMatchObject({ badge: '2nd', badgeAdv: true, advancing: true, tied: false }); + expect(rows.Baba).toMatchObject({ badge: '3rd', badgeAdv: false, advancing: false }); + }); + + it('shares the place with no bout owed when the pool was not drafted', () => { + const rows = renderPool(level, regular, { comp: fill, poolList: poolsOf(1) }); + + expect(rows.Baba).toMatchObject({ badge: '=2nd', badgeAdv: false, advancing: false, tied: true }); + expect(rows.Baba.title).toBe('Level on every criterion'); + }); +}); + +describe('a three-way tie straddling the cut', () => { + it('shows all three at the same place, none advancing', () => { + const four = [{ poolName: 'Pool A', players: [A, B, C, D] }]; + const level = { tied: true }; + const rows = renderPool([ + winner, + row(B, 2, level), + row(C, 3, level), + row(D, 4, level), + ], [], { poolList: four }); + + expect(rows.Aoki).toMatchObject({ badge: '1st', advancing: true, tied: false }); + for (const name of ['Baba', 'Cho', 'Dan']) { + expect(rows[name]).toMatchObject({ badge: '=2nd', badgeAdv: false, advancing: false, tied: true }); + } + }); +}); + +describe('tiedPlaces', () => { + const rows = (...r) => r; + it('shares the best place across a group and says whether it straddles the cut', () => { + const m = tiedPlaces(rows(winner, row(B, 2, { tied: true }), row(C, 3, { tied: true })), 2); + expect(m.get('b')).toEqual({ place: 2, boutOwed: true, decidesAdvancement: true }); + expect(m.get('c')).toEqual({ place: 2, boutOwed: true, decidesAdvancement: true }); + expect(m.has('a')).toBe(false); + }); + + it('keeps the advancing mark for a tie that sits wholly inside the qualifying places', () => { + const level = { points: 5, tied: true }; + const m = tiedPlaces(rows(row(A, 1, level), row(B, 2, level), row(C, 3, { points: 1 })), 2); + expect(m.get('a')).toEqual({ place: 1, boutOwed: true, decidesAdvancement: false }); + expect(m.get('b')).toEqual({ place: 1, boutOwed: true, decidesAdvancement: false }); + }); + + it('owes a bout for a tie over the extra qualifier place, since the cut is the pool\'s qualifier count', () => { + const m = tiedPlaces(rows(winner, row(B, 2, { points: 6 }), row(C, 3, { tied: true }), row(D, 4, { tied: true })), 3); + expect(m.get('c')).toEqual({ place: 3, boutOwed: true, decidesAdvancement: true }); + expect(m.get('d')).toEqual({ place: 3, boutOwed: true, decidesAdvancement: true }); + }); + + it('owes no bout for a tie wholly below the qualifying places', () => { + const m = tiedPlaces(rows(winner, row(B, 2, { points: 6 }), row(C, 3, { points: 4 }), row(D, 4, { tied: true }), row(player('e', 'Eri'), 5, { tied: true })), 3); + expect(m.get('d')).toEqual({ place: 4, boutOwed: false, decidesAdvancement: false }); + }); + + it('keeps two separate tied groups apart', () => { + const m = tiedPlaces(rows( + row(A, 1, { points: 9, tied: true }), row(B, 2, { points: 9, tied: true }), + row(C, 3, { points: 4, tied: true }), row(D, 4, { points: 4, tied: true }), + ), 2); + expect(m.get('a').place).toBe(1); + expect(m.get('c').place).toBe(3); + expect(m.get('d')).toEqual({ place: 3, boutOwed: false, decidesAdvancement: false }); + }); +}); diff --git a/web-mobile/js/__tests__/render/team_editor_row_stamp_cleared_on_landed_save.render.test.jsx b/web-mobile/js/__tests__/render/team_editor_row_stamp_cleared_on_landed_save.render.test.jsx index f14e3d51e..1cf6b6248 100644 --- a/web-mobile/js/__tests__/render/team_editor_row_stamp_cleared_on_landed_save.render.test.jsx +++ b/web-mobile/js/__tests__/render/team_editor_row_stamp_cleared_on_landed_save.render.test.jsx @@ -130,7 +130,7 @@ describe('a bout row stamp stands only while an edit is unsent', () => { expect(filled('shiro')).toBe(0); }); - it('keeps the row when the save was refused', async () => { + it('follows the server once the save was refused: the refusal clears the stamp', async () => { const onSubmit = vi.fn().mockResolvedValue({ applied: false, reason: 'superseded' }); let utils; const el = (match) => ; @@ -141,6 +141,6 @@ describe('a bout row stamp stands only while an edit is unsent', () => { await act(async () => { vi.advanceTimersByTime(400); }); await act(async () => { utils.rerender(el(otherDeviceScoredShiro(tappedAt - 1))); }); - expect(filled('shiro'), 'a refused save landed nothing, so the stamp stands').toBe(0); + expect(filled('shiro'), "a refused save is never followed by a broadcast, so the other device's point shows").toBe(1); }); }); diff --git a/web-mobile/js/__tests__/render/team_editor_stamps_only_while_running.render.test.jsx b/web-mobile/js/__tests__/render/team_editor_stamps_only_while_running.render.test.jsx new file mode 100644 index 000000000..eec4bef70 --- /dev/null +++ b/web-mobile/js/__tests__/render/team_editor_stamps_only_while_running.render.test.jsx @@ -0,0 +1,254 @@ +// The team sheet's bout-row and representative-pick edit stamps (lastRowEditRef, +// lastRepPickEditRef, bc-kclr) hold the operator's rows against a server copy stamped before +// the edit. Like the individual and engi editors' stamps they are taken only while the match +// is RUNNING, because only then is the edit autosaved and a landed save able to clear them, +// and they are cleared when the match leaves running. And a REFUSED autosave (nothing handed +// back, or applied:false) takes the server's current copy for the rows it carried: it stores +// and broadcasts nothing, so nothing else would bring the sheet back to what every other +// surface shows. A QUEUED answer keeps the sheet and the stamps: that write will land. See +// "The edit-time adopt rule" in .claude/rules/match-results-and-marks.md. + +import React from 'react'; +import { render, act, fireEvent, screen } from '@testing-library/react'; +import { describe, it, expect, vi, beforeAll, afterAll, beforeEach, afterEach } from 'vitest'; +import { installWindowStubs } from '../helpers/stub_globals.js'; + +const SQUADS = { + t1: [{ id: 'm1a', name: 'Alice', index: 1 }, { id: 'm2a', name: 'Brenda', index: 2 }], + t2: [{ id: 'm1b', name: 'Carol', index: 1 }, { id: 'm2b', name: 'Dana', index: 2 }], +}; + +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({ + id: 'comp1', config: { format: 'mixed', teamMatchType: 'kachinuki', naginata: false, players: [] }, + }), + fetchSquads: vi.fn().mockResolvedValue(SQUADS), + fetchTeamLineup: vi.fn().mockResolvedValue({ saved: false, positions: {} }), + fetchLineupInForce: vi.fn().mockResolvedValue({ saved: false, positions: {} }), + fetchMatchHistory: vi.fn().mockResolvedValue([]), + 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'); + await import('../../admin_lineup.jsx'); + ScoreEditorModal = window.ScoreEditorModal; +}); + +afterAll(() => restoreGlobals()); + +beforeEach(() => { vi.useFakeTimers(); }); +afterEach(() => { vi.useRealTimers(); }); + +const REFUSED = { applied: false, reason: 'superseded' }; +const QUEUED = { queued: true }; + +// A completed match is a CORRECTION: its rows are fixed-position, tappable from the start. +const kachinukiMatch = (subResults, modifiedAt = 0, status = 'running') => ({ + id: 'k1', compId: 'comp1', status, phase: 'pool', poolName: 'Pool 1', court: 'A', modifiedAt, + compKind: 'team', teamSize: 3, compFormat: 'mixed', teamMatchType: status === 'completed' ? 'fixed' : 'kachinuki', + ...(status === 'completed' ? { winner: { id: 'team-A', name: 'Team A' } } : {}), + sideA: { id: 'team-A', name: 'Team A' }, + sideB: { id: 'team-B', name: 'Team B' }, + subResults, +}); +// The other device scored Shiro's K on bout 1; its write left the match's stamp at `modifiedAt`. +const otherDeviceScoredShiro = (modifiedAt, status = 'running') => + kachinukiMatch([{ position: 1, sideA: '', sideB: '', ipponsA: [], ipponsB: ['K'] }], modifiedAt, status); + +const strikeAkaMen = async () => { + await act(async () => { fireEvent.keyDown(window, { key: 'M', shiftKey: true }); }); +}; +// A correction has no live bout for the keyboard to strike: tap Aka's M input on bout 1. +const tapAkaMenInput = async () => { + const row = document.querySelector('.team-sub-match:not(.team-sub-match--readonly)'); + const men = [...row.querySelectorAll('.ipt-btn')].filter((b) => b.textContent === 'M'); + await act(async () => { fireEvent.click(men[men.length - 1]); }); +}; +async function tapMark(side, letter) { + const mark = [...document.querySelectorAll(`.team-sub-match:not(.team-sub-match--readonly) .tsm-center-pts--${side} .editor-side__pt`)] + .find((b) => b.textContent === letter); + expect(mark, `a scored ${letter} on ${side}`).toBeTruthy(); + await act(async () => { fireEvent.click(mark); }); +} +const filled = (side) => document.querySelectorAll(`.team-sub-match:not(.team-sub-match--readonly) .tsm-center-pts--${side} .editor-side__pt--filled`).length; +const settle = async () => { for (let i = 0; i < 6; i++) await act(async () => { await Promise.resolve(); }); }; +const el = (match, onSubmit) => ; + +describe('the stamps are taken only while the match is running', () => { + it.each(['scheduled', 'completed'])('a net-zero fiddle on a %s match holds nothing against another device', async (status) => { + const onSubmit = vi.fn().mockResolvedValue({ applied: true }); + let utils; + await act(async () => { utils = render(el(kachinukiMatch([], 0, status), onSubmit)); }); + await (status === 'completed' ? tapAkaMenInput() : strikeAkaMen()); + await tapMark('aka', 'M'); + const tappedAt = Date.now(); + await act(async () => { vi.advanceTimersByTime(400); }); + expect(onSubmit, 'a match that is not running is not autosaved').not.toHaveBeenCalled(); + + await act(async () => { utils.rerender(el(otherDeviceScoredShiro(tappedAt - 1, status), onSubmit)); }); + expect(filled('shiro'), "the other device's point shows").toBe(1); + }); + + it('a stamp taken while running goes when the match leaves running inside the debounce', async () => { + const onSubmit = vi.fn().mockResolvedValue({ applied: true }); + let utils; + await act(async () => { utils = render(el(kachinukiMatch([]), onSubmit)); }); + await strikeAkaMen(); + await tapMark('aka', 'M'); + const tappedAt = Date.now(); + // Sent back to the queue elsewhere before the debounce fired: the write is dropped. + await act(async () => { utils.rerender(el(kachinukiMatch([], 5, 'scheduled'), onSubmit)); }); + await act(async () => { vi.advanceTimersByTime(400); }); + expect(onSubmit).not.toHaveBeenCalled(); + + await act(async () => { utils.rerender(el(otherDeviceScoredShiro(tappedAt - 1, 'scheduled'), onSubmit)); }); + expect(filled('shiro'), "the other device's point shows").toBe(1); + }); +}); + +describe('a refused autosave takes the server\'s copy of the rows it carried', () => { + // Aka strikes M; another device's copy (Shiro K, stamped before the tap) is held by the + // stamp; the autosave then comes back with `answer`. + async function strikeThenAnswer(answer) { + const onSubmit = vi.fn().mockResolvedValue(answer); + let utils; + await act(async () => { utils = render(el(kachinukiMatch([]), onSubmit)); }); + await strikeAkaMen(); + const tappedAt = Date.now(); + expect(filled('aka'), 'precondition: the strike shows').toBe(1); + await act(async () => { utils.rerender(el(otherDeviceScoredShiro(tappedAt - 1), onSubmit)); }); + expect([filled('aka'), filled('shiro')], 'precondition: the older copy is held').toEqual([1, 0]); + await act(async () => { vi.advanceTimersByTime(400); }); + await settle(); + expect(onSubmit, 'precondition: the autosave went out').toHaveBeenCalled(); + return utils; + } + + it('a superseded answer puts the server\'s point on the row and drops the operator\'s', async () => { + await strikeThenAnswer(REFUSED); + expect([filled('aka'), filled('shiro')]).toEqual([0, 1]); + }); + + it('a nothing-handed-back answer does the same', async () => { + await strikeThenAnswer(undefined); + expect([filled('aka'), filled('shiro')]).toEqual([0, 1]); + }); + + it('a queued answer keeps the sheet: that write will land', async () => { + await strikeThenAnswer(QUEUED); + expect([filled('aka'), filled('shiro')]).toEqual([1, 0]); + }); + + it('a clock_skew answer keeps the sheet: the tap is owed to the next save, not wiped', async () => { + // recordScore keeps a running clock_skew refusal silent and relearns the clock. + await strikeThenAnswer({ applied: false, reason: 'clock_skew' }); + expect([filled('aka'), filled('shiro')]).toEqual([1, 0]); + }); + + it('a strike made after the refused save was built is kept', async () => { + let settleFirst; + const onSubmit = vi.fn().mockImplementationOnce(() => new Promise((res) => { settleFirst = res; })) + .mockResolvedValue({ applied: true }); + let utils; + await act(async () => { utils = render(el(kachinukiMatch([]), onSubmit)); }); + await strikeAkaMen(); + const tappedAt = Date.now(); + await act(async () => { utils.rerender(el(otherDeviceScoredShiro(tappedAt - 1), onSubmit)); }); + await act(async () => { vi.advanceTimersByTime(400); }); // the first save is out and unanswered + await strikeAkaMen(); // after it was built + await act(async () => { settleFirst(REFUSED); }); + await settle(); + expect(filled('aka'), 'the newer edit is still owed').toBeGreaterThan(0); + }); + + // The representative bout (position -1) is taken PART by part: the server's row score for the + // bout the save carried, and the pick of each side whose pick the save carried. The other + // side's pick was not carried, so it stays the operator's (composeRepRow). + describe('the representative bout', () => { + const FOUGHT = [1, 2, 3].map((position) => ({ + position, sideA: '', sideB: '', ipponsA: [], ipponsB: [], winner: '', decision: 'hikiwake', + })); + const repRow = (extra) => ({ + position: -1, sideA: 'Team A', sideB: 'Team B', ipponsA: [], ipponsB: [], winner: '', decision: 'daihyosen', ...extra, + }); + const repMatch = (row, modifiedAt = 0) => ({ + id: 'k-rep', compId: 'comp1', status: 'running', phase: 'knockout', court: 'A', modifiedAt, + compKind: 'team', teamSize: 3, compFormat: 'mixed', teamMatchType: 'fixed', + sideA: { id: 't1', name: 'Team A' }, sideB: { id: 't2', name: 'Team B' }, + subResults: [...FOUGHT, row], + }); + const dhInput = (side) => screen.queryAllByLabelText(/^Daihyosen (SHIRO|AKA) player$/) + .find((e) => e.getAttribute('aria-label').includes(side)); + const repRowEl = () => dhInput('AKA').closest('.team-sub-match'); + const repFilled = (side) => repRowEl().querySelectorAll(`.tsm-center-pts--${side} .editor-side__pt--filled`).length; + + async function pickShiro(name) { + await act(async () => { fireEvent.focus(dhInput('SHIRO')); fireEvent.click(dhInput('SHIRO')); }); + await settle(); + const opt = screen.getByText(name, { selector: '.pmf__opt-name' }); + await act(async () => { fireEvent.click(opt.closest('button')); }); + await settle(); + } + + async function repEditThenAnswer(answer) { + const onSubmit = vi.fn().mockResolvedValue(answer); + let utils; + // Aka (t1) already holds Alice; the sheet starts level. + const start = repMatch(repRow({ sideAMemberId: 'm1a' })); + await act(async () => { utils = render(el(start, onSubmit)); }); + await settle(); + // Aka strikes M on the representative bout and Shiro's representative is picked: two edits. + const men = [...repRowEl().querySelectorAll('.ipt-btn')].filter((b) => b.textContent === 'M'); + await act(async () => { fireEvent.click(men[men.length - 1]); }); + await pickShiro('Carol'); + const tappedAt = Date.now(); + expect([repFilled('aka'), dhInput('SHIRO').value, dhInput('AKA').value], 'precondition: both edits show') + .toEqual([1, 'Carol', 'Alice']); + // Another device's copy, stamped before the tap, so the stamps hold the operator's row: it + // scored Shiro and picked Dana for Shiro; Aka's pick stays Alice (the side the save did not + // carry: an unedited pick is followed by the ordinary adopt, so only a take that wrongly + // reached it, or dropped the sheet's, would show here). + const served = repMatch(repRow({ ipponsB: ['K'], sideAMemberId: 'm1a', sideBMemberId: 'm2b' }), tappedAt - 1); + await act(async () => { utils.rerender(el(served, onSubmit)); }); + expect([repFilled('aka'), repFilled('shiro')], 'precondition: the older copy is held').toEqual([1, 0]); + await act(async () => { vi.advanceTimersByTime(400); }); + await settle(); + expect(onSubmit, 'precondition: the autosave went out').toHaveBeenCalled(); + return onSubmit; + } + + it.each([['a superseded answer', REFUSED], ['a nothing-handed-back answer', undefined]])( + '%s takes the server\'s score and the picked side\'s pick, and keeps the other side\'s pick', + async (_name, answer) => { + const onSubmit = await repEditThenAnswer(answer); + const sent = onSubmit.mock.calls[0][0].subResults.find((s) => s.position === -1); + // The save carried Aka's point and Shiro's pick, never Aka's pick. + expect(sent.ipponsA).toEqual(['M']); + expect(sent.sideBMemberId).toBe('m1b'); + expect([repFilled('aka'), repFilled('shiro')], 'the server\'s score on the row').toEqual([0, 1]); + expect(dhInput('SHIRO').value, 'the picked side shows the server\'s pick').toBe('Dana'); + expect(dhInput('AKA').value, 'the side the save did not carry keeps the operator\'s pick').toBe('Alice'); + }); + }); +}); 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 164d0ca3f..d0f6dbed1 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 @@ -23,7 +23,7 @@ const STUBBED_GLOBALS = { confirmDialog: vi.fn().mockResolvedValue(true), API: { fetchCompetitionDetails: vi.fn().mockResolvedValue(null), - recordScore: vi.fn().mockResolvedValue(undefined), + recordScore: vi.fn().mockResolvedValue({ applied: true }), recordDaihyosen: vi.fn(), removeDaihyosen: vi.fn(), putMatchLineup: vi.fn(), diff --git a/web-mobile/js/__tests__/viewer_pools_viewer.test.jsx b/web-mobile/js/__tests__/viewer_pools_viewer.test.jsx index 67714f886..73e059a99 100644 --- a/web-mobile/js/__tests__/viewer_pools_viewer.test.jsx +++ b/web-mobile/js/__tests__/viewer_pools_viewer.test.jsx @@ -299,10 +299,10 @@ describe('PoolsViewer draw-order standings (mp-938b)', () => { // ------------------------------------------------------------------ // bc-qual LP-5a: per-pool qualifier highlighting. PoolsViewer used to // highlight a single UNIFORM top-N (competition.poolWinners) across every -// pool; qualifiersForPool (viewer_standings.jsx) now computes the count -// PER POOL so an oversized pool under ExtraQualifiersLargerPools highlights -// one extra row, mirroring state.Competition.QualifiersForPool's oversized -// test (pool.players.length > competition.poolSize). +// pool; qualifiersForPool (viewer_standings.jsx) now reads the SERVER's count +// per pool (`pool.qualifiers`, the payload's poolQualifiers map) so an +// oversized pool under ExtraQualifiersLargerPools, or a pool whose 2nd a +// fill-bracket draw drafted, highlights one extra row (bc-fbtb). // ------------------------------------------------------------------ describe('PoolsViewer per-pool qualifier highlighting (bc-qual LP-5a)', () => { const realReact = global.React; @@ -390,16 +390,16 @@ describe('PoolsViewer per-pool qualifier highlighting (bc-qual LP-5a)', () => { it('standard mode: poolWinners=1 highlights only rank 1 in every pool, regardless of size', () => { const comp = { kind: 'individual', teamSize: 0, format: 'mixed', poolWinners: 1, poolSize: 3, extraQualifiers: '' }; - const tree = runtime.mount(PoolsViewer, { pools: [poolA, poolB], standings, poolMatches: [], tweaks, competition: comp }); + const tree = runtime.mount(PoolsViewer, { pools: [{ ...poolA, qualifiers: 1 }, { ...poolB, qualifiers: 1 }], standings, poolMatches: [], tweaks, competition: comp }); const rows = advancingRowTexts(tree); expect(rows).toHaveLength(2); expect(rows.some(t => t.includes('A1'))).toBe(true); expect(rows.some(t => t.includes('B1'))).toBe(true); }); - it('larger-pools mode: an oversized pool (4 players, min size 3) highlights top 2; a minimum-size pool highlights only 1', () => { + it('larger-pools mode: an oversized pool (server count 2) highlights top 2; a minimum-size pool (count 1) highlights only 1', () => { const comp = { kind: 'individual', teamSize: 0, format: 'mixed', poolWinners: 1, poolSize: 3, extraQualifiers: 'larger-pools' }; - const tree = runtime.mount(PoolsViewer, { pools: [poolA, poolB], standings, poolMatches: [], tweaks, competition: comp }); + const tree = runtime.mount(PoolsViewer, { pools: [{ ...poolA, qualifiers: 2 }, { ...poolB, qualifiers: 1 }], standings, poolMatches: [], tweaks, competition: comp }); const rows = advancingRowTexts(tree); // Pool A (oversized): top 2 (A1, A2). Pool B (exactly minimum): top 1 (B1) only. expect(rows).toHaveLength(3); @@ -410,14 +410,16 @@ describe('PoolsViewer per-pool qualifier highlighting (bc-qual LP-5a)', () => { expect(rows.some(t => t.includes('B2'))).toBe(false); }); - it('fill-bracket mode: only rank 1 highlights even in an oversized pool (drafted-2nd is a documented residue: the payload carries no draft-index list)', () => { + it('fill-bracket mode: the pool the draw drafted highlights its 2nd, wherever it sits and whatever its size', () => { const comp = { kind: 'individual', teamSize: 0, format: 'mixed', poolWinners: 1, poolSize: 3, extraQualifiers: 'fill-bracket' }; - const tree = runtime.mount(PoolsViewer, { pools: [poolA, poolB], standings, poolMatches: [], tweaks, competition: comp }); + // Pool B (the minimum size) was drafted; oversized Pool A was not. + const tree = runtime.mount(PoolsViewer, { pools: [{ ...poolA, qualifiers: 1 }, { ...poolB, qualifiers: 2 }], standings, poolMatches: [], tweaks, competition: comp }); const rows = advancingRowTexts(tree); - expect(rows).toHaveLength(2); + expect(rows).toHaveLength(3); expect(rows.some(t => t.includes('A1'))).toBe(true); expect(rows.some(t => t.includes('A2'))).toBe(false); expect(rows.some(t => t.includes('B1'))).toBe(true); + expect(rows.some(t => t.includes('B2'))).toBe(true); }); }); diff --git a/web-mobile/js/admin_branding.jsx b/web-mobile/js/admin_branding.jsx index 59c13dfe7..a6208c9e2 100644 --- a/web-mobile/js/admin_branding.jsx +++ b/web-mobile/js/admin_branding.jsx @@ -39,17 +39,17 @@ function BrandingManager({ tournament, password, showToast, onThemeChange }) { const [primaryColor, setPrimaryColor] = useStateBr(theme.primaryColor || "#1d3557"); const [accentSoftColor, setAccentSoftColor] = useStateBr(theme.accentSoftColor || "#e7eaf3"); const [windowTitle, setWindowTitle] = useStateBr(theme.windowTitle || ""); - const [hasLogo, setHasLogo] = useStateBr(false); + // Whether a custom logo is set comes with the tournament (theme.hasLogo), so + // the page never probes /api/branding/logo and meets a 404 while none is set + // (bc-lgnf). Upload and Remove below set it at once; the effect follows the + // tournament when it changes (an SSE reload, another organiser's upload). + const tournamentHasLogo = !!theme.hasLogo; + const [hasLogo, setHasLogo] = useStateBr(tournamentHasLogo); const [logoKey, setLogoKey] = useStateBr(0); // bump to force img reload const [busy, setBusy] = useStateBr(false); const fileRef = useRefBr(null); - // Probe whether a logo exists on mount. - useEffectBr(() => { - fetch('/api/branding/logo', { method: 'HEAD' }) - .then(r => setHasLogo(r.ok)) - .catch(() => setHasLogo(false)); - }, [logoKey]); + useEffectBr(() => { setHasLogo(tournamentHasLogo); }, [tournamentHasLogo]); // Sync incoming theme changes (e.g. after a tournament SSE reload). // Always update pickers/inputs, but only propagate to the parent when diff --git a/web-mobile/js/admin_scoring_autosave.jsx b/web-mobile/js/admin_scoring_autosave.jsx index e43929afd..26f0a7d73 100644 --- a/web-mobile/js/admin_scoring_autosave.jsx +++ b/web-mobile/js/admin_scoring_autosave.jsx @@ -4,7 +4,7 @@ import { toBackendMatchResult, matchWire } from './api_serializers.jsx'; import { changedGroups, statedGroupsOf, groupMatches, keptInHistoryNote, GROUP_RESULT } from './match_groups.jsx'; -import { writeDidNotLand, writeWasRefused } from './write_result.jsx'; +import { writeDidNotLand, writeWasRefused, writeWasRefusedForClock } from './write_result.jsx'; const { useState: useStateA, useEffect: useEffectA, useRef: useRefA } = React; @@ -142,14 +142,29 @@ export function useKeptInHistoryNote() { // carries every edit counted up to `seq` and none after it: the edit stamps the editor // keeps to hold its board against an older server copy (bc-kclr, bc-ltap) clear only for // edits at or before it, never for a tap made while the save was out. The individual -// editor's board stamp and the team sheet's row and pick stamps are its two callers. -export function landedAutosave(submit, seqRef, onLanded) { +// editor's board stamp, the team sheet's row and pick stamps and the engi editor's flags +// stamp are its three callers. +// +// `onRefused(seq)`, optional, runs instead when the save came back REFUSED +// (writeWasRefused: nothing handed back, or applied:false), with the same `seq`. A refused +// save stores nothing and broadcasts nothing, so no later server copy would ever bring +// the board back: the stamp it left set would hold the operator's value for good while the +// server and every other surface show the server's. The callback is for the editor to +// compare `seq` with its counter (an edit made since is still owed and keeps its stamp), +// then drop the stamp and take the server's CURRENT copy. A QUEUED answer calls neither: +// that write will land, so the stamp stays. So does a clock_skew refusal +// (writeWasRefusedForClock): recordScore keeps a running one silent on purpose, relearns the +// clock and leaves the tap on the board for the NEXT save to carry with a corrected stamp, so +// taking the server's copy here would wipe a tap that is still owed. +export function landedAutosave(submit, seqRef, onLanded, onRefused) { return (patch) => { const seq = seqRef.current; const p = submit(patch); if (p && typeof p.then === "function") { p.then((res) => { - if (!writeWasRefused(res) && !writeDidNotLand(res)) onLanded(seq); + if (writeWasRefusedForClock(res)) return; + if (writeWasRefused(res)) { if (onRefused) onRefused(seq); } + else if (!writeDidNotLand(res)) onLanded(seq); }, () => {}); } return p; diff --git a/web-mobile/js/admin_scoring_engi.jsx b/web-mobile/js/admin_scoring_engi.jsx index b6c6d87e1..523c39bd9 100644 --- a/web-mobile/js/admin_scoring_engi.jsx +++ b/web-mobile/js/admin_scoring_engi.jsx @@ -22,7 +22,8 @@ const { useState: useStateE, useEffect: useEffectE, useRef: useRefE } = React; import { ReasonPrompt, CORRECTION_PRESETS, useAdoptFromServer, HeldWriteDiscard, HeldWriteNotice, useClearPendingWhenNothingHeld } from './admin_scoring_shared.jsx'; -import { SyncStatusPill, useDebouncedRunningWrite, useChangedGroups, useKeptInHistoryNote, KeptInHistoryNote } from './admin_scoring_autosave.jsx'; +import { SyncStatusPill, useDebouncedRunningWrite, useChangedGroups, useKeptInHistoryNote, KeptInHistoryNote, landedAutosave } from './admin_scoring_autosave.jsx'; +import { serverNowMs } from './server_clock.jsx'; import { MatchHistoryDisclosure } from './match_history_view.jsx'; import { GROUP_FLAGS } from './match_groups.jsx'; import { useEscapeToClose, confirmDialog } from './ui.jsx'; @@ -143,7 +144,32 @@ export function EngiScoreEditorModal({ match, onClose, onSubmit, onSubmitAndNext autosaveIsRunningRef.current = m.status === "running"; autosaveSeenStampRef.current = m.modifiedAt || 0; autosaveBuildPatchRef.current = (status) => claimChanged({ flagsA, flagsB, status }); - autosaveOnSubmitRef.current = onSubmit; + // When the operator last changed a count here, in the server's clock frame; + // undefined once the board holds the server's copy again. A server copy + // stamped before it cannot know that edit, so the adopt below keeps the + // counts: comparing values alone reads a tap taken back during a save as + // untouched, and that save's echo then wipes it (bc-ltap, bc-kclr; the + // individual editor's boardEditedAtRef explains the rule at length). The seq + // counts edits so landedAutosave clears the stamp only for a save built after + // the latest one. The stamp is taken only while the match is running: only then + // is the edit autosaved, and only a landed autosave clears the stamp. + const flagsEditedAtRef = useRefE(undefined); + const flagsEditSeqRef = useRefE(0); + // A REFUSED autosave stores and broadcasts nothing, so no later copy would re-run the + // adopt: with no edit since the save was built, the counts take the server's current + // copy now (takeServerFlagsRef holds the latest render's adopt, not this one's). + const takeServerFlagsRef = useRefE(null); + autosaveOnSubmitRef.current = landedAutosave(onSubmit, flagsEditSeqRef, (seq) => { + if (flagsEditSeqRef.current === seq) flagsEditedAtRef.current = undefined; + }, (seq) => { + // The stamp must still be set: the seq counts only RUNNING edits, so a correction tapped + // after the match finished elsewhere (the status effect cleared the stamp, the seq did + // not move) would otherwise be wiped by a refusal that arrives late. + if (flagsEditSeqRef.current === seq && flagsEditedAtRef.current !== undefined && takeServerFlagsRef.current) takeServerFlagsRef.current(); + }); + // The match left running (finished, or sent back to the queue, elsewhere) inside the + // debounce: the write is dropped with no callback, so no landed save clears the stamp. + useEffectE(() => { if (m.status !== "running") flagsEditedAtRef.current = undefined; }, [m.status]); // An operator change to either count: the value, then the save it schedules. // A count adopted from the server does not come through here. A key pressed // at a bound (a/s at MAX_FLAGS, Backspace at 0; the buttons are disabled @@ -153,6 +179,10 @@ export function EngiScoreEditorModal({ match, onClose, onSubmit, onSubmitAndNext const next = clamp(n); if (next === (side === "a" ? flagsA : flagsB)) return; (side === "a" ? setFlagsA : setFlagsB)(next); + if (m.status === "running") { + flagsEditedAtRef.current = serverNowMs(); + flagsEditSeqRef.current += 1; + } markDirty(); }; @@ -173,14 +203,28 @@ export function EngiScoreEditorModal({ match, onClose, onSubmit, onSubmitAndNext // keepLocalEdits, like the individual editor's scoreline and unlike the team // board: a pair of flag counts is ONE reading of ONE kata performance, so // there is no per-row seam to merge along. An operator with unsaved flags - // keeps all of them. + // keeps all of them. isDirty is value-only, so it cannot see a tap taken + // back to the server's value while a save is out; the apply holds the + // counts against a server copy stamped before the last edit (flagsEditedAtRef) + // and, once it does take one, clears the stamp. // // Until the match is running nothing this editor holds reaches the server // before the operator submits, so a stale reading can sit in front of them // for as long as the match is open. + const takeServerFlags = () => { + flagsEditedAtRef.current = undefined; + setFlagsA(initialFlagsA); + setFlagsB(initialFlagsB); + claimChanged.agree(GROUP_FLAGS); + }; + takeServerFlagsRef.current = takeServerFlags; useAdoptFromServer({ signature: `${initialFlagsA}:${initialFlagsB}`, - apply: () => { setFlagsA(initialFlagsA); setFlagsB(initialFlagsB); claimChanged.agree(GROUP_FLAGS); }, + apply: () => { + const editedAt = flagsEditedAtRef.current; + if (editedAt !== undefined && (m.modifiedAt || 0) < editedAt) return; + takeServerFlags(); + }, keepLocalEdits: true, isDirty, }); diff --git a/web-mobile/js/admin_scoring_individual.jsx b/web-mobile/js/admin_scoring_individual.jsx index c79352c25..58792c180 100644 --- a/web-mobile/js/admin_scoring_individual.jsx +++ b/web-mobile/js/admin_scoring_individual.jsx @@ -329,18 +329,34 @@ export function ScoreEditorModal({ match, onClose, onSubmit, onSubmitAndNext, on // nothing on the server at all. Once an autosave built after the latest // board edit has landed, the board holds nothing the server lacks, so the // stamp is cleared (landedBoardSave) and every later server copy is followed. + // It is taken only while the match is running: only then is the edit autosaved, + // and a stamp no save can clear would hold the board against other devices. const boardEditedAtRef = useRefA(undefined); const boardEditSeqRef = useRefA(0); const markScoringDirty = () => { - boardEditedAtRef.current = serverNowMs(); - boardEditSeqRef.current += 1; + if (m.status === "running") { + boardEditedAtRef.current = serverNowMs(); + boardEditSeqRef.current += 1; + } markRunningWrite(); }; // The autosave's submit: the host's onSubmit, and on a landed answer the // board stamp cleared when no board edit was made since this save was built. + // A REFUSED autosave stores and broadcasts nothing, so no later copy would re-run the + // adopt: with no board edit since the save was built, the board takes the server's + // current copy now (applyServerScoreRef holds the latest render's, not this one's). + const applyServerScoreRef = useRefA(null); const landedBoardSave = (submit) => landedAutosave(submit, boardEditSeqRef, (seq) => { if (boardEditSeqRef.current === seq) boardEditedAtRef.current = undefined; + }, (seq) => { + // The stamp must still be set: the seq counts only RUNNING edits, so a correction tapped + // after the match finished elsewhere (the status effect cleared the stamp, the seq did + // not move) would otherwise be wiped by a refusal that arrives late. + if (boardEditSeqRef.current === seq && boardEditedAtRef.current !== undefined && applyServerScoreRef.current) applyServerScoreRef.current(); }); + // The match left running (finished, or sent back to the queue, elsewhere) inside the + // debounce: the write is dropped with no callback, so no landed save clears the stamp. + useEffectA(() => { if (m.status !== "running") boardEditedAtRef.current = undefined; }, [m.status]); useEffectA(() => { if (!m.compId) return; @@ -704,6 +720,7 @@ export function ScoreEditorModal({ match, onClose, onSubmit, onSubmitAndNext, on claimChanged.agree(GROUP_ENCHO); claimChanged.agree(GROUP_RESULT); }; + applyServerScoreRef.current = applyServerScore; // Arranged as [left, right]: left is always SHIRO (White), right is always AKA (Red). // onIncrement applies the FIK 2-foul auto-award rule via applyFoulIncrement: diff --git a/web-mobile/js/admin_scoring_team.jsx b/web-mobile/js/admin_scoring_team.jsx index c117b5d64..45a3ac967 100644 --- a/web-mobile/js/admin_scoring_team.jsx +++ b/web-mobile/js/admin_scoring_team.jsx @@ -2356,6 +2356,19 @@ export function TeamScoreEditorModal({ match, teamSize, onClose, onSubmit, onSub lastRepPickEditRef.current[side] = undefined; } }; + // The stamps are taken only while the match is RUNNING, because only then is the edit + // autosaved: on a scheduled match, or a correction of a completed one, nothing is sent, + // no landed save would ever clear a stamp, and a net-zero fiddle would make the sheet + // refuse a copy from another device it would otherwise follow. When the match leaves + // running (another device finished it or sent it back to the queue inside the debounce) + // the write is dropped with no callback, so no landed save clears them either: they go + // here. The individual and engi editors do the same. + useEffectA(() => { if (m.status !== "running") clearLandedEditStamps(Infinity); }, [m.status]); + // A REFUSED autosave stores and broadcasts nothing, so no later copy would re-run the + // adopt, and the stamps it left would hold the operator's rows against the server for + // good. With no edit since the save was built, every row and pick it carried takes the + // server's current copy (assigned beside the adopt below; the ref is the latest render's). + const takeServerRowsRef = useRefA(null); // C1: updateSub is the single choke-point for all sub-bout state // mutations. Calling markScoringDirty() here captures every edit // (pts add/remove, fouls, fusensho, draw) without repetition. @@ -2374,12 +2387,15 @@ export function TeamScoreEditorModal({ match, teamSize, onClose, onSubmit, onSub // part, because the adopt follows the server for each part on its own. const updateSub = (idx, fn, opts) => { editSeqRef.current += 1; - if (idx === daihyosenIdx && opts?.repPickSide) { - lastRepPickEditRef.current[opts.repPickSide] = serverNowMs(); - repPickEditSeqRef.current[opts.repPickSide] = editSeqRef.current; - } else { - lastRowEditRef.current.set(subs[idx]._pos, serverNowMs()); - rowEditSeqRef.current.set(subs[idx]._pos, editSeqRef.current); + // Stamped only while running: nothing else is autosaved to clear one (the effect above). + if (m.status === "running") { + if (idx === daihyosenIdx && opts?.repPickSide) { + lastRepPickEditRef.current[opts.repPickSide] = serverNowMs(); + repPickEditSeqRef.current[opts.repPickSide] = editSeqRef.current; + } else { + lastRowEditRef.current.set(subs[idx]._pos, serverNowMs()); + rowEditSeqRef.current.set(subs[idx]._pos, editSeqRef.current); + } } setSubsByOperator(prev => { const rows = reconcileRowsToPositions(prev, serverSubs); @@ -3751,7 +3767,9 @@ export function TeamScoreEditorModal({ match, teamSize, onClose, onSubmit, onSub // case where a prior [Remove this bout] left one to adopt into. _autosaveSeenStampRef.current = Math.max(m.modifiedAt || 0, _recordBoutAnswerStampRef.current); _autosaveBuildPatchRef.current = buildPatch; - _autosaveOnSubmitRef.current = landedAutosave(onSubmit, editSeqRef, clearLandedEditStamps); + _autosaveOnSubmitRef.current = landedAutosave(onSubmit, editSeqRef, clearLandedEditStamps, (seq) => { + if (takeServerRowsRef.current) takeServerRowsRef.current(seq); + }); const doSubmit = async (fn) => { cancelScoringDebounce(); // C1: cancel pending autosave before explicit submit @@ -3984,6 +4002,30 @@ export function TeamScoreEditorModal({ match, teamSize, onClose, onSubmit, onSub // per-MATCH, not per-bout, so it cannot merge two people's rows. That case is // theirs to resolve. const prevServerSubsRef = useRefA(serverSubs); + // A refused autosave (takeServerRowsRef above): the rows and picks stamped up to `seq` + // take the server's current copy, by the adopt's own composition (composeRepRow) and + // agreement (boutGroup, the pick groups). An edit since `seq` is still owed, so + // nothing is taken; the save that carries it decides. A row the server does not hold + // stays the operator's. + takeServerRowsRef.current = (seq) => { + if (editSeqRef.current !== seq) return; + const rows = new Set(rowEditSeqRef.current.keys()); + const picks = { a: repPickEditSeqRef.current.a !== undefined, b: repPickEditSeqRef.current.b !== undefined }; + clearLandedEditStamps(seq); + const servedByPos = new Map(serverSubs.map(s => [s._pos, s])); + const repServed = servedByPos.has(DAIHYOSEN_POSITION); + setSubs(prev => prev.map(local => { + const ss = servedByPos.get(local._pos); + if (!ss) return local; + if (local._pos === DAIHYOSEN_POSITION) { + return composeRepRow(local, ss, { score: rows.has(local._pos), a: picks.a, b: picks.b }); + } + return rows.has(local._pos) ? ss : local; + })); + for (const pos of rows) if (servedByPos.has(pos)) claimChanged.agree(boutGroup(pos)); + if (repServed && picks.a) claimChanged.agree(GROUP_REP_PICK_A); + if (repServed && picks.b) claimChanged.agree(GROUP_REP_PICK_B); + }; useAdoptFromServer({ signature: serverSubsSig, apply: () => { diff --git a/web-mobile/js/api_serializers.jsx b/web-mobile/js/api_serializers.jsx index 11a8e0bce..6d222fff1 100644 --- a/web-mobile/js/api_serializers.jsx +++ b/web-mobile/js/api_serializers.jsx @@ -521,13 +521,22 @@ function normalizeCompetitionDetail(data) { result.config = config; } - // Normalize pools (Go: PoolName, Players → poolName, players) + // Normalize pools (Go: PoolName, Players → poolName, players). `qualifiers` + // is how many of the pool's places send a qualifier, from the payload's + // sibling `poolQualifiers` map (engine.PoolQualifierCuts, keyed by pool + // name): the server owns it because which fill-bracket pools were drafted + // is a property of the stored draw. qualifiersForPool reads it back. if (result.pools) { - result.pools = result.pools.map(p => ({ - poolName: p.PoolName || p.poolName || "", - players: (p.Players || p.players || []).map(normalizePlayer), - matches: p.Matches || p.matches || [], - })); + const cuts = result.poolQualifiers || {}; + result.pools = result.pools.map(p => { + const poolName = p.PoolName || p.poolName || ""; + return { + poolName, + players: (p.Players || p.players || []).map(normalizePlayer), + matches: p.Matches || p.matches || [], + qualifiers: cuts[poolName], + }; + }); } // Normalize standings player field (carry flags for engi standings) diff --git a/web-mobile/js/viewer_standings.jsx b/web-mobile/js/viewer_standings.jsx index eff20f53d..1a6ac1058 100644 --- a/web-mobile/js/viewer_standings.jsx +++ b/web-mobile/js/viewer_standings.jsx @@ -706,47 +706,79 @@ export const PoolNumberedMatchRow = React.memo(({ m, num, onMatchClick, isEngi } }); PoolNumberedMatchRow.displayName = "PoolNumberedMatchRow"; -// qualifiersForPool: how many top-ranked finishers in `pool` advance to the -// knockout (bc-qual LP-5a). PoolsViewer uses this PER POOL instead of a -// single count across every pool, so an oversized pool's extra qualifier -// (state.ExtraQualifiersLargerPools) is highlighted correctly, e.g. a -// 4-person pool at minimum size 3 with poolWinners=1 highlights its top 2, -// not just its winner. +// tiedPlaces: the place each still-open tie holds, keyed by standings row. +// +// The server marks a row `tied` while the competitors level with it on EVERY +// criterion await a tie-break bout, but each row still carries its own sorted +// rank (draw order breaks the level pair), so reading `rank` alone badges one +// of them 2nd and the other 3rd as though the order were decided. A tied group +// shares its best member's place. // -// Mirrors state.Competition.QualifiersForPool's OVERSIZED test exactly (the -// "trivial larger-pools size test" this client-side module is allowed to -// duplicate, per the mp-3abe-adjacent rule against re-deriving the engine's -// own oversized/draft logic in JS): a pool is oversized when it holds more -// players than the competition's configured minimum PoolSize. -// competition.poolWinners is the RAW field (may be 0/unset); default to 2 -// exactly like EffectivePoolWinners() does, since this payload doesn't -// expose that method. +// One cut: `qualifiers`, how many of the pool's places advance +// (qualifiersForPool: the server's count, with a larger-pools extra place or a +// fill-bracket drafted 2nd). The server owes a +// bout for a tie whose best place is within it (engine.poolTieBreakCut), so +// `boutOwed` is true when the group's best place is, and a tie wholly below it +// just shares its place. `decidesAdvancement` is true only when a bout is owed +// AND the group straddles the line, so a pair level for 1st that both advance +// anyway still reads as advancing, while a tie over the extra larger-pools +// place leaves none of them marked until its bout is scored. +// `points` is the server's packed ranking key (engine individual/teamStandingPoints), +// equal exactly when two rows are level on every criterion, so rows that share +// it among the tied ones form one group. Pool standings only: a league has no +// advancing mark to withhold. +export function tiedPlaces(poolStandings, qualifiers) { + const groups = new Map(); + (poolStandings || []).forEach((s, i) => { + if (!s || !s.tied) return; + const rank = s.rank || i + 1; + const g = groups.get(s.points); + if (g) { + g.place = Math.min(g.place, rank); + g.last = Math.max(g.last, rank); + } else { + groups.set(s.points, { place: rank, last: rank }); + } + }); + const byRow = new Map(); + (poolStandings || []).forEach((s) => { + if (!s || !s.tied) return; + const g = groups.get(s.points); + const key = s.player.id || `${s.player.name}||${s.player.dojo || ""}`; + const boutOwed = g.place <= qualifiers; + byRow.set(key, { place: g.place, boutOwed, decidesAdvancement: boutOwed && g.last > qualifiers }); + }); + return byRow; +} + +// The tooltip on a tied row's badge. It promises a deciding bout only where the +// server owes one (`boutOwed`); a tie it holds none for just reads as level. A +// team pool settles with a representative bout, an individual one with a +// tie-break bout (engine.pendingTieBreaks). +function tieBadgeTitle(tie, isTeam) { + const level = "Level on every criterion"; + if (!tie.boutOwed) return level; + return `${level}: ${isTeam ? "a representative bout" : "a tie-break bout"} decides the order`; +} + +// qualifiersForPool: how many top-ranked finishers in `pool` advance to the +// knockout. PoolsViewer uses this PER POOL instead of a single count across +// every pool, so an oversized pool's extra qualifier (larger-pools) and a +// drafted pool's 2nd (fill-bracket) are both marked advancing. // -// Deliberately does NOT attempt the fill-bracket drafted-2nd calculation -// (helper.SelectFillBracketDrafts): drafts go to the SEEDED pools in seed -// order (oversized as fallback), stopping once the bracket's shortfall is -// met and skipping a candidate whose opposite half is full -- a WHOLE- -// competition scan over the pool set plus the court layout, which no -// per-pool test can reproduce. The per-pool seed ranks ARE on this wire -// (pools[].players[].seed comes through pools.csv), so it is the cross-pool -// stop-and-skip state that is missing, not the seed data. Under fill-bracket -// this therefore returns the uniform EffectivePoolWinners() count, so only -// rank 1 (never a drafted rank 2) is ever highlighted as advancing here; the -// residue is a deliberate accepted gap, not a bug: a correct highlight needs -// the server to say WHICH pools were drafted, which is future work if this -// becomes worth wiring (bc-qual LP-5a report). +// The count is the SERVER's, `pool.qualifiers` (the payload's `poolQualifiers` +// map, normalized onto each pool in api_serializers.jsx; engine.PoolQualifierCuts +// reads it off the stored draw): the same count the server holds a tie-break +// bout against. It cannot be derived here, because which fill-bracket pools +// were drafted is a whole-field selection over every pool's seeds and the court +// layout, and a larger-pools pool's extra place follows the draw too. Without +// it (a payload from before a draw, or a demo pool) the count is the base +// Winners per pool, mirroring EffectivePoolWinners() (unset or non-positive is +// 2). export function qualifiersForPool(competition, pool) { - const base = (competition && competition.poolWinners) || 2; - if ( - competition && - competition.extraQualifiers === "larger-pools" && - competition.poolSize > 0 && - pool && Array.isArray(pool.players) && - pool.players.length > competition.poolSize - ) { - return base + 1; - } - return base; + const served = pool && pool.qualifiers; + if (Number.isInteger(served) && served > 0) return served; + return (competition && competition.poolWinners) > 0 ? competition.poolWinners : 2; } // showDataIssues: opt-in for the operator-only data-integrity notices (see @@ -810,6 +842,11 @@ export function PoolsViewer({ pools, standings, poolMatches, tweaks, competition }); } + // A tie still waiting for its tie-break bout reads as tied (see + // tiedPlaces): the level rows share one place, and none of them is + // marked advancing when the tie decides who goes through. + const tiedByRow = isLeague ? new Map() : tiedPlaces(poolStandings, poolQualifiers); + // A pool has nothing to rank until at least one bout is decided: every // competitor is tied at 0 and the backend rank is merely the seed/draw // fallback. Suppress rank badges + the "advancing" qualification @@ -865,7 +902,9 @@ export function PoolsViewer({ pools, standings, poolMatches, tweaks, competition const lookup = rankByPlayerKey.get(p.id || `${p.name}||${p.dojo || ""}`); const s = lookup ? lookup.standing : null; const rank = lookup ? lookup.rank : null; - const isAdvancing = !isLeague && poolHasResults && rank !== null && rank <= poolQualifiers; + const tie = tiedByRow.get(p.id || `${p.name}||${p.dojo || ""}`); + const isAdvancing = !isLeague && poolHasResults && rank !== null && rank <= poolQualifiers + && !(tie && tie.decidesAdvancement); const rowClasses = [ isPlayerWatched(p, highlightPlayers) ? "pool__row--me" : "", @@ -886,8 +925,11 @@ export function PoolsViewer({ pools, standings, poolMatches, tweaks, competition )} {poolHasResults && rank !== null ? ( - - {rankOrdinal(rank)}{s && s.isOverridden ? "*" : ""} + + {tie ? `=${rankOrdinal(tie.place)}` : rankOrdinal(rank)}{s && s.isOverridden ? "*" : ""} ) : null} diff --git a/web-mobile/js/write_result.jsx b/web-mobile/js/write_result.jsx index 5644ed683..a497464e2 100644 --- a/web-mobile/js/write_result.jsx +++ b/web-mobile/js/write_result.jsx @@ -305,6 +305,12 @@ export function writeWasSuperseded(res) { // - applied:false: superseded or clock_skew (writeWasSuperseded). // A QUEUED write is not refused: it lands on reconnect. // +// Callers that must tell the two refusals apart ask writeWasRefusedForClock first: +// landedAutosave (admin_scoring_autosave.jsx) treats a clock_skew answer as neither +// landed nor refused, because recordScore keeps a running one silent and leaves the +// tap on the board for the next save to carry with a corrected stamp, while a +// superseded one is final and the editor takes the server's copy. +// // The editors ask it to DISARM a two-tap commit (Finish, Finish + Start Next, // End match, the engi Save) whose write was refused: left armed, one more tap // re-sent the very write just refused, and for a superseded one that tap