Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
dfd6df8
feat(tasks): split AOJ client by source and add task import UI
KATO-Hiro May 17, 2026
9edfb2e
fix(fixtures): add missing newline at end of fixture JSON files
KATO-Hiro May 17, 2026
1603f4d
feat(tasks): add filterContests util and route-scoped test coverage i…
KATO-Hiro May 17, 2026
5137166
fix: decouple importContests from form state, add loading state, use …
KATO-Hiro May 17, 2026
fe718a9
refactor: rename filter_contests to contests
KATO-Hiro May 17, 2026
0777406
style: format contests test fixture objects for readability
KATO-Hiro May 17, 2026
e3e93fe
fix: restore fetchTasks import for fetch action, align form layout wi…
KATO-Hiro May 17, 2026
2fba8f0
fix: widen search box to w-96
KATO-Hiro May 17, 2026
e4a24c2
fix: replace $effect+applyAction with direct result handling in handl…
KATO-Hiro May 17, 2026
e3225c1
docs: add import-removal rule and use:enhance lesson
KATO-Hiro May 17, 2026
9bc1737
refactor: reorder script declarations by UI section
KATO-Hiro May 17, 2026
321cae5
docs: update handleFetch comment to reflect direct result.data assign…
KATO-Hiro May 17, 2026
84303e7
feat: add search icon to TaskSearchBox
KATO-Hiro May 17, 2026
f30675f
fix: use Flowbite Input left snippet for search icon
KATO-Hiro May 17, 2026
f9e1f05
fix: add pl-9 to prevent text overlapping search icon
KATO-Hiro May 17, 2026
964daeb
fix: move fetchError block after content to preserve correct render o…
KATO-Hiro May 17, 2026
2c978b1
fix: reset currentPage to 1 when filteredContests changes
KATO-Hiro May 17, 2026
38f0226
docs: add Note prefix to filteredContests effect comment
KATO-Hiro May 17, 2026
40c0869
fix: remove redundant currentPage reset from fetch success handler
KATO-Hiro May 17, 2026
9bec079
docs: clean up dev-notes and ui-mock after task-import-by-source impl…
KATO-Hiro May 17, 2026
52f17f8
docs: remove completed task-import-by-source plan
KATO-Hiro May 17, 2026
0e1ec4e
fix: trim whitespace from API titles and fix error handling in import…
KATO-Hiro May 18, 2026
06b7f09
docs: remove completed review notes and reformat test objects
KATO-Hiro May 18, 2026
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
2 changes: 2 additions & 0 deletions .claude/rules/coding-style.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,8 @@ Extract to `src/lib/utils/` with adjacent tests.

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.

## Documentation

- **Plans/dev-notes**: Japanese
Expand Down
272 changes: 211 additions & 61 deletions src/lib/clients/aizu_online_judge/clients.test.ts
Original file line number Diff line number Diff line change
@@ -1,104 +1,254 @@
import { describe, test, expect, beforeAll } from 'vitest';
import { describe, test, expect, beforeEach, afterEach, vi } from 'vitest';
import nock from 'nock';

vi.mock('$lib/utils/time', () => ({
delay: vi.fn().mockResolvedValue(undefined),
}));

import { HttpRequestClient } from '$lib/clients/http_client';
import { ContestTaskCache } from '$lib/clients/cache_strategy';
import { Cache } from '$lib/clients/cache';

import { AojCoursesApiClient, AojChallengesApiClient } from './clients';

import type { TasksApiClient } from '$lib/clients/http_client';
import type { ContestsForImport } from '$lib/types/contest';
import type { TasksForImport } from '$lib/types/task';

import { AojApiClient } from '$lib/clients/aizu_online_judge/clients';
import type { AOJCourseAPI, AOJChallengeContestAPI, AOJTaskAPIs } from './types';

import { loadMockData } from '../fixtures/helpers';

