Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .claude/rules/coding-style.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`:
Expand Down
6 changes: 4 additions & 2 deletions .claude/rules/testing.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -40,6 +40,8 @@ paths:
- `Promise<void>`: 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<string, T>` 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:

Expand Down
10 changes: 7 additions & 3 deletions .claude/skills/add-contest-table-provider/instructions.md
Original file line number Diff line number Diff line change
Expand Up @@ -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**

---
Expand Down
11 changes: 11 additions & 0 deletions .claude/skills/verify-test-strength/SKILL.md
Original file line number Diff line number Diff line change
@@ -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
Comment thread
KATO-Hiro marked this conversation as resolved.

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
39 changes: 39 additions & 0 deletions .claude/skills/verify-test-strength/instructions.md
Original file line number Diff line number Diff line change
@@ -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 <source> /tmp/source.bak # 1. save
perl -0777 -pi -e 's/<pattern>/<mutant>/' <source> # 2. mutate
pnpm exec vitest run <test-file> --reporter=verbose # 3. run
cp /tmp/source.bak <source> && git diff <source> # 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.
7 changes: 6 additions & 1 deletion docs/guides/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 共有のサーバ処理
Expand Down
2 changes: 1 addition & 1 deletion docs/guides/how-to-add-contest-table-provider.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)` 対応済み
Expand Down
2 changes: 1 addition & 1 deletion prisma/seed.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
2 changes: 1 addition & 1 deletion src/features/tasks/utils/contest-table/abc_providers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down
2 changes: 1 addition & 1 deletion src/features/tasks/utils/contest-table/abs_provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down
2 changes: 1 addition & 1 deletion src/features/tasks/utils/contest-table/acl_providers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down
2 changes: 1 addition & 1 deletion src/features/tasks/utils/contest-table/agc_provider.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand Down
2 changes: 1 addition & 1 deletion src/features/tasks/utils/contest-table/arc_providers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down
2 changes: 1 addition & 1 deletion src/features/tasks/utils/contest-table/awc_provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down
2 changes: 1 addition & 1 deletion src/features/tasks/utils/contest-table/dp_providers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down
2 changes: 1 addition & 1 deletion src/features/tasks/utils/contest-table/fps24_provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
});

Expand Down
2 changes: 1 addition & 1 deletion src/features/tasks/utils/contest-table/joi_providers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
2 changes: 1 addition & 1 deletion src/lib/components/SubmissionStatus/UpdatingModal.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
2 changes: 1 addition & 1 deletion src/lib/components/TagForm.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
2 changes: 1 addition & 1 deletion src/lib/components/TaskForm.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down
2 changes: 1 addition & 1 deletion src/lib/components/TaskList.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
2 changes: 1 addition & 1 deletion src/lib/components/TaskListSorted.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
2 changes: 1 addition & 1 deletion src/lib/contests/fixtures/contest_name_and_task_index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import {
getAojContestLabel,
PAST_TRANSLATIONS,
AOJ_COURSES,
} from '$lib/contests/utils/contest';
} from '$lib/contests';

export type TestCaseForContestNameAndTaskIndex = {
contestId: string;
Expand Down
54 changes: 54 additions & 0 deletions src/lib/contests/fixtures/contest_name_labels.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
Loading
Loading