From 15f498f887800cc7edbacebe43575490a15269c2 Mon Sep 17 00:00:00 2001 From: "k.hiro1818" Date: Thu, 11 Jun 2026 11:38:16 +0000 Subject: [PATCH 1/5] feat(contest): add getTaskLabels for positional labels and fix prefix accumulation Introduce getTaskLabels(filtered) on ContestTableProviderBase to support display-only positional labels (e.g. "A. Problem Title"). The previous approach of mutating title inside generateTable caused prefix accumulation on optimistic-update re-runs because transformed objects were written back to the source $state array. AojIcpcPrelimProvider now overrides getTaskLabels instead of generateTable, and TaskTableBodyCell derives displayTitle via $derived to apply the label. Also add svelte-runes rule documenting the $derived accumulation trap. Co-Authored-By: Claude Sonnet 4.6 --- .claude/rules/svelte-runes.md | 8 ++ .../instructions.md | 6 +- .../icpc-prelim-prefix-accumulation/plan.md | 119 ++++++++++++++++++ .../how-to-add-contest-table-provider.md | 9 +- .../components/contest-table/TaskTable.svelte | 4 + .../contest-table/TaskTableBodyCell.svelte | 18 ++- .../contest-table/contest_table_provider.ts | 6 + .../contest-table/aoj_icpc_labels.test.ts | 12 +- .../utils/contest-table/aoj_icpc_labels.ts | 5 + .../contest-table/aoj_icpc_providers.test.ts | 87 ++++++++----- .../utils/contest-table/aoj_icpc_providers.ts | 28 ++--- .../contest_table_provider_base.test.ts | 6 + .../contest_table_provider_base.ts | 4 + 13 files changed, 254 insertions(+), 58 deletions(-) create mode 100644 docs/dev-notes/2026-06-10/icpc-prelim-prefix-accumulation/plan.md diff --git a/.claude/rules/svelte-runes.md b/.claude/rules/svelte-runes.md index 86145381b..bfea707a0 100644 --- a/.claude/rules/svelte-runes.md +++ b/.claude/rules/svelte-runes.md @@ -33,6 +33,14 @@ Inside `$derived`, plain `new Map()` suffices — reactive dependency tracked at - Use `.by()` for multi-statement: `$derived.by(() => { ... })` - Inside `$effect`, use `$store` syntax, not `get(store)` (bypasses signal graph) +## `$derived` Tracks Reactive Reads Inside Called Functions + +`$derived(fn)` tracks every reactive read inside `fn`, **including reads inside helper functions** called by `fn`. If a helper reads a `$state` array, the derived re-runs whenever that array changes. + +**Accumulation trap**: if the helper transforms data (e.g. `{ ...item, title: prefix + item.title }`) and the result is later written back to the source array (optimistic update), the next re-run reads the already-transformed value and transforms it again. + +Fix: put display formatting only in the leaf view (e.g. `$derived displayTitle` in a cell component), never inside a derived that feeds mutable state. + ## Optimistic Updates Derive computed fields from canonical data source, not re-implement inline. Divergence → "works after reload" bugs. diff --git a/.claude/skills/add-contest-table-provider/instructions.md b/.claude/skills/add-contest-table-provider/instructions.md index 2c987bf52..7f87f3c5c 100644 --- a/.claude/skills/add-contest-table-provider/instructions.md +++ b/.claude/skills/add-contest-table-provider/instructions.md @@ -30,7 +30,7 @@ Step 0 (seed check) is already done. Confirm the following before touching code: - Year/ID range: oldest and latest? Export both as named constants so tests can reference them. - Iteration order: latest-first so newest table renders on top. - `task_table_index` values numeric strings? → override `getHeaderIdsForTask`; sort with `Number(a) - Number(b)`. -- Display-only title transform needed (e.g. prepend letter)? → override `generateTable` for the transform AND override `getHeaderIdsForTask` using the same key derivation; mismatched keys between the two methods cause missing cells. +- Display-only positional label needed (e.g. prepend "A. ")? → override `getTaskLabels` to return `{ [contestId]: { index: letter } }`; **never mutate title inside `generateTable`** (transformed objects written back via optimistic update cause prefix accumulation on the next `$derived` re-run). - Known edge cases where the default algorithm breaks? → add a `Record>` module-level override map keyed by contest_id; exercise the override path in tests by mutating the export in `beforeEach` and cleaning up in `afterEach`. **Pattern 3 additional:** @@ -101,8 +101,8 @@ Step 0 (seed check) is already done. Confirm the following before touching code: - [ ] Export `OLDEST_YEAR` / `LATEST_YEAR` constants (module-level, before `prepareContestProviderPresets`) so tests can assert `getSize() === LATEST - OLDEST + 1` - [ ] Pass the parameter as `section` in `super(contestType, String(param))` → provider key becomes `TYPE::value` (unique per instance) -- [ ] If `generateTable` is overridden to key the table by `task_table_index` directly: also override `getHeaderIdsForTask` using the same field and sort order -- [ ] If display title needs transformation (e.g. prepend "A. "): do it inside `generateTable`; DB data must remain unchanged +- [ ] If `task_table_index` is a numeric string key: override `getHeaderIdsForTask` with numeric sort (`Number(a) - Number(b)`) +- [ ] If display title needs transformation (e.g. prepend "A. "): override `getTaskLabels` to return `{ [contestId]: { index: letter } }`; do NOT mutate title in `generateTable` - [ ] Write override map (`Record>`) for known edge cases; test the override path by mutating the export in `beforeEach` and cleaning up in `afterEach` - [ ] If provider headings need non-default font/weight/gap: return `titleStyle` (`headingTag` / `fontSize` / `fontWeight` / `bottomGap`) from `getMetadata()`; include all set fields in the `titleStyle` assertion - [ ] `pnpm test:unit ` — **expect GREEN** diff --git a/docs/dev-notes/2026-06-10/icpc-prelim-prefix-accumulation/plan.md b/docs/dev-notes/2026-06-10/icpc-prelim-prefix-accumulation/plan.md new file mode 100644 index 000000000..3cfe0e0af --- /dev/null +++ b/docs/dev-notes/2026-06-10/icpc-prelim-prefix-accumulation/plan.md @@ -0,0 +1,119 @@ +# ICPC 国内予選テーブル: プレフィックス累積バグ修正 + +Issue: https://github.com/AtCoder-NoviSteps/AtCoderNoviSteps/issues/3636 + +## Context(なぜ直すか) + +ICPC 国内予選テーブルで回答(提出ステータス)を更新するたびに、問題タイトルのプレフィックスが +`A. A. A. (タイトル)` のように累積する。本来は `A. (タイトル)` の1つだけであるべき。 + +### 根本原因(データフロー) + +1. [TaskTable.svelte:70](../../../../src/features/tasks/components/contest-table/TaskTable.svelte#L70) + `contestTableMaps = $derived(prepareContestTablesMap(providers))` は内部で `taskResults` を読む(L88)。 + Svelte 5 の `$derived` は関数内部の reactive read も追跡するため、`taskResults` 変更時に + **再実行され `generateTable` が走る**。 +2. [aoj_icpc_providers.ts:38](../../../../src/features/tasks/utils/contest-table/aoj_icpc_providers.ts#L38) + の `{ ...taskResult, title: \`${letter}. ${taskResult.title}\` }` がプレフィックス付きの**新オブジェクト**を生成。 +3. このオブジェクトがテンプレート L286-303 経由で `TaskTableBodyCell` → `UpdatingDropdown` に渡る。 +4. 回答更新時 `UpdatingDropdown.updateTaskResult` は `{ ...taskResult, ...status }`([L160](../../../../src/lib/components/SubmissionStatus/UpdatingDropdown.svelte#L160)) + で spread するため title `"A. title"` を保持。 +5. handleUpdateTaskResult L189-191 が、このプレフィックス付きオブジェクトを + **ソース `taskResults` 配列に書き戻す**。 +6. 次の再生成で `generateTable` が `"A. title"` を読み `"A. A. title"` を生成。更新ごとに累積。 + +**本質**: 表示用の整形(プレフィックス付与)を「再計算ループに乗った可変パイプライン(generateTable)」で行い、 +その整形済みオブジェクトが更新の書き戻しでソースに還流している。AtCoder 系が壊れないのは、プレフィックスが +**不変のソース(DB title)**由来でループ外にあるため。ICPC だけがこの非対称により累積する。 + +## 方針: 案B — 表示時整形(データは常に pristine) + +整形を**描画ループにもデータパイプラインにも入れず、読み取り専用の葉(cell)の表示文字列でのみ**付与する。 +`generateTable` は title を一切変更せず素の `TaskResult` を格納するため、書き戻しても整形が還流せず累積が +**構造的に発生しなくなる**(dedup 処理もフラグも不要)。`removeTaskIndexFromTitle` と対称の素直な設計。 + +letter はコンテスト内で**位置依存**(全問題集合から算出)のため cell 単独では計算できず、provider が出す。 +ICPC の `task_table_index` は AOJ の数値ID(`'1664'`)であって letter(`'A'`)ではない点に注意 +(=`removeTaskIndexFromTitle` の単純な逆操作では実装できない)。 + +### 不採用案 + +- **案A: `generateTable` を冪等化(strip-then-readd)**: 同じ letter の先行プレフィックスを除去してから付け直す。 + 累積を整形地点で後追い検知する**力技**。ソース title はメモリ上 `"A. name"` のまま汚染が残り、 + 表示整形をデータパイプラインに残す設計上の問題も未解決。 +- **案C: 書き戻し時に title を保持(`{ ...updatedTask, title: taskResults[index].title }`)**: 1行で済むが、 + **汎用**の更新ハンドラが「title は generateTable が付け直すので書き戻すな」という ICPC 由来の表示事情を + 暗黙に知る結合が生じ、トリッキー。将来 title 以外の表示派生フィールドが増えると取りこぼす。 +- **初期化フラグ**: `TaskResult` に「整形済み」フラグを持たせ二重付与を防ぐ。表示用の状態をドメイン型に + 持ち込み型を汚す。本質は案Aと同じ後追い検知で、汚染もループ外に出ない。 + +## 変更点 + +### 1. provider に `getTaskLabels` を追加(interface / base / ICPC) + +- `ContestTableProvider` interface([contest_table_provider.ts](../../../../src/features/tasks/types/contest-table/contest_table_provider.ts))に追加: + ```typescript + // Positional display labels for cells (contestId -> task_table_index -> letter). + // Empty for providers that show the index in the column header instead. + getTaskLabels(filteredTaskResults: TaskResults): Record>; + ``` +- `ContestTableProviderBase` はデフォルトで `{}` を返す(既存 provider は表示挙動不変)。 +- `AojIcpcPrelimProvider` が override: `buildAojIcpcLetterMap` から `{ [this.contestId]: { index: letter, ... } }` を返す。 + +### 2. ICPC `generateTable` から整形を除去(provider 層) + +[aoj_icpc_providers.ts](../../../../src/features/tasks/utils/contest-table/aoj_icpc_providers.ts) の +generateTable は title を変更せず素の `TaskResult` を格納(キー・並びは現状維持): + +```typescript +for (const taskResult of filtered) { + table[this.contestId][taskResult.task_table_index] = taskResult; +} +``` + +L26-27 のコメントも実態に合わせ更新。 + +### 3. ICPC 表示整形は AOJ 固有 util として `aoj_icpc_labels.ts` に追加 + +本当に新しいのは「AOJ ICPC の位置ラベルを title へ前置する」狭い・AOJ 固有の処理だけ。汎用名の関数に +束ねない(残りの raw / strip 分岐は既存で、汎用に見せかけない)。`removeTaskIndexFromTitle` は7箇所・複数 feature で +使う真に汎用な util で責務の広さが異なるため、そこには混ぜない。 + +`aoj_icpc_labels.ts`(ICPC のラベル**値**生成 `buildAojIcpcLetterMap` と同居 = 責務一致)に追加: + +```typescript +// Prepend the assigned positional letter to an ICPC title for inline display (e.g. "A. name"). +export function formatAojIcpcTitle(title: string, letter: string): string { + return `${letter}. ${title}`; +} +``` + +3分岐の組み立て自体は汎用 util に切り出さず、cell の `$derived displayTitle` で持つ(表示分岐はコンポーネントの責務)。 + +### 4. ProviderData / cell へ letter を配線 + +- [TaskTable.svelte](../../../../src/features/tasks/components/contest-table/TaskTable.svelte): `ProviderData` に + `taskLabels: Record>` を追加し、`prepareContestTable` で + `provider.getTaskLabels(filteredTaskResults)` をセット。テンプレートで + `{@const taskLabel = contestTable.taskLabels[contestId]?.[taskTableHeaderId]}` を引き、cell に prop で渡す。 +- [TaskTableBodyCell.svelte](../../../../src/features/tasks/components/contest-table/TaskTableBodyCell.svelte): + `taskLabel?: string` prop を追加。`$derived displayTitle` を `
- {@render taskTitleAndExternalLink(taskResult, isShownTaskIndex)} + {@render taskTitleAndExternalLink(taskResult)} {@render submissionUpdaterAndLinksOfTaskDetailPage(taskResult)}
@@ -52,13 +62,11 @@ /> {/snippet} -{#snippet taskTitleAndExternalLink(taskResult: TaskResult, isShownTaskIndex: boolean)} +{#snippet taskTitleAndExternalLink(taskResult: TaskResult)}
task_table_index -> letter). + * Empty for providers that show the index in the column header instead. + */ + getTaskLabels(filteredTaskResults: TaskResults): Record>; } /** diff --git a/src/features/tasks/utils/contest-table/aoj_icpc_labels.test.ts b/src/features/tasks/utils/contest-table/aoj_icpc_labels.test.ts index 5e746851d..3cd08a0cd 100644 --- a/src/features/tasks/utils/contest-table/aoj_icpc_labels.test.ts +++ b/src/features/tasks/utils/contest-table/aoj_icpc_labels.test.ts @@ -1,6 +1,16 @@ import { describe, test, expect, beforeEach, afterEach } from 'vitest'; -import { buildAojIcpcLetterMap, ICPC_PRELIM_LABEL_OVERRIDES } from './aoj_icpc_labels'; +import { + buildAojIcpcLetterMap, + formatAojIcpcTitle, + ICPC_PRELIM_LABEL_OVERRIDES, +} from './aoj_icpc_labels'; + +describe('formatAojIcpcTitle', () => { + test('prepends the letter and a dot to the title', () => { + expect(formatAojIcpcTitle('Amidakuji', 'B')).toBe('B. Amidakuji'); + }); +}); describe('buildAojIcpcLetterMap', () => { test('sorts indices numerically ascending and assigns letters A, B, C...', () => { diff --git a/src/features/tasks/utils/contest-table/aoj_icpc_labels.ts b/src/features/tasks/utils/contest-table/aoj_icpc_labels.ts index 97ce21e12..20ab1d17c 100644 --- a/src/features/tasks/utils/contest-table/aoj_icpc_labels.ts +++ b/src/features/tasks/utils/contest-table/aoj_icpc_labels.ts @@ -1,6 +1,11 @@ // contest_id -> (task_table_index -> letter). Used only for years with judge gaps. export const ICPC_PRELIM_LABEL_OVERRIDES: Record> = {}; +// Prepend the assigned positional letter to an ICPC title for inline display (e.g. "A. name"). +export function formatAojIcpcTitle(title: string, letter: string): string { + return `${letter}. ${title}`; +} + // Build task_table_index -> letter map for one contest. // Default: sort indices numerically asc, assign A, B, C... // Override: if ICPC_PRELIM_LABEL_OVERRIDES[contestId] exists, use it. diff --git a/src/features/tasks/utils/contest-table/aoj_icpc_providers.test.ts b/src/features/tasks/utils/contest-table/aoj_icpc_providers.test.ts index b877d1860..0442b9587 100644 --- a/src/features/tasks/utils/contest-table/aoj_icpc_providers.test.ts +++ b/src/features/tasks/utils/contest-table/aoj_icpc_providers.test.ts @@ -180,19 +180,27 @@ describe('AojIcpcPrelimProvider', () => { describe('generateTable', () => { describe('successful cases', () => { - test('assigns letter prefix A–H to all 8 titles in numeric ID order', () => { + test('stores raw titles (no letter prefix) for all 8 tasks', () => { const table = provider2023.generateTable(tasks2023); expect(table['ICPCPrelim2023']['1664'].title).toBe( - 'A. Which Team Should Receive the Sponsor Prize?', + 'Which Team Should Receive the Sponsor Prize?', ); - expect(table['ICPCPrelim2023']['1665'].title).toBe('B. Amidakuji'); - expect(table['ICPCPrelim2023']['1666'].title).toBe('C. Changing the Sitting Arrangement'); - expect(table['ICPCPrelim2023']['1667'].title).toBe('D. Efficient Problem Set'); - expect(table['ICPCPrelim2023']['1668'].title).toBe('E. Tampered Records'); - expect(table['ICPCPrelim2023']['1669'].title).toBe('F. Villa of Emblem Shape'); - expect(table['ICPCPrelim2023']['1670'].title).toBe('G. Fair Deal of Dice'); - expect(table['ICPCPrelim2023']['1671'].title).toBe('H. Planning Locations of Bus Stops'); + expect(table['ICPCPrelim2023']['1665'].title).toBe('Amidakuji'); + expect(table['ICPCPrelim2023']['1666'].title).toBe('Changing the Sitting Arrangement'); + expect(table['ICPCPrelim2023']['1667'].title).toBe('Efficient Problem Set'); + expect(table['ICPCPrelim2023']['1668'].title).toBe('Tampered Records'); + expect(table['ICPCPrelim2023']['1669'].title).toBe('Villa of Emblem Shape'); + expect(table['ICPCPrelim2023']['1670'].title).toBe('Fair Deal of Dice'); + expect(table['ICPCPrelim2023']['1671'].title).toBe('Planning Locations of Bus Stops'); + }); + + test('title is unchanged when generateTable is called twice (structurally idempotent)', () => { + const firstTable = provider2023.generateTable(tasks2023); + const secondInput = Object.values(firstTable['ICPCPrelim2023']) as TaskResults; + const secondTable = provider2023.generateTable(secondInput); + + expect(secondTable['ICPCPrelim2023']['1665'].title).toBe('Amidakuji'); }); test('uses task_table_index as the inner key', () => { @@ -216,14 +224,6 @@ describe('AojIcpcPrelimProvider', () => { expect(tasks2023[0].title).toBe(originalTitle); }); }); - - describe('edge cases', () => { - test('returns empty inner object when given empty input', () => { - const table = provider2023.generateTable([] as TaskResults); - - expect(table).toEqual({ ICPCPrelim2023: {} }); - }); - }); }); describe('getMetadata', () => { @@ -325,14 +325,14 @@ describe('AojIcpcPrelimProvider', () => { expect(provider1998.getMetadata().abbreviationName).toBe('icpcPrelim1998'); }); - test('oldest year 1998 assigns letters A–D', () => { + test('oldest year 1998 stores raw titles for 4 tasks', () => { const table = provider1998.generateTable(tasks1998); - expect(table['ICPCPrelim1998']['1100'].title).toBe('A. Area of Polygons'); - expect(table['ICPCPrelim1998']['1101'].title).toBe('B. A Simple Offline Text Editor'); - expect(table['ICPCPrelim1998']['1102'].title).toBe('C. Calculation of Expressions'); + expect(table['ICPCPrelim1998']['1100'].title).toBe('Area of Polygons'); + expect(table['ICPCPrelim1998']['1101'].title).toBe('A Simple Offline Text Editor'); + expect(table['ICPCPrelim1998']['1102'].title).toBe('Calculation of Expressions'); expect(table['ICPCPrelim1998']['1103'].title).toBe( - 'D. Board Arrangements for Concentration Games', + 'Board Arrangements for Concentration Games', ); }); @@ -349,11 +349,11 @@ describe('AojIcpcPrelimProvider', () => { expect(provider2025.getMetadata().abbreviationName).toBe('icpcPrelim2025'); }); - test('latest year 2025 assigns letters A–I (maximum problem count)', () => { + test('latest year 2025 stores raw titles (maximum problem count)', () => { const table = provider2025.generateTable(tasks2025); - expect(table['ICPCPrelim2025']['1681'].title).toBe('A. 2025'); - expect(table['ICPCPrelim2025']['1689'].title).toBe('I. Preparing the Lunch'); + expect(table['ICPCPrelim2025']['1681'].title).toBe('2025'); + expect(table['ICPCPrelim2025']['1689'].title).toBe('Preparing the Lunch'); }); test('latest year 2025 filter isolates its own contest_id', () => { @@ -385,12 +385,43 @@ describe('AojIcpcPrelimProvider', () => { delete ICPC_PRELIM_LABEL_OVERRIDES[TEST_CONTEST_ID]; }); - test('uses override map when ICPC_PRELIM_LABEL_OVERRIDES has an entry for the contest', () => { + test('generateTable stores raw titles even when override map is active', () => { const provider = createProvider(TEST_YEAR); const table = provider.generateTable(overrideTasks); - expect(table[TEST_CONTEST_ID]['9001'].title).toBe('X. Task One'); - expect(table[TEST_CONTEST_ID]['9002'].title).toBe('Y. Task Two'); + expect(table[TEST_CONTEST_ID]['9001'].title).toBe('Task One'); + expect(table[TEST_CONTEST_ID]['9002'].title).toBe('Task Two'); + }); + }); + + describe('getTaskLabels', () => { + describe('successful cases', () => { + test('returns letter map for all 8 tasks in numeric ID order (A–H)', () => { + const labels = provider2023.getTaskLabels(tasks2023); + + expect(labels['ICPCPrelim2023']['1664']).toBe('A'); + expect(labels['ICPCPrelim2023']['1665']).toBe('B'); + expect(labels['ICPCPrelim2023']['1666']).toBe('C'); + expect(labels['ICPCPrelim2023']['1667']).toBe('D'); + expect(labels['ICPCPrelim2023']['1668']).toBe('E'); + expect(labels['ICPCPrelim2023']['1669']).toBe('F'); + expect(labels['ICPCPrelim2023']['1670']).toBe('G'); + expect(labels['ICPCPrelim2023']['1671']).toBe('H'); + }); + + test('returns object keyed by contestId', () => { + const labels = provider2023.getTaskLabels(tasks2023); + + expect(Object.keys(labels)).toEqual(['ICPCPrelim2023']); + }); + }); + + describe('edge cases', () => { + test('returns empty inner object for empty input', () => { + const labels = provider2023.getTaskLabels([] as TaskResults); + + expect(labels).toEqual({ ICPCPrelim2023: {} }); + }); }); }); }); diff --git a/src/features/tasks/utils/contest-table/aoj_icpc_providers.ts b/src/features/tasks/utils/contest-table/aoj_icpc_providers.ts index b90a42f51..422e442e7 100644 --- a/src/features/tasks/utils/contest-table/aoj_icpc_providers.ts +++ b/src/features/tasks/utils/contest-table/aoj_icpc_providers.ts @@ -3,7 +3,6 @@ import type { TaskResult, TaskResults } from '$lib/types/task'; import { type ContestTableMetaData, type ContestTableDisplayConfig, - type ContestTable, } from '$features/tasks/types/contest-table/contest_table_provider'; import { ContestTableProviderBase } from './contest_table_provider_base'; @@ -23,24 +22,6 @@ export class AojIcpcPrelimProvider extends ContestTableProviderBase { return (taskResult: TaskResult) => taskResult.contest_id === this.contestId; } - // Override: prepend assigned letter to the title (display only; DB unchanged). - // ICPC titles are stored as "{name}" only, so no prefix removal is needed. - generateTable(filtered: TaskResults): ContestTable { - const letterMap = buildAojIcpcLetterMap( - this.contestId, - filtered.map((taskResult) => taskResult.task_table_index), - ); - const table: ContestTable = { [this.contestId]: {} }; - - for (const taskResult of filtered) { - const index = taskResult.task_table_index; - const letter = letterMap.get(index) ?? index; - table[this.contestId][index] = { ...taskResult, title: `${letter}. ${taskResult.title}` }; - } - - return table; - } - // Ensure left-to-right cell order is numeric (A,B,C...). Safeguard for variable-width ids. getHeaderIdsForTask(filtered: TaskResults): string[] { return Array.from(new Set(filtered.map((taskResult) => taskResult.task_table_index))).sort( @@ -74,4 +55,13 @@ export class AojIcpcPrelimProvider extends ContestTableProviderBase { getContestRoundLabel(_contestId: string): string { return `ICPC 国内予選 ${this.year}`; } + + override getTaskLabels(filtered: TaskResults): Record> { + const letterMap = buildAojIcpcLetterMap( + this.contestId, + filtered.map((taskResult) => taskResult.task_table_index), + ); + + return { [this.contestId]: Object.fromEntries(letterMap) }; + } } diff --git a/src/features/tasks/utils/contest-table/contest_table_provider_base.test.ts b/src/features/tasks/utils/contest-table/contest_table_provider_base.test.ts index cea4b994b..9e1b95feb 100644 --- a/src/features/tasks/utils/contest-table/contest_table_provider_base.test.ts +++ b/src/features/tasks/utils/contest-table/contest_table_provider_base.test.ts @@ -23,5 +23,11 @@ describe('ContestTableProviderBase', () => { expect(headerIds).toEqual(['A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J', 'K']); }); + + test('getTaskLabels returns empty object by default', () => { + const provider = new ABSProvider(ContestType.ABS); + + expect(provider.getTaskLabels([])).toEqual({}); + }); }); }); diff --git a/src/features/tasks/utils/contest-table/contest_table_provider_base.ts b/src/features/tasks/utils/contest-table/contest_table_provider_base.ts index ba6831599..ef41ae617 100644 --- a/src/features/tasks/utils/contest-table/contest_table_provider_base.ts +++ b/src/features/tasks/utils/contest-table/contest_table_provider_base.ts @@ -111,6 +111,10 @@ export abstract class ContestTableProviderBase implements ContestTableProvider { } abstract getContestRoundLabel(contestId: string): string; + + getTaskLabels(_filteredTaskResults: TaskResults): Record> { + return {}; + } } export function parseContestRound(contestId: string, prefix: string): number { From c55cfe9ca04d6f9e9e7cf2080a89f666ee816075 Mon Sep 17 00:00:00 2001 From: "k.hiro1818" Date: Thu, 11 Jun 2026 11:49:11 +0000 Subject: [PATCH 2/5] refactor(contest): rewrite displayTitle ternary as $derived.by with if/else Two-level nested ternary is hard to read. $derived.by with early returns makes the three cases (taskLabel / isShownTaskIndex / default) explicit. Co-Authored-By: Claude Sonnet 4.6 --- .../contest-table/TaskTableBodyCell.svelte | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/src/features/tasks/components/contest-table/TaskTableBodyCell.svelte b/src/features/tasks/components/contest-table/TaskTableBodyCell.svelte index 108acbf18..25afebfd4 100644 --- a/src/features/tasks/components/contest-table/TaskTableBodyCell.svelte +++ b/src/features/tasks/components/contest-table/TaskTableBodyCell.svelte @@ -30,13 +30,17 @@ }: Props = $props(); let estimatedGrade = $derived(voteResults.get(taskResult.task_id)?.grade); - let displayTitle = $derived( - taskLabel - ? formatAojIcpcTitle(taskResult.title, taskLabel) - : isShownTaskIndex - ? taskResult.title - : removeTaskIndexFromTitle(taskResult.title, taskResult.task_table_index), - ); + let displayTitle = $derived.by(() => { + if (taskLabel) { + return formatAojIcpcTitle(taskResult.title, taskLabel); + } + + if (isShownTaskIndex) { + return taskResult.title; + } + + return removeTaskIndexFromTitle(taskResult.title, taskResult.task_table_index); + });
Date: Thu, 11 Jun 2026 11:50:51 +0000 Subject: [PATCH 3/5] docs: remove icpc-prelim-prefix-accumulation dev-notes after implementation complete Co-Authored-By: Claude Sonnet 4.6 --- .../icpc-prelim-prefix-accumulation/plan.md | 119 ------------------ 1 file changed, 119 deletions(-) delete mode 100644 docs/dev-notes/2026-06-10/icpc-prelim-prefix-accumulation/plan.md diff --git a/docs/dev-notes/2026-06-10/icpc-prelim-prefix-accumulation/plan.md b/docs/dev-notes/2026-06-10/icpc-prelim-prefix-accumulation/plan.md deleted file mode 100644 index 3cfe0e0af..000000000 --- a/docs/dev-notes/2026-06-10/icpc-prelim-prefix-accumulation/plan.md +++ /dev/null @@ -1,119 +0,0 @@ -# ICPC 国内予選テーブル: プレフィックス累積バグ修正 - -Issue: https://github.com/AtCoder-NoviSteps/AtCoderNoviSteps/issues/3636 - -## Context(なぜ直すか) - -ICPC 国内予選テーブルで回答(提出ステータス)を更新するたびに、問題タイトルのプレフィックスが -`A. A. A. (タイトル)` のように累積する。本来は `A. (タイトル)` の1つだけであるべき。 - -### 根本原因(データフロー) - -1. [TaskTable.svelte:70](../../../../src/features/tasks/components/contest-table/TaskTable.svelte#L70) - `contestTableMaps = $derived(prepareContestTablesMap(providers))` は内部で `taskResults` を読む(L88)。 - Svelte 5 の `$derived` は関数内部の reactive read も追跡するため、`taskResults` 変更時に - **再実行され `generateTable` が走る**。 -2. [aoj_icpc_providers.ts:38](../../../../src/features/tasks/utils/contest-table/aoj_icpc_providers.ts#L38) - の `{ ...taskResult, title: \`${letter}. ${taskResult.title}\` }` がプレフィックス付きの**新オブジェクト**を生成。 -3. このオブジェクトがテンプレート L286-303 経由で `TaskTableBodyCell` → `UpdatingDropdown` に渡る。 -4. 回答更新時 `UpdatingDropdown.updateTaskResult` は `{ ...taskResult, ...status }`([L160](../../../../src/lib/components/SubmissionStatus/UpdatingDropdown.svelte#L160)) - で spread するため title `"A. title"` を保持。 -5. handleUpdateTaskResult L189-191 が、このプレフィックス付きオブジェクトを - **ソース `taskResults` 配列に書き戻す**。 -6. 次の再生成で `generateTable` が `"A. title"` を読み `"A. A. title"` を生成。更新ごとに累積。 - -**本質**: 表示用の整形(プレフィックス付与)を「再計算ループに乗った可変パイプライン(generateTable)」で行い、 -その整形済みオブジェクトが更新の書き戻しでソースに還流している。AtCoder 系が壊れないのは、プレフィックスが -**不変のソース(DB title)**由来でループ外にあるため。ICPC だけがこの非対称により累積する。 - -## 方針: 案B — 表示時整形(データは常に pristine) - -整形を**描画ループにもデータパイプラインにも入れず、読み取り専用の葉(cell)の表示文字列でのみ**付与する。 -`generateTable` は title を一切変更せず素の `TaskResult` を格納するため、書き戻しても整形が還流せず累積が -**構造的に発生しなくなる**(dedup 処理もフラグも不要)。`removeTaskIndexFromTitle` と対称の素直な設計。 - -letter はコンテスト内で**位置依存**(全問題集合から算出)のため cell 単独では計算できず、provider が出す。 -ICPC の `task_table_index` は AOJ の数値ID(`'1664'`)であって letter(`'A'`)ではない点に注意 -(=`removeTaskIndexFromTitle` の単純な逆操作では実装できない)。 - -### 不採用案 - -- **案A: `generateTable` を冪等化(strip-then-readd)**: 同じ letter の先行プレフィックスを除去してから付け直す。 - 累積を整形地点で後追い検知する**力技**。ソース title はメモリ上 `"A. name"` のまま汚染が残り、 - 表示整形をデータパイプラインに残す設計上の問題も未解決。 -- **案C: 書き戻し時に title を保持(`{ ...updatedTask, title: taskResults[index].title }`)**: 1行で済むが、 - **汎用**の更新ハンドラが「title は generateTable が付け直すので書き戻すな」という ICPC 由来の表示事情を - 暗黙に知る結合が生じ、トリッキー。将来 title 以外の表示派生フィールドが増えると取りこぼす。 -- **初期化フラグ**: `TaskResult` に「整形済み」フラグを持たせ二重付与を防ぐ。表示用の状態をドメイン型に - 持ち込み型を汚す。本質は案Aと同じ後追い検知で、汚染もループ外に出ない。 - -## 変更点 - -### 1. provider に `getTaskLabels` を追加(interface / base / ICPC) - -- `ContestTableProvider` interface([contest_table_provider.ts](../../../../src/features/tasks/types/contest-table/contest_table_provider.ts))に追加: - ```typescript - // Positional display labels for cells (contestId -> task_table_index -> letter). - // Empty for providers that show the index in the column header instead. - getTaskLabels(filteredTaskResults: TaskResults): Record>; - ``` -- `ContestTableProviderBase` はデフォルトで `{}` を返す(既存 provider は表示挙動不変)。 -- `AojIcpcPrelimProvider` が override: `buildAojIcpcLetterMap` から `{ [this.contestId]: { index: letter, ... } }` を返す。 - -### 2. ICPC `generateTable` から整形を除去(provider 層) - -[aoj_icpc_providers.ts](../../../../src/features/tasks/utils/contest-table/aoj_icpc_providers.ts) の -generateTable は title を変更せず素の `TaskResult` を格納(キー・並びは現状維持): - -```typescript -for (const taskResult of filtered) { - table[this.contestId][taskResult.task_table_index] = taskResult; -} -``` - -L26-27 のコメントも実態に合わせ更新。 - -### 3. ICPC 表示整形は AOJ 固有 util として `aoj_icpc_labels.ts` に追加 - -本当に新しいのは「AOJ ICPC の位置ラベルを title へ前置する」狭い・AOJ 固有の処理だけ。汎用名の関数に -束ねない(残りの raw / strip 分岐は既存で、汎用に見せかけない)。`removeTaskIndexFromTitle` は7箇所・複数 feature で -使う真に汎用な util で責務の広さが異なるため、そこには混ぜない。 - -`aoj_icpc_labels.ts`(ICPC のラベル**値**生成 `buildAojIcpcLetterMap` と同居 = 責務一致)に追加: - -```typescript -// Prepend the assigned positional letter to an ICPC title for inline display (e.g. "A. name"). -export function formatAojIcpcTitle(title: string, letter: string): string { - return `${letter}. ${title}`; -} -``` - -3分岐の組み立て自体は汎用 util に切り出さず、cell の `$derived displayTitle` で持つ(表示分岐はコンポーネントの責務)。 - -### 4. ProviderData / cell へ letter を配線 - -- [TaskTable.svelte](../../../../src/features/tasks/components/contest-table/TaskTable.svelte): `ProviderData` に - `taskLabels: Record>` を追加し、`prepareContestTable` で - `provider.getTaskLabels(filteredTaskResults)` をセット。テンプレートで - `{@const taskLabel = contestTable.taskLabels[contestId]?.[taskTableHeaderId]}` を引き、cell に prop で渡す。 -- [TaskTableBodyCell.svelte](../../../../src/features/tasks/components/contest-table/TaskTableBodyCell.svelte): - `taskLabel?: string` prop を追加。`$derived displayTitle` を `