diff --git a/jest.config.ts b/jest.config.ts index 339497d..b4a0f47 100644 --- a/jest.config.ts +++ b/jest.config.ts @@ -7,8 +7,9 @@ const config: Config = { moduleNameMapper: { '^@/(.*)$': '/src/$1', }, + transformIgnorePatterns: ['/node_modules/(?!@uidotdev/usehooks/)'], transform: { - '^.+\\.(ts|tsx)?$': 'ts-jest', + '^.+\\.[jt]sx?$': 'ts-jest', }, }; diff --git a/src/components/CompetitionListItem/CompetitionListItem.tsx b/src/components/CompetitionListItem/CompetitionListItem.tsx index 9edbc19..dd0c796 100644 --- a/src/components/CompetitionListItem/CompetitionListItem.tsx +++ b/src/components/CompetitionListItem/CompetitionListItem.tsx @@ -31,42 +31,40 @@ export const CompetitionListItem = ({ new Date(end_date).getTime() + 1000 * 60 * new Date().getTimezoneOffset(), ); - return ( - -
  • - {hasFlag(country_iso2) && ( -
    - {' '} - {getUnicodeFlagIcon(country_iso2)}{' '} -
    - )} -
    -

    {name}

    {' '} -

    - {formatDateRange(start_date, end_date)} - {' '}–{' '} - {city} -

    + const Item = variant === 'dropdown' ? 'div' : 'li'; + const content = ( + + {hasFlag(country_iso2) && ( +
    {getUnicodeFlagIcon(country_iso2)}
    + )} +
    +

    {name}

    {' '} +

    + {formatDateRange(start_date, end_date)} + {' '}–{' '} + {city} +

    +
    + {isLive && ( +
    +
    - {isLive && ( -
    - -
    - )} - {isBookmarked && !isLive && ( -
    - -
    - )} -
  • - + )} + {isBookmarked && !isLive && ( +
    + +
    + )} + ); + + return variant === 'dropdown' ? content : {content}; }; diff --git a/src/components/CompetitionSelect/CompetitionSelect.test.tsx b/src/components/CompetitionSelect/CompetitionSelect.test.tsx new file mode 100644 index 0000000..3c747a7 --- /dev/null +++ b/src/components/CompetitionSelect/CompetitionSelect.test.tsx @@ -0,0 +1,133 @@ +import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { MemoryRouter } from 'react-router-dom'; +import { fetchSearchCompetition } from '@/lib/api'; +import { CompetitionSelect } from './CompetitionSelect'; + +jest.mock('@/lib/api', () => ({ fetchSearchCompetition: jest.fn() })); +jest.mock('@/providers/UserSettingsProvider', () => ({ + useUserSettings: () => ({ effectiveTheme: 'light' }), +})); +jest.mock('react-i18next', () => ({ useTranslation: () => ({ t: (key: string) => key }) })); +const competition = { + id: 'Campulung2026', + name: 'Campulung Moldovenesc Open 2026', + start_date: '2026-10-31', + end_date: '2026-10-31', + country_iso2: 'RO', + city: 'Campulung Moldovenesc', +} as ApiCompetition; +const mockSearch = jest.mocked(fetchSearchCompetition); +beforeEach(() => { + jest.clearAllMocks(); + mockSearch.mockResolvedValue({ result: [competition] }); +}); + +it('exposes a named combobox and real options with usable active-descendant IDs', async () => { + render(, { wrapper: MemoryRouter }); + const input = screen.getByRole('combobox', { name: 'common.competitionSelect.placeholder' }); + await act(async () => { + await userEvent.type(input, 'Campulung'); + }); + const option = await screen.findByRole('option', { name: /Campulung Moldovenesc Open 2026/ }); + await act(async () => { + await userEvent.keyboard('{ArrowDown}'); + }); + expect(input).toHaveAttribute('aria-activedescendant', option.id); + expect(option.id).not.toBe(''); + expect(option).toHaveAttribute('aria-selected', 'false'); + expect(option.querySelector('a')).toBeNull(); +}); + +it.each(['keyboard', 'pointer'])('selects the same competition once by %s', async (method) => { + const onSelect = jest.fn(); + render(, { wrapper: MemoryRouter }); + await act(async () => { + await userEvent.type(screen.getByRole('combobox'), 'Campulung'); + }); + await screen.findByText(competition.name); + if (method === 'keyboard') + await act(async () => { + await userEvent.keyboard('{ArrowDown}{Enter}'); + }); + else + await act(async () => { + await userEvent.click(screen.getByText(competition.name)); + }); + expect(onSelect).toHaveBeenCalledTimes(1); + expect(onSelect).toHaveBeenCalledWith(competition.id); + expect(screen.queryByRole('listbox')).not.toBeInTheDocument(); + expect(screen.getByRole('combobox')).toHaveFocus(); +}); + +it('dismisses with Escape and can search and select again without accidental navigation', async () => { + const onSelect = jest.fn(); + render(, { wrapper: MemoryRouter }); + const input = screen.getByRole('combobox'); + await act(async () => { + await userEvent.type(input, 'Campulung'); + }); + await screen.findByText(competition.name); + await act(async () => { + await userEvent.keyboard('{Escape}'); + }); + await act(async () => { + await userEvent.keyboard('{Enter}'); + }); + expect(onSelect).not.toHaveBeenCalled(); + expect(input).toHaveFocus(); + await act(async () => { + await userEvent.clear(input); + }); + await act(async () => { + await userEvent.type(input, 'Campulung'); + }); + await screen.findByRole('option'); + await act(async () => { + await userEvent.keyboard('{ArrowDown}{Enter}'); + }); + expect(onSelect).toHaveBeenCalledWith(competition.id); +}); + +it('ignores a stale response after a newer query or after the search is cleared', async () => { + let resolveOld!: (value: { result: ApiCompetition[] }) => void; + mockSearch.mockImplementationOnce( + () => + new Promise((resolve) => { + resolveOld = resolve; + }), + ); + const onSelect = jest.fn(); + render(, { wrapper: MemoryRouter }); + const input = screen.getByRole('combobox'); + fireEvent.change(input, { target: { value: 'Old' } }); + await waitFor(() => expect(mockSearch).toHaveBeenCalledWith('Old')); + fireEvent.change(input, { target: { value: 'Campulung' } }); + await screen.findByText(competition.name); + await act(async () => + resolveOld({ result: [{ ...competition, id: 'Old2026', name: 'Old competition' }] }), + ); + expect(screen.queryByText('Old competition')).not.toBeInTheDocument(); + await act(async () => { + await userEvent.clear(input); + }); + expect(screen.queryByRole('option')).not.toBeInTheDocument(); + expect(onSelect).not.toHaveBeenCalled(); +}); + +it('cancels an unstarted search when navigation unmounts the control', async () => { + jest.useFakeTimers(); + try { + const { unmount } = render(, { + wrapper: MemoryRouter, + }); + fireEvent.change(screen.getByRole('combobox'), { target: { value: 'Campulung' } }); + unmount(); + await act(async () => { + jest.runAllTimers(); + }); + expect(mockSearch).not.toHaveBeenCalled(); + } finally { + jest.useRealTimers(); + } +}); diff --git a/src/components/CompetitionSelect/CompetitionSelect.tsx b/src/components/CompetitionSelect/CompetitionSelect.tsx index ff58920..6d6e934 100644 --- a/src/components/CompetitionSelect/CompetitionSelect.tsx +++ b/src/components/CompetitionSelect/CompetitionSelect.tsx @@ -1,8 +1,12 @@ -import { useCallback, useRef } from 'react'; +import { useCallback, useEffect, useRef } from 'react'; import { useTranslation } from 'react-i18next'; -import type { ClassNamesConfig, StylesConfig } from 'react-select'; +import { + components, + type ClassNamesConfig, + type OptionProps, + type StylesConfig, +} from 'react-select'; import AsyncSelect from 'react-select/async'; -import { OptionProps } from 'react-select/dist/declarations/src'; import { fetchSearchCompetition } from '@/lib/api'; import { useUserSettings } from '@/providers/UserSettingsProvider'; import { CompetitionListItem } from '../CompetitionListItem'; @@ -139,6 +143,11 @@ export const CompetitionSelect = ({ onSelect, className }: CompetitionSelectProp className={className} classNamePrefix="cg-select" cacheOptions + aria-label={t('common.competitionSelect.placeholder')} + getOptionLabel={(option) => option.name} + getOptionValue={(option) => option.id} + blurInputOnSelect={false} + loadingMessage={() => t('common.loading')} loadOptions={loadOptions} placeholder={t('common.competitionSelect.placeholder')} noOptionsMessage={({ inputValue }) => @@ -154,9 +163,11 @@ export const CompetitionSelect = ({ onSelect, className }: CompetitionSelectProp ); }; -export const CompetitionOption = ({ data }: OptionProps) => { - return ; -}; +export const CompetitionOption = (props: OptionProps) => ( + + + +); function useDebounced( fn: (arg: Input) => Promise, @@ -164,6 +175,13 @@ function useDebounced( ): (arg: Input) => Promise { const timeout = useRef | null>(null); + useEffect( + () => () => { + if (timeout.current) clearTimeout(timeout.current); + }, + [], + ); + return useCallback( (arg: Input) => { if (timeout.current) { diff --git a/src/components/Container/Container.tsx b/src/components/Container/Container.tsx index bc032f9..147d7e0 100644 --- a/src/components/Container/Container.tsx +++ b/src/components/Container/Container.tsx @@ -4,11 +4,18 @@ import { PropsWithChildren } from 'react'; interface ContainerProps extends PropsWithChildren { className?: string; fullWidth?: boolean; + scrollRestorationId?: string; } -export const Container = ({ children, className, fullWidth = false }: ContainerProps) => { +export const Container = ({ + children, + className, + fullWidth = false, + scrollRestorationId, +}: ContainerProps) => { return (
    ; +jest.mock('@/providers/WCIFProvider', () => ({ + useWCIF: () => ({ wcif: competitionMock }), +})); + +const makeRound = (id: string): Round => ({ + id, + format: 'a', + cutoff: null, + timeLimit: null, + linkedRounds: null, + participationRuleset: null, + results: [], + extensions: [], +}); + +const renderThreshold = async ( + eventId: EventId, + scope: RankingType, + value: number | null, + linked: boolean, +) => { + const rounds = Array.from({ length: linked ? 3 : 2 }, (_, i) => + makeRound(`${eventId}-r${i + 1}`), + ); + const sourceIds = rounds.slice(0, -1).map(({ id }) => id); + rounds.at(-1)!.participationRuleset = { + reservedPlaces: null, + participationSource: linked + ? { + type: 'linkedRounds', + roundIds: sourceIds, + resultCondition: { type: 'resultAchieved', scope, value }, + } + : { + type: 'round', + roundId: sourceIds[0], + resultCondition: { type: 'resultAchieved', scope, value }, + }, + }; + competitionMock = { events: [{ id: eventId, rounds, extensions: [] }] }; + const i18n = createInstance(); + await i18n.use(initReactI18next).init({ + lng: 'en', + fallbackLng: 'en', + resources: { + en: { translation: load(readFileSync('src/i18n/en/translation.yaml', 'utf8')) as object }, + }, + interpolation: { escapeValue: false }, + }); + return render( + + + + + , + ); +}; + +const cap = '; minimum of 25% of competitors must be eliminated'; + +describe.each([false, true])('actual translated advancement captions (linked=%s)', (linked) => { + const source = linked ? ' in dual rounds 1 & 2' : ''; + it.each(['single', 'average'])( + 'describes a null %s threshold as any successful result', + async (scope) => { + await renderThreshold('333', scope, null, linked); + expect( + screen.getByText(`Any successful ${scope}${source} advances to final${cap}`), + ).toBeInTheDocument(); + expect(document.body).not.toHaveTextContent('unknown result'); + expect(document.body).not.toHaveTextContent('<'); + }, + ); + + it('renders an FMC single threshold in moves', async () => { + await renderThreshold('333fm', 'single', 30, linked); + expect( + screen.getByText(`Singles better than 30 moves${source} advance to final${cap}`), + ).toBeInTheDocument(); + expect(document.body).not.toHaveTextContent('0.30'); + }); + + it('renders an FMC average threshold in hundredths of moves', async () => { + await renderThreshold('333fm', 'average', 3050, linked); + expect( + screen.getByText(`Averages better than 30.50 moves${source} advance to final${cap}`), + ).toBeInTheDocument(); + }); + + it('renders timed results without changing centisecond units', async () => { + await renderThreshold('333', 'average', 1234, linked); + expect( + screen.getByText(`Averages better than 12.34${source} advance to final${cap}`), + ).toBeInTheDocument(); + }); + + it('decodes multi-blind results and describes a better result rather than a numeric less-than score', async () => { + await renderThreshold('333mbf', 'single', 970036000, linked); + expect( + screen.getByText(`Singles better than 2/2 6:00${source} advance to final${cap}`), + ).toBeInTheDocument(); + }); +}); diff --git a/src/components/CutoffTimeLimitPanel/CutoffTimeLimitPanel.help.test.tsx b/src/components/CutoffTimeLimitPanel/CutoffTimeLimitPanel.help.test.tsx new file mode 100644 index 0000000..2842227 --- /dev/null +++ b/src/components/CutoffTimeLimitPanel/CutoffTimeLimitPanel.help.test.tsx @@ -0,0 +1,64 @@ +import { fireEvent, render, screen } from '@testing-library/react'; +import { Round } from '@wca/helpers'; +import { readFileSync } from 'fs'; +import { createInstance } from 'i18next'; +import { load } from 'js-yaml'; +import { I18nextProvider, initReactI18next } from 'react-i18next'; +import { MemoryRouter } from 'react-router-dom'; +import { CutoffTimeLimitPanel } from './CutoffTimeLimitPanel'; + +jest.mock('@/providers/WCIFProvider', () => ({ + useWCIF: () => ({ wcif: undefined }), +})); + +const round: Round = { + id: '333-r1', + format: 'a', + cutoff: { numberOfAttempts: 2, resultValue: 1200 }, + timeLimit: null, + linkedRounds: null, + participationRuleset: null, + results: [], + extensions: [], +}; + +it('renders actual translated help markup and supports repeated dismissal and reopening', async () => { + const i18n = createInstance(); + await i18n.use(initReactI18next).init({ + lng: 'en', + fallbackLng: 'en', + resources: { + en: { translation: load(readFileSync('src/i18n/en/translation.yaml', 'utf8')) as object }, + }, + interpolation: { escapeValue: false }, + }); + const { unmount } = render( + + + + + , + ); + const help = screen.getByRole('button', { name: /help/i, expanded: false }); + help.focus(); + for (let attempt = 0; attempt < 2; attempt++) { + fireEvent.click(help); + expect(help).toHaveAttribute('aria-expanded', 'true'); + expect(screen.getByText('cutoffs', { selector: 'strong' })).toBeInTheDocument(); + expect(document.body).not.toHaveTextContent(''); + fireEvent.keyDown(document, { key: 'Escape' }); + expect(help).toHaveAttribute('aria-expanded', 'false'); + expect(screen.queryByText('cutoffs', { selector: 'strong' })).not.toBeInTheDocument(); + expect(help).toHaveFocus(); + } + fireEvent.click(help); + fireEvent.click(help); + expect(help).toHaveAttribute('aria-expanded', 'false'); + fireEvent.click(help); + fireEvent.click(document.body); + expect(help).toHaveAttribute('aria-expanded', 'false'); + fireEvent.click(help); + unmount(); + fireEvent.keyDown(document, { key: 'Escape' }); + expect(screen.queryByText('cutoffs', { selector: 'strong' })).not.toBeInTheDocument(); +}); diff --git a/src/components/CutoffTimeLimitPanel/CutoffTimeLimitPanel.stories.tsx b/src/components/CutoffTimeLimitPanel/CutoffTimeLimitPanel.stories.tsx index f46eab7..fe43ccc 100644 --- a/src/components/CutoffTimeLimitPanel/CutoffTimeLimitPanel.stories.tsx +++ b/src/components/CutoffTimeLimitPanel/CutoffTimeLimitPanel.stories.tsx @@ -1,4 +1,5 @@ import type { Meta, StoryObj } from '@storybook/react'; +import { EventId } from '@wca/helpers'; import { getStorybookRoundFixture, makeStorybookCompetitionFixtureWithRoundUpdates, @@ -105,3 +106,72 @@ export const FinalRoundWithoutAdvancement: Story = { round: finalRoundCompetitionFixture.events[0].rounds[2], }, }; + +// Synthetic edge cases for browser UAT, not a captured real competition. +const makeThresholdFixture = ( + eventId: EventId, + scope: 'single' | 'average', + value: number | null, + linked = false, +) => { + const competition = makeStorybookCompetitionFixtureWithRoundUpdates({}); + const rounds = competition.events[0].rounds.slice(0, linked ? 3 : 2).map((round, index) => ({ + ...round, + id: `${eventId}-r${index + 1}`, + cutoff: null, + timeLimit: null, + linkedRounds: null, + participationRuleset: null, + results: [], + })); + const target = rounds.at(-1)!; + const participationRuleset = { + reservedPlaces: null, + participationSource: linked + ? { + type: 'linkedRounds' as const, + roundIds: rounds.slice(0, -1).map(({ id }) => id), + resultCondition: { type: 'resultAchieved' as const, scope, value }, + } + : { + type: 'round' as const, + roundId: rounds[0].id, + resultCondition: { type: 'resultAchieved' as const, scope, value }, + }, + }; + competition.events = [ + { + id: eventId, + rounds: [...rounds.slice(0, -1), { ...target, participationRuleset }], + extensions: [], + }, + ]; + return competition; +}; +const thresholdStory = ( + eventId: EventId, + scope: 'single' | 'average', + value: number | null, + linked = false, +): Story => { + const fixture = makeThresholdFixture(eventId, scope, value, linked); + return { + parameters: { competitionFixture: fixture }, + args: { round: fixture.events[0].rounds[0] }, + decorators: [ + (Story) => ( +
    +

    Synthetic advancement-caption fixture

    + +
    + ), + ], + }; +}; + +export const SyntheticNullSingleThreshold = thresholdStory('333', 'single', null); +export const SyntheticNullAverageThreshold = thresholdStory('333', 'average', null); +export const SyntheticLinkedNullAverageThreshold = thresholdStory('333', 'average', null, true); +export const SyntheticFmcSingleThreshold = thresholdStory('333fm', 'single', 30); +export const SyntheticFmcAverageThreshold = thresholdStory('333fm', 'average', 3050); +export const SyntheticMultiBlindThreshold = thresholdStory('333mbf', 'single', 970036000); diff --git a/src/components/CutoffTimeLimitPanel/CutoffTimeLimitPanel.tsx b/src/components/CutoffTimeLimitPanel/CutoffTimeLimitPanel.tsx index cb01f7b..64f02e6 100644 --- a/src/components/CutoffTimeLimitPanel/CutoffTimeLimitPanel.tsx +++ b/src/components/CutoffTimeLimitPanel/CutoffTimeLimitPanel.tsx @@ -1,10 +1,10 @@ import { Cutoff, Round, parseActivityCode } from '@wca/helpers'; import classNames from 'classnames'; -import { useMemo, useState } from 'react'; +import { useEffect, useMemo, useState } from 'react'; import { Trans, useTranslation } from 'react-i18next'; import { Link } from 'react-router-dom'; import { Popover } from 'react-tiny-popover'; -import { renderCentiseconds, renderCutoff } from '@/lib/results'; +import { renderCentiseconds, renderCutoff, renderResultByEventId } from '@/lib/results'; import { getEventRoundsForRound, joinLabels } from '@/lib/roundLabels'; import { getAdvancementConditionForRound } from '@/lib/wcif'; import { useWCIF } from '@/providers/WCIFProvider'; @@ -148,15 +148,16 @@ function renderLinkedRoundsAdvancementText( what: targetLabel, }); case 'resultAchieved': - return t('common.wca.advancement.linkedResultAchieved', { - scope: t(`common.wca.advancement.scope.${resultCondition.scope}`), - result: - resultCondition.value === null - ? t('common.wca.advancement.resultThresholdUnknown') - : renderCentiseconds(resultCondition.value), - rounds: sourceRoundsLabel, - what: targetLabel, - }); + return t( + resultCondition.value === null + ? 'common.wca.advancement.linkedAnyResultAchieved' + : 'common.wca.advancement.linkedResultAchieved', + { + ...getAdvancementResultText(t, advancement), + rounds: sourceRoundsLabel, + what: targetLabel, + }, + ); } } @@ -180,17 +181,32 @@ function renderSingleRoundAdvancementText( what: targetLabel, }); case 'resultAchieved': - return t('common.wca.advancement.resultAchieved', { - scope: t(`common.wca.advancement.scope.${resultCondition.scope}`), - result: - resultCondition.value === null - ? t('common.wca.advancement.resultThresholdUnknown') - : renderCentiseconds(resultCondition.value), - what: targetLabel, - }); + return t( + resultCondition.value === null + ? 'common.wca.advancement.anyResultAchieved' + : 'common.wca.advancement.resultAchieved', + { ...getAdvancementResultText(t, advancement), what: targetLabel }, + ); } } +function getAdvancementResultText( + t: ReturnType['t'], + advancement: NonNullable>, +) { + const { scope, value } = advancement.resultCondition; + if (value === null) { + return { scope: t(`common.wca.advancement.successfulScope.${scope}`) }; + } + + const { eventId } = parseActivityCode(advancement.sourceRoundIds[0]); + const result = renderResultByEventId(eventId, scope, value); + return { + scope: t(`common.wca.advancement.scope.${scope}`), + result: eventId === '333fm' ? t('common.wca.advancement.resultInMoves', { result }) : result, + }; +} + function getAdvancementTargetLabel( t: ReturnType['t'], eventRounds: Round[], @@ -212,6 +228,15 @@ function CutoffTimeLimitPopover({ cutoff }: { cutoff: Cutoff | null }) { const { t } = useTranslation(); const [open, setOpen] = useState(false); + useEffect(() => { + if (!open) return; + const dismissOnEscape = (event: KeyboardEvent) => { + if (event.key === 'Escape') setOpen(false); + }; + document.addEventListener('keydown', dismissOnEscape); + return () => document.removeEventListener('keydown', dismissOnEscape); + }, [open]); + return ( }} + components={{ b: , bold: }} />

    - }} /> + , bold: }} + />
    {cutoff && diff --git a/src/containers/About/About.tsx b/src/containers/About/About.tsx index 4c68988..dc9e15f 100644 --- a/src/containers/About/About.tsx +++ b/src/containers/About/About.tsx @@ -16,7 +16,7 @@ export function AboutContainer() { }, [t]); return ( - +

    {t('about.welcome')}

    diff --git a/src/containers/CompetitionPerson/CompetitionPerson.test.tsx b/src/containers/CompetitionPerson/CompetitionPerson.test.tsx new file mode 100644 index 0000000..746b3c0 --- /dev/null +++ b/src/containers/CompetitionPerson/CompetitionPerson.test.tsx @@ -0,0 +1,80 @@ +import { fireEvent, render, screen } from '@testing-library/react'; +import { Competition, Person } from '@wca/helpers'; +import { MemoryRouter, Route, Routes, useNavigate, useParams } from 'react-router-dom'; +import { WCIFContext } from '@/providers/WCIFProvider'; +import { CompetitionPersonContainer } from './CompetitionPerson'; + +jest.mock('react-i18next', () => ({ useTranslation: () => ({ t: (key: string) => key }) })); +jest.mock('@/containers/CompetitionPersonalResults', () => ({ + CompetitionPersonalResultsContent: () =>
    Person results
    , +})); +jest.mock('@/containers/PersonalBests', () => ({ + PersonalBestsContainer: () =>
    Person records
    , +})); +jest.mock('@/containers/PersonalSchedule', () => ({ + PersonalScheduleContent: () =>
    Person schedule
    , +})); +jest.mock('@/containers/PersonalSchedule/PersonalPageLayout', () => ({ + PersonalPageLayout: ({ children, person }: { children: React.ReactNode; person: Person }) => ( + <> +

    {person.name}

    + {children} + + ), +})); +jest.mock('@/providers/WCIFProvider', () => { + const React = jest.requireActual('react'); + const WCIFContext = React.createContext({}); + return { WCIFContext, useWCIF: () => React.useContext(WCIFContext) }; +}); +const wcif = { + persons: [{ registrantId: 1, name: 'Valid person', wcaId: '2026TEST01' }], +} as Competition; +const setTitle = jest.fn(); +function PersonPage() { + const { registrantId = '' } = useParams(); + return ; +} +function Navigation() { + const navigate = useNavigate(); + return ( + <> + + + + + ); +} +function renderPerson(path = '/competitions/Test/persons/999') { + return render( + + + + + Competition people
    } /> + } /> + + + , + ); +} +it.each(['', '/results', '/records'])( + 'explains a missing person and offers recovery on %s', + (suffix) => { + renderPerson(`/competitions/Test/persons/999${suffix}`); + expect(screen.getByRole('status')).toHaveTextContent('competition.person.notFound'); + fireEvent.click(screen.getByRole('link', { name: 'competition.person.backToCompetitors' })); + expect(screen.getByText('Competition people')).toBeInTheDocument(); + }, +); +it('updates the title and content when moving between valid and missing people and Back', () => { + renderPerson('/competitions/Test/persons/1'); + expect(screen.getByText('Person schedule')).toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: 'Missing person' })); + expect(screen.queryByText('Person schedule')).not.toBeInTheDocument(); + expect(screen.getByRole('status')).toBeInTheDocument(); + expect(setTitle).toHaveBeenLastCalledWith('competition.person.notFound'); + fireEvent.click(screen.getByRole('button', { name: 'Back' })); + expect(screen.getByText('Person schedule')).toBeInTheDocument(); + expect(setTitle).toHaveBeenLastCalledWith('Valid person'); +}); diff --git a/src/containers/CompetitionPerson/CompetitionPerson.tsx b/src/containers/CompetitionPerson/CompetitionPerson.tsx index f68aff2..c0249f0 100644 --- a/src/containers/CompetitionPerson/CompetitionPerson.tsx +++ b/src/containers/CompetitionPerson/CompetitionPerson.tsx @@ -1,7 +1,10 @@ import { Person } from '@wca/helpers'; import { Extension } from '@wca/helpers/lib/models/extension'; import { useEffect } from 'react'; +import { useTranslation } from 'react-i18next'; import { Link, useLocation } from 'react-router-dom'; +import { Container } from '@/components/Container'; +import { LinkButton } from '@/components/LinkButton'; import { CompetitionPersonalResultsContent } from '@/containers/CompetitionPersonalResults'; import { PersonalBestsContainer } from '@/containers/PersonalBests'; import { PersonalScheduleContent } from '@/containers/PersonalSchedule'; @@ -13,6 +16,7 @@ export interface CompetitionPersonContainerProps { } export function CompetitionPersonContainer({ registrantId }: CompetitionPersonContainerProps) { + const { t } = useTranslation(); const { competitionId, wcif, setTitle } = useWCIF(); const { pathname } = useLocation(); @@ -23,13 +27,24 @@ export function CompetitionPersonContainer({ registrantId }: CompetitionPersonCo | undefined; useEffect(() => { - if (person) { - setTitle(person.name); + if (wcif) { + setTitle(person?.name ?? t('competition.person.notFound')); } - }, [person, setTitle]); + }, [person, setTitle, t, wcif]); - if (!wcif || !person) { - return null; + if (!wcif) return null; + + if (!person) { + return ( + +

    {t('competition.person.notFound')}

    + +
    + ); } const activePage: PersonalPage = pathname.endsWith('/results') diff --git a/src/containers/CompetitionPersonalResults/CompetitionPersonalResults.test.tsx b/src/containers/CompetitionPersonalResults/CompetitionPersonalResults.test.tsx index 592e19d..d7713f9 100644 --- a/src/containers/CompetitionPersonalResults/CompetitionPersonalResults.test.tsx +++ b/src/containers/CompetitionPersonalResults/CompetitionPersonalResults.test.tsx @@ -340,7 +340,9 @@ describe('CompetitionPersonalResultsContainer', () => { fireEvent.click(screen.getByRole('row', { name: /2 Round 1/ })); expect(screen.getByRole('dialog')).toBeInTheDocument(); - expect(screen.getByRole('heading', { name: '#2' })).toBeInTheDocument(); + expect( + screen.getByRole('dialog', { name: 'Vikram Haldar · 3x3x3 Cube · Round 1 #2' }), + ).toBeInTheDocument(); expect(within(screen.getByRole('dialog')).getByText('Event')).toBeInTheDocument(); expect(within(screen.getByRole('dialog')).getByText('3x3x3 Cube')).toBeInTheDocument(); expect(within(screen.getByRole('dialog')).getByText('Round')).toBeInTheDocument(); diff --git a/src/containers/CompetitionPersonalResults/CompetitionPersonalResultsTable.tsx b/src/containers/CompetitionPersonalResults/CompetitionPersonalResultsTable.tsx index 42bfe44..578f8d2 100644 --- a/src/containers/CompetitionPersonalResults/CompetitionPersonalResultsTable.tsx +++ b/src/containers/CompetitionPersonalResults/CompetitionPersonalResultsTable.tsx @@ -1,6 +1,6 @@ import { ResultValue, EventId, Person } from '@wca/helpers'; import classNames from 'classnames'; -import { KeyboardEvent, useState } from 'react'; +import { KeyboardEvent, useEffect, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { CompetitionRoundResult } from '@/containers/CompetitionResults/CompetitionResultsTable'; import { ResultDetailsDialog } from '@/containers/CompetitionResults/ResultDetailsDialog'; @@ -58,7 +58,16 @@ const toCompetitionRoundResult = (result: PersonalRoundResult): CompetitionRound average: result.average, }); -export function CompetitionPersonalResultsTable({ +export function CompetitionPersonalResultsTable(props: CompetitionPersonalResultsTableProps) { + return ( + + ); +} + +function PersonalEventResultsTable({ competitionId, eventId, eventName, @@ -67,21 +76,30 @@ export function CompetitionPersonalResultsTable({ LinkComponent, }: CompetitionPersonalResultsTableProps) { const { t } = useTranslation(); - const [selectedResult, setSelectedResult] = useState(null); + const [selection, setSelection] = useState<{ + roundId: string; + trigger: HTMLTableRowElement; + } | null>(null); + const selectedResult = selection + ? roundResults.find((result) => result.roundId === selection.roundId) + : undefined; + useEffect(() => { + if (selection && !selectedResult) setSelection(null); + }, [selection, selectedResult]); const attemptColumnCount = Math.max(0, ...roundResults.map((result) => result.attempts.length)); const getRoundName = (result: PersonalRoundResult) => result.roundName ?? t('common.activityCodeToName.round', { roundNumber: result.roundNumber }); - const openDetailsDialog = (result: PersonalRoundResult) => { + const openDetailsDialog = (result: PersonalRoundResult, trigger: HTMLTableRowElement) => { if (!shouldOpenResultDetailsDialog()) { return; } - setSelectedResult(result); + setSelection({ roundId: result.roundId, trigger }); }; const openDetailsDialogFromKeyboard = (result: PersonalRoundResult) => (event: KeyboardEvent) => { - if (event.key !== 'Enter' && event.key !== ' ') { + if (event.target !== event.currentTarget || (event.key !== 'Enter' && event.key !== ' ')) { return; } @@ -90,7 +108,7 @@ export function CompetitionPersonalResultsTable({ } event.preventDefault(); - setSelectedResult(result); + setSelection({ roundId: result.roundId, trigger: event.currentTarget }); }; return ( @@ -137,7 +155,7 @@ export function CompetitionPersonalResultsTable({ key={result.roundId} className="cursor-pointer border-b border-tertiary-weak hover-transition hover:bg-gray-50 last:border-b-0 dark:hover:bg-gray-800 md:cursor-default" tabIndex={0} - onClick={() => openDetailsDialog(result)} + onClick={(event) => openDetailsDialog(result, event.currentTarget)} onKeyDown={openDetailsDialogFromKeyboard(result)}> setSelectedResult(null)} + returnFocusTo={selection?.trigger} + onClose={() => setSelection(null)} /> )}
    diff --git a/src/containers/CompetitionResults/CompetitionResults.test.tsx b/src/containers/CompetitionResults/CompetitionResults.test.tsx index 6f9e5f7..831821e 100644 --- a/src/containers/CompetitionResults/CompetitionResults.test.tsx +++ b/src/containers/CompetitionResults/CompetitionResults.test.tsx @@ -44,7 +44,7 @@ jest.mock('react-i18next', () => ({ if (key === 'competition.results.best') return 'Best'; if (key === 'competition.results.attempts') return 'Attempts'; if (key === 'competition.results.resultsSourceNote') { - return 'Results are merged from WCIF and WCA Live when available. WCA Live data may be delayed.'; + return 'Results combine WCA Live, WCIF, and published WCA results. Yellow highlights are provisional. Without Live, advancement uses next-round participants when available, otherwise calculated eligibility; incomplete source rounds remain provisional. Stored data may lag behind organizer changes.'; } if (key === 'competition.results.allResults') return 'All results'; if (key === 'competition.results.unknownCompetitor') { @@ -313,7 +313,7 @@ describe('CompetitionResultsContainer', () => { expect(screen.queryByRole('table', { name: 'Results' })).not.toBeInTheDocument(); expect( screen.getByText( - 'Results are merged from WCIF and WCA Live when available. WCA Live data may be delayed.', + 'Results combine WCA Live, WCIF, and published WCA results. Yellow highlights are provisional. Without Live, advancement uses next-round participants when available, otherwise calculated eligibility; incomplete source rounds remain provisional. Stored data may lag behind organizer changes.', ), ).toBeInTheDocument(); }); @@ -358,7 +358,7 @@ describe('CompetitionResultsContainer', () => { expect(screen.queryByLabelText('Attempts')).not.toBeInTheDocument(); expect( screen.getByText( - 'Results are merged from WCIF and WCA Live when available. WCA Live data may be delayed.', + 'Results combine WCA Live, WCIF, and published WCA results. Yellow highlights are provisional. Without Live, advancement uses next-round participants when available, otherwise calculated eligibility; incomplete source rounds remain provisional. Stored data may lag behind organizer changes.', ), ).toBeInTheDocument(); expect( @@ -367,9 +367,10 @@ describe('CompetitionResultsContainer', () => { .slice(1) .map((row) => within(row).getAllByRole('cell')[0].textContent), ).toEqual(['1', '2']); + // The stored average-of-five fixture has only three attempts per result. expect( within(screen.getByRole('row', { name: /1 Blake Thompson/ })).getByText('1'), - ).toHaveClass('bg-green-300'); + ).toHaveClass('bg-yellow-200'); expect( within(screen.getByRole('row', { name: /2 Nick Silvestri/ })).getByText('2'), ).not.toHaveClass('bg-green-300'); @@ -555,7 +556,7 @@ describe('CompetitionResultsContainer', () => { ).toHaveLength(2); expect( screen.getByText( - 'Results are merged from WCIF and WCA Live when available. WCA Live data may be delayed.', + 'Results combine WCA Live, WCIF, and published WCA results. Yellow highlights are provisional. Without Live, advancement uses next-round participants when available, otherwise calculated eligibility; incomplete source rounds remain provisional. Stored data may lag behind organizer changes.', ), ).toBeInTheDocument(); }); diff --git a/src/containers/CompetitionResults/CompetitionResultsTable.tsx b/src/containers/CompetitionResults/CompetitionResultsTable.tsx index 7eef876..d47739f 100644 --- a/src/containers/CompetitionResults/CompetitionResultsTable.tsx +++ b/src/containers/CompetitionResults/CompetitionResultsTable.tsx @@ -7,7 +7,7 @@ import { RoundFormat, } from '@wca/helpers'; import classNames from 'classnames'; -import { KeyboardEvent, ReactNode, useState } from 'react'; +import { KeyboardEvent, ReactNode, useEffect, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { AnchorLink, LinkRenderer } from '@/lib/linkRenderer'; import { renderResultByEventId } from '@/lib/results'; @@ -19,6 +19,7 @@ export interface CompetitionRoundResult { id: string | number; personId: number | null; personName?: string; + wcaId?: string | null; ranking: number | null; advancing: boolean; advancingQuestionable: boolean; @@ -162,6 +163,15 @@ const ResultValue = ({ ); +// Use the same identity as merged results, not a source-specific result ID. +// A stored result replaced by Live should keep its row, selection and focus. +const getResultKey = (result: CompetitionRoundResult) => + result.personId != null + ? `person-${result.personId}` + : result.wcaId + ? `wca-${result.wcaId}` + : `result-${result.id}`; + const isRosterOnlyResult = (result: CompetitionRoundResult) => result.ranking == null && result.attempts.length === 0 && @@ -206,7 +216,16 @@ const toCompetitionRoundResult = (result: Round['results'][number]): Competition }; }; -export function CompetitionResultsTable({ +export function CompetitionResultsTable(props: CompetitionResultsTableProps) { + return ( + + ); +} + +function RoundResultsTable({ competitionId, eventId, round, @@ -215,23 +234,32 @@ export function CompetitionResultsTable({ LinkComponent = AnchorLink, }: CompetitionResultsTableProps) { const { t } = useTranslation(); - const [selectedResult, setSelectedResult] = useState(null); + const [selection, setSelection] = useState<{ + resultKey: string; + trigger: HTMLTableRowElement; + } | null>(null); + const selectedResult = selection + ? results.find((result) => getResultKey(result) === selection.resultKey) + : undefined; + useEffect(() => { + if (selection && !selectedResult) setSelection(null); + }, [selection, selectedResult]); const primaryRankingType = getPrimaryRankingType(round.format); const showSecondaryBest = primaryRankingType === 'average'; const attemptColumnCount = Math.max(0, ...results.map((result) => result.attempts.length)); const sortedResults = sortResultsByRanking(results); - const openDetailsDialog = (result: CompetitionRoundResult) => { + const openDetailsDialog = (result: CompetitionRoundResult, trigger: HTMLTableRowElement) => { if (!shouldOpenResultDetailsDialog()) { return; } - setSelectedResult(result); + setSelection({ resultKey: getResultKey(result), trigger }); }; const openDetailsDialogFromKeyboard = (result: CompetitionRoundResult) => (event: KeyboardEvent) => { - if (event.key !== 'Enter' && event.key !== ' ') { + if (event.target !== event.currentTarget || (event.key !== 'Enter' && event.key !== ' ')) { return; } @@ -240,7 +268,7 @@ export function CompetitionResultsTable({ } event.preventDefault(); - setSelectedResult(result); + setSelection({ resultKey: getResultKey(result), trigger: event.currentTarget }); }; if (results.length === 0) { @@ -307,10 +335,10 @@ export function CompetitionResultsTable({ return ( openDetailsDialog(result)} + onClick={(event) => openDetailsDialog(result, event.currentTarget)} onKeyDown={openDetailsDialogFromKeyboard(result)}> setSelectedResult(null)} + returnFocusTo={selection?.trigger} + onClose={() => setSelection(null)} /> ); })()} diff --git a/src/containers/CompetitionResults/FallbackAdvancement.stories.tsx b/src/containers/CompetitionResults/FallbackAdvancement.stories.tsx new file mode 100644 index 0000000..ecd8087 --- /dev/null +++ b/src/containers/CompetitionResults/FallbackAdvancement.stories.tsx @@ -0,0 +1,120 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { Competition, Round } from '@wca/helpers'; +import { makeStorybookCompetitionFixtureWithRoundUpdates } from '@/storybook/competitionFixtures'; +import { makeCompetitionContainerDecorator } from '@/storybook/competitionStorybook'; +import { CompetitionResultsTable } from './CompetitionResultsTable'; +import { getRoundResultsFromSources } from './resultsProvider'; + +const makeFixture = (partial: boolean, actualParticipants: boolean): Competition => { + const competition = makeStorybookCompetitionFixtureWithRoundUpdates({}); + competition.id = 'SyntheticAdvancementFixture'; + competition.persons = Array.from({ length: 10 }, (_, index) => ({ + ...competition.persons[0], + registrantId: index + 1, + wcaId: null, + name: `Synthetic Solver ${index + 1}`, + assignments: [], + })); + const source: Round = { + ...competition.events[0].rounds[0], + cutoff: null, + format: 'a', + linkedRounds: null, + results: Array.from({ length: 10 }, (_, index) => { + const entered = !partial || index < 6; + return { + personId: index + 1, + ranking: entered ? index + 1 : null, + best: entered ? 1000 + index * 100 : 0, + average: partial ? 0 : 1000 + index * 100, + attempts: !entered + ? [] + : Array.from({ length: partial ? 1 : 5 }, () => ({ + value: 1000 + index * 100, + reconstruction: null, + })), + }; + }), + }; + const target: Round = { + ...competition.events[0].rounds[1], + linkedRounds: null, + participationRuleset: { + reservedPlaces: null, + participationSource: { + type: 'round', + roundId: source.id, + resultCondition: { type: 'percent', scope: 'average', value: 75 }, + }, + }, + results: actualParticipants + ? [1, 2, 3, 5, 6, 8].map((personId) => ({ + personId, + ranking: null, + best: 0, + average: 0, + attempts: [], + })) + : [], + }; + competition.events = [{ ...competition.events[0], rounds: [source, target] }]; + return competition; +}; + +const meta = { + title: 'Components/Competition/FallbackAdvancement', + component: CompetitionResultsTable, + decorators: [makeCompetitionContainerDecorator()], + tags: ['autodocs'], +} satisfies Meta; +export default meta; +type Story = StoryObj; + +const fixtureStory = ( + partial: boolean, + actualParticipants: boolean, + description: string, +): Story => { + const competition = makeFixture(partial, actualParticipants); + const event = competition.events[0]; + const round = event.rounds[0]; + return { + parameters: { competitionFixture: competition }, + args: { + competitionId: competition.id, + eventId: event.id, + round, + persons: competition.persons, + results: getRoundResultsFromSources({ + wcif: competition, + selectedRound: { event, round, roundNumber: 1 }, + wcaApiResults: [], + wcaLiveRound: null, + }), + }, + decorators: [ + (Story) => ( +
    +

    {description}

    + +
    + ), + ], + }; +}; + +export const SyntheticPartialRoster = fixtureStory( + true, + false, + 'Synthetic partial snapshot: ten stored participants, six with one of five attempts. The six currently qualifying ranks are yellow, not settled green.', +); +export const SyntheticCompletedRoster = fixtureStory( + false, + false, + 'Synthetic completed snapshot: ten participants with five attempts each. Seven calculated qualifiers are green.', +); +export const SyntheticActualParticipants = fixtureStory( + false, + true, + 'Synthetic next-round roster: participants 1, 2, 3, 5, 6, and 8 are green. Actual participation overrides calculated eligibility.', +); diff --git a/src/containers/CompetitionResults/ResultDetailsDialog.test.tsx b/src/containers/CompetitionResults/ResultDetailsDialog.test.tsx new file mode 100644 index 0000000..386820e --- /dev/null +++ b/src/containers/CompetitionResults/ResultDetailsDialog.test.tsx @@ -0,0 +1,386 @@ +import { act, fireEvent, render, screen, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { Person, Round } from '@wca/helpers'; +import { StrictMode } from 'react'; +import { Link, MemoryRouter, Route, Routes } from 'react-router-dom'; +import { CompetitionPersonalResultsTable } from '@/containers/CompetitionPersonalResults/CompetitionPersonalResultsTable'; +import { CompetitionResultsTable, CompetitionRoundResult } from './CompetitionResultsTable'; +import { ResultDetailsDialog } from './ResultDetailsDialog'; + +jest.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string) => + ({ + 'common.close': 'Close', + 'competition.results.allResults': 'All results', + })[key] ?? key, + }), +})); + +const persons = [ + { registrantId: 1, name: 'Competitor One' }, + { registrantId: 2, name: 'Competitor Two' }, +] as Person[]; +const results: CompetitionRoundResult[] = persons.map((person, index) => ({ + id: index + 1, + personId: person.registrantId, + ranking: index + 1, + attempts: [{ result: 1200 }], + best: 1200, + average: 1300, + advancing: false, + advancingQuestionable: false, +})); +const round = { id: '333-r1', format: 'a', results: [] } as unknown as Round; +const table = (roundId = '333-r1') => ( + +); + +async function openRow(name = 'Competitor One', key = '{Enter}') { + const row = screen.getByRole('row', { name: new RegExp(name) }); + act(() => row.focus()); + await act(() => userEvent.keyboard(key)); + return row; +} + +it('opens a named dialog, moves focus inside and traps forward/backward tabbing', async () => { + render({table()}); + const row = await openRow(); + const dialog = screen.getByRole('dialog', { name: 'Competitor One #1' }); + const close = within(dialog).getByRole('button', { name: 'Close' }); + const link = within(dialog).getByRole('link', { name: 'All results' }); + expect(close).toHaveFocus(); + await act(() => userEvent.tab()); + expect(link).toHaveFocus(); + await act(() => userEvent.tab({ shift: true })); + expect(close).toHaveFocus(); + await act(() => userEvent.tab({ shift: true })); + expect(link).toHaveFocus(); + await act(() => userEvent.tab()); + expect(close).toHaveFocus(); + // A programmatic focus escape is also redirected while the modal is open. + act(() => row.focus()); + expect(close).toHaveFocus(); +}); + +it.each(['Close', 'Escape', 'backdrop'])( + 'restores the invoking row after %s and repeated opening', + async (dismissal) => { + render( + + {table()} + , + ); + const row = await openRow(); + const dialog = screen.getByRole('dialog'); + if (dismissal === 'Close') + await act(() => userEvent.click(within(dialog).getByRole('button', { name: 'Close' }))); + else if (dismissal === 'Escape') await act(() => userEvent.keyboard('{Escape}')); + else await act(() => userEvent.click(dialog)); + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + expect(row).toHaveFocus(); + const secondRow = await openRow('Competitor Two', ' '); + expect(screen.getByRole('dialog', { name: 'Competitor Two #2' })).toBeInTheDocument(); + await act(() => userEvent.keyboard('{Escape}')); + expect(secondRow).toHaveFocus(); + }, +); + +it('keeps a single Close control focused and uses the latest close callback without resetting focus', async () => { + const firstClose = jest.fn(); + const nextClose = jest.fn(); + const props = { + competitionId: 'Test', + eventId: '333', + name: 'One', + primaryRankingType: 'average' as const, + result: results[0], + }; + const { rerender, unmount } = render(); + const close = screen.getByRole('button', { name: 'Close' }); + expect(close).toHaveFocus(); + await act(() => userEvent.tab()); + expect(close).toHaveFocus(); + await act(() => userEvent.tab({ shift: true })); + expect(close).toHaveFocus(); + rerender(); + await act(() => userEvent.keyboard('{Escape}')); + expect(nextClose).toHaveBeenCalledTimes(1); + expect(firstClose).not.toHaveBeenCalled(); + unmount(); + const event = new KeyboardEvent('keydown', { key: 'Tab', bubbles: true, cancelable: true }); + document.dispatchEvent(event); + expect(event.defaultPrevented).toBe(false); +}); + +it('releases focus and listeners on link navigation rather than restoring a removed row', async () => { + render( + + + + Personal page action} + /> + + , + ); + const row = await openRow(); + const link = within(screen.getByRole('dialog')).getByRole('link', { name: 'All results' }); + act(() => link.focus()); + await act(() => userEvent.keyboard('{Enter}')); + expect(screen.getByRole('button', { name: 'Personal page action' })).toBeInTheDocument(); + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + expect(row.isConnected).toBe(false); + await act(() => userEvent.tab()); + expect(screen.getByRole('button', { name: 'Personal page action' })).toHaveFocus(); + const event = new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true }); + document.dispatchEvent(event); + expect(event.defaultPrevented).toBe(false); +}); + +it('dismisses a stale selection on a round change and does not reopen it on Back', async () => { + const { rerender } = render({table()}); + await openRow(); + rerender({table('333-r2')}); + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + rerender({table()}); + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); +}); + +it('applies the same focus lifecycle and person isolation to personal result tables', async () => { + const personalTable = (person: Person) => ( + + ); + const { rerender } = render({personalTable(persons[0])}); + const row = screen.getByRole('row', { name: /Round 1/ }); + act(() => row.focus()); + await act(() => userEvent.keyboard('{Enter}')); + const dialog = screen.getByRole('dialog'); + expect(dialog).toHaveAccessibleName(); + expect(within(dialog).getByRole('button', { name: 'Close' })).toHaveFocus(); + await act(() => userEvent.tab()); + expect(within(dialog).getByRole('link', { name: 'All results' })).toHaveFocus(); + await act(() => userEvent.keyboard('{Escape}')); + expect(row).toHaveFocus(); + fireEvent.click(row); + rerender({personalTable(persons[1])}); + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); +}); + +it('keeps focus through live prop updates, calls the latest callback and restores the original opener', async () => { + const opener = document.createElement('button'); + opener.textContent = 'Original opener'; + document.body.appendChild(opener); + opener.focus(); + const firstClose = jest.fn(); + const nextClose = jest.fn(); + const props = { + competitionId: 'Test', + eventId: '333', + name: 'Competitor One', + person: persons[0], + primaryRankingType: 'average' as const, + result: results[0], + }; + const { rerender, unmount } = render( + + + , + ); + const link = within(screen.getByRole('dialog')).getByRole('link', { name: 'All results' }); + act(() => link.focus()); + rerender( + + + , + ); + expect(screen.getByRole('dialog', { name: 'Updated competitor #2' })).toBeInTheDocument(); + expect(link).toHaveFocus(); + await act(() => userEvent.keyboard('{Escape}')); + expect(nextClose).toHaveBeenCalledTimes(1); + expect(firstClose).not.toHaveBeenCalled(); + await act(async () => unmount()); + expect(opener).toHaveFocus(); + opener.remove(); +}); + +it('restores a pointer-invoked row even when the click did not focus the row', async () => { + render({table()}); + const row = screen.getByRole('row', { name: /Competitor Two/ }); + fireEvent.click(row); + expect(screen.getByRole('button', { name: 'Close' })).toHaveFocus(); + await act(() => userEvent.click(screen.getByRole('button', { name: 'Close' }))); + expect(row).toHaveFocus(); +}); + +it('does not steal focus from a replacement screen during unmount cleanup', async () => { + const opener = document.createElement('button'); + const replacement = document.createElement('button'); + document.body.append(opener, replacement); + opener.focus(); + const { unmount } = render( + , + ); + await act(async () => { + unmount(); + replacement.focus(); + }); + expect(replacement).toHaveFocus(); + opener.remove(); + replacement.remove(); +}); + +it('refreshes an open competition modal from current results while retaining focus and source-independent row identity', async () => { + const liveTable = (currentResults: CompetitionRoundResult[]) => ( + + + + ); + const { rerender } = render(liveTable(results)); + const row = await openRow(); + const link = within(screen.getByRole('dialog')).getByRole('link', { name: 'All results' }); + act(() => link.focus()); + // The result source changes its ID, but this is still the same registered person. + const refreshed = [ + { + ...results[0], + id: 'live-result-1', + ranking: 2, + attempts: [{ result: 900 }], + best: 900, + average: 1000, + }, + results[1], + ]; + rerender(liveTable(refreshed)); + const dialog = screen.getByRole('dialog', { name: 'Competitor One #2' }); + expect(within(dialog).getByText('10.00')).toBeInTheDocument(); + expect(within(dialog).getAllByText('9.00')).toHaveLength(2); + expect(within(dialog).queryByText('13.00')).not.toBeInTheDocument(); + expect(link).toHaveFocus(); + expect(screen.getByRole('row', { name: /Competitor One/ })).toBe(row); + expect(row).toHaveTextContent('10.00'); + await act(() => userEvent.keyboard('{Escape}')); + expect(row).toHaveFocus(); +}); + +it('dismisses a removed competition result without reopening it when results return', async () => { + const liveTable = (currentResults: CompetitionRoundResult[]) => ( + + + + ); + const { rerender } = render(liveTable(results)); + const row = await openRow(); + rerender(liveTable([results[1]])); + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + expect(row.isConnected).toBe(false); + rerender(liveTable(results)); + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + const event = new KeyboardEvent('keydown', { key: 'Tab', bubbles: true, cancelable: true }); + document.dispatchEvent(event); + expect(event.defaultPrevented).toBe(false); +}); + +it('refreshes and removes an open personal round modal from current round results', async () => { + const initial = [ + { + roundId: '333-r1', + roundName: 'Round 1', + roundNumber: 1, + ranking: 1, + attempts: [{ result: 1200 }], + best: 1200, + average: 1300, + advancing: false, + advancingQuestionable: false, + }, + ]; + const personalTable = (currentResults: typeof initial) => ( + + + + ); + const { rerender } = render(personalTable(initial)); + const row = screen.getByRole('row', { name: /Round 1/ }); + act(() => row.focus()); + await act(() => userEvent.keyboard('{Enter}')); + const link = within(screen.getByRole('dialog')).getByRole('link', { name: 'All results' }); + act(() => link.focus()); + const refreshed = [ + { ...initial[0], ranking: 2, attempts: [{ result: 900 }], best: 900, average: 1000 }, + ]; + rerender(personalTable(refreshed)); + const dialog = screen.getByRole('dialog', { name: 'Competitor One · 3x3x3 Cube · Round 1 #2' }); + expect(within(dialog).getByText('10.00')).toBeInTheDocument(); + expect(within(dialog).getAllByText('9.00')).toHaveLength(2); + expect(within(dialog).queryByText('13.00')).not.toBeInTheDocument(); + expect(link).toHaveFocus(); + await act(() => userEvent.keyboard('{Escape}')); + expect(row).toHaveFocus(); + await act(() => userEvent.keyboard('{Enter}')); + rerender(personalTable([])); + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + rerender(personalTable(refreshed)); + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); +}); diff --git a/src/containers/CompetitionResults/ResultDetailsDialog.tsx b/src/containers/CompetitionResults/ResultDetailsDialog.tsx index 6562e5f..0ad08e5 100644 --- a/src/containers/CompetitionResults/ResultDetailsDialog.tsx +++ b/src/containers/CompetitionResults/ResultDetailsDialog.tsx @@ -1,5 +1,5 @@ import { EventId, Person, RankingType } from '@wca/helpers'; -import { MouseEvent, useEffect } from 'react'; +import { MouseEvent, useId, useLayoutEffect, useRef } from 'react'; import { useTranslation } from 'react-i18next'; import { AnchorLink, LinkRenderer } from '@/lib/linkRenderer'; import { renderResultByEventId } from '@/lib/results'; @@ -24,6 +24,7 @@ interface ResultDetailsDialogProps { primaryRankingType: RankingType; result: CompetitionRoundResult; onClose: () => void; + returnFocusTo?: HTMLElement | null; LinkComponent?: LinkRenderer; } @@ -45,9 +46,15 @@ export function ResultDetailsDialog({ primaryRankingType, result, onClose, + returnFocusTo, LinkComponent = AnchorLink, }: ResultDetailsDialogProps) { const { t } = useTranslation(); + const titleId = useId(); + const dialogRef = useRef(null); + const closeButtonRef = useRef(null); + const onCloseRef = useRef(onClose); + onCloseRef.current = onClose; const attempts = result.attempts .map((attempt) => renderValue(eventId, 'single', attempt.result)) .filter(Boolean); @@ -70,29 +77,83 @@ export function ResultDetailsDialog({ event.stopPropagation(); }; - useEffect(() => { - const closeOnEscape = (event: KeyboardEvent) => { + useLayoutEffect(() => { + const dialog = dialogRef.current; + if (!dialog) return; + const previousFocus = + returnFocusTo ?? + (document.activeElement instanceof HTMLElement ? document.activeElement : null); + const focusInside = () => (closeButtonRef.current ?? dialog).focus({ preventScroll: true }); + const focusableElements = () => + Array.from( + dialog.querySelectorAll( + 'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]', + ), + ).filter((element) => { + const style = window.getComputedStyle(element); + return ( + element.tabIndex >= 0 && + !element.matches(':disabled') && + !element.closest('[hidden], [inert], [aria-hidden="true"]') && + style.display !== 'none' && + style.visibility !== 'hidden' + ); + }); + const handleKeyDown = (event: KeyboardEvent) => { if (event.key === 'Escape') { - onClose(); + event.preventDefault(); + event.stopPropagation(); + onCloseRef.current(); + } else if (event.key === 'Tab' && !event.altKey && !event.ctrlKey && !event.metaKey) { + event.preventDefault(); + const controls = focusableElements(); + const index = controls.indexOf(document.activeElement as HTMLElement); + const nextIndex = event.shiftKey + ? index <= 0 + ? controls.length - 1 + : index - 1 + : (index + 1) % controls.length; + (controls[nextIndex] ?? closeButtonRef.current ?? dialog).focus(); } }; + const containFocus = (event: FocusEvent) => { + if (event.target instanceof Node && !dialog.contains(event.target)) focusInside(); + }; - document.addEventListener('keydown', closeOnEscape); + document.addEventListener('keydown', handleKeyDown, true); + document.addEventListener('focusin', containFocus); + focusInside(); return () => { - document.removeEventListener('keydown', closeOnEscape); + document.removeEventListener('keydown', handleKeyDown, true); + document.removeEventListener('focusin', containFocus); + // Wait until removed route elements are detached. Do not steal focus from + // a new page/dialog, or restore during StrictMode's effect replay. + queueMicrotask(() => { + const active = document.activeElement; + if ( + !dialog.isConnected && + previousFocus?.isConnected && + (active === document.body || (active instanceof Node && dialog.contains(active))) + ) { + previousFocus.focus({ preventScroll: true }); + } + }); }; - }, [onClose]); + }, [returnFocusTo]); return (
    -

    +

    {title ?? `${name}${result.ranking ? ` #${result.ranking}` : ''}`}

    @@ -137,7 +198,8 @@ export function ResultDetailsDialog({
    ); }; diff --git a/src/containers/PersonalSchedule/Assignments.test.tsx b/src/containers/PersonalSchedule/Assignments.test.tsx new file mode 100644 index 0000000..4b6cc56 --- /dev/null +++ b/src/containers/PersonalSchedule/Assignments.test.tsx @@ -0,0 +1,135 @@ +import { act, render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { Person } from '@wca/helpers'; +import { PersonalScheduleContent } from './PersonalSchedule'; +import { getGroupedAssignmentsByDate } from './utils'; + +jest.mock('react-i18next', () => ({ useTranslation: () => ({ t: (key: string) => key }) })); +jest.mock('@/components', () => ({ DisclaimerText: () => null })); +jest.mock('@/providers/WCIFProvider', () => ({ useWCIF: () => ({ wcif: { id: 'Test' } }) })); +jest.mock('@/hooks/useNow/useNow', () => ({ useNow: () => 0 })); +jest.mock('@/lib/events', () => ({ isOfficialEventId: jest.fn() })); +jest.mock('@/lib/activityCodes', () => ({ parseActivityCodeFlexible: jest.fn() })); +jest.mock('@/lib/activities', () => ({ hasMultipleScheduleLocations: () => false })); +jest.mock('./utils', () => ({ + getGroupedAssignmentsByDate: jest.fn(() => [ + { date: 'Saturday', dateParts: [], assignments: [] }, + { date: 'Sunday', dateParts: [], assignments: [] }, + ]), +})); +jest.mock('./PersonalExtraAssignment', () => ({ ExtraAssignment: () => null })); +jest.mock('./PersonalNormalAssignment', () => ({ PersonalNormalAssignment: () => null })); +jest.mock('./PersonalPageLayout', () => ({ PersonalPageLayout: () => null })); + +const person = { registrantId: 1, assignments: [{ activityId: 1 }] } as Person; +beforeEach(() => { + localStorage.clear(); + jest + .mocked(getGroupedAssignmentsByDate) + .mockReset() + .mockReturnValue([ + { date: 'Saturday', dateParts: [], assignments: [] }, + { date: 'Sunday', dateParts: [], assignments: [] }, + ] as unknown as ReturnType); +}); + +it('makes personal schedule days operable by keyboard with a stable focused button', async () => { + render(); + const saturday = screen.getByRole('button', { name: 'Saturday' }); + expect(saturday).toHaveAttribute('aria-expanded', 'true'); + await act(() => userEvent.tab()); + expect(saturday).toHaveFocus(); + await act(() => userEvent.keyboard('{Enter}')); + expect(saturday).toHaveAttribute('aria-expanded', 'false'); + await act(() => userEvent.keyboard(' ')); + expect(saturday).toHaveAttribute('aria-expanded', 'true'); + expect(saturday).toHaveFocus(); +}); + +it('does not carry a collapsed day into a different person on repeated navigation', async () => { + const { rerender } = render(); + await act(() => userEvent.click(screen.getByText('Saturday'))); + rerender(); + expect(screen.getByRole('button', { name: 'Saturday' })).toHaveAttribute('aria-expanded', 'true'); + rerender(); + expect(screen.getByRole('button', { name: 'Saturday' })).toHaveAttribute( + 'aria-expanded', + 'false', + ); +}); + +const historicalAssignments = ['Saturday', 'Sunday'].map((date, index) => ({ + date, + dateParts: [], + approxDateTime: index, + assignments: [ + { + date, + activity: { id: index + 1, endTime: '2020-01-01T12:00:00Z' }, + assignment: { activityId: index + 1 }, + }, + ], +})) as unknown as ReturnType; + +it.each([[[]], [['Saturday']], [['Sunday']]])( + 'respects saved personal historical day choices %j', + (collapsed) => { + jest.mocked(getGroupedAssignmentsByDate).mockReturnValue(historicalAssignments); + localStorage.setItem( + 'collapse', + JSON.stringify({ 'Test-1': collapsed, 'Test-2': ['Saturday'] }), + ); + render(); + for (const { date } of historicalAssignments) { + expect(screen.getByRole('button', { name: date })).toHaveAttribute( + 'aria-expanded', + String(!collapsed.includes(date)), + ); + } + expect(JSON.parse(localStorage.getItem('collapse')!)).toEqual({ + 'Test-1': collapsed, + 'Test-2': ['Saturday'], + }); + }, +); + +it.each([false, true])( + 'retains personal historical keyboard choices across reload (mixed: %s)', + async (mixed) => { + jest.mocked(getGroupedAssignmentsByDate).mockReturnValue(historicalAssignments); + const { unmount } = render(); + expect(screen.getByRole('button', { name: 'Saturday' })).toHaveAttribute( + 'aria-expanded', + 'false', + ); + expect(screen.getByRole('button', { name: 'Sunday' })).toHaveAttribute( + 'aria-expanded', + 'false', + ); + await act(() => userEvent.tab()); + await act(() => userEvent.keyboard('{Enter}')); + await act(() => userEvent.tab()); + await act(() => userEvent.keyboard(' ')); + if (mixed) { + await act(() => userEvent.tab({ shift: true })); + await act(() => userEvent.keyboard('{Enter}')); + } + unmount(); + const { rerender } = render(); + expect(screen.getByRole('button', { name: 'Saturday' })).toHaveAttribute( + 'aria-expanded', + String(!mixed), + ); + expect(screen.getByRole('button', { name: 'Sunday' })).toHaveAttribute('aria-expanded', 'true'); + rerender(); + expect(screen.getByRole('button', { name: 'Sunday' })).toHaveAttribute( + 'aria-expanded', + 'false', + ); + rerender(); + expect(screen.getByRole('button', { name: 'Sunday' })).toHaveAttribute('aria-expanded', 'true'); + expect(JSON.parse(localStorage.getItem('collapse')!)).toEqual({ + 'Test-1': mixed ? ['Saturday'] : [], + }); + }, +); diff --git a/src/containers/PersonalSchedule/Assignments.tsx b/src/containers/PersonalSchedule/Assignments.tsx index 2068e9b..aa25806 100644 --- a/src/containers/PersonalSchedule/Assignments.tsx +++ b/src/containers/PersonalSchedule/Assignments.tsx @@ -1,5 +1,5 @@ import { Competition, Person } from '@wca/helpers'; -import { Fragment, useEffect, useMemo } from 'react'; +import { Fragment, useMemo } from 'react'; import { useTranslation } from 'react-i18next'; import { useCollapse } from '@/hooks/UseCollapse'; import { useNow } from '@/hooks/useNow/useNow'; @@ -24,37 +24,23 @@ export function Assignments({ wcif, person, showStationNumber }: AssignmentsProp const showRoom = useMemo(() => hasMultipleScheduleLocations(wcif), [wcif]); - const { collapsedDates, setCollapsedDates, toggleDate } = useCollapse( - key(wcif.id, person.registrantId), - ); - const now = useNow(15 * 1000); const scheduleDays = useMemo(() => getGroupedAssignmentsByDate(wcif, person), [person, wcif]); - useEffect(() => { - const today = new Date().getTime(); - - const collapse: string[] = []; - scheduleDays.forEach(({ date, assignments }) => { - const lastActivity = [...assignments].reverse().find((a) => a.activity)?.activity; - if (!lastActivity) { - return; - } - - const lastActivityEndTime = new Date(lastActivity.endTime); - // Collapse days that are more than 4 hours old. - if ( - today - lastActivityEndTime.getTime() > 1000 * 60 * 60 * 4 && - !collapsedDates.includes(date) - ) { - collapse.push(date); - } - }); - - setCollapsedDates((prev) => [...prev, ...collapse]); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [scheduleDays]); + const { collapsedDates, toggleDate } = useCollapse(key(wcif.id, person.registrantId), () => { + const today = Date.now(); + + return scheduleDays + .filter(({ assignments }) => { + const lastActivity = [...assignments].reverse().find((a) => a.activity)?.activity; + if (!lastActivity) return false; + + // Collapse days that are more than 4 hours old only as an initial default. + return today - new Date(lastActivity.endTime).getTime() > 1000 * 60 * 60 * 4; + }) + .map(({ date }) => date); + }); const isSingleDay = scheduleDays.length === 1; @@ -95,14 +81,20 @@ export function Assignments({ wcif, person, showStationNumber }: AssignmentsProp return ( {!isSingleDay && ( - toggleDate(date)} className="table-row-hover-bg"> - -
    + + +
    + + )} diff --git a/src/containers/PersonalSchedule/PersonHeader.test.tsx b/src/containers/PersonalSchedule/PersonHeader.test.tsx new file mode 100644 index 0000000..ddf738a --- /dev/null +++ b/src/containers/PersonalSchedule/PersonHeader.test.tsx @@ -0,0 +1,110 @@ +import { act, render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { Person } from '@wca/helpers'; +import { readFileSync } from 'fs'; +import { createInstance } from 'i18next'; +import { load } from 'js-yaml'; +import { I18nextProvider, initReactI18next } from 'react-i18next'; +import { PersonHeader } from './PersonHeader'; + +const competitionId = 'TestCompetition2026'; +const person = { + name: 'Test Competitor', + registrantId: 5, + countryIso2: 'FR', + roles: [], +} as unknown as Person; +const i18n = createInstance(); + +jest.mock('@/providers/WCIFProvider', () => ({ + useWCIF: () => ({ competitionId: 'TestCompetition2026' }), +})); + +beforeAll(async () => { + await i18n.use(initReactI18next).init({ + lng: 'en', + fallbackLng: 'en', + resources: { + en: { translation: load(readFileSync('src/i18n/en/translation.yaml', 'utf8')) as object }, + }, + }); +}); + +beforeEach(() => { + localStorage.clear(); +}); + +const header = (competitor = person) => ( + + + +); + +it('renders a translated, unpressed bookmark button with a decorative icon', () => { + render(header()); + + const button = screen.getByRole('button', { name: 'Bookmark competitor', pressed: false }); + expect(button).toBeEnabled(); + expect(button.querySelector('.fa-bookmark')).toHaveAttribute('aria-hidden', 'true'); +}); + +it('persists keyboard bookmark changes across remounts without changing another competition', async () => { + localStorage.setItem('pinnedPersons', JSON.stringify({ OtherCompetition2026: [5] })); + const { unmount } = render(header()); + + await act(() => userEvent.tab()); + expect(screen.getByRole('button', { name: 'Bookmark competitor', pressed: false })).toHaveFocus(); + await act(() => userEvent.keyboard('{Enter}')); + expect( + screen.getByRole('button', { name: 'Remove competitor bookmark', pressed: true }), + ).toHaveFocus(); + expect(JSON.parse(localStorage.getItem('pinnedPersons')!)).toEqual({ + OtherCompetition2026: [5], + [competitionId]: [5], + }); + + unmount(); + const { unmount: unmountPinned } = render(header()); + expect( + screen.getByRole('button', { name: 'Remove competitor bookmark', pressed: true }), + ).toBeEnabled(); + await act(() => userEvent.tab()); + await act(() => userEvent.keyboard(' ')); + expect(screen.getByRole('button', { name: 'Bookmark competitor', pressed: false })).toHaveFocus(); + expect(JSON.parse(localStorage.getItem('pinnedPersons')!)).toEqual({ + OtherCompetition2026: [5], + [competitionId]: [], + }); + + unmountPinned(); + render(header()); + expect(screen.getByRole('button', { name: 'Bookmark competitor', pressed: false })).toBeEnabled(); +}); + +it('reads the correct pressed state when navigating between competitors', () => { + localStorage.setItem('pinnedPersons', JSON.stringify({ [competitionId]: [5] })); + const { rerender } = render(header()); + expect( + screen.getByRole('button', { name: 'Remove competitor bookmark', pressed: true }), + ).toBeEnabled(); + + rerender(header({ ...person, registrantId: 6 })); + expect(screen.getByRole('button', { name: 'Bookmark competitor', pressed: false })).toBeEnabled(); + + rerender(header()); + expect( + screen.getByRole('button', { name: 'Remove competitor bookmark', pressed: true }), + ).toBeEnabled(); + expect(JSON.parse(localStorage.getItem('pinnedPersons')!)).toEqual({ [competitionId]: [5] }); +}); + +it('keeps a competitor without a registrant ID named, unpressed, and disabled', async () => { + const saved = { [competitionId]: [5] }; + localStorage.setItem('pinnedPersons', JSON.stringify(saved)); + render(header({ ...person, registrantId: null } as unknown as Person)); + const button = screen.getByRole('button', { name: 'Bookmark competitor', pressed: false }); + expect(button).toBeDisabled(); + + await act(() => userEvent.click(button)); + expect(JSON.parse(localStorage.getItem('pinnedPersons')!)).toEqual(saved); +}); diff --git a/src/containers/PersonalSchedule/PersonHeader.tsx b/src/containers/PersonalSchedule/PersonHeader.tsx index 44b59a6..504fb68 100644 --- a/src/containers/PersonalSchedule/PersonHeader.tsx +++ b/src/containers/PersonalSchedule/PersonHeader.tsx @@ -60,6 +60,10 @@ export const PersonHeader: React.FC = ({ person }) => {
    -
    + + +