Skip to content

Commit d6c5103

Browse files
authored
Merge pull request #83 from coder13/agent/fix-competition-results
Merge competition result sources safely
2 parents 661bddc + c46529f commit d6c5103

7 files changed

Lines changed: 268 additions & 36 deletions

File tree

src/containers/CompetitionResults/CompetitionResults.test.tsx

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -43,8 +43,8 @@ jest.mock('react-i18next', () => ({
4343
if (key === 'competition.results.average') return 'Avg';
4444
if (key === 'competition.results.best') return 'Best';
4545
if (key === 'competition.results.attempts') return 'Attempts';
46-
if (key === 'competition.results.liveResultsDelayNote') {
47-
return 'Data is pulled from WCA Live and may be delayed.';
46+
if (key === 'competition.results.resultsSourceNote') {
47+
return 'Results are merged from WCIF and WCA Live when available. WCA Live data may be delayed.';
4848
}
4949
if (key === 'competition.results.allResults') return 'All results';
5050
if (key === 'competition.results.unknownCompetitor') {
@@ -273,7 +273,9 @@ describe('CompetitionResultsContainer', () => {
273273
expect(screen.queryByRole('link', { name: 'Round 2' })).not.toBeInTheDocument();
274274
expect(screen.queryByRole('table', { name: 'Results' })).not.toBeInTheDocument();
275275
expect(
276-
screen.getByText('Data is pulled from WCA Live and may be delayed.'),
276+
screen.getByText(
277+
'Results are merged from WCIF and WCA Live when available. WCA Live data may be delayed.',
278+
),
277279
).toBeInTheDocument();
278280
});
279281

@@ -316,7 +318,9 @@ describe('CompetitionResultsContainer', () => {
316318
expect(screen.getAllByText('12.00')).toHaveLength(2);
317319
expect(screen.queryByLabelText('Attempts')).not.toBeInTheDocument();
318320
expect(
319-
screen.getByText('Data is pulled from WCA Live and may be delayed.'),
321+
screen.getByText(
322+
'Results are merged from WCIF and WCA Live when available. WCA Live data may be delayed.',
323+
),
320324
).toBeInTheDocument();
321325
expect(
322326
screen
@@ -511,7 +515,9 @@ describe('CompetitionResultsContainer', () => {
511515
within(screen.getByRole('row', { name: /2 Nick Silvestri/ })).getAllByText('15.00'),
512516
).toHaveLength(2);
513517
expect(
514-
screen.getByText('Data is pulled from WCA Live and may be delayed.'),
518+
screen.getByText(
519+
'Results are merged from WCIF and WCA Live when available. WCA Live data may be delayed.',
520+
),
515521
).toBeInTheDocument();
516522
});
517523

src/containers/CompetitionResults/CompetitionResults.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -274,7 +274,7 @@ export function CompetitionResultsContainer({
274274
roundNumber: selectedRound.roundNumber,
275275
})}
276276
</h2>
277-
<NoteBox text={t('competition.results.liveResultsDelayNote')} />
277+
<NoteBox text={t('competition.results.resultsSourceNote')} />
278278
<ResultsEventRoundSelector
279279
selectedRoundId={selectedRound.round.id}
280280
rounds={selectedEventRoundLinks}
@@ -313,7 +313,7 @@ export function CompetitionResultsContainer({
313313
return (
314314
<Container className="pt-4">
315315
<div className="flex flex-col p-2 space-y-4 type-body">
316-
<NoteBox text={t('competition.results.liveResultsDelayNote')} />
316+
<NoteBox text={t('competition.results.resultsSourceNote')} />
317317
<RoundActionPicker mode="results" events={pickerEvents} LinkComponent={LinkComponent} />
318318
</div>
319319
</Container>

src/containers/CompetitionResults/resultSources.ts

Lines changed: 17 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,16 @@ import { normalizeResultRecordTag } from './ResultRecordBadge';
55

66
const roundTypeOrder = ['0', '1', 'c', '2', 'e', '3', 'b', 'd', 'f'];
77

8+
const normalizeName = (value: string) => value.trim().toLowerCase();
9+
10+
export const findUniquePersonByName = (persons: Person[], name: string) => {
11+
const matchingPersons = persons.filter(
12+
(person) => normalizeName(person.name) === normalizeName(name),
13+
);
14+
15+
return matchingPersons.length === 1 ? matchingPersons[0] : undefined;
16+
};
17+
818
const compareRoundTypeIds = (a: string, b: string) => {
919
const aIndex = roundTypeOrder.indexOf(a);
1020
const bIndex = roundTypeOrder.indexOf(b);
@@ -24,12 +34,13 @@ const compareRoundTypeIds = (a: string, b: string) => {
2434
return a.localeCompare(b);
2535
};
2636

27-
export const findPersonForApiResult = (persons: Person[], result: WcaCompetitionResult) =>
28-
persons.find(
29-
(person) =>
30-
(result.wca_id && person.wcaId === result.wca_id) ||
31-
person.name.toLocaleLowerCase() === result.name.toLocaleLowerCase(),
32-
);
37+
export const findPersonForApiResult = (persons: Person[], result: WcaCompetitionResult) => {
38+
if (result.wca_id) {
39+
return persons.find((person) => person.wcaId === result.wca_id);
40+
}
41+
42+
return findUniquePersonByName(persons, result.name);
43+
};
3344

3445
export const getWcaApiRoundTypeMap = (results: WcaCompetitionResult[]) => {
3546
const roundTypeIdsByEventId = new Map<string, string[]>();

src/containers/CompetitionResults/resultsProvider.test.ts

Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { Competition, Person } from '@wca/helpers';
22
import { WcaLiveCompetitorResult, WcaLiveRound } from '@/hooks/queries/useWcaLive';
33
import { WcaCompetitionResult } from '@/lib/api';
4+
import { findPersonForApiResult } from './resultSources';
45
import { getPersonalResultsFromSources, getRoundResultsFromSources } from './resultsProvider';
56

67
jest.mock('@/lib/events', () => ({
@@ -186,6 +187,8 @@ const liveRoundResult = (
186187
person: {
187188
id: '2',
188189
registrantId: 2,
190+
wcaUserId: 1002,
191+
wcaId: '2010BOBS01',
189192
name: 'Bob Solver',
190193
},
191194
...overrides,
@@ -222,6 +225,39 @@ const livePersonalResult = (
222225
...overrides,
223226
});
224227

228+
describe('findPersonForApiResult', () => {
229+
const duplicateNamePerson: Person = {
230+
...persons[0],
231+
registrantId: 4,
232+
wcaUserId: 1004,
233+
wcaId: '2010ALIC02',
234+
};
235+
236+
it('matches an exact WCA ID before considering duplicate names', () => {
237+
expect(
238+
findPersonForApiResult(
239+
[...persons, duplicateNamePerson],
240+
apiResult({ name: 'Alice Solver', wca_id: '2010ALIC02' }),
241+
),
242+
).toBe(duplicateNamePerson);
243+
});
244+
245+
it('does not fall back to a name when a WCA ID is present but unmatched', () => {
246+
expect(
247+
findPersonForApiResult(persons, apiResult({ name: 'Alice Solver', wca_id: '2099MISS01' })),
248+
).toBeUndefined();
249+
});
250+
251+
it('does not use an ambiguous name when the API result has no WCA ID', () => {
252+
expect(
253+
findPersonForApiResult(
254+
[...persons, duplicateNamePerson],
255+
apiResult({ name: 'Alice Solver', wca_id: null }),
256+
),
257+
).toBeUndefined();
258+
});
259+
});
260+
225261
describe('resultsProvider', () => {
226262
describe('getRoundResultsFromSources', () => {
227263
it('returns no round results without WCIF or a selected round', () => {
@@ -301,6 +337,8 @@ describe('resultsProvider', () => {
301337
average: 2200,
302338
round_type_id: '1',
303339
attempts: [2100, 2200, 2300],
340+
best_index: 2,
341+
worst_index: 0,
304342
regional_single_record: 'NR',
305343
regional_average_record: 'WR',
306344
}),
@@ -326,6 +364,12 @@ describe('resultsProvider', () => {
326364
singleRecordTag: 'NR',
327365
averageRecordTag: 'WR',
328366
});
367+
expect(results.find((result) => result.personId === 2)).not.toHaveProperty(
368+
'bestAttemptIndex',
369+
);
370+
expect(results.find((result) => result.personId === 2)).not.toHaveProperty(
371+
'worstAttemptIndex',
372+
);
329373
expect(results.find((result) => result.personId === 1)).toMatchObject({
330374
ranking: 1,
331375
best: 1200,
@@ -341,6 +385,98 @@ describe('resultsProvider', () => {
341385
});
342386
});
343387

388+
it('matches live round results by WCA user id when registrant id is missing', () => {
389+
const results = getRoundResultsFromSources({
390+
wcif,
391+
selectedRound: selectedRound(0),
392+
wcaApiResults: [],
393+
wcaLiveRound: liveRound([
394+
liveRoundResult({
395+
id: 'live-alice',
396+
ranking: 4,
397+
best: 900,
398+
average: 1000,
399+
attempts: [{ result: 900 }, { result: 1000 }, { result: 1100 }],
400+
person: {
401+
id: 'missing-registrant-id',
402+
registrantId: null,
403+
wcaUserId: 1001,
404+
wcaId: '2010ALIC01',
405+
name: 'Alice Published Name',
406+
},
407+
}),
408+
]),
409+
});
410+
411+
expect(results).toHaveLength(3);
412+
expect(results.filter((result) => result.personId === 1)).toHaveLength(1);
413+
expect(results.find((result) => result.personId === 1)).toMatchObject({
414+
personName: 'Alice Published Name',
415+
ranking: 4,
416+
best: 900,
417+
average: 1000,
418+
attempts: [{ result: 900 }, { result: 1000 }, { result: 1100 }],
419+
});
420+
});
421+
422+
it('leaves a live result unlinked when its name is ambiguous and identifiers are missing', () => {
423+
const duplicateNamePerson: Person = {
424+
...persons[0],
425+
registrantId: 4,
426+
wcaUserId: 1004,
427+
wcaId: '2010ALIC02',
428+
};
429+
const duplicateNameWcif = {
430+
...wcif,
431+
persons: [...persons, duplicateNamePerson],
432+
} as Competition;
433+
const results = getRoundResultsFromSources({
434+
wcif: duplicateNameWcif,
435+
selectedRound: selectedRound(0),
436+
wcaApiResults: [],
437+
wcaLiveRound: liveRound([
438+
liveRoundResult({
439+
id: 'live-ambiguous-alice',
440+
person: {
441+
id: 'missing-identifiers',
442+
registrantId: null,
443+
name: 'Alice Solver',
444+
},
445+
}),
446+
]),
447+
});
448+
449+
expect(results.find((result) => result.id === 'live-ambiguous-alice')).toMatchObject({
450+
personId: null,
451+
personName: 'Alice Solver',
452+
});
453+
});
454+
455+
it('does not override an unmatched live identity with a name match', () => {
456+
const results = getRoundResultsFromSources({
457+
wcif,
458+
selectedRound: selectedRound(0),
459+
wcaApiResults: [],
460+
wcaLiveRound: liveRound([
461+
liveRoundResult({
462+
id: 'live-conflicting-alice',
463+
person: {
464+
id: 'conflicting-identifiers',
465+
registrantId: null,
466+
wcaUserId: 9999,
467+
wcaId: '2099MISS01',
468+
name: 'Alice Solver',
469+
},
470+
}),
471+
]),
472+
});
473+
474+
expect(results.find((result) => result.id === 'live-conflicting-alice')).toMatchObject({
475+
personId: null,
476+
personName: 'Alice Solver',
477+
});
478+
});
479+
344480
it('matches WCA API results by WCA ID before using the displayed name', () => {
345481
const results = getRoundResultsFromSources({
346482
wcif,
@@ -479,6 +615,23 @@ describe('resultsProvider', () => {
479615
]);
480616
});
481617

618+
it('does not include a same-name API result with a conflicting WCA ID', () => {
619+
const results = getPersonalResultsFromSources({
620+
wcif,
621+
person: persons[0],
622+
wcaApiResults: [
623+
apiResult({
624+
id: 9201,
625+
name: 'Alice Solver',
626+
wca_id: '2010BOBS01',
627+
}),
628+
],
629+
wcaLiveResults: [],
630+
});
631+
632+
expect(results[0].rounds.map((round) => round.roundId)).toEqual(['333-r1']);
633+
});
634+
482635
it('sorts live personal events and rounds before merging them', () => {
483636
const secondRound = livePersonalResult({
484637
id: 'live-personal-333-r2',

0 commit comments

Comments
 (0)