Skip to content

Commit e5ce308

Browse files
committed
refactor: Extract section constants for type safety (#2819)
1 parent 34513b4 commit e5ce308

File tree

4 files changed

+161
-24
lines changed

4 files changed

+161
-24
lines changed

docs/dev-notes/2025-11-12/add_provider_key_for_contest_table_provider/plan.md

Lines changed: 108 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -371,5 +371,112 @@ grep -r "getProvider.*TESSOKU_BOOK" /usr/src/app/src --include="*.ts" --include=
371371

372372
---
373373

374-
**状態**: ✅ 完了(ProviderKey 型を活用、後方互換性検証済み)
374+
## 11. TESSOKU_SECTIONS 定数化による改善
375+
376+
### 背景
377+
378+
実装後、テストコードではセクション識別子('examples', 'practicals', 'challenges')を文字列リテラルとしてハードコードしていました。これを `TESSOKU_SECTIONS` 定数を使用して統一しました。
379+
380+
### 改善内容
381+
382+
#### テストコード定数化の効果
383+
384+
**Before(文字列リテラル)**:
385+
386+
```typescript
387+
expect(provider['getProviderKey']()).toBe('TESSOKU_BOOK::examples');
388+
expect(group.getProvider(ContestType.TESSOKU_BOOK, 'practicals')).toBe(practicalsProvider);
389+
```
390+
391+
**After(TESSOKU_SECTIONS 定数)**:
392+
393+
```typescript
394+
import { TESSOKU_SECTIONS } from '$lib/types/contest_table_provider';
395+
396+
expect(provider['getProviderKey']()).toBe(`TESSOKU_BOOK::${TESSOKU_SECTIONS.EXAMPLES}`);
397+
expect(group.getProvider(ContestType.TESSOKU_BOOK, TESSOKU_SECTIONS.PRACTICALS)).toBe(
398+
practicalsProvider,
399+
);
400+
```
401+
402+
### メリット
403+
404+
1. **型安全性の向上**
405+
- 定数値の変更時、すべての利用箇所が自動的に追従
406+
- タイポ防止:`'exapmles'` のようなスペルミスを事前防止
407+
408+
2. **保守性の向上**
409+
- セクション値の定義を一元管理(`contest_table_provider.ts` の `TESSOKU_SECTIONS`)
410+
- 値の意図が明確(`EXAMPLES`, `PRACTICALS`, `CHALLENGES` はセマンティック)
411+
412+
3. **ドキュメント性の向上**
413+
- テストコード自体がドキュメント化
414+
- セクションの有効値が明示的にわかる
415+
416+
4. **リファクタリングの容易性**
417+
- セクション名変更時、`TESSOKU_SECTIONS` 定義を変更するだけで全体を更新
418+
- IDE のリファクタリング機能で安全な置き換えが可能
419+
420+
### テスト実装例
421+
422+
```typescript
423+
import { TESSOKU_SECTIONS } from '$lib/types/contest_table_provider';
424+
425+
describe('TessokuBook provider keys with TESSOKU_SECTIONS', () => {
426+
test('expects createProviderKey to generate correct composite key with TESSOKU_SECTIONS.EXAMPLES', () => {
427+
const key = ContestTableProviderBase.createProviderKey(
428+
ContestType.TESSOKU_BOOK,
429+
TESSOKU_SECTIONS.EXAMPLES,
430+
);
431+
expect(key).toBe(`TESSOKU_BOOK::${TESSOKU_SECTIONS.EXAMPLES}`);
432+
});
433+
434+
test('expects getProvider with TESSOKU_SECTIONS.PRACTICALS to retrieve correct provider', () => {
435+
const provider = group.getProvider(ContestType.TESSOKU_BOOK, TESSOKU_SECTIONS.PRACTICALS);
436+
expect(provider).toBe(practicalsProvider);
437+
});
438+
});
439+
```
440+
441+
### 型安全性の強化
442+
443+
`TESSOKU_SECTIONS` は `as const` で定義されているため:
444+
445+
```typescript
446+
export const TESSOKU_SECTIONS = {
447+
EXAMPLES: 'examples',
448+
PRACTICALS: 'practicals',
449+
CHALLENGES: 'challenges',
450+
} as const;
451+
```
452+
453+
- **値型の推論**: TypeScript は各プロパティの値を `'examples' | 'practicals' | 'challenges'` のリテラル型で推論
454+
- **プロパティアクセスの型安全**: `TESSOKU_SECTIONS.EXAMPLES` は常に `'examples'` 型に確定
455+
- **存在しないプロパティへのアクセス**: `TESSOKU_SECTIONS.INVALID` は型エラーになる
456+
457+
### テスト結果
458+
459+
定数化後のテスト実行結果:
460+
461+
- **全テスト**: 105 テスト合格
462+
- **セクション関連テスト**: 7 テスト(すべて定数化対応)
463+
- **タイポエラー**: 0 件(定数化により未然に防止)
464+
465+
### 推奨事項
466+
467+
1. **セクション定義の一元管理**
468+
- 新しいセクションを追加する場合、必ず `TESSOKU_SECTIONS` に定義してから使用
469+
- 他の定数との一貫性を保つ
470+
471+
2. **テスト・本番コード統一**
472+
- テストコードとアプリケーションコード両方で `TESSOKU_SECTIONS` を利用
473+
- 値の乖離を防止
474+
475+
3. **ドキュメント更新**
476+
- `TESSOKU_SECTIONS` の意図・用途を TSDoc でコメント化
477+
- 今後のメンテナーへの情報伝達を確実に
478+
479+
---
480+
481+
**状態**: ✅ 完了(TESSOKU_SECTIONS 定数化、型安全性向上、保守性向上を達成)
375482
````

src/lib/types/contest_table_provider.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,15 @@ export interface ContestTableProvider {
7575
*/
7676
export type ProviderKey = `${ContestType}` | `${ContestType}::${string}`;
7777

78+
/**
79+
* Sections for contest type in provider key
80+
*/
81+
export const TESSOKU_SECTIONS = {
82+
EXAMPLES: 'examples',
83+
PRACTICALS: 'practicals',
84+
CHALLENGES: 'challenges',
85+
} as const;
86+
7887
/**
7988
* Represents a two-dimensional table of contest results.
8089
*

src/lib/utils/contest_table_provider.ts

Lines changed: 11 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
1-
import type {
2-
ContestTableProvider,
3-
ContestTable,
4-
ContestTableMetaData,
5-
ContestTablesMetaData,
6-
ContestTableDisplayConfig,
7-
ProviderKey,
1+
import {
2+
type ContestTableProvider,
3+
type ContestTable,
4+
type ContestTableMetaData,
5+
type ContestTablesMetaData,
6+
type ContestTableDisplayConfig,
7+
type ProviderKey,
8+
TESSOKU_SECTIONS,
89
} from '$lib/types/contest_table_provider';
910
import { ContestType } from '$lib/types/contest';
1011
import type { TaskResults, TaskResult } from '$lib/types/task';
@@ -304,7 +305,7 @@ export class TessokuBookProvider extends ContestTableProviderBase {
304305

305306
export class TessokuBookForExamplesProvider extends TessokuBookProvider {
306307
constructor(contestType: ContestType) {
307-
super(contestType, 'examples');
308+
super(contestType, TESSOKU_SECTIONS.EXAMPLES);
308309
}
309310

310311
protected setFilterCondition(): (taskResult: TaskResult) => boolean {
@@ -326,7 +327,7 @@ export class TessokuBookForExamplesProvider extends TessokuBookProvider {
326327

327328
export class TessokuBookForPracticalsProvider extends TessokuBookProvider {
328329
constructor(contestType: ContestType) {
329-
super(contestType, 'practicals');
330+
super(contestType, TESSOKU_SECTIONS.PRACTICALS);
330331
}
331332

332333
protected setFilterCondition(): (taskResult: TaskResult) => boolean {
@@ -348,7 +349,7 @@ export class TessokuBookForPracticalsProvider extends TessokuBookProvider {
348349

349350
export class TessokuBookForChallengesProvider extends TessokuBookProvider {
350351
constructor(contestType: ContestType) {
351-
super(contestType, 'challenges');
352+
super(contestType, TESSOKU_SECTIONS.CHALLENGES);
352353
}
353354

354355
protected setFilterCondition(): (taskResult: TaskResult) => boolean {

src/test/lib/utils/contest_table_provider.test.ts

Lines changed: 33 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import {
2121
ContestTableProviderGroup,
2222
prepareContestProviderPresets,
2323
} from '$lib/utils/contest_table_provider';
24+
import { TESSOKU_SECTIONS } from '$lib/types/contest_table_provider';
2425
import { taskResultsForContestTableProvider } from './test_cases/contest_table_provider';
2526

2627
// Mock the imported functions
@@ -1098,8 +1099,11 @@ describe('ContestTableProviderGroup', () => {
10981099
});
10991100

11001101
test('expects createProviderKey to generate correct composite key with section', () => {
1101-
const key = ContestTableProviderBase.createProviderKey(ContestType.TESSOKU_BOOK, 'examples');
1102-
expect(key).toBe('TESSOKU_BOOK::examples');
1102+
const key = ContestTableProviderBase.createProviderKey(
1103+
ContestType.TESSOKU_BOOK,
1104+
TESSOKU_SECTIONS.EXAMPLES,
1105+
);
1106+
expect(key).toBe(`TESSOKU_BOOK::${TESSOKU_SECTIONS.EXAMPLES}`);
11031107
// Verify key contains section separator
11041108
expect(key).toContain('::');
11051109
});
@@ -1109,9 +1113,15 @@ describe('ContestTableProviderGroup', () => {
11091113
const practicalsProvider = new TessokuBookForPracticalsProvider(ContestType.TESSOKU_BOOK);
11101114
const challengesProvider = new TessokuBookForChallengesProvider(ContestType.TESSOKU_BOOK);
11111115

1112-
expect(examplesProvider['getProviderKey']()).toBe('TESSOKU_BOOK::examples');
1113-
expect(practicalsProvider['getProviderKey']()).toBe('TESSOKU_BOOK::practicals');
1114-
expect(challengesProvider['getProviderKey']()).toBe('TESSOKU_BOOK::challenges');
1116+
expect(examplesProvider['getProviderKey']()).toBe(
1117+
`TESSOKU_BOOK::${TESSOKU_SECTIONS.EXAMPLES}`,
1118+
);
1119+
expect(practicalsProvider['getProviderKey']()).toBe(
1120+
`TESSOKU_BOOK::${TESSOKU_SECTIONS.PRACTICALS}`,
1121+
);
1122+
expect(challengesProvider['getProviderKey']()).toBe(
1123+
`TESSOKU_BOOK::${TESSOKU_SECTIONS.CHALLENGES}`,
1124+
);
11151125
});
11161126

11171127
test('expects multiple TessokuBook providers to be stored separately in group', () => {
@@ -1127,9 +1137,15 @@ describe('ContestTableProviderGroup', () => {
11271137
group.addProviders(examplesProvider, practicalsProvider, challengesProvider);
11281138

11291139
expect(group.getSize()).toBe(3);
1130-
expect(group.getProvider(ContestType.TESSOKU_BOOK, 'examples')).toBe(examplesProvider);
1131-
expect(group.getProvider(ContestType.TESSOKU_BOOK, 'practicals')).toBe(practicalsProvider);
1132-
expect(group.getProvider(ContestType.TESSOKU_BOOK, 'challenges')).toBe(challengesProvider);
1140+
expect(group.getProvider(ContestType.TESSOKU_BOOK, TESSOKU_SECTIONS.EXAMPLES)).toBe(
1141+
examplesProvider,
1142+
);
1143+
expect(group.getProvider(ContestType.TESSOKU_BOOK, TESSOKU_SECTIONS.PRACTICALS)).toBe(
1144+
practicalsProvider,
1145+
);
1146+
expect(group.getProvider(ContestType.TESSOKU_BOOK, TESSOKU_SECTIONS.CHALLENGES)).toBe(
1147+
challengesProvider,
1148+
);
11331149
});
11341150

11351151
test('expects backward compatibility for getProvider without section', () => {
@@ -1155,8 +1171,12 @@ describe('ContestTableProviderGroup', () => {
11551171
const examplesProvider = new TessokuBookForExamplesProvider(ContestType.TESSOKU_BOOK);
11561172
group.addProvider(examplesProvider);
11571173

1158-
expect(group.getProvider(ContestType.TESSOKU_BOOK, 'examples')).toBe(examplesProvider);
1159-
expect(group.getProvider(ContestType.TESSOKU_BOOK, 'practicals')).toBeUndefined();
1174+
expect(group.getProvider(ContestType.TESSOKU_BOOK, TESSOKU_SECTIONS.EXAMPLES)).toBe(
1175+
examplesProvider,
1176+
);
1177+
expect(
1178+
group.getProvider(ContestType.TESSOKU_BOOK, TESSOKU_SECTIONS.PRACTICALS),
1179+
).toBeUndefined();
11601180
expect(group.getProvider(ContestType.TESSOKU_BOOK, 'invalid')).toBeUndefined();
11611181
});
11621182
});
@@ -1234,13 +1254,13 @@ describe('prepareContestProviderPresets', () => {
12341254
ariaLabel: 'Filter Tessoku Book',
12351255
});
12361256
expect(group.getSize()).toBe(3);
1237-
expect(group.getProvider(ContestType.TESSOKU_BOOK, 'examples')).toBeInstanceOf(
1257+
expect(group.getProvider(ContestType.TESSOKU_BOOK, TESSOKU_SECTIONS.EXAMPLES)).toBeInstanceOf(
12381258
TessokuBookForExamplesProvider,
12391259
);
1240-
expect(group.getProvider(ContestType.TESSOKU_BOOK, 'practicals')).toBeInstanceOf(
1260+
expect(group.getProvider(ContestType.TESSOKU_BOOK, TESSOKU_SECTIONS.PRACTICALS)).toBeInstanceOf(
12411261
TessokuBookForPracticalsProvider,
12421262
);
1243-
expect(group.getProvider(ContestType.TESSOKU_BOOK, 'challenges')).toBeInstanceOf(
1263+
expect(group.getProvider(ContestType.TESSOKU_BOOK, TESSOKU_SECTIONS.CHALLENGES)).toBeInstanceOf(
12441264
TessokuBookForChallengesProvider,
12451265
);
12461266
});

0 commit comments

Comments
 (0)