From 5688a3c865838f6077b16000ed47490d3c5c4418 Mon Sep 17 00:00:00 2001 From: river0525 <0525sotaro@gmail.com> Date: Fri, 26 Dec 2025 14:16:40 +0900 Subject: [PATCH 1/3] :sparkles: Add JOISecondQualAndSemiFinalRound (#2992) --- src/lib/types/contest_table_provider.ts | 9 + src/lib/utils/contest_table_provider.ts | 133 +++++++++++ .../lib/utils/contest_table_provider.test.ts | 215 ++++++++++++++++++ 3 files changed, 357 insertions(+) diff --git a/src/lib/types/contest_table_provider.ts b/src/lib/types/contest_table_provider.ts index 057013178..19fa3f6f9 100644 --- a/src/lib/types/contest_table_provider.ts +++ b/src/lib/types/contest_table_provider.ts @@ -84,6 +84,15 @@ export const TESSOKU_SECTIONS = { CHALLENGES: 'challenges', } as const; +export const JOI_SECOND_QUAL_ROUND_SECTIONS = { + '2020Onwards': '2020Onwards', + from2006To2019: 'from2006To2019', +} as const; + +export const JOI_FINAL_ROUND_SECTIONS = { + semiFinal: 'semiFinal', +} as const; + /** * Represents a two-dimensional table of contest results. * diff --git a/src/lib/utils/contest_table_provider.ts b/src/lib/utils/contest_table_provider.ts index 32bb3897a..48e36d180 100644 --- a/src/lib/utils/contest_table_provider.ts +++ b/src/lib/utils/contest_table_provider.ts @@ -6,6 +6,8 @@ import { type ContestTableDisplayConfig, type ProviderKey, TESSOKU_SECTIONS, + JOI_SECOND_QUAL_ROUND_SECTIONS, + JOI_FINAL_ROUND_SECTIONS, } from '$lib/types/contest_table_provider'; import { ContestType } from '$lib/types/contest'; import type { TaskResults, TaskResult } from '$lib/types/task'; @@ -834,6 +836,126 @@ export class JOIFirstQualRoundProvider extends ContestTableProviderBase { } } +const regexForJoiSecondQualRound = /^(joi)(\d{4})(yo2)$/i; + +export class JOISecondQualRound2020OnwardsProvider extends ContestTableProviderBase { + constructor(contestType: ContestType) { + super(contestType, JOI_SECOND_QUAL_ROUND_SECTIONS['2020Onwards']); + } + + protected setFilterCondition(): (taskResult: TaskResult) => boolean { + return (taskResult: TaskResult) => { + if (classifyContest(taskResult.contest_id) !== this.contestType) { + return false; + } + + return regexForJoiSecondQualRound.test(taskResult.contest_id); + }; + } + + getMetadata(): ContestTableMetaData { + return { + title: 'JOI 二次予選', + abbreviationName: 'joiSecondQualRound2020Onwards', + }; + } + + getDisplayConfig(): ContestTableDisplayConfig { + return { + isShownHeader: true, + isShownRoundLabel: true, + isShownTaskIndex: false, + tableBodyCellsWidth: 'w-1/2 xs:w-1/3 sm:w-1/4 md:w-1/5 px-1 py-1', + roundLabelWidth: 'xl:w-28', + }; + } + + getContestRoundLabel(contestId: string): string { + const contestNameLabel = getContestNameLabel(contestId); + return contestNameLabel.replace('JOI 二次予選 ', ''); + } +} + +const regexForJoiQualRound = /^(joi)(\d{4})(yo)$/i; + +export class JOIQualRoundFrom2006To2019Provider extends ContestTableProviderBase { + constructor(contestType: ContestType) { + super(contestType, JOI_SECOND_QUAL_ROUND_SECTIONS.from2006To2019); + } + + protected setFilterCondition(): (taskResult: TaskResult) => boolean { + return (taskResult: TaskResult) => { + if (classifyContest(taskResult.contest_id) !== this.contestType) { + return false; + } + + return regexForJoiQualRound.test(taskResult.contest_id); + }; + } + + getMetadata(): ContestTableMetaData { + return { + title: 'JOI 予選', + abbreviationName: 'joiQualRoundFrom2006To2019', + }; + } + + getDisplayConfig(): ContestTableDisplayConfig { + return { + isShownHeader: true, + isShownRoundLabel: true, + isShownTaskIndex: false, + tableBodyCellsWidth: 'w-1/2 xs:w-1/3 sm:w-1/4 md:w-1/5 lg:w-1/6 px-1 py-1', + roundLabelWidth: 'xl:w-28', + }; + } + + getContestRoundLabel(contestId: string): string { + const contestNameLabel = getContestNameLabel(contestId); + return contestNameLabel.replace('JOI 予選 ', ''); + } +} + +const regexForJoiSemiFinalRound = /^(joi)(\d{4})(ho)$/i; + +export class JOISemiFinalRoundProvider extends ContestTableProviderBase { + constructor(contestType: ContestType) { + super(contestType, JOI_FINAL_ROUND_SECTIONS.semiFinal); + } + + protected setFilterCondition(): (taskResult: TaskResult) => boolean { + return (taskResult: TaskResult) => { + if (classifyContest(taskResult.contest_id) !== this.contestType) { + return false; + } + + return regexForJoiSemiFinalRound.test(taskResult.contest_id); + }; + } + + getMetadata(): ContestTableMetaData { + return { + title: 'JOI 本選', + abbreviationName: 'joiSemiFinalRoundProvider', + }; + } + + getDisplayConfig(): ContestTableDisplayConfig { + return { + isShownHeader: true, + isShownRoundLabel: true, + isShownTaskIndex: false, + tableBodyCellsWidth: 'w-1/2 xs:w-1/3 sm:w-1/4 md:w-1/5 px-1 py-1', + roundLabelWidth: 'xl:w-28', + }; + } + + getContestRoundLabel(contestId: string): string { + const contestNameLabel = getContestNameLabel(contestId); + return contestNameLabel.replace('JOI 本選 ', ''); + } +} + /** * A class that manages individual provider groups * Manages multiple ContestTableProviders as a single group, @@ -1104,6 +1226,16 @@ export const prepareContestProviderPresets = () => { buttonLabel: 'JOI 一次予選', ariaLabel: 'Filter JOI First Qualifying Round', }).addProvider(new JOIFirstQualRoundProvider(ContestType.JOI)), + + JOISecondQualAndSemiFinalRound: () => + new ContestTableProviderGroup(`JOI 二次予選・予選・本選`, { + buttonLabel: 'JOI 二次予選・予選・本選', + ariaLabel: 'Filter JOI Second Qual Round', + }).addProviders( + new JOISecondQualRound2020OnwardsProvider(ContestType.JOI), + new JOIQualRoundFrom2006To2019Provider(ContestType.JOI), + new JOISemiFinalRoundProvider(ContestType.JOI), + ), }; }; @@ -1125,6 +1257,7 @@ export const contestTableProviderGroups = { dps: prepareContestProviderPresets().dps(), // Dynamic Programming (DP) Contests aclPractice: prepareContestProviderPresets().AclPractice(), joiFirstQualRound: prepareContestProviderPresets().JOIFirstQualRound(), + joiSecondQualAndSemiFinalRound: prepareContestProviderPresets().JOISecondQualAndSemiFinalRound(), }; export type ContestTableProviderGroups = keyof typeof contestTableProviderGroups; diff --git a/src/test/lib/utils/contest_table_provider.test.ts b/src/test/lib/utils/contest_table_provider.test.ts index 4f4f79bfa..0a3bec7c6 100644 --- a/src/test/lib/utils/contest_table_provider.test.ts +++ b/src/test/lib/utils/contest_table_provider.test.ts @@ -20,6 +20,9 @@ import { TDPCProvider, FPS24Provider, JOIFirstQualRoundProvider, + JOISecondQualRound2020OnwardsProvider, + JOIQualRoundFrom2006To2019Provider, + JOISemiFinalRoundProvider, Typical90Provider, TessokuBookProvider, TessokuBookForExamplesProvider, @@ -2191,6 +2194,218 @@ describe('ContestTableProviderBase and implementations', () => { }); }); + describe('JOI Second Qual Round 2020 Onwards provider', () => { + test('expects to filter tasks to include only joi{YYYY}yo2 contests', () => { + const provider = new JOISecondQualRound2020OnwardsProvider(ContestType.JOI); + const mockJOITasks = [ + { contest_id: 'joi2024yo2', task_id: 'joi2024yo2_a' }, + { contest_id: 'joi2023yo2', task_id: 'joi2023yo2_b' }, + { contest_id: 'joi2022yo2', task_id: 'joi2022yo2_c' }, + { contest_id: 'joi2024yo1a', task_id: 'joi2024yo1a_a' }, + { contest_id: 'joi2024ho', task_id: 'joi2024ho_a' }, + { contest_id: 'joi2024yo', task_id: 'joi2024yo_a' }, + { contest_id: 'abc123', task_id: 'abc123_a' }, + ]; + + const filtered = provider.filter(mockJOITasks as any); + + expect(filtered?.every((task) => task.contest_id.startsWith('joi'))).toBe(true); + expect(filtered?.length).toBe(3); + expect(filtered?.every((task) => task.contest_id.match(/joi\d{4}yo2/))).toBe(true); + }); + + test('expects to filter contests by year correctly', () => { + const provider = new JOISecondQualRound2020OnwardsProvider(ContestType.JOI); + const mockJOITasks = [ + { contest_id: 'joi2024yo2', task_id: 'joi2024yo2_a' }, + { contest_id: 'joi2024yo2', task_id: 'joi2024yo2_b' }, + { contest_id: 'joi2023yo2', task_id: 'joi2023yo2_a' }, + { contest_id: 'joi2022yo2', task_id: 'joi2022yo2_a' }, + ]; + + const filtered = provider.filter(mockJOITasks as any); + + expect(filtered?.length).toBe(4); + expect(filtered?.filter((task) => task.contest_id.includes('2024')).length).toBe(2); + expect(filtered?.filter((task) => task.contest_id.includes('2023')).length).toBe(1); + expect(filtered?.filter((task) => task.contest_id.includes('2022')).length).toBe(1); + }); + + test('expects to get correct metadata', () => { + const provider = new JOISecondQualRound2020OnwardsProvider(ContestType.JOI); + const metadata = provider.getMetadata(); + + expect(metadata.title).toBe('JOI 二次予選'); + expect(metadata.abbreviationName).toBe('joiSecondQualRound2020Onwards'); + }); + + test('expects to get correct display configuration', () => { + const provider = new JOISecondQualRound2020OnwardsProvider(ContestType.JOI); + const displayConfig = provider.getDisplayConfig(); + + expect(displayConfig.isShownHeader).toBe(true); + expect(displayConfig.isShownRoundLabel).toBe(true); + expect(displayConfig.isShownTaskIndex).toBe(false); + expect(displayConfig.roundLabelWidth).toBe('xl:w-28'); + expect(displayConfig.tableBodyCellsWidth).toBe('w-1/2 xs:w-1/3 sm:w-1/4 md:w-1/5 px-1 py-1'); + }); + + test('expects to get contest round label', () => { + const provider = new JOISecondQualRound2020OnwardsProvider(ContestType.JOI); + + expect(provider.getContestRoundLabel('joi2024yo2')).toBe('2024'); + expect(provider.getContestRoundLabel('joi2023yo2')).toBe('2023'); + }); + + test('expects to handle empty task results', () => { + const provider = new JOISecondQualRound2020OnwardsProvider(ContestType.JOI); + const filtered = provider.filter([] as any); + + expect(filtered).toEqual([]); + }); + }); + + describe('JOI Qual Round From 2006 To 2019 provider', () => { + test('expects to filter tasks to include only joi{YYYY}yo contests (without yo1/yo2)', () => { + const provider = new JOIQualRoundFrom2006To2019Provider(ContestType.JOI); + const mockJOITasks = [ + { contest_id: 'joi2019yo', task_id: 'joi2019yo_a' }, + { contest_id: 'joi2018yo', task_id: 'joi2018yo_b' }, + { contest_id: 'joi2017yo', task_id: 'joi2017yo_c' }, + { contest_id: 'joi2024yo1a', task_id: 'joi2024yo1a_a' }, + { contest_id: 'joi2024yo2', task_id: 'joi2024yo2_a' }, + { contest_id: 'joi2024ho', task_id: 'joi2024ho_a' }, + { contest_id: 'abc123', task_id: 'abc123_a' }, + ]; + + const filtered = provider.filter(mockJOITasks as any); + + expect(filtered?.every((task) => task.contest_id.startsWith('joi'))).toBe(true); + expect(filtered?.length).toBe(3); + expect(filtered?.every((task) => task.contest_id.match(/^joi\d{4}yo$/))).toBe(true); + }); + + test('expects to exclude yo1/yo2 variants', () => { + const provider = new JOIQualRoundFrom2006To2019Provider(ContestType.JOI); + const mockJOITasks = [ + { contest_id: 'joi2019yo', task_id: 'joi2019yo_a' }, + { contest_id: 'joi2024yo1a', task_id: 'joi2024yo1a_a' }, + { contest_id: 'joi2024yo2', task_id: 'joi2024yo2_a' }, + ]; + + const filtered = provider.filter(mockJOITasks as any); + + expect(filtered?.length).toBe(1); + expect(filtered?.[0].contest_id).toBe('joi2019yo'); + }); + + test('expects to get correct metadata', () => { + const provider = new JOIQualRoundFrom2006To2019Provider(ContestType.JOI); + const metadata = provider.getMetadata(); + + expect(metadata.title).toBe('JOI 予選'); + expect(metadata.abbreviationName).toBe('joiQualRoundFrom2006To2019'); + }); + + test('expects to get correct display configuration', () => { + const provider = new JOIQualRoundFrom2006To2019Provider(ContestType.JOI); + const displayConfig = provider.getDisplayConfig(); + + expect(displayConfig.isShownHeader).toBe(true); + expect(displayConfig.isShownRoundLabel).toBe(true); + expect(displayConfig.isShownTaskIndex).toBe(false); + expect(displayConfig.roundLabelWidth).toBe('xl:w-28'); + expect(displayConfig.tableBodyCellsWidth).toBe( + 'w-1/2 xs:w-1/3 sm:w-1/4 md:w-1/5 lg:w-1/6 px-1 py-1', + ); + }); + + test('expects to get contest round label', () => { + const provider = new JOIQualRoundFrom2006To2019Provider(ContestType.JOI); + + expect(provider.getContestRoundLabel('joi2019yo')).toBe('2019'); + expect(provider.getContestRoundLabel('joi2018yo')).toBe('2018'); + }); + + test('expects to handle empty task results', () => { + const provider = new JOIQualRoundFrom2006To2019Provider(ContestType.JOI); + const filtered = provider.filter([] as any); + + expect(filtered).toEqual([]); + }); + }); + + describe('JOI Semi Final Round provider', () => { + test('expects to filter tasks to include only joi{YYYY}ho contests', () => { + const provider = new JOISemiFinalRoundProvider(ContestType.JOI); + const mockJOITasks = [ + { contest_id: 'joi2024ho', task_id: 'joi2024ho_a' }, + { contest_id: 'joi2023ho', task_id: 'joi2023ho_b' }, + { contest_id: 'joi2022ho', task_id: 'joi2022ho_c' }, + { contest_id: 'joi2024yo1a', task_id: 'joi2024yo1a_a' }, + { contest_id: 'joi2024yo2', task_id: 'joi2024yo2_a' }, + { contest_id: 'joi2024yo', task_id: 'joi2024yo_a' }, + { contest_id: 'abc123', task_id: 'abc123_a' }, + ]; + + const filtered = provider.filter(mockJOITasks as any); + + expect(filtered?.every((task) => task.contest_id.startsWith('joi'))).toBe(true); + expect(filtered?.length).toBe(3); + expect(filtered?.every((task) => task.contest_id.match(/joi\d{4}ho/))).toBe(true); + }); + + test('expects to filter contests by year correctly', () => { + const provider = new JOISemiFinalRoundProvider(ContestType.JOI); + const mockJOITasks = [ + { contest_id: 'joi2024ho', task_id: 'joi2024ho_a' }, + { contest_id: 'joi2024ho', task_id: 'joi2024ho_b' }, + { contest_id: 'joi2023ho', task_id: 'joi2023ho_a' }, + { contest_id: 'joi2022ho', task_id: 'joi2022ho_a' }, + ]; + + const filtered = provider.filter(mockJOITasks as any); + + expect(filtered?.length).toBe(4); + expect(filtered?.filter((task) => task.contest_id.includes('2024')).length).toBe(2); + expect(filtered?.filter((task) => task.contest_id.includes('2023')).length).toBe(1); + expect(filtered?.filter((task) => task.contest_id.includes('2022')).length).toBe(1); + }); + + test('expects to get correct metadata', () => { + const provider = new JOISemiFinalRoundProvider(ContestType.JOI); + const metadata = provider.getMetadata(); + + expect(metadata.title).toBe('JOI 本選'); + expect(metadata.abbreviationName).toBe('joiSemiFinalRoundProvider'); + }); + + test('expects to get correct display configuration', () => { + const provider = new JOISemiFinalRoundProvider(ContestType.JOI); + const displayConfig = provider.getDisplayConfig(); + + expect(displayConfig.isShownHeader).toBe(true); + expect(displayConfig.isShownRoundLabel).toBe(true); + expect(displayConfig.isShownTaskIndex).toBe(false); + expect(displayConfig.roundLabelWidth).toBe('xl:w-28'); + expect(displayConfig.tableBodyCellsWidth).toBe('w-1/2 xs:w-1/3 sm:w-1/4 md:w-1/5 px-1 py-1'); + }); + + test('expects to get contest round label', () => { + const provider = new JOISemiFinalRoundProvider(ContestType.JOI); + + expect(provider.getContestRoundLabel('joi2024ho')).toBe('2024'); + expect(provider.getContestRoundLabel('joi2023ho')).toBe('2023'); + }); + + test('expects to handle empty task results', () => { + const provider = new JOISemiFinalRoundProvider(ContestType.JOI); + const filtered = provider.filter([] as any); + + expect(filtered).toEqual([]); + }); + }); + describe('Common provider functionality', () => { test('expects to get contest round IDs correctly', () => { const provider = new ABCLatest20RoundsProvider(ContestType.ABC); From c16818c2f99bf5c21ca6be8bd85a5b4ecddf0a7f Mon Sep 17 00:00:00 2001 From: river0525 <0525sotaro@gmail.com> Date: Fri, 26 Dec 2025 23:57:28 +0900 Subject: [PATCH 2/3] :pencil2: Fix mock (#2992) --- src/lib/utils/contest_table_provider.ts | 2 +- .../lib/utils/contest_table_provider.test.ts | 36 ++++++++++++++----- 2 files changed, 29 insertions(+), 9 deletions(-) diff --git a/src/lib/utils/contest_table_provider.ts b/src/lib/utils/contest_table_provider.ts index 48e36d180..248d60d04 100644 --- a/src/lib/utils/contest_table_provider.ts +++ b/src/lib/utils/contest_table_provider.ts @@ -936,7 +936,7 @@ export class JOISemiFinalRoundProvider extends ContestTableProviderBase { getMetadata(): ContestTableMetaData { return { title: 'JOI 本選', - abbreviationName: 'joiSemiFinalRoundProvider', + abbreviationName: 'joiSemiFinalRound', }; } diff --git a/src/test/lib/utils/contest_table_provider.test.ts b/src/test/lib/utils/contest_table_provider.test.ts index 0a3bec7c6..b1977e2a3 100644 --- a/src/test/lib/utils/contest_table_provider.test.ts +++ b/src/test/lib/utils/contest_table_provider.test.ts @@ -84,14 +84,35 @@ vi.mock('$lib/utils/contest', () => ({ } else if (contestId === 'dp' || contestId === 'tdpc' || contestId === 'typical90') { return ''; } else if (contestId.startsWith('joi')) { - // First qual round - const matched = contestId.match(/joi(\d{4})yo1([abc])/); - - if (matched) { - const [, year, round] = matched; + // JOI contest name formatting + // Handle: joiYYYYyo1[a-c] => JOI 一次予選 YYYY 第 N 回 + const firstQual = contestId.match(/joi(\d{4})yo1([abc])/); + if (firstQual) { + const [, year, round] = firstQual; const roundMap: Record = { a: '1', b: '2', c: '3' }; - return `${year} 第 ${roundMap[round]} 回`; + return `JOI 一次予選 ${year} 第 ${roundMap[round]} 回`; + } + + // Handle: joiYYYYyo2 => JOI 二次予選 YYYY + const secondQual = contestId.match(/joi(\d{4})yo2$/); + if (secondQual) { + const [, year] = secondQual; + return `JOI 二次予選 ${year}`; + } + + // Handle: joiYYYYyo (older format) => JOI 予選 YYYY + const qual = contestId.match(/joi(\d{4})yo$/); + if (qual) { + const [, year] = qual; + return `JOI 予選 ${year}`; + } + + // Handle: joiYYYYho => JOI 本選 YYYY + const finalMatch = contestId.match(/joi(\d{4})ho$/); + if (finalMatch) { + const [, year] = finalMatch; + return `JOI 本選 ${year}`; } } @@ -2149,7 +2170,6 @@ describe('ContestTableProviderBase and implementations', () => { expect(provider.getContestRoundLabel('invalid-id')).toBe('invalid-id'); expect(provider.getContestRoundLabel('joi2024yo1d')).toBe('joi2024yo1d'); // Invalid round - expect(provider.getContestRoundLabel('joi2024yo2')).toBe('joi2024yo2'); // Not first qual round }); test('expects to generate correct table structure', () => { @@ -2377,7 +2397,7 @@ describe('ContestTableProviderBase and implementations', () => { const metadata = provider.getMetadata(); expect(metadata.title).toBe('JOI 本選'); - expect(metadata.abbreviationName).toBe('joiSemiFinalRoundProvider'); + expect(metadata.abbreviationName).toBe('joiSemiFinalRound'); }); test('expects to get correct display configuration', () => { From 541317c44677147f85d8b1686a4555b68eac64ed Mon Sep 17 00:00:00 2001 From: river0525 <0525sotaro@gmail.com> Date: Sat, 27 Dec 2025 09:03:15 +0900 Subject: [PATCH 3/3] :pencil2:fix code style error --- src/lib/utils/contest_table_provider.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/lib/utils/contest_table_provider.ts b/src/lib/utils/contest_table_provider.ts index 248d60d04..d02503026 100644 --- a/src/lib/utils/contest_table_provider.ts +++ b/src/lib/utils/contest_table_provider.ts @@ -1226,7 +1226,7 @@ export const prepareContestProviderPresets = () => { buttonLabel: 'JOI 一次予選', ariaLabel: 'Filter JOI First Qualifying Round', }).addProvider(new JOIFirstQualRoundProvider(ContestType.JOI)), - + JOISecondQualAndSemiFinalRound: () => new ContestTableProviderGroup(`JOI 二次予選・予選・本選`, { buttonLabel: 'JOI 二次予選・予選・本選',