describe('AIZU ONLINE JUDGE API client', () => {
let client: TasksApiClient<void>;
let contestsMock: ContestsForImport;
let tasksMock: TasksForImport;

beforeAll(() => {
client = new AojApiClient();

const MOCK_DATA_PATHS = {
contests: './src/lib/clients/fixtures/aizu_online_judge/contests.json',
tasks: './src/lib/clients/fixtures/aizu_online_judge/tasks.json',
};

try {
contestsMock = loadMockData<ContestsForImport>(MOCK_DATA_PATHS.contests);
tasksMock = loadMockData<TasksForImport>(MOCK_DATA_PATHS.tasks);
} catch (error) {
throw new Error(
`Failed to load mock data: ${error}\nFile: ${
error instanceof Error && 'fileName' in error ? error.fileName : 'unknown'
}`,
);
}
const AOJ_API_BASE = 'https://judgeapi.u-aizu.ac.jp';

function buildCache(): ContestTaskCache {
return new ContestTaskCache(new Cache<ContestsForImport>(), new Cache<TasksForImport>());
}

function buildCoursesClient(): AojCoursesApiClient {
const httpClient = new HttpRequestClient(`${AOJ_API_BASE}/`);
return new AojCoursesApiClient(httpClient, buildCache());
}

function buildChallengesClient(): AojChallengesApiClient {
const httpClient = new HttpRequestClient(`${AOJ_API_BASE}/`);
return new AojChallengesApiClient(httpClient, buildCache());
}

const FIXTURE_PATHS = {
courses: {
contests: './src/lib/clients/fixtures/aizu_online_judge/courses/contests.json',
tasks: './src/lib/clients/fixtures/aizu_online_judge/courses/tasks.json',
},
// challenges: the AOJChallengeContestAPI response is a single endpoint that embeds tasks
// (days[].problems) inside the contest structure. The essential data is the problem list;
// contests and tasks both derive from this one fixture.
pckPrelim: {
contests: './src/lib/clients/fixtures/aizu_online_judge/challenges/pck_prelim/contests.json',
},
pckFinal: {
contests: './src/lib/clients/fixtures/aizu_online_judge/challenges/pck_final/contests.json',
},
jagPrelim: {
contests: './src/lib/clients/fixtures/aizu_online_judge/challenges/jag_prelim/contests.json',
},
jagRegional: {
contests: './src/lib/clients/fixtures/aizu_online_judge/challenges/jag_regional/contests.json',
},
};

describe('AojCoursesApiClient', () => {
const courseContestsMock = loadMockData<AOJCourseAPI>(FIXTURE_PATHS.courses.contests);
const courseTasksMock = loadMockData<AOJTaskAPIs>(FIXTURE_PATHS.courses.tasks);

beforeEach(() => {
nock.cleanAll();
});

afterEach(() => {
nock.cleanAll();
});

describe('getContests', () => {
test('expects to fetch contests', async () => {
// Use mock data instead of making a request.
client.getContests = async () => contestsMock;
test('fetches and transforms course contests', async () => {
nock(AOJ_API_BASE).get('/courses').reply(200, courseContestsMock);
const client = buildCoursesClient();
const contests = await client.getContests();

expect(contests.length).toEqual(contestsMock.length);
expect(contests.length).toBe(courseContestsMock.courses.length);
});

// See:
// https://vitest.dev/api/expect.html#tobedefined
test('each contest expects to have id and title', async () => {
contestsMock.forEach((contest) => {
test('each contest has id and title', async () => {
nock(AOJ_API_BASE).get('/courses').reply(200, courseContestsMock);
const client = buildCoursesClient();
const contests = await client.getContests();

contests.forEach((contest) => {
expect(contest.id).toBeDefined();
expect(contest.title).toBeDefined();
});
});

test('handles empty contests list', async () => {
client.getContests = async () => [];
test('contest id matches course shortName', async () => {
nock(AOJ_API_BASE).get('/courses').reply(200, courseContestsMock);
const client = buildCoursesClient();
const contests = await client.getContests();
expect(contests).toHaveLength(0);
});

test('validates contest properties format', async () => {
contestsMock.forEach((contest) => {
expect(typeof contest.id).toBe('string');
expect(contest.id).toMatch(/^[a-zA-Z0-9_-]+$/);
expect(typeof contest.title).toBe('string');
expect(contest.title.length).toBeGreaterThan(0);
const shortNames = courseContestsMock.courses.map((contest) => contest.shortName);
contests.forEach((contest) => {
expect(shortNames).toContain(contest.id);
});
});
});

describe('getTasks', () => {
test('expects to fetch tasks', async () => {
// Use mock data instead of making a request.
client.getTasks = async () => tasksMock;
test('fetches and transforms course tasks (only course-format ids)', async () => {
nock(AOJ_API_BASE).get('/problems').query({ size: '10000' }).reply(200, courseTasksMock);
const client = buildCoursesClient();
const tasks = await client.getTasks();

expect(tasks.length).toEqual(tasksMock.length);
// Only tasks whose id matches courseName_taskId_index format are returned
const expectedCount = courseTasksMock.filter(
(task) => task.id.split('_').length === 3,
).length;
expect(tasks.length).toBe(expectedCount);
});

test('each task expects to have id, contest_id, problem_index and title', async () => {
tasksMock.forEach((task) => {
test('each task has id, contest_id, problem_index, and title', async () => {
nock(AOJ_API_BASE).get('/problems').query({ size: '10000' }).reply(200, courseTasksMock);
const client = buildCoursesClient();
const tasks = await client.getTasks();

tasks.forEach((task) => {
expect(task.id).toBeDefined();
expect(task.contest_id).toBeDefined();
expect(task.problem_index).toBeDefined();
expect(task.title).toBeDefined();
});
});

test('handles empty tasks list', async () => {
client.getTasks = async () => [];
test('task contest_id is derived from task id prefix', async () => {
nock(AOJ_API_BASE).get('/problems').query({ size: '10000' }).reply(200, courseTasksMock);
const client = buildCoursesClient();
const tasks = await client.getTasks();
expect(tasks).toHaveLength(0);

tasks.forEach((task) => {
const expectedContestId = task.id.split('_')[0];
expect(task.contest_id).toBe(expectedContestId);
});
});
});
});

describe('AojChallengesApiClient', () => {
beforeEach(() => {
nock.cleanAll();
});

test('validates task properties format', async () => {
tasksMock.forEach((task) => {
expect(typeof task.id).toBe('string');
expect(typeof task.contest_id).toBe('string');
expect(typeof task.problem_index).toBe('string');
expect(typeof task.title).toBe('string');
expect(task.title.length).toBeGreaterThan(0);
afterEach(() => {
nock.cleanAll();
});

describe('PCK PRELIM', () => {
const contestsMock = loadMockData<AOJChallengeContestAPI>(FIXTURE_PATHS.pckPrelim.contests);
let client: AojChallengesApiClient;

beforeEach(() => {
nock(AOJ_API_BASE).get('/challenges/cl/PCK/PRELIM').reply(200, contestsMock);
client = buildChallengesClient();
});

test('fetches and transforms PCK PRELIM contests', async () => {
const contests = await client.getContests({ contestType: 'PCK', round: 'PRELIM' });
const expectedCount = contestsMock.contests.flatMap((contest) => contest.days).length;
expect(contests.length).toBe(expectedCount);
});

test('fetches and transforms PCK PRELIM tasks', async () => {
const tasks = await client.getTasks({ contestType: 'PCK', round: 'PRELIM' });
const expectedCount = contestsMock.contests
.flatMap((contest) => contest.days)
.flatMap((day) => day.problems).length;
expect(tasks.length).toBe(expectedCount);
});

test('each PCK PRELIM task has required fields', async () => {
const tasks = await client.getTasks({ contestType: 'PCK', round: 'PRELIM' });
tasks.forEach((task) => {
expect(task.id).toBeDefined();
expect(task.contest_id).toBeDefined();
expect(task.title).toBeDefined();
});
});
});

describe('PCK FINAL', () => {
const contestsMock = loadMockData<AOJChallengeContestAPI>(FIXTURE_PATHS.pckFinal.contests);
let client: AojChallengesApiClient;

beforeEach(() => {
nock(AOJ_API_BASE).get('/challenges/cl/PCK/FINAL').reply(200, contestsMock);
client = buildChallengesClient();
});

test('fetches and transforms PCK FINAL contests', async () => {
const contests = await client.getContests({ contestType: 'PCK', round: 'FINAL' });
const expectedCount = contestsMock.contests.flatMap((contest) => contest.days).length;
expect(contests.length).toBe(expectedCount);
});

test('fetches and transforms PCK FINAL tasks', async () => {
const tasks = await client.getTasks({ contestType: 'PCK', round: 'FINAL' });
const expectedCount = contestsMock.contests
.flatMap((contest) => contest.days)
.flatMap((day) => day.problems).length;
expect(tasks.length).toBe(expectedCount);
});
});

describe('JAG PRELIM', () => {
const contestsMock = loadMockData<AOJChallengeContestAPI>(FIXTURE_PATHS.jagPrelim.contests);
let client: AojChallengesApiClient;

beforeEach(() => {
nock(AOJ_API_BASE).get('/challenges/cl/JAG/PRELIM').reply(200, contestsMock);
client = buildChallengesClient();
});

test('fetches and transforms JAG PRELIM contests', async () => {
const contests = await client.getContests({ contestType: 'JAG', round: 'PRELIM' });
const expectedCount = contestsMock.contests.flatMap((contest) => contest.days).length;
expect(contests.length).toBe(expectedCount);
});

test('fetches and transforms JAG PRELIM tasks', async () => {
const tasks = await client.getTasks({ contestType: 'JAG', round: 'PRELIM' });
const expectedCount = contestsMock.contests
.flatMap((contest) => contest.days)
.flatMap((day) => day.problems).length;
expect(tasks.length).toBe(expectedCount);
});
});

describe('JAG REGIONAL', () => {
const contestsMock = loadMockData<AOJChallengeContestAPI>(FIXTURE_PATHS.jagRegional.contests);
let client: AojChallengesApiClient;

beforeEach(() => {
nock(AOJ_API_BASE).get('/challenges/cl/JAG/REGIONAL').reply(200, contestsMock);
client = buildChallengesClient();
});

test('fetches and transforms JAG REGIONAL contests', async () => {
const contests = await client.getContests({ contestType: 'JAG', round: 'REGIONAL' });
const expectedCount = contestsMock.contests.flatMap((contest) => contest.days).length;
expect(contests.length).toBe(expectedCount);
});

test('fetches and transforms JAG REGIONAL tasks', async () => {
const tasks = await client.getTasks({ contestType: 'JAG', round: 'REGIONAL' });
const expectedCount = contestsMock.contests
.flatMap((contest) => contest.days)
.flatMap((day) => day.problems).length;
expect(tasks.length).toBe(expectedCount);
});
});
});
Loading
Loading