From 3ae691cd22cce6f4375f20ed1b7880948910473a Mon Sep 17 00:00:00 2001 From: "k.hiro1818" Date: Sat, 5 Sep 2026 12:07:20 +0000 Subject: [PATCH 1/7] feat(contest-table): add AWC0151OnwardsProvider for AWC 151+ contests Add AWC0151OnwardsProvider using range filter pattern for contests from AWC0151 onwards (5 problems A-E). Includes seed data cleanup, fixtures, tests, and provider group registration. Co-Authored-By: Claude Opus 4.6 Claude-Session: https://claude.ai/code/session_01LpBu3jvGWLNEeyWVL2PQZT --- .../awc0151-onwards-provider/plan.md | 134 ++++++++++++++++++ .../how-to-add-contest-table-provider.md | 5 +- prisma/tasks.ts | 95 +++++++++---- .../contest-table/contest_table_provider.ts | 30 ++++ .../utils/contest-table/awc_provider.test.ts | 77 ++++++++++ .../tasks/utils/contest-table/awc_provider.ts | 37 +++++ .../contest_table_provider_groups.test.ts | 6 +- .../contest_table_provider_groups.ts | 2 + 8 files changed, 353 insertions(+), 33 deletions(-) create mode 100644 docs/dev-notes/2026-09-05/awc0151-onwards-provider/plan.md diff --git a/docs/dev-notes/2026-09-05/awc0151-onwards-provider/plan.md b/docs/dev-notes/2026-09-05/awc0151-onwards-provider/plan.md new file mode 100644 index 000000000..5729a39c5 --- /dev/null +++ b/docs/dev-notes/2026-09-05/awc0151-onwards-provider/plan.md @@ -0,0 +1,134 @@ +# AWC0151OnwardsProvider 実装計画 + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** AWC0151 以降(上限なし)の通常回(5問 A-E)用 Provider を追加し、AWC0150 の seed を実問題名に更新する + +**Architecture:** パターン1(範囲フィルタ型)。`AWC0101To0149Provider` と同じ表示設定をそのまま使い、`contestRound >= 151` でフィルタ。AWC グループの最上位に登録(AWC0150 の上)。 + +**Closes:** #3993 + +--- + +## 変更対象ファイル + +| ファイル | 変更内容 | +| ------------------------------------------------------------------------------ | ----------------------------------------------- | +| `prisma/tasks.ts` | AWC0150 seed → 実問題名 + AWC0151 ダミー5件追加 | +| `src/features/tasks/fixtures/contest-table/contest_table_provider.ts` | AWC0151 フィクスチャ追加 | +| `src/features/tasks/utils/contest-table/awc_provider.ts` | `AWC0151OnwardsProvider` 追加 | +| `src/features/tasks/utils/contest-table/awc_provider.test.ts` | テスト追加 | +| `src/features/tasks/utils/contest-table/contest_table_provider_groups.ts` | グループ登録 | +| `src/features/tasks/utils/contest-table/contest_table_provider_groups.test.ts` | size 4→5、instanceof 追加 | +| `docs/guides/how-to-add-contest-table-provider.md` | リファレンステーブル + 最終更新日 | + +## 設計根拠 + +- **上限なし**: `ABC319OnwardsProvider` と同パターン。次の特別回(AWC0200 等)で分割すればよい +- **却下**: `AWC0151To0199` 固定範囲 — 上限不明なので Onwards が適切 + +--- + +## Task 1: seed データ更新 + +- [ ] `prisma/tasks.ts:8358-8461` — AWC0150 の `name`/`title` を添付 `awc0150.json` の実問題名に差し替え(`id`/`contest_id`/`problem_index` は変更なし) +- [ ] AWC0150 ブロックの直前に AWC0151 ダミー 5 件(A-E)追加。フォーマットは既存の `Dummy Problem X` パターン準拠 + +## Task 2: フィクスチャ追加 + +- [ ] `contest_table_provider.ts` に AWC0151・0152 の 2 コンテスト分フィクスチャ追加。`taskResultsForAWC0151OnwardsProvider` として export + +```typescript +const [awc0151_a, ...] = createContestTasks('awc0151', [ + { taskTableIndex: 'A', statusName: AC }, + { taskTableIndex: 'B', statusName: AC }, + { taskTableIndex: 'C', statusName: AC_WITH_EDITORIAL }, + { taskTableIndex: 'D', statusName: TRYING }, + { taskTableIndex: 'E', statusName: PENDING }, +]); +// awc0152 も同様に 5 件 +``` + +## Task 3: Provider テスト → 実装 (TDD) + +### テスト(`awc_provider.test.ts`) + +`AWC0150Provider` describe の**前**に追加: + +```typescript +describe('AWC0151OnwardsProvider', () => { + // フィルタ: awc0151, awc0152 を含み awc0150, awc0149 を除外 + // metadata: title='AtCoder Weekday Contest 0151 〜', abbreviationName='awc0151Onwards' + // displayConfig: AWC0101To0149Provider と同一(header/roundLabel 表示、taskIndex 非表示) + // roundLabel: 'awc0151' → '0151', 'awc0200' → '0200' + // generateTable: 各コンテスト 5 問 (A-E) + // empty: 空配列 → 空配列 +}); +``` + +### 実装(`awc_provider.ts`) + +`AWC0150Provider` の**前**に追加: + +```typescript +export class AWC0151OnwardsProvider extends ContestTableProviderBase { + constructor(contestType: ContestType) { + super(contestType, '0151Onwards'); + } + + 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 >= 151; + }; + } + + getMetadata(): ContestTableMetaData { + return { title: 'AtCoder Weekday Contest 0151 〜', abbreviationName: 'awc0151Onwards' }; + } + + getDisplayConfig(): ContestTableDisplayConfig { + return { + isShownHeader: true, + isShownRoundLabel: true, + roundLabelWidth: 'xl:w-16', + tableBodyCellsWidth: 'w-1/2 sm:w-1/3 md:w-1/4 lg:w-1/5 px-1 py-1', + isShownTaskIndex: false, + }; + } + + getContestRoundLabel(contestId: string): string { + const contestNameLabel = getContestNameLabel(contestId); + return contestNameLabel.replace('AWC ', ''); + } +} +``` + +## Task 4: グループ登録テスト → 実装 (TDD) + +### テスト(`contest_table_provider_groups.test.ts`) + +```typescript +// AWC0001Onwards テストを更新: +expect(group.getSize()).toBe(5); // 4 → 5 +expect(group.getProvider(ContestType.AWC, '0151Onwards')).toBeInstanceOf(AWC0151OnwardsProvider); +// 既存の 0150, 0101To0149, 0100, default はそのまま +``` + +### 実装(`contest_table_provider_groups.ts`) + +```typescript +AWC0001Onwards: () => + new ContestTableProviderGroup(...) + .addProvider(new AWC0151OnwardsProvider(ContestType.AWC)) // ← 先頭に追加 + .addProvider(new AWC0150Provider(ContestType.AWC)) + .addProvider(new AWC0101To0149Provider(ContestType.AWC)) + .addProvider(new AWC0100Provider(ContestType.AWC)) + .addProvider(new AWC0001To0099Provider(ContestType.AWC)), +``` + +## Task 5: ドキュメント・フォーマット + +- [ ] `how-to-add-contest-table-provider.md` — 範囲フィルタ型テーブルに `| AWC 0151- | 0151〜 | A〜E | - |` 追加、†注に `'0151Onwards'` 追加、最終更新日 → `2026-09-05` +- [ ] `pnpm format && pnpm lint && pnpm test:unit` diff --git a/docs/guides/how-to-add-contest-table-provider.md b/docs/guides/how-to-add-contest-table-provider.md index a9a035498..23e7c46e5 100644 --- a/docs/guides/how-to-add-contest-table-provider.md +++ b/docs/guides/how-to-add-contest-table-provider.md @@ -82,6 +82,7 @@ AOJ/JAG 固有の詳細(`AOJ_LABEL_OVERRIDES`、`titleStyle`、同一年2回 | AGC 001- | 001~ | 4~7問 | - | | AWC 0001-0099 | 0001~0099 | A~E | - | | AWC 0101-0149 | 0101~0149 | A~E | - | +| AWC 0151- | 0151~ | A~E | - | ### 単一ソース型 @@ -95,7 +96,7 @@ AOJ/JAG 固有の詳細(`AOJ_LABEL_OVERRIDES`、`titleStyle`、同一年2回 | 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` グループで統一管理。 +†注: ContestType.AWC を再利用し、section(`'0100'`, `'0150'`, `'0151Onwards'`)で provider key を一意化。ACL 系は `Acl` グループ、DP 系は `dps` グループで統一管理。 ### コンストラクタパラメータ型 / regex フィルタ型 @@ -142,4 +143,4 @@ AOJ/JAG 固有の詳細(`AOJ_LABEL_OVERRIDES`、`titleStyle`、同一年2回 --- -**最終更新**: 2026-09-03 +**最終更新**: 2026-09-05 diff --git a/prisma/tasks.ts b/prisma/tasks.ts index 8848b31fe..ff89eb863 100755 --- a/prisma/tasks.ts +++ b/prisma/tasks.ts @@ -8355,109 +8355,144 @@ export const tasks = [ title: 'C. 4/N', }, { - id: 'awc0150_a', - contest_id: 'awc0150', + id: 'awc0151_a', + contest_id: 'awc0151', problem_index: 'A', name: 'Dummy Problem A', title: 'A. Dummy Problem A', }, { - id: 'awc0150_b', - contest_id: 'awc0150', + id: 'awc0151_b', + contest_id: 'awc0151', problem_index: 'B', name: 'Dummy Problem B', title: 'B. Dummy Problem B', }, { - id: 'awc0150_c', - contest_id: 'awc0150', + id: 'awc0151_c', + contest_id: 'awc0151', problem_index: 'C', name: 'Dummy Problem C', title: 'C. Dummy Problem C', }, { - id: 'awc0150_d', - contest_id: 'awc0150', + id: 'awc0151_d', + contest_id: 'awc0151', problem_index: 'D', name: 'Dummy Problem D', title: 'D. Dummy Problem D', }, { - id: 'awc0150_e', - contest_id: 'awc0150', + id: 'awc0151_e', + contest_id: 'awc0151', problem_index: 'E', name: 'Dummy Problem E', title: 'E. Dummy Problem E', }, + { + id: 'awc0150_a', + contest_id: 'awc0150', + problem_index: 'A', + name: 'Optimization of Circular Security', + title: 'A. Optimization of Circular Security', + }, + { + id: 'awc0150_b', + contest_id: 'awc0150', + problem_index: 'B', + name: 'Door Selection', + title: 'B. Door Selection', + }, + { + id: 'awc0150_c', + contest_id: 'awc0150', + problem_index: 'C', + name: 'Parking Lot Guidance', + title: 'C. Parking Lot Guidance', + }, + { + id: 'awc0150_d', + contest_id: 'awc0150', + problem_index: 'D', + name: 'Image Rotation Editor', + title: 'D. Image Rotation Editor', + }, + { + id: 'awc0150_e', + contest_id: 'awc0150', + problem_index: 'E', + name: 'Cherry Blossom Viewing Budget Check', + title: 'E. Cherry Blossom Viewing Budget Check', + }, { id: 'awc0150_f', contest_id: 'awc0150', problem_index: 'F', - name: 'Dummy Problem F', - title: 'F. Dummy Problem F', + name: 'Circle of Friends', + title: 'F. Circle of Friends', }, { id: 'awc0150_g', contest_id: 'awc0150', problem_index: 'G', - name: 'Dummy Problem G', - title: 'G. Dummy Problem G', + name: 'Ball Passing Game', + title: 'G. Ball Passing Game', }, { id: 'awc0150_h', contest_id: 'awc0150', problem_index: 'H', - name: 'Dummy Problem H', - title: 'H. Dummy Problem H', + name: 'Placement of Observation Decks', + title: 'H. Placement of Observation Decks', }, { id: 'awc0150_i', contest_id: 'awc0150', problem_index: 'I', - name: 'Dummy Problem I', - title: 'I. Dummy Problem I', + name: 'Task Schedule and Priority Filter', + title: 'I. Task Schedule and Priority Filter', }, { id: 'awc0150_j', contest_id: 'awc0150', problem_index: 'J', - name: 'Dummy Problem J', - title: 'J. Dummy Problem J', + name: 'Traveling Stall', + title: 'J. Traveling Stall', }, { id: 'awc0150_k', contest_id: 'awc0150', problem_index: 'K', - name: 'Dummy Problem K', - title: 'K. Dummy Problem K', + name: 'Network Installation', + title: 'K. Network Installation', }, { id: 'awc0150_l', contest_id: 'awc0150', problem_index: 'L', - name: 'Dummy Problem L', - title: 'L. Dummy Problem L', + name: 'Fire Station and Emergency Dispatch', + title: 'L. Fire Station and Emergency Dispatch', }, { id: 'awc0150_m', contest_id: 'awc0150', problem_index: 'M', - name: 'Dummy Problem M', - title: 'M. Dummy Problem M', + name: 'Adjusting Building Heights', + title: 'M. Adjusting Building Heights', }, { id: 'awc0150_n', contest_id: 'awc0150', problem_index: 'N', - name: 'Dummy Problem N', - title: 'N. Dummy Problem N', + name: 'Creating a Periodic String', + title: 'N. Creating a Periodic String', }, { id: 'awc0150_o', contest_id: 'awc0150', problem_index: 'O', - name: 'Dummy Problem O', - title: 'O. Dummy Problem O', + name: 'Maximum Score of a Playlist', + title: 'O. Maximum Score of a Playlist', }, { id: 'awc0101_a', 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 5ee534416..9bc94ef7c 100644 --- a/src/features/tasks/fixtures/contest-table/contest_table_provider.ts +++ b/src/features/tasks/fixtures/contest-table/contest_table_provider.ts @@ -1030,3 +1030,33 @@ export const taskResultsForAWC0150Provider: TaskResults = [ awc0150_n, awc0150_o, ]; + +// AWC 0151 onwards: 5 tasks (A-E) +const [awc0151_a, awc0151_b, awc0151_c, awc0151_d, awc0151_e] = createContestTasks('awc0151', [ + { taskTableIndex: 'A', statusName: AC }, + { taskTableIndex: 'B', statusName: AC }, + { taskTableIndex: 'C', statusName: AC_WITH_EDITORIAL }, + { taskTableIndex: 'D', statusName: TRYING }, + { taskTableIndex: 'E', statusName: PENDING }, +]); + +const [awc0152_a, awc0152_b, awc0152_c, awc0152_d, awc0152_e] = createContestTasks('awc0152', [ + { taskTableIndex: 'A', statusName: AC }, + { taskTableIndex: 'B', statusName: AC }, + { taskTableIndex: 'C', statusName: TRYING }, + { taskTableIndex: 'D', statusName: AC_WITH_EDITORIAL }, + { taskTableIndex: 'E', statusName: AC }, +]); + +export const taskResultsForAWC0151OnwardsProvider: TaskResults = [ + awc0151_a, + awc0151_b, + awc0151_c, + awc0151_d, + awc0151_e, + awc0152_a, + awc0152_b, + awc0152_c, + awc0152_d, + awc0152_e, +]; 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 c02594be6..d0b06f46d 100644 --- a/src/features/tasks/utils/contest-table/awc_provider.test.ts +++ b/src/features/tasks/utils/contest-table/awc_provider.test.ts @@ -8,14 +8,91 @@ import { AWC0100Provider, AWC0101To0149Provider, AWC0150Provider, + AWC0151OnwardsProvider, } from './awc_provider'; import { taskResultsForAWC0001To0099Provider, taskResultsForAWC0100Provider, taskResultsForAWC0101To0149Provider, taskResultsForAWC0150Provider, + taskResultsForAWC0151OnwardsProvider, } from '$features/tasks/fixtures/contest-table/contest_table_provider'; +describe('AWC0151OnwardsProvider', () => { + test('expects to filter tasks to include only AWC 0151 onwards contests', () => { + const provider = new AWC0151OnwardsProvider(ContestType.AWC); + const combined = [ + ...taskResultsForAWC0101To0149Provider, + ...taskResultsForAWC0150Provider, + ...taskResultsForAWC0151OnwardsProvider, + ]; + const filtered = provider.filter(combined); + + expect(filtered.length).toBeGreaterThan(0); + expect(filtered.every((task) => task.contest_id.startsWith('awc'))).toBe(true); + }); + + test('expects to include awc0151 and awc0152, exclude awc0150 and awc0149', () => { + const provider = new AWC0151OnwardsProvider(ContestType.AWC); + const combined = [ + ...taskResultsForAWC0101To0149Provider, + ...taskResultsForAWC0150Provider, + ...taskResultsForAWC0151OnwardsProvider, + ]; + const filtered = provider.filter(combined); + + expect(filtered.some((task) => task.contest_id === 'awc0151')).toBe(true); + expect(filtered.some((task) => task.contest_id === 'awc0152')).toBe(true); + expect(filtered.some((task) => task.contest_id === 'awc0150')).toBe(false); + expect(filtered.some((task) => task.contest_id === 'awc0149')).toBe(false); + }); + + test('expects to get correct metadata', () => { + const provider = new AWC0151OnwardsProvider(ContestType.AWC); + const metadata = provider.getMetadata(); + + expect(metadata.title).toBe('AtCoder Weekday Contest 0151 〜'); + expect(metadata.abbreviationName).toBe('awc0151Onwards'); + }); + + test('expects to return correct display config (same as AWC0101To0149)', () => { + const provider = new AWC0151OnwardsProvider(ContestType.AWC); + const config = provider.getDisplayConfig(); + + expect(config.isShownHeader).toBe(true); + expect(config.isShownRoundLabel).toBe(true); + expect(config.roundLabelWidth).toBe('xl:w-16'); + 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.isShownTaskIndex).toBe(false); + }); + + test('expects to format contest round label correctly', () => { + const provider = new AWC0151OnwardsProvider(ContestType.AWC); + + expect(provider.getContestRoundLabel('awc0151')).toBe('0151'); + expect(provider.getContestRoundLabel('awc0200')).toBe('0200'); + }); + + test('expects each AWC contest to have 5 problems (A-E)', () => { + const provider = new AWC0151OnwardsProvider(ContestType.AWC); + const filtered = provider.filter(taskResultsForAWC0151OnwardsProvider); + 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 AWC0151OnwardsProvider(ContestType.AWC); + const filtered = provider.filter([] as TaskResults); + + expect(filtered).toEqual([] as TaskResults); + }); +}); + describe('AWC0150Provider', () => { test('expects to filter only awc0150 tasks', () => { const provider = new AWC0150Provider(ContestType.AWC); diff --git a/src/features/tasks/utils/contest-table/awc_provider.ts b/src/features/tasks/utils/contest-table/awc_provider.ts index 0016aa98d..49f943910 100644 --- a/src/features/tasks/utils/contest-table/awc_provider.ts +++ b/src/features/tasks/utils/contest-table/awc_provider.ts @@ -9,6 +9,43 @@ import { classifyContest, getContestNameLabel } from '$lib/utils/contest'; import { ContestTableProviderBase, parseContestRound } from './contest_table_provider_base'; +// AWC0151 onwards (5 tasks per contest: A-E) +export class AWC0151OnwardsProvider extends ContestTableProviderBase { + constructor(contestType: ContestType) { + super(contestType, '0151Onwards'); + } + + 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 >= 151 && contestRound <= 9999; + }; + } + + getMetadata(): ContestTableMetaData { + return { title: 'AtCoder Weekday Contest 0151 〜', abbreviationName: 'awc0151Onwards' }; + } + + getDisplayConfig(): ContestTableDisplayConfig { + return { + isShownHeader: true, + isShownRoundLabel: true, + roundLabelWidth: 'xl:w-16', + tableBodyCellsWidth: 'w-1/2 sm:w-1/3 md:w-1/4 lg:w-1/5 px-1 py-1', + isShownTaskIndex: false, + }; + } + + getContestRoundLabel(contestId: string): string { + const contestNameLabel = getContestNameLabel(contestId); + return contestNameLabel.replace('AWC ', ''); + } +} + // AWC0150 (special edition, 15 tasks: A-O) export class AWC0150Provider extends ContestTableProviderBase { constructor(contestType: ContestType) { 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 1020f8d9e..21f26d7cb 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 @@ -18,6 +18,7 @@ import { AWC0100Provider, AWC0101To0149Provider, AWC0150Provider, + AWC0151OnwardsProvider, ACLPracticeProvider, ACLBeginnerProvider, ACLProvider, @@ -191,7 +192,10 @@ describe('prepareContestProviderPresets', () => { buttonLabel: 'AWC 0001 〜 ', ariaLabel: 'Filter contests from AWC 0001 onwards', }); - expect(group.getSize()).toBe(4); + expect(group.getSize()).toBe(5); + expect(group.getProvider(ContestType.AWC, '0151Onwards')).toBeInstanceOf( + AWC0151OnwardsProvider, + ); expect(group.getProvider(ContestType.AWC, '0150')).toBeInstanceOf(AWC0150Provider); expect(group.getProvider(ContestType.AWC, '0101To0149')).toBeInstanceOf(AWC0101To0149Provider); expect(group.getProvider(ContestType.AWC, '0100')).toBeInstanceOf(AWC0100Provider); 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 36d6b6335..3af5daa1d 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 @@ -20,6 +20,7 @@ import { AWC0100Provider, AWC0101To0149Provider, AWC0150Provider, + AWC0151OnwardsProvider, } from './awc_provider'; import { Typical90Provider } from './typical90_provider'; import { @@ -166,6 +167,7 @@ export const prepareContestProviderPresets = () => { buttonLabel: 'AWC 0001 〜 ', ariaLabel: 'Filter contests from AWC 0001 onwards', }) + .addProvider(new AWC0151OnwardsProvider(ContestType.AWC)) .addProvider(new AWC0150Provider(ContestType.AWC)) .addProvider(new AWC0101To0149Provider(ContestType.AWC)) .addProvider(new AWC0100Provider(ContestType.AWC)) From ae1dca6b874f5919d6cffe79febfbe12799b452b Mon Sep 17 00:00:00 2001 From: "k.hiro1818" Date: Sat, 5 Sep 2026 12:33:16 +0000 Subject: [PATCH 2/7] refactor(contest-table): simplify AWC providers and deduplicate group registration Reduce boilerplate in AWC provider classes and consolidate contest table provider group registration logic. Co-Authored-By: Claude Opus 4.6 Claude-Session: https://claude.ai/code/session_01LpBu3jvGWLNEeyWVL2PQZT --- .../utils/contest-table/awc_provider.test.ts | 588 +++++++----------- .../tasks/utils/contest-table/awc_provider.ts | 188 ++---- .../contest_table_provider_groups.test.ts | 19 +- .../contest_table_provider_groups.ts | 56 +- 4 files changed, 321 insertions(+), 530 deletions(-) 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 d0b06f46d..98c823e6f 100644 --- a/src/features/tasks/utils/contest-table/awc_provider.test.ts +++ b/src/features/tasks/utils/contest-table/awc_provider.test.ts @@ -3,13 +3,7 @@ import { describe, test, expect } from 'vitest'; import { ContestType } from '$lib/types/contest'; import type { TaskResults } from '$lib/types/task'; -import { - AWC0001To0099Provider, - AWC0100Provider, - AWC0101To0149Provider, - AWC0150Provider, - AWC0151OnwardsProvider, -} from './awc_provider'; +import { AWCRangeProvider, AWCSpecialContestProvider } from './awc_provider'; import { taskResultsForAWC0001To0099Provider, taskResultsForAWC0100Provider, @@ -18,402 +12,248 @@ import { taskResultsForAWC0151OnwardsProvider, } from '$features/tasks/fixtures/contest-table/contest_table_provider'; -describe('AWC0151OnwardsProvider', () => { - test('expects to filter tasks to include only AWC 0151 onwards contests', () => { - const provider = new AWC0151OnwardsProvider(ContestType.AWC); - const combined = [ - ...taskResultsForAWC0101To0149Provider, - ...taskResultsForAWC0150Provider, - ...taskResultsForAWC0151OnwardsProvider, - ]; - const filtered = provider.filter(combined); - - expect(filtered.length).toBeGreaterThan(0); - expect(filtered.every((task) => task.contest_id.startsWith('awc'))).toBe(true); - }); - - test('expects to include awc0151 and awc0152, exclude awc0150 and awc0149', () => { - const provider = new AWC0151OnwardsProvider(ContestType.AWC); - const combined = [ - ...taskResultsForAWC0101To0149Provider, - ...taskResultsForAWC0150Provider, - ...taskResultsForAWC0151OnwardsProvider, - ]; - const filtered = provider.filter(combined); - - expect(filtered.some((task) => task.contest_id === 'awc0151')).toBe(true); - expect(filtered.some((task) => task.contest_id === 'awc0152')).toBe(true); - expect(filtered.some((task) => task.contest_id === 'awc0150')).toBe(false); - expect(filtered.some((task) => task.contest_id === 'awc0149')).toBe(false); - }); - - test('expects to get correct metadata', () => { - const provider = new AWC0151OnwardsProvider(ContestType.AWC); - const metadata = provider.getMetadata(); - - expect(metadata.title).toBe('AtCoder Weekday Contest 0151 〜'); - expect(metadata.abbreviationName).toBe('awc0151Onwards'); - }); - - test('expects to return correct display config (same as AWC0101To0149)', () => { - const provider = new AWC0151OnwardsProvider(ContestType.AWC); - const config = provider.getDisplayConfig(); - - expect(config.isShownHeader).toBe(true); - expect(config.isShownRoundLabel).toBe(true); - expect(config.roundLabelWidth).toBe('xl:w-16'); - 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.isShownTaskIndex).toBe(false); - }); - - test('expects to format contest round label correctly', () => { - const provider = new AWC0151OnwardsProvider(ContestType.AWC); - - expect(provider.getContestRoundLabel('awc0151')).toBe('0151'); - expect(provider.getContestRoundLabel('awc0200')).toBe('0200'); - }); - - test('expects each AWC contest to have 5 problems (A-E)', () => { - const provider = new AWC0151OnwardsProvider(ContestType.AWC); - const filtered = provider.filter(taskResultsForAWC0151OnwardsProvider); - 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']); +const allAWCTaskResults: TaskResults = [ + ...taskResultsForAWC0001To0099Provider, + ...taskResultsForAWC0100Provider, + ...taskResultsForAWC0101To0149Provider, + ...taskResultsForAWC0150Provider, + ...taskResultsForAWC0151OnwardsProvider, +]; + +const RANGE_PROBLEMS = ['A', 'B', 'C', 'D', 'E']; +const SPECIAL_PROBLEMS = [ + 'A', + 'B', + 'C', + 'D', + 'E', + 'F', + 'G', + 'H', + 'I', + 'J', + 'K', + 'L', + 'M', + 'N', + 'O', +]; + +describe('AWCRangeProvider', () => { + const rangeProviderCases = [ + { + name: 'AWC0151Onwards', + config: { + section: '0151Onwards', + minRound: 151, + maxRound: 9999, + title: 'AtCoder Weekday Contest 0151 〜', + abbreviationName: 'awc0151Onwards', + }, + includedContestIds: ['awc0151', 'awc0152'], + excludedContestIds: ['awc0150', 'awc0149', 'awc0100'], + fixtureData: taskResultsForAWC0151OnwardsProvider, + roundLabelCases: [ + { contestId: 'awc0151', expected: '0151' }, + { contestId: 'awc0200', expected: '0200' }, + ], + }, + { + name: 'AWC0101To0149', + config: { + section: '0101To0149', + minRound: 101, + maxRound: 149, + title: 'AtCoder Weekday Contest 0101 〜 0149', + abbreviationName: 'awc0101To0149', + }, + includedContestIds: ['awc0101', 'awc0102', 'awc0149'], + excludedContestIds: ['awc0099', 'awc0100', 'awc0150'], + fixtureData: taskResultsForAWC0101To0149Provider, + roundLabelCases: [ + { contestId: 'awc0101', expected: '0101' }, + { contestId: 'awc0149', expected: '0149' }, + ], + }, + { + name: 'AWC0001To0099', + config: { + section: '0001To0099', + minRound: 1, + maxRound: 99, + title: 'AtCoder Weekday Contest 0001 〜 0099', + abbreviationName: 'awc0001To0099', + }, + includedContestIds: ['awc0001', 'awc0002', 'awc0099'], + excludedContestIds: ['awc0100'], + fixtureData: taskResultsForAWC0001To0099Provider, + roundLabelCases: [ + { contestId: 'awc0001', expected: '0001' }, + { contestId: 'awc0099', expected: '0099' }, + ], + }, + ]; + + describe.each(rangeProviderCases)('$name', (testCase) => { + const createProvider = () => new AWCRangeProvider(ContestType.AWC, testCase.config); + + test('filters correct contest range from mixed data', () => { + const provider = createProvider(); + const filtered = provider.filter(allAWCTaskResults); + + expect(filtered.length).toBeGreaterThan(0); + + for (const id of testCase.includedContestIds) { + expect(filtered.some((task) => task.contest_id === id)).toBe(true); + } + + for (const id of testCase.excludedContestIds) { + expect(filtered.some((task) => task.contest_id === id)).toBe(false); + } }); - }); - - test('expects to handle empty task results', () => { - const provider = new AWC0151OnwardsProvider(ContestType.AWC); - const filtered = provider.filter([] as TaskResults); - - expect(filtered).toEqual([] as TaskResults); - }); -}); - -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 === 'awc0150')).toBe(true); - }); - - test('expects to exclude awc0101 and awc0149', () => { - const provider = new AWC0150Provider(ContestType.AWC); - const combined = [...taskResultsForAWC0101To0149Provider, ...taskResultsForAWC0150Provider]; - const filtered = provider.filter(combined); + test('returns correct metadata', () => { + const provider = createProvider(); + const metadata = provider.getMetadata(); - 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 AWC0150Provider(ContestType.AWC); - const metadata = provider.getMetadata(); + expect(metadata.title).toBe(testCase.config.title); + expect(metadata.abbreviationName).toBe(testCase.config.abbreviationName); + }); - expect(metadata.title).toBe('AtCoder Weekday Contest 0150'); - expect(metadata.abbreviationName).toBe('awc0150'); - }); + test('generates table with expected problems per contest', () => { + const provider = createProvider(); + const filtered = provider.filter(testCase.fixtureData); + const table = provider.generateTable(filtered); - test('expects to return correct display config (EDPC format)', () => { - const provider = new AWC0150Provider(ContestType.AWC); - const config = provider.getDisplayConfig(); + Object.values(table).forEach((problems) => { + expect(Object.keys(problems)).toEqual(RANGE_PROBLEMS); + }); + }); - 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', + test.each(testCase.roundLabelCases)( + 'formats round label for $contestId', + ({ contestId, expected }) => { + const provider = createProvider(); + expect(provider.getContestRoundLabel(contestId)).toBe(expected); + }, ); - expect(config.isShownTaskIndex).toBe(true); - }); - - test('expects getContestRoundLabel to return empty string', () => { - const provider = new AWC0150Provider(ContestType.AWC); - - expect(provider.getContestRoundLabel('awc0150')).toBe(''); - }); - - 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('awc0150'); - const problems = table['awc0150']; - 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 AWC0150Provider(ContestType.AWC); - const filtered = provider.filter([] as TaskResults); - - expect(filtered).toEqual([] as TaskResults); - }); -}); - -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 awc0149)', () => { - const provider = new AWC0101To0149Provider(ContestType.AWC); - const combined = [ - ...taskResultsForAWC0001To0099Provider, - ...taskResultsForAWC0100Provider, - ...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 === '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 AWC0101To0149Provider(ContestType.AWC); - const metadata = provider.getMetadata(); - - expect(metadata.title).toBe('AtCoder Weekday Contest 0101 〜 0149'); - expect(metadata.abbreviationName).toBe('awc0101To0149'); - }); - - test('expects to return correct display config', () => { - const provider = new AWC0101To0149Provider(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 AWC0101To0149Provider(ContestType.AWC); - - expect(provider.getContestRoundLabel('awc0101')).toBe('0101'); - expect(provider.getContestRoundLabel('awc0149')).toBe('0149'); - }); - - test('expects to generate table for multiple AWC contests', () => { - 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('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('returns empty array for empty input', () => { + const provider = createProvider(); + expect(provider.filter([] as TaskResults)).toEqual([]); }); }); - 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('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).toBe(15); - expect(filtered.every((task) => task.contest_id === 'awc0100')).toBe(true); - }); - - 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(false); - expect(filtered.some((task) => task.contest_id === 'awc0099')).toBe(false); - }); - - test('expects to get correct metadata', () => { - const provider = new AWC0100Provider(ContestType.AWC); - const metadata = provider.getMetadata(); - - expect(metadata.title).toBe('AtCoder Weekday Contest 0100'); - expect(metadata.abbreviationName).toBe('awc0100'); - }); - - test('expects to return correct display config (EDPC format)', () => { - const provider = new AWC0100Provider(ContestType.AWC); - const config = provider.getDisplayConfig(); - - 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', + test('all range providers share the same display config', () => { + const configs = rangeProviderCases.map((testCase) => + new AWCRangeProvider(ContestType.AWC, testCase.config).getDisplayConfig(), ); - expect(config.isShownTaskIndex).toBe(true); - }); - test('expects getContestRoundLabel to return empty string', () => { - const provider = new AWC0100Provider(ContestType.AWC); + for (const config of configs) { + expect(config).toEqual(configs[0]); + } - expect(provider.getContestRoundLabel('awc0100')).toBe(''); - }); - - 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); - - 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 AWC0100Provider(ContestType.AWC); - const filtered = provider.filter([] as TaskResults); - - expect(filtered).toEqual([] as TaskResults); + expect(configs[0]).toEqual({ + isShownHeader: true, + isShownRoundLabel: true, + roundLabelWidth: 'xl:w-16', + tableBodyCellsWidth: 'w-1/2 sm:w-1/3 md:w-1/4 lg:w-1/5 px-1 py-1', + isShownTaskIndex: false, + }); }); }); -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); - }); +describe('AWCSpecialContestProvider', () => { + const specialContestCases = [ + { + name: 'AWC0150', + config: { + section: '0150', + contestId: 'awc0150', + title: 'AtCoder Weekday Contest 0150', + abbreviationName: 'awc0150', + }, + fixtureData: taskResultsForAWC0150Provider, + mixedData: [...taskResultsForAWC0101To0149Provider, ...taskResultsForAWC0150Provider], + excludedContestIds: ['awc0101', 'awc0149'], + }, + { + name: 'AWC0100', + config: { + section: '0100', + contestId: 'awc0100', + title: 'AtCoder Weekday Contest 0100', + abbreviationName: 'awc0100', + }, + fixtureData: taskResultsForAWC0100Provider, + mixedData: [...taskResultsForAWC0001To0099Provider, ...taskResultsForAWC0100Provider], + excludedContestIds: ['awc0001', 'awc0099'], + }, + ]; + + describe.each(specialContestCases)('$name', (testCase) => { + const createProvider = () => new AWCSpecialContestProvider(ContestType.AWC, testCase.config); + + test('filters only the target contest from mixed data', () => { + const provider = createProvider(); + const filtered = provider.filter(testCase.mixedData); + + expect(filtered.length).toBe(SPECIAL_PROBLEMS.length); + expect(filtered.every((task) => task.contest_id === testCase.config.contestId)).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); + test('excludes adjacent contests', () => { + const provider = createProvider(); + const filtered = provider.filter(testCase.mixedData); - 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); - }); + for (const id of testCase.excludedContestIds) { + expect(filtered.some((task) => task.contest_id === id)).toBe(false); + } + }); - test('expects to get correct metadata', () => { - const provider = new AWC0001To0099Provider(ContestType.AWC); - const metadata = provider.getMetadata(); + test('returns correct metadata', () => { + const provider = createProvider(); + const metadata = provider.getMetadata(); - expect(metadata.title).toBe('AtCoder Weekday Contest 0001 〜 0099'); - expect(metadata.abbreviationName).toBe('awc0001To0099'); - }); + expect(metadata.title).toBe(testCase.config.title); + expect(metadata.abbreviationName).toBe(testCase.config.abbreviationName); + }); - test('expects to return correct display config', () => { - const provider = new AWC0001To0099Provider(ContestType.AWC); - const config = provider.getDisplayConfig(); + test('generates table with expected problems', () => { + const provider = createProvider(); + const filtered = provider.filter(testCase.fixtureData); + const table = provider.generateTable(filtered); - 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); - }); + expect(table).toHaveProperty(testCase.config.contestId); + expect(Object.keys(table[testCase.config.contestId])).toEqual(SPECIAL_PROBLEMS); + }); - test('expects to format contest round label correctly', () => { - const provider = new AWC0001To0099Provider(ContestType.AWC); + test('returns empty round label', () => { + const provider = createProvider(); + expect(provider.getContestRoundLabel(testCase.config.contestId)).toBe(''); + }); - expect(provider.getContestRoundLabel('awc0001')).toBe('0001'); - expect(provider.getContestRoundLabel('awc0002')).toBe('0002'); - expect(provider.getContestRoundLabel('awc0099')).toBe('0099'); + test('returns empty array for empty input', () => { + const provider = createProvider(); + expect(provider.filter([] as TaskResults)).toEqual([]); + }); }); - 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('all special-contest providers share the same display config', () => { + const configs = specialContestCases.map((testCase) => + new AWCSpecialContestProvider(ContestType.AWC, testCase.config).getDisplayConfig(), + ); - test('expects each AWC contest to have 5 problems (A-E)', () => { - const provider = new AWC0001To0099Provider(ContestType.AWC); - const filtered = provider.filter(taskResultsForAWC0001To0099Provider); - const table = provider.generateTable(filtered); + for (const config of configs) { + expect(config).toEqual(configs[0]); + } - 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(configs[0]).toEqual({ + 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, }); }); - - test('expects to handle empty task results', () => { - 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 49f943910..0a32cc731 100644 --- a/src/features/tasks/utils/contest-table/awc_provider.ts +++ b/src/features/tasks/utils/contest-table/awc_provider.ts @@ -9,82 +9,50 @@ import { classifyContest, getContestNameLabel } from '$lib/utils/contest'; import { ContestTableProviderBase, parseContestRound } from './contest_table_provider_base'; -// AWC0151 onwards (5 tasks per contest: A-E) -export class AWC0151OnwardsProvider extends ContestTableProviderBase { - constructor(contestType: ContestType) { - super(contestType, '0151Onwards'); - } - - 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 >= 151 && contestRound <= 9999; - }; - } - - getMetadata(): ContestTableMetaData { - return { title: 'AtCoder Weekday Contest 0151 〜', abbreviationName: 'awc0151Onwards' }; - } - - getDisplayConfig(): ContestTableDisplayConfig { - return { - isShownHeader: true, - isShownRoundLabel: true, - roundLabelWidth: 'xl:w-16', - tableBodyCellsWidth: 'w-1/2 sm:w-1/3 md:w-1/4 lg:w-1/5 px-1 py-1', - isShownTaskIndex: false, - }; - } - - getContestRoundLabel(contestId: string): string { - const contestNameLabel = getContestNameLabel(contestId); - return contestNameLabel.replace('AWC ', ''); - } +interface AWCProviderConfig { + section: string; + title: string; + abbreviationName: string; } -// 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, - }; - } +interface AWCRangeConfig extends AWCProviderConfig { + minRound: number; + maxRound: number; +} - getContestRoundLabel(_contestId: string): string { - return ''; - } +interface AWCSpecialContestConfig extends AWCProviderConfig { + contestId: string; } -// AWC0101 〜 0149 (5 tasks per contest) -export class AWC0101To0149Provider extends ContestTableProviderBase { - constructor(contestType: ContestType) { - super(contestType, '0101To0149'); +const RANGE_DISPLAY_CONFIG: ContestTableDisplayConfig = { + isShownHeader: true, + isShownRoundLabel: true, + roundLabelWidth: 'xl:w-16', + tableBodyCellsWidth: 'w-1/2 sm:w-1/3 md:w-1/4 lg:w-1/5 px-1 py-1', + isShownTaskIndex: false, +}; + +const SPECIAL_CONTEST_DISPLAY_CONFIG: ContestTableDisplayConfig = { + 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, +}; + +export class AWCRangeProvider extends ContestTableProviderBase { + private readonly minRound: number; + private readonly maxRound: number; + private readonly title: string; + private readonly abbreviationName: string; + + constructor(contestType: ContestType, config: AWCRangeConfig) { + super(contestType, config.section); + + this.minRound = config.minRound; + this.maxRound = config.maxRound; + this.title = config.title; + this.abbreviationName = config.abbreviationName; } protected setFilterCondition(): (taskResult: TaskResult) => boolean { @@ -94,25 +62,16 @@ export class AWC0101To0149Provider extends ContestTableProviderBase { } const contestRound = parseContestRound(taskResult.contest_id, 'awc'); - return contestRound >= 101 && contestRound <= 149; + return contestRound >= this.minRound && contestRound <= this.maxRound; }; } getMetadata(): ContestTableMetaData { - return { - title: 'AtCoder Weekday Contest 0101 〜 0149', - abbreviationName: 'awc0101To0149', - }; + return { title: this.title, abbreviationName: this.abbreviationName }; } getDisplayConfig(): ContestTableDisplayConfig { - return { - isShownHeader: true, - isShownRoundLabel: true, - roundLabelWidth: 'xl:w-16', - tableBodyCellsWidth: 'w-1/2 sm:w-1/3 md:w-1/4 lg:w-1/5 px-1 py-1', - isShownTaskIndex: false, - }; + return RANGE_DISPLAY_CONFIG; } getContestRoundLabel(contestId: string): string { @@ -121,10 +80,17 @@ export class AWC0101To0149Provider extends ContestTableProviderBase { } } -// AWC0100 (2026/06/26. special edition, 15 tasks: A-O) -export class AWC0100Provider extends ContestTableProviderBase { - constructor(contestType: ContestType) { - super(contestType, '0100'); // provider key = 'AWC::0100' +export class AWCSpecialContestProvider extends ContestTableProviderBase { + private readonly contestId: string; + private readonly title: string; + private readonly abbreviationName: string; + + constructor(contestType: ContestType, config: AWCSpecialContestConfig) { + super(contestType, config.section); + + this.contestId = config.contestId; + this.title = config.title; + this.abbreviationName = config.abbreviationName; } protected setFilterCondition(): (taskResult: TaskResult) => boolean { @@ -133,61 +99,19 @@ export class AWC0100Provider extends ContestTableProviderBase { return false; } - return taskResult.contest_id === 'awc0100'; + return taskResult.contest_id === this.contestId; }; } getMetadata(): ContestTableMetaData { - return { title: 'AtCoder Weekday Contest 0100', abbreviationName: 'awc0100' }; + return { title: this.title, abbreviationName: this.abbreviationName }; } 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, - }; + return SPECIAL_CONTEST_DISPLAY_CONFIG; } getContestRoundLabel(_contestId: string): string { return ''; } } - -// 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 >= 1 && contestRound <= 99; - }; - } - - getMetadata(): ContestTableMetaData { - return { - title: 'AtCoder Weekday Contest 0001 〜 0099', - abbreviationName: 'awc0001To0099', - }; - } - - getDisplayConfig(): ContestTableDisplayConfig { - return { - isShownHeader: true, - isShownRoundLabel: true, - roundLabelWidth: 'xl:w-16', - tableBodyCellsWidth: 'w-1/2 sm:w-1/3 md:w-1/4 lg:w-1/5 px-1 py-1', - isShownTaskIndex: false, - }; - } - - getContestRoundLabel(contestId: string): string { - const contestNameLabel = getContestNameLabel(contestId); - return contestNameLabel.replace('AWC ', ''); - } -} 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 21f26d7cb..b3db08563 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 @@ -14,11 +14,8 @@ import { ARC001ToARC057Provider, AGC001OnwardsProvider, ABCLikeProvider, - AWC0001To0099Provider, - AWC0100Provider, - AWC0101To0149Provider, - AWC0150Provider, - AWC0151OnwardsProvider, + AWCRangeProvider, + AWCSpecialContestProvider, ACLPracticeProvider, ACLBeginnerProvider, ACLProvider, @@ -193,13 +190,11 @@ describe('prepareContestProviderPresets', () => { ariaLabel: 'Filter contests from AWC 0001 onwards', }); expect(group.getSize()).toBe(5); - expect(group.getProvider(ContestType.AWC, '0151Onwards')).toBeInstanceOf( - AWC0151OnwardsProvider, - ); - 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); + expect(group.getProvider(ContestType.AWC, '0151Onwards')).toBeInstanceOf(AWCRangeProvider); + expect(group.getProvider(ContestType.AWC, '0150')).toBeInstanceOf(AWCSpecialContestProvider); + expect(group.getProvider(ContestType.AWC, '0101To0149')).toBeInstanceOf(AWCRangeProvider); + expect(group.getProvider(ContestType.AWC, '0100')).toBeInstanceOf(AWCSpecialContestProvider); + expect(group.getProvider(ContestType.AWC, '0001To0099')).toBeInstanceOf(AWCRangeProvider); }); test('expects to create MathAndAlgorithm preset correctly', () => { 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 3af5daa1d..ea311d973 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,13 +15,7 @@ import { } from './arc_providers'; import { AGC001OnwardsProvider } from './agc_provider'; import { ABCLikeProvider } from './axc_like_provider'; -import { - AWC0001To0099Provider, - AWC0100Provider, - AWC0101To0149Provider, - AWC0150Provider, - AWC0151OnwardsProvider, -} from './awc_provider'; +import { AWCRangeProvider, AWCSpecialContestProvider } from './awc_provider'; import { Typical90Provider } from './typical90_provider'; import { TessokuBookForExamplesProvider, @@ -167,11 +161,49 @@ export const prepareContestProviderPresets = () => { buttonLabel: 'AWC 0001 〜 ', ariaLabel: 'Filter contests from AWC 0001 onwards', }) - .addProvider(new AWC0151OnwardsProvider(ContestType.AWC)) - .addProvider(new AWC0150Provider(ContestType.AWC)) - .addProvider(new AWC0101To0149Provider(ContestType.AWC)) - .addProvider(new AWC0100Provider(ContestType.AWC)) - .addProvider(new AWC0001To0099Provider(ContestType.AWC)), + .addProvider( + new AWCRangeProvider(ContestType.AWC, { + section: '0151Onwards', + minRound: 151, + maxRound: 9999, + title: 'AtCoder Weekday Contest 0151 〜', + abbreviationName: 'awc0151Onwards', + }), + ) + .addProvider( + new AWCSpecialContestProvider(ContestType.AWC, { + section: '0150', + contestId: 'awc0150', + title: 'AtCoder Weekday Contest 0150', + abbreviationName: 'awc0150', + }), + ) + .addProvider( + new AWCRangeProvider(ContestType.AWC, { + section: '0101To0149', + minRound: 101, + maxRound: 149, + title: 'AtCoder Weekday Contest 0101 〜 0149', + abbreviationName: 'awc0101To0149', + }), + ) + .addProvider( + new AWCSpecialContestProvider(ContestType.AWC, { + section: '0100', + contestId: 'awc0100', + title: 'AtCoder Weekday Contest 0100', + abbreviationName: 'awc0100', + }), + ) + .addProvider( + new AWCRangeProvider(ContestType.AWC, { + section: '0001To0099', + minRound: 1, + maxRound: 99, + title: 'AtCoder Weekday Contest 0001 〜 0099', + abbreviationName: 'awc0001To0099', + }), + ), /** * Single group for Typical 90 Problems From a955212c6c21999715fc0ee1fb5c2ed662072ae2 Mon Sep 17 00:00:00 2001 From: "k.hiro1818" Date: Sat, 5 Sep 2026 12:40:01 +0000 Subject: [PATCH 3/7] refactor(contest-table): simplify provider group tests and update guide Reduce provider group test boilerplate and update contest table provider guide to reflect current AWC provider structure. Co-Authored-By: Claude Opus 4.6 Claude-Session: https://claude.ai/code/session_01LpBu3jvGWLNEeyWVL2PQZT --- .../awc0151-onwards-provider/plan.md | 134 ----- .../how-to-add-contest-table-provider.md | 25 +- .../contest_table_provider_groups.test.ts | 558 +++++++----------- 3 files changed, 246 insertions(+), 471 deletions(-) delete mode 100644 docs/dev-notes/2026-09-05/awc0151-onwards-provider/plan.md diff --git a/docs/dev-notes/2026-09-05/awc0151-onwards-provider/plan.md b/docs/dev-notes/2026-09-05/awc0151-onwards-provider/plan.md deleted file mode 100644 index 5729a39c5..000000000 --- a/docs/dev-notes/2026-09-05/awc0151-onwards-provider/plan.md +++ /dev/null @@ -1,134 +0,0 @@ -# AWC0151OnwardsProvider 実装計画 - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** AWC0151 以降(上限なし)の通常回(5問 A-E)用 Provider を追加し、AWC0150 の seed を実問題名に更新する - -**Architecture:** パターン1(範囲フィルタ型)。`AWC0101To0149Provider` と同じ表示設定をそのまま使い、`contestRound >= 151` でフィルタ。AWC グループの最上位に登録(AWC0150 の上)。 - -**Closes:** #3993 - ---- - -## 変更対象ファイル - -| ファイル | 変更内容 | -| ------------------------------------------------------------------------------ | ----------------------------------------------- | -| `prisma/tasks.ts` | AWC0150 seed → 実問題名 + AWC0151 ダミー5件追加 | -| `src/features/tasks/fixtures/contest-table/contest_table_provider.ts` | AWC0151 フィクスチャ追加 | -| `src/features/tasks/utils/contest-table/awc_provider.ts` | `AWC0151OnwardsProvider` 追加 | -| `src/features/tasks/utils/contest-table/awc_provider.test.ts` | テスト追加 | -| `src/features/tasks/utils/contest-table/contest_table_provider_groups.ts` | グループ登録 | -| `src/features/tasks/utils/contest-table/contest_table_provider_groups.test.ts` | size 4→5、instanceof 追加 | -| `docs/guides/how-to-add-contest-table-provider.md` | リファレンステーブル + 最終更新日 | - -## 設計根拠 - -- **上限なし**: `ABC319OnwardsProvider` と同パターン。次の特別回(AWC0200 等)で分割すればよい -- **却下**: `AWC0151To0199` 固定範囲 — 上限不明なので Onwards が適切 - ---- - -## Task 1: seed データ更新 - -- [ ] `prisma/tasks.ts:8358-8461` — AWC0150 の `name`/`title` を添付 `awc0150.json` の実問題名に差し替え(`id`/`contest_id`/`problem_index` は変更なし) -- [ ] AWC0150 ブロックの直前に AWC0151 ダミー 5 件(A-E)追加。フォーマットは既存の `Dummy Problem X` パターン準拠 - -## Task 2: フィクスチャ追加 - -- [ ] `contest_table_provider.ts` に AWC0151・0152 の 2 コンテスト分フィクスチャ追加。`taskResultsForAWC0151OnwardsProvider` として export - -```typescript -const [awc0151_a, ...] = createContestTasks('awc0151', [ - { taskTableIndex: 'A', statusName: AC }, - { taskTableIndex: 'B', statusName: AC }, - { taskTableIndex: 'C', statusName: AC_WITH_EDITORIAL }, - { taskTableIndex: 'D', statusName: TRYING }, - { taskTableIndex: 'E', statusName: PENDING }, -]); -// awc0152 も同様に 5 件 -``` - -## Task 3: Provider テスト → 実装 (TDD) - -### テスト(`awc_provider.test.ts`) - -`AWC0150Provider` describe の**前**に追加: - -```typescript -describe('AWC0151OnwardsProvider', () => { - // フィルタ: awc0151, awc0152 を含み awc0150, awc0149 を除外 - // metadata: title='AtCoder Weekday Contest 0151 〜', abbreviationName='awc0151Onwards' - // displayConfig: AWC0101To0149Provider と同一(header/roundLabel 表示、taskIndex 非表示) - // roundLabel: 'awc0151' → '0151', 'awc0200' → '0200' - // generateTable: 各コンテスト 5 問 (A-E) - // empty: 空配列 → 空配列 -}); -``` - -### 実装(`awc_provider.ts`) - -`AWC0150Provider` の**前**に追加: - -```typescript -export class AWC0151OnwardsProvider extends ContestTableProviderBase { - constructor(contestType: ContestType) { - super(contestType, '0151Onwards'); - } - - 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 >= 151; - }; - } - - getMetadata(): ContestTableMetaData { - return { title: 'AtCoder Weekday Contest 0151 〜', abbreviationName: 'awc0151Onwards' }; - } - - getDisplayConfig(): ContestTableDisplayConfig { - return { - isShownHeader: true, - isShownRoundLabel: true, - roundLabelWidth: 'xl:w-16', - tableBodyCellsWidth: 'w-1/2 sm:w-1/3 md:w-1/4 lg:w-1/5 px-1 py-1', - isShownTaskIndex: false, - }; - } - - getContestRoundLabel(contestId: string): string { - const contestNameLabel = getContestNameLabel(contestId); - return contestNameLabel.replace('AWC ', ''); - } -} -``` - -## Task 4: グループ登録テスト → 実装 (TDD) - -### テスト(`contest_table_provider_groups.test.ts`) - -```typescript -// AWC0001Onwards テストを更新: -expect(group.getSize()).toBe(5); // 4 → 5 -expect(group.getProvider(ContestType.AWC, '0151Onwards')).toBeInstanceOf(AWC0151OnwardsProvider); -// 既存の 0150, 0101To0149, 0100, default はそのまま -``` - -### 実装(`contest_table_provider_groups.ts`) - -```typescript -AWC0001Onwards: () => - new ContestTableProviderGroup(...) - .addProvider(new AWC0151OnwardsProvider(ContestType.AWC)) // ← 先頭に追加 - .addProvider(new AWC0150Provider(ContestType.AWC)) - .addProvider(new AWC0101To0149Provider(ContestType.AWC)) - .addProvider(new AWC0100Provider(ContestType.AWC)) - .addProvider(new AWC0001To0099Provider(ContestType.AWC)), -``` - -## Task 5: ドキュメント・フォーマット - -- [ ] `how-to-add-contest-table-provider.md` — 範囲フィルタ型テーブルに `| AWC 0151- | 0151〜 | A〜E | - |` 追加、†注に `'0151Onwards'` 追加、最終更新日 → `2026-09-05` -- [ ] `pnpm format && pnpm lint && pnpm test:unit` diff --git a/docs/guides/how-to-add-contest-table-provider.md b/docs/guides/how-to-add-contest-table-provider.md index 23e7c46e5..a8c68d045 100644 --- a/docs/guides/how-to-add-contest-table-provider.md +++ b/docs/guides/how-to-add-contest-table-provider.md @@ -20,7 +20,19 @@ `contest_id` の数値部分で範囲フィルタ。ラウンド名・ヘッダー表示、問題 id 非表示。 +**AWC** は config-driven な `AWCRangeProvider` を使い、`minRound`/`maxRound` 等を config で渡してインスタンス化する(具象クラスの追加不要)。 + ```typescript +// AWC: config-driven(新規追加時はインスタンス化のみ) +new AWCRangeProvider(ContestType.AWC, { + section: '0001To0099', + minRound: 1, + maxRound: 99, + title: 'AtCoder Weekday Contest 0001 〜 0099', + abbreviationName: 'awc0001To0099', +}); + +// ABC 等: 具象クラスで実装(従来パターン) protected setFilterCondition(): (taskResult: TaskResult) => boolean { return (taskResult: TaskResult) => { if (classifyContest(taskResult.contest_id) !== this.contestType) return false; @@ -34,7 +46,18 @@ protected setFilterCondition(): (taskResult: TaskResult) => boolean { 単一 `contest_id` のみフィルタ。問題 id 表示、ラウンド名・ヘッダー非表示。 +**AWC 特殊回** は config-driven な `AWCSpecialContestProvider` を使い、`contestId` 等を config で渡してインスタンス化する(具象クラスの追加不要)。 + ```typescript +// AWC 特殊回: config-driven(新規追加時はインスタンス化のみ) +new AWCSpecialContestProvider(ContestType.AWC, { + section: '0100', + contestId: 'awc0100', + title: 'AtCoder Weekday Contest 0100', + abbreviationName: 'awc0100', +}); + +// EDPC 等: 具象クラスで実装(従来パターン) protected setFilterCondition(): (taskResult: TaskResult) => boolean { return (taskResult: TaskResult) => { if (classifyContest(taskResult.contest_id) !== this.contestType) return false; @@ -61,7 +84,7 @@ AOJ/JAG 固有の詳細(`AOJ_LABEL_OVERRIDES`、`titleStyle`、同一年2回 ### 補足: 固定セクションによる同一 ContestType 内の共存 -同一 `ContestType` で異なるクラスを共存させる場合、`super(contestType, '0100')` のように固定文字列で provider key を一意化する(例: `AWC::0100`)。AWC0100 / AWC0150 はパターン2だが、範囲フィルタ型の AWC0001-0099 等と同じ `ContestType.AWC` グループに登録されている。 +同一 `ContestType` で異なるプロバイダを共存させる場合、config の `section` フィールドで provider key を一意化する(例: `AWC::0100`)。AWC は `AWCRangeProvider`(通常回)と `AWCSpecialContestProvider`(特殊回)の2クラスを config で使い分けており、すべて同じ `ContestType.AWC` グループに登録される。新しい AWC 回の追加は `contest_table_provider_groups.ts` で `new AWCRangeProvider(...)` または `new AWCSpecialContestProvider(...)` をインスタンス化するだけでよい。 --- 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 b3db08563..77372acb4 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 @@ -2,41 +2,6 @@ import { describe, test, expect } from 'vitest'; import { ContestType } from '$lib/types/contest'; -import { - ABSProvider, - ABC319OnwardsProvider, - ABC212ToABC318Provider, - ABC126ToABC211Provider, - ABC042ToABC125Provider, - ABC001ToABC041Provider, - ARC104OnwardsProvider, - ARC058ToARC103Provider, - ARC001ToARC057Provider, - AGC001OnwardsProvider, - ABCLikeProvider, - AWCRangeProvider, - AWCSpecialContestProvider, - ACLPracticeProvider, - ACLBeginnerProvider, - ACLProvider, - EDPCProvider, - TDPCProvider, - NDPCProvider, - FPS24Provider, - JOIFirstQualRoundProvider, - JOISecondQualRound2020OnwardsProvider, - JOIQualRoundFrom2006To2019Provider, - JOISemiFinalRoundProvider, - Typical90Provider, - TessokuBookForExamplesProvider, - TessokuBookForPracticalsProvider, - TessokuBookForChallengesProvider, - MathAndAlgorithmProvider, - AojIcpcPrelimProvider, - AojIcpcRegionalProvider, - JagPrelimProvider, - prepareContestProviderPresets, -} from './contest_table_provider'; import { TESSOKU_SECTIONS } from '$features/tasks/types/contest-table/contest_table_provider'; import { @@ -46,337 +11,258 @@ import { ICPC_REGIONAL_LATEST_YEAR, JAG_PRELIM_OLDEST_YEAR, JAG_PRELIM_LATEST_YEAR, + prepareContestProviderPresets, } from './contest_table_provider_groups'; describe('prepareContestProviderPresets', () => { - test('expects to create ABS preset correctly', () => { - const group = prepareContestProviderPresets().ABS(); - - expect(group.getGroupName()).toBe('AtCoder Beginners Selection'); - expect(group.getMetadata()).toEqual({ - buttonLabel: 'ABS', - ariaLabel: 'Filter AtCoder Beginners Selection', - }); - expect(group.getSize()).toBe(1); - expect(group.getProvider(ContestType.ABS)).toBeInstanceOf(ABSProvider); - }); - - test('expects to create ABC319Onwards preset correctly', () => { - const group = prepareContestProviderPresets().ABC319Onwards(); - - expect(group.getGroupName()).toBe('ABC 319 Onwards'); - expect(group.getMetadata()).toEqual({ - buttonLabel: 'ABC 319 〜 ', - ariaLabel: 'Filter contests from ABC 319 onwards', - }); - expect(group.getSize()).toBe(1); - expect(group.getProvider(ContestType.ABC)).toBeInstanceOf(ABC319OnwardsProvider); - }); - - test('expects to create fromABC212ToABC318 preset correctly', () => { - const group = prepareContestProviderPresets().ABC212ToABC318(); - - expect(group.getGroupName()).toBe('From ABC 212 to ABC 318'); - expect(group.getMetadata()).toEqual({ - buttonLabel: 'ABC 212 〜 318', - ariaLabel: 'Filter contests from ABC 212 to ABC 318', - }); - expect(group.getSize()).toBe(1); - expect(group.getProvider(ContestType.ABC)).toBeInstanceOf(ABC212ToABC318Provider); - }); - - test('expects to create fromABC126ToABC211 preset correctly', () => { - const group = prepareContestProviderPresets().ABC126ToABC211(); - - expect(group.getGroupName()).toBe('From ABC 126 to ABC 211'); - expect(group.getMetadata()).toEqual({ - buttonLabel: 'ABC 126 〜 211', - ariaLabel: 'Filter contests from ABC 126 to ABC 211', - }); - expect(group.getSize()).toBe(1); - expect(group.getProvider(ContestType.ABC)).toBeInstanceOf(ABC126ToABC211Provider); - }); - - test('expects to create fromABC042ToABC125 preset correctly', () => { - const group = prepareContestProviderPresets().ABC042ToABC125(); - - expect(group.getGroupName()).toBe('From ABC 042 to ABC 125'); - expect(group.getMetadata()).toEqual({ - buttonLabel: 'ABC 042 〜 125', - ariaLabel: 'Filter contests from ABC 042 to ABC 125', - }); - expect(group.getSize()).toBe(1); - expect(group.getProvider(ContestType.ABC)).toBeInstanceOf(ABC042ToABC125Provider); - }); - - test('expects to create fromABC001ToABC041 preset correctly', () => { - const group = prepareContestProviderPresets().ABC001ToABC041(); - - expect(group.getGroupName()).toBe('From ABC 001 to ABC 041'); - expect(group.getMetadata()).toEqual({ - buttonLabel: '旧 ABC', - ariaLabel: 'Filter contests from ABC 001 to ABC 041', - }); - expect(group.getSize()).toBe(1); - expect(group.getProvider(ContestType.ABC)).toBeInstanceOf(ABC001ToABC041Provider); - }); - - test('expects to create ARC104Onwards preset correctly', () => { - const group = prepareContestProviderPresets().ARC104Onwards(); - - expect(group.getGroupName()).toBe('ARC 104 Onwards'); - expect(group.getMetadata()).toEqual({ - buttonLabel: 'ARC 104 〜 ', - ariaLabel: 'Filter contests from ARC 104 onwards', - }); - expect(group.getSize()).toBe(1); - expect(group.getProvider(ContestType.ARC)).toBeInstanceOf(ARC104OnwardsProvider); - }); - - test('expects to create ARC058ToARC103 preset correctly', () => { - const group = prepareContestProviderPresets().ARC058ToARC103(); - - expect(group.getGroupName()).toBe('ARC 058 To ARC 103'); - expect(group.getMetadata()).toEqual({ - buttonLabel: 'ARC 058 〜 103', - ariaLabel: 'Filter contests from ARC 058 to ARC 103', - }); - expect(group.getSize()).toBe(1); - expect(group.getProvider(ContestType.ARC)).toBeInstanceOf(ARC058ToARC103Provider); - }); - - test('expects to create ARC001ToARC057 preset correctly', () => { - const group = prepareContestProviderPresets().ARC001ToARC057(); - - expect(group.getGroupName()).toBe('ARC 001 To ARC 057'); - expect(group.getMetadata()).toEqual({ - buttonLabel: '旧 ARC', - ariaLabel: 'Filter contests from ARC 001 to ARC 057', - }); - expect(group.getSize()).toBe(1); - expect(group.getProvider(ContestType.ARC)).toBeInstanceOf(ARC001ToARC057Provider); - }); - - test('expects to create AGC001Onwards preset correctly', () => { - const group = prepareContestProviderPresets().AGC001Onwards(); - - expect(group.getGroupName()).toBe('AGC 001 Onwards'); - expect(group.getMetadata()).toEqual({ - buttonLabel: 'AGC 001 〜 ', - ariaLabel: 'Filter contests from AGC 001 onwards', - }); - expect(group.getSize()).toBe(1); - expect(group.getProvider(ContestType.AGC)).toBeInstanceOf(AGC001OnwardsProvider); + const presets = prepareContestProviderPresets(); + + describe('single-provider presets', () => { + test.each([ + { + name: 'ABS', + groupName: 'AtCoder Beginners Selection', + buttonLabel: 'ABS', + ariaLabel: 'Filter AtCoder Beginners Selection', + contestType: ContestType.ABS, + }, + { + name: 'ABC319Onwards', + groupName: 'ABC 319 Onwards', + buttonLabel: 'ABC 319 〜 ', + ariaLabel: 'Filter contests from ABC 319 onwards', + contestType: ContestType.ABC, + }, + { + name: 'ABC212ToABC318', + groupName: 'From ABC 212 to ABC 318', + buttonLabel: 'ABC 212 〜 318', + ariaLabel: 'Filter contests from ABC 212 to ABC 318', + contestType: ContestType.ABC, + }, + { + name: 'ABC126ToABC211', + groupName: 'From ABC 126 to ABC 211', + buttonLabel: 'ABC 126 〜 211', + ariaLabel: 'Filter contests from ABC 126 to ABC 211', + contestType: ContestType.ABC, + }, + { + name: 'ABC042ToABC125', + groupName: 'From ABC 042 to ABC 125', + buttonLabel: 'ABC 042 〜 125', + ariaLabel: 'Filter contests from ABC 042 to ABC 125', + contestType: ContestType.ABC, + }, + { + name: 'ABC001ToABC041', + groupName: 'From ABC 001 to ABC 041', + buttonLabel: '旧 ABC', + ariaLabel: 'Filter contests from ABC 001 to ABC 041', + contestType: ContestType.ABC, + }, + { + name: 'ARC104Onwards', + groupName: 'ARC 104 Onwards', + buttonLabel: 'ARC 104 〜 ', + ariaLabel: 'Filter contests from ARC 104 onwards', + contestType: ContestType.ARC, + }, + { + name: 'ARC058ToARC103', + groupName: 'ARC 058 To ARC 103', + buttonLabel: 'ARC 058 〜 103', + ariaLabel: 'Filter contests from ARC 058 to ARC 103', + contestType: ContestType.ARC, + }, + { + name: 'ARC001ToARC057', + groupName: 'ARC 001 To ARC 057', + buttonLabel: '旧 ARC', + ariaLabel: 'Filter contests from ARC 001 to ARC 057', + contestType: ContestType.ARC, + }, + { + name: 'AGC001Onwards', + groupName: 'AGC 001 Onwards', + buttonLabel: 'AGC 001 〜 ', + ariaLabel: 'Filter contests from AGC 001 onwards', + contestType: ContestType.AGC, + }, + { + name: 'ABCLike', + groupName: 'ABC-Like', + buttonLabel: 'ABC-Like', + ariaLabel: 'Filter contests from ABC-Like', + contestType: ContestType.ABC_LIKE, + }, + { + name: 'Typical90', + groupName: '競プロ典型 90 問', + buttonLabel: '競プロ典型 90 問', + ariaLabel: 'Filter Typical 90 Problems', + contestType: ContestType.TYPICAL90, + }, + { + name: 'MathAndAlgorithm', + groupName: 'アルゴリズムと数学', + buttonLabel: 'アルゴリズムと数学', + ariaLabel: 'Filter Math and Algorithm', + contestType: ContestType.MATH_AND_ALGORITHM, + }, + { + name: 'JOIFirstQualRound', + groupName: 'JOI 一次予選', + buttonLabel: 'JOI 一次予選', + ariaLabel: 'Filter JOI First Qualifying Round', + contestType: ContestType.JOI, + }, + ] as const)( + '$name creates group with correct metadata and provider', + ({ name, groupName, buttonLabel, ariaLabel, contestType }) => { + const group = presets[name](); + + expect(group.getGroupName()).toBe(groupName); + expect(group.getMetadata()).toEqual({ buttonLabel, ariaLabel }); + expect(group.getSize()).toBe(1); + expect(group.getProvider(contestType)).toBeDefined(); + }, + ); }); - test('expects to create ABCLike preset correctly', () => { - const group = prepareContestProviderPresets().ABCLike(); + describe('multi-provider presets', () => { + test('AWC0001Onwards registers 5 providers with correct keys', () => { + const group = presets.AWC0001Onwards(); - expect(group.getGroupName()).toBe('ABC-Like'); - expect(group.getMetadata()).toEqual({ - buttonLabel: 'ABC-Like', - ariaLabel: 'Filter contests from ABC-Like', - }); - expect(group.getSize()).toBe(1); - expect(group.getProvider(ContestType.ABC_LIKE)).toBeInstanceOf(ABCLikeProvider); - }); + expect(group.getGroupName()).toBe('AWC 0001 Onwards'); + expect(group.getMetadata()).toEqual({ + buttonLabel: 'AWC 0001 〜 ', + ariaLabel: 'Filter contests from AWC 0001 onwards', + }); + expect(group.getSize()).toBe(5); - test('expects to create AWC0001Onwards preset correctly', () => { - const group = prepareContestProviderPresets().AWC0001Onwards(); + const sections = ['0151Onwards', '0150', '0101To0149', '0100', '0001To0099']; - expect(group.getGroupName()).toBe('AWC 0001 Onwards'); - expect(group.getMetadata()).toEqual({ - buttonLabel: 'AWC 0001 〜 ', - ariaLabel: 'Filter contests from AWC 0001 onwards', + for (const section of sections) { + expect(group.getProvider(ContestType.AWC, section)).toBeDefined(); + } }); - expect(group.getSize()).toBe(5); - expect(group.getProvider(ContestType.AWC, '0151Onwards')).toBeInstanceOf(AWCRangeProvider); - expect(group.getProvider(ContestType.AWC, '0150')).toBeInstanceOf(AWCSpecialContestProvider); - expect(group.getProvider(ContestType.AWC, '0101To0149')).toBeInstanceOf(AWCRangeProvider); - expect(group.getProvider(ContestType.AWC, '0100')).toBeInstanceOf(AWCSpecialContestProvider); - expect(group.getProvider(ContestType.AWC, '0001To0099')).toBeInstanceOf(AWCRangeProvider); - }); - - test('expects to create MathAndAlgorithm preset correctly', () => { - const group = prepareContestProviderPresets().MathAndAlgorithm(); - expect(group.getGroupName()).toBe('アルゴリズムと数学'); - expect(group.getMetadata()).toEqual({ - buttonLabel: 'アルゴリズムと数学', - ariaLabel: 'Filter Math and Algorithm', + test('TessokuBook registers 3 section providers', () => { + const group = presets.TessokuBook(); + + expect(group.getGroupName()).toBe('競技プログラミングの鉄則'); + expect(group.getMetadata()).toEqual({ + buttonLabel: '競技プログラミングの鉄則', + ariaLabel: 'Filter Tessoku Book', + }); + expect(group.getSize()).toBe(3); + + const sections = [ + TESSOKU_SECTIONS.EXAMPLES, + TESSOKU_SECTIONS.PRACTICALS, + TESSOKU_SECTIONS.CHALLENGES, + ]; + for (const section of sections) { + expect(group.getProvider(ContestType.TESSOKU_BOOK, section)).toBeDefined(); + } }); - expect(group.getSize()).toBe(1); - expect(group.getProvider(ContestType.MATH_AND_ALGORITHM)).toBeInstanceOf( - MathAndAlgorithmProvider, - ); - }); - test('expects to create Typical90 preset correctly', () => { - const group = prepareContestProviderPresets().Typical90(); - - expect(group.getGroupName()).toBe('競プロ典型 90 問'); - expect(group.getMetadata()).toEqual({ - buttonLabel: '競プロ典型 90 問', - ariaLabel: 'Filter Typical 90 Problems', + test('dps registers EDPC, TDPC, NDPC, and FPS 24 providers', () => { + const group = presets.dps(); + + expect(group.getGroupName()).toBe('xDPC・FPS 24'); + expect(group.getMetadata()).toEqual({ + buttonLabel: 'xDPC・FPS 24', + ariaLabel: 'xDPC and FPS 24 contests', + }); + expect(group.getSize()).toBe(4); + + const contestTypes = [ + ContestType.EDPC, + ContestType.TDPC, + ContestType.NDPC, + ContestType.FPS_24, + ]; + + for (const contestType of contestTypes) { + expect(group.getProvider(contestType)).toBeDefined(); + } }); - expect(group.getSize()).toBe(1); - expect(group.getProvider(ContestType.TYPICAL90)).toBeInstanceOf(Typical90Provider); - }); - test('expects to create TessokuBook preset correctly with 3 providers', () => { - const group = prepareContestProviderPresets().TessokuBook(); + test('Acl registers ACL Practice, ACL Beginner, and ACL Contest providers', () => { + const group = presets.Acl(); - 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, - ); - }); + expect(group.getGroupName()).toBe('AtCoder Library Contests'); + expect(group.getMetadata()).toEqual({ + buttonLabel: 'AtCoder Library (ACL)', + ariaLabel: 'Filter ACL Contests', + }); + expect(group.getSize()).toBe(3); - test('expects to create DPs preset correctly', () => { - const group = prepareContestProviderPresets().dps(); + const contestTypes = [ContestType.ACL_PRACTICE, ContestType.ABC_LIKE, ContestType.ARC_LIKE]; - expect(group.getGroupName()).toBe('xDPC・FPS 24'); - expect(group.getMetadata()).toEqual({ - buttonLabel: 'xDPC・FPS 24', - ariaLabel: 'xDPC and FPS 24 contests', + for (const contestType of contestTypes) { + expect(group.getProvider(contestType)).toBeDefined(); + } }); - expect(group.getSize()).toBe(4); - expect(group.getProvider(ContestType.EDPC)).toBeInstanceOf(EDPCProvider); - expect(group.getProvider(ContestType.TDPC)).toBeInstanceOf(TDPCProvider); - expect(group.getProvider(ContestType.NDPC)).toBeInstanceOf(NDPCProvider); - expect(group.getProvider(ContestType.FPS_24)).toBeInstanceOf(FPS24Provider); - }); - test('expects to create ACL preset correctly with 3 providers', () => { - const group = prepareContestProviderPresets().Acl(); + test('JOISecondQualAndSemiFinalRound registers 3 section providers', () => { + const group = presets.JOISecondQualAndSemiFinalRound(); - expect(group.getGroupName()).toBe('AtCoder Library Contests'); - expect(group.getMetadata()).toEqual({ - buttonLabel: 'AtCoder Library (ACL)', - ariaLabel: 'Filter ACL Contests', - }); - expect(group.getSize()).toBe(3); - expect(group.getProvider(ContestType.ACL_PRACTICE)).toBeInstanceOf(ACLPracticeProvider); - expect(group.getProvider(ContestType.ABC_LIKE)).toBeInstanceOf(ACLBeginnerProvider); - expect(group.getProvider(ContestType.ARC_LIKE)).toBeInstanceOf(ACLProvider); - }); + expect(group.getGroupName()).toBe('JOI 二次予選・本選'); + expect(group.getMetadata()).toEqual({ + buttonLabel: 'JOI 二次予選・本選', + ariaLabel: 'Filter JOI Second Qual and Semi-Final Round', + }); + expect(group.getSize()).toBe(3); - test('expects to create JOI First Qual Round preset correctly with 1 provider', () => { - const group = prepareContestProviderPresets().JOIFirstQualRound(); + const sections = ['2020Onwards', 'from2006To2019', 'semiFinal']; - expect(group.getGroupName()).toBe('JOI 一次予選'); - expect(group.getMetadata()).toEqual({ - buttonLabel: 'JOI 一次予選', - ariaLabel: 'Filter JOI First Qualifying Round', + for (const section of sections) { + expect(group.getProvider(ContestType.JOI, section)).toBeDefined(); + } }); - expect(group.getSize()).toBe(1); - expect(group.getProvider(ContestType.JOI)).toBeInstanceOf(JOIFirstQualRoundProvider); }); - test('expects to create JOI Second Qual and Semi-Final Round preset correctly with 3 providers', () => { - const group = prepareContestProviderPresets().JOISecondQualAndSemiFinalRound(); - - expect(group.getGroupName()).toBe('JOI 二次予選・本選'); - expect(group.getMetadata()).toEqual({ - buttonLabel: 'JOI 二次予選・本選', - ariaLabel: 'Filter JOI Second Qual and Semi-Final Round', - }); - expect(group.getSize()).toBe(3); - expect(group.getProvider(ContestType.JOI, '2020Onwards')).toBeInstanceOf( - JOISecondQualRound2020OnwardsProvider, - ); - expect(group.getProvider(ContestType.JOI, 'from2006To2019')).toBeInstanceOf( - JOIQualRoundFrom2006To2019Provider, - ); - expect(group.getProvider(ContestType.JOI, 'semiFinal')).toBeInstanceOf( - JOISemiFinalRoundProvider, - ); - }); - - test('expects to create AojIcpcPrelim preset correctly', () => { - const group = prepareContestProviderPresets().AojIcpcPrelim(); - - expect(group.getGroupName()).toBe('ICPC 国内予選'); - expect(group.getMetadata()).toEqual({ - buttonLabel: 'ICPC 国内予選', - ariaLabel: 'Filter ICPC Domestic Preliminary', + describe('year-range presets', () => { + test('AojIcpcPrelim registers one provider per year', () => { + const group = presets.AojIcpcPrelim(); + + expect(group.getGroupName()).toBe('ICPC 国内予選'); + expect(group.getMetadata()).toEqual({ + buttonLabel: 'ICPC 国内予選', + ariaLabel: 'Filter ICPC Domestic Preliminary', + }); + expect(group.getSize()).toBe(ICPC_PRELIM_LATEST_YEAR - ICPC_PRELIM_OLDEST_YEAR + 1); + expect(group.getProvider(ContestType.AOJ_ICPC, '2023')).toBeDefined(); }); - expect(group.getSize()).toBe(ICPC_PRELIM_LATEST_YEAR - ICPC_PRELIM_OLDEST_YEAR + 1); // 29 - expect(group.getProvider(ContestType.AOJ_ICPC, '2023')).toBeInstanceOf(AojIcpcPrelimProvider); - }); - test('expects to create AojJagPrelim preset correctly', () => { - const group = prepareContestProviderPresets().AojJagPrelim(); + test('AojIcpcRegional registers one provider per year', () => { + const group = presets.AojIcpcRegional(); - expect(group.getGroupName()).toBe('JAG 模擬国内'); - expect(group.getMetadata()).toEqual({ - buttonLabel: 'JAG 模擬国内', - ariaLabel: 'Filter JAG Domestic Preliminary', + expect(group.getGroupName()).toBe('ICPC アジア地区'); + expect(group.getMetadata()).toEqual({ + buttonLabel: 'ICPC アジア地区', + ariaLabel: 'Filter ICPC Asia Regional', + }); + expect(group.getSize()).toBe(ICPC_REGIONAL_LATEST_YEAR - ICPC_REGIONAL_OLDEST_YEAR + 1); + expect(group.getProvider(ContestType.AOJ_ICPC, '2024')).toBeDefined(); }); - // 2016 was held twice (A/B), adding one extra table beyond the year span. - expect(group.getSize()).toBe(JAG_PRELIM_LATEST_YEAR - JAG_PRELIM_OLDEST_YEAR + 1 + 1); // 22 + 1 = 23 - expect(group.getProvider(ContestType.AOJ_JAG, '2023')).toBeInstanceOf(JagPrelimProvider); - expect(group.getProvider(ContestType.AOJ_JAG, '2016A')).toBeInstanceOf(JagPrelimProvider); - expect(group.getProvider(ContestType.AOJ_JAG, '2016B')).toBeInstanceOf(JagPrelimProvider); - }); - - test('expects to create AojIcpcRegional preset correctly', () => { - const group = prepareContestProviderPresets().AojIcpcRegional(); - expect(group.getGroupName()).toBe('ICPC アジア地区'); - expect(group.getMetadata()).toEqual({ - buttonLabel: 'ICPC アジア地区', - ariaLabel: 'Filter ICPC Asia Regional', + test('AojJagPrelim registers providers including A/B split for 2016', () => { + const group = presets.AojJagPrelim(); + + expect(group.getGroupName()).toBe('JAG 模擬国内'); + expect(group.getMetadata()).toEqual({ + buttonLabel: 'JAG 模擬国内', + ariaLabel: 'Filter JAG Domestic Preliminary', + }); + // 2016 was held twice (A/B), adding one extra provider beyond the year span. + expect(group.getSize()).toBe(JAG_PRELIM_LATEST_YEAR - JAG_PRELIM_OLDEST_YEAR + 1 + 1); + expect(group.getProvider(ContestType.AOJ_JAG, '2023')).toBeDefined(); + expect(group.getProvider(ContestType.AOJ_JAG, '2016A')).toBeDefined(); + expect(group.getProvider(ContestType.AOJ_JAG, '2016B')).toBeDefined(); }); - expect(group.getSize()).toBe(ICPC_REGIONAL_LATEST_YEAR - ICPC_REGIONAL_OLDEST_YEAR + 1); // 27 - expect(group.getProvider(ContestType.AOJ_ICPC, '2024')).toBeInstanceOf(AojIcpcRegionalProvider); - }); - - test('expects to verify all presets are functions', () => { - const presets = prepareContestProviderPresets(); - - expect(typeof presets.ABS).toBe('function'); - expect(typeof presets.ABC319Onwards).toBe('function'); - expect(typeof presets.ABC212ToABC318).toBe('function'); - expect(typeof presets.ABC126ToABC211).toBe('function'); - expect(typeof presets.ABC042ToABC125).toBe('function'); - expect(typeof presets.ARC104Onwards).toBe('function'); - expect(typeof presets.ARC058ToARC103).toBe('function'); - expect(typeof presets.AGC001Onwards).toBe('function'); - expect(typeof presets.ABCLike).toBe('function'); - expect(typeof presets.AWC0001Onwards).toBe('function'); - expect(typeof presets.ABC001ToABC041).toBe('function'); - expect(typeof presets.ARC001ToARC057).toBe('function'); - expect(typeof presets.Typical90).toBe('function'); - expect(typeof presets.TessokuBook).toBe('function'); - expect(typeof presets.MathAndAlgorithm).toBe('function'); - expect(typeof presets.dps).toBe('function'); - expect(typeof presets.Acl).toBe('function'); - expect(typeof presets.JOIFirstQualRound).toBe('function'); - expect(typeof presets.JOISecondQualAndSemiFinalRound).toBe('function'); - expect(typeof presets.AojIcpcPrelim).toBe('function'); - expect(typeof presets.AojIcpcRegional).toBe('function'); - expect(typeof presets.AojJagPrelim).toBe('function'); - }); - - test('expects each preset to create independent instances', () => { - const presets = prepareContestProviderPresets(); - const group1 = presets.ABS(); - const group2 = presets.ABS(); - - expect(group1).not.toBe(group2); - expect(group1.getGroupName()).toBe(group2.getGroupName()); - expect(group1.getProvider(ContestType.ABS)).not.toBe(group2.getProvider(ContestType.ABS)); }); }); From 27903a91b826b82a2190529d8803817c27844aba Mon Sep 17 00:00:00 2001 From: "k.hiro1818" Date: Sat, 5 Sep 2026 12:48:58 +0000 Subject: [PATCH 4/7] docs(skill): add AWC config-driven provider checks to add-contest-table-provider Update skill to cover 4 patterns and add checks for reusing AWCRangeProvider and AWCSpecialContestProvider before creating new classes. Co-Authored-By: Claude Opus 4.6 Claude-Session: https://claude.ai/code/session_01LpBu3jvGWLNEeyWVL2PQZT --- .claude/skills/add-contest-table-provider/SKILL.md | 2 +- .claude/skills/add-contest-table-provider/instructions.md | 5 +++++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/.claude/skills/add-contest-table-provider/SKILL.md b/.claude/skills/add-contest-table-provider/SKILL.md index 5df8a11d9..8fd84db23 100644 --- a/.claude/skills/add-contest-table-provider/SKILL.md +++ b/.claude/skills/add-contest-table-provider/SKILL.md @@ -1,6 +1,6 @@ --- name: add-contest-table-provider -description: Add a new ContestType and ContestTableProvider across 5 layers using TDD. Covers all 3 patterns. Asks targeted questions to gather pattern-specific requirements before touching code. +description: Add a new ContestType and ContestTableProvider across 5 layers using TDD. Covers all 4 patterns. Asks targeted questions to gather pattern-specific requirements before touching code. argument-hint: ' ' --- diff --git a/.claude/skills/add-contest-table-provider/instructions.md b/.claude/skills/add-contest-table-provider/instructions.md index ebeda9787..cc811f030 100644 --- a/.claude/skills/add-contest-table-provider/instructions.md +++ b/.claude/skills/add-contest-table-provider/instructions.md @@ -20,6 +20,7 @@ Step 0 (seed check) is already done. Confirm the following before touching code: **Pattern 1 additional:** +- **AWC config-driven check:** Can `AWCRangeProvider` cover this? If yes → skip Layer 4 (no new class); only add `new AWCRangeProvider(ContestType.AWC, { section, minRound, maxRound, title, abbreviationName })` in Layer 5. See `awc_provider.ts`. - Numeric range: start and end (open-ended if no upper bound)? - Splitting an existing "Onwards" provider? Rename to `{Start}To{End}Provider`; the new special-edition provider uses a fixed section string (e.g. `super(contestType, '0100')`) to coexist under the same ContestType. - Shared problems with another contest (e.g. ARC–ABC overlap)? Which contest_ids appear in both? @@ -34,6 +35,10 @@ Step 0 (seed check) is already done. Confirm the following before touching code: - Display-only positional label needed (e.g. prepend "A. ")? → override `getTaskLabels` to return `{ [contestId]: { index: letter } }`; **never mutate title inside `generateTable`** (transformed objects written back via optimistic update cause prefix accumulation on the next `$derived` re-run). - Known edge cases where the default algorithm breaks? → add a `Record>` module-level override map keyed by contest_id; exercise the override path in tests by mutating the export in `beforeEach` and cleaning up in `afterEach`. +**Pattern 2 additional:** + +- **AWC config-driven check:** Can `AWCSpecialContestProvider` cover this? (special AWC contest_id with section key) If yes → skip Layer 4; only add `new AWCSpecialContestProvider(ContestType.AWC, { section, contestId, title, abbreviationName })` in Layer 5. See `awc_provider.ts`. + **Pattern 3 additional:** - Show the full contest_id list found in `prisma/tasks.ts` — any missing or to exclude? From cdafa4263821bda7b36e6ff2b5bef3e7f2ec7b08 Mon Sep 17 00:00:00 2001 From: "k.hiro1818" Date: Sat, 5 Sep 2026 12:54:20 +0000 Subject: [PATCH 5/7] docs(skill): condense add-contest-table-provider instructions Remove verbose examples and redundant steps while preserving all essential checklist items and pattern-specific guidance. Co-Authored-By: Claude Opus 4.6 Claude-Session: https://claude.ai/code/session_01LpBu3jvGWLNEeyWVL2PQZT --- .../instructions.md | 136 +++++------------- 1 file changed, 38 insertions(+), 98 deletions(-) diff --git a/.claude/skills/add-contest-table-provider/instructions.md b/.claude/skills/add-contest-table-provider/instructions.md index cc811f030..d70360878 100644 --- a/.claude/skills/add-contest-table-provider/instructions.md +++ b/.claude/skills/add-contest-table-provider/instructions.md @@ -14,143 +14,83 @@ Step 0 (seed check) is already done. Confirm the following before touching code: - Pattern 1: numeric range filter (e.g. ABC 001–041) - Pattern 2: single fixed contest_id (e.g. NDPC, TDPC, FPS_24) - Pattern 3: multiple contest_ids unified in one table (e.g. ABS, ABC-Like) - - Pattern 4: one class instantiated N times via constructor parameter (e.g. ICPC Prelim by year) + - Pattern 4: one class instantiated N times via constructor parameter (e.g. ICPC by year) - Nearest neighbor ContestType for insertion order in `contestTypePriorities`? - New group or merge into existing? If new: group name / `buttonLabel` / `ariaLabel`? -**Pattern 1 additional:** +**Pattern 1:** For AWC, use config-driven `AWCRangeProvider` (skip Layer 4; instantiate in Layer 5 only. See `awc_provider.ts`). Otherwise confirm range, shared problems, and round label format. -- **AWC config-driven check:** Can `AWCRangeProvider` cover this? If yes → skip Layer 4 (no new class); only add `new AWCRangeProvider(ContestType.AWC, { section, minRound, maxRound, title, abbreviationName })` in Layer 5. See `awc_provider.ts`. -- Numeric range: start and end (open-ended if no upper bound)? -- Splitting an existing "Onwards" provider? Rename to `{Start}To{End}Provider`; the new special-edition provider uses a fixed section string (e.g. `super(contestType, '0100')`) to coexist under the same ContestType. -- Shared problems with another contest (e.g. ARC–ABC overlap)? Which contest_ids appear in both? -- Round label format (e.g. `ABC 042`)? +**Pattern 2:** For AWC special rounds, use config-driven `AWCSpecialContestProvider` (same as above). -**Pattern 4 additional:** +**Pattern 3:** Show the contest_id list from `prisma/tasks.ts`. Confirm `contest_task_pairs.ts` update needed, index format (numeric/alphabetic), section splits. -- Constructor parameter name and type (e.g. `year: number`)? -- Year/ID range: oldest and latest? Export both as named constants so tests can reference them. -- Iteration order: latest-first so newest table renders on top. -- `task_table_index` values numeric strings? → override `getHeaderIdsForTask`; sort with `Number(a) - Number(b)`. -- Display-only positional label needed (e.g. prepend "A. ")? → override `getTaskLabels` to return `{ [contestId]: { index: letter } }`; **never mutate title inside `generateTable`** (transformed objects written back via optimistic update cause prefix accumulation on the next `$derived` re-run). -- Known edge cases where the default algorithm breaks? → add a `Record>` module-level override map keyed by contest_id; exercise the override path in tests by mutating the export in `beforeEach` and cleaning up in `afterEach`. - -**Pattern 2 additional:** - -- **AWC config-driven check:** Can `AWCSpecialContestProvider` cover this? (special AWC contest_id with section key) If yes → skip Layer 4; only add `new AWCSpecialContestProvider(ContestType.AWC, { section, contestId, title, abbreviationName })` in Layer 5. See `awc_provider.ts`. - -**Pattern 3 additional:** - -- Show the full contest_id list found in `prisma/tasks.ts` — any missing or to exclude? -- Does `prisma/contest_task_pairs.ts` need updating (shared task_ids across contests)? -- task_table_index format: numeric (`001–`) or alphabetic (`A–`)? -- Section splits needed? If yes: split key and section names? +**Pattern 4:** Confirm parameter name/type, range (oldest/latest), whether index is numeric string, display label transformation needed, known edge cases. --- -## Layer 1 — Prisma schema - -> **Skip Layers 1–2** when an existing ContestType already covers this contest's family — e.g. -> `AOJ_JAG` spans JAG Prelim / Regional / Camp, so adding Prelim-only reuses it with no schema / -> priority / type change. Only add a new ContestType when no existing one fits; then Layer 3 is -> also just a classify-regex tweak, not a new branch. - -- [ ] Add `XXX // Full Contest Name` to `prisma/schema.prisma` ContestType enum (after nearest neighbor) -- [ ] `pnpm exec prisma generate` — non-interactive env; `migrate dev` requires interactive shell -- [ ] `pnpm check` — expect a type error in `src/lib/types/contest.ts` (confirms client regenerated) +## Layers 1–2 — Add ContestType -## Layer 2 — TypeScript ContestType constant +> Skip when an existing ContestType already covers this contest family (e.g. `AOJ_JAG` for JAG Prelim). -- [ ] Add `XXX: 'XXX', // Full Contest Name` to `ContestType` in `src/lib/types/contest.ts` (same position as schema) -- [ ] `pnpm check` — error should be gone +- [ ] Add to `prisma/schema.prisma` ContestType enum → `pnpm exec prisma generate` +- [ ] Add to `ContestType` in `src/lib/types/contest.ts` (same position as schema) +- [ ] `pnpm check` — confirm error resolved ## Layer 3 — Contest utilities (TDD) -### Write tests first +### Tests first -- [ ] Add export to `src/test/lib/utils/test_cases/contest_type.ts` (after nearest neighbor) -- [ ] Add export to `src/test/lib/utils/test_cases/contest_name_labels.ts` (after nearest neighbor) -- [ ] Add three `describe('when contest_id is xxx')` blocks to `src/test/lib/utils/contest.test.ts`: - - under `classify contest` - - under `get contest priority` - - under `get contest name label` -- [ ] `pnpm test:unit src/test/lib/utils/contest.test.ts` — **expect RED** +- [ ] Add exports to `src/test/lib/utils/test_cases/contest_type.ts` and `contest_name_labels.ts` +- [ ] Add 3 `describe` blocks to `src/test/lib/utils/contest.test.ts`: classify / priority / name label +- [ ] `pnpm test:unit src/test/lib/utils/contest.test.ts` — **RED** ### Implement -- [ ] Add `classifyContest` branch after nearest neighbor's branch in `src/lib/utils/contest.ts` -- [ ] Insert `[ContestType.XXX, N]` into `contestTypePriorities` after nearest neighbor - - All entries after the insertion point shift by +1 - - **Update the JSDoc numeric ranges** — do NOT rename or split the existing four categories - (Educational / Contests for genius / Special contests / External platforms) - - **Search `src/test/lib/utils/task.test.ts` for hardcoded priority-diff expected values** - and decrement by 1 for every ContestType that shifted -- [ ] Add `getContestNameLabel` branch after nearest neighbor's branch -- [ ] `pnpm test:unit src/test/lib/utils/contest.test.ts` — **expect GREEN** +- [ ] Add branches to `classifyContest` / `contestTypePriorities` / `getContestNameLabel` + - After priority insertion, all later entries shift +1 → **update JSDoc numeric ranges** (4 category names are immutable) + - Fix hardcoded priority-diff expected values in `src/test/lib/utils/task.test.ts` (-1 per shifted entry) +- [ ] **GREEN** --- ## Layer 4 — Provider class (TDD) -### Pattern 2: single source - -- [ ] Add entry to `describe.each` array in `dp_providers.test.ts` (or the appropriate `*_providers.test.ts`) -- [ ] Add import of new Provider class -- [ ] `pnpm test:unit ` — **expect RED** -- [ ] Implement Provider class in the appropriate `*_providers.ts` after nearest neighbor -- [ ] `pnpm test:unit ` — **expect GREEN** - ### Pattern 1: range filter -- [ ] Add test cases covering range boundaries and at least one mid-range value -- [ ] If shared problems exist: add a test case with mixed contest_ids to confirm exclusion -- [ ] If splitting an existing range: add a combined-fixture test confirming the upper bound excludes the adjacent range's contest_id: `[...fixtureA, ...fixtureB]` → `filter` → assert `some(task => task.contest_id === 'out-of-range') === false` -- [ ] `pnpm test:unit ` — **expect RED** -- [ ] Implement Provider using `parseContestRound()` range check -- [ ] `pnpm test:unit ` — **expect GREEN** +- [ ] Tests: boundary values + mid-range. If shared problems exist, verify exclusion with mixed contest_ids. If splitting a range, verify adjacent range exclusion with `[...fixtureA, ...fixtureB]` +- [ ] **RED** → implement with `parseContestRound()` range check → **GREEN** -### Pattern 4: N-instances via constructor parameter +### Pattern 2: single source -- [ ] Export `OLDEST_YEAR` / `LATEST_YEAR` constants (module-level, before `prepareContestProviderPresets`) so tests can assert `getSize() === LATEST - OLDEST + 1` -- [ ] Pass the parameter as `section` in `super(contestType, String(param))` → provider key becomes `TYPE::value` (unique per instance) -- [ ] If `task_table_index` is a numeric string key: override `getHeaderIdsForTask` with numeric sort (`Number(a) - Number(b)`) -- [ ] If display title needs transformation (e.g. prepend "A. "): override `getTaskLabels` to return `{ [contestId]: { index: letter } }`; do NOT mutate title in `generateTable` -- [ ] Write override map (`Record>`) for known edge cases; test the override path by mutating the export in `beforeEach` and cleaning up in `afterEach` -- [ ] If provider headings need non-default font/weight/gap: return `titleStyle` (`headingTag` / `fontSize` / `fontWeight` / `bottomGap`) from `getMetadata()`; include all set fields in the `titleStyle` assertion -- [ ] If column wrap threshold differs from default (8): return `columnWrapThreshold: N` from `getDisplayConfig()`; include it in the `getDisplayConfig` assertion -- [ ] `pnpm test:unit ` — **expect GREEN** +- [ ] Tests: add entry to `describe.each` +- [ ] **RED** → implement Provider class → **GREEN** ### Pattern 3: composite -- [ ] Confirm whether `prisma/contest_task_pairs.ts` needs new entries before writing tests -- [ ] Add test cases for each constituent contest_id, plus a mixed-source test -- [ ] If section splits: add one test per section -- [ ] `pnpm test:unit ` — **expect RED** -- [ ] Implement Provider (filter by `classifyContest` equality; add section subclasses if needed) -- [ ] `pnpm test:unit ` — **expect GREEN** +- [ ] Check whether `prisma/contest_task_pairs.ts` needs updates first +- [ ] Tests: each contest_id + mixed-source. If section splits, one test per section +- [ ] **RED** → implement with `classifyContest` equality filter → **GREEN** + +### Pattern 4: constructor parameter + +- [ ] Export `OLDEST_YEAR` / `LATEST_YEAR` constants. Use `super(contestType, String(param))` for unique provider key +- [ ] Tests: `getSize() === LATEST - OLDEST + 1`, etc. +- [ ] **RED** → implement → **GREEN** +- [ ] For AOJ-specific options (`getTaskLabels` / override map / `titleStyle` / `columnWrapThreshold`), see `aoj_icpc_providers.ts` / `aoj_jag_providers.ts` --- ## Layer 5 — Group registration (TDD) -- [ ] Update `contest_table_provider_groups.test.ts`: - - New group name string, `buttonLabel`, `ariaLabel` - - `getSize()` incremented to reflect the new provider count - - Add `getProvider(ContestType.XXX)` assertion; for section-based providers use `getProvider(ContestType.XXX, 'section')` - - Add import of new Provider class -- [ ] `pnpm test:unit contest_table_provider_groups.test.ts` — **expect RED** -- [ ] Update `contest_table_provider_groups.ts`: - - Add import of new Provider class - - Update group name string, `buttonLabel`, `ariaLabel` - - Add `new XXXProvider(ContestType.XXX)` to `addProvider()` chain — **`addProvider` call order = display order (first = top)** -- [ ] `pnpm test:unit src/features/tasks/utils/contest-table/` — **expect GREEN** +- [ ] `contest_table_provider_groups.test.ts`: add group name, `buttonLabel`, `ariaLabel`, `getSize()`, `getProvider()` assertions +- [ ] **RED** +- [ ] `contest_table_provider_groups.ts`: add import + `addProvider()` (**call order = display order; first = top**) +- [ ] `pnpm test:unit src/features/tasks/utils/contest-table/` — **GREEN** --- ## Final verification -- [ ] `pnpm test:unit` -- [ ] `pnpm check` -- [ ] `pnpm lint` +- [ ] `pnpm test:unit` / `pnpm check` / `pnpm lint` Commit Layer 1–3 and Layer 4–5 as separate commits. From 1d27f01a0900000d4be08e09b1e3ae34585fd58b Mon Sep 17 00:00:00 2001 From: "k.hiro1818" Date: Sat, 5 Sep 2026 12:55:34 +0000 Subject: [PATCH 6/7] docs(skill): add label mutation warning and section-based provider test hint Co-Authored-By: Claude Opus 4.6 Claude-Session: https://claude.ai/code/session_01LpBu3jvGWLNEeyWVL2PQZT --- .claude/skills/add-contest-table-provider/instructions.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.claude/skills/add-contest-table-provider/instructions.md b/.claude/skills/add-contest-table-provider/instructions.md index d70360878..5c3ee8062 100644 --- a/.claude/skills/add-contest-table-provider/instructions.md +++ b/.claude/skills/add-contest-table-provider/instructions.md @@ -76,13 +76,14 @@ Step 0 (seed check) is already done. Confirm the following before touching code: - [ ] Export `OLDEST_YEAR` / `LATEST_YEAR` constants. Use `super(contestType, String(param))` for unique provider key - [ ] Tests: `getSize() === LATEST - OLDEST + 1`, etc. - [ ] **RED** → implement → **GREEN** +- [ ] If display label needed: use `getTaskLabels` — do NOT mutate title inside `generateTable` (optimistic update + `$derived` re-run causes prefix accumulation) - [ ] For AOJ-specific options (`getTaskLabels` / override map / `titleStyle` / `columnWrapThreshold`), see `aoj_icpc_providers.ts` / `aoj_jag_providers.ts` --- ## Layer 5 — Group registration (TDD) -- [ ] `contest_table_provider_groups.test.ts`: add group name, `buttonLabel`, `ariaLabel`, `getSize()`, `getProvider()` assertions +- [ ] `contest_table_provider_groups.test.ts`: add group name, `buttonLabel`, `ariaLabel`, `getSize()`, `getProvider()` assertions; for section-based providers use `getProvider(ContestType.XXX, 'section')` - [ ] **RED** - [ ] `contest_table_provider_groups.ts`: add import + `addProvider()` (**call order = display order; first = top**) - [ ] `pnpm test:unit src/features/tasks/utils/contest-table/` — **GREEN** From 7adc3413bb0d4249382896e6fb24e49871727834 Mon Sep 17 00:00:00 2001 From: "k.hiro1818" Date: Sat, 5 Sep 2026 13:05:35 +0000 Subject: [PATCH 7/7] test(contest-table): guard against vacuous forEach in AWCRangeProvider test Co-Authored-By: Claude Opus 4.6 Claude-Session: https://claude.ai/code/session_01LpBu3jvGWLNEeyWVL2PQZT --- src/features/tasks/utils/contest-table/awc_provider.test.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) 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 98c823e6f..6306f96fa 100644 --- a/src/features/tasks/utils/contest-table/awc_provider.test.ts +++ b/src/features/tasks/utils/contest-table/awc_provider.test.ts @@ -125,7 +125,10 @@ describe('AWCRangeProvider', () => { const filtered = provider.filter(testCase.fixtureData); const table = provider.generateTable(filtered); - Object.values(table).forEach((problems) => { + const contests = Object.values(table); + expect(contests.length).toBeGreaterThan(0); + + contests.forEach((problems) => { expect(Object.keys(problems)).toEqual(RANGE_PROBLEMS); }); });