纠正仓库内容为衍生项目,标明 campaign-200-r2 最新版

This commit is contained in:
toshiocn
2026-10-19 16:56:31 +08:00
parent a492c5b80e
commit fdca4f67ea
1396 changed files with 392768 additions and 19530 deletions
+5
View File
@@ -0,0 +1,5 @@
import test from 'node:test';import assert from 'node:assert/strict';import {CasualAudio} from '../audio.mjs';
function host(){const events=[];class Audio{constructor(){this.currentTime=1;this.state='running';this.destination={};}createOscillator(){return {frequency:{setValueAtTime(f,t){events.push({f,t});}},connect(){},disconnect(){},start(){},stop(){}};}createGain(){return {gain:{setValueAtTime(){},linearRampToValueAtTime(){},exponentialRampToValueAtTime(){}},connect(){},disconnect(){}};}}return {AudioContext:Audio,events};}
test('six distinct soft cues and muted audio cannot start voices',()=>{const h=host(),memory=new Map(),storage={getItem:k=>memory.get(k),setItem:(k,v)=>memory.set(k,v)},audio=new CasualAudio(h,storage,'sound');const shapes=[];for(const kind of ['move','correct','error','undo','restart','complete']){const at=h.events.length;audio.play(kind);assert.equal(audio.lastEvent,kind);shapes.push(h.events.slice(at).map(e=>e.f).join(','));}assert.equal(new Set(shapes).size,6);audio.toggle();const n=h.events.length;audio.play('complete');assert.equal(h.events.length,n);assert.equal(memory.get('sound'),'off');});
test('rapid movement and repeated errors are throttled without suppressing correct arrival',()=>{const h=host(),a=new CasualAudio(h,{getItem(){return null},setItem(){}},'k');a.play('move');a.play('move');assert.equal(h.events.length,1);a.play('correct');assert.equal(h.events.length,3);a.play('error');a.play('error');assert.equal(h.events.length,5);a.context.currentTime+=.2;a.play('move');assert.equal(h.events.length,6);});
test('unsupported audio and blocked resume keep gameplay safe',()=>{assert.doesNotThrow(()=>new CasualAudio({}, {getItem(){throw Error()},setItem(){}},'k').play('move'));class FailingAudio{constructor(){throw Error('not supported')}}assert.doesNotThrow(()=>new CasualAudio({AudioContext:FailingAudio},{getItem(){return null}},'k').play('complete'));});
+17
View File
@@ -0,0 +1,17 @@
import test from 'node:test';import assert from 'node:assert/strict';import fs from 'node:fs';import {solve,violations,canAppend,assertStructure,doorOpen,doorLatched,outlineUnits,hint} from '../rules.mjs';
const pack=JSON.parse(fs.readFileSync(new URL('../levels.json',import.meta.url),'utf8'));
function reference(l){const at=new Map(l.cells.map(c=>[c.id,c]));function unit(c){if(c.shape)return c.shape.map(([x,y])=>[c.x+x,c.y+y]);let a=[];for(let y=0;y<(c.h||1);y++)for(let x=0;x<(c.w||1);x++)a.push([c.x+x,c.y+y]);return a;}const adjacency=(a,b)=>l.geometry==='hex'?[[1,0],[-1,0],[0,1],[0,-1],[1,-1],[-1,1]].some(([q,r])=>b.q-a.q===q&&b.r-a.r===r):unit(a).some(([x,y])=>unit(b).some(([xx,yy])=>Math.abs(x-xx)+Math.abs(y-yy)===1));const adj=new Map(l.cells.map(c=>[c.id,l.cells.filter(b=>b!==c&&adjacency(c,b)).map(c=>c.id)]));const result=[],p=[];
function valid(){const pos=id=>p.indexOf(id)+1;for(const c of l.cells){if(c.clue!==undefined&&pos(c.id)!==c.clue)return false;if(c.parity&&(pos(c.id)%2===1)!==(c.parity==='odd'))return false;if(c.direction){const k=pos(c.id)-1;if(k===0||k===p.length-1)return false;const b=c;const side=a=>a.x<b.x?'l':a.x>b.x?'r':a.y<b.y?'u':'d';if([side(at.get(p[k-1])),side(at.get(p[k+1]))].sort().join('')!==c.direction)return false;}}
for(let i=1;i<p.length;i++){if(l.features.includes('alternation')&&at.get(p[i-1]).category===at.get(p[i]).category)return false;if((l.walls||[]).some(e=>e.includes(p[i-1])&&e.includes(p[i])))return false;for(const d of l.doors||[])if(d.edge.includes(p[i-1])&&d.edge.includes(p[i])&&!(Boolean(d.initialOpen)!==Boolean(i%2)))return false;}return true;}
function walk(id){const c=at.get(id),step=p.length+1;if(c.clue!==undefined&&c.clue!==step)return;p.push(id);if(p.length===l.cells.length){if(valid())result.push([...p]);}else for(const id of adj.get(p.at(-1)))if(!p.includes(id))walk(id);p.pop();}for(const c of l.cells)walk(c.id);return result;}
for(const order of [1,2,3,5,6,11,13,16,18,21,22,24,26,28,31,33,37])test('independent reference order '+order,()=>{const l=pack.levels[order-1];const a=solve(l,{limit:Infinity}),b=reference(l);assert.deepEqual(a.map(p=>p.join()).sort(),b.map(p=>p.join()).sort());});
test('parity step check and reserved numeric clue coexist on different cells',()=>{const l={mode:'test',features:['parity'],geometry:'square',cells:[{id:'a',x:0,y:0,clue:1},{id:'b',x:1,y:0,parity:'even'},{id:'c',x:2,y:0}]};assert.equal(violations(l,['a','b']).length,0);assert.ok(violations(l,['b']).length);l.cells[1].parity='odd';assert.ok(violations(l,['a','b']).length);l.cells[1].clue=2;assert.throws(()=>assertStructure(l));});
test('alternation driven by feature, never by mode name',()=>{const l={mode:'campaign-50',features:['alternation'],geometry:'square',cells:[{id:'a',x:0,y:0,category:'circle'},{id:'b',x:1,y:0,category:'circle'},{id:'c',x:2,y:0,category:'triangle'}]};assert.equal(canAppend(l,['a'],'b'),false);l.features=[];assert.equal(canAppend(l,['a'],'b'),true);});
test('six port pairs, reverse traversal and deferred departure check',()=>{const coords={l:[-1,0],r:[1,0],u:[0,-1],d:[0,1]};for(const kind of ['lr','du','ru','dr','dl','lu']){const l={mode:'test',features:['direction'],geometry:'square',cells:[{id:'p',x:0,y:0,direction:kind},...Object.entries(coords).map(([id,[x,y]])=>({id,x,y}))]};const [a,b]=kind;assert.equal(violations(l,[a,'p'],{complete:false}).length,0);assert.equal(violations(l,[a,'p',b],{complete:false}).length,0);assert.equal(violations(l,[b,'p',a],{complete:false}).length,0);assert.ok(violations(l,['p',a],{complete:false}).length);assert.ok(violations(l,[a,'p'],{complete:true}).length);}});
test('door includes first landing and undo phase',()=>{const d={edge:['a','b'],initialOpen:false};assert.deepEqual([0,1,2,3,4].map(n=>doorOpen(d,n)),[false,true,false,true,false]);});
test('all campaign paths play with proper hints and gate pruning',()=>{for(const l of pack.levels){const p=solve(l)[0];for(let i=0;i<p.length;i++)assert.equal(canAppend(l,p.slice(0,i),p[i]),true);assert.equal(hint(p,[]).cell,p[0]);assert.equal(hint(p,p.slice(0,2)).cell,p[2]);assert.equal(hint(p,p).kind,'none');if(l.features.includes('rhythm-door'))assert.deepEqual(solve(l,{prune:true}),solve(l,{prune:false}));}});
test('parity and colour can combine when geometry and markers are valid',()=>{const l=structuredClone(pack.levels[20]);l.features.push('alternation');for(const [i,c]of l.cells.entries())c.category=i%2?'circle':'triangle';assert.doesNotThrow(()=>assertStructure(l));});
test('legal door crossing latches until that crossing is removed',()=>{const d={edge:['a','b'],initialOpen:false};assert.equal(doorLatched(d,['a']),false);assert.equal(doorOpen(d,['a','b']),true);assert.equal(doorOpen(d,['a','b','c','d']),true);assert.equal(doorLatched(d,['a','b','c']),true);assert.equal(doorLatched(d,['a']),false);assert.equal(doorOpen(d,[]),false);assert.equal(doorLatched(d,['x','a','b']),false);});
test('unit hex colour and parity levels do not require large blocks',()=>{for(const feature of ['alternation','parity'])assert.ok(pack.levels.some(l=>l.geometry==='hex'&&l.features.includes(feature)&&!l.features.includes('big')));});
test('whole block outline contains no internal shared edges',()=>{for(const c of [{x:0,y:0,w:2,h:2},{x:0,y:0,shape:[[0,0],[1,0],[0,1]]},{x:0,y:0,shape:[[0,0],[1,0],[2,0],[1,1]]}]){const contour=outlineUnits(c);const area=contour.reduce((s,a,i)=>{const b=contour[(i+1)%contour.length];return s+a[0]*b[1]-a[1]*b[0];},0)/2;assert.equal(area,c.shape?c.shape.length:c.w*c.h);assert.ok(contour.length>=4);}});
+7
View File
@@ -0,0 +1,7 @@
import test from 'node:test';import assert from 'node:assert/strict';import fs from 'node:fs';import crypto from 'node:crypto';
const source=fs.readFileSync('G:/项目/NumberSwipe/modes/01-number-stroke/web/style.css','utf8'),css=fs.readFileSync(new URL('../style.css',import.meta.url),'utf8');
test('original design tokens and core shell CSS are preserved',()=>{assert.ok(css.startsWith(source));for(const token of ['--primary: #6558f5','--bg: #ffffff','--cell: #e6e7ec','--danger: #e5484d','--hint: #f5a524','--font: ui-rounded'])assert.ok(css.includes(token));for(const selector of ['.level-tile','.topbar','.remaining','.bottombar','.toast','#app'])assert.ok(css.includes(selector));});
test('all UI element references exist in built single-file HTML',()=>{const html=fs.readFileSync(new URL('../index.html',import.meta.url),'utf8'),ui=fs.readFileSync(new URL('../ui.mjs',import.meta.url),'utf8'),ids=new Set([...html.matchAll(/id="([^"]+)"/g)].map(m=>m[1]));for(const match of ui.matchAll(/\$\('([^']+)'\)/g))assert.ok(ids.has(match[1]),'缺少DOM元素 '+match[1]);});
test('gameplay update preserves campaign order',()=>{const hashes=JSON.parse(fs.readFileSync(new URL('../reports/ui-before.json',import.meta.url),'utf8'));for(const [name,expected]of Object.entries(hashes).filter(([name])=>name.startsWith("order-"))){const actual=crypto.createHash('sha256').update(fs.readFileSync(new URL('../'+name,import.meta.url))).digest('hex');assert.equal(actual,expected,name);}});
test('completion feedback and next action occupy separate normal-flow elements',()=>{const html=fs.readFileSync(new URL('../index.html',import.meta.url),'utf8');assert.match(html,/<div class="feedback-row"[^>]*><div id="status"[\s\S]*?<\/div><button id="next"/);assert.equal((html.match(/id="status"/g)||[]).length,1);assert.equal((html.match(/id="next"/g)||[]).length,1);assert.match(css,/#status\{position:static/);assert.match(css,/#next\{position:static/);assert.match(css,/orientation:landscape/);assert.match(css,/font-size:16px/);});
+29
View File
@@ -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');});