diff --git a/docs/guides/how-to-add-contest-table-provider.md b/docs/guides/how-to-add-contest-table-provider.md index 6fa6242f1..5eba16fa5 100644 --- a/docs/guides/how-to-add-contest-table-provider.md +++ b/docs/guides/how-to-add-contest-table-provider.md @@ -35,6 +35,12 @@ - **パターン3(複合ソース型)**: ABS、ABC-Like など → 複数 contest_id を統一表示 - 対応セクション: [実装パターン](#実装パターン) +- [ ] **JOI の contest_id サフィックス変更の確認** + - JOI 本選は 2026 年より `joi{YYYY}ho` → `joi{YYYY}sf` にサフィックスが変更された + - `JOISemiFinalRoundProvider` の regex は `(ho|sf)` にマッチするよう対応済み + - `getJoiContestLabel()` は `sf` → `'セミファイナルステージ'` を返す + - テーブルの行ラベル (`getContestRoundLabel()`) は `sf` でも年のみ返す + - [ ] **ガイドの実装例の確認** - 判定したパターンの実装例を確認してテンプレート理解 @@ -525,6 +531,7 @@ describe('CustomProvider with unique config', () => { - [#2785](https://github.com/AtCoder-NoviSteps/AtCoderNoviSteps/issues/2785) - MathAndAlgorithmProvider - [#2797](https://github.com/AtCoder-NoviSteps/AtCoderNoviSteps/issues/2797) - FPS24Provider - [#2920](https://github.com/AtCoder-NoviSteps/AtCoderNoviSteps/issues/2920)、[#3120](https://github.com/AtCoder-NoviSteps/AtCoderNoviSteps/issues/3120) - ACLPracticeProvider、ACLBeginnerProvider、ACLProvider +- [#3152](https://github.com/AtCoder-NoviSteps/AtCoderNoviSteps/issues/3152) - JOISemiFinalRoundProvider(本選 → セミファイナルステージ への対応) ### 実装ファイル @@ -534,4 +541,4 @@ describe('CustomProvider with unique config', () => { --- -**最終更新**: 2026-02-14 +**最終更新**: 2026-02-22 diff --git a/prisma/tasks.ts b/prisma/tasks.ts index 4478415ea..3fe94daac 100755 --- a/prisma/tasks.ts +++ b/prisma/tasks.ts @@ -7066,6 +7066,48 @@ export const tasks = [ name: 'JOI 2006 予選 問題1', title: 'A. JOI 2006 予選 問題1', }, + { + id: 'joi2026_semifinal_f', + contest_id: 'joi2026sf', + problem_index: 'F', + name: '奇妙な機械 (Strange Machine)', + title: 'F. 奇妙な機械 (Strange Machine)', + }, + { + id: 'joi2026_semifinal_e', + contest_id: 'joi2026sf', + problem_index: 'E', + name: '新たな橋 (New Bridge)', + title: 'E. 新たな橋 (New Bridge)', + }, + { + id: 'joi2026_semifinal_d', + contest_id: 'joi2026sf', + problem_index: 'D', + name: '川下り(River Rafting)', + title: 'D. 川下り(River Rafting)', + }, + { + id: 'joi2026_semifinal_c', + contest_id: 'joi2026sf', + problem_index: 'C', + name: '衣服 (Clothes)', + title: 'C. 衣服 (Clothes)', + }, + { + id: 'joi2026_semifinal_b', + contest_id: 'joi2026sf', + problem_index: 'B', + name: '宝石商 (Jeweler)', + title: 'B. 宝石商 (Jeweler)', + }, + { + id: 'joi2026_semifinal_a', + contest_id: 'joi2026sf', + problem_index: 'A', + name: '座席 3 (Seats 3)', + title: 'A. 座席 3 (Seats 3)', + }, { id: 'joi2025ho_e', contest_id: 'joi2025ho', diff --git a/src/features/tasks/utils/contest-table/joi_providers.test.ts b/src/features/tasks/utils/contest-table/joi_providers.test.ts index 52d94514f..4b7013faa 100644 --- a/src/features/tasks/utils/contest-table/joi_providers.test.ts +++ b/src/features/tasks/utils/contest-table/joi_providers.test.ts @@ -338,4 +338,46 @@ describe('JOISemiFinalRoundProvider', () => { expect(filtered).toEqual([]); }); + + test('expects to filter joi{YYYY}sf tasks (new format from 2026)', () => { + const provider = new JOISemiFinalRoundProvider(ContestType.JOI); + const mockJOITasks = [ + { contest_id: 'joi2026sf', task_id: 'joi2026_semifinal_a' }, + { contest_id: 'joi2026sf', task_id: 'joi2026_semifinal_b' }, + { contest_id: 'joi2024ho', task_id: 'joi2024ho_a' }, + { contest_id: 'joi2024yo2', task_id: 'joi2024yo2_a' }, + { contest_id: 'joi2024yo1a', task_id: 'joi2024yo1a_a' }, + { contest_id: 'abc123', task_id: 'abc123_a' }, + ]; + + const filtered = provider.filter(mockJOITasks as any); + + expect(filtered?.length).toBe(3); + expect(filtered?.some((task) => task.contest_id === 'joi2026sf')).toBe(true); + expect(filtered?.some((task) => task.contest_id === 'joi2024ho')).toBe(true); + expect(filtered?.every((task) => task.contest_id.startsWith('joi'))).toBe(true); + }); + + test('expects to get contest round label for joi{YYYY}sf', () => { + const provider = new JOISemiFinalRoundProvider(ContestType.JOI); + + expect(provider.getContestRoundLabel('joi2026sf')).toBe('2026'); + }); + + test('expects to generate correct table structure including joi{YYYY}sf', () => { + const provider = new JOISemiFinalRoundProvider(ContestType.JOI); + const mockJOITasks = [ + { contest_id: 'joi2026sf', task_id: 'joi2026_semifinal_a', task_table_index: 'A' }, + { contest_id: 'joi2026sf', task_id: 'joi2026_semifinal_b', task_table_index: 'B' }, + { contest_id: 'joi2024ho', task_id: 'joi2024ho_a', task_table_index: 'A' }, + ]; + + const table = provider.generateTable(mockJOITasks as any); + + expect(table).toHaveProperty('joi2026sf'); + expect(table).toHaveProperty('joi2024ho'); + expect(table.joi2026sf).toHaveProperty('A'); + expect(table.joi2026sf).toHaveProperty('B'); + expect(table.joi2024ho).toHaveProperty('A'); + }); }); diff --git a/src/features/tasks/utils/contest-table/joi_providers.ts b/src/features/tasks/utils/contest-table/joi_providers.ts index 986b73b37..9e78c1257 100644 --- a/src/features/tasks/utils/contest-table/joi_providers.ts +++ b/src/features/tasks/utils/contest-table/joi_providers.ts @@ -127,8 +127,10 @@ export class JOIQualRoundFrom2006To2019Provider extends ContestTableProviderBase } } -const regexForJoiSemiFinalRound = /^(joi)(\d{4})(ho)$/i; +const regexForJoiSemiFinalRound = /^(joi)(\d{4})(ho|sf)$/i; +// Note: The JOI semi-final stage, which was renamed from the final round starting in 2026, is essentially the same as the final round in terms of its role in the competition. +// Therefore, we can use the same provider for both the final round and the semi-final stage, as they share the same structure and purpose in the contest. export class JOISemiFinalRoundProvider extends ContestTableProviderBase { constructor(contestType: ContestType) { super(contestType, JOI_FINAL_ROUND_SECTIONS.semiFinal); @@ -163,6 +165,6 @@ export class JOISemiFinalRoundProvider extends ContestTableProviderBase { getContestRoundLabel(contestId: string): string { const contestNameLabel = getContestNameLabel(contestId); - return contestNameLabel.replace('JOI 本選 ', ''); + return contestNameLabel.replace('JOI 本選 ', '').replace('JOI セミファイナルステージ ', ''); } } diff --git a/src/lib/utils/contest.ts b/src/lib/utils/contest.ts index f17a13eca..9df44b5b1 100644 --- a/src/lib/utils/contest.ts +++ b/src/lib/utils/contest.ts @@ -506,8 +506,9 @@ export function getPastContestLabel( * - "joisc2024" (matches) * - "joisp2022" (matches) * - "joi24yo3d" (does not match) + * - "joi2026sf" (matches) */ -const regexForJoi = /^(joi)(g|open)*(\d{4})*(yo|ho|sc|sp)*(\d{4})*(1|2)*(a|b|c)*/i; +const regexForJoi = /^(joi)(g|open)*(\d{4})*(yo|ho|sc|sp|sf)*(\d{4})*(1|2)*(a|b|c)*/i; /** * Transforms a contest ID into a formatted contest label. @@ -572,6 +573,8 @@ function addJoiDivisionNameIfNeeds(division: string, qual: string): string { } } else if (division === 'ho') { return '本選'; + } else if (division === 'sf') { + return 'セミファイナルステージ'; } else if (division === 'sc' || division === 'sp') { return '春合宿'; } diff --git a/src/test/lib/utils/test_cases/contest_name_and_task_index.ts b/src/test/lib/utils/test_cases/contest_name_and_task_index.ts index 5f11f3005..a07e6da9f 100644 --- a/src/test/lib/utils/test_cases/contest_name_and_task_index.ts +++ b/src/test/lib/utils/test_cases/contest_name_and_task_index.ts @@ -212,6 +212,10 @@ const JOI_TEST_DATA = { contestId: 'joi2024ho', tasks: ['A', 'B', 'E'], }, + joi2026sf: { + contestId: 'joi2026sf', + tasks: ['A', 'B', 'F'], + }, joisc2007: { contestId: 'joisc2007', tasks: ['anagra', 'buildi', 'salt', 'score'],