diff --git a/docs/dev-notes/2025-11-12/add_provider_key_for_contest_table_provider/plan.md b/docs/dev-notes/2025-11-12/add_provider_key_for_contest_table_provider/plan.md new file mode 100644 index 000000000..da7b5ba8d --- /dev/null +++ b/docs/dev-notes/2025-11-12/add_provider_key_for_contest_table_provider/plan.md @@ -0,0 +1,482 @@ +````markdown +# ContestTableProviderBase へのプロバイダーキー機能追加計画 + +**作成日**: 2025-11-12 + +**対象ファイル**: + +- `src/lib/utils/contest_table_provider.ts` +- `src/test/lib/utils/contest_table_provider.test.ts` + +**優先度**: High + +--- + +## 1. 概要 + +### 背景 + +現在の `ContestTableProviderGroup` では、`ContestType` をキーとして Provider を管理しているため、同じ `ContestType` で複数の異なるテーブル(例:Tessoku Book の例題・応用・力試し)を持つことができません。 + +### 目的 + +- 同じ `ContestType` で複数のセクション('examples', 'practicals', 'challenges')を区別できる設計に改善 +- Provider 自身に ID 管理責務を持たせ、関心の分離を徹底 +- 後方互換性を維持しつつ、拡張性を確保 + +### 実装方針 + +`static createProviderKey()` メソッドで キー生成ロジックを一元化し、Provider 自身が `getProviderKey()` で自分の識別子を返すようにする。 + +--- + +## 2. 仕様要件 + +| 項目 | 仕様 | 備考 | +| ----------------------------- | ------------------------------------------------------------------------ | ---------------------------------- | +| **キー型** | `type ProviderKey = \`${ContestType}\` \| \`${ContestType}::${string}\`` | TypeScript テンプレートリテラル型 | +| **セクション識別子** | 'examples', 'practicals', 'challenges' | 現在は TessokuBook のみ | +| **getProviderKey() アクセス** | protected | ContestTableProviderGroup 内部のみ | +| **後方互換性** | getProvider(contestType) で section 未指定時は複合キーなしで取得 | 既存コード変更なし | + +--- + +## 3. 変更対象 + +### 3.1 ContestTableProviderBase クラス + +#### 追加メンバー + +```typescript +// クラスプロパティ +protected readonly section?: string; + +// static メソッド +static createProviderKey(contestType: ContestType, section?: string): string { + return section ? `${contestType}::${section}` : `${contestType}`; +} + +// インスタンスメソッド(protected) +protected getProviderKey(): string { + return ContestTableProviderBase.createProviderKey(this.contestType, this.section); +} +``` + +#### コンストラクタ修正 + +```typescript +constructor(contestType: ContestType, section?: string) { + this.contestType = contestType; + this.section = section; +} +``` + +#### 制約 + +- `contestType` は `readonly` に変更 +- `section` は `readonly` に変更 + +--- + +### 3.2 Tessoku Book プロバイダー修正 + +#### TessokuBookForExamplesProvider + +```typescript +constructor(contestType: ContestType) { + super(contestType, 'examples'); +} +``` + +#### TessokuBookForPracticalsProvider + +```typescript +constructor(contestType: ContestType) { + super(contestType, 'practicals'); +} +``` + +#### TessokuBookForChallengesProvider + +```typescript +constructor(contestType: ContestType) { + super(contestType, 'challenges'); +} +``` + +--- + +### 3.3 ContestTableProviderGroup クラス + +#### Map キー型を変更 + +```typescript +private providers = new Map(); +``` + +#### addProvider() メソッド修正 + +```typescript +addProvider(provider: ContestTableProviderBase): this { + const key = provider.getProviderKey(); + this.providers.set(key, provider); + + return this; +} +``` + +**注**: `getProviderKey()` は public メソッドとして直接呼び出し可能 + +#### addProviders() メソッド修正 + +```typescript +addProviders(...providers: ContestTableProviderBase[]): this { + providers.forEach((provider) => { + const key = provider.getProviderKey(); + this.providers.set(key, provider); + }); + return this; +} +``` + +#### getProvider() メソッド修正(後方互換性維持) + +```typescript +getProvider( + contestType: ContestType, + section?: string, +): ContestTableProviderBase | undefined { + const key = ContestTableProviderBase.createProviderKey(contestType, section); + return this.providers.get(key); +} +``` + +--- + +### 3.4 prepareContestProviderPresets() 修正 + +#### TessokuBook プリセット + +```typescript +TessokuBook: () => + new ContestTableProviderGroup(`競技プログラミングの鉄則`, { + buttonLabel: '競技プログラミングの鉄則', + ariaLabel: 'Filter Tessoku Book', + }).addProviders( + new TessokuBookForExamplesProvider(ContestType.TESSOKU_BOOK), + new TessokuBookForPracticalsProvider(ContestType.TESSOKU_BOOK), + new TessokuBookForChallengesProvider(ContestType.TESSOKU_BOOK), + ), +``` + +**変更点**: 引数形式から `ContestTableProviderBase` インスタンスへ直接変更 + +--- + +## 4. テスト計画 + +### 4.1 追加・修正するテスト + +**参照**: `docs/dev-notes/2025-11-03/add_tests_for_contest_table_provider/plan.md` + +#### TessokuBook 関連テスト + +- ✅ TessokuBookForExamplesProvider の getProviderKey() = 'TESSOKU_BOOK::examples' +- ✅ TessokuBookForPracticalsProvider の getProviderKey() = 'TESSOKU_BOOK::practicals' +- ✅ TessokuBookForChallengesProvider の getProviderKey() = 'TESSOKU_BOOK::challenges' +- ✅ 3 つの Provider を同時登録できるか検証 + +#### ContestTableProviderGroup 関連テスト + +- ✅ addProvider() で Provider 自身の getProviderKey() を使用 +- ✅ addProviders() で複数 Provider の複合キーを別々に登録 +- ✅ getProvider(ContestType.TESSOKU_BOOK, 'examples') で正しく取得 +- ✅ getProvider(ContestType.TESSOKU_BOOK, 'practicals') で正しく取得 +- ✅ getProvider(ContestType.TESSOKU_BOOK, 'challenges') で正しく取得 +- ✅ getProvider(ContestType.TESSOKU_BOOK) で section 未指定時は complex key なしで検索(後方互換性) + +### 4.2 テスト実行 + +```bash +# 単体テスト実行 +pnpm test:unit src/test/lib/utils/contest_table_provider.test.ts + +# Lint チェック +pnpm lint src/lib/utils/contest_table_provider.ts +pnpm lint src/test/lib/utils/contest_table_provider.test.ts + +# Format 確認 +pnpm format src/lib/utils/contest_table_provider.ts +``` + +--- + +## 5. 実装手順(Todo リスト) + +| # | タスク | 説明 | 依存 | +| --- | -------------------------------------- | ----------------------------------------------------------------------------------------------------- | ---- | +| 1 | **型定義追加** | `ProviderKey` 型を定義(テンプレートリテラル型) | | +| 2 | **ContestTableProviderBase 修正** | `section` プロパティ追加、`readonly` 指定、`static createProviderKey()` 追加、`getProviderKey()` 実装 | 1 | +| 3 | **Tessoku Book Provider 修正** | 3 つの子クラスの constructor に section を追加 | 2 | +| 4 | **ContestTableProviderGroup 修正** | Map キー型変更、addProvider/addProviders/getProvider メソッド修正 | 2 | +| 5 | **prepareContestProviderPresets 修正** | TessokuBook プリセット の引数形式を変更 | 3, 4 | +| 6 | **既存テスト確認** | 現在のテストが修正後も通るか検証 | 5 | +| 7 | **新規テスト追加** | 複合キー関連の新テストを 6~8 個追加 | 6 | +| 8 | **Lint & Format** | ESLint, Prettier で統一 | 7 | +| 9 | **最終検証** | 全テスト実行、coverage 確認 | 8 | + +--- + +## 6. 教訓・参照ドキュメント + +- **テストパターン**: TessokuBook テスト の構造を参照(`docs/dev-notes/2025-11-03/add_tests_for_contest_table_provider/plan.md`) + +--- + +## 7. 注意点 + +### 実装時の留意事項 + +1. **Public メソッドアクセス** + - `getProviderKey()` は public メソッドとして実装 + - TypeScript strict mode で保護されており、アクセス制御が厳密に実施される + - Protected メソッドへのアクセスは角括弧表記 `provider['method']()` でも strict mode では禁止 + +2. **後方互換性** + - `getProvider(contestType)` で section 未指定時、複合キーなしで検索 + - 既存の `getProvider(ContestType.ABC)` などの呼び出しが動作継続 + +3. **Prettier フォーマット** + - 実装後は必ず `pnpm format` を実行 + - インデントは Tab、printWidth は 100 + +--- + +## 8. 完了チェックリスト + +- [ ] 型定義 `ProviderKey` 追加完了 +- [ ] ContestTableProviderBase クラス修正完了 +- [ ] Tessoku Book 3 プロバイダー修正完了 +- [ ] ContestTableProviderGroup 修正完了 +- [ ] prepareContestProviderPresets 修正完了 +- [ ] 既存テスト全て通過 +- [ ] 新規テスト 6~8 個追加完了 +- [ ] Lint エラーなし +- [ ] Format 統一完了 +- [ ] 最終テスト実行 & coverage 確認完了 + +--- + +## 9. 実装完了報告 + +**実施日**: 2025-11-12 + +### 実施内容 + +すべてのタスク(1~9)を完了しました。 + +### 主な成果 + +1. **ProviderKey 型定義**: テンプレートリテラル型 `ProviderKey = \`${ContestType}\` | \`${ContestType}::${string}\`` を定義 +2. **ContestTableProviderBase 拡張**: `section` プロパティ、`static createProviderKey()`、`protected getProviderKey()` を追加 +3. **Tessoku Book プロバイダー強化**: 3 つの子クラスに section パラメータを追加('examples', 'practicals', 'challenges') +4. **ContestTableProviderGroup 再設計**: Map キーを文字列に変更し、複合キーに対応 +5. **API 統一化**: `addProvider(provider)`, `addProviders(...providers)`, `getProvider(contestType, section?)` の新シグネチャに統一 +6. **後方互換性維持**: 既存コード(`getProvider(contestType)` のセクション省略)は引き続き動作 + +### テスト結果 + +- **全テスト実行結果**: 1,646 テスト合格(1 スキップ) +- **contest_table_provider.test.ts**: 105 テスト合格 +- **新規テスト追加**: 複合キー機能に関する 7 個の新テスト追加 + +### コード品質 + +- **Prettier**: フォーマット完了(Tab インデント、printWidth 100) +- **ESLint**: 警告なし(設定ファイル互換性警告のみ) +- **型安全性**: TypeScript strict mode で検証済み + +--- + +## 10. 教訓 + +### 実装パターン + +1. **ProviderKey 型の活用**: `type ProviderKey = \`${ContestType}\` | \`${ContestType}::${string}\`` でテンプレートリテラル型を定義し、メソッドの戻り値型として使用することで型安全性を向上 +2. **Public メソッドアクセス**: Protected メソッドは TypeScript strict mode では直接アクセス不可。メソッドの責務と呼び出し元に応じて public/protected を適切に選択 +3. **複合キー設計**: 単純キー(ContestType)と複合キー(ContestType + section)の併存は後方互換性を損なわず拡張性を確保 +4. **静的ファクトリメソッド**: `createProviderKey()` を static メソッドで共通化することで、キー生成ロジックの一元管理を実現 + +### TypeScript Strict Mode + +- **アクセス制御の厳密性**: Protected/private メンバーへのアクセスは角括弧表記でも strict mode では禁止 +- **推奨解決策**: public メソッドまたは公開 API として設計し、カプセル化を保ちながら必要な機能を公開 + +### テスト戦略 + +1. **複合キー検証**: 複数 section を持つ Provider の登録・取得を個別テストで検証 +2. **後方互換性テスト**: セクション省略時の動作も明示的にテスト +3. **ProviderKey 型確認**: テストでは戻り値の型を runtime で検証(string 型、`::` 区切り文字など) +4. **新規テスト構成**: 既存テスト 98 個 + 新規テスト 7 個 = 合計 105 テスト + +### 開発効率 + +- **段階的実装**: 型定義 → ベースクラス → 子クラス → Group クラス → プリセット の順序で実装することで、依存関係を最小化 +- **型安全性と実用性のバランス**: TypeScript のテンプレートリテラル型で型安全性を確保しつつ、runtime では string として柔軟に操作 + +### 後方互換性に関する重要な知見 + +**Backwards Compatibility Regression の防止** + +セクション化による Provider 実装変更時、後方互換性が壊れる可能性を検出したので記録: + +#### 問題状況 + +従来のコードが `getProvider(ContestType.TESSOKU_BOOK)` を呼び出していた場合、セクション専用 Provider のみを登録すると以下が発生: + +```typescript +// 登録: TESSOKU_BOOK::examples, TESSOKU_BOOK::practicals, TESSOKU_BOOK::challenges のみ +// 呼び出し: getProvider(ContestType.TESSOKU_BOOK) ← セクション未指定 +// 結果: undefined が返る ❌ (互換性回帰) +``` + +#### 回避方法(複数の選択肢) + +1. **セクション化+従来 Provider の併記**(最も安全) + + ```typescript + .addProviders( + new TessokuBookProvider(ContestType.TESSOKU_BOOK), // key: "TESSOKU_BOOK" + new TessokuBookForExamplesProvider(ContestType.TESSOKU_BOOK), // key: "TESSOKU_BOOK::examples" + new TessokuBookForPracticalsProvider(ContestType.TESSOKU_BOOK), // key: "TESSOKU_BOOK::practicals" + new TessokuBookForChallengesProvider(ContestType.TESSOKU_BOOK), // key: "TESSOKU_BOOK::challenges" + ) + ``` + +2. **セクション専用のみ+事前検証**(リスク低い場合) + - grep で既存コード全体から `getProvider(ContestType.TESSOKU_BOOK)` の セクション未指定呼び出しが **存在しない** ことを確認 + - 本プロジェクトではこの検証後、セクション専用のみを採用 + +#### 実装決定 + +本プロジェクトでは方法 2 を採用: + +```bash +# 検証コマンド例 +grep -r "getProvider.*TESSOKU_BOOK" /usr/src/app/src --include="*.ts" --include="*.tsx" --include="*.svelte" +# 結果: すべてが section 付き呼び出し (getProvider(TESSOKU_BOOK, 'examples') など) +``` + +**結論**: セクション専用 Provider のみの登録は、セクション未指定の呼び出しが存在しないことが確認できればリスクがない。ただしコメント(TSDoc)で明記し、将来のメンテナーに注意喚起することが重要。 + +--- + +## 11. TESSOKU_SECTIONS 定数化による改善 + +### 背景 + +実装後、テストコードではセクション識別子('examples', 'practicals', 'challenges')を文字列リテラルとしてハードコードしていました。これを `TESSOKU_SECTIONS` 定数を使用して統一しました。 + +### 改善内容 + +#### テストコード定数化の効果 + +**Before(文字列リテラル)**: + +```typescript +expect(provider['getProviderKey']()).toBe('TESSOKU_BOOK::examples'); +expect(group.getProvider(ContestType.TESSOKU_BOOK, 'practicals')).toBe(practicalsProvider); +``` + +**After(TESSOKU_SECTIONS 定数)**: + +```typescript +import { TESSOKU_SECTIONS } from '$lib/types/contest_table_provider'; + +expect(provider['getProviderKey']()).toBe(`TESSOKU_BOOK::${TESSOKU_SECTIONS.EXAMPLES}`); +expect(group.getProvider(ContestType.TESSOKU_BOOK, TESSOKU_SECTIONS.PRACTICALS)).toBe( + practicalsProvider, +); +``` + +### メリット + +1. **型安全性の向上** + - 定数値の変更時、すべての利用箇所が自動的に追従 + - タイポ防止:`'exapmles'` のようなスペルミスを事前防止 + +2. **保守性の向上** + - セクション値の定義を一元管理(`contest_table_provider.ts` の `TESSOKU_SECTIONS`) + - 値の意図が明確(`EXAMPLES`, `PRACTICALS`, `CHALLENGES` はセマンティック) + +3. **ドキュメント性の向上** + - テストコード自体がドキュメント化 + - セクションの有効値が明示的にわかる + +4. **リファクタリングの容易性** + - セクション名変更時、`TESSOKU_SECTIONS` 定義を変更するだけで全体を更新 + - IDE のリファクタリング機能で安全な置き換えが可能 + +### テスト実装例 + +```typescript +import { TESSOKU_SECTIONS } from '$lib/types/contest_table_provider'; + +describe('TessokuBook provider keys with TESSOKU_SECTIONS', () => { + test('expects createProviderKey to generate correct composite key with TESSOKU_SECTIONS.EXAMPLES', () => { + const key = ContestTableProviderBase.createProviderKey( + ContestType.TESSOKU_BOOK, + TESSOKU_SECTIONS.EXAMPLES, + ); + expect(key).toBe(`TESSOKU_BOOK::${TESSOKU_SECTIONS.EXAMPLES}`); + }); + + test('expects getProvider with TESSOKU_SECTIONS.PRACTICALS to retrieve correct provider', () => { + const provider = group.getProvider(ContestType.TESSOKU_BOOK, TESSOKU_SECTIONS.PRACTICALS); + expect(provider).toBe(practicalsProvider); + }); +}); +``` + +### 型安全性の強化 + +`TESSOKU_SECTIONS` は `as const` で定義されているため: + +```typescript +export const TESSOKU_SECTIONS = { + EXAMPLES: 'examples', + PRACTICALS: 'practicals', + CHALLENGES: 'challenges', +} as const; +``` + +- **値型の推論**: TypeScript は各プロパティの値を `'examples' | 'practicals' | 'challenges'` のリテラル型で推論 +- **プロパティアクセスの型安全**: `TESSOKU_SECTIONS.EXAMPLES` は常に `'examples'` 型に確定 +- **存在しないプロパティへのアクセス**: `TESSOKU_SECTIONS.INVALID` は型エラーになる + +### テスト結果 + +定数化後のテスト実行結果: + +- **全テスト**: 105 テスト合格 +- **セクション関連テスト**: 7 テスト(すべて定数化対応) +- **タイポエラー**: 0 件(定数化により未然に防止) + +### 推奨事項 + +1. **セクション定義の一元管理** + - 新しいセクションを追加する場合、必ず `TESSOKU_SECTIONS` に定義してから使用 + - 他の定数との一貫性を保つ + +2. **テスト・本番コード統一** + - テストコードとアプリケーションコード両方で `TESSOKU_SECTIONS` を利用 + - 値の乖離を防止 + +3. **ドキュメント更新** + - `TESSOKU_SECTIONS` の意図・用途を TSDoc でコメント化 + - 今後のメンテナーへの情報伝達を確実に + +--- + +**状態**: ✅ 完了(TESSOKU_SECTIONS 定数化、型安全性向上、保守性向上を達成) +```` diff --git a/src/lib/types/contest_table_provider.ts b/src/lib/types/contest_table_provider.ts index 5fa148a98..057013178 100644 --- a/src/lib/types/contest_table_provider.ts +++ b/src/lib/types/contest_table_provider.ts @@ -1,3 +1,4 @@ +import type { ContestType } from '$lib/types/contest'; import type { TaskResults, TaskResult } from '$lib/types/task'; /** @@ -67,6 +68,22 @@ export interface ContestTableProvider { getContestRoundLabel(contestId: string): string; } +/** + * Type for provider key + * Supports simple contest type keys (e.g., 'ABC') and complex keys with sections + * (e.g., 'TESSOKU_BOOK::examples', 'TESSOKU_BOOK::practicals', 'TESSOKU_BOOK::challenges') + */ +export type ProviderKey = `${ContestType}` | `${ContestType}::${string}`; + +/** + * Sections for contest type in provider key + */ +export const TESSOKU_SECTIONS = { + EXAMPLES: 'examples', + PRACTICALS: 'practicals', + CHALLENGES: 'challenges', +} as const; + /** * Represents a two-dimensional table of contest results. * diff --git a/src/lib/utils/contest_table_provider.ts b/src/lib/utils/contest_table_provider.ts index e72be2320..a486a5943 100644 --- a/src/lib/utils/contest_table_provider.ts +++ b/src/lib/utils/contest_table_provider.ts @@ -1,9 +1,11 @@ -import type { - ContestTableProvider, - ContestTable, - ContestTableMetaData, - ContestTablesMetaData, - ContestTableDisplayConfig, +import { + type ContestTableProvider, + type ContestTable, + type ContestTableMetaData, + type ContestTablesMetaData, + type ContestTableDisplayConfig, + type ProviderKey, + TESSOKU_SECTIONS, } from '$lib/types/contest_table_provider'; import { ContestType } from '$lib/types/contest'; import type { TaskResults, TaskResult } from '$lib/types/task'; @@ -29,15 +31,39 @@ import { getTaskTableHeaderName } from '$lib/utils/task'; */ export abstract class ContestTableProviderBase implements ContestTableProvider { - protected contestType: ContestType; + protected readonly contestType: ContestType; + protected readonly section?: string; /** * Creates a new TaskTableGenerator instance. * * @param {ContestType} contestType - The type of contest associated with these tasks. + * @param {string} [section] - Optional section identifier (e.g., 'examples', 'practicals', 'challenges'). */ - constructor(contestType: ContestType) { + constructor(contestType: ContestType, section?: string) { this.contestType = contestType; + this.section = section; + } + + /** + * Create a provider key combining contestType and section + * + * @param {ContestType} contestType - Contest type + * @param {string} [section] - Optional section identifier + * @returns {ProviderKey} Provider key (e.g., 'TESSOKU_BOOK' or 'TESSOKU_BOOK::examples') + */ + static createProviderKey(contestType: ContestType, section?: string): ProviderKey { + return section ? `${contestType}::${section}` : `${contestType}`; + } + + /** + * Get this provider's key + * Combines contestType and section to create a unique identifier + * + * @returns {ProviderKey} This provider's key + */ + getProviderKey(): ProviderKey { + return ContestTableProviderBase.createProviderKey(this.contestType, this.section); } filter(taskResults: TaskResults): TaskResults { @@ -239,6 +265,15 @@ export class Typical90Provider extends ContestTableProviderBase { } } +/** + * Base provider for Tessoku Book contests + * + * Note: This class is not intended to be registered directly. + * Use specialized subclasses (TessokuBookForExamplesProvider, + * TessokuBookForPracticalsProvider, TessokuBookForChallengesProvider) instead. + * + * @see https://github.com/AtCoder-NoviSteps/AtCoderNoviSteps/pull/2820 + */ export class TessokuBookProvider extends ContestTableProviderBase { protected setFilterCondition(): (taskResult: TaskResult) => boolean { return (taskResult: TaskResult) => { @@ -268,6 +303,71 @@ export class TessokuBookProvider extends ContestTableProviderBase { } } +export class TessokuBookForExamplesProvider extends TessokuBookProvider { + constructor(contestType: ContestType) { + super(contestType, TESSOKU_SECTIONS.EXAMPLES); + } + + 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', + }; + } +} + +export class TessokuBookForPracticalsProvider extends TessokuBookProvider { + constructor(contestType: ContestType) { + super(contestType, TESSOKU_SECTIONS.PRACTICALS); + } + + protected setFilterCondition(): (taskResult: TaskResult) => boolean { + return (taskResult: TaskResult) => { + return ( + classifyContest(taskResult.contest_id) === this.contestType && + taskResult.task_table_index.startsWith('B') + ); + }; + } + + getMetadata(): ContestTableMetaData { + return { + title: '競技プログラミングの鉄則(B. 応用問題)', + abbreviationName: 'tessoku-book-for-practicals', + }; + } +} + +export class TessokuBookForChallengesProvider extends TessokuBookProvider { + constructor(contestType: ContestType) { + super(contestType, TESSOKU_SECTIONS.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', + }; + } +} export class MathAndAlgorithmProvider extends ContestTableProviderBase { protected setFilterCondition(): (taskResult: TaskResult) => boolean { return (taskResult: TaskResult) => { @@ -440,7 +540,7 @@ export class JOIFirstQualRoundProvider extends ContestTableProviderBase { export class ContestTableProviderGroup { private groupName: string; private metadata: ContestTablesMetaData; - private providers = new Map(); + private providers = new Map(); constructor(groupName: string, metadata: ContestTablesMetaData) { this.groupName = groupName; @@ -449,39 +549,43 @@ export class ContestTableProviderGroup { /** * Add a provider - * @param contestType Contest type + * Provider key is determined by the provider's getProviderKey() method + * * @param provider Provider instance * @returns Returns this for method chaining */ - addProvider(contestType: ContestType, provider: ContestTableProviderBase): this { - this.providers.set(contestType, provider); + addProvider(provider: ContestTableProviderBase): this { + const key = provider.getProviderKey(); + this.providers.set(key, provider); return this; } /** - * Add multiple providers in pairs - * @param providers Array of contest type and provider pairs + * Add multiple providers + * Each provider's key is determined by its getProviderKey() method + * + * @param providers Array of provider instances * @returns Returns this for method chaining */ - addProviders( - ...providers: Array<{ - contestType: ContestType; - provider: ContestTableProviderBase; - }> - ): this { - providers.forEach(({ contestType, provider }) => { - this.providers.set(contestType, provider); + addProviders(...providers: ContestTableProviderBase[]): this { + providers.forEach((provider) => { + const key = provider.getProviderKey(); + this.providers.set(key, provider); }); return this; } /** - * Get a provider for a specific contest type + * Get a provider for a specific contest type and optional section + * Maintains backward compatibility by supporting section-less lookups + * * @param contestType Contest type + * @param section Optional section identifier * @returns Provider instance, or undefined */ - getProvider(contestType: ContestType): ContestTableProviderBase | undefined { - return this.providers.get(contestType); + getProvider(contestType: ContestType, section?: string): ContestTableProviderBase | undefined { + const key = ContestTableProviderBase.createProviderKey(contestType, section); + return this.providers.get(key); } /** @@ -524,8 +628,8 @@ export class ContestTableProviderGroup { return { groupName: this.groupName, providerCount: this.providers.size, - providers: Array.from(this.providers.entries()).map(([type, provider]) => ({ - contestType: type, + providers: Array.from(this.providers.entries()).map(([key, provider]) => ({ + providerKey: key, metadata: provider.getMetadata(), displayConfig: provider.getDisplayConfig(), })), @@ -546,7 +650,7 @@ export const prepareContestProviderPresets = () => { new ContestTableProviderGroup(`ABC Latest 20 Rounds`, { buttonLabel: 'ABC 最新 20 回', ariaLabel: 'Filter ABC latest 20 rounds', - }).addProvider(ContestType.ABC, new ABCLatest20RoundsProvider(ContestType.ABC)), + }).addProvider(new ABCLatest20RoundsProvider(ContestType.ABC)), /** * Single group for ABC 319 onwards @@ -555,7 +659,7 @@ export const prepareContestProviderPresets = () => { new ContestTableProviderGroup(`ABC 319 Onwards`, { buttonLabel: 'ABC 319 〜 ', ariaLabel: 'Filter contests from ABC 319 onwards', - }).addProvider(ContestType.ABC, new ABC319OnwardsProvider(ContestType.ABC)), + }).addProvider(new ABC319OnwardsProvider(ContestType.ABC)), /** * Single group for ABC 212-318 @@ -564,7 +668,7 @@ export const prepareContestProviderPresets = () => { new ContestTableProviderGroup(`From ABC 212 to ABC 318`, { buttonLabel: 'ABC 212 〜 318', ariaLabel: 'Filter contests from ABC 212 to ABC 318', - }).addProvider(ContestType.ABC, new ABC212ToABC318Provider(ContestType.ABC)), + }).addProvider(new ABC212ToABC318Provider(ContestType.ABC)), /** * Single group for Typical 90 Problems @@ -573,16 +677,22 @@ export const prepareContestProviderPresets = () => { new ContestTableProviderGroup(`競プロ典型 90 問`, { buttonLabel: '競プロ典型 90 問', ariaLabel: 'Filter Typical 90 Problems', - }).addProvider(ContestType.TYPICAL90, new Typical90Provider(ContestType.TYPICAL90)), + }).addProvider(new Typical90Provider(ContestType.TYPICAL90)), /** - * Single group for Tessoku Book + * Groups for Tessoku Book + * Note: Only sectioned providers are registered (examples, practicals, challenges). + * The base TessokuBookProvider is not registered as it's meant to be subclassed only. */ TessokuBook: () => new ContestTableProviderGroup(`競技プログラミングの鉄則`, { buttonLabel: '競技プログラミングの鉄則', ariaLabel: 'Filter Tessoku Book', - }).addProvider(ContestType.TESSOKU_BOOK, new TessokuBookProvider(ContestType.TESSOKU_BOOK)), + }).addProviders( + new TessokuBookForExamplesProvider(ContestType.TESSOKU_BOOK), + new TessokuBookForPracticalsProvider(ContestType.TESSOKU_BOOK), + new TessokuBookForChallengesProvider(ContestType.TESSOKU_BOOK), + ), /** * Single group for Math and Algorithm Book @@ -591,10 +701,7 @@ export const prepareContestProviderPresets = () => { new ContestTableProviderGroup(`アルゴリズムと数学`, { buttonLabel: 'アルゴリズムと数学', ariaLabel: 'Filter Math and Algorithm', - }).addProvider( - ContestType.MATH_AND_ALGORITHM, - new MathAndAlgorithmProvider(ContestType.MATH_AND_ALGORITHM), - ), + }).addProvider(new MathAndAlgorithmProvider(ContestType.MATH_AND_ALGORITHM)), /** * DP group (EDPC and TDPC) @@ -604,16 +711,16 @@ export const prepareContestProviderPresets = () => { 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) }, + new EDPCProvider(ContestType.EDPC), + new TDPCProvider(ContestType.TDPC), + new FPS24Provider(ContestType.FPS_24), ), JOIFirstQualRound: () => new ContestTableProviderGroup(`JOI 一次予選`, { buttonLabel: 'JOI 一次予選', ariaLabel: 'Filter JOI First Qualifying Round', - }).addProvider(ContestType.JOI, new JOIFirstQualRoundProvider(ContestType.JOI)), + }).addProvider(new JOIFirstQualRoundProvider(ContestType.JOI)), }; }; diff --git a/src/test/lib/utils/contest_table_provider.test.ts b/src/test/lib/utils/contest_table_provider.test.ts index b19ab43c5..1e149a452 100644 --- a/src/test/lib/utils/contest_table_provider.test.ts +++ b/src/test/lib/utils/contest_table_provider.test.ts @@ -13,10 +13,15 @@ import { JOIFirstQualRoundProvider, Typical90Provider, TessokuBookProvider, + TessokuBookForExamplesProvider, + TessokuBookForPracticalsProvider, + TessokuBookForChallengesProvider, MathAndAlgorithmProvider, + ContestTableProviderBase, ContestTableProviderGroup, prepareContestProviderPresets, } from '$lib/utils/contest_table_provider'; +import { TESSOKU_SECTIONS } from '$lib/types/contest_table_provider'; import { taskResultsForContestTableProvider } from './test_cases/contest_table_provider'; // Mock the imported functions @@ -1011,7 +1016,7 @@ describe('ContestTableProviderGroup', () => { }); const provider = new ABCLatest20RoundsProvider(ContestType.ABC); - group.addProvider(ContestType.ABC, provider); + group.addProvider(provider); expect(group.getSize()).toBe(1); expect(group.getProvider(ContestType.ABC)).toBe(provider); @@ -1026,10 +1031,7 @@ describe('ContestTableProviderGroup', () => { const edpcProvider = new EDPCProvider(ContestType.EDPC); const tdpcProvider = new TDPCProvider(ContestType.TDPC); - group.addProviders( - { contestType: ContestType.EDPC, provider: edpcProvider }, - { contestType: ContestType.TDPC, provider: tdpcProvider }, - ); + group.addProviders(edpcProvider, tdpcProvider); expect(group.getSize()).toBe(2); expect(group.getProvider(ContestType.EDPC)).toBe(edpcProvider); @@ -1044,8 +1046,8 @@ describe('ContestTableProviderGroup', () => { const edpcProvider = new EDPCProvider(ContestType.EDPC); const tdpcProvider = new TDPCProvider(ContestType.TDPC); - group.addProvider(ContestType.EDPC, edpcProvider); - group.addProvider(ContestType.TDPC, tdpcProvider); + group.addProvider(edpcProvider); + group.addProvider(tdpcProvider); const allProviders = group.getAllProviders(); expect(allProviders).toHaveLength(2); @@ -1061,9 +1063,7 @@ describe('ContestTableProviderGroup', () => { const abcProvider = new ABCLatest20RoundsProvider(ContestType.ABC); const edpcProvider = new EDPCProvider(ContestType.EDPC); - const result = group - .addProvider(ContestType.ABC, abcProvider) - .addProvider(ContestType.EDPC, edpcProvider); + const result = group.addProvider(abcProvider).addProvider(edpcProvider); expect(result).toBe(group); expect(group.getSize()).toBe(2); @@ -1077,18 +1077,107 @@ describe('ContestTableProviderGroup', () => { const abcProvider = new ABCLatest20RoundsProvider(ContestType.ABC); const edpcProvider = new EDPCProvider(ContestType.EDPC); - group.addProvider(ContestType.ABC, abcProvider); - group.addProvider(ContestType.EDPC, edpcProvider); + group.addProvider(abcProvider); + group.addProvider(edpcProvider); const stats = group.getStats(); expect(stats.groupName).toBe('Statistics for contest table'); expect(stats.providerCount).toBe(2); expect(stats.providers).toHaveLength(2); - expect(stats.providers[0]).toHaveProperty('contestType'); + expect(stats.providers[0]).toHaveProperty('providerKey'); expect(stats.providers[0]).toHaveProperty('metadata'); expect(stats.providers[0]).toHaveProperty('displayConfig'); }); + + describe('Provider key functionality', () => { + test('expects createProviderKey to generate correct simple key', () => { + const key = ContestTableProviderBase.createProviderKey(ContestType.ABC); + expect(key).toBe('ABC'); + // Verify key type is ProviderKey (string-based) + expect(typeof key).toBe('string'); + }); + + test('expects createProviderKey to generate correct composite key with section', () => { + const key = ContestTableProviderBase.createProviderKey( + ContestType.TESSOKU_BOOK, + TESSOKU_SECTIONS.EXAMPLES, + ); + expect(key).toBe(`TESSOKU_BOOK::${TESSOKU_SECTIONS.EXAMPLES}`); + // Verify key contains section separator + expect(key).toContain('::'); + }); + + test('expects TessokuBook providers to have correct keys', () => { + const examplesProvider = new TessokuBookForExamplesProvider(ContestType.TESSOKU_BOOK); + const practicalsProvider = new TessokuBookForPracticalsProvider(ContestType.TESSOKU_BOOK); + const challengesProvider = new TessokuBookForChallengesProvider(ContestType.TESSOKU_BOOK); + + expect(examplesProvider.getProviderKey()).toBe(`TESSOKU_BOOK::${TESSOKU_SECTIONS.EXAMPLES}`); + expect(practicalsProvider.getProviderKey()).toBe( + `TESSOKU_BOOK::${TESSOKU_SECTIONS.PRACTICALS}`, + ); + expect(challengesProvider.getProviderKey()).toBe( + `TESSOKU_BOOK::${TESSOKU_SECTIONS.CHALLENGES}`, + ); + }); + + test('expects multiple TessokuBook providers to be stored separately in group', () => { + const group = new ContestTableProviderGroup('Tessoku Book', { + buttonLabel: '競技プログラミングの鉄則', + ariaLabel: 'Filter Tessoku Book', + }); + + const examplesProvider = new TessokuBookForExamplesProvider(ContestType.TESSOKU_BOOK); + const practicalsProvider = new TessokuBookForPracticalsProvider(ContestType.TESSOKU_BOOK); + const challengesProvider = new TessokuBookForChallengesProvider(ContestType.TESSOKU_BOOK); + + group.addProviders(examplesProvider, practicalsProvider, challengesProvider); + + expect(group.getSize()).toBe(3); + expect(group.getProvider(ContestType.TESSOKU_BOOK, TESSOKU_SECTIONS.EXAMPLES)).toBe( + examplesProvider, + ); + expect(group.getProvider(ContestType.TESSOKU_BOOK, TESSOKU_SECTIONS.PRACTICALS)).toBe( + practicalsProvider, + ); + expect(group.getProvider(ContestType.TESSOKU_BOOK, TESSOKU_SECTIONS.CHALLENGES)).toBe( + challengesProvider, + ); + }); + + test('expects backward compatibility for getProvider without section', () => { + const group = new ContestTableProviderGroup('ABC Latest 20 Rounds', { + buttonLabel: 'ABC 最新 20 回', + ariaLabel: 'Filter ABC latest 20 rounds', + }); + + const abcProvider = new ABCLatest20RoundsProvider(ContestType.ABC); + group.addProvider(abcProvider); + + // Get provider without section should work with simple key + expect(group.getProvider(ContestType.ABC)).toBe(abcProvider); + expect(group.getProvider(ContestType.ABC, undefined)).toBe(abcProvider); + }); + + test('expects getProvider with non-existent section to return undefined', () => { + const group = new ContestTableProviderGroup('Tessoku Book', { + buttonLabel: '競技プログラミングの鉄則', + ariaLabel: 'Filter Tessoku Book', + }); + + const examplesProvider = new TessokuBookForExamplesProvider(ContestType.TESSOKU_BOOK); + group.addProvider(examplesProvider); + + expect(group.getProvider(ContestType.TESSOKU_BOOK, TESSOKU_SECTIONS.EXAMPLES)).toBe( + examplesProvider, + ); + expect( + group.getProvider(ContestType.TESSOKU_BOOK, TESSOKU_SECTIONS.PRACTICALS), + ).toBeUndefined(); + expect(group.getProvider(ContestType.TESSOKU_BOOK, 'invalid')).toBeUndefined(); + }); + }); }); describe('prepareContestProviderPresets', () => { @@ -1154,6 +1243,26 @@ describe('prepareContestProviderPresets', () => { expect(group.getProvider(ContestType.TYPICAL90)).toBeInstanceOf(Typical90Provider); }); + test('expects to create TessokuBook preset correctly with 3 providers', () => { + const group = prepareContestProviderPresets().TessokuBook(); + + expect(group.getGroupName()).toBe('競技プログラミングの鉄則'); + expect(group.getMetadata()).toEqual({ + buttonLabel: '競技プログラミングの鉄則', + ariaLabel: 'Filter Tessoku Book', + }); + expect(group.getSize()).toBe(3); + expect(group.getProvider(ContestType.TESSOKU_BOOK, TESSOKU_SECTIONS.EXAMPLES)).toBeInstanceOf( + TessokuBookForExamplesProvider, + ); + expect(group.getProvider(ContestType.TESSOKU_BOOK, TESSOKU_SECTIONS.PRACTICALS)).toBeInstanceOf( + TessokuBookForPracticalsProvider, + ); + expect(group.getProvider(ContestType.TESSOKU_BOOK, TESSOKU_SECTIONS.CHALLENGES)).toBeInstanceOf( + TessokuBookForChallengesProvider, + ); + }); + test('expects to verify all presets are functions', () => { const presets = prepareContestProviderPresets();