fix: preserve analysis results and flag stale user data
Publish GitHub Pages output / publish (push) Successful in 51s

This commit is contained in:
2026-09-09 18:00:33 +09:00
parent eb294ff5e3
commit 28035110f9
5 changed files with 234 additions and 46 deletions
@@ -0,0 +1,80 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { readFileSync } from 'node:fs';
import { createContext, runInContext } from 'node:vm';
import ts from 'typescript';
import { compile } from 'svelte/compiler';
const page = readFileSync(new URL('../../routes/analysis/+page.svelte', import.meta.url), 'utf8');
const script = page.match(/<script lang="ts">([\s\S]*?)<\/script>/)![1];
const source = ts.createSourceFile('analysis.ts', script, ts.ScriptTarget.Latest, true);
const names = new Set(['updateAwakening', 'updateFixedReduction', 'toggleAllBlueNodes', 'updateGenerationBonus',
'updateSongScoreBonusOverride', 'updateCard', 'updateLeader', 'updateOutfit', 'applyPreset']);
const snippets: string[] = [];
function collect(node: ts.Node): void {
if (ts.isFunctionDeclaration(node) && node.name && names.has(node.name.text)) snippets.push(node.getText(source));
if (ts.isBinaryExpression(node) && node.left.getText(source) === 'worker.onmessage') snippets.push(node.getText(source) + ';');
if (ts.isVariableDeclaration(node) && node.name.getText(source) === 'syncStorage') snippets.push(`const ${node.getText(source)};`);
ts.forEachChild(node, collect);
}
collect(source);
const code = ts.transpileModule(snippets.join('\n'), { compilerOptions: { target: ts.ScriptTarget.ES2022 } }).outputText;
test('편성·성장·프리셋을 수정해도 이전 결과와 확률을 유지한다', () => {
const result = { theoreticalMaxScore: 123 }, capture = { title: 'old song' }, probabilities = { maximum: 0.1 };
const context = createContext({ result, resultCapture: capture, achievementProbabilities: probabilities, worker: {},
selectedAwakenings: [1], fixedCooldownReductions: [null], allBlueNodes: [false], generationBonusOverrides: [null],
songScoreBonusOverride: null, selectedCardIds: ['old'], collection: {}, leaderCharacterId: 'old', leaderOutfitCardId: '',
fiveStarCards: [{ id: 'outfit', characterId: 'new', details: { costume: {} } }],
normalizeAwakening: Number, normalizeGenerationBonusOverride: Number, restoreAnalysisState: () => {}, errorMessage: '' });
runInContext(code, context);
for (const update of ['updateAwakening(0, 4)', 'updateFixedReduction(0, 12)', 'toggleAllBlueNodes(0)',
'updateGenerationBonus(0, "900")', 'updateSongScoreBonusOverride({value: "10"})', 'updateCard(0, "new")',
'updateLeader("new")', 'updateOutfit("outfit")', 'applyPreset({detail: {}})']) {
runInContext(update, context);
assert.equal(context.result, result, update);
assert.equal(context.resultCapture, capture, update);
assert.equal(context.achievementProbabilities, probabilities, update);
}
});
test('계산 완료는 현재 입력이 아닌 시작 당시 조건·시뮬레이션과 결과를 함께 교체한다', () => {
const next = { theoreticalMaxScore: 456 }, capture = { title: 'requested song' }, simulation = { chart: 'requested chart' };
const context = createContext({ worker: {}, result: { theoreticalMaxScore: 123 }, pendingCapture: capture,
pendingInputKey: 'requested conditions', currentAnalysisKey: 'edited while running', pendingSimulationContext: simulation,
resultCapture: null, resultInputKey: '', simulationContext: { chart: 'old chart' }, achievementProbabilities: null, running: true, next });
runInContext(code, context);
runInContext('worker.onmessage({data: {type: "result", result: next, achievementProbabilities: {maximum: 0.2}}})', context);
assert.equal(context.result, next);
assert.equal(context.resultCapture, capture);
assert.equal(context.resultInputKey, 'requested conditions');
assert.notEqual(context.resultInputKey, context.currentAnalysisKey);
assert.equal(context.simulationContext, simulation);
assert.equal(context.running, false);
});
test('이전 결과 안내는 입력 변화에 반응하고 타임라인은 저장된 결과의 악곡 길이를 사용한다', () => {
const code = compile(page, { filename: 'analysis.svelte', generate: 'client' }).js.code;
const effects = code.split('$.legacy_pre_effect(');
assert.ok(effects.find(part => part.includes('$.set(resultStale,') && part.includes('$.get(currentAnalysisKey)')));
assert.ok(effects.find(part => part.includes('$.set(currentAnalysisKey,') && part.includes('$.get(selectedSongId)') && part.includes('$.get(difficulty)')));
assert.ok(effects.find(part => part.includes('$.set(timelineDuration,') && part.includes('$.get(resultCapture)?.durationSeconds')));
});
test('다른 탭의 성장 변경과 재분석 오류가 기존 결과·기록·시뮬레이션을 지우지 않는다', () => {
const result = { theoreticalMaxScore: 123 }, capture = { title: 'old' }, simulation = { chart: 'old' };
const storage = { getItem: () => '{"memoryCount":30}' };
let loaded = '';
const context = createContext({ worker: {}, result, resultCapture: capture, simulationContext: simulation,
localStorage: storage, USER_DATA_STORAGE_KEY: 'growth', applyStoredUserData: (raw: string) => { loaded = raw; },
running: true, errorMessage: '' });
runInContext(code, context);
runInContext('syncStorage({key: "growth", storageArea: localStorage, newValue: "stale event"})', context);
assert.equal(loaded, '{"memoryCount":30}');
runInContext('worker.onmessage({data: {type: "error", message: "retry"}})', context);
assert.equal(context.result, result);
assert.equal(context.resultCapture, capture);
assert.equal(context.simulationContext, simulation);
assert.equal(context.running, false);
assert.equal(context.errorMessage, 'retry');
});
@@ -0,0 +1,58 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { readFileSync } from 'node:fs';
import { createContext, runInContext } from 'node:vm';
import ts from 'typescript';
// Exercise the page's actual storage handler without a browser or expensive workers.
const page = readFileSync(new URL('../../routes/combination/+page.svelte', import.meta.url), 'utf8');
const script = page.match(/<script lang="ts">([\s\S]*?)<\/script>/)![1];
const source = ts.createSourceFile('combination.ts', script, ts.ScriptTarget.Latest, true);
const snippets: string[] = [];
function collect(node: ts.Node): void {
if (ts.isFunctionDeclaration(node) && node.name?.text === 'loadUserData') snippets.push(node.getText(source));
if (ts.isVariableDeclaration(node) && node.name.getText(source) === 'syncStorage') {
snippets.push(`const ${node.getText(source)};`);
}
ts.forEachChild(node, collect);
}
collect(source);
assert.equal(snippets.length, 2);
const code = ts.transpileModule(snippets.join('\n'), { compilerOptions: { target: ts.ScriptTarget.ES2022 } }).outputText;
test('다른 탭의 데이터 변경은 계산 중인 스냅샷과 기존 결과를 보존하고 안내만 표시한다', () => {
let raw = JSON.stringify({ collection: { a: { awakening: 1 } }, memberBoards: { a: ['B1'] }, memoryCount: 1 });
const storage = { getItem: () => raw };
const result = { theoreticalMaxScore: 123 };
const context = createContext({ localStorage: storage, USER_DATA_STORAGE_KEY: 'growth',
loadedUserData: undefined, userDataChanged: false, collection: {}, memberBoards: {}, boardCards: {},
memoryCount: 0, memberPowerUpPercent: 0, result, rankedResults: [result], running: true });
runInContext(code + '\nloadUserData();', context);
raw = JSON.stringify({ collection: { a: { awakening: 5 } }, memberBoards: { a: ['B1', 'B2'] }, memoryCount: 30 });
runInContext('syncStorage({key: "growth", storageArea: localStorage});', context);
assert.equal(context.userDataChanged, true);
assert.equal(context.collection.a.awakening, 1);
assert.equal(context.memoryCount, 1);
assert.equal(context.memberBoards.a.length, 1);
assert.equal(context.result, result);
assert.equal(context.rankedResults[0], result);
assert.equal(context.running, true);
// Only the next explicit search loads the new snapshot.
runInContext('loadUserData();', context);
assert.equal(context.collection.a.awakening, 5);
assert.equal(context.memoryCount, 30);
assert.equal(context.memberBoards.a.length, 2);
assert.equal(context.userDataChanged, false);
runInContext('syncStorage({key: "growth", newValue: "outdated queued event", storageArea: localStorage});', context);
assert.equal(context.userDataChanged, false);
raw = 'null';
runInContext('syncStorage({key: "other", storageArea: localStorage});', context);
assert.equal(context.userDataChanged, false);
runInContext('syncStorage({key: "growth", storageArea: {}});', context);
assert.equal(context.userDataChanged, false);
runInContext('syncStorage({key: null, storageArea: localStorage});', context);
assert.equal(context.userDataChanged, true);
assert.equal(context.result, result);
});