From 0d74901229485ca7e5911d4b4eeaa55c8511e274 Mon Sep 17 00:00:00 2001 From: Ricardo Oliveira Date: Sun, 11 Oct 2026 01:08:17 +0100 Subject: [PATCH 01/29] docs(bc-tmdc): say where a team encounter's marks land, mark by mark Recording decisions said a team encounter's marks appear on the bracket and in the exported workbook. By the 2026-09-24 rulings the bracket card keeps only the winner's tick and (DH) (no Ht for a representative bout decided by the judges, no encounter-level (E) in kachinuki), and the workbook carries each bout's marks on its row, Kiken/Fus. beside the victory count, (DH) in the summary centre, and the representative bout's Ht on the Representative Bouts sheet. State each mark where it actually lands. Co-Authored-By: Claude Opus 5.5 --- docs/user-guide/court-operators/recording-decisions.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/user-guide/court-operators/recording-decisions.md b/docs/user-guide/court-operators/recording-decisions.md index a40168504..a8611c8eb 100644 --- a/docs/user-guide/court-operators/recording-decisions.md +++ b/docs/user-guide/court-operators/recording-decisions.md @@ -50,7 +50,7 @@ In the score editor, open the **Overtime** control and tick **Encho started**. A Completed results follow the paper score sheet's layout. The centre, between the two sides' points, only ever carries one mark: **vs** when no special mark applies, **X** for a tie, **(E)** for a match that went to overtime, or **(DH)** for a team encounter sent to a representative bout. The marks never combine: a match that went to encho cannot end in a tie, and a daihyosen has no overtime. A side with no points shows a plain dash. A match won by men in regulation reads **M vs –**, and the same win in overtime reads **M (E) –**, on the court console and the public viewer. The exported results workbook carries the same **(E)** marker in its centre column, but leaves a no-points cell blank rather than dashed. The marker is the same however many overtime periods were fought, because the counter records the number for the tournament log but results never show it. -Everything else is a result, written beside the competitor it names: **Ht** next to the winner of a judges' decision, **Kiken** next to the competitor who withdrew, **Fus.** next to a no-show, and also **Fus.** next to the winner when the match itself was decided by a fusensho. A match decided by hantei after a tied overtime reads **M Ht (E) K**, with the winner's mark on the winner's side of the centre. A team encounter shows its team totals in the score cell instead, so for those the marks appear on the bracket and in the exported workbook. +Everything else is a result, written beside the competitor it names: **Ht** next to the winner of a judges' decision, **Kiken** next to the competitor who withdrew, **Fus.** next to a no-show, and also **Fus.** next to the winner when the match itself was decided by a fusensho. A match decided by hantei after a tied overtime reads **M Ht (E) K**, with the winner's mark on the winner's side of the centre. A team encounter shows its team totals in the score cell instead, so its marks sit where they happened. A bout's own mark (**X**, **(E)**, or **Ht** for a representative bout decided by the judges) appears on that bout's row wherever the bouts are shown, such as the score editor, the match's page on the public viewer and the shiai-jo display. A withdrawal or no-show that decides the encounter shows **Kiken** or **Fus.** beside the team. On the bracket, the encounter shows its winner with a tick, and **(DH)** when a representative bout decided it. The exported workbook carries each numbered bout's marks on its row, **Kiken** or **Fus.** beside the team's victory count, and **(DH)** in the centre of the encounter's summary row; a knockout encounter's representative bout, with its **Ht**, is on the **Representative Bouts** sheet described below. ![Completed results on the public viewer: an overtime win reading M, the E marker, then a dash for the side with no points, and a hantei result with the Ht mark beside the winner.](../../screenshots/viewer-result-encho.png) From 5cd1c91f3ac22f21edf9a5550014222f9c6a38e0 Mon Sep 17 00:00:00 2001 From: Ricardo Oliveira Date: Sun, 11 Oct 2026 01:12:58 +0100 Subject: [PATCH 02/29] test(web-mobile): pin the shared-court console rules in render tests (bc-shcr) Two competitions on one shiaijo: the competition picker appears only with two or more and keeps an explicit pick; the Switch to nudge names the other competition's count only when the picked one has nothing running or scheduled here; the Another bout is running alert lists the extra running bouts with their competition; Finish + Start Next and the start after a withdrawal or no-show stay in the scored match's competition. Co-Authored-By: Claude Opus 5.5 --- ...admin_shiaijo_shared_court.render.test.jsx | 230 ++++++++++++++++++ 1 file changed, 230 insertions(+) create mode 100644 web-mobile/js/__tests__/render/admin_shiaijo_shared_court.render.test.jsx diff --git a/web-mobile/js/__tests__/render/admin_shiaijo_shared_court.render.test.jsx b/web-mobile/js/__tests__/render/admin_shiaijo_shared_court.render.test.jsx new file mode 100644 index 000000000..bad32e70d --- /dev/null +++ b/web-mobile/js/__tests__/render/admin_shiaijo_shared_court.render.test.jsx @@ -0,0 +1,230 @@ +import React from 'react'; +import { render, act, fireEvent } from '@testing-library/react'; +import { describe, it, expect, vi, beforeAll, afterAll, afterEach } from 'vitest'; +import { installWindowStubs } from '../helpers/stub_globals.js'; + +// bc-shcr: one shiaijo hosting two competitions. Pins only what the operator +// rulings call correct (2026-09-26/27: one match at a time on a court; the +// operator may switch the console to the other competition on the same court; +// an automatic advance stays in the scored match's competition). It does NOT +// pin which competition the console shows by itself when nothing was picked. + +// Captures the latest ScoreEditorModal props so a test can drive +// onSubmitAndNext / onAfterDecision directly. +const probe = { props: null }; + +const STUBBED_GLOBALS = { + // MODULE-EVAL-TIME: captured at import, so set before the dynamic import. + AdminTopbar: ({ children }) =>
{children}
, + Breadcrumbs: () => null, + ScoreEditorModal: (props) => { probe.props = props; return
; }, + CourtPicker: () => null, + BracketTree: () => null, + Icon: ({ name }) => {name}, + filterMatchesByCourt: (matches) => matches, + tournamentMatches: () => [], + API: { + fetchCompetitionDetails: vi.fn().mockResolvedValue(null), + fetchCourtMatches: vi.fn().mockResolvedValue([]), + subscribeToEvents: () => () => {}, + sendAnnouncement: vi.fn(), + updateMatchTime: vi.fn(), + startMatch: vi.fn(), + recordDecision: vi.fn().mockResolvedValue({ applied: true }), + reinstateCompetitor: vi.fn().mockResolvedValue({}), + revertMatchToQueue: vi.fn().mockResolvedValue(true), + }, + confirmDialog: vi.fn().mockResolvedValue(true), + PoolsViewer: () => null, + compMatches: () => [], +}; + +let restoreGlobals; +let AdminShiaijoPage; + +beforeAll(async () => { + restoreGlobals = installWindowStubs(STUBBED_GLOBALS); + await import('../../admin_shiaijo.jsx'); + AdminShiaijoPage = window.AdminShiaijoPage; +}); + +afterAll(() => restoreGlobals()); + +afterEach(() => { + window.tournamentMatches = STUBBED_GLOBALS.tournamentMatches; + probe.props = null; +}); + +// Two competitions on Shiaijo A: Cup (c1, individual pools) and League (c2). +const side = (id, name) => ({ id, name }); +const row = (id, status, at, over = {}) => ({ + id, compId: 'c1', compName: 'Cup', status, phase: 'pool', poolName: 'Pool A', court: 'A', scheduledAt: at, + sideA: side(`${id}-a`, `Aka ${id}`), sideB: side(`${id}-b`, `Shiro ${id}`), + ...over, +}); +const league = { compId: 'c2', compName: 'League' }; + +async function mountCourt(initial, onEditScore = vi.fn().mockResolvedValue({ status: 'running' })) { + const feed = { current: initial }; + window.tournamentMatches = () => feed.current; + let utils; + await act(async () => { + utils = render( + + ); + }); + const picker = () => utils.container.querySelector('select[aria-label="Select competition to officiate"]'); + return { + utils, + feed, + onEditScore, + picker, + pick: async (compId) => { await act(async () => { fireEvent.change(picker(), { target: { value: compId } }); }); }, + refresh: async () => { await act(async () => { utils.getByRole('button', { name: /refresh/i }).click(); }); }, + nudges: () => utils.queryAllByTestId('shiaijo-nudge').map((n) => n.querySelector('.shiaijo-nudge__text').textContent), + upNext: () => { + const title = [...utils.container.querySelectorAll('.section-title')].find((t) => t.textContent === 'Up next'); + return title ? title.parentElement.textContent : ''; + }, + }; +} + +describe('the competition picker on a shared court (bc-shcr)', () => { + it('is not offered when only one competition is on the court', async () => { + const c = await mountCourt([row('m1', 'running', '09:01'), row('m2', 'scheduled', '09:02')]); + expect(c.picker(), 'one competition: a plain heading, no picker').toBeNull(); + expect(c.utils.container.querySelector('.shiaijo-officiating__name').textContent).toBe('Cup'); + }); + + it('is offered with both competitions once a second one is on the court', async () => { + const c = await mountCourt([ + row('m1', 'running', '09:01'), + row('m2', 'scheduled', '09:02'), + row('m3', 'scheduled', '09:03', league), + ]); + expect(c.picker(), 'two competitions: the picker').toBeTruthy(); + expect([...c.picker().options].map((o) => o.textContent)).toEqual(['Cup', 'League']); + }); + + it("keeps an explicit pick and shows the picked competition's queue", async () => { + const c = await mountCourt([ + row('m1', 'running', '09:01'), + row('m2', 'scheduled', '09:02'), + row('m3', 'scheduled', '09:03', league), + ]); + await c.pick('c2'); + expect(c.picker().value).toBe('c2'); + expect(c.upNext(), "League's queue").toContain('Shiro m3'); + expect(c.upNext()).not.toContain('Shiro m2'); + // A refetch keeps the operator's pick even though Cup's bout still runs here. + await c.refresh(); + expect(c.picker().value, 'the pick is held across a refresh').toBe('c2'); + expect(c.upNext()).toContain('Shiro m3'); + }); +}); + +describe('the switch nudge on a shared court (bc-shcr)', () => { + it('says "Switch to" with the other competition\'s count only when the picked one has nothing running or scheduled here', async () => { + const c = await mountCourt([ + row('m1', 'completed', '09:01', { winner: side('m1-a', 'Aka m1') }), + row('m2', 'scheduled', '09:02', league), + row('m3', 'scheduled', '09:03', league), + ]); + await c.pick('c1'); + expect(c.nudges()).toEqual(['Switch to League: 2 matches waiting on this court.']); + expect(c.utils.getByTestId('shiaijo-nudge').classList.contains('alert--warn')).toBe(true); + }); + + it('stays quiet while the picked competition still has a scheduled match here', async () => { + const c = await mountCourt([ + row('m1', 'scheduled', '09:01'), + row('m2', 'scheduled', '09:02', league), + row('m3', 'scheduled', '09:03', league), + ]); + await c.pick('c1'); + expect(c.nudges()).toEqual(['2 League matches also waiting on this court.']); + expect(c.utils.getByTestId('shiaijo-nudge').classList.contains('alert--warn')).toBe(false); + }); + + it('stays quiet while the picked competition still has a running match here', async () => { + const c = await mountCourt([ + row('m1', 'running', '09:01'), + row('m2', 'scheduled', '09:02', league), + ]); + await c.pick('c1'); + expect(c.nudges()).toEqual(['1 League match also waiting on this court.']); + }); +}); + +describe('the "Another bout is running" alert on a shared court (bc-shcr)', () => { + it('lists every running bout beyond the first, with its competition', async () => { + const c = await mountCourt([ + row('m1', 'running', '09:01'), + row('m2', 'running', '09:02', league), + row('m3', 'running', '09:03', league), + ]); + const alert = c.utils.container.querySelector('.shiaijo-also-running'); + expect(alert, 'two bouts running on one court is flagged').toBeTruthy(); + expect(alert.querySelector('.shiaijo-also-running__title').textContent).toBe('Another bout is running on Shiaijo A'); + const items = [...alert.querySelectorAll('.shiaijo-also-running__list li')].map((li) => li.textContent); + expect(items).toEqual(['Shiro m2 vs Aka m2 · League', 'Shiro m3 vs Aka m3 · League']); + }); + + it('is not shown while only one bout runs on the court', async () => { + const c = await mountCourt([ + row('m1', 'running', '09:01'), + row('m2', 'scheduled', '09:02', league), + ]); + expect(c.utils.container.querySelector('.shiaijo-also-running')).toBeNull(); + }); +}); + +describe('the automatic advance stays in the scored competition on a shared court (bc-shcr)', () => { + // League's m2 is scheduled EARLIER than Cup's m3: the advance must still + // start Cup's next match, never the other competition's. + const sharedQueue = () => [ + row('m1', 'running', '09:01'), + row('m2', 'scheduled', '09:02', league), + row('m3', 'scheduled', '09:03'), + ]; + + it("Finish + Start Next starts the scored competition's next match", async () => { + const onEditScore = vi.fn().mockImplementation((_cid, _mid, patch) => + Promise.resolve({ applied: true, status: patch && patch.status })); + const c = await mountCourt(sharedQueue(), onEditScore); + expect(probe.props.match?.id).toBe('m1'); + await act(async () => { + await probe.props.onSubmitAndNext({ status: 'completed', winner: 'Aka m1' }); + }); + expect(c.onEditScore).toHaveBeenCalledTimes(2); + expect(c.onEditScore.mock.calls[0].slice(0, 2), 'the finish').toEqual(['c1', 'm1']); + expect(c.onEditScore.mock.calls[1].slice(0, 2), "Cup's next match, not League's earlier one").toEqual(['c1', 'm3']); + }); + + it("the start after a withdrawal starts the scored competition's next match", async () => { + const c = await mountCourt(sharedQueue()); + expect(probe.props.match?.id).toBe('m1'); + await act(async () => { + await probe.props.onAfterDecision({ + id: 'm1', sideA: 'Aka m1', sideB: 'Shiro m1', sideAId: 'm1-a', sideBId: 'm1-b', + winner: 'Shiro m1', winnerId: 'm1-b', status: 'completed', decision: 'kiken-voluntary', decisionBy: 'aka', + }); + }); + expect(c.onEditScore).toHaveBeenCalledTimes(1); + expect(c.onEditScore.mock.calls[0].slice(0, 2), "Cup's next match, not League's earlier one").toEqual(['c1', 'm3']); + }); + + it("the start after a no-show starts the scored competition's next match", async () => { + const c = await mountCourt(sharedQueue()); + await act(async () => { + await probe.props.onAfterDecision({ + id: 'm1', sideA: 'Aka m1', sideB: 'Shiro m1', sideAId: 'm1-a', sideBId: 'm1-b', + winner: 'Aka m1', winnerId: 'm1-a', status: 'completed', decision: 'fusenpai', decisionBy: 'shiro', + }); + }); + expect(c.onEditScore).toHaveBeenCalledTimes(1); + expect(c.onEditScore.mock.calls[0].slice(0, 2)).toEqual(['c1', 'm3']); + }); +}); From 85ab1c972697e4188863ae4b089eabc3727b91a7 Mon Sep 17 00:00:00 2001 From: Ricardo Oliveira Date: Sun, 11 Oct 2026 01:16:09 +0100 Subject: [PATCH 03/29] fix(a11y): tie the competition-config, sign-in and first-run labels to their controls (bc-lbla) The shared competition fields named no control: FieldLabel had no htmlFor, NumberField/TextField gave their input no id, and PillGroup's label named nothing while its pills showed their state only through the is-active class. The create form's and settings tab's hand-written fields (Display name, Day, Start time, Knockout qualifiers, Assigned shiaijo), the sign-in Password field and the first-run tournament form copied the same pattern, so getByLabel found none of them and a screen reader announced unnamed boxes and plain buttons. - stable_id.jsx: useStableId hoisted out of glossary.jsx into an import-only leaf; glossary.jsx and competition_fields.jsx import it. - competition_fields.jsx: FieldLabel takes htmlFor; NumberField and TextField mint an id; PillGroup's label gets an id and its row role="group" + aria-labelledby; PillButton is the one owner of a pill's is-active class and aria-pressed, both read from the same `active` (the isActive predicate, so a stored "" team match format announces Regular). - The create form and settings tab: static create-/settings- ids for Display name, Day (both the select and the date picker) and Start time; the two sanctioned hand-written pill rows become named groups of PillButtons. - app.jsx: the sign-in label points at #admin-password; the first-run fields get first-run-* ids; Tournament type names its group by its label. - The docs capture recipe finds the field by getByLabel and the pill inside its named group, checking the click through aria-pressed. - check-imports.mjs now checks competition_fields.jsx and glossary.jsx. No visible change. Co-Authored-By: Claude Opus 5.5 --- scripts/screenshots/recipes/adminsetup.mjs | 25 +-- web-mobile/check-imports.mjs | 4 + .../render/admin_competition.render.test.jsx | 40 +++- .../render/auth_modal_labels.render.test.jsx | 47 +++++ .../competition_fields_labels.render.test.jsx | 198 ++++++++++++++++++ .../create_tournament_labels.render.test.jsx | 56 +++++ web-mobile/js/admin_competition_settings.jsx | 47 ++--- web-mobile/js/admin_setup.jsx | 46 ++-- web-mobile/js/app.jsx | 25 ++- web-mobile/js/competition_fields.jsx | 64 ++++-- web-mobile/js/glossary.jsx | 23 +- web-mobile/js/stable_id.jsx | 24 +++ 12 files changed, 493 insertions(+), 106 deletions(-) create mode 100644 web-mobile/js/__tests__/render/auth_modal_labels.render.test.jsx create mode 100644 web-mobile/js/__tests__/render/competition_fields_labels.render.test.jsx create mode 100644 web-mobile/js/__tests__/render/create_tournament_labels.render.test.jsx create mode 100644 web-mobile/js/stable_id.jsx diff --git a/scripts/screenshots/recipes/adminsetup.mjs b/scripts/screenshots/recipes/adminsetup.mjs index 6cdc7305c..485353e27 100644 --- a/scripts/screenshots/recipes/adminsetup.mjs +++ b/scripts/screenshots/recipes/adminsetup.mjs @@ -24,20 +24,21 @@ const OVERVIEW_ID = 'up-to-2nd-dan'; const DRAW_ID = 'third-dan-and-above'; const KACHINUKI_ID = 'kachinuki-teams'; -// The create-competition form and the settings screen both render this -// field via competition_fields.jsx's PillGroup/`.field` markup -// (web-mobile/js/admin_setup.jsx:1176-1227), so a field is found by its -// visible label rather than a CSS hook the markup doesn't expose one for. +// Every field on the create-competition form is tied to its visible label +// (htmlFor/id, admin_setup.jsx's AdminCreateCompetition and +// competition_fields.jsx), so a field is found by the label a person reads. async function fillFieldByLabel(page, label, value) { - const field = page.locator('.field').filter({ has: page.locator('.field__label', { hasText: label }) }).first(); - await field.locator('input').first().fill(value); + await page.getByLabel(label, { exact: true }).fill(value); } -// PillGroup renders each option as a `.radio-pill` button carrying its -// label verbatim (web-mobile/js/competition_fields.jsx:68-86), used for -// both the Format and Competition-type groups on the create form. -async function clickPill(page, label) { - await page.locator('.radio-pill', { hasText: label }).first().click(); +// PillGroup renders each option row as a role="group" named by its label, +// and each option as a button carrying aria-pressed +// (competition_fields.jsx's PillGroup and PillButton), so a pill is found +// inside the group it belongs to and the click is checked through aria-pressed. +async function clickPill(page, group, label) { + const pill = page.getByRole('group', { name: group }).getByRole('button', { name: label, exact: true }); + await pill.click(); + await pill.and(page.locator('[aria-pressed="true"]')).waitFor(); } // Seed-rank input: aria-label is `Seed rank for ${p.name}` @@ -168,7 +169,7 @@ export const recipes = [ // Default format is Knockout only (COMPETITION_DEFAULTS.format, // competition_shape.jsx:1202); the committed shot has "Pools + // Knockout" selected. - await clickPill(page, 'Pools + Knockout'); + await clickPill(page, 'Format', 'Pools + Knockout'); }, }, diff --git a/web-mobile/check-imports.mjs b/web-mobile/check-imports.mjs index e2419e34b..1c1f69bc2 100644 --- a/web-mobile/check-imports.mjs +++ b/web-mobile/check-imports.mjs @@ -93,6 +93,10 @@ const CHECK_MODULES = [ // and side_marks.jsx; a typo'd or renamed export in any of them would fail // only in a native browser. 'admin_schedule_score_editor.jsx', + // bc-lbla: the shared competition-config controls import useStableId from + // the stable_id.jsx leaf, which the glossary's imports too. + 'competition_fields.jsx', + 'glossary.jsx', ]; // --------------------------------------------------------------------------- diff --git a/web-mobile/js/__tests__/render/admin_competition.render.test.jsx b/web-mobile/js/__tests__/render/admin_competition.render.test.jsx index 3ec9993b9..3103ce4e4 100644 --- a/web-mobile/js/__tests__/render/admin_competition.render.test.jsx +++ b/web-mobile/js/__tests__/render/admin_competition.render.test.jsx @@ -1,5 +1,5 @@ import React from 'react'; -import { render, act, fireEvent, waitFor } from '@testing-library/react'; +import { render, act, fireEvent, waitFor, screen, within } from '@testing-library/react'; import { describe, it, expect, vi, beforeAll, afterAll, afterEach } from 'vitest'; import { installWindowStubs } from '../helpers/stub_globals.js'; // Pure helper (no fetch, no DOM, no globals), imported directly so the @@ -1176,3 +1176,41 @@ describe('AdminCompetition page-head subtitle (bc-shfu)', () => { .toBe('Individual · 2 players · A'); }); }); + +// bc-lbla: the settings twin of the create form's hand-written fields is +// reachable by label, and its two hand-written pill rows are named groups +// whose pills say which are chosen (aria-pressed through PillButton). +describe('AdminSettings names its hand-written fields (bc-lbla)', () => { + const pressed = (group) => within(group).getAllByRole('button') + .filter((b) => b.getAttribute('aria-pressed') === 'true') + .map((b) => b.textContent.trim()); + + it('Display name, Day and Start time are reachable by their labels', async () => { + await mountSection('settings'); + expect(screen.getByLabelText('Display name').value).toBe('Mudansha'); + expect(screen.getByLabelText('Day').type).toBe('date'); + expect(screen.getByLabelText('Start time').value).toBe('09:00'); + }); + + it('Day is the day select when the tournament has days', async () => { + await mountSection('settings', { tournament: { date: '10-08-2026', durationDays: 2 } }); + expect(screen.getByLabelText('Day').tagName).toBe('SELECT'); + }); + + it('Assigned shiaijo is a named group, the orphan pill included', async () => { + const comp = makeCompetition({ courts: ['A', 'D'], format: 'knockout' }); + await mountSection('settings', { comp, tournament: { courts: ['A', 'B', 'C'] } }); + const group = screen.getByRole('group', { name: 'Assigned shiaijo (courts)' }); + expect(pressed(group)).toEqual(['Shiaijo (court) A', 'Shiaijo (court) D (not in tournament)']); + expect(within(group).getByRole('button', { name: 'Shiaijo (court) B' }).getAttribute('aria-pressed')).toBe('false'); + }); + + it('Knockout qualifiers is a named group; a stored "" announces Standard', async () => { + const comp = makeCompetition({ format: 'mixed', poolSizeMode: 'min', extraQualifiers: '' }); + await mountSection('settings', { comp }); + const group = screen.getByRole('group', { name: 'Knockout qualifiers' }); + expect(within(group).getAllByRole('button')).toHaveLength(3); + expect(pressed(group)).toHaveLength(1); + expect(within(group).getAllByRole('button')[0].getAttribute('aria-pressed')).toBe('true'); + }); +}); diff --git a/web-mobile/js/__tests__/render/auth_modal_labels.render.test.jsx b/web-mobile/js/__tests__/render/auth_modal_labels.render.test.jsx new file mode 100644 index 000000000..f68471cad --- /dev/null +++ b/web-mobile/js/__tests__/render/auth_modal_labels.render.test.jsx @@ -0,0 +1,47 @@ +// bc-lbla: the sign-in dialog's Password field is named by its visible label. +// Its input already carried id="admin-password" but the label had no htmlFor, +// so the field's accessible name fell back to the "••••••••" placeholder. +// +// AuthModal is not exported, so the real App is mounted on / with a probe in +// place of the home page, whose button is the Admin control (the harness of +// auth_modal_opened_tap.render.test.jsx). A keyboard-style click (detail 0) +// opens it, which the opened-tap guard never swallows. +import React from 'react'; +import { act, screen, fireEvent } from '@testing-library/react'; +import { describe, it, expect, vi, beforeAll, afterAll } from 'vitest'; +import { mountApp } from '../helpers/mount_app.js'; + +function ProbeViewerHome(props) { + return ; +} + +const STUBBED_GLOBALS = { + ViewerHome: ProbeViewerHome, + API: { + fetchTournament: vi.fn(async () => ({ name: 'London Cup', courts: ['A'] })), + fetchCompetitions: vi.fn(async () => []), + fetchAuthConfig: vi.fn(async () => ({ mode: 'file', resetEnabled: true })), + fetchAnnouncements: vi.fn(async () => []), + fetchCompetitionDetails: vi.fn(async () => null), + subscribeToEvents: vi.fn(() => () => {}), + reconnectEvents: vi.fn(), + resumeAfterAuth: vi.fn(), + }, +}; + +let unmount; + +beforeAll(async () => { + ({ unmount } = await mountApp({ path: '/', globals: STUBBED_GLOBALS })); +}); + +afterAll(() => { unmount(); }); + +describe('the sign-in dialog', () => { + it('names the password field by its visible label', async () => { + await act(async () => { fireEvent.click(screen.getByRole('button', { name: 'Open admin' })); }); + const input = screen.getByLabelText('Password'); + expect(input.id).toBe('admin-password'); + expect(input.type).toBe('password'); + }); +}); diff --git a/web-mobile/js/__tests__/render/competition_fields_labels.render.test.jsx b/web-mobile/js/__tests__/render/competition_fields_labels.render.test.jsx new file mode 100644 index 000000000..5d1851979 --- /dev/null +++ b/web-mobile/js/__tests__/render/competition_fields_labels.render.test.jsx @@ -0,0 +1,198 @@ +// bc-lbla: every competition-config control is reachable by its visible label, +// and every option pill says whether it is the chosen one. +// +// Before this, competition_fields.jsx's FieldLabel tied no label to its input, +// PillGroup's label named no control, and the pills carried their state only +// as the is-active class, so a screen reader announced a nameless text box and +// a row of plain buttons, and getByLabel found none of them. The create form's +// hand-written fields (Display name, Day, Start time, Knockout qualifiers, +// Assigned shiaijo) copied the same pattern. +// +// The shared components are rendered directly; the create form is mounted +// with real React, its window.* deps stubbed before the import (the same +// harness as admin_create_competition.render.test.jsx). +import React from 'react'; +import { render, screen, within, act, fireEvent } from '@testing-library/react'; +import { describe, it, expect, vi, beforeAll, afterAll } from 'vitest'; +import { installWindowStubs } from '../helpers/stub_globals.js'; +import { PillGroup, PillButton, NumberField, TextField } from '../../competition_fields.jsx'; +import { + LABEL_TEAM_MATCH_TYPE, TEAM_MATCH_TYPE_OPTIONS, teamMatchTypeActive, +} from '../../competition_shape.jsx'; + +const noop = () => {}; + +const pressedNames = (group) => within(group).getAllByRole('button') + .filter((b) => b.getAttribute('aria-pressed') === 'true') + .map((b) => b.textContent.trim()); + +describe('the shared competition fields name their controls', () => { + it('NumberField: the label names the number input', () => { + render(); + const input = screen.getByLabelText('Players per pool'); + expect(input.tagName).toBe('INPUT'); + expect(input.type).toBe('number'); + expect(input.value).toBe('3'); + }); + + it('NumberField: an optional field is named by its label and the suffix', () => { + render(); + expect(screen.getByLabelText('Team size (optional)').value).toBe('5'); + }); + + it('TextField: the label names the text input', () => { + render(); + const input = screen.getByLabelText('Player number prefix'); + expect(input.tagName).toBe('INPUT'); + expect(input.value).toBe('K'); + }); + + it('two fields on one page get two different ids', () => { + render( + <> + + + + ); + const a = screen.getByLabelText('Players per pool'); + const b = screen.getByLabelText('Winners per pool'); + expect(a.id).not.toBe(''); + expect(a.id).not.toBe(b.id); + }); + + it('PillGroup: the label names the row and only the active pill is pressed', () => { + render( + + ); + const group = screen.getByRole('group', { name: 'Format' }); + const pills = within(group).getAllByRole('button'); + expect(pills).toHaveLength(2); + // Every pill states its state: the inactive one says false, it does not + // just leave the attribute off. + expect(pills.map((b) => b.getAttribute('aria-pressed'))).toEqual(['false', 'true']); + expect(pressedNames(group)).toEqual(['Pools + Knockout']); + }); + + it('PillGroup: aria-pressed reads the isActive predicate, so a stored "" announces Regular', () => { + render( + teamMatchTypeActive(v, '')} + onChange={noop} + /> + ); + const group = screen.getByRole('group', { name: LABEL_TEAM_MATCH_TYPE }); + expect(pressedNames(group)).toEqual(['Regular']); + // The class and the announced state agree. + const regular = within(group).getByRole('button', { name: 'Regular' }); + expect(regular.className).toContain('is-active'); + }); + + it('PillGroup: a click still reports the option value', () => { + const onChange = vi.fn(); + render( + + ); + fireEvent.click(screen.getByRole('button', { name: 'League' })); + expect(onChange).toHaveBeenCalledWith('league'); + }); + + it('PillButton: passes extra props through and keeps the pill markup', () => { + render(Shiaijo (court) D); + const pill = screen.getByTestId('orphan-court-D'); + expect(pill.getAttribute('type')).toBe('button'); + expect(pill.className).toBe('radio-pill '); + expect(pill.getAttribute('aria-pressed')).toBe('false'); + expect(pill.style.color).toBe('red'); + }); +}); + +// --------------------------------------------------------------------------- +// The create-competition form's hand-written fields +// --------------------------------------------------------------------------- + +const Stub = (name) => { + const C = () =>
; + C.displayName = `Stub(${name})`; + return C; +}; + +const STUBBED_GLOBALS = { + AdminTopbar: Stub('AdminTopbar'), + Breadcrumbs: Stub('Breadcrumbs'), + BrandingManager: Stub('BrandingManager'), + SponsorsManager: Stub('SponsorsManager'), + isNonPublicOrigin: () => false, + buildCompetition: (cfg) => ({ ...cfg }), + addMinutes: (t) => t, + API: { + estimateCompetitionSchedule: vi.fn().mockResolvedValue(null), + getNumberPrefixDefault: vi.fn().mockResolvedValue({ numberPrefix: '' }), + }, +}; + +let restoreGlobals; +let AdminCreateCompetition; + +beforeAll(async () => { + restoreGlobals = installWindowStubs(STUBBED_GLOBALS); + await import('../../admin_setup.jsx'); + AdminCreateCompetition = window.AdminCreateCompetition; +}); + +afterAll(() => restoreGlobals()); + +async function mountForm(tournament) { + await act(async () => { + render( + + ); + }); +} + +describe('the create-competition form names its hand-written fields', () => { + it('Display name, Day (select) and Start time are reachable by their labels', async () => { + await mountForm({ date: '10-08-2026', durationDays: 2 }); + expect(screen.getByLabelText('Display name').tagName).toBe('INPUT'); + expect(screen.getByLabelText('Day').tagName).toBe('SELECT'); + expect(screen.getByLabelText('Start time').type).toBe('time'); + }); + + it('Day is still named when the tournament has no date and it is a date picker', async () => { + await mountForm({ date: '' }); + expect(screen.getByLabelText('Day').type).toBe('date'); + }); + + it('Assigned shiaijo is a named group whose pills say which are picked', async () => { + await mountForm({ date: '10-08-2026' }); + const group = screen.getByRole('group', { name: 'Assigned shiaijo (courts)' }); + expect(within(group).getAllByRole('button')).toHaveLength(4); + // The form starts on A + B. + expect(pressedNames(group)).toEqual(['Shiaijo (court) A', 'Shiaijo (court) B']); + await act(async () => { fireEvent.click(within(group).getByRole('button', { name: 'Shiaijo (court) C' })); }); + expect(pressedNames(group)).toContain('Shiaijo (court) C'); + }); + + it('Knockout qualifiers is a named group with one pressed pill', async () => { + await mountForm({ date: '10-08-2026' }); + await act(async () => { + fireEvent.click(within(screen.getByRole('group', { name: 'Format' })).getByRole('button', { name: 'Pools + Knockout' })); + }); + await act(async () => { + fireEvent.click(within(screen.getByRole('group', { name: 'Pool size is a' })).getAllByRole('button') + .find((b) => /minimum/i.test(b.textContent))); + }); + const group = screen.getByRole('group', { name: 'Knockout qualifiers' }); + expect(within(group).getAllByRole('button')).toHaveLength(3); + expect(pressedNames(group)).toHaveLength(1); + }); +}); diff --git a/web-mobile/js/__tests__/render/create_tournament_labels.render.test.jsx b/web-mobile/js/__tests__/render/create_tournament_labels.render.test.jsx new file mode 100644 index 000000000..708431d5e --- /dev/null +++ b/web-mobile/js/__tests__/render/create_tournament_labels.render.test.jsx @@ -0,0 +1,56 @@ +// bc-lbla: the first-run "Welcome to Bracket Creator" form names every field +// by its visible label, and its Tournament type buttons are a group named by +// the label above them. Before this, no label on the form was tied to its +// input, so getByLabel found none of them and a screen reader announced +// unnamed text boxes. +// +// CreateTournament is not exported: the real App renders it when the server +// has no tournament (fetchTournament answers null), so the App is mounted with +// that answer. +import React from 'react'; +import { act, screen, within, fireEvent } from '@testing-library/react'; +import { describe, it, expect, vi, beforeAll, afterAll } from 'vitest'; +import { mountApp } from '../helpers/mount_app.js'; + +const STUBBED_GLOBALS = { + API: { + fetchTournament: vi.fn(async () => null), + fetchCompetitions: vi.fn(async () => []), + fetchAuthConfig: vi.fn(async () => ({ mode: 'file', resetEnabled: true })), + fetchAnnouncements: vi.fn(async () => []), + fetchCompetitionDetails: vi.fn(async () => null), + subscribeToEvents: vi.fn(() => () => {}), + reconnectEvents: vi.fn(), + resumeAfterAuth: vi.fn(), + }, +}; + +let unmount; + +beforeAll(async () => { + ({ unmount } = await mountApp({ path: '/', globals: STUBBED_GLOBALS })); +}); + +afterAll(() => { unmount(); }); + +describe('the first-run tournament form', () => { + it('names every field by its visible label', () => { + expect(screen.getByText('Welcome to Bracket Creator')).toBeInTheDocument(); + expect(screen.getByLabelText('Tournament Name').tagName).toBe('INPUT'); + expect(screen.getByLabelText('Date').type).toBe('date'); + expect(screen.getByLabelText('Venue').tagName).toBe('INPUT'); + expect(screen.getByLabelText('Number of Shiaijo (courts)').type).toBe('number'); + expect(screen.getByLabelText('Admin Password').type).toBe('password'); + }); + + it('names the Tournament type group by its label, its buttons saying which is chosen', () => { + const group = screen.getByRole('group', { name: 'Tournament type' }); + expect(within(group).getByRole('button', { name: 'Officiated' }).getAttribute('aria-pressed')).toBe('true'); + expect(within(group).getByRole('button', { name: 'Self-run' }).getAttribute('aria-pressed')).toBe('false'); + }); + + it('names the self-run destructive-ops password once Self-run is chosen', async () => { + await act(async () => { fireEvent.click(screen.getByRole('button', { name: 'Self-run' })); }); + expect(screen.getByLabelText('Destructive-ops password (required for self-run)').type).toBe('password'); + }); +}); diff --git a/web-mobile/js/admin_competition_settings.jsx b/web-mobile/js/admin_competition_settings.jsx index b8a130014..cb1fc1925 100644 --- a/web-mobile/js/admin_competition_settings.jsx +++ b/web-mobile/js/admin_competition_settings.jsx @@ -51,7 +51,7 @@ import { LABEL_CHECK_IN, HINT_CHECK_IN, LABEL_NUMBER_PREFIX, cutNumberPrefix, numberPrefixHint, LABEL_COURTS, } from './competition_shape.jsx'; -import { PillGroup, CheckboxField, NumberField, TextField } from './competition_fields.jsx'; +import { PillGroup, PillButton, CheckboxField, NumberField, TextField } from './competition_fields.jsx'; import { seededRanks } from './admin_helpers.jsx'; const { useState: useStateA, useEffect: useEffectA, useRef: useRefA, useMemo: useMemoA } = React; @@ -1040,9 +1040,9 @@ function AdminSettings({ c, tournament, onUpdate, onBack, password, showToast, o beside an empty cell. Kept identical to admin_setup.jsx so the two screens lay the group out the same way, not just order it the same. */}
-
update("name", e.target.value)} />
+
update("name", e.target.value)} />
- + {/* When the tournament has a start date + durationDays, constrain */} {/* the competition date to the tournament's day list via a select. */} {/* Falls back to a free date picker when the tournament has no date. */} @@ -1066,6 +1066,7 @@ function AdminSettings({ c, tournament, onUpdate, onBack, password, showToast, o const outOfRange = local.date && !days.includes(local.date); return ( update("date", isoToDmy(e.target.value))} /> + update("date", isoToDmy(e.target.value))} /> ); })()}
Pick the competition day.
-
update("startTime", e.target.value)} />
+
update("startTime", e.target.value)} />
{/* Roster lock (kindLockReason above), NOT just isDrawReady/isStarted: a kind flip invalidates the roster itself, which exists long @@ -1300,32 +1301,29 @@ function AdminSettings({ c, tournament, onUpdate, onBack, password, showToast, o const previewLine = formatQualifierPreviewLine(activeShape); return (
- -
- - - + >{extraQualifiersLabel(EXTRA_QUALIFIERS_FILL_BRACKET)}
{extraQualifiersHint(local.extraQualifiers, local.poolSize)} @@ -1376,7 +1374,7 @@ function AdminSettings({ c, tournament, onUpdate, onBack, password, showToast, o
)}
- + {/* draw-ready lock: courts is output-affecting. Discard the draw to reassign. */} {isDrawReady && (
@@ -1389,17 +1387,16 @@ function AdminSettings({ c, tournament, onUpdate, onBack, password, showToast, o own flagged pill instead of vanishing from the screen while staying on disk. Deselecting it is the fix, so the pill stays clickable. */} -
+
{courtOptions.map(({ court: cc, selected, inTournament }) => ( - + >Shiaijo (court) {cc}{inTournament ? "" : " (not in tournament)"} ))}
{orphanedCourtsErr} diff --git a/web-mobile/js/admin_setup.jsx b/web-mobile/js/admin_setup.jsx index 8efa06d91..2bacb0351 100644 --- a/web-mobile/js/admin_setup.jsx +++ b/web-mobile/js/admin_setup.jsx @@ -148,7 +148,7 @@ import { LABEL_CHECK_IN, HINT_CHECK_IN, LABEL_NUMBER_PREFIX, HINT_NUMBER_PREFIX, cutNumberPrefix, LABEL_COURTS, } from './competition_shape.jsx'; -import { PillGroup, CheckboxField, NumberField, TextField } from './competition_fields.jsx'; +import { PillGroup, PillButton, CheckboxField, NumberField, TextField } from './competition_fields.jsx'; import { DurationInput } from './duration.jsx'; function AdminEditTournament({ tournament, onCancel, onSave, onLogout, onViewerMode, authConfig, password, showToast }) { @@ -1188,7 +1188,7 @@ function AdminCreateCompetition({ tournament, onCancel, onCreate, onLogout, onVi alongside `.row`, so this needs no new CSS. */}
- + {/* Placeholder shows the REAL default (deriveCompetitionName("", kind)), not an example, and tracks the live `kind` toggle so it flips Individual/Teams as the operator picks. A blank name is a @@ -1201,11 +1201,11 @@ function AdminCreateCompetition({ tournament, onCancel, onCreate, onLogout, onVi "Individual" already exists...` - naming a value the operator never typed, with the field still visibly empty. The naming example moved to the hint below so this slot can tell the truth. */} - { setName(e.target.value); setError(""); }} /> + { setName(e.target.value); setError(""); }} />
Example: Men's Individual
- + {/* When the tournament has a start date and durationDays, offer */} {/* a select over the derived day list so the competition date */} {/* is always within the tournament's range. For a single-day */} @@ -1216,6 +1216,7 @@ function AdminCreateCompetition({ tournament, onCancel, onCreate, onLogout, onVi if (days.length > 0) { return ( setDate(isoToDmy(e.target.value))} /> + setDate(isoToDmy(e.target.value))} /> ); })()}
For multi-day tournaments, specify which day this competition takes place.
- - { startTimeEditedRef.current = true; setStartTime(e.target.value); }} /> + + { startTimeEditedRef.current = true; setStartTime(e.target.value); }} />
@@ -1319,23 +1320,20 @@ function AdminCreateCompetition({ tournament, onCancel, onCreate, onLogout, onVi operator ruling: no federation names anywhere in this UI. */} {extraQualifiersRadioVisible(format, poolMode) && (
- -
- - - + >{extraQualifiersLabel(EXTRA_QUALIFIERS_FILL_BRACKET)}
{extraQualifiersHint(extraQualifiers, poolSize)} @@ -1390,10 +1388,10 @@ function AdminCreateCompetition({ tournament, onCancel, onCreate, onLogout, onVi )}
- -
+ +
{safeCourts.map((cc) => ( - + toggleCourt(cc)}>Shiaijo (court) {cc} ))}
{/* Same component the competition Settings screen renders, so the diff --git a/web-mobile/js/app.jsx b/web-mobile/js/app.jsx index 93cd0dd16..c5a912a44 100644 --- a/web-mobile/js/app.jsx +++ b/web-mobile/js/app.jsx @@ -1740,7 +1740,7 @@ function AuthModal({ onClose, onSuccess, onForgotPassword, resetEnabled, reauth
- + Set up your new tournament to get started.

- + {/* The form is noValidate (inline banner reports errors instead of native tooltips); `required` stays for assistive-tech semantics. */} - setName(e.target.value)} placeholder="e.g. London Cup 2026" required /> + setName(e.target.value)} placeholder="e.g. London Cup 2026" required />
-
+
{/* Picker bounds mirror admin_setup.jsx + admin_competition.jsx. */} {/* validateAndNormalizeDate enforces MIN_YEAR–MAX_YEAR; without */} {/* these bounds the user could pick 1850 and only learn on submit. */} - setDate(window.isoToDmy(e.target.value))} required /> + setDate(window.isoToDmy(e.target.value))} required />
-
setVenue(e.target.value)} />
+
setVenue(e.target.value)} />
- + - -
+ +
- + - + {children} + ); +} // PillGroup: label + radio-pill row + optional hint, the shape every // option-list control on both screens uses. @@ -65,20 +93,26 @@ // `
` in the tree, which carries the class's own // top margin and pushed the next control down by a few pixels on whichever // screen rendered it unconditionally. +// +// The label stays a `