From b62eb2b4cd240abdea4f51640e069dbd3d05f3ae Mon Sep 17 00:00:00 2001 From: Cailyn Sinclair Date: Tue, 3 Nov 2026 06:54:20 -0800 Subject: [PATCH 1/3] Fix venue timezone displays and fallback qualification highlights --- .../CutoffTimeLimitPanel.help.test.tsx | 64 +++++ .../CutoffTimeLimitPanel.tsx | 18 +- .../CompetitionResultsTable.tsx | 1 + .../CompetitionResults/advancement.test.ts | 144 +++++++++++- .../CompetitionResults/advancement.ts | 136 ++++++----- .../CompetitionResults/resultSources.ts | 1 + .../resultsProvider.test.ts | 219 ++++++++++++++++++ .../CompetitionResults/resultsProvider.ts | 65 ++++-- .../CompetitionRound.test.tsx | 59 ++++- .../CompetitionRound/CompetitionRound.tsx | 22 +- src/lib/time.test.ts | 47 ++++ src/lib/time.ts | 12 +- 12 files changed, 701 insertions(+), 87 deletions(-) create mode 100644 src/components/CutoffTimeLimitPanel/CutoffTimeLimitPanel.help.test.tsx create mode 100644 src/lib/time.test.ts 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.tsx b/src/components/CutoffTimeLimitPanel/CutoffTimeLimitPanel.tsx index cb01f7b..d630964 100644 --- a/src/components/CutoffTimeLimitPanel/CutoffTimeLimitPanel.tsx +++ b/src/components/CutoffTimeLimitPanel/CutoffTimeLimitPanel.tsx @@ -1,6 +1,6 @@ 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'; @@ -212,6 +212,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/CompetitionResults/CompetitionResultsTable.tsx b/src/containers/CompetitionResults/CompetitionResultsTable.tsx index 7eef876..1e04355 100644 --- a/src/containers/CompetitionResults/CompetitionResultsTable.tsx +++ b/src/containers/CompetitionResults/CompetitionResultsTable.tsx @@ -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; diff --git a/src/containers/CompetitionResults/advancement.test.ts b/src/containers/CompetitionResults/advancement.test.ts index e405dc9..833ca3b 100644 --- a/src/containers/CompetitionResults/advancement.test.ts +++ b/src/containers/CompetitionResults/advancement.test.ts @@ -116,7 +116,7 @@ describe('dual-round stored results', () => { ).size; const limit = condition.resultCondition.type === 'percent' - ? Math.ceil((entrants * condition.resultCondition.value) / 100) + ? Math.floor((entrants * condition.resultCondition.value) / 100) : condition.resultCondition.value!; expect( results.every( @@ -126,3 +126,145 @@ describe('dual-round stored results', () => { } }); }); + +const qualifiers = ( + results: Round['results'], + condition: ResultCondition, + format: Round['format'] = 'a', +) => { + const round = { ...makeRound('333-r1', results), format }; + return getStoredRoundResults(round, { + sourceType: 'round', + sourceRoundIds: [round.id], + resultCondition: condition, + }).filter(({ advancing }) => advancing); +}; +const rankedResults = (count: number) => + Array.from({ length: count }, (_, i) => result(i + 1, i + 1, 800 + i * 100)); + +describe('stored qualification rules', () => { + it.each([ + [11, 8], + [15, 11], + [17, 12], + ])('rounds 75%% of %i scored entrants down to %i', (count, expected) => { + expect( + qualifiers(rankedResults(count), { type: 'percent', scope: 'average', value: 75 }), + ).toHaveLength(expected); + }); + + it('caps a configured top 10 at six of eight entrants', () => { + expect( + qualifiers(rankedResults(8), { type: 'ranking', scope: 'average', value: 10 }), + ).toHaveLength(6); + }); + + it('keeps a top-N tie together when it stays below the elimination cap', () => { + const results = rankedResults(20); + results[6].ranking = 6; + expect( + qualifiers(results, { type: 'ranking', scope: 'average', value: 6 }).map( + ({ personId }) => personId, + ), + ).toEqual([1, 2, 3, 4, 5, 6, 7]); + }); + + it.each([ + { type: 'ranking', scope: 'average', value: 15 }, + { type: 'percent', scope: 'average', value: 75 }, + { type: 'resultAchieved', scope: 'average', value: 10000 }, + { type: 'resultAchieved', scope: 'average', value: null }, + ])('excludes a whole tied group crossing the cap for %j', (condition) => { + const results = rankedResults(20); + results[15].ranking = 15; + results[16].ranking = 15; + expect(qualifiers([...results].reverse(), condition)).toHaveLength(14); + }); + + it('counts failed results in the population but never qualifies all-DNF or DNS entrants', () => { + const results = rankedResults(16); + results[6].average = -1; // A successful single still makes this competitor eligible. + results.slice(7).forEach((entry, i) => { + entry.best = i % 2 ? -1 : -2; + entry.average = -1; + entry.ranking = 8; + }); + expect( + qualifiers(results, { type: 'percent', scope: 'average', value: 50 }).map( + ({ personId }) => personId, + ), + ).toEqual([1, 2, 3, 4, 5, 6, 7]); + }); + + it('does not count empty, unranked roster placeholders as scored entrants', () => { + const results: Round['results'] = [ + ...rankedResults(11), + { ...result(12, 12, 0), ranking: null, attempts: [] }, + { ...result(13, 13, 0), ranking: null, attempts: [] }, + ]; + expect(qualifiers(results, { type: 'percent', scope: 'average', value: 75 })).toHaveLength(8); + }); + + it('treats a null threshold as any successful value in the requested scope', () => { + const results = rankedResults(8); + results[1].average = -1; + results[2].average = -2; + results[3].average = 0; + expect( + qualifiers(results, { type: 'resultAchieved', scope: 'average', value: null }).map( + ({ personId }) => personId, + ), + ).toEqual([1, 5, 6]); + expect( + qualifiers(results, { type: 'resultAchieved', scope: 'single', value: null }), + ).toHaveLength(6); + }); + + it('requires a value strictly better than a non-null threshold', () => { + expect( + qualifiers(rankedResults(8), { + type: 'resultAchieved', + scope: 'average', + value: 1000, + }).map(({ personId }) => personId), + ).toEqual([1, 2]); + }); + + it('allows success in either linked component without replacing displayed DNF values', () => { + const rounds = linkedRounds({ type: 'ranking', scope: 'average', value: 3 }); + rounds[0].results[0] = { ...result(1, 2, -1), best: -1 }; + rounds[1].results[0] = { ...result(1, 2, -1), best: 900 }; + expect( + getStoredRoundResults( + rounds[0], + getAdvancementConditionForRound(rounds, rounds[0]), + rounds, + )[0], + ).toMatchObject({ ranking: 2, best: -1, average: -1, advancing: true }); + }); + + it('keeps an empty component row unhighlighted without losing sibling eligibility', () => { + const rounds = linkedRounds({ type: 'ranking', scope: 'average', value: 3 }); + rounds[0].results[0] = { + ...result(1, 2, 0), + ranking: null, + attempts: [], + }; + const condition = getAdvancementConditionForRound(rounds, rounds[0]); + expect(getStoredRoundResults(rounds[0], condition, rounds)[0]).toMatchObject({ + ranking: null, + best: 0, + average: 0, + attempts: [], + advancing: false, + }); + expect(getStoredRoundResults(rounds[1], condition, rounds)[0].advancing).toBe(true); + }); + + it('does not qualify a final or an entirely empty round', () => { + expect(getStoredRoundResults(makeRound('333-r1', rankedResults(8)), null)).toEqual( + expect.arrayContaining([expect.objectContaining({ advancing: false })]), + ); + expect(qualifiers([], { type: 'ranking', scope: 'average', value: 10 })).toEqual([]); + }); +}); diff --git a/src/containers/CompetitionResults/advancement.ts b/src/containers/CompetitionResults/advancement.ts index d940a32..e7cbea9 100644 --- a/src/containers/CompetitionResults/advancement.ts +++ b/src/containers/CompetitionResults/advancement.ts @@ -8,80 +8,93 @@ const averagedFormats = new Set(['a', 'm']); const getPrimaryRankingType = (round: Pick): RankingType => averagedFormats.has(round.format) ? 'average' : 'single'; -const getRankLimit = ( - resultCondition: RoundAdvancementCondition['resultCondition'], - resultCount: number, -) => { - if (resultCondition.type === 'ranking') { - return resultCondition.value; - } - - if (resultCondition.type === 'percent') { - return Math.ceil((resultCount * resultCondition.value) / 100); - } - - return null; +type QualificationResult = Pick & { + // Stable competitor identity across all source rounds, not the component result ID. + key: string; }; -const getResultValue = (result: Round['results'][number], scope: RankingType) => - scope === 'average' ? result.average : result.best; - -const isAdvancingFromStoredResult = ( - round: Round, - result: Round['results'][number], +export const getQualifyingResultKeys = ( + round: Pick, advancementCondition: RoundAdvancementCondition | null, - eventRounds: Round[], -) => { - if (!advancementCondition) { - return false; - } + sourceResults: QualificationResult[], +): Set => { + if (!advancementCondition) return new Set(); const { resultCondition } = advancementCondition; - // A dual-round percentage uses distinct competitors across both components, - // including people who only competed in one. WCIF already supplies shared ranks. - const sourceRounds = - advancementCondition.sourceType === 'linkedRounds' - ? eventRounds.filter((sourceRound) => - advancementCondition.sourceRoundIds.includes(sourceRound.id), - ) - : [round]; - const sourceResults = sourceRounds.flatMap((sourceRound) => sourceRound.results); - const rankedResultCount = new Set( - sourceResults - .filter((roundResult) => roundResult.ranking != null) - .map((roundResult) => roundResult.personId), - ).size; - const rankLimit = getRankLimit(resultCondition, rankedResultCount); - - if (rankLimit != null) { - return result.ranking != null && result.ranking <= rankLimit; - } - - if (resultCondition.type !== 'resultAchieved') { - return false; - } - - if (resultCondition.value == null) { - return false; - } + const resultsByPerson = new Map(); + sourceResults.forEach((result) => { + const personResults = resultsByPerson.get(result.key) ?? []; + personResults.push(result); + resultsByPerson.set(result.key, personResults); + }); - const scope = resultCondition.scope ?? getPrimaryRankingType(round); - return sourceResults.some((sourceResult) => { - const resultValue = getResultValue(sourceResult, scope); - return ( - sourceResult.personId === result.personId && - resultValue > 0 && - resultValue < resultCondition.value! + // WCIF supplies official ranks shared across linked components. Count each + // scored competitor once; an empty, unranked roster row is not a result. + const rankingsByPerson = new Map(); + resultsByPerson.forEach((results, key) => { + const rankings = results.flatMap(({ ranking }) => + ranking != null && ranking > 0 ? [ranking] : [], ); + if (rankings.length > 0) rankingsByPerson.set(key, Math.min(...rankings)); }); + const rankings = [...rankingsByPerson.values()].sort((a, b) => a - b); + const resultCount = rankings.length; + const maxQualifying = Math.floor(resultCount * 0.75); + // WCA Live's qualification algorithm excludes the entire tied group if it + // crosses the 75% cap (9p1). Never break an official tie by array order. + const firstNonQualifyingRanking = rankings[maxQualifying] ?? Infinity; + const rankLimit = + resultCondition.type === 'ranking' + ? resultCondition.value + : resultCondition.type === 'percent' + ? Math.floor((resultCount * resultCondition.value) / 100) + : null; + const scope = resultCondition.scope ?? getPrimaryRankingType(round); + + return new Set( + [...resultsByPerson].flatMap(([key, results]) => { + const ranking = rankingsByPerson.get(key); + if ( + ranking == null || + ranking >= firstNonQualifyingRanking || + // A DNF average is still eligible when either component has a successful + // single. Only all-DNF/DNS results are excluded by 9p4. + !results.some(({ best }) => best > 0) + ) { + return []; + } + + const satisfiesCondition = + rankLimit != null + ? ranking <= rankLimit + : resultCondition.type === 'resultAchieved' && + results.some((result) => { + const value = scope === 'average' ? result.average : result.best; + return value > 0 && (resultCondition.value == null || value < resultCondition.value); + }); + return satisfiesCondition ? [key] : []; + }), + ); }; export const getStoredRoundResults = ( round: Round, advancementCondition: RoundAdvancementCondition | null, eventRounds: Round[] = [round], -): CompetitionRoundResult[] => - round.results.map((result) => { +): CompetitionRoundResult[] => { + const sourceRounds = + advancementCondition?.sourceType === 'linkedRounds' + ? eventRounds.filter(({ id }) => advancementCondition.sourceRoundIds.includes(id)) + : [round]; + const qualifyingKeys = getQualifyingResultKeys( + round, + advancementCondition, + sourceRounds.flatMap(({ results }) => + results.map((result) => ({ ...result, key: String(result.personId) })), + ), + ); + + return round.results.map((result) => { const resultWithRecords = result as typeof result & { regionalSingleRecord?: string | null; regionalAverageRecord?: string | null; @@ -93,7 +106,7 @@ export const getStoredRoundResults = ( id: result.personId, personId: result.personId, ranking: result.ranking, - advancing: isAdvancingFromStoredResult(round, result, advancementCondition, eventRounds), + advancing: result.ranking != null && qualifyingKeys.has(String(result.personId)), advancingQuestionable: false, attempts: result.attempts.map((attempt) => ({ result: attempt.value })), best: result.best, @@ -106,3 +119,4 @@ export const getStoredRoundResults = ( ), }; }); +}; diff --git a/src/containers/CompetitionResults/resultSources.ts b/src/containers/CompetitionResults/resultSources.ts index ac0510a..2d9ece3 100644 --- a/src/containers/CompetitionResults/resultSources.ts +++ b/src/containers/CompetitionResults/resultSources.ts @@ -104,6 +104,7 @@ export const toCompetitionRoundResult = ( id: result.id, personId: person?.registrantId ?? null, personName: result.name, + wcaId: result.wca_id, ranking: result.pos, advancing: false, advancingQuestionable: false, diff --git a/src/containers/CompetitionResults/resultsProvider.test.ts b/src/containers/CompetitionResults/resultsProvider.test.ts index c6a467b..fa80165 100644 --- a/src/containers/CompetitionResults/resultsProvider.test.ts +++ b/src/containers/CompetitionResults/resultsProvider.test.ts @@ -741,3 +741,222 @@ describe('resultsProvider', () => { ]); }); }); + +const linkedCompetition = (): Competition => { + const competition = JSON.parse(JSON.stringify(wcif)) as Competition; + competition.persons.push({ + ...persons[0], + registrantId: 4, + name: 'Dana Solver', + wcaId: '2010DANA01', + }); + const rounds = competition.events[0].rounds; + rounds[0].results = competition.persons.map(({ registrantId }, index) => ({ + personId: registrantId!, + ranking: index + 1, + best: 1500 + index * 100, + average: 1500 + index * 100, + attempts: [{ value: 1500 + index * 100, reconstruction: null }], + })); + rounds[0].linkedRounds = ['333-r1', '333-r2']; + rounds[1].linkedRounds = ['333-r1', '333-r2']; + rounds[2].participationRuleset = { + participationSource: { + type: 'linkedRounds', + roundIds: ['333-r1', '333-r2'], + resultCondition: { type: 'resultAchieved', scope: 'average', value: 1000 }, + }, + reservedPlaces: null, + }; + return competition; +}; + +const linkedSourceResults = ( + competition: Competition, + apiResults: WcaCompetitionResult[], + roundIndex = 0, + live: WcaLiveRound | null = null, +) => + getRoundResultsFromSources({ + wcif: competition, + selectedRound: { + event: competition.events[0], + round: competition.events[0].rounds[roundIndex], + roundNumber: roundIndex + 1, + }, + wcaApiResults: apiResults, + wcaLiveRound: live, + }); + +const aliceSecondComponent = () => + apiResult({ + id: 9010, + pos: 1, + best: 800, + average: 900, + attempts: [800, 900, 1000], + name: 'Alice Solver', + wca_id: '2010ALIC01', + }); + +describe('mixed-source linked qualification', () => { + it('uses a sibling API result to qualify a WCIF row without replacing component values', () => { + const competition = linkedCompetition(); + const api = aliceSecondComponent(); + expect(linkedSourceResults(competition, [api])[0]).toMatchObject({ + personId: 1, + ranking: 1, + best: 1500, + average: 1500, + attempts: [{ result: 1500 }], + advancing: true, + }); + expect(linkedSourceResults(competition, [api], 1)).toEqual([ + expect.objectContaining({ id: 9010, average: 900, advancing: true }), + ]); + }); + + it('does not highlight an empty selected component when the sibling qualifies', () => { + const competition = linkedCompetition(); + const round = competition.events[0].rounds[0]; + round.results[0] = { + ...round.results[0], + ranking: null, + best: 0, + average: 0, + attempts: [], + }; + expect(linkedSourceResults(competition, [aliceSecondComponent()])[0]).toMatchObject({ + ranking: null, + best: 0, + average: 0, + advancing: false, + }); + expect(linkedSourceResults(competition, [aliceSecondComponent()], 1)[0].advancing).toBe(true); + }); + + it('keeps WCIF priority over a conflicting API result in a sibling component', () => { + const competition = linkedCompetition(); + competition.events[0].rounds[1].results = [ + { ...competition.events[0].rounds[0].results[0], best: 1100, average: 1200 }, + ]; + expect(linkedSourceResults(competition, [aliceSecondComponent()])[0].advancing).toBe(false); + }); + + it('allows a DNF component when the sibling API component has a successful single', () => { + const competition = linkedCompetition(); + const rounds = competition.events[0].rounds; + rounds[0].results[0] = { + ...rounds[0].results[0], + best: -1, + average: -1, + attempts: [{ value: -1, reconstruction: null }], + }; + rounds[2].participationRuleset!.participationSource = { + type: 'linkedRounds', + roundIds: ['333-r1', '333-r2'], + resultCondition: { type: 'ranking', scope: 'average', value: 2 }, + }; + expect( + linkedSourceResults(competition, [{ ...aliceSecondComponent(), average: -1 }])[0], + ).toMatchObject({ best: -1, average: -1, advancing: true }); + }); + + it('counts API-only entrants in the distinct linked union for percentages', () => { + const competition = linkedCompetition(); + const rounds = competition.events[0].rounds; + rounds[0].results = [rounds[0].results[0], { ...rounds[0].results[1], ranking: 3 }]; + rounds[2].participationRuleset!.participationSource = { + type: 'linkedRounds', + roundIds: ['333-r1', '333-r2'], + resultCondition: { type: 'percent', scope: 'average', value: 50 }, + }; + const api = [ + aliceSecondComponent(), + apiResult({ id: 9011, pos: 2, name: 'Casey Solver', wca_id: null }), + apiResult({ id: 9012, pos: 4, name: 'Dana Solver', wca_id: '2010DANA01' }), + ]; + expect(linkedSourceResults(competition, api).map(({ advancing }) => advancing)).toEqual([ + true, + false, + ]); + expect( + linkedSourceResults(competition, api, 1).map(({ personId, advancing }) => ({ + personId, + advancing, + })), + ).toEqual([ + { personId: 1, advancing: true }, + { personId: 3, advancing: true }, + { personId: 4, advancing: false }, + ]); + }); + + it('deduplicates an API-only competitor by WCA ID without inventing a WCIF identity', () => { + const competition = linkedCompetition(); + const rounds = competition.events[0].rounds; + rounds[0].results = [rounds[0].results[0], { ...rounds[0].results[1], ranking: 3 }]; + rounds[2].participationRuleset!.participationSource = { + type: 'linkedRounds', + roundIds: ['333-r1', '333-r2'], + resultCondition: { type: 'ranking', scope: 'average', value: 10 }, + }; + const api = [ + apiResult({ id: 9011, pos: 2, wca_id: '2010ELSE01', round_type_id: '1' }), + apiResult({ id: 9012, pos: 2, wca_id: '2010ELSE01', round_type_id: '2' }), + ]; + const results = linkedSourceResults(competition, api); + expect(results.find(({ id }) => id === 9011)).toMatchObject({ + personId: null, + wcaId: '2010ELSE01', + advancing: true, + }); + expect(results.find(({ personId }) => personId === 2)?.advancing).toBe(false); + }); + + it('does not invent a linked union when an API row has no resolvable identity', () => { + const competition = linkedCompetition(); + const results = linkedSourceResults(competition, [ + aliceSecondComponent(), + apiResult({ id: 9011, name: 'Unmatched Competitor', wca_id: null }), + ]); + expect(results.every(({ advancing }) => !advancing)).toBe(true); + }); + + it('allows authoritative live advancement even when unresolved linked identity suppresses fallback', () => { + const competition = linkedCompetition(); + const live = liveRound([ + liveRoundResult({ + id: 'live-alice', + person: { id: '1', registrantId: 1, name: 'Alice Solver' }, + advancing: true, + advancingQuestionable: false, + }), + ]); + expect( + linkedSourceResults( + competition, + [apiResult({ id: 9011, name: 'Unmatched Competitor', wca_id: null })], + 0, + live, + ).find(({ personId }) => personId === 1), + ).toMatchObject({ advancing: true, advancingQuestionable: false }); + }); + + it('keeps live advancement and questionable flags authoritative over computed qualification', () => { + const competition = linkedCompetition(); + const live = liveRound([ + liveRoundResult({ + id: 'live-alice', + person: { id: '1', registrantId: 1, name: 'Alice Solver' }, + advancing: false, + advancingQuestionable: true, + }), + ]); + expect( + linkedSourceResults(competition, [aliceSecondComponent()], 0, live).find( + ({ personId }) => personId === 1, + ), + ).toMatchObject({ advancing: false, advancingQuestionable: true }); + }); +}); diff --git a/src/containers/CompetitionResults/resultsProvider.ts b/src/containers/CompetitionResults/resultsProvider.ts index a1baddf..c1bbba6 100644 --- a/src/containers/CompetitionResults/resultsProvider.ts +++ b/src/containers/CompetitionResults/resultsProvider.ts @@ -7,12 +7,12 @@ import { getAdvancementConditionForRound } from '@/lib/wcif'; import { PersonalRoundResult } from '../CompetitionPersonalResults/CompetitionPersonalResultsTable'; import { CompetitionRoundResult } from './CompetitionResultsTable'; import { normalizeResultRecordTag } from './ResultRecordBadge'; -import { getStoredRoundResults } from './advancement'; +import { getQualifyingResultKeys, getStoredRoundResults } from './advancement'; import { findPersonForApiResult, findUniquePersonByName, - getApiRoundResults, getWcaApiResultsByRoundId, + toCompetitionRoundResult, } from './resultSources'; import { getRoundRosterResults } from './roundRoster'; @@ -26,7 +26,11 @@ export interface EventResults { } const getResultKey = (result: CompetitionRoundResult) => - result.personId == null ? `result-${result.id}` : `person-${result.personId}`; + result.personId != null + ? `person-${result.personId}` + : result.wcaId + ? `wca-${result.wcaId}` + : `result-${result.id}`; const mergeRoundResultSources = ( ...sources: CompetitionRoundResult[][] @@ -99,6 +103,7 @@ const getLiveRoundResults = (wcif: Competition, wcaLiveRound: WcaLiveRound | nul id: result.id, personId: matchedPerson?.registrantId ?? result.person.registrantId, personName: result.person.name, + wcaId: result.person.wcaId, ranking: result.ranking, advancing: result.advancing, advancingQuestionable: result.advancingQuestionable, @@ -125,22 +130,56 @@ export const getRoundResultsFromSources = ({ return []; } - const liveRoundResults = getLiveRoundResults(wcif, wcaLiveRound); - const storedRoundResults = getStoredRoundResults( - selectedRound.round, - getAdvancementConditionForRound(selectedRound.event.rounds, selectedRound.round), + const advancementCondition = getAdvancementConditionForRound( selectedRound.event.rounds, + selectedRound.round, ); - const apiRoundResults = getApiRoundResults(wcif, selectedRound.round.id, wcaApiResults); + const sourceRoundIds = + advancementCondition?.sourceType === 'linkedRounds' + ? advancementCondition.sourceRoundIds + : [selectedRound.round.id]; + const apiResultsByRound = getWcaApiResultsByRoundId(wcif, wcaApiResults ?? []); + // Apply the existing API < WCIF priority in each component before evaluating + // linked qualification. The selected table still shows only that component. + const fallbackResultsByRound = new Map( + selectedRound.event.rounds + .filter(({ id }) => id === selectedRound.round.id || sourceRoundIds.includes(id)) + .map((round) => [ + round.id, + mergeRoundResultSources( + (apiResultsByRound.get(round.id) ?? []).map((result) => + toCompetitionRoundResult(wcif.persons, result), + ), + getStoredRoundResults(round, null), + ), + ]), + ); + const sourceResults = sourceRoundIds.flatMap((id) => fallbackResultsByRound.get(id) ?? []); + // Without either identifier an API row cannot be deduplicated across linked + // components. Do not invent a person or overstate qualification in that case. + const hasUnresolvedLinkedIdentity = + advancementCondition?.sourceType === 'linkedRounds' && + sourceResults.some((result) => result.personId == null && !result.wcaId); + const qualifyingKeys = getQualifyingResultKeys( + selectedRound.round, + hasUnresolvedLinkedIdentity ? null : advancementCondition, + sourceResults.map((result) => ({ ...result, key: getResultKey(result) })), + ); + const fallbackResults = (fallbackResultsByRound.get(selectedRound.round.id) ?? []).map( + (result) => ({ + ...result, + // Keep empty component/roster rows visually empty, even if the competitor + // qualifies through the other component. Live flags remain authoritative. + advancing: result.ranking != null && qualifyingKeys.has(getResultKey(result)), + }), + ); + const liveRoundResults = getLiveRoundResults(wcif, wcaLiveRound); - if (liveRoundResults.length === 0) { - return mergeRoundResultSources(apiRoundResults, storedRoundResults); - } + if (liveRoundResults.length === 0) return fallbackResults; return mergeRoundResultSources( getRoundRosterResults(wcif, selectedRound.round.id), - apiRoundResults, - storedRoundResults, + fallbackResults, liveRoundResults, ); }; diff --git a/src/containers/CompetitionRound/CompetitionRound.test.tsx b/src/containers/CompetitionRound/CompetitionRound.test.tsx index dc0cd6e..0b7f791 100644 --- a/src/containers/CompetitionRound/CompetitionRound.test.tsx +++ b/src/containers/CompetitionRound/CompetitionRound.test.tsx @@ -195,7 +195,9 @@ jest.mock('@/providers/WCIFProvider', () => ({ setTitle: () => {}, }), useWcifUtils: () => ({ - roundActivies: linkedRoundsCompetition.schedule.venues[0].rooms[0].activities, + roundActivies: linkedRoundsCompetition.schedule.venues.flatMap((venue) => + venue.rooms.flatMap((room) => room.activities), + ), }), })); @@ -210,6 +212,61 @@ function renderRound(roundId: string) { } describe('CompetitionRoundContainer', () => { + it.each([ + ['Europe/Bucharest', '2026-10-31T07:00:00Z', '2026-10-31T07:22:30Z', 'Sat 09:00 AM - 09:25 AM'], + ['Asia/Calcutta', '2026-10-31T03:15:00Z', '2026-10-31T03:55:00Z', 'Sat 08:45 AM - 09:25 AM'], + ])('shows group times in the %s venue timezone', (timezone, startTime, endTime, expected) => { + const venue = linkedRoundsCompetition.schedule.venues[0]; + const group = venue.rooms[0].activities[0].childActivities[0]; + const original = { + timezone: venue.timezone, + startTime: group.startTime, + endTime: group.endTime, + }; + const languages = jest.spyOn(navigator, 'languages', 'get').mockReturnValue(['en-US']); + try { + venue.timezone = timezone; + group.startTime = startTime; + group.endTime = endTime; + renderRound('333-r2'); + expect(screen.getByText(expected)).toBeInTheDocument(); + } finally { + venue.timezone = original.timezone; + group.startTime = original.startTime; + group.endTime = original.endTime; + languages.mockRestore(); + } + }); + + it('keeps separate local ranges when the same group runs in different venue timezones', () => { + const originalVenue = linkedRoundsCompetition.schedule.venues[0]; + const secondVenue = { + ...originalVenue, + id: 2, + timezone: 'Asia/Calcutta', + rooms: originalVenue.rooms.map((room) => ({ + ...room, + id: 2, + activities: room.activities.map((activity) => ({ + ...activity, + id: 20, + childActivities: activity.childActivities.map((child) => ({ ...child, id: 21 })), + })), + })), + }; + const languages = jest.spyOn(navigator, 'languages', 'get').mockReturnValue(['en-US']); + linkedRoundsCompetition.schedule.venues.push(secondVenue); + try { + renderRound('333-r2'); + expect(screen.getByText('Sun 09:00 AM - 09:30 AM (America/Los_Angeles)')).toBeInTheDocument(); + expect(screen.getByText('Sun 09:30 PM - 10:00 PM (Asia/Calcutta)')).toBeInTheDocument(); + expect(screen.getAllByText('Group 1')).toHaveLength(1); + } finally { + linkedRoundsCompetition.schedule.venues.pop(); + languages.mockRestore(); + } + }); + it('shows linked-round context for rounds in a dual-round advancement set', () => { renderRound('333-r2'); diff --git a/src/containers/CompetitionRound/CompetitionRound.tsx b/src/containers/CompetitionRound/CompetitionRound.tsx index 812bc53..e0837e2 100644 --- a/src/containers/CompetitionRound/CompetitionRound.tsx +++ b/src/containers/CompetitionRound/CompetitionRound.tsx @@ -4,6 +4,7 @@ import { Breadcrumbs } from '@/components/Breadcrumbs/Breadcrumbs'; import { Container } from '@/components/Container'; import { CutoffTimeLimitPanel } from '@/components/CutoffTimeLimitPanel'; import { LinkButton } from '@/components/LinkButton'; +import { getVenueForActivity } from '@/lib/activities'; import { activityCodeToName, parseActivityCodeFlexible, @@ -53,6 +54,8 @@ export function CompetitionRoundContainer({ [eventRounds, round, roundId, t], ); + const findVenue = useMemo(() => (wcif ? getVenueForActivity(wcif) : undefined), [wcif]); + const rounds = roundActivies.filter((ra) => toRoundAttemptId(ra.activityCode) === roundId); const groups = rounds.flatMap((r) => r.childActivities); const uniqueGroupCodes = [...new Set(groups.map((g) => g.activityCode))]; @@ -82,8 +85,16 @@ export function CompetitionRoundContainer({ {uniqueGroupCodes.map((value) => { const { groupNumber } = parseActivityCodeFlexible(value); const activities = groups.filter((g) => g.activityCode === value); - const minStartTime = activities.map((a) => a.startTime).sort()[0]; - const maxEndTime = activities.map((a) => a.endTime).sort()[activities.length - 1]; + const timeZones = [...new Set(activities.map((a) => findVenue?.(a)?.timezone))]; + const timeRanges = timeZones.map((timeZone) => { + const venueActivities = activities.filter((a) => findVenue?.(a)?.timezone === timeZone); + const minStartTime = venueActivities.map((a) => a.startTime).sort()[0]; + const maxEndTime = venueActivities + .map((a) => a.endTime) + .sort() + .at(-1)!; + return { timeZone, label: formatDateTimeRange(minStartTime, maxEndTime, 5, timeZone) }; + }); return ( {t('common.activityCodeToName.group', { groupNumber })} - {formatDateTimeRange(minStartTime, maxEndTime)} + {timeRanges.map(({ timeZone, label }) => ( + + {label} + {timeRanges.length > 1 && timeZone && ` (${timeZone})`} + + ))} ); diff --git a/src/lib/time.test.ts b/src/lib/time.test.ts new file mode 100644 index 0000000..7795486 --- /dev/null +++ b/src/lib/time.test.ts @@ -0,0 +1,47 @@ +import { formatDateTimeRange, formmatDate } from './time'; + +describe('venue-local date and time ranges', () => { + beforeEach(() => { + jest.spyOn(navigator, 'languages', 'get').mockReturnValue(['en-US']); + jest.spyOn(navigator, 'language', 'get').mockReturnValue('en-US'); + }); + + afterEach(() => jest.restoreAllMocks()); + + it('shows the Campulung venue time and preserves five-minute rounding', () => { + expect( + formatDateTimeRange('2026-10-31T07:00:00Z', '2026-10-31T07:22:30Z', 5, 'Europe/Bucharest'), + ).toBe('Sat 09:00 AM - 09:25 AM'); + }); + + it('shows Saturday for Concours even when the viewer is still on Friday', () => { + expect( + formatDateTimeRange('2026-10-31T03:15:00Z', '2026-10-31T03:55:00Z', 5, 'Asia/Calcutta'), + ).toBe('Sat 08:45 AM - 09:25 AM'); + expect(formmatDate('2026-10-31T03:15:00Z', 5, 'Asia/Calcutta')).toBe('Sat'); + }); + + it('repeats weekdays when a range crosses venue midnight but not viewer midnight', () => { + expect( + formatDateTimeRange('2026-10-31T18:00:00Z', '2026-10-31T19:00:00Z', 5, 'Asia/Calcutta'), + ).toBe('Sat 11:30 PM - Sun 12:30 AM'); + }); + + it('uses one weekday when a range crosses UTC midnight but not venue midnight', () => { + expect( + formatDateTimeRange('2026-10-30T23:30:00Z', '2026-10-31T00:30:00Z', 5, 'Asia/Calcutta'), + ).toBe('Sat 05:00 AM - 06:00 AM'); + }); + + it('compares dates after rounding across midnight', () => { + expect( + formatDateTimeRange('2026-10-31T18:29:00Z', '2026-10-31T18:40:00Z', 5, 'Asia/Calcutta'), + ).toBe('Sun 12:00 AM - 12:10 AM'); + }); + + it('uses the requested rounding interval for the weekday as well as the time', () => { + expect( + formatDateTimeRange('2026-10-31T18:29:00Z', '2026-10-31T18:30:00Z', 1, 'Asia/Calcutta'), + ).toBe('Sat 11:59 PM - Sun 12:00 AM'); + }); +}); diff --git a/src/lib/time.ts b/src/lib/time.ts index cd966c3..f7e06af 100644 --- a/src/lib/time.ts +++ b/src/lib/time.ts @@ -21,9 +21,10 @@ export const formatTime = (isoString: string, minutes: number = 5, timeZone?: st }), }); -export const formmatDate = (isoString: string, minutes: number = 5) => +export const formmatDate = (isoString: string, minutes: number = 5, timeZone?: string) => roundTime(new Date(isoString), minutes).toLocaleDateString([...navigator.languages], { weekday: 'short', + timeZone, }); export const formatDateTime = (isoString: string, minutes: number = 5, timeZone?: string) => @@ -48,11 +49,12 @@ export const formatDateTimeRange = ( minutes: number = 5, timeZone?: string, ) => { - const startDate = new Date(start); - const endDate = new Date(end); + const startDate = roundTime(new Date(start), minutes); + const endDate = roundTime(new Date(end), minutes); + const dateFormatter = getNumericDateFormatter(timeZone); - if (startDate.toLocaleDateString() === endDate.toLocaleDateString()) { - return `${formmatDate(start)} ${formatTimeRange(start, end, minutes, timeZone)}`; + if (dateFormatter.format(startDate) === dateFormatter.format(endDate)) { + return `${formmatDate(start, minutes, timeZone)} ${formatTimeRange(start, end, minutes, timeZone)}`; } return `${formatDateTime(start, minutes, timeZone)} - ${formatDateTime(end, minutes, timeZone)}`; From 6a52b5e86ae22b90e8afcfa981ad14707e195563 Mon Sep 17 00:00:00 2001 From: Cailyn Sinclair Date: Tue, 3 Nov 2026 08:11:22 -0800 Subject: [PATCH 2/3] Fix UAT keyboard, schedule state and fallback advancement defects Resolve keyboard selection, recovery states, day controls and persisted collapse state. Align stored advancement with actual participant rosters and conservative provisional flags, and format event-aware threshold captions. Validated source tree: 5e880755b7f1572f6bad99ac8f4236678cdc9554 (342 tests; type, lint, production and Storybook builds). --- jest.config.ts | 3 +- .../CompetitionListItem.tsx | 70 +++-- .../CompetitionSelect.test.tsx | 133 +++++++++ .../CompetitionSelect/CompetitionSelect.tsx | 30 +- .../CutoffTimeLimitPanel.advancement.test.tsx | 113 ++++++++ .../CutoffTimeLimitPanel.stories.tsx | 70 +++++ .../CutoffTimeLimitPanel.tsx | 52 ++-- .../CompetitionPerson.test.tsx | 80 ++++++ .../CompetitionPerson/CompetitionPerson.tsx | 25 +- .../CompetitionResults.test.tsx | 11 +- .../FallbackAdvancement.stories.tsx | 120 ++++++++ .../CompetitionResults/advancement.test.ts | 198 ++++++++++++- .../CompetitionResults/advancement.ts | 124 +++++++- .../CompetitionResults/fixtures/README.md | 17 ++ .../resultsProvider.test.ts | 265 ++++++++++++++++++ .../CompetitionResults/resultsProvider.ts | 92 ++++-- .../CompetitionRound.test.tsx | 16 ++ .../CompetitionRound/CompetitionRound.tsx | 27 +- .../Competitors/Competitors.test.tsx | 22 +- src/containers/Competitors/Competitors.tsx | 12 +- .../PersonalSchedule/Assignments.test.tsx | 49 ++++ .../PersonalSchedule/Assignments.tsx | 16 +- .../PersonalSchedule/PersonalSchedule.tsx | 1 + src/containers/Schedule/Schedule.test.tsx | 92 ++++++ src/containers/Schedule/Schedule.tsx | 66 +++-- .../UpcomingCompetitions.test.tsx | 38 +++ .../UpcomingCompetitions.tsx | 2 +- src/hooks/UseCollapse/UseCollapse.test.tsx | 26 ++ src/hooks/UseCollapse/UseCollapse.ts | 33 +-- src/i18n/en/translation.yaml | 18 +- .../CompetitionLayout.test.tsx | 26 ++ .../CompetitionLayout/CompetitionLayout.tsx | 5 + src/layouts/RootLayout/Header.test.tsx | 42 +++ src/layouts/RootLayout/Header.tsx | 20 +- src/pages/Home/Home.test.tsx | 69 +++++ src/pages/Home/index.tsx | 5 +- 36 files changed, 1793 insertions(+), 195 deletions(-) create mode 100644 src/components/CompetitionSelect/CompetitionSelect.test.tsx create mode 100644 src/components/CutoffTimeLimitPanel/CutoffTimeLimitPanel.advancement.test.tsx create mode 100644 src/containers/CompetitionPerson/CompetitionPerson.test.tsx create mode 100644 src/containers/CompetitionResults/FallbackAdvancement.stories.tsx create mode 100644 src/containers/PersonalSchedule/Assignments.test.tsx create mode 100644 src/containers/Schedule/Schedule.test.tsx create mode 100644 src/containers/UpcomingCompetitions/UpcomingCompetitions.test.tsx create mode 100644 src/hooks/UseCollapse/UseCollapse.test.tsx create mode 100644 src/layouts/RootLayout/Header.test.tsx create mode 100644 src/pages/Home/Home.test.tsx 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/CutoffTimeLimitPanel/CutoffTimeLimitPanel.advancement.test.tsx b/src/components/CutoffTimeLimitPanel/CutoffTimeLimitPanel.advancement.test.tsx new file mode 100644 index 0000000..56dea46 --- /dev/null +++ b/src/components/CutoffTimeLimitPanel/CutoffTimeLimitPanel.advancement.test.tsx @@ -0,0 +1,113 @@ +import { render, screen } from '@testing-library/react'; +import { Competition, EventId, RankingType, 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'; + +let competitionMock: Pick; +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.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 d630964..64f02e6 100644 --- a/src/components/CutoffTimeLimitPanel/CutoffTimeLimitPanel.tsx +++ b/src/components/CutoffTimeLimitPanel/CutoffTimeLimitPanel.tsx @@ -4,7 +4,7 @@ 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,15 +181,30 @@ 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( 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/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/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/advancement.test.ts b/src/containers/CompetitionResults/advancement.test.ts index 833ca3b..93d5526 100644 --- a/src/containers/CompetitionResults/advancement.test.ts +++ b/src/containers/CompetitionResults/advancement.test.ts @@ -1,6 +1,6 @@ import { ResultCondition, Round } from '@wca/helpers'; import { getAdvancementConditionForRound, getLinkedRoundIds } from '@/lib/wcif'; -import { getStoredRoundResults } from './advancement'; +import { getQualifyingResultKeys, getStoredRoundResults, hasExpectedAttempts } from './advancement'; import * as hongKongRounds from './fixtures/CubeAgainstCancerHongKong2026.json'; import * as fmcRounds from './fixtures/FMCEastMelbourne2026.json'; @@ -108,8 +108,13 @@ describe('dual-round stored results', () => { average, })), ); - // Later participation may differ after withdrawals and replacements. Keep - // this assertion on the stated qualifying condition, not the final roster. + // The actual target participants override the earlier eligibility estimate. + const targetIds = new Set(rounds[2].results.map(({ personId }) => personId)); + expect(results.filter(({ advancing }) => advancing).map(({ personId }) => personId)).toEqual( + round.results + .filter(({ personId, ranking }) => ranking != null && targetIds.has(personId)) + .map(({ personId }) => personId), + ); const condition = getAdvancementConditionForRound(rounds, round)!; const entrants = new Set( rounds.slice(0, 2).flatMap(({ results }) => results.map(({ personId }) => personId)), @@ -118,9 +123,19 @@ describe('dual-round stored results', () => { condition.resultCondition.type === 'percent' ? Math.floor((entrants * condition.resultCondition.value) / 100) : condition.resultCondition.value!; + const qualifying = getQualifyingResultKeys( + round, + condition, + rounds + .slice(0, 2) + .flatMap(({ results }) => + results.map((result) => ({ ...result, key: String(result.personId) })), + ), + ); expect( - results.every( - ({ ranking, advancing }) => advancing === (ranking != null && ranking <= limit), + round.results.every( + ({ personId, ranking }) => + qualifying.has(String(personId)) === (ranking != null && ranking <= limit), ), ).toBe(true); } @@ -196,13 +211,18 @@ describe('stored qualification rules', () => { ).toEqual([1, 2, 3, 4, 5, 6, 7]); }); - it('does not count empty, unranked roster placeholders as scored entrants', () => { + it('counts empty participant rows in the roster but never highlights them', () => { const results: Round['results'] = [ - ...rankedResults(11), - { ...result(12, 12, 0), ranking: null, attempts: [] }, - { ...result(13, 13, 0), ranking: null, attempts: [] }, + ...rankedResults(6), + ...Array.from({ length: 4 }, (_, index) => ({ + ...result(index + 7, 0, 0), + ranking: null, + attempts: [], + })), ]; - expect(qualifiers(results, { type: 'percent', scope: 'average', value: 75 })).toHaveLength(8); + const advancing = qualifiers(results, { type: 'percent', scope: 'average', value: 75 }); + expect(advancing.map(({ personId }) => personId)).toEqual([1, 2, 3, 4, 5, 6]); + expect(advancing.every(({ advancingQuestionable }) => advancingQuestionable)).toBe(true); }); it('treats a null threshold as any successful value in the requested scope', () => { @@ -268,3 +288,161 @@ describe('stored qualification rules', () => { expect(qualifiers([], { type: 'ranking', scope: 'average', value: 10 })).toEqual([]); }); }); + +const fullResult = (personId: number, ranking: number, average = 1000) => ({ + ...result(personId, ranking, average), + attempts: Array.from({ length: 5 }, () => ({ value: average, reconstruction: null })), +}); +const withTarget = (rounds: Round[]) => { + rounds[1].participationRuleset = { + reservedPlaces: null, + participationSource: { + type: 'round', + roundId: rounds[0].id, + resultCondition: { type: 'percent', scope: 'average', value: 75 }, + }, + }; + return rounds; +}; + +describe('fallback qualification certainty and participation', () => { + it('settles calculated eligibility when all source attempts are complete', () => { + const rounds = withTarget([ + makeRound( + '333-r1', + Array.from({ length: 8 }, (_, i) => fullResult(i + 1, i + 1)), + ), + makeRound('333-r2'), + ]); + const results = getStoredRoundResults( + rounds[0], + getAdvancementConditionForRound(rounds, rounds[0]), + rounds, + ); + expect(results.filter(({ advancing }) => advancing)).toHaveLength(6); + expect(results.every(({ advancingQuestionable }) => !advancingQuestionable)).toBe(true); + }); + + it('keeps every calculated qualifier provisional when even one row is partial', () => { + const rounds = withTarget([ + makeRound( + '333-r1', + Array.from({ length: 8 }, (_, i) => fullResult(i + 1, i + 1)), + ), + makeRound('333-r2'), + ]); + rounds[0].results[7].attempts = [{ value: 1100, reconstruction: null }]; + const results = getStoredRoundResults( + rounds[0], + getAdvancementConditionForRound(rounds, rounds[0]), + rounds, + ); + expect( + results + .filter(({ advancing }) => advancing) + .every(({ advancingQuestionable }) => advancingQuestionable), + ).toBe(true); + expect( + results + .filter(({ advancing }) => !advancing) + .every(({ advancingQuestionable }) => !advancingQuestionable), + ).toBe(true); + }); + + it.each(['missing', 'empty', 'partial'])( + 'does not settle linked eligibility when a component is %s', + (state) => { + const rounds = linkedRounds({ type: 'ranking', scope: 'average', value: 3 }); + rounds[0].results = Array.from({ length: 4 }, (_, i) => fullResult(i + 1, i + 1)); + rounds[1].results = state === 'partial' ? [result(1, 1, 900)] : []; + const condition = getAdvancementConditionForRound(rounds, rounds[0]); + const sourceRounds = state === 'missing' ? [rounds[0], rounds[2]] : rounds; + expect(getStoredRoundResults(rounds[0], condition, sourceRounds)[0]).toMatchObject({ + advancing: true, + advancingQuestionable: true, + }); + }, + ); + + it('uses actual next-round participants, including blank rows, over theoretical qualifiers', () => { + const rounds = withTarget([ + makeRound( + '333-r1', + Array.from({ length: 8 }, (_, i) => result(i + 1, i + 1, 1000)), + ), + makeRound('333-r2', [ + { ...result(1, 0, 0), ranking: null, attempts: [] }, + { ...result(8, 0, 0), ranking: null, attempts: [] }, + ]), + ]); + const results = getStoredRoundResults( + rounds[0], + getAdvancementConditionForRound(rounds, rounds[0]), + rounds, + ); + expect(results.filter(({ advancing }) => advancing).map(({ personId }) => personId)).toEqual([ + 1, 8, + ]); + expect(results.every(({ advancingQuestionable }) => !advancingQuestionable)).toBe(true); + }); + + it('uses the union if a future WCIF target stage has linked components', () => { + // Current WCA dual rounds are first-round only. This covers WCIF's extensible links. + const rounds = withTarget([ + makeRound( + '333-r1', + Array.from({ length: 8 }, (_, i) => result(i + 1, i + 1, 1000)), + ), + makeRound('333-r2', [result(1, 1, 1000)]), + makeRound('333-r3', [result(8, 1, 900)]), + ]); + rounds[1].linkedRounds = ['333-r2', '333-r3']; + rounds[2].linkedRounds = ['333-r2', '333-r3']; + expect( + getStoredRoundResults(rounds[0], getAdvancementConditionForRound(rounds, rounds[0]), rounds) + .filter(({ advancing }) => advancing) + .map(({ personId }) => personId), + ).toEqual([1, 8]); + }); +}); + +describe('expected attempts in stored snapshots', () => { + it.each<[Round['format'], number]>([ + ['1', 1], + ['2', 2], + ['3', 3], + ['5', 5], + ['a', 5], + ['m', 3], + ])('recognizes completed format %s and rejects one missing attempt', (format, count) => { + const round = { ...makeRound('333-r1'), format }; + const attempts = Array.from({ length: count }, () => ({ result: -1 })); + expect(hasExpectedAttempts(round, { attempts })).toBe(true); + expect(hasExpectedAttempts(round, { attempts: attempts.slice(1) })).toBe(false); + expect(hasExpectedAttempts(round, { attempts: [...attempts.slice(1), { result: 0 }] })).toBe( + false, + ); + expect( + hasExpectedAttempts(round, { attempts: [...attempts, { result: 0 }, { result: 0 }] }), + ).toBe(true); + }); + + it('recognizes a missed cutoff, but requires all attempts once the cutoff is met', () => { + const round = { ...makeRound('333-r1'), cutoff: { numberOfAttempts: 2, resultValue: 1000 } }; + const hasAttempts = (values: number[]) => + hasExpectedAttempts(round, { attempts: values.map((result) => ({ result })) }); + expect(hasAttempts([1200])).toBe(false); + expect(hasAttempts([1200, -1, 0, 0, 0])).toBe(true); + expect(hasAttempts([-2, -2])).toBe(true); + expect(hasAttempts([1000, 1000])).toBe(true); // equality does not meet the cutoff + expect(hasAttempts([900, -1])).toBe(false); + expect(hasAttempts([900, -1, -2, 1000, 1100])).toBe(true); + expect(hasAttempts([900, -1, -2, 1000, 0])).toBe(false); + }); + + it('does not infer completion for an unsupported head-to-head format', () => { + expect(hasExpectedAttempts({ ...makeRound('333-r1'), format: 'h' }, { attempts: [] })).toBe( + false, + ); + }); +}); diff --git a/src/containers/CompetitionResults/advancement.ts b/src/containers/CompetitionResults/advancement.ts index e7cbea9..10346db 100644 --- a/src/containers/CompetitionResults/advancement.ts +++ b/src/containers/CompetitionResults/advancement.ts @@ -1,5 +1,5 @@ -import { RankingType, Round } from '@wca/helpers'; -import { RoundAdvancementCondition } from '@/lib/wcif'; +import { RankingType, Round, getFormatExpectedSolves } from '@wca/helpers'; +import { RoundAdvancementCondition, getLinkedRoundIds } from '@/lib/wcif'; import { CompetitionRoundResult } from './CompetitionResultsTable'; import { normalizeResultRecordTag } from './ResultRecordBadge'; @@ -28,8 +28,9 @@ export const getQualifyingResultKeys = ( resultsByPerson.set(result.key, personResults); }); - // WCIF supplies official ranks shared across linked components. Count each - // scored competitor once; an empty, unranked roster row is not a result. + // WCIF supplies official ranks shared across linked components. Like Live, + // count every participant result row, including an empty one, exactly once. + // Scheduled group assignments are not evidence of current participation. const rankingsByPerson = new Map(); resultsByPerson.forEach((results, key) => { const rankings = results.flatMap(({ ranking }) => @@ -38,7 +39,7 @@ export const getQualifyingResultKeys = ( if (rankings.length > 0) rankingsByPerson.set(key, Math.min(...rankings)); }); const rankings = [...rankingsByPerson.values()].sort((a, b) => a - b); - const resultCount = rankings.length; + const resultCount = resultsByPerson.size; const maxQualifying = Math.floor(resultCount * 0.75); // WCA Live's qualification algorithm excludes the entire tied group if it // crosses the 75% cap (9p1). Never break an official tie by array order. @@ -77,6 +78,97 @@ export const getQualifyingResultKeys = ( ); }; +type QualificationRound = { + round: Pick; + results: (QualificationResult & Pick)[]; +}; + +export const hasExpectedAttempts = ( + round: Pick, + result: Pick, +): boolean => { + const maxAttempts = getFormatExpectedSolves(round.format); + if (maxAttempts === 0) return false; + + // Published API results can be padded with zeros. A trailing zero is missing, + // not an entered attempt; an interior skipped attempt still occupies its slot. + const attempts = result.attempts.map(({ result }) => result); + while (attempts.at(-1) === 0) attempts.pop(); + const meetsCutoff = + !round.cutoff || + attempts + .slice(0, round.cutoff.numberOfAttempts) + .some((value) => value > 0 && value < round.cutoff!.resultValue); + const expected = meetsCutoff ? maxAttempts : round.cutoff!.numberOfAttempts; + return attempts.length === expected; +}; + +/** + * Stored snapshots can establish next-round participation or estimate eligibility. + * Live exports all result rows, including blank ones, so a populated target round + * is the participant list, not a reason to reapply the previous qualifying rule. + * https://github.com/thewca/wca-live/blob/main/lib/wca_live/scoretaking/advancing.ex + * + * For incomplete snapshots we deliberately do not claim Live's clinching parity: + * all calculated qualifiers remain provisional until the source results are + * complete. WCIF has no Live edit history or organizer-only disqualification state. + */ +export const getFallbackAdvancement = ( + round: Pick, + advancementCondition: RoundAdvancementCondition | null, + sourceRounds: QualificationRound[], + targetResults: (Pick & { identityResolved?: boolean })[] = [], + sourceIdentityComplete = true, +): { advancingKeys: Set; questionableKeys: Set } => { + if (!advancementCondition) return { advancingKeys: new Set(), questionableKeys: new Set() }; + const actualParticipantKeys = new Set( + targetResults + .filter(({ identityResolved }) => identityResolved !== false) + .map(({ key }) => key), + ); + const targetIdentityComplete = targetResults.every( + ({ identityResolved }) => identityResolved !== false, + ); + if (targetResults.length > 0 && targetIdentityComplete) { + return { advancingKeys: actualParticipantKeys, questionableKeys: new Set() }; + } + + const sourceResults = sourceRounds.flatMap(({ results }) => results); + const qualifyingKeys = sourceIdentityComplete + ? getQualifyingResultKeys(round, advancementCondition, sourceResults) + : new Set(); + const complete = + targetIdentityComplete && + sourceRounds.length === advancementCondition.sourceRoundIds.length && + sourceRounds.every( + ({ round, results }) => + results.length > 0 && results.every((result) => hasExpectedAttempts(round, result)), + ); + + // Unidentified target rows cannot prove that a known qualifier was excluded. + // Keep those estimates provisional, but settle the actual matches we do know. + return { + advancingKeys: new Set([...qualifyingKeys, ...actualParticipantKeys]), + questionableKeys: new Set( + [...qualifyingKeys].filter((key) => !complete && !actualParticipantKeys.has(key)), + ), + }; +}; + +/** Never interpret a linked source component as evidence of later participation. */ +export const getAdvancementTargetRoundIds = ( + eventRounds: Round[], + advancementCondition: RoundAdvancementCondition | null, +): string[] => { + const targetRound = eventRounds.find(({ id }) => id === advancementCondition?.targetRoundId); + if (!targetRound || !advancementCondition) return []; + const linkedTargetIds = getLinkedRoundIds(eventRounds, targetRound); + return linkedTargetIds.length > 0 && + !linkedTargetIds.some((id) => advancementCondition.sourceRoundIds.includes(id)) + ? linkedTargetIds + : [targetRound.id]; +}; + export const getStoredRoundResults = ( round: Round, advancementCondition: RoundAdvancementCondition | null, @@ -86,12 +178,21 @@ export const getStoredRoundResults = ( advancementCondition?.sourceType === 'linkedRounds' ? eventRounds.filter(({ id }) => advancementCondition.sourceRoundIds.includes(id)) : [round]; - const qualifyingKeys = getQualifyingResultKeys( + const targetRoundIds = getAdvancementTargetRoundIds(eventRounds, advancementCondition); + const { advancingKeys, questionableKeys } = getFallbackAdvancement( round, advancementCondition, - sourceRounds.flatMap(({ results }) => - results.map((result) => ({ ...result, key: String(result.personId) })), - ), + sourceRounds.map((sourceRound) => ({ + round: sourceRound, + results: sourceRound.results.map((result) => ({ + ...result, + key: String(result.personId), + attempts: result.attempts.map(({ value }) => ({ result: value })), + })), + })), + eventRounds + .filter(({ id }) => targetRoundIds.includes(id)) + .flatMap(({ results }) => results.map(({ personId }) => ({ key: String(personId) }))), ); return round.results.map((result) => { @@ -102,12 +203,13 @@ export const getStoredRoundResults = ( averageRecordTag?: string | null; }; + const advancing = result.ranking != null && advancingKeys.has(String(result.personId)); return { id: result.personId, personId: result.personId, ranking: result.ranking, - advancing: result.ranking != null && qualifyingKeys.has(String(result.personId)), - advancingQuestionable: false, + advancing, + advancingQuestionable: advancing && questionableKeys.has(String(result.personId)), attempts: result.attempts.map((attempt) => ({ result: attempt.value })), best: result.best, average: result.average, diff --git a/src/containers/CompetitionResults/fixtures/README.md b/src/containers/CompetitionResults/fixtures/README.md index 4306821..33bb153 100644 --- a/src/containers/CompetitionResults/fixtures/README.md +++ b/src/containers/CompetitionResults/fixtures/README.md @@ -10,3 +10,20 @@ Only the relevant event rounds are retained (FMC and 3x3, respectively). Person WCA Regulation 9v4 defines ranking by the better result of either round; 9v4a retains both component results; 9v5 prohibits elimination between components. Advancement percentages count distinct competitors across the linked source rounds. Later participants need not match the original qualification estimate because of withdrawals/replacements. Published-results endpoint verified separately on 2026-11-03: Hong Kong uses `1`, `2`, `f` for its three 3x3 rounds; FMC East Melbourne returned no published results. WCIF is available for both. + + +## Stored advancement semantics + +- Qualification uses the distinct union of participant result rows, including blank rows, but not scheduled group assignments or registrations. Official ranks, the 75% cap, and ties are preserved. +- A populated target result roster takes precedence over calculated qualification. WCIF target membership takes precedence over published API membership; the latter is used only when the corresponding WCIF roster is empty. An unresolved API identity cannot prove that a known qualifier was excluded: known matches are settled, remaining eligible rows stay provisional. +- Without target participation, all calculated qualifiers are provisional when any source component is missing/empty or any result lacks its expected attempts. Completion respects format, cutoff, and zero-padded API attempts. Complete snapshots settle the estimate; they do not establish future organizer decisions. +- Selected Live rows override stored values and flags, including blank Live rows. Stored snapshots cannot reproduce all Live edit history, organizer-only state, or exact clinching simulation. Marking all calculated qualifiers provisional during incomplete rounds is deliberately conservative. +- Caption tests use actual English resources for timed, FMC, multi-blind, null, and linked thresholds. Storybook stories prefixed `Synthetic` are isolated edge-case fixtures, not evidence of a real competition incident. + +Primary implementation references, inspected 2026-11-03: + +- [Live advancement and clinching](https://github.com/thewca/wca-live/blob/main/lib/wca_live/scoretaking/advancing.ex) +- [Live open-round definition](https://github.com/thewca/wca-live/blob/main/lib/wca_live/scoretaking/round.ex): any result row, even blank, makes a round open. +- [Live expected attempts](https://github.com/thewca/wca-live/blob/main/lib/wca_live/scoretaking/result.ex) +- [Live WCIF export](https://github.com/thewca/wca-live/blob/main/lib/wca_live/synchronization/export.ex): includes blank result rows. +- [WCIF2 result conditions and units](https://github.com/thewca/wcif/blob/latest/specification.md#resultcondition) diff --git a/src/containers/CompetitionResults/resultsProvider.test.ts b/src/containers/CompetitionResults/resultsProvider.test.ts index fa80165..59fed34 100644 --- a/src/containers/CompetitionResults/resultsProvider.test.ts +++ b/src/containers/CompetitionResults/resultsProvider.test.ts @@ -960,3 +960,268 @@ describe('mixed-source linked qualification', () => { ).toMatchObject({ advancing: false, advancingQuestionable: true }); }); }); + +const singleSourceCompetition = (): Competition => { + const competition = linkedCompetition(); + const rounds = competition.events[0].rounds; + rounds[0].linkedRounds = null; + rounds[1].linkedRounds = null; + rounds[0].results = Array.from({ length: 8 }, (_, index) => ({ + personId: index + 1, + ranking: index + 1, + best: 1000 + index * 100, + average: 1000 + index * 100, + attempts: Array.from({ length: 5 }, () => ({ + value: 1000 + index * 100, + reconstruction: null, + })), + })); + rounds[1].participationRuleset = { + reservedPlaces: null, + participationSource: { + type: 'round', + roundId: '333-r1', + resultCondition: { type: 'percent', scope: 'average', value: 75 }, + }, + }; + rounds[2].participationRuleset = null; + return competition; +}; +const blankResult = (personId: number) => ({ + personId, + ranking: null, + best: 0, + average: 0, + attempts: [], +}); +const advancingPersonIds = (results: ReturnType) => + results.filter(({ advancing }) => advancing).map(({ personId }) => personId); + +describe('stored roster and actual-participant semantics', () => { + it('counts empty WCIF result rows but does not count group assignments in the denominator', () => { + const competition = singleSourceCompetition(); + const round = competition.events[0].rounds[0]; + round.results = [...round.results.slice(0, 6), ...[7, 8, 9, 10].map(blankResult)]; + let results = linkedSourceResults(competition, []); + expect(advancingPersonIds(results)).toEqual([1, 2, 3, 4, 5, 6]); + expect( + results + .filter(({ advancing }) => advancing) + .every(({ advancingQuestionable }) => advancingQuestionable), + ).toBe(true); + expect( + results + .slice(6) + .every(({ advancing, advancingQuestionable }) => !advancing && !advancingQuestionable), + ).toBe(true); + + // Four people have group assignments, but only this stored result is a participant. + round.results = [round.results[0]]; + results = linkedSourceResults(competition, []); + expect(results).toHaveLength(1); + expect(advancingPersonIds(results)).toEqual([]); + }); + + it('settles complete snapshots and marks partial snapshots provisional without changing values', () => { + const competition = singleSourceCompetition(); + let results = linkedSourceResults(competition, []); + expect(advancingPersonIds(results)).toEqual([1, 2, 3, 4, 5, 6]); + expect(results.every(({ advancingQuestionable }) => !advancingQuestionable)).toBe(true); + competition.events[0].rounds[0].results[0].attempts = [{ value: 1000, reconstruction: null }]; + results = linkedSourceResults(competition, []); + expect(results[0]).toMatchObject({ + best: 1000, + average: 1000, + attempts: [{ result: 1000 }], + advancing: true, + advancingQuestionable: true, + }); + expect(results[1]).toMatchObject({ advancing: true, advancingQuestionable: true }); + }); + + it('uses a populated next-round WCIF roster, including blank rows, instead of qualification', () => { + const competition = singleSourceCompetition(); + competition.events[0].rounds[1].results = [blankResult(1), blankResult(8)]; + const results = linkedSourceResults(competition, []); + expect(advancingPersonIds(results)).toEqual([1, 8]); + expect(results.every(({ advancingQuestionable }) => !advancingQuestionable)).toBe(true); + }); + + it('uses published next-round API participants when that WCIF roster is empty', () => { + const competition = singleSourceCompetition(); + const api = [ + apiResult({ name: 'Alice Solver', wca_id: '2010ALIC01', pos: 2 }), + apiResult({ name: 'Dana Solver', wca_id: '2010DANA01', pos: 1 }), + ]; + const results = linkedSourceResults(competition, api); + expect(advancingPersonIds(results)).toEqual([1, 4]); + expect(results.every(({ advancingQuestionable }) => !advancingQuestionable)).toBe(true); + }); + + it('does not restore API-only target participants when WCIF provides a newer roster', () => { + const competition = singleSourceCompetition(); + competition.events[0].rounds[1].results = [blankResult(1)]; + const results = linkedSourceResults(competition, [apiResult()]); + expect(advancingPersonIds(results)).toEqual([1]); + expect(results.find(({ personId }) => personId === 2)?.advancing).toBe(false); + }); + + it('does not mistake a group assignment in the target round for an actual participant', () => { + const competition = singleSourceCompetition(); + const group = competition.schedule.venues[0].rooms[0].activities[0].childActivities![0]; + group.activityCode = '333-r2-g1'; + const results = linkedSourceResults(competition, []); + expect(advancingPersonIds(results)).toEqual([1, 2, 3, 4, 5, 6]); + }); + + it('matches actual target API participants by stable WCA ID without creating WCIF identities', () => { + const competition = singleSourceCompetition(); + const api = [ + apiResult({ + id: 9111, + round_type_id: '1', + name: 'External Person', + wca_id: '2010ELSE01', + pos: 9, + }), + apiResult({ + id: 9222, + round_type_id: '2', + name: 'Different Published Name', + wca_id: '2010ELSE01', + pos: 1, + }), + ]; + const results = linkedSourceResults(competition, api); + expect(results.filter(({ advancing }) => advancing)).toEqual([ + expect.objectContaining({ + id: 9111, + personId: null, + wcaId: '2010ELSE01', + advancing: true, + advancingQuestionable: false, + }), + ]); + }); + + it('does not treat an unresolved target identity as proof that known qualifiers were excluded', () => { + const competition = singleSourceCompetition(); + const results = linkedSourceResults(competition, [ + apiResult({ id: 9222, round_type_id: '2', wca_id: null, name: 'Unresolved Name' }), + ]); + expect(advancingPersonIds(results)).toEqual([1, 2, 3, 4, 5, 6]); + expect( + results + .filter(({ advancing }) => advancing) + .every(({ advancingQuestionable }) => advancingQuestionable), + ).toBe(true); + }); + + it('settles known target matches while leaving other eligible rows provisional if target identity is incomplete', () => { + const competition = singleSourceCompetition(); + const results = linkedSourceResults(competition, [ + apiResult({ id: 9222, round_type_id: '2', wca_id: null, name: 'Unresolved Name' }), + apiResult({ id: 9333, round_type_id: '2', wca_id: '2010ALIC01', name: 'Alice Solver' }), + ]); + expect(advancingPersonIds(results)).toEqual([1, 2, 3, 4, 5, 6]); + expect(results[0]).toMatchObject({ advancing: true, advancingQuestionable: false }); + expect(results[1]).toMatchObject({ advancing: true, advancingQuestionable: true }); + }); + + it('never treats a linked source row as evidence of target participation', () => { + const competition = singleSourceCompetition(); + const rounds = competition.events[0].rounds; + rounds[1].linkedRounds = ['333-r1', '333-r2']; + rounds[1].results = [blankResult(8)]; + expect(advancingPersonIds(linkedSourceResults(competition, []))).toEqual([8]); + }); + + it('uses known actual target participation even when linked source identity cannot be resolved', () => { + const competition = linkedCompetition(); + competition.events[0].rounds[2].results = [blankResult(2)]; + const results = linkedSourceResults(competition, [ + apiResult({ name: 'Unresolved Person', wca_id: null }), + ]); + expect(advancingPersonIds(results)).toEqual([2]); + expect(results.find(({ personId }) => personId === 2)).toMatchObject({ + advancingQuestionable: false, + }); + }); + + it('keeps each component value while using the shared target participation set', () => { + const competition = linkedCompetition(); + const rounds = competition.events[0].rounds; + rounds[1].results = [{ ...rounds[0].results[0], best: 800, average: 900 }]; + rounds[2].results = [blankResult(1)]; + expect(linkedSourceResults(competition, [])[0]).toMatchObject({ + best: 1500, + average: 1500, + advancing: true, + advancingQuestionable: false, + }); + expect(linkedSourceResults(competition, [], 1)[0]).toMatchObject({ + best: 800, + average: 900, + advancing: true, + advancingQuestionable: false, + }); + }); + + it('lets a blank selected Live row clear stale WCIF scores and qualifying flags', () => { + const competition = singleSourceCompetition(); + const results = linkedSourceResults( + competition, + [], + 0, + liveRound([ + liveRoundResult({ + person: { id: '1', registrantId: 1, name: 'Alice Solver' }, + ranking: null, + best: 0, + average: 0, + attempts: [], + advancing: false, + advancingQuestionable: false, + }), + ]), + ); + expect(results.find(({ personId }) => personId === 1)).toMatchObject({ + ranking: null, + best: 0, + average: 0, + attempts: [], + advancing: false, + advancingQuestionable: false, + }); + }); + + it('keeps selected Live flags authoritative over an actual stored target roster', () => { + const competition = singleSourceCompetition(); + competition.events[0].rounds[1].results = [blankResult(1)]; + const results = linkedSourceResults( + competition, + [], + 0, + liveRound([ + liveRoundResult({ + person: { id: '1', registrantId: 1, name: 'Alice Solver' }, + advancing: false, + advancingQuestionable: false, + }), + liveRoundResult({ + person: { id: '2', registrantId: 2, name: 'Bob Solver' }, + advancing: true, + advancingQuestionable: true, + }), + ]), + ); + expect(results.find(({ personId }) => personId === 1)).toMatchObject({ + advancing: false, + advancingQuestionable: false, + }); + expect(results.find(({ personId }) => personId === 2)).toMatchObject({ + advancing: true, + advancingQuestionable: true, + }); + }); +}); diff --git a/src/containers/CompetitionResults/resultsProvider.ts b/src/containers/CompetitionResults/resultsProvider.ts index c1bbba6..df5a8a0 100644 --- a/src/containers/CompetitionResults/resultsProvider.ts +++ b/src/containers/CompetitionResults/resultsProvider.ts @@ -7,7 +7,11 @@ import { getAdvancementConditionForRound } from '@/lib/wcif'; import { PersonalRoundResult } from '../CompetitionPersonalResults/CompetitionPersonalResultsTable'; import { CompetitionRoundResult } from './CompetitionResultsTable'; import { normalizeResultRecordTag } from './ResultRecordBadge'; -import { getQualifyingResultKeys, getStoredRoundResults } from './advancement'; +import { + getAdvancementTargetRoundIds, + getFallbackAdvancement, + getStoredRoundResults, +} from './advancement'; import { findPersonForApiResult, findUniquePersonByName, @@ -94,26 +98,25 @@ const findPersonForLiveRoundResult = ( }; const getLiveRoundResults = (wcif: Competition, wcaLiveRound: WcaLiveRound | null | undefined) => - wcaLiveRound?.results - .filter((result) => result.attempts.length > 0) - .map((result) => { - const matchedPerson = findPersonForLiveRoundResult(wcif, result); - - return { - id: result.id, - personId: matchedPerson?.registrantId ?? result.person.registrantId, - personName: result.person.name, - wcaId: result.person.wcaId, - ranking: result.ranking, - advancing: result.advancing, - advancingQuestionable: result.advancingQuestionable, - attempts: result.attempts, - best: result.best, - average: result.average, - singleRecordTag: normalizeResultRecordTag(result.singleRecordTag), - averageRecordTag: normalizeResultRecordTag(result.averageRecordTag), - }; - }) ?? []; + // Blank Live rows also supersede stale stored scores and advancement flags. + wcaLiveRound?.results.map((result) => { + const matchedPerson = findPersonForLiveRoundResult(wcif, result); + + return { + id: result.id, + personId: matchedPerson?.registrantId ?? result.person.registrantId, + personName: result.person.name, + wcaId: result.person.wcaId, + ranking: result.ranking, + advancing: result.advancing, + advancingQuestionable: result.advancingQuestionable, + attempts: result.attempts, + best: result.best, + average: result.average, + singleRecordTag: normalizeResultRecordTag(result.singleRecordTag), + averageRecordTag: normalizeResultRecordTag(result.averageRecordTag), + }; + }) ?? []; export const getRoundResultsFromSources = ({ wcif, @@ -160,18 +163,51 @@ export const getRoundResultsFromSources = ({ const hasUnresolvedLinkedIdentity = advancementCondition?.sourceType === 'linkedRounds' && sourceResults.some((result) => result.personId == null && !result.wcaId); - const qualifyingKeys = getQualifyingResultKeys( + // A WCIF target roster is a complete membership snapshot, so do not resurrect + // an API-only participant that was removed from it. Use published API results + // only when WCIF has no target rows. Assignments are not actual participation. + const targetRoundIds = getAdvancementTargetRoundIds( + selectedRound.event.rounds, + advancementCondition, + ); + const targetResults = selectedRound.event.rounds + .filter(({ id }) => targetRoundIds.includes(id)) + .flatMap((round) => + round.results.length > 0 + ? getStoredRoundResults(round, null) + : (apiResultsByRound.get(round.id) ?? []).map((result) => + toCompetitionRoundResult(wcif.persons, result), + ), + ); + const { advancingKeys, questionableKeys } = getFallbackAdvancement( selectedRound.round, - hasUnresolvedLinkedIdentity ? null : advancementCondition, - sourceResults.map((result) => ({ ...result, key: getResultKey(result) })), + advancementCondition, + selectedRound.event.rounds + .filter(({ id }) => sourceRoundIds.includes(id)) + .map((round) => ({ + round, + results: (fallbackResultsByRound.get(round.id) ?? []).map((result) => ({ + ...result, + key: getResultKey(result), + })), + })), + targetResults.map((result) => ({ + key: getResultKey(result), + identityResolved: result.personId != null || Boolean(result.wcaId), + })), + !hasUnresolvedLinkedIdentity, ); const fallbackResults = (fallbackResultsByRound.get(selectedRound.round.id) ?? []).map( - (result) => ({ - ...result, + (result) => { // Keep empty component/roster rows visually empty, even if the competitor // qualifies through the other component. Live flags remain authoritative. - advancing: result.ranking != null && qualifyingKeys.has(getResultKey(result)), - }), + const advancing = result.ranking != null && advancingKeys.has(getResultKey(result)); + return { + ...result, + advancing, + advancingQuestionable: advancing && questionableKeys.has(getResultKey(result)), + }; + }, ); const liveRoundResults = getLiveRoundResults(wcif, wcaLiveRound); diff --git a/src/containers/CompetitionRound/CompetitionRound.test.tsx b/src/containers/CompetitionRound/CompetitionRound.test.tsx index 0b7f791..81f9b4c 100644 --- a/src/containers/CompetitionRound/CompetitionRound.test.tsx +++ b/src/containers/CompetitionRound/CompetitionRound.test.tsx @@ -322,3 +322,19 @@ describe('CompetitionRoundContainer', () => { } }); }); + +it.each(['333-r99', 'not-a-round', '999-r1'])( + 'explains missing round %s without implying that it has a schedule or results', + (roundId) => { + const { rerender } = renderRound(roundId); + expect(screen.getByRole('status')).toHaveTextContent('competition.results.roundNotFound'); + expect(screen.queryByText('See Results')).not.toBeInTheDocument(); + expect(screen.getByText('Back To Events')).toHaveAttribute( + 'href', + '/competitions/TestComp2026/events/', + ); + rerender(); + expect(screen.queryByRole('status')).not.toBeInTheDocument(); + expect(screen.getByText('Group 1')).toBeInTheDocument(); + }, +); diff --git a/src/containers/CompetitionRound/CompetitionRound.tsx b/src/containers/CompetitionRound/CompetitionRound.tsx index e0837e2..f26d8dd 100644 --- a/src/containers/CompetitionRound/CompetitionRound.tsx +++ b/src/containers/CompetitionRound/CompetitionRound.tsx @@ -32,17 +32,17 @@ export function CompetitionRoundContainer({ const { wcif, setTitle } = useWCIF(); const { roundActivies } = useWcifUtils(); - useEffect(() => { - setTitle(activityCodeToName(roundId)); - }, [roundId, setTitle]); - const round = useMemo(() => { const events = wcif && getAllEvents(wcif); return events?.flatMap((e) => e.rounds).find((r) => r.id === roundId); }, [roundId, wcif]); + useEffect(() => { + setTitle(round ? activityCodeToName(roundId) : t('competition.results.roundNotFound')); + }, [round, roundId, setTitle, t]); + const eventRounds = useMemo( - () => getEventRoundsForRound(wcif?.events, roundId), - [roundId, wcif?.events], + () => (round ? getEventRoundsForRound(wcif?.events, roundId) : []), + [round, roundId, wcif?.events], ); const linkedRoundNames = useMemo( () => @@ -61,6 +61,21 @@ export function CompetitionRoundContainer({ const uniqueGroupCodes = [...new Set(groups.map((g) => g.activityCode))]; const hasResults = (round?.results.length ?? 0) > 0; + if (!wcif) return null; + + if (!round) { + return ( + +

    {t('competition.results.roundNotFound')}

    + +
    + ); + } + return (
    diff --git a/src/containers/Competitors/Competitors.test.tsx b/src/containers/Competitors/Competitors.test.tsx index 2d9b92d..eeb33e4 100644 --- a/src/containers/Competitors/Competitors.test.tsx +++ b/src/containers/Competitors/Competitors.test.tsx @@ -1,10 +1,13 @@ import { fireEvent, render, screen } from '@testing-library/react'; import { Competition, Person } from '@wca/helpers'; import { Link, MemoryRouter, Route, Routes, useLocation, useNavigate } from 'react-router-dom'; +import { usePinnedPersons } from '@/hooks/UsePinnedPersons'; import { Competitors } from './Competitors'; jest.mock('react-i18next', () => ({ useTranslation: () => ({ t: (key: string) => key }) })); -jest.mock('@/hooks/UsePinnedPersons', () => ({ usePinnedPersons: () => ({ pinnedPersons: [] }) })); +jest.mock('@/hooks/UsePinnedPersons', () => ({ + usePinnedPersons: jest.fn(() => ({ pinnedPersons: [] })), +})); jest.mock('@/hooks/useOngoingActivities', () => ({ useOngoingActivities: () => ({ ongoingActivities: [] }), })); @@ -96,3 +99,20 @@ it('renders no unpinned competitors for a search with no match', () => { renderCompetition('/competitions/Test2026?search=unknown'); expect(screen.queryAllByRole('link')).toHaveLength(0); }); + +it('announces no matches and removes the message when the search is cleared', () => { + renderCompetition('/competitions/Test2026?search=unknown'); + expect(screen.getByText('competition.competitors.noMatches')).toHaveAttribute('role', 'status'); + fireEvent.change(screen.getByRole('searchbox'), { target: { value: '' } }); + expect(screen.queryByText('competition.competitors.noMatches')).not.toBeInTheDocument(); + expect(screen.getAllByRole('link')).toHaveLength(2); +}); + +it('does not claim there are no matches when a matching competitor is pinned above the search', () => { + jest + .mocked(usePinnedPersons) + .mockReturnValueOnce({ pinnedPersons: [2] } as ReturnType); + renderCompetition('/competitions/Test2026?search=Tin'); + expect(screen.getByRole('link', { name: 'Tin Huang' })).toBeInTheDocument(); + expect(screen.queryByText('competition.competitors.noMatches')).not.toBeInTheDocument(); +}); diff --git a/src/containers/Competitors/Competitors.tsx b/src/containers/Competitors/Competitors.tsx index 1dac564..94d2f1f 100644 --- a/src/containers/Competitors/Competitors.tsx +++ b/src/containers/Competitors/Competitors.tsx @@ -61,6 +61,10 @@ export const Competitors = ({ wcif }: { wcif: Competition }) => { !person.pinned && (!input || person.name.toLowerCase().includes(input.toLowerCase().trim())), ); + const hasMatchingPerson = acceptedPersons.some((person) => + person.name.toLowerCase().includes(input.toLowerCase().trim()), + ); + return (
    {me && ( @@ -90,7 +94,13 @@ export const Competitors = ({ wcif }: { wcif: Competition }) => {
    - + {input.trim() && !hasMatchingPerson ? ( +

    + {t('competition.competitors.noMatches')} +

    + ) : ( + + )} ); }; diff --git a/src/containers/PersonalSchedule/Assignments.test.tsx b/src/containers/PersonalSchedule/Assignments.test.tsx new file mode 100644 index 0000000..f3fffe3 --- /dev/null +++ b/src/containers/PersonalSchedule/Assignments.test.tsx @@ -0,0 +1,49 @@ +import { act, render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { Person } from '@wca/helpers'; +import { PersonalScheduleContent } from './PersonalSchedule'; + +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: () => [ + { 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()); + +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', + ); +}); diff --git a/src/containers/PersonalSchedule/Assignments.tsx b/src/containers/PersonalSchedule/Assignments.tsx index 2068e9b..99a36b6 100644 --- a/src/containers/PersonalSchedule/Assignments.tsx +++ b/src/containers/PersonalSchedule/Assignments.tsx @@ -95,14 +95,20 @@ export function Assignments({ wcif, person, showStationNumber }: AssignmentsProp return ( {!isSingleDay && ( - toggleDate(date)} className="table-row-hover-bg"> - -
    + + +
    + + )} diff --git a/src/containers/PersonalSchedule/PersonalSchedule.tsx b/src/containers/PersonalSchedule/PersonalSchedule.tsx index 9fdcbf1..d7da249 100644 --- a/src/containers/PersonalSchedule/PersonalSchedule.tsx +++ b/src/containers/PersonalSchedule/PersonalSchedule.tsx @@ -24,6 +24,7 @@ export function PersonalScheduleContent({ person }: PersonalScheduleContainerPro <> {person.assignments && person.assignments.length > 0 ? ( ({ + getScheduledDays: jest.fn(() => [ + { date: 'Saturday, 1/1/2030', activities: [{ id: 1, endTime: '2030-01-01T12:00:00Z' }] }, + ]), + hasMultipleScheduleLocations: () => false, +})); +jest.mock('./scheduleActivityGroups', () => ({ getScheduleActivityGroups: () => [{ id: '1' }] })); +jest.mock('./ScheduleActivityRow', () => ({ + ScheduleActivityRow: () =>
    Day activities
    , +})); +const wcif = { id: 'One2026' } as Competition; +beforeEach(() => localStorage.clear()); + +it('toggles with Enter and Space, preserves focus, and exposes the controlled day', async () => { + render(); + const toggle = screen.getByRole('button', { name: 'Saturday, 1/1/2030' }); + expect(toggle).toHaveAttribute('aria-expanded', 'true'); + expect(document.getElementById(toggle.getAttribute('aria-controls')!)).toContainElement( + screen.getByText('Day activities'), + ); + await act(() => userEvent.tab()); + expect(toggle).toHaveFocus(); + await act(() => userEvent.keyboard('{Enter}')); + expect(toggle).toHaveAttribute('aria-expanded', 'false'); + expect(screen.queryByText('Day activities')).not.toBeInTheDocument(); + expect(toggle).toHaveFocus(); + await act(() => userEvent.keyboard(' ')); + expect(toggle).toHaveAttribute('aria-expanded', 'true'); + expect(screen.getByText('Day activities')).toBeInTheDocument(); + expect(toggle).toHaveFocus(); +}); + +it('restores a saved collapse state without carrying it into another competition on the same date', async () => { + const { rerender } = render(); + await act(() => userEvent.click(screen.getByText('Saturday, 1/1/2030'))); + expect(screen.queryByText('Day activities')).not.toBeInTheDocument(); + rerender(); + expect(screen.getByText('Day activities')).toBeInTheDocument(); + rerender(); + expect(screen.queryByText('Day activities')).not.toBeInTheDocument(); +}); + +it('persists toggles for multiple days rather than overwriting the previous day', async () => { + const mockDays = jest.mocked(getScheduledDays); + const days = mockDays(wcif); + mockDays.mockReturnValueOnce([...days, { ...days[0], date: 'Sunday, 1/2/2030' }]); + const { unmount } = render(); + await act(() => userEvent.click(screen.getByText('Saturday, 1/1/2030'))); + await act(() => userEvent.click(screen.getByText('Sunday, 1/2/2030'))); + const saved = JSON.parse(localStorage.getItem('collapse')!) as Record; + expect(saved['One2026-schedule']).toEqual(['Saturday, 1/1/2030', 'Sunday, 1/2/2030']); + unmount(); + mockDays.mockReturnValueOnce([...days, { ...days[0], date: 'Sunday, 1/2/2030' }]); + render(); + expect(screen.getAllByRole('button')).toHaveLength(2); + screen + .getAllByRole('button') + .forEach((button) => expect(button).toHaveAttribute('aria-expanded', 'false')); +}); + +it('automatically collapses past days, lets the user reopen them, and keeps later days expanded', async () => { + const mockDays = jest.mocked(getScheduledDays); + const days = mockDays(wcif); + mockDays.mockReturnValueOnce([ + { + ...days[0], + date: 'Past day', + activities: [{ ...days[0].activities[0], endTime: '2020-01-01T12:00:00Z' }], + }, + { ...days[0], date: 'Future day' }, + ]); + try { + render(); + const pastDay = screen.getByRole('button', { name: 'Past day' }); + expect(pastDay).toHaveAttribute('aria-expanded', 'false'); + expect(screen.getByRole('button', { name: 'Future day' })).toHaveAttribute( + 'aria-expanded', + 'true', + ); + await act(() => userEvent.click(pastDay)); + expect(pastDay).toHaveAttribute('aria-expanded', 'true'); + expect(pastDay).toHaveFocus(); + } finally { + jest.restoreAllMocks(); + } +}); diff --git a/src/containers/Schedule/Schedule.tsx b/src/containers/Schedule/Schedule.tsx index 40006ba..54ffc26 100644 --- a/src/containers/Schedule/Schedule.tsx +++ b/src/containers/Schedule/Schedule.tsx @@ -1,5 +1,5 @@ import { Competition } from '@wca/helpers'; -import { useCallback, useEffect, useMemo } from 'react'; +import { useEffect, useId, useMemo } from 'react'; import { useCollapse } from '@/hooks/UseCollapse'; import { getScheduledDays, hasMultipleScheduleLocations } from '@/lib/activities'; import { LinkRenderer } from '@/lib/linkRenderer'; @@ -14,34 +14,39 @@ const ScheduleDay = ({ date, activities, showRoom, + collapsed, + onToggle, LinkComponent, }: { - wcif; + wcif: Competition; date: string; activities: ActivityWithRoomOrParent[]; showRoom: boolean; + collapsed: boolean; + onToggle: () => void; LinkComponent?: LinkRenderer; }) => { - const { collapsedDates, toggleDate } = useCollapse(key(wcif.id)); const activityGroups = useMemo( () => getScheduleActivityGroups(wcif, activities), [activities, wcif], ); - const collapsed = collapsedDates.includes(date); - const toggleCollapsed = useCallback(() => { - toggleDate(date); - }, [date, toggleDate]); + const activitiesId = useId(); return (
    -
    toggleCollapsed()}> +
    -
    + + +