123 lines
7.6 KiB
JavaScript
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];}
|
|
}
|
|
});
|