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
9 changes: 8 additions & 1 deletion docs/guides/how-to-add-contest-table-provider.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,12 @@
- **パターン3(複合ソース型)**: ABS、ABC-Like など → 複数 contest_id を統一表示
- 対応セクション: [実装パターン](#実装パターン)

- [ ] **JOI の contest_id サフィックス変更の確認**
- JOI 本選は 2026 年より `joi{YYYY}ho` → `joi{YYYY}sf` にサフィックスが変更された
- `JOISemiFinalRoundProvider` の regex は `(ho|sf)` にマッチするよう対応済み
- `getJoiContestLabel()` は `sf` → `'セミファイナルステージ'` を返す
- テーブルの行ラベル (`getContestRoundLabel()`) は `sf` でも年のみ返す

- [ ] **ガイドの実装例の確認**
- 判定したパターンの実装例を確認してテンプレート理解

Expand Down Expand Up @@ -525,6 +531,7 @@ describe('CustomProvider with unique config', () => {
- [#2785](https://github.com/AtCoder-NoviSteps/AtCoderNoviSteps/issues/2785) - MathAndAlgorithmProvider
- [#2797](https://github.com/AtCoder-NoviSteps/AtCoderNoviSteps/issues/2797) - FPS24Provider
- [#2920](https://github.com/AtCoder-NoviSteps/AtCoderNoviSteps/issues/2920)、[#3120](https://github.com/AtCoder-NoviSteps/AtCoderNoviSteps/issues/3120) - ACLPracticeProvider、ACLBeginnerProvider、ACLProvider
- [#3152](https://github.com/AtCoder-NoviSteps/AtCoderNoviSteps/issues/3152) - JOISemiFinalRoundProvider(本選 → セミファイナルステージ への対応)

### 実装ファイル

Expand All @@ -534,4 +541,4 @@ describe('CustomProvider with unique config', () => {

---

**最終更新**: 2026-02-14
**最終更新**: 2026-02-22
42 changes: 42 additions & 0 deletions prisma/tasks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7066,6 +7066,48 @@ export const tasks = [
name: 'JOI 2006 予選 問題1',
title: 'A. JOI 2006 予選 問題1',
},
{
id: 'joi2026_semifinal_f',
contest_id: 'joi2026sf',
problem_index: 'F',
name: '奇妙な機械 (Strange Machine)',
title: 'F. 奇妙な機械 (Strange Machine)',
},
{
id: 'joi2026_semifinal_e',
contest_id: 'joi2026sf',
problem_index: 'E',
name: '新たな橋 (New Bridge)',
title: 'E. 新たな橋 (New Bridge)',
},
{
id: 'joi2026_semifinal_d',
contest_id: 'joi2026sf',
problem_index: 'D',
name: '川下り(River Rafting)',
title: 'D. 川下り(River Rafting)',
},
{
id: 'joi2026_semifinal_c',
contest_id: 'joi2026sf',
problem_index: 'C',
name: '衣服 (Clothes)',
title: 'C. 衣服 (Clothes)',
},
{
id: 'joi2026_semifinal_b',
contest_id: 'joi2026sf',
problem_index: 'B',
name: '宝石商 (Jeweler)',
title: 'B. 宝石商 (Jeweler)',
},
{
id: 'joi2026_semifinal_a',
contest_id: 'joi2026sf',
problem_index: 'A',
name: '座席 3 (Seats 3)',
title: 'A. 座席 3 (Seats 3)',
},
{
id: 'joi2025ho_e',
contest_id: 'joi2025ho',
Expand Down
42 changes: 42 additions & 0 deletions src/features/tasks/utils/contest-table/joi_providers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -338,4 +338,46 @@ describe('JOISemiFinalRoundProvider', () => {

expect(filtered).toEqual([]);
});

test('expects to filter joi{YYYY}sf tasks (new format from 2026)', () => {
const provider = new JOISemiFinalRoundProvider(ContestType.JOI);
const mockJOITasks = [
{ contest_id: 'joi2026sf', task_id: 'joi2026_semifinal_a' },
{ contest_id: 'joi2026sf', task_id: 'joi2026_semifinal_b' },
{ contest_id: 'joi2024ho', task_id: 'joi2024ho_a' },
{ contest_id: 'joi2024yo2', task_id: 'joi2024yo2_a' },
{ contest_id: 'joi2024yo1a', task_id: 'joi2024yo1a_a' },
{ contest_id: 'abc123', task_id: 'abc123_a' },
];

const filtered = provider.filter(mockJOITasks as any);

expect(filtered?.length).toBe(3);
expect(filtered?.some((task) => task.contest_id === 'joi2026sf')).toBe(true);
expect(filtered?.some((task) => task.contest_id === 'joi2024ho')).toBe(true);
expect(filtered?.every((task) => task.contest_id.startsWith('joi'))).toBe(true);
});

test('expects to get contest round label for joi{YYYY}sf', () => {
const provider = new JOISemiFinalRoundProvider(ContestType.JOI);

expect(provider.getContestRoundLabel('joi2026sf')).toBe('2026');
});

test('expects to generate correct table structure including joi{YYYY}sf', () => {
const provider = new JOISemiFinalRoundProvider(ContestType.JOI);
const mockJOITasks = [
{ contest_id: 'joi2026sf', task_id: 'joi2026_semifinal_a', task_table_index: 'A' },
{ contest_id: 'joi2026sf', task_id: 'joi2026_semifinal_b', task_table_index: 'B' },
{ contest_id: 'joi2024ho', task_id: 'joi2024ho_a', task_table_index: 'A' },
];

const table = provider.generateTable(mockJOITasks as any);

expect(table).toHaveProperty('joi2026sf');
expect(table).toHaveProperty('joi2024ho');
expect(table.joi2026sf).toHaveProperty('A');
expect(table.joi2026sf).toHaveProperty('B');
expect(table.joi2024ho).toHaveProperty('A');
});
});
6 changes: 4 additions & 2 deletions src/features/tasks/utils/contest-table/joi_providers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -127,8 +127,10 @@ export class JOIQualRoundFrom2006To2019Provider extends ContestTableProviderBase
}
}

const regexForJoiSemiFinalRound = /^(joi)(\d{4})(ho)$/i;
const regexForJoiSemiFinalRound = /^(joi)(\d{4})(ho|sf)$/i;

// Note: The JOI semi-final stage, which was renamed from the final round starting in 2026, is essentially the same as the final round in terms of its role in the competition.
// Therefore, we can use the same provider for both the final round and the semi-final stage, as they share the same structure and purpose in the contest.
export class JOISemiFinalRoundProvider extends ContestTableProviderBase {
constructor(contestType: ContestType) {
super(contestType, JOI_FINAL_ROUND_SECTIONS.semiFinal);
Expand Down Expand Up @@ -163,6 +165,6 @@ export class JOISemiFinalRoundProvider extends ContestTableProviderBase {

getContestRoundLabel(contestId: string): string {
const contestNameLabel = getContestNameLabel(contestId);
return contestNameLabel.replace('JOI 本選 ', '');
return contestNameLabel.replace('JOI 本選 ', '').replace('JOI セミファイナルステージ ', '');
}
}
5 changes: 4 additions & 1 deletion src/lib/utils/contest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -506,8 +506,9 @@ export function getPastContestLabel(
* - "joisc2024" (matches)
* - "joisp2022" (matches)
* - "joi24yo3d" (does not match)
* - "joi2026sf" (matches)
*/
const regexForJoi = /^(joi)(g|open)*(\d{4})*(yo|ho|sc|sp)*(\d{4})*(1|2)*(a|b|c)*/i;
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.
Expand Down Expand Up @@ -572,6 +573,8 @@ function addJoiDivisionNameIfNeeds(division: string, qual: string): string {
}
} else if (division === 'ho') {
return '本選';
} else if (division === 'sf') {
return 'セミファイナルステージ';
} else if (division === 'sc' || division === 'sp') {
return '春合宿';
}
Expand Down
4 changes: 4 additions & 0 deletions src/test/lib/utils/test_cases/contest_name_and_task_index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -212,6 +212,10 @@ const JOI_TEST_DATA = {
contestId: 'joi2024ho',
tasks: ['A', 'B', 'E'],
},
joi2026sf: {
contestId: 'joi2026sf',
tasks: ['A', 'B', 'F'],
},
joisc2007: {
contestId: 'joisc2007',
tasks: ['anagra', 'buildi', 'salt', 'score'],
Expand Down
Loading