import test from 'node:test'; import assert from 'node:assert/strict'; import fs from 'node:fs'; import crypto from 'node:crypto'; import {Feedback,feedback} from './src/feedback.mjs'; import {Haptics,HAPTIC_KEY} from './src/haptics.mjs'; import {inBacktrackCore} from './src/backtrack.mjs'; import {landingScale,checkpointScale,renderBrightBoard} from './src/bright-classroom/board-renderer.mjs'; import {boot} from './qa/runtime-harness.mjs'; import {solve} from './src/upstream/rules.mjs'; import {CasualAudio} from './src/audio.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 storage=()=>{const map=new Map();return {getItem:k=>map.get(k),setItem:(k,v)=>map.set(k,v),map};}; function clock(){let now=1000,id=0;const jobs=new Map();return {now:()=>now,later:(fn,ms)=>{jobs.set(++id,{fn,at:now+ms});return id;},cancel:i=>jobs.delete(i),advance(ms){now+=ms;for(const [i,j]of [...jobs])if(j.at<=now){jobs.delete(i);j.fn();}},jobs};} test('event batches preserve every landing and sequence but commit only highest audio and haptic',()=>{ const f=new Feedback({now:()=>1234}),sounds=[],pulses=[];f.audio={play:(...p)=>sounds.push(p)};f.haptics={play:k=>pulses.push(k),stop(){}}; f.begin();f.emit('move','l','a',1);f.emit('checkpoint','l','b',2);f.emit('complete','l','c',3);f.end(); assert.deepEqual(f.events.map(e=>e.sequence),[1,2,3]);assert.deepEqual(f.events.map(e=>e.kind),['move','checkpoint','complete']);assert.equal(f.events[1].time,1234);assert.deepEqual(sounds,[['complete',0,3]]);assert.deepEqual(pulses,['complete']);assert.equal(f.visuals.length,1); }); test('completion waits at most 120ms and ignores late audio callbacks',()=>{ const c=clock(),f=new Feedback({now:c.now});f.emit('complete','l','a',5);assert.equal(f.completionStart(null),null);c.advance(119);assert.equal(f.completionStart(null),null);c.advance(1);assert.equal(f.completionStart(null),1000);assert.equal(f.completionStart(1300),1000); f.reset();f.emit('complete','l','a',5);assert.equal(f.completionStart(1150),1150);f.reset();assert.equal(f.completionStart(1150),null); }); test('light haptics are limited, checkpoint replaces light, and completion adds only one delayed short pulse',()=>{ const c=clock(),calls=[],s=storage(),h=new Haptics({vibrateShort:o=>calls.push(o.type||'default')},s,{now:c.now,later:c.later,cancel:c.cancel}); h.play('move');h.play('move');assert.deepEqual(calls,['light']);c.advance(70);h.play('move');h.play('checkpoint');assert.deepEqual(calls,['light','light','medium']);h.play('complete');c.advance(89);assert.equal(calls.at(-1),'medium');c.advance(1);assert.equal(calls.at(-1),'light');h.play('undo');h.play('error');assert.equal(calls.length,5); }); test('unsupported graded haptic falls back to one short pulse, never long vibration',()=>{ const calls=[],h=new Haptics({vibrateShort:o=>{if(o.type)o.fail();else calls.push('short');},vibrateLong:()=>{throw Error('must not call long');}},storage());h.play('checkpoint');assert.deepEqual(calls,['short']); const absent=new Haptics({},storage());assert.doesNotThrow(()=>absent.play('move')); }); test('haptic disable, preview and lifecycle cancellation suppress delayed or expired work',()=>{ const c=clock(),calls=[],s=storage(),api={vibrateShort:o=>calls.push(o.type)},h=new Haptics(api,s,{now:c.now,later:c.later,cancel:c.cancel});h.play('complete');h.stop();c.advance(100);assert.deepEqual(calls,['medium']);h.setEnabled(false);h.play('checkpoint');assert.equal(s.map.get(HAPTIC_KEY),'off');assert.equal(new Haptics(api,s).enabled,false);new Haptics(api,storage(),{preview:true}).play('complete');assert.equal(calls.length,1); }); test('late failed haptic requests cannot revive old pulses and ungraded completion falls back once',()=>{ const c=clock(),calls=[],failed=[];const h=new Haptics({vibrateShort:o=>{if(o.type)failed.push(o.fail);else calls.push('short');}},storage(),{now:c.now,later:c.later,cancel:c.cancel}); h.play('move');h.play('checkpoint');failed[0]();assert.equal(calls.length,0);failed[1]();assert.equal(calls.length,1); h.play('complete');c.advance(100);assert.equal(calls.length,2,'completion on legacy device is exactly one short pulse'); }); test('rectangles, concave shapes and hexagons require geometric inward distance without treating internal seams as walls',()=>{ const square=(x,y)=>[[x,y],[x+100,y],[x+100,y+100],[x,y+100]],polys=[{id:'a',p:square(0,0)},{id:'a',p:square(100,0)},{id:'a',p:square(0,100)}]; assert.equal(inBacktrackCore(polys,'a',2.9,50,100),false);assert.equal(inBacktrackCore(polys,'a',3,50,100),true);assert.equal(inBacktrackCore(polys,'a',100,50,100),true);assert.equal(inBacktrackCore(polys,'a',99,101,100),false);assert.equal(inBacktrackCore(polys,'a',101,101,100),false); const hex=Array.from({length:6},(_,i)=>[30*Math.cos(i*Math.PI/3),30*Math.sin(i*Math.PI/3)]);assert.equal(inBacktrackCore([{id:'h',p:hex}],'h',0,0,60),true);assert.equal(inBacktrackCore([{id:'h',p:hex}],'h',29,0,60),false); }); test('one high-speed native move records all intermediate cells and completion without duplicate final node',()=>{ const ui=boot();ui.select(0);const s=ui.api.snapshot(),a=s.centers.t0,b=s.centers.t2,offset=s.boardRect.y; const touch=(kind,p)=>ui.handlers[kind]({changedTouches:[{identifier:1,clientX:p[0],clientY:p[1]+offset}],touches:[{identifier:1,clientX:p[0],clientY:p[1]+offset}]}); touch('TouchStart',a);touch('TouchMove',b);touch('TouchEnd',b);ui.tick();const events=ui.api.snapshot().feedback.events.filter(e=>e.level===levels[0].id);assert.deepEqual(Array.from(events,e=>e.kind),['move','move','complete']);assert.deepEqual(Array.from(events,e=>e.step),[1,2,3]);assert.equal(ui.api.snapshot().cleared,true); }); test('legal but off-solution moves use public rules, while invalid clues only produce error',()=>{ const ui=boot();ui.select(1);ui.cell('c3');ui.cell('c2');assert.equal(ui.api.snapshot().feedback.events.at(-1).kind,'move');assert.equal(ui.api.snapshot().violations.length,0);ui.select(3);ui.cell('c7');assert.equal(ui.api.snapshot().feedback.events.at(-1).kind,'error'); }); test('drag near a previous-block boundary cannot undo; deeper entry can, and undo button remains immediate',()=>{ const ui=boot();ui.select(3);ui.cell('c3');ui.cell('c0');const s=ui.api.snapshot(),a=s.centers.c0,b=s.centers.c3,offset=s.boardRect.y; const send=(kind,x,y)=>ui.handlers[kind]({changedTouches:[{identifier:1,clientX:x,clientY:y+offset}],touches:[{identifier:1,clientX:x,clientY:y+offset}]}); send('TouchStart',...a);const mid=(a[1]+b[1])/2;send('TouchMove',a[0],mid+.5);assert.equal(ui.api.snapshot().path.length,2);send('TouchMove',...b);assert.equal(ui.api.snapshot().path.length,1);send('TouchEnd',...b);ui.tick();ui.cell('c0');ui.button('撤销');assert.equal(ui.api.snapshot().path.length,1); }); test('tutorial, redraw, hint and restored saved routes do not generate player feedback',()=>{ const ui=boot();ui.select(4);const n=ui.api.snapshot().feedback.sequence;ui.button('规则');ui.advance(1000);ui.advance(1000);ui.button('返回游戏');ui.button('指路');ui.advance(400);assert.equal(ui.api.snapshot().feedback.sequence,n);ui.cell(solve(levels[4])[0][0]);const restored=boot(ui.storage);assert.equal(restored.api.snapshot().feedback.sequence,0); }); test('pressure and rebound have bounded amplitude and duration',()=>{assert.equal(landingScale(45),1.1);assert.equal(landingScale(90),1);assert.equal(checkpointScale(45),1.3);assert.equal(checkpointScale(90),1);}); test('interpolation cannot duplicate the same failed-cell event within one native movement',()=>{ const f=new Feedback();f.begin();for(let i=0;i<40;i++)f.emit('error','l','wall-cell',3);f.end();assert.equal(f.sequence,1);f.begin();f.emit('error','l','wall-cell',3);f.end();assert.equal(f.sequence,2); }); test('nodes are driven by explicit events even when multiple arrive before rendering; at most two ribbons survive',()=>{ const level=levels.find(l=>l.cells.filter(c=>c.clue>1&&c.clue=3);assert.ok(level);const nodes=level.cells.filter(c=>c.clue>1&&c.clue1000; for(const c of nodes)feedback.emit('checkpoint',level,c.id,c.clue);feedback.now=originalNow; const ctx=new Proxy({measureText:()=>({width:20})},{get:(o,k)=>o[k]??(()=>{}),set:(o,k,v)=>(o[k]=v,true)}); const route=solve(level)[0],scene=renderBrightBoard(ctx,level,route,375,400,{now:1100});assert.equal(scene.bursts.length,2);assert.deepEqual(scene.bursts.map(b=>b.cell),nodes.slice(1).map(c=>c.id)); feedback.now=()=>1100;feedback.emit('complete',level,route.at(-1),route.length);feedback.now=originalNow;assert.equal(renderBrightBoard(ctx,level,route,375,400,{now:1100}).bursts.length,0);feedback.reset(); }); test('audio fast moves merge to latest pitch and completion stops the whole four-channel pool',t=>{ const c=clock(),originalNow=Date.now,originalLater=globalThis.setTimeout,originalCancel=globalThis.clearTimeout;Date.now=c.now;globalThis.setTimeout=c.later;globalThis.clearTimeout=c.cancel;t.after(()=>{Date.now=originalNow;globalThis.setTimeout=originalLater;globalThis.clearTimeout=originalCancel;}); const channels=[],played=[];globalThis.wx={createInnerAudioContext:()=>{const a={stop(){this.stops=(this.stops||0)+1;},play(){played.push(this.src);},onError(){},onPlay(fn){this.callback=fn;}};channels.push(a);return a;}}; const audio=new CasualAudio({},storage(),'a');audio.play('move',0,1);audio.play('move',0,2);audio.play('move',0,36);assert.equal(c.jobs.size,1);c.advance(50);assert.deepEqual(played,['audio/move-1.wav','audio/move-36.wav']);audio.play('checkpoint',0,8);assert.equal(channels[3].src,'audio/checkpoint-8.wav');audio.play('complete');assert.ok(channels.every(a=>a.stops>0));const count=played.length;audio.play('move',0,9);c.advance(500);assert.equal(played.length,count);const callback=channels[3].callback;audio.stop();callback(); }); test('settings persist haptic preference independently and chapter cards always use three columns',()=>{ const ui=boot();ui.select(3);ui.button('选关');const cards=ui.api.buttons().filter(b=>/^\d+( ✓)?$/.test(b.label));assert.equal(cards.filter(b=>b.y===cards[0].y).length,3);ui.button('设置');ui.button('振动:开');assert.equal(ui.api.snapshot().feedback.hapticsEnabled,false);assert.equal(boot(ui.storage).api.snapshot().feedback.hapticsEnabled,false); }); test('original audio is preserved and high pitches lose energy rather than growing sharper',()=>{ const hashes=JSON.parse(fs.readFileSync(new URL('./qa/original-audio-sha256.json',import.meta.url))); for(const [file,hash]of Object.entries(hashes))assert.equal(crypto.createHash('sha256').update(fs.readFileSync(new URL('./audio-original/'+file,import.meta.url))).digest('hex'),hash); const rms=file=>{const b=fs.readFileSync(new URL('./audio/'+file,import.meta.url));let energy=0;for(let i=44;irms('move-12.wav')); });