diff --git a/docs/dev-notes/2025-11-03/add_tests_for_contest_table_provider/plan.md b/docs/dev-notes/2025-11-03/add_tests_for_contest_table_provider/plan.md new file mode 100644 index 000000000..28e192bd4 --- /dev/null +++ b/docs/dev-notes/2025-11-03/add_tests_for_contest_table_provider/plan.md @@ -0,0 +1,351 @@ +# MathAndAlgorithmProvider テスト追加計画 + +**作成日**: 2025-11-03 + +**対象ブランチ**: #2785 + +**優先度**: High + +--- + +## 参照ドキュメント + +テストの書き方・スタイル・ベストプラクティスについては、以下を参照: + +📖 [`docs/dev-notes/2025-11-01/add_and_refactoring_tests_for_contest_table_provider/plan.md`](../../2025-11-01/add_and_refactoring_tests_for_contest_table_provider/plan.md) + +--- + +## 1. 概要 + +### 背景 + +`MathAndAlgorithmProvider` は `TessokuBookProvider` と同じ構造で、複数のコンテストの問題を統合した問題集を提供します。 + +- **セクション範囲**: 001 ~ 104(一部欠損) +- **フォーマット**: 3桁数字(0 padding) +- **複数ソース対応**: 異なる `task_id`(問題集のリンク) + +### 目的 + +TessokuBook テストと同等の粒度で、MathAndAlgorithmProvider の単体テスト 11 個を追加。 + +--- + +## 2. 仕様要件 + +| 項目 | 仕様 | 備考 | +| ------------------ | --------------------------- | ------------------------ | +| **セクション範囲** | 001 ~ 104 | 一部欠損あり(原典準拠) | +| **ソート順序** | 昇順(001 → 102 → ... 104) | 必須 | +| **フォーマット** | 3桁数字(0 padding) | 例: 001, 028, 102 | +| **複数ソース対応** | 異なる problem_id | DB 一意制約で保証 | + +--- + +## 3. テストケース(11件) + +### テスト1: フィルタリング + +```typescript +test('expects to filter tasks to include only math-and-algorithm contest', () => { + const provider = new MathAndAlgorithmProvider(ContestType.MATH_AND_ALGORITHM); + const mixedTasks = [ + { contest_id: 'abc123', task_id: 'abc123_a', task_table_index: 'A' }, + { contest_id: 'math-and-algorithm', task_id: 'math_and_algorithm_a', task_table_index: '001' }, + { contest_id: 'math-and-algorithm', task_id: 'typical90_o', task_table_index: '101' }, + { contest_id: 'math-and-algorithm', task_id: 'arc117_c', task_table_index: '102' }, + ]; + const filtered = provider.filter(mixedTasks); + + expect(filtered?.every((task) => task.contest_id === 'math-and-algorithm')).toBe(true); + expect(filtered).not.toContainEqual(expect.objectContaining({ contest_id: 'abc123' })); +}); +``` + +--- + +### テスト2: メタデータ取得 + +```typescript +test('expects to get correct metadata', () => { + const provider = new MathAndAlgorithmProvider(ContestType.MATH_AND_ALGORITHM); + const metadata = provider.getMetadata(); + + expect(metadata.title).toBe('アルゴリズムと数学'); + expect(metadata.abbreviationName).toBe('math-and-algorithm'); +}); +``` + +--- + +### テスト3: 表示設定 + +```typescript +test('expects to get correct display configuration', () => { + const provider = new MathAndAlgorithmProvider(ContestType.MATH_AND_ALGORITHM); + 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 MathAndAlgorithmProvider(ContestType.MATH_AND_ALGORITHM); + const label = provider.getContestRoundLabel('math-and-algorithm'); + + expect(label).toBe(''); +}); +``` + +--- + +### テスト5: テーブル生成(複数ソース対応) + +```typescript +test('expects to generate correct table structure with mixed problem sources', () => { + const provider = new MathAndAlgorithmProvider(ContestType.MATH_AND_ALGORITHM); + const tasks = [ + { contest_id: 'math-and-algorithm', task_id: 'math_and_algorithm_a', task_table_index: '001' }, + { contest_id: 'math-and-algorithm', task_id: 'dp_a', task_table_index: '028' }, + { contest_id: 'math-and-algorithm', task_id: 'abc168_c', task_table_index: '036' }, + { contest_id: 'math-and-algorithm', task_id: 'arc117_c', task_table_index: '102' }, + ]; + const table = provider.generateTable(tasks); + + expect(table).toHaveProperty('math-and-algorithm'); + expect(table['math-and-algorithm']).toHaveProperty('028'); + expect(table['math-and-algorithm']['028']).toEqual(expect.objectContaining({ task_id: 'dp_a' })); +}); +``` + +--- + +### テスト6: ラウンド ID 取得 + +```typescript +test('expects to get contest round IDs correctly', () => { + const provider = new MathAndAlgorithmProvider(ContestType.MATH_AND_ALGORITHM); + const tasks = [ + { contest_id: 'math-and-algorithm', task_id: 'arc117_c', task_table_index: '102' }, + { contest_id: 'math-and-algorithm', task_id: 'typical90_o', task_table_index: '101' }, + ]; + const roundIds = provider.getContestRoundIds(tasks); + + expect(roundIds).toEqual(['math-and-algorithm']); +}); +``` + +--- + +### テスト7: ヘッダー ID 取得(昇順・複数ソース混在) + +```typescript +test('expects to get header IDs for tasks correctly in ascending order', () => { + const provider = new MathAndAlgorithmProvider(ContestType.MATH_AND_ALGORITHM); + const tasks = [ + { contest_id: 'math-and-algorithm', task_id: 'math_and_algorithm_a', task_table_index: '001' }, + { contest_id: 'math-and-algorithm', task_id: 'arc117_c', task_table_index: '102' }, + { contest_id: 'math-and-algorithm', task_id: 'dp_a', task_table_index: '028' }, + { contest_id: 'math-and-algorithm', task_id: 'abc168_c', task_table_index: '036' }, + ]; + const headerIds = provider.getHeaderIdsForTask(tasks); + + expect(headerIds).toEqual(['001', '028', '036', '102']); +}); +``` + +--- + +### テスト8: ソート順序の厳密性(数字ソート) + +```typescript +test('expects to maintain proper sort order with numeric indices', () => { + const provider = new MathAndAlgorithmProvider(ContestType.MATH_AND_ALGORITHM); + const tasks = [ + { contest_id: 'math-and-algorithm', task_id: 'typical90_bz', task_table_index: '045' }, + { contest_id: 'math-and-algorithm', task_id: 'abc168_c', task_table_index: '036' }, + ]; + const headerIds = provider.getHeaderIdsForTask(tasks); + + // 036 < 045 の順序を厳密に検証 + expect(headerIds).toEqual(['036', '045']); +}); +``` + +--- + +### テスト9: セクション範囲検証 + +```typescript +test('expects to handle section boundaries correctly (001-104)', () => { + const provider = new MathAndAlgorithmProvider(ContestType.MATH_AND_ALGORITHM); + const tasks = [ + { contest_id: 'math-and-algorithm', task_id: 'math_and_algorithm_a', task_table_index: '001' }, + { contest_id: 'math-and-algorithm', task_id: 'math_and_algorithm_bx', task_table_index: '104' }, + ]; + const headerIds = provider.getHeaderIdsForTask(tasks); + + expect(headerIds).toEqual(['001', '104']); +}); +``` + +--- + +### テスト10: 空入力処理 + +```typescript +test('expects to handle empty task results', () => { + const provider = new MathAndAlgorithmProvider(ContestType.MATH_AND_ALGORITHM); + const filtered = provider.filter([]); + + expect(filtered).toEqual([]); +}); +``` + +--- + +### テスト11: 混合コンテストタイプの排除 + +```typescript +test('expects to handle task results with different contest types', () => { + const provider = new MathAndAlgorithmProvider(ContestType.MATH_AND_ALGORITHM); + const mixedTasks = [ + { contest_id: 'math-and-algorithm', task_id: 'math_and_algorithm_a', task_table_index: '001' }, + { contest_id: 'math-and-algorithm', task_id: 'arc117_c', task_table_index: '102' }, + { contest_id: 'typical90', task_id: 'typical90_a', task_table_index: 'A' }, + ]; + const filtered = provider.filter(mixedTasks); + + expect(filtered).toHaveLength(2); + expect(filtered?.every((task) => task.contest_id === 'math-and-algorithm')).toBe(true); +}); +``` + +--- + +## 4. モックデータ + +追加先: `src/test/lib/utils/test_cases/contest_table_provider.ts` + +```typescript +export const taskResultsForMathAndAlgorithmProvider: TaskResults = [ + { + contest_id: 'math-and-algorithm', + task_id: 'dp_a', + task_table_index: '028', + }, + { + contest_id: 'math-and-algorithm', + task_id: 'abc168_c', + task_table_index: '036', + }, + { + contest_id: 'math-and-algorithm', + task_id: 'typical90_bz', + task_table_index: '045', + }, + { + contest_id: 'math-and-algorithm', + task_id: 'abc007_3', + task_table_index: '046', + }, + { + contest_id: 'math-and-algorithm', + task_id: 'arc084_b', + task_table_index: '048', + }, + { + contest_id: 'math-and-algorithm', + task_id: 'abc145_d', + task_table_index: '052', + }, + { + contest_id: 'math-and-algorithm', + task_id: 'abc172_d', + task_table_index: '042', + }, + { + contest_id: 'math-and-algorithm', + task_id: 'typical90_j', + task_table_index: '095', + }, + { + contest_id: 'math-and-algorithm', + task_id: 'typical90_o', + task_table_index: '101', + }, + { + contest_id: 'math-and-algorithm', + task_id: 'arc117_c', + task_table_index: '102', + }, +]; +``` + +**出典**: [`prisma/contest_task_pairs.ts`](../../../../prisma/contest_task_pairs.ts) 行 14 ~ 52 + +--- + +## 5. 実装手順 + +**ステップ1**: モックデータを `src/test/lib/utils/test_cases/contest_table_provider.ts` に追加 + +**ステップ2**: 上記 11 個のテストを `src/test/lib/utils/contest_table_provider.test.ts` に追加 + +**ステップ3**: テスト実行・検証 + +```bash +pnpm test:unit src/test/lib/utils/contest_table_provider.test.ts +``` + +**ステップ4**: Lint チェック + +```bash +pnpm lint src/test/lib/utils/contest_table_provider.test.ts +``` + +--- + +## 6. 注意点 + +詳細は参照ドキュメント「教訓統合」セクションを参照。特に以下を確認: + +- **ソート順序**: 文字列の辞書順ソート(`'028' < '036' < '045'`) +- **複数ソース混在**: `problem_id` が異なる複雑なテストケース(テスト5・11) +- **パラメータ化テスト**: TessokuBook との共通パターン活用可能(参考ドキュメント フェーズ3) + +--- + +## 7. 実装結果・教訓 + +### ✅ 実装完了 + +**実施時間**: 13.4 秒(テスト実行含む) + +**実装内容**: + +1. モックデータ追加: 10 個のサンプルタスク(`contest_table_provider.ts`) +2. テストケース実装: 11 個の単体テスト +3. モック拡張: `classifyContest` に `math-and-algorithm` サポートを追加 + +### 📚 得られた教訓 + +1. **コンテストタイプのモック更新**:新規プロバイダー追加時、`vi.mock()` に新しいコンテストタイプを追加する必要あり。参照ドキュメント(2025-11-01)では言及されていなかった重要なポイント + +2. **テストの再利用性**:TessokuBook と MathAndAlgorithmProvider は構造同一のため、テストテンプレートを完全流用可能。共有パターン化の価値が確認できた + +3. **ソート順序の自動確認**:文字列ソート(昇順)が正確に機能するため、インデックス形式の統一(3桁数字)が重要 + +4. **ファイルフォーマット**:Prettier による自動フォーマットで一部ファイルが修正されたため、実装後の linting 実行は必須 diff --git a/docs/dev-notes/2025-11-03/import-contest-task-pair-to-supabase/plan.md b/docs/dev-notes/2025-11-03/import-contest-task-pair-to-supabase/plan.md new file mode 100644 index 000000000..5175a8582 --- /dev/null +++ b/docs/dev-notes/2025-11-03/import-contest-task-pair-to-supabase/plan.md @@ -0,0 +1,115 @@ +# Supabase に CSV からデータをインポートする方法 + +## 基本的な手順 + +### 1. CSV ファイルの準備 + +Supabase Dashboard からのインポートでは、以下のカラムを **必ず含める** 必要があります: + +- `id` - UUID(自動生成ではなく、CSV に含める必要がある) +- `createdAt` - タイムスタンプ +- `updatedAt` - タイムスタンプ +- その他のビジネスロジック用カラム + +**CSV ファイルの例(`contest_task_pairs.csv`):** + +```csv +id,contestId,taskId,taskTableIndex,createdAt,updatedAt +550e8400-e29b-41d4-a716-446655440000,tessoku-book,typical90_s,C18,2025-11-03T10:00:00.000Z,2025-11-03T10:00:00.000Z +6ba7b810-9dad-11d1-80b4-00c04fd430c8,tessoku-book,math_and_algorithm_ac,C09,2025-11-03T10:00:00.000Z,2025-11-03T10:00:00.000Z +7cb12b42-0b4a-11d2-91c5-00d04fd430c9,tessoku-book,abc007_3,B63,2025-11-03T10:00:00.000Z,2025-11-03T10:00:00.000Z +``` + +### 2. Python で UUID と タイムスタンプを生成 + +既存の CSV に `id`、`createdAt`、`updatedAt` を追加するスクリプト: + +```python +import uuid +import csv +from datetime import datetime + +# 現在時刻を ISO 8601 形式で取得 +now = datetime.utcnow().isoformat() + 'Z' + +# CSV を読み込み +with open('tessoku-book.csv', 'r') as f: + reader = csv.reader(f) + header = next(reader) + rows = list(reader) + +# ID とタイムスタンプを追加して新しい CSV を作成 +with open('tessoku-book-with-id.csv', 'w', newline='') as f: + writer = csv.writer(f) + writer.writerow(['id', 'createdAt', 'updatedAt'] + header) + + for row in rows: + writer.writerow([str(uuid.uuid4()), now, now] + row) + +print(f"✅ {len(rows)} 件のデータを処理しました") +``` + +### 3. Supabase Dashboard からインポート + +1. [Supabase ダッシュボード](https://supabase.com/dashboard)にログイン +2. 対象のプロジェクト → 「SQL Editor」または「Table Editor」を選択 +3. 対象テーブル(例:`contesttaskpair`)を選択 +4. 右上の「Import data」または「↓ Import」ボタンをクリック +5. 生成した CSV ファイル(`tessoku-book-with-id.csv`)を選択 +6. カラムのマッピングを確認 +7. 「Import」をクリック + +## トラブルシューティング + +### エラー: "null value in column "id" violates not-null constraint" + +**原因:** CSV に `id` カラムが含まれていない場合、Supabase が自動生成してくれません。 + +**解決方法:** 上記の Python スクリプトを使用して、UUID を生成した CSV を作成してください。 + +### エラー: カラム名が一致しない + +**原因:** CSV のカラム名とテーブルの属性名が異なる + +**対応:** + +- CSV のカラム名とテーブルの属性名が完全に一致していることを確認 +- インポート画面でカラムマッピングを手動で行う +- 大文字小文字は区別されるので注意 + +### CSV のカラム順序が異なる場合 + +**結果:** 問題ありません。Supabase はカラム名で識別するため、順序は関係ありません。 + +## 得られた教訓 + +### 1. 自動生成カラムについて + +Prisma スキーマで `@default(uuid())` や `@default(now())` が定義されていても、Supabase Dashboard の CSV インポート機能はこのデフォルト値を適用しません。 + +```prisma +model ContestTaskPair { + id String @id @default(uuid()) // ← デフォルト値が定義されていても + createdAt DateTime @default(now()) // ← CSV インポートには反映されない + updatedAt DateTime @updatedAt +} +``` + +**対策:** CSV ファイルに明示的にこれらのカラムを含める必要があります。 + +### 2. カラム名の一致は必須 + +- **必須:** CSV のカラム名 = テーブルの属性名 +- **不要:** カラムの順序 +- **補完:** インポート画面でマッピングできるが、あらかじめ一致させておくのが確実 + +### 3. UUID v4 の選択 + +デフォルトの `uuid()` (v4) はランダムなため、DB インデックスの効率が低下します。大規模なデータセットの場合は、`cuid()` の使用や UUID v6 の検討を推奨します。 + +## 参考資料 + +- [Supabase 公式 - Import data](https://supabase.com/docs/guides/database/import-data) +- [Prisma - @default 関数](https://www.prisma.io/docs/reference/api-reference/prisma-schema-reference#default) +- [RFC 9562 - UUID Versions](https://tools.ietf.org/html/rfc9562) +- [UUID Library for Python - uuid4](https://docs.python.org/3/library/uuid.html#uuid.uuid4) diff --git a/prisma/contest_task_pairs.ts b/prisma/contest_task_pairs.ts index 2eaadff9b..d61cbdd58 100644 --- a/prisma/contest_task_pairs.ts +++ b/prisma/contest_task_pairs.ts @@ -64,4 +64,139 @@ export const contest_task_pairs = [ problem_id: 'math_and_algorithm_ai', problem_index: 'A06', }, + { + contest_id: 'math-and-algorithm', + problem_id: 'arc117_c', + problem_index: '102', + }, + { + contest_id: 'math-and-algorithm', + problem_id: 'typical90_o', + problem_index: '101', + }, + { + contest_id: 'math-and-algorithm', + problem_id: 'typical90_am', + problem_index: '099', + }, + { + contest_id: 'math-and-algorithm', + problem_id: 'abc204_d', + problem_index: '096', + }, + { + contest_id: 'math-and-algorithm', + problem_id: 'typical90_j', + problem_index: '095', + }, + { + contest_id: 'math-and-algorithm', + problem_id: 'abc140_c', + problem_index: '094', + }, + { + contest_id: 'math-and-algorithm', + problem_id: 'typical90_al', + problem_index: '093', + }, + { + contest_id: 'math-and-algorithm', + problem_id: 'typical90_y', + problem_index: '090', + }, + { + contest_id: 'math-and-algorithm', + problem_id: 'arc107_a', + problem_index: '088', + }, + { + contest_id: 'math-and-algorithm', + problem_id: 'panasonic2020_c', + problem_index: '084', + }, + { + contest_id: 'math-and-algorithm', + problem_id: 'typical90_n', + problem_index: '083', + }, + { + contest_id: 'math-and-algorithm', + problem_id: 'abc139_d', + problem_index: '079', + }, + { + contest_id: 'math-and-algorithm', + problem_id: 'abc186_d', + problem_index: '076', + }, + { + contest_id: 'math-and-algorithm', + problem_id: 'jsc2021_c', + problem_index: '072', + }, + { + contest_id: 'math-and-algorithm', + problem_id: 'abc075_d', + problem_index: '070', + }, + { + contest_id: 'math-and-algorithm', + problem_id: 'abc178_b', + problem_index: '069', + }, + { + contest_id: 'math-and-algorithm', + problem_id: 'typical90_d', + problem_index: '067', + }, + { + contest_id: 'math-and-algorithm', + problem_id: 'panasonic2020_b', + problem_index: '065', + }, + { + contest_id: 'math-and-algorithm', + problem_id: 'abc167_d', + problem_index: '062', + }, + { + contest_id: 'math-and-algorithm', + problem_id: 'abc145_d', + problem_index: '052', + }, + { + contest_id: 'math-and-algorithm', + problem_id: 'arc084_b', + problem_index: '048', + }, + { + contest_id: 'math-and-algorithm', + problem_id: 'abc007_3', + problem_index: '046', + }, + { + contest_id: 'math-and-algorithm', + problem_id: 'typical90_bz', + problem_index: '045', + }, + { + contest_id: 'math-and-algorithm', + problem_id: 'abc172_d', + problem_index: '042', + }, + { + contest_id: 'math-and-algorithm', + problem_id: 'abc168_c', + problem_index: '036', + }, + { + contest_id: 'math-and-algorithm', + problem_id: 'dp_d', + problem_index: '030', + }, + { + contest_id: 'math-and-algorithm', + problem_id: 'dp_a', + problem_index: '028', + }, ]; diff --git a/prisma/tasks.ts b/prisma/tasks.ts index f604b0581..13950df35 100755 --- a/prisma/tasks.ts +++ b/prisma/tasks.ts @@ -3870,6 +3870,13 @@ export const tasks = [ title: 'A. kcal', grade: 'Q9', }, + { + id: 'abc204_d', + contest_id: 'abc204', + problem_index: 'D', + name: 'Cooking', + title: 'D. Cooking', + }, { id: 'abc202_a', contest_id: 'abc202', @@ -3901,6 +3908,13 @@ export const tasks = [ title: 'A. Three-Point Shot', grade: 'Q8', }, + { + id: 'abc186_d', + contest_id: 'abc186', + problem_index: 'D', + name: 'Sum of difference', + title: 'D. Sum of difference', + }, { id: 'abc184_a', contest_id: 'abc184', @@ -3925,6 +3939,13 @@ export const tasks = [ title: 'A. box', grade: 'Q10', }, + { + id: 'abc178_b', + contest_id: 'abc178', + problem_index: 'B', + name: 'Product Max', + title: 'B. Product Max', + }, { id: 'abc174_a', contest_id: 'abc174', @@ -3933,6 +3954,13 @@ export const tasks = [ title: 'A. Air Conditioner', grade: 'Q9', }, + { + id: 'abc172_d', + contest_id: 'abc172', + problem_index: 'D', + name: 'Sum of Divisors', + title: 'D. Sum of Divisors', + }, { id: 'abc172_a', contest_id: 'abc172', @@ -3949,6 +3977,20 @@ export const tasks = [ title: 'A. Multiplication 1', grade: 'Q10', }, + { + id: 'abc168_c', + contest_id: 'abc168', + problem_index: 'C', + name: ': (Colon)', + title: 'C. : (Colon)', + }, + { + id: 'abc167_d', + contest_id: 'abc167', + problem_index: 'D', + name: 'Teleporter', + title: 'D. Teleporter', + }, { id: 'abc163_a', contest_id: 'abc163', @@ -3981,6 +4023,13 @@ export const tasks = [ title: 'E. Rem of Sum is Num', grade: 'D1', }, + { + id: 'abc145_d', + contest_id: 'abc145', + problem_index: 'D', + name: 'Knight', + title: 'D. Knight', + }, { id: 'abc142_a', contest_id: 'abc142', @@ -3989,6 +4038,13 @@ export const tasks = [ title: 'A. Odds of Oddness', grade: 'Q7', }, + { + id: 'abc140_c', + contest_id: 'abc140', + problem_index: 'C', + name: 'Maximal Value', + title: 'C. Maximal Value', + }, { id: 'abc140_a', contest_id: 'abc140', @@ -3997,6 +4053,13 @@ export const tasks = [ title: 'A. Password', grade: 'Q7', }, + { + id: 'abc139_d', + contest_id: 'abc139', + problem_index: 'D', + name: 'ModSum', + title: 'D. ModSum', + }, { id: 'abc123_d', contest_id: 'abc123', @@ -4005,6 +4068,13 @@ export const tasks = [ title: 'D. Cake 123', grade: 'Q1', }, + { + id: 'abc075_d', + contest_id: 'abc075', + problem_index: 'D', + name: 'Axis-Parallel Rectangle', + title: 'D. Axis-Parallel Rectangle', + }, { id: 'abc007_3', contest_id: 'abc007', @@ -4020,6 +4090,27 @@ export const tasks = [ title: 'C. Honest or Liar or Confused', grade: 'D3', }, + { + id: 'arc117_c', + contest_id: 'arc117', + problem_index: 'C', + name: 'Tricolor Pyramid', + title: 'C. Tricolor Pyramid', + }, + { + id: 'arc107_a', + contest_id: 'arc107', + problem_index: 'A', + name: 'Simple Math', + title: 'A. Simple Math', + }, + { + id: 'arc084_b', + contest_id: 'abc077', + problem_index: 'D', + name: 'Small Multiple', + title: 'D. Small Multiple', + }, { id: 'arc076_c', contest_id: 'arc076', @@ -5296,6 +5387,27 @@ export const tasks = [ title: '078. Easy Graph Problem(★2)', grade: 'Q5', }, + { + id: 'typical90_am', + contest_id: 'typical90', + problem_index: '039', + name: 'Tree Distance(★5)', + title: '039. Tree Distance(★5)', + }, + { + id: 'typical90_al', + contest_id: 'typical90', + problem_index: '038', + name: 'Large LCM(★3)', + title: '038. Large LCM(★3)', + }, + { + id: 'typical90_y', + contest_id: 'typical90', + problem_index: '025', + name: 'Digit Product Equation(★7)', + title: '025. Digit Product Equation(★7)', + }, { id: 'typical90_s', contest_id: 'typical90', @@ -5303,6 +5415,13 @@ export const tasks = [ name: 'Pick Two(★6)', title: '019. Pick Two(★6)', }, + { + id: 'typical90_o', + contest_id: 'typical90', + problem_index: '015', + name: "Don't be too close(★6)", + title: "015. Don't be too close(★6)", + }, { id: 'typical90_n', contest_id: 'typical90', @@ -5455,6 +5574,20 @@ export const tasks = [ title: 'G. Wildcards', grade: 'Q1', }, + { + id: 'panasonic2020_c', + contest_id: 'panasonic2020', + problem_index: 'C', + name: 'Sqrt Inequality', + title: 'C. Sqrt Inequality', + }, + { + id: 'panasonic2020_b', + contest_id: 'panasonic2020', + problem_index: 'B', + name: 'Bishop', + title: 'B. Bishop', + }, { id: 'panasonic2020_a', contest_id: 'panasonic2020', @@ -5463,4 +5596,11 @@ export const tasks = [ title: 'A. Kth Term', grade: 'Q8', }, + { + id: 'jsc2021_c', + contest_id: 'jsc2021', + problem_index: 'C', + name: 'Max GCD 2', + title: 'C. Max GCD 2', + }, ]; diff --git a/src/lib/utils/contest_table_provider.ts b/src/lib/utils/contest_table_provider.ts index 505991322..4763333f5 100644 --- a/src/lib/utils/contest_table_provider.ts +++ b/src/lib/utils/contest_table_provider.ts @@ -268,6 +268,35 @@ export class TessokuBookProvider extends ContestTableProviderBase { } } +export class MathAndAlgorithmProvider extends ContestTableProviderBase { + protected setFilterCondition(): (taskResult: TaskResult) => boolean { + return (taskResult: TaskResult) => { + return classifyContest(taskResult.contest_id) === this.contestType; + }; + } + + getMetadata(): ContestTableMetaData { + return { + title: 'アルゴリズムと数学', + abbreviationName: 'math-and-algorithm', + }; + } + + getDisplayConfig(): ContestTableDisplayConfig { + return { + isShownHeader: false, + isShownRoundLabel: false, + roundLabelWidth: '', // No specific width for the round label + 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 ''; + } +} + export class EDPCProvider extends ContestTableProviderBase { protected setFilterCondition(): (taskResult: TaskResult) => boolean { return (taskResult: TaskResult) => { @@ -522,6 +551,18 @@ export const prepareContestProviderPresets = () => { ariaLabel: 'Filter Tessoku Book', }).addProvider(ContestType.TESSOKU_BOOK, new TessokuBookProvider(ContestType.TESSOKU_BOOK)), + /** + * Single group for Math and Algorithm Book + */ + MathAndAlgorithm: () => + new ContestTableProviderGroup(`アルゴリズムと数学`, { + buttonLabel: 'アルゴリズムと数学', + ariaLabel: 'Filter Math and Algorithm', + }).addProvider( + ContestType.MATH_AND_ALGORITHM, + new MathAndAlgorithmProvider(ContestType.MATH_AND_ALGORITHM), + ), + /** * DP group (EDPC and TDPC) */ @@ -548,6 +589,7 @@ export const contestTableProviderGroups = { fromAbc212ToAbc318: prepareContestProviderPresets().ABC212ToABC318(), typical90: prepareContestProviderPresets().Typical90(), tessokuBook: prepareContestProviderPresets().TessokuBook(), + mathAndAlgorithm: prepareContestProviderPresets().MathAndAlgorithm(), dps: prepareContestProviderPresets().dps(), // Dynamic Programming (DP) Contests joiFirstQualRound: prepareContestProviderPresets().JOIFirstQualRound(), }; diff --git a/src/test/lib/utils/contest_table_provider.test.ts b/src/test/lib/utils/contest_table_provider.test.ts index e2839b1f1..4d101a4dd 100644 --- a/src/test/lib/utils/contest_table_provider.test.ts +++ b/src/test/lib/utils/contest_table_provider.test.ts @@ -12,6 +12,7 @@ import { JOIFirstQualRoundProvider, Typical90Provider, TessokuBookProvider, + MathAndAlgorithmProvider, ContestTableProviderGroup, prepareContestProviderPresets, } from '$lib/utils/contest_table_provider'; @@ -32,6 +33,8 @@ vi.mock('$lib/utils/contest', () => ({ return ContestType.TYPICAL90; } else if (contestId === 'tessoku-book') { return ContestType.TESSOKU_BOOK; + } else if (contestId === 'math-and-algorithm') { + return ContestType.MATH_AND_ALGORITHM; } return ContestType.OTHERS; @@ -552,6 +555,157 @@ describe('ContestTableProviderBase and implementations', () => { }); }); + describe('MathAndAlgorithm provider', () => { + test('expects to filter tasks to include only math-and-algorithm contest', () => { + const provider = new MathAndAlgorithmProvider(ContestType.MATH_AND_ALGORITHM); + const mixedTasks = [ + { contest_id: 'abc123', task_id: 'abc123_a', task_table_index: 'A' }, + { + contest_id: 'math-and-algorithm', + task_id: 'math_and_algorithm_a', + task_table_index: '001', + }, + { contest_id: 'math-and-algorithm', task_id: 'typical90_o', task_table_index: '101' }, + { contest_id: 'math-and-algorithm', task_id: 'arc117_c', task_table_index: '102' }, + ]; + const filtered = provider.filter(mixedTasks as TaskResults); + + expect(filtered?.every((task) => task.contest_id === 'math-and-algorithm')).toBe(true); + expect(filtered).not.toContainEqual(expect.objectContaining({ contest_id: 'abc123' })); + }); + + test('expects to get correct metadata', () => { + const provider = new MathAndAlgorithmProvider(ContestType.MATH_AND_ALGORITHM); + const metadata = provider.getMetadata(); + + expect(metadata.title).toBe('アルゴリズムと数学'); + expect(metadata.abbreviationName).toBe('math-and-algorithm'); + }); + + test('expects to get correct display configuration', () => { + const provider = new MathAndAlgorithmProvider(ContestType.MATH_AND_ALGORITHM); + 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); + }); + + test('expects to format contest round label correctly', () => { + const provider = new MathAndAlgorithmProvider(ContestType.MATH_AND_ALGORITHM); + const label = provider.getContestRoundLabel('math-and-algorithm'); + + expect(label).toBe(''); + }); + + test('expects to generate correct table structure with mixed problem sources', () => { + const provider = new MathAndAlgorithmProvider(ContestType.MATH_AND_ALGORITHM); + const tasks = [ + { + contest_id: 'math-and-algorithm', + task_id: 'math_and_algorithm_a', + task_table_index: '001', + }, + { contest_id: 'math-and-algorithm', task_id: 'dp_a', task_table_index: '028' }, + { contest_id: 'math-and-algorithm', task_id: 'abc168_c', task_table_index: '036' }, + { contest_id: 'math-and-algorithm', task_id: 'arc117_c', task_table_index: '102' }, + ]; + const table = provider.generateTable(tasks as TaskResults); + + expect(table).toHaveProperty('math-and-algorithm'); + expect(table['math-and-algorithm']).toHaveProperty('028'); + expect(table['math-and-algorithm']['028']).toEqual( + expect.objectContaining({ task_id: 'dp_a' }), + ); + }); + + test('expects to get contest round IDs correctly', () => { + const provider = new MathAndAlgorithmProvider(ContestType.MATH_AND_ALGORITHM); + const tasks = [ + { contest_id: 'math-and-algorithm', task_id: 'arc117_c', task_table_index: '102' }, + { contest_id: 'math-and-algorithm', task_id: 'typical90_o', task_table_index: '101' }, + ]; + const roundIds = provider.getContestRoundIds(tasks as TaskResults); + + expect(roundIds).toEqual(['math-and-algorithm']); + }); + + test('expects to get header IDs for tasks correctly in ascending order', () => { + const provider = new MathAndAlgorithmProvider(ContestType.MATH_AND_ALGORITHM); + const tasks = [ + { + contest_id: 'math-and-algorithm', + task_id: 'math_and_algorithm_a', + task_table_index: '001', + }, + { contest_id: 'math-and-algorithm', task_id: 'arc117_c', task_table_index: '102' }, + { contest_id: 'math-and-algorithm', task_id: 'dp_a', task_table_index: '028' }, + { contest_id: 'math-and-algorithm', task_id: 'abc168_c', task_table_index: '036' }, + ]; + const headerIds = provider.getHeaderIdsForTask(tasks as TaskResults); + + expect(headerIds).toEqual(['001', '028', '036', '102']); + }); + + test('expects to maintain proper sort order with numeric indices', () => { + const provider = new MathAndAlgorithmProvider(ContestType.MATH_AND_ALGORITHM); + const tasks = [ + { contest_id: 'math-and-algorithm', task_id: 'typical90_bz', task_table_index: '045' }, + { contest_id: 'math-and-algorithm', task_id: 'abc168_c', task_table_index: '036' }, + ]; + const headerIds = provider.getHeaderIdsForTask(tasks as TaskResults); + + expect(headerIds).toEqual(['036', '045']); + }); + + test('expects to handle section boundaries correctly (001-104)', () => { + const provider = new MathAndAlgorithmProvider(ContestType.MATH_AND_ALGORITHM); + const tasks = [ + { + contest_id: 'math-and-algorithm', + task_id: 'math_and_algorithm_a', + task_table_index: '001', + }, + { + contest_id: 'math-and-algorithm', + task_id: 'math_and_algorithm_bx', + task_table_index: '104', + }, + ]; + const headerIds = provider.getHeaderIdsForTask(tasks as TaskResults); + + expect(headerIds).toEqual(['001', '104']); + }); + + test('expects to handle empty task results', () => { + const provider = new MathAndAlgorithmProvider(ContestType.MATH_AND_ALGORITHM); + const filtered = provider.filter([] as TaskResults); + + expect(filtered).toEqual([] as TaskResults); + }); + + test('expects to handle task results with different contest types', () => { + const provider = new MathAndAlgorithmProvider(ContestType.MATH_AND_ALGORITHM); + const mixedTasks = [ + { + contest_id: 'math-and-algorithm', + task_id: 'math_and_algorithm_a', + task_table_index: '001', + }, + { contest_id: 'math-and-algorithm', task_id: 'arc117_c', task_table_index: '102' }, + { contest_id: 'typical90', task_id: 'typical90_a', task_table_index: 'A' }, + ]; + const filtered = provider.filter(mixedTasks as TaskResults); + + expect(filtered).toHaveLength(2); + expect(filtered?.every((task) => task.contest_id === 'math-and-algorithm')).toBe(true); + }); + }); + describe.each([ { providerClass: EDPCProvider, 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 f90af8280..7bfafcfb2 100644 --- a/src/test/lib/utils/test_cases/contest_table_provider.ts +++ b/src/test/lib/utils/test_cases/contest_table_provider.ts @@ -298,3 +298,43 @@ export const taskResultsForTessokuBookProvider: TaskResults = [ tessoku_c09, tessoku_c18, ]; + +// Math and Algorithm: 10 problems (028, 036, 042, 045, 046, 048, 052, 095, 101, 102) +// Sources: dp_*, abc*_*, typical90_*, arc*_* +// Problem indices follow the format: 3-digit numbers (001-104) +const [ + math_and_algorithm_028, + math_and_algorithm_036, + math_and_algorithm_042, + math_and_algorithm_045, + math_and_algorithm_046, + math_and_algorithm_048, + math_and_algorithm_052, + math_and_algorithm_095, + math_and_algorithm_101, + math_and_algorithm_102, +] = createContestTasks('math-and-algorithm', [ + { taskId: 'dp_a', taskTableIndex: '028', statusName: AC }, + { taskId: 'abc168_c', taskTableIndex: '036', statusName: AC }, + { taskId: 'abc172_d', taskTableIndex: '042', statusName: AC_WITH_EDITORIAL }, + { taskId: 'typical90_bz', taskTableIndex: '045', statusName: AC }, + { taskId: 'abc007_3', taskTableIndex: '046', statusName: TRYING }, + { taskId: 'arc084_b', taskTableIndex: '048', statusName: AC }, + { taskId: 'abc145_d', taskTableIndex: '052', statusName: PENDING }, + { taskId: 'typical90_j', taskTableIndex: '095', statusName: AC }, + { taskId: 'typical90_o', taskTableIndex: '101', statusName: AC }, + { taskId: 'arc117_c', taskTableIndex: '102', statusName: AC }, +]); + +export const taskResultsForMathAndAlgorithmProvider: TaskResults = [ + math_and_algorithm_028, + math_and_algorithm_036, + math_and_algorithm_042, + math_and_algorithm_045, + math_and_algorithm_046, + math_and_algorithm_048, + math_and_algorithm_052, + math_and_algorithm_095, + math_and_algorithm_101, + math_and_algorithm_102, +];