Skip to content

Commit dde7b93

Browse files
committed
feat: Add table for ACL Practice (#2920)
1 parent 56b4690 commit dde7b93

File tree

4 files changed

+494
-0
lines changed

4 files changed

+494
-0
lines changed
Lines changed: 315 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,315 @@
1+
# ACLPracticeProvider 単体テスト追加計画
2+
3+
**作成日**: 2025-12-17
4+
5+
**対象ブランチ**: #2962
6+
7+
**優先度**: High
8+
9+
---
10+
11+
## 概要
12+
13+
`ACLPracticeProvider`(AtCoder Library Practice Contest)に対する単体テストを追加する計画。
14+
15+
**対象ファイル**:
16+
17+
- **Provider実装**: [`src/lib/utils/contest_table_provider.ts`](../../../../../src/lib/utils/contest_table_provider.ts)
18+
- `ACLPracticeProvider` (773行目~)
19+
- **テストファイル**: [`src/test/lib/utils/contest_table_provider.test.ts`](../../../../../src/test/lib/utils/contest_table_provider.test.ts)
20+
- **テストケースファイル**: [`src/test/lib/utils/test_cases/contest_table_provider.ts`](../../../../../src/test/lib/utils/test_cases/contest_table_provider.ts)
21+
22+
**参照ドキュメント**:
23+
24+
- [`docs/dev-notes/2025-12-11/add_tests_for_contest_table_provider/plan.md`](../../2025-12-11/add_tests_for_contest_table_provider/plan.md) - ABSProvider テスト設計パターンの参考
25+
- [`prisma/tasks.ts`](../../../../../prisma/tasks.ts) - practice2 及び各問題のタスク定義
26+
- [`src/lib/utils/contest.ts`](../../../../../src/lib/utils/contest.ts) - contest_id 'practice2' → ContestType.ACL_PRACTICE の判別ロジック
27+
28+
---
29+
30+
## ACLPracticeProvider の仕様と特徴
31+
32+
### 基本情報
33+
34+
- **名称**: AtCoder Library Practice Contest
35+
- **contest_id**: `'practice2'`
36+
- **ContestType**: `ContestType.ACL_PRACTICE`
37+
38+
### 問題構成
39+
40+
ACL Practice は高度なアルゴリズム技法を学ぶための教育的コンテンツで、**12問**で構成されている:
41+
42+
| 問題番号 | task_id | 難易度 | problem_index |
43+
| -------- | ----------- | ------ | ------------- |
44+
| 1 | practice2_a | Q3 | A |
45+
| 2 | practice2_b | Q1 | B |
46+
| 3 | practice2_c | D2 | C |
47+
| 4 | practice2_d | D2 | D |
48+
| 5 | practice2_e | D3 | E |
49+
| 6 | practice2_f | D2 | F |
50+
| 7 | practice2_g | D2 | G |
51+
| 8 | practice2_h | D2 | H |
52+
| 9 | practice2_i | D2 | I |
53+
| 10 | practice2_j | D1 | J |
54+
| 11 | practice2_k | D2 | K |
55+
| 12 | practice2_l | D2 | L |
56+
57+
**重要な特徴**:
58+
59+
- **単一コンテスト由来**: すべての問題が同じコンテスト 'practice2' に属する(複数コンテスト由来の問題はない)
60+
- **難易度順**: A~L の順序が段階的難易度を表している
61+
- **contest_idの統一**: すべての問題のcontest_idは'practice2'で統一される
62+
63+
### ディスプレイ設定
64+
65+
ACLPracticeProviderは EDPCProvider と同じ設定を持つ:
66+
67+
```typescript
68+
{
69+
isShownHeader: false, // ヘッダーを非表示
70+
isShownRoundLabel: false, // ラウンドラベルを非表示
71+
isShownTaskIndex: true, // タスクインデックスを表示
72+
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',
73+
roundLabelWidth: '', // ラウンドラベル幅なし
74+
}
75+
```
76+
77+
---
78+
79+
## テスト設計
80+
81+
### テストファイル配置
82+
83+
**ファイル**: `src/test/lib/utils/contest_table_provider.test.ts`
84+
85+
**配置**: 「JOI First Qual Round provider」セクションの直前(1981行目付近)
86+
87+
### テストデータ構築
88+
89+
#### テストケースファイルでの準備
90+
91+
`src/test/lib/utils/test_cases/contest_table_provider.ts`に以下を追加:
92+
93+
```typescript
94+
/**
95+
* Test data for ACLPracticeProvider (AtCoder Library Practice Contest)
96+
* 12 problems with progressive difficulty, problem_index from A to L
97+
* Test data includes varied submission statuses:
98+
*/
99+
export const taskResultsForACLPracticeProvider: TaskResults = [
100+
createContestTasksForACLPractice('practice2_a', 'practice2', 'A', AC),
101+
createContestTasksForACLPractice('practice2_b', 'practice2', 'B', AC),
102+
createContestTasksForACLPractice('practice2_c', 'practice2', 'C', AC_WITH_EDITORIAL),
103+
createContestTasksForACLPractice('practice2_d', 'practice2', 'D', AC_WITH_EDITORIAL),
104+
createContestTasksForACLPractice('practice2_e', 'practice2', 'E', TRYING),
105+
createContestTasksForACLPractice('practice2_f', 'practice2', 'F', AC_WITH_EDITORIAL),
106+
createContestTasksForACLPractice('practice2_g', 'practice2', 'G', AC_WITH_EDITORIAL),
107+
createContestTasksForACLPractice('practice2_h', 'practice2', 'H', TRYING),
108+
createContestTasksForACLPractice('practice2_i', 'practice2', 'I', TRYING),
109+
createContestTasksForACLPractice('practice2_j', 'practice2', 'J', AC),
110+
createContestTasksForACLPractice('practice2_k', 'practice2', 'K', PENDING),
111+
createContestTasksForACLPractice('practice2_l', 'practice2', 'L', AC_WITH_EDITORIAL),
112+
];
113+
114+
function createContestTasksForACLPractice(
115+
taskId: string,
116+
contestId: string,
117+
taskTableIndex: string,
118+
statusName: string,
119+
): TaskResult {
120+
return createTaskResultWithTaskTableIndex(contestId, taskId, taskTableIndex, statusName);
121+
}
122+
```
123+
124+
### テストケース詳細
125+
126+
#### テスト1.1: フィルタリング(contest_id検証)
127+
128+
contest_id='practice2' のタスクのみをフィルタリングすることを確認。
129+
130+
```typescript
131+
test('expects to filter tasks with contest_id "practice2"', () => {
132+
const provider = new ACLPracticeProvider(ContestType.ACL_PRACTICE);
133+
const mixed = [
134+
{ contest_id: 'practice2', task_id: 'practice2_a', task_table_index: 'A' },
135+
{ contest_id: 'practice2', task_id: 'practice2_l', task_table_index: 'L' },
136+
{ contest_id: 'dp', task_id: 'dp_a', task_table_index: 'A' },
137+
{ contest_id: 'abc123', task_id: 'abc123_a', task_table_index: 'A' },
138+
];
139+
140+
const filtered = provider.filter(mixed as TaskResults);
141+
142+
expect(filtered).toHaveLength(2);
143+
expect(filtered.every((task) => task.contest_id === 'practice2')).toBe(true);
144+
});
145+
```
146+
147+
#### テスト1.2: コンテストタイプ判別
148+
149+
ContestType.ACL_PRACTICE のみをフィルタリングすることを確認。
150+
151+
```typescript
152+
test('expects to filter only ACL_PRACTICE-type contests', () => {
153+
const provider = new ACLPracticeProvider(ContestType.ACL_PRACTICE);
154+
const mixed = [
155+
{ contest_id: 'practice2', task_id: 'practice2_a', task_table_index: 'A' },
156+
{ contest_id: 'dp', task_id: 'dp_a', task_table_index: 'A' },
157+
{ contest_id: 'abc378', task_id: 'abc378_a', task_table_index: 'A' },
158+
];
159+
160+
const filtered = provider.filter(mixed as TaskResults);
161+
162+
expect(filtered).toHaveLength(1);
163+
expect(filtered[0].contest_id).toBe('practice2');
164+
});
165+
```
166+
167+
#### テスト1.3: メタデータ取得
168+
169+
```typescript
170+
test('expects to return correct metadata', () => {
171+
const provider = new ACLPracticeProvider(ContestType.ACL_PRACTICE);
172+
const metadata = provider.getMetadata();
173+
174+
expect(metadata.title).toBe('AtCoder Library Practice Contest');
175+
expect(metadata.abbreviationName).toBe('aclPractice');
176+
});
177+
```
178+
179+
#### テスト1.4: ディスプレイ設定確認
180+
181+
ディスプレイ設定が ACL Practice 固有の値であることを確認。
182+
183+
```typescript
184+
test('expects to return correct display config with ACL Practice-specific settings', () => {
185+
const provider = new ACLPracticeProvider(ContestType.ACL_PRACTICE);
186+
const config = provider.getDisplayConfig();
187+
188+
expect(config.isShownHeader).toBe(false);
189+
expect(config.isShownRoundLabel).toBe(false);
190+
expect(config.isShownTaskIndex).toBe(true);
191+
expect(config.tableBodyCellsWidth).toBe(
192+
'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',
193+
);
194+
expect(config.roundLabelWidth).toBe('');
195+
});
196+
```
197+
198+
#### テスト1.5: ラウンドラベルフォーマット
199+
200+
ACL Practice ではラウンドラベルが空文字列で返されることを確認。
201+
202+
```typescript
203+
test('expects to return empty string for contest round label', () => {
204+
const provider = new ACLPracticeProvider(ContestType.ACL_PRACTICE);
205+
206+
expect(provider.getContestRoundLabel('practice2')).toBe('');
207+
});
208+
```
209+
210+
#### テスト1.6: テストケースデータ検証
211+
212+
準備されたテストケースデータが正しく構成されていることを確認。
213+
214+
```typescript
215+
test('expects test data to have 12 tasks with correct properties', () => {
216+
expect(taskResultsForACLPracticeProvider).toHaveLength(12);
217+
expect(taskResultsForACLPracticeProvider.every((task) => task.contest_id === 'practice2')).toBe(
218+
true,
219+
);
220+
221+
const expectedIndices = ['A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J', 'K', 'L'];
222+
const actualIndices = taskResultsForACLPracticeProvider.map((task) => task.task_table_index);
223+
224+
expect(actualIndices).toEqual(expectedIndices);
225+
});
226+
```
227+
228+
#### テスト1.7: フィルタリング統合テスト
229+
230+
実際のテストケースデータを使用して、フィルタリング機能を検証。
231+
232+
```typescript
233+
test('expects to filter test data correctly', () => {
234+
const provider = new ACLPracticeProvider(ContestType.ACL_PRACTICE);
235+
const allTasks = [...taskResultsForACLPracticeProvider, ...someOtherContestTasks];
236+
237+
const filtered = provider.filter(allTasks);
238+
239+
expect(filtered).toHaveLength(12);
240+
expect(filtered).toEqual(taskResultsForACLPracticeProvider);
241+
});
242+
```
243+
244+
---
245+
246+
## 実装ステップ
247+
248+
### ステップ 1: テストケースデータの追加
249+
250+
`src/test/lib/utils/test_cases/contest_table_provider.ts` に以下を追加:
251+
252+
1. `taskResultsForACLPracticeProvider` 定数の定義
253+
2. `createContestTasksForACLPractice` ヘルパー関数の定義
254+
255+
### ステップ 2: テストケースのエクスポート
256+
257+
`src/test/lib/utils/test_cases/contest_table_provider.ts` のエクスポート一覧に `taskResultsForACLPracticeProvider` を追加
258+
259+
### ステップ 3: テストスイートの追加
260+
261+
`src/test/lib/utils/contest_table_provider.test.ts` に以下を追加:
262+
263+
1. インポート文に `taskResultsForACLPracticeProvider` を追加
264+
2. 「JOI First Qual Round provider」セクションの直前に「ACL Practice Provider」セクションを追加
265+
3. 上記のテストケース(1.1~1.7)を実装
266+
267+
### ステップ 4: テスト実行と検証
268+
269+
```bash
270+
pnpm test:unit src/test/lib/utils/contest_table_provider.test.ts
271+
```
272+
273+
すべてのテストが PASSすることを確認
274+
275+
---
276+
277+
## 注記
278+
279+
- テストケースのステータス分布は現実的な使用パターンを反映:初期問題は解けているが、高度な問題は挑戦中
280+
- ACLPracticeProvider は EDPCProvider と同じ表示設定を共有するため、比較的シンプルなテスト設計が可能
281+
- テストは JOI First Qual Round provider の前に配置されることで、テストスイートの論理的な順序を保つ
282+
283+
---
284+
285+
## 実装後の教訓
286+
287+
### ✅ 実装完了
288+
289+
- **テスト結果**: 203/203 PASS ✅
290+
- テストケースデータ追加 + テストスイート実装完了
291+
292+
### 📌 重要な学習ポイント
293+
294+
#### 1. **モック関数の漏れは必ず発生する** ⚠️
295+
296+
- `classifyContest` モックに `practice2``ContestType.ACL_PRACTICE` を忘れずに追加すること
297+
- **毎回チェックリスト**:
298+
- [ ] 新しい contest_id に対応するモック処理を追加したか
299+
- [ ] テストデータの contest_id とモックの対応が一致しているか
300+
- [ ] 初回実行で失敗した場合、モック定義を最優先で確認すること
301+
302+
#### 2. **既存ヘルパー関数の活用**
303+
304+
- `createContestTasks` 関数を使用することで、テストデータの一貫性を維持
305+
- 手動で TaskResult を構築するより、ヘルパー関数を優先する
306+
307+
#### 3. **統合テストの重要性**
308+
309+
- 単体テスト(個別コンテスト)だけでなく、同一問題で複数コンテストが混在するテストも必須
310+
- フィルタリングの正確性確保には、他のコンテストデータとの組み合わせが効果的
311+
312+
#### 4. **テスト配置の順序**
313+
314+
- 新しいテストスイートは既存のセクション構成を考慮して配置
315+
- 論理的な順序(年代順・難易度順など)を保つことで保守性が向上

src/lib/utils/contest_table_provider.ts

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import type { TaskResults, TaskResult } from '$lib/types/task';
1212

1313
import { classifyContest, getContestNameLabel } from '$lib/utils/contest';
1414
import { getTaskTableHeaderName } from '$lib/utils/task';
15+
import { aclPractice } from '@/test/lib/utils/test_cases/contest_type';
1516

1617
/**
1718
* How to add a new contest table provider:
@@ -769,6 +770,35 @@ export class FPS24Provider extends ContestTableProviderBase {
769770
}
770771
}
771772

773+
export class ACLPracticeProvider extends ContestTableProviderBase {
774+
protected setFilterCondition(): (taskResult: TaskResult) => boolean {
775+
return (taskResult: TaskResult) => {
776+
return classifyContest(taskResult.contest_id) === this.contestType;
777+
};
778+
}
779+
780+
getMetadata(): ContestTableMetaData {
781+
return {
782+
title: 'AtCoder Library Practice Contest',
783+
abbreviationName: 'aclPractice',
784+
};
785+
}
786+
787+
getDisplayConfig(): ContestTableDisplayConfig {
788+
return {
789+
isShownHeader: false,
790+
isShownRoundLabel: false,
791+
roundLabelWidth: '', // No specific width for the round label
792+
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',
793+
isShownTaskIndex: true,
794+
};
795+
}
796+
797+
getContestRoundLabel(_contestId: string): string {
798+
return '';
799+
}
800+
}
801+
772802
const regexForJoiFirstQualRound = /^(joi)(\d{4})(yo1)(a|b|c)$/i;
773803

774804
export class JOIFirstQualRoundProvider extends ContestTableProviderBase {
@@ -1061,6 +1091,15 @@ export const prepareContestProviderPresets = () => {
10611091
new FPS24Provider(ContestType.FPS_24),
10621092
),
10631093

1094+
/**
1095+
* Single group for ACL Practice Contest
1096+
*/
1097+
AclPractice: () =>
1098+
new ContestTableProviderGroup(`AtCoder Library Practice Contest`, {
1099+
buttonLabel: 'ACL Practice',
1100+
ariaLabel: 'Filter ACL Practice Contest',
1101+
}).addProvider(new ACLPracticeProvider(ContestType.ACL_PRACTICE)),
1102+
10641103
JOIFirstQualRound: () =>
10651104
new ContestTableProviderGroup(`JOI 一次予選`, {
10661105
buttonLabel: 'JOI 一次予選',
@@ -1085,6 +1124,7 @@ export const contestTableProviderGroups = {
10851124
tessokuBook: prepareContestProviderPresets().TessokuBook(),
10861125
mathAndAlgorithm: prepareContestProviderPresets().MathAndAlgorithm(),
10871126
dps: prepareContestProviderPresets().dps(), // Dynamic Programming (DP) Contests
1127+
aclPractice: prepareContestProviderPresets().AclPractice(),
10881128
joiFirstQualRound: prepareContestProviderPresets().JOIFirstQualRound(),
10891129
};
10901130

0 commit comments

Comments
 (0)