import test from 'node:test'; import assert from 'node:assert/strict'; import fs from 'node:fs'; import {boot} from './qa/runtime-harness.mjs'; import {solve,canAppend} from './src/upstream/rules.mjs'; import {RESUME_KEY,resumeTarget,validResumePath} from './src/resume.mjs'; import {chapterLayout} from './src/level-select.mjs'; import {prepareHintMotion,hintPointAt} from './src/hints.mjs'; const pack=JSON.parse(fs.readFileSync(new URL('./src/upstream/levels.json',import.meta.url))),levels=pack.playOrder.map(id=>pack.levels.find(l=>l.id===id)); const closeRules=ui=>{if(ui.api.buttons().some(b=>b.label==='返回游戏'))ui.button('返回游戏');}; test('first launch goes directly to gameplay',()=>{const ui=boot();assert.equal(ui.api.snapshot().screen,'play');assert.equal(ui.api.snapshot().displayOrder,1);}); for(const exitFromSelect of [false,true])test('resume unfinished route after exit from '+(exitFromSelect?'selection':'gameplay'),()=>{ const ui=boot();ui.select(3);const route=solve(levels[3])[0].slice(0,3);route.forEach(ui.cell); if(exitFromSelect)ui.button('选关');ui.handlers.Hide(); ui.handlers.Show();ui.tick();assert.equal(ui.api.snapshot().screen,'play','warm re-entry also goes to gameplay');assert.deepEqual(Array.from(ui.api.snapshot().path),route); const restored=boot(ui.storage);assert.equal(restored.api.snapshot().screen,'play');assert.equal(restored.api.snapshot().displayOrder,4);assert.deepEqual(Array.from(restored.api.snapshot().path),route); assert.ok(restored.api.buttons().some(b=>b.label==='撤销'&&b.enabled));restored.button('撤销');assert.equal(restored.api.snapshot().path.length,2); restored.button('重开');const clean=boot(restored.storage);assert.equal(clean.api.snapshot().path.length,0); }); test('completed level resumes next unfinished, including leaving from cleared screen',()=>{ const ui=boot();ui.select(0);solve(levels[0])[0].forEach(ui.cell);ui.handlers.Hide();const restored=boot(ui.storage);assert.equal(restored.api.snapshot().displayOrder,2);assert.equal(restored.api.snapshot().screen,'play');assert.equal(restored.api.snapshot().path.length,0); }); test('replaying a cleared level does not replace the unfinished save',()=>{ const store=new Map([['variant:campaign-200-r2:v1',JSON.stringify([levels[0].id])]]),ui=boot(store);ui.select(3);solve(levels[3])[0].slice(0,2).forEach(ui.cell);const saved=ui.storage.get(RESUME_KEY);ui.select(0);ui.cell(solve(levels[0])[0][0]);ui.button('选关');ui.handlers.Hide();assert.equal(ui.storage.get(RESUME_KEY),saved);assert.equal(boot(store).api.snapshot().displayOrder,4); }); test('legacy saves, malformed data and full completion have valid startup targets',()=>{ assert.equal(resumeTarget(levels,[levels[3].id],null,levels[3].id).index,4); assert.equal(resumeTarget(levels,levels.map(l=>l.id),null,levels[199].id).index,199); assert.deepEqual(validResumePath(levels[0],['missing'],canAppend),[]); for(const value of ['broken','{}','{"version":1,"levelId":"missing","path":[]}']){const ui=boot(new Map([[RESUME_KEY,value]]));assert.equal(ui.api.snapshot().screen,'play');assert.equal(ui.api.snapshot().displayOrder,1);} }); test('selection pages expose fifteen cards and fit phone safe areas',()=>{ for(const [w,h]of [[320,568],[375,667],[390,844],[414,896]]){ const ui=boot(new Map(),w,h);closeRules(ui);ui.button('选关'); for(let chapter=1;chapter<=14;chapter++){ const cards=ui.api.buttons().filter(b=>/^\d+( ✓)?$/.test(b.label));assert.equal(cards.length,chapter===14?5:15);assert.equal(ui.api.snapshot().selection.chapter,chapter); for(const b of cards){assert.ok(b.w>=44&&b.h>=44);assert.ok(b.x>=0&&b.x+b.w<=w&&b.y+b.h=44); if(chapter<14)ui.button('下一页'); } ui.button('200');closeRules(ui);ui.button('选关');assert.equal(ui.api.snapshot().selection.chapter,14);assert.ok(ui.api.buttons().some(b=>b.label==='200')); } }); test('hint-only frames reuse static UI, and player input invalidates it',()=>{ const ui=boot();ui.select(3);ui.button('指路');ui.advance(1500);const before=ui.api.snapshot().hintBaseHits; for(let i=0;i<12;i++)ui.advance(16);assert.ok(ui.api.snapshot().hintBaseHits>=before+10); ui.cell(solve(levels[3])[0][0]);assert.equal(ui.api.snapshot().path.length,1);ui.button('撤销');assert.equal(ui.api.snapshot().path.length,0); ui.button('规则');assert.ok(ui.api.buttons().some(b=>b.label==='返回游戏'));ui.button('返回游戏');assert.ok(ui.api.snapshot().hint); }); test('hint curves are cached and distance lookup is stable at boundaries',()=>{ const hint={guides:[{a:'a',b:'b',points:[[0,0],[50,0],[50,50]]}]};const motion=prepareHintMotion(hint,40,[]);assert.equal(prepareHintMotion(hint,40,[]),motion);assert.notEqual(prepareHintMotion(hint,40,['a','b']),motion); assert.equal(hintPointAt(motion,0).x,0);assert.equal(hintPointAt(motion,motion.total).y,50); assert.equal(prepareHintMotion(hint,40,['a','b']).total,0); }); test('new-mechanic lesson reuses the board while the continuous cursor advances',()=>{ const ui=boot();ui.select(4);ui.button('规则');const before=ui.api.snapshot(); for(let i=0;i<6;i++)ui.advance(1); const after=ui.api.snapshot();assert.ok(after.renderedFrames>=before.renderedFrames+6);assert.equal(after.lessonBoardRenders,before.lessonBoardRenders);assert.deepEqual(after.path,before.path); ui.button('返回游戏');ui.button('规则');assert.ok(ui.api.snapshot().lesson); });