From 05eaa37b545323aba2c6a87dde488a5512f5fef9 Mon Sep 17 00:00:00 2001 From: "k.hiro1818" Date: Sun, 31 May 2026 13:24:22 +0000 Subject: [PATCH 1/5] feat(contest): add AOJ_UNIVERSITY contest type for university programming contests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds support for RUPC/HUPC/UAPC-style university contests hosted on AOJ, classified via regex /^AOJ-[A-Z]+PC\d{4}/ with priority 25 and localized labels (e.g. "(ACPC 2019 in RUPC 2019 Day2)"). Extends AojGenerator.canHandle so getTaskUrl routes these contests to the correct AOJ problem URL. Co-Authored-By: Claude Sonnet 4.6 --- .../2026-05-31/add-aoj-university/plan.md | 320 ++++++++++++++++++ .../migration.sql | 2 + prisma/schema.prisma | 1 + prisma/tasks.ts | 21 ++ src/lib/types/contest.ts | 1 + src/lib/utils/contest.ts | 28 +- src/lib/utils/task.ts | 5 +- src/test/lib/utils/contest.test.ts | 24 ++ src/test/lib/utils/task.test.ts | 8 + .../utils/test_cases/contest_name_labels.ts | 27 ++ src/test/lib/utils/test_cases/contest_type.ts | 16 + src/test/lib/utils/test_cases/task_url.ts | 17 + 12 files changed, 465 insertions(+), 5 deletions(-) create mode 100644 docs/dev-notes/2026-05-31/add-aoj-university/plan.md create mode 100644 prisma/migrations/20260531130053_add_aoj_university_to_contest_type/migration.sql diff --git a/docs/dev-notes/2026-05-31/add-aoj-university/plan.md b/docs/dev-notes/2026-05-31/add-aoj-university/plan.md new file mode 100644 index 000000000..aaf17c763 --- /dev/null +++ b/docs/dev-notes/2026-05-31/add-aoj-university/plan.md @@ -0,0 +1,320 @@ +# AOJ_UNIVERSITY ContestType 追加 + +## 概要 + +Issue #3598。RUPC / HUPC / UAPC 系の大学対抗プログラミングコンテスト(AOJ ホスト)を新しい ContestType `AOJ_UNIVERSITY` として分類・ラベル表示できるようにする。 + +## 設計方針 + +### 対象 contest_id パターン + +正規表現 `/^AOJ-[A-Z]+PC\d{4}/` でマッチする ID: +- `AOJ-RUPC2018-in-ACPC2018-day1` +- `AOJ-HUPC2020-in-HUPC2020-day1` +- `AOJ-UAPC2019-in-RUPC2019-day2` +- `AOJ-UAPC2003`(年のみ) +- `AOJ-UAPC2011-summer`(season 付き) +- `AOJ-UAPC2012-day1`(day 付き) +- など + +既存 ContestType(`AOJ_JAG` = `JAGPrelim...` など)は `AOJ-` プレフィックスを持たないため衝突しない。 + +### ラベル変換方針 + +`getContestNameLabel` が返す文字列(**JAG 同様、`()` で囲む**): + +| 入力 | 出力 | +|---|---| +| `AOJ-RUPC2018-in-ACPC2018-day1` | `(RUPC 2018 in ACPC 2018 Day1)` | +| `AOJ-HUPC2020-in-HUPC2020-day1` | `(HUPC 2020 in HUPC 2020 Day1)` | +| `AOJ-UAPC2019-in-RUPC2019-day2` | `(ACPC 2019 in RUPC 2019 Day2)` | +| `AOJ-UAPC2003` | `(ACPC 2003)` | +| `AOJ-UAPC2011-summer` | `(ACPC 2011 Summer)` | +| `AOJ-UAPC2012-day1` | `(ACPC 2012 Day1)` | + +ルール:contest_id 中の `UAPC` はラベルでは **`ACPC`** に変換する(contest_id の分類 regex は変更しない)。 + +`addContestNameToTaskIndex` の出力例(`()` 付きで正しく表示される): +- `AOJ 3058(ACPC 2019 in RUPC 2019 Day2)` + +変換ロジック: + +```typescript +function getAojUniversityContestLabel(contestId: string): string { + const label = contestId + .replace(/^AOJ-/, '') + .replace(/UAPC/g, 'ACPC') // UAPC → ACPC(ラベル表示上の変換) + .replace(/([A-Z]{2,})(\d{4})/g, '$1 $2') // 全箇所の大文字列+4桁年にスペース挿入 + .replace(/-in-/, ' in ') + .replace(/-day(\d+)/, ' Day$1') + .replace(/-summer/, ' Summer'); + return '(' + label + ')'; +} +``` + +JAG_TRANSLATIONS(文字列辞書方式)と異なり、埋め込みコンテスト名も含め全箇所の年にスペースを入れるため regex 方式を採用。`()` 付与は `getAojContestLabel` と同じ規則に合わせる。 + +### priority + +`AOJ_JAG`(priority 24)の末尾に追加 → **25**。既存値シフトなし。 + +### isAojContest + +`regexForAojUniversity` を追加し、`addContestNameToTaskIndex` が AOJ 形式(`AOJ {index}(label)`)を使えるようにする。 + +### getTaskUrl の AOJ_UNIVERSITY 対応 + +`src/lib/utils/task.ts` の `AojGenerator.canHandle` が `AOJ-[A-Z]+PC\d{4}` パターンを未処理のため、AtCoder URL にフォールスルーしてしまう。`regexForAojUniversity` を `contest.ts` から export し、`AojGenerator.canHandle` で使用する。 + +AOJ University の問題 URL は他の AOJ コンテストと同形式: +``` +${AOJ_TASKS_URL}/${taskId} // 例: https://judge.u-aizu.ac.jp/onlinejudge/description.jsp?id=3058 +``` + +### スコープ + +| レイヤー | 内容 | 今回 | +|---|---|---| +| Layer 1 | prisma/schema.prisma | ✅ | +| Layer 2 | ContestType 定数 | ✅ | +| Layer 3 | classifyContest / contestTypePriorities / getContestNameLabel / isAojContest + テスト | ✅ | +| Layer 3b | getTaskUrl の AOJ_UNIVERSITY 対応(AojGenerator.canHandle + テスト)| ✅ | +| Layer 4 | Provider クラス(TDD) | ❌ 別 PR | +| Layer 5 | Group 登録 | ❌ 別 PR | +| seed データ | tasks.ts への全件追加 | ❌ 別 PR | + +--- + +## 実装タスク + +### Phase 1: Layer 1–2(Prisma スキーマ + 型定数) + +**ファイル**: `prisma/schema.prisma` + +`AOJ_JAG` 直後に追加: + +```prisma +AOJ_UNIVERSITY // University Programming Contest (RUPC, HUPC, UAPC) +``` + +```bash +pnpm exec prisma generate +pnpm check # src/lib/types/contest.ts で型エラーが出ることを確認 +``` + +**ファイル**: `src/lib/types/contest.ts` + +`AOJ_JAG` 直後に追加: + +```typescript +AOJ_UNIVERSITY: 'AOJ_UNIVERSITY', // University Programming Contest (RUPC, HUPC, UAPC) +``` + +```bash +pnpm check # エラー解消を確認 +``` + +### Phase 2: Layer 3(TDD) + +#### テスト先行(RED) + +**`src/test/lib/utils/test_cases/contest_type.ts`** — `aojIcpc` の直後に追加: + +```typescript +const aojUniversityContestData = [ + { name: 'AOJ, RUPC 2018 in ACPC 2018 Day1', contestId: 'AOJ-RUPC2018-in-ACPC2018-day1' }, + { name: 'AOJ, HUPC 2020 in HUPC 2020 Day1', contestId: 'AOJ-HUPC2020-in-HUPC2020-day1' }, + { name: 'AOJ, UAPC 2019 in RUPC 2019 Day2', contestId: 'AOJ-UAPC2019-in-RUPC2019-day2' }, + { name: 'AOJ, UAPC 2003', contestId: 'AOJ-UAPC2003' }, + { name: 'AOJ, UAPC 2011 Summer', contestId: 'AOJ-UAPC2011-summer' }, + { name: 'AOJ, UAPC 2012 Day1', contestId: 'AOJ-UAPC2012-day1' }, +]; + +export const aojUniversity = aojUniversityContestData.map(({ name, contestId }) => + createTestCaseForContestType(name)({ + contestId, + expected: ContestType.AOJ_UNIVERSITY, + }), +); +``` + +**`src/test/lib/utils/test_cases/contest_name_labels.ts`** — `aojIcpc` の直後に追加(`expected` は `()` 込み): + +```typescript +export const aojUniversity = [ + createTestCaseForContestNameLabel('AOJ, RUPC 2018 in ACPC 2018 Day1')({ + contestId: 'AOJ-RUPC2018-in-ACPC2018-day1', + expected: '(RUPC 2018 in ACPC 2018 Day1)', + }), + createTestCaseForContestNameLabel('AOJ, HUPC 2020 in HUPC 2020 Day1')({ + contestId: 'AOJ-HUPC2020-in-HUPC2020-day1', + expected: '(HUPC 2020 in HUPC 2020 Day1)', + }), + createTestCaseForContestNameLabel('AOJ, UAPC 2019 in RUPC 2019 Day2')({ + contestId: 'AOJ-UAPC2019-in-RUPC2019-day2', + expected: '(ACPC 2019 in RUPC 2019 Day2)', + }), + createTestCaseForContestNameLabel('AOJ, UAPC 2003')({ + contestId: 'AOJ-UAPC2003', + expected: '(ACPC 2003)', + }), + createTestCaseForContestNameLabel('AOJ, UAPC 2011 Summer')({ + contestId: 'AOJ-UAPC2011-summer', + expected: '(ACPC 2011 Summer)', + }), + createTestCaseForContestNameLabel('AOJ, UAPC 2012 Day1')({ + contestId: 'AOJ-UAPC2012-day1', + expected: '(ACPC 2012 Day1)', + }), +]; +``` + +**`src/test/lib/utils/contest.test.ts`** — `describe('AOJ', ...)` ブロック内の 3 箇所(classify / priority / label)に `when contest_id means AOJ University (RUPC, HUPC, UAPC)` describe を追加。 + +```bash +pnpm test:unit src/test/lib/utils/contest.test.ts # RED 確認 +``` + +#### 実装(GREEN) + +**`src/lib/utils/contest.ts`** に以下を追加・変更: + +1. ファイル先頭(`regexForJag` 直後)— **export に変更**: + ```typescript + export const regexForAojUniversity = /^AOJ-[A-Z]+PC\d{4}/; + ``` + +2. `classifyContest` — `regexForJag` ブランチの直後: + ```typescript + if (regexForAojUniversity.test(contest_id)) { + return ContestType.AOJ_UNIVERSITY; + } + ``` + +3. `contestTypePriorities` — `[ContestType.AOJ_JAG, 24]` の直後: + ```typescript + [ContestType.AOJ_UNIVERSITY, 25], + ``` + JSDoc の数値範囲コメントも更新。 + +4. 新関数(`JAG_TRANSLATIONS` の近くに追加、`()` 付き): + ```typescript + function getAojUniversityContestLabel(contestId: string): string { + const label = contestId + .replace(/^AOJ-/, '') + .replace(/UAPC/g, 'ACPC') + .replace(/([A-Z]{2,})(\d{4})/g, '$1 $2') + .replace(/-in-/, ' in ') + .replace(/-day(\d+)/, ' Day$1') + .replace(/-summer/, ' Summer'); + return '(' + label + ')'; + } + ``` + +5. `getContestNameLabel` — `regexForJag` ブランチの直後: + ```typescript + if (regexForAojUniversity.test(contestId)) { + return getAojUniversityContestLabel(contestId); + } + ``` + +6. `isAojContest` に追加: + ```typescript + regexForAojUniversity.test(contestId) + ``` + +```bash +pnpm test:unit src/test/lib/utils/contest.test.ts # GREEN 確認 +``` + +### Phase 3: Layer 3b(getTaskUrl の AOJ_UNIVERSITY 対応、TDD) + +#### テスト先行(RED) + +**`src/test/lib/utils/test_cases/task_url.ts`** — `aojIcpc` の直後に追加: + +```typescript +// AOJ University contests: contest ID = AOJ-{NAME}PC{YEAR}[-...], task ID = numeric problem ID +const aojUniversityContests = [ + { contestId: 'AOJ-RUPC2018-in-ACPC2018-day1', tasks: ['2856', '2857'] }, + { contestId: 'AOJ-UAPC2019-in-RUPC2019-day2', tasks: ['3058', '3059'] }, +]; + +export const aojUniversity = aojUniversityContests.flatMap((contest) => + contest.tasks.map((task) => { + return createTestCaseForTaskUrl(`AOJ University, ${contest.contestId} ${task}`)({ + contestId: contest.contestId, + taskId: task, + expected: `${AOJ_TASKS_URL}/${task}`, + }); + }), +); +``` + +**`src/test/lib/utils/task.test.ts`** — `aojIcpc` の describe の直後に追加: + +```typescript +describe('when contest ids and task ids for AOJ University (RUPC, HUPC, UAPC) are given', () => { + TestCasesForTaskUrl.aojUniversity.forEach(({ name, value }) => { + runTests(`${name}`, [value], ({ contestId, taskId, expected }: TestCaseForTaskUrl) => { + expect(getTaskUrl(contestId, taskId)).toBe(expected); + }); + }); +}); +``` + +```bash +pnpm test:unit src/test/lib/utils/task.test.ts # RED 確認 +``` + +#### 実装(GREEN) + +**`src/lib/utils/task.ts`**: + +1. import に `regexForAojUniversity` を追加: + ```typescript + import { getPrefixForAojCourses, getContestPriority, regexForAojUniversity } from '$lib/utils/contest'; + ``` + +2. `AojGenerator.canHandle` に条件追加: + ```typescript + canHandle(contestId: string): boolean { + return ( + getPrefixForAojCourses().includes(contestId) || + contestId.startsWith('PCK') || + contestId.startsWith('JAG') || + contestId.startsWith('ICPC') || + regexForAojUniversity.test(contestId) + ); + } + ``` + +```bash +pnpm test:unit src/test/lib/utils/task.test.ts # GREEN 確認 +``` + +### Phase 4: 最終確認 + +```bash +pnpm test:unit # 全テスト GREEN +pnpm check # 型エラーなし +pnpm lint # lint クリーン +``` + +--- + +## コミット方針 + +| # | 対象レイヤー | 内容 | +|---|---|---| +| 1 | Layer 1–2 | prisma schema + ContestType 定数 | +| 2 | Layer 3 + 3b | テスト + contest.ts / task.ts 実装 | + +--- + +## 未決・後続タスク + +- seed データ(JSON 3 ファイル計約 635 件)の tasks.ts への追加 → 別 PR +- Layer 4: `aoj_university_provider.ts` Provider クラス → 別 PR +- Layer 5: `contest_table_provider_groups.ts` へのグループ登録 → 別 PR +- `addContestNameToTaskIndex` の表示品質確認(`()` 付きラベル + isAojContest=true の組み合わせ)→ Provider 追加時に対処 diff --git a/prisma/migrations/20260531130053_add_aoj_university_to_contest_type/migration.sql b/prisma/migrations/20260531130053_add_aoj_university_to_contest_type/migration.sql new file mode 100644 index 000000000..5f0b86460 --- /dev/null +++ b/prisma/migrations/20260531130053_add_aoj_university_to_contest_type/migration.sql @@ -0,0 +1,2 @@ +-- AlterEnum +ALTER TYPE "ContestType" ADD VALUE 'AOJ_UNIVERSITY'; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index e6b28ba97..75e189678 100755 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -323,6 +323,7 @@ enum ContestType { AOJ_PCK // All-Japan High School Programming Contest (PCK) AOJ_ICPC // ICPC (International Collegiate Programming Contest) AOJ_JAG // ACM-ICPC Japan Alumni Group Contest (JAG) + AOJ_UNIVERSITY // University Programming Contest (RUPC, HUPC, UAPC) } // 11Q(最も簡単)〜6D(最難関)。 diff --git a/prisma/tasks.ts b/prisma/tasks.ts index b2f9534d2..b728a0d75 100755 --- a/prisma/tasks.ts +++ b/prisma/tasks.ts @@ -11973,4 +11973,25 @@ export const tasks = [ name: 'World Trip', title: '2349. World Trip', }, + { + id: '3058', + contest_id: 'AOJ-UAPC2019-in-RUPC2019-day2', + problem_index: '3058', + name: 'Ghost', + title: '3058. Ghost', + }, + { + id: '2903', + contest_id: 'AOJ-RUPC2018-in-ACPC2018-day1', + problem_index: '2903', + name: 'Board', + title: '2903. Board', + }, + { + id: '3171', + contest_id: 'AOJ-HUPC2020-in-HUPC2020-day1', + problem_index: '3171', + name: 'Traditional Company', + title: '3171. Traditional Company', + }, ]; diff --git a/src/lib/types/contest.ts b/src/lib/types/contest.ts index e2758739b..d60f9a20b 100644 --- a/src/lib/types/contest.ts +++ b/src/lib/types/contest.ts @@ -53,6 +53,7 @@ export const ContestType: { [key in ContestTypeOrigin]: key } = { AOJ_PCK: 'AOJ_PCK', // All-Japan High School Programming Contest (PCK) AOJ_ICPC: 'AOJ_ICPC', // ICPC (International Collegiate Programming Contest) AOJ_JAG: 'AOJ_JAG', // ACM-ICPC Japan Alumni Group Contest (JAG) + AOJ_UNIVERSITY: 'AOJ_UNIVERSITY', // University Programming Contest (RUPC, HUPC, UAPC) } as const; // Re-exporting the original type with the original name. diff --git a/src/lib/utils/contest.ts b/src/lib/utils/contest.ts index 803c5d045..e64a7ecb9 100644 --- a/src/lib/utils/contest.ts +++ b/src/lib/utils/contest.ts @@ -1,6 +1,7 @@ import { ContestType, type ContestPrefix, type ContestLabelTranslations } from '$lib/types/contest'; const regexForJag = /^JAG(Prelim|Regional|Summer|Winter|Spring)\d{4}(-day\d+[A-Z]?)?$/; +export const regexForAojUniversity = /^AOJ-[A-Z]+PC\d{4}/; // See: // https://github.com/kenkoooo/AtCoderProblems/blob/master/atcoder-problems-frontend/src/utils/ContestClassifier.ts @@ -107,6 +108,10 @@ export const classifyContest = (contest_id: string) => { return ContestType.AOJ_JAG; } + if (regexForAojUniversity.test(contest_id)) { + return ContestType.AOJ_UNIVERSITY; + } + return null; }; @@ -271,13 +276,13 @@ export function getContestPrefixes(contestPrefixes: Record) { } /** - * Contest type priorities (0 = Highest, 24 = Lowest) + * Contest type priorities (0 = Highest, 25 = Lowest) * * Priority assignment rationale: * - Educational contests (0-11, 17): ABS, ABC, APG4B and AWC etc. * - Contests for genius (12-16): ARC, AGC, and their variants * - Special contests (18-20): UNIVERSITY, FPS_24, OTHERS - * - External platforms (21-24): AOJ_COURSES, AOJ_PCK, AOJ_ICPC, AOJ_JAG + * - External platforms (21-25): AOJ_COURSES, AOJ_PCK, AOJ_ICPC, AOJ_JAG, AOJ_UNIVERSITY * * @remarks * HACK: The priorities for ARC, AGC, UNIVERSITY, AOJ_COURSES, and AOJ_PCK are temporary @@ -312,6 +317,7 @@ export const contestTypePriorities: Map = new Map([ [ContestType.AOJ_PCK, 22], [ContestType.AOJ_ICPC, 23], [ContestType.AOJ_JAG, 24], + [ContestType.AOJ_UNIVERSITY, 25], ]); export function getContestPriority(contestId: string): number { @@ -462,6 +468,10 @@ export const getContestNameLabel = (contestId: string) => { return getAojContestLabel(JAG_TRANSLATIONS, contestId); } + if (regexForAojUniversity.test(contestId)) { + return getAojUniversityContestLabel(contestId); + } + return contestId.toUpperCase(); }; @@ -689,6 +699,17 @@ const PCK_TRANSLATIONS = { Final: ' 本選 ', }; +function getAojUniversityContestLabel(contestId: string): string { + const label = contestId + .replace(/^AOJ-/, '') + .replace(/UAPC/g, 'ACPC') + .replace(/([A-Z]{2,})(\d{4})/g, '$1 $2') + .replace(/-in-/, ' in ') + .replace(/-day(\d+)/, ' Day$1') + .replace(/-summer/, ' Summer'); + return '(' + label + ')'; +} + /** * Maps JAG contest type abbreviations to their Japanese translations. * @@ -740,6 +761,7 @@ function isAojContest(contestId: string): boolean { aojCoursePrefixes.has(contestId) || contestId.startsWith('PCK') || regexForJag.test(contestId) || - contestId.startsWith('ICPC') + contestId.startsWith('ICPC') || + regexForAojUniversity.test(contestId) ); } diff --git a/src/lib/utils/task.ts b/src/lib/utils/task.ts index 3f384e5a4..29cdf064f 100644 --- a/src/lib/utils/task.ts +++ b/src/lib/utils/task.ts @@ -5,7 +5,7 @@ import type { UrlGenerator, UrlGenerators } from '$lib/types/url'; import { type WorkBookTaskBase } from '$features/workbooks/types/workbook'; import { ATCODER_BASE_CONTEST_URL, AOJ_TASKS_URL } from '$lib/constants/urls'; -import { getPrefixForAojCourses, getContestPriority } from '$lib/utils/contest'; +import { getPrefixForAojCourses, getContestPriority, regexForAojUniversity } from '$lib/utils/contest'; // TODO: Codeforces、yukicoder、BOJなどに対応できるようにする /** @@ -37,7 +37,8 @@ class AojGenerator implements UrlGenerator { getPrefixForAojCourses().includes(contestId) || contestId.startsWith('PCK') || contestId.startsWith('JAG') || - contestId.startsWith('ICPC') + contestId.startsWith('ICPC') || + regexForAojUniversity.test(contestId) ); } diff --git a/src/test/lib/utils/contest.test.ts b/src/test/lib/utils/contest.test.ts index dea8e29dd..323f828ac 100644 --- a/src/test/lib/utils/contest.test.ts +++ b/src/test/lib/utils/contest.test.ts @@ -229,6 +229,14 @@ describe('Contest', () => { }); }); }); + + describe('when contest_id means AOJ University (RUPC, HUPC, UAPC)', () => { + TestCasesForContestType.aojUniversity.forEach(({ name, value }) => { + runTests(`${name}`, [value], ({ contestId, expected }: TestCaseForContestType) => { + expect(classifyContest(contestId)).toEqual(expected); + }); + }); + }); }); }); @@ -435,6 +443,14 @@ describe('Contest', () => { }); }); }); + + describe('when contest_id means AOJ University (RUPC, HUPC, UAPC)', () => { + TestCasesForContestType.aojUniversity.forEach(({ name, value }) => { + runTests(`${name}`, [value], ({ contestId, expected }: TestCaseForContestType) => { + expect(getContestPriority(contestId)).toEqual(contestTypePriorities.get(expected)); + }); + }); + }); }); }); @@ -530,6 +546,14 @@ describe('Contest', () => { }); }); }); + + describe('when contest_id means AOJ University (RUPC, HUPC, UAPC)', () => { + TestCasesForContestNameLabel.aojUniversity.forEach(({ name, value }) => { + runTests(`${name}`, [value], ({ contestId, expected }: TestCaseForContestNameLabel) => { + expect(getContestNameLabel(contestId)).toEqual(expected); + }); + }); + }); }); }); diff --git a/src/test/lib/utils/task.test.ts b/src/test/lib/utils/task.test.ts index ee6984523..f069db06a 100644 --- a/src/test/lib/utils/task.test.ts +++ b/src/test/lib/utils/task.test.ts @@ -107,6 +107,14 @@ describe('Task', () => { }); }); }); + + describe('when contest ids and task ids for AOJ University (RUPC, HUPC, UAPC) are given', () => { + TestCasesForTaskUrl.aojUniversity.forEach(({ name, value }) => { + runTests(`${name}`, [value], ({ contestId, taskId, expected }: TestCaseForTaskUrl) => { + expect(getTaskUrl(contestId, taskId)).toBe(expected); + }); + }); + }); }); describe('count accepted tasks', () => { diff --git a/src/test/lib/utils/test_cases/contest_name_labels.ts b/src/test/lib/utils/test_cases/contest_name_labels.ts index 7e1681ea3..fb7bd7f58 100644 --- a/src/test/lib/utils/test_cases/contest_name_labels.ts +++ b/src/test/lib/utils/test_cases/contest_name_labels.ts @@ -124,3 +124,30 @@ export const aojIcpc = [ expected: '(ICPC 地区予選 2023)', }), ]; + +export const aojUniversity = [ + createTestCaseForContestNameLabel('AOJ, RUPC 2018 in ACPC 2018 Day1')({ + contestId: 'AOJ-RUPC2018-in-ACPC2018-day1', + expected: '(RUPC 2018 in ACPC 2018 Day1)', + }), + createTestCaseForContestNameLabel('AOJ, HUPC 2020 in HUPC 2020 Day1')({ + contestId: 'AOJ-HUPC2020-in-HUPC2020-day1', + expected: '(HUPC 2020 in HUPC 2020 Day1)', + }), + createTestCaseForContestNameLabel('AOJ, UAPC 2019 in RUPC 2019 Day2')({ + contestId: 'AOJ-UAPC2019-in-RUPC2019-day2', + expected: '(ACPC 2019 in RUPC 2019 Day2)', + }), + createTestCaseForContestNameLabel('AOJ, UAPC 2003')({ + contestId: 'AOJ-UAPC2003', + expected: '(ACPC 2003)', + }), + createTestCaseForContestNameLabel('AOJ, UAPC 2011 Summer')({ + contestId: 'AOJ-UAPC2011-summer', + expected: '(ACPC 2011 Summer)', + }), + createTestCaseForContestNameLabel('AOJ, UAPC 2012 Day1')({ + contestId: 'AOJ-UAPC2012-day1', + expected: '(ACPC 2012 Day1)', + }), +]; diff --git a/src/test/lib/utils/test_cases/contest_type.ts b/src/test/lib/utils/test_cases/contest_type.ts index 6dfdc8e20..c19f6af47 100644 --- a/src/test/lib/utils/test_cases/contest_type.ts +++ b/src/test/lib/utils/test_cases/contest_type.ts @@ -665,3 +665,19 @@ export const aojIcpc = aojIcpcContestData.map(({ name, contestId }) => expected: ContestType.AOJ_ICPC, }), ); + +const aojUniversityContestData = [ + { name: 'AOJ, RUPC 2018 in ACPC 2018 Day1', contestId: 'AOJ-RUPC2018-in-ACPC2018-day1' }, + { name: 'AOJ, HUPC 2020 in HUPC 2020 Day1', contestId: 'AOJ-HUPC2020-in-HUPC2020-day1' }, + { name: 'AOJ, UAPC 2019 in RUPC 2019 Day2', contestId: 'AOJ-UAPC2019-in-RUPC2019-day2' }, + { name: 'AOJ, UAPC 2003', contestId: 'AOJ-UAPC2003' }, + { name: 'AOJ, UAPC 2011 Summer', contestId: 'AOJ-UAPC2011-summer' }, + { name: 'AOJ, UAPC 2012 Day1', contestId: 'AOJ-UAPC2012-day1' }, +]; + +export const aojUniversity = aojUniversityContestData.map(({ name, contestId }) => + createTestCaseForContestType(name)({ + contestId, + expected: ContestType.AOJ_UNIVERSITY, + }), +); diff --git a/src/test/lib/utils/test_cases/task_url.ts b/src/test/lib/utils/test_cases/task_url.ts index 49f518d51..abbbb8758 100644 --- a/src/test/lib/utils/test_cases/task_url.ts +++ b/src/test/lib/utils/test_cases/task_url.ts @@ -195,3 +195,20 @@ export const aojIcpc = icpcContests.flatMap((icpc) => }); }), ); + +// AOJ University contests: contest ID = AOJ-{NAME}PC{YEAR}[-...], task ID = numeric problem ID +const aojUniversityContests = [ + { contestId: 'AOJ-RUPC2018-in-ACPC2018-day1', tasks: ['2903', '2904'] }, + { contestId: 'AOJ-UAPC2019-in-RUPC2019-day2', tasks: ['3058', '3059'] }, + { contestId: 'AOJ-HUPC2020-in-HUPC2020-day1', tasks: ['3171', '3172'] }, +]; + +export const aojUniversity = aojUniversityContests.flatMap((contest) => + contest.tasks.map((task) => { + return createTestCaseForTaskUrl(`AOJ University, ${contest.contestId} ${task}`)({ + contestId: contest.contestId, + taskId: task, + expected: `${AOJ_TASKS_URL}/${task}`, + }); + }), +); From 2a1504cfc2caad4d022986db8573ece9f4c94006 Mon Sep 17 00:00:00 2001 From: "k.hiro1818" Date: Sun, 31 May 2026 13:24:57 +0000 Subject: [PATCH 2/5] style: format plan.md tables and fix import style in task.ts Co-Authored-By: Claude Sonnet 4.6 --- .../2026-05-31/add-aoj-university/plan.md | 62 ++++++++++++------- src/lib/utils/task.ts | 6 +- src/test/lib/utils/test_cases/contest_type.ts | 6 +- 3 files changed, 46 insertions(+), 28 deletions(-) diff --git a/docs/dev-notes/2026-05-31/add-aoj-university/plan.md b/docs/dev-notes/2026-05-31/add-aoj-university/plan.md index aaf17c763..72f051929 100644 --- a/docs/dev-notes/2026-05-31/add-aoj-university/plan.md +++ b/docs/dev-notes/2026-05-31/add-aoj-university/plan.md @@ -9,6 +9,7 @@ Issue #3598。RUPC / HUPC / UAPC 系の大学対抗プログラミングコン ### 対象 contest_id パターン 正規表現 `/^AOJ-[A-Z]+PC\d{4}/` でマッチする ID: + - `AOJ-RUPC2018-in-ACPC2018-day1` - `AOJ-HUPC2020-in-HUPC2020-day1` - `AOJ-UAPC2019-in-RUPC2019-day2` @@ -23,18 +24,19 @@ Issue #3598。RUPC / HUPC / UAPC 系の大学対抗プログラミングコン `getContestNameLabel` が返す文字列(**JAG 同様、`()` で囲む**): -| 入力 | 出力 | -|---|---| +| 入力 | 出力 | +| ------------------------------- | --------------------------------- | | `AOJ-RUPC2018-in-ACPC2018-day1` | `(RUPC 2018 in ACPC 2018 Day1)` | | `AOJ-HUPC2020-in-HUPC2020-day1` | `(HUPC 2020 in HUPC 2020 Day1)` | | `AOJ-UAPC2019-in-RUPC2019-day2` | `(ACPC 2019 in RUPC 2019 Day2)` | -| `AOJ-UAPC2003` | `(ACPC 2003)` | -| `AOJ-UAPC2011-summer` | `(ACPC 2011 Summer)` | -| `AOJ-UAPC2012-day1` | `(ACPC 2012 Day1)` | +| `AOJ-UAPC2003` | `(ACPC 2003)` | +| `AOJ-UAPC2011-summer` | `(ACPC 2011 Summer)` | +| `AOJ-UAPC2012-day1` | `(ACPC 2012 Day1)` | ルール:contest_id 中の `UAPC` はラベルでは **`ACPC`** に変換する(contest_id の分類 regex は変更しない)。 `addContestNameToTaskIndex` の出力例(`()` 付きで正しく表示される): + - `AOJ 3058(ACPC 2019 in RUPC 2019 Day2)` 変換ロジック: @@ -43,7 +45,7 @@ Issue #3598。RUPC / HUPC / UAPC 系の大学対抗プログラミングコン function getAojUniversityContestLabel(contestId: string): string { const label = contestId .replace(/^AOJ-/, '') - .replace(/UAPC/g, 'ACPC') // UAPC → ACPC(ラベル表示上の変換) + .replace(/UAPC/g, 'ACPC') // UAPC → ACPC(ラベル表示上の変換) .replace(/([A-Z]{2,})(\d{4})/g, '$1 $2') // 全箇所の大文字列+4桁年にスペース挿入 .replace(/-in-/, ' in ') .replace(/-day(\d+)/, ' Day$1') @@ -67,21 +69,22 @@ JAG_TRANSLATIONS(文字列辞書方式)と異なり、埋め込みコンテ `src/lib/utils/task.ts` の `AojGenerator.canHandle` が `AOJ-[A-Z]+PC\d{4}` パターンを未処理のため、AtCoder URL にフォールスルーしてしまう。`regexForAojUniversity` を `contest.ts` から export し、`AojGenerator.canHandle` で使用する。 AOJ University の問題 URL は他の AOJ コンテストと同形式: + ``` ${AOJ_TASKS_URL}/${taskId} // 例: https://judge.u-aizu.ac.jp/onlinejudge/description.jsp?id=3058 ``` ### スコープ -| レイヤー | 内容 | 今回 | -|---|---|---| -| Layer 1 | prisma/schema.prisma | ✅ | -| Layer 2 | ContestType 定数 | ✅ | -| Layer 3 | classifyContest / contestTypePriorities / getContestNameLabel / isAojContest + テスト | ✅ | -| Layer 3b | getTaskUrl の AOJ_UNIVERSITY 対応(AojGenerator.canHandle + テスト)| ✅ | -| Layer 4 | Provider クラス(TDD) | ❌ 別 PR | -| Layer 5 | Group 登録 | ❌ 別 PR | -| seed データ | tasks.ts への全件追加 | ❌ 別 PR | +| レイヤー | 内容 | 今回 | +| ----------- | ------------------------------------------------------------------------------------- | -------- | +| Layer 1 | prisma/schema.prisma | ✅ | +| Layer 2 | ContestType 定数 | ✅ | +| Layer 3 | classifyContest / contestTypePriorities / getContestNameLabel / isAojContest + テスト | ✅ | +| Layer 3b | getTaskUrl の AOJ_UNIVERSITY 対応(AojGenerator.canHandle + テスト) | ✅ | +| Layer 4 | Provider クラス(TDD) | ❌ 別 PR | +| Layer 5 | Group 登録 | ❌ 別 PR | +| seed データ | tasks.ts への全件追加 | ❌ 別 PR | --- @@ -125,9 +128,9 @@ const aojUniversityContestData = [ { name: 'AOJ, RUPC 2018 in ACPC 2018 Day1', contestId: 'AOJ-RUPC2018-in-ACPC2018-day1' }, { name: 'AOJ, HUPC 2020 in HUPC 2020 Day1', contestId: 'AOJ-HUPC2020-in-HUPC2020-day1' }, { name: 'AOJ, UAPC 2019 in RUPC 2019 Day2', contestId: 'AOJ-UAPC2019-in-RUPC2019-day2' }, - { name: 'AOJ, UAPC 2003', contestId: 'AOJ-UAPC2003' }, - { name: 'AOJ, UAPC 2011 Summer', contestId: 'AOJ-UAPC2011-summer' }, - { name: 'AOJ, UAPC 2012 Day1', contestId: 'AOJ-UAPC2012-day1' }, + { name: 'AOJ, UAPC 2003', contestId: 'AOJ-UAPC2003' }, + { name: 'AOJ, UAPC 2011 Summer', contestId: 'AOJ-UAPC2011-summer' }, + { name: 'AOJ, UAPC 2012 Day1', contestId: 'AOJ-UAPC2012-day1' }, ]; export const aojUniversity = aojUniversityContestData.map(({ name, contestId }) => @@ -180,11 +183,13 @@ pnpm test:unit src/test/lib/utils/contest.test.ts # RED 確認 **`src/lib/utils/contest.ts`** に以下を追加・変更: 1. ファイル先頭(`regexForJag` 直後)— **export に変更**: + ```typescript export const regexForAojUniversity = /^AOJ-[A-Z]+PC\d{4}/; ``` 2. `classifyContest` — `regexForJag` ブランチの直後: + ```typescript if (regexForAojUniversity.test(contest_id)) { return ContestType.AOJ_UNIVERSITY; @@ -192,12 +197,15 @@ pnpm test:unit src/test/lib/utils/contest.test.ts # RED 確認 ``` 3. `contestTypePriorities` — `[ContestType.AOJ_JAG, 24]` の直後: + ```typescript [ContestType.AOJ_UNIVERSITY, 25], ``` + JSDoc の数値範囲コメントも更新。 4. 新関数(`JAG_TRANSLATIONS` の近くに追加、`()` 付き): + ```typescript function getAojUniversityContestLabel(contestId: string): string { const label = contestId @@ -212,6 +220,7 @@ pnpm test:unit src/test/lib/utils/contest.test.ts # RED 確認 ``` 5. `getContestNameLabel` — `regexForJag` ブランチの直後: + ```typescript if (regexForAojUniversity.test(contestId)) { return getAojUniversityContestLabel(contestId); @@ -220,7 +229,7 @@ pnpm test:unit src/test/lib/utils/contest.test.ts # RED 確認 6. `isAojContest` に追加: ```typescript - regexForAojUniversity.test(contestId) + regexForAojUniversity.test(contestId); ``` ```bash @@ -272,8 +281,13 @@ pnpm test:unit src/test/lib/utils/task.test.ts # RED 確認 **`src/lib/utils/task.ts`**: 1. import に `regexForAojUniversity` を追加: + ```typescript - import { getPrefixForAojCourses, getContestPriority, regexForAojUniversity } from '$lib/utils/contest'; + import { + getPrefixForAojCourses, + getContestPriority, + regexForAojUniversity, + } from '$lib/utils/contest'; ``` 2. `AojGenerator.canHandle` に条件追加: @@ -305,10 +319,10 @@ pnpm lint # lint クリーン ## コミット方針 -| # | 対象レイヤー | 内容 | -|---|---|---| -| 1 | Layer 1–2 | prisma schema + ContestType 定数 | -| 2 | Layer 3 + 3b | テスト + contest.ts / task.ts 実装 | +| # | 対象レイヤー | 内容 | +| --- | ------------ | ---------------------------------- | +| 1 | Layer 1–2 | prisma schema + ContestType 定数 | +| 2 | Layer 3 + 3b | テスト + contest.ts / task.ts 実装 | --- diff --git a/src/lib/utils/task.ts b/src/lib/utils/task.ts index 29cdf064f..226216909 100644 --- a/src/lib/utils/task.ts +++ b/src/lib/utils/task.ts @@ -5,7 +5,11 @@ import type { UrlGenerator, UrlGenerators } from '$lib/types/url'; import { type WorkBookTaskBase } from '$features/workbooks/types/workbook'; import { ATCODER_BASE_CONTEST_URL, AOJ_TASKS_URL } from '$lib/constants/urls'; -import { getPrefixForAojCourses, getContestPriority, regexForAojUniversity } from '$lib/utils/contest'; +import { + getPrefixForAojCourses, + getContestPriority, + regexForAojUniversity, +} from '$lib/utils/contest'; // TODO: Codeforces、yukicoder、BOJなどに対応できるようにする /** diff --git a/src/test/lib/utils/test_cases/contest_type.ts b/src/test/lib/utils/test_cases/contest_type.ts index c19f6af47..7463da6ff 100644 --- a/src/test/lib/utils/test_cases/contest_type.ts +++ b/src/test/lib/utils/test_cases/contest_type.ts @@ -670,9 +670,9 @@ const aojUniversityContestData = [ { name: 'AOJ, RUPC 2018 in ACPC 2018 Day1', contestId: 'AOJ-RUPC2018-in-ACPC2018-day1' }, { name: 'AOJ, HUPC 2020 in HUPC 2020 Day1', contestId: 'AOJ-HUPC2020-in-HUPC2020-day1' }, { name: 'AOJ, UAPC 2019 in RUPC 2019 Day2', contestId: 'AOJ-UAPC2019-in-RUPC2019-day2' }, - { name: 'AOJ, UAPC 2003', contestId: 'AOJ-UAPC2003' }, - { name: 'AOJ, UAPC 2011 Summer', contestId: 'AOJ-UAPC2011-summer' }, - { name: 'AOJ, UAPC 2012 Day1', contestId: 'AOJ-UAPC2012-day1' }, + { name: 'AOJ, UAPC 2003', contestId: 'AOJ-UAPC2003' }, + { name: 'AOJ, UAPC 2011 Summer', contestId: 'AOJ-UAPC2011-summer' }, + { name: 'AOJ, UAPC 2012 Day1', contestId: 'AOJ-UAPC2012-day1' }, ]; export const aojUniversity = aojUniversityContestData.map(({ name, contestId }) => From c0a37f87579a42f27461008cb5ec08182ebcf82b Mon Sep 17 00:00:00 2001 From: "k.hiro1818" Date: Sun, 31 May 2026 13:25:26 +0000 Subject: [PATCH 3/5] docs: remove plan.md after AOJ_UNIVERSITY implementation complete Co-Authored-By: Claude Sonnet 4.6 --- .../2026-05-31/add-aoj-university/plan.md | 334 ------------------ 1 file changed, 334 deletions(-) delete mode 100644 docs/dev-notes/2026-05-31/add-aoj-university/plan.md diff --git a/docs/dev-notes/2026-05-31/add-aoj-university/plan.md b/docs/dev-notes/2026-05-31/add-aoj-university/plan.md deleted file mode 100644 index 72f051929..000000000 --- a/docs/dev-notes/2026-05-31/add-aoj-university/plan.md +++ /dev/null @@ -1,334 +0,0 @@ -# AOJ_UNIVERSITY ContestType 追加 - -## 概要 - -Issue #3598。RUPC / HUPC / UAPC 系の大学対抗プログラミングコンテスト(AOJ ホスト)を新しい ContestType `AOJ_UNIVERSITY` として分類・ラベル表示できるようにする。 - -## 設計方針 - -### 対象 contest_id パターン - -正規表現 `/^AOJ-[A-Z]+PC\d{4}/` でマッチする ID: - -- `AOJ-RUPC2018-in-ACPC2018-day1` -- `AOJ-HUPC2020-in-HUPC2020-day1` -- `AOJ-UAPC2019-in-RUPC2019-day2` -- `AOJ-UAPC2003`(年のみ) -- `AOJ-UAPC2011-summer`(season 付き) -- `AOJ-UAPC2012-day1`(day 付き) -- など - -既存 ContestType(`AOJ_JAG` = `JAGPrelim...` など)は `AOJ-` プレフィックスを持たないため衝突しない。 - -### ラベル変換方針 - -`getContestNameLabel` が返す文字列(**JAG 同様、`()` で囲む**): - -| 入力 | 出力 | -| ------------------------------- | --------------------------------- | -| `AOJ-RUPC2018-in-ACPC2018-day1` | `(RUPC 2018 in ACPC 2018 Day1)` | -| `AOJ-HUPC2020-in-HUPC2020-day1` | `(HUPC 2020 in HUPC 2020 Day1)` | -| `AOJ-UAPC2019-in-RUPC2019-day2` | `(ACPC 2019 in RUPC 2019 Day2)` | -| `AOJ-UAPC2003` | `(ACPC 2003)` | -| `AOJ-UAPC2011-summer` | `(ACPC 2011 Summer)` | -| `AOJ-UAPC2012-day1` | `(ACPC 2012 Day1)` | - -ルール:contest_id 中の `UAPC` はラベルでは **`ACPC`** に変換する(contest_id の分類 regex は変更しない)。 - -`addContestNameToTaskIndex` の出力例(`()` 付きで正しく表示される): - -- `AOJ 3058(ACPC 2019 in RUPC 2019 Day2)` - -変換ロジック: - -```typescript -function getAojUniversityContestLabel(contestId: string): string { - const label = contestId - .replace(/^AOJ-/, '') - .replace(/UAPC/g, 'ACPC') // UAPC → ACPC(ラベル表示上の変換) - .replace(/([A-Z]{2,})(\d{4})/g, '$1 $2') // 全箇所の大文字列+4桁年にスペース挿入 - .replace(/-in-/, ' in ') - .replace(/-day(\d+)/, ' Day$1') - .replace(/-summer/, ' Summer'); - return '(' + label + ')'; -} -``` - -JAG_TRANSLATIONS(文字列辞書方式)と異なり、埋め込みコンテスト名も含め全箇所の年にスペースを入れるため regex 方式を採用。`()` 付与は `getAojContestLabel` と同じ規則に合わせる。 - -### priority - -`AOJ_JAG`(priority 24)の末尾に追加 → **25**。既存値シフトなし。 - -### isAojContest - -`regexForAojUniversity` を追加し、`addContestNameToTaskIndex` が AOJ 形式(`AOJ {index}(label)`)を使えるようにする。 - -### getTaskUrl の AOJ_UNIVERSITY 対応 - -`src/lib/utils/task.ts` の `AojGenerator.canHandle` が `AOJ-[A-Z]+PC\d{4}` パターンを未処理のため、AtCoder URL にフォールスルーしてしまう。`regexForAojUniversity` を `contest.ts` から export し、`AojGenerator.canHandle` で使用する。 - -AOJ University の問題 URL は他の AOJ コンテストと同形式: - -``` -${AOJ_TASKS_URL}/${taskId} // 例: https://judge.u-aizu.ac.jp/onlinejudge/description.jsp?id=3058 -``` - -### スコープ - -| レイヤー | 内容 | 今回 | -| ----------- | ------------------------------------------------------------------------------------- | -------- | -| Layer 1 | prisma/schema.prisma | ✅ | -| Layer 2 | ContestType 定数 | ✅ | -| Layer 3 | classifyContest / contestTypePriorities / getContestNameLabel / isAojContest + テスト | ✅ | -| Layer 3b | getTaskUrl の AOJ_UNIVERSITY 対応(AojGenerator.canHandle + テスト) | ✅ | -| Layer 4 | Provider クラス(TDD) | ❌ 別 PR | -| Layer 5 | Group 登録 | ❌ 別 PR | -| seed データ | tasks.ts への全件追加 | ❌ 別 PR | - ---- - -## 実装タスク - -### Phase 1: Layer 1–2(Prisma スキーマ + 型定数) - -**ファイル**: `prisma/schema.prisma` - -`AOJ_JAG` 直後に追加: - -```prisma -AOJ_UNIVERSITY // University Programming Contest (RUPC, HUPC, UAPC) -``` - -```bash -pnpm exec prisma generate -pnpm check # src/lib/types/contest.ts で型エラーが出ることを確認 -``` - -**ファイル**: `src/lib/types/contest.ts` - -`AOJ_JAG` 直後に追加: - -```typescript -AOJ_UNIVERSITY: 'AOJ_UNIVERSITY', // University Programming Contest (RUPC, HUPC, UAPC) -``` - -```bash -pnpm check # エラー解消を確認 -``` - -### Phase 2: Layer 3(TDD) - -#### テスト先行(RED) - -**`src/test/lib/utils/test_cases/contest_type.ts`** — `aojIcpc` の直後に追加: - -```typescript -const aojUniversityContestData = [ - { name: 'AOJ, RUPC 2018 in ACPC 2018 Day1', contestId: 'AOJ-RUPC2018-in-ACPC2018-day1' }, - { name: 'AOJ, HUPC 2020 in HUPC 2020 Day1', contestId: 'AOJ-HUPC2020-in-HUPC2020-day1' }, - { name: 'AOJ, UAPC 2019 in RUPC 2019 Day2', contestId: 'AOJ-UAPC2019-in-RUPC2019-day2' }, - { name: 'AOJ, UAPC 2003', contestId: 'AOJ-UAPC2003' }, - { name: 'AOJ, UAPC 2011 Summer', contestId: 'AOJ-UAPC2011-summer' }, - { name: 'AOJ, UAPC 2012 Day1', contestId: 'AOJ-UAPC2012-day1' }, -]; - -export const aojUniversity = aojUniversityContestData.map(({ name, contestId }) => - createTestCaseForContestType(name)({ - contestId, - expected: ContestType.AOJ_UNIVERSITY, - }), -); -``` - -**`src/test/lib/utils/test_cases/contest_name_labels.ts`** — `aojIcpc` の直後に追加(`expected` は `()` 込み): - -```typescript -export const aojUniversity = [ - createTestCaseForContestNameLabel('AOJ, RUPC 2018 in ACPC 2018 Day1')({ - contestId: 'AOJ-RUPC2018-in-ACPC2018-day1', - expected: '(RUPC 2018 in ACPC 2018 Day1)', - }), - createTestCaseForContestNameLabel('AOJ, HUPC 2020 in HUPC 2020 Day1')({ - contestId: 'AOJ-HUPC2020-in-HUPC2020-day1', - expected: '(HUPC 2020 in HUPC 2020 Day1)', - }), - createTestCaseForContestNameLabel('AOJ, UAPC 2019 in RUPC 2019 Day2')({ - contestId: 'AOJ-UAPC2019-in-RUPC2019-day2', - expected: '(ACPC 2019 in RUPC 2019 Day2)', - }), - createTestCaseForContestNameLabel('AOJ, UAPC 2003')({ - contestId: 'AOJ-UAPC2003', - expected: '(ACPC 2003)', - }), - createTestCaseForContestNameLabel('AOJ, UAPC 2011 Summer')({ - contestId: 'AOJ-UAPC2011-summer', - expected: '(ACPC 2011 Summer)', - }), - createTestCaseForContestNameLabel('AOJ, UAPC 2012 Day1')({ - contestId: 'AOJ-UAPC2012-day1', - expected: '(ACPC 2012 Day1)', - }), -]; -``` - -**`src/test/lib/utils/contest.test.ts`** — `describe('AOJ', ...)` ブロック内の 3 箇所(classify / priority / label)に `when contest_id means AOJ University (RUPC, HUPC, UAPC)` describe を追加。 - -```bash -pnpm test:unit src/test/lib/utils/contest.test.ts # RED 確認 -``` - -#### 実装(GREEN) - -**`src/lib/utils/contest.ts`** に以下を追加・変更: - -1. ファイル先頭(`regexForJag` 直後)— **export に変更**: - - ```typescript - export const regexForAojUniversity = /^AOJ-[A-Z]+PC\d{4}/; - ``` - -2. `classifyContest` — `regexForJag` ブランチの直後: - - ```typescript - if (regexForAojUniversity.test(contest_id)) { - return ContestType.AOJ_UNIVERSITY; - } - ``` - -3. `contestTypePriorities` — `[ContestType.AOJ_JAG, 24]` の直後: - - ```typescript - [ContestType.AOJ_UNIVERSITY, 25], - ``` - - JSDoc の数値範囲コメントも更新。 - -4. 新関数(`JAG_TRANSLATIONS` の近くに追加、`()` 付き): - - ```typescript - function getAojUniversityContestLabel(contestId: string): string { - const label = contestId - .replace(/^AOJ-/, '') - .replace(/UAPC/g, 'ACPC') - .replace(/([A-Z]{2,})(\d{4})/g, '$1 $2') - .replace(/-in-/, ' in ') - .replace(/-day(\d+)/, ' Day$1') - .replace(/-summer/, ' Summer'); - return '(' + label + ')'; - } - ``` - -5. `getContestNameLabel` — `regexForJag` ブランチの直後: - - ```typescript - if (regexForAojUniversity.test(contestId)) { - return getAojUniversityContestLabel(contestId); - } - ``` - -6. `isAojContest` に追加: - ```typescript - regexForAojUniversity.test(contestId); - ``` - -```bash -pnpm test:unit src/test/lib/utils/contest.test.ts # GREEN 確認 -``` - -### Phase 3: Layer 3b(getTaskUrl の AOJ_UNIVERSITY 対応、TDD) - -#### テスト先行(RED) - -**`src/test/lib/utils/test_cases/task_url.ts`** — `aojIcpc` の直後に追加: - -```typescript -// AOJ University contests: contest ID = AOJ-{NAME}PC{YEAR}[-...], task ID = numeric problem ID -const aojUniversityContests = [ - { contestId: 'AOJ-RUPC2018-in-ACPC2018-day1', tasks: ['2856', '2857'] }, - { contestId: 'AOJ-UAPC2019-in-RUPC2019-day2', tasks: ['3058', '3059'] }, -]; - -export const aojUniversity = aojUniversityContests.flatMap((contest) => - contest.tasks.map((task) => { - return createTestCaseForTaskUrl(`AOJ University, ${contest.contestId} ${task}`)({ - contestId: contest.contestId, - taskId: task, - expected: `${AOJ_TASKS_URL}/${task}`, - }); - }), -); -``` - -**`src/test/lib/utils/task.test.ts`** — `aojIcpc` の describe の直後に追加: - -```typescript -describe('when contest ids and task ids for AOJ University (RUPC, HUPC, UAPC) are given', () => { - TestCasesForTaskUrl.aojUniversity.forEach(({ name, value }) => { - runTests(`${name}`, [value], ({ contestId, taskId, expected }: TestCaseForTaskUrl) => { - expect(getTaskUrl(contestId, taskId)).toBe(expected); - }); - }); -}); -``` - -```bash -pnpm test:unit src/test/lib/utils/task.test.ts # RED 確認 -``` - -#### 実装(GREEN) - -**`src/lib/utils/task.ts`**: - -1. import に `regexForAojUniversity` を追加: - - ```typescript - import { - getPrefixForAojCourses, - getContestPriority, - regexForAojUniversity, - } from '$lib/utils/contest'; - ``` - -2. `AojGenerator.canHandle` に条件追加: - ```typescript - canHandle(contestId: string): boolean { - return ( - getPrefixForAojCourses().includes(contestId) || - contestId.startsWith('PCK') || - contestId.startsWith('JAG') || - contestId.startsWith('ICPC') || - regexForAojUniversity.test(contestId) - ); - } - ``` - -```bash -pnpm test:unit src/test/lib/utils/task.test.ts # GREEN 確認 -``` - -### Phase 4: 最終確認 - -```bash -pnpm test:unit # 全テスト GREEN -pnpm check # 型エラーなし -pnpm lint # lint クリーン -``` - ---- - -## コミット方針 - -| # | 対象レイヤー | 内容 | -| --- | ------------ | ---------------------------------- | -| 1 | Layer 1–2 | prisma schema + ContestType 定数 | -| 2 | Layer 3 + 3b | テスト + contest.ts / task.ts 実装 | - ---- - -## 未決・後続タスク - -- seed データ(JSON 3 ファイル計約 635 件)の tasks.ts への追加 → 別 PR -- Layer 4: `aoj_university_provider.ts` Provider クラス → 別 PR -- Layer 5: `contest_table_provider_groups.ts` へのグループ登録 → 別 PR -- `addContestNameToTaskIndex` の表示品質確認(`()` 付きラベル + isAojContest=true の組み合わせ)→ Provider 追加時に対処 From 01c915382221757b6c614c80fd860ba727fad466 Mon Sep 17 00:00:00 2001 From: "k.hiro1818" Date: Sun, 31 May 2026 13:54:04 +0000 Subject: [PATCH 4/5] fix(contest): replace startsWith('JAG') with regex in AojGenerator to prevent false positives Export regexForJag from contest.ts and use it in task.ts URL generation check. Add test cases for AOJ University contest name formatting. Co-Authored-By: Claude Sonnet 4.6 --- src/lib/utils/contest.ts | 2 +- src/lib/utils/task.ts | 3 ++- src/test/lib/utils/contest.test.ts | 12 ++++++++++++ .../test_cases/contest_name_and_task_index.ts | 17 +++++++++++++++++ 4 files changed, 32 insertions(+), 2 deletions(-) diff --git a/src/lib/utils/contest.ts b/src/lib/utils/contest.ts index e64a7ecb9..70d975c7a 100644 --- a/src/lib/utils/contest.ts +++ b/src/lib/utils/contest.ts @@ -1,6 +1,6 @@ import { ContestType, type ContestPrefix, type ContestLabelTranslations } from '$lib/types/contest'; -const regexForJag = /^JAG(Prelim|Regional|Summer|Winter|Spring)\d{4}(-day\d+[A-Z]?)?$/; +export const regexForJag = /^JAG(Prelim|Regional|Summer|Winter|Spring)\d{4}(-day\d+[A-Z]?)?$/; export const regexForAojUniversity = /^AOJ-[A-Z]+PC\d{4}/; // See: diff --git a/src/lib/utils/task.ts b/src/lib/utils/task.ts index 226216909..3827cf94b 100644 --- a/src/lib/utils/task.ts +++ b/src/lib/utils/task.ts @@ -9,6 +9,7 @@ import { getPrefixForAojCourses, getContestPriority, regexForAojUniversity, + regexForJag, } from '$lib/utils/contest'; // TODO: Codeforces、yukicoder、BOJなどに対応できるようにする @@ -40,7 +41,7 @@ class AojGenerator implements UrlGenerator { return ( getPrefixForAojCourses().includes(contestId) || contestId.startsWith('PCK') || - contestId.startsWith('JAG') || + regexForJag.test(contestId) || contestId.startsWith('ICPC') || regexForAojUniversity.test(contestId) ); diff --git a/src/test/lib/utils/contest.test.ts b/src/test/lib/utils/contest.test.ts index 323f828ac..85404122d 100644 --- a/src/test/lib/utils/contest.test.ts +++ b/src/test/lib/utils/contest.test.ts @@ -749,6 +749,18 @@ describe('Contest', () => { ); }); }); + + describe('when contest_id means AOJ University (RUPC, HUPC, UAPC)', () => { + TestCasesForContestNameAndTaskIndex.aojUniversity.forEach(({ name, value }) => { + runTests( + `${name}`, + [value], + ({ contestId, taskTableIndex, expected }: TestCaseForContestNameAndTaskIndex) => { + expect(addContestNameToTaskIndex(contestId, taskTableIndex)).toEqual(expected); + }, + ); + }); + }); }); }); diff --git a/src/test/lib/utils/test_cases/contest_name_and_task_index.ts b/src/test/lib/utils/test_cases/contest_name_and_task_index.ts index b877863ce..36208f3ce 100644 --- a/src/test/lib/utils/test_cases/contest_name_and_task_index.ts +++ b/src/test/lib/utils/test_cases/contest_name_and_task_index.ts @@ -888,3 +888,20 @@ const generateAojIcpcTestCases = ( export const aojIcpc = Object.entries(AOJ_ICPC_TEST_DATA).flatMap(([contestId, tasks]) => generateAojIcpcTestCases(Array(tasks.tasks.length).fill(contestId), tasks.tasks), ); + +const AOJ_UNIVERSITY_TEST_DATA = [ + { contestId: 'AOJ-RUPC2018-in-ACPC2018-day1', taskTableIndex: '2903', expected: 'AOJ 2903(RUPC 2018 in ACPC 2018 Day1)' }, + { contestId: 'AOJ-UAPC2019-in-RUPC2019-day2', taskTableIndex: '3058', expected: 'AOJ 3058(ACPC 2019 in RUPC 2019 Day2)' }, + { contestId: 'AOJ-HUPC2020-in-HUPC2020-day1', taskTableIndex: '3171', expected: 'AOJ 3171(HUPC 2020 in HUPC 2020 Day1)' }, + { contestId: 'AOJ-UAPC2003', taskTableIndex: '1000', expected: 'AOJ 1000(ACPC 2003)' }, + { contestId: 'AOJ-UAPC2011-summer', taskTableIndex: '1001', expected: 'AOJ 1001(ACPC 2011 Summer)' }, + { contestId: 'AOJ-UAPC2012-day1', taskTableIndex: '1002', expected: 'AOJ 1002(ACPC 2012 Day1)' }, +]; + +export const aojUniversity = AOJ_UNIVERSITY_TEST_DATA.map(({ contestId, taskTableIndex, expected }) => + createTestCaseForContestNameAndTaskIndex(`AOJ, ${contestId} - ${taskTableIndex}`)({ + contestId, + taskTableIndex, + expected, + }), +); From 5a988074964628d7c9f6f15676af6b385a7bb916 Mon Sep 17 00:00:00 2001 From: "k.hiro1818" Date: Sun, 31 May 2026 13:55:29 +0000 Subject: [PATCH 5/5] style: format AOJ University test cases to multi-line object style Co-Authored-By: Claude Sonnet 4.6 --- .../test_cases/contest_name_and_task_index.ts | 43 ++++++++++++++----- 1 file changed, 32 insertions(+), 11 deletions(-) diff --git a/src/test/lib/utils/test_cases/contest_name_and_task_index.ts b/src/test/lib/utils/test_cases/contest_name_and_task_index.ts index 36208f3ce..dd7366111 100644 --- a/src/test/lib/utils/test_cases/contest_name_and_task_index.ts +++ b/src/test/lib/utils/test_cases/contest_name_and_task_index.ts @@ -890,18 +890,39 @@ export const aojIcpc = Object.entries(AOJ_ICPC_TEST_DATA).flatMap(([contestId, t ); const AOJ_UNIVERSITY_TEST_DATA = [ - { contestId: 'AOJ-RUPC2018-in-ACPC2018-day1', taskTableIndex: '2903', expected: 'AOJ 2903(RUPC 2018 in ACPC 2018 Day1)' }, - { contestId: 'AOJ-UAPC2019-in-RUPC2019-day2', taskTableIndex: '3058', expected: 'AOJ 3058(ACPC 2019 in RUPC 2019 Day2)' }, - { contestId: 'AOJ-HUPC2020-in-HUPC2020-day1', taskTableIndex: '3171', expected: 'AOJ 3171(HUPC 2020 in HUPC 2020 Day1)' }, + { + contestId: 'AOJ-RUPC2018-in-ACPC2018-day1', + taskTableIndex: '2903', + expected: 'AOJ 2903(RUPC 2018 in ACPC 2018 Day1)', + }, + { + contestId: 'AOJ-UAPC2019-in-RUPC2019-day2', + taskTableIndex: '3058', + expected: 'AOJ 3058(ACPC 2019 in RUPC 2019 Day2)', + }, + { + contestId: 'AOJ-HUPC2020-in-HUPC2020-day1', + taskTableIndex: '3171', + expected: 'AOJ 3171(HUPC 2020 in HUPC 2020 Day1)', + }, { contestId: 'AOJ-UAPC2003', taskTableIndex: '1000', expected: 'AOJ 1000(ACPC 2003)' }, - { contestId: 'AOJ-UAPC2011-summer', taskTableIndex: '1001', expected: 'AOJ 1001(ACPC 2011 Summer)' }, - { contestId: 'AOJ-UAPC2012-day1', taskTableIndex: '1002', expected: 'AOJ 1002(ACPC 2012 Day1)' }, + { + contestId: 'AOJ-UAPC2011-summer', + taskTableIndex: '1001', + expected: 'AOJ 1001(ACPC 2011 Summer)', + }, + { + contestId: 'AOJ-UAPC2012-day1', + taskTableIndex: '1002', + expected: 'AOJ 1002(ACPC 2012 Day1)', + }, ]; -export const aojUniversity = AOJ_UNIVERSITY_TEST_DATA.map(({ contestId, taskTableIndex, expected }) => - createTestCaseForContestNameAndTaskIndex(`AOJ, ${contestId} - ${taskTableIndex}`)({ - contestId, - taskTableIndex, - expected, - }), +export const aojUniversity = AOJ_UNIVERSITY_TEST_DATA.map( + ({ contestId, taskTableIndex, expected }) => + createTestCaseForContestNameAndTaskIndex(`AOJ, ${contestId} - ${taskTableIndex}`)({ + contestId, + taskTableIndex, + expected, + }), );