diff --git a/prisma/ERD.md b/prisma/ERD.md index 49fc9ffb8..bd458e6bd 100644 --- a/prisma/ERD.md +++ b/prisma/ERD.md @@ -29,6 +29,7 @@ UNIVERSITY UNIVERSITY OTHERS OTHERS AOJ_COURSES AOJ_COURSES AOJ_PCK AOJ_PCK +AOJ_JAG AOJ_JAG } diff --git a/prisma/migrations/20241120113542_add_aoj_jag_to_contest_type/migration.sql b/prisma/migrations/20241120113542_add_aoj_jag_to_contest_type/migration.sql new file mode 100644 index 000000000..40b8d8f0d --- /dev/null +++ b/prisma/migrations/20241120113542_add_aoj_jag_to_contest_type/migration.sql @@ -0,0 +1,2 @@ +-- AlterEnum +ALTER TYPE "ContestType" ADD VALUE 'AOJ_JAG'; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 669dec9dc..543ef8f8c 100755 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -224,6 +224,7 @@ enum ContestType { OTHERS // AtCoder (その他) AOJ_COURSES // AIZU ONLINE JUDGE Courses AOJ_PCK // All-Japan High School Programming Contest (PCK) + AOJ_JAG // ACM-ICPC Japan Alumni Group Contest (JAG) } // 11Q(最も簡単)〜6D(最難関)。 diff --git a/src/lib/clients/aizu_online_judge.ts b/src/lib/clients/aizu_online_judge.ts index fe6847120..648baf364 100644 --- a/src/lib/clients/aizu_online_judge.ts +++ b/src/lib/clients/aizu_online_judge.ts @@ -30,6 +30,11 @@ type AOJChallengeContestAPI = { readonly contests: ChallengeContests; }; +/** + * Represents the types of challenge contests available. + */ +type ChallengeContestType = 'PCK' | 'JAG'; + /** * Represents a challenge contest in the AOJ */ @@ -73,12 +78,26 @@ type AOJTaskAPI = { type AOJTaskAPIs = AOJTaskAPI[]; /** - * Enum representing PCK contest rounds + * Represents PCK contest rounds */ -enum PckRound { - PRELIM = 'prelim', - FINAL = 'final', -} +type PckRound = 'PRELIM' | 'FINAL'; + +/** + * Represents JAG contest rounds + */ +type JagRound = 'PRELIM' | 'REGIONAL'; + +/** + * A map that associates each type of challenge contest with its corresponding round type. + * + * @typedef {Object} ChallengeRoundMap + * @property {PckRound} PCK - The round type for PCK contests. + * @property {JagRound} JAG - The round type for JAG contests. + */ +type ChallengeRoundMap = { + PCK: PckRound; + JAG: JagRound; +}; /** * Constant used as a placeholder for missing timestamp data in AOJ contests @@ -86,6 +105,191 @@ enum PckRound { */ const PENDING = -1; +/** + * The time-to-live (TTL) for the cache, specified in milliseconds. + * This value represents 1 hour. + */ +const DEFAULT_CACHE_TTL = 60 * 60 * 1000; // 1 hour in milliseconds +const DEFAULT_MAX_CACHE_SIZE = 50; + +/** + * Configuration options for caching. + * + * @property {number} [timeToLive] - The duration (in milliseconds) for which a cache entry should remain valid. + * @property {number} [maxSize] - The maximum number of entries that the cache can hold. + */ +interface CacheConfig { + timeToLive?: number; + maxSize?: number; +} + +/** + * Represents a cache entry with data and a timestamp. + * + * @template T - The type of the cached data. + * @property {T} data - The cached data. + * @property {number} timestamp - The timestamp when the data was cached. + */ +type CacheEntry = { + data: T; + timestamp: number; +}; + +/** + * A generic cache class that stores data with a timestamp and provides methods to set, get, and delete cache entries. + * The cache automatically removes the oldest entry when the maximum cache size is reached. + * Entries are also automatically invalidated and removed if they exceed a specified time-to-live (TTL). + * + * @template T - The type of data to be stored in the cache. + */ +class Cache { + private cache: Map> = new Map(); + private cleanupInterval: NodeJS.Timeout; + + /** + * Constructs an instance of the class with the specified cache time-to-live (TTL) and maximum cache size. + * + * @param timeToLive - The time-to-live for the cache entries, in milliseconds. Defaults to `CACHE_TTL`. + * @param maxSize - The maximum number of entries the cache can hold. Defaults to `MAX_CACHE_SIZE`. + */ + constructor( + private readonly timeToLive: number = DEFAULT_CACHE_TTL, + private readonly maxSize: number = DEFAULT_MAX_CACHE_SIZE, + ) { + if (timeToLive <= 0) { + throw new Error('TTL must be positive'); + } + if (maxSize <= 0) { + throw new Error('Max size must be positive'); + } + + this.cleanupInterval = setInterval(() => this.cleanup(), timeToLive); + } + + /** + * Gets the size of the cache. + * + * @returns {number} The number of items in the cache. + */ + get size(): number { + return this.cache.size; + } + + /** + * Retrieves the health status of the cache. + * + * @returns An object containing the size of the cache and the timestamp of the oldest entry. + * @property {number} size - The number of entries in the cache. + * @property {number} oldestEntry - The timestamp of the oldest entry in the cache. + */ + get health(): { size: number; oldestEntry: number } { + const oldestEntry = Math.min( + ...Array.from(this.cache.values()).map((entry) => entry.timestamp), + ); + return { size: this.cache.size, oldestEntry }; + } + + /** + * Sets a new entry in the cache with the specified key and data. + * If the cache size exceeds the maximum limit, the oldest entry is removed. + * + * @param key - The key associated with the data to be cached. + * @param data - The data to be cached. + */ + set(key: string, data: T): void { + if (!key || typeof key !== 'string' || key.length > 255) { + throw new Error('Invalid cache key'); + } + + if (this.cache.size >= this.maxSize) { + const oldestKey = this.findOldestEntry(); + + if (oldestKey) { + this.cache.delete(oldestKey); + } + } + + this.cache.set(key, { data, timestamp: Date.now() }); + } + + /** + * Retrieves an entry from the cache. + * + * @param key - The key associated with the cache entry. + * @returns The cached data if it exists and is not expired, otherwise `undefined`. + */ + get(key: string): T | undefined { + const entry = this.cache.get(key); + + if (!entry) { + return undefined; + } + + if (Date.now() - entry.timestamp > this.timeToLive) { + this.cache.delete(key); + return undefined; + } + + return entry.data; + } + + /** + * Disposes of resources used by the Aizu Online Judge client. + * + * This method clears the interval used for cleanup and clears the cache. + * It should be called when the client is no longer needed to prevent memory leaks. + */ + dispose(): void { + clearInterval(this.cleanupInterval); + this.cache.clear(); + } + + /** + * Clears all entries from the cache. + */ + clear(): void { + this.cache.clear(); + } + + /** + * Deletes an entry from the cache. + * + * @param key - The key of the entry to delete. + */ + delete(key: string): void { + this.cache.delete(key); + } + + private cleanup(): void { + const now = Date.now(); + + for (const [key, entry] of this.cache.entries()) { + if (now - entry.timestamp > this.timeToLive) { + this.cache.delete(key); + } + } + } + + private findOldestEntry(): string | undefined { + let oldestKey: string | undefined; + let oldestTime = Infinity; + + for (const [key, entry] of this.cache.entries()) { + if (entry.timestamp < oldestTime) { + oldestTime = entry.timestamp; + oldestKey = key; + } + } + + return oldestKey; + } +} + +interface ApiClientConfig { + contestCache: CacheConfig; + taskCache: CacheConfig; +} + /** * AojApiClient is a client for interacting with the Aizu Online Judge (AOJ) API. * It extends the ContestSiteApiClient and provides methods to fetch contests and tasks @@ -95,24 +299,94 @@ const PENDING = -1; * @extends {ContestSiteApiClient} */ export class AojApiClient extends ContestSiteApiClient { + /** + * A cache for storing contests to be imported. + * This cache is used to temporarily hold contest data to improve performance + * and reduce the number of requests to the Aizu Online Judge API. + * + * @private + * @readonly + * @type {Cache} + */ + private readonly contestCache = new Cache(); + + /** + * A cache for storing tasks to be imported. + * This cache helps in reducing the number of requests to the external source + * by storing previously fetched tasks. + * + * @private + * @readonly + * @type {Cache} + */ + private readonly taskCache = new Cache(); + + /** + * Constructs an instance of the Aizu Online Judge client. + * + * @param {ApiClientConfig} [config] - Optional configuration object for the API client. + * @param {Cache} [config.contestCache] - Configuration for the contest cache. + * @param {number} [config.contestCache.timeToLive] - Time to live for contest cache entries. + * @param {number} [config.contestCache.maxSize] - Maximum size of the contest cache. + * @param {Cache} [config.taskCache] - Configuration for the task cache. + * @param {number} [config.taskCache.timeToLive] - Time to live for task cache entries. + * @param {number} [config.taskCache.maxSize] - Maximum size of the task cache. + */ + constructor(config?: ApiClientConfig) { + super(); + + this.contestCache = new Cache( + config?.contestCache?.timeToLive, + config?.contestCache?.maxSize, + ); + this.taskCache = new Cache( + config?.taskCache?.timeToLive, + config?.taskCache?.maxSize, + ); + } + + /** + * Disposes of the resources used by the client. + * Clears the contest and task caches to free up memory. + */ + dispose(): void { + this.contestCache.dispose(); + this.taskCache.dispose(); + } + /** * Fetches and combines contests from different sources. * - * This method concurrently fetches course contests, preliminary PCK contests, - * and final PCK contests, then combines them into a single array. + * This method concurrently fetches course contests, preliminary and final PCK contests, + * and prelim and regional JAG contests, then combines them into a single array. * * @returns {Promise} A promise that resolves to an array of contests. */ async getContests(): Promise { try { - const [courses, pckPrelims, pckFinals] = await Promise.all([ + const results = await Promise.allSettled([ this.fetchCourseContests(), - this.fetchPckContests(PckRound.PRELIM), - this.fetchPckContests(PckRound.FINAL), + this.fetchChallengeContests('PCK', 'PRELIM'), + this.fetchChallengeContests('PCK', 'FINAL'), + this.fetchChallengeContests('JAG', 'PRELIM'), + this.fetchChallengeContests('JAG', 'REGIONAL'), ]); - const contests = courses.concat(pckPrelims, pckFinals); - console.log(`Found AOJ: ${contests.length} contests.`); + const [courses, pckPrelims, pckFinals, jagPrelims, jagRegionals] = results.map((result) => { + if (result.status === 'rejected') { + console.error(`Failed to fetch contests from AOJ API:`, result.reason); + return []; + } + + return result.value; + }); + const contests = courses.concat(pckPrelims, pckFinals, jagPrelims, jagRegionals); + + this.logEntityCount('contests', { + courses: courses.length, + pck: pckPrelims.length + pckFinals.length, + jag: jagPrelims.length + jagRegionals.length, + }); return contests; } catch (error) { @@ -158,24 +432,39 @@ export class AojApiClient extends ContestSiteApiClient { } /** - * Fetches PCK contests from the AOJ API for a given round. + * Fetches challenge contests from the AOJ API for a given round. * - * @param {PckRound} round - The round identifier for which to fetch contests. + * @param {ChallengeContestType} contestType - The type of challenge contest for which to fetch contests. + * @param {PckRound | JagRound} round - The round identifier for which to fetch contests. * @returns {Promise} A promise that resolves to an array of contests for import. * * @throws Will throw an error if the API request fails or the response validation fails. * * @example + * const contestType = ChallengeContestType.PCK; * const round = 'PRELIM'; - * const contests = await fetchPckContests(round); + * const contests = await fetchChallengeContests(contestType, round); * console.log(contests); */ - private async fetchPckContests(round: PckRound): Promise { + private async fetchChallengeContests( + contestType: T, + round: ChallengeRoundMap[T], + ): Promise { + const cacheKey = `${contestType.toLowerCase()}_${round.toLowerCase()}`; + const cachedContests = this.contestCache.get(cacheKey); + + if (cachedContests) { + console.log('Using cached contests for', cacheKey); + return cachedContests; + } + + const contestTypeLabel = contestType.toUpperCase(); + try { const results = await this.fetchApiWithConfig({ baseApiUrl: AOJ_API_BASE_URL, - endpoint: `challenges/cl/pck/${round}`, - errorMessage: `Failed to fetch ${round} contests from AOJ API`, + endpoint: this.buildEndpoint(['challenges', 'cl', contestType, round]), + errorMessage: `Failed to fetch ${contestTypeLabel} ${round} contests from AOJ API`, validateResponse: (data) => 'contests' in data && Array.isArray(data.contests) && data.contests.length > 0, }); @@ -192,15 +481,68 @@ export class AojApiClient extends ContestSiteApiClient { [] as ContestsForImport, ); - console.log(`Found AOJ PCK ${round}: ${contests.length} contests.`); + console.log(`Found AOJ ${contestTypeLabel} ${round}: ${contests.length} contests.`); + + this.contestCache.set(cacheKey, contests); return contests; } catch (error) { - console.error(`Failed to fetch from AOJ PCK ${round} contests:`, error); + console.error(`Failed to fetch from AOJ ${contestTypeLabel} ${round} contests:`, error); return []; } } + /** + * Logs the count of AOJ entities (contests or tasks) to the console. + * + * @param entity - The type of entity being logged, either 'contests' or 'tasks'. + * @param counts - An object containing the counts of different categories. + * @param counts.courses - The count of courses. + * @param counts.pck - The count of PCK. + * @param counts.jag - The count of JAG. + */ + private logEntityCount( + entity: 'contests' | 'tasks', + counts: { courses: number; pck: number; jag: number }, + ): void { + console.info( + `Found AOJ ${entity} - Total: ${counts.courses + counts.pck + counts.jag} ` + + `(Courses: ${counts.courses}, PCK: ${counts.pck}, JAG: ${counts.jag})`, + ); + } + + /** + * Constructs an endpoint URL by encoding each segment and joining them with a '/'. + * + * @param segments - An array of strings representing the segments of the URL. + * @returns The constructed endpoint URL as a string. + */ + private buildEndpoint(segments: string[]): string { + if (!segments?.length) { + throw new Error('Endpoint segments array cannot be empty'); + } + + // Allow alphanumeric characters, hyphens, and underscores + const MAX_SEGMENT_LENGTH = 100; + const validateSegment = (segment: string): boolean => { + return ( + segment.length <= MAX_SEGMENT_LENGTH && + /^[a-zA-Z](?:[a-zA-Z0-9]|[-_](?=[a-zA-Z0-9])){0,98}[a-zA-Z0-9]$/.test(segment) && + !segment.includes('..') + ); + }; + + for (const segment of segments) { + if (!validateSegment(segment)) { + throw new Error( + `Invalid segment: ${segment}. Segments must be alphanumeric with hyphens and underscores, max length ${MAX_SEGMENT_LENGTH}`, + ); + } + } + + return segments.map((segment) => encodeURIComponent(segment)).join('/'); + } + /** * Maps the given contest details to a `ContestForImport` object. * @@ -226,10 +568,12 @@ export class AojApiClient extends ContestSiteApiClient { /** * Fetches tasks from various sources and combines them into a single list. * - * This method concurrently fetches tasks from three different sources: + * This method concurrently fetches tasks from five different sources: * - Course tasks * - PCK Prelim tasks * - PCK Final tasks + * - JAG Prelim tasks + * - JAG Regional tasks * * The fetched tasks are then concatenated into a single array and returned. * @@ -239,13 +583,29 @@ export class AojApiClient extends ContestSiteApiClient { */ async getTasks(): Promise { try { - const [courses, pckPrelims, pckFinals] = await Promise.all([ + const results = await Promise.allSettled([ this.fetchCourseTasks(), - this.fetchPckTasks(PckRound.PRELIM), - this.fetchPckTasks(PckRound.FINAL), + this.fetchChallengeTasks('PCK', 'PRELIM'), + this.fetchChallengeTasks('PCK', 'FINAL'), + this.fetchChallengeTasks('JAG', 'PRELIM'), + this.fetchChallengeTasks('JAG', 'REGIONAL'), ]); - const tasks = courses.concat(pckPrelims, pckFinals); - console.log(`Found AOJ: ${tasks.length} tasks.`); + + const [courses, pckPrelims, pckFinals, jagPrelims, jagRegionals] = results.map((result) => { + if (result.status === 'rejected') { + console.error(`Failed to fetch tasks from AOJ API:`, result.reason); + return []; + } + + return result.value; + }); + const tasks = courses.concat(pckPrelims, pckFinals, jagPrelims, jagRegionals); + + this.logEntityCount('tasks', { + courses: courses.length, + pck: pckPrelims.length + pckFinals.length, + jag: jagPrelims.length + jagRegionals.length, + }); return tasks; } catch (error) { @@ -309,9 +669,10 @@ export class AojApiClient extends ContestSiteApiClient { }; /** - * Fetches tasks for a specified PCK round from the AOJ API. + * Fetches tasks for a specified challenge contest round from the AOJ API. * - * @param {string} round - The round identifier for which to fetch tasks. + * @param {ChallengeContestType} contestType - The type of challenge contest for which to fetch tasks. + * @param {PckRound | JagRound} round - The round identifier for which to fetch tasks. * @returns {Promise} A promise that resolves to an object containing tasks for import. * @throws Will throw an error if the API request fails or the response is invalid. * @@ -321,17 +682,25 @@ export class AojApiClient extends ContestSiteApiClient { * 3. Maps the contest data to a list of tasks, extracting relevant information such as task ID, contest ID, and title. * 4. Logs the number of tasks found for the specified round. */ - private async fetchPckTasks(round: string): Promise { - if (!Object.values(PckRound).includes(round as PckRound)) { - console.error(`Found invalid PCK round: ${round}`); - return []; + private async fetchChallengeTasks( + contestType: T, + round: ChallengeRoundMap[T], + ): Promise { + const cacheKey = `${contestType.toLowerCase()}_${round.toLowerCase()}`; + const cachedTasks = this.taskCache.get(cacheKey); + + if (cachedTasks) { + console.log('Using cached tasks for', cacheKey); + return cachedTasks; } + const contestTypeLabel = contestType.toUpperCase(); + try { const allPckContests = await this.fetchApiWithConfig({ baseApiUrl: AOJ_API_BASE_URL, - endpoint: `challenges/cl/pck/${round}`, - errorMessage: `Failed to fetch PCK ${round} tasks from AOJ API`, + endpoint: this.buildEndpoint(['challenges', 'cl', contestType, round]), + errorMessage: `Failed to fetch ${contestTypeLabel} ${round} tasks from AOJ API`, validateResponse: (data) => 'contests' in data && Array.isArray(data.contests) && data.contests.length > 0, }); @@ -350,11 +719,13 @@ export class AojApiClient extends ContestSiteApiClient { }, [], ); - console.log(`Found PCK ${round}: ${tasks.length} tasks.`); + console.log(`Found ${contestTypeLabel} ${round}: ${tasks.length} tasks.`); + + this.taskCache.set(cacheKey, tasks); return tasks; } catch (error) { - console.error(`Failed to fetch from PCK ${round} tasks:`, error); + console.error(`Failed to fetch from ${contestTypeLabel} ${round} tasks:`, error); return []; } } diff --git a/src/lib/clients/index.ts b/src/lib/clients/index.ts index 83cc5dd0f..1a4bd1f0d 100644 --- a/src/lib/clients/index.ts +++ b/src/lib/clients/index.ts @@ -16,6 +16,7 @@ import type { TaskForImport, TasksForImport } from '$lib/types/task'; // ・Courses // ・Challenges // ・PCK (All-Japan High School Programming Contest) +// ・JAG (ACM-ICPC Japan Alumni Group Contest) /** * Creates and returns an object containing instances of various API clients. diff --git a/src/lib/types/contest.ts b/src/lib/types/contest.ts index 130d9bcbb..ae23ac7d2 100644 --- a/src/lib/types/contest.ts +++ b/src/lib/types/contest.ts @@ -23,6 +23,10 @@ export type ContestsForImport = ContestForImport[]; // Import original enum as type. import type { ContestType as ContestTypeOrigin } from '@prisma/client'; +/** + * An object representing various types of programming contests. + * Each key is a contest type identifier, and the value is the same identifier as a string. + */ export const ContestType: { [key in ContestTypeOrigin]: key } = { ABC: 'ABC', // AtCoder Beginner Contest APG4B: 'APG4B', // C++入門 AtCoder Programming Guide for beginners @@ -44,6 +48,7 @@ export const ContestType: { [key in ContestTypeOrigin]: key } = { OTHERS: 'OTHERS', // AtCoder (その他) AOJ_COURSES: 'AOJ_COURSES', // AIZU ONLINE JUDGE Courses AOJ_PCK: 'AOJ_PCK', // All-Japan High School Programming Contest (PCK) + AOJ_JAG: 'AOJ_JAG', // ACM-ICPC Japan Alumni Group Contest (JAG) } as const; // Re-exporting the original type with the original name. @@ -65,3 +70,14 @@ export type ContestType = ContestTypeOrigin; export interface ContestPrefix { [key: string]: string; } + +/** + * Represents a collection of translations for contest labels. + * The keys are language codes or identifiers, and the values are the translated strings. + * + * @property {string} [key] - The abbr contest type in English. + * @property {string} [key: string] - The contest name in Japanese. + */ +export type ContestLabelTranslations = { + [key: string]: string; +}; diff --git a/src/lib/utils/contest.ts b/src/lib/utils/contest.ts index 244117fcd..94f31d765 100644 --- a/src/lib/utils/contest.ts +++ b/src/lib/utils/contest.ts @@ -1,4 +1,4 @@ -import { ContestType, type ContestPrefix } from '$lib/types/contest'; +import { ContestType, type ContestPrefix, type ContestLabelTranslations } from '$lib/types/contest'; // See: // https://github.com/kenkoooo/AtCoderProblems/blob/master/atcoder-problems-frontend/src/utils/ContestClassifier.ts @@ -81,6 +81,10 @@ export const classifyContest = (contest_id: string) => { return ContestType.AOJ_PCK; } + if (/^JAG(Prelim|Regional|Summer|Winter|Spring)\d*$/.exec(contest_id)) { + return ContestType.AOJ_JAG; + } + return null; }; @@ -190,7 +194,7 @@ export function getContestPrefixes(contestPrefixes: Record) { * - Educational contests (0-10): ABS, ABC, APG4B, etc. * - Contests for genius (11-15): ARC, AGC, and their variants * - Special contests (16-17): UNIVERSITY, OTHERS - * - External platforms (18-19): AOJ_COURSES, AOJ_PCK + * - External platforms (18-20): AOJ_COURSES, AOJ_PCK, AOJ_JAG * * @remarks * HACK: The priorities for ARC, AGC, UNIVERSITY, AOJ_COURSES, and AOJ_PCK are temporary @@ -220,6 +224,7 @@ export const contestTypePriorities: Map = new Map([ [ContestType.OTHERS, 17], // AtCoder (その他) [ContestType.AOJ_COURSES, 18], [ContestType.AOJ_PCK, 19], + [ContestType.AOJ_JAG, 20], ]); export function getContestPriority(contestId: string): number { @@ -271,26 +276,59 @@ export const getContestNameLabel = (contest_id: string) => { } if (contest_id.startsWith('PCK')) { - return getAojPckLabel(contest_id); + return getAojChallengeLabel(PCK_TRANSLATIONS, contest_id); + } + + if (contest_id.startsWith('JAG')) { + return getAojChallengeLabel(JAG_TRANSLATIONS, contest_id); } return contest_id.toUpperCase(); }; -function getAojPckLabel(contestId: string): string { - const PCK_TRANSLATIONS = { - PCK: 'パソコン甲子園', - Prelim: '予選', - Final: '本選', - }; +/** + * Maps PCK contest type abbreviations to their Japanese translations. + * + * @example + * { + * PCK: 'パソコン甲子園', + * Prelim: '予選', + * Final: '本選' + * } + */ +const PCK_TRANSLATIONS = { + PCK: 'パソコン甲子園', + Prelim: '予選', + Final: '本選', +}; + +/** + * Maps JAG contest type abbreviations to their Japanese translations. + * + * @example + * { + * Prelim: '模擬国内予選', + * Regional: '模擬アジア地区予選' + * } + */ +const JAG_TRANSLATIONS = { + Prelim: '模擬国内予選', + Regional: '模擬アジア地区予選', +}; + +const aojBaseLabel = 'AOJ - '; - const baseLabel = 'AOJ - '; +function getAojChallengeLabel( + translations: Readonly, + contestId: string, +): string { + let label = contestId; - Object.entries(PCK_TRANSLATIONS).forEach(([abbrEnglish, japanese]) => { - contestId = contestId.replace(abbrEnglish, japanese); + Object.entries(translations).forEach(([abbrEnglish, japanese]) => { + label = label.replace(abbrEnglish, japanese); }); - return baseLabel + contestId; + return aojBaseLabel + label; } export const addContestNameToTaskIndex = (contestId: string, taskTableIndex: string): string => { diff --git a/src/lib/utils/task.ts b/src/lib/utils/task.ts index bad7c6c3d..758fd5900 100644 --- a/src/lib/utils/task.ts +++ b/src/lib/utils/task.ts @@ -30,7 +30,11 @@ class AtCoderGenerator implements UrlGenerator { class AojGenerator implements UrlGenerator { canHandle(contestId: string): boolean { - return getPrefixForAojCourses().includes(contestId) || contestId.startsWith('PCK'); + return ( + getPrefixForAojCourses().includes(contestId) || + contestId.startsWith('PCK') || + contestId.startsWith('JAG') + ); } // Note: contestId is not used because it is not included in the URL. diff --git a/src/test/lib/utils/contest.test.ts b/src/test/lib/utils/contest.test.ts index dd5293099..c3ca37ea1 100644 --- a/src/test/lib/utils/contest.test.ts +++ b/src/test/lib/utils/contest.test.ts @@ -171,6 +171,14 @@ describe('Contest', () => { }); }); }); + + describe('when contest_id mean AOJ JAG (prelim and regional) ', () => { + TestCasesForContestType.aojJag.forEach(({ name, value }) => { + runTests(`${name}`, [value], ({ contestId, expected }: TestCaseForContestType) => { + expect(classifyContest(contestId)).toEqual(expected); + }); + }); + }); }); }); @@ -329,6 +337,14 @@ describe('Contest', () => { }); }); }); + + describe('when contest_id means AOJ JAG (prelim and regional)', () => { + TestCasesForContestType.aojJag.forEach(({ name, value }) => { + runTests(`${name}`, [value], ({ contestId, expected }: TestCaseForContestType) => { + expect(getContestPriority(contestId)).toEqual(contestTypePriorities.get(expected)); + }); + }); + }); }); }); @@ -471,6 +487,14 @@ describe('Contest', () => { }); }); }); + + describe('when contest_id means AOJ JAG (prelim and regional)', () => { + TestCasesForContestNameLabel.aojJag.forEach(({ name, value }) => { + runTests(`${name}`, [value], ({ contestId, expected }: TestCaseForContestNameLabel) => { + expect(getContestNameLabel(contestId)).toEqual(expected); + }); + }); + }); }); }); @@ -597,6 +621,18 @@ describe('Contest', () => { ); }); }); + + describe('when contest_id means AOJ JAG (prelim and regional)', () => { + TestCasesForContestNameAndTaskIndex.aojJag.forEach(({ name, value }) => { + runTests( + `${name}`, + [value], + ({ contestId, taskTableIndex, expected }: TestCaseForContestNameAndTaskIndex) => { + expect(addContestNameToTaskIndex(contestId, taskTableIndex)).toEqual(expected); + }, + ); + }); + }); }); }); }); diff --git a/src/test/lib/utils/task.test.ts b/src/test/lib/utils/task.test.ts index 5a9834454..d2b6adc51 100644 --- a/src/test/lib/utils/task.test.ts +++ b/src/test/lib/utils/task.test.ts @@ -78,6 +78,14 @@ describe('Task', () => { }); }); }); + + describe('when contest ids and task ids for AOJ JAG (Prelim and Regional) are given', () => { + TestCasesForTaskUrl.aojJag.forEach(({ name, value }) => { + runTests(`${name}`, [value], ({ contestId, taskId, expected }: TestCaseForTaskUrl) => { + expect(getTaskUrl(contestId, taskId)).toBe(expected); + }); + }); + }); }); describe('count accepted tasks', () => { diff --git a/src/test/lib/utils/test_cases/contest_name_and_task_index.ts b/src/test/lib/utils/test_cases/contest_name_and_task_index.ts index 1c3dffd69..bb1145705 100644 --- a/src/test/lib/utils/test_cases/contest_name_and_task_index.ts +++ b/src/test/lib/utils/test_cases/contest_name_and_task_index.ts @@ -417,3 +417,101 @@ const generateAojPckTestCases = ( export const aojPck = Object.entries(AOJ_PCK_TEST_DATA).flatMap(([contestId, tasks]) => generateAojPckTestCases(Array(tasks.tasks.length).fill(contestId), tasks.tasks), ); + +/** + * Test cases for AOJ JAG contests + * Includes both preliminary (模擬国内予選) and regional (模擬アジア地区予選) rounds + * Format: {round}{year} - {problemId} + * - Task ID format: + * - Recent contests (2021+): 33xx-33xx + * - Older contests (2005-2006): 20xx-20xx + */ +const AOJ_JAG_TEST_DATA = { + Prelim2005: { + contestId: 'Prelim2005', + tasks: ['2006', '2007', '2011'], + }, + Prelim2006: { + contestId: 'Prelim2006', + tasks: ['2000', '2001', '2005'], + }, + Prelim2023: { + contestId: 'Prelim2023', + tasks: ['3358', '3359', '3365'], + }, + Prelim2024: { + contestId: 'Prelim2024', + tasks: ['3386', '3387', '3394'], + }, + Regional2005: { + contestId: 'Regional2005', + tasks: ['2024', '2025', '2029'], + }, + Regional2006: { + contestId: 'Regional2006', + tasks: ['2030', '2031', '2038'], + }, + Regional2017: { + contestId: 'Regional2017', + tasks: ['2856', '2857', '2866'], + }, + Regional2020: { + contestId: 'Regional2020', + tasks: ['3218', '3219', '3228'], + }, + Regional2021: { + contestId: 'Regional2021', + tasks: ['3300', '3301', '3310'], + }, + Regional2022: { + contestId: 'Regional2022', + tasks: ['3346', '3347', '3357'], + }, +}; + +// Note: Test cases cover years when JAG contests were actually held +// Prelims: 2005-2006, 2009-2011, 2020-2024 +// Regionals: 2005-2006, 2009-2011, 2016-2017, 2020-2022 +type JagRound = 'Prelim' | 'Regional'; +type JagYear = + | '2005' + | '2006' + | '2009' + | '2010' + | '2011' + | '2017' + | '2020' + | '2021' + | '2022' + | '2023' + | '2024'; +type JagContestId = `${JagRound}${JagYear}`; +type JagContestIds = JagContestId[]; + +const generateContestTestCases = ( + contestIds: T[], + taskIndices: string[], + formattedName: (contestId: T, taskIndex: string) => string, + expectedFormat: (contestId: T, taskIndex: string) => string, +): { name: string; value: TestCaseForContestNameAndTaskIndex }[] => { + return zip(contestIds, taskIndices).map(([contestId, taskIndex]) => { + return createTestCaseForContestNameAndTaskIndex(formattedName(contestId, taskIndex))({ + contestId: `JAG${contestId}`, + taskTableIndex: taskIndex, + expected: expectedFormat(contestId, taskIndex), + }); + }); +}; + +const generateAojJagTestCases = (contestIds: JagContestIds, taskIndices: string[]) => + generateContestTestCases( + contestIds, + taskIndices, + (contestId, taskIndex) => `AOJ, JAG${contestId} - ${taskIndex}`, + (contestId, taskIndex) => + `AOJ - JAG${contestId.replace('Prelim', '模擬国内予選').replace('Regional', '模擬アジア地区予選')} - ${taskIndex}`, + ); + +export const aojJag = Object.entries(AOJ_JAG_TEST_DATA).flatMap(([contestId, tasks]) => + generateAojJagTestCases(Array(tasks.tasks.length).fill(contestId), tasks.tasks), +); diff --git a/src/test/lib/utils/test_cases/contest_name_labels.ts b/src/test/lib/utils/test_cases/contest_name_labels.ts index 8e88a0c48..6cc1025a5 100644 --- a/src/test/lib/utils/test_cases/contest_name_labels.ts +++ b/src/test/lib/utils/test_cases/contest_name_labels.ts @@ -355,3 +355,86 @@ export const aojPck = [ expected: 'AOJ - パソコン甲子園本選2003', }), ]; + +export const aojJag = [ + createTestCaseForContestNameLabel('AOJ, JAG Prelim 2005')({ + contestId: 'JAGPrelim2005', + expected: 'AOJ - JAG模擬国内予選2005', + }), + createTestCaseForContestNameLabel('AOJ, JAG Prelim 2006')({ + contestId: 'JAGPrelim2006', + expected: 'AOJ - JAG模擬国内予選2006', + }), + createTestCaseForContestNameLabel('AOJ, JAG Prelim 2009')({ + contestId: 'JAGPrelim2009', + expected: 'AOJ - JAG模擬国内予選2009', + }), + createTestCaseForContestNameLabel('AOJ, JAG Prelim 2010')({ + contestId: 'JAGPrelim2010', + expected: 'AOJ - JAG模擬国内予選2010', + }), + createTestCaseForContestNameLabel('AOJ, JAG Prelim 2011')({ + contestId: 'JAGPrelim2011', + expected: 'AOJ - JAG模擬国内予選2011', + }), + createTestCaseForContestNameLabel('AOJ, JAG Prelim 2020')({ + contestId: 'JAGPrelim2020', + expected: 'AOJ - JAG模擬国内予選2020', + }), + createTestCaseForContestNameLabel('AOJ, JAG Prelim 2021')({ + contestId: 'JAGPrelim2021', + expected: 'AOJ - JAG模擬国内予選2021', + }), + createTestCaseForContestNameLabel('AOJ, JAG Prelim 2022')({ + contestId: 'JAGPrelim2022', + expected: 'AOJ - JAG模擬国内予選2022', + }), + createTestCaseForContestNameLabel('AOJ, JAG Prelim 2023')({ + contestId: 'JAGPrelim2023', + expected: 'AOJ - JAG模擬国内予選2023', + }), + createTestCaseForContestNameLabel('AOJ, JAG Prelim 2024')({ + contestId: 'JAGPrelim2024', + expected: 'AOJ - JAG模擬国内予選2024', + }), + createTestCaseForContestNameLabel('AOJ, JAG Regional 2005')({ + contestId: 'JAGRegional2005', + expected: 'AOJ - JAG模擬アジア地区予選2005', + }), + createTestCaseForContestNameLabel('AOJ, JAG Regional 2006')({ + contestId: 'JAGRegional2006', + expected: 'AOJ - JAG模擬アジア地区予選2006', + }), + createTestCaseForContestNameLabel('AOJ, JAG Regional 2009')({ + contestId: 'JAGRegional2009', + expected: 'AOJ - JAG模擬アジア地区予選2009', + }), + createTestCaseForContestNameLabel('AOJ, JAG Regional 2010')({ + contestId: 'JAGRegional2010', + expected: 'AOJ - JAG模擬アジア地区予選2010', + }), + createTestCaseForContestNameLabel('AOJ, JAG Regional 2011')({ + contestId: 'JAGRegional2011', + expected: 'AOJ - JAG模擬アジア地区予選2011', + }), + createTestCaseForContestNameLabel('AOJ, JAG Regional 2016')({ + contestId: 'JAGRegional2016', + expected: 'AOJ - JAG模擬アジア地区予選2016', + }), + createTestCaseForContestNameLabel('AOJ, JAG Regional 2017')({ + contestId: 'JAGRegional2017', + expected: 'AOJ - JAG模擬アジア地区予選2017', + }), + createTestCaseForContestNameLabel('AOJ, JAG Regional 2020')({ + contestId: 'JAGRegional2020', + expected: 'AOJ - JAG模擬アジア地区予選2020', + }), + createTestCaseForContestNameLabel('AOJ, JAG Regional 2021')({ + contestId: 'JAGRegional2021', + expected: 'AOJ - JAG模擬アジア地区予選2021', + }), + createTestCaseForContestNameLabel('AOJ, JAG Regional 2022')({ + contestId: 'JAGRegional2022', + expected: 'AOJ - JAG模擬アジア地区予選2022', + }), +]; diff --git a/src/test/lib/utils/test_cases/contest_type.ts b/src/test/lib/utils/test_cases/contest_type.ts index db1457862..70051f08a 100644 --- a/src/test/lib/utils/test_cases/contest_type.ts +++ b/src/test/lib/utils/test_cases/contest_type.ts @@ -343,3 +343,33 @@ export const aojPck = aojPckContestData.map(({ name, contestId }) => expected: ContestType.AOJ_PCK, }), ); + +const aojJagContestData = [ + { name: 'AOJ, JAG Prelim 2005', contestId: 'JAGPrelim2005' }, + { name: 'AOJ, JAG Prelim 2006', contestId: 'JAGPrelim2006' }, + { name: 'AOJ, JAG Prelim 2009', contestId: 'JAGPrelim2009' }, + { name: 'AOJ, JAG Prelim 2010', contestId: 'JAGPrelim2010' }, + { name: 'AOJ, JAG Prelim 2011', contestId: 'JAGPrelim2011' }, + { name: 'AOJ, JAG Prelim 2020', contestId: 'JAGPrelim2020' }, + { name: 'AOJ, JAG Prelim 2021', contestId: 'JAGPrelim2021' }, + { name: 'AOJ, JAG Prelim 2022', contestId: 'JAGPrelim2022' }, + { name: 'AOJ, JAG Prelim 2023', contestId: 'JAGPrelim2023' }, + { name: 'AOJ, JAG Prelim 2024', contestId: 'JAGPrelim2024' }, + { name: 'AOJ, JAG Regional 2005', contestId: 'JAGRegional2005' }, + { name: 'AOJ, JAG Regional 2006', contestId: 'JAGRegional2006' }, + { name: 'AOJ, JAG Regional 2009', contestId: 'JAGRegional2009' }, + { name: 'AOJ, JAG Regional 2010', contestId: 'JAGRegional2010' }, + { name: 'AOJ, JAG Regional 2011', contestId: 'JAGRegional2011' }, + { name: 'AOJ, JAG Regional 2016', contestId: 'JAGRegional2016' }, + { name: 'AOJ, JAG Regional 2017', contestId: 'JAGRegional2017' }, + { name: 'AOJ, JAG Regional 2020', contestId: 'JAGRegional2020' }, + { name: 'AOJ, JAG Regional 2021', contestId: 'JAGRegional2021' }, + { name: 'AOJ, JAG Regional 2022', contestId: 'JAGRegional2022' }, +]; + +export const aojJag = aojJagContestData.map(({ name, contestId }) => + createTestCaseForContestType(name)({ + contestId: contestId, + expected: ContestType.AOJ_JAG, + }), +); diff --git a/src/test/lib/utils/test_cases/task_url.ts b/src/test/lib/utils/test_cases/task_url.ts index 675065c34..4f68ba845 100644 --- a/src/test/lib/utils/test_cases/task_url.ts +++ b/src/test/lib/utils/test_cases/task_url.ts @@ -128,10 +128,33 @@ const pckContests = [ { contestId: 'PCKFinal2003', tasks: ['0000', '0098'] }, ]; -export const aojPck = pckContests.flatMap((course) => - course.tasks.map((task) => { - return createTestCaseForTaskUrl(`AOJ Courses, ${course.contestId} ${task}`)({ - contestId: course.contestId, +export const aojPck = pckContests.flatMap((pck) => + pck.tasks.map((task) => { + return createTestCaseForTaskUrl(`AOJ PCK, ${pck.contestId} ${task}`)({ + contestId: pck.contestId, + taskId: task, + expected: `${AOJ_TASKS_URL}/${task}`, + }); + }), +); + +// JAG contests follow these patterns: +// - Contest ID format: JAG(Prelim|Regional) +const jagContests = [ + { contestId: 'JAGPrelim2005', tasks: ['2006', '2007', '2011'] }, + { contestId: 'JAGPrelim2006', tasks: ['2000', '2001', '2005'] }, + { contestId: 'JAGPrelim2023', tasks: ['3358', '3359', '3365'] }, + { contestId: 'JAGPrelim2024', tasks: ['3386', '3387', '3394'] }, + { contestId: 'JAGRegional2005', tasks: ['2024', '2025', '2029'] }, + { contestId: 'JAGRegional2006', tasks: ['2030', '2031', '2038'] }, + { contestId: 'JAGRegional2021', tasks: ['3300', '3301', '3310'] }, + { contestId: 'JAGRegional2022', tasks: ['3346', '3347', '3357'] }, +]; + +export const aojJag = jagContests.flatMap((jag) => + jag.tasks.map((task) => { + return createTestCaseForTaskUrl(`AOJ JAG, ${jag.contestId} ${task}`)({ + contestId: jag.contestId, taskId: task, expected: `${AOJ_TASKS_URL}/${task}`, });