diff --git a/.agents/skills/add-tasks/instructions.md b/.agents/skills/add-tasks/instructions.md index eaf319cbb..7fb7808a5 100644 --- a/.agents/skills/add-tasks/instructions.md +++ b/.agents/skills/add-tasks/instructions.md @@ -18,6 +18,8 @@ - atcoder.jp is unreachable and WebFetch truncates problems.json, so a miss there is not evidence of absence — ask the user to paste the rows - Use `id` / `problem_index` / `name` / `title` verbatim; do not derive `id` from contest_id - AOJ ICPC and every AOJ JAG contest (Prelim, Regional, training camps): set `problem_index` to the problem letter, not the AOJ problem id — A, B, C... in ascending problem id within the contest, as `assignProblemLetters` does on import. When a problem before the last one is missing from AOJ, take the letters from the maintainer's problem list and add the contest to `LETTERS_OF_SHIFTED_CONTESTS`. Keep `title` plain, without a `. ` prefix. `seed_problem_letters.test.ts` checks both +- A problem the maintainer's list has but AOJ lacks (even a trailing one): add it with `id` `NoJudge-{contest_id}_{lowercase letter}`, register its jag-icpc.org page in `NO_JUDGE_PAGES` (`src/lib/contests/utils/no_judge.ts`), and add the contest to `LETTERS_OF_SHIFTED_CONTESTS` — `Number()` cannot sort these ids. This is the exception to "do not derive `id` from contest_id". A problem that exists on AOJ but is missing from the contest list keeps its AOJ problem id +- `addContestNameToTaskIndex` takes `taskId` (required); `contest_name_and_task_index.ts` cases set it only for NoJudge ## Tests diff --git a/docs/dev-notes/2026-09-09/task-title-index-change-impact/done.md b/docs/dev-notes/2026-09-09/task-title-index-change-impact/done.md deleted file mode 100644 index d404545e0..000000000 --- a/docs/dev-notes/2026-09-09/task-title-index-change-impact/done.md +++ /dev/null @@ -1,215 +0,0 @@ -# 完了分(PR0〜PR2.5)— 意思決定の要約 - -コードと git の履歴から読み取れない判断と事実だけを、項目ごとにまとめる。変更したファイルや関数は各 PR の差分を見る。全体像は [plan.md](./plan.md)、根拠データは [investigation.md](./investigation.md)。 - -| PR | 内容 | 状態 | -| -------------- | --------------------------------------------------------------------- | ------------------------------------------------ | -| PR0(#4212) | 出典欄の AOJ の書式 | 完了 | -| PR1(#4217) | ICPC 国内予選 / アジア地区 / JAG 模擬国内(79 / 632)を問題記号にする | 完了 | -| PR1.5(#4219) | AOJ API の移転に追従する(#4028) | 完了 | -| PR2(#4220) | JAG 合宿系と JAG 模擬地区(66 / 625)を問題記号にする(#4030) | staging に反映、本番は未反映 | -| PR2.1(#4222) | DB に無い 10 コンテストを入れる(#4221) | staging に反映(データも投入済み)、本番は未反映 | -| PR2.5 | 76 コンテストのテーブルを、グループ「JAG 模擬地区」に作る(#4026) | 実装済み、未マージ | - -## 残っている作業 - -### 本番・staging での確認 - -- PR2: 本番(`main` への昇格後)で、移行が通ること -- PR2: 問題一覧の出典欄の表示(例: `JAG 夏合宿 2024 Day3 - H(AOJ)`) -- PR2.1: 本番(`main` への昇格後)で、デプロイが終わってから 4 コンテストを取り込み、PR2 の移行が通った後に CSV を投入する(staging と同じ手順) -- PR2.1 の CSV には `updated_at` を含める(`@updatedAt` は Prisma 側の実装で、DB に既定値が無い。`created_at` / `grade` / `atcoder_problems_difficulty` には既定値がある)。投入は `invalidateTaskCaches()` を経ないので、反映はキャッシュの TTL(1 時間)の後になる -- PR1: コンテストテーブルのスクリーンショットでの目視確認 -- PR2.5: staging で「JAG 模擬地区」の表示を目視で確認する(並び、括弧書き、AtCoder の 4 コンテストのセルのリンク先)。開発サーバーでは、2026-10-25 にメンテナが同じ 3 点を確かめた -- PR2.5: 本番では、PR2 の移行が通ってから表に出ること。移行の前に出ると、合宿系の問題 ID の index と `'2389. …'` の title が表に出る -- PR2.5: 本番で PR2.1 のデータ(4 コンテストの取り込みと CSV)を入れるまでは、「JAG 模擬地区」に 10 コンテストの見出しと空の枠が出る(下の残タスク) - -### 残タスク - -PR2 以降の計画には含めていない。 - -- 2016 年の JAG 模擬国内を API から取り込むと、記号が seed と食い違う(Codex / CodeRabbit)。直すなら、取り込みで 2 回開催を `A` / `B` の `contest_id` に分け、それぞれ A から振る。`getContests` 側もそろえる -- seed 整合テストの期待値を `assignProblemLetters` で作っている(PR1 と PR2.1 の Codex)。testing.md の「期待値は literal」に合わない。PR2 では補正したコンテストだけを literal にする(M18)。全件を literal にするかは未判断 -- `TaskTable.svelte` は、データの無い Provider でも見出しとテーブルの枠を描画する(PR2.5 の Codex)。`prepareContestTable` が `null` を返して消えるのは行だけ。直すなら、見出しから枠までを `{#if contestTable}` で囲む(全グループに効く)。既存の挙動なので PR2.5 では直さなかった(メンテナの判断) -- テーブル名・出典欄の名称(春合宿 / 冬合宿 / 模擬地区)を、スプレッドシートの名称(春コンテスト / 冬コンテスト / 模擬アジア地区)にそろえるかは #4018(コンテスト情報の DB 化)で決める。PR2.5 の配列(並びと括弧書き)が移行元になる([名称の違い](./investigation.md#名称の違い)) -- `src/lib/services/tasks.ts` の `title.replace(task_table_index, …)` は、AOJ の問題に ContestTaskPair を作るとタイトル本文を置換する(Codex)。AOJ の pair は現在 0 件。直すときは先頭の `${index}. ` だけを置換し、`Amidakuji` のような本文と `A. Title` の両方を回帰テストに含める - -## 何をしたか - -- **PR0**: 出典欄の AOJ 分岐を `${contestName} - ${taskTableIndex}(AOJ)` にし、AOJ のコンテスト名ラベルから全角括弧を外した -- **PR1**: ICPC 国内予選 / アジア地区 / JAG 模擬国内(79 コンテスト / 632 問)の `task_table_index` を問題記号にした。移行 SQL は `prisma/migrations/20261024033928_update_aoj_task_table_index/`。管理画面の取り込みでも、この範囲は基本ルールで記号を付ける。表示側の変換(`buildAojLetterMap` / `AOJ_LABEL_OVERRIDES` / `formatAojTitle` / `getTaskLabels`)は削除した -- **PR1.5**: `AOJ_API_BASE_URL` を `https://judgeapi.u-aizu.ac.jp/` から `https://onlinejudge.u-aizu.ac.jp/api/` に変えた。旧ホストは 410 を返し、管理画面からの AOJ の取り込み(Courses / PCK / JAG / ICPC)がすべて失敗していた - - `clients.test.ts` のクライアントを本番の定数から作り、nock で受ける URL(`/api/…`)を期待値として直書きした。`nock.disableNetConnect()` も足した - - 開発コンテナの外向き通信の許可リスト(`.devcontainer/init-firewall.sh`)の `judgeapi.u-aizu.ac.jp` を `onlinejudge.u-aizu.ac.jp` に置き換えた -- **PR2**: JAG 合宿系(夏 / 冬 / 春、50 コンテスト / 467 問)と JAG 模擬地区(16 / 158)の `task_table_index` を、問題 ID から問題記号にした。合宿系の title から、前に付いていた `<問題 ID>. ` を外した。移行 SQL は `prisma/migrations/20261025000719_update_aoj_jag_task_table_index/` - - seed に、本番だけにあった模擬地区 158 問を足した(grade は本番のまま。3355 の title は trim) - - 管理画面の取り込みで、JAG は全 round(模擬国内 / 模擬地区)で記号を付けるようにした -- **PR2.1**: ContestType `ATCODER_JAG` を新設し、AtCoder 上の JAG 4 コンテスト(`jag2018summer-day2` / `jag2017summer-day1` / `jag2015summer-day3` / `jag2013summer-warmingup`)を完全一致で分類するようにした。マイグレーションは `prisma/migrations/20261025030756_add_atcoder_jag_to_contest_type/` - - seed に、この 4 コンテスト(42 問)と、2025 / 2026 の JAG 夏合宿(`JAGSummer2025-day1`〜`JAGSummer2026-day3`、AOJ 3648–3719、6 コンテスト / 72 問、記号 A–L・title は素)を足した。値はメンテナから受け取った(AtCoder 側は AtCoder Problems の値) -- **PR2.5**: グループ「JAG 模擬地区」を「JAG 模擬国内」の後に足し、合宿系 50・模擬地区 16・PR2.1 の 10 の計 76 コンテストを、コンテストごとのテーブルにした。並びと括弧書きは `aoj_jag_regional_entries.ts` の配列で持つ。中身は [investigation.md の表示順](./investigation.md#表示順76-コンテスト) と同じ - -## 進め方 - -| 決定 | PR | 理由 | -| ---------------------------- | ---- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| PR0 → PR1 → 合宿系の順にする | 全体 | PR0 は DB に依存しない。PR1 は出典欄の成果がすぐ見え、欠落が少ないので対応表の形式を楽なケースで決められる。逆順はレビュー負荷が低いが、出典欄の反映が遅れ、対応表の形式を最も判断の重い合宿系で作ることになる | - -## 移行 SQL - -| 決定 | PR | 理由 | -| ----------------------------------------------------------------------------------------------------------- | --------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| 対応表を焼き込まず、基本ルールを SQL で表す | PR1 | 本番のタスクは管理画面の取り込みで入っており、seed と一致する保証が無い。`VALUES` で書くと本番にしか無い行が残り、`UPDATE` は該当行が無くてもエラーにならないので、取りこぼしが静かに起きる | -| 基本ルールで振り、8 コンテストはコンテストの全行を上書きする(45 行) | PR2 | 8 コンテストは先頭や途中の問題が AOJ に無く、基本ルールでは記号がずれる。正しい記号は、メンテナのスプレッドシートの位置(欠けた問題を飛ばした位置)で決まる。全行を上書きするのは、1 つのコンテストに 2 つの規則を混ぜないため | -| 範囲は `contest_type` ではなく `contest_id` の接頭辞で絞る | PR1 / PR2 | 合宿系も模擬国内と同じ `AOJ_JAG` に分類される。PR1 で `contest_type` だけで絞ると合宿系 467 件まで更新し、title のプレフィックスが表に出る。PR2 は `^JAG(Summer\|Winter\|Spring\|Regional)` で絞り、PR1 で記号にした模擬国内を外す | -| 各ステップの更新件数を DO ブロックで検査し、想定外なら RAISE する | PR2 | UPDATE は対象が 0 件でもエラーにならない。想定値は、空の DB(`prisma migrate reset`)の 0、模擬地区の無い開発 DB の 467 / 32 / 467、staging・本番の 625 / 45 / 467 | -| 件数は固定値のまま。模擬地区が一部だけの DB では RAISE し、エラー文で「全部取り込むか、全部消す」と案内する | PR2 | 本番で範囲の誤りを止める力を保つため。模擬地区だけ実際の件数に合わせる案は、範囲の誤りを見逃しやすくなるので採らなかった | -| 順位はコンテストの全行で付け、更新は問題 ID のままの行だけにする | PR2 | 後から記号付きで入る行(PR2.1)が先にあっても触らないため。順位を付ける側でも絞ると、記号の行と問題 ID の行が混ざったコンテストで A から振り直してしまう(Codex の指摘) | -| title から外すのは「自分の `task_id` + `. `」で始まるものだけ | PR2 | `^[0-9]+\. ` で外すと、番号で始まる本来の title まで削りうる | -| `AOJ-KUPC2011` の 2279 は title だけを素にし、index(`I`)は触らない | PR1 | 本番でも index は既に正しい。AOJ に 1 問しか無いので、大学系に範囲を広げるときは基本ルール(`A` になる)の対象から外す | - -分かったこと: - -- **SQL**(PR1): `row_number()` は `bigint` を返し、`chr(bigint)` は存在しないので `::int` が要る。`EXPLAIN` は `task` の Seq Scan 2 回 + Hash Join(`contest_type` に索引が無いため想定どおり) -- **トランザクション**(PR1): マイグレーションはファイル単位で原子的に適用される(3 文目が失敗するマイグレーションで、1–2 文目も残らないことをローカルで確認) -- **ローカルでの移行**(PR2): 管理画面で模擬地区を取り込み、本番と同じ状態にしてから確かめた。`SELECT` のプレビューは 625 行すべてで照合の結果と一致し、適用後もプレビューと 625 / 625 で一致した。範囲外の AOJ(PCK、大学系、PR1 の範囲)は変わらない。`EXPLAIN` は CTE 側の Seq Scan 1 回と、`task_task_id_key` の Index Scan を使う Nested Loop -- **Codex の指摘の再現**(PR2): 記号の行と問題 ID の行が混ざったコンテストを読み取り専用で再現すると、直す前の SQL は A から振り直し、直した後は B, C, D と正しく振った。ローカルは移行前のスナップショットに戻し、直した移行を適用し直した(625 / 625)。「`task_id` だけで結合している」という指摘は、`task_id` が schema で `@unique` なので該当しない -- **staging**(PR2): 当初は模擬地区が 2 コンテスト / 16 問しか無く、件数チェックで RAISE するところだった。PR1.5 の後に staging で取り込み、PR を開く前に本番と同じ状態(模擬地区 16 / 158、合宿系 50 / 467、index はすべて問題 ID、title の問題 ID は合宿系だけ)であることを確かめた。#4220 で反映した(2026-10-25)。移行の後に Vercel のデプロイが走るので、件数チェックも通っている - -## 照合(記号の対応表) - -- **PR1**: メンテナのスプレッドシートを 2026-10-21 のスクリーンショットから書き写し、632 問すべてが基本ルールの記号の位置で一致した(補正なし)。ICPC 国内予選 2002 の E(Enclosing Circles)は AOJ に 1123 として存在するが、コンテスト一覧に無いので取り込まれていない(末尾なので記号はずれない。PR3 の対象) -- **PR2**: スプレッドシートの書き写し(66 コンテスト)と 625 問の title が、記号の位置ですべて一致した。補正は 8 コンテスト / 45 行(事前の目視どおり) -- **PR2.1**: 114 問すべてが、スプレッドシートの問題名と記号の位置ごとに一致した(目視)。AOJ の 6 コンテストは問題 ID が連番で欠落が無く、基本ルールと一致する -- **照合スクリプト**: PR1 は `git show 171bf3c9:docs/dev-notes/2026-09-09/task-title-index-change-impact/generate_aoj_problem_letters.ts`、PR2 は `git show c605d3bd:docs/dev-notes/2026-09-09/task-title-index-change-impact/generate_aoj_jag_problem_letters.ts`。`prisma/tasks.ts` を import し、件数が既知の値と違えば止まる。title は大文字小文字・空白・記号を無視して比べ、セルで切れた title は前方一致にする。スプレッドシート自体の誤りは検出できない -- **スクリプトの検出力**: わざと誤りを入れたコピーを、PR1 / PR2 とも 5 種類すべて検出した。PR1 は順番の入れ替え、途中の title の誤り、途中の欠番、問題数の不足、切れた title の前半の誤り。PR2 は欠けた記号を外す、順番の入れ替え、途中の title の誤り、欠けた数の不足、切れた title の前半の誤り - -## 取り込み(管理画面・AOJ API) - -| 決定 | PR | 理由 | -| ---------------------------------------------------------- | ----- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| 取り込みでは、対応表ではなく基本ルールで記号を付ける(M5) | PR1 | 対応表を AOJ client に置く案は、毎年の新しいコンテストで追記が要り、忘れると数値の index が黙って入る。`prisma/tasks.ts` と同じ情報を 2 か所に持つことにもなる | -| 取り込みでは補正しない(M14) | PR2 | 8 コンテストは本番・staging・seed のすべてにあり、取り込みは既存の問題を skip する。Codex も「取り込みで補正すべき」と指摘したが、同じ理由で採らなかった | -| 合宿系は管理画面の取り込みに足さない | PR2.1 | 合宿系も AOJ API の JAG の filter(summer / winter / spring)にあるが、既存の contest_id(`JAGSummer2012-day3A` など)にそろえるアドホックな対応が多く、年 1 回の追加には重い。応答の形は意図的に確かめていない(メンテナの判断) | -| AOJ API はベース URL だけを変える | PR1.5 | AOJ Developers の告知(2026-09-07 に Main API が移転)で変わったのはホストだけ。メンテナが新しい API をブラウザで開き、取り込みが使う 8 つのエンドポイント(`courses`、`problems?size=10000`、`challenges` の 6 種別)のパスと応答の形が fixture と同じことを確かめた | - -分かったこと: - -- **2 日開催の JAG 模擬国内**(PR1): AOJ API には `JAGPrelim20162`(2738–2751)のように 2 日を 1 コンテストにしたものがある。取り込みは `contest_id` 単位で振るので A–N になる。seed と本番は `JAGPrelim2016A` / `B` に分かれており、取り込みは既存の問題を skip するので影響しない -- **応答の違い**(PR1.5): 値の違いは最上位の `largeCl.id` が小文字になったこと(`"JAG"` → `"jag"` など)だけ。コードはここを読まない。fixture に無い回(ICPC 国内予選 2026、JAG 模擬国内 2026、PCK 予選 2026 など)は、fixture の記録(2026-05)の後に AOJ に追加されたもの -- **ベース URL の落とし穴**(PR1.5): `new URL(endpoint, base)` は、ベースの末尾に `/` が無いか、エンドポイントの先頭に `/` があると `/api` を落とす。旧ホストはパスが無かったので表に出ていなかった -- **問題ページへのリンク**(PR1.5): `https://onlinejudge.u-aizu.ac.jp/problems/{id}` は今のまま開ける(メンテナが確認) -- **staging(Vercel)での取り込み**(PR1.5): 2026-10-24 にメンテナが JAG REGIONAL を取り込み、16 / 158 になった(PR2 の前提)。こちらの取得ツールが新しいホストに `ECONNREFUSED` で拒まれたことから送信元の制限を心配していたが、Vercel からは問題なく取得できた - -## 分類と表示 - -| 決定 | PR | 理由 | -| ------------------------------------------------------------------------------------------- | ----- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| AOJ ラベルから全角括弧を外す(M11) | PR0 | 出典欄の目標書式ではコンテスト名を括弧で囲まない。ラベルは検索・投票・タグ・取り込み画面・提出状況の更新モーダルでも使われており、その表示も変わるが許容した | -| 表示側の変換と `getTaskLabels` を削除する(M7 / M10) | PR1 | DB が記号を持てば変換は恒等になる。インターフェイスの `getTaskLabels` だけを残す案は、上書きする Provider が 0 になり使われないコードが残るので採らなかった | -| ヘッダーの並びは `localeCompare`(M4) | PR1 | 記号に `Number()` を当てると NaN になり、ソートが効かなくなる | -| `buildTitleWithTaskIndex` は「外してから付ける」 | PR1 | AtCoder 側の title は `A. …` を含むので、付けるだけだと二重になる。title にプレフィックスの無い AtCoder の問題は `abs` の practice_1 だけで(本番も同じ)、ABS は index を表示しない | -| `removeTaskIndexFromTitle` は先頭のプレフィックスだけを消す(レビュー) | PR1 | 記号は title の本文にも現れうる。以前は index が空のとき `Dr. Podboq` → `DrPodboq` のように本文の `. ` まで消していた | -| `ATCODER_JAG` を新設し、4 つを contest_id の完全一致で分類する(M17) | PR2.1 | `jag` の前方一致だと、AtCoder Problems の `JAG2013Spring` / `JAG2014Spring`(AOJ の `JAGSpring2013` / `2014` と同じコンテスト)まで入り、問題が二重になる。`OTHERS` に入れないのはメンテナの判断。`AOJ_JAG` だと出典欄の `(AOJ)` や `getTaskUrl` が AOJ を前提に動く | -| ラベル(出典欄)に(有志)を付けない | PR2.1 | PR2.5 のテーブル名でだけ、2018 Day2 / 2017 Day1 に付ける(M16) | -| 合宿系・模擬地区・AtCoder 上の JAG を 1 グループにし、テーブルはコンテストごとにする(M15) | PR2.5 | 同じ年の中の前後関係(模擬地区 2014 は夏合宿 2014 の Day3 として開催)を表せるのは 1 グループだけ。既存の「JAG 模擬国内」とは統合しない(年のループで完成しており、統合するとそれも配列に置き換えることになる) | -| 並びは明示的な配列で持つ | PR2.5 | スプレッドシートの行順(新しい順)は contest_id から導けない(冬合宿 2010 は夏合宿 2011 の後、など)。今後の合宿は seed と配列の両方に足す(配列の書き漏れは seed との整合テストで落ちる) | -| テーブル名は `getContestNameLabel` の名称に括弧書きを付け、出典欄は変えない(M16) | PR2.5 | スプレッドシートの名称(春コンテスト / 冬コンテスト / 模擬アジア地区)にすると、出典欄と食い違う。出典欄もそろえると、ラベルを使う検索・投票・タグなどと fixture に波及するので #4018 に回した。「過去問」「in AtCoder」「AMATSUKAZE2025」は括弧ごと書かない | -| Provider の `contestType` は、AtCoder 上の 4 コンテストだけ `ATCODER_JAG`、ほかは `AOJ_JAG` | PR2.5 | 計画では、PR2.1 と順序を問わないように全件 `AOJ_JAG` にしていた。PR2.1 が先に staging に入ったので、実際の分類に合わせた(メンテナの判断)。`contestType` が効くのは provider key と `getTaskTableHeaderName`(違いは ABC の H/Ex だけ)なので、表示は変わらない | -| グループのキーは `aojJagRegional` | PR2.5 | キーは localStorage(`contest_table_providers`)に保存され、後から変えると利用者の選択が消える。`aojJagPrelim` と対にし、配列のファイル・型・定数(`aoj_jag_regional_entries.ts`)とクラス(同じファイルの `JagPrelimProvider` と対の `JagRegionalProvider`)もこの名前にそろえた(メンテナの判断) | - -分かったこと: - -- **表示の回帰**(PR1): 旧コードで適用前のデータを、新コードで適用後のデータを描画し、79 コンテストすべてで列順とセルの文字列が一致した。スクリーンショットでの目視は未実施 -- **Codex のレビュー**(PR2.5): 指摘は 2 件で、上の空の枠(残タスクへ)と、plan.md の「PR2.1 は PR2.5 と前後してよい」が古いこと(直した)。Codex が確かめられなかった 2 点はこちらで確かめた。AtCoder の 4 コンテストのセルは AtCoder に飛ぶ(`getTaskUrl` が AOJ に振るのは大文字の `JAG…` だけ)。title の記号は二重にならない(`buildTitleWithTaskIndex` が先頭の `A. ` を外してから付ける) -- **DB の `contest_type`**(PR2.1): 書き込むだけで、表示などで読む箇所は無い(分類は毎回 `contest_id` から引く)。enum の値は `ADD VALUE` で DB 上の末尾に足され、schema.prisma の並びと違うが、並べ替えにも使っていない - -## seed とデータの投入 - -| 決定 | PR | 理由 | -| ------------------------------------------------------------------------------------------ | ----- | ------------------------------------------------------------------------------------------------------------------------------------------------------ | -| 前後に空白のある title(本番 46 件、3355 の末尾のタブを含む)は直さない | PR2 | 表示では空白が潰れ、完全一致で比べるコードも無い。取り込みが trim するので再発もしない([plan.md](./plan.md#スコープ外)) | -| staging / 本番の 2025 / 2026 夏合宿は、PR2 の移行の後に Supabase の CSV で記号付きで入れる | PR2.1 | 移行の前に問題 ID のまま入れると、PR2 の件数の想定値が変わる。記号付きなら移行と独立する | -| CSV の `id` は既存の合宿系と同じ `sha256(contest_id + title)` | PR2.1 | 取り込みの `sha256(contest_id + task_id)` とは違うが、`Task.id` を再計算して引く箇所が無い([根拠](./investigation.md#id-の採番規則が経路ごとに違う)) | -| seed の AOJ 72 行は古い順・問題 ID の昇順に置く | PR2.1 | add-tasks の規則(新しい順・降順)は AtCoder 側の慣習で、既存の AOJ のブロックはすべて昇順。Codex の指摘は採らなかった | - -分かったこと: - -- **seed の模擬地区**(PR2): 本番から取り出した 158 問は、AOJ の fixture(2026-05 記録)と問題 ID・コンテスト・名前がすべて一致した -- **ローカルの取り込み**(PR2.1): 2026-10-25 にメンテナが AtCoder の 4 コンテストを取り込み、42 行が task_id・contest_id・記号・title まで seed と一致した -- **staging への投入**(PR2.1): 2026-10-25 にメンテナが、管理画面から AtCoder の 4 コンテストを取り込み、2025 / 2026 夏合宿の CSV を投入した。10 コンテストの行は `ATCODER_JAG` 42 行・`AOJ_JAG` 72 行で、想定どおり - -### PR2.1 をロールバックするとき - -取り込みの後に PR2.1 より前の版へ戻すと、旧版の Prisma Client は `ATCODER_JAG` を読めない。取り込みは既存の task_id を skip するので、戻した値はロールフォワードしても残る。 - -1. ロールバックの前に、該当行を `OTHERS` に変え、返った `expected_count` を環境名とともに控える(全問を取り込んでいれば 42)。復旧が終わるまで、4 コンテストの追加・削除・分類の変更はしない - -```sql -WITH changed AS ( - UPDATE "task" - SET "contest_type" = 'OTHERS' - WHERE "contest_id" IN ('jag2018summer-day2', 'jag2017summer-day1', 'jag2015summer-day3', 'jag2013summer-warmingup') - AND "contest_type" = 'ATCODER_JAG' - RETURNING "id" -) -SELECT count(*) AS expected_count FROM changed; -``` - -2. ロールフォワードでは、PR2.1 以降をデプロイしてから、`expected_count` を控えた件数に置き換えて実行する。件数が違えば `RAISE` で UPDATE ごと取り消されるので、期待値を書き換えて通さず、記録と対象行を調べる。最後の SELECT が `ATCODER_JAG` の 1 行(件数は控えた値)だけを返せば復旧完了。控えた件数が 0 なら SELECT も 0 行になる。`OTHERS` など別の分類が残れば、復旧は終わっていない - -```sql -DO $$ -DECLARE - expected_count integer := 42; -- Replace with the recorded rollback count. - restored_count integer; -BEGIN - UPDATE "task" - SET "contest_type" = 'ATCODER_JAG' - WHERE "contest_id" IN ('jag2018summer-day2', 'jag2017summer-day1', 'jag2015summer-day3', 'jag2013summer-warmingup') - AND "contest_type" = 'OTHERS'; - - GET DIAGNOSTICS restored_count = ROW_COUNT; - IF restored_count <> expected_count THEN - RAISE EXCEPTION 'restored % rows, expected %', restored_count, expected_count; - END IF; - RAISE NOTICE 'restored % rows, expected %', restored_count, expected_count; -END $$; - --- Expected: ATCODER_JAG only, with the recorded rollback count. -SELECT "contest_type", count(*) FROM "task" -WHERE "contest_id" IN ('jag2018summer-day2', 'jag2017summer-day1', 'jag2015summer-day3', 'jag2013summer-warmingup') -GROUP BY "contest_type"; -``` - -## テスト - -| 決定 | PR | 理由 | -| ------------------------------------------------------------------------------------------- | ----- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| seed の検査は、補正した 8 コンテストを literal で、title を「問題名(`name`)と同じ」で見る | PR2 | 補正の正しさは基本ルールの関数では検査できない。title は「問題 ID が前に付いていない」だけだと、空や別の問題の title にすり替わっても通る(Codex の指摘) | -| AOJ の fixture は取り直さない | PR1.5 | 応答の形が同じため。取り直すと統計値(`solvedUser` など)や新しい回の追加で差分が出て、fixture に依存するテストの期待値まで変わる | -| AOJ のテストは nock のまま、クライアントを本番の定数から作る | PR1.5 | CI で実 API を叩かないために nock でモックするのはメンテナの意図どおりで、そのまま残す。問題は、テストが自前の旧ホストからクライアントを作っていて、定数を間違えても通ることだった | -| `nock.disableNetConnect()` を足す | PR1.5 | モックを書き忘れたリクエストが、黙って実 API に飛ぶのを防ぐ | -| 配列の contest_id を、seed の JAG 合宿系・模擬地区・AtCoder 上の JAG と突き合わせる | PR2.5 | 計画のテスト(件数・先頭と末尾・2014 の並び・括弧書き)だけでは、手書きの 76 件の id の誤り(そのテーブルが黙って表示されなくなる)を検出できない(メンテナの判断で追加) | - -分かったこと(`verify-test-strength` による変異テスト): - -- **PR1**: 15 個の変異をすべて検出した。seed 整合テストの `ICPC Prelim 2024 F is problem 1677` は単独で検出する変異が無いが、唯一の literal な期待値(対応表とデータが一緒にずれたときの歯止め)なので残した -- **PR1.5**: `AOJ_API_BASE_URL` の 4 つの変異(末尾の `/` を外す、旧ホストに戻す、`/api/v1/` にする、`/api` を外す)は、どれも 26 件すべてのテストで検出された -- **PR2**: 6 つの変異をすべて検出した(JAG の記号を PRELIM だけ / ICPC だけにする、補正した記号を基本ルールに戻す、基本ルールのコンテストで A・B を入れ替える、title に問題 ID を付け直す、模擬地区の記号を問題 ID に戻す)。title のテストは、空の title・別の問題の title・問題 ID の付け直しも検出する -- **PR2.1**: 9 つの変異をすべて検出した(seed の記号の入れ替え・行の欠落・title の不一致、map の欠落、優先度の入れ替え、`jag` / `jag2` の前方一致、4 つのキーでの前方一致、ラベルの定数化)。4 つのキーでの前方一致は当初見逃しており、Codex の指摘でテストを足した -- **PR2.5**: 11 個の変異をすべて検出した(AtCoder の 4 件を `AOJ_JAG` にする、分類の判定を反転する、括弧を半角にする、括弧書きを常に付ける、`abbreviationName` を定数にする、provider key から contest_id を外す、2014 の模擬地区を Day4 の後に動かす、配列の行の欠落・先頭の行の欠落・id の誤り・id の重複)。テストで並びを見ているのは先頭・末尾・2014 の 3 件だけなので、配列の並びと括弧書きは investigation.md の表と機械的に突き合わせ、76 件すべて一致した - -## 開発環境(dev container) - -| 決定 | PR | 理由 | -| ------------------------------------------------------ | ----- | --------------------------------------------------------------------------------------------------- | -| 許可リストの旧ホストは残さず、新しいホストに置き換える | PR1.5 | 旧ホストは 410 を返すだけで使い道が無い。許可先はデータを持ち出せる経路でもあるので、数を増やさない | - -分かったこと(ローカルの管理画面の取り込みが 500 になった原因。ブラウザにはどちらも「Failed to load resource: 500」とだけ出る): - -- **許可リスト**(PR1.5): 開発サーバーはコンテナの中で動き、外向き通信は許可リスト方式で、新しいホスト(163.143.87.177)が入っていなかった(旧ホストは 163.143.87.219)。サーバーの `fetch` が接続で拒否され、管理画面のアクションが `fail(500)` を返していた。ブラウザはホストの Mac から直接開くので見えていた。`init-firewall.sh` は Dockerfile の `COPY` でイメージに入り、起動時に 1 回だけ DNS で IP を引くので、編集を反映するにはコンテナの再起動ではなく再ビルドが要る。再ビルドの後、2026-10-24 にメンテナがローカルで AOJ から取得できることを確かめた -- **古い Prisma Client**(PR2.1): enum を足す前から動いている開発サーバーは、古い Prisma Client を持ったままで、`ATCODER_JAG` の登録が 500 になる。Vite はソースの変更を反映するが `node_modules` は読み込み直さないので、一覧には新しいコンテストが出る。開発サーバーの再起動で直る - -分かったこと(ローカルでテーブルが出ない): - -- **seed の流し直し**(PR2.5): PR2.1 より前に seed したローカル DB には 2025 / 2026 の夏合宿が無く、「JAG 模擬地区」にテーブルが出なかった。seed は既存の行を skip して足りない行だけ入れるので、`pnpm db:seed` を流し直せば入る diff --git a/docs/dev-notes/2026-09-09/task-title-index-change-impact/investigation.md b/docs/dev-notes/2026-09-09/task-title-index-change-impact/investigation.md deleted file mode 100644 index 8f124017c..000000000 --- a/docs/dev-notes/2026-09-09/task-title-index-change-impact/investigation.md +++ /dev/null @@ -1,471 +0,0 @@ -# 調査の根拠データと検討の経緯 - -[plan.md](./plan.md) と PR ごとの計画(`plan-pr*.md`)の裏付け。単独では読まず、計画から参照される想定。**計画の決定が常に優先される。** - -| 章 | 内容 | 主に使う PR | -| ----------------------------------------------- | --------------------------------------------------- | ------------------- | -| [1](#1-pr2-の照合2026-10-24) | スプレッドシートとの照合、補正、欠落、表示順 | PR2 / 2.1 / 2.5 / 3 | -| [2](#2-実測データ) | 件数、title の形、復元可能性、一意性 | PR2 | -| [3](#3-sql-の解説) | 基本ルールの SQL の読み方と計算量 | PR2 | -| [4](#4-検討の経緯採らなかった案) | 採らなかった案の理由 | — | -| [5](#5-task-テーブルへの書き込み経路の全数調査) | Task への書き込み経路(移行 UPDATE の安全性の根拠) | PR2 / PR3 | -| [6](#6-調査中の誤りと訂正自戒) | 調査中の誤り | — | - -PR0 / PR1 だけに関わる調査(出典欄の呼び出し元、テストの影響範囲など)は、コードと #4212 / #4217 の差分から読めるので削除した。判断の要約は [done.md](./done.md)。 - ---- - -# 1. PR2 の照合(2026-10-24) - -メンテナのスプレッドシート(スクリーンショット 16 枚、行 2〜1025。上が新しい)と、本番の問題一覧の JAG 模擬地区(スクリーンショット 10 枚、158 問)を目視で突き合わせた。目視の結果で、PR2 の照合スクリプト(書き写したスプレッドシートとの機械的な照合)でも同じ結果になった([done.md](./done.md#照合記号の対応表))。 - -スプレッドシートで折りたたまれた行(40–50 ほか 19 か所)は JAG 模擬国内だけで、合宿系・模擬地区は含まない(メンテナに確認済み)。 - -## 対応の確認 - -- seed の合宿系 50 コンテストは、すべてスプレッドシートにある。問題名は記号の位置ごとに一致した -- 本番の模擬地区 16 コンテスト(2005〜2017 と 2020〜2022)も、すべてある。問題 ID は 2024〜3357 で、すべて数値 -- スプレッドシートにあって DB に無いのは 10 コンテスト([下記](#db-に無いコンテスト)) - -## 記号の補正が要るコンテスト - -基本ルール(問題 ID の昇順に A, B, C…)と、スプレッドシートの位置が食い違うコンテスト。いずれも先頭か途中に、AOJ に無い問題がある。末尾だけの欠落(模擬地区 2008 の J)は記号がずれないので含めない。 - -| contest_id | 問題 ID → 記号 | 欠けている記号 | -| -------------------- | ---------------------------------------------------------- | ------------------- | -| `JAGSummer2006-day1` | 2057 → B | A, C | -| `JAGSummer2006-day3` | 2067〜2072 → B〜G | A | -| `JAGWinter2006-day2` | 2073 → D、2074 → F | A, B, C, E, G, H, I | -| `JAGWinter2006-day3` | 2075 → A、2076 → D、2077 → G、2078 → H | B, C, E, F, I, J | -| `JAGWinter2007-day3` | 2122〜2125 → A〜D、2126〜2130 → F〜J | E | -| `JAGSummer2024-day3` | 3637〜3642 → A〜F、3644〜3647 → H〜K | G | -| `JAGRegional2005` | 2024 → A、2025 → B、2026 → D、2027 → E、2028 → G、2029 → I | C, F, H | -| `JAGRegional2013` | 2568〜2574 → D〜J | A, B, C | - -8 コンテスト / 45 行(合宿系 32、模擬地区 13)。基本ルールから値が変わるのは 32 行(合宿系 21、模擬地区 11)。 - -## 欠落の一覧(PR3) - -スプレッドシートで「AOJ 未収録」と書かれている問題。22 問(合宿系 18、模擬地区 4)。 - -| コンテスト | 記号と問題名 | -| -------------------- | -------------------------------------------------------------------------------------------------------------------- | -| JAG 夏合宿 2006 Day1 | A Monkey Business、C Sort that Queue | -| JAG 夏合宿 2006 Day3 | A Rolling Cubes | -| JAG 冬合宿 2006 Day2 | A Cells、B Japan、C Maximum、E Payment System、G Roof、H Seminar Room、I String Compression | -| JAG 冬合宿 2006 Day3 | B Widget Factory、C Hills、E Safe Packing、F The "Shortest" Pair of Paths、I Counting Triangles、J Wireing Assistant | -| JAG 冬合宿 2007 Day3 | E Networking Company | -| JAG 夏合宿 2024 Day3 | G Interactive String Guessing | -| JAG 模擬地区 2005 | C Geometry?! Why Not??、F University Rankings、H Tiling Polygons | -| JAG 模擬地区 2008 | J Colony Maintenance | - -一覧に無い問題について、メンテナが AOJ で確かめた結果(2026-10-24): - -- **JAG 夏合宿 2024 Day3 の G(Interactive String Guessing)**: スプレッドシートでは「AOJ 未収録」だが、AOJ に 3643 の問題ページがある(`onlinejudge.u-aizu.ac.jp/challenges/search/titles/3643`)。コンテスト一覧から外れているだけで、ICPC 国内予選 2002 の E(AOJ 1123)と同じ種類。**スプレッドシートの「AOJ 未収録」は、AOJ に無いことの保証にならない** -- **JAG 模擬地区 2013 の A Broken Audio Signal / B Restore Calculation / C SIRO Challenge**: スプレッドシートではリンク付き(未収録の表記が無い)で、リンク先は AOJ 2565 / 2566 / 2567。ただし AOJ 側で title が見つからない。AOJ の模擬地区 2013 の一覧(本番の 7 問、2568〜2574)にも無い - -どちらも PR2 の記号(2013 は D〜J、2024 Day3 は H〜K)には影響しない。AOJ へのアクセスは、この環境からは拒否された(`judgeapi.u-aizu.ac.jp` が HTTP 410)。 - -## DB に無いコンテスト - -PR2.1 で入れる。contest_id と問題番号は 2026-10-24 にメンテナから受け取った。 - -| スプレッドシートの見出し | contest_id | 問題(task_id) | 入手元 | -| --------------------------------------------------- | ------------------------- | --------------------------------- | ------- | -| JAG 夏合宿 2026 Day1(JAG) | `JAGSummer2026-day1` | 3684〜3695 | AOJ | -| JAG 夏合宿 2026 Day2(有志)(AMATSUKAZE2025) | `JAGSummer2026-day2` | 3696〜3707 | AOJ | -| JAG 夏合宿 2026 Day3(JAG) | `JAGSummer2026-day3` | 3708〜3719 | AOJ | -| JAG 夏合宿 2025 Day1(有志) | `JAGSummer2025-day1` | 3648〜3659 | AOJ | -| JAG 夏合宿 2025 Day2(JAG) | `JAGSummer2025-day2` | 3660〜3671 | AOJ | -| JAG 夏合宿 2025 Day3(有志) | `JAGSummer2025-day3` | 3672〜3683 | AOJ | -| JAG 夏合宿 2018 Day2(有志)(in AtCoder) | `jag2018summer-day2` | `jag2018summer_day2_a`〜`k` | AtCoder | -| JAG 夏合宿 2017 Day1(有志)(in AtCoder) | `jag2017summer-day1` | `jag2017summer_day1_a`〜`k` | AtCoder | -| JAG 夏合宿 2015 Day3(過去問)(in AtCoder) | `jag2015summer-day3` | `icpc2015summer_day3_a`〜`j` | AtCoder | -| JAG 夏合宿 2013 Day2 WarmUp(過去問)(in AtCoder) | `jag2013summer-warmingup` | `icpc2013summer_warmingUp_a`〜`j` | AtCoder | - -計 114 問(AOJ 72 は各 12 問、AtCoder 42)。いずれも「AOJ 未収録」の表記は無く、受け取った問題名はスプレッドシートと記号の位置ごとに一致した(目視)。AOJ の 6 コンテストは問題 ID が連番で、記号は基本ルールと一致する。 - -AtCoder Problems の `contests.json` には、ほかに `JAG2013Spring` / `JAG2014Spring` もある。AOJ の `JAGSpring2013` / `JAGSpring2014` と同じコンテストなので対象外とする。 - -## 表示順(76 コンテスト) - -PR2.5 の配列(`aoj_jag_regional_entries.ts`)の元。スプレッドシートの行順そのまま(上が新しい)。括弧書きは M16 に従う(「過去問」「in AtCoder」「AMATSUKAZE2025」は書かない)。「DB」列の「無し」は PR2.1 で入るもの、「補正」は上の 8 コンテスト。 - -| # | 行 | contest_id | 括弧書き | DB | -| --- | ---- | ------------------------------------ | ----------------------- | ---- | -| 1 | 2 | `JAGSummer2026-day1` | JAG | 無し | -| 2 | 15 | `JAGSummer2026-day2` | 有志 | 無し | -| 3 | 28 | `JAGSummer2026-day3` | JAG | 無し | -| 4 | 51 | `JAGSummer2025-day1` | 有志 | 無し | -| 5 | 64 | `JAGSummer2025-day2` | JAG | 無し | -| 6 | 77 | `JAGSummer2025-day3` | 有志 | 無し | -| 7 | 100 | `JAGSummer2024-day2` | 有志 | | -| 8 | 115 | `JAGSummer2024-day3` | JAG | 補正 | -| 9 | 137 | `JAGSummer2023-day2` | 有志 | | -| 10 | 150 | `JAGSummer2023-day3` | JAG | | -| 11 | 171 | `JAGRegional2022` | JAG | | -| 12 | 193 | `JAGRegional2021` | JAG | | -| 13 | 214 | `JAGRegional2020` | JAG | | -| 14 | 235 | `JAGSummer2019-day1` | JAG | | -| 15 | 247 | `JAGSummer2019-day2` | 有志 | | -| 16 | 258 | `JAGSummer2019-day3` | 有志 | | -| 17 | 278 | `jag2018summer-day2`(AtCoder) | 有志 | 無し | -| 18 | 290 | `JAGSummer2018-day3` | JAG | | -| 19 | 311 | `JAGRegional2017` | JAG | | -| 20 | 323 | `jag2017summer-day1`(AtCoder) | 有志 | 無し | -| 21 | 335 | `JAGSummer2017-day3` | JAG | | -| 22 | 356 | `JAGRegional2016` | in JAG 夏合宿 2016 Day3 | | -| 23 | 384 | `JAGRegional2015` | JAG | | -| 24 | 396 | `JAGSummer2015-day2` | 有志 | | -| 25 | 408 | `jag2015summer-day3`(AtCoder) | なし | 無し | -| 26 | 419 | `JAGSummer2015-day4` | JAG | | -| 27 | 438 | `JAGSpring2015` | JAG | | -| 28 | 451 | `JAGSummer2014-day2` | 有志 | | -| 29 | 462 | `JAGRegional2014` | in JAG 夏合宿 2014 Day3 | | -| 30 | 474 | `JAGSummer2014-day4` | 有志 | | -| 31 | 493 | `JAGSpring2014` | JAG | | -| 32 | 504 | `JAGRegional2013` | JAG | 補正 | -| 33 | 515 | `jag2013summer-warmingup`(AtCoder) | なし | 無し | -| 34 | 526 | `JAGSummer2013-day2` | 有志 | | -| 35 | 534 | `JAGSummer2013-day3` | 有志 | | -| 36 | 547 | `JAGSummer2013-day4` | JAG | | -| 37 | 566 | `JAGSpring2013` | JAG | | -| 38 | 577 | `JAGRegional2012` | JAG | | -| 39 | 588 | `JAGSummer2012-day2` | 有志 | | -| 40 | 599 | `JAGSummer2012-day3A` | 有志 | | -| 41 | 606 | `JAGSummer2012-day3B` | 有志 | | -| 42 | 614 | `JAGSummer2012-day4` | JAG | | -| 43 | 633 | `JAGSpring2012` | JAG | | -| 44 | 644 | `JAGWinter2011` | JAG | | -| 45 | 655 | `JAGRegional2011` | JAG | | -| 46 | 674 | `JAGSummer2011-day2` | 有志 | | -| 47 | 685 | `JAGSummer2011-day3` | JAG | | -| 48 | 696 | `JAGSummer2011-day4` | 有志 | | -| 49 | 707 | `JAGWinter2010-day3` | 有志 | | -| 50 | 718 | `JAGWinter2010-day4` | JAG | | -| 51 | 729 | `JAGRegional2010` | JAG | | -| 52 | 740 | `JAGSummer2010-day2` | 有志 | | -| 53 | 751 | `JAGSummer2010-day3` | 有志 | | -| 54 | 762 | `JAGSummer2010-day4` | JAG | | -| 55 | 781 | `JAGWinter2009` | JAG | | -| 56 | 792 | `JAGRegional2009` | JAG | | -| 57 | 803 | `JAGSummer2009-day2` | JAG | | -| 58 | 814 | `JAGSummer2009-day3` | JAG | | -| 59 | 832 | `JAGWinter2008-day2` | JAG | | -| 60 | 844 | `JAGWinter2008-day3` | JAG | | -| 61 | 856 | `JAGRegional2008` | JAG | | -| 62 | 867 | `JAGSummer2008-day2` | JAG | | -| 63 | 878 | `JAGSummer2008-day4` | JAG | | -| 64 | 894 | `JAGWinter2007-day2` | JAG | | -| 65 | 905 | `JAGWinter2007-day3` | JAG | 補正 | -| 66 | 916 | `JAGRegional2007` | JAG | | -| 67 | 926 | `JAGSummer2007-day1` | JAG | | -| 68 | 932 | `JAGSummer2007-day2` | JAG | | -| 69 | 942 | `JAGSummer2007-day3` | JAG | | -| 70 | 957 | `JAGWinter2006-day2` | JAG | 補正 | -| 71 | 967 | `JAGWinter2006-day3` | JAG | 補正 | -| 72 | 978 | `JAGRegional2006` | JAG | | -| 73 | 988 | `JAGSummer2006-day1` | JAG | 補正 | -| 74 | 992 | `JAGSummer2006-day2` | JAG | | -| 75 | 1002 | `JAGSummer2006-day3` | JAG | 補正 | -| 76 | 1017 | `JAGRegional2005` | JAG | 補正 | - -内訳: 合宿系 50、模擬地区 16、DB に無いもの 10(AOJ 6、AtCoder 4)。 - -### 年の中での並び - -contest_id から並びを導けない例(スプレッドシートの上から順)。冬合宿・冬コンテストは、年の数字が同じ夏合宿・模擬地区より上にある。 - -- 2014: 夏合宿 Day2 → 模擬地区(夏合宿の Day3 として開催)→ 夏合宿 Day4 → 春コンテスト 2014 -- 2012: 模擬地区 2012 → 夏合宿 2012 → 春コンテスト 2012 → 冬コンテスト 2011 → 模擬地区 2011 → 夏合宿 2011 -- 2010: 冬合宿 2010 → 模擬地区 2010 → 夏合宿 2010 - -## 名称の違い - -| スプレッドシート | 現行のコード(`JAG_TRANSLATIONS`) | 対象 | -| ---------------- | ---------------------------------- | ------------------------------------------------- | -| 春コンテスト | 春合宿 | `JAGSpring2012`〜`2015` | -| 冬コンテスト | 冬合宿 | `JAGWinter2009` / `JAGWinter2011`(日の無いもの) | -| 模擬アジア地区 | 模擬地区 | `JAGRegional*` | - -テーブル名は現行のコードに合わせる(M16)。出典欄と同じ名称になり、違うのは括弧書きの有無だけになる。 - ---- - -# 2. 実測データ - -## 対象規模 - -`prisma/tasks.ts` の集計(2026-10-21)と、本番だけにある JAG 模擬地区。 - -| 種別 | コンテスト | 問題数 | 所在 | PR | -| ------------ | ---------- | ------ | ----------- | --- | -| ICPCPrelim | 29 | 197 | seed と本番 | PR1 | -| ICPCRegional | 27 | 266 | seed と本番 | PR1 | -| JAGPrelim | 23 | 169 | seed と本番 | PR1 | -| JAGSummer | 36 | 338 | seed と本番 | PR2 | -| JAGWinter | 10 | 87 | seed と本番 | PR2 | -| JAGSpring | 4 | 42 | seed と本番 | PR2 | -| JAGRegional | 16 | 158 | 本番のみ | PR2 | - -PR1 / PR2 の合計は 1,257 問 / 145 コンテスト。PR1 の前は、seed の 1,099 問すべてで `problem_index` == `task_id`(AOJ 問題 ID)だった。 - -## 本番の件数(2026-10-21) - -本番で `contest_type` ごとに集計した結果。seed と一致するのは `AOJ_ICPC` だけだった。 - -| contest_type | 本番(コンテスト / 問題) | seed(コンテスト / 問題) | -| ---------------- | ------------------------- | ------------------------- | -| `AOJ_ICPC` | 56 / 463 | 56 / 463 | -| `AOJ_JAG` | 89 / 794 | 73 / 636 | -| `AOJ_UNIVERSITY` | 27 / 30 | 27 / 29 | -| `AOJ_COURSES` | 8 / 247 | 0 / 0 | -| `AOJ_PCK` | 1 / 12 | 0 / 0 | - -`AOJ_JAG` の差(16 コンテスト / 158 問)はすべて JAG 模擬地区(管理画面から取り込める。seed には 0 件)。接頭辞ごとに分けると、ほかはすべて本番と seed で一致した。 - -AtCoder 側(`AOJ_*` 以外)で title が `${task_table_index}. ` で始まらない問題は、本番でも `abs` の practice_1 の 1 件だけだった。 - -## PR2 の範囲の本番と staging(2026-10-24) - -本番と staging で範囲の状態を集計した結果(SQL は `git show 7c68cef6:docs/dev-notes/2026-09-09/task-title-index-change-impact/plan-pr2.md` の「確かめ方」)。 - -| 範囲 | 本番 | staging | index が問題 ID のまま | title が問題 ID 付き | -| -------- | -------- | ------------------------------- | ---------------------- | -------------------- | -| Regional | 16 / 158 | 16 / 158(取り込み前は 2 / 16) | 全行 | 0 行 | -| Spring | 4 / 42 | 4 / 42 | 全行 | 全行 | -| Summer | 36 / 338 | 36 / 338 | 全行 | 全行 | -| Winter | 10 / 87 | 10 / 87 | 全行 | 全行 | - -- staging には当初、模擬地区が 2 コンテスト / 16 問しか無かった。PR1.5(#4219)の後に staging で取り込み、本番と同じ 16 / 158 にそろえた(2026-10-24) -- 本番の模擬地区 158 問(本番から取り出したもの)は、AOJ の一覧のスクリーンショットと問題名・問題 ID がすべて一致した。grade が付いているのは 6 問 -- 本番の 3355 の title の末尾にタブがある(`Most distant point from the stations\t`)。前後に空白のある title はほかにもある(下記) - -## 前後に空白のある title - -本番で `title <> btrim(title, E' \t\r\n')` の行を数えると 46 件あった(2026-10-24)。 - -| 範囲 | 件数 | 例 | -| --------------------------------------- | ---- | -------------------------------------------------------- | -| JAG 模擬国内(3202 / 3250 / 3299) | 3 | `"Chains of Explosions "`(末尾の空白) | -| JAG 模擬地区(3355) | 1 | `"Most distant point from the stations\t"`(末尾のタブ) | -| AtCoder(ABC / AGC / ARC / JOI / TTPC) | 42 | `"A. Weather Prediction "`(末尾の空白) | - -- **出どころは上流のデータ**。AOJ の API が返す名前そのものに空白がある(2026-05 に記録した fixture の `jag_prelim` / `jag_regional` にも同じ空白がある)。AtCoder Problems も同様 -- **再発はしない**。取り込みが title を `trim()` するようになった(2026-05-18、`0e1ec4e7`)。46 件はそれより前に入った行。seed の値は trim 済み -- **実害は無い**。HTML の表示では末尾の空白が潰れる。title を比べるコードは `removeTaskIndexFromTitle` の `startsWith`(先頭の `A. ` を見るだけ)しか無く、完全一致で比べる箇所は無い。検索は部分一致なので、空白で終わる検索語でなければ影響しない -- **起こりうる害**: 将来 title の完全一致で照合する処理(#4018 のコンテスト情報の同期など)を書くと、この 46 件だけが食い違う。CSV の `id` は `sha256(contest_id + title)` だが、既存行の `id` は再計算されないので関係ない - -PR2 では直さず、残タスクとした([plan.md](./plan.md#スコープ外))。 - -## 欠落は機械的に検出できない - -AOJ 問題 ID の連番性を調べると、seed の 129 コンテスト中 128 が連番。**だが欠落が無い証拠にならない。** AOJ に登録されていない問題なら ID も無いため、残った問題の ID は連番のままになる。AOJ に問題はあるのにコンテスト一覧(取り込み元)から外れている場合も同じである。ICPC 国内予選 2002 の E(Enclosing Circles)は AOJ に問題 1123 として存在するが一覧に無く、取り込まれていない。2002 年の A〜D は 1119〜1122 で連番のままであり、1123 の次の 1124 は 2003 年の A である。 - -連番でない 1 コンテストは `JAGSummer2024-day3`(3643 が無い)で、スプレッドシートの G(Interactive String Guessing)に当たる。スプレッドシートでは「AOJ 未収録」だが、AOJ に 3643 はあり、コンテスト一覧から外れているだけだった。 - -データから欠落を示唆するのは**問題数の異常**の方だが、それも決め手にならない: - -| コンテスト | 問題数 | 同種の中央値 | スプレッドシートとの照合 | -| -------------------------- | ------ | ------------ | ------------------------------------------------------------- | -| `JAGSummer2006-day1` | 1 | 10 | 公式は 3 問。A・C が AOJ 未収録 | -| `JAGWinter2006-day2` | 2 | 10 | 公式は 9 問。7 問が AOJ 未収録 | -| `JAGWinter2006-day3` | 4 | 10 | 公式は 10 問。6 問が AOJ 未収録 | -| `JAGSummer2007-day1` | 5 | 10 | 公式も 5 問(欠落なし) | -| `ICPCPrelim1998/2000/2002` | 各 4 | 8 | 1998 / 2000 は公式も 4 問。2002 は E が一覧に無い(AOJ 1123) | - -## `title` の持ち方が AOJ 内で割れている - -| 範囲 | 件数 | `title` の形 | 例 | -| ------------------------------------------ | ----- | ------------------------------- | --------------------------------- | -| ICPC国内 / アジア / JAG模擬国内(**PR1**) | 632 | 素 | `'Peculiar Protocol'` | -| JAG 合宿系(**PR2**) | 467 | **数値ID のプレフィックス付き** | `'2389. Asynchronous Exceptions'` | -| JAG 模擬地区(**PR2**、本番のみ) | 158 | 素 | `'Restriction Enzyme'` | -| AtCoder 等(対象外) | 1,366 | index のプレフィックス付き | `'EX8. たくさんのA足すB問題'` | - -**プレフィックスは合宿系の 467 件すべてで `task_id` と完全に一致していた(不一致 0 件)。** したがって除去も復元も機械的に安全に行える。 - -模擬地区は管理画面の取り込みで本番に入っており、取り込みは導入時(2024-10、`7dcc5e4c`)から `problem.name` をそのまま title にしている。範囲の集計でもプレフィックスは 0 行だった(本番・staging)。 - -退行の機序(title を直さずに index だけ記号にした場合): - -``` -現在: index='2389', title='2389. Asynchronous Exceptions' - → 先頭の '2389. ' を除去 → 'Asynchronous Exceptions' 正常 - -PR2 後: index='A', title='2389. Asynchronous Exceptions' - → 先頭の 'A. ' を除去 → 空振り - → '2389. Asynchronous Exceptions' が露出 退行 -``` - -## 復元可能性の実測 - -| 対象 | 件数 | 復元 | -| ------------------------ | ----- | ---------------------------------------------------------------------------------------- | -| AOJ ICPC/JAG(更新対象) | 1,099 | **無条件に可能**。`task_table_index == task_id` が例外ゼロで成立(seed、PR1 の前) | -| その他(AtCoder 等) | 1,367 | **条件付き**。84.2%(1,151 件)は `task_id` のサフィックスから導出できるが、216 件は例外 | - -本番にある模擬地区 158 件も、範囲の集計で全行 `task_table_index == task_id` だった(2026-10-24)。 - -導出できない 216 件の内訳: `abc` 41(`abc318_h` に対し index は `Ex`)、`typical` 32、 `APG` 27(`APG4bPython_co` に対し index は `EX8`)、`arc` 27、`tdpc` 20、 `math-and-algorithm` 15、ほか(`task_id` にサフィックスが無い AOJ 大学系 29 件を含む)。 - -`abc*_h → Ex` は規則として書けるため理論上は 90% 超まで導出できるが、 **事故対応の手段としては成立しない**。理由は 2 つ。 - -1. 「84% は規則で戻せるが 16% は個別対応」という状態は、復旧手順として使えない -2. `task_table_index` を上書きした後では、**どの行が元から `A` だったのかを区別できない** - -## 既存 DB は自動更新されない - -- seed: `prisma/seed.ts` で `findUnique(task_id)` → 既存なら **skip** -- 取り込み: `src/lib/services/tasks.ts` の `createTask` で `isExistsTask(task_id)` → 既存なら **silent return**。`classifyContest` が `null` を返す contest_id も黙って捨てる -- `updateTask` は `grade` しか触らない - -## 一意性は壊れない - -`prisma/schema.prisma` の Task で unique なのは 2 つのみ。 - -| フィールド | 由来 | -| ------------------- | ------------------------------ | -| `id` (@id @unique) | `sha256(contest_id + task.id)` | -| `task_id` (@unique) | `task.id` をそのまま | - -どちらも入力は contest_id と AOJ 問題 ID のみ。`problem_index` は混ざらない。FK も全て `task_id` 参照(`TaskTag` / `TaskAnswer` / `WorkBookTask` / `VoteGrade` / `VotedGradeCounter` / `VotedGradeStatistics`)。ハッシュの `id` を FK にしている箇所は無い。 - -**性質の変化**: `task_table_index` は「全 DB でユニーク」から**「コンテスト内でのみユニーク」**になる。unique 制約は無く、コンテスト横断で index をキーにしている箇所も無い(`sortAojHeaderIds` は単一コンテスト内で閉じる)ため問題ない。 - ---- - -# 3. SQL の解説 - -## SQL の読み方 - -**大きなクエリは内側から読み、各段階で「これは何の表を作るのか」を問う。** `WITH` 形式ならその順序がそのまま上から下になる。 - -`letters` が作るのは **`task_id` と `letter` の 2 列だけの一時的な表**。 - -| task_id | letter | -| ------- | ----------------------------------------- | -| 1100 | A | -| 1101 | B | -| 1102 | C | -| 1103 | D | -| 1672 | **A** ← コンテストが変わり番号が 1 に戻る | -| 1673 | B | - -これが分かれば `UPDATE` 側は単純で、「`task` と `letters` を `task_id` で突き合わせ、一致した行に `letter` を書き込む」だけ。 - -| 句 | 意味 | -| -------------------------------- | -------------------------------------------------------------------------- | -| `PARTITION BY contest_id` | **コンテストごとにグループ分けし、番号を 1 から振り直す** | -| `ORDER BY task_id::int` | 各グループ内で問題 ID の数値昇順に並べる | -| `row_number()` | 並べた順に 1, 2, 3… を返す(**1 始まり**、`bigint` なので `::int` が要る) | -| `chr(64 + n)` | 文字コード変換。`chr(65)` = `A` なので `n=1 → A`、`n=2 → B` | -| `contest_id ~ '^JAG(Summer\|…)'` | `AOJ_JAG` には PR1 で済んだ模擬国内も含まれるため、接頭辞で範囲を絞る | - -`PARTITION BY` が無いと対象の全問題に通し番号が振られ、2 つ目以降のコンテストが `Z` を超えて壊れる。 - -> **SQL は書かれた順に評価されない。** 論理的な処理順序は `FROM`(サブクエリと別名の確定)→ `WHERE` → `SELECT` / `SET`。このため別名を定義より前に参照しているように見えても正しい(`SELECT u.name FROM users AS u` と同じ理屈)。 - -実データでの確認: `ICPCPrelim2024`(9 問)は `1672 → A`, `1673 → B`, … `1680 → I`。 `ICPCPrelim1998` は 4 問なので `A`〜`D`。 - -## 計算量 — O(N²) にはならない - -- `WHERE` の条件は **join ではなくフィルタ**。1 行あたり O(1) で評価され、行数を減らす方向にしか働かない -- join 側も、`task_id` に UNIQUE インデックスがある(`prisma/migrations/0_init/migration.sql` の `task_task_id_key`)ため、Nested Loop + Index Scan なら O(M log N)、Hash Join なら O(N + M)。二乗にはならない -- 支配項はウィンドウ関数のソート O(K log K)(K = PR2 で 625)。実行時間はミリ秒台 - -**推測ではなく実行計画で確認すること。** PR1 では `task` の Seq Scan 2 回 + Hash Join だった(`contest_type` に索引が無いため想定どおり)。 - ---- - -# 4. 検討の経緯(採らなかった案) - -**以下はいずれも計画で却下・保留と決定済み。** 判断の理由を残すための記録。 - -## `title` に記号を入れる案を採らない理由 - -`title` を `'A. Peculiar Protocol'` に揃えれば AtCoder 側 1,366 件と形式が揃うが、採らない。 - -- **同じ情報を 2 列に持つとドリフトする。** 欠落判断の誤りを修正するとき(修正は起こる前提)、両方を更新しないと不整合になる -- **G2 に逆行する。** title にプレフィックスを埋め込む設計は、表示側に文字列の除去を要求する。除去したかった種類の変換そのもの -- **除去は脆い。** 合宿系の title の退行がその実例 - -「入れない」を選んでも AtCoder と AOJ で title の意味は揃わないが、 **どちらを選んでも不整合は残る**(AtCoder 側を触るのはスコープ外)。AOJ を「素の title」に寄せ、セルで `A. ` を付ける方が、PR1 の設計(`buildTitleWithTaskIndex`)と整合する。 - -## 管理画面案を保留にした理由 - -「管理画面から SvelteKit の action で更新する」案には実質的な利点がある。 - -- **再実行できる。** マイグレーションは適用後 immutable なので、欠落判断の誤りが後から見つかると新しいマイグレーションを積むことになる -- 欠落判断は人間が行う(M6)以上、**修正は起こる前提**であるべき - -一方で、この案は**問題記号の入力元が定義できないと成立しない**。手入力なら 1,000 回超の手作業に戻り、難所 1 で否定したものに戻る。コード上の対応表を読むなら、実質「対応表を DB に同期するボタン」になる。 - -さらに、素直に実装すると **N+1 になりやすい**。既存の `createTask` がまさにその形で(`src/routes/(admin)/tasks/+page.server.ts` の `Promise.all` + check-then-act)、トランザクションが無いため途中で失敗すると部分適用が残る。採るなら `updateMany` か `$executeRaw` で一括処理する設計が前提になる。 - -**再検討の結果(2026-10-24)**: PR2 の照合で補正が要るのは 8 コンテストと分かり、マイグレーション 1 本で足りると判断した。補正の誤りが繰り返し見つかるようなら再検討する。 - ---- - -# 5. Task テーブルへの書き込み経路の全数調査 - -リポジトリ全体(`src` / `prisma` / `e2e`)を確認した結果。移行 UPDATE の安全性の根拠。 - -## 書き込み経路は 3 つだけ、削除経路はゼロ - -| 経路 | 実装 | 書き込むフィールド | -| -------------------- | ------------------------------------------- | ------------------------------------------------------------------------------- | -| 管理画面の取り込み | `src/lib/services/tasks.ts` の `createTask` | `id` / `task_id` / `contest_id` / `contest_type` / `task_table_index` / `title` | -| 管理画面の難易度更新 | `src/lib/services/tasks.ts` の `updateTask` | `grade` のみ | -| seed | `prisma/seed.ts` | 上記 + `grade`(`id` は未指定) | - -- `updateTask` の呼び出し元は 2 つ(`src/routes/(admin)/tasks/[task_id]/+page.server.ts`、 `src/routes/(admin)/tasks/grade/+page.server.ts`)。いずれも `grade` のみ -- `task.delete` / `deleteMany` / `upsert` / `createMany` は**リポジトリ全体に存在しない** -- 生 SQL(`$executeRaw` / `$queryRaw`)による Task への書き込みもなし -- 他モデルからのネストした `task: { create: ... }` もなし - -⇒ **一意フィールドを UPDATE するコードは構造的に存在しない。** リポジトリの外では、staging / production に Supabase の CSV で行を入れることがある(既存の合宿系、PR2.1)。 - -## `id` の採番規則が経路ごとに違う - -- 取り込み: `sha256(contest_id + task.id)`(16 進)— 決定的 -- Supabase の CSV(既存の合宿系、PR2.1): `sha256(contest_id + title)`(16 進)。メンテナが別プロジェクトのスクリプトで作る(2026-10-24 にメンテナが説明) -- seed: `id` 未指定のため fabbrica の自動採番で連番文字列 - -一意性は破れないが「`Task.id` は `task_id` から決まるハッシュ」という前提は**取り込み以外では成立しない**。ただし **`Task.id` を再計算して引く箇所はゼロ**で、全ルックアップは `task_id` 経由。現状の実害はないが、将来 `Task.id` に決定性を期待するコードを書くと静かに壊れる。 - -## check-then-act の競合 - -`createTask` は `isExistsTask`(`findMany`)→ `create` の check-then-act で、 `src/routes/(admin)/tasks/+page.server.ts` がこれを `Promise.all` で並列実行している。 - -同一 `task_id` が 1 回の取り込み JSON に 2 件含まれると、両方がチェックを通過して 2 件目が P2002 で落ちる。`Promise.all` 全体が reject して 500 を返すが、 **トランザクションが無いため先に成功した分はロールバックされない**(部分適用)。 - -`prisma/seed.ts` も同じ形で PQueue concurrency 3 で並列実行しており同種のレースがある。 - -一意性は DB 制約が守るので壊れはしないが、エラー時の挙動が中途半端。 `docs/guides/agent-rules/prisma-db.md` の「Idempotent Writes」に該当し、本来は `createMany({ skipDuplicates: true })` か `upsert` が解。**本件とは独立の既存課題**(O5)。 - -## 他の sha256 決定的 ID は Task から独立 - -`src/lib/services/answers.ts` と `src/features/votes/services/vote_grade.ts` はいずれも入力が `Task.id` ではなく `task_id`。`vote_grade.ts` は全て `upsert` で `where` に複合ユニークを使うため id 衝突の経路もない。 - -## 削除経路がないことの帰結 - -Task を削除するコードが存在しないため、関連テーブルが孤児 FK を持つ事故は現状起こらない。将来 Task 削除機能を追加する場合(PR3 のダミーの扱いなど)、6 つの関連のうち `TaskTag` / `TaskAnswer` / `WorkBookTask` は `onDelete` 未指定なので要注意(投票系 3 つは `Cascade`)。 - ---- - -# 6. 調査中の誤りと訂正(自戒) - -1. **対象を 632 問と集計した** — Provider が存在する 3 種のみを数え、seed にデータがある合宿系 467 問を落としていた。正しくは 1,099 問。 _教訓: 「実装がある範囲」と「データがある範囲」は一致しない。集計条件は後者で取る。_ - -2. **「問題 ID が連番だから欠落なし、機械生成できる」と結論した** — 欠落問題は ID も採番されないため、連番性は欠落の有無について何も語らない。検証が問いに答えていなかった。 _教訓: 「観測できる範囲に異常が無い」ことを「異常が無い」と読み替えない。欠落は定義上、観測できない側にある。_ - -3. **抽出スクリプトのバグを、データの欠落として提示しかけた** — `prisma/tasks.ts` からタスクを抜き出す正規表現がシングルクォート前提だったため、 `name: "A Bug That's Not a Pill Bug"`(アポストロフィを含むためダブルクォート)を取りこぼし、`ICPCPrelim2024` が 8 問に見えた。実際は 9 問で、欠番は存在しなかった。 _教訓: 抽出スクリプトを書いたら、まず総数が既知の値と一致するかを確認する。1 と同じ「観測範囲を実際の範囲と取り違える」失敗であり、本件では欠落の誤検出に直結する。_ - -4. **移行 SQL を seed 由来の対応表から生成する案を出した** — production のタスクは管理画面の取り込み経由で入っており seed と一致する保証がないため、production にしか無い行が取り残される。しかも `UPDATE` は該当行が無くてもエラーにならない。 _教訓: 2 と同じ型。「seed で見えている範囲」を「production の実態」と取り違えた。_ - -5. **AtCoder 系の復元を「不可能」と断定した** — 実際は `task_id` のサフィックスから 83.4% が導出できる。ただし事故対応としては成立しないという結論自体は変わらない。 _教訓: 断定する前に測る。結論が同じでも、根拠が誤っていると次の判断で誤用される。_ - -6. **PR を分けた後も、移行 SQL の範囲を 1,099 件のままにしていた**(2026-10-21 に発見)— `contest_type IN ('AOJ_ICPC', 'AOJ_JAG')` は合宿系も含むため、PR1 で 467 件を余計に更新するところだった。 _教訓: 範囲を分割したら、範囲を表す条件をすべて追従させる。分類(`contest_type`)と分割の単位が一致しているとは限らない。_ - -7. **欠落した問題を「AOJ に存在しない(ID も無い)」と決めつけていた**(2026-10-21 に発見)— ICPC 国内予選 2002 の E は AOJ に問題 1123 として存在し、コンテスト一覧(取り込み元)から外れているだけだった。PR3 でダミーを作るという前提は、この問題には当てはまらない。 _教訓: 「一覧に無い」と「存在しない」は別。データの取得元の範囲を、対象が実在する範囲と取り違えない(1・4 と同じ型)。_ diff --git a/docs/dev-notes/2026-09-09/task-title-index-change-impact/plan-pr3.md b/docs/dev-notes/2026-09-09/task-title-index-change-impact/plan-pr3.md deleted file mode 100644 index 973b0bb17..000000000 --- a/docs/dev-notes/2026-09-09/task-title-index-change-impact/plan-pr3.md +++ /dev/null @@ -1,37 +0,0 @@ -# PR3 — 欠落問題の補完 - -前提と共通の決定は [plan.md](./plan.md)。欠落の一覧は [investigation.md](./investigation.md#欠落の一覧pr3)。 - -**状態**: 未着手。着手前に調査(3-0)を置く。PR1 / PR2 のデータ移行とは性質が異なる(既存データの正規化ではなく、新しいレコードの設計判断)ため独立させる。PR2 で記号を補正するので、欠落を後から足しても既存の記号はずれない。 - -## 欠落の 2 種類 - -| 種類 | 例 | 扱い | -| ---------------------------------------- | ------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------- | -| AOJ に問題はあるが、コンテスト一覧に無い | ICPC 国内予選 2002 の E = AOJ 1123(AOJ の Sources > ICPC - Prelim では 2002 年の問題) | 実在の ID で通常の問題として登録できる。管理画面の取り込みは一覧を使うので取り込めず、seed と本番への INSERT が要る | -| AOJ に問題そのものが無い | スプレッドシートで「AOJ 未収録」のもの(ただし下記の 3643 のように、AOJ にある場合もある) | ID が無いので、ダミー Task レコードの設計が要る | - -## 分かっている欠落 - -| 問題 | 分かっていること(2026-10-24 にメンテナが確認) | 種類 | -| -------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ | ---------------------------- | -| ICPC 国内予選 2002 の E | AOJ 1123。PR1 で判明 | 前者 | -| JAG 夏合宿 2024 Day3 の G(Interactive String Guessing) | スプレッドシートでは「AOJ 未収録」だが、AOJ に 3643 の問題ページがある(`onlinejudge.u-aizu.ac.jp/challenges/search/titles/3643`)。コンテスト一覧に無いだけ | 前者 | -| JAG 模擬地区 2013 の A〜C | スプレッドシートのリンク先は AOJ 2565 / 2566 / 2567。ただし AOJ 側で title が見つからない | 未確定(問題として使えるか) | -| 上記以外の「AOJ 未収録」21 問(合宿系 17 / 模擬地区 4) | 未確認 | 後者の候補 | - -3643 の例から、スプレッドシートの「AOJ 未収録」は AOJ に無いことの保証にならない。3-0 では 21 問すべてを AOJ の問題番号で確かめる。一覧は [investigation.md](./investigation.md#欠落の一覧pr3)。 - -## タスク - -| # | 内容 | -| --- | ---------------------------------------------------------------------------------------------------------------------- | -| 3-0 | 「AOJ 未収録」21 問と 2565〜2567 について、AOJ に問題番号で存在するかを確かめて上の 2 種類に分け、実数と分布を確認する | -| 3-1 | 一覧に無いだけの問題を、通常の問題として登録する(seed と本番への INSERT) | -| 3-2 | AOJ に無い問題のダミー Task レコードの設計 → 実装 | - -3-2 で決めること: - -- **`task_id` の採番**。AOJ に無い問題には ID が存在しないため、衝突しない採番規則が要る -- **提出リンクの扱い**。リンク先が存在しないので無効化するか、表示自体を変えるか -- **投票 / タグ / 問題集への登場可否**。FK が `task_id` 参照であるため、ダミーを入れると各機能に波及する diff --git a/docs/dev-notes/2026-09-09/task-title-index-change-impact/plan.md b/docs/dev-notes/2026-09-09/task-title-index-change-impact/plan.md deleted file mode 100644 index 281a0b351..000000000 --- a/docs/dev-notes/2026-09-09/task-title-index-change-impact/plan.md +++ /dev/null @@ -1,176 +0,0 @@ -# AOJ ICPC / JAG の `problem_index` を A/B/C 形式へ変更する - -**日付**: 2026-09-09 作成、2026-10-24 に PR2 以降を再構成/ **状態**: PR0・PR1・PR1.5(#4219)完了。PR2(#4220)・PR2.1(#4222)は staging に反映、本番は未反映。PR2.5 は実装済み、未マージ - -このファイルは全体像と、PR をまたぐ決定・手順だけを書く。各 PR の作業はそれぞれのファイルにある。 - -| ファイル | 内容 | -| -------------------------------------- | -------------------------------------------------- | -| [plan-pr3.md](./plan-pr3.md) | PR3: 欠落問題の補完 | -| [done.md](./done.md) | PR0〜PR2.5 の決定の要約と残っている作業 | -| [investigation.md](./investigation.md) | 根拠データ。PR2 の照合結果、表示順、欠落の一覧など | - -## ゴール - -`problem_index`(DB 上は `task_table_index`)に AOJ 問題 ID ではなく**問題記号**を持たせ、表示側が変換なしにそれを使える状態にする。 - -> **問題記号**: `A`, `B`, `C`… のように、そのコンテスト内で何番目の問題かを表す記号。AtCoder では `abc300_a` の `a` にあたる。AOJ には存在しない(難所 1)。コード上は `letter`。 - -| | 変更前 | 目標 | -| ------------------------- | ---------------------------------------- | -------------------------------------------- | -| **G1** 出典欄 | `AOJ 3467(ICPC 国内予選 2024)` | `ICPC 国内予選 2024 - F(AOJ)` | -| **G2** コンテストテーブル | 問題 ID 3467 → ソート位置から `F` を算出 | `task_table_index` の `F` を**そのまま使う** | - -## PR1 完了時点の状態 - -| 範囲(コンテスト / 問題) | `task_table_index` | `title` | 管理画面の取り込み | コンテストテーブル | -| ------------------------------------------------------ | ------------------ | --------------------------- | ------------------ | ------------------ | -| ICPC 国内予選 / アジア地区 / JAG 模擬国内(79 / 632) | 記号 | 素 | 記号を付ける | あり | -| JAG 夏 / 冬 / 春合宿(50 / 467。seed と本番) | 問題 ID | `'2389. …'`(問題 ID 付き) | 経路が無い | 無し | -| JAG 模擬地区(16 / 158。本番と staging。seed は 0 件) | 問題 ID | 素 | 問題 ID のまま | 無し | - -表示側の変換は PR1 で削除済みで、コンテストテーブルのセルは `task_table_index` から `A. タイトル` を組み立てる。出典欄の書式(G1)は AOJ 全体に入っているので、合宿系・模擬地区の出典欄は今 `JAG 夏合宿 2024 Day3 - 3644(AOJ)` のように問題 ID を出している。 - -AOJ の API は 2026-09-07 に別のホストへ移り、旧ホストは 410 を返す。PR1.5(#4219)で追従し、staging では取り込めることを確かめた。本番は `main` への昇格まで取り込めない。 - -PR2(#4220)で、合宿系と模擬地区も記号になり、合宿系の title は素になった。管理画面の取り込みも、JAG は全 round で記号を付ける(staging に反映。本番は未反映)。 - -PR2.1(#4222)で、AtCoder 上の JAG 4 コンテスト用に `ATCODER_JAG` を新設し、seed に 4 コンテストと 2025 / 2026 の夏合宿を足した(staging に反映し、データも投入した。本番は未反映)。 - -PR2.5 で、グループ「JAG 模擬地区」に 76 コンテストのテーブルを作った(実装済み、未マージ)。 - -## PR の分割と順序 - -| | 範囲 | コンテスト | 問題数 | 内容 | -| -------------------- | ------------------------------------------------------- | ---------- | ------------------------- | ---------------------------------------------------------------------------- | -| [PR1.5](./done.md) | AOJ の API クライアント | — | — | ベース URL を新しいホストへ変える(#4028)。完了(#4219) | -| [PR2](./done.md) | JAG 夏合宿 / 冬合宿 / 春合宿 + JAG 模擬地区 | 66 | 625 | 既存データを記号にする。staging に反映(#4220) | -| [PR2.1](./done.md) | 2025 / 2026 の夏合宿(AOJ)+ in AtCoder の 4 コンテスト | 10 | 114(スプレッドシート上) | 新しいデータを入れる(`ATCODER_JAG` の新設を含む)。staging に反映(#4222) | -| [PR2.5](./done.md) | PR2 / PR2.1 の 76 コンテスト | 76 | — | グループ「JAG 模擬地区」に、コンテスト別のテーブルを作る。実装済み、未マージ | -| [PR3](./plan-pr3.md) | 欠落問題の補完 | — | — | AOJ のコンテスト一覧に無い問題をどう埋めるか | - -PR1 と PR2 で、記号にするのは 1,257 問 / 145 コンテストになる。PR2 で完結し、PR3 に進まなくても機能として成立する。 - -- **PR1.5 → staging で JAG REGIONAL を取り込む → PR2** の順で進めた(済)。PR2 の件数チェックを staging で通すには、staging の模擬地区を本番と同じ 16 / 158 にそろえる必要があったため -- **PR2 → PR2.5 は守る**。Provider を先に出すと、合宿系の数値の index と `'2389. …'` の title がそのまま表に出る -- **PR2.1 は PR2 と前後してよい**。PR2 の移行 SQL は index が問題 ID のままの行しか書き換えない -- **PR2.5 のコードは PR2.1 の後に出す**。AtCoder の 4 コンテストの Provider に `ATCODER_JAG` を使い、seed との整合テストも PR2.1 の seed を前提にする(計画では PR2.1 と独立させていたが、実装時に変えた。[理由](./done.md#分類と表示))。本番の PR2.1 のデータ投入は PR2.5 の後でもよいが、投入までは「JAG 模擬地区」に 10 コンテストの見出しと空の枠が出る(`TaskTable.svelte` は、データの無い Provider でも見出しと枠を描画する。[残タスク](./done.md#残タスク)) -- **PR3 は独立**。PR2 で記号を補正するので、欠落を後から足しても既存の記号はずれない - -### スコープ外 - -次の AOJ は数値の index のまま残し、別タスクとする(本番の件数、2026-10-21)。PR1 から残っているタスクは [done.md](./done.md#残タスク)。 - -本番の title のうち 46 件は前後に空白がある(AOJ 4 件、AtCoder 42 件。[詳細](./investigation.md#前後に空白のある-title))。実害が無く再発もしないので、本件では直さず残タスクとする。直すときは、1 本の UPDATE(`btrim`、46 件の件数チェック付き)で済む。 - -| 範囲 | 本番(コンテスト / 問題) | 今の出典欄 | 残す理由 | -| ----------- | ------------------------- | ----------------------------------------------------- | ---------------------------------------------------------- | -| AOJ Courses | 8 / 247 | `プログラミング入門 - ITP1_1_A(AOJ)` のように不格好 | 問題 ID が非数値で、基本ルールの `task_id::int` が使えない | -| AOJ PCK | 1 / 12 | 数値の問題 ID のまま | 優先度が低い | -| AOJ 大学系 | 27 / 30 | `UTPC 2010 - 2207(AOJ)` | 優先度が低い。本方式をそのまま当てられる | - -大学系に広げるときは、`AOJ-KUPC2011` の 2279 を移行 SQL の対象から外す(index は既に `I` で正しく、基本ルールでは `A` になる)。 - -## 3 つの難所 - -**難所 1: 問題記号は AOJ API から取得できない。** API は「その問題がコンテストで何番目か」を返さない。ただし**基本ルール**(コンテスト内で問題 ID が若い順に `A`, `B`, `C`…)で復元できる。⇒ 対応表はスクリプトで機械生成できる。 - -**難所 2: 欠落があると基本ルールが破れる。しかも欠落はデータから検出できない。** ここでの欠落は、公式の問題が AOJ のコンテスト一覧(管理画面の取り込み元)に無いことを指す。AOJ に登録されていない問題なら ID も無いため、残った問題の ID は連番のままになる。AOJ に問題はあるのに一覧から外れている場合もある(ICPC 国内予選 2002 の E = AOJ 1123)。⇒ **コンテスト単位で外部情報(メンテナのスプレッドシート)と照合するしかない**。PR2 の範囲では、実際に 8 コンテストで先頭・途中の欠落が見つかった([照合結果](./investigation.md#記号の補正が要るコンテスト))。 - -**難所 3: 表示層で逃げられない。** 出典欄は `task_table_index` を直接読む。⇒ DB の値そのものを直す以外に手段がない。 - -## 決定事項 - -番号は PR0 からの通し番号。PR0 / PR1 で完了した M4 / M7 / M10 / M11 は [done.md](./done.md)。 - -| # | PR | 決定 | 理由 | -| --- | ----- | --------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| M1 | 全体 | DB の値そのものを A/B/C に直す | 難所 3 | -| M2 | 全体 | 既存データは移行 UPDATE で直す | seed も取り込みも「既存なら skip」で、既存レコードは自動更新されない | -| M3 | PR2 | JAG 合宿系 467 問も対象に含める | seed にデータがあり、Provider だけが無い | -| M5 | 全体 | 管理画面からの取り込みでは、基本ルールで記号を付ける | 放置すると取り込むたびに数値の index が再混入する。毎年の新しいコンテストも追記なしで記号になる | -| M6 | 全体 | 対応表は基本ルールで生成し、欠落は人間がスプレッドシートと照合する | 難所 2 | -| M8 | 全体 | 移行は Prisma のデータマイグレーションで適用する | CI の既存の仕組みに乗る | -| M9 | PR2 | 合宿系の `title` から問題 ID のプレフィックスを除く | index だけ記号にすると `removeTaskIndexFromTitle` が空振りし、`'2389. …'` が表に出る | -| M12 | PR2 | JAG 模擬地区(本番のみ)を PR2 に含め、seed にも本番から抽出して追加する | 取り込みとの整合を seed で検査でき、ローカルで移行 SQL を確かめられる。記号がずれる 2005 / 2013 も seed に入るので、ローカルで取り込んでも skip される | -| M13 | PR2 | 基本ルールで記号がずれるコンテストは、移行 SQL でコンテストの全行を上書きし、各 UPDATE の件数を DO ブロックで検査する | UPDATE は 0 件でもエラーにならないため、想定外の件数なら RAISE してマイグレーション全体を戻す | -| M14 | PR2 | 管理画面からの取り込みでは、JAG は全 round(模擬国内 / 模擬地区)で記号を付ける | M5 の拡張。2005 / 2013 の補正は取り込み側に持たない(既存行は skip される) | -| M15 | PR2.5 | 合宿系・模擬地区・in AtCoder を 1 グループ「JAG 模擬地区」にまとめ、テーブルはコンテストごとにする | 並びはスプレッドシートの行順(新しい順)。contest_id から導けないので明示的な配列で持つ | -| M16 | PR2.5 | テーブル名は `getContestNameLabel` の名称に括弧書き(`JAG` / `有志` / `in JAG 夏合宿 20xx Day3`)を付ける。出典欄は変えない | 名称は現行の `JAG_TRANSLATIONS` に合わせる。「過去問」「in AtCoder」「AMATSUKAZE2025」は括弧ごと書かない。出典欄とそろえるのは #4018(コンテスト情報の DB 化)で行う | -| M17 | PR2.1 | AtCoder 上の 4 つは `ATCODER_JAG` を新設し、AOJ の 2025 / 2026 は seed と Supabase の CSV で入れる | `ATCODER_JAG` は enum・優先度・分類ルールのいずれも `UNIVERSITY` の直前に置き、4 つを完全一致で分類する。CSV は移行の後に記号付きで入れる | -| M18 | PR2 | seed の記号の検査では、補正したコンテストだけ期待値を literal で書く | 補正していないコンテストは `assignProblemLetters` と一致することを見る | - -OPTIONAL は 1 件のみ。**O5**: `createTask` の check-then-act を upsert 化 — 本件と独立の既存課題で別ブランチ推奨([詳細](./investigation.md#check-then-act-の競合))。 - -### 却下した方針(全体) - -PR ごとの却下は各ファイルに書いた。 - -| 案 | 却下理由 | -| -------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------- | -| 対応表を `prisma/tasks.ts` に手書きし、seed だけ直す | 取り込みが数値を入れ続け、**新旧の書式が混在**する | -| 問題 ID の連番性から欠落を機械判定する | **論理的に成立しない**(難所 2) | -| seed を冪等化して production に流す | **不可**。`addUsers` / `addAtCoderAccounts` / `addWorkBooks` / `addAnswers` / `addVoteStatisticsDemoData` まで走る(`prisma/seed.ts`) | -| `title` に記号を入れて `'A. Peculiar Protocol'` に揃える | 同じ情報を 2 列に持つとドリフトする([詳細](./investigation.md#title-に記号を入れる案を採らない理由)) | -| 管理画面に同期 action を足す | **保留**。再実行できる利点はあるが、問題記号の入力元が定義できない([詳細](./investigation.md#管理画面案を保留にした理由)) | - -## データ移行の共通手順 - -PR2(記号の移行)と PR2.1(enum の追加)、PR3 に当てはまる。 - -### CI での適用 - -`.github/workflows/ci.yml` の流れ(2026-10-24 に確認): - -1. `main` / `staging` への push と、`pull_request` で起動する -2. build / lint / check / unit-test が並列に走る -3. 4 つとも通ると、`main` 以外では `preview` ジョブ、`main` では `production` ジョブが走る。どちらも `vercel build` の**後**・Vercel デプロイの**前**に `prisma migrate deploy` を流す(build が失敗すれば DB に触れない) - -| 起動したもの | ジョブ | 適用先 | -| ----------------------------------------------------------- | ------------ | --------------------------------------- | -| PR の CI(ref は `refs/pull/N/merge`)、`staging` への push | `preview` | `PREVIEW_DATABASE_URL`(staging の DB) | -| `main` への push | `production` | `PRODUCTION_DATABASE_URL` | - -- **PR の CI でも staging の DB に適用される**。PR を開いた瞬間ではなく、その PR の CI で 4 ジョブが通った後だが、**staging へのマージより前**である。`preview` ジョブの条件は `github.ref != 'refs/heads/main'` だけで、PR の ref も当たる。実際、#4217 の `pull_request` の実行でも `preview` ジョブが成功している(ログはログインしないと見られないので、`migrate deploy` の出力までは確かめていない)。staging へマージしたときにも走るが、適用済みなので何もしない -- このため SQL の確認は PR を開く前にローカルで済ませる。PR に push し直すたびに CI が走るが、適用済みのマイグレーションは編集しても再適用されない -- `main` へ昇格した時点で production に適用されるため、**staging での確認が自然にゲートになる** -- 本件の範囲外だが、`preview` ジョブの「Assign staging domain」の手順は、コメントに「if staging branch」とあるのに条件は `github.ref != 'refs/heads/main'` である。PR のデプロイにも staging のドメインが付け替わる - -### 手順 - -1. `pnpm exec prisma migrate dev --create-only --name ` -2. SQL を `migration.sql` に書く -3. **ローカル DB で `UPDATE` を `SELECT` に置き換えて「現在値 → 変更後」を確認する** -4. ローカルで `pnpm exec prisma migrate dev` を実行して適用し、表示を確認する -5. PR を開く → staging へマージして確認 → `main` へ昇格 - -手順 3–4 は**必ず PR を開く前に**済ませる。PR を開いた時点で preview DB に適用されうるうえ、適用済みのマイグレーションは編集しても再適用されない。PR を開いた後に SQL の誤りが見つかったら、新しいマイグレーションを積む。 - -手順 3 により **SQL の正しさを構文から判断する必要がなくなる**。全件を見る必要はないが、**コンテストの切り替わり目(記号が `A` に戻る箇所)**と**問題数の少ない古いコンテスト**は目視する価値がある。 - -**staging が防げないもの**: **対応表・欠落判断そのものの誤り**。誤った記号でも SQL は正常に完走し、staging では「それらしい表示」になる(難所 2)。 - -### 事故対策 - -| 観点 | 内容 | -| ------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| **爆発半径** | 一括で復元できるのは AOJ ICPC / JAG だけ(`task_table_index == task_id` が成り立っていたため)。WHERE 句が壊れて AtCoder 系に波及すると**一括復元の手段が無くなる**([実測](./investigation.md#復元可能性の実測)) | -| **復元** | 一度目の適用に限り、`task_table_index` も `title` のプレフィックスも `task_id` から再構成できる | -| **バックアップ** | Supabase Pro のため PITR が利用可。復元トリックが効くのは一度目だけなので、**再修正時は PITR が唯一の手段** | -| **`updated_at`** | `@updatedAt` は Prisma Client 側の実装で DB トリガーではない。生 SQL では更新されない。**意図的に含めない** | -| **キャッシュ** | TTL 1 時間(`src/lib/clients/cache.ts`)。生 SQL と CSV の投入は `invalidateTaskCaches()` を経由しない。「更新したのに表示が変わらない」ときはこれを疑う | -| **トランザクション境界** | マイグレーションファイル単位で原子的に適用される([done.md](./done.md#移行-sql)) | -| **デプロイ直前の数分** | マイグレーションの適用から Vercel のデプロイ完了までの間は、旧コードが新しいデータを描画する。数分で解消するので**許容する** | - -### スプレッドシートとの照合 - -対応表は次の手順で作る(M6)。PR1 のスクリプトを直して使う(仕組みは [done.md](./done.md#照合記号の対応表))。 - -1. 基本ルールで対象範囲の記号を生成する -2. コンテスト単位で、メンテナのスプレッドシートと照合する ← 人手が要るのはここだけ(書き写し) -3. 先頭・途中に欠落があるコンテストは、スプレッドシートの位置から記号を決める(末尾の欠落は記号がずれないので補正不要) -4. 欠落した問題が AOJ に問題番号で存在するかを確かめ、PR3 に引き継ぐ - -> **注意**: 「これまで正しく見えていた」は「欠落が無い」の証明ではない。欠落があっても表示は成立してしまう(前詰めでずれた記号が出るだけ)。 - -**スクリプトには総数のアサーションを入れる。** 抽出漏れが欠落の誤検出に直結する(調査中に実際に踏んだ。[事例](./investigation.md#6-調査中の誤りと訂正自戒))。抽出件数が対象範囲の既知の値と一致しなければエラーで止め、欠落として扱わない。 diff --git a/docs/guides/how-to-add-contest-table-provider.md b/docs/guides/how-to-add-contest-table-provider.md index 68fa3f2f7..a0b071fce 100644 --- a/docs/guides/how-to-add-contest-table-provider.md +++ b/docs/guides/how-to-add-contest-table-provider.md @@ -93,6 +93,7 @@ AOJ/JAG 固有の詳細(`titleStyle`、同一年2回開催の `suffix` 分割 - **title**: AtCoder は `A. ` 付き、AOJ は素。セル(`buildTitleWithTaskIndex`)がどちらにも記号を付け直すので、Provider で title を加工しない - **データ**: AOJ の合宿系は管理画面から取り込めないので、seed と Supabase の CSV で記号付きで入れる。AtCoder 側は管理画面から取り込む - **記号**: AOJ は問題 ID の昇順で A, B, C… と振るが、問題が AOJ に無いコンテストではずれる。新しい回はメンテナのスプレッドシートと照合する +- **AOJ に無い問題**: `NoJudge-{contest_id}_{小文字の記号}` の task_id でダミーを足し、`no_judge.ts` にリンク先(jag-icpc.org のページ)を登録し、コンテストを `LETTERS_OF_SHIFTED_CONTESTS` に入れる。出典欄は `(No Judge)` になる ### 補足: 固定セクションによる同一 ContestType 内の共存 diff --git a/prisma/tasks.ts b/prisma/tasks.ts index 8dc228599..001f211ee 100755 --- a/prisma/tasks.ts +++ b/prisma/tasks.ts @@ -10419,6 +10419,13 @@ export const tasks = [ name: 'What is the Number in my Mind ?', title: 'What is the Number in my Mind ?', }, + { + id: '1123', + contest_id: 'ICPCPrelim2002', + problem_index: 'E', + name: 'Enclosing Circles', + title: 'Enclosing Circles', + }, { id: '1124', contest_id: 'ICPCPrelim2003', @@ -14731,6 +14738,27 @@ export const tasks = [ name: 'Light The Room', title: 'Light The Room', }, + { + id: 'NoJudge-JAGRegional2005_c', + contest_id: 'JAGRegional2005', + problem_index: 'C', + name: 'Geometry?! Why Not??', + title: 'Geometry?! Why Not??', + }, + { + id: 'NoJudge-JAGRegional2005_f', + contest_id: 'JAGRegional2005', + problem_index: 'F', + name: 'University Rankings', + title: 'University Rankings', + }, + { + id: 'NoJudge-JAGRegional2005_h', + contest_id: 'JAGRegional2005', + problem_index: 'H', + name: 'Tiling Polygons', + title: 'Tiling Polygons', + }, { id: '2030', contest_id: 'JAGRegional2006', @@ -14920,6 +14948,13 @@ export const tasks = [ name: "Cousin's Aunt", title: "Cousin's Aunt", }, + { + id: 'NoJudge-JAGRegional2008_j', + contest_id: 'JAGRegional2008', + problem_index: 'J', + name: 'Colony Maintenance', + title: 'Colony Maintenance', + }, { id: '2175', contest_id: 'JAGRegional2009', @@ -15250,6 +15285,27 @@ export const tasks = [ name: 'Magical Switches', title: 'Magical Switches', }, + { + id: '2565', + contest_id: 'JAGRegional2013', + problem_index: 'A', + name: 'Broken Audio Signal', + title: 'Broken Audio Signal', + }, + { + id: '2566', + contest_id: 'JAGRegional2013', + problem_index: 'B', + name: 'Restore Calculation', + title: 'Restore Calculation', + }, + { + id: '2567', + contest_id: 'JAGRegional2013', + problem_index: 'C', + name: 'SIRO Challenge', + title: 'SIRO Challenge', + }, { id: '2589', contest_id: 'JAGRegional2014', @@ -16102,6 +16158,20 @@ export const tasks = [ name: 'The Closest Circle', title: 'The Closest Circle', }, + { + id: 'NoJudge-JAGSummer2006-day1_a', + contest_id: 'JAGSummer2006-day1', + problem_index: 'A', + name: 'Monkey Business', + title: 'Monkey Business', + }, + { + id: 'NoJudge-JAGSummer2006-day1_c', + contest_id: 'JAGSummer2006-day1', + problem_index: 'C', + name: 'Sort that Queue', + title: 'Sort that Queue', + }, { id: '2058', contest_id: 'JAGSummer2006-day2', @@ -16207,6 +16277,13 @@ export const tasks = [ name: "Philosopher's Stone", title: "Philosopher's Stone", }, + { + id: 'NoJudge-JAGSummer2006-day3_a', + contest_id: 'JAGSummer2006-day3', + problem_index: 'A', + name: 'Rolling Cubes', + title: 'Rolling Cubes', + }, { id: '2079', contest_id: 'JAGSummer2007-day1', @@ -18461,6 +18538,13 @@ export const tasks = [ name: 'Equal or Not Equal', title: 'Equal or Not Equal', }, + { + id: '3643', + contest_id: 'JAGSummer2024-day3', + problem_index: 'G', + name: 'Interactive String Guessing', + title: 'Interactive String Guessing', + }, { id: '3648', contest_id: 'JAGSummer2025-day1', @@ -18979,6 +19063,55 @@ export const tasks = [ name: 'Rakunarok', title: 'Rakunarok', }, + { + id: 'NoJudge-JAGWinter2006-day2_a', + contest_id: 'JAGWinter2006-day2', + problem_index: 'A', + name: 'Cells', + title: 'Cells', + }, + { + id: 'NoJudge-JAGWinter2006-day2_b', + contest_id: 'JAGWinter2006-day2', + problem_index: 'B', + name: 'Japan', + title: 'Japan', + }, + { + id: 'NoJudge-JAGWinter2006-day2_c', + contest_id: 'JAGWinter2006-day2', + problem_index: 'C', + name: 'Maximum', + title: 'Maximum', + }, + { + id: 'NoJudge-JAGWinter2006-day2_e', + contest_id: 'JAGWinter2006-day2', + problem_index: 'E', + name: 'Payment System', + title: 'Payment System', + }, + { + id: 'NoJudge-JAGWinter2006-day2_g', + contest_id: 'JAGWinter2006-day2', + problem_index: 'G', + name: 'Roof', + title: 'Roof', + }, + { + id: 'NoJudge-JAGWinter2006-day2_h', + contest_id: 'JAGWinter2006-day2', + problem_index: 'H', + name: 'Seminar Room', + title: 'Seminar Room', + }, + { + id: 'NoJudge-JAGWinter2006-day2_i', + contest_id: 'JAGWinter2006-day2', + problem_index: 'I', + name: 'String Compression', + title: 'String Compression', + }, { id: '2075', contest_id: 'JAGWinter2006-day3', @@ -19007,6 +19140,48 @@ export const tasks = [ name: 'Exact Arithmetic', title: 'Exact Arithmetic', }, + { + id: 'NoJudge-JAGWinter2006-day3_b', + contest_id: 'JAGWinter2006-day3', + problem_index: 'B', + name: 'Widget Factory', + title: 'Widget Factory', + }, + { + id: 'NoJudge-JAGWinter2006-day3_c', + contest_id: 'JAGWinter2006-day3', + problem_index: 'C', + name: 'Hills', + title: 'Hills', + }, + { + id: 'NoJudge-JAGWinter2006-day3_e', + contest_id: 'JAGWinter2006-day3', + problem_index: 'E', + name: 'Safe Packing', + title: 'Safe Packing', + }, + { + id: 'NoJudge-JAGWinter2006-day3_f', + contest_id: 'JAGWinter2006-day3', + problem_index: 'F', + name: 'The "Shortest" Pair of Paths', + title: 'The "Shortest" Pair of Paths', + }, + { + id: 'NoJudge-JAGWinter2006-day3_i', + contest_id: 'JAGWinter2006-day3', + problem_index: 'I', + name: 'Counting Triangles', + title: 'Counting Triangles', + }, + { + id: 'NoJudge-JAGWinter2006-day3_j', + contest_id: 'JAGWinter2006-day3', + problem_index: 'J', + name: 'Wireing Assistant', + title: 'Wireing Assistant', + }, { id: '2112', contest_id: 'JAGWinter2007-day2', @@ -19140,6 +19315,13 @@ export const tasks = [ name: 'Billion Million Thousand', title: 'Billion Million Thousand', }, + { + id: 'NoJudge-JAGWinter2007-day3_e', + contest_id: 'JAGWinter2007-day3', + problem_index: 'E', + name: 'Networking Company', + title: 'Networking Company', + }, { id: '2457', contest_id: 'JAGWinter2008-day2', diff --git a/src/features/workbooks/components/detail/WorkBookTasksTable.svelte b/src/features/workbooks/components/detail/WorkBookTasksTable.svelte index e79245e40..655555eab 100644 --- a/src/features/workbooks/components/detail/WorkBookTasksTable.svelte +++ b/src/features/workbooks/components/detail/WorkBookTasksTable.svelte @@ -202,6 +202,7 @@ {addContestNameToTaskIndex( task.contestId, getTaskTableIndex(tasksMapByIds, task.taskId), + task.taskId, )} diff --git a/src/lib/clients/aizu_online_judge/seed_problem_letters.test.ts b/src/lib/clients/aizu_online_judge/seed_problem_letters.test.ts index a98c7eb03..fec5dd654 100644 --- a/src/lib/clients/aizu_online_judge/seed_problem_letters.test.ts +++ b/src/lib/clients/aizu_online_judge/seed_problem_letters.test.ts @@ -1,6 +1,7 @@ import { describe, test, expect } from 'vitest'; import { ContestType } from '#lib/contests/types/contest.js'; +import { isNoJudgeTask } from '#lib/contests/index.js'; import { classifyContest } from '#lib/contests/utils/classification.js'; import { tasks } from '../../../../prisma/tasks'; @@ -15,9 +16,15 @@ function usesProblemLetters(contestId: string): boolean { } // Problems missing from AOJ shift the basic rule here, so letters follow the maintainer's problem list. +// NoJudge ids cannot be sorted with Number(), so contests with NoJudge tasks must be listed here. const LETTERS_OF_SHIFTED_CONTESTS: Record> = { - 'JAGSummer2006-day1': { '2057': 'B' }, + 'JAGSummer2006-day1': { + 'NoJudge-JAGSummer2006-day1_a': 'A', + '2057': 'B', + 'NoJudge-JAGSummer2006-day1_c': 'C', + }, 'JAGSummer2006-day3': { + 'NoJudge-JAGSummer2006-day3_a': 'A', '2067': 'B', '2068': 'C', '2069': 'D', @@ -25,47 +32,63 @@ const LETTERS_OF_SHIFTED_CONTESTS: Record> = { '2071': 'F', '2072': 'G', }, - 'JAGWinter2006-day2': { '2073': 'D', '2074': 'F' }, - 'JAGWinter2006-day3': { '2075': 'A', '2076': 'D', '2077': 'G', '2078': 'H' }, + 'JAGWinter2006-day2': { + 'NoJudge-JAGWinter2006-day2_a': 'A', + 'NoJudge-JAGWinter2006-day2_b': 'B', + 'NoJudge-JAGWinter2006-day2_c': 'C', + '2073': 'D', + 'NoJudge-JAGWinter2006-day2_e': 'E', + '2074': 'F', + 'NoJudge-JAGWinter2006-day2_g': 'G', + 'NoJudge-JAGWinter2006-day2_h': 'H', + 'NoJudge-JAGWinter2006-day2_i': 'I', + }, + 'JAGWinter2006-day3': { + '2075': 'A', + 'NoJudge-JAGWinter2006-day3_b': 'B', + 'NoJudge-JAGWinter2006-day3_c': 'C', + '2076': 'D', + 'NoJudge-JAGWinter2006-day3_e': 'E', + 'NoJudge-JAGWinter2006-day3_f': 'F', + '2077': 'G', + '2078': 'H', + 'NoJudge-JAGWinter2006-day3_i': 'I', + 'NoJudge-JAGWinter2006-day3_j': 'J', + }, 'JAGWinter2007-day3': { '2122': 'A', '2123': 'B', '2124': 'C', '2125': 'D', + 'NoJudge-JAGWinter2007-day3_e': 'E', '2126': 'F', '2127': 'G', '2128': 'H', '2129': 'I', '2130': 'J', }, - 'JAGSummer2024-day3': { - '3637': 'A', - '3638': 'B', - '3639': 'C', - '3640': 'D', - '3641': 'E', - '3642': 'F', - '3644': 'H', - '3645': 'I', - '3646': 'J', - '3647': 'K', - }, JAGRegional2005: { '2024': 'A', '2025': 'B', + 'NoJudge-JAGRegional2005_c': 'C', '2026': 'D', '2027': 'E', + 'NoJudge-JAGRegional2005_f': 'F', '2028': 'G', + 'NoJudge-JAGRegional2005_h': 'H', '2029': 'I', }, - JAGRegional2013: { - '2568': 'D', - '2569': 'E', - '2570': 'F', - '2571': 'G', - '2572': 'H', - '2573': 'I', - '2574': 'J', + JAGRegional2008: { + '2048': 'A', + '2049': 'B', + '2050': 'C', + '2051': 'D', + '2052': 'E', + '2053': 'F', + '2054': 'G', + '2055': 'H', + '2056': 'I', + 'NoJudge-JAGRegional2008_j': 'J', }, }; @@ -89,14 +112,14 @@ describe('problem_index of AOJ ICPC / JAG tasks in prisma/tasks.ts', () => { const indicesByContest = groupProblemIndicesByContest(); // Guards against the filter silently matching fewer tasks. - test('covers 151 contests and 1329 tasks', () => { + test('covers 151 contests and 1355 tasks', () => { const taskCount = [...indicesByContest.values()].reduce( (sum, indices) => sum + indices.size, 0, ); expect(indicesByContest.size).toBe(151); - expect(taskCount).toBe(1329); + expect(taskCount).toBe(1355); }); test.each( @@ -112,6 +135,16 @@ describe('problem_index of AOJ ICPC / JAG tasks in prisma/tasks.ts', () => { }, ); + test('every contest with NoJudge tasks is in LETTERS_OF_SHIFTED_CONTESTS', () => { + const contestIds = tasks + .filter((task) => isNoJudgeTask(task.id)) + .map((task) => task.contest_id); + + expect(contestIds.filter((contestId) => !(contestId in LETTERS_OF_SHIFTED_CONTESTS))).toEqual( + [], + ); + }); + test('ICPC Prelim 2024 F is problem 1677', () => { expect(indicesByContest.get('ICPCPrelim2024')?.get('1677')).toBe('F'); }); diff --git a/src/lib/components/TaskForm.svelte b/src/lib/components/TaskForm.svelte index 5fee48d96..bfbe15458 100644 --- a/src/lib/components/TaskForm.svelte +++ b/src/lib/components/TaskForm.svelte @@ -48,7 +48,7 @@ 出典 - {addContestNameToTaskIndex(task.contest_id, task.task_table_index)} + {addContestNameToTaskIndex(task.contest_id, task.task_table_index, task.task_id)} diff --git a/src/lib/components/TaskList.svelte b/src/lib/components/TaskList.svelte index 79d5d4ea7..992d5b103 100644 --- a/src/lib/components/TaskList.svelte +++ b/src/lib/components/TaskList.svelte @@ -121,7 +121,11 @@ diff --git a/src/lib/components/TaskListSorted.svelte b/src/lib/components/TaskListSorted.svelte index 1c257b500..e0463bd29 100644 --- a/src/lib/components/TaskListSorted.svelte +++ b/src/lib/components/TaskListSorted.svelte @@ -52,7 +52,11 @@ - {addContestNameToTaskIndex(taskResult.contest_id, taskResult.task_table_index)} + {addContestNameToTaskIndex( + taskResult.contest_id, + taskResult.task_table_index, + taskResult.task_id, + )} {taskResult.updated_at.toLocaleString()} diff --git a/src/lib/contests/fixtures/contest_name_and_task_index.ts b/src/lib/contests/fixtures/contest_name_and_task_index.ts index 1dcbfd0a2..1ba64e3c1 100644 --- a/src/lib/contests/fixtures/contest_name_and_task_index.ts +++ b/src/lib/contests/fixtures/contest_name_and_task_index.ts @@ -9,6 +9,8 @@ import { export type TestCaseForContestNameAndTaskIndex = { contestId: string; taskTableIndex: string; + // Only NoJudge cases set it; the label otherwise depends on contestId alone. + taskId?: string; expected: string; }; @@ -994,3 +996,19 @@ export const aojUniversity = AOJ_UNIVERSITY_TEST_DATA.map( expected, }), ); + +// NoJudge tasks share AOJ contest ids but have no judge, so the label ends with (No Judge). +export const noJudge = [ + createTestCaseForContestNameAndTaskIndex('NoJudge, JAGWinter2006-day2 - A')({ + contestId: 'JAGWinter2006-day2', + taskTableIndex: 'A', + taskId: 'NoJudge-JAGWinter2006-day2_a', + expected: 'JAG 冬合宿 2006 Day2 - A(No Judge)', + }), + createTestCaseForContestNameAndTaskIndex('NoJudge, JAGRegional2005 - C')({ + contestId: 'JAGRegional2005', + taskTableIndex: 'C', + taskId: 'NoJudge-JAGRegional2005_c', + expected: 'JAG 模擬地区 2005 - C(No Judge)', + }), +]; diff --git a/src/lib/contests/index.ts b/src/lib/contests/index.ts index bcedf6b37..2837eb4dc 100644 --- a/src/lib/contests/index.ts +++ b/src/lib/contests/index.ts @@ -12,6 +12,9 @@ export { getJoiContestLabel } from './utils/labels/joi'; export { getAtCoderUniversityContestLabel } from './utils/labels/universities'; export { getAojContestLabel } from './utils/labels/aoj'; +// No Judge tasks (missing from AOJ) +export { isNoJudgeTask, NO_JUDGE_TASK_URLS } from './utils/no_judge'; + // Task index label export { addContestNameToTaskIndex } from './utils/task_index_label'; diff --git a/src/lib/contests/utils/no_judge.test.ts b/src/lib/contests/utils/no_judge.test.ts new file mode 100644 index 000000000..ed62d1d3f --- /dev/null +++ b/src/lib/contests/utils/no_judge.test.ts @@ -0,0 +1,92 @@ +import { describe, test, expect } from 'vitest'; + +import { tasks } from '../../../../prisma/tasks'; + +import { isNoJudgeTask, NO_JUDGE_TASK_URLS } from './no_judge'; + +describe('isNoJudgeTask', () => { + test('returns true for task ids starting with NoJudge-', () => { + expect(isNoJudgeTask('NoJudge-JAGWinter2006-day2_a')).toBe(true); + }); + + test('returns false for AOJ and AtCoder task ids', () => { + expect(isNoJudgeTask('1123')).toBe(false); + expect(isNoJudgeTask('abc300_a')).toBe(false); + expect(isNoJudgeTask('nojudge-JAGWinter2006-day2_a')).toBe(false); + }); +}); + +describe('NO_JUDGE_TASK_URLS', () => { + // Expected pages are literals grouped by event, so a task moved to the wrong page fails. + const expectedPages: [string, string[]][] = [ + [ + 'https://jag-icpc.org/?2006%2FPractice%2F%E5%A4%8F%E5%90%88%E5%AE%BF', + [ + 'NoJudge-JAGSummer2006-day1_a', + 'NoJudge-JAGSummer2006-day1_c', + 'NoJudge-JAGSummer2006-day3_a', + ], + ], + [ + 'https://jag-icpc.org/?2006%2FPractice%2F%E5%86%AC%E5%90%88%E5%AE%BF', + [ + 'NoJudge-JAGWinter2006-day2_a', + 'NoJudge-JAGWinter2006-day2_b', + 'NoJudge-JAGWinter2006-day2_c', + 'NoJudge-JAGWinter2006-day2_e', + 'NoJudge-JAGWinter2006-day2_g', + 'NoJudge-JAGWinter2006-day2_h', + 'NoJudge-JAGWinter2006-day2_i', + 'NoJudge-JAGWinter2006-day3_b', + 'NoJudge-JAGWinter2006-day3_c', + 'NoJudge-JAGWinter2006-day3_e', + 'NoJudge-JAGWinter2006-day3_f', + 'NoJudge-JAGWinter2006-day3_i', + 'NoJudge-JAGWinter2006-day3_j', + ], + ], + [ + 'https://jag-icpc.org/?2007%2FPractice%2F%E5%86%AC%E5%90%88%E5%AE%BF', + ['NoJudge-JAGWinter2007-day3_e'], + ], + [ + 'https://jag-icpc.org/?2005%2FPractice%2F%E6%A8%A1%E6%93%AC%E5%9C%B0%E5%8C%BA%E4%BA%88%E9%81%B8', + ['NoJudge-JAGRegional2005_c', 'NoJudge-JAGRegional2005_f', 'NoJudge-JAGRegional2005_h'], + ], + [ + 'https://jag-icpc.org/?2008%2FPractice%2F%E6%A8%A1%E6%93%AC%E5%9C%B0%E5%8C%BA%E4%BA%88%E9%81%B8', + ['NoJudge-JAGRegional2008_j'], + ], + ]; + + test.each(expectedPages.flatMap(([url, taskIds]) => taskIds.map((taskId) => [taskId, url])))( + 'maps %s to its jag-icpc.org page url verbatim', + (taskId, url) => { + expect(NO_JUDGE_TASK_URLS.get(taskId)).toBe(url); + }, + ); + + test('has exactly the 21 expected NoJudge tasks', () => { + expect(expectedPages.flatMap(([, taskIds]) => taskIds)).toHaveLength(21); + expect(NO_JUDGE_TASK_URLS.size).toBe(21); + }); + + test('has no entry for AOJ task ids', () => { + expect(NO_JUDGE_TASK_URLS.has('1123')).toBe(false); + }); + + test('every NoJudge task in prisma/tasks.ts has a page url', () => { + const missing = tasks + .filter((task) => isNoJudgeTask(task.id) && !NO_JUDGE_TASK_URLS.has(task.id)) + .map((task) => task.id); + + expect(missing).toEqual([]); + }); + + test('every task id with a page url exists in prisma/tasks.ts', () => { + const seededIds = new Set(tasks.map((task) => task.id)); + const unknown = [...NO_JUDGE_TASK_URLS.keys()].filter((taskId) => !seededIds.has(taskId)); + + expect(unknown).toEqual([]); + }); +}); diff --git a/src/lib/contests/utils/no_judge.ts b/src/lib/contests/utils/no_judge.ts new file mode 100644 index 000000000..fe5df8d6d --- /dev/null +++ b/src/lib/contests/utils/no_judge.ts @@ -0,0 +1,58 @@ +export const NO_JUDGE_TASK_ID_PREFIX = 'NoJudge-'; + +export function isNoJudgeTask(taskId: string): boolean { + return taskId.startsWith(NO_JUDGE_TASK_ID_PREFIX); +} + +type NoJudgePage = { url: string; taskIds: string[] }; + +// Tasks missing from AOJ link to the jag-icpc.org page of their event, where the statements, judge data and reviews live. +// Urls are pasted as opened in a browser, percent-encoded. +const NO_JUDGE_PAGES: NoJudgePage[] = [ + { + url: 'https://jag-icpc.org/?2006%2FPractice%2F%E5%A4%8F%E5%90%88%E5%AE%BF', + taskIds: [ + 'NoJudge-JAGSummer2006-day1_a', + 'NoJudge-JAGSummer2006-day1_c', + 'NoJudge-JAGSummer2006-day3_a', + ], + }, + { + url: 'https://jag-icpc.org/?2006%2FPractice%2F%E5%86%AC%E5%90%88%E5%AE%BF', + taskIds: [ + 'NoJudge-JAGWinter2006-day2_a', + 'NoJudge-JAGWinter2006-day2_b', + 'NoJudge-JAGWinter2006-day2_c', + 'NoJudge-JAGWinter2006-day2_e', + 'NoJudge-JAGWinter2006-day2_g', + 'NoJudge-JAGWinter2006-day2_h', + 'NoJudge-JAGWinter2006-day2_i', + 'NoJudge-JAGWinter2006-day3_b', + 'NoJudge-JAGWinter2006-day3_c', + 'NoJudge-JAGWinter2006-day3_e', + 'NoJudge-JAGWinter2006-day3_f', + 'NoJudge-JAGWinter2006-day3_i', + 'NoJudge-JAGWinter2006-day3_j', + ], + }, + { + url: 'https://jag-icpc.org/?2007%2FPractice%2F%E5%86%AC%E5%90%88%E5%AE%BF', + taskIds: ['NoJudge-JAGWinter2007-day3_e'], + }, + { + url: 'https://jag-icpc.org/?2005%2FPractice%2F%E6%A8%A1%E6%93%AC%E5%9C%B0%E5%8C%BA%E4%BA%88%E9%81%B8', + taskIds: [ + 'NoJudge-JAGRegional2005_c', + 'NoJudge-JAGRegional2005_f', + 'NoJudge-JAGRegional2005_h', + ], + }, + { + url: 'https://jag-icpc.org/?2008%2FPractice%2F%E6%A8%A1%E6%93%AC%E5%9C%B0%E5%8C%BA%E4%BA%88%E9%81%B8', + taskIds: ['NoJudge-JAGRegional2008_j'], + }, +]; + +export const NO_JUDGE_TASK_URLS: ReadonlyMap = new Map( + NO_JUDGE_PAGES.flatMap(({ url, taskIds }) => taskIds.map((taskId) => [taskId, url] as const)), +); diff --git a/src/lib/contests/utils/task_index_label.test.ts b/src/lib/contests/utils/task_index_label.test.ts index cf2244eb8..d07777fc7 100644 --- a/src/lib/contests/utils/task_index_label.test.ts +++ b/src/lib/contests/utils/task_index_label.test.ts @@ -5,6 +5,9 @@ import * as TestCasesForContestNameAndTaskIndex from '#lib/contests/fixtures/con import { type TestCaseForContestNameAndTaskIndex } from '#lib/contests/fixtures/contest_name_and_task_index.js'; import { addContestNameToTaskIndex } from '#lib/contests/utils/task_index_label.js'; +// Any id that is not a NoJudge task. +const ORDINARY_TASK_ID = '1'; + describe('add contest name to task index', () => { describe('AtCoder', () => { describe('when contest_id contains abc', () => { @@ -12,8 +15,10 @@ describe('add contest name to task index', () => { runTests( `${name}`, [value], - ({ contestId, taskTableIndex, expected }: TestCaseForContestNameAndTaskIndex) => { - expect(addContestNameToTaskIndex(contestId, taskTableIndex)).toEqual(expected); + ({ contestId, taskTableIndex, taskId, expected }: TestCaseForContestNameAndTaskIndex) => { + expect( + addContestNameToTaskIndex(contestId, taskTableIndex, taskId ?? ORDINARY_TASK_ID), + ).toEqual(expected); }, ); }); @@ -24,8 +29,10 @@ describe('add contest name to task index', () => { runTests( `${name}`, [value], - ({ contestId, taskTableIndex, expected }: TestCaseForContestNameAndTaskIndex) => { - expect(addContestNameToTaskIndex(contestId, taskTableIndex)).toEqual(expected); + ({ contestId, taskTableIndex, taskId, expected }: TestCaseForContestNameAndTaskIndex) => { + expect( + addContestNameToTaskIndex(contestId, taskTableIndex, taskId ?? ORDINARY_TASK_ID), + ).toEqual(expected); }, ); }); @@ -36,8 +43,10 @@ describe('add contest name to task index', () => { runTests( `${name}`, [value], - ({ contestId, taskTableIndex, expected }: TestCaseForContestNameAndTaskIndex) => { - expect(addContestNameToTaskIndex(contestId, taskTableIndex)).toEqual(expected); + ({ contestId, taskTableIndex, taskId, expected }: TestCaseForContestNameAndTaskIndex) => { + expect( + addContestNameToTaskIndex(contestId, taskTableIndex, taskId ?? ORDINARY_TASK_ID), + ).toEqual(expected); }, ); }); @@ -48,8 +57,10 @@ describe('add contest name to task index', () => { runTests( `${name}`, [value], - ({ contestId, taskTableIndex, expected }: TestCaseForContestNameAndTaskIndex) => { - expect(addContestNameToTaskIndex(contestId, taskTableIndex)).toEqual(expected); + ({ contestId, taskTableIndex, taskId, expected }: TestCaseForContestNameAndTaskIndex) => { + expect( + addContestNameToTaskIndex(contestId, taskTableIndex, taskId ?? ORDINARY_TASK_ID), + ).toEqual(expected); }, ); }); @@ -60,8 +71,10 @@ describe('add contest name to task index', () => { runTests( `${name}`, [value], - ({ contestId, taskTableIndex, expected }: TestCaseForContestNameAndTaskIndex) => { - expect(addContestNameToTaskIndex(contestId, taskTableIndex)).toEqual(expected); + ({ contestId, taskTableIndex, taskId, expected }: TestCaseForContestNameAndTaskIndex) => { + expect( + addContestNameToTaskIndex(contestId, taskTableIndex, taskId ?? ORDINARY_TASK_ID), + ).toEqual(expected); }, ); }); @@ -72,8 +85,10 @@ describe('add contest name to task index', () => { runTests( `${name}`, [value], - ({ contestId, taskTableIndex, expected }: TestCaseForContestNameAndTaskIndex) => { - expect(addContestNameToTaskIndex(contestId, taskTableIndex)).toEqual(expected); + ({ contestId, taskTableIndex, taskId, expected }: TestCaseForContestNameAndTaskIndex) => { + expect( + addContestNameToTaskIndex(contestId, taskTableIndex, taskId ?? ORDINARY_TASK_ID), + ).toEqual(expected); }, ); }); @@ -84,8 +99,10 @@ describe('add contest name to task index', () => { runTests( `${name}`, [value], - ({ contestId, taskTableIndex, expected }: TestCaseForContestNameAndTaskIndex) => { - expect(addContestNameToTaskIndex(contestId, taskTableIndex)).toEqual(expected); + ({ contestId, taskTableIndex, taskId, expected }: TestCaseForContestNameAndTaskIndex) => { + expect( + addContestNameToTaskIndex(contestId, taskTableIndex, taskId ?? ORDINARY_TASK_ID), + ).toEqual(expected); }, ); }); @@ -96,8 +113,10 @@ describe('add contest name to task index', () => { runTests( `${name}`, [value], - ({ contestId, taskTableIndex, expected }: TestCaseForContestNameAndTaskIndex) => { - expect(addContestNameToTaskIndex(contestId, taskTableIndex)).toEqual(expected); + ({ contestId, taskTableIndex, taskId, expected }: TestCaseForContestNameAndTaskIndex) => { + expect( + addContestNameToTaskIndex(contestId, taskTableIndex, taskId ?? ORDINARY_TASK_ID), + ).toEqual(expected); }, ); }); @@ -108,8 +127,10 @@ describe('add contest name to task index', () => { runTests( `${name}`, [value], - ({ contestId, taskTableIndex, expected }: TestCaseForContestNameAndTaskIndex) => { - expect(addContestNameToTaskIndex(contestId, taskTableIndex)).toEqual(expected); + ({ contestId, taskTableIndex, taskId, expected }: TestCaseForContestNameAndTaskIndex) => { + expect( + addContestNameToTaskIndex(contestId, taskTableIndex, taskId ?? ORDINARY_TASK_ID), + ).toEqual(expected); }, ); }); @@ -120,8 +141,10 @@ describe('add contest name to task index', () => { runTests( `${name}`, [value], - ({ contestId, taskTableIndex, expected }: TestCaseForContestNameAndTaskIndex) => { - expect(addContestNameToTaskIndex(contestId, taskTableIndex)).toEqual(expected); + ({ contestId, taskTableIndex, taskId, expected }: TestCaseForContestNameAndTaskIndex) => { + expect( + addContestNameToTaskIndex(contestId, taskTableIndex, taskId ?? ORDINARY_TASK_ID), + ).toEqual(expected); }, ); }); @@ -132,8 +155,10 @@ describe('add contest name to task index', () => { runTests( `${name}`, [value], - ({ contestId, taskTableIndex, expected }: TestCaseForContestNameAndTaskIndex) => { - expect(addContestNameToTaskIndex(contestId, taskTableIndex)).toEqual(expected); + ({ contestId, taskTableIndex, taskId, expected }: TestCaseForContestNameAndTaskIndex) => { + expect( + addContestNameToTaskIndex(contestId, taskTableIndex, taskId ?? ORDINARY_TASK_ID), + ).toEqual(expected); }, ); }); @@ -146,8 +171,10 @@ describe('add contest name to task index', () => { runTests( `${name}`, [value], - ({ contestId, taskTableIndex, expected }: TestCaseForContestNameAndTaskIndex) => { - expect(addContestNameToTaskIndex(contestId, taskTableIndex)).toEqual(expected); + ({ contestId, taskTableIndex, taskId, expected }: TestCaseForContestNameAndTaskIndex) => { + expect( + addContestNameToTaskIndex(contestId, taskTableIndex, taskId ?? ORDINARY_TASK_ID), + ).toEqual(expected); }, ); }); @@ -158,8 +185,10 @@ describe('add contest name to task index', () => { runTests( `${name}`, [value], - ({ contestId, taskTableIndex, expected }: TestCaseForContestNameAndTaskIndex) => { - expect(addContestNameToTaskIndex(contestId, taskTableIndex)).toEqual(expected); + ({ contestId, taskTableIndex, taskId, expected }: TestCaseForContestNameAndTaskIndex) => { + expect( + addContestNameToTaskIndex(contestId, taskTableIndex, taskId ?? ORDINARY_TASK_ID), + ).toEqual(expected); }, ); }); @@ -170,8 +199,10 @@ describe('add contest name to task index', () => { runTests( `${name}`, [value], - ({ contestId, taskTableIndex, expected }: TestCaseForContestNameAndTaskIndex) => { - expect(addContestNameToTaskIndex(contestId, taskTableIndex)).toEqual(expected); + ({ contestId, taskTableIndex, taskId, expected }: TestCaseForContestNameAndTaskIndex) => { + expect( + addContestNameToTaskIndex(contestId, taskTableIndex, taskId ?? ORDINARY_TASK_ID), + ).toEqual(expected); }, ); }); @@ -181,7 +212,9 @@ describe('add contest name to task index', () => { test.each(['JAGSummer-day2', 'JAGPrelim', 'JAGRegional-day1'])( 'does not produce AOJ format for %s', (contestId) => { - expect(addContestNameToTaskIndex(contestId, '1')).not.toMatch(/(AOJ)$/); + expect(addContestNameToTaskIndex(contestId, '1', ORDINARY_TASK_ID)).not.toMatch( + /(AOJ)$/, + ); }, ); }); @@ -191,8 +224,10 @@ describe('add contest name to task index', () => { runTests( `${name}`, [value], - ({ contestId, taskTableIndex, expected }: TestCaseForContestNameAndTaskIndex) => { - expect(addContestNameToTaskIndex(contestId, taskTableIndex)).toEqual(expected); + ({ contestId, taskTableIndex, taskId, expected }: TestCaseForContestNameAndTaskIndex) => { + expect( + addContestNameToTaskIndex(contestId, taskTableIndex, taskId ?? ORDINARY_TASK_ID), + ).toEqual(expected); }, ); }); @@ -203,11 +238,27 @@ describe('add contest name to task index', () => { runTests( `${name}`, [value], - ({ contestId, taskTableIndex, expected }: TestCaseForContestNameAndTaskIndex) => { - expect(addContestNameToTaskIndex(contestId, taskTableIndex)).toEqual(expected); + ({ contestId, taskTableIndex, taskId, expected }: TestCaseForContestNameAndTaskIndex) => { + expect( + addContestNameToTaskIndex(contestId, taskTableIndex, taskId ?? ORDINARY_TASK_ID), + ).toEqual(expected); }, ); }); }); }); + + describe('when task_id means a task missing from AOJ', () => { + TestCasesForContestNameAndTaskIndex.noJudge.forEach(({ name, value }) => { + runTests( + `appends (No Judge) for a NoJudge task in a JAG contest: ${name}`, + [value], + ({ contestId, taskTableIndex, taskId, expected }: TestCaseForContestNameAndTaskIndex) => { + expect(addContestNameToTaskIndex(contestId, taskTableIndex, taskId ?? '')).toEqual( + expected, + ); + }, + ); + }); + }); }); diff --git a/src/lib/contests/utils/task_index_label.ts b/src/lib/contests/utils/task_index_label.ts index 1eb26ddd7..ab326ecef 100644 --- a/src/lib/contests/utils/task_index_label.ts +++ b/src/lib/contests/utils/task_index_label.ts @@ -1,14 +1,20 @@ import { regexForJag, regexForAojUniversity, aojCoursePrefixes } from './prefixes'; import { getContestNameLabel } from './labels/index'; +import { isNoJudgeTask } from './no_judge'; -export const addContestNameToTaskIndex = (contestId: string, taskTableIndex: string): string => { - const contestName = getContestNameLabel(contestId); +export const addContestNameToTaskIndex = ( + contestId: string, + taskTableIndex: string, + taskId: string, +): string => { + const label = `${getContestNameLabel(contestId)} - ${taskTableIndex}`; - if (isAojContest(contestId)) { - return `${contestName} - ${taskTableIndex}(AOJ)`; + // No Judge tasks share AOJ contest ids but have no judge, so check the task id first. + if (isNoJudgeTask(taskId)) { + return `${label}(No Judge)`; } - return `${contestName} - ${taskTableIndex}`; + return isAojContest(contestId) ? `${label}(AOJ)` : label; }; function isAojContest(contestId: string): boolean { diff --git a/src/lib/types/url.ts b/src/lib/types/url.ts index 8c72b21de..c53ed243a 100644 --- a/src/lib/types/url.ts +++ b/src/lib/types/url.ts @@ -5,7 +5,8 @@ * * @method canHandle * @param {string} contestId - The ID of the contest. - * @returns {boolean} - Returns true if the generator can handle the given contest ID, otherwise false. + * @param {string} taskId - The ID of the task within the contest. + * @returns {boolean} - Returns true if the generator can handle the given contest and task IDs, otherwise false. * * @method generateUrl * @param {string} contestId - The ID of the contest. @@ -13,7 +14,7 @@ * @returns {string} - Returns the generated URL for the given contest and task IDs. */ export interface UrlGenerator { - canHandle(contestId: string): boolean; + canHandle(contestId: string, taskId: string): boolean; generateUrl(contestId: string, taskId: string): string; } diff --git a/src/lib/utils/task.ts b/src/lib/utils/task.ts index 298714a79..f5e8b6f31 100644 --- a/src/lib/utils/task.ts +++ b/src/lib/utils/task.ts @@ -7,6 +7,8 @@ import { type WorkBookTaskBase } from '#features/workbooks/types/workbook.js'; import { ATCODER_BASE_CONTEST_URL, AOJ_TASKS_URL } from '#lib/constants/urls.js'; import { getPrefixForAojCourses, + isNoJudgeTask, + NO_JUDGE_TASK_URLS, getContestPriority, regexForAojUniversity, regexForJag, @@ -21,7 +23,7 @@ import { * @returns The generated URL or empty string if no suitable generator is found */ export const getTaskUrl = (contestId: string, taskId: string): string => { - const generators = urlGenerators.find((generator) => generator.canHandle(contestId)); + const generators = urlGenerators.find((generator) => generator.canHandle(contestId, taskId)); return generators?.generateUrl(contestId, taskId) ?? ''; }; @@ -53,9 +55,25 @@ class AojGenerator implements UrlGenerator { } } +class NoJudgeGenerator implements UrlGenerator { + canHandle(_: string, taskId: string): boolean { + return isNoJudgeTask(taskId); + } + + // Empty on a missing entry so the task never falls through to a dead AOJ link. + generateUrl(_: string, taskId: string): string { + return NO_JUDGE_TASK_URLS.get(taskId) ?? ''; + } +} + // Note: +// NoJudge must precede AOJ: its tasks share JAG contest ids. // Default generator last -const urlGenerators: UrlGenerators = [new AojGenerator(), new AtCoderGenerator()]; +const urlGenerators: UrlGenerators = [ + new NoJudgeGenerator(), + new AojGenerator(), + new AtCoderGenerator(), +]; export const countAcceptedTasks = (taskResults: TaskResults) => { const acceptedResults = taskResults.filter((taskResult: TaskResult) => taskResult.is_ac); diff --git a/src/routes/(admin)/tasks/grade/_components/TaskGradeTable.svelte b/src/routes/(admin)/tasks/grade/_components/TaskGradeTable.svelte index 7e2f4b7ab..00ddd438c 100644 --- a/src/routes/(admin)/tasks/grade/_components/TaskGradeTable.svelte +++ b/src/routes/(admin)/tasks/grade/_components/TaskGradeTable.svelte @@ -103,7 +103,7 @@ - {addContestNameToTaskIndex(task.contest_id, task.task_table_index)} + {addContestNameToTaskIndex(task.contest_id, task.task_table_index, task.task_id)} {@render adminGradeCell(task)} diff --git a/src/routes/workbooks/[slug]/+page.svelte b/src/routes/workbooks/[slug]/+page.svelte index 7e86dc037..dd6b36fd4 100644 --- a/src/routes/workbooks/[slug]/+page.svelte +++ b/src/routes/workbooks/[slug]/+page.svelte @@ -49,7 +49,7 @@ const contestId = getContestIdFrom(taskId); const taskTableIndex = getTaskTableIndex(taskId); - return addContestNameToTaskIndex(contestId, taskTableIndex); + return addContestNameToTaskIndex(contestId, taskTableIndex, taskId); }; const getTaskName = (taskId: string): string => { diff --git a/src/test/lib/utils/task.test.ts b/src/test/lib/utils/task.test.ts index b872b3d7d..e6922e874 100644 --- a/src/test/lib/utils/task.test.ts +++ b/src/test/lib/utils/task.test.ts @@ -108,6 +108,14 @@ describe('Task', () => { }); }); + describe('when a NoJudge task and an AOJ task share a JAG contest id', () => { + TestCasesForTaskUrl.noJudgeJag.forEach(({ name, value }) => { + runTests(`${name}`, [value], ({ contestId, taskId, expected }: TestCaseForTaskUrl) => { + expect(getTaskUrl(contestId, taskId)).toBe(expected); + }); + }); + }); + describe('when contest ids and task ids for AOJ ICPC (Prelim and Regional) are given', () => { TestCasesForTaskUrl.aojIcpc.forEach(({ name, value }) => { runTests(`${name}`, [value], ({ contestId, taskId, expected }: TestCaseForTaskUrl) => { diff --git a/src/test/lib/utils/test_cases/task_url.ts b/src/test/lib/utils/test_cases/task_url.ts index f6d227792..78d0dcd54 100644 --- a/src/test/lib/utils/test_cases/task_url.ts +++ b/src/test/lib/utils/test_cases/task_url.ts @@ -191,6 +191,20 @@ export const aojJag = jagContests.flatMap((jag) => }), ); +// NoJudge tasks share JAG contest ids with AOJ tasks; the task id decides the url. +export const noJudgeJag = [ + createTestCaseForTaskUrl('NoJudge, JAGWinter2006-day2 task A')({ + contestId: 'JAGWinter2006-day2', + taskId: 'NoJudge-JAGWinter2006-day2_a', + expected: 'https://jag-icpc.org/?2006%2FPractice%2F%E5%86%AC%E5%90%88%E5%AE%BF', + }), + createTestCaseForTaskUrl('AOJ, JAGWinter2006-day2 task D')({ + contestId: 'JAGWinter2006-day2', + taskId: '2073', + expected: `${AOJ_TASKS_URL}/2073`, + }), +]; + // ICPC contests follow these patterns: // - Contest ID format: ICPC(Prelim|Regional) const icpcContests = [