Files
HexTrails/tests/rapid-tap-app.test.js
T

123 lines
7.6 KiB
JavaScript

import test from 'node:test';
import assert from 'node:assert/strict';
import { Game, available, solveRotations } from '../src/core.js';
import { campaignVersion } from '../src/campaign-versions.js';
import { layout, center } from '../src/geometry.js';
import { CanvasPainter } from '../src/renderer.js';
// Run the actual main-game pointer handlers and frame loop with virtual time.
// Checking TapQueue alone misses the regression where taps wait for animation.
test('main game starts rapid departures together and safely finishes or resets them',async t=>{
const keys=['document','window','location','performance','localStorage','devicePixelRatio','matchMedia','Image','ResizeObserver','MutationObserver','requestAnimationFrame','cancelAnimationFrame','setTimeout','clearTimeout'];
const saved=new Map(keys.map(k=>[k,Object.getOwnPropertyDescriptor(globalThis,k)]));
const elements=new Map(),frames=new Map(),timers=new Map(),actions=[];
let now=0,serial=0,sprites=[];
const rect={left:0,top:0,width:700,height:700};
const context=new Proxy({clearRect(){sprites=[];}},{get:(o,k)=>o[k]??(()=>{}),set:(o,k,v)=>(o[k]=v,true)});
function element(){
const listeners=new Map();
return {style:{setProperty(){}},dataset:{},classList:{add(){},remove(){},toggle(){},contains(){return false;}},
textContent:'',innerHTML:'',hidden:false,open:false,disabled:false,
addEventListener(name,fn){if(!listeners.has(name))listeners.set(name,[]);listeners.get(name).push(fn);},
emit(name,event={}){for(const fn of listeners.get(name)||[])fn(event);},
getBoundingClientRect:()=>rect,getContext:()=>context,setPointerCapture(){},
querySelector:()=>get('dialog-close'),querySelectorAll:()=>[element(),element(),element()],
insertBefore(){},setAttribute(){},focus(){},
showModal(){this.open=true;},close(){this.open=false;this.emit('close');},
click(){if(!this.disabled)this.onclick?.();},
};
}
function get(id){if(!elements.has(id))elements.set(id,element());return elements.get(id);}
get('board').parentElement=element();
const slots=new Map([['hex-trails-campaign-version','v0.1']]);
const storage={getItem:key=>slots.get(key)??null,setItem:(key,value)=>slots.set(key,value)};
const level=campaignVersion('v0.1').levels[0],view=layout(level.cells,700,700);
const originalTap=Game.prototype.tap,originalSprite=CanvasPainter.prototype.sprite;
const clickTile=id=>{
const p=center(level.tiles.find(t=>t.id===id),view),event={clientX:p.x,clientY:p.y,pointerId:1};
get('board').emit('pointerdown',event);get('board').emit('pointerup',event);
};
const advance=ms=>{
now+=ms;
const pending=[...frames.values()];frames.clear();for(const callback of pending)callback(now);
for(const [id,timer] of [...timers])if(timer.at<=now){timers.delete(id);timer.fn();}
};
const remaining=()=>Number(get('moves').textContent);
const reset=()=>{get('reset-button').click();actions.length=0;};
try{
const replacements={document:{getElementById:get,createElement:element,querySelectorAll:()=>[],addEventListener(){},body:element()},
window:{localStorage:storage},localStorage:storage,location:{search:''},performance:{now:()=>now},devicePixelRatio:1,
matchMedia:()=>({matches:false}),Image:class {},ResizeObserver:class {observe(){}},MutationObserver:class {observe(){}},
requestAnimationFrame:fn=>{frames.set(++serial,fn);return serial;},cancelAnimationFrame:id=>frames.delete(id),
setTimeout:(fn,delay)=>{timers.set(++serial,{fn,at:now+delay});return serial;},clearTimeout:id=>timers.delete(id)};
for(const [key,value] of Object.entries(replacements))Object.defineProperty(globalThis,key,{configurable:true,writable:true,value});
Game.prototype.tap=function(id){const result=originalTap.call(this,id);actions.push({id,status:result.status});return result;};
CanvasPainter.prototype.sprite=function(color,x,y,r){sprites.push({color,x,y,r});return true;};
await import('../src/app.js');
await t.test('three rapid taps all start before any frame finishes, without duplicate sprites',()=>{
const free=available(level).map(t=>t.id);assert.equal(free.length,3);
for(const id of free){clickTile(id);clickTile(id);}
assert.equal(remaining(),2,'all three logical departures happen immediately');
assert.deepEqual(actions,free.map(id=>({id,status:'exit'})));
advance(40);
assert.equal(sprites.length,5,'three moving tiles and two parked tiles remain visible');
for(const id of free){
const at=center(level.tiles.find(t=>t.id===id),view);
assert.ok(sprites.every(p=>Math.hypot(p.x-at.x,p.y-at.y)>1),'each departed tile has already moved');
}
assert.equal(get('undo-button').disabled,true);
advance(30000);get('undo-button').click();assert.equal(remaining(),3);
advance(30000);assert.equal(remaining(),3,'undo does not replay a queued tap');
});
await t.test('a newly unblocked tile can depart while its former blocker is still animating',()=>{
reset();clickTile('t0');clickTile('t1');
assert.deepEqual(actions,[{id:'t0',status:'exit'},{id:'t1',status:'exit'}]);
assert.equal(remaining(),3);
});
await t.test('failed probes keep their collision feedback and do not hold up other departures',()=>{
reset();clickTile('t1');clickTile('t1');clickTile('t2');clickTile('t4');
assert.deepEqual(actions,[{id:'t1',status:'blocked'},{id:'t2',status:'exit'},{id:'t4',status:'exit'}]);
assert.equal(remaining(),3);
advance(700);assert.equal(remaining(),3);assert.equal(sprites.length,3,'failed tile is drawn once alongside the two parked tiles');
advance(30000);get('undo-button').click();assert.equal(remaining(),4);
get('undo-button').click();assert.equal(remaining(),5);
get('undo-button').click();assert.equal(remaining(),5,'failed probe is undoable without removing a tile');
});
await t.test('reset cancels every active departure and pending frame',()=>{
reset();clickTile('t0');clickTile('t2');reset();advance(30000);
assert.equal(remaining(),5);assert.equal(get('result').open,false);assert.deepEqual(actions,[]);
assert.equal(frames.size,0);
});
await t.test('rotation holds input until tracks settle, then releases the whole burst immediately',()=>{
reset();get('rotate-right').click();clickTile('t3');clickTile('t2');
assert.deepEqual(actions,[]);
advance(300);
assert.equal(actions.length,2,'both clicks dispatch in the same frame after rotation');
reset();advance(30000);
});
await t.test('the result waits for every departure, and switching levels cancels old animations',()=>{
reset();
const plan=solveRotations(level);
for(const action of plan.actions){
if(action.type==='rotate'){advance(30000);get(action.direction===1?'rotate-right':'rotate-left').click();advance(300);}
else clickTile(action.id);
}
assert.equal(remaining(),0);assert.equal(get('result').open,false);
advance(30000);assert.equal(get('result').open,true);
reset();clickTile('t0');clickTile('t2');
get('dialog-content').emit('click',{target:{closest:selector=>selector==='[data-pick]'?{dataset:{pick:'1'}}:null}});
const next=campaignVersion('v0.1').levels[1];assert.equal(remaining(),next.tiles.length);
advance(30000);assert.equal(remaining(),next.tiles.length);assert.equal(get('result').open,false);
});
}finally{
Game.prototype.tap=originalTap;CanvasPainter.prototype.sprite=originalSprite;
for(const [key,descriptor] of saved){if(descriptor)Object.defineProperty(globalThis,key,descriptor);else delete globalThis[key];}
}
});