diff --git a/.claude/rules/coding-style.md b/.claude/rules/coding-style.md index 527b7253e..db6c904d3 100644 --- a/.claude/rules/coding-style.md +++ b/.claude/rules/coding-style.md @@ -49,12 +49,20 @@ Extract to `src/lib/utils/` with adjacent tests. Ensure guard clauses don't make later code unreachable. If an early return covers all remaining cases, delete the dead code below it rather than leaving it. +## Dispatcher and Handler Domains + +A classifier must not match more broadly than its handler accepts (`startsWith` classifier → anchored-regex handler throws on the gap). Fix both sides: tighten the classifier, and return `null` instead of throwing. + ## Dead Code: Three-Condition Rule Delete function only if: (1) zero callers, (2) replacement exists, (3) dependent fields also deleted. Before removing an import, grep the entire file for all usages — removing one call site doesn't mean no others exist. +## Barrel Exports + +`index.ts` re-exports public API only. Files within the same module import each other directly — never through the barrel (circular dependency). + ## Residual-Reference Sweeps When removing a dependency or renaming a symbol, sweep the **whole repo**, not just `src`: diff --git a/.claude/rules/testing.md b/.claude/rules/testing.md index b804f1c4f..c8b063393 100644 --- a/.claude/rules/testing.md +++ b/.claude/rules/testing.md @@ -13,11 +13,11 @@ paths: - **Tests ship with implementation** — same commit; feature not done until tests pass. Never defer tests for non-trivial logic - **English only** — describe expected behavior (e.g., `'returns empty array when workbooks is empty'`) -- **Test integrity** — never weaken assertions to make tests pass; fix implementation instead +- **Test integrity** — never weaken assertions to make tests pass; fix implementation instead. Never let the expected value be computed by the code under test: `expect(f(x)).toBe(map.get(expected))` where `f` is `map.get(classify(x))`, or a fixture whose `expected` calls a production function. Both pass unconditionally. Expected values are literals - **Unused imports** — signal missing tests, not dead code — add the test case first - **TDD exceptions** — skip test-first for exploratory spikes, type-only changes, and config files with no branching logic - **Component testing** — extract logic to `utils/`; omit component Vitest if template-only **and** E2E covers rendering paths -- **Coverage** — cover happy path, error cases, and domain edge cases (empty arrays, null, enum extremes). Treat low coverage as a signal to review, not a target +- **Coverage** — cover happy path, error cases, and domain edge cases of the input values (empty arrays, null, enum extremes); for registry coverage see Assertions & Structure. Treat low coverage as a signal to review, not a target ## File Layout & Environment @@ -40,6 +40,8 @@ paths: - `Promise`: use `await fn()` to assert no throw, or `.resolves.toBeUndefined()`. **Never** bare `.resolves` (false-positive) - **Stubs**: parameter types must match production signature — use domain types (`TaskGrade`), not `string` - **Test data**: realistic values (real task IDs, grade names). Extract shared fixtures to file/describe scope; inline for single-use +- **Registry exhaustiveness**: `Record` and `new Map([...])` give no compile-time enum coverage — assert it at runtime, with a named, commented exception list so deliberate gaps stay documented: `expect(Object.values(Enum).filter((key) => !REGISTRY.has(key))).toEqual(KNOWN_GAPS)` (`Record` registries: swap `REGISTRY.has(key)` for the `Object.hasOwn` form above) +- **Test count as a proxy**: after a file split, compare counts (`--reporter=verbose`) — a drop means a describe block was lost in the move. A deliberate drop is valid only when mutation analysis shows detection is unchanged; record the evidence in the commit Group by scenario, not flat: diff --git a/.claude/skills/add-contest-table-provider/instructions.md b/.claude/skills/add-contest-table-provider/instructions.md index 8fbdd838d..1bd29cc75 100644 --- a/.claude/skills/add-contest-table-provider/instructions.md +++ b/.claude/skills/add-contest-table-provider/instructions.md @@ -41,14 +41,18 @@ Step 0 (seed check) is already done. Confirm the following before touching code: ### Tests first - [ ] Add exports to `src/lib/contests/fixtures/contest_type.ts` and `contest_name_labels.ts` -- [ ] Add 3 `describe` blocks to `src/lib/contests/utils/contest.test.ts`: classify / priority / name label -- [ ] `pnpm test:unit src/lib/contests/utils/contest.test.ts` — **RED** +- [ ] Add `describe` block to `src/lib/contests/utils/classification.test.ts` (classify) +- [ ] Add `describe` block to `src/lib/contests/utils/priority.test.ts` (priority) +- [ ] Add `describe` block to `src/lib/contests/utils/labels/index.test.ts` (name label) +- [ ] `pnpm test:unit src/lib/contests/` — **RED** ### Implement -- [ ] Add branches to `classifyContest` / `contestTypePriorities` / `getContestNameLabel` +- [ ] `CONTEST_TYPES_BY_ID` or `CLASSIFICATION_RULES` in `src/lib/contests/utils/classification.ts` +- [ ] `contestTypePriorities` in `src/lib/contests/utils/priority.ts` - After priority insertion, all later entries shift +1 → **update JSDoc numeric ranges** (4 category names are immutable) - Fix hardcoded priority-diff expected values in `src/test/lib/utils/task.test.ts` (-1 per shifted entry) +- [ ] `LABEL_GENERATORS` in `src/lib/contests/utils/labels/index.ts` (add label generator, create per-type file if complex) - [ ] **GREEN** --- diff --git a/.claude/skills/verify-test-strength/SKILL.md b/.claude/skills/verify-test-strength/SKILL.md new file mode 100644 index 000000000..dc208cb00 --- /dev/null +++ b/.claude/skills/verify-test-strength/SKILL.md @@ -0,0 +1,11 @@ +--- +name: verify-test-strength +description: Prove what a test file actually detects by mutating the production source. Use when a test file's value is in doubt — after writing tests for existing behavior, before deleting or compressing tests, or when reviewing a large test file that may be tautological. +argument-hint: '[test-file-path]' +--- + +Measure the detection power of the tests for: $ARGUMENTS + +1. **Baseline** — run the target test file; record the pass count +2. **Mutate** — apply the mutants in [instructions.md](instructions.md) one at a time, restoring after each; record which test names fail +3. **Report** — a mutant × detecting-test matrix; name the missing tests and the deletion candidates diff --git a/.claude/skills/verify-test-strength/instructions.md b/.claude/skills/verify-test-strength/instructions.md new file mode 100644 index 000000000..e531e8305 --- /dev/null +++ b/.claude/skills/verify-test-strength/instructions.md @@ -0,0 +1,39 @@ +# Mutation-Based Test Verification + +Mutate the production source, never the test. A test's worth is what it detects, not how many cases it enumerates. + +## Mutants + +Apply 3–4, one at a time, restoring before the next. + +| Mutant | Detects | +| ------------------------------------ | ------------------------------------- | +| Remove one entry from a lookup table | Registry exhaustiveness | +| Swap two values that encode an order | The ordering contract | +| Make the function return a constant | That the function is exercised at all | +| Invert one guard clause | Boundary handling | + +**Mutants must preserve the original type.** A `0.5` mutant for an integer field is caught by a type check (`Number.isInteger`), not by the assertion being measured — a false positive about the test's strength. + +## Procedure + +```bash +cp /tmp/source.bak # 1. save +perl -0777 -pi -e 's///' # 2. mutate +pnpm exec vitest run --reporter=verbose # 3. run +cp /tmp/source.bak && git diff # 4. restore, then verify +``` + +Repeat per mutant. Step 4 is not optional — an unrestored mutant silently poisons every later measurement. + +## Reading results + +Read stdout from `--reporter=default` / `--reporter=verbose`. **Never read a cached JSON report** (`.vitest/json/output.json`) — under a CLI wrapper it may not be rewritten, leaving a stale file from a previous run that reports the mutant as undetected. + +## Interpreting + +- **A mutant nothing catches** — a missing test. Write it. +- **A test catching nothing that other tests miss** — a deletion candidate. Confirm by re-running the full mutant set without it. +- **N tests failing on one mutant** — not N times the value. Parameterized cases over one fixture usually report the same fact N times; the count is a proxy, not evidence. + +Record the matrix in the commit message or `plan.md` when it justifies removing tests — it is the evidence `.claude/rules/testing.md` requires for a deliberate test-count drop. diff --git a/docs/guides/architecture.md b/docs/guides/architecture.md index db684468c..88710bb36 100644 --- a/docs/guides/architecture.md +++ b/docs/guides/architecture.md @@ -158,7 +158,12 @@ src/lib/ ├── clients/ # 外部 API クライアント(AtCoder Problems, AOJ) ├── components/ # 共通 UI コンポーネント(GradeLabel, TaskGradeList, TaskList, FormWrapper 等) ├── constants/ # アプリ定数 -├── contests/ # コンテスト分類・ラベル生成・優先度(types/, utils/, fixtures/) +├── contests/ # コンテスト分類・ラベル生成・優先度 +│ ├── types/ # ContestType, ContestPrefix 等の型定義 +│ ├── utils/ # classification, priority, task_index_label +│ │ ├── labels/ # 種別ごとのラベル生成(axc, joi, past, aoj, ...) +│ │ └── prefixes.ts # 定数辞書(ABC_LIKE, AOJ_COURSES 等) +│ └── fixtures/ # テストデータ ├── server/ # サーバー専用の共有インフラ │ ├── database.ts # Prisma クライアント(14+ サービスが依存) │ ├── tasks/ # cache.ts など複数 feature 共有のサーバ処理 diff --git a/docs/guides/how-to-add-contest-table-provider.md b/docs/guides/how-to-add-contest-table-provider.md index 70ef94fa9..23c90c82d 100644 --- a/docs/guides/how-to-add-contest-table-provider.md +++ b/docs/guides/how-to-add-contest-table-provider.md @@ -7,7 +7,7 @@ ## 事前確認 - [ ] `ContestType`(`src/lib/contests/types/contest.ts`)— 既存で対応できるか?複数 contest_id を統一表示するなら複合型 -- [ ] `classifyContest()`(`src/lib/contests/utils/contest.ts`)— 新 contest_id に正しい ContestType を返すか +- [ ] `classifyContest()`(`src/lib/contests/utils/classification.ts`)— 新 contest_id に正しい ContestType を返すか - [ ] `prisma/tasks.ts` にデータが存在するか(複合型は `prisma/contest_task_pairs.ts` も) - [ ] 実装パターン判定(後述4パターン) - [ ] JOI: 2026 年より `joi{YYYY}ho` → `joi{YYYY}sf`。regex は `(ho|sf)` 対応済み diff --git a/prisma/seed.ts b/prisma/seed.ts index 68a498fa3..959c921d7 100755 --- a/prisma/seed.ts +++ b/prisma/seed.ts @@ -24,7 +24,7 @@ import { hashPassword } from '../src/features/auth/server/password'; import { getTaskGrade } from '../src/lib/types/task'; import type { PlacementCreate } from '../src/features/workbooks/types/workbook_placement'; -import { classifyContest } from '../src/lib/contests/utils/contest'; +import { classifyContest } from '../src/lib/contests/utils/classification'; import { users, USER_PASSWORD_FOR_SEED } from './users'; import { tasks } from './tasks'; diff --git a/src/features/tasks/utils/contest-table/abc_providers.ts b/src/features/tasks/utils/contest-table/abc_providers.ts index 6dd8d795f..84b68538f 100644 --- a/src/features/tasks/utils/contest-table/abc_providers.ts +++ b/src/features/tasks/utils/contest-table/abc_providers.ts @@ -4,7 +4,7 @@ import { } from '$features/tasks/types/contest-table/contest_table_provider'; import type { TaskResult } from '$lib/types/task'; -import { classifyContest, getContestNameLabel } from '$lib/contests/utils/contest'; +import { classifyContest, getContestNameLabel } from '$lib/contests'; import { ContestTableProviderBase, parseContestRound } from './contest_table_provider_base'; diff --git a/src/features/tasks/utils/contest-table/abs_provider.ts b/src/features/tasks/utils/contest-table/abs_provider.ts index 12b9669e4..8d27f2a1f 100644 --- a/src/features/tasks/utils/contest-table/abs_provider.ts +++ b/src/features/tasks/utils/contest-table/abs_provider.ts @@ -4,7 +4,7 @@ import { } from '$features/tasks/types/contest-table/contest_table_provider'; import type { TaskResult } from '$lib/types/task'; -import { classifyContest } from '$lib/contests/utils/contest'; +import { classifyContest } from '$lib/contests'; import { ContestTableProviderBase } from './contest_table_provider_base'; diff --git a/src/features/tasks/utils/contest-table/acl_providers.ts b/src/features/tasks/utils/contest-table/acl_providers.ts index 86ae9572f..e369eb9c7 100644 --- a/src/features/tasks/utils/contest-table/acl_providers.ts +++ b/src/features/tasks/utils/contest-table/acl_providers.ts @@ -4,7 +4,7 @@ import { } from '$features/tasks/types/contest-table/contest_table_provider'; import type { TaskResult } from '$lib/types/task'; -import { classifyContest } from '$lib/contests/utils/contest'; +import { classifyContest } from '$lib/contests'; import { ContestTableProviderBase } from './contest_table_provider_base'; diff --git a/src/features/tasks/utils/contest-table/agc_provider.ts b/src/features/tasks/utils/contest-table/agc_provider.ts index efb7703af..560a8fa49 100644 --- a/src/features/tasks/utils/contest-table/agc_provider.ts +++ b/src/features/tasks/utils/contest-table/agc_provider.ts @@ -1,7 +1,7 @@ import { type ContestTableMetaData } from '$features/tasks/types/contest-table/contest_table_provider'; import type { TaskResult } from '$lib/types/task'; -import { classifyContest, getContestNameLabel } from '$lib/contests/utils/contest'; +import { classifyContest, getContestNameLabel } from '$lib/contests'; import { ContestTableProviderBase, parseContestRound } from './contest_table_provider_base'; diff --git a/src/features/tasks/utils/contest-table/arc_providers.ts b/src/features/tasks/utils/contest-table/arc_providers.ts index 4751de367..070dc1e01 100644 --- a/src/features/tasks/utils/contest-table/arc_providers.ts +++ b/src/features/tasks/utils/contest-table/arc_providers.ts @@ -4,7 +4,7 @@ import { } from '$features/tasks/types/contest-table/contest_table_provider'; import type { TaskResult } from '$lib/types/task'; -import { classifyContest, getContestNameLabel } from '$lib/contests/utils/contest'; +import { classifyContest, getContestNameLabel } from '$lib/contests'; import { ContestTableProviderBase, parseContestRound } from './contest_table_provider_base'; diff --git a/src/features/tasks/utils/contest-table/awc_provider.ts b/src/features/tasks/utils/contest-table/awc_provider.ts index 0c9293ff6..ad690d1fd 100644 --- a/src/features/tasks/utils/contest-table/awc_provider.ts +++ b/src/features/tasks/utils/contest-table/awc_provider.ts @@ -5,7 +5,7 @@ import { import { ContestType } from '$lib/contests/types/contest'; import type { TaskResult } from '$lib/types/task'; -import { classifyContest, getContestNameLabel } from '$lib/contests/utils/contest'; +import { classifyContest, getContestNameLabel } from '$lib/contests'; import { ContestTableProviderBase, parseContestRound } from './contest_table_provider_base'; diff --git a/src/features/tasks/utils/contest-table/axc_like_provider.ts b/src/features/tasks/utils/contest-table/axc_like_provider.ts index d411d8be5..d70865ff9 100644 --- a/src/features/tasks/utils/contest-table/axc_like_provider.ts +++ b/src/features/tasks/utils/contest-table/axc_like_provider.ts @@ -4,7 +4,7 @@ import { } from '$features/tasks/types/contest-table/contest_table_provider'; import type { TaskResult } from '$lib/types/task'; -import { classifyContest } from '$lib/contests/utils/contest'; +import { classifyContest } from '$lib/contests'; import { ContestTableProviderBase } from './contest_table_provider_base'; diff --git a/src/features/tasks/utils/contest-table/dp_providers.ts b/src/features/tasks/utils/contest-table/dp_providers.ts index 9936ec050..bec79d884 100644 --- a/src/features/tasks/utils/contest-table/dp_providers.ts +++ b/src/features/tasks/utils/contest-table/dp_providers.ts @@ -4,7 +4,7 @@ import { } from '$features/tasks/types/contest-table/contest_table_provider'; import type { TaskResult } from '$lib/types/task'; -import { classifyContest } from '$lib/contests/utils/contest'; +import { classifyContest } from '$lib/contests'; import { ContestTableProviderBase } from './contest_table_provider_base'; diff --git a/src/features/tasks/utils/contest-table/fps24_provider.ts b/src/features/tasks/utils/contest-table/fps24_provider.ts index 488669c77..6fd2e8eb6 100644 --- a/src/features/tasks/utils/contest-table/fps24_provider.ts +++ b/src/features/tasks/utils/contest-table/fps24_provider.ts @@ -4,7 +4,7 @@ import { } from '$features/tasks/types/contest-table/contest_table_provider'; import type { TaskResult } from '$lib/types/task'; -import { classifyContest } from '$lib/contests/utils/contest'; +import { classifyContest } from '$lib/contests'; import { ContestTableProviderBase } from './contest_table_provider_base'; diff --git a/src/features/tasks/utils/contest-table/joi_providers.test.ts b/src/features/tasks/utils/contest-table/joi_providers.test.ts index 46e7cfd99..ce586939f 100644 --- a/src/features/tasks/utils/contest-table/joi_providers.test.ts +++ b/src/features/tasks/utils/contest-table/joi_providers.test.ts @@ -81,7 +81,7 @@ describe('JOIFirstQualRoundProvider', () => { test('expects to handle invalid contest IDs gracefully', () => { const provider = new JOIFirstQualRoundProvider(ContestType.JOI); - expect(provider.getContestRoundLabel('invalid-id')).toBe('INVALID-ID'); // See: getContestNameLabel() in src/lib/contests/utils/contest.ts + expect(provider.getContestRoundLabel('invalid-id')).toBe('INVALID-ID'); // See: getContestNameLabel() in src/lib/contests/ expect(provider.getContestRoundLabel('joi2024yo1d')).toBe('2024d'); // 'd' doesn't match valid round (a|b|c) }); diff --git a/src/features/tasks/utils/contest-table/joi_providers.ts b/src/features/tasks/utils/contest-table/joi_providers.ts index 950ae6841..eb9abd041 100644 --- a/src/features/tasks/utils/contest-table/joi_providers.ts +++ b/src/features/tasks/utils/contest-table/joi_providers.ts @@ -7,7 +7,7 @@ import { import { ContestType } from '$lib/contests/types/contest'; import type { TaskResult } from '$lib/types/task'; -import { classifyContest, getContestNameLabel } from '$lib/contests/utils/contest'; +import { classifyContest, getContestNameLabel } from '$lib/contests'; import { ContestTableProviderBase } from './contest_table_provider_base'; diff --git a/src/features/tasks/utils/contest-table/math_and_algorithm_provider.ts b/src/features/tasks/utils/contest-table/math_and_algorithm_provider.ts index 51e175562..8ded8b1b8 100644 --- a/src/features/tasks/utils/contest-table/math_and_algorithm_provider.ts +++ b/src/features/tasks/utils/contest-table/math_and_algorithm_provider.ts @@ -4,7 +4,7 @@ import { } from '$features/tasks/types/contest-table/contest_table_provider'; import type { TaskResult } from '$lib/types/task'; -import { classifyContest } from '$lib/contests/utils/contest'; +import { classifyContest } from '$lib/contests'; import { ContestTableProviderBase } from './contest_table_provider_base'; diff --git a/src/features/tasks/utils/contest-table/tessoku_book_providers.ts b/src/features/tasks/utils/contest-table/tessoku_book_providers.ts index c11ae6e78..71e1af349 100644 --- a/src/features/tasks/utils/contest-table/tessoku_book_providers.ts +++ b/src/features/tasks/utils/contest-table/tessoku_book_providers.ts @@ -6,7 +6,7 @@ import { import { ContestType } from '$lib/contests/types/contest'; import type { TaskResult } from '$lib/types/task'; -import { classifyContest } from '$lib/contests/utils/contest'; +import { classifyContest } from '$lib/contests'; import { ContestTableProviderBase } from './contest_table_provider_base'; diff --git a/src/features/tasks/utils/contest-table/typical90_provider.ts b/src/features/tasks/utils/contest-table/typical90_provider.ts index 9afd35298..8a2505ef2 100644 --- a/src/features/tasks/utils/contest-table/typical90_provider.ts +++ b/src/features/tasks/utils/contest-table/typical90_provider.ts @@ -4,7 +4,7 @@ import { } from '$features/tasks/types/contest-table/contest_table_provider'; import type { TaskResult } from '$lib/types/task'; -import { classifyContest } from '$lib/contests/utils/contest'; +import { classifyContest } from '$lib/contests'; import { ContestTableProviderBase } from './contest_table_provider_base'; diff --git a/src/features/workbooks/components/detail/WorkBookTasksTable.svelte b/src/features/workbooks/components/detail/WorkBookTasksTable.svelte index 71d62dccd..342c45730 100644 --- a/src/features/workbooks/components/detail/WorkBookTasksTable.svelte +++ b/src/features/workbooks/components/detail/WorkBookTasksTable.svelte @@ -14,7 +14,7 @@ import GradeLabel from '$lib/components/GradeLabel.svelte'; import ExternalLinkWrapper from '$lib/components/ExternalLinkWrapper.svelte'; - import { addContestNameToTaskIndex } from '$lib/contests/utils/contest'; + import { addContestNameToTaskIndex } from '$lib/contests'; import { getTaskUrl, removeTaskIndexFromTitle } from '$lib/utils/task'; import type { diff --git a/src/lib/components/SubmissionStatus/UpdatingModal.svelte b/src/lib/components/SubmissionStatus/UpdatingModal.svelte index 74d94fe74..e5def16c7 100644 --- a/src/lib/components/SubmissionStatus/UpdatingModal.svelte +++ b/src/lib/components/SubmissionStatus/UpdatingModal.svelte @@ -8,7 +8,7 @@ import { submission_statuses } from '$lib/services/submission_status'; import { errorMessageStore } from '$lib/stores/error_message'; - import { getContestNameLabel } from '$lib/contests/utils/contest'; + import { getContestNameLabel } from '$lib/contests'; import InputFieldWrapper from '$lib/components/InputFieldWrapper.svelte'; interface Props { diff --git a/src/lib/components/TagForm.svelte b/src/lib/components/TagForm.svelte index 5561c6145..177b62c2d 100644 --- a/src/lib/components/TagForm.svelte +++ b/src/lib/components/TagForm.svelte @@ -14,7 +14,7 @@ import type { Task } from '$lib/types/task'; import type { Tag } from '$lib/types/tag'; - import { getContestNameLabel } from '$lib/contests/utils/contest'; + import { getContestNameLabel } from '$lib/contests'; import { ATCODER_BASE_CONTEST_URL } from '$lib/constants/urls'; interface Props { diff --git a/src/lib/components/TaskForm.svelte b/src/lib/components/TaskForm.svelte index 1951a4746..5eb7993f9 100644 --- a/src/lib/components/TaskForm.svelte +++ b/src/lib/components/TaskForm.svelte @@ -10,7 +10,7 @@ Label, Button, } from 'flowbite-svelte'; - import { addContestNameToTaskIndex } from '$lib/contests/utils/contest'; + import { addContestNameToTaskIndex } from '$lib/contests'; import { taskGradeValues, type Task } from '$lib/types/task'; import { getTaskGradeLabel, removeTaskIndexFromTitle } from '$lib/utils/task'; diff --git a/src/lib/components/TaskList.svelte b/src/lib/components/TaskList.svelte index d74a8df7d..b8174ef6a 100644 --- a/src/lib/components/TaskList.svelte +++ b/src/lib/components/TaskList.svelte @@ -21,7 +21,7 @@ import { getBackgroundColorFrom } from '$lib/services/submission_status'; - import { addContestNameToTaskIndex } from '$lib/contests/utils/contest'; + import { addContestNameToTaskIndex } from '$lib/contests'; import { countAcceptedTasks, countAllTasks, diff --git a/src/lib/components/TaskListSorted.svelte b/src/lib/components/TaskListSorted.svelte index 0c2a2f286..a58be92ab 100644 --- a/src/lib/components/TaskListSorted.svelte +++ b/src/lib/components/TaskListSorted.svelte @@ -12,7 +12,7 @@ } from 'flowbite-svelte'; import type { TaskResults } from '$lib/types/task'; - import { addContestNameToTaskIndex } from '$lib/contests/utils/contest'; + import { addContestNameToTaskIndex } from '$lib/contests'; import { removeTaskIndexFromTitle } from '$lib/utils/task'; interface Props { 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 23d75007e..2b9310bc1 100644 --- a/src/lib/contests/fixtures/contest_name_and_task_index.ts +++ b/src/lib/contests/fixtures/contest_name_and_task_index.ts @@ -6,7 +6,7 @@ import { getAojContestLabel, PAST_TRANSLATIONS, AOJ_COURSES, -} from '$lib/contests/utils/contest'; +} from '$lib/contests'; export type TestCaseForContestNameAndTaskIndex = { contestId: string; diff --git a/src/lib/contests/fixtures/contest_name_labels.ts b/src/lib/contests/fixtures/contest_name_labels.ts index fc7dad54f..bb1a23509 100644 --- a/src/lib/contests/fixtures/contest_name_labels.ts +++ b/src/lib/contests/fixtures/contest_name_labels.ts @@ -139,6 +139,60 @@ export const fps24 = [ }), ]; +export const aojCourses = [ + createTestCaseForContestNameLabel('AOJ, ITP1')({ + contestId: 'ITP1', + expected: '(プログラミング入門)', + }), + createTestCaseForContestNameLabel('AOJ, ALDS1')({ + contestId: 'ALDS1', + expected: '(アルゴリズムとデータ構造入門)', + }), + createTestCaseForContestNameLabel('AOJ, ITP2')({ + contestId: 'ITP2', + expected: '(プログラミング応用)', + }), + createTestCaseForContestNameLabel('AOJ, DPL')({ + contestId: 'DPL', + expected: '(組み合わせ最適化)', + }), + createTestCaseForContestNameLabel('AOJ, GRL')({ + contestId: 'GRL', + expected: '(グラフ)', + }), + createTestCaseForContestNameLabel('AOJ, DSL')({ + contestId: 'DSL', + expected: '(データ構造)', + }), + createTestCaseForContestNameLabel('AOJ, CGL')({ + contestId: 'CGL', + expected: '(計算幾何学)', + }), + createTestCaseForContestNameLabel('AOJ, NTL')({ + contestId: 'NTL', + expected: '(整数論)', + }), +]; + +export const aojPck = [ + createTestCaseForContestNameLabel('AOJ, PCK Prelim 2024')({ + contestId: 'PCKPrelim2024', + expected: '(パソコン甲子園 予選 2024)', + }), + createTestCaseForContestNameLabel('AOJ, PCK Final 2023')({ + contestId: 'PCKFinal2023', + expected: '(パソコン甲子園 本選 2023)', + }), + createTestCaseForContestNameLabel('AOJ, PCK Prelim 2005')({ + contestId: 'PCKPrelim2005', + expected: '(パソコン甲子園 予選 2005)', + }), + createTestCaseForContestNameLabel('AOJ, PCK Final 2004')({ + contestId: 'PCKFinal2004', + expected: '(パソコン甲子園 本選 2004)', + }), +]; + export const aojJag = [ createTestCaseForContestNameLabel('AOJ, JAG Prelim 2016 A')({ contestId: 'JAGPrelim2016A', diff --git a/src/lib/contests/fixtures/contest_type.ts b/src/lib/contests/fixtures/contest_type.ts index bb6a0de05..6686675a0 100644 --- a/src/lib/contests/fixtures/contest_type.ts +++ b/src/lib/contests/fixtures/contest_type.ts @@ -615,7 +615,7 @@ export const atCoderMainOfficialOnsite = [ ]; // See: -// getPrefixForAojCourses() in src/lib/contests/utils/contest.ts +// getPrefixForAojCourses() in src/lib/contests/ const aojCoursesData = [ { name: 'AOJ Courses, ITP1', contestId: 'ITP1' }, { name: 'AOJ Courses, ALDS1', contestId: 'ALDS1' }, diff --git a/src/lib/contests/index.ts b/src/lib/contests/index.ts new file mode 100644 index 000000000..bcedf6b37 --- /dev/null +++ b/src/lib/contests/index.ts @@ -0,0 +1,25 @@ +// Classification +export { classifyContest } from './utils/classification'; +export { isWorldTourFinals, getWorldTourFinalsLabel } from './utils/labels/world_tour_finals'; + +// Priority +export { getContestPriority, contestTypePriorities } from './utils/priority'; + +// Labels +export { getContestNameLabel } from './utils/labels/index'; +export { getPastContestLabel, PAST_TRANSLATIONS } from './utils/labels/past'; +export { getJoiContestLabel } from './utils/labels/joi'; +export { getAtCoderUniversityContestLabel } from './utils/labels/universities'; +export { getAojContestLabel } from './utils/labels/aoj'; + +// Task index label +export { addContestNameToTaskIndex } from './utils/task_index_label'; + +// Constants +export { + AOJ_COURSES, + getPrefixForAojCourses, + getContestPrefixes, + regexForJag, + regexForAojUniversity, +} from './utils/prefixes'; diff --git a/src/lib/contests/utils/classification.test.ts b/src/lib/contests/utils/classification.test.ts new file mode 100644 index 000000000..a5ab90258 --- /dev/null +++ b/src/lib/contests/utils/classification.test.ts @@ -0,0 +1,253 @@ +import { expect } from 'vitest'; + +import { ContestType } from '$lib/contests/types/contest'; +import { runTests } from '../../../test/lib/common/test_helpers'; +import * as TestCasesForContestType from '$lib/contests/fixtures/contest_type'; +import { type TestCaseForContestType } from '$lib/contests/fixtures/contest_type'; +import { classifyContest } from '$lib/contests/utils/classification'; + +describe('classify contest', () => { + describe('AtCoder', () => { + describe('when contest_id is abs', () => { + TestCasesForContestType.abs.forEach(({ name, value }) => { + runTests(`${name}`, [value], ({ contestId, expected }: TestCaseForContestType) => { + expect(classifyContest(contestId)).toEqual(expected); + }); + }); + }); + + describe('when contest_id contains abc', () => { + TestCasesForContestType.abc.forEach(({ name, value }) => { + runTests(`${name}`, [value], ({ contestId, expected }: TestCaseForContestType) => { + expect(classifyContest(contestId)).toEqual(expected); + }); + }); + }); + + describe('when contest_id starts with APG4b', () => { + TestCasesForContestType.apg4b.forEach(({ name, value }) => { + runTests(`${name}`, [value], ({ contestId, expected }: TestCaseForContestType) => { + expect(classifyContest(contestId)).toEqual(expected); + }); + }); + }); + + describe('when contest_id is typical90', () => { + TestCasesForContestType.typical90.forEach(({ name, value }) => { + runTests(`${name}`, [value], ({ contestId, expected }: TestCaseForContestType) => { + expect(classifyContest(contestId)).toEqual(expected); + }); + }); + }); + + describe('when contest_id is dp (EDPC)', () => { + TestCasesForContestType.edpc.forEach(({ name, value }) => { + runTests(`${name}`, [value], ({ contestId, expected }: TestCaseForContestType) => { + expect(classifyContest(contestId)).toEqual(expected); + }); + }); + }); + + describe('when contest_id is tdpc', () => { + TestCasesForContestType.tdpc.forEach(({ name, value }) => { + runTests(`${name}`, [value], ({ contestId, expected }: TestCaseForContestType) => { + expect(classifyContest(contestId)).toEqual(expected); + }); + }); + }); + + describe('when contest_id is ndpc', () => { + TestCasesForContestType.ndpc.forEach(({ name, value }) => { + runTests(`${name}`, [value], ({ contestId, expected }: TestCaseForContestType) => { + expect(classifyContest(contestId)).toEqual(expected); + }); + }); + }); + + describe('when contest_id contains past', () => { + TestCasesForContestType.past.forEach(({ name, value }) => { + runTests(`${name}`, [value], ({ contestId, expected }: TestCaseForContestType) => { + expect(classifyContest(contestId)).toEqual(expected); + }); + }); + }); + + describe('when contest_id is practice2 (ACL practice)', () => { + TestCasesForContestType.aclPractice.forEach(({ name, value }) => { + runTests(`${name}`, [value], ({ contestId, expected }: TestCaseForContestType) => { + expect(classifyContest(contestId)).toEqual(expected); + }); + }); + }); + + describe('when contest_id contains joi', () => { + TestCasesForContestType.joi.forEach(({ name, value }) => { + runTests(`${name}`, [value], ({ contestId, expected }: TestCaseForContestType) => { + expect(classifyContest(contestId)).toEqual(expected); + }); + }); + }); + + describe('when contest_id is tessoku-book', () => { + TestCasesForContestType.tessokuBook.forEach(({ name, value }) => { + runTests(`${name}`, [value], ({ contestId, expected }: TestCaseForContestType) => { + expect(classifyContest(contestId)).toEqual(expected); + }); + }); + }); + + describe('when contest_id is math_and_algorithm', () => { + TestCasesForContestType.mathAndAlgorithm.forEach(({ name, value }) => { + runTests(`${name}`, [value], ({ contestId, expected }: TestCaseForContestType) => { + expect(classifyContest(contestId)).toEqual(expected); + }); + }); + }); + + describe('when contest_id is fps-24', () => { + TestCasesForContestType.fps24.forEach(({ name, value }) => { + runTests(`${name}`, [value], ({ contestId, expected }: TestCaseForContestType) => { + expect(classifyContest(contestId)).toEqual(expected); + }); + }); + }); + + describe('when contest_id contains arc', () => { + TestCasesForContestType.arc.forEach(({ name, value }) => { + runTests(`${name}`, [value], ({ contestId, expected }: TestCaseForContestType) => { + expect(classifyContest(contestId)).toEqual(expected); + }); + }); + }); + + describe('when contest_id contains agc', () => { + TestCasesForContestType.agc.forEach(({ name, value }) => { + runTests(`${name}`, [value], ({ contestId, expected }: TestCaseForContestType) => { + expect(classifyContest(contestId)).toEqual(expected); + }); + }); + }); + + describe('when contest_id means abc-like', () => { + TestCasesForContestType.abcLike.forEach(({ name, value }) => { + runTests(`${name}`, [value], ({ contestId, expected }: TestCaseForContestType) => { + expect(classifyContest(contestId)).toEqual(expected); + }); + }); + }); + + describe('when contest_id means arc-like', () => { + TestCasesForContestType.arcLike.forEach(({ name, value }) => { + runTests(`${name}`, [value], ({ contestId, expected }: TestCaseForContestType) => { + expect(classifyContest(contestId)).toEqual(expected); + }); + }); + }); + + describe('when contest_id means agc-like', () => { + TestCasesForContestType.agcLike.forEach(({ name, value }) => { + runTests(`${name}`, [value], ({ contestId, expected }: TestCaseForContestType) => { + expect(classifyContest(contestId)).toEqual(expected); + }); + }); + }); + + describe('when contest_id contains awc', () => { + TestCasesForContestType.awc.forEach(({ name, value }) => { + runTests(`${name}`, [value], ({ contestId, expected }: TestCaseForContestType) => { + expect(classifyContest(contestId)).toEqual(expected); + }); + }); + }); + + describe('when contest_id matches contests held by university students', () => { + TestCasesForContestType.universities.forEach(({ name, value }) => { + runTests(`${name}`, [value], ({ contestId, expected }: TestCaseForContestType) => { + expect(classifyContest(contestId)).toEqual(expected); + }); + }); + }); + + describe('when contest_id mean AtCoder others', () => { + TestCasesForContestType.atCoderOthers.forEach(({ name, value }) => { + runTests(`${name}`, [value], ({ contestId, expected }: TestCaseForContestType) => { + expect(classifyContest(contestId)).toEqual(expected); + }); + }); + }); + + describe('when contest_id means AtCoder World Tour Finals (official onsite finals)', () => { + TestCasesForContestType.atCoderMainOfficialOnsite.forEach(({ name, value }) => { + runTests(`${name}`, [value], ({ contestId, expected }: TestCaseForContestType) => { + expect(classifyContest(contestId)).toEqual(expected); + }); + }); + }); + + describe('when contest_id is awtf2025heuristic (Heuristic division, out of scope)', () => { + test('returns null', () => { + expect(classifyContest('awtf2025heuristic')).toBeNull(); + }); + }); + + describe('when contest_id lacks the "-open" suffix seen in most seeded data', () => { + test.each(['wtf19', 'wtf22-day1', 'awtf2024', 'awtf2025algo'])( + 'still classifies %s as ATCODER_MAIN_OFFICIAL_ONSITE', + (contestId) => { + expect(classifyContest(contestId)).toEqual(ContestType.ATCODER_MAIN_OFFICIAL_ONSITE); + }, + ); + }); + }); + + describe('AOJ', () => { + describe('when contest_id mean AOJ courses', () => { + TestCasesForContestType.aojCourses.forEach(({ name, value }) => { + runTests(`${name}`, [value], ({ contestId, expected }: TestCaseForContestType) => { + expect(classifyContest(contestId)).toEqual(expected); + }); + }); + }); + + describe('when contest_id mean AOJ PCK (prelim and final) ', () => { + TestCasesForContestType.aojPck.forEach(({ name, value }) => { + runTests(`${name}`, [value], ({ contestId, expected }: TestCaseForContestType) => { + expect(classifyContest(contestId)).toEqual(expected); + }); + }); + }); + + describe('when contest_id means AOJ JAG', () => { + TestCasesForContestType.aojJag.forEach(({ name, value }) => { + runTests(`${name}`, [value], ({ contestId, expected }: TestCaseForContestType) => { + expect(classifyContest(contestId)).toEqual(expected); + }); + }); + }); + + describe('when contest_id is JAG-like but has no 4-digit year', () => { + test.each(['JAGSummer-day2', 'JAGPrelim', 'JAGRegional'])( + 'returns null for %s', + (contestId) => { + expect(classifyContest(contestId)).toBeNull(); + }, + ); + }); + + describe('when contest_id means AOJ ICPC (prelim and regional)', () => { + TestCasesForContestType.aojIcpc.forEach(({ name, value }) => { + runTests(`${name}`, [value], ({ contestId, expected }: TestCaseForContestType) => { + expect(classifyContest(contestId)).toEqual(expected); + }); + }); + }); + + 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); + }); + }); + }); + }); +}); diff --git a/src/lib/contests/utils/classification.ts b/src/lib/contests/utils/classification.ts new file mode 100644 index 000000000..13d293864 --- /dev/null +++ b/src/lib/contests/utils/classification.ts @@ -0,0 +1,79 @@ +import { ContestType } from '$lib/contests/types/contest'; +import { + regexForJag, + regexForAojUniversity, + abcLikePrefixes, + arcLikePrefixes, + agcLikePrefixes, + atCoderUniversityPrefixes, + atCoderOthersPrefixes, + aojCoursePrefixes, +} from './prefixes'; +import { isWorldTourFinals } from './labels/world_tour_finals'; + +export { + isWorldTourFinals, + getWorldTourFinalsLabel, + stripOpenSuffix, +} from './labels/world_tour_finals'; + +// See: +// https://github.com/kenkoooo/AtCoderProblems/blob/master/atcoder-problems-frontend/src/utils/ContestClassifier.ts + +// Exact-match table: O(1) lookup for contest IDs that map to a single type. +const CONTEST_TYPES_BY_ID: ReadonlyMap = new Map([ + ['abs', ContestType.ABS], + ['typical90', ContestType.TYPICAL90], + ['dp', ContestType.EDPC], + ['tdpc', ContestType.TDPC], + ['ndpc', ContestType.NDPC], + ['practice2', ContestType.ACL_PRACTICE], + ['tessoku-book', ContestType.TESSOKU_BOOK], + ['math-and-algorithm', ContestType.MATH_AND_ALGORITHM], + ['fps-24', ContestType.FPS_24], +]); + +type ClassificationRule = { + matches: (contestId: string) => boolean; + type: ContestType; +}; + +// Ordered rules: first match wins. Regex and prefix-based checks. +const CLASSIFICATION_RULES: readonly ClassificationRule[] = [ + // AtCoder numbered contests + { matches: (id) => /^abc\d{3}$/.test(id), type: ContestType.ABC }, + { matches: (id) => /^arc\d{3}$/.test(id), type: ContestType.ARC }, + { matches: (id) => /^agc\d{3}$/.test(id), type: ContestType.AGC }, + { matches: (id) => /^awc\d{4}$/.test(id), type: ContestType.AWC }, + { matches: (id) => id.startsWith('APG4b'), type: ContestType.APG4B }, + { matches: (id) => id.startsWith('past'), type: ContestType.PAST }, + { matches: (id) => id.startsWith('joi'), type: ContestType.JOI }, + { matches: (id) => isWorldTourFinals(id), type: ContestType.ATCODER_MAIN_OFFICIAL_ONSITE }, + // Set-based exact matches + { matches: (id) => abcLikePrefixes.has(id), type: ContestType.ABC_LIKE }, + { matches: (id) => arcLikePrefixes.has(id), type: ContestType.ARC_LIKE }, + // Prefix-based matches + { matches: (id) => agcLikePrefixes.some((p) => id.startsWith(p)), type: ContestType.AGC_LIKE }, + { + matches: (id) => atCoderUniversityPrefixes.some((p) => id.startsWith(p)), + type: ContestType.UNIVERSITY, + }, + { + matches: (id) => atCoderOthersPrefixes.some((p) => id.startsWith(p)), + type: ContestType.OTHERS, + }, + // AOJ + { matches: (id) => aojCoursePrefixes.has(id), type: ContestType.AOJ_COURSES }, + { matches: (id) => /^PCK(Prelim|Final)\d*$/.test(id), type: ContestType.AOJ_PCK }, + { matches: (id) => /^ICPC(Prelim|Regional)\d*$/.test(id), type: ContestType.AOJ_ICPC }, + { matches: (id) => regexForJag.test(id), type: ContestType.AOJ_JAG }, + { matches: (id) => regexForAojUniversity.test(id), type: ContestType.AOJ_UNIVERSITY }, +]; + +export const classifyContest = (contestId: string): ContestType | null => { + const exactMatch = CONTEST_TYPES_BY_ID.get(contestId); + if (exactMatch) return exactMatch; + + const matchedRule = CLASSIFICATION_RULES.find((rule) => rule.matches(contestId)); + return matchedRule?.type ?? null; +}; diff --git a/src/lib/contests/utils/contest.test.ts b/src/lib/contests/utils/contest.test.ts deleted file mode 100644 index eb1600212..000000000 --- a/src/lib/contests/utils/contest.test.ts +++ /dev/null @@ -1,825 +0,0 @@ -import { expect } from 'vitest'; - -import { ContestType } from '$lib/contests/types/contest'; -import { runTests } from '../../../test/lib/common/test_helpers'; -import * as TestCasesForContestType from '$lib/contests/fixtures/contest_type'; -import { type TestCaseForContestType } from '$lib/contests/fixtures/contest_type'; -import * as TestCasesForContestNameLabel from '$lib/contests/fixtures/contest_name_labels'; -import { type TestCaseForContestNameLabel } from '$lib/contests/fixtures/contest_name_labels'; -import * as TestCasesForContestNameAndTaskIndex from '$lib/contests/fixtures/contest_name_and_task_index'; -import { type TestCaseForContestNameAndTaskIndex } from '$lib/contests/fixtures/contest_name_and_task_index'; -import { - classifyContest, - getContestPriority, - contestTypePriorities, - getContestNameLabel, - addContestNameToTaskIndex, - getAtCoderUniversityContestLabel, -} from '$lib/contests/utils/contest'; - -describe('Contest', () => { - describe('classify contest', () => { - describe('AtCoder', () => { - describe('when contest_id is abs', () => { - TestCasesForContestType.abs.forEach(({ name, value }) => { - runTests(`${name}`, [value], ({ contestId, expected }: TestCaseForContestType) => { - expect(classifyContest(contestId)).toEqual(expected); - }); - }); - }); - - describe('when contest_id contains abc', () => { - TestCasesForContestType.abc.forEach(({ name, value }) => { - runTests(`${name}`, [value], ({ contestId, expected }: TestCaseForContestType) => { - expect(classifyContest(contestId)).toEqual(expected); - }); - }); - }); - - describe('when contest_id starts with APG4b', () => { - TestCasesForContestType.apg4b.forEach(({ name, value }) => { - runTests(`${name}`, [value], ({ contestId, expected }: TestCaseForContestType) => { - expect(classifyContest(contestId)).toEqual(expected); - }); - }); - }); - - describe('when contest_id is typical90', () => { - TestCasesForContestType.typical90.forEach(({ name, value }) => { - runTests(`${name}`, [value], ({ contestId, expected }: TestCaseForContestType) => { - expect(classifyContest(contestId)).toEqual(expected); - }); - }); - }); - - describe('when contest_id is dp (EDPC)', () => { - TestCasesForContestType.edpc.forEach(({ name, value }) => { - runTests(`${name}`, [value], ({ contestId, expected }: TestCaseForContestType) => { - expect(classifyContest(contestId)).toEqual(expected); - }); - }); - }); - - describe('when contest_id is tdpc', () => { - TestCasesForContestType.tdpc.forEach(({ name, value }) => { - runTests(`${name}`, [value], ({ contestId, expected }: TestCaseForContestType) => { - expect(classifyContest(contestId)).toEqual(expected); - }); - }); - }); - - describe('when contest_id is ndpc', () => { - TestCasesForContestType.ndpc.forEach(({ name, value }) => { - runTests(`${name}`, [value], ({ contestId, expected }: TestCaseForContestType) => { - expect(classifyContest(contestId)).toEqual(expected); - }); - }); - }); - - describe('when contest_id contains past', () => { - TestCasesForContestType.past.forEach(({ name, value }) => { - runTests(`${name}`, [value], ({ contestId, expected }: TestCaseForContestType) => { - expect(classifyContest(contestId)).toEqual(expected); - }); - }); - }); - - describe('when contest_id is practice2 (ACL practice)', () => { - TestCasesForContestType.aclPractice.forEach(({ name, value }) => { - runTests(`${name}`, [value], ({ contestId, expected }: TestCaseForContestType) => { - expect(classifyContest(contestId)).toEqual(expected); - }); - }); - }); - - describe('when contest_id contains joi', () => { - TestCasesForContestType.joi.forEach(({ name, value }) => { - runTests(`${name}`, [value], ({ contestId, expected }: TestCaseForContestType) => { - expect(classifyContest(contestId)).toEqual(expected); - }); - }); - }); - - describe('when contest_id is tessoku-book', () => { - TestCasesForContestType.tessokuBook.forEach(({ name, value }) => { - runTests(`${name}`, [value], ({ contestId, expected }: TestCaseForContestType) => { - expect(classifyContest(contestId)).toEqual(expected); - }); - }); - }); - - describe('when contest_id is math_and_algorithm', () => { - TestCasesForContestType.mathAndAlgorithm.forEach(({ name, value }) => { - runTests(`${name}`, [value], ({ contestId, expected }: TestCaseForContestType) => { - expect(classifyContest(contestId)).toEqual(expected); - }); - }); - }); - - describe('when contest_id is fps-24', () => { - TestCasesForContestType.fps24.forEach(({ name, value }) => { - runTests(`${name}`, [value], ({ contestId, expected }: TestCaseForContestType) => { - expect(classifyContest(contestId)).toEqual(expected); - }); - }); - }); - - describe('when contest_id contains arc', () => { - TestCasesForContestType.arc.forEach(({ name, value }) => { - runTests(`${name}`, [value], ({ contestId, expected }: TestCaseForContestType) => { - expect(classifyContest(contestId)).toEqual(expected); - }); - }); - }); - - describe('when contest_id contains agc', () => { - TestCasesForContestType.agc.forEach(({ name, value }) => { - runTests(`${name}`, [value], ({ contestId, expected }: TestCaseForContestType) => { - expect(classifyContest(contestId)).toEqual(expected); - }); - }); - }); - - describe('when contest_id means abc-like', () => { - TestCasesForContestType.abcLike.forEach(({ name, value }) => { - runTests(`${name}`, [value], ({ contestId, expected }: TestCaseForContestType) => { - expect(classifyContest(contestId)).toEqual(expected); - }); - }); - }); - - describe('when contest_id means arc-like', () => { - TestCasesForContestType.arcLike.forEach(({ name, value }) => { - runTests(`${name}`, [value], ({ contestId, expected }: TestCaseForContestType) => { - expect(classifyContest(contestId)).toEqual(expected); - }); - }); - }); - - describe('when contest_id means agc-like', () => { - TestCasesForContestType.agcLike.forEach(({ name, value }) => { - runTests(`${name}`, [value], ({ contestId, expected }: TestCaseForContestType) => { - expect(classifyContest(contestId)).toEqual(expected); - }); - }); - }); - - describe('when contest_id contains awc', () => { - TestCasesForContestType.awc.forEach(({ name, value }) => { - runTests(`${name}`, [value], ({ contestId, expected }: TestCaseForContestType) => { - expect(classifyContest(contestId)).toEqual(expected); - }); - }); - }); - - describe('when contest_id matches contests held by university students', () => { - TestCasesForContestType.universities.forEach(({ name, value }) => { - runTests(`${name}`, [value], ({ contestId, expected }: TestCaseForContestType) => { - expect(classifyContest(contestId)).toEqual(expected); - }); - }); - }); - - describe('when contest_id mean AtCoder others', () => { - TestCasesForContestType.atCoderOthers.forEach(({ name, value }) => { - runTests(`${name}`, [value], ({ contestId, expected }: TestCaseForContestType) => { - expect(classifyContest(contestId)).toEqual(expected); - }); - }); - }); - - describe('when contest_id means AtCoder World Tour Finals (official onsite finals)', () => { - TestCasesForContestType.atCoderMainOfficialOnsite.forEach(({ name, value }) => { - runTests(`${name}`, [value], ({ contestId, expected }: TestCaseForContestType) => { - expect(classifyContest(contestId)).toEqual(expected); - }); - }); - }); - - describe('when contest_id is awtf2025heuristic (Heuristic division, out of scope)', () => { - test('returns null', () => { - expect(classifyContest('awtf2025heuristic')).toBeNull(); - }); - }); - - describe('when contest_id lacks the "-open" suffix seen in most seeded data', () => { - test.each(['wtf19', 'wtf22-day1', 'awtf2024', 'awtf2025algo'])( - 'still classifies %s as ATCODER_MAIN_OFFICIAL_ONSITE', - (contestId) => { - expect(classifyContest(contestId)).toEqual(ContestType.ATCODER_MAIN_OFFICIAL_ONSITE); - }, - ); - }); - }); - - describe('AOJ', () => { - describe('when contest_id mean AOJ courses', () => { - TestCasesForContestType.aojCourses.forEach(({ name, value }) => { - runTests(`${name}`, [value], ({ contestId, expected }: TestCaseForContestType) => { - expect(classifyContest(contestId)).toEqual(expected); - }); - }); - }); - - describe('when contest_id mean AOJ PCK (prelim and final) ', () => { - TestCasesForContestType.aojPck.forEach(({ name, value }) => { - runTests(`${name}`, [value], ({ contestId, expected }: TestCaseForContestType) => { - expect(classifyContest(contestId)).toEqual(expected); - }); - }); - }); - - describe('when contest_id means AOJ JAG', () => { - TestCasesForContestType.aojJag.forEach(({ name, value }) => { - runTests(`${name}`, [value], ({ contestId, expected }: TestCaseForContestType) => { - expect(classifyContest(contestId)).toEqual(expected); - }); - }); - }); - - describe('when contest_id is JAG-like but has no 4-digit year', () => { - test.each(['JAGSummer-day2', 'JAGPrelim', 'JAGRegional'])( - 'returns null for %s', - (contestId) => { - expect(classifyContest(contestId)).toBeNull(); - }, - ); - }); - - describe('when contest_id means AOJ ICPC (prelim and regional)', () => { - TestCasesForContestType.aojIcpc.forEach(({ name, value }) => { - runTests(`${name}`, [value], ({ contestId, expected }: TestCaseForContestType) => { - expect(classifyContest(contestId)).toEqual(expected); - }); - }); - }); - - 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); - }); - }); - }); - }); - }); - - describe('get contest priority', () => { - describe('AtCoder', () => { - describe('when contest_id is abs', () => { - TestCasesForContestType.abs.forEach(({ name, value }) => { - runTests(`${name}`, [value], ({ contestId, expected }: TestCaseForContestType) => { - expect(getContestPriority(contestId)).toEqual(contestTypePriorities.get(expected)); - }); - }); - }); - - describe('when contest_id contains abc', () => { - TestCasesForContestType.abc.forEach(({ name, value }) => { - runTests(`${name}`, [value], ({ contestId, expected }: TestCaseForContestType) => { - expect(getContestPriority(contestId)).toEqual(contestTypePriorities.get(expected)); - }); - }); - }); - - describe('when contest_id starts with APG4b', () => { - TestCasesForContestType.apg4b.forEach(({ name, value }) => { - runTests(`${name}`, [value], ({ contestId, expected }: TestCaseForContestType) => { - expect(getContestPriority(contestId)).toEqual(contestTypePriorities.get(expected)); - }); - }); - }); - - describe('when contest_id is typical90', () => { - TestCasesForContestType.typical90.forEach(({ name, value }) => { - runTests(`${name}`, [value], ({ contestId, expected }: TestCaseForContestType) => { - expect(getContestPriority(contestId)).toEqual(contestTypePriorities.get(expected)); - }); - }); - }); - - describe('when contest_id is dp (EDPC)', () => { - TestCasesForContestType.edpc.forEach(({ name, value }) => { - runTests(`${name}`, [value], ({ contestId, expected }: TestCaseForContestType) => { - expect(getContestPriority(contestId)).toEqual(contestTypePriorities.get(expected)); - }); - }); - }); - - describe('when contest_id is tdpc', () => { - TestCasesForContestType.tdpc.forEach(({ name, value }) => { - runTests(`${name}`, [value], ({ contestId, expected }: TestCaseForContestType) => { - expect(getContestPriority(contestId)).toEqual(contestTypePriorities.get(expected)); - }); - }); - }); - - describe('when contest_id is ndpc', () => { - TestCasesForContestType.ndpc.forEach(({ name, value }) => { - runTests(`${name}`, [value], ({ contestId, expected }: TestCaseForContestType) => { - expect(getContestPriority(contestId)).toEqual(contestTypePriorities.get(expected)); - }); - }); - }); - - describe('when contest_id contains past', () => { - TestCasesForContestType.past.forEach(({ name, value }) => { - runTests(`${name}`, [value], ({ contestId, expected }: TestCaseForContestType) => { - expect(getContestPriority(contestId)).toEqual(contestTypePriorities.get(expected)); - }); - }); - }); - - describe('when contest_id is practice2 (ACL practice)', () => { - TestCasesForContestType.aclPractice.forEach(({ name, value }) => { - runTests(`${name}`, [value], ({ contestId, expected }: TestCaseForContestType) => { - expect(getContestPriority(contestId)).toEqual(contestTypePriorities.get(expected)); - }); - }); - }); - - describe('when contest_id contains joi', () => { - TestCasesForContestType.joi.forEach(({ name, value }) => { - runTests(`${name}`, [value], ({ contestId, expected }: TestCaseForContestType) => { - expect(getContestPriority(contestId)).toEqual(contestTypePriorities.get(expected)); - }); - }); - }); - - describe('when contest_id is tessoku-book', () => { - TestCasesForContestType.tessokuBook.forEach(({ name, value }) => { - runTests(`${name}`, [value], ({ contestId, expected }: TestCaseForContestType) => { - expect(getContestPriority(contestId)).toEqual(contestTypePriorities.get(expected)); - }); - }); - }); - - describe('when contest_id is math-and-algorithm', () => { - TestCasesForContestType.mathAndAlgorithm.forEach(({ name, value }) => { - runTests(`${name}`, [value], ({ contestId, expected }: TestCaseForContestType) => { - expect(getContestPriority(contestId)).toEqual(contestTypePriorities.get(expected)); - }); - }); - }); - - describe('when contest_id is fps-24', () => { - TestCasesForContestType.fps24.forEach(({ name, value }) => { - runTests(`${name}`, [value], ({ contestId, expected }: TestCaseForContestType) => { - expect(getContestPriority(contestId)).toEqual(contestTypePriorities.get(expected)); - }); - }); - }); - - describe('when contest_id contains arc', () => { - TestCasesForContestType.arc.forEach(({ name, value }) => { - runTests(`${name}`, [value], ({ contestId, expected }: TestCaseForContestType) => { - expect(getContestPriority(contestId)).toEqual(contestTypePriorities.get(expected)); - }); - }); - }); - - describe('when contest_id contains agc', () => { - TestCasesForContestType.agc.forEach(({ name, value }) => { - runTests(`${name}`, [value], ({ contestId, expected }: TestCaseForContestType) => { - expect(getContestPriority(contestId)).toEqual(contestTypePriorities.get(expected)); - }); - }); - }); - - describe('when contest_id means abc-like', () => { - TestCasesForContestType.abcLike.forEach(({ name, value }) => { - runTests(`${name}`, [value], ({ contestId, expected }: TestCaseForContestType) => { - expect(getContestPriority(contestId)).toEqual(contestTypePriorities.get(expected)); - }); - }); - }); - - describe('when contest_id means arc-like', () => { - TestCasesForContestType.arcLike.forEach(({ name, value }) => { - runTests(`${name}`, [value], ({ contestId, expected }: TestCaseForContestType) => { - expect(getContestPriority(contestId)).toEqual(contestTypePriorities.get(expected)); - }); - }); - }); - - describe('when contest_id means agc-like', () => { - TestCasesForContestType.agcLike.forEach(({ name, value }) => { - runTests(`${name}`, [value], ({ contestId, expected }: TestCaseForContestType) => { - expect(getContestPriority(contestId)).toEqual(contestTypePriorities.get(expected)); - }); - }); - }); - - describe('when contest_id contains awc', () => { - TestCasesForContestType.awc.forEach(({ name, value }) => { - runTests(`${name}`, [value], ({ contestId, expected }: TestCaseForContestType) => { - expect(getContestPriority(contestId)).toEqual(contestTypePriorities.get(expected)); - }); - }); - }); - - describe('when contest_id matches contests held by university students', () => { - TestCasesForContestType.universities.forEach(({ name, value }) => { - runTests(`${name}`, [value], ({ contestId, expected }: TestCaseForContestType) => { - expect(getContestPriority(contestId)).toEqual(contestTypePriorities.get(expected)); - }); - }); - }); - - describe('when contest_id means AtCoder others', () => { - TestCasesForContestType.atCoderOthers.forEach(({ name, value }) => { - runTests(`${name}`, [value], ({ contestId, expected }: TestCaseForContestType) => { - expect(getContestPriority(contestId)).toEqual(contestTypePriorities.get(expected)); - }); - }); - }); - - describe('when contest_id means AtCoder World Tour Finals (official onsite finals)', () => { - TestCasesForContestType.atCoderMainOfficialOnsite.forEach(({ name, value }) => { - runTests(`${name}`, [value], ({ contestId, expected }: TestCaseForContestType) => { - expect(getContestPriority(contestId)).toEqual(contestTypePriorities.get(expected)); - }); - }); - }); - }); - - describe('AOJ', () => { - describe('when contest_id means AOJ courses', () => { - TestCasesForContestType.aojCourses.forEach(({ name, value }) => { - runTests(`${name}`, [value], ({ contestId, expected }: TestCaseForContestType) => { - expect(getContestPriority(contestId)).toEqual(contestTypePriorities.get(expected)); - }); - }); - }); - - describe('when contest_id means AOJ PCK (prelim and final)', () => { - TestCasesForContestType.aojPck.forEach(({ name, value }) => { - runTests(`${name}`, [value], ({ contestId, expected }: TestCaseForContestType) => { - expect(getContestPriority(contestId)).toEqual(contestTypePriorities.get(expected)); - }); - }); - }); - - describe('when contest_id means AOJ JAG', () => { - TestCasesForContestType.aojJag.forEach(({ name, value }) => { - runTests(`${name}`, [value], ({ contestId, expected }: TestCaseForContestType) => { - expect(getContestPriority(contestId)).toEqual(contestTypePriorities.get(expected)); - }); - }); - }); - - describe('when contest_id means AOJ ICPC (prelim and regional)', () => { - TestCasesForContestType.aojIcpc.forEach(({ name, value }) => { - runTests(`${name}`, [value], ({ contestId, expected }: TestCaseForContestType) => { - expect(getContestPriority(contestId)).toEqual(contestTypePriorities.get(expected)); - }); - }); - }); - - 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)); - }); - }); - }); - }); - }); - - describe('get contest name label', () => { - describe('AtCoder', () => { - describe('when contest_id is dp (EDPC)', () => { - TestCasesForContestNameLabel.edpc.forEach(({ name, value }) => { - runTests(`${name}`, [value], ({ contestId, expected }: TestCaseForContestNameLabel) => { - expect(getContestNameLabel(contestId)).toEqual(expected); - }); - }); - }); - - describe('when contest_id is tdpc', () => { - TestCasesForContestNameLabel.tdpc.forEach(({ name, value }) => { - runTests(`${name}`, [value], ({ contestId, expected }: TestCaseForContestNameLabel) => { - expect(getContestNameLabel(contestId)).toEqual(expected); - }); - }); - }); - - describe('when contest_id is ndpc', () => { - TestCasesForContestNameLabel.ndpc.forEach(({ name, value }) => { - runTests(`${name}`, [value], ({ contestId, expected }: TestCaseForContestNameLabel) => { - expect(getContestNameLabel(contestId)).toEqual(expected); - }); - }); - }); - - describe('when contest_id is practice2 (ACL practice)', () => { - TestCasesForContestNameLabel.aclPractice.forEach(({ name, value }) => { - runTests(`${name}`, [value], ({ contestId, expected }: TestCaseForContestNameLabel) => { - expect(getContestNameLabel(contestId)).toEqual(expected); - }); - }); - }); - - describe('when contest_id contains chokudai_S', () => { - TestCasesForContestNameLabel.atCoderOthers.forEach(({ name, value }) => { - runTests(`${name}`, [value], ({ contestId, expected }: TestCaseForContestNameLabel) => { - expect(getContestNameLabel(contestId)).toEqual(expected); - }); - }); - }); - - describe('when contest_id is math-and-algorithm', () => { - TestCasesForContestNameLabel.mathAndAlgorithm.forEach(({ name, value }) => { - runTests(`${name}`, [value], ({ contestId, expected }: TestCaseForContestNameLabel) => { - expect(getContestNameLabel(contestId)).toEqual(expected); - }); - }); - }); - - describe('when contest_id is fps-24', () => { - TestCasesForContestNameLabel.fps24.forEach(({ name, value }) => { - runTests(`${name}`, [value], ({ contestId, expected }: TestCaseForContestNameLabel) => { - expect(getContestNameLabel(contestId)).toEqual(expected); - }); - }); - }); - - describe('when contest_id contains awc', () => { - TestCasesForContestNameLabel.awc.forEach(({ name, value }) => { - runTests(`${name}`, [value], ({ contestId, expected }: TestCaseForContestNameLabel) => { - expect(getContestNameLabel(contestId)).toEqual(expected); - }); - }); - }); - - describe('when contest_id means AtCoder World Tour Finals (official onsite finals)', () => { - TestCasesForContestNameLabel.atCoderMainOfficialOnsite.forEach(({ name, value }) => { - runTests(`${name}`, [value], ({ contestId, expected }: TestCaseForContestNameLabel) => { - expect(getContestNameLabel(contestId)).toEqual(expected); - }); - }); - }); - }); - - describe('AOJ', () => { - describe('when contest_id means AOJ JAG', () => { - TestCasesForContestNameLabel.aojJag.forEach(({ name, value }) => { - runTests(`${name}`, [value], ({ contestId, expected }: TestCaseForContestNameLabel) => { - expect(getContestNameLabel(contestId)).toEqual(expected); - }); - }); - }); - - describe('when contest_id is JAG-like but has no 4-digit year', () => { - test.each(['JAGSummer-day2', 'JAGPrelim', 'JAGRegional-day1'])( - 'does not return a JAG-style label for %s', - (contestId) => { - expect(getContestNameLabel(contestId)).not.toMatch(/^(/); - }, - ); - }); - - describe('when contest_id means AOJ ICPC (prelim and regional)', () => { - TestCasesForContestNameLabel.aojIcpc.forEach(({ name, value }) => { - runTests(`${name}`, [value], ({ contestId, expected }: TestCaseForContestNameLabel) => { - expect(getContestNameLabel(contestId)).toEqual(expected); - }); - }); - }); - - 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); - }); - }); - }); - }); - }); - - describe('add contest name to task index', () => { - describe('AtCoder', () => { - describe('when contest_id contains abc', () => { - TestCasesForContestNameAndTaskIndex.abc.forEach(({ name, value }) => { - runTests( - `${name}`, - [value], - ({ contestId, taskTableIndex, expected }: TestCaseForContestNameAndTaskIndex) => { - expect(addContestNameToTaskIndex(contestId, taskTableIndex)).toEqual(expected); - }, - ); - }); - }); - - describe('when contest_id starts with APG4b', () => { - TestCasesForContestNameAndTaskIndex.apg4b.forEach(({ name, value }) => { - runTests( - `${name}`, - [value], - ({ contestId, taskTableIndex, expected }: TestCaseForContestNameAndTaskIndex) => { - expect(addContestNameToTaskIndex(contestId, taskTableIndex)).toEqual(expected); - }, - ); - }); - }); - - describe('when contest_id is typical90', () => { - TestCasesForContestNameAndTaskIndex.typical90.forEach(({ name, value }) => { - runTests( - `${name}`, - [value], - ({ contestId, taskTableIndex, expected }: TestCaseForContestNameAndTaskIndex) => { - expect(addContestNameToTaskIndex(contestId, taskTableIndex)).toEqual(expected); - }, - ); - }); - }); - - describe('when contest_id contains past', () => { - TestCasesForContestNameAndTaskIndex.past.forEach(({ name, value }) => { - runTests( - `${name}`, - [value], - ({ contestId, taskTableIndex, expected }: TestCaseForContestNameAndTaskIndex) => { - expect(addContestNameToTaskIndex(contestId, taskTableIndex)).toEqual(expected); - }, - ); - }); - }); - - describe('when contest_id contains joi', () => { - TestCasesForContestNameAndTaskIndex.joi.forEach(({ name, value }) => { - runTests( - `${name}`, - [value], - ({ contestId, taskTableIndex, expected }: TestCaseForContestNameAndTaskIndex) => { - expect(addContestNameToTaskIndex(contestId, taskTableIndex)).toEqual(expected); - }, - ); - }); - }); - - describe('when contest_id is tessoku-book', () => { - TestCasesForContestNameAndTaskIndex.tessokuBook.forEach(({ name, value }) => { - runTests( - `${name}`, - [value], - ({ contestId, taskTableIndex, expected }: TestCaseForContestNameAndTaskIndex) => { - expect(addContestNameToTaskIndex(contestId, taskTableIndex)).toEqual(expected); - }, - ); - }); - }); - - describe('when contest_id is math-and-algorithm', () => { - TestCasesForContestNameAndTaskIndex.mathAndAlgorithm.forEach(({ name, value }) => { - runTests( - `${name}`, - [value], - ({ contestId, taskTableIndex, expected }: TestCaseForContestNameAndTaskIndex) => { - expect(addContestNameToTaskIndex(contestId, taskTableIndex)).toEqual(expected); - }, - ); - }); - }); - - describe('when contest_id contains arc', () => { - TestCasesForContestNameAndTaskIndex.arc.forEach(({ name, value }) => { - runTests( - `${name}`, - [value], - ({ contestId, taskTableIndex, expected }: TestCaseForContestNameAndTaskIndex) => { - expect(addContestNameToTaskIndex(contestId, taskTableIndex)).toEqual(expected); - }, - ); - }); - }); - - describe('when contest_id contains agc', () => { - TestCasesForContestNameAndTaskIndex.agc.forEach(({ name, value }) => { - runTests( - `${name}`, - [value], - ({ contestId, taskTableIndex, expected }: TestCaseForContestNameAndTaskIndex) => { - expect(addContestNameToTaskIndex(contestId, taskTableIndex)).toEqual(expected); - }, - ); - }); - }); - - describe('when contest_id contains awc', () => { - TestCasesForContestNameAndTaskIndex.awc.forEach(({ name, value }) => { - runTests( - `${name}`, - [value], - ({ contestId, taskTableIndex, expected }: TestCaseForContestNameAndTaskIndex) => { - expect(addContestNameToTaskIndex(contestId, taskTableIndex)).toEqual(expected); - }, - ); - }); - }); - - describe('when contest_id matches contests held by university students', () => { - TestCasesForContestNameAndTaskIndex.universities.forEach(({ name, value }) => { - runTests( - `${name}`, - [value], - ({ contestId, taskTableIndex, expected }: TestCaseForContestNameAndTaskIndex) => { - expect(addContestNameToTaskIndex(contestId, taskTableIndex)).toEqual(expected); - }, - ); - }); - }); - }); - - describe('AOJ', () => { - describe('when contest_id means AOJ courses', () => { - TestCasesForContestNameAndTaskIndex.aojCourses.forEach(({ name, value }) => { - runTests( - `${name}`, - [value], - ({ contestId, taskTableIndex, expected }: TestCaseForContestNameAndTaskIndex) => { - expect(addContestNameToTaskIndex(contestId, taskTableIndex)).toEqual(expected); - }, - ); - }); - }); - - describe('when contest_id means AOJ PCK (prelim and final)', () => { - TestCasesForContestNameAndTaskIndex.aojPck.forEach(({ name, value }) => { - runTests( - `${name}`, - [value], - ({ contestId, taskTableIndex, expected }: TestCaseForContestNameAndTaskIndex) => { - expect(addContestNameToTaskIndex(contestId, taskTableIndex)).toEqual(expected); - }, - ); - }); - }); - - describe('when contest_id means AOJ JAG', () => { - TestCasesForContestNameAndTaskIndex.aojJag.forEach(({ name, value }) => { - runTests( - `${name}`, - [value], - ({ contestId, taskTableIndex, expected }: TestCaseForContestNameAndTaskIndex) => { - expect(addContestNameToTaskIndex(contestId, taskTableIndex)).toEqual(expected); - }, - ); - }); - }); - - describe('when contest_id is JAG-like but has no 4-digit year', () => { - test.each(['JAGSummer-day2', 'JAGPrelim', 'JAGRegional-day1'])( - 'does not produce AOJ format for %s', - (contestId) => { - expect(addContestNameToTaskIndex(contestId, '1')).not.toMatch(/^AOJ /); - }, - ); - }); - - describe('when contest_id means AOJ ICPC (prelim and regional)', () => { - TestCasesForContestNameAndTaskIndex.aojIcpc.forEach(({ name, value }) => { - runTests( - `${name}`, - [value], - ({ contestId, taskTableIndex, expected }: TestCaseForContestNameAndTaskIndex) => { - expect(addContestNameToTaskIndex(contestId, taskTableIndex)).toEqual(expected); - }, - ); - }); - }); - - 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); - }, - ); - }); - }); - }); - }); - - describe('get AtCoder university contest label', () => { - describe('expected to return correct label for valid format', () => { - test.each([ - ['utpc2019', 'UTPC 2019'], - ['ttpc2022', 'TTPC 2022'], - ])('when %s is given', (input, expected) => { - expect(getAtCoderUniversityContestLabel(input)).toBe(expected); - }); - }); - - describe('expected to be thrown an error if an invalid format is given', () => { - test.each(['utpc24', 'ttpc', 'tupc'])('when %s is given', (input) => { - expect(() => getAtCoderUniversityContestLabel(input)).toThrow( - `Invalid university contest ID format: ${input}`, - ); - }); - }); - }); -}); diff --git a/src/lib/contests/utils/contest.ts b/src/lib/contests/utils/contest.ts deleted file mode 100644 index 0a0ae5706..000000000 --- a/src/lib/contests/utils/contest.ts +++ /dev/null @@ -1,743 +0,0 @@ -import { - ContestType, - type ContestPrefix, - type ContestLabelTranslations, -} from '$lib/contests/types/contest'; - -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: -// https://github.com/kenkoooo/AtCoderProblems/blob/master/atcoder-problems-frontend/src/utils/ContestClassifier.ts - -// Exact-match table: O(1) lookup for contest IDs that map to a single type. -const CONTEST_TYPES_BY_ID: ReadonlyMap = new Map([ - ['abs', ContestType.ABS], - ['typical90', ContestType.TYPICAL90], - ['dp', ContestType.EDPC], - ['tdpc', ContestType.TDPC], - ['ndpc', ContestType.NDPC], - ['practice2', ContestType.ACL_PRACTICE], - ['tessoku-book', ContestType.TESSOKU_BOOK], - ['math-and-algorithm', ContestType.MATH_AND_ALGORITHM], - ['fps-24', ContestType.FPS_24], -]); - -type ClassificationRule = { - matches: (contestId: string) => boolean; - type: ContestType; -}; - -// Ordered rules: first match wins. Regex and prefix-based checks. -const CLASSIFICATION_RULES: readonly ClassificationRule[] = [ - // AtCoder numbered contests - { matches: (id) => /^abc\d{3}$/.test(id), type: ContestType.ABC }, - { matches: (id) => /^arc\d{3}$/.test(id), type: ContestType.ARC }, - { matches: (id) => /^agc\d{3}$/.test(id), type: ContestType.AGC }, - { matches: (id) => /^awc\d{4}$/.test(id), type: ContestType.AWC }, - { matches: (id) => id.startsWith('APG4b'), type: ContestType.APG4B }, - { matches: (id) => id.startsWith('past'), type: ContestType.PAST }, - { matches: (id) => id.startsWith('joi'), type: ContestType.JOI }, - { matches: (id) => isWorldTourFinals(id), type: ContestType.ATCODER_MAIN_OFFICIAL_ONSITE }, - // Set-based exact matches - { matches: (id) => abcLikePrefixes.has(id), type: ContestType.ABC_LIKE }, - { matches: (id) => arcLikePrefixes.has(id), type: ContestType.ARC_LIKE }, - // Prefix-based matches - { matches: (id) => agcLikePrefixes.some((p) => id.startsWith(p)), type: ContestType.AGC_LIKE }, - { - matches: (id) => atCoderUniversityPrefixes.some((p) => id.startsWith(p)), - type: ContestType.UNIVERSITY, - }, - { - matches: (id) => atCoderOthersPrefixes.some((p) => id.startsWith(p)), - type: ContestType.OTHERS, - }, - // AOJ - { matches: (id) => aojCoursePrefixes.has(id), type: ContestType.AOJ_COURSES }, - { matches: (id) => /^PCK(Prelim|Final)\d*$/.test(id), type: ContestType.AOJ_PCK }, - { matches: (id) => /^ICPC(Prelim|Regional)\d*$/.test(id), type: ContestType.AOJ_ICPC }, - { matches: (id) => regexForJag.test(id), type: ContestType.AOJ_JAG }, - { matches: (id) => regexForAojUniversity.test(id), type: ContestType.AOJ_UNIVERSITY }, -]; - -export const classifyContest = (contestId: string): ContestType | null => { - const exactMatch = CONTEST_TYPES_BY_ID.get(contestId); - if (exactMatch) return exactMatch; - - const matchedRule = CLASSIFICATION_RULES.find((rule) => rule.matches(contestId)); - return matchedRule?.type ?? null; -}; - -// HACK: As of December 2025, the following contests are applicable. -// Note: The classification logic may need to be revised when new contests are added. -const ABC_LIKE: ContestPrefix = { - 'tenka1-2017-beginner': 'Tenka1 Programmer Beginner Contest 2017', - abl: 'ACL Beginner Contest', - caddi2018b: 'CADDi 2018 for Beginners', - 'soundhound2018-summer-qual': 'SoundHound Inc. Programming Contest 2018 -Masters Tournament-', - 'tenka1-2018-beginner': 'Tenka1 Programmer Beginner Contest 2018', - aising2019: 'エイシング プログラミング コンテスト 2019', - sumitrust2019: '三井住友信託銀行プログラミングコンテスト2019', - 'tenka1-2019-beginner': 'Tenka1 Programmer Beginner Contest 2019', - aising2020: 'エイシング プログラミング コンテスト 2020', - hhkb2020: 'HHKB プログラミングコンテスト 2020', - 'm-solutions2020': 'M-SOLUTIONS プロコンオープン 2020', - panasonic2020: 'パナソニックプログラミングコンテスト 2020', - jsc2021: '第二回日本最強プログラマー学生選手権', - zone2021: 'ZONeエナジー プログラミングコンテスト “HELLO SPACE”', - 'jsc2025advance-final': '日本最強プログラマー学生選手権~Advance~', -} as const; -const abcLikePrefixes = new Set(getContestPrefixes(ABC_LIKE)); - -const ARC_LIKE: ContestPrefix = { - 'tenka1-2017': 'Tenka1 Programmer Contest 2017', - 'tenka1-2018': 'Tenka1 Programmer Contest 2018', - 'tenka1-2019': 'Tenka1 Programmer Contest 2019', - caddi2018: 'CADDi 2018', - 'dwacon5th-prelims': '第5回 ドワンゴからの挑戦状 予選', - 'dwacon6th-prelims': '第6回 ドワンゴからの挑戦状 予選', - diverta2019: 'diverta 2019 Programming Contest', - keyence2019: 'キーエンス プログラミング コンテスト 2019', - keyence2020: 'キーエンス プログラミング コンテスト 2020', - keyence2021: 'キーエンス プログラミング コンテスト 2021', - 'jsc2019-qual': '第一回日本最強プログラマー学生選手権-予選-', - 'nikkei2019-qual': '全国統一プログラミング王決定戦予選', - acl1: 'ACL Contest 1', -} as const; -const arcLikePrefixes = new Set(getContestPrefixes(ARC_LIKE)); - -const AGC_LIKE: ContestPrefix = { - 'code-festival-2016-qual': 'CODE FESTIVAL 2016 qual', - 'code-festival-2017-qual': 'CODE FESTIVAL 2017 qual', - 'cf16-final': 'CODE FESTIVAL 2016 final', - 'cf17-final': 'CODE FESTIVAL 2017 final', -} as const; -const agcLikePrefixes = getContestPrefixes(AGC_LIKE); - -// HACK: As of September 2025, KUPC, QUPC, UTPC, TTPC and TUPC are included. -// More university contests may be added in the future. -/** - * Maps university contest ID prefixes to their display names. - * - * @example - * { - * kupc: 'KUPC' // Kyoto University Programming Contest - * qupc: 'QUPC' // Kyushu University Programming Contest - * utpc: 'UTPC' // University of Tokyo Programming Contest - * ttpc: 'TTPC' // Tokyo Institute of Technology Programming Contest - * tupc: 'TUPC' // Tohoku University Programming Contest - * wupc: 'WUPC' // Waseda University Programming Contest - * } - * - * @remarks - * When adding new university contests: - * 1. Use lowercase prefix as key - * 2. Use official contest name as value - * 3. Ensure prefix doesn't conflict with existing contest types - */ -const ATCODER_UNIVERSITIES: ContestPrefix = { - kupc: 'KUPC', - qupc: 'QUPC', - utpc: 'UTPC', - ttpc: 'TTPC', - tupc: 'TUPC', - wupc: 'WUPC', -} as const; - -const atCoderUniversityPrefixes = getContestPrefixes(ATCODER_UNIVERSITIES); - -// World Tour Finals (AtCoder official onsite finals), Algorithm division only. -// -// Seeded contest_id values carry a trailing "-open" (e.g. wtf19-open), except -// wtf22-day2 which AtCoder Problems records without it; strip it before matching -// so both forms work and it never leaks into the display label. -// -// From 2025 the id gains an "algo" infix (awtf2025algo-open) to disambiguate -// from the new Heuristic division (awtf2025heuristic), which stays out of scope. -const regexForWorldTourFinals = /^(wtf19|wtf22-day[12]|awtf2024|awtf20\d{2}algo)$/; - -export const isWorldTourFinals = (contestId: string): boolean => - regexForWorldTourFinals.test(stripOpenSuffix(contestId)); - -export const getWorldTourFinalsLabel = (contestId: string): string => { - const base = 'World Tour Finals'; - const id = stripOpenSuffix(contestId); - - if (id === 'wtf19') { - return `${base} 2019`; - } - - const dayMatch = /^wtf22-day([12])$/.exec(id); - - if (dayMatch) { - return `${base} 2022 Day${dayMatch[1]}`; - } - - if (id === 'awtf2024') { - return `${base} 2024`; - } - - const algoMatch = /^awtf(20\d{2})algo$/.exec(id); - - if (algoMatch) { - // "Algorithm" distinguishes from the Heuristic division, introduced in 2025. - return `${base} ${algoMatch[1]} Algorithm`; - } - - return contestId.toUpperCase(); -}; - -const stripOpenSuffix = (contestId: string): string => - contestId.endsWith('-open') ? contestId.slice(0, -'-open'.length) : contestId; - -/** - * Maps other AtCoder contest ID prefixes to their display names. - * Includes special, corporate, and promotional contests that don't fit other categories. - * - * @example - * { - * 'mujin-pc-2018': 'Mujin Programming Challenge 2018', - * 'discovery2016': 'DISCO presents ディスカバリーチャンネル プログラミングコンテスト2016' - * } - * - * @remarks - * When adding new contests: - * 1. Use kebab-case for contest ID prefix as key - * 2. Use official contest name in English or Japanese as value - * 3. Ensure the contest doesn't belong to other specific categories - */ -const ATCODER_OTHERS: ContestPrefix = { - chokudai_S: 'Chokudai SpeedRun', - atc001: 'AtCoder Typical Contest 001', - geocon2013: '幾何コンテスト2013', - 's8pc-3': 'square869120Contest #3', - 's8pc-4': 'square869120Contest #4', - 'maximum-cup-2013': 'Maximum-Cup 2013', - 'maximum-cup-2018': 'Maximum-Cup 2018', - 'code-festival-2014-quala': 'Code Festival 2014 予選 A', - 'code-festival-2014-qualb': 'Code Festival 2014 予選 B', - 'code-festival-2014-final': 'Code Festival 2014 決勝', - 'code-festival-2014-china-open': 'Code Festival 2014 上海', - 'code-festival-2015-qualb': 'Code Festival 2015 予選 B', - 'code-festival-2015-morning-middle': 'CODE FESTIVAL 2015 あさぷろ Middle', - 'code-festival-2015-exhibition': 'CODE FESTIVAL 2015 エキシビション', - 'code-thanks-festival': 'CODE THANKS FESTIVAL', - donuts: 'Donutsプロコンチャレンジ', - indeednow: 'Indeedなう', - 'tkppc4-2': '技術室奥プログラミングコンテスト#4 Day2', - 'dwango2016-prelims': '第2回 ドワンゴからの挑戦状 予選', - 'dwacon2017-prelims': '第3回 ドワンゴからの挑戦状 予選', - 'mujin-pc-2016': 'Mujin Programming Challenge 2016', - 'mujin-pc-2018': 'Mujin Programming Challenge 2018', - 'bitflyer2018-qual': 'codeFlyer (bitFlyer Programming Contest)', - soundhound2018: 'SoundHound Inc. Programming Contest 2018 (春)', - 'pakencamp-2018-day3': 'パ研合宿コンペティション 3日目', - 'pakencamp-2024-day1': 'パ研合宿2024 第1日「SpeedRun」', - 'tenka1-2012-qualB': '天下一プログラマーコンテスト2012予選B', - 'tenka1-2015-quala': '天下一プログラマーコンテスト2015予選A', - 'tenka1-2015-qualb': '天下一プログラマーコンテスト2015予選B', - 'tenka1-2016-final': '天下一プログラマーコンテスト2016本戦', - // Discovery Channel contest featuring algorithm problems - discovery2016: 'DISCO presents ディスカバリーチャンネル プログラミングコンテスト2016', - colopl: 'COLOCON', - gigacode: 'GigaCode', - cpsco2019: 'CPSCO 2019', - 'iroha2019-day4': 'いろはちゃんコンテスト Day4', - 'nikkei2019-final': '全国統一プログラミング王決定戦本戦', - 'jsc2019-final': '第一回日本最強プログラマー学生選手権決勝', - 'jsc2025-final': '第六回日本最強プログラマー学生選手権 -決勝-', - DEGwer2023: 'DEGwer さんの D 論応援コンテスト', - xmascon19: 'Xmas Contest 2019', -} as const; -const atCoderOthersPrefixes = getContestPrefixes(ATCODER_OTHERS); - -// AIZU ONLINE JUDGE AOJ Courses -export const AOJ_COURSES: ContestPrefix = { - ITP1: 'プログラミング入門', - ALDS1: 'アルゴリズムとデータ構造入門', - ITP2: 'プログラミング応用', - DPL: '組み合わせ最適化', - GRL: 'グラフ', - DSL: 'データ構造', - CGL: '計算幾何学', - NTL: '整数論', -} as const; - -export function getPrefixForAojCourses() { - return getContestPrefixes(AOJ_COURSES); -} - -const aojCoursePrefixes = new Set(getPrefixForAojCourses()); // For O(1) lookups - -/** - * Extracts contest prefixes (keys) from a contest prefix object. - * @param contestPrefixes - Object mapping contest IDs to their display names - * @returns Array of contest prefix strings - */ -export function getContestPrefixes(contestPrefixes: Record) { - return Object.keys(contestPrefixes); -} - -/** - * Contest type priorities (0 = Highest, 26 = 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-21): UNIVERSITY, FPS_24, ATCODER_MAIN_OFFICIAL_ONSITE, OTHERS - * - External platforms (22-26): 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 - * and may be adjusted based on future requirements. - * - * See: - * https://jsprimer.net/basic/map-and-set/ - */ -export const contestTypePriorities: Map = new Map([ - [ContestType.ABS, 0], - [ContestType.ABC, 1], - [ContestType.APG4B, 2], - [ContestType.TYPICAL90, 3], - [ContestType.EDPC, 4], - [ContestType.TDPC, 5], - [ContestType.NDPC, 6], - [ContestType.PAST, 7], - [ContestType.ACL_PRACTICE, 8], - [ContestType.JOI, 9], - [ContestType.TESSOKU_BOOK, 10], - [ContestType.MATH_AND_ALGORITHM, 11], - [ContestType.ARC, 12], - [ContestType.AGC, 13], - [ContestType.ABC_LIKE, 14], - [ContestType.ARC_LIKE, 15], - [ContestType.AGC_LIKE, 16], - [ContestType.AWC, 17], - [ContestType.UNIVERSITY, 18], - [ContestType.FPS_24, 19], - [ContestType.ATCODER_MAIN_OFFICIAL_ONSITE, 20], - [ContestType.OTHERS, 21], // AtCoder (その他) - [ContestType.AOJ_COURSES, 22], - [ContestType.AOJ_PCK, 23], - [ContestType.AOJ_ICPC, 24], - [ContestType.AOJ_JAG, 25], - [ContestType.AOJ_UNIVERSITY, 26], -]); - -export function getContestPriority(contestId: string): number { - const contestType = classifyContest(contestId); - const INF: number = 10 ** 3; - - if (contestType === null || contestType === undefined) { - return INF; - } else { - return contestTypePriorities.get(contestType) as number; - } -} - -/** - * Regular expression to match contest codes. - * - * This regex matches strings that start with one of the following prefixes: - * - "abc" - * - "arc" - * - "agc" - * - "awc" - * - * followed by exactly three or four digits. The matching is case-insensitive. - * - * Example matches: - * - "abc376" - * - "ARC128" - * - "agc045" - * - "atc001" - * - "awc0001" - * - * Example non-matches: - * - "xyz123" - * - "abc12" - * - "abc1234" - * - "atc1234" - * - "awc12345" - */ -const regexForAxc = /^(abc|arc|agc|atc)(\d{3})$/i; -const regexForAwc = /^(awc)(\d{4})$/i; - -/** - * Regular expression to match AtCoder University contest identifiers. - * - * The pattern matches strings that: - * - Start with either "ku", "qu", "ut", "tt","tu", or "wu" - * - Followed by "pc" - * - End with exactly year (four digits) - * - * Example matches: - * - "kupc2024" - * - "qupc2018" - * - "utpc2014" - * - "ttpc2022" - * - "tupc2023" - * - "wupc2019" - */ -const regexForAtCoderUniversity = /^(ku|qu|ut|tt|tu|wu)(pc)(\d{4})$/i; - -type LabelGenerator = (contestId: string) => string | null; - -function generateAxcLabel(contestId: string): string { - return contestId.replace( - regexForAxc, - (_, contestType, contestNumber) => `${contestType.toUpperCase()} ${contestNumber}`, - ); -} - -function generateAwcLabel(contestId: string): string { - return contestId.replace( - regexForAwc, - (_, contestType, contestNumber) => `${contestType.toUpperCase()} ${contestNumber}`, - ); -} - -// Handles atc\d{3}, ATCODER_OTHERS dict, chokudai_S prefix, and uppercase fallback. -// classifyContest maps these to OTHERS via prefix match, but the original -// getContestNameLabel dispatched them independently — this chain preserves that behavior. -function generateOthersLabel(contestId: string): string { - if (regexForAxc.test(contestId)) { - return generateAxcLabel(contestId); - } - - const othersLabel = ATCODER_OTHERS[contestId as keyof typeof ATCODER_OTHERS]; - if (othersLabel) return othersLabel; - - if (contestId.startsWith('chokudai_S')) { - return contestId.replace('chokudai_S', 'Chokudai SpeedRun '); - } - - return contestId.toUpperCase(); -} - -const LABEL_GENERATORS: ReadonlyMap = new Map([ - [ContestType.ABC, generateAxcLabel], - [ContestType.ARC, generateAxcLabel], - [ContestType.AGC, generateAxcLabel], - [ContestType.AWC, generateAwcLabel], - [ContestType.APG4B, (id) => id], - [ContestType.TYPICAL90, () => '競プロ典型 90 問'], - [ContestType.EDPC, () => 'EDPC'], - [ContestType.TDPC, () => 'TDPC'], - [ContestType.NDPC, () => 'NDPC'], - [ContestType.PAST, (id) => getPastContestLabel(PAST_TRANSLATIONS, id)], - [ContestType.ACL_PRACTICE, () => 'ACL Practice'], - [ContestType.JOI, (id) => getJoiContestLabel(id)], - [ContestType.TESSOKU_BOOK, () => '競技プログラミングの鉄則'], - [ContestType.MATH_AND_ALGORITHM, () => 'アルゴリズムと数学'], - [ContestType.FPS_24, () => 'FPS 24 題'], - [ContestType.ATCODER_MAIN_OFFICIAL_ONSITE, (id) => getWorldTourFinalsLabel(id)], - [ContestType.UNIVERSITY, (id) => getAtCoderUniversityContestLabel(id)], - [ContestType.OTHERS, generateOthersLabel], - [ContestType.AOJ_COURSES, (id) => getAojContestLabel(AOJ_COURSES, id)], - [ContestType.AOJ_PCK, (id) => getAojContestLabel(PCK_TRANSLATIONS, id)], - [ContestType.AOJ_ICPC, (id) => getAojContestLabel(ICPC_TRANSLATIONS, id)], - [ContestType.AOJ_JAG, (id) => getAojContestLabel(JAG_TRANSLATIONS, id)], - [ContestType.AOJ_UNIVERSITY, (id) => getAojUniversityContestLabel(id)], -]); - -export const getContestNameLabel = (contestId: string): string => { - const contestType = classifyContest(contestId); - if (!contestType) return contestId.toUpperCase(); - - const generator = LABEL_GENERATORS.get(contestType); - if (!generator) return contestId.toUpperCase(); - - return generator(contestId) ?? contestId.toUpperCase(); -}; - -/** - * A mapping of contest dates to their respective Japanese translations. - * Each key represents a date in the format 'YYYYMM', and the corresponding value - * is the Japanese translation indicating the contest number. - * - * Note: - * After the 15th contest, the URL includes the number of times the contest has been held - * - * See: - * https://atcoder.jp/contests/archive?ratedType=0&category=50 - * - * Example: - * - '201912': ' 第 1 回' (The 1st contest in December 2019) - * - '202303': ' 第 14 回' (The 14th contest in March 2023) - */ -export const PAST_TRANSLATIONS = { - '201912': ' 第 1 回', - '202004': ' 第 2 回', - '202005': ' 第 3 回', - '202010': ' 第 4 回', - '202012': ' 第 5 回', - '202104': ' 第 6 回', - '202107': ' 第 7 回', - '202109': ' 第 8 回', - '202112': ' 第 9 回', - '202203': ' 第 10 回', - '202206': ' 第 11 回', - '202209': ' 第 12 回', - '202212': ' 第 13 回', - '202303': ' 第 14 回', -}; - -/** - * A regular expression to match strings that representing the 15th or later PAST contests. - * The string should start with "past" followed by exactly two digits and end with "-open". - * The matching is case-insensitive. - * - * Examples: - * - "past15-open" (matches) - * - "past16-open" (matches) - * - "past99-open" (matches) - */ -const regexForPast = /^past(\d+)-open$/i; - -export function getPastContestLabel( - translations: Readonly, - contestId: string, -): string { - let label = contestId; - - Object.entries(translations).forEach(([abbrEnglish, japanese]) => { - label = label.replace(abbrEnglish, japanese); - }); - - if (label == contestId) { - label = label.replace(regexForPast, (_, round) => { - return `PAST 第 ${round} 回`; - }); - } - - // Remove suffix - return label.replace('-open', '').toUpperCase(); -} - -/** - * Regular expression to match specific patterns in contest identifiers. - * - * The pattern matches strings that follow these rules: - * - Starts with "joi" (case insensitive). - * - Optionally followed by "g" or "open". - * - Optionally represents year (4-digit number). - * - Optionally followed by "yo", "ho", "sc", or "sp" (Qual, Final and Spring camp). - * - Optionally represents year (4-digit number). - * - Optionally followed by "1" or "2" (Qual 1st, 2nd). - * - Optionally followed by "a", "b", or "c" (Round 1, 2 and 3). - * - * Flags: - * - `i`: Case insensitive matching. - * - * Examples: - * - "joi2024yo1a" (matches) - * - "joi2023ho" (matches) - * - "joisc2022" (matches) - * - "joisp2021" (matches) - * - "joig2024-open" (matches) - * - "joisc2024" (matches) - * - "joisp2022" (matches) - * - "joi24yo3d" (does not match) - * - "joi2026sf" (matches) - */ -const regexForJoi = /^(joi)(g|open)*(\d{4})*(yo|ho|sc|sp|sf)*(\d{4})*(1|2)*(a|b|c)*/i; - -/** - * Transforms a contest ID into a formatted contest label. - * - * This function processes the given contest ID by removing specific suffixes - * and applying various transformations to generate a human-readable contest label. - * - * @param contestId - The ID of the contest to be transformed. - * @returns The formatted contest label. - */ -export function getJoiContestLabel(contestId: string): string { - let label = contestId; - // Remove suffix - label = label.replace('-open', ''); - - label = label.replace( - regexForJoi, - (_, base, subType, yearPrefix, division, yearSuffix, qual, qualRound) => { - const SPACE = ' '; - - let newLabel = base.toUpperCase(); - newLabel += addJoiSubTypeIfNeeds(subType); - - if (division !== undefined) { - newLabel += SPACE; - newLabel += addJoiDivisionNameIfNeeds(division, qual); - } - - newLabel += SPACE; - newLabel += addJoiYear(yearSuffix, yearPrefix); - - if (qualRound !== undefined) { - newLabel += SPACE; - newLabel += addJoiQualRoundNameIfNeeds(qualRound); - } - - return newLabel; - }, - ); - - return label; -} - -function addJoiSubTypeIfNeeds(subType: string): string { - if (subType === 'g') { - return subType.toUpperCase(); - } else if (subType === 'open') { - return ' Open'; - } - - return ''; -} - -function addJoiDivisionNameIfNeeds(division: string, qual: string): string { - if (division === 'yo') { - if (qual === undefined) { - return '予選'; - } else if (qual === '1') { - return '一次予選'; - } else if (qual === '2') { - return '二次予選'; - } - } else if (division === 'ho') { - return '本選'; - } else if (division === 'sf') { - return 'セミファイナルステージ'; - } else if (division === 'sc' || division === 'sp') { - return '春合宿'; - } - - return ''; -} - -function addJoiYear(yearSuffix: string, yearPrefix: string): string { - if (yearPrefix !== undefined) { - return yearPrefix; - } else if (yearSuffix !== undefined) { - return yearSuffix; - } - - return ''; -} - -function addJoiQualRoundNameIfNeeds(qualRound: string): string { - if (qualRound === 'a') { - return '第 1 回'; - } else if (qualRound === 'b') { - return '第 2 回'; - } else if (qualRound === 'c') { - return '第 3 回'; - } - - return ''; -} - -/** - * Generates a formatted contest label for AtCoder University contests. - * - * This function takes a contest ID string and replaces parts of it using a regular expression - * to generate a formatted label. The label is constructed by converting the contest type and - * common part to uppercase and appending the contest year. - * - * @param contestId - The ID of the contest to format (ex: utpc2023). - * @returns The formatted contest label (ex: UTPC 2023). - */ -export function getAtCoderUniversityContestLabel(contestId: string): string { - if (!regexForAtCoderUniversity.test(contestId)) { - throw new Error(`Invalid university contest ID format: ${contestId}`); - } - - return contestId.replace( - regexForAtCoderUniversity, - (_, contestType, common, contestYear) => - `${(contestType + common).toUpperCase()} ${contestYear}`, - ); -} - -/** - * Maps PCK contest type abbreviations to their Japanese translations. - * - * @example - * { - * PCK: 'パソコン甲子園', - * Prelim: '予選', - * Final: '本選' - * } - */ -const PCK_TRANSLATIONS = { - PCK: 'パソコン甲子園', - Prelim: ' 予選 ', - 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. - * - * @example - * { - * Prelim: '模擬国内', - * Regional: '模擬地区' - * } - */ -const JAG_TRANSLATIONS = { - Prelim: ' 模擬国内 ', - Regional: ' 模擬地区 ', - Summer: ' 夏合宿 ', - Winter: ' 冬合宿 ', - Spring: ' 春合宿 ', - '-day': ' Day', -}; - -const ICPC_TRANSLATIONS = { - Prelim: ' 国内予選 ', - Regional: ' アジア地区 ', -}; - -export function getAojContestLabel( - translations: Readonly, - contestId: string, -): string { - let label = contestId; - - Object.entries(translations).forEach(([abbrEnglish, japanese]) => { - label = label.replace(abbrEnglish, japanese); - }); - - return '(' + label + ')'; -} - -export const addContestNameToTaskIndex = (contestId: string, taskTableIndex: string): string => { - const contestName = getContestNameLabel(contestId); - - if (isAojContest(contestId)) { - return `AOJ ${taskTableIndex}${contestName}`; - } - - return `${contestName} - ${taskTableIndex}`; -}; - -function isAojContest(contestId: string): boolean { - return ( - aojCoursePrefixes.has(contestId) || - contestId.startsWith('PCK') || - regexForJag.test(contestId) || - contestId.startsWith('ICPC') || - regexForAojUniversity.test(contestId) - ); -} diff --git a/src/lib/contests/utils/labels/aoj.test.ts b/src/lib/contests/utils/labels/aoj.test.ts new file mode 100644 index 000000000..9722362b8 --- /dev/null +++ b/src/lib/contests/utils/labels/aoj.test.ts @@ -0,0 +1,82 @@ +import { expect } from 'vitest'; + +import { AOJ_COURSES } from '$lib/contests/utils/prefixes'; +import { + getAojContestLabel, + getAojUniversityContestLabel, + PCK_TRANSLATIONS, + JAG_TRANSLATIONS, + ICPC_TRANSLATIONS, +} from '$lib/contests/utils/labels/aoj'; + +describe('get AOJ contest label', () => { + describe('AOJ courses', () => { + test.each([ + ['ITP1', '(プログラミング入門)'], + ['ALDS1', '(アルゴリズムとデータ構造入門)'], + ['NTL', '(整数論)'], + ])('converts %s to %s', (contestId, expected) => { + expect(getAojContestLabel(AOJ_COURSES, contestId)).toBe(expected); + }); + }); + + describe('PCK', () => { + test.each([ + ['PCKPrelim2024', '(パソコン甲子園 予選 2024)'], + ['PCKFinal2023', '(パソコン甲子園 本選 2023)'], + ])('converts %s to %s', (contestId, expected) => { + expect(getAojContestLabel(PCK_TRANSLATIONS, contestId)).toBe(expected); + }); + }); + + describe('ICPC', () => { + test.each([ + ['ICPCPrelim2024', '(ICPC 国内予選 2024)'], + ['ICPCRegional2023', '(ICPC アジア地区 2023)'], + ])('converts %s to %s', (contestId, expected) => { + expect(getAojContestLabel(ICPC_TRANSLATIONS, contestId)).toBe(expected); + }); + }); + + describe('JAG', () => { + test.each([ + ['JAGPrelim2024', '(JAG 模擬国内 2024)'], + ['JAGRegional2022', '(JAG 模擬地区 2022)'], + ['JAGSummer2019-day2', '(JAG 夏合宿 2019 Day2)'], + ['JAGWinter2020', '(JAG 冬合宿 2020)'], + ['JAGSpring2018', '(JAG 春合宿 2018)'], + ])('converts %s to %s', (contestId, expected) => { + expect(getAojContestLabel(JAG_TRANSLATIONS, contestId)).toBe(expected); + }); + }); + + describe('when the contest_id contains no translatable token', () => { + test('wraps the contest_id in parentheses unchanged', () => { + expect(getAojContestLabel(PCK_TRANSLATIONS, 'unknown2024')).toBe('(unknown2024)'); + }); + }); +}); + +describe('get AOJ university contest label', () => { + describe('when the contest_id has a joint-contest venue', () => { + test.each([ + ['AOJ-RUPC2018-in-ACPC2018-day1', '(RUPC 2018 in ACPC 2018 Day1)'], + ['AOJ-HUPC2020-in-HUPC2020-day1', '(HUPC 2020 in HUPC 2020 Day1)'], + ['AOJ-OUPC2012-in-RUPC2012-day2', '(OUPC 2012 in RUPC 2012 Day2)'], + ])('converts %s to %s', (contestId, expected) => { + expect(getAojUniversityContestLabel(contestId)).toBe(expected); + }); + }); + + describe('when the contest_id is UAPC', () => { + // UAPC was renamed to ACPC, so the label uses the current name. + test.each([ + ['AOJ-UAPC2003', '(ACPC 2003)'], + ['AOJ-UAPC2012-day1', '(ACPC 2012 Day1)'], + ['AOJ-UAPC2011-summer', '(ACPC 2011 Summer)'], + ['AOJ-UAPC2019-in-RUPC2019-day2', '(ACPC 2019 in RUPC 2019 Day2)'], + ])('converts %s to %s', (contestId, expected) => { + expect(getAojUniversityContestLabel(contestId)).toBe(expected); + }); + }); +}); diff --git a/src/lib/contests/utils/labels/aoj.ts b/src/lib/contests/utils/labels/aoj.ts new file mode 100644 index 000000000..9cf3165a6 --- /dev/null +++ b/src/lib/contests/utils/labels/aoj.ts @@ -0,0 +1,64 @@ +import type { ContestLabelTranslations } from '$lib/contests/types/contest'; + +/** + * Maps PCK contest type abbreviations to their Japanese translations. + * + * @example + * { + * PCK: 'パソコン甲子園', + * Prelim: '予選', + * Final: '本選' + * } + */ +export const PCK_TRANSLATIONS = { + PCK: 'パソコン甲子園', + Prelim: ' 予選 ', + Final: ' 本選 ', +}; + +/** + * Maps JAG contest type abbreviations to their Japanese translations. + * + * @example + * { + * Prelim: '模擬国内', + * Regional: '模擬地区' + * } + */ +export const JAG_TRANSLATIONS = { + Prelim: ' 模擬国内 ', + Regional: ' 模擬地区 ', + Summer: ' 夏合宿 ', + Winter: ' 冬合宿 ', + Spring: ' 春合宿 ', + '-day': ' Day', +}; + +export const ICPC_TRANSLATIONS = { + Prelim: ' 国内予選 ', + Regional: ' アジア地区 ', +}; + +export function getAojContestLabel( + translations: Readonly, + contestId: string, +): string { + let label = contestId; + + Object.entries(translations).forEach(([abbrEnglish, japanese]) => { + label = label.replace(abbrEnglish, japanese); + }); + + return '(' + label + ')'; +} + +export 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 + ')'; +} diff --git a/src/lib/contests/utils/labels/atcoder_others.test.ts b/src/lib/contests/utils/labels/atcoder_others.test.ts new file mode 100644 index 000000000..80109f882 --- /dev/null +++ b/src/lib/contests/utils/labels/atcoder_others.test.ts @@ -0,0 +1,43 @@ +import { expect } from 'vitest'; + +import { generateOthersLabel } from '$lib/contests/utils/labels/atcoder_others'; + +describe('generate AtCoder others label', () => { + describe('when the contest_id matches the AxC pattern', () => { + // The regex is checked before the dictionary, so atc001 keeps its numeric form + // instead of the ATCODER_OTHERS entry 'AtCoder Typical Contest 001'. + test('converts atc001 to ATC 001', () => { + expect(generateOthersLabel('atc001')).toBe('ATC 001'); + }); + }); + + describe('when the contest_id is in the fixed-label dictionary', () => { + test.each([ + ['s8pc-3', 'square869120Contest #3'], + ['donuts', 'Donutsプロコンチャレンジ'], + ['xmascon19', 'Xmas Contest 2019'], + ['DEGwer2023', 'DEGwer さんの D 論応援コンテスト'], + ['code-festival-2014-final', 'Code Festival 2014 決勝'], + ])('converts %s to %s', (contestId, expected) => { + expect(generateOthersLabel(contestId)).toBe(expected); + }); + }); + + describe('when the contest_id starts with chokudai_S', () => { + test.each([ + ['chokudai_S001', 'Chokudai SpeedRun 001'], + ['chokudai_S002', 'Chokudai SpeedRun 002'], + ])('converts %s to %s', (contestId, expected) => { + expect(generateOthersLabel(contestId)).toBe(expected); + }); + }); + + describe('when the contest_id matches nothing', () => { + test.each(['unknown-xyz', 'some-contest-2099'])( + 'falls back to the uppercased %s', + (contestId) => { + expect(generateOthersLabel(contestId)).toBe(contestId.toUpperCase()); + }, + ); + }); +}); diff --git a/src/lib/contests/utils/labels/atcoder_others.ts b/src/lib/contests/utils/labels/atcoder_others.ts new file mode 100644 index 000000000..91f52d48b --- /dev/null +++ b/src/lib/contests/utils/labels/atcoder_others.ts @@ -0,0 +1,20 @@ +import { ATCODER_OTHERS } from '../prefixes'; +import { generateAxcLabel, regexForAxc } from './axc'; + +// Handles atc\d{3}, ATCODER_OTHERS dict, chokudai_S prefix, and uppercase fallback. +// classifyContest maps these to OTHERS via prefix match, but the original +// getContestNameLabel dispatched them independently — this chain preserves that behavior. +export function generateOthersLabel(contestId: string): string { + if (regexForAxc.test(contestId)) { + return generateAxcLabel(contestId); + } + + const othersLabel = ATCODER_OTHERS[contestId as keyof typeof ATCODER_OTHERS]; + if (othersLabel) return othersLabel; + + if (contestId.startsWith('chokudai_S')) { + return contestId.replace('chokudai_S', 'Chokudai SpeedRun '); + } + + return contestId.toUpperCase(); +} diff --git a/src/lib/contests/utils/labels/axc.test.ts b/src/lib/contests/utils/labels/axc.test.ts new file mode 100644 index 000000000..390e1ca2d --- /dev/null +++ b/src/lib/contests/utils/labels/axc.test.ts @@ -0,0 +1,46 @@ +import { expect } from 'vitest'; + +import { generateAxcLabel, generateAwcLabel } from '$lib/contests/utils/labels/axc'; + +describe('generate AxC label', () => { + describe('when a three-digit contest_id is given', () => { + test.each([ + ['abc001', 'ABC 001'], + ['abc376', 'ABC 376'], + ['arc128', 'ARC 128'], + ['agc045', 'AGC 045'], + ['atc001', 'ATC 001'], + ])('converts %s to %s', (contestId, expected) => { + expect(generateAxcLabel(contestId)).toBe(expected); + }); + }); + + describe('when the contest_id is uppercase', () => { + test('normalizes the contest type to uppercase', () => { + expect(generateAxcLabel('ARC128')).toBe('ARC 128'); + }); + }); + + describe('when the digit count does not match', () => { + test.each(['abc12', 'abc1234', 'xyz123'])('returns %s unchanged', (contestId) => { + expect(generateAxcLabel(contestId)).toBe(contestId); + }); + }); +}); + +describe('generate AWC label', () => { + describe('when a four-digit contest_id is given', () => { + test.each([ + ['awc0001', 'AWC 0001'], + ['awc0123', 'AWC 0123'], + ])('converts %s to %s', (contestId, expected) => { + expect(generateAwcLabel(contestId)).toBe(expected); + }); + }); + + describe('when the digit count does not match', () => { + test.each(['awc001', 'awc12345'])('returns %s unchanged', (contestId) => { + expect(generateAwcLabel(contestId)).toBe(contestId); + }); + }); +}); diff --git a/src/lib/contests/utils/labels/axc.ts b/src/lib/contests/utils/labels/axc.ts new file mode 100644 index 000000000..29b73ae18 --- /dev/null +++ b/src/lib/contests/utils/labels/axc.ts @@ -0,0 +1,41 @@ +/** + * Regular expression to match contest codes. + * + * This regex matches strings that start with one of the following prefixes: + * - "abc" + * - "arc" + * - "agc" + * - "awc" + * + * followed by exactly three or four digits. The matching is case-insensitive. + * + * Example matches: + * - "abc376" + * - "ARC128" + * - "agc045" + * - "atc001" + * - "awc0001" + * + * Example non-matches: + * - "xyz123" + * - "abc12" + * - "abc1234" + * - "atc1234" + * - "awc12345" + */ +export const regexForAxc = /^(abc|arc|agc|atc)(\d{3})$/i; +const regexForAwc = /^(awc)(\d{4})$/i; + +export function generateAxcLabel(contestId: string): string { + return contestId.replace( + regexForAxc, + (_, contestType, contestNumber) => `${contestType.toUpperCase()} ${contestNumber}`, + ); +} + +export function generateAwcLabel(contestId: string): string { + return contestId.replace( + regexForAwc, + (_, contestType, contestNumber) => `${contestType.toUpperCase()} ${contestNumber}`, + ); +} diff --git a/src/lib/contests/utils/labels/index.test.ts b/src/lib/contests/utils/labels/index.test.ts new file mode 100644 index 000000000..7c45fc57e --- /dev/null +++ b/src/lib/contests/utils/labels/index.test.ts @@ -0,0 +1,168 @@ +import { expect } from 'vitest'; + +import { ContestType } from '$lib/contests/types/contest'; +import { runTests } from '../../../../test/lib/common/test_helpers'; +import * as TestCasesForContestNameLabel from '$lib/contests/fixtures/contest_name_labels'; +import { type TestCaseForContestNameLabel } from '$lib/contests/fixtures/contest_name_labels'; +import { getContestNameLabel, LABEL_GENERATORS } from '$lib/contests/utils/labels/index'; + +/** + * Contest types that classifyContest can return but LABEL_GENERATORS deliberately + * does not handle yet, so they fall back to the uppercased contest_id. + * + * TODO: Remove this list once contest display names move to the database. + */ +const CONTEST_TYPES_WITHOUT_LABEL_GENERATOR: readonly ContestType[] = [ + // ABS is unwired but harmless: the fallback uppercases 'abs' into the intended 'ABS'. + ContestType.ABS, + // These three have display names in prefixes.ts that the fallback never reaches. + ContestType.ABC_LIKE, + ContestType.ARC_LIKE, + ContestType.AGC_LIKE, +]; + +describe('label generator coverage', () => { + test('every contest type either has a generator or is a known exception', () => { + const missing = Object.values(ContestType).filter( + (contestType) => !LABEL_GENERATORS.has(contestType), + ); + + expect(missing.toSorted()).toEqual([...CONTEST_TYPES_WITHOUT_LABEL_GENERATOR].toSorted()); + }); + + test('registers no generator for a contest type outside ContestType', () => { + const knownTypes = new Set(Object.values(ContestType)); + const unknown = [...LABEL_GENERATORS.keys()].filter( + (contestType) => !knownTypes.has(contestType), + ); + + expect(unknown).toEqual([]); + }); +}); + +describe('get contest name label', () => { + describe('AtCoder', () => { + describe('when contest_id is dp (EDPC)', () => { + TestCasesForContestNameLabel.edpc.forEach(({ name, value }) => { + runTests(`${name}`, [value], ({ contestId, expected }: TestCaseForContestNameLabel) => { + expect(getContestNameLabel(contestId)).toEqual(expected); + }); + }); + }); + + describe('when contest_id is tdpc', () => { + TestCasesForContestNameLabel.tdpc.forEach(({ name, value }) => { + runTests(`${name}`, [value], ({ contestId, expected }: TestCaseForContestNameLabel) => { + expect(getContestNameLabel(contestId)).toEqual(expected); + }); + }); + }); + + describe('when contest_id is ndpc', () => { + TestCasesForContestNameLabel.ndpc.forEach(({ name, value }) => { + runTests(`${name}`, [value], ({ contestId, expected }: TestCaseForContestNameLabel) => { + expect(getContestNameLabel(contestId)).toEqual(expected); + }); + }); + }); + + describe('when contest_id is practice2 (ACL practice)', () => { + TestCasesForContestNameLabel.aclPractice.forEach(({ name, value }) => { + runTests(`${name}`, [value], ({ contestId, expected }: TestCaseForContestNameLabel) => { + expect(getContestNameLabel(contestId)).toEqual(expected); + }); + }); + }); + + describe('when contest_id contains chokudai_S', () => { + TestCasesForContestNameLabel.atCoderOthers.forEach(({ name, value }) => { + runTests(`${name}`, [value], ({ contestId, expected }: TestCaseForContestNameLabel) => { + expect(getContestNameLabel(contestId)).toEqual(expected); + }); + }); + }); + + describe('when contest_id is math-and-algorithm', () => { + TestCasesForContestNameLabel.mathAndAlgorithm.forEach(({ name, value }) => { + runTests(`${name}`, [value], ({ contestId, expected }: TestCaseForContestNameLabel) => { + expect(getContestNameLabel(contestId)).toEqual(expected); + }); + }); + }); + + describe('when contest_id is fps-24', () => { + TestCasesForContestNameLabel.fps24.forEach(({ name, value }) => { + runTests(`${name}`, [value], ({ contestId, expected }: TestCaseForContestNameLabel) => { + expect(getContestNameLabel(contestId)).toEqual(expected); + }); + }); + }); + + describe('when contest_id contains awc', () => { + TestCasesForContestNameLabel.awc.forEach(({ name, value }) => { + runTests(`${name}`, [value], ({ contestId, expected }: TestCaseForContestNameLabel) => { + expect(getContestNameLabel(contestId)).toEqual(expected); + }); + }); + }); + + describe('when contest_id means AtCoder World Tour Finals (official onsite finals)', () => { + TestCasesForContestNameLabel.atCoderMainOfficialOnsite.forEach(({ name, value }) => { + runTests(`${name}`, [value], ({ contestId, expected }: TestCaseForContestNameLabel) => { + expect(getContestNameLabel(contestId)).toEqual(expected); + }); + }); + }); + }); + + describe('AOJ', () => { + describe('when contest_id means AOJ courses', () => { + TestCasesForContestNameLabel.aojCourses.forEach(({ name, value }) => { + runTests(`${name}`, [value], ({ contestId, expected }: TestCaseForContestNameLabel) => { + expect(getContestNameLabel(contestId)).toEqual(expected); + }); + }); + }); + + describe('when contest_id means AOJ PCK (prelim and final)', () => { + TestCasesForContestNameLabel.aojPck.forEach(({ name, value }) => { + runTests(`${name}`, [value], ({ contestId, expected }: TestCaseForContestNameLabel) => { + expect(getContestNameLabel(contestId)).toEqual(expected); + }); + }); + }); + + describe('when contest_id means AOJ JAG', () => { + TestCasesForContestNameLabel.aojJag.forEach(({ name, value }) => { + runTests(`${name}`, [value], ({ contestId, expected }: TestCaseForContestNameLabel) => { + expect(getContestNameLabel(contestId)).toEqual(expected); + }); + }); + }); + + describe('when contest_id is JAG-like but has no 4-digit year', () => { + test.each(['JAGSummer-day2', 'JAGPrelim', 'JAGRegional-day1'])( + 'does not return a JAG-style label for %s', + (contestId) => { + expect(getContestNameLabel(contestId)).not.toMatch(/^(/); + }, + ); + }); + + describe('when contest_id means AOJ ICPC (prelim and regional)', () => { + TestCasesForContestNameLabel.aojIcpc.forEach(({ name, value }) => { + runTests(`${name}`, [value], ({ contestId, expected }: TestCaseForContestNameLabel) => { + expect(getContestNameLabel(contestId)).toEqual(expected); + }); + }); + }); + + 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/lib/contests/utils/labels/index.ts b/src/lib/contests/utils/labels/index.ts new file mode 100644 index 000000000..5d1bc2fdd --- /dev/null +++ b/src/lib/contests/utils/labels/index.ts @@ -0,0 +1,61 @@ +import { ContestType } from '$lib/contests/types/contest'; + +import { classifyContest } from '../classification'; +import { AOJ_COURSES } from '../prefixes'; +import { generateAxcLabel, generateAwcLabel } from './axc'; +import { getAtCoderUniversityContestLabel } from './universities'; +import { getWorldTourFinalsLabel } from './world_tour_finals'; +import { generateOthersLabel } from './atcoder_others'; +import { getJoiContestLabel } from './joi'; +import { getPastContestLabel, PAST_TRANSLATIONS } from './past'; +import { + getAojContestLabel, + getAojUniversityContestLabel, + PCK_TRANSLATIONS, + JAG_TRANSLATIONS, + ICPC_TRANSLATIONS, +} from './aoj'; + +type LabelGenerator = (contestId: string) => string | null; + +export const LABEL_GENERATORS: ReadonlyMap = new Map([ + [ContestType.ABC, generateAxcLabel], + [ContestType.ARC, generateAxcLabel], + [ContestType.AGC, generateAxcLabel], + [ContestType.AWC, generateAwcLabel], + [ContestType.APG4B, (id) => id], + [ContestType.TYPICAL90, () => '競プロ典型 90 問'], + [ContestType.EDPC, () => 'EDPC'], + [ContestType.TDPC, () => 'TDPC'], + [ContestType.NDPC, () => 'NDPC'], + [ContestType.PAST, (id) => getPastContestLabel(PAST_TRANSLATIONS, id)], + [ContestType.ACL_PRACTICE, () => 'ACL Practice'], + [ContestType.JOI, (id) => getJoiContestLabel(id)], + [ContestType.TESSOKU_BOOK, () => '競技プログラミングの鉄則'], + [ContestType.MATH_AND_ALGORITHM, () => 'アルゴリズムと数学'], + [ContestType.FPS_24, () => 'FPS 24 題'], + [ContestType.ATCODER_MAIN_OFFICIAL_ONSITE, (id) => getWorldTourFinalsLabel(id)], + [ContestType.UNIVERSITY, (id) => getAtCoderUniversityContestLabel(id)], + [ContestType.OTHERS, generateOthersLabel], + [ContestType.AOJ_COURSES, (id) => getAojContestLabel(AOJ_COURSES, id)], + [ContestType.AOJ_PCK, (id) => getAojContestLabel(PCK_TRANSLATIONS, id)], + [ContestType.AOJ_ICPC, (id) => getAojContestLabel(ICPC_TRANSLATIONS, id)], + [ContestType.AOJ_JAG, (id) => getAojContestLabel(JAG_TRANSLATIONS, id)], + [ContestType.AOJ_UNIVERSITY, (id) => getAojUniversityContestLabel(id)], +]); + +export const getContestNameLabel = (contestId: string): string => { + const contestType = classifyContest(contestId); + + if (!contestType) { + return contestId.toUpperCase(); + } + + const generator = LABEL_GENERATORS.get(contestType); + + if (!generator) { + return contestId.toUpperCase(); + } + + return generator(contestId) ?? contestId.toUpperCase(); +}; diff --git a/src/lib/contests/utils/labels/joi.test.ts b/src/lib/contests/utils/labels/joi.test.ts new file mode 100644 index 000000000..93b00f4e7 --- /dev/null +++ b/src/lib/contests/utils/labels/joi.test.ts @@ -0,0 +1,39 @@ +import { expect } from 'vitest'; + +import { getJoiContestLabel } from '$lib/contests/utils/labels/joi'; + +describe('get JOI contest label', () => { + describe('qualifying rounds', () => { + test.each([ + ['joi2018yo', 'JOI 予選 2018'], + ['joi2024yo1a', 'JOI 一次予選 2024 第 1 回'], + ['joi2024yo2', 'JOI 二次予選 2024'], + ])('converts %s to %s', (contestId, expected) => { + expect(getJoiContestLabel(contestId)).toBe(expected); + }); + }); + + describe('final and later stages', () => { + test.each([ + ['joi2023ho', 'JOI 本選 2023'], + ['joi2026sf', 'JOI セミファイナルステージ 2026'], + ])('converts %s to %s', (contestId, expected) => { + expect(getJoiContestLabel(contestId)).toBe(expected); + }); + }); + + describe('spring camp', () => { + test.each([ + ['joisc2022', 'JOI 春合宿 2022'], + ['joisp2021', 'JOI 春合宿 2021'], + ])('converts %s to %s', (contestId, expected) => { + expect(getJoiContestLabel(contestId)).toBe(expected); + }); + }); + + describe('JOIG (for girls)', () => { + test('converts joig2024-open to JOIG 2024, stripping the -open suffix', () => { + expect(getJoiContestLabel('joig2024-open')).toBe('JOIG 2024'); + }); + }); +}); diff --git a/src/lib/contests/utils/labels/joi.ts b/src/lib/contests/utils/labels/joi.ts new file mode 100644 index 000000000..1e25f7f99 --- /dev/null +++ b/src/lib/contests/utils/labels/joi.ts @@ -0,0 +1,121 @@ +/** + * Regular expression to match specific patterns in contest identifiers. + * + * The pattern matches strings that follow these rules: + * - Starts with "joi" (case insensitive). + * - Optionally followed by "g" or "open". + * - Optionally represents year (4-digit number). + * - Optionally followed by "yo", "ho", "sc", or "sp" (Qual, Final and Spring camp). + * - Optionally represents year (4-digit number). + * - Optionally followed by "1" or "2" (Qual 1st, 2nd). + * - Optionally followed by "a", "b", or "c" (Round 1, 2 and 3). + * + * Flags: + * - `i`: Case insensitive matching. + * + * Examples: + * - "joi2024yo1a" (matches) + * - "joi2023ho" (matches) + * - "joisc2022" (matches) + * - "joisp2021" (matches) + * - "joig2024-open" (matches) + * - "joisc2024" (matches) + * - "joisp2022" (matches) + * - "joi24yo3d" (does not match) + * - "joi2026sf" (matches) + */ +const regexForJoi = /^(joi)(g|open)*(\d{4})*(yo|ho|sc|sp|sf)*(\d{4})*(1|2)*(a|b|c)*/i; + +/** + * Transforms a contest ID into a formatted contest label. + * + * This function processes the given contest ID by removing specific suffixes + * and applying various transformations to generate a human-readable contest label. + * + * @param contestId - The ID of the contest to be transformed. + * @returns The formatted contest label. + */ +export function getJoiContestLabel(contestId: string): string { + let label = contestId; + // Remove suffix + label = label.replace('-open', ''); + + label = label.replace( + regexForJoi, + (_, base, subType, yearPrefix, division, yearSuffix, qual, qualRound) => { + const SPACE = ' '; + + let newLabel = base.toUpperCase(); + newLabel += addJoiSubTypeIfNeeds(subType); + + if (division !== undefined) { + newLabel += SPACE; + newLabel += addJoiDivisionNameIfNeeds(division, qual); + } + + newLabel += SPACE; + newLabel += addJoiYear(yearSuffix, yearPrefix); + + if (qualRound !== undefined) { + newLabel += SPACE; + newLabel += addJoiQualRoundNameIfNeeds(qualRound); + } + + return newLabel; + }, + ); + + return label; +} + +function addJoiSubTypeIfNeeds(subType: string): string { + if (subType === 'g') { + return subType.toUpperCase(); + } else if (subType === 'open') { + return ' Open'; + } + + return ''; +} + +function addJoiDivisionNameIfNeeds(division: string, qual: string): string { + if (division === 'yo') { + if (qual === undefined) { + return '予選'; + } else if (qual === '1') { + return '一次予選'; + } else if (qual === '2') { + return '二次予選'; + } + } else if (division === 'ho') { + return '本選'; + } else if (division === 'sf') { + return 'セミファイナルステージ'; + } else if (division === 'sc' || division === 'sp') { + return '春合宿'; + } + + return ''; +} + +function addJoiYear(yearSuffix: string, yearPrefix: string): string { + if (yearPrefix !== undefined) { + return yearPrefix; + } else if (yearSuffix !== undefined) { + return yearSuffix; + } + + return ''; +} + +function addJoiQualRoundNameIfNeeds(qualRound: string): string { + if (qualRound === 'a') { + return '第 1 回'; + } else if (qualRound === 'b') { + return '第 2 回'; + } else if (qualRound === 'c') { + return '第 3 回'; + } + + return ''; +} diff --git a/src/lib/contests/utils/labels/past.test.ts b/src/lib/contests/utils/labels/past.test.ts new file mode 100644 index 000000000..e36ed6bbb --- /dev/null +++ b/src/lib/contests/utils/labels/past.test.ts @@ -0,0 +1,25 @@ +import { expect } from 'vitest'; + +import { getPastContestLabel, PAST_TRANSLATIONS } from '$lib/contests/utils/labels/past'; + +describe('get PAST contest label', () => { + describe('contests identified by year and month (1st to 14th)', () => { + test.each([ + ['past201912-open', 'PAST 第 1 回'], + ['past202004-open', 'PAST 第 2 回'], + ['past202303-open', 'PAST 第 14 回'], + ])('converts %s to %s', (contestId, expected) => { + expect(getPastContestLabel(PAST_TRANSLATIONS, contestId)).toBe(expected); + }); + }); + + describe('contests identified by round number (15th onwards)', () => { + test.each([ + ['past15-open', 'PAST 第 15 回'], + ['past16-open', 'PAST 第 16 回'], + ['past99-open', 'PAST 第 99 回'], + ])('converts %s to %s', (contestId, expected) => { + expect(getPastContestLabel(PAST_TRANSLATIONS, contestId)).toBe(expected); + }); + }); +}); diff --git a/src/lib/contests/utils/labels/past.ts b/src/lib/contests/utils/labels/past.ts new file mode 100644 index 000000000..45ff9e968 --- /dev/null +++ b/src/lib/contests/utils/labels/past.ts @@ -0,0 +1,65 @@ +import type { ContestLabelTranslations } from '$lib/contests/types/contest'; + +/** + * A mapping of contest dates to their respective Japanese translations. + * Each key represents a date in the format 'YYYYMM', and the corresponding value + * is the Japanese translation indicating the contest number. + * + * Note: + * After the 15th contest, the URL includes the number of times the contest has been held + * + * See: + * https://atcoder.jp/contests/archive?ratedType=0&category=50 + * + * Example: + * - '201912': ' 第 1 回' (The 1st contest in December 2019) + * - '202303': ' 第 14 回' (The 14th contest in March 2023) + */ +export const PAST_TRANSLATIONS = { + '201912': ' 第 1 回', + '202004': ' 第 2 回', + '202005': ' 第 3 回', + '202010': ' 第 4 回', + '202012': ' 第 5 回', + '202104': ' 第 6 回', + '202107': ' 第 7 回', + '202109': ' 第 8 回', + '202112': ' 第 9 回', + '202203': ' 第 10 回', + '202206': ' 第 11 回', + '202209': ' 第 12 回', + '202212': ' 第 13 回', + '202303': ' 第 14 回', +}; + +/** + * A regular expression to match strings that representing the 15th or later PAST contests. + * The string should start with "past" followed by exactly two digits and end with "-open". + * The matching is case-insensitive. + * + * Examples: + * - "past15-open" (matches) + * - "past16-open" (matches) + * - "past99-open" (matches) + */ +const regexForPast = /^past(\d+)-open$/i; + +export function getPastContestLabel( + translations: Readonly, + contestId: string, +): string { + let label = contestId; + + Object.entries(translations).forEach(([abbrEnglish, japanese]) => { + label = label.replace(abbrEnglish, japanese); + }); + + if (label == contestId) { + label = label.replace(regexForPast, (_, round) => { + return `PAST 第 ${round} 回`; + }); + } + + // Remove suffix + return label.replace('-open', '').toUpperCase(); +} diff --git a/src/lib/contests/utils/labels/universities.test.ts b/src/lib/contests/utils/labels/universities.test.ts new file mode 100644 index 000000000..f53ec21e1 --- /dev/null +++ b/src/lib/contests/utils/labels/universities.test.ts @@ -0,0 +1,28 @@ +import { expect } from 'vitest'; + +import { getAtCoderUniversityContestLabel } from '$lib/contests/utils/labels/universities'; + +describe('get AtCoder university contest label', () => { + describe('expected to return correct label for valid format', () => { + test.each([ + ['kupc2024', 'KUPC 2024'], + ['qupc2018', 'QUPC 2018'], + ['utpc2019', 'UTPC 2019'], + ['ttpc2022', 'TTPC 2022'], + ['tupc2023', 'TUPC 2023'], + ['wupc2019', 'WUPC 2019'], + ['UTPC2019', 'UTPC 2019'], + ])('when %s is given', (input, expected) => { + expect(getAtCoderUniversityContestLabel(input)).toBe(expected); + }); + }); + + describe('expected to return null if an invalid format is given', () => { + test.each(['utpc24', 'ttpc', 'tupc', 'xxpc2024', 'utpc20245', '2024utpc', ''])( + 'when %s is given', + (input) => { + expect(getAtCoderUniversityContestLabel(input)).toBeNull(); + }, + ); + }); +}); diff --git a/src/lib/contests/utils/labels/universities.ts b/src/lib/contests/utils/labels/universities.ts new file mode 100644 index 000000000..9168347fa --- /dev/null +++ b/src/lib/contests/utils/labels/universities.ts @@ -0,0 +1,39 @@ +/** + * Regular expression to match AtCoder University contest identifiers. + * + * The pattern matches strings that: + * - Start with either "ku", "qu", "ut", "tt","tu", or "wu" + * - Followed by "pc" + * - End with exactly year (four digits) + * + * Example matches: + * - "kupc2024" + * - "qupc2018" + * - "utpc2014" + * - "ttpc2022" + * - "tupc2023" + * - "wupc2019" + */ +const regexForAtCoderUniversity = /^(ku|qu|ut|tt|tu|wu)(pc)(\d{4})$/i; + +/** + * Generates a formatted contest label for AtCoder University contests. + * + * classifyContest matches university contests by prefix, so an ID such as + * `utpc24` reaches this function without a four-digit year. Returning null lets + * the caller fall back to the raw contest_id instead of breaking the whole page. + * + * @param contestId - The ID of the contest to format (ex: utpc2023). + * @returns The formatted contest label (ex: UTPC 2023), or null if the format is unknown. + */ +export function getAtCoderUniversityContestLabel(contestId: string): string | null { + if (!regexForAtCoderUniversity.test(contestId)) { + return null; + } + + return contestId.replace( + regexForAtCoderUniversity, + (_, contestType, common, contestYear) => + `${(contestType + common).toUpperCase()} ${contestYear}`, + ); +} diff --git a/src/lib/contests/utils/labels/world_tour_finals.ts b/src/lib/contests/utils/labels/world_tour_finals.ts new file mode 100644 index 000000000..10cf799e7 --- /dev/null +++ b/src/lib/contests/utils/labels/world_tour_finals.ts @@ -0,0 +1,43 @@ +// World Tour Finals (AtCoder official onsite finals), Algorithm division only. +// +// Seeded contest_id values carry a trailing "-open" (e.g. wtf19-open), except +// wtf22-day2 which AtCoder Problems records without it; strip it before matching +// so both forms work and it never leaks into the display label. +// +// From 2025 the id gains an "algo" infix (awtf2025algo-open) to disambiguate +// from the new Heuristic division (awtf2025heuristic), which stays out of scope. +const regexForWorldTourFinals = /^(wtf19|wtf22-day[12]|awtf2024|awtf20\d{2}algo)$/; + +export const stripOpenSuffix = (contestId: string): string => + contestId.endsWith('-open') ? contestId.slice(0, -'-open'.length) : contestId; + +export const isWorldTourFinals = (contestId: string): boolean => + regexForWorldTourFinals.test(stripOpenSuffix(contestId)); + +export const getWorldTourFinalsLabel = (contestId: string): string => { + const base = 'World Tour Finals'; + const id = stripOpenSuffix(contestId); + + if (id === 'wtf19') { + return `${base} 2019`; + } + + const dayMatch = /^wtf22-day([12])$/.exec(id); + + if (dayMatch) { + return `${base} 2022 Day${dayMatch[1]}`; + } + + if (id === 'awtf2024') { + return `${base} 2024`; + } + + const algoMatch = /^awtf(20\d{2})algo$/.exec(id); + + if (algoMatch) { + // "Algorithm" distinguishes from the Heuristic division, introduced in 2025. + return `${base} ${algoMatch[1]} Algorithm`; + } + + return contestId.toUpperCase(); +}; diff --git a/src/lib/contests/utils/prefixes.ts b/src/lib/contests/utils/prefixes.ts new file mode 100644 index 000000000..2956400e0 --- /dev/null +++ b/src/lib/contests/utils/prefixes.ts @@ -0,0 +1,134 @@ +import type { ContestPrefix } from '$lib/contests/types/contest'; + +export const regexForJag = /^JAG(Prelim|Regional|Summer|Winter|Spring)\d{4}(-day\d+)?[A-Z]?$/; +export const regexForAojUniversity = /^AOJ-[A-Z]+PC\d{4}/; + +// HACK: As of December 2025, the following contests are applicable. +// Note: The classification logic may need to be revised when new contests are added. +export const ABC_LIKE: ContestPrefix = { + 'tenka1-2017-beginner': 'Tenka1 Programmer Beginner Contest 2017', + abl: 'ACL Beginner Contest', + caddi2018b: 'CADDi 2018 for Beginners', + 'soundhound2018-summer-qual': 'SoundHound Inc. Programming Contest 2018 -Masters Tournament-', + 'tenka1-2018-beginner': 'Tenka1 Programmer Beginner Contest 2018', + aising2019: 'エイシング プログラミング コンテスト 2019', + sumitrust2019: '三井住友信託銀行プログラミングコンテスト2019', + 'tenka1-2019-beginner': 'Tenka1 Programmer Beginner Contest 2019', + aising2020: 'エイシング プログラミング コンテスト 2020', + hhkb2020: 'HHKB プログラミングコンテスト 2020', + 'm-solutions2020': 'M-SOLUTIONS プロコンオープン 2020', + panasonic2020: 'パナソニックプログラミングコンテスト 2020', + jsc2021: '第二回日本最強プログラマー学生選手権', + zone2021: 'ZONeエナジー プログラミングコンテスト "HELLO SPACE"', + 'jsc2025advance-final': '日本最強プログラマー学生選手権~Advance~', +} as const; + +export const ARC_LIKE: ContestPrefix = { + 'tenka1-2017': 'Tenka1 Programmer Contest 2017', + 'tenka1-2018': 'Tenka1 Programmer Contest 2018', + 'tenka1-2019': 'Tenka1 Programmer Contest 2019', + caddi2018: 'CADDi 2018', + 'dwacon5th-prelims': '第5回 ドワンゴからの挑戦状 予選', + 'dwacon6th-prelims': '第6回 ドワンゴからの挑戦状 予選', + diverta2019: 'diverta 2019 Programming Contest', + keyence2019: 'キーエンス プログラミング コンテスト 2019', + keyence2020: 'キーエンス プログラミング コンテスト 2020', + keyence2021: 'キーエンス プログラミング コンテスト 2021', + 'jsc2019-qual': '第一回日本最強プログラマー学生選手権-予選-', + 'nikkei2019-qual': '全国統一プログラミング王決定戦予選', + acl1: 'ACL Contest 1', +} as const; + +export const AGC_LIKE: ContestPrefix = { + 'code-festival-2016-qual': 'CODE FESTIVAL 2016 qual', + 'code-festival-2017-qual': 'CODE FESTIVAL 2017 qual', + 'cf16-final': 'CODE FESTIVAL 2016 final', + 'cf17-final': 'CODE FESTIVAL 2017 final', +} as const; + +// HACK: As of September 2025, KUPC, QUPC, UTPC, TTPC and TUPC are included. +// More university contests may be added in the future. +export const ATCODER_UNIVERSITIES: ContestPrefix = { + kupc: 'KUPC', + qupc: 'QUPC', + utpc: 'UTPC', + ttpc: 'TTPC', + tupc: 'TUPC', + wupc: 'WUPC', +} as const; + +export const ATCODER_OTHERS: ContestPrefix = { + chokudai_S: 'Chokudai SpeedRun', + atc001: 'AtCoder Typical Contest 001', + geocon2013: '幾何コンテスト2013', + 's8pc-3': 'square869120Contest #3', + 's8pc-4': 'square869120Contest #4', + 'maximum-cup-2013': 'Maximum-Cup 2013', + 'maximum-cup-2018': 'Maximum-Cup 2018', + 'code-festival-2014-quala': 'Code Festival 2014 予選 A', + 'code-festival-2014-qualb': 'Code Festival 2014 予選 B', + 'code-festival-2014-final': 'Code Festival 2014 決勝', + 'code-festival-2014-china-open': 'Code Festival 2014 上海', + 'code-festival-2015-qualb': 'Code Festival 2015 予選 B', + 'code-festival-2015-morning-middle': 'CODE FESTIVAL 2015 あさぷろ Middle', + 'code-festival-2015-exhibition': 'CODE FESTIVAL 2015 エキシビション', + 'code-thanks-festival': 'CODE THANKS FESTIVAL', + donuts: 'Donutsプロコンチャレンジ', + indeednow: 'Indeedなう', + 'tkppc4-2': '技術室奥プログラミングコンテスト#4 Day2', + 'dwango2016-prelims': '第2回 ドワンゴからの挑戦状 予選', + 'dwacon2017-prelims': '第3回 ドワンゴからの挑戦状 予選', + 'mujin-pc-2016': 'Mujin Programming Challenge 2016', + 'mujin-pc-2018': 'Mujin Programming Challenge 2018', + 'bitflyer2018-qual': 'codeFlyer (bitFlyer Programming Contest)', + soundhound2018: 'SoundHound Inc. Programming Contest 2018 (春)', + 'pakencamp-2018-day3': 'パ研合宿コンペティション 3日目', + 'pakencamp-2024-day1': 'パ研合宿2024 第1日「SpeedRun」', + 'tenka1-2012-qualB': '天下一プログラマーコンテスト2012予選B', + 'tenka1-2015-quala': '天下一プログラマーコンテスト2015予選A', + 'tenka1-2015-qualb': '天下一プログラマーコンテスト2015予選B', + 'tenka1-2016-final': '天下一プログラマーコンテスト2016本戦', + discovery2016: 'DISCO presents ディスカバリーチャンネル プログラミングコンテスト2016', + colopl: 'COLOCON', + gigacode: 'GigaCode', + cpsco2019: 'CPSCO 2019', + 'iroha2019-day4': 'いろはちゃんコンテスト Day4', + 'nikkei2019-final': '全国統一プログラミング王決定戦本戦', + 'jsc2019-final': '第一回日本最強プログラマー学生選手権決勝', + 'jsc2025-final': '第六回日本最強プログラマー学生選手権 -決勝-', + DEGwer2023: 'DEGwer さんの D 論応援コンテスト', + xmascon19: 'Xmas Contest 2019', +} as const; + +// AIZU ONLINE JUDGE AOJ Courses +export const AOJ_COURSES: ContestPrefix = { + ITP1: 'プログラミング入門', + ALDS1: 'アルゴリズムとデータ構造入門', + ITP2: 'プログラミング応用', + DPL: '組み合わせ最適化', + GRL: 'グラフ', + DSL: 'データ構造', + CGL: '計算幾何学', + NTL: '整数論', +} as const; + +export function getPrefixForAojCourses() { + return getContestPrefixes(AOJ_COURSES); +} + +/** + * Extracts contest prefixes (keys) from a contest prefix object. + * @param contestPrefixes - Object mapping contest IDs to their display names + * @returns Array of contest prefix strings + */ +export function getContestPrefixes(contestPrefixes: Record) { + return Object.keys(contestPrefixes); +} + +// Pre-computed prefix sets/arrays for classification lookups +export const abcLikePrefixes = new Set(getContestPrefixes(ABC_LIKE)); +export const arcLikePrefixes = new Set(getContestPrefixes(ARC_LIKE)); +export const agcLikePrefixes = getContestPrefixes(AGC_LIKE); +export const atCoderUniversityPrefixes = getContestPrefixes(ATCODER_UNIVERSITIES); +export const atCoderOthersPrefixes = getContestPrefixes(ATCODER_OTHERS); +export const aojCoursePrefixes = new Set(getPrefixForAojCourses()); diff --git a/src/lib/contests/utils/priority.test.ts b/src/lib/contests/utils/priority.test.ts new file mode 100644 index 000000000..e59927e38 --- /dev/null +++ b/src/lib/contests/utils/priority.test.ts @@ -0,0 +1,128 @@ +import { expect } from 'vitest'; + +import { ContestType } from '$lib/contests/types/contest'; +import { + getContestPriority, + contestTypePriorities, + UNCLASSIFIED_CONTEST_PRIORITY, +} from '$lib/contests/utils/priority'; + +/** One representative contest_id per category, listed from highest to lowest priority. */ +const contestIdsInDisplayOrder = [ + 'abs', + 'abc001', + 'arc001', + 'agc001', + 'abl', + 'acl1', + 'cf16-final', + 'awc0001', + 'utpc2023', + 'atc001', + 'ITP1', + 'PCKPrelim2024', + 'AOJ-UAPC2003', +]; + +describe('get contest priority', () => { + describe('successful cases', () => { + test('sorts contest_ids from every category into the intended display order', () => { + const shuffled = contestIdsInDisplayOrder.toReversed(); + + const sorted = shuffled.toSorted( + (left, right) => getContestPriority(left) - getContestPriority(right), + ); + + expect(sorted).toEqual(contestIdsInDisplayOrder); + }); + }); + + describe('boundary and error cases', () => { + describe('returns the fallback priority', () => { + describe('when contest_id matches no known contest', () => { + test.each(['unknown-contest-2099', 'not-a-contest'])('for %s', (contestId) => { + expect(getContestPriority(contestId)).toBe(UNCLASSIFIED_CONTEST_PRIORITY); + }); + }); + + describe('when contest_id belongs to a division out of scope', () => { + test('for awtf2025heuristic', () => { + expect(getContestPriority('awtf2025heuristic')).toBe(UNCLASSIFIED_CONTEST_PRIORITY); + }); + }); + + describe('when contest_id nearly matches a known pattern', () => { + test.each([ + ['JAGPrelim', 'a JAG contest without a 4-digit year'], + ['abc12', 'an ABC contest with too few digits'], + ['awc001', 'an AWC contest with too few digits'], + ])('for %s (%s)', (contestId) => { + expect(getContestPriority(contestId)).toBe(UNCLASSIFIED_CONTEST_PRIORITY); + }); + }); + + describe('when contest_id is empty', () => { + test('for an empty string', () => { + expect(getContestPriority('')).toBe(UNCLASSIFIED_CONTEST_PRIORITY); + }); + }); + }); + }); +}); + +describe('contest type priorities', () => { + const priorityOf = (contestType: ContestType): number => + contestTypePriorities.get(contestType) as number; + + describe('registration', () => { + test('assigns a priority to every contest type', () => { + const missing = Object.values(ContestType).filter( + (contestType) => !contestTypePriorities.has(contestType), + ); + + expect(missing).toEqual([]); + }); + + test('assigns a distinct priority to each contest type', () => { + const priorities = [...contestTypePriorities.values()]; + + expect(new Set(priorities).size).toBe(priorities.length); + }); + + test('ranks every contest type ahead of an unclassifiable contest', () => { + expect(Math.max(...contestTypePriorities.values())).toBeLessThan( + UNCLASSIFIED_CONTEST_PRIORITY, + ); + }); + }); + + describe('ordering across contest categories', () => { + test('ranks educational contests above contests for genius', () => { + expect(priorityOf(ContestType.ABS)).toBeLessThan(priorityOf(ContestType.ARC)); + expect(priorityOf(ContestType.ABC)).toBeLessThan(priorityOf(ContestType.ARC)); + expect(priorityOf(ContestType.ARC)).toBeLessThan(priorityOf(ContestType.AGC)); + }); + + test('ranks each AtCoder contest above every AOJ contest', () => { + const atCoderLowest = Math.max( + priorityOf(ContestType.OTHERS), + priorityOf(ContestType.UNIVERSITY), + ); + const aojHighest = Math.min( + priorityOf(ContestType.AOJ_COURSES), + priorityOf(ContestType.AOJ_PCK), + priorityOf(ContestType.AOJ_ICPC), + priorityOf(ContestType.AOJ_JAG), + priorityOf(ContestType.AOJ_UNIVERSITY), + ); + + expect(atCoderLowest).toBeLessThan(aojHighest); + }); + + test('ranks a contest variant directly below its base contest', () => { + expect(priorityOf(ContestType.ABC_LIKE)).toBeGreaterThan(priorityOf(ContestType.ABC)); + expect(priorityOf(ContestType.ARC_LIKE)).toBeGreaterThan(priorityOf(ContestType.ARC)); + expect(priorityOf(ContestType.AGC_LIKE)).toBeGreaterThan(priorityOf(ContestType.AGC)); + }); + }); +}); diff --git a/src/lib/contests/utils/priority.ts b/src/lib/contests/utils/priority.ts new file mode 100644 index 000000000..9b96e4feb --- /dev/null +++ b/src/lib/contests/utils/priority.ts @@ -0,0 +1,61 @@ +import { ContestType } from '$lib/contests/types/contest'; +import { classifyContest } from './classification'; + +/** + * Contest type priorities (0 = Highest, 26 = 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-21): UNIVERSITY, FPS_24, ATCODER_MAIN_OFFICIAL_ONSITE, OTHERS + * - External platforms (22-26): 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 + * and may be adjusted based on future requirements. + * + * See: + * https://jsprimer.net/basic/map-and-set/ + */ +export const contestTypePriorities: Map = new Map([ + [ContestType.ABS, 0], + [ContestType.ABC, 1], + [ContestType.APG4B, 2], + [ContestType.TYPICAL90, 3], + [ContestType.EDPC, 4], + [ContestType.TDPC, 5], + [ContestType.NDPC, 6], + [ContestType.PAST, 7], + [ContestType.ACL_PRACTICE, 8], + [ContestType.JOI, 9], + [ContestType.TESSOKU_BOOK, 10], + [ContestType.MATH_AND_ALGORITHM, 11], + [ContestType.ARC, 12], + [ContestType.AGC, 13], + [ContestType.ABC_LIKE, 14], + [ContestType.ARC_LIKE, 15], + [ContestType.AGC_LIKE, 16], + [ContestType.AWC, 17], + [ContestType.UNIVERSITY, 18], + [ContestType.FPS_24, 19], + [ContestType.ATCODER_MAIN_OFFICIAL_ONSITE, 20], + [ContestType.OTHERS, 21], // AtCoder (その他) + [ContestType.AOJ_COURSES, 22], + [ContestType.AOJ_PCK, 23], + [ContestType.AOJ_ICPC, 24], + [ContestType.AOJ_JAG, 25], + [ContestType.AOJ_UNIVERSITY, 26], +]); + +/** Priority given to a contest_id that no classification rule matches, so it sorts last. */ +export const UNCLASSIFIED_CONTEST_PRIORITY = 10 ** 3; + +export function getContestPriority(contestId: string): number { + const contestType = classifyContest(contestId); + + if (contestType === null || contestType === undefined) { + return UNCLASSIFIED_CONTEST_PRIORITY; + } else { + return contestTypePriorities.get(contestType) as number; + } +} diff --git a/src/lib/contests/utils/task_index_label.test.ts b/src/lib/contests/utils/task_index_label.test.ts new file mode 100644 index 000000000..7a00ef12c --- /dev/null +++ b/src/lib/contests/utils/task_index_label.test.ts @@ -0,0 +1,213 @@ +import { expect } from 'vitest'; + +import { runTests } from '../../../test/lib/common/test_helpers'; +import * as TestCasesForContestNameAndTaskIndex from '$lib/contests/fixtures/contest_name_and_task_index'; +import { type TestCaseForContestNameAndTaskIndex } from '$lib/contests/fixtures/contest_name_and_task_index'; +import { addContestNameToTaskIndex } from '$lib/contests/utils/task_index_label'; + +describe('add contest name to task index', () => { + describe('AtCoder', () => { + describe('when contest_id contains abc', () => { + TestCasesForContestNameAndTaskIndex.abc.forEach(({ name, value }) => { + runTests( + `${name}`, + [value], + ({ contestId, taskTableIndex, expected }: TestCaseForContestNameAndTaskIndex) => { + expect(addContestNameToTaskIndex(contestId, taskTableIndex)).toEqual(expected); + }, + ); + }); + }); + + describe('when contest_id starts with APG4b', () => { + TestCasesForContestNameAndTaskIndex.apg4b.forEach(({ name, value }) => { + runTests( + `${name}`, + [value], + ({ contestId, taskTableIndex, expected }: TestCaseForContestNameAndTaskIndex) => { + expect(addContestNameToTaskIndex(contestId, taskTableIndex)).toEqual(expected); + }, + ); + }); + }); + + describe('when contest_id is typical90', () => { + TestCasesForContestNameAndTaskIndex.typical90.forEach(({ name, value }) => { + runTests( + `${name}`, + [value], + ({ contestId, taskTableIndex, expected }: TestCaseForContestNameAndTaskIndex) => { + expect(addContestNameToTaskIndex(contestId, taskTableIndex)).toEqual(expected); + }, + ); + }); + }); + + describe('when contest_id contains past', () => { + TestCasesForContestNameAndTaskIndex.past.forEach(({ name, value }) => { + runTests( + `${name}`, + [value], + ({ contestId, taskTableIndex, expected }: TestCaseForContestNameAndTaskIndex) => { + expect(addContestNameToTaskIndex(contestId, taskTableIndex)).toEqual(expected); + }, + ); + }); + }); + + describe('when contest_id contains joi', () => { + TestCasesForContestNameAndTaskIndex.joi.forEach(({ name, value }) => { + runTests( + `${name}`, + [value], + ({ contestId, taskTableIndex, expected }: TestCaseForContestNameAndTaskIndex) => { + expect(addContestNameToTaskIndex(contestId, taskTableIndex)).toEqual(expected); + }, + ); + }); + }); + + describe('when contest_id is tessoku-book', () => { + TestCasesForContestNameAndTaskIndex.tessokuBook.forEach(({ name, value }) => { + runTests( + `${name}`, + [value], + ({ contestId, taskTableIndex, expected }: TestCaseForContestNameAndTaskIndex) => { + expect(addContestNameToTaskIndex(contestId, taskTableIndex)).toEqual(expected); + }, + ); + }); + }); + + describe('when contest_id is math-and-algorithm', () => { + TestCasesForContestNameAndTaskIndex.mathAndAlgorithm.forEach(({ name, value }) => { + runTests( + `${name}`, + [value], + ({ contestId, taskTableIndex, expected }: TestCaseForContestNameAndTaskIndex) => { + expect(addContestNameToTaskIndex(contestId, taskTableIndex)).toEqual(expected); + }, + ); + }); + }); + + describe('when contest_id contains arc', () => { + TestCasesForContestNameAndTaskIndex.arc.forEach(({ name, value }) => { + runTests( + `${name}`, + [value], + ({ contestId, taskTableIndex, expected }: TestCaseForContestNameAndTaskIndex) => { + expect(addContestNameToTaskIndex(contestId, taskTableIndex)).toEqual(expected); + }, + ); + }); + }); + + describe('when contest_id contains agc', () => { + TestCasesForContestNameAndTaskIndex.agc.forEach(({ name, value }) => { + runTests( + `${name}`, + [value], + ({ contestId, taskTableIndex, expected }: TestCaseForContestNameAndTaskIndex) => { + expect(addContestNameToTaskIndex(contestId, taskTableIndex)).toEqual(expected); + }, + ); + }); + }); + + describe('when contest_id contains awc', () => { + TestCasesForContestNameAndTaskIndex.awc.forEach(({ name, value }) => { + runTests( + `${name}`, + [value], + ({ contestId, taskTableIndex, expected }: TestCaseForContestNameAndTaskIndex) => { + expect(addContestNameToTaskIndex(contestId, taskTableIndex)).toEqual(expected); + }, + ); + }); + }); + + describe('when contest_id matches contests held by university students', () => { + TestCasesForContestNameAndTaskIndex.universities.forEach(({ name, value }) => { + runTests( + `${name}`, + [value], + ({ contestId, taskTableIndex, expected }: TestCaseForContestNameAndTaskIndex) => { + expect(addContestNameToTaskIndex(contestId, taskTableIndex)).toEqual(expected); + }, + ); + }); + }); + }); + + describe('AOJ', () => { + describe('when contest_id means AOJ courses', () => { + TestCasesForContestNameAndTaskIndex.aojCourses.forEach(({ name, value }) => { + runTests( + `${name}`, + [value], + ({ contestId, taskTableIndex, expected }: TestCaseForContestNameAndTaskIndex) => { + expect(addContestNameToTaskIndex(contestId, taskTableIndex)).toEqual(expected); + }, + ); + }); + }); + + describe('when contest_id means AOJ PCK (prelim and final)', () => { + TestCasesForContestNameAndTaskIndex.aojPck.forEach(({ name, value }) => { + runTests( + `${name}`, + [value], + ({ contestId, taskTableIndex, expected }: TestCaseForContestNameAndTaskIndex) => { + expect(addContestNameToTaskIndex(contestId, taskTableIndex)).toEqual(expected); + }, + ); + }); + }); + + describe('when contest_id means AOJ JAG', () => { + TestCasesForContestNameAndTaskIndex.aojJag.forEach(({ name, value }) => { + runTests( + `${name}`, + [value], + ({ contestId, taskTableIndex, expected }: TestCaseForContestNameAndTaskIndex) => { + expect(addContestNameToTaskIndex(contestId, taskTableIndex)).toEqual(expected); + }, + ); + }); + }); + + describe('when contest_id is JAG-like but has no 4-digit year', () => { + test.each(['JAGSummer-day2', 'JAGPrelim', 'JAGRegional-day1'])( + 'does not produce AOJ format for %s', + (contestId) => { + expect(addContestNameToTaskIndex(contestId, '1')).not.toMatch(/^AOJ /); + }, + ); + }); + + describe('when contest_id means AOJ ICPC (prelim and regional)', () => { + TestCasesForContestNameAndTaskIndex.aojIcpc.forEach(({ name, value }) => { + runTests( + `${name}`, + [value], + ({ contestId, taskTableIndex, expected }: TestCaseForContestNameAndTaskIndex) => { + expect(addContestNameToTaskIndex(contestId, taskTableIndex)).toEqual(expected); + }, + ); + }); + }); + + 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/lib/contests/utils/task_index_label.ts b/src/lib/contests/utils/task_index_label.ts new file mode 100644 index 000000000..0b4d8fc48 --- /dev/null +++ b/src/lib/contests/utils/task_index_label.ts @@ -0,0 +1,22 @@ +import { regexForJag, regexForAojUniversity, aojCoursePrefixes } from './prefixes'; +import { getContestNameLabel } from './labels/index'; + +export const addContestNameToTaskIndex = (contestId: string, taskTableIndex: string): string => { + const contestName = getContestNameLabel(contestId); + + if (isAojContest(contestId)) { + return `AOJ ${taskTableIndex}${contestName}`; + } + + return `${contestName} - ${taskTableIndex}`; +}; + +function isAojContest(contestId: string): boolean { + return ( + aojCoursePrefixes.has(contestId) || + contestId.startsWith('PCK') || + regexForJag.test(contestId) || + contestId.startsWith('ICPC') || + regexForAojUniversity.test(contestId) + ); +} diff --git a/src/lib/services/tasks.ts b/src/lib/services/tasks.ts index 7b5e828db..2391da398 100644 --- a/src/lib/services/tasks.ts +++ b/src/lib/services/tasks.ts @@ -18,7 +18,7 @@ import { } from '$lib/server/tasks/cache'; import { invalidateVoteCaches } from '$features/votes/server/cache'; -import { classifyContest } from '$lib/contests/utils/contest'; +import { classifyContest } from '$lib/contests'; import { createContestTaskPairKey } from '$lib/utils/contest_task_pair'; // See: diff --git a/src/lib/utils/task.ts b/src/lib/utils/task.ts index f67755165..ae57371ab 100644 --- a/src/lib/utils/task.ts +++ b/src/lib/utils/task.ts @@ -10,7 +10,7 @@ import { getContestPriority, regexForAojUniversity, regexForJag, -} from '$lib/contests/utils/contest'; +} from '$lib/contests'; // TODO: Codeforces、yukicoder、BOJなどに対応できるようにする /** @@ -89,7 +89,7 @@ export function compareByContestIdAndTaskId( // 1. コンテスト種類別の優先度(昇順) // // See: - // contestTypePriorities in src/lib/contests/utils/contest.ts + // contestTypePriorities in src/lib/contests/ if (firstContestPriority !== secondContestPriority) { return firstContestPriority - secondContestPriority; } diff --git a/src/lib/utils/task_filter.ts b/src/lib/utils/task_filter.ts index c011c4ef6..20ad9f281 100644 --- a/src/lib/utils/task_filter.ts +++ b/src/lib/utils/task_filter.ts @@ -1,4 +1,4 @@ -import { getContestNameLabel } from '$lib/contests/utils/contest'; +import { getContestNameLabel } from '$lib/contests'; import { getTaskUrl } from '$lib/utils/task'; type SearchableTask = { diff --git a/src/routes/(admin)/tasks/+page.server.ts b/src/routes/(admin)/tasks/+page.server.ts index 4f070c7f0..2975674c2 100644 --- a/src/routes/(admin)/tasks/+page.server.ts +++ b/src/routes/(admin)/tasks/+page.server.ts @@ -8,7 +8,7 @@ import { validateAdminAccess } from '$features/auth/services/admin_access'; import { fetchContests, fetchTasks, isContestTaskImportSource } from '$lib/clients'; -import { classifyContest } from '$lib/contests/utils/contest'; +import { classifyContest } from '$lib/contests'; import { sha256 } from '$lib/utils/hash'; import { BAD_REQUEST, INTERNAL_SERVER_ERROR } from '$lib/constants/http-response-status-codes'; diff --git a/src/routes/(admin)/tasks/_components/TaskTableForImport.svelte b/src/routes/(admin)/tasks/_components/TaskTableForImport.svelte index 0bc03db98..f556a4985 100644 --- a/src/routes/(admin)/tasks/_components/TaskTableForImport.svelte +++ b/src/routes/(admin)/tasks/_components/TaskTableForImport.svelte @@ -15,7 +15,7 @@ import type { Contests } from '$lib/contests/types/contest'; import type { ContestTaskImportSource } from '$lib/clients'; - import { getContestNameLabel } from '$lib/contests/utils/contest'; + import { getContestNameLabel } from '$lib/contests'; import { newline } from '$lib/utils/newline'; interface Props { diff --git a/src/routes/(admin)/tasks/grade/_components/TaskGradeTable.svelte b/src/routes/(admin)/tasks/grade/_components/TaskGradeTable.svelte index 3a55a777f..730c96dd2 100644 --- a/src/routes/(admin)/tasks/grade/_components/TaskGradeTable.svelte +++ b/src/routes/(admin)/tasks/grade/_components/TaskGradeTable.svelte @@ -19,7 +19,7 @@ import { taskGradeValues, TaskGrade } from '$lib/types/task'; import type { TaskWithVoteInfo } from '$features/votes/services/vote_statistics'; - import { addContestNameToTaskIndex } from '$lib/contests/utils/contest'; + import { addContestNameToTaskIndex } from '$lib/contests'; import { getTaskGradeLabel, compareByContestIdAndTaskId, diff --git a/src/routes/votes/+page.svelte b/src/routes/votes/+page.svelte index 358d80f05..23538c2b5 100644 --- a/src/routes/votes/+page.svelte +++ b/src/routes/votes/+page.svelte @@ -20,7 +20,7 @@ import { TaskGrade } from '$lib/types/task'; import { MIN_VOTES_FOR_PROVISIONAL_GRADE } from '$features/votes/constants/statistics'; - import { getContestNameLabel } from '$lib/contests/utils/contest'; + import { getContestNameLabel } from '$lib/contests'; import { getTaskUrl, compareByContestIdAndTaskId } from '$lib/utils/task'; import { filterTasksBySearch } from '$lib/utils/task_filter'; import { resolveDisplayGrade } from '$features/votes/utils/grade_options'; diff --git a/src/routes/workbooks/[slug]/+page.svelte b/src/routes/workbooks/[slug]/+page.svelte index a490ebb13..d4659fe1b 100644 --- a/src/routes/workbooks/[slug]/+page.svelte +++ b/src/routes/workbooks/[slug]/+page.svelte @@ -20,7 +20,7 @@ import CommentAndHint from '$features/workbooks/components/detail/CommentAndHint.svelte'; import { getBackgroundColorFrom } from '$lib/services/submission_status'; - import { addContestNameToTaskIndex } from '$lib/contests/utils/contest'; + import { addContestNameToTaskIndex } from '$lib/contests'; import { getTaskUrl, removeTaskIndexFromTitle } from '$lib/utils/task'; import type { TaskResult } from '$lib/types/task';