diff --git a/docs/guides/how-to-add-contest-table-provider.md b/docs/guides/how-to-add-contest-table-provider.md index ca60b6927..13c6b9b01 100644 --- a/docs/guides/how-to-add-contest-table-provider.md +++ b/docs/guides/how-to-add-contest-table-provider.md @@ -18,139 +18,66 @@ 1. **テスト設計フェーズ** - モックデータを定義 - - テストケースをリスト化 + - テストケースをリスト化(→ テストケース設計参照) - 各テストの期待値を明確化 2. **テスト実装フェーズ** - - テストコードを先に記述 - - Provider クラスはスケルトンで作成(メソッドは未実装でOK) - - **スケルトン Provider を `prepareContestProviderPresets()` と `contestTableProviderGroups` に登録・エクスポート**(テスト実行可能な環境構築) + - Vitest でテストコードを記述 + - Provider クラスをスケルトン実装(下記参照) + - `src/lib/utils/contest_table_provider.ts` の `prepareContestProviderPresets()` と `contestTableProviderGroups` に登録 3. **Provider 実装フェーズ** - - 登録済みの Provider クラスに実装を追加 - テストが RED → GREEN になるまで段階的に実装 - - `setFilterCondition()`、`getMetadata()`、`getDisplayConfig()`、`getContestRoundLabel()` を完成 + - 実行: `pnpm test:unit src/test/lib/utils/contest_table_provider.test.ts` -### 利点 - -- **要件の明確化**: テスト設計時点で仕様が固まる -- **品質保証**: 実装中にテストで即座に検証 -- **保守性**: テストが仕様書として機能 - -### 実装フェーズ詳細 - -#### ステップ1: Provider クラス作成(スケルトン) - -テスト実装フェーズで使用するスケルトン Provider クラスを作成: - -**要件**: - -- `ContestTableProviderBase` を継承 -- `protected contestType` を指定 -- 抽象メソッドは暫定実装(テストが実行可能な状態まで) - -**例**: +### スケルトン Provider の最小例 ```typescript export class MyNewProvider extends ContestTableProviderBase { protected contestType = ContestType.MY_NEW; protected setFilterCondition(): (taskResult: TaskResult) => boolean { - return () => true; // 暫定実装 + return () => true; // テストをパスさせるための最小限の実装 } getMetadata(): ContestTableMetaData { - return { title: '', abbreviationName: '' }; // 暫定実装 + return { title: '', abbreviationName: '' }; } getDisplayConfig(): ContestTableDisplayConfig { return { - /* 暫定実装 */ + /* 未定義 */ }; } getContestRoundLabel(): string { - return ''; // 暫定実装 + return ''; } } ``` -#### ステップ2: Provider 登録・エクスポート(テスト実行環境構築) +### Provider 登録(テスト実行環境構築) **ファイル**: `src/lib/utils/contest_table_provider.ts` -`prepareContestProviderPresets()` 関数内に新規 Provider を追加: - ```typescript function prepareContestProviderPresets() { return { - // ... 既存の Provider + // ... 既存のコード myNewProvider: () => new ContestTableProviderGroup().addProvider(new MyNewProvider(ContestType.MY_NEW)), }; } -``` -次に、`contestTableProviderGroups` オブジェクトに新規 Provider グループをエクスポート: - -```typescript export const contestTableProviderGroups: Record = { - // ... 既存の Provider グループ + // ... 既存のコード myNewProvider: prepareContestProviderPresets().myNewProvider(), }; ``` -**重要**: この登録がないとテストが実行されず、RED 状態が継続します。 - -#### ステップ3: Provider 実装フェーズ(本体実装) - -登録済みの Provider クラスに実装を追加: - -**要件**: - -- `setFilterCondition()`: 条件関数を返すメソッド -- `getMetadata()`: `title`、`abbreviationName` を返す -- `getDisplayConfig()`: 表示設定を返す -- `getContestRoundLabel(contestId)`: ラウンドラベルを返す - -**例**: - -```typescript -export class MyNewProvider extends ContestTableProviderBase { - protected contestType = ContestType.MY_NEW; - - protected setFilterCondition(): (taskResult: TaskResult) => boolean { - return (taskResult: TaskResult) => taskResult.contest_id === 'my-contest'; - } - - getMetadata(): ContestTableMetaData { - return { - title: 'My New Contest', - abbreviationName: 'myNew', - }; - } - - getDisplayConfig(): ContestTableDisplayConfig { - return { - isShownHeader: true, - isShownRoundLabel: false, - roundLabelWidth: '', - tableBodyCellsWidth: 'w-1/2 md:w-1/3 lg:w-1/4 px-1 py-2', - isShownTaskIndex: false, - }; - } - - getContestRoundLabel(contestId: string): string { - return ''; - } -} -``` - -テストを実行しながら段階的に実装を完成させてください: +**重要**: これらを登録しないとテストが実行できません。 -```bash -pnpm test:unit src/test/lib/utils/contest_table_provider.test.ts -``` +詳細な実装例は [Provider実装ファイル](../src/lib/utils/contest_table_provider.ts) を参照してください。 --- @@ -160,54 +87,27 @@ pnpm test:unit src/test/lib/utils/contest_table_provider.test.ts **特徴**: -- 範囲内の contest_id をフィルタリング -- contest_id から数字を抽出して範囲判定 -- すべての ARC/AGC はこのパターン +- `contest_id` の範囲内でフィルタリング - 表示: コンテストのラウンド名、ヘッダー -- 非表示: 問題名の前にある問題 id +- 非表示: 問題 id -**実装例**(ABC 001-041): +**実装例** - `setFilterCondition()` のコア部分: ```typescript -class ABC001ToABC041Provider extends ContestTableProviderBase { - protected setFilterCondition(): (taskResult: TaskResult) => boolean { - return (taskResult: TaskResult) => { - if (classifyContest(taskResult.contest_id) !== this.contestType) { - return false; - } - - const contestRound = parseContestRound(taskResult.contest_id, 'abc'); - return contestRound >= 1 && contestRound <= 41; - }; - } - - getMetadata(): ContestTableMetaData { - return { - title: 'AtCoder Beginner Contest 001 〜 041(レーティング導入前)', - abbreviationName: 'fromAbc001ToAbc041', - }; - } - - getDisplayConfig(): ContestTableDisplayConfig { - return { - isShownHeader: true, - isShownRoundLabel: true, - roundLabelWidth: 'xl:w-16', - tableBodyCellsWidth: 'w-1/2 md:w-1/3 lg:w-1/4 px-1 py-1', - isShownTaskIndex: false, - }; - } - - getContestRoundLabel(contestId: string): string { - const contestNameLabel = getContestNameLabel(contestId); - return contestNameLabel.replace('ABC ', ''); - } +protected setFilterCondition(): (taskResult: TaskResult) => boolean { + return (taskResult: TaskResult) => { + if (classifyContest(taskResult.contest_id) !== this.contestType) { + return false; + } + const contestRound = parseContestRound(taskResult.contest_id, 'abc'); + return contestRound >= 1 && contestRound <= 41; + }; } ``` -**注意**: +その他の実装(`getMetadata()`、`getDisplayConfig()` など)は [Provider実装ファイル](../src/lib/utils/contest_table_provider.ts) を参照してください。 -- 他の ABC 範囲も同じパターン(ARC、AGC も同様) +**注意**: ARC、AGC も同じパターン。範囲フィルタ型を参照してください。 --- @@ -215,51 +115,27 @@ class ABC001ToABC041Provider extends ContestTableProviderBase { **特徴**: -- 単一の contest_id のみをフィルタリング -- セクションは固定フォーマット(A~Z など) -- 表示: 問題名の前にある問題 id -- 非表示: コンテストのラウンド名、ヘッダー +- 単一の `contest_id` のみフィルタリング +- セクション: 固定フォーマット(A~Z など) +- 表示: 問題 id +- 非表示: ラウンド名、ヘッダー -**実装例**(EDPC): +**実装例** - `setFilterCondition()` のコア部分: ```typescript -class EDPCProvider extends ContestTableProviderBase { - protected setFilterCondition(): (taskResult: TaskResult) => boolean { - return (taskResult: TaskResult) => { - if (classifyContest(taskResult.contest_id) !== this.contestType) { - return false; - } - - return taskResult.contest_id === 'dp'; - }; - } - - getMetadata(): ContestTableMetaData { - return { - title: 'Educational DP Contest / DP まとめコンテスト', - abbreviationName: 'edpc', - }; - } - - getDisplayConfig(): ContestTableDisplayConfig { - return { - isShownHeader: false, - isShownRoundLabel: false, - roundLabelWidth: '', - 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 ''; - } +protected setFilterCondition(): (taskResult: TaskResult) => boolean { + return (taskResult: TaskResult) => { + if (classifyContest(taskResult.contest_id) !== this.contestType) { + return false; + } + return taskResult.contest_id === 'dp'; // ← contest_id だけ変更 + }; } ``` -**注意**: +その他の実装は [Provider実装ファイル](../src/lib/utils/contest_table_provider.ts) を参照してください。 -- TDPC、FPS_24 も同じパターン(contest_id と メタデータだけ異なる) +**注意**: TDPC、FPS_24 も同じパターン。`contest_id` とメタデータだけ異なります。 --- @@ -268,423 +144,271 @@ class EDPCProvider extends ContestTableProviderBase { **特徴**: - 複数の異なる contest/task_id を1つのテーブルに表示 -- task_table_index が共通フォーマット(A~K、001~104 など) - セクション分割可能(Tessoku Book のみ) +- 表示: 問題 id、セクション分割 +- 非表示: ラウンド名、ヘッダー -**実装例**(ABS): +**実装例** - `setFilterCondition()` のコア部分: ```typescript -class ABSProvider extends ContestTableProviderBase { - protected setFilterCondition(): (taskResult: TaskResult) => boolean { - return (taskResult: TaskResult) => { - return classifyContest(taskResult.contest_id) === this.contestType; - }; - } - - getMetadata(): ContestTableMetaData { - return { - title: 'AtCoder Beginners Selection', - abbreviationName: 'abs', - }; - } - - getDisplayConfig(): ContestTableDisplayConfig { - return { - isShownHeader: false, - isShownRoundLabel: false, - roundLabelWidth: '', - tableBodyCellsWidth: 'w-1/2 md:w-1/3 lg:w-1/4 px-1 py-2', - isShownTaskIndex: false, - }; - } - - getContestRoundLabel(contestId: string): string { - return ''; - } - - getHeaderIdsForTask(tasks: TaskResults): string[] { - return ( - this.filter(tasks) - ?.map((task) => task.task_table_index) - .sort() ?? [] - ); - } +protected setFilterCondition(): (taskResult: TaskResult) => boolean { + return (taskResult: TaskResult) => { + return classifyContest(taskResult.contest_id) === this.contestType; + }; } ``` **Tessoku Book(セクション分割あり)**: -```typescript -// 基底クラス: TessokuBookProvider -export class TessokuBookProvider extends ContestTableProviderBase { - protected setFilterCondition(): (taskResult: TaskResult) => boolean { - return (taskResult: TaskResult) => { - return classifyContest(taskResult.contest_id) === this.contestType; - }; - } - - getMetadata(): ContestTableMetaData { - return { - title: '競技プログラミングの鉄則', - abbreviationName: 'tessoku-book', - }; - } -} - -// 例題: A01~A77 -export class TessokuBookForExamplesProvider extends TessokuBookProvider { - constructor(contestType: ContestType) { - super(contestType, 'examples'); - } +セクション分割が必要な場合は、例えば `task_table_index` の先頭文字でフィルタリングします: - protected setFilterCondition(): (taskResult: TaskResult) => boolean { - return (taskResult: TaskResult) => { - return ( - classifyContest(taskResult.contest_id) === this.contestType && - taskResult.task_table_index.startsWith('A') - ); - }; - } - - getMetadata(): ContestTableMetaData { - return { - title: '競技プログラミングの鉄則(A. 例題)', - abbreviationName: 'tessoku-book-for-examples', - }; - } -} - -// 応用: B01~B69 -export class TessokuBookForPracticalsProvider extends TessokuBookProvider { - constructor(contestType: ContestType) { - super(contestType, 'practicals'); +```typescript +class TessokuBookSectionProvider extends TessokuBookProvider { + constructor( + contestType: ContestType, + private sectionPrefix: 'A' | 'B' | 'C', + ) { + super( + contestType, + sectionPrefix === 'A' ? 'examples' : sectionPrefix === 'B' ? 'practicals' : 'challenges', + ); } protected setFilterCondition(): (taskResult: TaskResult) => boolean { return (taskResult: TaskResult) => { return ( classifyContest(taskResult.contest_id) === this.contestType && - taskResult.task_table_index.startsWith('B') + taskResult.task_table_index.startsWith(this.sectionPrefix) ); }; } - - getMetadata(): ContestTableMetaData { - return { - title: '競技プログラミングの鉄則(B. 応用問題)', - abbreviationName: 'tessoku-book-for-practicals', - }; - } -} - -// 力試し: C01~C20 -export class TessokuBookForChallengesProvider extends TessokuBookProvider { - constructor(contestType: ContestType) { - super(contestType, 'challenges'); - } - - protected setFilterCondition(): (taskResult: TaskResult) => boolean { - return (taskResult: TaskResult) => { - return ( - classifyContest(taskResult.contest_id) === this.contestType && - taskResult.task_table_index.startsWith('C') - ); - }; - } - - getMetadata(): ContestTableMetaData { - return { - title: '競技プログラミングの鉄則(C. 力試し問題)', - abbreviationName: 'tessoku-book-for-challenges', - }; - } } ``` +その他の実装は [Provider実装ファイル](../src/lib/utils/contest_table_provider.ts) を参照してください。 + --- ## 各コンテスト種別の特有仕様 ### 範囲フィルタ型 -| コンテスト | 範囲 | フォーマット | 問題数 | ラベル表示 | 特有の注意 | -| ----------- | -------- | ------------ | ------------- | ---------- | ------------------------- | -| ABC 001-041 | 001~041 | 001, 041 | 4問(A-D) | あり | 古いID形式混在 | -| ABC 042-125 | 042~125 | 042, 125 | 4問(A-D) | あり | 共有問題あり(ARC) | -| ABC 126-211 | 126~211 | 126, 211 | 6問(A-F) | あり | 標準形式 | -| ABC 212-318 | 212~318 | 212, 318 | 8問(A-Ex/H) | あり | 標準形式 | -| ABC 319- | 319~ | 319 | 7問(A-G) | あり | 標準形式 | -| ARC 001-057 | 001~057 | 001, 057 | 4問(A-D) | あり | 古いID形式 | -| ARC 058-103 | 058~103 | 058, 103 | 4問(C-F) | あり | 共有問題あり (ABC) | -| ARC 104- | 104~ | 104 | 4~6問 | あり | - | -| AGC 001- | 001~ | 001 | 4~7問 | あり | 7問パターン(AGC028のみ) | +| コンテスト | 範囲 | フォーマット | セクション | ラベル | 特有の注意 | +| ----------- | -------- | ------------ | ---------- | ------ | ------------- | +| ABC 001-041 | 001~041 | 001, 041 | A~D | あり | 旧形式 | +| ABC 042-125 | 042~125 | 042, 125 | A~D | あり | 共有問題(ARC) | +| ABC 126-211 | 126~211 | 126, 211 | A~F | あり | 6問制 | +| ABC 212-318 | 212~318 | 212, 318 | A~Ex/H | あり | 8問制 | +| ABC 319- | 319~ | 319 | A~G | あり | 標準形式 | +| ARC 001-057 | 001~057 | 001, 057 | A~D | あり | 旧形式 | +| ARC 058-103 | 058~103 | 058, 103 | C~F | あり | 共有問題(ABC) | +| ARC 104- | 104~ | 104 | 4~6問 | あり | - | +| AGC 001- | 001~ | 001 | 4~7問 | あり | - | ### 単一ソース型 -| コンテスト | contest_id | セクション範囲 | フォーマット | ヘッダー表示 | 問題インデックス表示 | -| ------------ | ------------- | -------------- | ------------ | ------------ | -------------------- | -| EDPC | `'dp'` | A~Z(26問) | A, B, ..., Z | なし | あり | -| TDPC | `'tdpc'` | A~Z(26問) | A, B, ..., Z | なし | あり | -| FPS_24 | `'fps-24'` | A~X(24問) | A, B, ..., X | なし | あり | -| ACL_PRACTICE | `'practice2'` | A~L(12問) | A, B, ..., L | なし | あり | +| コンテスト | contest_id | セクション | フォーマット | +| ------------ | ------------- | ---------- | ------------ | +| EDPC | `'dp'` | 26問 | A~Z | +| TDPC | `'tdpc'` | 26問 | A~Z | +| FPS_24 | `'fps-24'` | 24問 | A~X | +| ACL_PRACTICE | `'practice2'` | 12問 | A~L | ### 複合ソース型 -| コンテスト | contest_id | 問題数 | セクション範囲 | フォーマット | セクション分割 | 複数コンテスト | ヘッダー表示 | インデックス表示 | 特有の注意 | -| ------------------ | ---------------------- | ------ | ---------------------------- | ------------------ | -------------- | -------------- | ------------ | ---------------- | -------------------- | -| ABS | `'abs'` | 11問 | A~K | A, B, ..., K | なし | あり(11個) | なし | なし | task_idが複雑 | -| TESSOKU_BOOK | `'tessoku-book'` | 166問 | A(01-77), B(01-69), C(01-20) | A01, A77, B63, C20 | あり | あり | なし | あり | セクション判定が複雑 | -| MATH_AND_ALGORITHM | `'math-and-algorithm'` | 104問 | 001~104 | 001, 028, 104 | なし | あり | なし | あり | 範囲内に欠損あり | - ---- - -## テスト設計ガイド - -### テストケース設計 - -#### 全 Provider 共通の必須テスト項目 - -1. **基本的なフィルタリング**: contest_id / 型の検証 -2. **メタデータ取得**: title、abbreviationName -3. **ディスプレイ設定確認**: isShownHeader、isShownRoundLabel 等 -4. **ラウンドラベルフォーマット**: getContestRoundLabel() -5. **テーブル生成**: generateTable() の構造(問題数確認) -6. **ラウンドID取得**: getContestRoundIds() -7. **ヘッダーID取得**: getHeaderIdsForTask() -8. **当該Provider の特徴的な検証**: 共有問題確認、複数由来確認等 -9. **空入力ハンドリング**: 空配列での動作 - -#### パターン固有テスト - -- **範囲フィルタ型**: 範囲境界値(最小値、最大値、範囲外)の検証、共有問題がないか確認 -- **複合ソース型**: 複数 contest_id の混在検証、セクション分割ロジック(該当する場合) - -### モックデータの準備と確認事項 - -#### ステップ1: データソース確認 - -テスト設計開始前に、以下のファイルで当該コンテストの仕様を把握してください: - -**ファイル**: `prisma/tasks.ts` - -- 当該 contest_id のエントリを確認 -- 問題ID フォーマット(数字 `001` / 英字サフィックス `A`)を把握 -- 複数コンテスト由来の問題がないか確認 - -**ファイル**: `prisma/contest_task_pairs.ts` - -- 共有問題の有無を確認(同一の問題が複数コンテストで使用されているか) -- 複合ソース型の場合、各問題の元のコンテストを特定 - -**ファイル**: `prisma/schema.prisma` - -- `task_table_index` フィールドのフォーマットを確認 - -#### ステップ2: ContestType 確認 - -- **ファイル**: `src/lib/types/contest.ts` - - 対応する `ContestType` が定義されているか確認 - - 定義されていない場合は、先に ContestType を追加 - -- **Provider 実装**の確認 - - `protected contestType` が正しく指定されているか - - `classifyContest()` ユーティリティ関数に分類ロジックが実装されているか確認 - -#### ステップ3: テストデータ構築 - -**ファイル**: `src/test/lib/utils/test_cases/contest_table_provider.ts` +| コンテスト | contest_id | 問題数 | セクション | 分割 | 複数コンテスト | +| ------------------ | ---------------------- | ------ | ------------ | ---- | -------------- | +| ABS | `'abs'` | 11問 | A~K | なし | あり(11個) | +| ABC-Like | 計15コンテスト | 2~8問 | A~H | なし | あり(15個) | +| TESSOKU_BOOK | `'tessoku-book'` | 166問 | A(01-77)/B/C | あり | あり | +| MATH_AND_ALGORITHM | `'math-and-algorithm'` | 104問 | 001~104 | なし | あり | -以下のヘルパー関数を使用してテストデータを作成してください: +**複合型の参照解決**: `getMergedTasksMap()` が複数コンテスト由来の task_id を自動統合。テストデータは [prisma/contest_task_pairs.ts](../../prisma/contest_task_pairs.ts) を参照。 -```typescript -// 既存のヘルパー関数(汎用) -export function createTaskResultWithTaskTableIndex( - contestId: string, - taskId: string, - taskTableIndex: string, - submissionStatus: SubmissionStatus, -): TaskResult { - return { - contest_id: contestId, - task_id: taskId, - task_table_index: taskTableIndex, - submission_status: submissionStatus, - // ... その他のプロパティ - }; -} - -// 新規 Provider 用テストデータ定数 -export const taskResultsForNewProvider: TaskResults = [ - createTaskResultWithTaskTableIndex('contest_id', 'task_id_1', 'A', AC), - createTaskResultWithTaskTableIndex('contest_id', 'task_id_2', 'B', AC), - // ... 問題数分のエントリ -]; -``` +--- -**重要なポイント**: +## テスト実装ガイド -- `createTaskResultWithTaskTableIndex` を使用して、contest_id、task_id、task_table_index を明確に指定 -- テストデータは「全 Provider 共通の必須テスト項目」すべてをカバーする構成にする -- 複数コンテスト由来の問題がある場合は、そのパターンも明示的に含める -- `AC` 以外のステータスが必要な場合は、テストケースごとに明示的に指定 +### 必須テスト項目(全 Provider 共通) -### テスト実装の最小構成 +1. 基本的なフィルタリング検証(contest_id / 型) +2. メタデータ取得(title、abbreviationName) +3. ディスプレイ設定確認(isShownHeader、isShownRoundLabel 等) +4. ラウンドラベルフォーマット(`getContestRoundLabel()`) +5. テーブル生成構造(問題数確認) +6. ヘッダー・ラウンドID取得 +7. 空入力ハンドリング -#### Vitest の基本概念 +### パターン固有テスト -- `describe()`: テストスイートをグループ化 -- `test()` または `it()`: 個別のテストケース -- `expect()`: アサーション(期待値の検証) -- `vi.mock()`: モック関数の定義 -- `beforeEach()`: 各テスト前に実行する初期化処理 +- **範囲フィルタ型**: 範囲境界値テスト、共有問題の有無確認 +- **複合ソース型**: 複数 contest_id 混在テスト、セクション分割ロジック -#### Vitest を使用したテスト例 +### Vitest テスト例 ```typescript import { describe, test, expect, vi } from 'vitest'; -describe('NewProvider', () => { - // モック定義 +describe('MyNewProvider', () => { beforeEach(() => { vi.mock('src/lib/utils/contest', () => ({ classifyContest: vi.fn((contestId) => { - if (contestId === 'new-contest') return ContestType.NEW; - // ... その他の処理 + if (contestId === 'my-contest') return ContestType.MY_NEW; + // ... その他 }), })); }); - test('expects to filter tasks correctly', () => { - const provider = new NewProvider(ContestType.NEW); + test('filters tasks correctly', () => { + const provider = new MyNewProvider(ContestType.MY_NEW); const filtered = provider.filter(mockTasks); - expect(filtered?.every((t) => /* condition */)).toBe(true); + expect(filtered?.every((t) => t.contest_id === 'my-contest')).toBe(true); }); - test('expects to return correct metadata', () => { - const provider = new NewProvider(ContestType.NEW); + test('returns correct metadata', () => { + const provider = new MyNewProvider(ContestType.MY_NEW); expect(provider.getMetadata().title).toBe('Expected Title'); }); - test('expects to return correct display config', () => { - const provider = new NewProvider(ContestType.NEW); + test('returns correct display config', () => { + const provider = new MyNewProvider(ContestType.MY_NEW); const config = provider.getDisplayConfig(); expect(config.isShownHeader).toBe(true); }); }); ``` ---- +### モックデータ準備 -## 教訓: よくあるミス +**ステップ1: データソース確認** -### 1. **モック定義の漏れ**(最頻出) +- `prisma/tasks.ts`: contest_id、task_id フォーマット確認 +- `prisma/contest_task_pairs.ts`: 共有問題の確認 +- `prisma/schema.prisma`: task_table_index フォーマット確認 -**問題**: テストで新しい contest_id を使うと、`classifyContest()` モックに対応定義がなく失敗 +**ステップ2: ContestType 確認** -**対策チェックリスト**: +- `src/lib/types/contest.ts` で定義済みか確認 +- `classifyContest()` ユーティリティで分類ロジック実装済みか確認 -- [ ] 新規 contest_id をテストで使用する場合、`classifyContest` モックに追加したか -- [ ] テストデータと モック定義が対応しているか -- [ ] 初回実行で失敗したら、**モック定義を最優先で確認** +**ステップ3: テストデータ構築** -**例**: +```typescript +export const taskResultsForNewProvider: TaskResults = [ + createTaskResultWithTaskTableIndex('contest_id', 'task_id_1', 'A', AC), + createTaskResultWithTaskTableIndex('contest_id', 'task_id_2', 'B', AC), + // ... 問題数分 +]; +``` + +--- + +## よくあるミス Top 5 + +### 1. **モック定義の漏れ**(最頻出) + +**問題**: テストで新しい `contest_id` を使うと、`classifyContest()` モックに対応定義がなく失敗 + +**解決策**: ```typescript vi.mock('src/lib/utils/contest', () => ({ classifyContest: vi.fn((contestId) => { if (contestId === 'practice2') return ContestType.ACL_PRACTICE; if (contestId === 'new-contest') return ContestType.NEW; - // ... + // ... その他を追加 }), })); ``` -### 2. **ソート順序の不安定** +--- -**問題**: 数字ソート(001, 028, 036, 102)と文字ソート(A, B, M, X)の混在で誤り +### 2. **getDisplayConfig() での属性漏れ** -**対策**: +**問題**: 一部の属性だけ定義し、他の属性(`isShownHeader` など)を未定義にすると、ベースクラスのデフォルト値が適用される -- 数字フォーマット(001など)は **文字列として数値ソート** -- 英字フォーマット(A, Bなど)は **localeCompare() を使用** +**解決策**: `ContestTableDisplayConfig` の **全属性を明示的に定義** ```typescript -// ❌ 間違い -const sorted = indices.sort(); - -// ✅ 正解(数字フォーマット) -const sorted = indices.sort((a, b) => { - const aNum = parseInt(a, 10); - const bNum = parseInt(b, 10); - return aNum - bNum; -}); - -// ✅ 正解(英字フォーマット) -const sorted = indices.sort((a, b) => a.localeCompare(b)); +getDisplayConfig() { + return { + isShownHeader: true, // 必ず指定 + isShownRoundLabel: true, // 必ず指定 + roundLabelWidth: 'xl:w-16', + tableBodyCellsWidth: 'w-8 h-8 px-1 py-1', + isShownTaskIndex: false, // 必ず指定 + }; +} ``` -### 3. **複数コンテスト由来の問題を見落とし** +--- -**問題**: ABC042-125 の共有問題(ARC との同日開催)で task_id が arc58_a なのに contest_id は abc042 のケースを処理し忘れ +### 3. **複数コンテスト由来の問題を見落とし** -**対策**: +**問題**: ABC042-125 の共有問題(ARC との同日開催)で `task_id` が `arc58_a` なのに `contest_id` は `abc042` のケースを処理し忘れ -- 範囲フィルタ型で複数コンテスト由来の問題がないか確認 -- テストケースに **混合パターン** を明示的に含める +**解決策**: テストケースに **混合パターンを明示的に含める** ```typescript -test('expects to filter only ABC-type contests', () => { +test('filters correctly with shared problems', () => { const mixed = [ - { contest_id: 'abc042', task_id: 'abc042_a', task_table_index: 'A' }, // ABC側 - { contest_id: 'abc042', task_id: 'arc058_a', task_table_index: 'C' }, // 共有問題 - { contest_id: 'arc058', task_id: 'arc058_a', task_table_index: 'C' }, // ARC側 + { contest_id: 'abc042', task_id: 'abc042_a' }, // ABC側 + { contest_id: 'abc042', task_id: 'arc058_a' }, // 共有問題 + { contest_id: 'arc058', task_id: 'arc058_a' }, // ARC側 ]; - // ARC側は除外、ABC042側の2つだけが返る + // ABC042側の2つだけが返る }); ``` -### 4. **セクション判定ロジックの複雑化** +--- -**問題**: Tessoku Book の A01~A77, B01~B69, C01~C20 で正規表現を間違える +### 4. **ソート順序の不安定** -**対策**: +**問題**: 数字ソート(001, 028, 036, 102)と文字ソート(A, B, M, X)の混在で誤り -- 正規表現は **段階的にテスト** -- 範囲の上限が2桁(A77, B69)と可変の場合、明示的に記述 +**解決策**: ```typescript -// Tessoku Book 例題(A01~A30) -const isExample = /^A(0[1-9]|[12][0-9]|30)$/.test(index); +// ❌ 間違い +const sorted = indices.sort(); -// 応用(A31~A77) -const isPractical = /^A(3[1-9]|[4-6][0-9]|7[0-7])$/.test(index); +// ✅ 正解(数字フォーマット) +const sorted = indices.sort((a, b) => parseInt(a, 10) - parseInt(b, 10)); + +// ✅ 正解(英字フォーマット) +const sorted = indices.sort((a, b) => a.localeCompare(b)); ``` -### 5. **パラメータ化テスト(describe.each)での誤り** +--- -**問題**: 複数 Provider で displayConfig が共通だが、1つだけ異なる値を設定するとテスト全体が失敗 +### 5. **パラメータ化テストでの共通化誤り** -**対策**: +**問題**: `describe.each()` で複数 Provider テストを共通化したが、1つの Provider だけ `displayConfig` が異なるため全体が失敗 -- `describe.each()` で共通テストは共有、固有テストは別ブロック -- 表示設定が異なる Provider は `describe.each()` から **除外** +**解決策**: 異なる Provider は個別 `describe` ブロック ```typescript -describe.each([ - { Provider: EDPCProvider, ... }, - { Provider: TDPCProvider, ... }, - // ただし displayConfig が異なる場合は個別 describe ブロック -])('...', ({ Provider, ... }) => { - test('shared test', () => { ... }); -}); +describe.each([...])('shared tests', () => { /* ... */ }); -describe('CustomProvider with unique displayConfig', () => { - test('custom test', () => { ... }); +describe('CustomProvider with unique config', () => { + test('custom test', () => { /* ... */ }); }); ``` --- +## 実装完了後 + +### ドキュメント更新チェックリスト + +- [ ] 各コンテスト種別テーブル に新規 Provider の行を追加 +- [ ] 複合型参照情報がある場合は複合型コンテストの実装パターン に追加 +- [ ] テストデータ参考ファイル に新規ファイルがあれば追加 +- [ ] GitHub Issues に当該 Provider のリンクを追加 +- [ ] 最終更新日を現在日付に変更 + +--- + ## 参考資料 ### GitHub Issues @@ -695,6 +419,7 @@ describe('CustomProvider with unique displayConfig', () => { - [#2835](https://github.com/AtCoder-NoviSteps/AtCoderNoviSteps/issues/2835) - ARC104OnwardsProvider - [#2837](https://github.com/AtCoder-NoviSteps/AtCoderNoviSteps/issues/2837) - AGC001OnwardsProvider - [#2838](https://github.com/AtCoder-NoviSteps/AtCoderNoviSteps/issues/2838) - ABC001~041 & ARC001~057 +- [#2840](https://github.com/AtCoder-NoviSteps/AtCoderNoviSteps/issues/2840) - ABCLikeProvider - [#2776](https://github.com/AtCoder-NoviSteps/AtCoderNoviSteps/issues/2776) - TessokuBookProvider - [#2785](https://github.com/AtCoder-NoviSteps/AtCoderNoviSteps/issues/2785) - MathAndAlgorithmProvider - [#2797](https://github.com/AtCoder-NoviSteps/AtCoderNoviSteps/issues/2797) - FPS24Provider @@ -708,4 +433,4 @@ describe('CustomProvider with unique displayConfig', () => { --- -**最終更新**: 2026-01-23 +**最終更新**: 2026-01-26 diff --git a/prisma/contest_task_pairs.ts b/prisma/contest_task_pairs.ts index 0877556f7..06223edf1 100644 --- a/prisma/contest_task_pairs.ts +++ b/prisma/contest_task_pairs.ts @@ -949,6 +949,71 @@ export const contest_task_pairs = [ problem_id: 'arc060_a', problem_index: 'C', }, + { + contest_id: 'jsc2025advance-final', + problem_id: 'abc422_g', + problem_index: 'G', + }, + { + contest_id: 'jsc2025advance-final', + problem_id: 'abc422_f', + problem_index: 'F', + }, + { + contest_id: 'jsc2025advance-final', + problem_id: 'abc422_e', + problem_index: 'E', + }, + { + contest_id: 'jsc2025advance-final', + problem_id: 'abc422_d', + problem_index: 'D', + }, + { + contest_id: 'jsc2025advance-final', + problem_id: 'abc422_c', + problem_index: 'C', + }, + { + contest_id: 'jsc2025advance-final', + problem_id: 'abc422_b', + problem_index: 'B', + }, + { + contest_id: 'jsc2025advance-final', + problem_id: 'abc422_a', + problem_index: 'A', + }, + { + contest_id: 'tenka1-2019-beginner', + problem_id: 'tenka1_2019_d', + problem_index: 'D', + }, + { + contest_id: 'tenka1-2019-beginner', + problem_id: 'tenka1_2019_c', + problem_index: 'C', + }, + { + contest_id: 'tenka1-2018-beginner', + problem_id: 'tenka1_2018_d', + problem_index: 'D', + }, + { + contest_id: 'tenka1-2018-beginner', + problem_id: 'tenka1_2018_c', + problem_index: 'C', + }, + { + contest_id: 'tenka1-2017-beginner', + problem_id: 'tenka1_2017_d', + problem_index: 'D', + }, + { + contest_id: 'tenka1-2017-beginner', + problem_id: 'tenka1_2017_c', + problem_index: 'C', + }, { contest_id: 'tessoku-book', problem_id: 'typical90_s', diff --git a/prisma/tasks.ts b/prisma/tasks.ts index 76a4aa9f7..574eeede4 100755 --- a/prisma/tasks.ts +++ b/prisma/tasks.ts @@ -9,6 +9,55 @@ export const tasks = [ title: 'EX8. たくさんのA足すB問題', grade: 'Q8', }, + { + id: 'abc422_g', + contest_id: 'abc422', + problem_index: 'G', + name: 'Balls and Boxes', + title: 'G. Balls and Boxes', + }, + { + id: 'abc422_f', + contest_id: 'abc422', + problem_index: 'F', + name: 'Eat and Ride', + title: 'F. Eat and Ride', + }, + { + id: 'abc422_e', + contest_id: 'abc422', + problem_index: 'E', + name: 'Colinear', + title: 'E. Colinear', + }, + { + id: 'abc422_d', + contest_id: 'abc422', + problem_index: 'D', + name: 'Least Unbalanced', + title: 'D. Least Unbalanced', + }, + { + id: 'abc422_c', + contest_id: 'abc422', + problem_index: 'C', + name: 'AtCoder AAC Contest', + title: 'C. AtCoder AAC Contest', + }, + { + id: 'abc422_b', + contest_id: 'abc422', + problem_index: 'B', + name: 'Looped Rope', + title: 'B. Looped Rope', + }, + { + id: 'abc422_a', + contest_id: 'abc422', + problem_index: 'A', + name: 'Stage Clear', + title: 'A. Stage Clear', + }, { id: 'abc408_g', contest_id: 'abc408', @@ -7436,6 +7485,293 @@ export const tasks = [ title: 'G. Wildcards', grade: 'Q1', }, + { + id: 'zone2021_f', + contest_id: 'zone2021', + problem_index: 'F', + name: 'Encounter and Farewell', + title: 'F. Encounter and Farewell', + }, + { + id: 'zone2021_e', + contest_id: 'zone2021', + problem_index: 'E', + name: 'Sneaking', + title: 'E. Sneaking', + }, + { + id: 'zone2021_d', + contest_id: 'zone2021', + problem_index: 'D', + name: 'Message from Aliens', + title: 'D. Message from Aliens', + }, + { + id: 'zone2021_c', + contest_id: 'zone2021', + problem_index: 'C', + name: 'MAD TEAM', + title: 'C. MAD TEAM', + }, + { + id: 'zone2021_b', + contest_id: 'zone2021', + problem_index: 'B', + name: 'Sign of Friendship', + title: 'B. Sign of Friendship', + }, + { + id: 'zone2021_a', + contest_id: 'zone2021', + problem_index: 'A', + name: 'UFO Invasion', + title: 'A. UFO Invasion', + }, + { + id: 'jsc2021_h', + contest_id: 'jsc2021', + problem_index: 'H', + name: 'Shipping', + title: 'H. Shipping', + }, + { + id: 'jsc2021_g', + contest_id: 'jsc2021', + problem_index: 'G', + name: 'Spanning Tree', + title: 'G. Spanning Tree', + }, + { + id: 'jsc2021_f', + contest_id: 'jsc2021', + problem_index: 'F', + name: 'Max Matrix', + title: 'F. Max Matrix', + }, + { + id: 'jsc2021_e', + contest_id: 'jsc2021', + problem_index: 'E', + name: 'Level K Palindrome', + title: 'E. Level K Palindrome', + }, + { + id: 'jsc2021_d', + contest_id: 'jsc2021', + problem_index: 'D', + name: 'Nowhere P', + title: 'D. Nowhere P', + }, + { + id: 'jsc2021_c', + contest_id: 'jsc2021', + problem_index: 'C', + name: 'Max GCD 2', + title: 'C. Max GCD 2', + }, + { + id: 'jsc2021_b', + contest_id: 'jsc2021', + problem_index: 'B', + name: 'Xor of Sequences', + title: 'B. Xor of Sequences', + }, + { + id: 'jsc2021_a', + contest_id: 'jsc2021', + problem_index: 'A', + name: 'Competition', + title: 'A. Competition', + }, + { + id: 'hhkb2020_f', + contest_id: 'hhkb2020', + problem_index: 'F', + name: 'Random Max', + title: 'F. Random Max', + }, + { + id: 'hhkb2020_e', + contest_id: 'hhkb2020', + problem_index: 'E', + name: 'Lamps', + title: 'E. Lamps', + }, + { + id: 'hhkb2020_d', + contest_id: 'hhkb2020', + problem_index: 'D', + name: 'Squares', + title: 'D. Squares', + }, + { + id: 'hhkb2020_c', + contest_id: 'hhkb2020', + problem_index: 'C', + name: 'Neq Min', + title: 'C. Neq Min', + }, + { + id: 'hhkb2020_b', + contest_id: 'hhkb2020', + problem_index: 'B', + name: 'Futon', + title: 'B. Futon', + }, + { + id: 'hhkb2020_a', + contest_id: 'hhkb2020', + problem_index: 'A', + name: 'Keyboard', + title: 'A. Keyboard', + }, + { + id: 'abl_f', + contest_id: 'abl', + problem_index: 'F', + name: 'Heights and Pairs', + title: 'F. Heights and Pairs', + }, + { + id: 'abl_e', + contest_id: 'abl', + problem_index: 'E', + name: 'Replace Digits', + title: 'E. Replace Digits', + }, + { + id: 'abl_d', + contest_id: 'abl', + problem_index: 'D', + name: 'Flat Subsequence', + title: 'D. Flat Subsequence', + }, + { + id: 'abl_c', + contest_id: 'abl', + problem_index: 'C', + name: 'Connect Cities', + title: 'C. Connect Cities', + }, + { + id: 'abl_b', + contest_id: 'abl', + problem_index: 'B', + name: 'Integer Preference', + title: 'B. Integer Preference', + }, + { + id: 'abl_a', + contest_id: 'abl', + problem_index: 'A', + name: 'Repeat ACL', + title: 'A. Repeat ACL', + }, + { + id: 'm_solutions2020_f', + contest_id: 'm-solutions2020', + problem_index: 'F', + name: 'Air Safety', + title: 'F. Air Safety', + }, + { + id: 'm_solutions2020_e', + contest_id: 'm-solutions2020', + problem_index: 'E', + name: "M's Solution", + title: "E. M's Solution", + }, + { + id: 'm_solutions2020_d', + contest_id: 'm-solutions2020', + problem_index: 'D', + name: 'Road to Millionaire', + title: 'D. Road to Millionaire', + }, + { + id: 'm_solutions2020_c', + contest_id: 'm-solutions2020', + problem_index: 'C', + name: 'Marks', + title: 'C. Marks', + }, + { + id: 'm_solutions2020_b', + contest_id: 'm-solutions2020', + problem_index: 'B', + name: 'Magic 2', + title: 'B. Magic 2', + }, + { + id: 'm_solutions2020_a', + contest_id: 'm-solutions2020', + problem_index: 'A', + name: 'Kyu in AtCoder', + title: 'A. Kyu in AtCoder', + }, + { + id: 'aising2020_f', + contest_id: 'aising2020', + problem_index: 'F', + name: 'Two Snuke', + title: 'F. Two Snuke', + }, + { + id: 'aising2020_e', + contest_id: 'aising2020', + problem_index: 'E', + name: 'Camel Train', + title: 'E. Camel Train', + }, + { + id: 'aising2020_d', + contest_id: 'aising2020', + problem_index: 'D', + name: 'Anything Goes to Zero', + title: 'D. Anything Goes to Zero', + }, + { + id: 'aising2020_c', + contest_id: 'aising2020', + problem_index: 'C', + name: 'XYZ Triplets', + title: 'C. XYZ Triplets', + }, + { + id: 'aising2020_b', + contest_id: 'aising2020', + problem_index: 'B', + name: 'An Odd Problem', + title: 'B. An Odd Problem', + }, + { + id: 'aising2020_a', + contest_id: 'aising2020', + problem_index: 'A', + name: 'Number of Multiples', + title: 'A. Number of Multiples', + }, + { + id: 'panasonic2020_f', + contest_id: 'panasonic2020', + problem_index: 'F', + name: 'Fractal Shortest Path', + title: 'F. Fractal Shortest Path', + }, + { + id: 'panasonic2020_e', + contest_id: 'panasonic2020', + problem_index: 'E', + name: 'Three Substrings', + title: 'E. Three Substrings', + }, + { + id: 'panasonic2020_d', + contest_id: 'panasonic2020', + problem_index: 'D', + name: 'String Equivalence', + title: 'D. String Equivalence', + }, { id: 'panasonic2020_c', contest_id: 'panasonic2020', @@ -7459,11 +7795,284 @@ export const tasks = [ grade: 'Q8', }, { - id: 'jsc2021_c', - contest_id: 'jsc2021', + id: 'sumitb2019_f', + contest_id: 'sumitrust2019', + problem_index: 'F', + name: 'Interval Running', + title: 'F. Interval Running', + }, + { + id: 'sumitb2019_e', + contest_id: 'sumitrust2019', + problem_index: 'E', + name: 'Colorful Hats 2', + title: 'E. Colorful Hats 2', + }, + { + id: 'sumitb2019_d', + contest_id: 'sumitrust2019', + problem_index: 'D', + name: 'Lucky PIN', + title: 'D. Lucky PIN', + }, + { + id: 'sumitb2019_c', + contest_id: 'sumitrust2019', problem_index: 'C', - name: 'Max GCD 2', - title: 'C. Max GCD 2', + name: '100 to 105', + title: 'C. 100 to 105', + }, + { + id: 'sumitb2019_b', + contest_id: 'sumitrust2019', + problem_index: 'B', + name: 'Tax Rate', + title: 'B. Tax Rate', + }, + { + id: 'sumitb2019_a', + contest_id: 'sumitrust2019', + problem_index: 'A', + name: 'November 30', + title: 'A. November 30', + }, + { + id: 'tenka1_2019_b', + contest_id: 'tenka1-2019-beginner', + problem_index: 'B', + name: '*e**** ********e* *e****e* ****e**', + title: 'B. *e**** ********e* *e****e* ****e**', + }, + { + id: 'tenka1_2019_a', + contest_id: 'tenka1-2019-beginner', + problem_index: 'A', + name: 'On the Way', + title: 'A. On the Way', + }, + { + id: 'aising2019_e', + contest_id: 'aising2019', + problem_index: 'E', + name: 'Attack to a Tree', + title: 'E. Attack to a Tree', + }, + { + id: 'aising2019_d', + contest_id: 'aising2019', + problem_index: 'D', + name: 'Nearest Card Game', + title: 'D. Nearest Card Game', + }, + { + id: 'aising2019_c', + contest_id: 'aising2019', + problem_index: 'C', + name: 'Alternating Path', + title: 'C. Alternating Path', + }, + { + id: 'aising2019_b', + contest_id: 'aising2019', + problem_index: 'B', + name: 'Contests', + title: 'B. Contests', + }, + { + id: 'aising2019_a', + contest_id: 'aising2019', + problem_index: 'A', + name: 'Bulletin Board', + title: 'A. Bulletin Board', + }, + { + id: 'caddi2018_b', + contest_id: 'caddi2018b', + problem_index: 'D', + name: 'Harlequin', + title: 'D. Harlequin', + }, + { + id: 'caddi2018_a', + contest_id: 'caddi2018b', + problem_index: 'C', + name: 'Product and GCD', + title: 'C. Product and GCD', + }, + { + id: 'caddi2018b_b', + contest_id: 'caddi2018b', + problem_index: 'B', + name: 'AtCoder Alloy', + title: 'B. AtCoder Alloy', + }, + { + id: 'caddi2018b_a', + contest_id: 'caddi2018b', + problem_index: 'A', + name: '12/22', + title: 'A. 12/22', + }, + { + id: 'soundhound2018_summer_qual_e', + contest_id: 'soundhound2018-summer-qual', + problem_index: 'E', + name: '+ Graph', + title: 'E. + Graph', + }, + { + id: 'soundhound2018_summer_qual_d', + contest_id: 'soundhound2018-summer-qual', + problem_index: 'D', + name: 'Saving Snuuk', + title: 'D. Saving Snuuk', + }, + { + id: 'soundhound2018_summer_qual_c', + contest_id: 'soundhound2018-summer-qual', + problem_index: 'C', + name: 'Ordinary Beauty', + title: 'C. Ordinary Beauty', + }, + { + id: 'soundhound2018_summer_qual_b', + contest_id: 'soundhound2018-summer-qual', + problem_index: 'B', + name: 'Acrostic', + title: 'B. Acrostic', + }, + { + id: 'soundhound2018_summer_qual_a', + contest_id: 'soundhound2018-summer-qual', + problem_index: 'A', + name: 'F', + title: 'A. F', + }, + { + id: 'tenka1_2018_b', + contest_id: 'tenka1-2018-beginner', + problem_index: 'B', + name: 'Exchange', + title: 'B. Exchange', + }, + { + id: 'tenka1_2018_a', + contest_id: 'tenka1-2018-beginner', + problem_index: 'A', + name: 'Measure', + title: 'A. Measure', + }, + { + id: 'tenka1_2017_b', + contest_id: 'tenka1-2017-beginner', + problem_index: 'B', + name: 'Different Distribution', + title: 'B. Different Distribution', + }, + { + id: 'tenka1_2017_a', + contest_id: 'tenka1-2017-beginner', + problem_index: 'A', + name: 'Accepted...?', + title: 'A. Accepted...?', + }, + { + id: 'tenka1_2019_f', + contest_id: 'tenka1-2019', + problem_index: 'F', + name: 'Banned X', + title: 'F. Banned X', + }, + { + id: 'tenka1_2019_e', + contest_id: 'tenka1-2019', + problem_index: 'E', + name: 'Polynomial Divisors', + title: 'E. Polynomial Divisors', + }, + { + id: 'tenka1_2019_d', + contest_id: 'tenka1-2019', + problem_index: 'D', + name: 'Three Colors', + title: 'D. Three Colors', + }, + { + id: 'tenka1_2019_c', + contest_id: 'tenka1-2019', + problem_index: 'C', + name: 'Stones', + title: 'C. Stones', + }, + { + id: 'caddi2018_d', + contest_id: 'caddi2018', + problem_index: 'F', + name: 'Square', + title: 'F. Square', + }, + { + id: 'caddi2018_c', + contest_id: 'caddi2018', + problem_index: 'E', + name: 'Negative Doubling', + title: 'E. Negative Doubling', + }, + { + id: 'tenka1_2018_f', + contest_id: 'tenka1-2018', + problem_index: 'F', + name: 'Circular', + title: 'F. Circular', + }, + { + id: 'tenka1_2018_e', + contest_id: 'tenka1-2018', + problem_index: 'E', + name: 'Equilateral', + title: 'E. Equilateral', + }, + { + id: 'tenka1_2018_d', + contest_id: 'tenka1-2018', + problem_index: 'D', + name: 'Crossing', + title: 'D. Crossing', + }, + { + id: 'tenka1_2018_c', + contest_id: 'tenka1-2018', + problem_index: 'C', + name: 'Align', + title: 'C. Align', + }, + { + id: 'tenka1_2017_f', + contest_id: 'tenka1-2017', + problem_index: 'F', + name: 'ModularPowerEquation!!', + title: 'F. ModularPowerEquation!!', + }, + { + id: 'tenka1_2017_e', + contest_id: 'tenka1-2017', + problem_index: 'E', + name: 'CARtesian Coodinate', + title: 'E. CARtesian Coodinate', + }, + { + id: 'tenka1_2017_d', + contest_id: 'tenka1-2017', + problem_index: 'D', + name: 'IntegerotS', + title: 'D. IntegerotS', + }, + { + id: 'tenka1_2017_c', + contest_id: 'tenka1-2017', + problem_index: 'C', + name: '4/N', + title: 'C. 4/N', }, { id: 'fps_24_x', diff --git a/src/lib/utils/contest.ts b/src/lib/utils/contest.ts index 52710bf6d..26c239e1f 100644 --- a/src/lib/utils/contest.ts +++ b/src/lib/utils/contest.ts @@ -118,7 +118,10 @@ const ABC_LIKE: ContestPrefix = { const abcLikePrefixes = new Set(getContestPrefixes(ABC_LIKE)); const ARC_LIKE: ContestPrefix = { + 'tenka1-2017': 'Tenka1 Programmer Contest 2017', 'tenka1-2018': 'Tenka1 Programmer Contest 2018', + 'tenka1-2019': 'Tenka1 Programmer Contest 2019', + caddi2018: 'CADDi 2018', 'dwacon5th-prelims': '第5回 ドワンゴからの挑戦状 予選', 'dwacon6th-prelims': '第6回 ドワンゴからの挑戦状 予選', diverta2019: 'diverta 2019 Programming Contest', diff --git a/src/lib/utils/contest_table_provider.ts b/src/lib/utils/contest_table_provider.ts index c3f6c191e..a868325de 100644 --- a/src/lib/utils/contest_table_provider.ts +++ b/src/lib/utils/contest_table_provider.ts @@ -465,6 +465,35 @@ export class AGC001OnwardsProvider extends ContestTableProviderBase { } } +export class ABCLikeProvider extends ContestTableProviderBase { + protected setFilterCondition(): (taskResult: TaskResult) => boolean { + return (taskResult: TaskResult) => { + return classifyContest(taskResult.contest_id) === this.contestType; + }; + } + + getMetadata(): ContestTableMetaData { + return { + title: 'ABC-Like Contest', + abbreviationName: 'abcLike', + }; + } + + getDisplayConfig(): ContestTableDisplayConfig { + return { + isShownHeader: true, + isShownRoundLabel: true, + roundLabelWidth: 'xl:w-28', + tableBodyCellsWidth: 'w-1/2 xs:w-1/3 sm:w-1/4 md:w-1/5 lg:w-1/6 px-1 py-1', + isShownTaskIndex: false, + }; + } + + getContestRoundLabel(contestId: string): string { + return contestId.toUpperCase(); + } +} + function parseContestRound(contestId: string, prefix: string): number { const withoutPrefix = contestId.replace(prefix, ''); @@ -1122,6 +1151,15 @@ export const prepareContestProviderPresets = () => { ariaLabel: 'Filter contests from AGC 001 onwards', }).addProvider(new AGC001OnwardsProvider(ContestType.AGC)), + /** + * Single group for ABC-Like Contests + */ + ABCLike: () => + new ContestTableProviderGroup('ABC-Like', { + buttonLabel: 'ABC-Like', + ariaLabel: 'Filter contests from ABC-Like', + }).addProvider(new ABCLikeProvider(ContestType.ABC_LIKE)), + /** * Single group for Typical 90 Problems */ @@ -1204,6 +1242,7 @@ export const contestTableProviderGroups = { arc104Onwards: prepareContestProviderPresets().ARC104Onwards(), fromArc058ToArc103: prepareContestProviderPresets().ARC058ToARC103(), agc001Onwards: prepareContestProviderPresets().AGC001Onwards(), + abcLike: prepareContestProviderPresets().ABCLike(), fromAbc001ToAbc041: prepareContestProviderPresets().ABC001ToABC041(), fromArc001ToArc057: prepareContestProviderPresets().ARC001ToARC057(), typical90: prepareContestProviderPresets().Typical90(), diff --git a/src/test/lib/services/task_results.test.ts b/src/test/lib/services/task_results.test.ts index 79f97b654..6f8077068 100644 --- a/src/test/lib/services/task_results.test.ts +++ b/src/test/lib/services/task_results.test.ts @@ -116,6 +116,187 @@ vi.mock('$lib/services/tasks', () => { title: 'How Many Guests?', grade: 'Q4' as const, }, + // ABC-Like Provider test data + { + id: '5', + contest_id: 'tenka1-2017-beginner', + task_id: 'tenka1_2017_a', + contest_type: 'ABC_LIKE' as const, + task_table_index: 'A', + title: 'Accepted...?', + grade: 'Q7' as const, + }, + { + id: '6', + contest_id: 'tenka1-2017-beginner', + task_id: 'tenka1_2017_c', + contest_type: 'ABC_LIKE' as const, + task_table_index: 'C', + title: '4/N', + grade: 'Q5' as const, + }, + { + id: '7', + contest_id: 'tenka1-2017', + task_id: 'tenka1_2017_c', + contest_type: 'ARC_LIKE' as const, + task_table_index: 'C', + title: '4/N', + grade: 'Q5' as const, + }, + { + id: '8', + contest_id: 'abl', + task_id: 'abl_a', + contest_type: 'ABC_LIKE' as const, + task_table_index: 'A', + title: 'Repeat ACL', + grade: 'Q7' as const, + }, + { + id: '9', + contest_id: 'abl', + task_id: 'abl_f', + contest_type: 'ABC_LIKE' as const, + task_table_index: 'F', + title: 'Heights and Pairs', + grade: 'D4' as const, + }, + { + id: '10', + contest_id: 'caddi2018b', + task_id: 'caddi2018b_a', + contest_type: 'ABC_LIKE' as const, + task_table_index: 'A', + title: '12/22', + grade: 'Q7' as const, + }, + { + id: '11', + contest_id: 'soundhound2018-summer-qual', + task_id: 'soundhound2018_summer_qual_a', + contest_type: 'ABC_LIKE' as const, + task_table_index: 'A', + title: 'F', + grade: 'Q7' as const, + }, + { + id: '12', + contest_id: 'aising2019', + task_id: 'aising2019_a', + contest_type: 'ABC_LIKE' as const, + task_table_index: 'A', + title: 'Bulletin Board', + grade: 'Q7' as const, + }, + { + id: '13', + contest_id: 'aising2019', + task_id: 'aising2019_e', + contest_type: 'ABC_LIKE' as const, + task_table_index: 'E', + title: 'Attack to a Tree', + grade: 'D2' as const, + }, + { + id: '14', + contest_id: 'sumitrust2019', + task_id: 'sumitrust2019_a', + contest_type: 'ABC_LIKE' as const, + task_table_index: 'A', + title: 'November 30', + grade: 'Q7' as const, + }, + { + id: '15', + contest_id: 'aising2020', + task_id: 'aising2020_a', + contest_type: 'ABC_LIKE' as const, + task_table_index: 'A', + title: 'Number of Multiples', + grade: 'Q7' as const, + }, + { + id: '16', + contest_id: 'aising2020', + task_id: 'aising2020_f', + contest_type: 'ABC_LIKE' as const, + task_table_index: 'F', + title: 'Two Snuke', + grade: 'Q1' as const, + }, + { + id: '17', + contest_id: 'hhkb2020', + task_id: 'hhkb2020_a', + contest_type: 'ABC_LIKE' as const, + task_table_index: 'A', + title: 'Keyboard', + grade: 'Q7' as const, + }, + { + id: '18', + contest_id: 'm-solutions2020', + task_id: 'm_solutions2020_a', + contest_type: 'ABC_LIKE' as const, + task_table_index: 'A', + title: 'Kyu in AtCoder', + grade: 'Q7' as const, + }, + { + id: '19', + contest_id: 'panasonic2020', + task_id: 'panasonic2020_a', + contest_type: 'ABC_LIKE' as const, + task_table_index: 'A', + title: 'Kth Term', + grade: 'Q8' as const, + }, + { + id: '20', + contest_id: 'jsc2021', + task_id: 'jsc2021_a', + contest_type: 'ABC_LIKE' as const, + task_table_index: 'A', + title: 'Competition', + grade: 'Q7' as const, + }, + { + id: '21', + contest_id: 'jsc2021', + task_id: 'jsc2021_h', + contest_type: 'ABC_LIKE' as const, + task_table_index: 'H', + title: 'Shipping', + grade: 'Q0' as const, + }, + { + id: '22', + contest_id: 'zone2021', + task_id: 'zone2021_a', + contest_type: 'ABC_LIKE' as const, + task_table_index: 'A', + title: 'UFO Invasion', + grade: 'Q7' as const, + }, + { + id: '23', + contest_id: 'jsc2025advance-final', + task_id: 'abc422_a', + contest_type: 'ABC_LIKE' as const, + task_table_index: 'A', + title: 'Stage Clear', + grade: 'Q7' as const, + }, + { + id: '24', + contest_id: 'jsc2025advance-final', + task_id: 'abc422_g', + contest_type: 'ABC_LIKE' as const, + task_table_index: 'G', + title: 'Balls and Boxes', + grade: 'D3' as const, + }, ]; const mockTasksMap = new Map( diff --git a/src/test/lib/utils/contest_table_provider.test.ts b/src/test/lib/utils/contest_table_provider.test.ts index 93b66ff4e..7d7c0a6e7 100644 --- a/src/test/lib/utils/contest_table_provider.test.ts +++ b/src/test/lib/utils/contest_table_provider.test.ts @@ -14,6 +14,7 @@ import { ARC058ToARC103Provider, ARC001ToARC057Provider, AGC001OnwardsProvider, + ABCLikeProvider, ACLPracticeProvider, EDPCProvider, TDPCProvider, @@ -39,6 +40,7 @@ import { taskResultsForARC104OnwardsProvider, taskResultsForAGC001OnwardsProvider, taskResultsForACLPracticeProvider, + taskResultsForABCLikeProvider, } from './test_cases/contest_table_provider'; // Mock the imported functions @@ -68,6 +70,26 @@ vi.mock('$lib/utils/contest', () => ({ return ContestType.MATH_AND_ALGORITHM; } else if (contestId === 'practice2') { return ContestType.ACL_PRACTICE; + } else if ( + [ + 'tenka1-2017-beginner', + 'tenka1-2018-beginner', + 'tenka1-2019-beginner', + 'abl', + 'caddi2018b', + 'soundhound2018-summer-qual', + 'aising2019', + 'aising2020', + 'sumitrust2019', + 'hhkb2020', + 'm-solutions2020', + 'panasonic2020', + 'jsc2021', + 'zone2021', + 'jsc2025advance-final', + ].includes(contestId) + ) { + return ContestType.ABC_LIKE; } return ContestType.OTHERS; @@ -113,6 +135,36 @@ vi.mock('$lib/utils/contest', () => ({ const [, year] = finalMatch; return `JOI 本選 ${year}`; } + } else if (contestId === 'abl') { + return 'ACL Beginner Contest'; + } else if (contestId === 'tenka1-2017-beginner') { + return 'Tenka1 2017 Beginner'; + } else if (contestId === 'tenka1-2018-beginner') { + return 'Tenka1 2018 Beginner'; + } else if (contestId === 'tenka1-2019-beginner') { + return 'Tenka1 2019 Beginner'; + } else if (contestId === 'caddi2018b') { + return 'CADDi 2018 for Beginners'; + } else if (contestId === 'soundhound2018-summer-qual') { + return 'SoundHound 2018'; + } else if (contestId === 'aising2019') { + return 'Aising 2019'; + } else if (contestId === 'aising2020') { + return 'Aising 2020'; + } else if (contestId === 'sumitrust2019') { + return 'Sumitomo Mitsui Trust Bank Programming Contest 2019'; + } else if (contestId === 'hhkb2020') { + return 'HHKB Programming Contest 2020'; + } else if (contestId === 'm-solutions2020') { + return 'M-SOLUTIONS Programming Contest 2020'; + } else if (contestId === 'panasonic2020') { + return 'Panasonic Programming Contest 2020'; + } else if (contestId === 'jsc2021') { + return 'Japanese Student Championship 2021'; + } else if (contestId === 'zone2021') { + return 'ZONe Energy Programming Contest 2021'; + } else if (contestId === 'jsc2025advance-final') { + return '日本最強プログラマー学生選手権~Advance~'; } return contestId; @@ -1405,6 +1457,159 @@ describe('ContestTableProviderBase and implementations', () => { }); }); + describe('ABC-Like Provider', () => { + test('expects to filter tasks to include only ABC-Like contests', () => { + const provider = new ABCLikeProvider(ContestType.ABC_LIKE); + const filtered = provider.filter(taskResultsForABCLikeProvider); + + expect(filtered.length).toBeGreaterThan(0); + expect( + filtered.every((task) => { + const contestId = task.contest_id; + + return [ + 'tenka1-2017-beginner', + 'tenka1-2018-beginner', + 'tenka1-2019-beginner', + 'abl', + 'caddi2018b', + 'soundhound2018-summer-qual', + 'aising2019', + 'aising2020', + 'sumitrust2019', + 'hhkb2020', + 'm-solutions2020', + 'panasonic2020', + 'jsc2021', + 'zone2021', + 'jsc2025advance-final', + ].includes(contestId); + }), + ).toBe(true); + }); + + test('expects to get correct metadata', () => { + const provider = new ABCLikeProvider(ContestType.ABC_LIKE); + const metadata = provider.getMetadata(); + + expect(metadata.title).toBe('ABC-Like Contest'); + expect(metadata.abbreviationName).toBe('abcLike'); + }); + + test('expects to get correct display configuration', () => { + const provider = new ABCLikeProvider(ContestType.ABC_LIKE); + const displayConfig = provider.getDisplayConfig(); + + 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 format contest ID as round label', () => { + const provider = new ABCLikeProvider(ContestType.ABC_LIKE); + const label = provider.getContestRoundLabel('abl'); + + expect(label).toBe('ABL'); + }); + + test('expects to generate table with multiple contests', () => { + const provider = new ABCLikeProvider(ContestType.ABC_LIKE); + const filtered = provider.filter(taskResultsForABCLikeProvider); + const table = provider.generateTable(filtered); + + expect(Object.keys(table).length).toBeGreaterThan(0); + expect(table).toHaveProperty('abl'); + expect(table).toHaveProperty('jsc2021'); + expect(table).toHaveProperty('jsc2025advance-final'); + }); + + test('expects to handle shared task references (jsc2025advance-final → ABC422)', () => { + const provider = new ABCLikeProvider(ContestType.ABC_LIKE); + const filtered = provider.filter(taskResultsForABCLikeProvider); + + const jsc2025Tasks = filtered.filter((task) => task.contest_id === 'jsc2025advance-final'); + expect(jsc2025Tasks.length).toBeGreaterThan(0); + expect(jsc2025Tasks.some((task) => task.task_id === 'abc422_a')).toBe(true); + expect(jsc2025Tasks.some((task) => task.task_id === 'abc422_h')).toBe(true); + }); + + test('expects to handle shared problem patterns (tenka1-201x-beginner → tenka1-201x)', () => { + const provider = new ABCLikeProvider(ContestType.ABC_LIKE); + const filtered = provider.filter(taskResultsForABCLikeProvider); + + const tenka2017Beginner = filtered.filter( + (task) => task.contest_id === 'tenka1-2017-beginner', + ); + const tenka2017 = filtered.filter((task) => task.contest_id === 'tenka1-2017'); + + expect(tenka2017Beginner.length).toBeGreaterThan(0); + expect(tenka2017.length).toBe(0); + + // Check that both have A,B (from beginner) and C (shared from tenka1) + expect(tenka2017Beginner.some((task) => task.task_table_index === 'A')).toBe(true); + expect(tenka2017Beginner.some((task) => task.task_table_index === 'B')).toBe(true); + expect(tenka2017Beginner.some((task) => task.task_table_index === 'C')).toBe(true); + expect(tenka2017.some((task) => task.task_table_index === 'C')).toBe(false); + }); + + test('expects to handle contests with different problem ranges (A-F, A-H)', () => { + const provider = new ABCLikeProvider(ContestType.ABC_LIKE); + const filtered = provider.filter(taskResultsForABCLikeProvider); + + const ablTasks = filtered.filter((task) => task.contest_id === 'abl'); + const jsc2021Tasks = filtered.filter((task) => task.contest_id === 'jsc2021'); + + // ABL: A-F + expect(ablTasks.some((task) => task.task_table_index === 'F')).toBe(true); + // JSC2021: A-H + expect(jsc2021Tasks.some((task) => task.task_table_index === 'H')).toBe(true); + }); + + test('expects to filter out non-ABC-Like contests', () => { + const provider = new ABCLikeProvider(ContestType.ABC_LIKE); + const mixedTasks = [ + ...taskResultsForABCLikeProvider, + { contest_id: 'abc212', task_id: 'abc212_a', task_table_index: 'A' }, + { contest_id: 'arc100', task_id: 'arc100_a', task_table_index: 'A' }, + { contest_id: 'agc001', task_id: 'agc001_a', task_table_index: 'A' }, + ]; + const filtered = provider.filter(mixedTasks as TaskResults); + + expect( + filtered.every((task) => + [ + 'tenka1-2017-beginner', + 'tenka1-2018-beginner', + 'tenka1-2019-beginner', + 'abl', + 'caddi2018b', + 'soundhound2018-summer-qual', + 'aising2019', + 'aising2020', + 'sumitrust2019', + 'hhkb2020', + 'm-solutions2020', + 'panasonic2020', + 'jsc2021', + 'zone2021', + 'jsc2025advance-final', + ].includes(task.contest_id), + ), + ).toBe(true); + expect(filtered).not.toContainEqual(expect.objectContaining({ contest_id: 'abc212' })); + expect(filtered).not.toContainEqual(expect.objectContaining({ contest_id: 'arc100' })); + expect(filtered).not.toContainEqual(expect.objectContaining({ contest_id: 'agc001' })); + }); + + test('expects to handle empty task results', () => { + const provider = new ABCLikeProvider(ContestType.ABC_LIKE); + const filtered = provider.filter([] as TaskResults); + + expect(filtered).toEqual([] as TaskResults); + }); + }); + describe('Typical90 provider', () => { test('expects to filter tasks to include only typical90 contest', () => { const provider = new Typical90Provider(ContestType.TYPICAL90); 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 33dd0da1d..69ca0ac6e 100644 --- a/src/test/lib/utils/test_cases/contest_table_provider.ts +++ b/src/test/lib/utils/test_cases/contest_table_provider.ts @@ -707,3 +707,110 @@ export const taskResultsForACLPracticeProvider: TaskResults = [ practice2_k, practice2_l, ]; + +// ABC-Like Contests: Multiple contest types combined +const [tenka1_2017_beginner_a, tenka1_2017_beginner_b, tenka1_2017_beginner_c] = createContestTasks( + 'tenka1-2017-beginner', + [ + { taskTableIndex: 'A', statusName: AC }, + { taskTableIndex: 'B', statusName: AC_WITH_EDITORIAL }, + { taskId: 'tenka1_2017_c', taskTableIndex: 'C', statusName: TRYING }, + ], +); +const [tenka1_2017_c] = createContestTasks('tenka1-2017', [ + { taskId: 'tenka1_2017_c', taskTableIndex: 'C', statusName: AC }, +]); + +const [abl_a, abl_b, abl_f] = createContestTasks('abl', [ + { taskTableIndex: 'A', statusName: AC }, + { taskTableIndex: 'B', statusName: TRYING }, + { taskTableIndex: 'F', statusName: PENDING }, +]); + +const [caddi2018b_a, caddi2018b_d] = createContestTasks('caddi2018b', [ + { taskTableIndex: 'A', statusName: AC }, + { taskTableIndex: 'D', statusName: AC_WITH_EDITORIAL }, +]); + +const [soundhound2018_a] = createContestTasks('soundhound2018-summer-qual', [ + { taskTableIndex: 'A', statusName: AC }, +]); + +const [aising2019_a, aising2019_e] = createContestTasks('aising2019', [ + { taskTableIndex: 'A', statusName: AC }, + { taskTableIndex: 'E', statusName: TRYING }, +]); + +const [aising2020_a, aising2020_f] = createContestTasks('aising2020', [ + { taskTableIndex: 'A', statusName: AC }, + { taskTableIndex: 'F', statusName: PENDING }, +]); + +const [hhkb2020_a] = createContestTasks('hhkb2020', [{ taskTableIndex: 'A', statusName: AC }]); + +const [m_solutions2020_a] = createContestTasks('m-solutions2020', [ + { taskTableIndex: 'A', statusName: AC }, +]); + +const [panasonic2020_a] = createContestTasks('panasonic2020', [ + { taskTableIndex: 'A', statusName: AC }, +]); + +const [jsc2021_a, jsc2021_h] = createContestTasks('jsc2021', [ + { taskTableIndex: 'A', statusName: AC }, + { taskTableIndex: 'H', statusName: TRYING }, +]); + +const [zone2021_a] = createContestTasks('zone2021', [{ taskTableIndex: 'A', statusName: AC }]); + +const [sumitrust2019_a] = createContestTasks('sumitrust2019', [ + { taskTableIndex: 'A', statusName: AC }, +]); + +// jsc2025advance-final references ABC422 task_id +const [jsc2025advance_final_a, jsc2025advance_final_h] = createContestTasks( + 'jsc2025advance-final', + [ + { taskId: 'abc422_a', taskTableIndex: 'A', statusName: AC }, + { taskId: 'abc422_h', taskTableIndex: 'H', statusName: PENDING }, + ], +); + +export const taskResultsForABCLikeProvider: TaskResults = [ + // tenka1-2017-beginner (shared C with tenka1-2017) + tenka1_2017_beginner_a, + tenka1_2017_beginner_b, + tenka1_2017_beginner_c, + tenka1_2017_c, + // ABL (6 problems: A-F) + abl_a, + abl_b, + abl_f, + // CADDI2018B (4 problems: A-D) + caddi2018b_a, + caddi2018b_d, + // SoundHound2018 (5 problems: A-E) + soundhound2018_a, + // Aising2019 (5 problems: A-E) + aising2019_a, + aising2019_e, + // Aising2020 (6 problems: A-F) + aising2020_a, + aising2020_f, + // HHKB2020 (6 problems: A-F) + hhkb2020_a, + // M-Solutions2020 (6 problems: A-F) + m_solutions2020_a, + // Panasonic2020 (6 problems: A-F) + panasonic2020_a, + // JSC2021 (8 problems: A-H) + jsc2021_a, + jsc2021_h, + // Zone2021 (6 problems: A-F) + zone2021_a, + // Sumitrust2019 (6 problems: A-F) + sumitrust2019_a, + // JSC2025Advance-Final (references ABC422 task_id) + jsc2025advance_final_a, + jsc2025advance_final_h, +]; diff --git a/src/test/lib/utils/test_cases/contest_type.ts b/src/test/lib/utils/test_cases/contest_type.ts index fdf6cefe3..5a48f56be 100644 --- a/src/test/lib/utils/test_cases/contest_type.ts +++ b/src/test/lib/utils/test_cases/contest_type.ts @@ -257,10 +257,22 @@ export const abcLike = [ ]; export const arcLike = [ + createTestCaseForContestType('Tenka1 2017')({ + contestId: 'tenka1-2017', + expected: ContestType.ARC_LIKE, + }), createTestCaseForContestType('Tenka1 2018')({ contestId: 'tenka1-2018', expected: ContestType.ARC_LIKE, }), + createTestCaseForContestType('Tenka1 2019')({ + contestId: 'tenka1-2019', + expected: ContestType.ARC_LIKE, + }), + createTestCaseForContestType('CADDi 2018')({ + contestId: 'caddi2018', + expected: ContestType.ARC_LIKE, + }), createTestCaseForContestType('DWACON 5TH PRELIMS')({ contestId: 'dwacon5th-prelims', expected: ContestType.ARC_LIKE,