Skip to content

Commit 729dceb

Browse files
authored
Merge pull request #1540 from AtCoder-NoviSteps/#1526
:docs: Improve contest labels (#1526)
2 parents d50fed5 + 41bba20 commit 729dceb

File tree

5 files changed

+479
-123
lines changed

5 files changed

+479
-123
lines changed

src/lib/utils/contest.ts

Lines changed: 202 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -302,10 +302,18 @@ export const getContestNameLabel = (contestId: string) => {
302302
return 'TDPC';
303303
}
304304

305+
if (contestId.startsWith('past')) {
306+
return getPastContestLabel(PAST_TRANSLATIONS, contestId);
307+
}
308+
305309
if (contestId === 'practice2') {
306310
return 'ACL Practice';
307311
}
308312

313+
if (contestId.startsWith('joi')) {
314+
return getJoiContestLabel(contestId);
315+
}
316+
309317
if (contestId === 'tessoku-book') {
310318
return '競技プログラミングの鉄則';
311319
}
@@ -324,20 +332,203 @@ export const getContestNameLabel = (contestId: string) => {
324332

325333
// AIZU ONLINE JUDGE
326334
if (aojCoursePrefixes.has(contestId)) {
327-
return 'AOJ Courses';
335+
return getAojContestLabel(AOJ_COURSES, contestId);
328336
}
329337

330338
if (contestId.startsWith('PCK')) {
331-
return getAojChallengeLabel(PCK_TRANSLATIONS, contestId);
339+
return getAojContestLabel(PCK_TRANSLATIONS, contestId);
332340
}
333341

334342
if (contestId.startsWith('JAG')) {
335-
return getAojChallengeLabel(JAG_TRANSLATIONS, contestId);
343+
return getAojContestLabel(JAG_TRANSLATIONS, contestId);
336344
}
337345

338346
return contestId.toUpperCase();
339347
};
340348

349+
/**
350+
* A mapping of contest dates to their respective Japanese translations.
351+
* Each key represents a date in the format 'YYYYMM', and the corresponding value
352+
* is the Japanese translation indicating the contest number.
353+
*
354+
* Note:
355+
* After the 15th contest, the URL includes the number of times the contest has been held
356+
*
357+
* See:
358+
* https://atcoder.jp/contests/archive?ratedType=0&category=50
359+
*
360+
* Example:
361+
* - '201912': ' 第 1 回' (The 1st contest in December 2019)
362+
* - '202303': ' 第 14 回' (The 14th contest in March 2023)
363+
*/
364+
export const PAST_TRANSLATIONS = {
365+
'201912': ' 第 1 回',
366+
'202004': ' 第 2 回',
367+
'202005': ' 第 3 回',
368+
'202010': ' 第 4 回',
369+
'202012': ' 第 5 回',
370+
'202104': ' 第 6 回',
371+
'202107': ' 第 7 回',
372+
'202109': ' 第 8 回',
373+
'202112': ' 第 9 回',
374+
'202203': ' 第 10 回',
375+
'202206': ' 第 11 回',
376+
'202209': ' 第 12 回',
377+
'202212': ' 第 13 回',
378+
'202303': ' 第 14 回',
379+
};
380+
381+
/**
382+
* A regular expression to match strings that representing the 15th or later PAST contests.
383+
* The string should start with "past" followed by exactly two digits and end with "-open".
384+
* The matching is case-insensitive.
385+
*
386+
* Examples:
387+
* - "past15-open" (matches)
388+
* - "past16-open" (matches)
389+
* - "past99-open" (matches)
390+
*/
391+
const regexForPast = /^past(\d+)-open$/i;
392+
393+
export function getPastContestLabel(
394+
translations: Readonly<ContestLabelTranslations>,
395+
contestId: string,
396+
): string {
397+
let label = contestId;
398+
399+
Object.entries(translations).forEach(([abbrEnglish, japanese]) => {
400+
label = label.replace(abbrEnglish, japanese);
401+
});
402+
403+
if (label == contestId) {
404+
label = label.replace(regexForPast, (_, round) => {
405+
return `PAST 第 ${round} 回`;
406+
});
407+
}
408+
409+
// Remove suffix
410+
return label.replace('-open', '').toUpperCase();
411+
}
412+
413+
/**
414+
* Regular expression to match specific patterns in contest identifiers.
415+
*
416+
* The pattern matches strings that follow these rules:
417+
* - Starts with "joi" (case insensitive).
418+
* - Optionally followed by "g" or "open".
419+
* - Optionally represents year (4-digit number).
420+
* - Optionally followed by "yo", "ho", "sc", or "sp" (Qual, Final and Spring camp).
421+
* - Optionally represents year (4-digit number).
422+
* - Optionally followed by "1" or "2" (Qual 1st, 2nd).
423+
* - Optionally followed by "a", "b", or "c" (Round 1, 2 and 3).
424+
*
425+
* Flags:
426+
* - `i`: Case insensitive matching.
427+
*
428+
* Examples:
429+
* - "joi2024yo1a" (matches)
430+
* - "joi2023ho" (matches)
431+
* - "joisc2022" (matches)
432+
* - "joisp2021" (matches)
433+
* - "joig2024-open" (matches)
434+
* - "joisc2024" (matches)
435+
* - "joisp2022" (matches)
436+
* - "joi24yo3d" (does not match)
437+
*/
438+
const regexForJoi = /^(joi)(g|open)*(\d{4})*(yo|ho|sc|sp)*(\d{4})*(1|2)*(a|b|c)*/i;
439+
440+
/**
441+
* Transforms a contest ID into a formatted contest label.
442+
*
443+
* This function processes the given contest ID by removing specific suffixes
444+
* and applying various transformations to generate a human-readable contest label.
445+
*
446+
* @param contestId - The ID of the contest to be transformed.
447+
* @returns The formatted contest label.
448+
*/
449+
export function getJoiContestLabel(contestId: string): string {
450+
let label = contestId;
451+
// Remove suffix
452+
label = label.replace('-open', '');
453+
454+
label = label.replace(
455+
regexForJoi,
456+
(_, base, subType, yearPrefix, division, yearSuffix, qual, qualRound) => {
457+
const SPACE = ' ';
458+
459+
let newLabel = base.toUpperCase();
460+
newLabel += addJoiSubTypeIfNeeds(subType);
461+
462+
if (division !== undefined) {
463+
newLabel += SPACE;
464+
newLabel += addJoiDivisionNameIfNeeds(division, qual);
465+
}
466+
467+
newLabel += SPACE;
468+
newLabel += addJoiYear(yearSuffix, yearPrefix);
469+
470+
if (qualRound !== undefined) {
471+
newLabel += SPACE;
472+
newLabel += addJoiQualRoundNameIfNeeds(qualRound);
473+
}
474+
475+
return newLabel;
476+
},
477+
);
478+
479+
return label;
480+
}
481+
482+
function addJoiSubTypeIfNeeds(subType: string): string {
483+
if (subType === 'g') {
484+
return subType.toUpperCase();
485+
} else if (subType === 'open') {
486+
return ' Open';
487+
}
488+
489+
return '';
490+
}
491+
492+
function addJoiDivisionNameIfNeeds(division: string, qual: string): string {
493+
if (division === 'yo') {
494+
if (qual === undefined) {
495+
return '予選';
496+
} else if (qual === '1') {
497+
return '一次予選';
498+
} else if (qual === '2') {
499+
return '二次予選';
500+
}
501+
} else if (division === 'ho') {
502+
return '本選';
503+
} else if (division === 'sc' || division === 'sp') {
504+
return '春合宿';
505+
}
506+
507+
return '';
508+
}
509+
510+
function addJoiYear(yearSuffix: string, yearPrefix: string): string {
511+
if (yearPrefix !== undefined) {
512+
return yearPrefix;
513+
} else if (yearSuffix !== undefined) {
514+
return yearSuffix;
515+
}
516+
517+
return '';
518+
}
519+
520+
function addJoiQualRoundNameIfNeeds(qualRound: string): string {
521+
if (qualRound === 'a') {
522+
return '第 1 回';
523+
} else if (qualRound === 'b') {
524+
return '第 2 回';
525+
} else if (qualRound === 'c') {
526+
return '第 3 回';
527+
}
528+
529+
return '';
530+
}
531+
341532
/**
342533
* Generates a formatted contest label for AtCoder University contests.
343534
*
@@ -349,6 +540,10 @@ export const getContestNameLabel = (contestId: string) => {
349540
* @returns The formatted contest label (ex: UTPC 2023).
350541
*/
351542
export function getAtCoderUniversityContestLabel(contestId: string): string {
543+
if (!regexForAtCoderUniversity.test(contestId)) {
544+
throw new Error(`Invalid university contest ID format: ${contestId}`);
545+
}
546+
352547
return contestId.replace(
353548
regexForAtCoderUniversity,
354549
(_, contestType, common, contestYear) =>
@@ -386,7 +581,7 @@ const JAG_TRANSLATIONS = {
386581
Regional: ' 模擬地区 ',
387582
};
388583

389-
function getAojChallengeLabel(
584+
export function getAojContestLabel(
390585
translations: Readonly<ContestLabelTranslations>,
391586
contestId: string,
392587
): string {
@@ -410,5 +605,7 @@ export const addContestNameToTaskIndex = (contestId: string, taskTableIndex: str
410605
};
411606

412607
function isAojContest(contestId: string): boolean {
413-
return contestId.startsWith('PCK') || contestId.startsWith('JAG');
608+
return (
609+
aojCoursePrefixes.has(contestId) || contestId.startsWith('PCK') || contestId.startsWith('JAG')
610+
);
414611
}

src/test/lib/utils/contest.test.ts

Lines changed: 44 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import {
1313
contestTypePriorities,
1414
getContestNameLabel,
1515
addContestNameToTaskIndex,
16+
getAtCoderUniversityContestLabel,
1617
} from '$lib/utils/contest';
1718

1819
describe('Contest', () => {
@@ -366,18 +367,6 @@ describe('Contest', () => {
366367
});
367368
});
368369

369-
// TODO(#issue): Skipped until notational inconsistencies are resolved.
370-
// Current issues:
371-
// 1. Contest names use inconsistent formats (e.g., "past201912-open" vs "past17-open")
372-
// 2. Need to standardize naming conventions across all contests
373-
describe.skip('when contest_id contains past', () => {
374-
TestCasesForContestNameLabel.past.forEach(({ name, value }) => {
375-
runTests(`${name}`, [value], ({ contestId, expected }: TestCaseForContestNameLabel) => {
376-
expect(getContestNameLabel(contestId)).toEqual(expected);
377-
});
378-
});
379-
});
380-
381370
describe('when contest_id is practice2 (ACL practice)', () => {
382371
TestCasesForContestNameLabel.aclPractice.forEach(({ name, value }) => {
383372
runTests(`${name}`, [value], ({ contestId, expected }: TestCaseForContestNameLabel) => {
@@ -386,18 +375,6 @@ describe('Contest', () => {
386375
});
387376
});
388377

389-
// TODO(#issue): Skipped until notational inconsistencies are resolved.
390-
// Current issues:
391-
// 1. Contest names use inconsistent formats
392-
// 2. Need to standardize naming conventions across all contests
393-
describe.skip('when contest_id contains joi', () => {
394-
TestCasesForContestNameLabel.joi.forEach(({ name, value }) => {
395-
runTests(`${name}`, [value], ({ contestId, expected }: TestCaseForContestNameLabel) => {
396-
expect(getContestNameLabel(contestId)).toEqual(expected);
397-
});
398-
});
399-
});
400-
401378
describe('when contest_id contains chokudai_S', () => {
402379
TestCasesForContestNameLabel.atCoderOthers.forEach(({ name, value }) => {
403380
runTests(`${name}`, [value], ({ contestId, expected }: TestCaseForContestNameLabel) => {
@@ -446,6 +423,30 @@ describe('Contest', () => {
446423
});
447424
});
448425

426+
describe('when contest_id contains past', () => {
427+
TestCasesForContestNameAndTaskIndex.past.forEach(({ name, value }) => {
428+
runTests(
429+
`${name}`,
430+
[value],
431+
({ contestId, taskTableIndex, expected }: TestCaseForContestNameAndTaskIndex) => {
432+
expect(addContestNameToTaskIndex(contestId, taskTableIndex)).toEqual(expected);
433+
},
434+
);
435+
});
436+
});
437+
438+
describe('when contest_id contains joi', () => {
439+
TestCasesForContestNameAndTaskIndex.joi.forEach(({ name, value }) => {
440+
runTests(
441+
`${name}`,
442+
[value],
443+
({ contestId, taskTableIndex, expected }: TestCaseForContestNameAndTaskIndex) => {
444+
expect(addContestNameToTaskIndex(contestId, taskTableIndex)).toEqual(expected);
445+
},
446+
);
447+
});
448+
});
449+
449450
describe('when contest_id is tessoku-book', () => {
450451
TestCasesForContestNameAndTaskIndex.tessokuBook.forEach(({ name, value }) => {
451452
runTests(
@@ -545,4 +546,23 @@ describe('Contest', () => {
545546
});
546547
});
547548
});
549+
550+
describe('get AtCoder university contest label', () => {
551+
describe('expected to return correct label for valid format', () => {
552+
test.each([
553+
['utpc2019', 'UTPC 2019'],
554+
['ttpc2022', 'TTPC 2022'],
555+
])('when %s is given', (input, expected) => {
556+
expect(getAtCoderUniversityContestLabel(input)).toBe(expected);
557+
});
558+
});
559+
560+
describe('expected to be thrown an error if an invalid format is given', () => {
561+
test.each(['utpc24', 'ttpc', 'tupc'])('when %s is given', (input) => {
562+
expect(() => getAtCoderUniversityContestLabel(input)).toThrow(
563+
`Invalid university contest ID format: ${input}`,
564+
);
565+
});
566+
});
567+
});
548568
});

0 commit comments

Comments
 (0)