diff --git a/docs/guides/how-to-add-contest-table-provider.md b/docs/guides/how-to-add-contest-table-provider.md index b71ccae8e..591e86522 100644 --- a/docs/guides/how-to-add-contest-table-provider.md +++ b/docs/guides/how-to-add-contest-table-provider.md @@ -10,6 +10,48 @@ --- +## 0. 実装前確認フェーズ + +新しい Provider を実装する前に、必ず以下の事項を確認してください。 + +### 事前確認チェックリスト + +- [ ] **ContestType の選択** + - 新規追加が必要か? → `src/lib/types/contest.ts` を確認 + - 既存の `ContestType` で対応できないか? → 「各コンテスト種別の特有仕様」を参照 + - 判断基準: 複数の異なる contest_id を統一表示するなら「複合型」の可能性 + +- [ ] **データ存在確認** + - `prisma/tasks.ts` に該当 `contest_id` のタスクが存在するか確認 + - 複合型の場合は `prisma/contest_task_pairs.ts` で共有問題を確認 + +- [ ] **実装パターン判定** + - **パターン1(範囲フィルタ型)**: ABC 001~041、ARC 058~103 など → 数値範囲でフィルタ + - **パターン2(単一ソース型)**: EDPC、TDPC、ACL_PRACTICE など → 単一 contest_id のみ + - **パターン3(複合ソース型)**: ABS、ABC-Like など → 複数 contest_id を統一表示 + - 対応セクション: [実装パターン](#実装パターン) + +- [ ] **ガイドの実装例の確認** + - 判定したパターンの実装例を確認してテンプレート理解 + - モック設定時に必要な `classifyContest()` の戻り値を確認 + +### 記入例 + +```markdown +**新規 Provider 名**: ACLBeginnerProvider + +事前確認結果: + +- ContestType: ABC_LIKE(既存流用) +- contest_id: abl(prisma/tasks.ts に 6 つのタスク存在確認) +- パターン: パターン2(単一ソース型) +- テンプレート: EDPC と同一構造 + +→ 実装フェーズ開始 +``` + +--- + ## Test Driven Development (TDD) 設計ガイド 新しい Provider を実装する際は、**テストファースト** のアプローチを推奨します。 @@ -207,12 +249,16 @@ class TessokuBookSectionProvider extends TessokuBookProvider { ### 単一ソース型 -| コンテスト | contest_id | セクション | フォーマット | -| ------------ | ------------- | ---------- | ------------ | -| EDPC | `'dp'` | 26問 | A~Z | -| TDPC | `'tdpc'` | 26問 | A~Z | -| FPS_24 | `'fps-24'` | 24問 | A~X | -| ACL_PRACTICE | `'practice2'` | 12問 | A~L | +| コンテスト | contest_id | セクション | フォーマット | +| -------------- | ------------- | ---------- | ------------ | +| EDPC | `'dp'` | 26問 | A~Z | +| TDPC | `'tdpc'` | 26問 | A~Z | +| FPS_24 | `'fps-24'` | 24問 | A~X | +| ACL_PRACTICE | `'practice2'` | 12問 | A~L | +| ACL_BEGINNER\* | `'abl'` | 6問 | A~F | +| ACL_CONTEST1\* | `'acl1'` | 6問 | A~F | + +\*注: ACL_PRACTICE、ACL_BEGINNER、ACL_CONTEST1 は `Acl` グループの下で 3 つのコンテストが統一管理されています。 ### 複合ソース型 @@ -419,11 +465,11 @@ describe('CustomProvider with unique config', () => { - [#2835](https://github.com/AtCoder-NoviSteps/AtCoderNoviSteps/issues/2835) - ARC104OnwardsProvider - [#2837](https://github.com/AtCoder-NoviSteps/AtCoderNoviSteps/issues/2837) - AGC001OnwardsProvider - [#2838](https://github.com/AtCoder-NoviSteps/AtCoderNoviSteps/issues/2838) - ABC001~041 & ARC001~057 -- [#2840](https://github.com/AtCoder-NoviSteps/AtCoderNoviSteps/issues/2840) - ABCLikeProvider +- [#2840](https://github.com/AtCoder-NoviSteps/AtCoderNoviSteps/issues/2840)、[#3108](https://github.com/AtCoder-NoviSteps/AtCoderNoviSteps/issues/3108) - ABCLikeProvider - [#2776](https://github.com/AtCoder-NoviSteps/AtCoderNoviSteps/issues/2776) - TessokuBookProvider - [#2785](https://github.com/AtCoder-NoviSteps/AtCoderNoviSteps/issues/2785) - MathAndAlgorithmProvider - [#2797](https://github.com/AtCoder-NoviSteps/AtCoderNoviSteps/issues/2797) - FPS24Provider -- [#2920](https://github.com/AtCoder-NoviSteps/AtCoderNoviSteps/issues/2920) - ACLPracticeProvider +- [#2920](https://github.com/AtCoder-NoviSteps/AtCoderNoviSteps/issues/2920)、[#3120](https://github.com/AtCoder-NoviSteps/AtCoderNoviSteps/issues/3120) - ACLPracticeProvider、ACLBeginnerProvider、ACLProvider ### 実装ファイル @@ -433,4 +479,4 @@ describe('CustomProvider with unique config', () => { --- -**最終更新**: 2026-01-26 +**最終更新**: 2026-02-01 diff --git a/prisma/tasks.ts b/prisma/tasks.ts index 574eeede4..53364e4cc 100755 --- a/prisma/tasks.ts +++ b/prisma/tasks.ts @@ -7976,6 +7976,48 @@ export const tasks = [ name: 'Accepted...?', title: 'A. Accepted...?', }, + { + id: 'acl1_f', + contest_id: 'acl1', + problem_index: 'F', + name: 'Center Rearranging', + title: 'F. Center Rearranging', + }, + { + id: 'acl1_e', + contest_id: 'acl1', + problem_index: 'E', + name: 'Shuffle Window', + title: 'E. Shuffle Window', + }, + { + id: 'acl1_d', + contest_id: 'acl1', + problem_index: 'D', + name: 'Keep Distances', + title: 'D. Keep Distances', + }, + { + id: 'acl1_c', + contest_id: 'acl1', + problem_index: 'C', + name: 'Moving Pieces', + title: 'C. Moving Pieces', + }, + { + id: 'acl1_b', + contest_id: 'acl1', + problem_index: 'B', + name: 'Sum is Multiple', + title: 'B. Sum is Multiple', + }, + { + id: 'acl1_a', + contest_id: 'acl1', + problem_index: 'A', + name: 'Reachable Towns', + title: 'A. Reachable Towns', + }, { id: 'tenka1_2019_f', contest_id: 'tenka1-2019', diff --git a/src/lib/utils/contest.ts b/src/lib/utils/contest.ts index 26c239e1f..0e4f8f0a8 100644 --- a/src/lib/utils/contest.ts +++ b/src/lib/utils/contest.ts @@ -130,6 +130,7 @@ const ARC_LIKE: ContestPrefix = { keyence2021: 'キーエンス プログラミング コンテスト 2021', 'jsc2019-qual': '第一回日本最強プログラマー学生選手権-予選-', 'nikkei2019-qual': '全国統一プログラミング王決定戦予選', + acl1: 'ACL Contest 1', } as const; const arcLikePrefixes = new Set(getContestPrefixes(ARC_LIKE)); diff --git a/src/lib/utils/contest_table_provider.ts b/src/lib/utils/contest_table_provider.ts index 13c8e5a1d..a7f974d6e 100644 --- a/src/lib/utils/contest_table_provider.ts +++ b/src/lib/utils/contest_table_provider.ts @@ -796,6 +796,70 @@ export class ACLPracticeProvider extends ContestTableProviderBase { } } +export class ACLBeginnerProvider extends ContestTableProviderBase { + protected setFilterCondition(): (taskResult: TaskResult) => boolean { + return (taskResult: TaskResult) => { + if (classifyContest(taskResult.contest_id) !== this.contestType) { + return false; + } + return taskResult.contest_id === 'abl'; + }; + } + + getMetadata(): ContestTableMetaData { + return { + title: 'ACL Beginner Contest', + abbreviationName: 'ABL', + }; + } + + 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 px-1 py-2', + isShownTaskIndex: true, + }; + } + + getContestRoundLabel(_contestId: string): string { + return ''; + } +} + +export class ACLProvider extends ContestTableProviderBase { + protected setFilterCondition(): (taskResult: TaskResult) => boolean { + return (taskResult: TaskResult) => { + if (classifyContest(taskResult.contest_id) !== this.contestType) { + return false; + } + return taskResult.contest_id === 'acl1'; + }; + } + + getMetadata(): ContestTableMetaData { + return { + title: 'ACL Contest 1', + abbreviationName: 'ACL', + }; + } + + 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 px-1 py-2', + isShownTaskIndex: true, + }; + } + + getContestRoundLabel(_contestId: string): string { + return ''; + } +} + const regexForJoiFirstQualRound = /^(joi)(\d{4})(yo1)(a|b|c)$/i; export class JOIFirstQualRoundProvider extends ContestTableProviderBase { @@ -1209,13 +1273,18 @@ export const prepareContestProviderPresets = () => { ), /** - * Single group for ACL Practice Contest + * Group for AtCoder Library Contests (ACL) + * Includes ACL Practice, ACL Beginner, and ACL Contest 1 */ - AclPractice: () => - new ContestTableProviderGroup(`AtCoder Library Practice Contest`, { - buttonLabel: 'ACL Practice', - ariaLabel: 'Filter ACL Practice Contest', - }).addProvider(new ACLPracticeProvider(ContestType.ACL_PRACTICE)), + Acl: () => + new ContestTableProviderGroup(`AtCoder Library Contests`, { + buttonLabel: 'ACL', + ariaLabel: 'Filter ACL Contests', + }).addProviders( + new ACLPracticeProvider(ContestType.ACL_PRACTICE), + new ACLBeginnerProvider(ContestType.ABC_LIKE), + new ACLProvider(ContestType.ARC_LIKE), + ), JOIFirstQualRound: () => new ContestTableProviderGroup(`JOI 一次予選`, { @@ -1251,7 +1320,7 @@ export const contestTableProviderGroups = { tessokuBook: prepareContestProviderPresets().TessokuBook(), mathAndAlgorithm: prepareContestProviderPresets().MathAndAlgorithm(), dps: prepareContestProviderPresets().dps(), // Dynamic Programming (DP) Contests - aclPractice: prepareContestProviderPresets().AclPractice(), + acl: prepareContestProviderPresets().Acl(), joiFirstQualRound: prepareContestProviderPresets().JOIFirstQualRound(), joiSecondQualAndSemiFinalRound: prepareContestProviderPresets().JOISecondQualAndSemiFinalRound(), }; diff --git a/src/test/lib/utils/contest_table_provider.test.ts b/src/test/lib/utils/contest_table_provider.test.ts index 5a681f56a..b84d0778a 100644 --- a/src/test/lib/utils/contest_table_provider.test.ts +++ b/src/test/lib/utils/contest_table_provider.test.ts @@ -16,6 +16,8 @@ import { AGC001OnwardsProvider, ABCLikeProvider, ACLPracticeProvider, + ACLBeginnerProvider, + ACLProvider, EDPCProvider, TDPCProvider, FPS24Provider, @@ -40,6 +42,8 @@ import { taskResultsForARC104OnwardsProvider, taskResultsForAGC001OnwardsProvider, taskResultsForACLPracticeProvider, + taskResultsForACLBeginnerProvider, + taskResultsForACLProvider, taskResultsForABCLikeProvider, } from './test_cases/contest_table_provider'; @@ -70,6 +74,10 @@ vi.mock('$lib/utils/contest', () => ({ return ContestType.MATH_AND_ALGORITHM; } else if (contestId === 'practice2') { return ContestType.ACL_PRACTICE; + } else if (contestId === 'abl') { + return ContestType.ABC_LIKE; + } else if (contestId === 'acl1') { + return ContestType.ARC_LIKE; } else if ( [ 'tenka1-2017-beginner', @@ -137,6 +145,8 @@ vi.mock('$lib/utils/contest', () => ({ } } else if (contestId === 'abl') { return 'ACL Beginner Contest'; + } else if (contestId === 'acl1') { + return 'ACL Contest 1'; } else if (contestId === 'tenka1-2017-beginner') { return 'Tenka1 2017 Beginner'; } else if (contestId === 'tenka1-2018-beginner') { @@ -2242,6 +2252,116 @@ describe('ContestTableProviderBase and implementations', () => { }); }); + describe('ACL Beginner Provider', () => { + test('filters tasks by contest_id (abl)', () => { + const provider = new ACLBeginnerProvider(ContestType.ABC_LIKE); + const filtered = provider.filter(taskResultsForACLBeginnerProvider); + + expect(filtered).toBeDefined(); + expect(filtered?.length).toBe(3); + expect(filtered?.every((task) => task.contest_id === 'abl')).toBe(true); + }); + + test('returns correct metadata', () => { + const provider = new ACLBeginnerProvider(ContestType.ABC_LIKE); + const metadata = provider.getMetadata(); + + expect(metadata.title).toBe('ACL Beginner Contest'); + expect(metadata.abbreviationName).toBe('ABL'); + }); + + test('returns correct display config', () => { + const provider = new ACLBeginnerProvider(ContestType.ABC_LIKE); + const config = provider.getDisplayConfig(); + + expect(config.isShownHeader).toBe(false); + expect(config.isShownRoundLabel).toBe(false); + expect(config.isShownTaskIndex).toBe(true); + expect(config.tableBodyCellsWidth).toBe( + 'w-1/2 xs:w-1/3 sm:w-1/4 md:w-1/5 lg:w-1/6 px-1 py-2', + ); + }); + + test('getContestRoundLabel returns empty string', () => { + const provider = new ACLBeginnerProvider(ContestType.ABC_LIKE); + const label = provider.getContestRoundLabel('abl_a'); + + expect(label).toBe(''); + }); + + test('generates correct table structure', () => { + const provider = new ACLBeginnerProvider(ContestType.ABC_LIKE); + const filtered = provider.filter(taskResultsForACLBeginnerProvider); + const table = provider.generateTable(filtered!); + + expect(table).toBeDefined(); + expect(table).not.toBeNull(); + }); + + test('does not include tasks with different contest_id', () => { + const provider = new ACLBeginnerProvider(ContestType.ABC_LIKE); + const allTasks = [...taskResultsForACLBeginnerProvider, ...taskResultsForACLProvider]; + const filtered = provider.filter(allTasks); + + expect(filtered?.every((task) => task.contest_id === 'abl')).toBe(true); + }); + }); + + describe('ACL Provider', () => { + test('filters tasks by contest_id (acl1)', () => { + const provider = new ACLProvider(ContestType.ARC_LIKE); + const filtered = provider.filter(taskResultsForACLProvider); + + expect(filtered).toBeDefined(); + expect(filtered?.length).toBe(6); + expect(filtered?.every((t) => t.contest_id === 'acl1')).toBe(true); + }); + + test('returns correct metadata', () => { + const provider = new ACLProvider(ContestType.ARC_LIKE); + const metadata = provider.getMetadata(); + + expect(metadata.title).toBe('ACL Contest 1'); + expect(metadata.abbreviationName).toBe('ACL'); + }); + + test('returns correct display config', () => { + const provider = new ACLProvider(ContestType.ARC_LIKE); + const config = provider.getDisplayConfig(); + + expect(config.isShownHeader).toBe(false); + expect(config.isShownRoundLabel).toBe(false); + expect(config.isShownTaskIndex).toBe(true); + expect(config.tableBodyCellsWidth).toBe( + 'w-1/2 xs:w-1/3 sm:w-1/4 md:w-1/5 lg:w-1/6 px-1 py-2', + ); + }); + + test('getContestRoundLabel returns empty string', () => { + const provider = new ACLProvider(ContestType.ARC_LIKE); + const label = provider.getContestRoundLabel('acl1_a'); + + expect(label).toBe(''); + }); + + test('generates correct table structure', () => { + const provider = new ACLProvider(ContestType.ARC_LIKE); + const filtered = provider.filter(taskResultsForACLProvider); + const table = provider.generateTable(filtered!); + + expect(table).toBeDefined(); + expect(table).not.toBeNull(); + }); + + test('does not include tasks with different contest_id', () => { + const provider = new ACLProvider(ContestType.ARC_LIKE); + const allTasks = [...taskResultsForACLBeginnerProvider, ...taskResultsForACLProvider]; + const filtered = provider.filter(allTasks); + + expect(filtered?.every((task) => task.contest_id === 'acl1')).toBe(true); + }); + }); + describe('JOI First Qual Round provider', () => { test('expects to filter tasks to include only JOI contests', () => { const provider = new JOIFirstQualRoundProvider(ContestType.JOI); diff --git a/src/test/lib/utils/test_cases/contest_table_provider.ts b/src/test/lib/utils/test_cases/contest_table_provider.ts index 63d90eb6d..5deab83e6 100644 --- a/src/test/lib/utils/test_cases/contest_table_provider.ts +++ b/src/test/lib/utils/test_cases/contest_table_provider.ts @@ -814,3 +814,24 @@ export const taskResultsForABCLikeProvider: TaskResults = [ jsc2025advance_final_a, jsc2025advance_final_h, ]; + +// ACL Contest 1: 6 tasks (A~F) +const [acl1_a, acl1_b, acl1_c, acl1_d, acl1_e, acl1_f] = createContestTasks('acl1', [ + { taskTableIndex: 'A', statusName: AC }, + { taskTableIndex: 'B', statusName: AC }, + { taskTableIndex: 'C', statusName: AC_WITH_EDITORIAL }, + { taskTableIndex: 'D', statusName: TRYING }, + { taskTableIndex: 'E', statusName: PENDING }, + { taskTableIndex: 'F', statusName: PENDING }, +]); + +export const taskResultsForACLBeginnerProvider: TaskResults = [abl_a, abl_b, abl_f]; + +export const taskResultsForACLProvider: TaskResults = [ + acl1_a, + acl1_b, + acl1_c, + acl1_d, + acl1_e, + acl1_f, +]; diff --git a/src/test/lib/utils/test_cases/contest_type.ts b/src/test/lib/utils/test_cases/contest_type.ts index 5a48f56be..3eefd3a77 100644 --- a/src/test/lib/utils/test_cases/contest_type.ts +++ b/src/test/lib/utils/test_cases/contest_type.ts @@ -305,6 +305,10 @@ export const arcLike = [ contestId: 'nikkei2019-qual', expected: ContestType.ARC_LIKE, }), + createTestCaseForContestType('ACL1')({ + contestId: 'acl1', + expected: ContestType.ARC_LIKE, + }), ]; export const agcLike = [