Files
HexTrails/tests/campaign-versions.test.js
T
cabinwandClaude Opus 5 b35ea201db fix(campaign): register c50 with the fields the app actually reads
Opening the c50 edition threw `campaign.legacyStorageKeys is not iterable`
and left the board blank — the header still drew, so the level set looked
loaded. Every other edition carries the array; the hand-written c50 entry
did not, and 197 passing tests had nothing to say about it, because nothing
tested the registration itself.

The test now walks every edition and asserts the shape app.js relies on,
including that the chapter list can actually hold the levels. It fails on the
entry as it shipped.

The task board was reading levels/prototypes/d5.json while the campaign sat in
levels/campaign-50.json, so it showed "候选关卡 18 / 50" with fifty levels
built. It reads the shipping set when there is one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 17:03:37 +08:00

138 lines
7.4 KiB
JavaScript

import test from 'node:test';
import assert from 'node:assert/strict';
import { readFile } from 'node:fs/promises';
import { createHash } from 'node:crypto';
import { Game, solveRotations, validateLevel } from '../src/core.js';
import { LEVELS } from '../src/levels.js';
import { CAMPAIGN_VERSIONS, campaignVersion } from '../src/campaign-versions.js';
import { CAMPAIGN_SELECTION_KEY, campaignProgressKey, selectedCampaign, normalizeProgress, readCampaignProgress, writeCampaignProgress } from '../src/campaign-progress.js';
const storage=entries=>{
const items=new Map(Object.entries(entries||{}));
return {getItem:key=>items.get(key)??null,setItem:(key,value)=>items.set(key,value)};
};
test('every selectable edition retains its own level count, order and chapters',()=>{
// Historical editions keep their exact shape; new ones are appended, because
// other tests address the originals by position.
assert.deepEqual(CAMPAIGN_VERSIONS.map(c=>[c.id,c.levels.length,c.chapterSize,c.chapters.length]),[
['v0.1',24,8,3],['v0.2',48,6,8],['v0.3',48,6,8],['v0.4',20,5,4],['v0.5',1000,50,20],['c50',50,10,5],
]);
assert.equal(campaignVersion('v0.1').levels[0].name,'转一转,路就通了');
assert.equal(campaignVersion('v0.1').levels.at(-1).name,'六角大满贯');
assert.equal(campaignVersion('v0.2').levels[0].name,'一眼看见出口');
assert.equal(campaignVersion('v0.3').levels,LEVELS);
assert.equal(campaignVersion('v0.3').levels[0].name,'跟着弯道走');
for(const c of CAMPAIGN_VERSIONS)assert.equal(c.chapterSize*c.chapters.length,c.levels.length);
});
test('browser archive module matches the exact historical JSON bytes and provenance',async()=>{
const manifest=JSON.parse(await readFile(new URL('../levels/versions/provenance.json',import.meta.url),'utf8'));
for(const source of manifest.versions){
const bytes=await readFile(new URL('../'+source.archivePath,import.meta.url));
assert.equal(bytes.length,source.bytes);
assert.equal(createHash('sha256').update(bytes).digest('hex'),source.sha256);
assert.deepEqual(campaignVersion(source.version).levels,JSON.parse(bytes.toString('utf8')));
}
});
test('all 120 earlier layouts solve and replay with the current free rotation rules',()=>{
for(const c of CAMPAIGN_VERSIONS.slice(0,3))for(const [i,level] of c.levels.entries()){
const label=`${c.id} level ${i+1}`;
assert.deepEqual(validateLevel(level),[],label);
const plan=solveRotations(level),game=new Game(level);
assert.ok(plan.solvable&&!plan.exhausted,label);
for(const action of plan.actions){
const before=game.moves;
if(action.type==='rotate'){
assert.equal(game.rotate(action.direction).status,'rotated',label);
assert.equal(game.moves,before,label);
}else assert.equal(game.tap(action.id).status,'exit',label);
}
assert.ok(game.won,label);
}
});
test('new players default to v0.5 while an explicit version choice survives reload',()=>{
const s=storage();assert.equal(selectedCampaign(s).id,'v0.5');
assert.ok(writeCampaignProgress(s,campaignVersion('v0.1'),{current:23,completed:[0,22]}));
assert.equal(selectedCampaign(s).id,'v0.1');
assert.deepEqual(readCampaignProgress(s,selectedCampaign(s)),{current:23,completed:[0,22]});
s.setItem(CAMPAIGN_SELECTION_KEY,'v9.9');assert.equal(selectedCampaign(s).id,'v0.5');
});
test('versions keep progress separate even when v0.2 and v0.3 reuse level IDs',()=>{
const s=storage(),v2=campaignVersion('v0.2'),v3=campaignVersion('v0.3');
assert.equal(v2.levels[0].id,v3.levels[0].id);
writeCampaignProgress(s,v2,{current:47,completed:[0,1,47]});
assert.deepEqual(readCampaignProgress(s,v3),{current:0,completed:[]});
writeCampaignProgress(s,v3,{current:7,completed:[6]});
assert.deepEqual(readCampaignProgress(s,v2),{current:47,completed:[0,1,47]});
assert.deepEqual(readCampaignProgress(s,v3),{current:7,completed:[6]});
assert.deepEqual(readCampaignProgress(s,campaignVersion('v0.1')),{current:0,completed:[]});
});
test('each edition migrates only its own legacy slot without changing the old data',()=>{
const old={
'hex-trails-v2':JSON.stringify({current:20,completed:[0,20]}),
'hex-trails-observe-v3':JSON.stringify({current:45,completed:[0,45]}),
'hex-trails-observe-v3-r2':JSON.stringify({current:6,completed:[6]}),
};
const s=storage(old);
for(const [i,c] of CAMPAIGN_VERSIONS.slice(0,3).entries()){
const key=Object.keys(old)[i],expected=JSON.parse(old[key]);
assert.deepEqual(readCampaignProgress(s,c),expected);
writeCampaignProgress(s,c,{current:1,completed:[1]});
assert.deepEqual(readCampaignProgress(s,c),{current:1,completed:[1]});
assert.equal(s.getItem(key),old[key]);
}
});
test('invalid saves are bounded to each edition and do not resurrect legacy progress',()=>{
assert.deepEqual(normalizeProgress({current:47,completed:[-1,0,0,23,24,47,1.5,'2',null]},24),{current:0,completed:[0,23]});
const c=campaignVersion('v0.1');
for(const raw of ['{','null','[]','true','"text"']){
const s=storage({[campaignProgressKey(c.id)]:raw,'hex-trails-v2':JSON.stringify({current:20,completed:[20]})});
assert.deepEqual(readCampaignProgress(s,c),{current:0,completed:[]});
}
});
test('unavailable browser storage still permits loading a campaign',()=>{
const denied={getItem(){throw new Error('denied');},setItem(){throw new Error('denied');}};
for(const s of [undefined,denied]){
assert.equal(selectedCampaign(s).id,'v0.5');
assert.deepEqual(readCampaignProgress(s,campaignVersion('v0.1')),{current:0,completed:[]});
assert.equal(writeCampaignProgress(s,campaignVersion('v0.1'),{current:1,completed:[0]}),false);
}
});
test('the production campaign starts independently and retains its thousandth level',()=>{
const old=JSON.stringify({current:19,completed:[0,19]});
const s=storage({[CAMPAIGN_SELECTION_KEY]:'v0.4',[campaignProgressKey('v0.4')]:old});
const production=campaignVersion('v0.5');
assert.equal(selectedCampaign(s).id,'v0.4');
assert.deepEqual(readCampaignProgress(s,production),{current:0,completed:[]});
writeCampaignProgress(s,production,{current:999,completed:[0,998,999]});
assert.deepEqual(readCampaignProgress(s,production),{current:999,completed:[0,998,999]});
assert.equal(s.getItem(campaignProgressKey('v0.4')),old);
assert.equal(selectedCampaign(s).id,'v0.5');
});
// A campaign edition is registered by hand, and app.js spreads several of its
// fields on load. Leaving one out registers an edition that looks fine in every
// other test and blanks the board the moment somebody opens it — which is how
// c50 shipped its first build.
test('every campaign edition carries the fields the app reads on load', () => {
for (const edition of CAMPAIGN_VERSIONS) {
assert.ok(edition.id && edition.name, `${edition.id}: 缺 id/name`);
assert.ok(Array.isArray(edition.levels) && edition.levels.length, `${edition.id}: 缺关卡`);
assert.ok(Array.isArray(edition.chapters) && edition.chapters.length, `${edition.id}: 缺章节`);
assert.ok(Number.isFinite(edition.chapterSize) && edition.chapterSize > 0, `${edition.id}: chapterSize 非法`);
// app.js iterates this one directly, so a missing array is a TypeError.
assert.ok(Array.isArray(edition.legacyStorageKeys), `${edition.id}: legacyStorageKeys 必须是数组`);
assert.ok(edition.chapters.length >= Math.ceil(edition.levels.length / edition.chapterSize),
`${edition.id}: 章节数不够放下 ${edition.levels.length} 关`);
}
});