|
|
|
@@ -0,0 +1,29 @@
|
|
|
|
|
import test from 'node:test';import assert from 'node:assert/strict';import fs from 'node:fs';import vm from 'node:vm';import {solve,graph,canAppend,violations} from '../rules.mjs';
|
|
|
|
|
const pack=JSON.parse(fs.readFileSync(new URL('../levels.json',import.meta.url),'utf8'));
|
|
|
|
|
function boot(width=340,storage=new Map(),height=420,withAudio=false){const map=new Map(),draws=[],downloads=[];class Element{constructor(id=''){this.id=id;this.children=[];this.handlers={};this.value='';this.style={};this.className='';this.textContent='';this.hidden=false;this.disabled=false;}replaceChildren(){this.children=[];}append(...e){this.children.push(...e);}addEventListener(k,f){this.handlers[k]=f;}setPointerCapture(){}getBoundingClientRect(){return {left:0,top:0,width,height};}click(){this.onclick?.();if(this.download)downloads.push(this.download);}}
|
|
|
|
|
const get=id=>{if(!map.has(id))map.set(id,new Element(id));return map.get(id);};const ctx={setTransform(){},clearRect(){draws.length=0;},beginPath(){this.p=[];},moveTo(x,y){this.p.push([x,y]);},lineTo(x,y){this.p.push([x,y]);},quadraticCurveTo(cx,cy,x,y){this.p.push([x,y]);},closePath(){},fill(){draws.push({kind:'fill',colour:this.fillStyle,points:this.p});},stroke(){draws.push({kind:'stroke',colour:this.strokeStyle,points:this.p});},fillText(s,x,y){draws.push({kind:'text',s,x,y});},save(){},restore(){},translate(){},rotate(){},setLineDash(){}};const canvas=get('board');canvas.clientWidth=width;canvas.clientHeight=height;canvas.getContext=()=>ctx;get('screen-play').className='screen hidden';get('screen-select').className='screen';const window={devicePixelRatio:1,handlers:{},addEventListener(k,f){this.handlers[k]=f;}};if(withAudio){let clock=0;window.AudioContext=class{get currentTime(){return clock+=.25;}constructor(){this.state='running';this.destination={};}createOscillator(){return {frequency:{setValueAtTime(){}},connect(){},disconnect(){},start(){},stop(){}};}createGain(){return {gain:{setValueAtTime(){},linearRampToValueAtTime(){},exponentialRampToValueAtTime(){}},connect(){},disconnect(){}};}};}const html=fs.readFileSync(new URL('../index.html',import.meta.url),'utf8');const script=html.match(/<script>([\s\S]*)<\/script>/)[1];const context=vm.createContext({document:{getElementById:get,createElement:()=>new Element()},window,localStorage:{getItem:k=>storage.get(k)||null,setItem:(k,v)=>storage.set(k,v)},performance:{now:()=>1000},console,Blob,URL:{createObjectURL:()=> 'blob:test',revokeObjectURL(){}},setTimeout:f=>f(),clearTimeout(){}});new vm.Script(script).runInContext(context);
|
|
|
|
|
const snapshot=()=>JSON.parse(JSON.stringify(window.variantSnapshot()));const press=id=>{const [x,y]=snapshot().centers[id];canvas.handlers.pointerdown({preventDefault(){},pointerId:1,clientX:x,clientY:y});canvas.handlers.pointerup({});};const select=n=>{const id=pack.levels[n-1].id;for(const section of get('pack-list').children){const b=section.children[1].children.find(b=>b.levelId===id);if(b){b.click();return;}}throw Error('Missing level '+id);};return {get,draws,canvas,window,snapshot,press,select,storage,downloads};}
|
|
|
|
|
test('all 50 embedded levels select, solve, undo and restart',()=>{const ui=boot();assert.equal(ui.get('pack-list').children.length,5);for(let n=1;n<=50;n++){ui.select(n);const l=pack.levels[n-1],p=solve(l)[0];assert.equal(ui.snapshot().order,n);assert.equal(ui.get('pack-list').children[Math.floor((n-1)/10)].children[1].children.length,n>=11&&n<=20?13:10);ui.get('hint').click();for(const [x,y]of Object.values(ui.snapshot().centers)){assert.ok(x>=0&&x<=340);assert.ok(y>=0&&y<=420);}ui.press(p[0]);ui.press(p[1]);ui.press(p[0]);assert.deepEqual(ui.snapshot().path,p.slice(0,1));ui.get('restart').click();for(const id of p)ui.press(id);assert.equal(ui.snapshot().cleared,true);assert.equal(ui.get('remaining-n').textContent,'完成');ui.get('undo').click();assert.equal(ui.snapshot().cleared,false);ui.press(p.at(-1));assert.equal(ui.snapshot().cleared,true);if(n<50)assert.equal(ui.get('next').hidden,false);else assert.equal(ui.get('next').hidden,true);}});
|
|
|
|
|
test('chapter transition through next button',()=>{const ui=boot();ui.select(10);for(const id of solve(pack.levels[9])[0])ui.press(id);ui.get('next').click();assert.equal(ui.snapshot().order,51);assert.equal(ui.snapshot().tutorial,'straight');assert.equal(ui.snapshot().chapter,2);});
|
|
|
|
|
test('parity marker renders and active rule explains it',()=>{const ui=boot();ui.select(21);assert.ok(ui.draws.some(d=>d.kind==='text'&&d.s==='偶'));assert.match(ui.get('rule-text').textContent,/奇.*偶/);const p=solve(pack.levels[20])[0];for(const id of p)ui.press(id);assert.ok(ui.draws.some(d=>d.kind==='text'&&d.s==='偶'));});
|
|
|
|
|
test('colour preserved after visiting and rule switch removes irrelevant mechanics',()=>{const ui=boot();ui.select(16);const p=solve(pack.levels[15])[0];ui.press(p[0]);ui.press(p[1]);assert.ok(ui.draws.some(d=>d.kind==='fill'&&d.colour==='#64acf3'));assert.ok(ui.draws.some(d=>d.kind==='fill'&&d.colour==='#ffae55'));ui.select(21);assert.ok(!ui.get('rule-text').textContent.includes('蓝→橙'));});
|
|
|
|
|
test('door current/next labels and undo phase restore',()=>{const ui=boot();ui.select(26);const p=solve(pack.levels[25])[0];assert.deepEqual(ui.snapshot().doors,[false]);ui.press(p[0]);assert.deepEqual(ui.snapshot().doors,[true]);ui.press(p[1]);assert.deepEqual(ui.snapshot().doors,[false]);ui.get('undo').click();assert.deepEqual(ui.snapshot().doors,[true]);ui.get('restart').click();assert.deepEqual(ui.snapshot().doors,[false]);});
|
|
|
|
|
test('resume current level and cleared IDs, record feedback locally and export all',()=>{const storage=new Map(),ui=boot(340,storage);ui.select(18);for(const id of solve(pack.levels[17])[0])ui.press(id);ui.get('reasoning').value='这关有趣';ui.get('export').click();assert.equal(ui.downloads[0],'NumberSwipe-DEMO53-试玩记录.json');const records=JSON.parse(storage.get('variant:campaign-50:v1:records'));assert.ok(records.some(r=>r.order===18&&r.reasoning==='这关有趣'&&r.completed));const reopened=boot(340,storage);assert.equal(reopened.snapshot().order,18);assert.match(reopened.get('progress').textContent,/1\s*\/\s*53/);});
|
|
|
|
|
test('fast drag samples crossed neighbours; release preserves prefix',()=>{const ui=boot();const p=solve(pack.levels[0])[0],a=ui.snapshot().centers[p[0]],b=ui.snapshot().centers[p[1]];ui.canvas.handlers.pointerdown({preventDefault(){},pointerId:1,clientX:a[0],clientY:a[1]});ui.canvas.handlers.pointermove({preventDefault(){},clientX:b[0],clientY:b[1]});ui.canvas.handlers.pointerup({});assert.deepEqual(ui.snapshot().path,p.slice(0,2));});
|
|
|
|
|
|
|
|
|
|
test('original project select/play screens, progress segments and rule sheet',()=>{const ui=boot();assert.equal(ui.snapshot().screen,'select');ui.get('btn-continue').click();assert.equal(ui.snapshot().screen,'play');assert.equal(ui.get('level-progress').children.length,10);ui.get('btn-rules').click();assert.equal(ui.get('rules-sheet').hidden,false);ui.get('close-rules').click();assert.equal(ui.get('rules-sheet').hidden,true);ui.get('btn-back').click();assert.equal(ui.snapshot().screen,'select');ui.select(22);assert.equal(ui.snapshot().screen,'play');assert.equal(ui.get('position').textContent,'3-2');});
|
|
|
|
|
|
|
|
|
|
test('direction glyph stays centered and same size on landing, exit, undo and errors',()=>{const ui=boot();ui.select(11);const l=pack.levels[10],p=solve(l)[0],c=l.cells.find(c=>c.direction),at=p.indexOf(c.id);const initial=ui.snapshot().directionMarkers.find(m=>m.cell===c.id);assert.equal(initial.state,'idle');for(const id of p.slice(0,at+1))ui.press(id);let active=ui.snapshot().directionMarkers.find(m=>m.cell===c.id);assert.deepEqual(active.center,initial.center);assert.equal(active.radius,initial.radius);assert.equal(active.state,'correct');assert.ok(ui.draws.some(d=>d.kind==='stroke'&&d.colour==='#BEB8FF'));ui.press(p[at+1]);active=ui.snapshot().directionMarkers.find(m=>m.cell===c.id);assert.deepEqual(active.center,initial.center);assert.equal(active.radius,initial.radius);ui.get('undo').click();assert.deepEqual(ui.snapshot().directionMarkers.find(m=>m.cell===c.id).center,initial.center);ui.get('restart').click();const g=graph(l);let wrong=null;function search(prefix){if(wrong)return;const last=prefix.at(-1);if(last===c.id){if(prefix.length>1){const from=l.cells.find(x=>x.id===prefix.at(-2));const side=from.x<c.x?'l':from.x>c.x?'r':from.y<c.y?'u':'d';if(!c.direction.includes(side))wrong=prefix;}return;}for(const n of g.adj[g.ids.get(last)]){const id=l.cells[n].id;if(!prefix.includes(id))search([...prefix,id]);}}search([p[0]]);assert.ok(wrong);for(const id of wrong)ui.press(id);const bad=ui.snapshot().directionMarkers.find(m=>m.cell===c.id);assert.equal(bad.state,'error');assert.deepEqual(bad.center,initial.center);assert.equal(bad.radius,initial.radius);});
|
|
|
|
|
|
|
|
|
|
test('movement only undoes the immediately previous cell, one at a time',()=>{const ui=boot();ui.select(11);const p=solve(pack.levels[10])[0];for(const id of p.slice(0,5))ui.press(id);ui.press(p[0]);ui.press(p[2]);assert.deepEqual(ui.snapshot().path,p.slice(0,5));ui.press(p[4]);assert.deepEqual(ui.snapshot().path,p.slice(0,5));ui.press(p[3]);assert.deepEqual(ui.snapshot().path,p.slice(0,4));ui.press(p[2]);assert.deepEqual(ui.snapshot().path,p.slice(0,3));});
|
|
|
|
|
test('hex door stays open after crossing and undo restores the crossing phase',()=>{const ui=boot();const l=pack.levels.find(l=>l.geometry==='hex'&&l.doors?.length);assert.ok(l);ui.select(l.order);const p=solve(l)[0],d=l.doors[0],at=p.findIndex((id,i)=>i>0&&d.edge.includes(id)&&d.edge.includes(p[i-1]));assert.ok(at>0&&at<p.length-1);for(const id of p.slice(0,at+1))ui.press(id);assert.deepEqual(ui.snapshot().latchedDoors,[true]);assert.deepEqual(ui.snapshot().doors,[true]);for(const id of p.slice(at+1))ui.press(id);assert.deepEqual(ui.snapshot().doors,[true]);for(let i=p.length;i>at+1;i--)ui.get('undo').click();assert.deepEqual(ui.snapshot().latchedDoors,[true]);ui.get('undo').click();assert.deepEqual(ui.snapshot().latchedDoors,[false]);ui.press(p[at]);assert.deepEqual(ui.snapshot().latchedDoors,[true]);ui.get('restart').click();assert.deepEqual(ui.snapshot().doors,[false]);});
|
|
|
|
|
test('irregular block is painted as one silhouette per layer',()=>{const ui=boot();ui.select(5);const fills=ui.draws.filter(d=>d.kind==='fill');const l=pack.levels[4];assert.equal(fills.length,l.cells.length+l.cells.filter(c=>c.clue!==undefined||c.parity||c.direction).length);});
|
|
|
|
|
|
|
|
|
|
test('5-2 visiting number 11 as step 7 paints the colour cell red and undo restores it',()=>{const ui=boot();ui.select(42);const l=pack.levels[41],target=l.cells.find(c=>c.clue===11).id,start=l.cells.find(c=>c.clue===1).id,g=graph(l);let prefix=null;function walk(p){if(prefix)return;if(p.length===6){if(canAppend(l,p,target))prefix=p;return;}for(const i of g.adj[g.ids.get(p.at(-1))]){const id=l.cells[i].id;if(id!==target&&canAppend(l,p,id)&&!violations(l,[...p,id]).length)walk([...p,id]);}}walk([start]);assert.ok(prefix);for(const id of prefix)ui.press(id);ui.press(target);assert.equal(ui.snapshot().path.length,7);assert.ok(ui.snapshot().violations.some(v=>v.key==='clue:'+target));const view=ui.snapshot().cellViews.find(c=>c.cell===target);assert.equal(view.fill,'#E5484D');assert.equal(view.error,true);assert.ok(ui.draws.some(d=>d.kind==='text'&&d.s==='需11'));ui.get('undo').click();assert.equal(ui.snapshot().cellViews.find(c=>c.cell===target).fill,'#64acf3');});
|
|
|
|
|
test('door hint changes neither path nor phase, even at equal counts with different crossing history',()=>{const ui=boot();ui.select(26);const l=pack.levels[25],p=solve(l)[0],d=l.doors[0],cross=p.findIndex((id,i)=>i>0&&d.edge.includes(id)&&d.edge.includes(p[i-1]));for(const id of p.slice(0,cross+1))ui.press(id);const after=ui.snapshot();ui.get('hint').click();assert.deepEqual(ui.snapshot().path,after.path);assert.deepEqual(ui.snapshot().doors,after.doors);assert.deepEqual(ui.snapshot().latchedDoors,after.latchedDoors);assert.ok(ui.get('constraints').children.some(c=>c.textContent.includes('保持开启')));ui.get('restart').click();const g=graph(l),count=cross+1;let alternate=null;function walk(a){if(alternate)return;if(a.length===count){if(!a.some((id,i)=>i>0&&d.edge.includes(id)&&d.edge.includes(a[i-1])))alternate=a;return;}for(const i of g.adj[g.ids.get(a.at(-1))]){const id=l.cells[i].id;if(canAppend(l,a,id)&&!violations(l,[...a,id]).length)walk([...a,id]);}}walk([p[0]]);assert.ok(alternate);for(const id of alternate)ui.press(id);assert.equal(ui.snapshot().path.length,after.path.length);assert.deepEqual(ui.snapshot().doors,[false]);const before=ui.snapshot();ui.get('hint').click();assert.deepEqual(ui.snapshot().doors,before.doors);assert.deepEqual(ui.snapshot().path,before.path);});
|
|
|
|
|
test('all levels remain playable in narrow portrait and short landscape canvas sizes',()=>{for(const [width,height]of [[296,290],[351,430],[480,260]]){const ui=boot(width,new Map(),height);for(let n=1;n<=50;n++){ui.select(n);for(const [x,y]of Object.values(ui.snapshot().centers)){assert.ok(x>=0&&x<=width);assert.ok(y>=0&&y<=height);}for(const id of solve(pack.levels[n-1])[0])ui.press(id);assert.equal(ui.snapshot().cleared,true,'viewport '+width+'x'+height+' level '+n);}}});
|
|
|
|
|
|
|
|
|
|
test('three tutorials are inserted before first direction and colour levels and can finish',()=>{const ui=boot();assert.equal(pack.levels.length,53);assert.equal(ui.get('pack-list').children[1].children[1].children.length,13);for(const n of [51,52,53]){ui.select(n);const l=pack.levels[n-1];assert.equal(ui.snapshot().tutorial,l.tutorial);assert.equal(ui.snapshot().displayOrder,n===51?11:n===52?12:18);assert.ok(ui.get('caption').textContent.includes(n===53?'蓝':'连接'));for(const id of solve(l)[0])ui.press(id);assert.equal(ui.snapshot().cleared,true);}ui.select(52);for(const id of solve(pack.levels[51])[0])ui.press(id);ui.get('next').click();assert.equal(ui.snapshot().order,11);ui.select(15);for(const id of solve(pack.levels[14])[0])ui.press(id);ui.get('next').click();assert.equal(ui.snapshot().tutorial,'colour');});
|
|
|
|
|
test('sound toggle persists and does not change progress or path',()=>{const storage=new Map(),ui=boot(340,storage);assert.equal(ui.snapshot().soundEnabled,true);ui.get('btn-sound').click();assert.equal(ui.snapshot().soundEnabled,false);assert.deepEqual(ui.snapshot().path,[]);assert.equal(boot(340,storage).snapshot().soundEnabled,false);ui.get('btn-sound').click();assert.equal(ui.snapshot().soundEnabled,true);});
|
|
|
|
|
|
|
|
|
|
test('actual input dispatches move, numeric correct/error, undo, restart and completion cues',()=>{const ui=boot(340,new Map(),420,true);ui.select(1);const p=solve(pack.levels[0])[0];ui.press(p[0]);assert.equal(ui.snapshot().lastSound,'correct');ui.press(p[1]);assert.equal(ui.snapshot().lastSound,'move');ui.press(p[0]);assert.equal(ui.snapshot().lastSound,'undo');ui.get('restart').click();assert.equal(ui.snapshot().lastSound,'restart');for(const id of p)ui.press(id);assert.equal(ui.snapshot().lastSound,'complete');ui.select(42);for(const id of ['c14','c11','c12','c8','c7','c0','c4'])ui.press(id);assert.equal(ui.snapshot().lastSound,'error');assert.ok(ui.snapshot().violations.length);ui.get('undo').click();assert.equal(ui.snapshot().lastSound,'undo');ui.get('restart').click();const correct=solve(pack.levels[41])[0];for(const id of correct.slice(0,11))ui.press(id);assert.equal(ui.snapshot().lastSound,'correct');});
|