From 7636bf3250a459432dcc3ea4cf492a237548bcaa Mon Sep 17 00:00:00 2001 From: Kato Hiroki Date: Thu, 6 Nov 2025 13:02:21 +0000 Subject: [PATCH 1/2] feat(table): Add FPS 24 to contest table (#2797) --- .../plan.md | 335 ++++++++++++++++++ src/lib/utils/contest_table_provider.ts | 40 ++- .../lib/utils/contest_table_provider.test.ts | 106 +++++- .../test_cases/contest_table_provider.ts | 11 + 4 files changed, 485 insertions(+), 7 deletions(-) create mode 100644 docs/dev-notes/2025-11-06/add_tests_for_contest_table_provider/plan.md diff --git a/docs/dev-notes/2025-11-06/add_tests_for_contest_table_provider/plan.md b/docs/dev-notes/2025-11-06/add_tests_for_contest_table_provider/plan.md new file mode 100644 index 000000000..e1b282c4f --- /dev/null +++ b/docs/dev-notes/2025-11-06/add_tests_for_contest_table_provider/plan.md @@ -0,0 +1,335 @@ +# FPS24Provider テスト追加計画 + +**作成日**: 2025-11-06 + +**対象ブランチ**: #2797 + +**優先度**: High + +--- + +## 参照ドキュメント + +テストの書き方・スタイル・ベストプラクティスについては、以下を参照: + +📖 [`docs/dev-notes/2025-11-03/add_tests_for_contest_table_provider/plan.md`](../../2025-11-03/add_tests_for_contest_table_provider/plan.md) + +--- + +## 1. 概要 + +### 背景 + +`FPS24Provider` は `EDPCProvider`・`TDPCProvider` と同じ構造で、単一のコンテスト(`fps-24`)からなる問題集を提供します。 + +- **セクション範囲**: A ~ X(24文字) +- **フォーマット**: 大文字アルファベット(A, B, C, ..., X) +- **単一ソース**: `contest_id === 'fps-24'` で統一 + +### 目的 + +EDPC・TDPC テストと同等の粒度で、FPS24Provider の単体テスト 8 個を追加。 + +--- + +## 2. 仕様要件 + +| 項目 | 仕様 | 備考 | +| ------------------ | --------------------- | ------------------------- | +| **セクション範囲** | A ~ X | 24文字分 | +| **ソート順序** | 昇順(A → B → ... X) | 必須 | +| **フォーマット** | 大文字アルファベット | 例: A, B, X | +| **単一ソース** | contest_id = 'fps-24' | EDPC・TDPC と同じパターン | + +--- + +## 3. テストケース(8件) + +### テスト1: フィルタリング + +```typescript +test('expects to filter tasks to include only fps-24 contest', () => { + const provider = new FPS24Provider(ContestType.FPS_24); + const mixedTasks = [ + { contest_id: 'abc123', task_id: 'abc123_a', task_table_index: 'A' }, + { contest_id: 'fps-24', task_id: 'fps-24_a', task_table_index: 'A' }, + { contest_id: 'fps-24', task_id: 'fps-24_b', task_table_index: 'B' }, + { contest_id: 'typical90', task_id: 'typical90_a', task_table_index: '001' }, + ]; + const filtered = provider.filter(mixedTasks); + + expect(filtered?.every((task) => task.contest_id === 'fps-24')).toBe(true); + expect(filtered).not.toContainEqual(expect.objectContaining({ contest_id: 'abc123' })); + expect(filtered).not.toContainEqual(expect.objectContaining({ contest_id: 'typical90' })); +}); +``` + +--- + +### テスト2: メタデータ取得 + +```typescript +test('expects to get correct metadata', () => { + const provider = new FPS24Provider(ContestType.FPS_24); + const metadata = provider.getMetadata(); + + expect(metadata.title).toBe('FPS 24 題'); + expect(metadata.abbreviationName).toBe('fps-24'); +}); +``` + +--- + +### テスト3: 表示設定 + +```typescript +test('expects to get correct display configuration', () => { + const provider = new FPS24Provider(ContestType.FPS_24); + const displayConfig = provider.getDisplayConfig(); + + expect(displayConfig.isShownHeader).toBe(false); + expect(displayConfig.isShownRoundLabel).toBe(false); + expect(displayConfig.roundLabelWidth).toBe(''); + expect(displayConfig.tableBodyCellsWidth).toBe( + 'w-1/2 xs:w-1/3 sm:w-1/4 md:w-1/5 lg:w-1/6 2xl:w-1/7 px-1 py-2', + ); + expect(displayConfig.isShownTaskIndex).toBe(true); +}); +``` + +--- + +### テスト4: ラウンドラベルフォーマット + +```typescript +test('expects to format contest round label correctly', () => { + const provider = new FPS24Provider(ContestType.FPS_24); + const label = provider.getContestRoundLabel('fps-24'); + + expect(label).toBe(''); +}); +``` + +--- + +### テスト5: テーブル生成 + +```typescript +test('expects to generate correct table structure', () => { + const provider = new FPS24Provider(ContestType.FPS_24); + const tasks = [ + { contest_id: 'fps-24', task_id: 'fps_24_a', task_table_index: 'A' }, + { contest_id: 'fps-24', task_id: 'fps_24_b', task_table_index: 'B' }, + { contest_id: 'fps-24', task_id: 'fps_24_x', task_table_index: 'X' }, + ]; + const table = provider.generateTable(tasks); + + expect(table).toHaveProperty('fps-24'); + expect(table['fps-24']).toHaveProperty('A'); + expect(table['fps-24']).toHaveProperty('B'); + expect(table['fps-24']).toHaveProperty('X'); + expect(table['fps-24']['A']).toEqual(expect.objectContaining({ task_id: 'fps-24_a' })); +}); +``` + +--- + +### テスト6: ラウンド ID 取得 + +```typescript +test('expects to get contest round IDs correctly', () => { + const provider = new FPS24Provider(ContestType.FPS_24); + const tasks = [ + { contest_id: 'fps-24', task_id: 'fps_24_a', task_table_index: 'A' }, + { contest_id: 'fps-24', task_id: 'fps_24_x', task_table_index: 'X' }, + ]; + const roundIds = provider.getContestRoundIds(tasks); + + expect(roundIds).toEqual(['fps-24']); +}); +``` + +--- + +### テスト7: ヘッダー ID 取得(昇順) + +```typescript +test('expects to get header IDs for tasks correctly in ascending order', () => { + const provider = new FPS24Provider(ContestType.FPS_24); + const tasks = [ + { contest_id: 'fps-24', task_id: 'fps_24_a', task_table_index: 'A' }, + { contest_id: 'fps-24', task_id: 'fps_24_x', task_table_index: 'X' }, + { contest_id: 'fps-24', task_id: 'fps_24_m', task_table_index: 'M' }, + { contest_id: 'fps-24', task_id: 'fps_24_b', task_table_index: 'B' }, + ]; + const headerIds = provider.getHeaderIdsForTask(tasks); + + expect(headerIds).toEqual(['A', 'B', 'M', 'X']); +}); +``` + +--- + +### テスト8: セクション範囲検証(A ~ X) + +```typescript +test('expects to handle section boundaries correctly (A-X)', () => { + const provider = new FPS24Provider(ContestType.FPS_24); + const tasks = [ + { contest_id: 'fps-24', task_id: 'fps_24_a', task_table_index: 'A' }, + { contest_id: 'fps-24', task_id: 'fps_24_x', task_table_index: 'X' }, + ]; + const headerIds = provider.getHeaderIdsForTask(tasks); + + expect(headerIds).toEqual(['A', 'X']); +}); +``` + +--- + +## 4. モックデータ + +追加先: `src/test/lib/utils/test_cases/contest_table_provider.ts` + +```typescript +export const taskResultsForFPS24Provider: TaskResults = [ + { + contest_id: 'fps-24', + task_id: 'fps_24_a', + task_table_index: 'A', + }, + { + contest_id: 'fps-24', + task_id: 'fps_24_b', + task_table_index: 'B', + }, + { + contest_id: 'fps-24', + task_id: 'fps_24_m', + task_table_index: 'M', + }, + { + contest_id: 'fps-24', + task_id: 'fps_24_x', + task_table_index: 'X', + }, +]; +``` + +--- + +## 5. テスト統合パターン + +### 既存テスト構造(変更しない) + +以下は変更対象外: + +- Typical90 provider テスト +- TessokuBook provider テスト +- MathAndAlgorithm provider テスト + +### 新規追加パターン + +`describe.each()` に FPS24 を追加(EDPC・TDPC と同じ共通テストパターン): + +```typescript +describe.each([ + { + providerClass: EDPCProvider, + contestType: ContestType.EDPC, + title: 'Educational DP Contest / DP まとめコンテスト', + abbreviationName: 'edpc', + label: 'EDPC provider', + }, + { + providerClass: TDPCProvider, + contestType: ContestType.TDPC, + title: 'Typical DP Contest', + abbreviationName: 'tdpc', + label: 'TDPC provider', + }, + { + providerClass: FPS24Provider, + contestType: ContestType.FPS24, + title: 'FPS 24 題', + abbreviationName: 'fps-24', + label: 'FPS24 provider', + }, +])('$label', ({ providerClass, contestType, title, abbreviationName }) => { + // 共通テスト: メタデータ、表示設定、ラウンドラベル +}); +``` + +### FPS24 特有テスト + +独立した `describe('FPS24 provider', ...)` ブロックで以下をテスト: + +- フィルタリング機能 +- テーブル生成 +- ラウンド ID 取得 +- ヘッダー ID 取得(昇順) +- セクション範囲検証(A ~ X) + +--- + +## 6. 実装手順 + +**ステップ1**: ✅ モックデータを `src/test/lib/utils/test_cases/contest_table_provider.ts` に追加 + +**ステップ2**: ✅ `describe.each()` に FPS24 パラメータを追加(EDPC・TDPC と並べる) + +**ステップ3**: ✅ FPS24 特有テスト 7 個を `src/test/lib/utils/contest_table_provider.test.ts` に追加 + +**ステップ4**: ✅ テスト実行・検証 + +```bash +pnpm test:unit src/test/lib/utils/contest_table_provider.test.ts +``` + +**ステップ5**: ✅ Lint チェック + +```bash +pnpm lint src/test/lib/utils/contest_table_provider.test.ts +``` + +--- + +## 7. 注意点 + +1. **セクション形式**: 大文字アルファベット(A ~ X)であり、3桁数字ではない +2. **コンテスト ID**: `contest_id === 'fps-24'` で統一(ハイフン含む) +3. **単一ソース**: EDPC・TDPC と同様に、常に `contest_id === 'fps-24'` +4. **ソート順序**: 文字列の辞書順ソート(`'A' < 'B' < ... < 'X'`) + +--- + +## 8. 参考資料 + +- PR #2286: FPS24Provider 実装(https://github.com/AtCoder-NoviSteps/AtCoderNoviSteps/pull/2286) +- PR #2780: リファクタリング(https://github.com/AtCoder-NoviSteps/AtCoderNoviSteps/pull/2780) +- 参照ドキュメント: `docs/dev-notes/2025-11-01/add_and_refactoring_tests_for_contest_table_provider/plan.md` + +--- + +## 9. 実装結果・教訓 + +### ✅ 実装完了 + +**実施時間**: 12.4 秒(テスト実行 7.47 秒含む) + +**実装内容**: + +1. モックデータ追加: 4 個のサンプルタスク(`fps_24_a`, `fps_24_b`, `fps_24_m`, `fps_24_x`)を `contest_table_provider.ts` に追加 +2. classifyContest mock 拡張: `fps-24` → `ContestType.FPS_24` のマッピングを追加 +3. describe.each に FPS24 パラメータ追加: EDPC・TDPC と並べて共通テスト(メタデータ、表示設定、ラウンドラベル)を定義 +4. FPS24 特有テスト 7 個を実装: フィルタリング、テーブル生成、ラウンド ID 取得、ヘッダー ID 取得(昇順)、セクション範囲検証、空入力処理、混合コンテストタイプ処理 + +### 📚 得られた教訓 + +1. **既存のプリセット関数への影響**:新規プロバイダーを `prepareContestProviderPresets().dps()` に追加する際、既存テストケース(`expects to create DPs preset correctly`)が自動的に期待値が変わることに注意。既存テストを更新する必要がある + +2. **共通テストパターンの有効性確認**:FPS24 が EDPC・TDPC と全く同じ構造(単一コンテスト ID、大文字アルファベット形式)であることから、`describe.each()` による共通テスト化が非常に効果的。テストコードの重複排除に成功 + +3. **アルファベット順ソートの正確性**:大文字アルファベット(A ~ X)のソートは JavaScript の標準文字列ソート(`sort()`)で正しく動作することを確認。ただし Unicode 順序に依存するため、テストケースで明示的に検証することは重要 + +4. **プリセット機能と外部ラベルの同期**:`prepareContestProviderPresets().dps()` が返すグループ名・ボタンラベル・aria-label が既に FPS24 を含むよう更新されていたため、テストの期待値調整が必須。実装時はプリセット関数の実装と共にテストも確認すること diff --git a/src/lib/utils/contest_table_provider.ts b/src/lib/utils/contest_table_provider.ts index 4763333f5..e72be2320 100644 --- a/src/lib/utils/contest_table_provider.ts +++ b/src/lib/utils/contest_table_provider.ts @@ -363,6 +363,39 @@ export class TDPCProvider extends ContestTableProviderBase { } } +export class FPS24Provider extends ContestTableProviderBase { + protected setFilterCondition(): (taskResult: TaskResult) => boolean { + return (taskResult: TaskResult) => { + if (classifyContest(taskResult.contest_id) !== this.contestType) { + return false; + } + + return taskResult.contest_id === 'fps-24'; + }; + } + + getMetadata(): ContestTableMetaData { + return { + title: 'FPS 24 題', + abbreviationName: 'fps-24', + }; + } + + getDisplayConfig(): ContestTableDisplayConfig { + return { + isShownHeader: false, + isShownRoundLabel: false, + roundLabelWidth: '', // No specific width for task index in FPS 24 + tableBodyCellsWidth: 'w-1/2 xs:w-1/3 sm:w-1/4 md:w-1/5 lg:w-1/6 2xl:w-1/7 px-1 py-2', + isShownTaskIndex: true, + }; + } + + getContestRoundLabel(contestId: string): string { + return ''; + } +} + const regexForJoiFirstQualRound = /^(joi)(\d{4})(yo1)(a|b|c)$/i; export class JOIFirstQualRoundProvider extends ContestTableProviderBase { @@ -567,12 +600,13 @@ export const prepareContestProviderPresets = () => { * DP group (EDPC and TDPC) */ dps: () => - new ContestTableProviderGroup(`EDPC・TDPC`, { - buttonLabel: 'EDPC・TDPC', - ariaLabel: 'EDPC and TDPC contests', + new ContestTableProviderGroup(`EDPC・TDPC・FPS 24`, { + buttonLabel: 'EDPC・TDPC・FPS 24', + ariaLabel: 'EDPC and TDPC and FPS 24 contests', }).addProviders( { contestType: ContestType.EDPC, provider: new EDPCProvider(ContestType.EDPC) }, { contestType: ContestType.TDPC, provider: new TDPCProvider(ContestType.TDPC) }, + { contestType: ContestType.FPS_24, provider: new FPS24Provider(ContestType.FPS_24) }, ), JOIFirstQualRound: () => diff --git a/src/test/lib/utils/contest_table_provider.test.ts b/src/test/lib/utils/contest_table_provider.test.ts index 4d101a4dd..b19ab43c5 100644 --- a/src/test/lib/utils/contest_table_provider.test.ts +++ b/src/test/lib/utils/contest_table_provider.test.ts @@ -9,6 +9,7 @@ import { ABC212ToABC318Provider, EDPCProvider, TDPCProvider, + FPS24Provider, JOIFirstQualRoundProvider, Typical90Provider, TessokuBookProvider, @@ -27,6 +28,8 @@ vi.mock('$lib/utils/contest', () => ({ return ContestType.EDPC; } else if (contestId === 'tdpc') { return ContestType.TDPC; + } else if (contestId === 'fps-24') { + return ContestType.FPS_24; } else if (contestId.startsWith('joi')) { return ContestType.JOI; } else if (contestId === 'typical90') { @@ -555,6 +558,93 @@ describe('ContestTableProviderBase and implementations', () => { }); }); + describe('FPS24 provider', () => { + test('expects to filter tasks to include only fps-24 contest', () => { + const provider = new FPS24Provider(ContestType.FPS_24); + const mixedTasks = [ + { contest_id: 'abc123', task_id: 'abc123_a', task_table_index: 'A' }, + { contest_id: 'fps-24', task_id: 'fps_24_a', task_table_index: 'A' }, + { contest_id: 'fps-24', task_id: 'fps_24_b', task_table_index: 'B' }, + { contest_id: 'typical90', task_id: 'typical90_a', task_table_index: '001' }, + ]; + const filtered = provider.filter(mixedTasks as TaskResults); + + expect(filtered?.every((task) => task.contest_id === 'fps-24')).toBe(true); + expect(filtered).not.toContainEqual(expect.objectContaining({ contest_id: 'abc123' })); + expect(filtered).not.toContainEqual(expect.objectContaining({ contest_id: 'typical90' })); + }); + + test('expects to generate correct table structure', () => { + const provider = new FPS24Provider(ContestType.FPS_24); + const tasks = [ + { contest_id: 'fps-24', task_id: 'fps_24_a', task_table_index: 'A' }, + { contest_id: 'fps-24', task_id: 'fps_24_b', task_table_index: 'B' }, + { contest_id: 'fps-24', task_id: 'fps_24_x', task_table_index: 'X' }, + ]; + const table = provider.generateTable(tasks as TaskResults); + + expect(table).toHaveProperty('fps-24'); + expect(table['fps-24']).toHaveProperty('A'); + expect(table['fps-24']).toHaveProperty('B'); + expect(table['fps-24']).toHaveProperty('X'); + expect(table['fps-24']['A']).toEqual(expect.objectContaining({ task_id: 'fps_24_a' })); + }); + + test('expects to get contest round IDs correctly', () => { + const provider = new FPS24Provider(ContestType.FPS_24); + const tasks = [ + { contest_id: 'fps-24', task_id: 'fps_24_a', task_table_index: 'A' }, + { contest_id: 'fps-24', task_id: 'fps_24_x', task_table_index: 'X' }, + ]; + const roundIds = provider.getContestRoundIds(tasks as TaskResults); + + expect(roundIds).toEqual(['fps-24']); + }); + + test('expects to get header IDs for tasks correctly in ascending order', () => { + const provider = new FPS24Provider(ContestType.FPS_24); + const tasks = [ + { contest_id: 'fps-24', task_id: 'fps_24_a', task_table_index: 'A' }, + { contest_id: 'fps-24', task_id: 'fps_24_x', task_table_index: 'X' }, + { contest_id: 'fps-24', task_id: 'fps_24_m', task_table_index: 'M' }, + { contest_id: 'fps-24', task_id: 'fps_24_b', task_table_index: 'B' }, + ]; + const headerIds = provider.getHeaderIdsForTask(tasks as TaskResults); + + expect(headerIds).toEqual(['A', 'B', 'M', 'X']); + }); + + test('expects to handle section boundaries correctly (A-X)', () => { + const provider = new FPS24Provider(ContestType.FPS_24); + const tasks = [ + { contest_id: 'fps-24', task_id: 'fps_24_a', task_table_index: 'A' }, + { contest_id: 'fps-24', task_id: 'fps_24_x', task_table_index: 'X' }, + ]; + const headerIds = provider.getHeaderIdsForTask(tasks as TaskResults); + + expect(headerIds).toEqual(['A', 'X']); + }); + + test('expects to handle empty task results', () => { + const provider = new FPS24Provider(ContestType.FPS_24); + const filtered = provider.filter([] as TaskResults); + + expect(filtered).toEqual([] as TaskResults); + }); + + test('expects to handle task results with different contest types', () => { + const provider = new FPS24Provider(ContestType.FPS_24); + const mockMixedTasks = [ + { contest_id: 'abc123', task_id: 'abc123_a', task_table_index: 'A' }, + { contest_id: 'dp', task_id: 'dp_a', task_table_index: 'A' }, + { contest_id: 'tdpc', task_id: 'tdpc_a', task_table_index: 'A' }, + ]; + const filtered = provider.filter(mockMixedTasks as TaskResults); + + expect(filtered).toEqual([] as TaskResults); + }); + }); + describe('MathAndAlgorithm provider', () => { test('expects to filter tasks to include only math-and-algorithm contest', () => { const provider = new MathAndAlgorithmProvider(ContestType.MATH_AND_ALGORITHM); @@ -721,6 +811,13 @@ describe('ContestTableProviderBase and implementations', () => { abbreviationName: 'tdpc', label: 'TDPC provider', }, + { + providerClass: FPS24Provider, + contestType: ContestType.FPS_24, + title: 'FPS 24 題', + abbreviationName: 'fps-24', + label: 'FPS24 provider', + }, ])('$label', ({ providerClass, contestType, title, abbreviationName }) => { test('expects to get correct metadata', () => { const provider = new providerClass(contestType); @@ -1034,14 +1131,15 @@ describe('prepareContestProviderPresets', () => { test('expects to create DPs preset correctly', () => { const group = prepareContestProviderPresets().dps(); - expect(group.getGroupName()).toBe('EDPC・TDPC'); + expect(group.getGroupName()).toBe('EDPC・TDPC・FPS 24'); expect(group.getMetadata()).toEqual({ - buttonLabel: 'EDPC・TDPC', - ariaLabel: 'EDPC and TDPC contests', + buttonLabel: 'EDPC・TDPC・FPS 24', + ariaLabel: 'EDPC and TDPC and FPS 24 contests', }); - expect(group.getSize()).toBe(2); + expect(group.getSize()).toBe(3); expect(group.getProvider(ContestType.EDPC)).toBeInstanceOf(EDPCProvider); expect(group.getProvider(ContestType.TDPC)).toBeInstanceOf(TDPCProvider); + expect(group.getProvider(ContestType.FPS_24)).toBeInstanceOf(FPS24Provider); }); test('expects to create Typical90 preset correctly', () => { diff --git a/src/test/lib/utils/test_cases/contest_table_provider.ts b/src/test/lib/utils/test_cases/contest_table_provider.ts index 7bfafcfb2..3d5bd4a5f 100644 --- a/src/test/lib/utils/test_cases/contest_table_provider.ts +++ b/src/test/lib/utils/test_cases/contest_table_provider.ts @@ -338,3 +338,14 @@ export const taskResultsForMathAndAlgorithmProvider: TaskResults = [ math_and_algorithm_101, math_and_algorithm_102, ]; + +// FPS 24: 4 problems (A, B, M, X) +// Represents a smaller problem set with uppercase letter indices +const [fps24_a, fps24_b, fps24_m, fps24_x] = createContestTasks('fps-24', [ + { taskId: 'fps_24_a', taskTableIndex: 'A', statusName: AC }, + { taskId: 'fps_24_b', taskTableIndex: 'B', statusName: AC }, + { taskId: 'fps_24_m', taskTableIndex: 'M', statusName: AC_WITH_EDITORIAL }, + { taskId: 'fps_24_x', taskTableIndex: 'X', statusName: TRYING }, +]); + +export const taskResultsForFPS24Provider: TaskResults = [fps24_a, fps24_b, fps24_m, fps24_x]; From a64608742deb53430fd7b032877e07e0fb041f0c Mon Sep 17 00:00:00 2001 From: Kato Hiroki Date: Thu, 6 Nov 2025 13:08:33 +0000 Subject: [PATCH 2/2] chore(docs): Format the PR references as Markdown links (#2797) --- .../2025-11-06/add_tests_for_contest_table_provider/plan.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/dev-notes/2025-11-06/add_tests_for_contest_table_provider/plan.md b/docs/dev-notes/2025-11-06/add_tests_for_contest_table_provider/plan.md index e1b282c4f..e87d95200 100644 --- a/docs/dev-notes/2025-11-06/add_tests_for_contest_table_provider/plan.md +++ b/docs/dev-notes/2025-11-06/add_tests_for_contest_table_provider/plan.md @@ -305,8 +305,8 @@ pnpm lint src/test/lib/utils/contest_table_provider.test.ts ## 8. 参考資料 -- PR #2286: FPS24Provider 実装(https://github.com/AtCoder-NoviSteps/AtCoderNoviSteps/pull/2286) -- PR #2780: リファクタリング(https://github.com/AtCoder-NoviSteps/AtCoderNoviSteps/pull/2780) +- PR #2286: FPS24Provider 実装 ([PR #2286](https://github.com/AtCoder-NoviSteps/AtCoderNoviSteps/pull/2286)) +- PR #2780: リファクタリング ([PR #2780](https://github.com/AtCoder-NoviSteps/AtCoderNoviSteps/pull/2780)) - 参照ドキュメント: `docs/dev-notes/2025-11-01/add_and_refactoring_tests_for_contest_table_provider/plan.md` ---