From be30b5bf190a27900fec5de22abd19db0d3836aa Mon Sep 17 00:00:00 2001 From: Kato Hiroki Date: Mon, 23 Feb 2026 12:15:36 +0000 Subject: [PATCH 1/3] feat: Add joi2026sf to JOISemiFinalRoundProvider (#3152) --- .../plan.md | 60 +++++++++++++++++++ .../how-to-add-contest-table-provider.md | 9 ++- prisma/tasks.ts | 42 +++++++++++++ .../utils/contest-table/joi_providers.test.ts | 42 +++++++++++++ .../utils/contest-table/joi_providers.ts | 6 +- src/lib/utils/contest.ts | 5 +- .../test_cases/contest_name_and_task_index.ts | 4 ++ 7 files changed, 164 insertions(+), 4 deletions(-) create mode 100644 docs/dev-notes/2026-02-23/add-joi-semifinal-to-contest-table/plan.md diff --git a/docs/dev-notes/2026-02-23/add-joi-semifinal-to-contest-table/plan.md b/docs/dev-notes/2026-02-23/add-joi-semifinal-to-contest-table/plan.md new file mode 100644 index 000000000..dde583027 --- /dev/null +++ b/docs/dev-notes/2026-02-23/add-joi-semifinal-to-contest-table/plan.md @@ -0,0 +1,60 @@ +# JOI セミファイナルステージ (joi2026sf) 対応 + +## Context + +Issue #3152: JOI 2025/2026 より「本選」のコンテスト ID のサフィックスが `ho` から `sf` に変更された(例: `joi2026sf`)。`prisma/tasks.ts` にはすでに `joi2026sf` の6問(A〜F)が追加済み。既存の `JOISemiFinalRoundProvider` は `joi{YYYY}ho` のみを対象としており、`joi2026sf` が表示されない状態。これを修正する。 + +## 方針まとめ + +- **表示テーブル**: 既存「JOI 本選」テーブルに統合(`JOISemiFinalRoundProvider` を拡張) +- **`getJoiContestLabel('joi2026sf')` 戻り値**: `"JOI セミファイナルステージ 2026"` +- **`getContestRoundLabel('joi2026sf')` 戻り値**: `"2026"`(テーブル行ラベル) + +--- + +## 完了済み変更 + +### `src/lib/utils/contest.ts` + +- `regexForJoi` に `sf` を追加(`yo|ho|sc|sp` → `yo|ho|sc|sp|sf`) +- `addJoiDivisionNameIfNeeds()` に `sf` → `'セミファイナルステージ'` の分岐追加 + +### `src/features/tasks/utils/contest-table/joi_providers.ts` + +- `regexForJoiSemiFinalRound` を `(ho|sf)` にマッチするよう変更 +- `getContestRoundLabel()` で `sf` の場合は `.replace('JOI セミファイナルステージ ', '')` で年を取り出す + +### `src/features/tasks/utils/contest-table/joi_providers.test.ts` + +- `joi2026sf` のフィルタリング・ラウンドラベル・テーブル生成テストを追加(全 259 tests passed) + +--- + +## 残タスク + +### `src/test/lib/utils/test_cases/contest_name_and_task_index.ts` + +`getJoiContestLabel('joi2026sf')` → `"JOI セミファイナルステージ 2026"` のテストケースを追加: + +```typescript +joi2026sf: { + contestId: 'joi2026sf', + // expected: `${getJoiContestLabel('joi2026sf')} - {taskIndex}` + // → "JOI セミファイナルステージ 2026 - A" のようになる +}, +``` + +### 検証 + +```bash +pnpm test:unit src/test/lib/utils/ +``` + +--- + +## 教訓 + +- **`getContestRoundLabel()` の prefix 依存に注意**: 既存の `ho` サフィックスは `getJoiContestLabel()` が `"JOI 本選 YYYY"` を返すため `.replace('JOI 本選 ', '')` でラベルを取り出せる。新サフィックス `sf` では `getJoiContestLabel()` が `"JOI セミファイナルステージ YYYY"` を返すので prefix が異なる。 +- **同一テーブルへの統合でも `getContestRoundLabel()` のロジック分岐が発生しうる**: 表示名を統一しても、ラベル生成ロジックがサフィックスごとに異なる場合はプロバイダー内で個別ハンドリングが必要。 +- **`regexForJoi` の division グループへの追加で分類・ラベル両方が動く**: `classifyContest()` は `startsWith('joi')` のため追加不要だが、`getJoiContestLabel()` 用の regex と `addJoiDivisionNameIfNeeds()` は両方セットで更新が必要。 +- **`src/test/lib/utils/` の JOI テストデータも忘れず更新**: Provider テストとは別に `contest_name_and_task_index.ts` にも JOI の `getJoiContestLabel()` テストケースがある。新サフィックスを追加した際は必ずこちらも更新する。テストケース追加は既存の `joi{YYYY}ho` エントリと同じ構造(`contestId` + `tasks` 配列)を踏襲するだけでよい。 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..a757562fd 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'], From a53679981738eadcd159bb714bc4b6852e507b3f Mon Sep 17 00:00:00 2001 From: Kato Hiroki Date: Mon, 23 Feb 2026 12:16:53 +0000 Subject: [PATCH 2/3] docs: Remove old plan (#3152) --- .../plan.md | 60 ------------------- 1 file changed, 60 deletions(-) delete mode 100644 docs/dev-notes/2026-02-23/add-joi-semifinal-to-contest-table/plan.md diff --git a/docs/dev-notes/2026-02-23/add-joi-semifinal-to-contest-table/plan.md b/docs/dev-notes/2026-02-23/add-joi-semifinal-to-contest-table/plan.md deleted file mode 100644 index dde583027..000000000 --- a/docs/dev-notes/2026-02-23/add-joi-semifinal-to-contest-table/plan.md +++ /dev/null @@ -1,60 +0,0 @@ -# JOI セミファイナルステージ (joi2026sf) 対応 - -## Context - -Issue #3152: JOI 2025/2026 より「本選」のコンテスト ID のサフィックスが `ho` から `sf` に変更された(例: `joi2026sf`)。`prisma/tasks.ts` にはすでに `joi2026sf` の6問(A〜F)が追加済み。既存の `JOISemiFinalRoundProvider` は `joi{YYYY}ho` のみを対象としており、`joi2026sf` が表示されない状態。これを修正する。 - -## 方針まとめ - -- **表示テーブル**: 既存「JOI 本選」テーブルに統合(`JOISemiFinalRoundProvider` を拡張) -- **`getJoiContestLabel('joi2026sf')` 戻り値**: `"JOI セミファイナルステージ 2026"` -- **`getContestRoundLabel('joi2026sf')` 戻り値**: `"2026"`(テーブル行ラベル) - ---- - -## 完了済み変更 - -### `src/lib/utils/contest.ts` - -- `regexForJoi` に `sf` を追加(`yo|ho|sc|sp` → `yo|ho|sc|sp|sf`) -- `addJoiDivisionNameIfNeeds()` に `sf` → `'セミファイナルステージ'` の分岐追加 - -### `src/features/tasks/utils/contest-table/joi_providers.ts` - -- `regexForJoiSemiFinalRound` を `(ho|sf)` にマッチするよう変更 -- `getContestRoundLabel()` で `sf` の場合は `.replace('JOI セミファイナルステージ ', '')` で年を取り出す - -### `src/features/tasks/utils/contest-table/joi_providers.test.ts` - -- `joi2026sf` のフィルタリング・ラウンドラベル・テーブル生成テストを追加(全 259 tests passed) - ---- - -## 残タスク - -### `src/test/lib/utils/test_cases/contest_name_and_task_index.ts` - -`getJoiContestLabel('joi2026sf')` → `"JOI セミファイナルステージ 2026"` のテストケースを追加: - -```typescript -joi2026sf: { - contestId: 'joi2026sf', - // expected: `${getJoiContestLabel('joi2026sf')} - {taskIndex}` - // → "JOI セミファイナルステージ 2026 - A" のようになる -}, -``` - -### 検証 - -```bash -pnpm test:unit src/test/lib/utils/ -``` - ---- - -## 教訓 - -- **`getContestRoundLabel()` の prefix 依存に注意**: 既存の `ho` サフィックスは `getJoiContestLabel()` が `"JOI 本選 YYYY"` を返すため `.replace('JOI 本選 ', '')` でラベルを取り出せる。新サフィックス `sf` では `getJoiContestLabel()` が `"JOI セミファイナルステージ YYYY"` を返すので prefix が異なる。 -- **同一テーブルへの統合でも `getContestRoundLabel()` のロジック分岐が発生しうる**: 表示名を統一しても、ラベル生成ロジックがサフィックスごとに異なる場合はプロバイダー内で個別ハンドリングが必要。 -- **`regexForJoi` の division グループへの追加で分類・ラベル両方が動く**: `classifyContest()` は `startsWith('joi')` のため追加不要だが、`getJoiContestLabel()` 用の regex と `addJoiDivisionNameIfNeeds()` は両方セットで更新が必要。 -- **`src/test/lib/utils/` の JOI テストデータも忘れず更新**: Provider テストとは別に `contest_name_and_task_index.ts` にも JOI の `getJoiContestLabel()` テストケースがある。新サフィックスを追加した際は必ずこちらも更新する。テストケース追加は既存の `joi{YYYY}ho` エントリと同じ構造(`contestId` + `tasks` 配列)を踏襲するだけでよい。 From ccabfcf33e96fe509da95aae9281d50b939e3367 Mon Sep 17 00:00:00 2001 From: Kato Hiroki Date: Mon, 23 Feb 2026 12:37:36 +0000 Subject: [PATCH 3/3] chore: Trim trailing whitespace (#3152) --- prisma/tasks.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/prisma/tasks.ts b/prisma/tasks.ts index a757562fd..3fe94daac 100755 --- a/prisma/tasks.ts +++ b/prisma/tasks.ts @@ -7084,8 +7084,8 @@ export const tasks = [ id: 'joi2026_semifinal_d', contest_id: 'joi2026sf', problem_index: 'D', - name: '川下り(River Rafting) ', - title: 'D. 川下り(River Rafting) ', + name: '川下り(River Rafting)', + title: 'D. 川下り(River Rafting)', }, { id: 'joi2026_semifinal_c',