Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@
"@tanstack/react-query-persist-client": "^5.48.0",
"@total-typescript/ts-reset": "^0.3.7",
"@uidotdev/usehooks": "^2.4.1",
"@wca/helpers": "^1.1.5",
"@wca/helpers": "^2.0.0",
"classnames": "^2.3.2",
"country-flag-icons": "^1.5.4",
"date-fns": "^2.30.0",
Expand Down
Original file line number Diff line number Diff line change
@@ -1,35 +1,35 @@
import type { Meta, StoryObj } from '@storybook/react';
import {
getStorybookRoundFixture,
makeStorybookCompetitionFixtureWithRound,
makeStorybookCompetitionFixtureWithRoundUpdates,
storybookParticipationConditionLinkedRoundsFixture,
storybookParticipationConditionPercentFixture,
} from '@/storybook/competitionFixtures';
import { makeCompetitionContainerDecorator } from '@/storybook/competitionStorybook';
import { CutoffTimeLimitPanel } from './CutoffTimeLimitPanel';

const cumulativeTimeLimitCompetitionFixture = makeStorybookCompetitionFixtureWithRound(
'333-r2',
(round) => ({
const cumulativeTimeLimitCompetitionFixture = makeStorybookCompetitionFixtureWithRoundUpdates({
'333-r2': (round) => ({
...round,
timeLimit: {
centiseconds: 15000,
cumulativeRoundIds: ['333-r2', '333-r3'],
},
advancementCondition: {
type: 'attemptResult',
level: 950,
},
}),
);

const finalRoundCompetitionFixture = makeStorybookCompetitionFixtureWithRound(
'333-r3',
(round) => ({
'333-r3': (round) => ({
...round,
advancementCondition: null,
participationRuleset: {
reservedPlaces: null,
participationSource: {
type: 'round',
roundId: '333-r2',
resultCondition: { type: 'resultAchieved', scope: 'single', value: 950 },
},
},
}),
);
});

const finalRoundCompetitionFixture = makeStorybookCompetitionFixtureWithRoundUpdates({});

const meta = {
title: 'Components/Competition/CutoffTimeLimitPanel',
Expand Down
99 changes: 77 additions & 22 deletions src/components/CutoffTimeLimitPanel/CutoffTimeLimitPanel.test.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import '@testing-library/jest-dom';
import { fireEvent, render, screen } from '@testing-library/react';
import { Round } from '@wca/helpers';
import { Competition, Round } from '@wca/helpers';
import { MemoryRouter } from 'react-router-dom';
import { CutoffTimeLimitPanel } from './CutoffTimeLimitPanel';

Expand Down Expand Up @@ -110,35 +110,54 @@ jest.mock('react-i18next', () => ({
}),
}));

const wcifMock = {
const wcifMock: Competition = {
formatVersion: '2.0',
name: 'Test Competition',
shortName: 'Test Competition',
persons: [],
series: null,
competitorLimit: null,
extensions: [],
registrationInfo: {
openTime: '2026-01-01T00:00:00Z',
closeTime: '2026-04-01T00:00:00Z',
baseEntryFee: 0,
currencyCode: 'USD',
onTheSpotRegistration: false,
useWcaRegistration: true,
},
id: 'TestComp2026',
schedule: { venues: [] },
schedule: { startDate: '2026-05-03', numberOfDays: 1, venues: [] },
events: [
{
id: '333',
extensions: [],
rounds: [
{
id: '333-r1',
extensions: [],
format: 'a',
cutoff: null,
timeLimit: null,
advancementCondition: {
type: 'ranking',
level: 16,
},
linkedRounds: null,
participationRuleset: null,
results: [],
},
{
id: '333-r2',
extensions: [],
format: 'a',
cutoff: null,
timeLimit: null,
linkedRounds: null,
participationRuleset: {
reservedPlaces: null,
participationSource: {
type: 'round',
roundId: '333-r1',
resultCondition: {
type: 'percent',
scope: 'average',
value: 75,
},
},
Expand All @@ -147,15 +166,19 @@ const wcifMock = {
},
{
id: '333-r3',
extensions: [],
format: 'a',
cutoff: null,
timeLimit: null,
linkedRounds: null,
participationRuleset: {
reservedPlaces: null,
participationSource: {
type: 'linkedRounds',
roundIds: ['333-r1', '333-r2'],
resultCondition: {
type: 'ranking',
scope: 'average',
value: 12,
},
},
Expand All @@ -166,32 +189,43 @@ const wcifMock = {
},
{
id: '222',
extensions: [],
rounds: [
{
id: '222-r1',
extensions: [],
format: 'a',
cutoff: null,
timeLimit: null,
linkedRounds: null,
participationRuleset: null,
results: [],
},
{
id: '222-r2',
extensions: [],
format: 'a',
cutoff: null,
timeLimit: null,
linkedRounds: null,
participationRuleset: null,
results: [],
},
{
id: '222-r3',
extensions: [],
format: 'a',
cutoff: null,
timeLimit: null,
linkedRounds: null,
participationRuleset: {
reservedPlaces: null,
participationSource: {
type: 'linkedRounds',
roundIds: ['222-r1', '222-r2'],
resultCondition: {
type: 'ranking',
scope: 'average',
value: 8,
},
},
Expand All @@ -211,15 +245,19 @@ jest.mock('@/providers/WCIFProvider', () => ({
}),
}));

const cutoffOnlyRound = {
const cutoffOnlyRound: Round = {
format: 'a',
extensions: [],
results: [],
id: '333-r1',
cutoff: {
numberOfAttempts: 2,
attemptResult: 1200,
resultValue: 1200,
},
timeLimit: null,
advancementCondition: null,
} as unknown as Round;
linkedRounds: null,
participationRuleset: null,
};

function renderPanel(round: Round) {
return render(
Expand All @@ -233,7 +271,9 @@ describe('CutoffTimeLimitPanel', () => {
it('uses theme-aware popover classes for help content', () => {
renderPanel(cutoffOnlyRound);

fireEvent.click(screen.getByRole('button', { name: /help/i }));
const helpButton = screen.getByRole('button', { name: /^help$/i, expanded: false });
fireEvent.click(helpButton);
expect(helpButton).toHaveAttribute('aria-expanded', 'true');

const popoverContent = screen
.getByText(/common\.cutoffTimeLimitPopover\.timeLimits/i)
Expand All @@ -242,31 +282,46 @@ describe('CutoffTimeLimitPanel', () => {
expect(popoverContent).toHaveClass('bg-panel');
expect(popoverContent).toHaveClass('text-default');
expect(popoverContent).not.toHaveClass('bg-white');
});

it('shows the legacy advancement text for stable wcif rounds', () => {
renderPanel(wcifMock.events[0].rounds[0] as unknown as Round);
fireEvent.click(helpButton);
expect(helpButton).toHaveAttribute('aria-expanded', 'false');
expect(popoverContent).not.toBeInTheDocument();
});

expect(screen.getByText('Top 16 advance to next round')).toBeInTheDocument();
it('shows ranking advancement from the target round', () => {
const nextRound = wcifMock.events[0].rounds[1];
const originalRuleset = nextRound.participationRuleset;
nextRound.participationRuleset = {
reservedPlaces: null,
participationSource: {
type: 'round',
roundId: '333-r1',
resultCondition: { type: 'ranking', scope: 'average', value: 16 },
},
};

try {
renderPanel(wcifMock.events[0].rounds[0]);
expect(screen.getByText('Top 16 advance to next round')).toBeInTheDocument();
} finally {
nextRound.participationRuleset = originalRuleset;
}
});

it('shows advancement text derived from the next round participation ruleset', () => {
renderPanel({
...(wcifMock.events[0].rounds[0] as object),
advancementCondition: null,
} as Round);
renderPanel(wcifMock.events[0].rounds[0]);

expect(screen.getByText('Top 75% advance to next round')).toBeInTheDocument();
});

it('shows linked-round advancement text when a later round depends on combined results', () => {
renderPanel(wcifMock.events[0].rounds[1] as unknown as Round);
renderPanel(wcifMock.events[0].rounds[1]);

expect(screen.getByText('Top 12 in dual rounds 1 & 2 advance to final')).toBeInTheDocument();
});

it('shows the same dual-round advancement text for the first round in a linked-round set', () => {
renderPanel(wcifMock.events[1].rounds[0] as unknown as Round);
renderPanel(wcifMock.events[1].rounds[0]);

expect(screen.getByText('Top 8 in dual rounds 1 & 2 advance to final')).toBeInTheDocument();
});
Expand Down
13 changes: 7 additions & 6 deletions src/components/CutoffTimeLimitPanel/CutoffTimeLimitPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { Link } from 'react-router-dom';
import { Popover } from 'react-tiny-popover';
import { renderCentiseconds, renderCutoff } from '@/lib/results';
import { getEventRoundsForRound, joinLabels } from '@/lib/roundLabels';
import { CompatibleRound, getAdvancementConditionForRound, ResultCondition } from '@/lib/wcif';
import { getAdvancementConditionForRound } from '@/lib/wcif';
import { useWCIF } from '@/providers/WCIFProvider';

export function CutoffTimeLimitPanel({
Expand All @@ -28,7 +28,7 @@ export function CutoffTimeLimitPanel({
[round.id, wcif?.events],
);
const advancement = useMemo(
() => getAdvancementConditionForRound(eventRounds, round as CompatibleRound),
() => getAdvancementConditionForRound(eventRounds, round),
[eventRounds, round],
);

Expand Down Expand Up @@ -110,7 +110,7 @@ function getCumulativeRoundIds(timeLimit: Round['timeLimit'], roundId: string) {

function renderAdvancementText(
t: ReturnType<typeof useTranslation>['t'],
eventRounds: CompatibleRound[],
eventRounds: Round[],
sourceType: 'registrations' | 'round' | 'linkedRounds',
advancement: NonNullable<ReturnType<typeof getAdvancementConditionForRound>>,
) {
Expand All @@ -123,7 +123,7 @@ function renderAdvancementText(

function renderLinkedRoundsAdvancementText(
t: ReturnType<typeof useTranslation>['t'],
eventRounds: CompatibleRound[],
eventRounds: Round[],
advancement: NonNullable<ReturnType<typeof getAdvancementConditionForRound>>,
) {
const { resultCondition } = advancement;
Expand Down Expand Up @@ -162,7 +162,7 @@ function renderLinkedRoundsAdvancementText(

function renderSingleRoundAdvancementText(
t: ReturnType<typeof useTranslation>['t'],
eventRounds: CompatibleRound[],
eventRounds: Round[],
advancement: NonNullable<ReturnType<typeof getAdvancementConditionForRound>>,
) {
const { resultCondition } = advancement;
Expand Down Expand Up @@ -193,7 +193,7 @@ function renderSingleRoundAdvancementText(

function getAdvancementTargetLabel(
t: ReturnType<typeof useTranslation>['t'],
eventRounds: CompatibleRound[],
eventRounds: Round[],
targetRoundId: string | null | undefined,
) {
if (!targetRoundId) {
Expand Down Expand Up @@ -250,6 +250,7 @@ function CutoffTimeLimitPopover({ cutoff }: { cutoff: Cutoff | null }) {
<button
className="px-1 mr-2"
aria-label={t('common.help')}
aria-expanded={open}
// className="p-2 px-3 text-sm rounded-full shadow-sm fa-solid fa-question bg-slate-300 hover:opacity-70"
onClick={() => setOpen((prev) => !prev)}>
<svg
Expand Down
43 changes: 43 additions & 0 deletions src/components/PinCompetitionButton/PinCompetitionButton.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { fireEvent, render, screen } from '@testing-library/react';
import { PinCompetitionButton } from './PinCompetitionButton';

const mockPin = jest.fn();
const mockUnpin = jest.fn();
const mockCompetition = { id: 'TestCompetition2026' };
let mockPinned = false;

jest.mock('@/hooks/queries/useCompetition', () => ({
useCompetition: () => ({ data: mockCompetition }),
}));
jest.mock('@/hooks/UsePinnedCompetitions', () => ({
usePinnedCompetitions: () => ({
pinnedCompetitions: mockPinned ? [mockCompetition] : [],
pinCompetition: mockPin,
unpinCompetition: mockUnpin,
}),
}));
jest.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => (key === 'common.bookmarkCompetition' ? 'Bookmark competition' : key),
}),
}));

it('exposes a named bookmark toggle and updates its pressed state after pinning/unpinning', () => {
mockPinned = false;
const { rerender } = render(<PinCompetitionButton competitionId={mockCompetition.id} />);
const button = screen.getByRole('button', { name: 'Bookmark competition', pressed: false });

fireEvent.click(button);
expect(mockPin).toHaveBeenCalledWith(mockCompetition);

mockPinned = true;
rerender(<PinCompetitionButton competitionId={mockCompetition.id} />);
fireEvent.click(screen.getByRole('button', { name: 'Bookmark competition', pressed: true }));
expect(mockUnpin).toHaveBeenCalledWith(mockCompetition.id);

mockPinned = false;
rerender(<PinCompetitionButton competitionId={mockCompetition.id} />);
expect(
screen.getByRole('button', { name: 'Bookmark competition', pressed: false }),
).toBeEnabled();
});
Loading
Loading