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];} } });