From 0737d2c6a832c0213f5638c452886e69fd8c9dc4 Mon Sep 17 00:00:00 2001 From: "k.hiro1818" Date: Wed, 17 Jun 2026 05:53:37 +0000 Subject: [PATCH 1/3] feat(contest-table): add AojIcpcRegionalProvider for ICPC Asia Regional (Phase 1-4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Rename ICPC_PRELIM_LABEL_OVERRIDES → ICPC_LABEL_OVERRIDES (shared by Prelim/Regional) - Extract shared helpers to aoj_icpc_labels.ts: sortAojIcpcHeaderIds, AOJ_ICPC_TITLE_STYLE, buildAojIcpcDisplayConfig - Add AojIcpcRegionalProvider (ICPCRegional1998–2024, 27 years) - Register aojIcpcRegional preset in contestTableProviderGroups (inserted after aojIcpcPrelim) - Add ICPCRegional task seed data (prisma/tasks.ts, 1998–2024) - Add plan doc for ICPC 地区予選 table (issue #3680) Co-Authored-By: Claude Sonnet 4.6 --- .../2026-06-17/icpc-regional-table/plan.md | 130 ++ prisma/tasks.ts | 1862 +++++++++++++++++ .../contest-table/aoj_icpc_labels.test.ts | 12 +- .../utils/contest-table/aoj_icpc_labels.ts | 38 +- .../contest-table/aoj_icpc_providers.test.ts | 430 +++- .../utils/contest-table/aoj_icpc_providers.ts | 70 +- .../contest_table_provider_groups.test.ts | 21 +- .../contest_table_provider_groups.ts | 19 +- 8 files changed, 2544 insertions(+), 38 deletions(-) create mode 100644 docs/dev-notes/2026-06-17/icpc-regional-table/plan.md diff --git a/docs/dev-notes/2026-06-17/icpc-regional-table/plan.md b/docs/dev-notes/2026-06-17/icpc-regional-table/plan.md new file mode 100644 index 000000000..d46033e5e --- /dev/null +++ b/docs/dev-notes/2026-06-17/icpc-regional-table/plan.md @@ -0,0 +1,130 @@ +# テーブル「ICPC 地区予選」追加 (issue #3680) + +## 概要 + +コンテストテーブル一覧に「ICPC 地区予選」(ICPC Asia Regional) を追加する。 +PR #3635 で追加済みの「ICPC 国内予選」(`AojIcpcPrelimProvider`, Pattern 4 = 年ごとに N インスタンス化) と +ほぼ同一の仕様(テーブル inner ラベル A,B,C…・タイトル文言・prefix)で、対象データだけが `ICPCRegional{year}` に変わる。 + +- ボタンラベル: `ICPC 地区予選` +- 順序: `ICPC 国内予選` の直後(画面上では右側) + +## 設計の根拠(スコープ削減) + +`classifyContest` / `contestTypePriorities` / `getContestNameLabel` は **既に `ICPCRegional*` を +`ContestType.AOJ_ICPC` として扱える**。 + +- [contest.ts:103](../../../../src/lib/utils/contest.ts) の正規表現 `^ICPC(Prelim|Regional)\d*$` が一致 +- `ICPC_TRANSLATIONS` に `Regional: ' 地区予選 '` が存在([contest.ts:735](../../../../src/lib/utils/contest.ts)) + +よって **スキル `add-contest-table-provider` の Layer 1(schema)・Layer 2(ContestType 定数)・Layer 3(utils)は不要**。 +ContestType は `AOJ_ICPC` を Prelim と共用する(provider key は `AOJ_ICPC::{year}` だが Prelim/Regional は +別グループ=別 Map のため衝突しない)。 + +実作業は **Layer 4(provider)+ Layer 5(グループ登録)+ シードデータ取込** に集約される。 + +### 順序の担保 + +[TaskTable.svelte:197](../../../../src/features/tasks/components/contest-table/TaskTable.svelte) は +`{#each Object.entries(contestTableProviderGroups) ...}` でボタンを描画する。 +登録オブジェクトの `aojIcpcPrelim` 直後に `aojIcpcRegional` を追加すれば挿入順で国内予選の右側になる。 + +## ユーザー決定事項 + +| 項目 | 決定 | +| ------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| シードデータ | `prisma/tasks.ts` に `ICPCRegional*` 行は未登録。行データはユーザーが提供 → 本作業で取り込む | +| 実装方針 | 独立クラス `AojIcpcRegionalProvider` を作成しつつ、Prelim と意味的に重複するロジックは**共有関数/定数として切り出す**(Prelim 側もそれを使うようリファクタ) | +| columnWrapThreshold | Prelim と同じ `6`(地区予選は最大 12 問だが 6×2 行で折返し、レイアウト一貫性を優先) | +| 年範囲 | 1998–2024(AOJ fixture 実績、連続 27 年) | + +## 却下した代替案 + +- **共有抽象基底クラス `AojIcpcProviderBase` を導入**: より DRY だが、稼働中の Prelim クラスの継承構造に手を入れリスクが上がる。 + ユーザー方針「独立クラス+関数切り出し」に従い不採用。 +- **Regional 用に新規 `ContestType.AOJ_ICPC_REGIONAL` を追加**: schema/enum/utils の 3 層改修が発生する。 + 既存の `AOJ_ICPC` が Regional を完全に扱えるため YAGNI として不採用。 + +## フェーズ(TDD・低リスク→高リスク順) + +> コミットは「共有関数リファクタ+Regional provider」「グループ登録」「シード取込」を分ける。 + +### Phase 1: 共有ロジックの関数/定数化(既存 Prelim のリファクタ) + +差分が無いメソッドを切り出し、Prelim/Regional 双方から使う。挙動不変のリファクタ。 + +- `src/features/tasks/utils/contest-table/aoj_icpc_labels.ts` + - `ICPC_PRELIM_LABEL_OVERRIDES` → **`ICPC_LABEL_OVERRIDES`** に改名 + (full contest_id をキーにするため Prelim/Regional を 1 map で共用可。現状は空 `{}` でデータ移行不要) + - `buildAojIcpcLetterMap` / `formatAojIcpcTitle` はそのまま流用 + - 共有の表示系を追加(または新規 `aoj_icpc_shared.ts` に集約) + - `sortAojIcpcHeaderIds(filtered: TaskResults): string[]`(数値昇順、現 `getHeaderIdsForTask` 本体) + - `AOJ_ICPC_TITLE_STYLE`(`getMetadata().titleStyle` 共有定数) + - `buildAojIcpcDisplayConfig(): ContestTableDisplayConfig`(`columnWrapThreshold: 6` 等、共有) +- `aoj_icpc_providers.ts` の `AojIcpcPrelimProvider` を上記共有関数/定数を呼ぶ薄いラッパへ修正 +- 既存テストの参照名更新: `aoj_icpc_providers.test.ts` / `aoj_icpc_labels.test.ts` の + `ICPC_PRELIM_LABEL_OVERRIDES` → `ICPC_LABEL_OVERRIDES` +- 検証: `pnpm test:unit src/features/tasks/utils/contest-table/` GREEN + +### Phase 2: AojIcpcRegionalProvider(Layer 4・TDD) + +- テスト先行: `aoj_icpc_providers.test.ts` に `describe('AojIcpcRegionalProvider')` を追加。 + 代表年の inline fixture(例: 1998=8問, 2024=最多問, 年フィルタ用に別年1件+非ICPC1件の mixed)。 + Prelim のテスト構成(filter / generateTable 冪等&非破壊 / getMetadata / getDisplayConfig=6 / + getContestRoundLabel / getHeaderIdsForTask / getTaskLabels A,B,C… / override map path)をミラー。**RED 確認** +- 実装: `aoj_icpc_providers.ts` に `AojIcpcRegionalProvider extends ContestTableProviderBase` を Prelim の隣に追加。差分のみ: + - `contestId = `ICPCRegional${year}`` + - `getMetadata().title` / `getContestRoundLabel` = `ICPC 地区予選 ${year}`、`abbreviationName = `icpcRegional${year}`` + - `getTaskLabels` は `buildAojIcpcLetterMap(this.contestId, …)` を流用(共有 overrides map 経由) + - `getHeaderIdsForTask` / `getDisplayConfig` / `titleStyle` は Phase 1 の共有関数/定数を使用 +- 検証: `pnpm test:unit ` GREEN + +### Phase 3: グループ登録(Layer 5・TDD) + +- `contest_table_provider_groups.ts` + - `ICPC_REGIONAL_OLDEST_YEAR = 1998` / `ICPC_REGIONAL_LATEST_YEAR = 2024` を export(テストが getSize 参照) + - `AojIcpcPrelim` プリセットに倣い `AojIcpcRegional` プリセットを追加 + (latest→oldest で `addProvider`、buttonLabel/ariaLabel = `ICPC 地区予選` / `Filter ICPC Asia Regional`、 + groupName = `ICPC 地区予選`) + - `contestTableProviderGroups` オブジェクトの **`aojIcpcPrelim` の直後**に + `aojIcpcRegional: presets.AojIcpcRegional()` を追加(描画順=右側) +- `contest_table_provider_groups.test.ts`: import 追加、`AojIcpcRegional` プリセットの + groupName/metadata/getSize(=LATEST−OLDEST+1=27)/`getProvider(ContestType.AOJ_ICPC, '2024') instanceof AojIcpcRegionalProvider` + を追加。`presets are functions` 一覧にも追加 +- 検証: `pnpm test:unit src/features/tasks/utils/contest-table/` GREEN + +### Phase 4: シードデータ取込 + +- ユーザー提供の `ICPCRegional{1998..2024}` タスク行を `prisma/tasks.ts` に追記 + (`ICPCPrelim*` 群の直後など。`id`/`contest_id`/`problem_index`/`name`/`title`/`grade` 形式に整合) +- 取込後 `pnpm db:seed` でローカル DB に反映 +- 注: AOJ admin 取込フロー(`src/routes/(admin)/tasks/`)は `classifyContest` が既に + `ICPCRegional*` を認識するため別途改修不要 + +## 主要変更ファイル + +- `src/features/tasks/utils/contest-table/aoj_icpc_labels.ts`(共有関数化・overrides 改名) +- `src/features/tasks/utils/contest-table/aoj_icpc_providers.ts`(Regional provider 追加・Prelim 薄化) +- `src/features/tasks/utils/contest-table/aoj_icpc_providers.test.ts`(Regional テスト・参照名更新) +- `src/features/tasks/utils/contest-table/aoj_icpc_labels.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`(グループテスト) +- `prisma/tasks.ts`(ICPCRegional シード行・ユーザー提供) + +## 再利用する既存実装 + +- `buildAojIcpcLetterMap` / `formatAojIcpcTitle` — `aoj_icpc_labels.ts` +- `ContestTableProviderBase`(`generateTable` / `createProviderKey` / `getProviderKey`)— `contest_table_provider_base.ts` +- `classifyContest` / `getContestNameLabel` / `ICPC_TRANSLATIONS` — `src/lib/utils/contest.ts`(改修不要・既に Regional 対応) +- `ContestTableProviderGroup` — `contest_table_provider_group.ts` + +## 検証 + +1. `pnpm test:unit src/features/tasks/utils/contest-table/`(Phase 1–3 各 RED→GREEN) +2. `pnpm test:unit`(全体回帰。Prelim リファクタの非破壊確認) +3. `pnpm check` / `pnpm lint` +4. シード取込後 `pnpm db:seed` → `pnpm dev` でタスクテーブル画面を開き、 + - 「ICPC 地区予選」ボタンが「ICPC 国内予選」の右に出る + - 押下で年別テーブル(最新年が上)が表示され、各行に A,B,C… のラベルと正しいタイトルが並ぶ + - 1998(8問)/ 2024(最多問)が空でなく描画される +5. 全フェーズ完了後、AGENTS.md 規約に従い `coderabbit review --plain` と `/session-close` を実施 diff --git a/prisma/tasks.ts b/prisma/tasks.ts index 395864035..ec1d33f2b 100755 --- a/prisma/tasks.ts +++ b/prisma/tasks.ts @@ -10013,6 +10013,1868 @@ export const tasks = [ name: 'Preparing the Lunch', title: 'Preparing the Lunch', }, + { + id: '1200', + contest_id: 'ICPCRegional1998', + problem_index: '1200', + name: "Goldbach's Conjecture", + title: "Goldbach's Conjecture", + }, + { + id: '1201', + contest_id: 'ICPCRegional1998', + problem_index: '1201', + name: 'Lattice Practices', + title: 'Lattice Practices', + }, + { + id: '1202', + contest_id: 'ICPCRegional1998', + problem_index: '1202', + name: 'Mobile Phone Coverage', + title: 'Mobile Phone Coverage', + }, + { + id: '1203', + contest_id: 'ICPCRegional1998', + problem_index: '1203', + name: "Napoleon's Grumble", + title: "Napoleon's Grumble", + }, + { + id: '1204', + contest_id: 'ICPCRegional1998', + problem_index: '1204', + name: 'Pipeline Scheduling', + title: 'Pipeline Scheduling', + }, + { + id: '1205', + contest_id: 'ICPCRegional1998', + problem_index: '1205', + name: 'Triangle Partition', + title: 'Triangle Partition', + }, + { + id: '1206', + contest_id: 'ICPCRegional1998', + problem_index: '1206', + name: 'BUT We Need a Diagram', + title: 'BUT We Need a Diagram', + }, + { + id: '1207', + contest_id: 'ICPCRegional1998', + problem_index: '1207', + name: 'Digital Racing Circuil', + title: 'Digital Racing Circuil', + }, + { + id: '1208', + contest_id: 'ICPCRegional1999', + problem_index: '1208', + name: 'Rational Irrationals', + title: 'Rational Irrationals', + }, + { + id: '1209', + contest_id: 'ICPCRegional1999', + problem_index: '1209', + name: 'Square Coins', + title: 'Square Coins', + }, + { + id: '1210', + contest_id: 'ICPCRegional1999', + problem_index: '1210', + name: 'Die Game', + title: 'Die Game', + }, + { + id: '1211', + contest_id: 'ICPCRegional1999', + problem_index: '1211', + name: 'Trapezoids', + title: 'Trapezoids', + }, + { + id: '1212', + contest_id: 'ICPCRegional1999', + problem_index: '1212', + name: 'Mirror Illusion', + title: 'Mirror Illusion', + }, + { + id: '1213', + contest_id: 'ICPCRegional1999', + problem_index: '1213', + name: 'Heavenly Jewels', + title: 'Heavenly Jewels', + }, + { + id: '1214', + contest_id: 'ICPCRegional1999', + problem_index: '1214', + name: 'Walking Ant', + title: 'Walking Ant', + }, + { + id: '1215', + contest_id: 'ICPCRegional1999', + problem_index: '1215', + name: 'Co-occurrence Search', + title: 'Co-occurrence Search', + }, + { + id: '1216', + contest_id: 'ICPCRegional2000', + problem_index: '1216', + name: 'Lost in Space', + title: 'Lost in Space', + }, + { + id: '1217', + contest_id: 'ICPCRegional2000', + problem_index: '1217', + name: 'Family Tree', + title: 'Family Tree', + }, + { + id: '1218', + contest_id: 'ICPCRegional2000', + problem_index: '1218', + name: 'Push!!', + title: 'Push!!', + }, + { + id: '1219', + contest_id: 'ICPCRegional2000', + problem_index: '1219', + name: 'Pump up Batteries', + title: 'Pump up Batteries', + }, + { + id: '1220', + contest_id: 'ICPCRegional2000', + problem_index: '1220', + name: 'The Devil of Gravity', + title: 'The Devil of Gravity', + }, + { + id: '1221', + contest_id: 'ICPCRegional2000', + problem_index: '1221', + name: 'Numoeba', + title: 'Numoeba', + }, + { + id: '1222', + contest_id: 'ICPCRegional2000', + problem_index: '1222', + name: 'Telescope', + title: 'Telescope', + }, + { + id: '1224', + contest_id: 'ICPCRegional2001', + problem_index: '1224', + name: 'Starship Hakodate-maru', + title: 'Starship Hakodate-maru', + }, + { + id: '1225', + contest_id: 'ICPCRegional2001', + problem_index: '1225', + name: 'e-market', + title: 'e-market', + }, + { + id: '1226', + contest_id: 'ICPCRegional2001', + problem_index: '1226', + name: 'Fishnet', + title: 'Fishnet', + }, + { + id: '1227', + contest_id: 'ICPCRegional2001', + problem_index: '1227', + name: '77377', + title: '77377', + }, + { + id: '1228', + contest_id: 'ICPCRegional2001', + problem_index: '1228', + name: 'Beehives', + title: 'Beehives', + }, + { + id: '1229', + contest_id: 'ICPCRegional2001', + problem_index: '1229', + name: 'Young, Poor and Busy', + title: 'Young, Poor and Busy', + }, + { + id: '1230', + contest_id: 'ICPCRegional2001', + problem_index: '1230', + name: 'Nim', + title: 'Nim', + }, + { + id: '1231', + contest_id: 'ICPCRegional2001', + problem_index: '1231', + name: 'Super Star', + title: 'Super Star', + }, + { + id: '1232', + contest_id: 'ICPCRegional2002', + problem_index: '1232', + name: 'Calling Extraterrestrial Intelligence Again', + title: 'Calling Extraterrestrial Intelligence Again', + }, + { + id: '1233', + contest_id: 'ICPCRegional2002', + problem_index: '1233', + name: 'Equals are Equals', + title: 'Equals are Equals', + }, + { + id: '1234', + contest_id: 'ICPCRegional2002', + problem_index: '1234', + name: 'GIGA Universe Cup', + title: 'GIGA Universe Cup', + }, + { + id: '1235', + contest_id: 'ICPCRegional2002', + problem_index: '1235', + name: 'Life Line', + title: 'Life Line', + }, + { + id: '1236', + contest_id: 'ICPCRegional2002', + problem_index: '1236', + name: 'Map of Ninja House', + title: 'Map of Ninja House', + }, + { + id: '1237', + contest_id: 'ICPCRegional2002', + problem_index: '1237', + name: 'Shredding Company', + title: 'Shredding Company', + }, + { + id: '1238', + contest_id: 'ICPCRegional2002', + problem_index: '1238', + name: 'True Liars', + title: 'True Liars', + }, + { + id: '1239', + contest_id: 'ICPCRegional2002', + problem_index: '1239', + name: 'Viva Confetti', + title: 'Viva Confetti', + }, + { + id: '1240', + contest_id: 'ICPCRegional2003', + problem_index: '1240', + name: 'Unreliable Message', + title: 'Unreliable Message', + }, + { + id: '1241', + contest_id: 'ICPCRegional2003', + problem_index: '1241', + name: "Lagrange's Four-Square Theorem", + title: "Lagrange's Four-Square Theorem", + }, + { + id: '1242', + contest_id: 'ICPCRegional2003', + problem_index: '1242', + name: 'Area of Polygons', + title: 'Area of Polygons', + }, + { + id: '1243', + contest_id: 'ICPCRegional2003', + problem_index: '1243', + name: 'Weather Forecast', + title: 'Weather Forecast', + }, + { + id: '1244', + contest_id: 'ICPCRegional2003', + problem_index: '1244', + name: 'Molecular Formula', + title: 'Molecular Formula', + }, + { + id: '1245', + contest_id: 'ICPCRegional2003', + problem_index: '1245', + name: 'Gap', + title: 'Gap', + }, + { + id: '1246', + contest_id: 'ICPCRegional2003', + problem_index: '1246', + name: 'Concert Hall Scheduling', + title: 'Concert Hall Scheduling', + }, + { + id: '1247', + contest_id: 'ICPCRegional2003', + problem_index: '1247', + name: 'Monster Trap', + title: 'Monster Trap', + }, + { + id: '1248', + contest_id: 'ICPCRegional2004', + problem_index: '1248', + name: 'The Balance', + title: 'The Balance', + }, + { + id: '1249', + contest_id: 'ICPCRegional2004', + problem_index: '1249', + name: 'Make a Sequence', + title: 'Make a Sequence', + }, + { + id: '1250', + contest_id: 'ICPCRegional2004', + problem_index: '1250', + name: 'Leaky Cryptography', + title: 'Leaky Cryptography', + }, + { + id: '1251', + contest_id: 'ICPCRegional2004', + problem_index: '1251', + name: 'Pathological Paths', + title: 'Pathological Paths', + }, + { + id: '1252', + contest_id: 'ICPCRegional2004', + problem_index: '1252', + name: 'Confusing Login Names', + title: 'Confusing Login Names', + }, + { + id: '1253', + contest_id: 'ICPCRegional2004', + problem_index: '1253', + name: 'Dice Puzzle', + title: 'Dice Puzzle', + }, + { + id: '1254', + contest_id: 'ICPCRegional2004', + problem_index: '1254', + name: 'Color the Map', + title: 'Color the Map', + }, + { + id: '1255', + contest_id: 'ICPCRegional2004', + problem_index: '1255', + name: 'Inherit the Spheres', + title: 'Inherit the Spheres', + }, + { + id: '1256', + contest_id: 'ICPCRegional2004', + problem_index: '1256', + name: 'Crossing Prisms', + title: 'Crossing Prisms', + }, + { + id: '1257', + contest_id: 'ICPCRegional2005', + problem_index: '1257', + name: 'Sum of Consecutive prime Numbers', + title: 'Sum of Consecutive prime Numbers', + }, + { + id: '1258', + contest_id: 'ICPCRegional2005', + problem_index: '1258', + name: 'Book Replacement', + title: 'Book Replacement', + }, + { + id: '1259', + contest_id: 'ICPCRegional2005', + problem_index: '1259', + name: 'Colored Cubes', + title: 'Colored Cubes', + }, + { + id: '1260', + contest_id: 'ICPCRegional2005', + problem_index: '1260', + name: 'Organize Your Train', + title: 'Organize Your Train', + }, + { + id: '1261', + contest_id: 'ICPCRegional2005', + problem_index: '1261', + name: 'Mobile Computing', + title: 'Mobile Computing', + }, + { + id: '1262', + contest_id: 'ICPCRegional2005', + problem_index: '1262', + name: 'Atomic Car Race', + title: 'Atomic Car Race', + }, + { + id: '1263', + contest_id: 'ICPCRegional2005', + problem_index: '1263', + name: 'Network Mess', + title: 'Network Mess', + }, + { + id: '1264', + contest_id: 'ICPCRegional2005', + problem_index: '1264', + name: 'Bingo', + title: 'Bingo', + }, + { + id: '1265', + contest_id: 'ICPCRegional2005', + problem_index: '1265', + name: 'Shy Polygons', + title: 'Shy Polygons', + }, + { + id: '1266', + contest_id: 'ICPCRegional2006', + problem_index: '1266', + name: 'How I Wonder What You Are!', + title: 'How I Wonder What You Are!', + }, + { + id: '1267', + contest_id: 'ICPCRegional2006', + problem_index: '1267', + name: 'How I Mathematician Wonder What You Are!', + title: 'How I Mathematician Wonder What You Are!', + }, + { + id: '1268', + contest_id: 'ICPCRegional2006', + problem_index: '1268', + name: 'Cubic Eight-Puzzle', + title: 'Cubic Eight-Puzzle', + }, + { + id: '1269', + contest_id: 'ICPCRegional2006', + problem_index: '1269', + name: 'Sum of Different Primes', + title: 'Sum of Different Primes', + }, + { + id: '1270', + contest_id: 'ICPCRegional2006', + problem_index: '1270', + name: 'Manhattan Wiring', + title: 'Manhattan Wiring', + }, + { + id: '1271', + contest_id: 'ICPCRegional2006', + problem_index: '1271', + name: 'Power Calculus', + title: 'Power Calculus', + }, + { + id: '1272', + contest_id: 'ICPCRegional2006', + problem_index: '1272', + name: 'Polygons on the Grid', + title: 'Polygons on the Grid', + }, + { + id: '1273', + contest_id: 'ICPCRegional2006', + problem_index: '1273', + name: 'The Best Name for Your Baby', + title: 'The Best Name for Your Baby', + }, + { + id: '1274', + contest_id: 'ICPCRegional2006', + problem_index: '1274', + name: 'Enjoyable Commutation', + title: 'Enjoyable Commutation', + }, + { + id: '1275', + contest_id: 'ICPCRegional2007', + problem_index: '1275', + name: 'And Then There Was One', + title: 'And Then There Was One', + }, + { + id: '1276', + contest_id: 'ICPCRegional2007', + problem_index: '1276', + name: 'Prime Gap', + title: 'Prime Gap', + }, + { + id: '1277', + contest_id: 'ICPCRegional2007', + problem_index: '1277', + name: 'Minimal Backgammon', + title: 'Minimal Backgammon', + }, + { + id: '1278', + contest_id: 'ICPCRegional2007', + problem_index: '1278', + name: 'Lowest Pyramid', + title: 'Lowest Pyramid', + }, + { + id: '1279', + contest_id: 'ICPCRegional2007', + problem_index: '1279', + name: 'Geometric Map', + title: 'Geometric Map', + }, + { + id: '1280', + contest_id: 'ICPCRegional2007', + problem_index: '1280', + name: 'Slim Span', + title: 'Slim Span', + }, + { + id: '1281', + contest_id: 'ICPCRegional2007', + problem_index: '1281', + name: 'The Morning after Halloween', + title: 'The Morning after Halloween', + }, + { + id: '1282', + contest_id: 'ICPCRegional2007', + problem_index: '1282', + name: 'Bug Hunt', + title: 'Bug Hunt', + }, + { + id: '1283', + contest_id: 'ICPCRegional2007', + problem_index: '1283', + name: 'Most Distant Point from the Sea', + title: 'Most Distant Point from the Sea', + }, + { + id: '1284', + contest_id: 'ICPCRegional2007', + problem_index: '1284', + name: "The Teacher's Side of Math", + title: "The Teacher's Side of Math", + }, + { + id: '1285', + contest_id: 'ICPCRegional2008', + problem_index: '1285', + name: 'Grey Area', + title: 'Grey Area', + }, + { + id: '1286', + contest_id: 'ICPCRegional2008', + problem_index: '1286', + name: 'Expected Allowance', + title: 'Expected Allowance', + }, + { + id: '1287', + contest_id: 'ICPCRegional2008', + problem_index: '1287', + name: 'Stopped Watches', + title: 'Stopped Watches', + }, + { + id: '1288', + contest_id: 'ICPCRegional2008', + problem_index: '1288', + name: 'Digits on the Floor', + title: 'Digits on the Floor', + }, + { + id: '1289', + contest_id: 'ICPCRegional2008', + problem_index: '1289', + name: 'Spherical Mirrors', + title: 'Spherical Mirrors', + }, + { + id: '1290', + contest_id: 'ICPCRegional2008', + problem_index: '1290', + name: 'Traveling Cube', + title: 'Traveling Cube', + }, + { + id: '1291', + contest_id: 'ICPCRegional2008', + problem_index: '1291', + name: 'Search of Concatenated Strings', + title: 'Search of Concatenated Strings', + }, + { + id: '1292', + contest_id: 'ICPCRegional2008', + problem_index: '1292', + name: 'Top Spinning', + title: 'Top Spinning', + }, + { + id: '1293', + contest_id: 'ICPCRegional2008', + problem_index: '1293', + name: 'Common Polynomial', + title: 'Common Polynomial', + }, + { + id: '1294', + contest_id: 'ICPCRegional2008', + problem_index: '1294', + name: 'Zigzag', + title: 'Zigzag', + }, + { + id: '1295', + contest_id: 'ICPCRegional2009', + problem_index: '1295', + name: 'Cubist Artwork', + title: 'Cubist Artwork', + }, + { + id: '1296', + contest_id: 'ICPCRegional2009', + problem_index: '1296', + name: 'Repeated Substitution with Sed', + title: 'Repeated Substitution with Sed', + }, + { + id: '1297', + contest_id: 'ICPCRegional2009', + problem_index: '1297', + name: 'Swimming Jam', + title: 'Swimming Jam', + }, + { + id: '1298', + contest_id: 'ICPCRegional2009', + problem_index: '1298', + name: 'Separate Points', + title: 'Separate Points', + }, + { + id: '1299', + contest_id: 'ICPCRegional2009', + problem_index: '1299', + name: 'Origami Through-Hole', + title: 'Origami Through-Hole', + }, + { + id: '1300', + contest_id: 'ICPCRegional2009', + problem_index: '1300', + name: "Chemist's Math", + title: "Chemist's Math", + }, + { + id: '1301', + contest_id: 'ICPCRegional2009', + problem_index: '1301', + name: 'Malfatti Circles', + title: 'Malfatti Circles', + }, + { + id: '1302', + contest_id: 'ICPCRegional2009', + problem_index: '1302', + name: 'Twenty Questions', + title: 'Twenty Questions', + }, + { + id: '1303', + contest_id: 'ICPCRegional2009', + problem_index: '1303', + name: 'Hobby on Rails', + title: 'Hobby on Rails', + }, + { + id: '1304', + contest_id: 'ICPCRegional2009', + problem_index: '1304', + name: 'Infected Land', + title: 'Infected Land', + }, + { + id: '1305', + contest_id: 'ICPCRegional2010', + problem_index: '1305', + name: 'Membership Management', + title: 'Membership Management', + }, + { + id: '1306', + contest_id: 'ICPCRegional2010', + problem_index: '1306', + name: 'Balloon Collecting', + title: 'Balloon Collecting', + }, + { + id: '1307', + contest_id: 'ICPCRegional2010', + problem_index: '1307', + name: 'Towns along a Highway', + title: 'Towns along a Highway', + }, + { + id: '1308', + contest_id: 'ICPCRegional2010', + problem_index: '1308', + name: 'Awkward Lights', + title: 'Awkward Lights', + }, + { + id: '1309', + contest_id: 'ICPCRegional2010', + problem_index: '1309', + name: 'The Two Men of the Japanese Alps', + title: 'The Two Men of the Japanese Alps', + }, + { + id: '1310', + contest_id: 'ICPCRegional2010', + problem_index: '1310', + name: 'Find the Multiples', + title: 'Find the Multiples', + }, + { + id: '1311', + contest_id: 'ICPCRegional2010', + problem_index: '1311', + name: 'Test Case Tweaking', + title: 'Test Case Tweaking', + }, + { + id: '1312', + contest_id: 'ICPCRegional2010', + problem_index: '1312', + name: "Where's Wally", + title: "Where's Wally", + }, + { + id: '1313', + contest_id: 'ICPCRegional2010', + problem_index: '1313', + name: 'Intersection of Two Prisms', + title: 'Intersection of Two Prisms', + }, + { + id: '1314', + contest_id: 'ICPCRegional2010', + problem_index: '1314', + name: 'Matrix Calculator', + title: 'Matrix Calculator', + }, + { + id: '1315', + contest_id: 'ICPCRegional2011', + problem_index: '1315', + name: 'Gift from the Goddess of Programming', + title: 'Gift from the Goddess of Programming', + }, + { + id: '1316', + contest_id: 'ICPCRegional2011', + problem_index: '1316', + name: "The Sorcerer's Donut", + title: "The Sorcerer's Donut", + }, + { + id: '1317', + contest_id: 'ICPCRegional2011', + problem_index: '1317', + name: 'Weaker than Planned', + title: 'Weaker than Planned', + }, + { + id: '1318', + contest_id: 'ICPCRegional2011', + problem_index: '1318', + name: 'Long Distance Taxi', + title: 'Long Distance Taxi', + }, + { + id: '1319', + contest_id: 'ICPCRegional2011', + problem_index: '1319', + name: 'Driving an Icosahedral Rover', + title: 'Driving an Icosahedral Rover', + }, + { + id: '1320', + contest_id: 'ICPCRegional2011', + problem_index: '1320', + name: 'City Merger', + title: 'City Merger', + }, + { + id: '1321', + contest_id: 'ICPCRegional2011', + problem_index: '1321', + name: "Captain Q's Treasure", + title: "Captain Q's Treasure", + }, + { + id: '1322', + contest_id: 'ICPCRegional2011', + problem_index: '1322', + name: 'ASCII Expression', + title: 'ASCII Expression', + }, + { + id: '1323', + contest_id: 'ICPCRegional2011', + problem_index: '1323', + name: 'Encircling Circles', + title: 'Encircling Circles', + }, + { + id: '1324', + contest_id: 'ICPCRegional2011', + problem_index: '1324', + name: 'Round Trip', + title: 'Round Trip', + }, + { + id: '1325', + contest_id: 'ICPCRegional2012', + problem_index: '1325', + name: 'Ginkgo Numbers', + title: 'Ginkgo Numbers', + }, + { + id: '1326', + contest_id: 'ICPCRegional2012', + problem_index: '1326', + name: 'Stylish', + title: 'Stylish', + }, + { + id: '1327', + contest_id: 'ICPCRegional2012', + problem_index: '1327', + name: 'One-Dimensional Cellular Automaton', + title: 'One-Dimensional Cellular Automaton', + }, + { + id: '1328', + contest_id: 'ICPCRegional2012', + problem_index: '1328', + name: 'Find the Outlier', + title: 'Find the Outlier', + }, + { + id: '1329', + contest_id: 'ICPCRegional2012', + problem_index: '1329', + name: 'Sliding Block Puzzle', + title: 'Sliding Block Puzzle', + }, + { + id: '1330', + contest_id: 'ICPCRegional2012', + problem_index: '1330', + name: 'Never Wait for Weights', + title: 'Never Wait for Weights', + }, + { + id: '1331', + contest_id: 'ICPCRegional2012', + problem_index: '1331', + name: 'Let There Be Light', + title: 'Let There Be Light', + }, + { + id: '1332', + contest_id: 'ICPCRegional2012', + problem_index: '1332', + name: 'Company Organization', + title: 'Company Organization', + }, + { + id: '1333', + contest_id: 'ICPCRegional2012', + problem_index: '1333', + name: 'Beautiful Spacing', + title: 'Beautiful Spacing', + }, + { + id: '1334', + contest_id: 'ICPCRegional2012', + problem_index: '1334', + name: 'Cubic Colonies', + title: 'Cubic Colonies', + }, + { + id: '1335', + contest_id: 'ICPCRegional2013', + problem_index: '1335', + name: 'Equal Sum Sets', + title: 'Equal Sum Sets', + }, + { + id: '1336', + contest_id: 'ICPCRegional2013', + problem_index: '1336', + name: 'The Last Ant', + title: 'The Last Ant', + }, + { + id: '1337', + contest_id: 'ICPCRegional2013', + problem_index: '1337', + name: 'Count the Regions', + title: 'Count the Regions', + }, + { + id: '1338', + contest_id: 'ICPCRegional2013', + problem_index: '1338', + name: 'Clock Hands', + title: 'Clock Hands', + }, + { + id: '1339', + contest_id: 'ICPCRegional2013', + problem_index: '1339', + name: "Dragon's Cruller", + title: "Dragon's Cruller", + }, + { + id: '1340', + contest_id: 'ICPCRegional2013', + problem_index: '1340', + name: 'Directional Resemblance', + title: 'Directional Resemblance', + }, + { + id: '1341', + contest_id: 'ICPCRegional2013', + problem_index: '1341', + name: 'Longest Chain', + title: 'Longest Chain', + }, + { + id: '1342', + contest_id: 'ICPCRegional2013', + problem_index: '1342', + name: "Don't Burst the Balloon", + title: "Don't Burst the Balloon", + }, + { + id: '1343', + contest_id: 'ICPCRegional2013', + problem_index: '1343', + name: 'Hidden Tree', + title: 'Hidden Tree', + }, + { + id: '1344', + contest_id: 'ICPCRegional2013', + problem_index: '1344', + name: 'C(O|W|A*RD*|S)* CROSSWORD Puzzle', + title: 'C(O|W|A*RD*|S)* CROSSWORD Puzzle', + }, + { + id: '1345', + contest_id: 'ICPCRegional2014', + problem_index: '1345', + name: 'Bit String Reordering', + title: 'Bit String Reordering', + }, + { + id: '1346', + contest_id: 'ICPCRegional2014', + problem_index: '1346', + name: 'Miscalculation', + title: 'Miscalculation', + }, + { + id: '1347', + contest_id: 'ICPCRegional2014', + problem_index: '1347', + name: 'Shopping', + title: 'Shopping', + }, + { + id: '1348', + contest_id: 'ICPCRegional2014', + problem_index: '1348', + name: 'Space Golf', + title: 'Space Golf', + }, + { + id: '1349', + contest_id: 'ICPCRegional2014', + problem_index: '1349', + name: 'Automotive Navigation', + title: 'Automotive Navigation', + }, + { + id: '1350', + contest_id: 'ICPCRegional2014', + problem_index: '1350', + name: 'There is No Alternative', + title: 'There is No Alternative', + }, + { + id: '1351', + contest_id: 'ICPCRegional2014', + problem_index: '1351', + name: 'Flipping Parentheses', + title: 'Flipping Parentheses', + }, + { + id: '1352', + contest_id: 'ICPCRegional2014', + problem_index: '1352', + name: 'Cornering at Poles', + title: 'Cornering at Poles', + }, + { + id: '1353', + contest_id: 'ICPCRegional2014', + problem_index: '1353', + name: 'Sweet War', + title: 'Sweet War', + }, + { + id: '1354', + contest_id: 'ICPCRegional2014', + problem_index: '1354', + name: 'Exhibition', + title: 'Exhibition', + }, + { + id: '1355', + contest_id: 'ICPCRegional2014', + problem_index: '1355', + name: 'L Jumps', + title: 'L Jumps', + }, + { + id: '1356', + contest_id: 'ICPCRegional2015', + problem_index: '1356', + name: 'Decimal Sequences', + title: 'Decimal Sequences', + }, + { + id: '1357', + contest_id: 'ICPCRegional2015', + problem_index: '1357', + name: 'Squeeze the Cylinders', + title: 'Squeeze the Cylinders', + }, + { + id: '1358', + contest_id: 'ICPCRegional2015', + problem_index: '1358', + name: 'Sibling Rivalry', + title: 'Sibling Rivalry', + }, + { + id: '1359', + contest_id: 'ICPCRegional2015', + problem_index: '1359', + name: 'Wall Clocks', + title: 'Wall Clocks', + }, + { + id: '1360', + contest_id: 'ICPCRegional2015', + problem_index: '1360', + name: 'Bringing Order to Disorder', + title: 'Bringing Order to Disorder', + }, + { + id: '1361', + contest_id: 'ICPCRegional2015', + problem_index: '1361', + name: 'Deadlock Detection', + title: 'Deadlock Detection', + }, + { + id: '1362', + contest_id: 'ICPCRegional2015', + problem_index: '1362', + name: 'Do Geese See God?', + title: 'Do Geese See God?', + }, + { + id: '1363', + contest_id: 'ICPCRegional2015', + problem_index: '1363', + name: 'Rotating Cutter Bits', + title: 'Rotating Cutter Bits', + }, + { + id: '1364', + contest_id: 'ICPCRegional2015', + problem_index: '1364', + name: 'Routing a Marathon Race', + title: 'Routing a Marathon Race', + }, + { + id: '1365', + contest_id: 'ICPCRegional2015', + problem_index: '1365', + name: 'Post Office Investigation', + title: 'Post Office Investigation', + }, + { + id: '1366', + contest_id: 'ICPCRegional2015', + problem_index: '1366', + name: 'Min-Max Distance Game', + title: 'Min-Max Distance Game', + }, + { + id: '1367', + contest_id: 'ICPCRegional2016', + problem_index: '1367', + name: 'Rearranging a Sequence', + title: 'Rearranging a Sequence', + }, + { + id: '1368', + contest_id: 'ICPCRegional2016', + problem_index: '1368', + name: 'Quality of Check Digits', + title: 'Quality of Check Digits', + }, + { + id: '1369', + contest_id: 'ICPCRegional2016', + problem_index: '1369', + name: 'Distribution Center', + title: 'Distribution Center', + }, + { + id: '1370', + contest_id: 'ICPCRegional2016', + problem_index: '1370', + name: 'Hidden Anagrams', + title: 'Hidden Anagrams', + }, + { + id: '1371', + contest_id: 'ICPCRegional2016', + problem_index: '1371', + name: 'Infallibly Crack Perplexing Cryptarithm', + title: 'Infallibly Crack Perplexing Cryptarithm', + }, + { + id: '1372', + contest_id: 'ICPCRegional2016', + problem_index: '1372', + name: 'Three Kingdoms of Bourdelot', + title: 'Three Kingdoms of Bourdelot', + }, + { + id: '1373', + contest_id: 'ICPCRegional2016', + problem_index: '1373', + name: 'Placing Medals on a Binary Tree', + title: 'Placing Medals on a Binary Tree', + }, + { + id: '1374', + contest_id: 'ICPCRegional2016', + problem_index: '1374', + name: 'Animal Companion in Maze', + title: 'Animal Companion in Maze', + }, + { + id: '1375', + contest_id: 'ICPCRegional2016', + problem_index: '1375', + name: 'Skinny Polygon', + title: 'Skinny Polygon', + }, + { + id: '1376', + contest_id: 'ICPCRegional2016', + problem_index: '1376', + name: 'Cover the Polygon with Your Disk', + title: 'Cover the Polygon with Your Disk', + }, + { + id: '1377', + contest_id: 'ICPCRegional2016', + problem_index: '1377', + name: 'Black and White Boxes', + title: 'Black and White Boxes', + }, + { + id: '1378', + contest_id: 'ICPCRegional2017', + problem_index: '1378', + name: 'Secret of Chocolate Poles', + title: 'Secret of Chocolate Poles', + }, + { + id: '1379', + contest_id: 'ICPCRegional2017', + problem_index: '1379', + name: 'Parallel Lines', + title: 'Parallel Lines', + }, + { + id: '1380', + contest_id: 'ICPCRegional2017', + problem_index: '1380', + name: 'Medical Checkup', + title: 'Medical Checkup', + }, + { + id: '1381', + contest_id: 'ICPCRegional2017', + problem_index: '1381', + name: 'Making Perimeter of the Convex Hull Shortest', + title: 'Making Perimeter of the Convex Hull Shortest', + }, + { + id: '1382', + contest_id: 'ICPCRegional2017', + problem_index: '1382', + name: 'Black or White', + title: 'Black or White', + }, + { + id: '1383', + contest_id: 'ICPCRegional2017', + problem_index: '1383', + name: 'Pizza Delivery', + title: 'Pizza Delivery', + }, + { + id: '1384', + contest_id: 'ICPCRegional2017', + problem_index: '1384', + name: 'Rendezvous on a Tetrahedron', + title: 'Rendezvous on a Tetrahedron', + }, + { + id: '1385', + contest_id: 'ICPCRegional2017', + problem_index: '1385', + name: 'Homework', + title: 'Homework', + }, + { + id: '1386', + contest_id: 'ICPCRegional2017', + problem_index: '1386', + name: 'Starting a Scenic Railroad Service', + title: 'Starting a Scenic Railroad Service', + }, + { + id: '1387', + contest_id: 'ICPCRegional2017', + problem_index: '1387', + name: 'String Puzzle', + title: 'String Puzzle', + }, + { + id: '1388', + contest_id: 'ICPCRegional2017', + problem_index: '1388', + name: 'Counting Cycles', + title: 'Counting Cycles', + }, + { + id: '1389', + contest_id: 'ICPCRegional2018', + problem_index: '1389', + name: 'Digits Are Not Just Characters', + title: 'Digits Are Not Just Characters', + }, + { + id: '1390', + contest_id: 'ICPCRegional2018', + problem_index: '1390', + name: 'Arithmetic Progressions', + title: 'Arithmetic Progressions', + }, + { + id: '1391', + contest_id: 'ICPCRegional2018', + problem_index: '1391', + name: 'Emergency Evacuation', + title: 'Emergency Evacuation', + }, + { + id: '1392', + contest_id: 'ICPCRegional2018', + problem_index: '1392', + name: 'Shortest Common Non-Subsequence', + title: 'Shortest Common Non-Subsequence', + }, + { + id: '1393', + contest_id: 'ICPCRegional2018', + problem_index: '1393', + name: 'Eulerian Flight Tour', + title: 'Eulerian Flight Tour', + }, + { + id: '1394', + contest_id: 'ICPCRegional2018', + problem_index: '1394', + name: 'Fair Chocolate-Cutting', + title: 'Fair Chocolate-Cutting', + }, + { + id: '1395', + contest_id: 'ICPCRegional2018', + problem_index: '1395', + name: 'What Goes Up Must Come Down', + title: 'What Goes Up Must Come Down', + }, + { + id: '1396', + contest_id: 'ICPCRegional2018', + problem_index: '1396', + name: 'Four-Coloring', + title: 'Four-Coloring', + }, + { + id: '1397', + contest_id: 'ICPCRegional2018', + problem_index: '1397', + name: 'Ranks', + title: 'Ranks', + }, + { + id: '1398', + contest_id: 'ICPCRegional2018', + problem_index: '1398', + name: 'Colorful Tree', + title: 'Colorful Tree', + }, + { + id: '1399', + contest_id: 'ICPCRegional2018', + problem_index: '1399', + name: 'Sixth Sense', + title: 'Sixth Sense', + }, + { + id: '1400', + contest_id: 'ICPCRegional2019', + problem_index: '1400', + name: 'Fast Forwarding', + title: 'Fast Forwarding', + }, + { + id: '1401', + contest_id: 'ICPCRegional2019', + problem_index: '1401', + name: 'Estimating the Flood Risk', + title: 'Estimating the Flood Risk', + }, + { + id: '1402', + contest_id: 'ICPCRegional2019', + problem_index: '1402', + name: 'Wall Painting', + title: 'Wall Painting', + }, + { + id: '1403', + contest_id: 'ICPCRegional2019', + problem_index: '1403', + name: 'Twin Trees Bros.', + title: 'Twin Trees Bros.', + }, + { + id: '1404', + contest_id: 'ICPCRegional2019', + problem_index: '1404', + name: 'Reordering the Documents', + title: 'Reordering the Documents', + }, + { + id: '1405', + contest_id: 'ICPCRegional2019', + problem_index: '1405', + name: 'Halting Problem', + title: 'Halting Problem', + }, + { + id: '1406', + contest_id: 'ICPCRegional2019', + problem_index: '1406', + name: 'Ambiguous Encoding', + title: 'Ambiguous Encoding', + }, + { + id: '1407', + contest_id: 'ICPCRegional2019', + problem_index: '1407', + name: 'Parentheses Editor', + title: 'Parentheses Editor', + }, + { + id: '1408', + contest_id: 'ICPCRegional2019', + problem_index: '1408', + name: 'One-Way Conveyors', + title: 'One-Way Conveyors', + }, + { + id: '1409', + contest_id: 'ICPCRegional2019', + problem_index: '1409', + name: 'Fun Region', + title: 'Fun Region', + }, + { + id: '1410', + contest_id: 'ICPCRegional2019', + problem_index: '1410', + name: 'Draw in Straight Lines', + title: 'Draw in Straight Lines', + }, + { + id: '1411', + contest_id: 'ICPCRegional2020', + problem_index: '1411', + name: 'Three-Axis Views', + title: 'Three-Axis Views', + }, + { + id: '1412', + contest_id: 'ICPCRegional2020', + problem_index: '1412', + name: 'Secrets of Legendary Treasure', + title: 'Secrets of Legendary Treasure', + }, + { + id: '1413', + contest_id: 'ICPCRegional2020', + problem_index: '1413', + name: 'Short Coding', + title: 'Short Coding', + }, + { + id: '1414', + contest_id: 'ICPCRegional2020', + problem_index: '1414', + name: 'Colorful Rectangle', + title: 'Colorful Rectangle', + }, + { + id: '1415', + contest_id: 'ICPCRegional2020', + problem_index: '1415', + name: 'Jewelry Size', + title: 'Jewelry Size', + }, + { + id: '1416', + contest_id: 'ICPCRegional2020', + problem_index: '1416', + name: 'Solar Car', + title: 'Solar Car', + }, + { + id: '1417', + contest_id: 'ICPCRegional2020', + problem_index: '1417', + name: 'To be Connected or not to be that is the Question', + title: 'To be Connected or not to be that is the Question', + }, + { + id: '1418', + contest_id: 'ICPCRegional2020', + problem_index: '1418', + name: 'LCM of GCDs', + title: 'LCM of GCDs', + }, + { + id: '1419', + contest_id: 'ICPCRegional2020', + problem_index: '1419', + name: 'High-Tech Detective', + title: 'High-Tech Detective', + }, + { + id: '1420', + contest_id: 'ICPCRegional2020', + problem_index: '1420', + name: 'Formica Sokobanica', + title: 'Formica Sokobanica', + }, + { + id: '1421', + contest_id: 'ICPCRegional2020', + problem_index: '1421', + name: 'Suffixes may Contain Prefixes', + title: 'Suffixes may Contain Prefixes', + }, + { + id: '1422', + contest_id: 'ICPCRegional2021', + problem_index: '1422', + name: 'Loop of Chocolate', + title: 'Loop of Chocolate', + }, + { + id: '1423', + contest_id: 'ICPCRegional2021', + problem_index: '1423', + name: 'Lottery Fun Time', + title: 'Lottery Fun Time', + }, + { + id: '1424', + contest_id: 'ICPCRegional2021', + problem_index: '1424', + name: 'Reversible Compressio', + title: 'Reversible Compressio', + }, + { + id: '1425', + contest_id: 'ICPCRegional2021', + problem_index: '1425', + name: 'Wireless Communication Network', + title: 'Wireless Communication Network', + }, + { + id: '1426', + contest_id: 'ICPCRegional2021', + problem_index: '1426', + name: 'Planning Railroad Discontinuation', + title: 'Planning Railroad Discontinuation', + }, + { + id: '1427', + contest_id: 'ICPCRegional2021', + problem_index: '1427', + name: 'It’s Surely Complex', + title: 'It’s Surely Complex', + }, + { + id: '1428', + contest_id: 'ICPCRegional2021', + problem_index: '1428', + name: 'Genealogy of Puppets', + title: 'Genealogy of Puppets', + }, + { + id: '1429', + contest_id: 'ICPCRegional2021', + problem_index: '1429', + name: 'Cancer DNA', + title: 'Cancer DNA', + }, + { + id: '1430', + contest_id: 'ICPCRegional2021', + problem_index: '1430', + name: 'Even Division', + title: 'Even Division', + }, + { + id: '1431', + contest_id: 'ICPCRegional2021', + problem_index: '1431', + name: 'The Cross Covers Everything', + title: 'The Cross Covers Everything', + }, + { + id: '1432', + contest_id: 'ICPCRegional2021', + problem_index: '1432', + name: 'Distributing the Treasure', + title: 'Distributing the Treasure', + }, + { + id: '1433', + contest_id: 'ICPCRegional2022', + problem_index: '1433', + name: 'Hasty Santa Claus', + title: 'Hasty Santa Claus', + }, + { + id: '1434', + contest_id: 'ICPCRegional2022', + problem_index: '1434', + name: 'Interactive Number Guessing', + title: 'Interactive Number Guessing', + }, + { + id: '1435', + contest_id: 'ICPCRegional2022', + problem_index: '1435', + name: 'Secure the Top Secret', + title: 'Secure the Top Secret', + }, + { + id: '1436', + contest_id: 'ICPCRegional2022', + problem_index: '1436', + name: 'Move One Coin', + title: 'Move One Coin', + }, + { + id: '1437', + contest_id: 'ICPCRegional2022', + problem_index: '1437', + name: 'Incredibly Cute Penguin Chicks', + title: 'Incredibly Cute Penguin Chicks', + }, + { + id: '1438', + contest_id: 'ICPCRegional2022', + problem_index: '1438', + name: 'Make a Loop', + title: 'Make a Loop', + }, + { + id: '1439', + contest_id: 'ICPCRegional2022', + problem_index: '1439', + name: 'Remodeling the Dungeon', + title: 'Remodeling the Dungeon', + }, + { + id: '1440', + contest_id: 'ICPCRegional2022', + problem_index: '1440', + name: 'Cake Decoration', + title: 'Cake Decoration', + }, + { + id: '1441', + contest_id: 'ICPCRegional2022', + problem_index: '1441', + name: 'Quiz Contest', + title: 'Quiz Contest', + }, + { + id: '1442', + contest_id: 'ICPCRegional2022', + problem_index: '1442', + name: 'Traveling Salesperson in an Island', + title: 'Traveling Salesperson in an Island', + }, + { + id: '1443', + contest_id: 'ICPCRegional2022', + problem_index: '1443', + name: 'New Year Festival', + title: 'New Year Festival', + }, + { + id: '1444', + contest_id: 'ICPCRegional2023', + problem_index: '1444', + name: 'Yokohama Phenomena', + title: 'Yokohama Phenomena', + }, + { + id: '1445', + contest_id: 'ICPCRegional2023', + problem_index: '1445', + name: 'Rank Promotion', + title: 'Rank Promotion', + }, + { + id: '1446', + contest_id: 'ICPCRegional2023', + problem_index: '1446', + name: 'Ferris Wheel', + title: 'Ferris Wheel', + }, + { + id: '1447', + contest_id: 'ICPCRegional2023', + problem_index: '1447', + name: 'Nested Repetition Compression', + title: 'Nested Repetition Compression', + }, + { + id: '1448', + contest_id: 'ICPCRegional2023', + problem_index: '1448', + name: 'Chayas', + title: 'Chayas', + }, + { + id: '1449', + contest_id: 'ICPCRegional2023', + problem_index: '1449', + name: 'Color Inversion on a Huge Chessboard', + title: 'Color Inversion on a Huge Chessboard', + }, + { + id: '1450', + contest_id: 'ICPCRegional2023', + problem_index: '1450', + name: 'Fortune Telling', + title: 'Fortune Telling', + }, + { + id: '1451', + contest_id: 'ICPCRegional2023', + problem_index: '1451', + name: 'Task Assignment to Two Employees', + title: 'Task Assignment to Two Employees', + }, + { + id: '1452', + contest_id: 'ICPCRegional2023', + problem_index: '1452', + name: 'Liquid Distribution', + title: 'Liquid Distribution', + }, + { + id: '1453', + contest_id: 'ICPCRegional2023', + problem_index: '1453', + name: 'Do It Yourself?', + title: 'Do It Yourself?', + }, + { + id: '1454', + contest_id: 'ICPCRegional2023', + problem_index: '1454', + name: 'Probing the Disk', + title: 'Probing the Disk', + }, + { + id: '1455', + contest_id: 'ICPCRegional2024', + problem_index: '1455', + name: 'Ribbon on the Christmas Present', + title: 'Ribbon on the Christmas Present', + }, + { + id: '1456', + contest_id: 'ICPCRegional2024', + problem_index: '1456', + name: 'The Sparsest Number in Between', + title: 'The Sparsest Number in Between', + }, + { + id: '1457', + contest_id: 'ICPCRegional2024', + problem_index: '1457', + name: 'Omnes Viae Yokohamam Ducunt?', + title: 'Omnes Viae Yokohamam Ducunt?', + }, + { + id: '1458', + contest_id: 'ICPCRegional2024', + problem_index: '1458', + name: 'Tree Generators', + title: 'Tree Generators', + }, + { + id: '1459', + contest_id: 'ICPCRegional2024', + problem_index: '1459', + name: 'E-Circuit Is Now on Sale!', + title: 'E-Circuit Is Now on Sale!', + }, + { + id: '1460', + contest_id: 'ICPCRegional2024', + problem_index: '1460', + name: 'The Farthest Point', + title: 'The Farthest Point', + }, + { + id: '1461', + contest_id: 'ICPCRegional2024', + problem_index: '1461', + name: 'Beyond the Former Explorer', + title: 'Beyond the Former Explorer', + }, + { + id: '1462', + contest_id: 'ICPCRegional2024', + problem_index: '1462', + name: 'Remodeling the Dungeon 2', + title: 'Remodeling the Dungeon 2', + }, + { + id: '1463', + contest_id: 'ICPCRegional2024', + problem_index: '1463', + name: 'Greatest of the Greatest Common Divisors', + title: 'Greatest of the Greatest Common Divisors', + }, + { + id: '1464', + contest_id: 'ICPCRegional2024', + problem_index: '1464', + name: 'Mixing Solutions', + title: 'Mixing Solutions', + }, + { + id: '1465', + contest_id: 'ICPCRegional2024', + problem_index: '1465', + name: 'Scheduling Two Meetings', + title: 'Scheduling Two Meetings', + }, + { + id: '1466', + contest_id: 'ICPCRegional2024', + problem_index: '1466', + name: 'Peculiar Protocol', + title: 'Peculiar Protocol', + }, { id: '2389', contest_id: 'JAGSpring2012', diff --git a/src/features/tasks/utils/contest-table/aoj_icpc_labels.test.ts b/src/features/tasks/utils/contest-table/aoj_icpc_labels.test.ts index 3cd08a0cd..413e981b9 100644 --- a/src/features/tasks/utils/contest-table/aoj_icpc_labels.test.ts +++ b/src/features/tasks/utils/contest-table/aoj_icpc_labels.test.ts @@ -1,10 +1,6 @@ import { describe, test, expect, beforeEach, afterEach } from 'vitest'; -import { - buildAojIcpcLetterMap, - formatAojIcpcTitle, - ICPC_PRELIM_LABEL_OVERRIDES, -} from './aoj_icpc_labels'; +import { buildAojIcpcLetterMap, formatAojIcpcTitle, ICPC_LABEL_OVERRIDES } from './aoj_icpc_labels'; describe('formatAojIcpcTitle', () => { test('prepends the letter and a dot to the title', () => { @@ -36,7 +32,7 @@ describe('buildAojIcpcLetterMap', () => { describe('override path', () => { beforeEach(() => { - ICPC_PRELIM_LABEL_OVERRIDES['ICPCPrelimTest'] = { + ICPC_LABEL_OVERRIDES['ICPCPrelimTest'] = { '1150': 'A', '1152': 'C', '1155': 'E', @@ -44,10 +40,10 @@ describe('buildAojIcpcLetterMap', () => { }); afterEach(() => { - delete ICPC_PRELIM_LABEL_OVERRIDES['ICPCPrelimTest']; + delete ICPC_LABEL_OVERRIDES['ICPCPrelimTest']; }); - test('uses override map when contest_id has an entry in ICPC_PRELIM_LABEL_OVERRIDES', () => { + test('uses override map when contest_id has an entry in ICPC_LABEL_OVERRIDES', () => { const result = buildAojIcpcLetterMap('ICPCPrelimTest', ['1150', '1152', '1155']); expect(result.get('1150')).toBe('A'); diff --git a/src/features/tasks/utils/contest-table/aoj_icpc_labels.ts b/src/features/tasks/utils/contest-table/aoj_icpc_labels.ts index 20ab1d17c..9ae7ab003 100644 --- a/src/features/tasks/utils/contest-table/aoj_icpc_labels.ts +++ b/src/features/tasks/utils/contest-table/aoj_icpc_labels.ts @@ -1,19 +1,51 @@ +import type { TaskResults } from '$lib/types/task'; +import type { + ContestTableDisplayConfig, + ContestTableTitleStyle, +} from '$features/tasks/types/contest-table/contest_table_provider'; + // contest_id -> (task_table_index -> letter). Used only for years with judge gaps. -export const ICPC_PRELIM_LABEL_OVERRIDES: Record> = {}; +// Keyed by full contest_id so both Prelim and Regional can share one map. +export const ICPC_LABEL_OVERRIDES: Record> = {}; + +export const AOJ_ICPC_TITLE_STYLE: ContestTableTitleStyle = { + headingTag: 'h2', + fontSize: 'text-xl', + fontWeight: 'font-bold', + bottomGap: 'pb-1', +}; // Prepend the assigned positional letter to an ICPC title for inline display (e.g. "A. name"). export function formatAojIcpcTitle(title: string, letter: string): string { return `${letter}. ${title}`; } +// Return unique task_table_index values sorted numerically ascending. +export function sortAojIcpcHeaderIds(filtered: TaskResults): string[] { + return Array.from(new Set(filtered.map((taskResult) => taskResult.task_table_index))).sort( + (first, second) => Number(first) - Number(second), + ); +} + +export function buildAojIcpcDisplayConfig(): 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, + columnWrapThreshold: 6, + }; +} + // Build task_table_index -> letter map for one contest. // Default: sort indices numerically asc, assign A, B, C... -// Override: if ICPC_PRELIM_LABEL_OVERRIDES[contestId] exists, use it. +// Override: if ICPC_LABEL_OVERRIDES[contestId] exists, use it. export function buildAojIcpcLetterMap( contestId: string, taskTableIndices: string[], ): Map { - const override = ICPC_PRELIM_LABEL_OVERRIDES[contestId]; + const override = ICPC_LABEL_OVERRIDES[contestId]; if (override !== undefined) { return new Map(Object.entries(override)); diff --git a/src/features/tasks/utils/contest-table/aoj_icpc_providers.test.ts b/src/features/tasks/utils/contest-table/aoj_icpc_providers.test.ts index b5f3f3ed3..a18b3701f 100644 --- a/src/features/tasks/utils/contest-table/aoj_icpc_providers.test.ts +++ b/src/features/tasks/utils/contest-table/aoj_icpc_providers.test.ts @@ -3,8 +3,8 @@ import { describe, test, expect, beforeEach, afterEach } from 'vitest'; import { ContestType } from '$lib/types/contest'; import type { TaskResults } from '$lib/types/task'; -import { ICPC_PRELIM_LABEL_OVERRIDES } from './aoj_icpc_labels'; -import { AojIcpcPrelimProvider } from './aoj_icpc_providers'; +import { ICPC_LABEL_OVERRIDES } from './aoj_icpc_labels'; +import { AojIcpcPrelimProvider, AojIcpcRegionalProvider } from './aoj_icpc_providers'; const createProvider = (year: number) => new AojIcpcPrelimProvider(ContestType.AOJ_ICPC, year); @@ -379,14 +379,14 @@ describe('AojIcpcPrelimProvider', () => { ] as TaskResults; beforeEach(() => { - ICPC_PRELIM_LABEL_OVERRIDES[TEST_CONTEST_ID] = { + ICPC_LABEL_OVERRIDES[TEST_CONTEST_ID] = { '9001': 'X', '9002': 'Y', }; }); afterEach(() => { - delete ICPC_PRELIM_LABEL_OVERRIDES[TEST_CONTEST_ID]; + delete ICPC_LABEL_OVERRIDES[TEST_CONTEST_ID]; }); test('generateTable stores raw titles even when override map is active', () => { @@ -448,17 +448,17 @@ describe('AojIcpcPrelimProvider', () => { ] as TaskResults; beforeEach(() => { - ICPC_PRELIM_LABEL_OVERRIDES[TEST_CONTEST_ID] = { + ICPC_LABEL_OVERRIDES[TEST_CONTEST_ID] = { '9001': 'X', '9002': 'Y', }; }); afterEach(() => { - delete ICPC_PRELIM_LABEL_OVERRIDES[TEST_CONTEST_ID]; + delete ICPC_LABEL_OVERRIDES[TEST_CONTEST_ID]; }); - test('returns custom labels from ICPC_PRELIM_LABEL_OVERRIDES', () => { + test('returns custom labels from ICPC_LABEL_OVERRIDES', () => { const provider = createProvider(TEST_YEAR); const labels = provider.getTaskLabels(overrideTasks); @@ -468,3 +468,419 @@ describe('AojIcpcPrelimProvider', () => { }); }); }); + +const createRegionalProvider = (year: number) => + new AojIcpcRegionalProvider(ContestType.AOJ_ICPC, year); + +// ICPCRegional1998: 8 problems (A–H) +const regionalTasks1998: TaskResults = [ + { + contest_id: 'ICPCRegional1998', + task_id: '1200', + task_table_index: '1200', + title: "Goldbach's Conjecture", + }, + { + contest_id: 'ICPCRegional1998', + task_id: '1201', + task_table_index: '1201', + title: 'Lattice Practices', + }, + { + contest_id: 'ICPCRegional1998', + task_id: '1202', + task_table_index: '1202', + title: 'Mobile Phone Coverage', + }, + { + contest_id: 'ICPCRegional1998', + task_id: '1203', + task_table_index: '1203', + title: "Napoleon's Grumble", + }, + { + contest_id: 'ICPCRegional1998', + task_id: '1204', + task_table_index: '1204', + title: 'Pipeline Scheduling', + }, + { + contest_id: 'ICPCRegional1998', + task_id: '1205', + task_table_index: '1205', + title: 'Triangle Partition', + }, + { + contest_id: 'ICPCRegional1998', + task_id: '1206', + task_table_index: '1206', + title: 'BUT We Need a Diagram', + }, + { + contest_id: 'ICPCRegional1998', + task_id: '1207', + task_table_index: '1207', + title: 'Digital Racing Circuil', + }, +] as TaskResults; + +// ICPCRegional2024: 12 problems (A–L), maximum problem count +const regionalTasks2024: TaskResults = [ + { + contest_id: 'ICPCRegional2024', + task_id: '1455', + task_table_index: '1455', + title: 'Ribbon on the Christmas Present', + }, + { + contest_id: 'ICPCRegional2024', + task_id: '1456', + task_table_index: '1456', + title: 'The Sparsest Number in Between', + }, + { + contest_id: 'ICPCRegional2024', + task_id: '1457', + task_table_index: '1457', + title: 'Omnes Viae Yokohamam Ducunt?', + }, + { + contest_id: 'ICPCRegional2024', + task_id: '1458', + task_table_index: '1458', + title: 'Tree Generators', + }, + { + contest_id: 'ICPCRegional2024', + task_id: '1459', + task_table_index: '1459', + title: 'E-Circuit Is Now on Sale!', + }, + { + contest_id: 'ICPCRegional2024', + task_id: '1460', + task_table_index: '1460', + title: 'The Farthest Point', + }, + { + contest_id: 'ICPCRegional2024', + task_id: '1461', + task_table_index: '1461', + title: 'Beyond the Former Explorer', + }, + { + contest_id: 'ICPCRegional2024', + task_id: '1462', + task_table_index: '1462', + title: 'Remodeling the Dungeon 2', + }, + { + contest_id: 'ICPCRegional2024', + task_id: '1463', + task_table_index: '1463', + title: 'Greatest of the Greatest Common Divisors', + }, + { + contest_id: 'ICPCRegional2024', + task_id: '1464', + task_table_index: '1464', + title: 'Mixing Solutions', + }, + { + contest_id: 'ICPCRegional2024', + task_id: '1465', + task_table_index: '1465', + title: 'Scheduling Two Meetings', + }, + { + contest_id: 'ICPCRegional2024', + task_id: '1466', + task_table_index: '1466', + title: 'Peculiar Protocol', + }, +] as TaskResults; + +const mixedRegionalTasks: TaskResults = [ + ...regionalTasks1998, + // ICPCRegional1999: one task to verify year filtering + { + contest_id: 'ICPCRegional1999', + task_id: '1208', + task_table_index: '1208', + title: 'Rational Irrationals', + }, + // Non-ICPC contest, to verify contest-type filtering + { contest_id: 'abc123', task_id: 'abc123_a', task_table_index: 'A', title: 'Divisor' }, +] as TaskResults; + +describe('AojIcpcRegionalProvider', () => { + const provider1998 = createRegionalProvider(1998); + + describe('filter', () => { + describe('successful cases', () => { + test('returns only tasks belonging to the given year contest', () => { + const filtered = provider1998.filter(mixedRegionalTasks); + + expect(filtered).toHaveLength(8); + expect(filtered.every((task) => task.contest_id === 'ICPCRegional1998')).toBe(true); + }); + + test('excludes tasks from other ICPC Regional years', () => { + const filtered = provider1998.filter(mixedRegionalTasks); + + expect(filtered.some((task) => task.contest_id === 'ICPCRegional1999')).toBe(false); + }); + + test('excludes tasks from non-ICPC contests', () => { + const filtered = provider1998.filter(mixedRegionalTasks); + + expect(filtered.some((task) => task.contest_id === 'abc123')).toBe(false); + }); + }); + + describe('edge cases', () => { + test('returns empty array for empty input', () => { + expect(provider1998.filter([] as TaskResults)).toEqual([]); + }); + + test('returns empty array when no tasks match the given year', () => { + const provider2024 = createRegionalProvider(2024); + + expect(provider2024.filter(regionalTasks1998)).toEqual([]); + }); + }); + }); + + describe('generateTable', () => { + describe('successful cases', () => { + test('stores raw titles (no letter prefix) for all 8 tasks', () => { + const table = provider1998.generateTable(regionalTasks1998); + + expect(table['ICPCRegional1998']['1200'].title).toBe("Goldbach's Conjecture"); + expect(table['ICPCRegional1998']['1207'].title).toBe('Digital Racing Circuil'); + }); + + test('title is unchanged when generateTable is called twice (structurally idempotent)', () => { + const firstTable = provider1998.generateTable(regionalTasks1998); + const secondInput = Object.values(firstTable['ICPCRegional1998']) as TaskResults; + const secondTable = provider1998.generateTable(secondInput); + + expect(secondTable['ICPCRegional1998']['1201'].title).toBe('Lattice Practices'); + }); + + test('uses task_table_index as the inner key', () => { + const table = provider1998.generateTable(regionalTasks1998); + + expect(Object.keys(table['ICPCRegional1998'])).toEqual( + expect.arrayContaining(['1200', '1201', '1202', '1203', '1204', '1205', '1206', '1207']), + ); + }); + + test('creates table keyed by contest_id', () => { + const table = provider1998.generateTable(regionalTasks1998); + + expect(Object.keys(table)).toEqual(['ICPCRegional1998']); + }); + + test('does not mutate original task data', () => { + const originalTitle = regionalTasks1998[0].title; + provider1998.generateTable(regionalTasks1998); + + expect(regionalTasks1998[0].title).toBe(originalTitle); + }); + }); + }); + + describe('getMetadata', () => { + test('returns correct title with year', () => { + expect(provider1998.getMetadata().title).toBe('ICPC 地区予選 1998'); + }); + + test('returns correct abbreviationName with year', () => { + expect(provider1998.getMetadata().abbreviationName).toBe('icpcRegional1998'); + }); + + test('returns shared titleStyle (h2, text-xl, font-bold, pb-1)', () => { + expect(provider1998.getMetadata().titleStyle).toEqual({ + headingTag: 'h2', + fontSize: 'text-xl', + fontWeight: 'font-bold', + bottomGap: 'pb-1', + }); + }); + }); + + describe('getDisplayConfig', () => { + test('returns isShownHeader as false', () => { + expect(provider1998.getDisplayConfig().isShownHeader).toBe(false); + }); + + test('returns isShownRoundLabel as false', () => { + expect(provider1998.getDisplayConfig().isShownRoundLabel).toBe(false); + }); + + test('returns isShownTaskIndex as true', () => { + expect(provider1998.getDisplayConfig().isShownTaskIndex).toBe(true); + }); + + test('returns empty roundLabelWidth', () => { + expect(provider1998.getDisplayConfig().roundLabelWidth).toBe(''); + }); + + test('returns correct tableBodyCellsWidth', () => { + expect(provider1998.getDisplayConfig().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('returns columnWrapThreshold as 6', () => { + expect(provider1998.getDisplayConfig().columnWrapThreshold).toBe(6); + }); + }); + + describe('getContestRoundLabel', () => { + test('returns label with year', () => { + expect(provider1998.getContestRoundLabel('ICPCRegional1998')).toBe('ICPC 地区予選 1998'); + }); + }); + + describe('getContestRoundIds', () => { + test('returns contest_id of the filtered tasks', () => { + expect(provider1998.getContestRoundIds(regionalTasks1998)).toEqual(['ICPCRegional1998']); + }); + + test('returns empty array for empty input', () => { + expect(provider1998.getContestRoundIds([] as TaskResults)).toEqual([]); + }); + }); + + describe('getHeaderIdsForTask', () => { + describe('successful cases', () => { + test('returns indices sorted numerically ascending regardless of input order', () => { + const reversedTasks = [...regionalTasks1998].reverse() as TaskResults; + + expect(provider1998.getHeaderIdsForTask(reversedTasks)).toEqual([ + '1200', + '1201', + '1202', + '1203', + '1204', + '1205', + '1206', + '1207', + ]); + }); + + test('deduplicates repeated task_table_index values', () => { + const duplicateTasks = [regionalTasks1998[0], regionalTasks1998[0]] as TaskResults; + + expect(provider1998.getHeaderIdsForTask(duplicateTasks)).toEqual(['1200']); + }); + }); + + describe('edge cases', () => { + test('returns empty array for empty input', () => { + expect(provider1998.getHeaderIdsForTask([] as TaskResults)).toEqual([]); + }); + }); + }); + + describe('year boundary behavior', () => { + const provider2024 = createRegionalProvider(2024); + + test('latest year 2024 returns correct metadata (12 problems, A–L)', () => { + expect(provider2024.getMetadata().title).toBe('ICPC 地区予選 2024'); + expect(provider2024.getMetadata().abbreviationName).toBe('icpcRegional2024'); + }); + + test('latest year 2024 stores raw titles (maximum problem count)', () => { + const table = provider2024.generateTable(regionalTasks2024); + + expect(table['ICPCRegional2024']['1455'].title).toBe('Ribbon on the Christmas Present'); + expect(table['ICPCRegional2024']['1466'].title).toBe('Peculiar Protocol'); + }); + + test('latest year 2024 filter isolates its own contest_id', () => { + const mixed = [...regionalTasks2024, ...regionalTasks1998] as TaskResults; + const filtered = provider2024.filter(mixed); + + expect(filtered).toHaveLength(12); + expect(filtered.every((task) => task.contest_id === 'ICPCRegional2024')).toBe(true); + }); + }); + + describe('getTaskLabels', () => { + describe('successful cases', () => { + test('returns letter map for all 8 tasks in numeric ID order (A–H)', () => { + const labels = provider1998.getTaskLabels(regionalTasks1998); + + expect(labels['ICPCRegional1998']['1200']).toBe('A'); + expect(labels['ICPCRegional1998']['1201']).toBe('B'); + expect(labels['ICPCRegional1998']['1202']).toBe('C'); + expect(labels['ICPCRegional1998']['1203']).toBe('D'); + expect(labels['ICPCRegional1998']['1204']).toBe('E'); + expect(labels['ICPCRegional1998']['1205']).toBe('F'); + expect(labels['ICPCRegional1998']['1206']).toBe('G'); + expect(labels['ICPCRegional1998']['1207']).toBe('H'); + }); + + test('returns letter map A–L for 12 tasks (2024)', () => { + const provider2024 = createRegionalProvider(2024); + const labels = provider2024.getTaskLabels(regionalTasks2024); + + expect(labels['ICPCRegional2024']['1455']).toBe('A'); + expect(labels['ICPCRegional2024']['1466']).toBe('L'); + }); + + test('returns object keyed by contestId', () => { + const labels = provider1998.getTaskLabels(regionalTasks1998); + + expect(Object.keys(labels)).toEqual(['ICPCRegional1998']); + }); + }); + + describe('edge cases', () => { + test('returns empty inner object for empty input', () => { + const labels = provider1998.getTaskLabels([] as TaskResults); + + expect(labels).toEqual({ ICPCRegional1998: {} }); + }); + }); + + describe('override map path', () => { + const TEST_YEAR = 8888; + const TEST_CONTEST_ID = `ICPCRegional${TEST_YEAR}`; + + const overrideTasks: TaskResults = [ + { + contest_id: TEST_CONTEST_ID, + task_id: '8001', + task_table_index: '8001', + title: 'Task One', + }, + { + contest_id: TEST_CONTEST_ID, + task_id: '8002', + task_table_index: '8002', + title: 'Task Two', + }, + ] as TaskResults; + + beforeEach(() => { + ICPC_LABEL_OVERRIDES[TEST_CONTEST_ID] = { '8001': 'X', '8002': 'Y' }; + }); + + afterEach(() => { + delete ICPC_LABEL_OVERRIDES[TEST_CONTEST_ID]; + }); + + test('returns custom labels from ICPC_LABEL_OVERRIDES', () => { + const provider = createRegionalProvider(TEST_YEAR); + const labels = provider.getTaskLabels(overrideTasks); + + expect(labels[TEST_CONTEST_ID]['8001']).toBe('X'); + expect(labels[TEST_CONTEST_ID]['8002']).toBe('Y'); + }); + }); + }); +}); diff --git a/src/features/tasks/utils/contest-table/aoj_icpc_providers.ts b/src/features/tasks/utils/contest-table/aoj_icpc_providers.ts index 63bf5b406..6654b65e3 100644 --- a/src/features/tasks/utils/contest-table/aoj_icpc_providers.ts +++ b/src/features/tasks/utils/contest-table/aoj_icpc_providers.ts @@ -6,7 +6,12 @@ import { } from '$features/tasks/types/contest-table/contest_table_provider'; import { ContestTableProviderBase } from './contest_table_provider_base'; -import { buildAojIcpcLetterMap } from './aoj_icpc_labels'; +import { + buildAojIcpcLetterMap, + sortAojIcpcHeaderIds, + AOJ_ICPC_TITLE_STYLE, + buildAojIcpcDisplayConfig, +} from './aoj_icpc_labels'; export class AojIcpcPrelimProvider extends ContestTableProviderBase { private readonly year: number; @@ -22,39 +27,68 @@ export class AojIcpcPrelimProvider extends ContestTableProviderBase { return (taskResult: TaskResult) => taskResult.contest_id === this.contestId; } - // Ensure left-to-right cell order is numeric (A,B,C...). Safeguard for variable-width ids. getHeaderIdsForTask(filtered: TaskResults): string[] { - return Array.from(new Set(filtered.map((taskResult) => taskResult.task_table_index))).sort( - (first, second) => Number(first) - Number(second), - ); + return sortAojIcpcHeaderIds(filtered); } getMetadata(): ContestTableMetaData { return { title: `ICPC 国内予選 ${this.year}`, abbreviationName: `icpcPrelim${this.year}`, - titleStyle: { - headingTag: 'h2', - fontSize: 'text-xl', - fontWeight: 'font-bold', - bottomGap: 'pb-1', - }, + titleStyle: AOJ_ICPC_TITLE_STYLE, }; } getDisplayConfig(): ContestTableDisplayConfig { + return buildAojIcpcDisplayConfig(); + } + + getContestRoundLabel(_contestId: string): string { + return `ICPC 国内予選 ${this.year}`; + } + + override getTaskLabels(filtered: TaskResults): Record> { + const letterMap = buildAojIcpcLetterMap( + this.contestId, + filtered.map((taskResult) => taskResult.task_table_index), + ); + + return { [this.contestId]: Object.fromEntries(letterMap) }; + } +} + +export class AojIcpcRegionalProvider extends ContestTableProviderBase { + private readonly year: number; + private readonly contestId: string; + + constructor(contestType: ContestType, year: number) { + super(contestType, String(year)); // provider key: AOJ_ICPC::1998 + this.year = year; + this.contestId = `ICPCRegional${year}`; + } + + protected setFilterCondition(): (taskResult: TaskResult) => boolean { + return (taskResult: TaskResult) => taskResult.contest_id === this.contestId; + } + + getHeaderIdsForTask(filtered: TaskResults): string[] { + return sortAojIcpcHeaderIds(filtered); + } + + getMetadata(): ContestTableMetaData { 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, - columnWrapThreshold: 6, + title: `ICPC 地区予選 ${this.year}`, + abbreviationName: `icpcRegional${this.year}`, + titleStyle: AOJ_ICPC_TITLE_STYLE, }; } + getDisplayConfig(): ContestTableDisplayConfig { + return buildAojIcpcDisplayConfig(); + } + getContestRoundLabel(_contestId: string): string { - return `ICPC 国内予選 ${this.year}`; + return `ICPC 地区予選 ${this.year}`; } override getTaskLabels(filtered: TaskResults): Record> { 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 d8d0f1d55..6e54b5cb3 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 @@ -32,11 +32,17 @@ import { TessokuBookForChallengesProvider, MathAndAlgorithmProvider, AojIcpcPrelimProvider, + AojIcpcRegionalProvider, prepareContestProviderPresets, } from './contest_table_provider'; import { TESSOKU_SECTIONS } from '$features/tasks/types/contest-table/contest_table_provider'; -import { ICPC_PRELIM_OLDEST_YEAR, ICPC_PRELIM_LATEST_YEAR } from './contest_table_provider_groups'; +import { + ICPC_PRELIM_OLDEST_YEAR, + ICPC_PRELIM_LATEST_YEAR, + ICPC_REGIONAL_OLDEST_YEAR, + ICPC_REGIONAL_LATEST_YEAR, +} from './contest_table_provider_groups'; describe('prepareContestProviderPresets', () => { test('expects to create ABS preset correctly', () => { @@ -302,6 +308,18 @@ describe('prepareContestProviderPresets', () => { expect(group.getProvider(ContestType.AOJ_ICPC, '2023')).toBeInstanceOf(AojIcpcPrelimProvider); }); + 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', + }); + 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(); @@ -325,6 +343,7 @@ describe('prepareContestProviderPresets', () => { expect(typeof presets.JOIFirstQualRound).toBe('function'); expect(typeof presets.JOISecondQualAndSemiFinalRound).toBe('function'); expect(typeof presets.AojIcpcPrelim).toBe('function'); + expect(typeof presets.AojIcpcRegional).toBe('function'); }); test('expects each preset to create independent instances', () => { 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 5bde6224d..f2978e1b4 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 @@ -32,12 +32,15 @@ import { JOIQualRoundFrom2006To2019Provider, JOISemiFinalRoundProvider, } from './joi_providers'; -import { AojIcpcPrelimProvider } from './aoj_icpc_providers'; +import { AojIcpcPrelimProvider, AojIcpcRegionalProvider } from './aoj_icpc_providers'; import { ContestTableProviderGroup } from './contest_table_provider_group'; export const ICPC_PRELIM_OLDEST_YEAR = 1998; export const ICPC_PRELIM_LATEST_YEAR = 2025; +export const ICPC_REGIONAL_OLDEST_YEAR = 1998; +export const ICPC_REGIONAL_LATEST_YEAR = 2024; + /** * Prepare predefined provider groups * Easily create groups with commonly used combinations @@ -241,6 +244,19 @@ export const prepareContestProviderPresets = () => { return group; }, + + AojIcpcRegional: () => { + const group = new ContestTableProviderGroup('ICPC 地区予選', { + buttonLabel: 'ICPC 地区予選', + ariaLabel: 'Filter ICPC Asia Regional', + }); + // Iterate from latest to oldest so the newest year's table renders on top. + for (let year = ICPC_REGIONAL_LATEST_YEAR; year >= ICPC_REGIONAL_OLDEST_YEAR; year--) { + group.addProvider(new AojIcpcRegionalProvider(ContestType.AOJ_ICPC, year)); + } + + return group; + }, }; }; @@ -267,6 +283,7 @@ export const contestTableProviderGroups = { joiFirstQualRound: presets.JOIFirstQualRound(), joiSecondQualAndSemiFinalRound: presets.JOISecondQualAndSemiFinalRound(), aojIcpcPrelim: presets.AojIcpcPrelim(), + aojIcpcRegional: presets.AojIcpcRegional(), }; export type ContestTableProviderGroups = keyof typeof contestTableProviderGroups; From 12ea761dee1daa00b08978c8b7897c182dbf7140 Mon Sep 17 00:00:00 2001 From: "k.hiro1818" Date: Wed, 17 Jun 2026 06:01:13 +0000 Subject: [PATCH 2/3] test(contest-table): expand aoj_icpc_labels test coverage for Regional and shared helpers - Add Regional-specific test cases for formatAojIcpcTitle and buildAojIcpcLetterMap - Add tests for sortAojIcpcHeaderIds and buildAojIcpcDisplayConfig shared helpers - Reorganize describe blocks by Prelim/Regional/shared for clarity Co-Authored-By: Claude Sonnet 4.6 --- .../contest-table/aoj_icpc_labels.test.ts | 149 ++++++++++++++---- 1 file changed, 115 insertions(+), 34 deletions(-) diff --git a/src/features/tasks/utils/contest-table/aoj_icpc_labels.test.ts b/src/features/tasks/utils/contest-table/aoj_icpc_labels.test.ts index 413e981b9..843672483 100644 --- a/src/features/tasks/utils/contest-table/aoj_icpc_labels.test.ts +++ b/src/features/tasks/utils/contest-table/aoj_icpc_labels.test.ts @@ -3,60 +3,141 @@ import { describe, test, expect, beforeEach, afterEach } from 'vitest'; import { buildAojIcpcLetterMap, formatAojIcpcTitle, ICPC_LABEL_OVERRIDES } from './aoj_icpc_labels'; describe('formatAojIcpcTitle', () => { - test('prepends the letter and a dot to the title', () => { + test('prepends the letter and a dot to the title (Prelim)', () => { expect(formatAojIcpcTitle('Amidakuji', 'B')).toBe('B. Amidakuji'); }); + + test('prepends the letter and a dot to the title (Regional)', () => { + expect(formatAojIcpcTitle('Yokohama Phenomena', 'A')).toBe('A. Yokohama Phenomena'); + }); }); describe('buildAojIcpcLetterMap', () => { - test('sorts indices numerically ascending and assigns letters A, B, C...', () => { - const result = buildAojIcpcLetterMap('ICPCPrelim2023', ['1665', '1664']); + describe('Prelim', () => { + test('sorts indices numerically ascending and assigns letters A, B, C...', () => { + const result = buildAojIcpcLetterMap('ICPCPrelim2023', ['1665', '1664']); - expect(result.get('1664')).toBe('A'); - expect(result.get('1665')).toBe('B'); - expect(result.size).toBe(2); - }); + expect(result.get('1664')).toBe('A'); + expect(result.get('1665')).toBe('B'); + expect(result.size).toBe(2); + }); - test('returns empty Map for empty input', () => { - const result = buildAojIcpcLetterMap('ICPCPrelim2023', []); + test('returns empty Map for empty input', () => { + const result = buildAojIcpcLetterMap('ICPCPrelim2023', []); - expect(result.size).toBe(0); - }); + expect(result.size).toBe(0); + }); + + test('returns Map with single entry assigned letter A', () => { + const result = buildAojIcpcLetterMap('ICPCPrelim2023', ['1000']); + + expect(result.get('1000')).toBe('A'); + expect(result.size).toBe(1); + }); + + describe('override path', () => { + beforeEach(() => { + ICPC_LABEL_OVERRIDES['ICPCPrelimTest'] = { + '1150': 'A', + '1152': 'C', + '1155': 'E', + }; + }); + + afterEach(() => { + delete ICPC_LABEL_OVERRIDES['ICPCPrelimTest']; + }); + + test('uses override map when contest_id has an entry in ICPC_LABEL_OVERRIDES', () => { + const result = buildAojIcpcLetterMap('ICPCPrelimTest', ['1150', '1152', '1155']); - test('returns Map with single entry assigned letter A', () => { - const result = buildAojIcpcLetterMap('ICPCPrelim2023', ['1000']); + expect(result.get('1150')).toBe('A'); + expect(result.get('1152')).toBe('C'); + expect(result.get('1155')).toBe('E'); + expect(result.size).toBe(3); + }); - expect(result.get('1000')).toBe('A'); - expect(result.size).toBe(1); + test('ignores the input indices and uses only the override map entries', () => { + const result = buildAojIcpcLetterMap('ICPCPrelimTest', ['1150', '1152', '1155']); + + expect(result.has('1151')).toBe(false); + expect(result.has('1153')).toBe(false); + }); + }); }); - describe('override path', () => { - beforeEach(() => { - ICPC_LABEL_OVERRIDES['ICPCPrelimTest'] = { - '1150': 'A', - '1152': 'C', - '1155': 'E', - }; + describe('Regional', () => { + test('sorts indices numerically ascending and assigns letters A, B, C...', () => { + const result = buildAojIcpcLetterMap('ICPCRegional2023', ['1445', '1444']); + + expect(result.get('1444')).toBe('A'); + expect(result.get('1445')).toBe('B'); + expect(result.size).toBe(2); }); - afterEach(() => { - delete ICPC_LABEL_OVERRIDES['ICPCPrelimTest']; + test('assigns letters A through L for the 12 problems of 2024', () => { + const indices = [ + '1455', + '1456', + '1457', + '1458', + '1459', + '1460', + '1461', + '1462', + '1463', + '1464', + '1465', + '1466', + ]; + const result = buildAojIcpcLetterMap('ICPCRegional2024', indices); + + expect(result.get('1455')).toBe('A'); + expect(result.get('1466')).toBe('L'); + expect(result.size).toBe(12); }); - test('uses override map when contest_id has an entry in ICPC_LABEL_OVERRIDES', () => { - const result = buildAojIcpcLetterMap('ICPCPrelimTest', ['1150', '1152', '1155']); + test('returns empty Map for empty input', () => { + const result = buildAojIcpcLetterMap('ICPCRegional2023', []); - expect(result.get('1150')).toBe('A'); - expect(result.get('1152')).toBe('C'); - expect(result.get('1155')).toBe('E'); - expect(result.size).toBe(3); + expect(result.size).toBe(0); }); - test('ignores the input indices and uses only the override map entries', () => { - const result = buildAojIcpcLetterMap('ICPCPrelimTest', ['1150', '1152', '1155']); + test('returns Map with single entry assigned letter A', () => { + const result = buildAojIcpcLetterMap('ICPCRegional2023', ['1444']); + + expect(result.get('1444')).toBe('A'); + expect(result.size).toBe(1); + }); + + describe('override path', () => { + beforeEach(() => { + ICPC_LABEL_OVERRIDES['ICPCRegionalTest'] = { + '1444': 'A', + '1446': 'C', + '1448': 'E', + }; + }); + + afterEach(() => { + delete ICPC_LABEL_OVERRIDES['ICPCRegionalTest']; + }); + + test('uses override map when contest_id has an entry in ICPC_LABEL_OVERRIDES', () => { + const result = buildAojIcpcLetterMap('ICPCRegionalTest', ['1444', '1446', '1448']); + + expect(result.get('1444')).toBe('A'); + expect(result.get('1446')).toBe('C'); + expect(result.get('1448')).toBe('E'); + expect(result.size).toBe(3); + }); + + test('ignores the input indices and uses only the override map entries', () => { + const result = buildAojIcpcLetterMap('ICPCRegionalTest', ['1444', '1446', '1448']); - expect(result.has('1151')).toBe(false); - expect(result.has('1153')).toBe(false); + expect(result.has('1445')).toBe(false); + expect(result.has('1447')).toBe(false); + }); }); }); }); From b20520e61b1f5166445515ffa189ef2e371ea9e6 Mon Sep 17 00:00:00 2001 From: "k.hiro1818" Date: Wed, 17 Jun 2026 07:39:40 +0000 Subject: [PATCH 3/3] docs(dev-notes): remove completed plan for ICPC Regional table (issue #3680) Co-Authored-By: Claude Sonnet 4.6 --- .../2026-06-17/icpc-regional-table/plan.md | 130 ------------------ 1 file changed, 130 deletions(-) delete mode 100644 docs/dev-notes/2026-06-17/icpc-regional-table/plan.md diff --git a/docs/dev-notes/2026-06-17/icpc-regional-table/plan.md b/docs/dev-notes/2026-06-17/icpc-regional-table/plan.md deleted file mode 100644 index d46033e5e..000000000 --- a/docs/dev-notes/2026-06-17/icpc-regional-table/plan.md +++ /dev/null @@ -1,130 +0,0 @@ -# テーブル「ICPC 地区予選」追加 (issue #3680) - -## 概要 - -コンテストテーブル一覧に「ICPC 地区予選」(ICPC Asia Regional) を追加する。 -PR #3635 で追加済みの「ICPC 国内予選」(`AojIcpcPrelimProvider`, Pattern 4 = 年ごとに N インスタンス化) と -ほぼ同一の仕様(テーブル inner ラベル A,B,C…・タイトル文言・prefix)で、対象データだけが `ICPCRegional{year}` に変わる。 - -- ボタンラベル: `ICPC 地区予選` -- 順序: `ICPC 国内予選` の直後(画面上では右側) - -## 設計の根拠(スコープ削減) - -`classifyContest` / `contestTypePriorities` / `getContestNameLabel` は **既に `ICPCRegional*` を -`ContestType.AOJ_ICPC` として扱える**。 - -- [contest.ts:103](../../../../src/lib/utils/contest.ts) の正規表現 `^ICPC(Prelim|Regional)\d*$` が一致 -- `ICPC_TRANSLATIONS` に `Regional: ' 地区予選 '` が存在([contest.ts:735](../../../../src/lib/utils/contest.ts)) - -よって **スキル `add-contest-table-provider` の Layer 1(schema)・Layer 2(ContestType 定数)・Layer 3(utils)は不要**。 -ContestType は `AOJ_ICPC` を Prelim と共用する(provider key は `AOJ_ICPC::{year}` だが Prelim/Regional は -別グループ=別 Map のため衝突しない)。 - -実作業は **Layer 4(provider)+ Layer 5(グループ登録)+ シードデータ取込** に集約される。 - -### 順序の担保 - -[TaskTable.svelte:197](../../../../src/features/tasks/components/contest-table/TaskTable.svelte) は -`{#each Object.entries(contestTableProviderGroups) ...}` でボタンを描画する。 -登録オブジェクトの `aojIcpcPrelim` 直後に `aojIcpcRegional` を追加すれば挿入順で国内予選の右側になる。 - -## ユーザー決定事項 - -| 項目 | 決定 | -| ------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| シードデータ | `prisma/tasks.ts` に `ICPCRegional*` 行は未登録。行データはユーザーが提供 → 本作業で取り込む | -| 実装方針 | 独立クラス `AojIcpcRegionalProvider` を作成しつつ、Prelim と意味的に重複するロジックは**共有関数/定数として切り出す**(Prelim 側もそれを使うようリファクタ) | -| columnWrapThreshold | Prelim と同じ `6`(地区予選は最大 12 問だが 6×2 行で折返し、レイアウト一貫性を優先) | -| 年範囲 | 1998–2024(AOJ fixture 実績、連続 27 年) | - -## 却下した代替案 - -- **共有抽象基底クラス `AojIcpcProviderBase` を導入**: より DRY だが、稼働中の Prelim クラスの継承構造に手を入れリスクが上がる。 - ユーザー方針「独立クラス+関数切り出し」に従い不採用。 -- **Regional 用に新規 `ContestType.AOJ_ICPC_REGIONAL` を追加**: schema/enum/utils の 3 層改修が発生する。 - 既存の `AOJ_ICPC` が Regional を完全に扱えるため YAGNI として不採用。 - -## フェーズ(TDD・低リスク→高リスク順) - -> コミットは「共有関数リファクタ+Regional provider」「グループ登録」「シード取込」を分ける。 - -### Phase 1: 共有ロジックの関数/定数化(既存 Prelim のリファクタ) - -差分が無いメソッドを切り出し、Prelim/Regional 双方から使う。挙動不変のリファクタ。 - -- `src/features/tasks/utils/contest-table/aoj_icpc_labels.ts` - - `ICPC_PRELIM_LABEL_OVERRIDES` → **`ICPC_LABEL_OVERRIDES`** に改名 - (full contest_id をキーにするため Prelim/Regional を 1 map で共用可。現状は空 `{}` でデータ移行不要) - - `buildAojIcpcLetterMap` / `formatAojIcpcTitle` はそのまま流用 - - 共有の表示系を追加(または新規 `aoj_icpc_shared.ts` に集約) - - `sortAojIcpcHeaderIds(filtered: TaskResults): string[]`(数値昇順、現 `getHeaderIdsForTask` 本体) - - `AOJ_ICPC_TITLE_STYLE`(`getMetadata().titleStyle` 共有定数) - - `buildAojIcpcDisplayConfig(): ContestTableDisplayConfig`(`columnWrapThreshold: 6` 等、共有) -- `aoj_icpc_providers.ts` の `AojIcpcPrelimProvider` を上記共有関数/定数を呼ぶ薄いラッパへ修正 -- 既存テストの参照名更新: `aoj_icpc_providers.test.ts` / `aoj_icpc_labels.test.ts` の - `ICPC_PRELIM_LABEL_OVERRIDES` → `ICPC_LABEL_OVERRIDES` -- 検証: `pnpm test:unit src/features/tasks/utils/contest-table/` GREEN - -### Phase 2: AojIcpcRegionalProvider(Layer 4・TDD) - -- テスト先行: `aoj_icpc_providers.test.ts` に `describe('AojIcpcRegionalProvider')` を追加。 - 代表年の inline fixture(例: 1998=8問, 2024=最多問, 年フィルタ用に別年1件+非ICPC1件の mixed)。 - Prelim のテスト構成(filter / generateTable 冪等&非破壊 / getMetadata / getDisplayConfig=6 / - getContestRoundLabel / getHeaderIdsForTask / getTaskLabels A,B,C… / override map path)をミラー。**RED 確認** -- 実装: `aoj_icpc_providers.ts` に `AojIcpcRegionalProvider extends ContestTableProviderBase` を Prelim の隣に追加。差分のみ: - - `contestId = `ICPCRegional${year}`` - - `getMetadata().title` / `getContestRoundLabel` = `ICPC 地区予選 ${year}`、`abbreviationName = `icpcRegional${year}`` - - `getTaskLabels` は `buildAojIcpcLetterMap(this.contestId, …)` を流用(共有 overrides map 経由) - - `getHeaderIdsForTask` / `getDisplayConfig` / `titleStyle` は Phase 1 の共有関数/定数を使用 -- 検証: `pnpm test:unit ` GREEN - -### Phase 3: グループ登録(Layer 5・TDD) - -- `contest_table_provider_groups.ts` - - `ICPC_REGIONAL_OLDEST_YEAR = 1998` / `ICPC_REGIONAL_LATEST_YEAR = 2024` を export(テストが getSize 参照) - - `AojIcpcPrelim` プリセットに倣い `AojIcpcRegional` プリセットを追加 - (latest→oldest で `addProvider`、buttonLabel/ariaLabel = `ICPC 地区予選` / `Filter ICPC Asia Regional`、 - groupName = `ICPC 地区予選`) - - `contestTableProviderGroups` オブジェクトの **`aojIcpcPrelim` の直後**に - `aojIcpcRegional: presets.AojIcpcRegional()` を追加(描画順=右側) -- `contest_table_provider_groups.test.ts`: import 追加、`AojIcpcRegional` プリセットの - groupName/metadata/getSize(=LATEST−OLDEST+1=27)/`getProvider(ContestType.AOJ_ICPC, '2024') instanceof AojIcpcRegionalProvider` - を追加。`presets are functions` 一覧にも追加 -- 検証: `pnpm test:unit src/features/tasks/utils/contest-table/` GREEN - -### Phase 4: シードデータ取込 - -- ユーザー提供の `ICPCRegional{1998..2024}` タスク行を `prisma/tasks.ts` に追記 - (`ICPCPrelim*` 群の直後など。`id`/`contest_id`/`problem_index`/`name`/`title`/`grade` 形式に整合) -- 取込後 `pnpm db:seed` でローカル DB に反映 -- 注: AOJ admin 取込フロー(`src/routes/(admin)/tasks/`)は `classifyContest` が既に - `ICPCRegional*` を認識するため別途改修不要 - -## 主要変更ファイル - -- `src/features/tasks/utils/contest-table/aoj_icpc_labels.ts`(共有関数化・overrides 改名) -- `src/features/tasks/utils/contest-table/aoj_icpc_providers.ts`(Regional provider 追加・Prelim 薄化) -- `src/features/tasks/utils/contest-table/aoj_icpc_providers.test.ts`(Regional テスト・参照名更新) -- `src/features/tasks/utils/contest-table/aoj_icpc_labels.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`(グループテスト) -- `prisma/tasks.ts`(ICPCRegional シード行・ユーザー提供) - -## 再利用する既存実装 - -- `buildAojIcpcLetterMap` / `formatAojIcpcTitle` — `aoj_icpc_labels.ts` -- `ContestTableProviderBase`(`generateTable` / `createProviderKey` / `getProviderKey`)— `contest_table_provider_base.ts` -- `classifyContest` / `getContestNameLabel` / `ICPC_TRANSLATIONS` — `src/lib/utils/contest.ts`(改修不要・既に Regional 対応) -- `ContestTableProviderGroup` — `contest_table_provider_group.ts` - -## 検証 - -1. `pnpm test:unit src/features/tasks/utils/contest-table/`(Phase 1–3 各 RED→GREEN) -2. `pnpm test:unit`(全体回帰。Prelim リファクタの非破壊確認) -3. `pnpm check` / `pnpm lint` -4. シード取込後 `pnpm db:seed` → `pnpm dev` でタスクテーブル画面を開き、 - - 「ICPC 地区予選」ボタンが「ICPC 国内予選」の右に出る - - 押下で年別テーブル(最新年が上)が表示され、各行に A,B,C… のラベルと正しいタイトルが並ぶ - - 1998(8問)/ 2024(最多問)が空でなく描画される -5. 全フェーズ完了後、AGENTS.md 規約に従い `coderabbit review --plain` と `/session-close` を実施