diff --git a/docs/guides/how-to-add-contest-table-provider.md b/docs/guides/how-to-add-contest-table-provider.md index 0e35f78bb..a9a035498 100644 --- a/docs/guides/how-to-add-contest-table-provider.md +++ b/docs/guides/how-to-add-contest-table-provider.md @@ -1,693 +1,145 @@ # ContestTableProvider 実装・テストガイド -## 概要 - -競技プログラミングコンテストサイト の各コンテスト種別に対応した `ContestTableProvider` を新規に実装・テストするときのガイドです。 - -**対象者**: JavaScript の基本知識がある開発者 - -**スコープ**: Provider クラス実装 + 単体テスト設計 +タスク一覧をコンテスト別テーブルとして描画するための `ContestTableProvider`(フィルタ条件 + メタデータ + レイアウト定義)を新規に追加するときのリファレンス。基底クラスは `ContestTableProviderBase`。TDD フロー・テスト規約は AGENTS.md、コーディングスタイルは `.claude/rules/` を参照。 --- -## 0. 実装前確認フェーズ - -新しい Provider を実装する前に、必ず以下の事項を確認してください。 - -### 事前確認チェックリスト - -- [ ] **ContestType の選択** - - 新規追加が必要か? → `src/lib/types/contest.ts` を確認 - - 既存の `ContestType` で対応できないか? → 「各コンテスト種別の特有仕様」を参照 - - 判断基準: 複数の異なる contest_id を統一表示するなら「複合型」の可能性 - -- [ ] **コンテスト分類の確認** - - `classifyContest()` が新しい `contest_id` に対して正しい `ContestType` を返すか確認 - - 既存の分類ロジック(`src/lib/utils/contest.ts`)に追加が必要か判断 - -- [ ] **データ存在確認** - - `prisma/tasks.ts` に該当 `contest_id` のタスクが存在するか確認 - - 複合型の場合は `prisma/contest_task_pairs.ts` で共有問題を確認 - -- [ ] **実装パターン判定** - - **パターン1(範囲フィルタ型)**: ABC 001~041、ARC 058~103 など → 数値範囲でフィルタ - - **パターン2(単一ソース型)**: EDPC、TDPC、ACL_PRACTICE など → 単一 contest_id のみ - - **パターン3(複合ソース型)**: ABS、ABC-Like など → 複数 contest_id を統一表示 - - **パターン4(コンストラクタパラメータ型)**: ICPC 国内予選など → 1クラスをコンストラクタ引数(年度等)で N 回インスタンス化 - - 対応セクション: [実装パターン](#実装パターン) - -- [ ] **JOI の contest_id サフィックス変更の確認** - - JOI 本選は 2026 年より `joi{YYYY}ho` → `joi{YYYY}sf` にサフィックスが変更された - - `JOISemiFinalRoundProvider` の regex は `(ho|sf)` にマッチするよう対応済み - - `getJoiContestLabel()` は `sf` → `'セミファイナルステージ'` を返す - - テーブルの行ラベル (`getContestRoundLabel()`) は `sf` でも年のみ返す - -- [ ] **ガイドの実装例の確認** - - 判定したパターンの実装例を確認してテンプレート理解 - -### 記入例 +## 事前確認 -```markdown -**新規 Provider 名**: ACLBeginnerProvider - -事前確認結果: - -- ContestType: ABC_LIKE(既存流用) -- contest_id: abl(prisma/tasks.ts に 6 つのタスク存在確認) -- パターン: パターン2(単一ソース型) -- テンプレート: EDPC と同一構造 - -→ 実装フェーズ開始 -``` - ---- - -## Test Driven Development (TDD) 設計ガイド - -新しい Provider を実装する際は、**テストファースト** のアプローチを推奨します。 - -### 実装フロー - -1. **テスト設計フェーズ** - - モックデータを定義 - - テストケースをリスト化(→ テストケース設計参照) - - 各テストの期待値を明確化 - -2. **テスト実装フェーズ** - - Vitest でテストコードを記述 - - Provider クラスをスケルトン実装(下記参照) - - `src/features/tasks/utils/contest-table/contest_table_provider_groups.ts` の `prepareContestProviderPresets()` と `contestTableProviderGroups` に登録 - -3. **Provider 実装フェーズ** - - テストが RED → GREEN になるまで段階的に実装 - - 実行: `pnpm test:unit src/features/tasks/utils/contest-table/` - -### スケルトン Provider の最小例 - -```typescript -export class MyNewProvider extends ContestTableProviderBase { - protected contestType = ContestType.MY_NEW; - - protected setFilterCondition(): (taskResult: TaskResult) => boolean { - return () => true; // テストをパスさせるための最小限の実装 - } - - getMetadata(): ContestTableMetaData { - return { title: '', abbreviationName: '' }; - } - - getDisplayConfig(): ContestTableDisplayConfig { - return { - /* 未定義 */ - // columnWrapThreshold?: number // optional: デフォルト8、AOJ系は6 - }; - } - - getContestRoundLabel(): string { - return ''; - } -} -``` - -### Provider 登録(テスト実行環境構築) - -**ファイル**: `src/features/tasks/utils/contest-table/contest_table_provider_groups.ts` - -```typescript -function prepareContestProviderPresets() { - return { - // ... 既存のコード - myNewProvider: () => - new ContestTableProviderGroup().addProvider(new MyNewProvider(ContestType.MY_NEW)), - }; -} - -const presets = prepareContestProviderPresets(); - -export const contestTableProviderGroups: Record = { - // ... 既存のコード - myNewProvider: presets.myNewProvider(), -}; -``` - -**重要**: これらを登録しないとテストが実行できません。 - -詳細な実装例は [Provider実装ディレクトリ](../src/features/tasks/utils/contest-table/) を参照してください。 +- [ ] `ContestType`(`src/lib/types/contest.ts`)— 既存で対応できるか?複数 contest_id を統一表示するなら複合型 +- [ ] `classifyContest()`(`src/lib/utils/contest.ts`)— 新 contest_id に正しい ContestType を返すか +- [ ] `prisma/tasks.ts` にデータが存在するか(複合型は `prisma/contest_task_pairs.ts` も) +- [ ] 実装パターン判定(後述4パターン) +- [ ] JOI: 2026 年より `joi{YYYY}ho` → `joi{YYYY}sf`。regex は `(ho|sf)` 対応済み --- ## 実装パターン -### パターン1: 範囲フィルタ型(ABC / ARC / AGC) - -**特徴**: - -- `contest_id` の範囲内でフィルタリング -- 表示: コンテストのラウンド名、ヘッダー -- 非表示: 問題 id +### パターン1: 範囲フィルタ型(ABC / ARC / AGC / AWC) -**実装例** - `setFilterCondition()` のコア部分: +`contest_id` の数値部分で範囲フィルタ。ラウンド名・ヘッダー表示、問題 id 非表示。 ```typescript protected setFilterCondition(): (taskResult: TaskResult) => boolean { return (taskResult: TaskResult) => { - if (classifyContest(taskResult.contest_id) !== this.contestType) { - return false; - } + 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/features/tasks/utils/contest-table/) を参照してください。 - -**注意**: ARC、AGC も同じパターン。範囲フィルタ型を参照してください。 - ---- - -### パターン2: 単一ソース型(EDPC / TDPC / FPS_24 / ACL_PRACTICE) - -**特徴**: +### パターン2: 単一ソース型(EDPC / TDPC / AWC0100 / AWC0150) -- 単一の `contest_id` のみフィルタリング -- セクション: 固定フォーマット(A~Z など) -- 表示: 問題 id -- 非表示: ラウンド名、ヘッダー - -**実装例** - `setFilterCondition()` のコア部分: +単一 `contest_id` のみフィルタ。問題 id 表示、ラウンド名・ヘッダー非表示。 ```typescript protected setFilterCondition(): (taskResult: TaskResult) => boolean { return (taskResult: TaskResult) => { - if (classifyContest(taskResult.contest_id) !== this.contestType) { - return false; - } + if (classifyContest(taskResult.contest_id) !== this.contestType) return false; return taskResult.contest_id === 'dp'; // ← contest_id だけ変更 }; } ``` -その他の実装は [Provider実装ディレクトリ](../src/features/tasks/utils/contest-table/) を参照してください。 - -**注意**: TDPC、FPS_24 も同じパターン。`contest_id` とメタデータだけ異なります。 - ---- - -### パターン4: コンストラクタパラメータ型(ICPC 国内予選) - -**特徴**: - -- 年度などのパラメータを受け取る1つのクラスを N 回インスタンス化 -- `super(contestType, String(year))` でセクションを年度文字列にし、プロバイダキーを `AOJ_ICPC::2025` のように一意化 -- 年度範囲定数(`OLDEST_YEAR` / `LATEST_YEAR`)をモジュールトップで `export` し、tests でも参照できるようにする -- グループ登録時は最新年から古い年へ降順ループし、テーブルを新しい順に並べる -- **固定セクションバリアント**: N 回インスタンス化ではなく、同一グループ内で同一 ContestType のプロバイダーを 2 種類共存させる場合も同じ仕組みが使える。一方に固定文字列を渡し(例: `super(contestType, '0100')` → key `AWC::0100`)、もう一方はセクションなし(key `AWC`)とすることで衝突を回避する - -**実装例**: - -```typescript -export const ICPC_PRELIM_OLDEST_YEAR = 1998; -export const ICPC_PRELIM_LATEST_YEAR = 2026; - -export class AojIcpcPrelimProvider extends ContestTableProviderBase { - private readonly year: number; - private readonly contestId: string; - - constructor(contestType: ContestType, year: number) { - super(contestType, String(year)); // provider key: AOJ_ICPC::2025 - this.year = year; - this.contestId = `ICPCPrelim${year}`; - } - // ... -} - -// グループ登録(最新年が上に来るよう降順) -for (let year = ICPC_PRELIM_LATEST_YEAR; year >= ICPC_PRELIM_OLDEST_YEAR; year--) { - group.addProvider(new AojIcpcPrelimProvider(ContestType.AOJ_ICPC, year)); -} -``` - -**注意**: - -- `task_table_index` が数値文字列キー(例: `'1664'`)の場合、ベースクラスの `getHeaderIdsForTask` は辞書順ソートになるため、**必ず数値昇順ソートでオーバーライド**すること。`generateTable`(ベースクラスが `getTaskTableHeaderName()` 経由で同じキーを使う)との一致が崩れるとセルが表示されない。`generateTable` 自体のオーバーライドは不要。 -- アルゴリズムが成立しない例外年度には上書き Map を用意する: - -```typescript -// contest_id -> (task_table_index -> letter). Used only for years with judge gaps. -export const AOJ_LABEL_OVERRIDES: Record> = {}; -``` - -上書き Map のテストは `beforeEach`/`afterEach` でエントリを直接追加・削除する(`vi.mock` 不要): - -```typescript -beforeEach(() => { - AOJ_LABEL_OVERRIDES['ICPCPrelimTest'] = { '1150': 'A', '1152': 'C' }; -}); -afterEach(() => { - delete AOJ_LABEL_OVERRIDES['ICPCPrelimTest']; -}); -``` - -- **表示ラベルは `getTaskLabels` で返し、`generateTable` ではタイトルを変更しない**。 - `generateTable` で `{ ...taskResult, title: \`${letter}. ${title}\` }`のような整形をすると、 -optimistic update で整形済みオブジェクトがソース配列に書き戻され、次の`$derived`再計算で -累積する(Issue [#3636](https://github.com/AtCoder-NoviSteps/AtCoderNoviSteps/issues/3636))。 -位置ラベルは`getTaskLabels(filtered)`が`{ [contestId]: { index: letter } }`を返し、`TaskTableBodyCell`の`$derived displayTitle`で`formatAojTitle` を呼ぶ設計にすること。 - -- provider 見出しのフォント・太字・余白をデフォルトから変えたい場合は `getMetadata()` で `titleStyle` を返す。`ContestTableTitleStyle`(`headingTag` / `fontSize` / `fontWeight` / `bottomGap`)のうち必要なフィールドだけ指定すればよい。 - -- **1つの論理単位を同一グループ内の2テーブルに分割する(同一クラスの2回インスタンス化)**: 例として JAG 2016 は同一年に 2 回開催され、seed 上は `JAGPrelim2016A` / `JAGPrelim2016B` に分かれる。コンストラクタに `suffix: '' | 'A' | 'B'` を追加し、`super(contestType, \`${year}${suffix}\`)`で provider key を一意化する(→`AOJ_JAG::2016A`)。分割対象は `Set`にデータとして持たせ、将来の分割年は 1 行追加で済むようにする。テストの`getSize()`は`年数スパン + 分割Setのサイズ` で検証する: - - ```typescript - const JAG_PRELIM_YEARS_HELD_AS_A_AND_B = new Set([2016]); - - if (JAG_PRELIM_YEARS_HELD_AS_A_AND_B.has(year)) { - group.addProvider(new JagPrelimProvider(type, year, 'A')); // 上 - group.addProvider(new JagPrelimProvider(type, year, 'B')); // 下 - } else { - group.addProvider(new JagPrelimProvider(type, year)); - } - ``` - - 上記の「固定セクションバリアント」(_異なる_ 2 クラスを共存させる)とは異なり、こちらは _同一_ クラスを 2 回インスタンス化する点に注意。 - ---- - ### パターン3: 複合ソース型(ABS / TESSOKU_BOOK / MATH_AND_ALGORITHM) -**特徴**: +複数の異なる contest/task_id を1つのテーブルに表示。`classifyContest()` のみでフィルタ。セクション分割が必要な場合は `task_table_index` の先頭文字でサブフィルタ(Tessoku Book 参照)。 -- 複数の異なる contest/task_id を1つのテーブルに表示 -- セクション分割可能(Tessoku Book のみ) -- 表示: 問題 id、セクション分割 -- 非表示: ラウンド名、ヘッダー +`getMergedTasksMap()` が複数コンテスト由来の task_id を自動統合。テストデータは [prisma/contest_task_pairs.ts](../../prisma/contest_task_pairs.ts) 参照。 -**実装例** - `setFilterCondition()` のコア部分: +### パターン4: コンストラクタパラメータ型(ICPC / JAG / JOI) -```typescript -protected setFilterCondition(): (taskResult: TaskResult) => boolean { - return (taskResult: TaskResult) => { - return classifyContest(taskResult.contest_id) === this.contestType; - }; -} -``` +年度などのパラメータを受け取る1クラスを N 回インスタンス化。 -**Tessoku Book(セクション分割あり)**: +- `super(contestType, String(year))` で provider key を一意化(例: `AOJ_ICPC::2025`) +- 年度範囲定数(`OLDEST_YEAR` / `LATEST_YEAR`)を `export` してテストでも参照 +- グループ登録は最新年→古い年の降順(`addProvider` 順 = 表示順、先 = 上) -セクション分割が必要な場合は、例えば `task_table_index` の先頭文字でフィルタリングします: +AOJ/JAG 固有の詳細(`AOJ_LABEL_OVERRIDES`、`titleStyle`、同一年2回開催の `suffix` 分割など)は `aoj_icpc_providers.ts` / `aoj_jag_providers.ts` を参照。 -```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(this.sectionPrefix) - ); - }; - } -} -``` +### 補足: 固定セクションによる同一 ContestType 内の共存 -その他の実装は [Provider実装ディレクトリ](../src/features/tasks/utils/contest-table/) を参照してください。 +同一 `ContestType` で異なるクラスを共存させる場合、`super(contestType, '0100')` のように固定文字列で provider key を一意化する(例: `AWC::0100`)。AWC0100 / AWC0150 はパターン2だが、範囲フィルタ型の AWC0001-0099 等と同じ `ContestType.AWC` グループに登録されている。 --- -## 各コンテスト種別の特有仕様 +## コンテスト種別リファレンス ### 範囲フィルタ型 -| コンテスト | 範囲 | フォーマット | セクション | ラベル | 特有の注意 | -| ------------- | ---------- | ------------ | ---------- | ------ | ------------- | -| 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問 | あり | - | -| AWC 0001-0099 | 0001~0099 | 0001 | A~E | あり | - | +| コンテスト | 範囲 | セクション | 備考 | +| ------------- | ---------- | ---------- | ------------- | +| ABC 001-041 | 001~041 | A~D | 旧形式 | +| ABC 042-125 | 042~125 | A~D | 共有問題(ARC) | +| ABC 126-211 | 126~211 | A~F | 6問制 | +| ABC 212-318 | 212~318 | A~Ex/H | 8問制 | +| ABC 319- | 319~ | A~G | 標準形式 | +| ARC 001-057 | 001~057 | A~D | 旧形式 | +| ARC 058-103 | 058~103 | C~F | 共有問題(ABC) | +| ARC 104- | 104~ | 4~6問 | - | +| AGC 001- | 001~ | 4~7問 | - | +| AWC 0001-0099 | 0001~0099 | A~E | - | +| AWC 0101-0149 | 0101~0149 | A~E | - | ### 単一ソース型 -| コンテスト | contest_id | セクション | フォーマット | -| -------------- | ------------- | ---------- | ------------ | -| EDPC | `'dp'` | 26問 | A~Z | -| TDPC | `'tdpc'` | 26問 | A~Z | -| NDPC | `'ndpc'` | 20問 | A~T | -| FPS_24 | `'fps-24'` | 24問 | A~X | -| ACL_PRACTICE | `'practice2'` | 12問 | A~L | -| ACL_BEGINNER\* | `'abl'` | 6問 | A~F | -| ACL_CONTEST1\* | `'acl1'` | 6問 | A~F | -| AWC0100† | `'awc0100'` | 15問 | A~O | - -\*注: ACL_PRACTICE、ACL_BEGINNER、ACL_CONTEST1 は `Acl` グループの下で 3 つのコンテストが統一管理されています。 -\*\*注: EDPC・TDPC・NDPC・FPS 24 は `dps` グループ下で 4 つのコンテストが統一管理されています。 -†注: ContestType.AWC を再利用し、section `'0100'` で同グループ内の AWC0001To0099Provider と共存(provider key = `AWC::0100`)。`getProvider(ContestType.AWC, '0100')` で取得。 +| コンテスト | contest_id | 問題数 | フォーマット | +| --------------------------- | ----------------------- | ------ | ------------ | +| EDPC | `'dp'` | 26問 | A~Z | +| TDPC | `'tdpc'` | 26問 | A~Z | +| NDPC | `'ndpc'` | 20問 | A~T | +| FPS_24 | `'fps-24'` | 24問 | A~X | +| ACL_PRACTICE | `'practice2'` | 12問 | A~L | +| ACL_BEGINNER / ACL_CONTEST1 | `'abl'`/`'acl1'` | 6問 | A~F | +| AWC0100 / AWC0150† | `'awc0100'`/`'awc0150'` | 15問 | A~O | + +†注: ContestType.AWC を再利用し、section(`'0100'`, `'0150'`)で provider key を一意化。ACL 系は `Acl` グループ、DP 系は `dps` グループで統一管理。 + +### コンストラクタパラメータ型 / regex フィルタ型 + +| コンテスト | フィルタ方式 | 備考 | +| --------------- | --------------- | -------------------------------------- | +| ICPC 国内予選 | 年度パラメータ | `AojIcpcPrelimProvider(type, year)` | +| ICPC アジア地区 | 年度パラメータ | `AojIcpcRegionalProvider(type, year)` | +| JAG 模擬国内 | 年度パラメータ | 同一年2回開催は suffix 分割 | +| JOI 一次予選 | regex | `joi{YYYY}yo1{a\|b\|c}` | +| JOI 二次予選 | regex + section | 2020 以降 / 2006-2019 で Provider 分離 | +| JOI 本選 | regex | 2026 年より `ho` → `sf` サフィックス | ### 複合ソース型 -| コンテスト | contest_id | 問題数 | セクション | 分割 | 複数コンテスト | -| ------------------ | ---------------------- | ------ | ------------ | ---- | ----------------------------------------- | -| ABS | `'abs'` | 11問 | A~K | なし | あり(11個) | -| ABC-Like | 計14コンテスト | 2~8問 | A~H | なし | あり(14個)、ABL は ACL と同じ区分で表示 | -| 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) を参照。 - ---- - -## テスト実装ガイド - -### 必須テスト項目(全 Provider 共通) - -1. 基本的なフィルタリング検証(contest_id / 型) -2. メタデータ取得(title、abbreviationName) -3. ディスプレイ設定確認(isShownHeader、isShownRoundLabel、columnWrapThreshold 等) -4. ラウンドラベルフォーマット(`getContestRoundLabel()`) -5. テーブル生成構造(問題数確認) -6. ヘッダー・ラウンドID取得 -7. 空入力ハンドリング - -### パターン固有テスト - -- **範囲フィルタ型**: 範囲境界値テスト、共有問題の有無確認。既存プロバイダーを上限付きに分割した場合は、隣接するもう一方のフィクスチャを結合して上限境界の除外を確認する: - - ```typescript - const combined = [...taskResultsForAWC0001To0099Provider, ...taskResultsForAWC0100Provider]; - const filtered = provider.filter(combined); - expect(filtered.some((task) => task.contest_id === 'awc0100')).toBe(false); - ``` - -- **複合ソース型**: 複数 contest_id 混在テスト、セクション分割ロジック - -### Vitest テスト例 - -```typescript -import { describe, test, expect } from 'vitest'; - -import { ContestType } from '$lib/types/contest'; -import type { TaskResults } from '$lib/types/task'; - -import { MyNewProvider } from './my_new_provider'; -import { taskResultsForMyNew } from '$features/tasks/fixtures/contest-table/contest_table_provider'; - -describe('MyNewProvider', () => { - test('filters tasks correctly', () => { - const provider = new MyNewProvider(ContestType.MY_NEW); - const filtered = provider.filter(taskResultsForMyNew); - expect(filtered.every((task) => task.contest_id === 'my-contest')).toBe(true); - }); - - test('returns correct metadata', () => { - const provider = new MyNewProvider(ContestType.MY_NEW); - expect(provider.getMetadata().title).toBe('Expected Title'); - }); - - test('returns correct display config', () => { - const provider = new MyNewProvider(ContestType.MY_NEW); - const config = provider.getDisplayConfig(); - expect(config.isShownHeader).toBe(true); - // columnWrapThreshold を明示する場合はアサーションを追加 - // expect(config.columnWrapThreshold).toBe(6); - }); -}); -``` - -### prepareContestProviderPresets() のテスト - -Provider クラスのテスト完了後、`prepareContestProviderPresets()` から返されるグループ(`ContestTableProviderGroup`)の動作を検証します。 - -**テスト項目**: - -1. **グループ名の確認** - - ```typescript - const group = prepareContestProviderPresets().MyNewProvider(); - expect(group.getGroupName()).toBe('Expected Group Name'); - ``` - -2. **グループメタデータの確認**(`buttonLabel`、`ariaLabel`) - - ```typescript - expect(group.getMetadata()).toEqual({ - buttonLabel: 'Button Label', - ariaLabel: 'ARIA Label', - }); - ``` - -3. **プロバイダー数の確認** - - **単一プロバイダーグループ**: `getSize()` が 1 を返す - - **複数プロバイダーグループ**: `getSize()` が複数を返す(例:ACL は 3) - -4. **プロバイダーインスタンス型の確認** - - **単一プロバイダー**: - ```typescript - expect(group.getProvider(ContestType.MY_NEW)).toBeInstanceOf(MyNewProvider); - ``` - - **複数プロバイダー**(セクション指定あり): - ```typescript - expect(group.getProvider(ContestType.TESSOKU_BOOK, 'examples')).toBeInstanceOf( - TessokuBookForExamplesProvider, - ); - expect(group.getProvider(ContestType.TESSOKU_BOOK, 'practicals')).toBeInstanceOf( - TessokuBookForPracticalsProvider, - ); - ``` - -5. **プリセット登録の確認** - - 新しい Provider を追加したら、`prepareContestProviderPresets()` の戻り値に含まれているか必ずテストを追加する: - - ```typescript - test('expects to verify all presets are functions', () => { - const presets = prepareContestProviderPresets(); - // 既存のアサーションに加えて新規分を追加 - expect(typeof presets.MyNewProvider).toBe('function'); - }); - ``` - - このテストを更新し忘れると、Provider を実装しても画面に表示されない場合に検出できない。 - -**セクション識別子の指定**: - -複数プロバイダーを含むグループでは、`getProvider()` の第2引数にセクション識別子を渡します。セクション定数は `src/features/tasks/types/contest-table/contest_table_provider.ts` で定義されています: - -- `TESSOKU_SECTIONS`: `examples`、`practicals`、`challenges` -- `JOI_SECOND_QUAL_ROUND_SECTIONS`: `'2020Onwards'`、`from2006To2019` -- `JOI_FINAL_ROUND_SECTIONS`: `semiFinal` - -詳細な実装例は [単体テストディレクトリ](../src/features/tasks/utils/contest-table/) の各 `*.test.ts` ファイルを参照してください。 - ---- - -### モックデータ準備 - -**ステップ1: データソース確認** - -- `prisma/tasks.ts`: contest_id、task_id フォーマット確認 -- `prisma/contest_task_pairs.ts`: 共有問題の確認 -- `prisma/schema.prisma`: task_table_index フォーマット確認 - -**ステップ2: ContestType 確認** - -- `src/lib/types/contest.ts` で定義済みか確認 -- `classifyContest()`(`src/lib/utils/contest.ts`)が新しい 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), - // ... 問題数分 -]; -``` +| コンテスト | 問題数 | セクション | 備考 | +| ------------------ | ------ | ------------ | --------------------------------- | +| ABS | 11問 | A~K | 11個の contest_id | +| ABC-Like | 2~8問 | A~H | 14コンテスト、ABL は ACL と同区分 | +| TESSOKU_BOOK | 166問 | A(01-77)/B/C | セクション分割あり | +| MATH_AND_ALGORITHM | 104問 | 001~104 | - | --- ## よくあるミス -### 1. **getDisplayConfig() での属性漏れ** - -**問題**: 一部の属性だけ定義し、他の属性(`isShownHeader` など)を未定義にすると、ベースクラスのデフォルト値が適用される - -**解決策**: `ContestTableDisplayConfig` の **全属性を明示的に定義** - -```typescript -getDisplayConfig() { - return { - isShownHeader: true, // 必ず指定 - isShownRoundLabel: true, // 必ず指定 - roundLabelWidth: 'xl:w-16', - tableBodyCellsWidth: 'w-8 h-8 px-1 py-1', - isShownTaskIndex: false, // 必ず指定 - // columnWrapThreshold?: number // optional: 省略時は8(デフォルト)、AOJ系は6 - }; -} -``` - ---- - -### 2. **複数コンテスト由来の問題を見落とし** - -**問題**: ABC042-125 の共有問題(ARC との同日開催)で `task_id` が `arc58_a` なのに `contest_id` は `abc042` のケースを処理し忘れ - -**解決策**: テストケースに **混合パターンを明示的に含める** - -```typescript -test('filters correctly with shared problems', () => { - const mixed = [ - { contest_id: 'abc042', task_id: 'abc042_a' }, // ABC側 - { contest_id: 'abc042', task_id: 'arc058_a' }, // 共有問題 - { contest_id: 'arc058', task_id: 'arc058_a' }, // ARC側 - ]; - // ABC042側の2つだけが返る -}); -``` - ---- - -### 3. **ソート順序の不安定** - -**問題**: 数字ソート(001, 028, 036, 102)と文字ソート(A, B, M, X)の混在で誤り - -**解決策**: - -```typescript -// ❌ 間違い -const sorted = indices.sort(); - -// ✅ 正解(数字フォーマット) -const sorted = indices.sort((a, b) => parseInt(a, 10) - parseInt(b, 10)); - -// ✅ 正解(英字フォーマット) -const sorted = indices.sort((a, b) => a.localeCompare(b)); -``` - ---- - -### 4. **パラメータ化テストでの共通化誤り** - -**問題**: `describe.each()` で複数 Provider テストを共通化したが、1つの Provider だけ `displayConfig` が異なるため全体が失敗 - -**解決策**: 異なる Provider は個別 `describe` ブロック - -```typescript -describe.each([...])('shared tests', () => { /* ... */ }); - -describe('CustomProvider with unique config', () => { - test('custom test', () => { /* ... */ }); -}); -``` - ---- - -### 5. **contestTypePriorities の JSDoc カテゴリ名を変更してしまう** - -**問題**: 新しい ContestType を挿入して数値範囲が変わったとき、既存の4カテゴリ名 -(`Educational` / `Contests for genius` / `Special contests` / `External platforms`)を -意図せず改名・分割・合体してしまい、歴史的経緯や分類上の意味が失われる。 - -**解決策**: **カテゴリ名は絶対に変更しない**。変えてよいのは括弧内の数値範囲だけ。 - -```typescript -// Before: [ContestType.TDPC, 5] ... [ContestType.PAST, 6] -// After inserting NDPC at 6: -// [ContestType.NDPC, 6], [ContestType.PAST, 7], ... - -// ✅ 数値範囲だけ更新 -// Educational contests (0–11, 17) -// Contests for genius (12–16) -// Special contests (18–20) -// External platforms (21–23) - -// ❌ カテゴリを改名・分割・合体しない -// Educational / DP contests (0–6) ← NG -``` - ---- - -### 6. `addProvider` の順序を意識しない - -**問題**: 同一グループ内の複数プロバイダーは `addProvider` の呼び出し順が画面上の表示順(先 = 上)になる。 - -**解決策**: 上に表示したいプロバイダーを先に `addProvider` する。 - -```typescript -// AWC0100 を AWC0001-0099 の上に表示する場合 -group - .addProvider(new AWC0100Provider(ContestType.AWC)) // 上 - .addProvider(new AWC0001To0099Provider(ContestType.AWC)); // 下 -``` - ---- - -### 7. 既存の分類 regex が新形状の contest_id を拾えない - -**問題**: 分類 branch(例: `regexForJag`)は既にあるが、新しい contest_id の形状 —— サフィックス付きや -同一年分割(`JAGPrelim2016A` / `JAGPrelim2016B` など)—— にマッチしない。その結果、管理画面 `/tasks` の -未登録タスク fetch(`classifyContest(id) !== null` が条件)から静かに漏れ、気づきにくい。 - -**解決策**: サフィックス付き・分割の contest_id を追加するときは、**全形状** の分類テストケースを追加し、 -既存 regex がそれらにマッチするか確認する。regex を広げると下流の利用箇所(`getContestNameLabel`、 -`AojGenerator.canHandle`)にも波及するため、副作用がないか併せて検証する。 +1. **ABC042-125 の共有問題** — `task_id` が `arc58_a` なのに `contest_id` は `abc042` のケースがある。テストに混合パターンを含めること +2. **数値ソートと辞書順の混在** — `indices.sort()` のデフォルト(辞書順)に頼らない。数字は `parseInt` 比較 +3. **contestTypePriorities の JSDoc カテゴリ名** — 4カテゴリ名(`Educational` / `Contests for genius` / `Special contests` / `External platforms`)は変更不可。括弧内の数値範囲のみ変更可 +4. **addProvider の順序** — 呼び出し順 = 表示順(先 = 上)。新しい回が上に来るように +5. **分類 regex の拡張** — 新形状の contest_id を追加するとき、regex を広げると `getContestNameLabel`・`AojGenerator.canHandle` にも波及する --- ## 実装完了後 -### ドキュメント更新チェックリスト - -- [ ] **実装例・テスト結果の報告** — dev-notes に実装教訓を記載 -- [ ] **各コンテスト種別テーブル** — 新規 Provider の行を追加(範囲フィルタ型 / 単一ソース型 / 複合ソース型) -- [ ] **実装パターン説明** — 複合型参照情報がある場合は該当セクション に追加 -- [ ] **このガイド(how-to-add-contest-table-provider.md)** — 「事前確認チェックリスト」に新規学習項目を追加 -- [ ] **参考資料** — GitHub Issues に当該 Provider (#xxxx) のリンクを追加 -- [ ] **最終更新日** — 現在日付に変更 +このガイドのリファレンステーブルに新 Provider の行を追加し、最終更新日を更新すること --- ## 参考資料 -### GitHub Issues - -- [#2919](https://github.com/AtCoder-NoviSteps/AtCoderNoviSteps/issues/2919) - ABS -- [#2830](https://github.com/AtCoder-NoviSteps/AtCoderNoviSteps/issues/2830) - ABC126ToABC211Provider -- [#2836](https://github.com/AtCoder-NoviSteps/AtCoderNoviSteps/issues/2836) - ABC042~125 & ARC058~103 -- [#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)、[#3108](https://github.com/AtCoder-NoviSteps/AtCoderNoviSteps/issues/3108) - ABCLikeProvider -- [#3153](https://github.com/AtCoder-NoviSteps/AtCoderNoviSteps/issues/3153) - AWC0001To0099Provider(旧: AWC0001OnwardsProvider)、AWC0100Provider -- [#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 -- [#2920](https://github.com/AtCoder-NoviSteps/AtCoderNoviSteps/issues/2920)、[#3120](https://github.com/AtCoder-NoviSteps/AtCoderNoviSteps/issues/3120) - ACLPracticeProvider、ACLBeginnerProvider、ACLProvider -- [#3152](https://github.com/AtCoder-NoviSteps/AtCoderNoviSteps/issues/3152) - JOISemiFinalRoundProvider(本選 → セミファイナルステージ への対応) -- NDPC実装 - NDPCProvider(パターン2: 単一ソース型、prisma/tasks.ts に 20 問存在) - -### 実装ファイル - -- [Provider 実装ディレクトリ](../src/features/tasks/utils/contest-table/) -- [単体テスト](../src/features/tasks/utils/contest-table/) (各 `*.test.ts` ファイル) -- [テストフィクスチャ](../src/features/tasks/fixtures/contest-table/contest_table_provider.ts) +- [Provider 実装・テスト・フィクスチャ](../../src/features/tasks/utils/contest-table/) +- 各 Provider の実装経緯: `git log --oneline --all --grep='#NNNN'`(issue 番号で検索) --- -**最終更新**: 2026-06-24 +**最終更新**: 2026-09-03 diff --git a/prisma/tasks.ts b/prisma/tasks.ts index be18958a1..8848b31fe 100755 --- a/prisma/tasks.ts +++ b/prisma/tasks.ts @@ -8354,6 +8354,111 @@ export const tasks = [ name: '4/N', title: 'C. 4/N', }, + { + id: 'awc0150_a', + contest_id: 'awc0150', + problem_index: 'A', + name: 'Dummy Problem A', + title: 'A. Dummy Problem A', + }, + { + id: 'awc0150_b', + contest_id: 'awc0150', + problem_index: 'B', + name: 'Dummy Problem B', + title: 'B. Dummy Problem B', + }, + { + id: 'awc0150_c', + contest_id: 'awc0150', + problem_index: 'C', + name: 'Dummy Problem C', + title: 'C. Dummy Problem C', + }, + { + id: 'awc0150_d', + contest_id: 'awc0150', + problem_index: 'D', + name: 'Dummy Problem D', + title: 'D. Dummy Problem D', + }, + { + id: 'awc0150_e', + contest_id: 'awc0150', + problem_index: 'E', + name: 'Dummy Problem E', + title: 'E. Dummy Problem E', + }, + { + id: 'awc0150_f', + contest_id: 'awc0150', + problem_index: 'F', + name: 'Dummy Problem F', + title: 'F. Dummy Problem F', + }, + { + id: 'awc0150_g', + contest_id: 'awc0150', + problem_index: 'G', + name: 'Dummy Problem G', + title: 'G. Dummy Problem G', + }, + { + id: 'awc0150_h', + contest_id: 'awc0150', + problem_index: 'H', + name: 'Dummy Problem H', + title: 'H. Dummy Problem H', + }, + { + id: 'awc0150_i', + contest_id: 'awc0150', + problem_index: 'I', + name: 'Dummy Problem I', + title: 'I. Dummy Problem I', + }, + { + id: 'awc0150_j', + contest_id: 'awc0150', + problem_index: 'J', + name: 'Dummy Problem J', + title: 'J. Dummy Problem J', + }, + { + id: 'awc0150_k', + contest_id: 'awc0150', + problem_index: 'K', + name: 'Dummy Problem K', + title: 'K. Dummy Problem K', + }, + { + id: 'awc0150_l', + contest_id: 'awc0150', + problem_index: 'L', + name: 'Dummy Problem L', + title: 'L. Dummy Problem L', + }, + { + id: 'awc0150_m', + contest_id: 'awc0150', + problem_index: 'M', + name: 'Dummy Problem M', + title: 'M. Dummy Problem M', + }, + { + id: 'awc0150_n', + contest_id: 'awc0150', + problem_index: 'N', + name: 'Dummy Problem N', + title: 'N. Dummy Problem N', + }, + { + id: 'awc0150_o', + contest_id: 'awc0150', + problem_index: 'O', + name: 'Dummy Problem O', + title: 'O. Dummy Problem O', + }, { id: 'awc0101_a', contest_id: 'awc0101', diff --git a/src/features/tasks/fixtures/contest-table/contest_table_provider.ts b/src/features/tasks/fixtures/contest-table/contest_table_provider.ts index b1231e7fa..5ee534416 100644 --- a/src/features/tasks/fixtures/contest-table/contest_table_provider.ts +++ b/src/features/tasks/fixtures/contest-table/contest_table_provider.ts @@ -951,15 +951,16 @@ const [awc0102_a, awc0102_b, awc0102_c, awc0102_d, awc0102_e] = createContestTas { taskTableIndex: 'E', statusName: AC }, ]); -const [awc9999_a, awc9999_b, awc9999_c, awc9999_d, awc9999_e] = createContestTasks('awc9999', [ +// AWC 0101-0149: 5 tasks (A, B, C, D, E) +const [awc0149_a, awc0149_b, awc0149_c, awc0149_d, awc0149_e] = createContestTasks('awc0149', [ { taskTableIndex: 'A', statusName: AC }, - { taskTableIndex: 'B', statusName: PENDING }, + { taskTableIndex: 'B', statusName: AC_WITH_EDITORIAL }, { taskTableIndex: 'C', statusName: AC }, - { taskTableIndex: 'D', statusName: TRYING }, - { taskTableIndex: 'E', statusName: AC_WITH_EDITORIAL }, + { taskTableIndex: 'D', statusName: PENDING }, + { taskTableIndex: 'E', statusName: TRYING }, ]); -export const taskResultsForAWC0101OnwardsProvider: TaskResults = [ +export const taskResultsForAWC0101To0149Provider: TaskResults = [ awc0101_a, awc0101_b, awc0101_c, @@ -970,9 +971,62 @@ export const taskResultsForAWC0101OnwardsProvider: TaskResults = [ awc0102_c, awc0102_d, awc0102_e, - awc9999_a, - awc9999_b, - awc9999_c, - awc9999_d, - awc9999_e, + awc0149_a, + awc0149_b, + awc0149_c, + awc0149_d, + awc0149_e, +]; + +// AWC0150 (special edition): 15 tasks (A-O) +const [ + awc0150_a, + awc0150_b, + awc0150_c, + awc0150_d, + awc0150_e, + awc0150_f, + awc0150_g, + awc0150_h, + awc0150_i, + awc0150_j, + awc0150_k, + awc0150_l, + awc0150_m, + awc0150_n, + awc0150_o, +] = createContestTasks('awc0150', [ + { taskTableIndex: 'A', statusName: AC }, + { taskTableIndex: 'B', statusName: AC }, + { taskTableIndex: 'C', statusName: AC_WITH_EDITORIAL }, + { taskTableIndex: 'D', statusName: AC }, + { taskTableIndex: 'E', statusName: TRYING }, + { taskTableIndex: 'F', statusName: AC_WITH_EDITORIAL }, + { taskTableIndex: 'G', statusName: TRYING }, + { taskTableIndex: 'H', statusName: PENDING }, + { taskTableIndex: 'I', statusName: PENDING }, + { taskTableIndex: 'J', statusName: PENDING }, + { taskTableIndex: 'K', statusName: PENDING }, + { taskTableIndex: 'L', statusName: PENDING }, + { taskTableIndex: 'M', statusName: PENDING }, + { taskTableIndex: 'N', statusName: PENDING }, + { taskTableIndex: 'O', statusName: PENDING }, +]); + +export const taskResultsForAWC0150Provider: TaskResults = [ + awc0150_a, + awc0150_b, + awc0150_c, + awc0150_d, + awc0150_e, + awc0150_f, + awc0150_g, + awc0150_h, + awc0150_i, + awc0150_j, + awc0150_k, + awc0150_l, + awc0150_m, + awc0150_n, + awc0150_o, ]; diff --git a/src/features/tasks/utils/contest-table/awc_provider.test.ts b/src/features/tasks/utils/contest-table/awc_provider.test.ts index 3664db079..f07a477bd 100644 --- a/src/features/tasks/utils/contest-table/awc_provider.test.ts +++ b/src/features/tasks/utils/contest-table/awc_provider.test.ts @@ -3,121 +3,129 @@ import { describe, test, expect } from 'vitest'; import { ContestType } from '$lib/types/contest'; import type { TaskResults } from '$lib/types/task'; -import { AWC0001To0099Provider, AWC0100Provider, AWC0101OnwardsProvider } from './awc_provider'; +import { + AWC0001To0099Provider, + AWC0100Provider, + AWC0101To0149Provider, + AWC0150Provider, +} from './awc_provider'; import { taskResultsForAWC0001To0099Provider, taskResultsForAWC0100Provider, - taskResultsForAWC0101OnwardsProvider, + taskResultsForAWC0101To0149Provider, + taskResultsForAWC0150Provider, } from '$features/tasks/fixtures/contest-table/contest_table_provider'; -describe('AWC0001To0099Provider', () => { - test('expects to filter tasks to include only AWC contests', () => { - const provider = new AWC0001To0099Provider(ContestType.AWC); - const filtered = provider.filter(taskResultsForAWC0001To0099Provider); +describe('AWC0100Provider', () => { + test('expects to filter only awc0100 tasks', () => { + const provider = new AWC0100Provider(ContestType.AWC); + const combined = [...taskResultsForAWC0001To0099Provider, ...taskResultsForAWC0100Provider]; + const filtered = provider.filter(combined); - expect(filtered.length).toBeGreaterThan(0); - expect(filtered.every((task) => task.contest_id.startsWith('awc'))).toBe(true); - expect(filtered.every((task) => /^awc\d{4}$/.test(task.contest_id))).toBe(true); + expect(filtered.length).toBe(15); + expect(filtered.every((task) => task.contest_id === 'awc0100')).toBe(true); }); - test('expects to filter by range (awc0001 to awc0099)', () => { - const provider = new AWC0001To0099Provider(ContestType.AWC); + test('expects to exclude awc0001 and awc0099', () => { + const provider = new AWC0100Provider(ContestType.AWC); const combined = [...taskResultsForAWC0001To0099Provider, ...taskResultsForAWC0100Provider]; const filtered = provider.filter(combined); - expect(filtered.some((task) => task.contest_id === 'awc0001')).toBe(true); - expect(filtered.some((task) => task.contest_id === 'awc0002')).toBe(true); - expect(filtered.some((task) => task.contest_id === 'awc0099')).toBe(true); - expect(filtered.some((task) => task.contest_id === 'awc0100')).toBe(false); + expect(filtered.some((task) => task.contest_id === 'awc0001')).toBe(false); + expect(filtered.some((task) => task.contest_id === 'awc0099')).toBe(false); }); test('expects to get correct metadata', () => { - const provider = new AWC0001To0099Provider(ContestType.AWC); + const provider = new AWC0100Provider(ContestType.AWC); const metadata = provider.getMetadata(); - expect(metadata.title).toBe('AtCoder Weekday Contest 0001 〜 0099'); - expect(metadata.abbreviationName).toBe('awc0001To0099'); + expect(metadata.title).toBe('AtCoder Weekday Contest 0100'); + expect(metadata.abbreviationName).toBe('awc0100'); }); - test('expects to return correct display config', () => { - const provider = new AWC0001To0099Provider(ContestType.AWC); + test('expects to return correct display config (EDPC format)', () => { + const provider = new AWC0100Provider(ContestType.AWC); const config = provider.getDisplayConfig(); - expect(config.isShownHeader).toBe(true); - expect(config.isShownRoundLabel).toBe(true); - expect(config.tableBodyCellsWidth).toBe('w-1/2 sm:w-1/3 md:w-1/4 lg:w-1/5 px-1 py-1'); - expect(config.roundLabelWidth).toBe('xl:w-16'); - expect(config.isShownTaskIndex).toBe(false); - }); - - test('expects to format contest round label correctly', () => { - const provider = new AWC0001To0099Provider(ContestType.AWC); - - expect(provider.getContestRoundLabel('awc0001')).toBe('0001'); - expect(provider.getContestRoundLabel('awc0002')).toBe('0002'); - expect(provider.getContestRoundLabel('awc0099')).toBe('0099'); + expect(config.isShownHeader).toBe(false); + expect(config.isShownRoundLabel).toBe(false); + expect(config.roundLabelWidth).toBe(''); + expect(config.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(config.isShownTaskIndex).toBe(true); }); - test('expects to generate table for multiple AWC contests', () => { - const provider = new AWC0001To0099Provider(ContestType.AWC); - const filtered = provider.filter(taskResultsForAWC0001To0099Provider); - const table = provider.generateTable(filtered); + test('expects getContestRoundLabel to return empty string', () => { + const provider = new AWC0100Provider(ContestType.AWC); - expect(Object.keys(table).length).toBeGreaterThan(0); - expect(table).toHaveProperty('awc0001'); - expect(table).toHaveProperty('awc0002'); - expect(table).toHaveProperty('awc0099'); + expect(provider.getContestRoundLabel('awc0100')).toBe(''); }); - test('expects each AWC contest to have 5 problems (A-E)', () => { - const provider = new AWC0001To0099Provider(ContestType.AWC); - const filtered = provider.filter(taskResultsForAWC0001To0099Provider); + test('expects generateTable to produce 15 tasks (A-O) for awc0100', () => { + const provider = new AWC0100Provider(ContestType.AWC); + const filtered = provider.filter(taskResultsForAWC0100Provider); const table = provider.generateTable(filtered); - Object.entries(table).forEach(([_contestId, problems]) => { - const problemCount = Object.keys(problems).length; - expect(problemCount).toBe(5); - expect(Object.keys(problems)).toEqual(['A', 'B', 'C', 'D', 'E']); - }); + expect(table).toHaveProperty('awc0100'); + const problems = table['awc0100']; + expect(Object.keys(problems)).toHaveLength(15); + expect(Object.keys(problems)).toEqual([ + 'A', + 'B', + 'C', + 'D', + 'E', + 'F', + 'G', + 'H', + 'I', + 'J', + 'K', + 'L', + 'M', + 'N', + 'O', + ]); }); test('expects to handle empty task results', () => { - const provider = new AWC0001To0099Provider(ContestType.AWC); + const provider = new AWC0100Provider(ContestType.AWC); const filtered = provider.filter([] as TaskResults); expect(filtered).toEqual([] as TaskResults); }); }); -describe('AWC0100Provider', () => { - test('expects to filter only awc0100 tasks', () => { - const provider = new AWC0100Provider(ContestType.AWC); - const combined = [...taskResultsForAWC0001To0099Provider, ...taskResultsForAWC0100Provider]; +describe('AWC0150Provider', () => { + test('expects to filter only awc0150 tasks', () => { + const provider = new AWC0150Provider(ContestType.AWC); + const combined = [...taskResultsForAWC0101To0149Provider, ...taskResultsForAWC0150Provider]; const filtered = provider.filter(combined); expect(filtered.length).toBe(15); - expect(filtered.every((task) => task.contest_id === 'awc0100')).toBe(true); + expect(filtered.every((task) => task.contest_id === 'awc0150')).toBe(true); }); - test('expects to exclude awc0001 and awc0099', () => { - const provider = new AWC0100Provider(ContestType.AWC); - const combined = [...taskResultsForAWC0001To0099Provider, ...taskResultsForAWC0100Provider]; + test('expects to exclude awc0101 and awc0149', () => { + const provider = new AWC0150Provider(ContestType.AWC); + const combined = [...taskResultsForAWC0101To0149Provider, ...taskResultsForAWC0150Provider]; const filtered = provider.filter(combined); - expect(filtered.some((task) => task.contest_id === 'awc0001')).toBe(false); - expect(filtered.some((task) => task.contest_id === 'awc0099')).toBe(false); + expect(filtered.some((task) => task.contest_id === 'awc0101')).toBe(false); + expect(filtered.some((task) => task.contest_id === 'awc0149')).toBe(false); }); test('expects to get correct metadata', () => { - const provider = new AWC0100Provider(ContestType.AWC); + const provider = new AWC0150Provider(ContestType.AWC); const metadata = provider.getMetadata(); - expect(metadata.title).toBe('AtCoder Weekday Contest 0100'); - expect(metadata.abbreviationName).toBe('awc0100'); + expect(metadata.title).toBe('AtCoder Weekday Contest 0150'); + expect(metadata.abbreviationName).toBe('awc0150'); }); test('expects to return correct display config (EDPC format)', () => { - const provider = new AWC0100Provider(ContestType.AWC); + const provider = new AWC0150Provider(ContestType.AWC); const config = provider.getDisplayConfig(); expect(config.isShownHeader).toBe(false); @@ -130,18 +138,18 @@ describe('AWC0100Provider', () => { }); test('expects getContestRoundLabel to return empty string', () => { - const provider = new AWC0100Provider(ContestType.AWC); + const provider = new AWC0150Provider(ContestType.AWC); - expect(provider.getContestRoundLabel('awc0100')).toBe(''); + expect(provider.getContestRoundLabel('awc0150')).toBe(''); }); - test('expects generateTable to produce 15 tasks (A-O) for awc0100', () => { - const provider = new AWC0100Provider(ContestType.AWC); - const filtered = provider.filter(taskResultsForAWC0100Provider); + test('expects generateTable to produce 15 tasks (A-O) for awc0150', () => { + const provider = new AWC0150Provider(ContestType.AWC); + const filtered = provider.filter(taskResultsForAWC0150Provider); const table = provider.generateTable(filtered); - expect(table).toHaveProperty('awc0100'); - const problems = table['awc0100']; + expect(table).toHaveProperty('awc0150'); + const problems = table['awc0150']; expect(Object.keys(problems)).toHaveLength(15); expect(Object.keys(problems)).toEqual([ 'A', @@ -163,49 +171,50 @@ describe('AWC0100Provider', () => { }); test('expects to handle empty task results', () => { - const provider = new AWC0100Provider(ContestType.AWC); + const provider = new AWC0150Provider(ContestType.AWC); const filtered = provider.filter([] as TaskResults); expect(filtered).toEqual([] as TaskResults); }); }); -describe('AWC0101OnwardsProvider', () => { - test('expects to filter tasks to include only AWC 0101 onwards contests', () => { - const provider = new AWC0101OnwardsProvider(ContestType.AWC); - const filtered = provider.filter(taskResultsForAWC0101OnwardsProvider); +describe('AWC0101To0149Provider', () => { + test('expects to filter tasks to include only AWC 0101-0149 contests', () => { + const provider = new AWC0101To0149Provider(ContestType.AWC); + const filtered = provider.filter(taskResultsForAWC0101To0149Provider); expect(filtered.length).toBeGreaterThan(0); expect(filtered.every((task) => task.contest_id.startsWith('awc'))).toBe(true); }); - test('expects to filter by range (awc0101 to awc9999)', () => { - const provider = new AWC0101OnwardsProvider(ContestType.AWC); + test('expects to filter by range (awc0101 to awc0149)', () => { + const provider = new AWC0101To0149Provider(ContestType.AWC); const combined = [ ...taskResultsForAWC0001To0099Provider, ...taskResultsForAWC0100Provider, - ...taskResultsForAWC0101OnwardsProvider, + ...taskResultsForAWC0101To0149Provider, + ...taskResultsForAWC0150Provider, ]; const filtered = provider.filter(combined); expect(filtered.some((task) => task.contest_id === 'awc0101')).toBe(true); expect(filtered.some((task) => task.contest_id === 'awc0102')).toBe(true); - expect(filtered.some((task) => task.contest_id === 'awc9999')).toBe(true); - expect(filtered.some((task) => task.contest_id === 'awc0001')).toBe(false); + expect(filtered.some((task) => task.contest_id === 'awc0149')).toBe(true); expect(filtered.some((task) => task.contest_id === 'awc0099')).toBe(false); expect(filtered.some((task) => task.contest_id === 'awc0100')).toBe(false); + expect(filtered.some((task) => task.contest_id === 'awc0150')).toBe(false); }); test('expects to get correct metadata', () => { - const provider = new AWC0101OnwardsProvider(ContestType.AWC); + const provider = new AWC0101To0149Provider(ContestType.AWC); const metadata = provider.getMetadata(); - expect(metadata.title).toBe('AtCoder Weekday Contest 0101 〜 '); - expect(metadata.abbreviationName).toBe('awc0101Onwards'); + expect(metadata.title).toBe('AtCoder Weekday Contest 0101 〜 0149'); + expect(metadata.abbreviationName).toBe('awc0101To0149'); }); test('expects to return correct display config', () => { - const provider = new AWC0101OnwardsProvider(ContestType.AWC); + const provider = new AWC0101To0149Provider(ContestType.AWC); const config = provider.getDisplayConfig(); expect(config.isShownHeader).toBe(true); @@ -216,27 +225,105 @@ describe('AWC0101OnwardsProvider', () => { }); test('expects to format contest round label correctly', () => { - const provider = new AWC0101OnwardsProvider(ContestType.AWC); + const provider = new AWC0101To0149Provider(ContestType.AWC); expect(provider.getContestRoundLabel('awc0101')).toBe('0101'); - expect(provider.getContestRoundLabel('awc0102')).toBe('0102'); - expect(provider.getContestRoundLabel('awc9999')).toBe('9999'); + expect(provider.getContestRoundLabel('awc0149')).toBe('0149'); }); test('expects to generate table for multiple AWC contests', () => { - const provider = new AWC0101OnwardsProvider(ContestType.AWC); - const filtered = provider.filter(taskResultsForAWC0101OnwardsProvider); + const provider = new AWC0101To0149Provider(ContestType.AWC); + const filtered = provider.filter(taskResultsForAWC0101To0149Provider); const table = provider.generateTable(filtered); expect(Object.keys(table).length).toBeGreaterThan(0); expect(table).toHaveProperty('awc0101'); expect(table).toHaveProperty('awc0102'); - expect(table).toHaveProperty('awc9999'); + expect(table).toHaveProperty('awc0149'); + }); + + test('expects each AWC contest to have 5 problems (A-E)', () => { + const provider = new AWC0101To0149Provider(ContestType.AWC); + const filtered = provider.filter(taskResultsForAWC0101To0149Provider); + const table = provider.generateTable(filtered); + + Object.entries(table).forEach(([_contestId, problems]) => { + const problemCount = Object.keys(problems).length; + expect(problemCount).toBe(5); + expect(Object.keys(problems)).toEqual(['A', 'B', 'C', 'D', 'E']); + }); + }); + + test('expects to handle empty task results', () => { + const provider = new AWC0101To0149Provider(ContestType.AWC); + const filtered = provider.filter([] as TaskResults); + + expect(filtered).toEqual([] as TaskResults); + }); +}); + +describe('AWC0001To0099Provider', () => { + test('expects to filter tasks to include only AWC contests', () => { + const provider = new AWC0001To0099Provider(ContestType.AWC); + const filtered = provider.filter(taskResultsForAWC0001To0099Provider); + + expect(filtered.length).toBeGreaterThan(0); + expect(filtered.every((task) => task.contest_id.startsWith('awc'))).toBe(true); + expect(filtered.every((task) => /^awc\d{4}$/.test(task.contest_id))).toBe(true); + }); + + test('expects to filter by range (awc0001 to awc0099)', () => { + const provider = new AWC0001To0099Provider(ContestType.AWC); + const combined = [...taskResultsForAWC0001To0099Provider, ...taskResultsForAWC0100Provider]; + const filtered = provider.filter(combined); + + expect(filtered.some((task) => task.contest_id === 'awc0001')).toBe(true); + expect(filtered.some((task) => task.contest_id === 'awc0002')).toBe(true); + expect(filtered.some((task) => task.contest_id === 'awc0099')).toBe(true); + expect(filtered.some((task) => task.contest_id === 'awc0100')).toBe(false); + }); + + test('expects to get correct metadata', () => { + const provider = new AWC0001To0099Provider(ContestType.AWC); + const metadata = provider.getMetadata(); + + expect(metadata.title).toBe('AtCoder Weekday Contest 0001 〜 0099'); + expect(metadata.abbreviationName).toBe('awc0001To0099'); + }); + + test('expects to return correct display config', () => { + const provider = new AWC0001To0099Provider(ContestType.AWC); + const config = provider.getDisplayConfig(); + + expect(config.isShownHeader).toBe(true); + expect(config.isShownRoundLabel).toBe(true); + expect(config.tableBodyCellsWidth).toBe('w-1/2 sm:w-1/3 md:w-1/4 lg:w-1/5 px-1 py-1'); + expect(config.roundLabelWidth).toBe('xl:w-16'); + expect(config.isShownTaskIndex).toBe(false); + }); + + test('expects to format contest round label correctly', () => { + const provider = new AWC0001To0099Provider(ContestType.AWC); + + expect(provider.getContestRoundLabel('awc0001')).toBe('0001'); + expect(provider.getContestRoundLabel('awc0002')).toBe('0002'); + expect(provider.getContestRoundLabel('awc0099')).toBe('0099'); + }); + + test('expects to generate table for multiple AWC contests', () => { + const provider = new AWC0001To0099Provider(ContestType.AWC); + const filtered = provider.filter(taskResultsForAWC0001To0099Provider); + const table = provider.generateTable(filtered); + + expect(Object.keys(table).length).toBeGreaterThan(0); + expect(table).toHaveProperty('awc0001'); + expect(table).toHaveProperty('awc0002'); + expect(table).toHaveProperty('awc0099'); }); test('expects each AWC contest to have 5 problems (A-E)', () => { - const provider = new AWC0101OnwardsProvider(ContestType.AWC); - const filtered = provider.filter(taskResultsForAWC0101OnwardsProvider); + const provider = new AWC0001To0099Provider(ContestType.AWC); + const filtered = provider.filter(taskResultsForAWC0001To0099Provider); const table = provider.generateTable(filtered); Object.entries(table).forEach(([_contestId, problems]) => { @@ -247,7 +334,7 @@ describe('AWC0101OnwardsProvider', () => { }); test('expects to handle empty task results', () => { - const provider = new AWC0101OnwardsProvider(ContestType.AWC); + const provider = new AWC0001To0099Provider(ContestType.AWC); const filtered = provider.filter([] as TaskResults); expect(filtered).toEqual([] as TaskResults); diff --git a/src/features/tasks/utils/contest-table/awc_provider.ts b/src/features/tasks/utils/contest-table/awc_provider.ts index 8b1f686cc..0016aa98d 100644 --- a/src/features/tasks/utils/contest-table/awc_provider.ts +++ b/src/features/tasks/utils/contest-table/awc_provider.ts @@ -9,23 +9,62 @@ import { classifyContest, getContestNameLabel } from '$lib/utils/contest'; import { ContestTableProviderBase, parseContestRound } from './contest_table_provider_base'; -// AWC0001 〜 0099 (2026/02/09 〜 2026/06/25) -// 5 tasks per contest -export class AWC0001To0099Provider extends ContestTableProviderBase { +// AWC0150 (special edition, 15 tasks: A-O) +export class AWC0150Provider extends ContestTableProviderBase { + constructor(contestType: ContestType) { + super(contestType, '0150'); + } + + protected setFilterCondition(): (taskResult: TaskResult) => boolean { + return (taskResult: TaskResult) => { + if (classifyContest(taskResult.contest_id) !== this.contestType) { + return false; + } + + return taskResult.contest_id === 'awc0150'; + }; + } + + getMetadata(): ContestTableMetaData { + return { title: 'AtCoder Weekday Contest 0150', abbreviationName: 'awc0150' }; + } + + 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 ''; + } +} + +// AWC0101 〜 0149 (5 tasks per contest) +export class AWC0101To0149Provider extends ContestTableProviderBase { + constructor(contestType: ContestType) { + super(contestType, '0101To0149'); + } + protected setFilterCondition(): (taskResult: TaskResult) => boolean { return (taskResult: TaskResult) => { if (classifyContest(taskResult.contest_id) !== this.contestType) { return false; } + const contestRound = parseContestRound(taskResult.contest_id, 'awc'); - return contestRound >= 1 && contestRound <= 99; + return contestRound >= 101 && contestRound <= 149; }; } getMetadata(): ContestTableMetaData { return { - title: 'AtCoder Weekday Contest 0001 〜 0099', - abbreviationName: 'awc0001To0099', + title: 'AtCoder Weekday Contest 0101 〜 0149', + abbreviationName: 'awc0101To0149', }; } @@ -80,28 +119,23 @@ export class AWC0100Provider extends ContestTableProviderBase { } } -// AWC0101 onwards (2026/06/29 〜 ) -// 5 tasks per contest. Upper bound 9999 = max of 4-digit format (cf. ARC104Onwards uses <= 999). -export class AWC0101OnwardsProvider extends ContestTableProviderBase { - constructor(contestType: ContestType) { - super(contestType, '0101To9999'); - } - +// AWC0001 〜 0099 (2026/02/09 〜 2026/06/25) +// 5 tasks per contest +export class AWC0001To0099Provider 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, 'awc'); - return contestRound >= 101 && contestRound <= 9999; + return contestRound >= 1 && contestRound <= 99; }; } getMetadata(): ContestTableMetaData { return { - title: 'AtCoder Weekday Contest 0101 〜 ', - abbreviationName: 'awc0101Onwards', + title: 'AtCoder Weekday Contest 0001 〜 0099', + abbreviationName: 'awc0001To0099', }; } diff --git a/src/features/tasks/utils/contest-table/contest_table_provider_groups.test.ts b/src/features/tasks/utils/contest-table/contest_table_provider_groups.test.ts index 5a14286ec..1020f8d9e 100644 --- a/src/features/tasks/utils/contest-table/contest_table_provider_groups.test.ts +++ b/src/features/tasks/utils/contest-table/contest_table_provider_groups.test.ts @@ -16,7 +16,8 @@ import { ABCLikeProvider, AWC0001To0099Provider, AWC0100Provider, - AWC0101OnwardsProvider, + AWC0101To0149Provider, + AWC0150Provider, ACLPracticeProvider, ACLBeginnerProvider, ACLProvider, @@ -190,8 +191,9 @@ describe('prepareContestProviderPresets', () => { buttonLabel: 'AWC 0001 〜 ', ariaLabel: 'Filter contests from AWC 0001 onwards', }); - expect(group.getSize()).toBe(3); - expect(group.getProvider(ContestType.AWC, '0101To9999')).toBeInstanceOf(AWC0101OnwardsProvider); + expect(group.getSize()).toBe(4); + expect(group.getProvider(ContestType.AWC, '0150')).toBeInstanceOf(AWC0150Provider); + expect(group.getProvider(ContestType.AWC, '0101To0149')).toBeInstanceOf(AWC0101To0149Provider); expect(group.getProvider(ContestType.AWC, '0100')).toBeInstanceOf(AWC0100Provider); expect(group.getProvider(ContestType.AWC)).toBeInstanceOf(AWC0001To0099Provider); }); diff --git a/src/features/tasks/utils/contest-table/contest_table_provider_groups.ts b/src/features/tasks/utils/contest-table/contest_table_provider_groups.ts index 3838637c0..36d6b6335 100644 --- a/src/features/tasks/utils/contest-table/contest_table_provider_groups.ts +++ b/src/features/tasks/utils/contest-table/contest_table_provider_groups.ts @@ -15,7 +15,12 @@ import { } from './arc_providers'; import { AGC001OnwardsProvider } from './agc_provider'; import { ABCLikeProvider } from './axc_like_provider'; -import { AWC0001To0099Provider, AWC0100Provider, AWC0101OnwardsProvider } from './awc_provider'; +import { + AWC0001To0099Provider, + AWC0100Provider, + AWC0101To0149Provider, + AWC0150Provider, +} from './awc_provider'; import { Typical90Provider } from './typical90_provider'; import { TessokuBookForExamplesProvider, @@ -161,7 +166,8 @@ export const prepareContestProviderPresets = () => { buttonLabel: 'AWC 0001 〜 ', ariaLabel: 'Filter contests from AWC 0001 onwards', }) - .addProvider(new AWC0101OnwardsProvider(ContestType.AWC)) + .addProvider(new AWC0150Provider(ContestType.AWC)) + .addProvider(new AWC0101To0149Provider(ContestType.AWC)) .addProvider(new AWC0100Provider(ContestType.AWC)) .addProvider(new AWC0001To0099Provider(ContestType.AWC)),