Files
NumberSwipe_New/wechat-campaign-200-r2/feedback.test.mjs
T

86 lines
11 KiB
JavaScript

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(60),.985);assert.equal(landingScale(120),1);assert.equal(checkpointScale(130),1.035);assert.equal(checkpointScale(260),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<l.cells.length).length>=3);assert.ok(level);const nodes=level.cells.filter(c=>c.clue>1&&c.clue<level.cells.length).slice(0,3),originalNow=feedback.now;feedback.reset();feedback.now=()=>1000;
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;i<b.length;i+=2)energy+=b.readInt16LE(i)**2;return Math.sqrt(energy/((b.length-44)/2));};
assert.ok(rms('move-36.wav')<rms('move-1.wav')*.5);assert.ok(rms('checkpoint-12.wav')>rms('move-12.wav'));
});