Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
86 changes: 86 additions & 0 deletions docs/dev-notes/2026-03-14/add-rerooting-dp-to-contests/plan.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
# 全方位木DPの問題追加 (Issue #3264)

[Issue #3264](https://github.com/AtCoder-NoviSteps/AtCoderNoviSteps/issues/3264) で、s8pc-4 (square869120Contest #4) の問題 D を追加。

## 変更概要

- `ATCODER_OTHERS` に `'s8pc-4': 'square869120Contest #4'` を追加
- `getContestNameLabel` に辞書ルックアップを追加(`regexForAtCoderUniversity` の直後、`chokudai_S` の直前)
- テストケースを `contest_type.ts` と `contest_name_labels.ts` に追加
- `prisma/tasks.ts` に `s8pc_4_d`(Driving on a Tree、grade なし)を追加

## 教訓

### `ATCODER_OTHERS` は分類辞書であり、ラベル辞書でもある

変更前は `getContestNameLabel` が `ATCODER_OTHERS` を参照しておらず、辞書登録済みのコンテストでも `toUpperCase()` のフォールバックに落ちていた。今回の汎用化で辞書1本が「分類」と「表示名解決」を兼ねるようになった。**新コンテストは辞書に1エントリ追加するだけで両方が自動的に有効になる。**

### ルックアップの挿入位置が正確性を決める

`getContestNameLabel` は上から順に評価する if チェーンであるため、挿入位置が重要。

- `chokudai_S` はプレフィックス一致(`chokudai_S001` 等)で辞書キーと完全一致しない → 専用ブランチを辞書ルックアップの**後**に残す
- `atc001` は `regexForAxc`(`/^(abc|arc|agc|atc)\d{3}$/i`)に**先に**マッチするため辞書ルックアップに到達しない → 既存の表示 `'ATC 001'` は維持される

### 一般化できる知見

新コンテストを追加する際は、分類ロジック(`classifyContest`)と表示名ロジック(`getContestNameLabel`)の**両方**への反映が必要かを確認する。共通辞書で両方を賄える場合はそうする。プレフィックス一致が必要な場合のみ専用ブランチを追加する。

## 将来的なリファクタリング候補:ContestHandler による if チェーンの解消

### 概略

`classifyContest` と `getContestNameLabel` の if チェーンを、コンテスト種別ごとの handler 配列に置き換える。

```ts
type ContestHandler = {
type: ContestType;
matches: (contestId: string) => boolean;
label: (contestId: string) => string;
};

const handlers: ContestHandler[] = [
{
type: ContestType.ABC,
matches: (id) => /^abc\d{3}$/.test(id),
label: (id) =>
id.replace(
regexForAxc,
(_, contestType, contestNumber) => `${contestType.toUpperCase()} ${contestNumber}`,
),
},
{
type: ContestType.JOI,
matches: (id) => id.startsWith('joi'),
label: getJoiContestLabel,
},
{
type: ContestType.OTHERS,
matches: (id) => atCoderOthersPrefixes.some((prefix) => id.startsWith(prefix)),
label: (id) =>
ATCODER_OTHERS[id as keyof typeof ATCODER_OTHERS] ??
id.replace('chokudai_S', 'Chokudai SpeedRun '),
},
// ...
];

// classifyContest / getContestNameLabel はどちらも handlers を1回スキャンするだけになる
```

マッチング戦略(正規表現・プレフィックス・辞書引き)の異質さは各 handler の `matches` / `label` 内部に閉じ込められ、呼び出し側から消える。

### 設計上の判断

- 順序に依存するため `Map` ではなく**順序付き配列**が必要(先勝ち)
- `ATCODER_OTHERS` の `chokudai_S` は辞書キーでもあり、OTHERS handler 内部で両方を吸収する
- 既存の単体テストがそのまま安全網として機能する

### 踏み切れない理由(現状)

- `classifyContest` / `getContestNameLabel` は `contest-table/` providers・URL生成・表示ラベルなど**広範囲から参照**されており、影響範囲が大きい
- リファクタリング自体は機械的だが「同じ動作を別の構造で書き直す」変更はテストが通っても見落としが出やすい
- 現状の if チェーンは「読みにくいが壊れていない」状態であり、コストが便益を上回る

### 実行の目安

JOI のような複雑な変換ロジックを持つ新カテゴリが増え、if チェーンの同期ミスによるバグが実際に発生したとき。
7 changes: 7 additions & 0 deletions prisma/tasks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8501,4 +8501,11 @@ export const tasks = [
name: 'お菓子',
title: 'A. お菓子',
},
{
id: 's8pc_4_d',
contest_id: 's8pc-4',
problem_index: 'D',
name: 'Driving on a Tree',
title: 'D. Driving on a Tree',
},
];
7 changes: 7 additions & 0 deletions src/lib/utils/contest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,7 @@ const atCoderUniversityPrefixes = getContestPrefixes(ATCODER_UNIVERSITIES);
const ATCODER_OTHERS: ContestPrefix = {
chokudai_S: 'Chokudai SpeedRun',
atc001: 'AtCoder Typical Contest 001',
's8pc-4': 'square869120Contest #4',
'code-festival-2014-quala': 'Code Festival 2014 予選 A',
'code-festival-2014-qualb': 'Code Festival 2014 予選 B',
'code-festival-2014-final': 'Code Festival 2014 決勝',
Expand Down Expand Up @@ -404,6 +405,12 @@ export const getContestNameLabel = (contestId: string) => {
return getAtCoderUniversityContestLabel(contestId);
}

const othersLabel = ATCODER_OTHERS[contestId as keyof typeof ATCODER_OTHERS];

if (othersLabel) {
return othersLabel;
}

if (contestId.startsWith('chokudai_S')) {
return contestId.replace('chokudai_S', 'Chokudai SpeedRun ');
}
Expand Down
4 changes: 4 additions & 0 deletions src/test/lib/utils/test_cases/contest_name_labels.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,10 @@ export const atCoderOthers = [
contestId: 'atc001',
expected: 'ATC 001',
}),
createTestCaseForContestNameLabel('square869120Contest #4')({
contestId: 's8pc-4',
expected: 'square869120Contest #4',
}),
];

export const mathAndAlgorithm = [
Expand Down
4 changes: 4 additions & 0 deletions src/test/lib/utils/test_cases/contest_type.ts
Original file line number Diff line number Diff line change
Expand Up @@ -402,6 +402,10 @@ export const atCoderOthers = [
contestId: 'atc001',
expected: ContestType.OTHERS,
}),
createTestCaseForContestType('square869120Contest #4')({
contestId: 's8pc-4',
expected: ContestType.OTHERS,
}),
createTestCaseForContestType('CODE FESTIVAL 2014 qual A')({
contestId: 'code-festival-2014-quala',
expected: ContestType.OTHERS,
Expand Down
Loading