diff --git a/docs/dev-notes/2026-03-14/add-rerooting-dp-to-contests/plan.md b/docs/dev-notes/2026-03-14/add-rerooting-dp-to-contests/plan.md new file mode 100644 index 000000000..3ac765bc2 --- /dev/null +++ b/docs/dev-notes/2026-03-14/add-rerooting-dp-to-contests/plan.md @@ -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 チェーンの同期ミスによるバグが実際に発生したとき。 diff --git a/prisma/tasks.ts b/prisma/tasks.ts index 3fe94daac..8f8de9da5 100755 --- a/prisma/tasks.ts +++ b/prisma/tasks.ts @@ -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', + }, ]; diff --git a/src/lib/utils/contest.ts b/src/lib/utils/contest.ts index 01f4fc6ee..186ecf30a 100644 --- a/src/lib/utils/contest.ts +++ b/src/lib/utils/contest.ts @@ -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 決勝', @@ -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 '); } diff --git a/src/test/lib/utils/test_cases/contest_name_labels.ts b/src/test/lib/utils/test_cases/contest_name_labels.ts index 6abe638ba..30832f6b9 100644 --- a/src/test/lib/utils/test_cases/contest_name_labels.ts +++ b/src/test/lib/utils/test_cases/contest_name_labels.ts @@ -41,6 +41,10 @@ export const atCoderOthers = [ contestId: 'atc001', expected: 'ATC 001', }), + createTestCaseForContestNameLabel('square869120Contest #4')({ + contestId: 's8pc-4', + expected: 'square869120Contest #4', + }), ]; export const mathAndAlgorithm = [ diff --git a/src/test/lib/utils/test_cases/contest_type.ts b/src/test/lib/utils/test_cases/contest_type.ts index 53283c55c..81b7fdb4d 100644 --- a/src/test/lib/utils/test_cases/contest_type.ts +++ b/src/test/lib/utils/test_cases/contest_type.ts @@ -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,