diff --git a/.gitignore b/.gitignore index 4b15e01cf..ae4378039 100644 --- a/.gitignore +++ b/.gitignore @@ -65,6 +65,11 @@ Thumbs.db !web-mobile/.oxlintrc.json !web/package.json !web/package-lock.json +# Go testdata fixtures. These are test INPUTS, not build output: the blanket +# *.json above would silently drop them, and a golden table that never lands +# fails CI on a checkout that works locally. /**/ matches zero or more +# directories, so nested layouts (testdata/golden/...) are covered too. +!**/testdata/**/*.json # Fixture data for scripts/setup_tournament.py (make mobile-app-example); # without this the demo target fails with FileNotFoundError on any checkout # other than the one that happens to have the file untracked on disk. @@ -76,6 +81,9 @@ node_modules/ # rule above would not match. web-mobile/node_modules specs/* +# Re-include the spec directories themselves so git descends into them; a +# directory excluded outright can never have its contents re-included below. +!specs/*/ specs/*/* !specs/openapi.yaml # UAT screenshot evidence (review.md V5): per-task PNGs captured during browser @@ -85,6 +93,12 @@ specs/*/* .specify .agent* .playwright-mcp +# The Playwright MCP writes browser_take_screenshot files to the repo/worktree +# root, where `git add -A` would sweep them into a commit. PR screenshots belong +# on the pr-assets branch, never in the tree. Root-anchored on purpose: a blanket +# *.png would silently swallow new docs/screenshots/, docs/assets/images/ and +# specs/*/screenshots/ files, which are tracked. +/*.png .claude/skills/speckit-* .claude/worktrees/* diff --git a/CLAUDE.md b/CLAUDE.md index b17c73da9..e3692fab5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -119,7 +119,7 @@ tournament-data/ - **Excel Layout**: Uses an **8-column per court** layout. Columns A and G (and their court-shifted counterparts) are 30 units wide; others are 5 units wide. A blank row separates pools vertically. - **Team Match Labels**: Summaries use **"IV"** (Individual Victories) and **"PW"** (Points Won). - **Court limit**: courts are labelled A–Z, so `--courts` is hard-capped at 26 and any value over that returns an error rather than silently truncating. -- **Match Decision Types** (`internal/domain/decision.go`): 10 canonical wire values: `""` (none), `"fought"`, `"hikiwake"` (draw), `"kiken"` (legacy withdrawal, maps to kiken-voluntary on YAML load), `"kiken-voluntary"` (FIK Art. 31, permanent), `"kiken-injury"` (FIK Art. 30, reinstateable), `"fusenpai"` (no-show), `"fusensho"` (per-bout default win), `"daihyosen"` (rep bout), `"kachinuki-exhaustion"`. Use `domain.IsKikenDecision(d)` / `domain.IsKikenDecisionStr(s)` to check any kiken variant. Legacy YAML `decision: true` migrates to `"hikiwake"`, `false` to `"fought"`, `"kiken"` to `"kiken-voluntary"` (Decision.UnmarshalYAML). Visual suffixes in the UI: `Kiken`, `Fus.`, `DH`, `(E)` for encho. +- **Match Decision Types** (`internal/domain/decision.go`): 10 canonical wire values: `""` (none), `"fought"`, `"hikiwake"` (draw), `"kiken"` (legacy withdrawal, maps to kiken-voluntary on YAML load), `"kiken-voluntary"` (FIK Art. 31, permanent), `"kiken-injury"` (FIK Art. 30, reinstateable), `"fusenpai"` (no-show), `"fusensho"` (per-bout default win), `"daihyosen"` (rep bout), `"kachinuki-exhaustion"`. Use `domain.IsKikenDecision(d)` / `domain.IsKikenDecisionStr(s)` to check any kiken variant. Legacy YAML `decision: true` migrates to `"hikiwake"`, `false` to `"fought"`, `"kiken"` to `"kiken-voluntary"` (Decision.UnmarshalYAML). Visual marks follow the score sheet's layout, split into two kinds. MIDDLE marks (the centre "vs" cell / score-string centre) — exactly one of `X` (tie), `(E)` (overtime, always bare: `periodCount` is recorded but never displayed), `(DH)` (rep bout); mutually exclusive by rule (a match that went to encho cannot end tied, so X beats (E); a daihyosen is one-point sudden death, so DH bouts have no encho). RESULT marks ride beside the competitor they name: `Ht` (hantei winner), `Kiken` (withdrawer), `Fus.` (no-show; the Excel export also marks the fusensho winner — the JS uses a bout badge instead). Web score strings read `[left cell] [middle] [right cell]` with `vs` as the plain middle and `–` (or empty) meaning no points — never digits, ippon renders as waza letters only: `M vs –`, `M (E) K`, `M Ht (E) K`. Mirrored helpers: `middleMark`/`sideMarks` (bracket.jsx) ↔ `MiddleMark`/`SideMarks` (internal/export/suffix.go); `enchoLabel` pinned in both languages via `internal/export/testdata/encho_labels.json`. - **Competitor Eligibility** (`internal/state/competitor_status.go`, `internal/engine/eligibility.go`): a kiken/fusenpai decision auto-writes a `CompetitorStatus{Eligible: false}` for the loser; `engine.StartMatch(compID, matchID)` is the pre-flight gate that returns `*IneligibleCompetitorError` (matches `errors.Is(err, ErrIneligibleCompetitor)`). Maps to HTTP 409. Kiken-injury (FIK Art. 30) sets `CompetitorStatus.Reinstateable: true`; the admin can call `POST /api/competitions/:cid/competitors/:pid/reinstate` to restore eligibility. Kiken-voluntary (Art. 31) and fusenpai are not reinstateable. - **Team Lineups & Kachinuki** (`internal/domain/team_lineup.go`, `internal/engine/kachinuki.go`): TeamLineup pins position→player for a round. FIK 5-person rule: Senpo + Taisho mandatory; 1 vacancy must be Jiho, 2 must be Jiho+Fukusho, 3+ disqualifies. Kachinuki ("winner-stays-on") dynamically appends bouts via `engine.AdvanceKachinuki` until one team is exhausted (`DecisionKachinukiExhaustion`). - **Schedule Estimator** (`internal/engine/schedule.go`): `EstimateSchedule(EstimateInput) ScheduleEstimate` produces total/per-court minutes from match duration × multiplier × slowest-court buffer. Exposed via stateless `GET /api/schedule/estimate` on both the CLI web server and the mobile app. diff --git a/docs/screenshots/mobile-encho-overtime.png b/docs/screenshots/mobile-encho-overtime.png new file mode 100644 index 000000000..166c2971d Binary files /dev/null and b/docs/screenshots/mobile-encho-overtime.png differ diff --git a/docs/screenshots/mobile-pool-standings.png b/docs/screenshots/mobile-pool-standings.png index d87426dc4..6293346e1 100644 Binary files a/docs/screenshots/mobile-pool-standings.png and b/docs/screenshots/mobile-pool-standings.png differ diff --git a/docs/screenshots/viewer-competition.png b/docs/screenshots/viewer-competition.png index 7d93b0440..1ebd277c6 100644 Binary files a/docs/screenshots/viewer-competition.png and b/docs/screenshots/viewer-competition.png differ diff --git a/docs/screenshots/viewer-result-encho.png b/docs/screenshots/viewer-result-encho.png new file mode 100644 index 000000000..8bbc85cab Binary files /dev/null and b/docs/screenshots/viewer-result-encho.png differ diff --git a/docs/user-guide/court-operators/recording-decisions.md b/docs/user-guide/court-operators/recording-decisions.md index 34295571e..4c3b92c7e 100644 --- a/docs/user-guide/court-operators/recording-decisions.md +++ b/docs/user-guide/court-operators/recording-decisions.md @@ -26,6 +26,20 @@ An injury kiken can be reinstated later by the operator if the competitor recove A hikiwake is a draw. It applies in pool, league, and Swiss matches (not in the knockout phase) and contributes to the standings separately from wins and losses. +## Encho (overtime) + +Encho is the extra period played when a knockout match is level at the end of regulation. It follows ippon-shobu rules: the first competitor to score wins. + +In the score editor, open the **Overtime** control and tick **Encho started**. A counter appears so you can record how many overtime periods were fought, using the **+** and **-** buttons. The counter starts at 1 and has no upper limit: how many periods are fought, and how a match still level after them is finally settled (a judges' decision for an individual bout, a daihyosen for a team encounter), is the shimpan's call. Record what actually happened on court, however many periods that took. + + + +Completed results follow the paper score sheet's layout. The centre, between the two sides' points, only ever carries one mark: **vs** when no special mark applies, **X** for a tie, **(E)** for a match that went to overtime, or **(DH)** for a team encounter sent to a representative bout. The marks never combine: a match that went to encho cannot end in a tie, and a daihyosen has no overtime. A side with no points shows a plain dash. A match won by men in regulation reads **M vs -** and the same win in overtime reads **M (E) -** on the court console and the public viewer; the exported results workbook carries the same **(E)** marker in its centre column, with a no-points cell left blank rather than dashed. The marker is the same however many overtime periods were fought, because the counter records the number for the tournament log but results never show it. + +Everything else is a result, written beside the competitor it names: **Ht** next to the winner of a judges' decision, **Kiken** next to the competitor who withdrew, **Fus.** next to a no-show. A match decided by hantei after a tied overtime reads **M Ht (E) K**, with the winner's mark on the winner's side of the centre. A team encounter shows its team totals in the score cell instead, so for those the marks appear on the bracket and in the exported workbook. + + + ## Daihyosen A daihyosen is a representative bout used to break a tie when points and ranking criteria cannot separate two teams. @@ -37,7 +51,7 @@ It applies in two situations: A tie that does not affect advancement is left as a shared rank with no extra bout. In league play, running the daihyosen is the operator's choice rather than an automatic step; see [Team standings and tie-breaks](../organisers/team-tournaments.md#team-standings-and-tie-breaks) for both options. -The bout is a single-point ippon-shobu with no time limit, between one representative from each tied team. The score editor lets you pick each team's representative from its roster. On the court console, the bout appears with a **DH** tag. In pool and league standings, the team that won its daihyosen carries a **DH** badge. +The bout is a single-point ippon-shobu with no time limit, between one representative from each tied team; because it runs until someone scores, a daihyosen has no encho. The score editor lets you pick each team's representative from its roster. On the court console, the bout appears with a **(DH)** mark in the centre of the score. In pool and league standings, the team that won its daihyosen carries a **DH** badge. ## Chusen (drawing lots) diff --git a/internal/export/builder.go b/internal/export/builder.go index 5e1ae3cdd..732691b94 100644 --- a/internal/export/builder.go +++ b/internal/export/builder.go @@ -335,11 +335,9 @@ func overlayPoolScores(f *excelize.File, pools []helper.Pool, resultByID map[str // startCol+1 = left victories (Red/SideA), startCol+3 = middle/vs, // startCol+5 = right victories (White/SideB). With mirror=true the sides // swap physically (White left, Red right); leftIppons/rightIppons below - // are swapped to match, so lVCol/rVCol always align with the header. + // are swapped to match, so the victory columns always align with the + // header. writeScoreRowCells derives the columns from courtStartCol. courtStartCol := 1 + c*helper.CourtsColumnsPerCourt - lVCol := colNum(courtStartCol + 1) - middleCol := colNum(courtStartCol + 3) - rVCol := colNum(courtStartCol + 5) ords := poolOrdinals[pool.PoolName] for i := range pool.Matches { @@ -364,9 +362,6 @@ func overlayPoolScores(f *excelize.File, pools []helper.Pool, resultByID map[str leftIppons, rightIppons = mr.IpponsB, mr.IpponsA } - hantei := mr.DecidedByHantei != nil && *mr.DecidedByHantei - sfx := DecisionSuffix(mr.Decision, mr.Encho, hantei) - var leftScore, rightScore string if engi { leftScore, rightScore = mirroredFlagsScore(mr.FlagsA, mr.FlagsB, mirror) @@ -374,11 +369,7 @@ func overlayPoolScores(f *excelize.File, pools []helper.Pool, resultByID map[str leftScore = IpponsScore(leftIppons) rightScore = IpponsScore(rightIppons) } - setCellStr(f, sheetName, lVCol, excelRow, leftScore) - setCellStr(f, sheetName, rVCol, excelRow, rightScore) - if mid := MiddleCellText(mr.Decision, sfx); mid != "" { - setCellStr(f, sheetName, middleCol, excelRow, mid) - } + writeScoreRowCells(f, sheetName, courtStartCol, excelRow, leftScore, rightScore, mr, mirror) } } } @@ -492,8 +483,10 @@ func buildCourtMatchJobs(pools []helper.Pool, numCourts int, poolOrdinals map[st // rVCol (startCol+5) = right IV, rPCol (startCol+4) = right PW // // SideA is Red (left by default), SideB is Shiro (right); mirror swaps sides. -// The middle "vs" cell carries the encounter's decision suffix (DH etc.) or the -// hikiwake "X" marker when the team encounter is a draw. +// The middle "vs" cell carries only the encounter's single middle mark +// (X for a drawn encounter, (DH) when it went to a representative bout); +// encounter-level result marks (Kiken/Fus./Ht) ride in the competitor's IV +// cell, next to their victory count. func writeTeamSummaryCells(f *excelize.File, sheetName string, courtStartCol, excelRow int, mr state.MatchResult, mirror bool) { lVCol := colNum(courtStartCol + 1) lPCol := colNum(courtStartCol + 2) @@ -501,6 +494,9 @@ func writeTeamSummaryCells(f *excelize.File, sheetName string, courtStartCol, ex rPCol := colNum(courtStartCol + 4) rVCol := colNum(courtStartCol + 5) + hantei := mr.DecidedByHantei != nil && *mr.DecidedByHantei + lMark, rMark := SideMarksLR(mr.Decision, hantei, mr.Winner, mr.SideA, mr.SideB, mirror) + line := state.TeamResultFrom(mr.SubResults, mr.SideA, mr.SideB) if line != nil { // SideA = Aka, SideB = Shiro. Left is Aka unless mirror. @@ -509,26 +505,72 @@ func writeTeamSummaryCells(f *excelize.File, sheetName string, courtStartCol, ex if mirror { leftIV, leftPW, rightIV, rightPW = rightIV, rightPW, leftIV, leftPW } - setIntCellDirect(f, sheetName, lVCol, excelRow, leftIV) + setIVCellWithMark(f, sheetName, lVCol, excelRow, leftIV, lMark) setIntCellDirect(f, sheetName, lPCol, excelRow, leftPW) - setIntCellDirect(f, sheetName, rVCol, excelRow, rightIV) + setIVCellWithMark(f, sheetName, rVCol, excelRow, rightIV, rMark) setIntCellDirect(f, sheetName, rPCol, excelRow, rightPW) + } else { + // No summary line (e.g. a forfeit before any bout was fought): + // the result marks still need a home in the competitor's cell. + if lMark != "" { + setCellStr(f, sheetName, lVCol, excelRow, lMark) + } + if rMark != "" { + setCellStr(f, sheetName, rVCol, excelRow, rMark) + } } - hantei := mr.DecidedByHantei != nil && *mr.DecidedByHantei - sfx := DecisionSuffix(mr.Decision, mr.Encho, hantei) - if mid := MiddleCellText(mr.Decision, sfx); mid != "" { + if mid := MiddleMark(mr.Decision, mr.Encho); mid != "" { setCellStr(f, sheetName, middleCol, excelRow, mid) } } +// writeScoreRowCells writes an individual match's score row: each side's +// score joined with its result mark (Kiken/Fus./Ht ride in the competitor's +// cell), and the single middle mark — the middle is left untouched when no +// mark applies so the template's own "vs" survives. Shared by the pool and +// bracket overlays so the cell contract lives in one place. +func writeScoreRowCells(f *excelize.File, sheetName string, courtStartCol, excelRow int, leftScore, rightScore string, mr state.MatchResult, mirror bool) { + hantei := mr.DecidedByHantei != nil && *mr.DecidedByHantei + lMark, rMark := SideMarksLR(mr.Decision, hantei, mr.Winner, mr.SideA, mr.SideB, mirror) + setCellStr(f, sheetName, colNum(courtStartCol+1), excelRow, joinSp(leftScore, lMark)) + setCellStr(f, sheetName, colNum(courtStartCol+5), excelRow, joinSp(rightScore, rMark)) + if mid := MiddleMark(mr.Decision, mr.Encho); mid != "" { + setCellStr(f, sheetName, colNum(courtStartCol+3), excelRow, mid) + } +} + +// bracketMatchResultView adapts a BracketMatch to the MatchResult shape the +// shared row writers consume (they read only the result fields). +func bracketMatchResultView(bm *state.BracketMatch) state.MatchResult { + return state.MatchResult{ + SideA: bm.SideA, + SideB: bm.SideB, + Winner: bm.Winner, + Decision: bm.Decision, + Encho: bm.Encho, + DecidedByHantei: &bm.DecidedByHantei, + SubResults: bm.SubResults, + } +} + +// setIVCellWithMark writes a team IV count, appending a result mark +// ("2 Kiken") when one applies to that side; a markless cell stays numeric. +func setIVCellWithMark(f *excelize.File, sheetName, col string, row, iv int, mark string) { + if mark == "" { + setIntCellDirect(f, sheetName, col, row, iv) + return + } + setCellStr(f, sheetName, col, row, joinSp(fmt.Sprintf("%d", iv), mark)) +} + // writeTeamSubMatchScores writes each sub-bout's ippon letters onto the team // sub-match rows. Left ippons -> lVCol (startCol+1), right -> rVCol (startCol+5), // middle "vs" -> tie marker / suffix. subResults are keyed by Position (1-based); // the daihyosen placeholder (Position < 0) is skipped so its blank row stays clean. // teamSize bounds the number of sub-match rows the grid actually has; a Position // outside [1, teamSize] (corrupted state) is skipped rather than writing into the -// next encounter's cells (mirrors the guard in overlayTeamBracketScores). +// next encounter's cells. Shared by the pool sheet and overlayTeamBracketScores. func writeTeamSubMatchScores(f *excelize.File, sheetName string, courtStartCol, subStartExcelRow int, subResults []state.SubMatchResult, teamSize int, mirror bool) { lVCol := colNum(courtStartCol + 1) middleCol := colNum(courtStartCol + 3) @@ -545,18 +587,15 @@ func writeTeamSubMatchScores(f *excelize.File, sheetName string, courtStartCol, if mirror { leftIppons, rightIppons = sub.IpponsB, sub.IpponsA } - lScore := IpponsScore(leftIppons) - rScore := IpponsScore(rightIppons) - if lScore != "" { + lMark, rMark := SideMarksLR(sub.Decision, sub.DecidedByHantei, sub.Winner, sub.SideA, sub.SideB, mirror) + if lScore := joinSp(IpponsScore(leftIppons), lMark); lScore != "" { setCellStr(f, sheetName, lVCol, excelRow, lScore) } - if rScore != "" { + if rScore := joinSp(IpponsScore(rightIppons), rMark); rScore != "" { setCellStr(f, sheetName, rVCol, excelRow, rScore) } - hantei := sub.DecidedByHantei - sfx := DecisionSuffix(sub.Decision, sub.Encho, hantei) - if mid := MiddleCellText(sub.Decision, sfx); mid != "" { + if mid := MiddleMark(sub.Decision, sub.Encho); mid != "" { setCellStr(f, sheetName, middleCol, excelRow, mid) } } @@ -854,10 +893,6 @@ func overlayBracketScores(f *excelize.File, bracketByNum map[int]state.BracketMa } } - lVCol := colNum(courtStartCol + 1) - middleCol := colNum(courtStartCol + 3) - rVCol := colNum(courtStartCol + 5) - // For engi, the bracket stores flag counts in FlagsA/FlagsB; // ScoreA/ScoreB hold ippon letters that do not apply. Render the // flag count via mirroredFlagsScore instead, matching @@ -873,14 +908,7 @@ func overlayBracketScores(f *excelize.File, bracketByNum map[int]state.BracketMa } } - sfx := DecisionSuffix(bm.Decision, bm.Encho, bm.DecidedByHantei) - - setCellStr(f, sheetName, lVCol, excelRow, leftScore) - setCellStr(f, sheetName, rVCol, excelRow, rightScore) - - if mid := MiddleCellText(bm.Decision, sfx); mid != "" { - setCellStr(f, sheetName, middleCol, excelRow, mid) - } + writeScoreRowCells(f, sheetName, courtStartCol, excelRow, leftScore, rightScore, bracketMatchResultView(&bm), mirror) if bm.Winner != "" { writeWinnerCell(f, sheetName, rows, scoreRowIdx, headerCol, bm.Winner) @@ -922,13 +950,7 @@ func overlayTeamBracketScores(f *excelize.File, bracketByNum map[int]state.Brack } courtStartCol := headerCol + 1 // 1-based - lVCol := colNum(courtStartCol + 1) - lPCol := colNum(courtStartCol + 2) - middleCol := colNum(courtStartCol + 3) - rPCol := colNum(courtStartCol + 4) - rVCol := colNum(courtStartCol + 5) - - headerExcelRow := rowIdx + 1 // H (1-based) + headerExcelRow := rowIdx + 1 // H (1-based) // For the 3rd Place block write entrant names unconditionally so they // appear even when the bronze match is not yet played. Sub-match rows, @@ -940,46 +962,15 @@ func overlayTeamBracketScores(f *excelize.File, bracketByNum map[int]state.Brack } } - // Sub-match ippon letters: Position p sits at H+2+p. - for _, sub := range bm.SubResults { - if sub.Position <= 0 || sub.Position > teamSize { - continue // daihyosen placeholder / out-of-range - } - excelRow := headerExcelRow + 2 + sub.Position - leftIppons, rightIppons := sub.IpponsA, sub.IpponsB - if mirror { - leftIppons, rightIppons = sub.IpponsB, sub.IpponsA - } - if s := IpponsScore(leftIppons); s != "" { - setCellStr(f, sheetName, lVCol, excelRow, s) - } - if s := IpponsScore(rightIppons); s != "" { - setCellStr(f, sheetName, rVCol, excelRow, s) - } - subSfx := DecisionSuffix(sub.Decision, sub.Encho, sub.DecidedByHantei) - if mid := MiddleCellText(sub.Decision, subSfx); mid != "" { - setCellStr(f, sheetName, middleCol, excelRow, mid) - } - } + // Sub-match ippon letters: Position p sits at H+2+p, i.e. the sub + // rows start at H+3. Same writer as the pool sheet. + writeTeamSubMatchScores(f, sheetName, courtStartCol, headerExcelRow+3, bm.SubResults, teamSize, mirror) - // IV/PW summary row = H + 5 + teamSize. + // IV/PW summary row = H + 5 + teamSize. Route through the shared + // pool-sheet writer so the IV-mark contract (and the forfeit + // fallback when no summary line exists) lives in one place. summaryExcelRow := headerExcelRow + 5 + teamSize - if line := state.TeamResultFrom(bm.SubResults, bm.SideA, bm.SideB); line != nil { - leftIV, leftPW := line.AkaIV, line.AkaPW - rightIV, rightPW := line.ShiroIV, line.ShiroPW - if mirror { - leftIV, leftPW, rightIV, rightPW = rightIV, rightPW, leftIV, leftPW - } - setIntCellDirect(f, sheetName, lVCol, summaryExcelRow, leftIV) - setIntCellDirect(f, sheetName, lPCol, summaryExcelRow, leftPW) - setIntCellDirect(f, sheetName, rVCol, summaryExcelRow, rightIV) - setIntCellDirect(f, sheetName, rPCol, summaryExcelRow, rightPW) - } - - sfx := DecisionSuffix(bm.Decision, bm.Encho, bm.DecidedByHantei) - if mid := MiddleCellText(bm.Decision, sfx); mid != "" { - setCellStr(f, sheetName, middleCol, summaryExcelRow, mid) - } + writeTeamSummaryCells(f, sheetName, courtStartCol, summaryExcelRow, bracketMatchResultView(&bm), mirror) // Winner marker: the "1." row is 3 rows below the summary row; reuse the // individual writer, which scans forward for the "1." ordinal. diff --git a/internal/export/builder_test.go b/internal/export/builder_test.go index 9848ff72a..1268e5028 100644 --- a/internal/export/builder_test.go +++ b/internal/export/builder_test.go @@ -234,7 +234,7 @@ func TestBuildResultsWorkbook_StandingsLiteral_NoFormulaCollapse(t *testing.T) { assert.True(t, winsFound, "W (wins) column must contain literal '1' for Alice after a win, not a collapsed formula '0'") } -func TestBuildResultsWorkbook_DecisionSuffixInSheet(t *testing.T) { +func TestBuildResultsWorkbook_ResultMarksInScoreCells(t *testing.T) { t.Parallel() dir, store, eng, compID := testSetup(t) defer os.RemoveAll(dir) @@ -272,20 +272,13 @@ func TestBuildResultsWorkbook_DecisionSuffixInSheet(t *testing.T) { rows, err := f.GetRows(helper.SheetPoolMatches) require.NoError(t, err) - // The vs/middle cell should contain "Kiken (E) Ht". - foundSuffix := false - for _, row := range rows { - for _, cell := range row { - if cell == "Kiken (E) Ht" { - foundSuffix = true - break - } - } - if foundSuffix { - break - } - } - assert.True(t, foundSuffix, "pool matches sheet must contain 'Kiken (E) Ht' suffix") + // The middle carries only its one mark ("(E)"); the result marks ride in + // the competitors' score cells: Alice (winner, scored M, hantei) "M Ht", + // Bob (withdrew) "Kiken". + assert.True(t, sheetContainsCell(rows, "(E)"), "the vs cell must carry the (E) middle mark") + assert.True(t, sheetContainsCell(rows, "M Ht"), "the winner's cell must carry her score plus the Ht mark") + assert.True(t, sheetContainsCell(rows, "Kiken"), "the withdrawing side's cell must carry the Kiken mark") + assert.False(t, sheetContainsCell(rows, "Kiken (E) Ht"), "the old composed middle suffix must be gone") } func TestBuildResultsWorkbook_BracketScores(t *testing.T) { @@ -405,12 +398,13 @@ func TestBuildResultsWorkbook_DrawMatch(t *testing.T) { assert.True(t, foundX, "pool matches sheet must contain 'X' for a hikiwake (draw) with no ippons") } -// TestBuildResultsWorkbook_DrawWithSuffixKeepsMarker is the regression test for -// the draw-marker overwrite bug: a hikiwake that also went to encho (or was -// hantei-decided) must export the COMBINED "X (E)" / "X Ht" in the vs column, not -// just the suffix. Previously the code wrote "X" then overwrote it with the -// suffix, silently dropping the draw indicator from the archived workbook. -func TestBuildResultsWorkbook_DrawWithSuffixKeepsMarker(t *testing.T) { +// TestBuildResultsWorkbook_DrawExcludesEnchoMark pins the draw-vs-encho +// exclusivity rule: X means a tie and a match that went to encho cannot end +// tied, so stale data carrying both exports the X ALONE — the middle column +// holds exactly one mark and the tie wins. (This test previously guarded the +// opposite, a combined "X (E)" cell, from the era when the middle carried a +// composed suffix.) +func TestBuildResultsWorkbook_DrawExcludesEnchoMark(t *testing.T) { t.Parallel() dir, store, eng, compID := testSetup(t) defer os.RemoveAll(dir) @@ -441,10 +435,12 @@ func TestBuildResultsWorkbook_DrawWithSuffixKeepsMarker(t *testing.T) { rows, err := f.GetRows(helper.SheetPoolMatches) require.NoError(t, err) - assert.True(t, sheetContainsCell(rows, "X (E)"), - "a hikiwake-in-encho must export the combined 'X (E)' marker, not just '(E)'") + assert.True(t, sheetContainsCell(rows, "X"), + "a draw with stale encho data must export the X marker") assert.False(t, sheetContainsCell(rows, "(E)"), - "the bare '(E)' cell must not appear: it means the draw marker was dropped") + "a tie can never carry the overtime marker: X wins, (E) is dropped") + assert.False(t, sheetContainsCell(rows, "X (E)"), + "the old combined draw+encho cell must be gone") } // ------------------------------------------------------------ @@ -1903,7 +1899,7 @@ func TestBuildResultsWorkbook_TeamResults(t *testing.T) { require.Greater(t, len(elimRows[vp]), vpCol+1) assert.Equal(t, "1", elimRows[vp][vpCol+1], "Red A bracket IV must be literal 1 on the summary row") - assert.True(t, sheetContainsCell(elimRows, "DH"), "daihyosen suffix 'DH' must appear on the team encounter") + assert.True(t, sheetContainsCell(elimRows, "(DH)"), "the '(DH)' middle mark must appear on the team encounter") assert.True(t, sheetContainsCell(elimRows, "Red A"), "winner 'Red A' must be written as a literal") } @@ -2595,11 +2591,13 @@ func TestBuildResultsWorkbook_NonEngiStandingsHeadersUnchanged(t *testing.T) { // TDD-5: Engi special-case characterization tests // ------------------------------------------------------------ -// TestBuildResultsWorkbook_EngiDecisionSuffix characterizes the vs-cell text for -// a kiken-voluntary engi match: the middle cell must carry "Kiken" and both -// adjacent score cells (at column offsets -2 and +2 from the vs cell) must be -// blank because FlagsScorePair returns ("", "") when neither side scored flags. -func TestBuildResultsWorkbook_EngiDecisionSuffix(t *testing.T) { +// TestBuildResultsWorkbook_EngiKikenMarkPlacement characterizes the result-mark +// placement for a kiken-voluntary engi match: "Kiken" rides in the WITHDRAWING +// pair's score cell (the loser, SideB, right of the vs column), the winner's +// score cell stays blank (FlagsScorePair returns ("", "") when neither side +// scored flags), and the middle cell keeps its template "vs" (kiken is a +// result, not a middle mark). +func TestBuildResultsWorkbook_EngiKikenMarkPlacement(t *testing.T) { t.Parallel() dir, store, eng, compID := testSetup(t) defer os.RemoveAll(dir) @@ -2637,26 +2635,24 @@ func TestBuildResultsWorkbook_EngiDecisionSuffix(t *testing.T) { rows, err := f.GetRows(helper.SheetPoolMatches) require.NoError(t, err) - // The vs/middle cell must carry "Kiken" for a kiken-voluntary engi match. + // The withdrawing pair's score cell must carry "Kiken". assert.True(t, sheetContainsCell(rows, "Kiken"), - "Pool Matches vs-cell must render 'Kiken' for a kiken-voluntary engi match") + "the withdrawing pair's score cell must render 'Kiken' for a kiken-voluntary engi match") - // Score cells flanking the vs column must be blank: FlagsScorePair returns ("", "") for a no-flag decision. - // The vs cell sits at column offset 3 from the court band start; score cells - // are at offsets 1 (left) and 5 (right), so -2 and +2 from the vs index. + // Geometry: the loser (SideB) sits RIGHT of the vs column, so from the + // Kiken cell the middle is 2 columns left (template "vs") and the + // winner's score cell 4 columns left (blank: no flags were scored). for _, row := range rows { for j, cell := range row { if cell != "Kiken" { continue } - require.GreaterOrEqual(t, j, 2, - "Kiken vs-cell must not appear in the first two columns") - assert.Equal(t, "", row[j-2], - "left score cell (col offset -2 from vs) must be blank for kiken with FlagsA=0") - if j+2 < len(row) { - assert.Equal(t, "", row[j+2], - "right score cell (col offset +2 from vs) must be blank for kiken with FlagsB=0") - } + require.GreaterOrEqual(t, j, 4, + "Kiken must sit in the right-hand score cell, not the first columns") + assert.Contains(t, []string{"", "vs"}, row[j-2], + "the middle cell must stay untouched (template text): kiken is a result, not a middle mark") + assert.Equal(t, "", row[j-4], + "the winner's score cell must be blank for kiken with FlagsA=0") } } } diff --git a/internal/export/suffix.go b/internal/export/suffix.go index cfd6105f0..9683438dd 100644 --- a/internal/export/suffix.go +++ b/internal/export/suffix.go @@ -14,77 +14,117 @@ import ( "github.com/gitrgoliveira/bracket-creator/internal/state" ) -// DecisionSuffix returns the display suffix for a match decision, encho, and -// hantei flag. It follows the canonical JS decisionSuffix() in -// web-mobile/js/bracket.jsx, including the "Ht" suffix mandated by the "Excel + -// viewer parity" comment there (FIK 7-5 / 29-6). +// MiddleMark returns the ONE mark the centre "vs" cell may carry for a +// completed match. The middle column of a score sheet can only ever read: // -// Composition order: -// 1. Base decision label: kiken variants -> "Kiken"; fusenpai/fusensho -> "Fus."; daihyosen -> "DH". -// 2. If enchoOn -> append " (E)". -// 3. If hanteiOn -> append " Ht". +// vs not yet decided (the template's own text; we return "" and leave it) +// X a tie (hikiwake) +// (E) the match went to overtime +// (DH) a team encounter sent to a representative bout // -// DELIBERATE DIVERGENCE from the JS: the JS omits fusensho (the per-bout default -// WIN) here because the viewer surfaces it via a separate bout badge. A flat -// spreadsheet cell has no such badge, so this export folds fusensho into the -// suffix ("Fus.") too, preserving the defaulted-bout signal in the archive -// rather than dropping it. +// The marks are mutually exclusive by rule, not by accident: X means a tie +// and a match that went to encho cannot end tied (encho runs until someone +// scores), so X beats (E) if stale data carries both; and a daihyosen bout is +// one-point sudden death, so DH bouts do not have encho and (DH) beats (E). // -// A zero/nil Encho (or PeriodCount == 0) is treated as no encho. -// Returns "" when no suffix applies. -func DecisionSuffix(decision string, encho *state.EnchoMetadata, decidedByHantei bool) string { - enchoOn := encho != nil && encho.PeriodCount > 0 - - var suffix string +// Everything else — Kiken, Fus., Ht — is a RESULT, not a middle mark, and +// belongs beside the competitor it names: see SideMarks. Mirrors +// middleMark()/formatIpponsScore in web-mobile/js/bracket.jsx. +func MiddleMark(decision string, encho *state.EnchoMetadata) string { switch { - case domain.IsKikenDecisionStr(decision): - suffix = "Kiken" - case decision == string(domain.DecisionFusenpai), decision == string(domain.DecisionFusensho): - suffix = "Fus." + case state.IsDraw(decision): + return "X" case decision == string(domain.DecisionDaihyosen): - suffix = "DH" + return "(DH)" + default: + return enchoLabel(encho) } +} - if enchoOn { - if suffix != "" { - suffix += " (E)" - } else { - suffix = "(E)" - } +// SideMarks returns the per-side result marks for a decision: winnerMark goes +// in the winning side's score cell, loserMark in the losing side's. +// +// hantei -> winner "Ht" (FIK 7-5 / 29-6: judges picked the winner) +// kiken -> loser "Kiken" (the mark names the competitor who withdrew) +// fusenpai -> loser "Fus." (the mark names the no-show) +// fusensho -> winner "Fus." (the default WIN names the present side) +// +// The JS viewer surfaces fusensho via a separate bout badge, so its +// sideMarks() omits it; a flat spreadsheet cell has no badge, so this export +// keeps the "Fus." mark (deliberate divergence, mirrored in the JS docstring). +func SideMarks(decision string, decidedByHantei bool) (winnerMark, loserMark string) { + switch { + case domain.IsKikenDecisionStr(decision): + loserMark = "Kiken" + case decision == string(domain.DecisionFusenpai): + loserMark = "Fus." + case decision == string(domain.DecisionFusensho): + winnerMark = "Fus." } - if decidedByHantei { - if suffix != "" { - suffix += " Ht" - } else { - suffix = "Ht" - } + winnerMark = joinSp(winnerMark, "Ht") } - - return suffix + return winnerMark, loserMark } -// MiddleCellText composes the value for a match's centre "vs" cell from the -// hikiwake draw marker and the decision suffix. When a match is a draw AND also -// carries a suffix (a scoreless encho draw -> "X (E)", a hantei-decided draw -> -// "X Ht", a team encounter drawn into a daihyosen -> "X DH"), BOTH are kept so -// the exported workbook never loses the draw indicator. This mirrors -// formatIpponsScore in web-mobile/js/bracket.jsx, which renders "X" + suffix for -// a scoreless draw. Returns "" when neither applies, so the caller can leave the -// cell untouched rather than blanking a formula. -func MiddleCellText(decision, suffix string) string { - marker := "" - if decision == state.DecisionDraw { - marker = "X" +// SideMarksLR resolves SideMarks into (left, right) on-sheet order for a +// match between sideA and sideB. Default layout is SideA (Aka) on the left; +// mirror swaps the sides physically, matching the leftIppons/rightIppons +// swaps at the call sites. A missing or unmatchable winner (a draw, an +// unfinished match, or drifted data) yields no marks: result marks hang off +// a winner by definition. +func SideMarksLR(decision string, decidedByHantei bool, winner, sideA, sideB string, mirror bool) (left, right string) { + winnerMark, loserMark := SideMarks(decision, decidedByHantei) + if winner == "" { + return "", "" // an empty winner must not string-match an empty side } + var aMark, bMark string + switch winner { + case sideA: + aMark, bMark = winnerMark, loserMark + case sideB: + aMark, bMark = loserMark, winnerMark + default: + return "", "" + } + if mirror { + return bMark, aMark + } + return aMark, bMark +} + +// joinSp joins two display fragments with a single space, skipping empties, so +// a composed suffix never carries a leading, trailing, or doubled space. The JS +// mirror does the same job with [...].filter(Boolean).join(" "). +func joinSp(a, b string) string { switch { - case marker != "" && suffix != "": - return marker + " " + suffix - case marker != "": - return marker + case a == "": + return b + case b == "": + return a default: - return suffix + return a + " " + b + } +} + +// enchoLabel renders the overtime marker for an encho block: "" when no +// overtime ran, "(E)" otherwise — always bare, never a count. +// +// mp-m4bn: encho is just encho. The stepper records how many periods were +// fought (PeriodCount persists for the tournament log), but the result +// marking deliberately never carries the number: operator feedback is that +// counted markers ("(E×3)") confuse readers of brackets and result sheets. +// Do not reintroduce the count here. Mirrors enchoLabel() in +// web-mobile/js/bracket.jsx, pinned by the shared table in +// testdata/encho_labels.json (which includes multi-digit counts precisely to +// pin that digits never leak into the marker). The editors' "· (E) Overtime +// ×N" eyebrow is different on purpose: a live readout of the stepper the +// operator is using, not a result marking. +func enchoLabel(encho *state.EnchoMetadata) string { + if encho == nil || encho.PeriodCount <= 0 { + return "" } + return "(E)" } // FlagsScorePair returns the display strings for both sides of an engi bout. diff --git a/internal/export/suffix_test.go b/internal/export/suffix_test.go index 1675e3b5f..780119c71 100644 --- a/internal/export/suffix_test.go +++ b/internal/export/suffix_test.go @@ -1,10 +1,14 @@ package export import ( + "encoding/json" "fmt" + "os" + "path/filepath" "testing" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" "github.com/gitrgoliveira/bracket-creator/internal/state" ) @@ -13,102 +17,106 @@ func encho(periods int) *state.EnchoMetadata { return &state.EnchoMetadata{PeriodCount: periods} } -func TestDecisionSuffix(t *testing.T) { +func TestMiddleMark(t *testing.T) { t.Parallel() + // The middle can only ever be "" (template keeps its "vs"), "X", "(E)" + // or "(DH)" — one mark, mutually exclusive. X beats (E) because a match + // that went to encho cannot end tied; (DH) beats (E) because a daihyosen + // bout is one-point sudden death with no overtime. tests := []struct { name string decision string encho *state.EnchoMetadata - hantei bool want string }{ - // Base: no suffix - {name: "empty decision, no encho, no hantei", decision: "", encho: nil, hantei: false, want: ""}, - {name: "fought, no extras", decision: "fought", encho: nil, hantei: false, want: ""}, - - // Kiken variants - {name: "kiken-voluntary", decision: "kiken-voluntary", encho: nil, hantei: false, want: "Kiken"}, - {name: "kiken-injury", decision: "kiken-injury", encho: nil, hantei: false, want: "Kiken"}, - {name: "kiken (legacy)", decision: "kiken", encho: nil, hantei: false, want: "Kiken"}, - - // Fusenpai / fusensho - {name: "fusenpai", decision: "fusenpai", encho: nil, hantei: false, want: "Fus."}, - {name: "fusensho", decision: "fusensho", encho: nil, hantei: false, want: "Fus."}, - - // Daihyosen - {name: "daihyosen", decision: "daihyosen", encho: nil, hantei: false, want: "DH"}, - - // Encho only - {name: "encho only (fought)", decision: "fought", encho: encho(1), hantei: false, want: "(E)"}, - {name: "encho nil vs zero periods", decision: "fought", encho: encho(0), hantei: false, want: ""}, - - // Hantei only - {name: "hantei only (fought)", decision: "fought", encho: nil, hantei: true, want: "Ht"}, - - // Encho + hantei - {name: "encho + hantei (fought)", decision: "fought", encho: encho(2), hantei: true, want: "(E) Ht"}, - - // Base label + encho - {name: "Kiken + encho", decision: "kiken-voluntary", encho: encho(1), hantei: false, want: "Kiken (E)"}, - {name: "Fus. + encho", decision: "fusenpai", encho: encho(1), hantei: false, want: "Fus. (E)"}, - {name: "DH + encho", decision: "daihyosen", encho: encho(1), hantei: false, want: "DH (E)"}, + {name: "fought, no encho", decision: "fought", encho: nil, want: ""}, + {name: "empty decision, no encho", decision: "", encho: nil, want: ""}, + {name: "encho win", decision: "fought", encho: encho(1), want: "(E)"}, + {name: "encho win, multi-period stays bare", decision: "fought", encho: encho(4), want: "(E)"}, + {name: "zero periods is no encho", decision: "fought", encho: encho(0), want: ""}, + {name: "tie", decision: "hikiwake", encho: nil, want: "X"}, + {name: "tie beats stale encho data", decision: "hikiwake", encho: encho(2), want: "X"}, + {name: "daihyosen", decision: "daihyosen", encho: nil, want: "(DH)"}, + {name: "daihyosen beats stale encho data (DH bouts have no encho)", decision: "daihyosen", encho: encho(1), want: "(DH)"}, + {name: "kiken leaves the middle alone", decision: "kiken-voluntary", encho: nil, want: ""}, + {name: "kiken during overtime keeps the (E) middle", decision: "kiken-voluntary", encho: encho(1), want: "(E)"}, + {name: "fusenpai leaves the middle alone", decision: "fusenpai", encho: nil, want: ""}, + } - // Base label + hantei - {name: "Kiken + hantei", decision: "kiken-voluntary", encho: nil, hantei: true, want: "Kiken Ht"}, - {name: "DH + hantei", decision: "daihyosen", encho: nil, hantei: true, want: "DH Ht"}, + for _, tc := range tests { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + assert.Equal(t, tc.want, MiddleMark(tc.decision, tc.encho)) + }) + } +} - // Full composition: base + encho + hantei - {name: "Kiken + encho + hantei", decision: "kiken-voluntary", encho: encho(1), hantei: true, want: "Kiken (E) Ht"}, - {name: "DH + encho + hantei", decision: "daihyosen", encho: encho(3), hantei: true, want: "DH (E) Ht"}, +func TestSideMarks(t *testing.T) { + t.Parallel() - // Hikiwake (draw) produces no base label; suffix still applies - {name: "hikiwake + hantei", decision: "hikiwake", encho: nil, hantei: true, want: "Ht"}, - {name: "hikiwake + encho", decision: "hikiwake", encho: encho(1), hantei: false, want: "(E)"}, - {name: "hikiwake + encho + hantei", decision: "hikiwake", encho: encho(1), hantei: true, want: "(E) Ht"}, + // Result marks name their competitor: Ht the hantei winner, Kiken the + // withdrawer, Fus. the no-show (fusenpai, loser) or the defaulted winner + // (fusensho — kept in the export because a spreadsheet has no bout badge). + tests := []struct { + name string + decision string + hantei bool + wantWinner string + wantLoser string + }{ + {name: "fought", decision: "fought", hantei: false, wantWinner: "", wantLoser: ""}, + {name: "hantei", decision: "fought", hantei: true, wantWinner: "Ht", wantLoser: ""}, + {name: "kiken-voluntary", decision: "kiken-voluntary", hantei: false, wantWinner: "", wantLoser: "Kiken"}, + {name: "kiken-injury", decision: "kiken-injury", hantei: false, wantWinner: "", wantLoser: "Kiken"}, + {name: "kiken (legacy)", decision: "kiken", hantei: false, wantWinner: "", wantLoser: "Kiken"}, + {name: "fusenpai marks the no-show loser", decision: "fusenpai", hantei: false, wantWinner: "", wantLoser: "Fus."}, + {name: "fusensho marks the defaulted winner", decision: "fusensho", hantei: false, wantWinner: "Fus.", wantLoser: ""}, + {name: "daihyosen is a middle mark, not a side mark", decision: "daihyosen", hantei: false, wantWinner: "", wantLoser: ""}, + {name: "hikiwake has no side marks", decision: "hikiwake", hantei: false, wantWinner: "", wantLoser: ""}, } for _, tc := range tests { tc := tc t.Run(tc.name, func(t *testing.T) { t.Parallel() - got := DecisionSuffix(tc.decision, tc.encho, tc.hantei) - assert.Equal(t, tc.want, got) + w, l := SideMarks(tc.decision, tc.hantei) + assert.Equal(t, tc.wantWinner, w, "winner mark") + assert.Equal(t, tc.wantLoser, l, "loser mark") }) } } -func TestMiddleCellText(t *testing.T) { +func TestSideMarksLR(t *testing.T) { t.Parallel() tests := []struct { - name string - decision string - suffix string - want string + name string + decision string + hantei bool + winner string + mirror bool + wantLeft string + wantRight string }{ - // Non-draw: just the suffix (may be empty). - {name: "fought, no suffix", decision: "fought", suffix: "", want: ""}, - {name: "fought, kiken suffix", decision: "fought", suffix: "Kiken", want: "Kiken"}, - {name: "daihyosen suffix only", decision: "daihyosen", suffix: "DH", want: "DH"}, - - // Draw alone -> bare "X". - {name: "draw, no suffix", decision: "hikiwake", suffix: "", want: "X"}, - - // The regression this helper fixes: draw AND suffix must be COMBINED, - // not overwritten. Previously the code wrote "X" then replaced it with - // the suffix, dropping the draw marker. - {name: "draw + encho", decision: "hikiwake", suffix: "(E)", want: "X (E)"}, - {name: "draw + hantei", decision: "hikiwake", suffix: "Ht", want: "X Ht"}, - {name: "draw + encho + hantei", decision: "hikiwake", suffix: "(E) Ht", want: "X (E) Ht"}, - {name: "draw + DH", decision: "hikiwake", suffix: "DH", want: "X DH"}, + // Default layout: SideA (Aka) left, SideB right. + {name: "hantei, A wins", decision: "fought", hantei: true, winner: "A", wantLeft: "Ht", wantRight: ""}, + {name: "hantei, B wins", decision: "fought", hantei: true, winner: "B", wantLeft: "", wantRight: "Ht"}, + {name: "hantei, B wins, mirrored", decision: "fought", hantei: true, winner: "B", mirror: true, wantLeft: "Ht", wantRight: ""}, + {name: "kiken, A wins marks B", decision: "kiken-voluntary", winner: "A", wantLeft: "", wantRight: "Kiken"}, + {name: "kiken, A wins, mirrored", decision: "kiken-voluntary", winner: "A", mirror: true, wantLeft: "Kiken", wantRight: ""}, + {name: "no winner recorded: marks have no home", decision: "kiken-voluntary", winner: "", wantLeft: "", wantRight: ""}, + {name: "drifted winner name: no marks rather than a guess", decision: "kiken-voluntary", winner: "C", wantLeft: "", wantRight: ""}, } for _, tc := range tests { tc := tc t.Run(tc.name, func(t *testing.T) { t.Parallel() - assert.Equal(t, tc.want, MiddleCellText(tc.decision, tc.suffix)) + l, r := SideMarksLR(tc.decision, tc.hantei, tc.winner, "A", "B", tc.mirror) + assert.Equal(t, tc.wantLeft, l, "left mark") + assert.Equal(t, tc.wantRight, r, "right mark") }) } } @@ -168,3 +176,35 @@ func TestIpponsScore(t *testing.T) { }) } } + +// TestEnchoLabel_GoldenTable is the Go half of the shared Go/JS golden table +// for the overtime marker — see the `_comment` in testdata/encho_labels.json +// for why the table is shared and why it pins values, not source text. JS +// half: the "enchoLabel Go/JS mirror" describe in +// web-mobile/js/__tests__/score_display.test.jsx. +func TestEnchoLabel_GoldenTable(t *testing.T) { + t.Parallel() + + raw, err := os.ReadFile(filepath.Join("testdata", "encho_labels.json")) + require.NoError(t, err, "shared Go/JS golden table is missing") + + var table struct { + Cases []struct { + PeriodCount int `json:"periodCount"` + Label string `json:"label"` + } `json:"cases"` + } + require.NoError(t, json.Unmarshal(raw, &table)) + require.NotEmpty(t, table.Cases, "golden table parsed to zero cases: it would assert nothing") + + for _, tc := range table.Cases { + t.Run(fmt.Sprintf("periodCount=%d", tc.PeriodCount), func(t *testing.T) { + t.Parallel() + assert.Equal(t, tc.Label, enchoLabel(encho(tc.PeriodCount)), + "Go enchoLabel disagrees with the shared table; update BOTH renderers, not just this one") + }) + } + + // nil is not expressible in the shared table but must render like 0. + assert.Equal(t, "", enchoLabel(nil)) +} diff --git a/internal/export/testdata/encho_labels.json b/internal/export/testdata/encho_labels.json new file mode 100644 index 000000000..da42f555f --- /dev/null +++ b/internal/export/testdata/encho_labels.json @@ -0,0 +1,32 @@ +{ + "_comment": [ + "mp-m4bn: the single source of truth for the overtime marker, shared by", + "BOTH language implementations of enchoLabel: internal/export/suffix.go", + "(the exported results workbook) and web-mobile/js/bracket.jsx (bracket,", + "court console, viewer, TV). internal/export/suffix_test.go table-drives", + "the Go side over these cases; web-mobile/js/__tests__/score_display.test.jsx", + "reads this same file and drives the JS side.", + "", + "THE CONTRACT: encho is just encho. Any positive periodCount renders the", + "bare \"(E)\"; zero/absent renders \"\". The stepper records how many periods", + "were fought (periodCount persists for the tournament log), but the result", + "marking never carries the number: counted markers (\"(E×3)\") confuse", + "readers of brackets and result sheets (operator decision, PR #376). The", + "multi-digit periodCount 11 case pins that digits never leak in.", + "", + "Why a shared file rather than each suite restating the strings: changing", + "one language AND its own tests together is the realistic drift, and two", + "independent tables both stay green through it. Editing this file fails", + "the OTHER language's suite immediately, so the marker cannot move in one", + "renderer without the other following. Pin VALUES, not source shape: an", + "earlier grep-the-other-source attempt passed unchanged when the marker", + "was switched to \"(OT)\" and went red on behaviour-preserving refactors." + ], + "cases": [ + { "periodCount": 0, "label": "" }, + { "periodCount": 1, "label": "(E)" }, + { "periodCount": 2, "label": "(E)" }, + { "periodCount": 3, "label": "(E)" }, + { "periodCount": 11, "label": "(E)" } + ] +} diff --git a/internal/mobileapp/concurrency_test.go b/internal/mobileapp/concurrency_test.go index ba6c96ba4..b750e9233 100644 --- a/internal/mobileapp/concurrency_test.go +++ b/internal/mobileapp/concurrency_test.go @@ -81,9 +81,9 @@ func TestConcurrentScoresPreserveOrder(t *testing.T) { RegisterMatchHandlers(admin, eng, store, store, hub, NewFileVerifier(store), store) // Goroutines all fire concurrently; each scores a different match - // so the per-comp lock is contended on every step (LoadCompetition - // to check encho cap, StartMatchTx, RecordMatchResultWithIneligibilityTx, - // MaybeAdvanceKachinuki, tryAutoCompletePools). + // so the per-comp lock is contended on every step (StartMatchTx, + // RecordMatchResultWithIneligibilityTx, MaybeAdvanceKachinuki, + // tryAutoCompletePools). var wg sync.WaitGroup wg.Add(N) for i := range N { diff --git a/internal/mobileapp/handlers_decision.go b/internal/mobileapp/handlers_decision.go index 6ef3615f7..b8c8a7019 100644 --- a/internal/mobileapp/handlers_decision.go +++ b/internal/mobileapp/handlers_decision.go @@ -125,14 +125,6 @@ func RegisterDecisionHandlers(r *gin.RouterGroup, eng ScoringEngine, store Compe return } - // T104/CHK029: enforce MaxEnchoPeriods cap on the encho block. - // Same shape as the score handler, Force bypasses, 0 cap means - // unlimited. Done BEFORE the tx so the read is cheap and we - // don't take the lock when the request is going to 400 anyway. - if !enforceEnchoCap(c, store, id, req.Encho, req.Force) { - return - } - // T156: run the entire RecordDecision flow inside one // WithTransaction. The engine call chain, sides lookup, T103 // downstream-match check, T105 concurrent-kiken pre-check, diff --git a/internal/mobileapp/handlers_match.go b/internal/mobileapp/handlers_match.go index 901bed96f..985e084e9 100644 --- a/internal/mobileapp/handlers_match.go +++ b/internal/mobileapp/handlers_match.go @@ -150,77 +150,6 @@ func annotateBracketQueuePositions(b *state.Bracket) { } } -// enchoExceedsCap reports whether an encho block would exceed the -// competition's MaxEnchoPeriods cap. Returns false (within limit) when -// encho is unset, comp is nil, the cap is 0 (unlimited, FIK default), -// the count is within the cap, or force is set. T104/CHK029. -func enchoExceedsCap(encho *state.EnchoMetadata, comp *state.Competition, force bool) bool { - if encho == nil || encho.PeriodCount <= 0 { - return false - } - if comp == nil || comp.MaxEnchoPeriods <= 0 { - return false - } - return encho.PeriodCount > comp.MaxEnchoPeriods && !force -} - -// anySubBoutEnchoExceedsCap returns true if any sub-result's encho -// period count exceeds the competition cap. The same cap applies -// per-sub-bout because each bout is a standalone overtime bout. -func anySubBoutEnchoExceedsCap(subResults []state.SubMatchResult, comp *state.Competition, force bool) bool { - for i := range subResults { - if enchoExceedsCap(subResults[i].Encho, comp, force) { - return true - } - } - return false -} - -// anySubBoutHasEncho reports whether any sub-result carries encho with at -// least one period. Used to decide whether the cap check needs to load the -// competition at all, ordinary team scoring (every bout has SubResults but -// no encho) must not pay that store load. -func anySubBoutHasEncho(subResults []state.SubMatchResult) bool { - for i := range subResults { - if subResults[i].Encho != nil && subResults[i].Encho.PeriodCount > 0 { - return true - } - } - return false -} - -// enforceEnchoCap is the gin-handler wrapper around enchoExceedsCap for -// the single-result score / decision endpoints. Loads the competition -// once, checks the top-level encho and every sub-bout encho against the -// cap (writing 500 on store failure, 400 on cap exceeded). -// Returns true if the handler should continue. -func enforceEnchoCap(c *gin.Context, store CompetitionStore, id string, encho *state.EnchoMetadata, force bool) bool { - return enforceEnchoCapWithSubs(c, store, id, encho, nil, force) -} - -// enforceEnchoCapWithSubs is the variant used by the score endpoint. It -// checks both the top-level encho and each sub-result's encho against the -// competition cap in a single competition load. -func enforceEnchoCapWithSubs(c *gin.Context, store CompetitionStore, id string, encho *state.EnchoMetadata, subs []state.SubMatchResult, force bool) bool { - needsCheck := (encho != nil && encho.PeriodCount > 0) || anySubBoutHasEncho(subs) - if !needsCheck { - return true - } - comp, err := store.LoadCompetition(id) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to validate encho limits"}) - return false - } - if enchoExceedsCap(encho, comp, force) || anySubBoutEnchoExceedsCap(subs, comp, force) { - c.JSON(http.StatusBadRequest, gin.H{ - "error": "max_encho_exceeded", - "limit": comp.MaxEnchoPeriods, - }) - return false - } - return true -} - // tryAutoCompletePools runs the auto-complete check after a successful score // write. The score itself has already been recorded, so we don't fail the // request when the auto-complete check errors; instead we log full details @@ -314,23 +243,10 @@ func RegisterMatchHandlers(r *gin.RouterGroup, eng *engine.Engine, store Competi // the batch, mirrors the single-score handler (T085/T092). var eligibilityUpdates []*domain.CompetitorStatus - comp, err := store.LoadCompetition(id) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to validate encho limits"}) - return - } - force := c.Query("force") == "true" - for i := range results { // Reject a hostile/buggy far-future or negative client timestamp so // it cannot freeze the match against later legitimate writes (mp-y3nk). results[i].ModifiedAt = clampClientModifiedAt(results[i].ModifiedAt) - // T104/CHK029: enforce MaxEnchoPeriods cap on bulk-score payload - // (top-level and each sub-bout independently). - if enchoExceedsCap(results[i].Encho, comp, force) || anySubBoutEnchoExceedsCap(results[i].SubResults, comp, force) { - errs = append(errs, scoreError{MatchID: results[i].ID, Error: "max_encho_exceeded"}) - continue - } if err := validateBulkScoreLengths(&results[i]); err != nil { errs = append(errs, scoreError{MatchID: results[i].ID, Error: err.Error()}) @@ -547,7 +463,7 @@ func RegisterMatchHandlers(r *gin.RouterGroup, eng *engine.Engine, store Competi // behaviour as before. T156 added the CompetitionTransactor `tx` // parameter so the match-write + ineligibility-write + lineup-freeze // commit under one per-comp lock acquire. - registerScoreHandler(r, eng, store, tx, hub, verifier, tl) + registerScoreHandler(r, eng, tx, hub, verifier, tl) r.PUT("/competitions/:id/matches/:mid/court", func(c *gin.Context) { id, ok := requireValidCompID(c) @@ -966,7 +882,7 @@ type scoreRequestBody struct { KachinukiBoutFinal bool `json:"kachinukiBoutFinal"` } -func registerScoreHandler(r *gin.RouterGroup, eng ScoringEngine, store CompetitionStore, tx CompetitionTransactor, hub Broadcaster, verifier PasswordVerifier, tl TournamentLoader) { +func registerScoreHandler(r *gin.RouterGroup, eng ScoringEngine, tx CompetitionTransactor, hub Broadcaster, verifier PasswordVerifier, tl TournamentLoader) { // C3: coalesce high-frequency "running" match_updated broadcasts to ≤4/s // per match. Completed writes always proceed (isRunning=false). coalescer := newMatchBroadcastCoalescer() @@ -1007,15 +923,6 @@ func registerScoreHandler(r *gin.RouterGroup, eng ScoringEngine, store Competiti return } - // T104/CHK029: enforce MaxEnchoPeriods cap. The cap is a - // per-competition setting; 0 means unlimited (FIK default). The - // operator can override by sending ?force=true after confirming - // the warning banner, the UI's job is to surface that prompt - // when the cap is reached. - if !enforceEnchoCapWithSubs(c, store, id, req.Encho, req.SubResults, c.Query("force") == "true") { - return - } - // mp-ba3: self-run decision allowlist + result provenance. Runs // after Validate() so the request is structurally valid. resultSource, ok := enforceSelfRunPolicy(c, tl, verifier, &req) diff --git a/internal/mobileapp/handlers_match_test.go b/internal/mobileapp/handlers_match_test.go index 2ff3609bf..0a932077a 100644 --- a/internal/mobileapp/handlers_match_test.go +++ b/internal/mobileapp/handlers_match_test.go @@ -1314,8 +1314,8 @@ func TestPostScoreKikenInvalidScoreline(t *testing.T) { } // failingCompetitionStore returns the configured error from -// LoadCompetition. Used to drive the fail-closed path in -// enforceEnchoCap / bulk-score when config.md can't be loaded. +// LoadCompetition. Used to drive the fail-closed path in the +// override-winner handler when config.md can't be loaded. type failingCompetitionStore struct{ err error } func (f failingCompetitionStore) LoadCompetition(string) (*state.Competition, error) { @@ -1330,268 +1330,6 @@ func (f failingCompetitionStore) LoadBracket(string) (*state.Bracket, error) { return nil, f.err } -// TestEnchoExceedsCap covers the pure predicate. Force, missing comp, -// 0 cap, and within-cap all return false; only an over-cap count with -// !force returns true. -func TestEnchoExceedsCap(t *testing.T) { - cases := []struct { - name string - encho *state.EnchoMetadata - comp *state.Competition - force bool - want bool - }{ - {name: "nil encho", encho: nil, comp: &state.Competition{MaxEnchoPeriods: 2}, want: false}, - {name: "zero period count", encho: &state.EnchoMetadata{PeriodCount: 0}, comp: &state.Competition{MaxEnchoPeriods: 2}, want: false}, - {name: "nil comp", encho: &state.EnchoMetadata{PeriodCount: 5}, comp: nil, want: false}, - {name: "zero cap means unlimited", encho: &state.EnchoMetadata{PeriodCount: 99}, comp: &state.Competition{MaxEnchoPeriods: 0}, want: false}, - {name: "within cap", encho: &state.EnchoMetadata{PeriodCount: 2}, comp: &state.Competition{MaxEnchoPeriods: 2}, want: false}, - {name: "at cap boundary", encho: &state.EnchoMetadata{PeriodCount: 2}, comp: &state.Competition{MaxEnchoPeriods: 3}, want: false}, - {name: "over cap without force", encho: &state.EnchoMetadata{PeriodCount: 3}, comp: &state.Competition{MaxEnchoPeriods: 2}, want: true}, - {name: "over cap with force", encho: &state.EnchoMetadata{PeriodCount: 3}, comp: &state.Competition{MaxEnchoPeriods: 2}, force: true, want: false}, - } - for _, tc := range cases { - t.Run(tc.name, func(t *testing.T) { - got := enchoExceedsCap(tc.encho, tc.comp, tc.force) - assert.Equal(t, tc.want, got) - }) - } -} - -// TestEnforceEnchoCap_ScoreHandler covers the gin wrapper as wired -// into the single-score endpoint: 500 on store failure (the bug this -// fix closes), 400 with limit echoed on cap exceeded, and 200 when -// the cap is unset. -func TestEnforceEnchoCap_ScoreHandler(t *testing.T) { - tempDir, err := os.MkdirTemp("", "encho-cap-*") - require.NoError(t, err) - defer os.RemoveAll(tempDir) - realStore, err := state.NewStore(tempDir) - require.NoError(t, err) - eng := engine.New(realStore) - hub := NewHub() - - compID := "encho-cap-test" - require.NoError(t, realStore.SaveCompetition(&state.Competition{ - ID: compID, Format: state.CompFormatMixed, Status: state.CompStatusPools, - MaxEnchoPeriods: 2, - })) - require.NoError(t, realStore.SaveParticipants(compID, []domain.Player{ - {Name: "Alice"}, {Name: "Bob"}, - })) - require.NoError(t, realStore.SavePoolMatches(compID, []state.MatchResult{ - {ID: "PoolA-1", SideA: "Alice", SideB: "Bob"}, - })) - - score := func(periodCount int) []byte { - body, _ := json.Marshal(state.MatchResult{ - ID: "PoolA-1", SideA: "Alice", SideB: "Bob", - Winner: "Alice", IpponsA: []string{"M"}, - Encho: &state.EnchoMetadata{PeriodCount: periodCount}, - Status: state.MatchStatusCompleted, - }) - return body - } - - t.Run("load failure returns 500", func(t *testing.T) { - gin.SetMode(gin.TestMode) - r := gin.New() - admin := r.Group("/api") - // Wire the score handler with a failing CompetitionStore so the - // cap check exercises the new fail-closed branch. The engine - // keeps the real store but never gets called, enforceEnchoCap - // aborts the request first. - registerScoreHandler(admin, eng, failingCompetitionStore{err: errors.New("disk on fire")}, realStore, hub, NewFileVerifier(realStore), realStore) - - w := httptest.NewRecorder() - req, _ := http.NewRequest("PUT", "/api/competitions/"+compID+"/matches/PoolA-1/score", bytes.NewBuffer(score(1))) - req.Header.Set("Content-Type", "application/json") - r.ServeHTTP(w, req) - - require.Equalf(t, http.StatusInternalServerError, w.Code, "body=%s", w.Body.String()) - assert.Contains(t, w.Body.String(), "failed to validate encho limits") - }) - - t.Run("over cap returns 400 with limit", func(t *testing.T) { - gin.SetMode(gin.TestMode) - r := gin.New() - admin := r.Group("/api") - registerScoreHandler(admin, eng, realStore, realStore, hub, NewFileVerifier(realStore), realStore) - - w := httptest.NewRecorder() - req, _ := http.NewRequest("PUT", "/api/competitions/"+compID+"/matches/PoolA-1/score", bytes.NewBuffer(score(3))) - req.Header.Set("Content-Type", "application/json") - r.ServeHTTP(w, req) - - require.Equalf(t, http.StatusBadRequest, w.Code, "body=%s", w.Body.String()) - var resp struct { - Error string `json:"error"` - Limit int `json:"limit"` - } - require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp)) - assert.Equal(t, "max_encho_exceeded", resp.Error) - assert.Equal(t, 2, resp.Limit) - }) - - t.Run("force bypasses cap", func(t *testing.T) { - gin.SetMode(gin.TestMode) - r := gin.New() - admin := r.Group("/api") - registerScoreHandler(admin, eng, realStore, realStore, hub, NewFileVerifier(realStore), realStore) - - w := httptest.NewRecorder() - req, _ := http.NewRequest("PUT", "/api/competitions/"+compID+"/matches/PoolA-1/score?force=true", bytes.NewBuffer(score(3))) - req.Header.Set("Content-Type", "application/json") - r.ServeHTTP(w, req) - - require.Equalf(t, http.StatusOK, w.Code, "body=%s", w.Body.String()) - }) -} - -// TestEnforceEnchoCapWithSubs covers the sub-bout encho cap path added in mp-4pc. -// anySubBoutEnchoExceedsCap inspects each subResults[].encho.periodCount; the -// single-score and bulk-score handlers must both enforce it (same cap, same 400 -// response shape). force=true bypasses the cap for both paths. -func TestEnforceEnchoCapWithSubs(t *testing.T) { - tempDir, err := os.MkdirTemp("", "sub-encho-cap-*") - require.NoError(t, err) - defer os.RemoveAll(tempDir) - realStore, err := state.NewStore(tempDir) - require.NoError(t, err) - eng := engine.New(realStore) - hub := NewHub() - - compID := "sub-encho-cap-test" - require.NoError(t, realStore.SaveCompetition(&state.Competition{ - ID: compID, Format: state.CompFormatMixed, Status: state.CompStatusPools, - MaxEnchoPeriods: 2, - })) - require.NoError(t, realStore.SaveParticipants(compID, []domain.Player{ - {Name: "TeamA"}, {Name: "TeamB"}, - })) - require.NoError(t, realStore.SavePoolMatches(compID, []state.MatchResult{ - {ID: "PoolA-1", SideA: "TeamA", SideB: "TeamB"}, - })) - - overCapSubResult := state.SubMatchResult{ - Position: state.DaihyosenSubPosition, SideA: "TeamA", SideB: "TeamB", - IpponsA: []string{"M"}, Winner: "TeamA", - Encho: &state.EnchoMetadata{PeriodCount: 3}, // exceeds cap of 2; daihyosen is the only sub-bout with encho - } - - t.Run("single-score: sub-bout encho over cap returns 400", func(t *testing.T) { - gin.SetMode(gin.TestMode) - r := gin.New() - admin := r.Group("/api") - registerScoreHandler(admin, eng, realStore, realStore, hub, NewFileVerifier(realStore), realStore) - - body, _ := json.Marshal(state.MatchResult{ - ID: "PoolA-1", SideA: "TeamA", SideB: "TeamB", - Winner: "TeamA", Status: state.MatchStatusCompleted, - SubResults: []state.SubMatchResult{overCapSubResult}, - }) - w := httptest.NewRecorder() - req, _ := http.NewRequest("PUT", "/api/competitions/"+compID+"/matches/PoolA-1/score", bytes.NewBuffer(body)) - req.Header.Set("Content-Type", "application/json") - r.ServeHTTP(w, req) - - require.Equalf(t, http.StatusBadRequest, w.Code, "body=%s", w.Body.String()) - var resp struct { - Error string `json:"error"` - Limit int `json:"limit"` - } - require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp)) - assert.Equal(t, "max_encho_exceeded", resp.Error) - assert.Equal(t, 2, resp.Limit) - }) - - t.Run("single-score: force=true bypasses sub-bout encho cap", func(t *testing.T) { - gin.SetMode(gin.TestMode) - r := gin.New() - admin := r.Group("/api") - registerScoreHandler(admin, eng, realStore, realStore, hub, NewFileVerifier(realStore), realStore) - - body, _ := json.Marshal(state.MatchResult{ - ID: "PoolA-1", SideA: "TeamA", SideB: "TeamB", - Winner: "TeamA", Status: state.MatchStatusCompleted, - SubResults: []state.SubMatchResult{overCapSubResult}, - }) - w := httptest.NewRecorder() - req, _ := http.NewRequest("PUT", "/api/competitions/"+compID+"/matches/PoolA-1/score?force=true", bytes.NewBuffer(body)) - req.Header.Set("Content-Type", "application/json") - r.ServeHTTP(w, req) - - require.Equalf(t, http.StatusOK, w.Code, "body=%s", w.Body.String()) - }) - - t.Run("bulk-score: sub-bout encho over cap is recorded as per-item error", func(t *testing.T) { - gin.SetMode(gin.TestMode) - r := gin.New() - admin := r.Group("/api") - RegisterMatchHandlers(admin, eng, realStore, realStore, hub, NewFileVerifier(realStore), realStore) - - body, _ := json.Marshal([]state.MatchResult{ - { - ID: "PoolA-1", SideA: "TeamA", SideB: "TeamB", - Winner: "TeamA", Status: state.MatchStatusCompleted, - SubResults: []state.SubMatchResult{overCapSubResult}, - }, - }) - w := httptest.NewRecorder() - req, _ := http.NewRequest("POST", "/api/competitions/"+compID+"/matches/bulk-score", bytes.NewBuffer(body)) - req.Header.Set("Content-Type", "application/json") - r.ServeHTTP(w, req) - - // Bulk-score always returns 200; cap violations land in the errors array. - require.Equalf(t, http.StatusOK, w.Code, "body=%s", w.Body.String()) - var resp struct { - Succeeded int `json:"succeeded"` - Errors []struct { - MatchID string `json:"matchId"` - Error string `json:"error"` - } `json:"errors"` - } - require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp)) - assert.Equal(t, 0, resp.Succeeded) - require.Len(t, resp.Errors, 1) - assert.Equal(t, "PoolA-1", resp.Errors[0].MatchID) - assert.Equal(t, "max_encho_exceeded", resp.Errors[0].Error) - }) -} - -// TestBulkScore_FailsClosedOnLoadError, when the cap-check load -// fails for a bulk-score request, the entire batch is rejected with -// 500 rather than silently bypassing the MaxEnchoPeriods cap on every -// entry. -func TestBulkScore_FailsClosedOnLoadError(t *testing.T) { - tempDir, err := os.MkdirTemp("", "bulk-cap-*") - require.NoError(t, err) - defer os.RemoveAll(tempDir) - realStore, err := state.NewStore(tempDir) - require.NoError(t, err) - eng := engine.New(realStore) - hub := NewHub() - - gin.SetMode(gin.TestMode) - r := gin.New() - admin := r.Group("/api") - // RegisterMatchHandlers takes the concrete *Hub; the cap check - // uses the CompetitionStore parameter (which we fault here) and - // returns 500 before any handler reaches the hub or engine. - RegisterMatchHandlers(admin, eng, failingCompetitionStore{err: errors.New("disk on fire")}, realStore, hub, NewFileVerifier(realStore), realStore) - - body, _ := json.Marshal([]state.MatchResult{ - {ID: "PoolA-1", SideA: "P1", SideB: "P2", Winner: "P1", Status: state.MatchStatusCompleted}, - }) - w := httptest.NewRecorder() - req, _ := http.NewRequest("POST", "/api/competitions/c1/matches/bulk-score", bytes.NewBuffer(body)) - req.Header.Set("Content-Type", "application/json") - r.ServeHTTP(w, req) - - require.Equalf(t, http.StatusInternalServerError, w.Code, "body=%s", w.Body.String()) - assert.Contains(t, w.Body.String(), "failed to validate encho limits") -} - // TestAnnotateQueuePositions_NonEmpty verifies that annotateQueuePositions // fills in per-court queue positions for a non-empty match list. func TestAnnotateQueuePositions_NonEmpty(t *testing.T) { diff --git a/internal/mobileapp/validation.go b/internal/mobileapp/validation.go index 72c79c4e2..0f28cd4b4 100644 --- a/internal/mobileapp/validation.go +++ b/internal/mobileapp/validation.go @@ -167,10 +167,10 @@ func validateURLHasHost(field, val string) error { func validateSubBout(prefix string, sr *state.SubMatchResult) error { // Encho period counts are bounded two ways. A negative count is never // valid on any bout (it would slip past the > 0 guards below and be - // silently treated as "no encho", bypassing the cap check). On a - // numbered bout, ANY non-zero count is rejected, a regular bout has - // fixed regulation time and cannot go to overtime; only the daihyosen - // representative bout (Position == -1) may carry encho. + // silently treated as "no encho"). On a numbered bout, ANY non-zero + // count is rejected, a regular bout has fixed regulation time and + // cannot go to overtime; only the daihyosen representative bout + // (Position == -1) may carry encho. if sr.Encho != nil { if sr.Encho.PeriodCount < 0 { return &ValidationError{ diff --git a/internal/mobileapp/validation_test.go b/internal/mobileapp/validation_test.go index 49fd38822..2cb626754 100644 --- a/internal/mobileapp/validation_test.go +++ b/internal/mobileapp/validation_test.go @@ -935,8 +935,7 @@ func TestScoreRequestValidate_SubBoutDecidedByHantei(t *testing.T) { t.Run("invalid: negative encho on regular bout position", func(t *testing.T) { // A negative period count would slip past the > 0 guard and be - // silently treated as "no encho", bypassing the cap check. It must - // be rejected outright. + // silently treated as "no encho". It must be rejected outright. req := ScoreRequest{ SubResults: []state.SubMatchResult{ { diff --git a/internal/state/models.go b/internal/state/models.go index 119f0f7b5..445086ffa 100644 --- a/internal/state/models.go +++ b/internal/state/models.go @@ -338,10 +338,6 @@ type Competition struct { // one written to new config.md files. PoolMatchDurationSeconds int `yaml:"pool_match_duration_seconds,omitempty" json:"poolMatchDurationSeconds,omitempty"` PlayoffMatchDurationSeconds int `yaml:"playoff_match_duration_seconds,omitempty" json:"playoffMatchDurationSeconds,omitempty"` - // MaxEnchoPeriods caps how many encho (overtime) periods one match - // may run before the operator must call daihyosen. Zero means - // unlimited (FIK general default). T104, CHK029. - MaxEnchoPeriods int `yaml:"max_encho_periods,omitempty" json:"maxEnchoPeriods,omitempty"` // TeamMatchType selects the team-match format (FR-044). Empty value // is treated as TeamMatchTypeFixed for backward compatibility; all diff --git a/specs/openapi.yaml b/specs/openapi.yaml index 53c769a66..c9cf0a233 100644 --- a/specs/openapi.yaml +++ b/specs/openapi.yaml @@ -3112,9 +3112,6 @@ components: type: string description: Team-match scoring model; `fixed` (regular position-by-position order, shown as "Regular" in the UI) or `kachinuki` (winner stays on). enum: [fixed, kachinuki] - maxEnchoPeriods: - type: integer - description: Maximum number of encho (extended time) periods permitted per match. poolMatchDurationSeconds: type: integer minimum: 0 diff --git a/web-mobile/css/styles.css b/web-mobile/css/styles.css index 847cde6dc..b6efc54a5 100644 --- a/web-mobile/css/styles.css +++ b/web-mobile/css/styles.css @@ -6947,11 +6947,6 @@ abbr.msb-lab { text-decoration: none; cursor: help; } font-family: var(--font-mono); font-weight: 700; } -.encho-row__max-banner { - color: var(--red); - font-size: 11px; - display: block; -} .encho-row__btn { min-width: 28px; min-height: 28px; diff --git a/web-mobile/js/__tests__/admin_competition.test.jsx b/web-mobile/js/__tests__/admin_competition.test.jsx index 3d289b02b..3fc48b556 100644 --- a/web-mobile/js/__tests__/admin_competition.test.jsx +++ b/web-mobile/js/__tests__/admin_competition.test.jsx @@ -1031,8 +1031,8 @@ describe('ScoreEditorModal isEngi derived synchronously (finding 10)', () => { }); it('the async fetch effect no longer calls setIsEngi', () => { - // The fetchCompetitionDetails effect still runs for maxEnchoPeriods - // and isNaginata; it must not set isEngi. + // The fetchCompetitionDetails effect still runs for isNaginata; it + // must not set isEngi. const fetchBlock = src.match(/fetchCompetitionDetails[\s\S]{0,500}catch\(\)/)?.[0] || ''; expect(fetchBlock).not.toContain('setIsEngi'); expect(fetchBlock).not.toContain('engi'); diff --git a/web-mobile/js/__tests__/admin_scoring_modal.test.jsx b/web-mobile/js/__tests__/admin_scoring_modal.test.jsx index 45d1e6277..44588fa89 100644 --- a/web-mobile/js/__tests__/admin_scoring_modal.test.jsx +++ b/web-mobile/js/__tests__/admin_scoring_modal.test.jsx @@ -4,10 +4,8 @@ import { assertRunningWritePersisted, buildDecisionBody, submitDecisionRequest, - shouldShowEnchoMaxBanner, getIpponButtons, getValidPointKeys, - canIncrementEncho, nextEnchoPeriod, prevEnchoPeriod, initialEnchoPeriodsForMatch, @@ -33,8 +31,8 @@ import { isKikenDecision } from '../api_serializers.jsx'; window.isKikenDecision = isKikenDecision; -// admin_scoring_modal.jsx ships seven module-private helpers that together -// implement the FR-033 encho-period flow (T104 cap + banner) and the +// admin_scoring_modal.jsx ships module-private helpers that together +// implement the FR-033 encho-period flow (unbounded stepper) and the // T093–T098 decision prompt path. Each helper is exported separately so // vitest can pin the behaviour without mounting either ScoreEditorModal / // TeamScoreEditorModal (those run useState/useEffect at the top and the @@ -143,11 +141,10 @@ describe('buildDecisionBody', () => { expect(body.decision).toBe('daihyosen'); }); - describe('force flag (T103/T104 override loop)', () => { - // T103 (decision_locked) and T104 (max_encho_exceeded) both use a - // confirm-and-retry-with-force flow. The helper accepts `opts.force` - // so the parent's retry path can call back into it without - // re-implementing the body shape. + describe('force flag (T103 override loop)', () => { + // T103 (decision_locked) uses a confirm-and-retry-with-force flow. + // The helper accepts `opts.force` so the parent's retry path can + // call back into it without re-implementing the body shape. it('attaches force=true when opts.force is set', () => { const body = buildDecisionBody('kiken-voluntary', { decisionBy: 'shiro' }, 0, { force: true }); @@ -178,96 +175,15 @@ describe('buildDecisionBody', () => { }); }); -describe('shouldShowEnchoMaxBanner (T104)', () => { - // The "Maximum encho periods reached" banner surfaces once the operator - // has incremented to the cap. maxEnchoPeriods === 0 means "unlimited" - // per state.CompetitionConfig.MaxEnchoPeriods's FIK-default semantics. +describe('nextEnchoPeriod (the + button)', () => { + // mp-m4bn: unbounded by design — see the nextEnchoPeriod docstring in + // admin_scoring_shared.jsx for why the shimpan, not the software, decide + // how many periods are fought. - it('returns false when maxEnchoPeriods is 0 (unlimited)', () => { - expect(shouldShowEnchoMaxBanner(0, 0)).toBe(false); - expect(shouldShowEnchoMaxBanner(5, 0)).toBe(false); - expect(shouldShowEnchoMaxBanner(100, 0)).toBe(false); - }); - - it('returns false when maxEnchoPeriods is null/undefined (unlimited)', () => { - // Defensive: a missing field on the wire shouldn't surprise the - // operator with a banner; treat as unlimited. - expect(shouldShowEnchoMaxBanner(5, null)).toBe(false); - expect(shouldShowEnchoMaxBanner(5, undefined)).toBe(false); - }); - - it('returns false when enchoPeriodCount is below the cap', () => { - expect(shouldShowEnchoMaxBanner(0, 3)).toBe(false); - expect(shouldShowEnchoMaxBanner(1, 3)).toBe(false); - expect(shouldShowEnchoMaxBanner(2, 3)).toBe(false); - }); - - it('returns true when enchoPeriodCount equals the cap (at-cap warning)', () => { - expect(shouldShowEnchoMaxBanner(3, 3)).toBe(true); - expect(shouldShowEnchoMaxBanner(1, 1)).toBe(true); - }); - - it('returns true when enchoPeriodCount exceeds the cap (defensive)', () => { - // The + button is clamped, so this is unreachable through the UI. - // but pinning the `>=` so a future refactor doesn't accidentally - // narrow to `===` and leave operators staring at a non-existent - // banner if the count somehow over-shoots. - expect(shouldShowEnchoMaxBanner(4, 3)).toBe(true); - expect(shouldShowEnchoMaxBanner(10, 3)).toBe(true); - }); - - it('returns false when maxEnchoPeriods is negative (defensive)', () => { - // Negative cap is meaningless; treat as unlimited rather than - // banner-on-everything. - expect(shouldShowEnchoMaxBanner(5, -1)).toBe(false); - }); -}); - -describe('canIncrementEncho (T104 + button gate)', () => { - it('returns true when maxEnchoPeriods is 0 (unlimited)', () => { - expect(canIncrementEncho(0, 0)).toBe(true); - expect(canIncrementEncho(100, 0)).toBe(true); - }); - - it('returns true when maxEnchoPeriods is null/undefined', () => { - expect(canIncrementEncho(5, null)).toBe(true); - expect(canIncrementEncho(5, undefined)).toBe(true); - }); - - it('returns true when below the cap', () => { - expect(canIncrementEncho(0, 3)).toBe(true); - expect(canIncrementEncho(2, 3)).toBe(true); - }); - - it('returns false when at the cap', () => { - expect(canIncrementEncho(3, 3)).toBe(false); - }); - - it('returns false when above the cap (defensive)', () => { - expect(canIncrementEncho(4, 3)).toBe(false); - }); -}); - -describe('nextEnchoPeriod (T104 + button clamp)', () => { - // The + button uses this helper: increments by 1 but clamps at the - // configured cap. Combined with disabled={!canIncrementEncho(...)} - // the button can't fire over-the-cap, but pinning the clamp here so - // a future refactor that drops the disabled gate doesn't silently - // let the count run away. - - it('increments by 1 when unlimited (maxEnchoPeriods = 0)', () => { - expect(nextEnchoPeriod(1, 0)).toBe(2); - expect(nextEnchoPeriod(99, 0)).toBe(100); - }); - - it('increments by 1 when below the cap', () => { - expect(nextEnchoPeriod(1, 3)).toBe(2); - expect(nextEnchoPeriod(2, 3)).toBe(3); - }); - - it('does NOT exceed the cap (clamps at max)', () => { - expect(nextEnchoPeriod(3, 3)).toBe(3); - expect(nextEnchoPeriod(5, 3)).toBe(5); // already over → stays put + it('increments by 1, with no upper bound', () => { + expect(nextEnchoPeriod(1)).toBe(2); + expect(nextEnchoPeriod(2)).toBe(3); + expect(nextEnchoPeriod(99)).toBe(100); }); }); @@ -521,11 +437,10 @@ describe('DecisionPrompt → /decision POST integration', () => { }); it('parent flow attaches force=true on the retry-after-409 path', async () => { - // T103/T104: when the server replies decision_locked or - // max_encho_exceeded the parent's submitDecision recurses with - // { force: true } after the operator confirms. The body must carry - // that flag through to the server so the second attempt isn't - // also rejected. + // T103: when the server replies decision_locked the parent's + // submitDecision recurses with { force: true } after the operator + // confirms. The body must carry that flag through to the server so + // the second attempt isn't also rejected. const onSubmit = vi.fn((payload) => { const body = buildDecisionBody('kiken-voluntary', payload, 0, { force: true }); const password = resolveDecisionPassword('pw'); @@ -578,59 +493,6 @@ describe('DecisionPrompt → /decision POST integration', () => { }); }); -describe('+ / − encho button behaviour (T104 clamp invariants)', () => { - // End-to-end pin: the clamp on the + button must not let the count - // exceed maxEnchoPeriods even across repeated clicks, and the − button - // must not drop below 1. These compose the canIncrementEncho / - // nextEnchoPeriod / prevEnchoPeriod helpers. - - it('+ button: repeated clicks stop at the cap', () => { - let count = 1; - const maxEnchoPeriods = 3; - for (let i = 0; i < 10; i++) { - count = nextEnchoPeriod(count, maxEnchoPeriods); - } - expect(count).toBe(3); - }); - - it('+ button: when unlimited (max=0), repeated clicks keep climbing', () => { - let count = 1; - for (let i = 0; i < 5; i++) { - count = nextEnchoPeriod(count, 0); - } - expect(count).toBe(6); - }); - - it('− button: repeated clicks bottom out at 1, not 0', () => { - let count = 3; - for (let i = 0; i < 10; i++) { - count = prevEnchoPeriod(count); - } - expect(count).toBe(1); - }); - - it('the +/− pair is symmetric within the [1, max] window', () => { - const maxEnchoPeriods = 5; - let count = 1; - // Climb to cap - count = nextEnchoPeriod(count, maxEnchoPeriods); // 2 - count = nextEnchoPeriod(count, maxEnchoPeriods); // 3 - count = nextEnchoPeriod(count, maxEnchoPeriods); // 4 - count = nextEnchoPeriod(count, maxEnchoPeriods); // 5 - expect(count).toBe(5); - expect(canIncrementEncho(count, maxEnchoPeriods)).toBe(false); - // Step down to floor - count = prevEnchoPeriod(count); // 4 - count = prevEnchoPeriod(count); // 3 - count = prevEnchoPeriod(count); // 2 - count = prevEnchoPeriod(count); // 1 - expect(count).toBe(1); - // Try to go below - count = prevEnchoPeriod(count); // still 1 - expect(count).toBe(1); - }); -}); - describe('isBoutDecided / MAX_IPPONS_PER_SIDE', () => { // Kendo best-of-3: once either side reaches 2 ippons the bout ends. // isBoutDecided drives the disabled-prop on ippon-add buttons in both diff --git a/web-mobile/js/__tests__/admin_scoring_modal_barrel.test.jsx b/web-mobile/js/__tests__/admin_scoring_modal_barrel.test.jsx index 995a669f1..2178dc058 100644 --- a/web-mobile/js/__tests__/admin_scoring_modal_barrel.test.jsx +++ b/web-mobile/js/__tests__/admin_scoring_modal_barrel.test.jsx @@ -46,8 +46,6 @@ const EXPECTED_EXPORTS = [ 'buildDecisionBody', 'submitDecisionRequest', 'makeSubmitDecision', - 'shouldShowEnchoMaxBanner', - 'canIncrementEncho', 'nextEnchoPeriod', 'prevEnchoPeriod', 'initialEnchoPeriodsForMatch', diff --git a/web-mobile/js/__tests__/engi_flag_score.test.jsx b/web-mobile/js/__tests__/engi_flag_score.test.jsx index 41cf3fd70..579ef9c7c 100644 --- a/web-mobile/js/__tests__/engi_flag_score.test.jsx +++ b/web-mobile/js/__tests__/engi_flag_score.test.jsx @@ -43,13 +43,13 @@ describe('engiFlagScore', () => { describe('matchScoreStr; engi takes priority and everything else stays letters', () => { it('an engi match returns the numeric flag score, not ippon letters', () => { const m = { flagsA: 3, flagsB: 2, ipponsA: [], ipponsB: [] }; - expect(matchScoreStr(m, [], [])).toBe('2–3'); + expect(matchScoreStr(m)).toBe('2–3'); }); it('a non-engi individual match still returns ippon letters, never digits', () => { - const m = { status: 'completed' }; - const s = matchScoreStr(m, ['M', 'K'], ['D']); - expect(s).toBe('MK–D'); + const m = { status: 'completed', ipponsB: ['M', 'K'], ipponsA: ['D'] }; + const s = matchScoreStr(m); + expect(s).toBe('MK vs D'); expect(s).not.toMatch(/^\d/); }); @@ -60,7 +60,7 @@ describe('matchScoreStr; engi takes priority and everything else stays letters', subResults: [{ position: 0, winner: 'TeamB', sideA: 'P1', sideB: 'P2' }], teamResult: { shiroIV: 1, akaIV: 0, shiroPW: 2, akaPW: 1 }, }; - const s = matchScoreStr(m, [], []); + const s = matchScoreStr(m); expect(s).toBe('IV 1–0\nPW 2–1'); expect(s).not.toMatch(/^\d/); }); diff --git a/web-mobile/js/__tests__/render/individual_editor_config_matrix.render.test.jsx b/web-mobile/js/__tests__/render/individual_editor_config_matrix.render.test.jsx index ac7c3d4b4..b6fa95044 100644 --- a/web-mobile/js/__tests__/render/individual_editor_config_matrix.render.test.jsx +++ b/web-mobile/js/__tests__/render/individual_editor_config_matrix.render.test.jsx @@ -1,10 +1,10 @@ // mp-yqxn.1: individual (kendo) ScoreEditorModal config matrix — the same // matrix applied to the team editor, minus the axes that don't exist for // individuals (teamSize, teamMatchType): -// {format(+phase)} × {naginata: on, off} × {maxEnchoPeriods: 0, 2} +// {format(+phase)} × {naginata: on, off} // // Config reaches the individual editor ONLY via the async competition fetch -// (window.API.fetchCompetitionDetails → naginata + maxEnchoPeriods); the +// (window.API.fetchCompetitionDetails → naginata); the // format/phase axes ride on the match stamps. // // REGRESSION-PIN ONLY (epic mp-yqxn constraint): DOM assertions prove nothing @@ -14,7 +14,7 @@ import React from 'react'; import { render, act, screen } from '@testing-library/react'; import { describe, it, expect, vi, beforeAll, afterAll } from 'vitest'; -import { FORMAT_PHASES, IMPOSSIBLE_FORMAT_PHASES, cellKey, NAGINATA, MAX_ENCHO, KENDO_SET, NAGINATA_SET } from './score_editor_matrix_axes.js'; +import { FORMAT_PHASES, IMPOSSIBLE_FORMAT_PHASES, cellKey, NAGINATA, KENDO_SET, NAGINATA_SET } from './score_editor_matrix_axes.js'; const STUBBED_GLOBALS = { isHikiwake: (_type) => false, @@ -60,13 +60,11 @@ afterAll(() => { // Shared axes + letter tables live in score_editor_matrix_axes.js (kept in // lockstep with the team-editor matrix). const CELLS = FORMAT_PHASES.flatMap(fp => - NAGINATA.flatMap(naginata => - MAX_ENCHO.map(maxEncho => ({ ...fp, naginata, maxEncho })) - ) + NAGINATA.map(naginata => ({ ...fp, naginata })) ); function cellName(c) { - return `${c.format}/${c.phase} naginata=${c.naginata} maxEncho=${c.maxEncho}`; + return `${c.format}/${c.phase} naginata=${c.naginata}`; } function makeMatch(cell, overrides = {}) { @@ -92,7 +90,6 @@ async function renderCell(cell, matchOverrides = {}, props = {}) { config: { format: cell.format, naginata: cell.naginata, - maxEnchoPeriods: cell.maxEncho, players: [], }, }); @@ -172,7 +169,7 @@ describe('individual editor IMPOSSIBLE CELLS (asserted, not skipped)', () => { it.each(IMPOSSIBLE_FORMAT_PHASES.map(fp => [`${fp.format} × phase "${fp.phase}"`, fp]))( '%s: product-impossible; the phase stamp alone decides the draw gate', async (_name, fp) => { - await renderCell({ ...fp, naginata: false, maxEncho: 0 }); + await renderCell({ ...fp, naginata: false }); expect(screen.getByTestId('scoring-modal-mark-draw').disabled).toBe(IMPOSSIBLE_EXPECT_DRAW_DISABLED[cellKey(fp)]); } ); @@ -186,7 +183,7 @@ describe('individual editor selfReport (public self-run surface)', () => { // self-run surface should be able to record a judges' decision is ruled // on by mp-yqxn.5. await renderCell( - { format: 'mixed', phase: 'pool', naginata: false, maxEncho: 0 }, + { format: 'mixed', phase: 'pool', naginata: false }, {}, { selfReport: true } ); diff --git a/web-mobile/js/__tests__/render/score_editor_matrix_axes.js b/web-mobile/js/__tests__/render/score_editor_matrix_axes.js index f93ef6182..f29d5483b 100644 --- a/web-mobile/js/__tests__/render/score_editor_matrix_axes.js +++ b/web-mobile/js/__tests__/render/score_editor_matrix_axes.js @@ -38,7 +38,6 @@ export const IMPOSSIBLE_FORMAT_PHASES = FORMATS export const cellKey = (fp) => `${fp.format}/${fp.phase}`; export const NAGINATA = [false, true]; -export const MAX_ENCHO = [0, 2]; // Independent hardcoded expectations for getIpponButtons(isNaginata) — NOT // computed from the component, so a letter-set change in diff --git a/web-mobile/js/__tests__/render/team_editor_config_matrix.render.test.jsx b/web-mobile/js/__tests__/render/team_editor_config_matrix.render.test.jsx index 47a478aea..1c3cd25f8 100644 --- a/web-mobile/js/__tests__/render/team_editor_config_matrix.render.test.jsx +++ b/web-mobile/js/__tests__/render/team_editor_config_matrix.render.test.jsx @@ -3,11 +3,11 @@ // Mounts the REAL team editor (via the ScoreEditorModal dispatch, exactly as // every mount site does) across the full config matrix // {format(+phase)} × {teamSize: 3, 5} × {teamMatchType: fixed, kachinuki} -// × {naginata: on, off} × {maxEnchoPeriods: 0, 2} +// × {naginata: on, off} // and asserts, per cell, WHICH controls render. Config reaches the editor two // ways, mirroring production: match-level stamps (compFormat, teamMatchType — // stamped by viewer_utils.compMatches) and the async competition fetch -// (naginata, maxEnchoPeriods — via window.API.fetchCompetitionDetails). +// (naginata — via window.API.fetchCompetitionDetails). // // Formats map to phases: playoffs has ONLY bracket matches; league and swiss // have ONLY pool-shaped matches; mixed has both. Cells outside that mapping @@ -21,7 +21,7 @@ import React from 'react'; import { render, act, fireEvent, screen } from '@testing-library/react'; import { describe, it, expect, vi, beforeAll, afterAll } from 'vitest'; -import { FORMAT_PHASES, IMPOSSIBLE_FORMAT_PHASES, cellKey, NAGINATA, MAX_ENCHO, KENDO_SET, NAGINATA_SET } from './score_editor_matrix_axes.js'; +import { FORMAT_PHASES, IMPOSSIBLE_FORMAT_PHASES, cellKey, NAGINATA, KENDO_SET, NAGINATA_SET } from './score_editor_matrix_axes.js'; const STUBBED_GLOBALS = { isHikiwake: (_type) => false, @@ -74,15 +74,13 @@ const MATCH_TYPES = ['fixed', 'kachinuki']; const CELLS = FORMAT_PHASES.flatMap(fp => TEAM_SIZES.flatMap(teamSize => MATCH_TYPES.flatMap(tmt => - NAGINATA.flatMap(naginata => - MAX_ENCHO.map(maxEncho => ({ ...fp, teamSize, tmt, naginata, maxEncho })) - ) + NAGINATA.map(naginata => ({ ...fp, teamSize, tmt, naginata })) ) ) ); function cellName(c) { - return `${c.format}/${c.phase} size=${c.teamSize} ${c.tmt} naginata=${c.naginata} maxEncho=${c.maxEncho}`; + return `${c.format}/${c.phase} size=${c.teamSize} ${c.tmt} naginata=${c.naginata}`; } function makeTeamMatch(cell, overrides = {}) { @@ -114,7 +112,6 @@ async function renderCell(cell, matchOverrides = {}, props = {}) { format: cell.format, teamMatchType: cell.tmt, naginata: cell.naginata, - maxEnchoPeriods: cell.maxEncho, players: [], }, }); @@ -152,8 +149,8 @@ describe('TeamScoreEditorModal config matrix (running match, admin surface)', () expect(letters).toEqual(cell.naginata ? NAGINATA_SET : KENDO_SET); // Encho affordance: always present, collapsed to the pill while no - // overtime is active. maxEnchoPeriods only caps the stepper (covered by - // the focused cap tests below). + // overtime is active. The stepper itself is unbounded above (mp-m4bn, + // covered by the focused stepper test below). expect(screen.queryByTestId('scoring-modal-encho-pill')).not.toBeNull(); // Daihyosen affordance is knockout-only (T141): phase "bracket", or the @@ -209,7 +206,7 @@ describe('TeamScoreEditorModal IMPOSSIBLE CELLS (asserted, not skipped)', () => it.each(IMPOSSIBLE_FORMAT_PHASES.map(fp => [`${fp.format} × phase "${fp.phase}"`, fp]))( '%s: product-impossible; the editor trusts the phase stamp', async (_name, fp) => { - await renderCell({ ...fp, teamSize: 5, tmt: 'fixed', naginata: false, maxEncho: 0 }); + await renderCell({ ...fp, teamSize: 5, tmt: 'fixed', naginata: false }); expect(!!screen.queryByTestId('scoring-modal-daihyosen-button')).toBe(IMPOSSIBLE_EXPECT_DAIHYOSEN[cellKey(fp)]); } ); @@ -218,7 +215,7 @@ describe('TeamScoreEditorModal IMPOSSIBLE CELLS (asserted, not skipped)', () => describe('TeamScoreEditorModal selfReport (public self-run surface)', () => { it('selfReport hides the admin decision controls', async () => { await renderCell( - { format: 'mixed', phase: 'pool', teamSize: 5, tmt: 'fixed', naginata: false, maxEncho: 0 }, + { format: 'mixed', phase: 'pool', teamSize: 5, tmt: 'fixed', naginata: false }, {}, { selfReport: true } ); @@ -229,7 +226,7 @@ describe('TeamScoreEditorModal selfReport (public self-run surface)', () => { }); }); -describe('TeamScoreEditorModal encho cap (maxEnchoPeriods)', () => { +describe('TeamScoreEditorModal encho stepper is unbounded (mp-m4bn)', () => { async function expandEncho() { await act(async () => { fireEvent.click(screen.getByTestId('scoring-modal-encho-pill')); @@ -239,24 +236,14 @@ describe('TeamScoreEditorModal encho cap (maxEnchoPeriods)', () => { }); } - it('maxEncho=2: the + stepper disables at the cap and the max banner shows', async () => { - await renderCell({ format: 'mixed', phase: 'bracket', teamSize: 5, tmt: 'fixed', naginata: false, maxEncho: 2 }); + it('the + stepper never disables, however many periods were fought', async () => { + // mp-m4bn: unbounded by design — see the nextEnchoPeriod docstring in + // admin_scoring_shared.jsx. This is the end-to-end pin: the button never + // disables and there is no "maximum reached" alert to show. + await renderCell({ format: 'mixed', phase: 'bracket', teamSize: 5, tmt: 'fixed', naginata: false }); await expandEncho(); const inc = screen.getByRole('button', { name: 'Increase overtime period count' }); - expect(inc.disabled).toBe(false); // ×1 < cap - await act(async () => { fireEvent.click(inc); }); // ×2 = cap - expect(inc.disabled).toBe(true); - expect(screen.getByRole('alert').textContent).toContain('Maximum encho periods reached'); - }); - - it('maxEncho=0 means UNCAPPED: the + stepper never disables', async () => { - // canIncrementEncho treats 0 as "no cap". mp-m4bn tracks the fact that no - // UI or import path can currently SET maxEnchoPeriods, so 0/uncapped is - // the only reachable state in practice. - await renderCell({ format: 'mixed', phase: 'bracket', teamSize: 5, tmt: 'fixed', naginata: false, maxEncho: 0 }); - await expandEncho(); - const inc = screen.getByRole('button', { name: 'Increase overtime period count' }); - for (let i = 0; i < 4; i++) { + for (let i = 0; i < 6; i++) { expect(inc.disabled).toBe(false); await act(async () => { fireEvent.click(inc); }); } @@ -266,7 +253,7 @@ describe('TeamScoreEditorModal encho cap (maxEnchoPeriods)', () => { }); describe('TeamScoreEditorModal kachinuki bout navigation', () => { - const KACHI_CELL = { format: 'playoffs', phase: 'bracket', teamSize: 5, tmt: 'kachinuki', naginata: false, maxEncho: 0 }; + const KACHI_CELL = { format: 'playoffs', phase: 'bracket', teamSize: 5, tmt: 'kachinuki', naginata: false }; it('RUNNING: only the current bout renders; the operator CANNOT navigate back to a scored earlier bout', async () => { // Server bout log: bout 1 fought (won by A1), bout 2 appended by diff --git a/web-mobile/js/__tests__/score_display.test.jsx b/web-mobile/js/__tests__/score_display.test.jsx index dc16481d4..36f929648 100644 --- a/web-mobile/js/__tests__/score_display.test.jsx +++ b/web-mobile/js/__tests__/score_display.test.jsx @@ -1,5 +1,7 @@ import { describe, it, expect } from 'vitest'; -import { formatIpponsScore, ipponsFromScore, matchStateCell } from '../bracket.jsx'; +import { readFileSync } from 'fs'; +import { resolve } from 'path'; +import { enchoLabel, formatIpponsScore, ipponsFromScore, matchStateCell } from '../bracket.jsx'; // Convention enforced across all match-list views: // SHIRO (sideB) is always displayed on the LEFT. @@ -9,23 +11,23 @@ import { formatIpponsScore, ipponsFromScore, matchStateCell } from '../bracket.j // formatIpponsScore(m.ipponsB, m.ipponsA, m.score, m.decision) // ^^^^^^^^ ^^^^^^^^ // SHIRO AKA -// so the result reads left-to-right as SHIRO_score–AKA_score. +// so the result reads left-to-right as SHIRO_score vs AKA_score. describe('formatIpponsScore', () => { describe('basic ippon formatting', () => { it('shows first-arg ippons on the left of the separator', () => { const score = formatIpponsScore(['M'], [], null, null); - // first arg scored M, second arg scored nothing → "M–·" - expect(score).toBe('M–·'); + // first arg scored M, second arg scored nothing → "M vs –" + expect(score).toBe('M vs –'); }); it('shows second-arg ippons on the right of the separator', () => { const score = formatIpponsScore([], ['K'], null, null); - expect(score).toBe('·–K'); + expect(score).toBe('– vs K'); }); it('shows both sides when both scored', () => { - expect(formatIpponsScore(['M', 'K'], ['D'], null, null)).toBe('MK–D'); + expect(formatIpponsScore(['M', 'K'], ['D'], null, null)).toBe('MK vs D'); }); it('returns empty string when no ippons and no score', () => { @@ -33,7 +35,7 @@ describe('formatIpponsScore', () => { }); it('filters out placeholder bullets', () => { - expect(formatIpponsScore(['M', '•'], ['•'], null, null)).toBe('M–·'); + expect(formatIpponsScore(['M', '•'], ['•'], null, null)).toBe('M vs –'); }); }); @@ -52,35 +54,27 @@ describe('formatIpponsScore', () => { expect(formatIpponsScore([], [], null, 'hikiwake')).toBe('X'); }); - it('returns the points for a scored equal draw (1–1), not bare X', () => { - // Item 6: operator entered M on one side, K on the other, then toggled - // hikiwake. The ippons are preserved on the server (the score.type is - // hikiwake but ipponsA/B are non-empty). Show the techniques so the - // viewer sees what was struck rather than losing that information. - expect(formatIpponsScore(['M'], ['K'], { type: 'hikiwake' }, null)).toBe('M–K'); + it('returns the points around the X for a scored equal draw (1–1)', () => { + // Item 6 + the middle-column rule: the ippons are preserved on the + // server, so show the techniques, AND the tie's X is the middle mark, + // so the viewer sees both what was struck and that it was a tie. + expect(formatIpponsScore(['M'], ['K'], { type: 'hikiwake' }, null)).toBe('M X K'); }); it('shows scored draw with one empty side using the placeholder dot', () => { - expect(formatIpponsScore(['M'], [], { type: 'hikiwake' }, null)).toBe('M–·'); + expect(formatIpponsScore(['M'], [], { type: 'hikiwake' }, null)).toBe('M X –'); }); - it('falls back to numeric score when ippons arrays are empty AND score has no ippon letters', () => { - const score = formatIpponsScore([], [], { type: 'ippon', winnerPts: 2, loserPts: 1 }, null); - expect(score).toBe('2–1'); - }); - - it('prefers the winner waza LETTERS over a count when score.ippons is present', () => { - // Per-side arrays absent (server bracket score) but score.ippons carries - // the winner's techniques. Show "MK–1", not "2–1". Loser is a count-only - // value in this degenerate path, so it stays numeric. Display-only: the - // numeric winnerPts/loserPts fields are untouched for logic that needs them. - const score = formatIpponsScore([], [], { type: 'ippon', winnerPts: 2, loserPts: 1, ippons: ['M', 'K'] }, null); - expect(score).toBe('MK–1'); - }); - - it('ignores empty/dot placeholders in score.ippons and falls back to the count', () => { - const score = formatIpponsScore([], [], { type: 'ippon', winnerPts: 1, loserPts: 0, ippons: ['•'] }, null); - expect(score).toBe('1–0'); + // Numbers are NOT a valid display for ippon. The per-side waza-letter + // arrays are the only source of an ippon score string: real data always + // carries them (callers derive via ipponsFromScore from scoreA/scoreB), + // and count-only score objects render NO score rather than digits. The + // numeric winnerPts/loserPts fields stay untouched for logic that needs + // them (activity checks, standings) — they just never render as ippon. + it('never renders numeric counts as an ippon score', () => { + expect(formatIpponsScore([], [], { type: 'ippon', winnerPts: 2, loserPts: 1 }, null)).toBe(''); + expect(formatIpponsScore([], [], { type: 'ippon', winnerPts: 2, loserPts: 1, ippons: ['M', 'K'] }, null)).toBe(''); + expect(formatIpponsScore([], [], { type: 'ippon', winnerPts: 1, loserPts: 0, ippons: ['•'] }, null)).toBe(''); }); }); @@ -103,15 +97,15 @@ describe('formatIpponsScore', () => { it('calling with (ipponsB, ipponsA) → left side shows SHIRO score', () => { const result = formatIpponsScore(akaMatch.ipponsB, akaMatch.ipponsA, akaMatch.score, akaMatch.decision); - // SHIRO scored nothing → left of separator is "·" + // SHIRO scored nothing → left cell is the no-points dash // AKA scored M → right of separator is "M" - expect(result).toBe('·–M'); + expect(result).toBe('– vs M'); }); it('calling with (ipponsA, ipponsB) would wrongly put AKA score on the left', () => { // This is the WRONG call order for SHIRO-left views. Test documents the mistake const wrong = formatIpponsScore(akaMatch.ipponsA, akaMatch.ipponsB, akaMatch.score, akaMatch.decision); - expect(wrong).toBe('M–·'); // M appears left, but AKA is visually on the right → misleading + expect(wrong).toBe('M vs –'); // M appears left, but AKA is visually on the right → misleading }); it('SHIRO-left view: result string reads SHIRO_score–AKA_score', () => { @@ -121,55 +115,105 @@ describe('formatIpponsScore', () => { score: null, decision: null, }; const result = formatIpponsScore(shiroMatch.ipponsB, shiroMatch.ipponsA, shiroMatch.score, shiroMatch.decision); - // SHIRO (left) scored M, AKA (right) scored K → "M–K" - expect(result).toBe('M–K'); + // SHIRO (left) scored M, AKA (right) scored K → "M vs K" + expect(result).toBe('M vs K'); }); }); - // FR-033: encho is rendered as a trailing " (E)" so the match list and - // bracket views surface that the match went to overtime. - describe('encho suffix', () => { - it('appends (E) to a normal ippon score when encho has a positive period count', () => { - expect(formatIpponsScore(['M'], ['K'], null, null, { periodCount: 1 })).toBe('M–K (E)'); + // The middle of a score string carries exactly ONE mark — X (tie), (E) + // (overtime), (DH) (rep bout) — or the plain "vs". X beats (E) + // because a match that went to encho cannot end tied; (DH) beats (E) + // because a daihyosen bout is one-point sudden death with no overtime. + describe('middle mark', () => { + it('places (E) between the scores when encho has a positive period count', () => { + expect(formatIpponsScore(['M'], ['K'], null, null, { periodCount: 1 })).toBe('M (E) K'); }); - it('appends (E) to a scored draw (shows points, not X)', () => { - // Item 6: scored equal draw shows techniques + encho suffix, not bare X. - expect(formatIpponsScore(['M'], ['K'], { type: 'hikiwake' }, null, { periodCount: 1 })).toBe('M–K (E)'); + it('a tie is X, never (E): stale draw+encho data renders the X alone', () => { + expect(formatIpponsScore(['M'], ['K'], { type: 'hikiwake' }, null, { periodCount: 1 })).toBe('M X K'); + expect(formatIpponsScore([], [], null, 'hikiwake', { periodCount: 2 })).toBe('X'); }); - it('appends (E) to a no-score draw (X is the scoreless-draw glyph)', () => { - expect(formatIpponsScore([], [], null, 'hikiwake', { periodCount: 2 })).toBe('X (E)'); + it('a daihyosen is (DH), never (E): DH bouts have no encho', () => { + // Winner side known: Ht (the hantei winner mark) sits in the winner's + // cell, (DH) alone holds the middle. + expect(formatIpponsScore([], [], null, 'daihyosen', { periodCount: 3 }, true, 'left')).toBe('Ht (DH) –'); }); - it('does not append (E) when periodCount is 0', () => { - expect(formatIpponsScore(['M'], ['K'], null, null, { periodCount: 0 })).toBe('M–K'); + it('does not mark the middle when periodCount is 0', () => { + expect(formatIpponsScore(['M'], ['K'], null, null, { periodCount: 0 })).toBe('M vs K'); }); it('is a no-op when encho argument is missing entirely', () => { - expect(formatIpponsScore(['M'], ['K'], null, null)).toBe('M–K'); + expect(formatIpponsScore(['M'], ['K'], null, null)).toBe('M vs K'); + }); + }); + + // Result marks (Kiken / Fus. / Ht) ride in the cell of the competitor they + // name when the caller supplies winnerSide (matchScoreStr derives it via + // winnerSideLR); without it they trail so the result is never dropped. + describe('side result marks', () => { + it('kiken marks the withdrawing (losing) side', () => { + expect(formatIpponsScore(['M'], [], null, 'kiken-voluntary', null, false, 'left')).toBe('M vs Kiken'); + expect(formatIpponsScore([], [], null, 'kiken-voluntary', null, false, 'right')).toBe('Kiken vs –'); + }); + + it('legacy bare "kiken" (old pool-matches.csv rows) marks the loser like kiken-voluntary', () => { + // CSV/JSON decisions are plain strings — only YAML migrates the legacy + // value — so old data can still serve "kiken" and must render the same. + expect(formatIpponsScore(['M'], [], null, 'kiken', null, false, 'left')).toBe('M vs Kiken'); + }); + + it('fusenpai marks the no-show (losing) side', () => { + // Winner on the left → the no-show's Fus. lands in the right cell. + expect(formatIpponsScore([], [], null, 'fusenpai', null, false, 'left')).toBe('– vs Fus.'); + }); + + it('fusensho places NO side mark: the bout badge carries it (documented Excel divergence)', () => { + // internal/export/suffix.go SideMarks folds fusensho in as a winner-side + // "Fus." because Excel has no badges; the JS mirror deliberately does + // not. Pin the divergence from this side too so a future change folding + // "Fus." back into the string cannot land silently. + expect(formatIpponsScore(['M'], [], null, 'fusensho', null, false, 'left')).toBe('M vs –'); + expect(formatIpponsScore([], [], null, 'fusensho', null, false, 'left')).toBe(''); + }); + + it('kiken during overtime: loser mark plus the (E) middle', () => { + expect(formatIpponsScore(['M'], [], null, 'kiken-injury', { periodCount: 1 }, false, 'left')).toBe('M (E) Kiken'); + }); + + it('falls back to a trailing mark when the winner side is unknown', () => { + expect(formatIpponsScore(['M'], [], null, 'kiken-voluntary', null, false)).toBe('M vs – Kiken'); }); }); // FIK Art. 7-5 / 29-6: a knockout match that remains tied in encho is // decided by referee hantei. The renderer must mark this distinctly so // it's not confused with an ippon-derived win. - describe('hantei (judges\' decision) suffix', () => { - it('appends "(E) Ht" for a 0-0 hantei-decided overtime', () => { - // Tied 0-0 in encho, AKA awarded by hantei. No ippons → suffix only. + describe('hantei (judges\' decision) winner mark', () => { + it('a 0-0 hantei-decided overtime puts Ht in the winner\'s cell', () => { + // Tied 0-0 in encho, SHIRO (left) awarded by hantei: the winner's cell + // carries the Ht mark, the middle carries (E), the loser shows the dot. + expect(formatIpponsScore([], [], null, null, { periodCount: 1 }, true, 'left')).toBe('Ht (E) –'); + expect(formatIpponsScore([], [], null, null, { periodCount: 1 }, true, 'right')).toBe('– (E) Ht'); + }); + + it('falls back to "(E) Ht" when the winner side is unknown', () => { expect(formatIpponsScore([], [], null, null, { periodCount: 1 }, true)).toBe('(E) Ht'); }); - it('combines (E) Ht for a hantei-decided overtime', () => { - // Realistic: tied with scores, then hantei chose a winner. Backend - // sends decidedByHantei=true alongside the tied ippons. - const result = formatIpponsScore(['M'], ['K'], null, null, { periodCount: 1 }, true); - expect(result).toBe('M–K (E) Ht'); + it('rides next to the winner\'s letters on a scored hantei overtime', () => { + const result = formatIpponsScore(['M'], ['K'], null, null, { periodCount: 1 }, true, 'left'); + expect(result).toBe('M Ht (E) K'); + }); + + it('trails when the winner side is unknown (never dropped)', () => { + expect(formatIpponsScore(['M'], ['K'], null, null, { periodCount: 1 }, true)).toBe('M (E) K Ht'); }); it('omits Ht when decidedByHantei is false/missing', () => { - expect(formatIpponsScore(['M'], ['K'], null, null, { periodCount: 1 }, false)).toBe('M–K (E)'); - expect(formatIpponsScore(['M'], ['K'], null, null, { periodCount: 1 })).toBe('M–K (E)'); + expect(formatIpponsScore(['M'], ['K'], null, null, { periodCount: 1 }, false)).toBe('M (E) K'); + expect(formatIpponsScore(['M'], ['K'], null, null, { periodCount: 1 })).toBe('M (E) K'); }); it('score.hantei is not read. Only the decidedByHantei param controls Ht', () => { @@ -177,15 +221,16 @@ describe('formatIpponsScore', () => { // API fields (ipponsA/B, scoreA/B). The backend never emits a `score` // object, so score.hantei can never appear in real match data. Only the // positional decidedByHantei arg matters. - expect(formatIpponsScore(['M'], ['K'], { type: 'ippon', hantei: true }, null, { periodCount: 1 })).toBe('M–K (E)'); - expect(formatIpponsScore(['M'], ['K'], { type: 'ippon', hantei: true }, null, { periodCount: 1 }, true)).toBe('M–K (E) Ht'); + expect(formatIpponsScore(['M'], ['K'], { type: 'ippon', hantei: true }, null, { periodCount: 1 })).toBe('M (E) K'); + expect(formatIpponsScore(['M'], ['K'], { type: 'ippon', hantei: true }, null, { periodCount: 1 }, true, 'left')).toBe('M Ht (E) K'); }); }); }); // Item 6 regression suite: scored-draw rendering (formatIpponsScore). -// Pinned so future changes to the hikiwake branch can't silently revert -// the display from "M–K" back to the bare-X glyph. +// Pinned so future changes to the hikiwake branch can't silently drop the +// struck techniques (they surround the X) or the X itself (the tie's one +// legal middle mark). describe('formatIpponsScore: hikiwake draw display (item 6)', () => { it('0–0 hikiwake (score.type) → bare X', () => { expect(formatIpponsScore([], [], { type: 'hikiwake' }, null)).toBe('X'); @@ -195,15 +240,17 @@ describe('formatIpponsScore: hikiwake draw display (item 6)', () => { expect(formatIpponsScore([], [], null, 'hikiwake')).toBe('X'); }); - it('1–1 hikiwake (ipponsA=[M], ipponsB=[K]) → "M–K" (points shown, no X)', () => { + it('1–1 hikiwake (ipponsA=[M], ipponsB=[K]) → "M X K" (points around the X)', () => { // Canonical scored-equal-draw case: both sides hit one ippon, operator - // toggled hikiwake. Server keeps ippons; display must show techniques. - expect(formatIpponsScore(['M'], ['K'], { type: 'hikiwake' }, null)).toBe('M–K'); + // toggled hikiwake. Server keeps ippons; display shows the techniques + // AND the draw mark in the middle. + expect(formatIpponsScore(['M'], ['K'], { type: 'hikiwake' }, null)).toBe('M X K'); }); - it('1–1 hikiwake with encho (periodCount>0) → "M–K (E)"', () => { - // Encho suffix must survive the scored-draw path unchanged. - expect(formatIpponsScore(['M'], ['K'], { type: 'hikiwake' }, null, { periodCount: 1 })).toBe('M–K (E)'); + it('1–1 hikiwake with stale encho data → still "M X K" (X beats (E))', () => { + // A tie cannot have gone to encho; if drifted data carries both, the + // draw mark wins and the overtime marker is dropped. + expect(formatIpponsScore(['M'], ['K'], { type: 'hikiwake' }, null, { periodCount: 1 })).toBe('M X K'); }); }); @@ -211,33 +258,33 @@ describe('formatIpponsScore: hikiwake draw display (item 6)', () => { // string (with "-" fallback), running → "vs" (the row highlight is the "now" // signal, NOT a centre dot), scheduled/other → "–". describe('matchStateCell: shared running-row centre cue', () => { - it('completed → the formatted ippon score (first arg = SHIRO/left)', () => { - // matchStateCell(m, ipponsB, ipponsA) → matchScoreStr → formatIpponsScore - // renders firstArg–secondArg, so ['M'],['K'] → "M–K". - expect(matchStateCell({ status: 'completed' }, ['M'], ['K'])).toBe('M–K'); + it('completed → the formatted ippon score (ipponsB = SHIRO = left)', () => { + // matchStateCell(m) → matchScoreStr derives the arrays from the match: + // ipponsB renders left, ipponsA right, so B:['M'] A:['K'] → "M vs K". + expect(matchStateCell({ status: 'completed', ipponsB: ['M'], ipponsA: ['K'] })).toBe('M vs K'); }); it('completed with no derivable score → "-" fallback', () => { // No ippons, no score, no decision → matchScoreStr returns "" → "-". - expect(matchStateCell({ status: 'completed' }, [], [])).toBe('-'); + expect(matchStateCell({ status: 'completed' })).toBe('-'); }); it('running → "vs" (no centre dot; the row highlight is the now signal)', () => { - expect(matchStateCell({ status: 'running' }, [], [])).toBe('vs'); + expect(matchStateCell({ status: 'running' })).toBe('vs'); }); it('scheduled → "–"', () => { - expect(matchStateCell({ status: 'scheduled' }, [], [])).toBe('–'); + expect(matchStateCell({ status: 'scheduled' })).toBe('–'); }); it('unknown/missing status → "–" (treated as not-yet-run)', () => { - expect(matchStateCell({ status: 'bye' }, [], [])).toBe('–'); - expect(matchStateCell({}, [], [])).toBe('–'); + expect(matchStateCell({ status: 'bye' })).toBe('–'); + expect(matchStateCell({})).toBe('–'); }); it('never emits a bare "●" for any state', () => { for (const status of ['completed', 'running', 'scheduled', 'bye', undefined]) { - expect(matchStateCell({ status }, [], [])).not.toContain('●'); + expect(matchStateCell({ status })).not.toContain('●'); } }); }); @@ -289,7 +336,7 @@ describe('team score string carries IV and PW', () => { { position: 1, sideA: 'Ryu Ichiro', sideB: 'Tora Ichiro', winner: 'Ryu Ichiro', ipponsA: ['M'] }, ], }; - expect(matchStateCell(m, [], [])).toBe('IV 0–5\nPW 0–5'); + expect(matchStateCell(m)).toBe('IV 0–5\nPW 0–5'); }); it('renders the tied IV and PW for a daihyosen-decided final', () => { @@ -298,6 +345,38 @@ describe('team score string carries IV and PW', () => { teamResult: { shiroIV: 0, akaIV: 0, shiroPW: 0, akaPW: 0 }, subResults: [{ position: -1, sideA: 'Ryu', sideB: 'Kaze', winner: 'Ryu', ipponsA: ['M'] }], }; - expect(matchStateCell(m, [], [])).toBe('IV 0–0\nPW 0–0'); + expect(matchStateCell(m)).toBe('IV 0–0\nPW 0–0'); + }); +}); + +// mp-m4bn: JS half of the shared Go/JS golden table for the overtime marker — +// see the `_comment` in encho_labels.json for why the table is shared and why +// it pins values, not source text. Go half: TestEnchoLabel_GoldenTable in +// internal/export/suffix_test.go. +describe('enchoLabel Go/JS mirror (mp-m4bn)', () => { + const table = JSON.parse( + readFileSync( + resolve(__dirname, '..', '..', '..', 'internal', 'export', 'testdata', 'encho_labels.json'), + 'utf8' + ) + ); + + // Load-bearing: vitest's it.each over an empty array silently produces + // zero tests (no red), so a degraded table needs its own failure. + it('the shared golden table is present and non-empty', () => { + expect( + table.cases?.length, + 'internal/export/testdata/encho_labels.json parsed to zero cases: the mirror would assert nothing' + ).toBeGreaterThan(0); + }); + + // enchoLabel is driven directly, so the table value is asserted EXACTLY — + // an unrelated formatIpponsScore change cannot redden this with a + // misleading "update both renderers" message. + it.each(table.cases)('periodCount $periodCount renders "$label"', ({ periodCount, label }) => { + expect( + enchoLabel({ periodCount }), + 'JS enchoLabel disagrees with the shared table; update BOTH renderers, not just this one' + ).toBe(label); }); }); diff --git a/web-mobile/js/__tests__/streaming_overlay_dh.test.jsx b/web-mobile/js/__tests__/streaming_overlay_dh.test.jsx index 8be7646d0..11e656d11 100644 --- a/web-mobile/js/__tests__/streaming_overlay_dh.test.jsx +++ b/web-mobile/js/__tests__/streaming_overlay_dh.test.jsx @@ -27,7 +27,7 @@ describe('StreamingOverlay DH signal', () => { let runtime; let StreamingOverlay; const savedGlobals = {}; - const STUBBED = ['isHikiwake', 'decisionSuffix', 'Term']; + const STUBBED = ['isHikiwake', 'matchMiddleMark', 'Term']; const runningMatch = (id) => ({ id, court: 'A', status: 'running', @@ -48,7 +48,7 @@ describe('StreamingOverlay DH signal', () => { ? { had: true, val: global.window[k] } : { had: false }; }); global.window.isHikiwake = () => false; - global.window.decisionSuffix = () => ''; + global.window.matchMiddleMark = () => ''; global.window.Term = function Term(props) { return { type: 'span', props, children: props?.children }; }; vi.resetModules(); ({ StreamingOverlay } = await import('../streaming_overlay.jsx')); diff --git a/web-mobile/js/admin_competition_bracket.jsx b/web-mobile/js/admin_competition_bracket.jsx index 87365bd96..af0c878c2 100644 --- a/web-mobile/js/admin_competition_bracket.jsx +++ b/web-mobile/js/admin_competition_bracket.jsx @@ -254,7 +254,7 @@ function AdminBracket({ c, t, bracket, onMoveCourt, onEditScore, tweaks, passwor // Enrich the bracket match with the competition metadata the shared scorer // (ScoreEditorModal / MatchDetailCard) reads off the match object: the raw // bracket.rounds entries carry none of it. Mirrors enrichPoolMatchWithComp: - // compId → decision endpoints + maxEncho/naginata fetch + // compId → decision endpoints + naginata fetch // compKind / → individual vs team editor routing // teamSize // phase → "bracket" makes isKnockoutPhase true: blocks hikiwake (no diff --git a/web-mobile/js/admin_competition_swiss.jsx b/web-mobile/js/admin_competition_swiss.jsx index c0727deec..04acfaeff 100644 --- a/web-mobile/js/admin_competition_swiss.jsx +++ b/web-mobile/js/admin_competition_swiss.jsx @@ -8,8 +8,8 @@ const { useState: useStateA, useEffect: useEffectA, useRef: useRefA } = React; // section. Extracted so the conditional logic ("which round, are // matches complete, can we generate next?") is unit-testable without // mounting AdminSwissRounds. Mirrors the admin_scoring_modal.jsx -// pattern (buildDecisionBody / shouldShowEnchoMaxBanner pure helpers -// exported for tests). +// pattern (buildDecisionBody / nextEnchoPeriod pure helpers exported +// for tests). // Returns the canonical match-ID prefix for a Swiss round. Matches // engine/swiss.go's `swissPoolName`/`swissMatchID`. Keep in sync. diff --git a/web-mobile/js/admin_pools.jsx b/web-mobile/js/admin_pools.jsx index 459f6840d..7792aab80 100644 --- a/web-mobile/js/admin_pools.jsx +++ b/web-mobile/js/admin_pools.jsx @@ -31,7 +31,7 @@ function poolMatchesForPool(poolMatches, poolName) { // raw MatchResult shape (id, status, sides, ippons, decision) with none // of the comp-level fields the modal needs: // * compKind / teamSize: picks TeamScoreEditorModal vs individual editor -// * compId: fetches competition details (maxEnchoPeriods, naginata) +// * compId: fetches competition details (naginata) // and is the path for decision/score endpoints // * compName: header eyebrow // * phase / poolName: header subtitle ("CompName · PoolName") diff --git a/web-mobile/js/admin_schedule_page.jsx b/web-mobile/js/admin_schedule_page.jsx index 2cbafc183..8432f46c3 100644 --- a/web-mobile/js/admin_schedule_page.jsx +++ b/web-mobile/js/admin_schedule_page.jsx @@ -102,16 +102,9 @@ const AdminTWMatch = React.memo(({ m, highlight, courts, onMove, onTimeChange }) serializer and the schedule row exposes bout details, append an "FS" badge to each affected bout cell. */}