86 lines
6.1 KiB
JavaScript
86 lines
6.1 KiB
JavaScript
import test from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import {build} from 'esbuild';
|
|
const result=await build({entryPoints:[new URL('../src/core/fleet/roverHoldInput.ts',import.meta.url).pathname],bundle:true,write:false,platform:'node',format:'esm'});
|
|
const {bindRoverHoldInput}=await import('data:text/javascript;base64,'+Buffer.from(result.outputFiles[0].contents).toString('base64'));
|
|
class Events {
|
|
listeners=new Map();timers=new Map();next=0;
|
|
addEventListener(type,fn){if(!this.listeners.has(type))this.listeners.set(type,new Set());this.listeners.get(type).add(fn);}
|
|
removeEventListener(type,fn){this.listeners.get(type)?.delete(fn);}
|
|
emit(type,detail={}){const e={target:this,isTrusted:true,preventDefault(){},...detail};for(const fn of [...(this.listeners.get(type)??[])])fn(e);}
|
|
setInterval(fn){const id=++this.next;this.timers.set(id,fn);return id;}
|
|
clearInterval(id){this.timers.delete(id);}
|
|
tick(){for(const fn of this.timers.values())fn();}
|
|
}
|
|
function fixture(mode='arcade'){
|
|
let time=0,focus=true;const win=new Events(),doc=new Events();doc.defaultView=win;doc.hidden=false;doc.hasFocus=()=>focus;
|
|
const scope={ownerDocument:doc,closest:()=>null},child={closest:()=>null};scope.contains=x=>x===scope||x===child;doc.activeElement=scope;
|
|
const states=[],demands=[];let stops=0,pauses=0;
|
|
const binding=bindRoverHoldInput(scope,mode,{held:k=>states.push([...k]),demand:d=>demands.push(d),stop:()=>stops++,pause:()=>{pauses++;demands.push({left:0,right:0});}},()=>time);
|
|
const key=(code,repeat=false)=>win.emit('keydown',{code,repeat,target:scope});
|
|
return {win,doc,scope,child,binding,states,demands,key,stops:()=>stops,pauses:()=>pauses,advance:ms=>{time+=ms;win.tick();},loseFocus:()=>{focus=false;},restoreFocus:()=>{focus=true;},up:code=>win.emit('keyup',{code,target:scope})};
|
|
}
|
|
test('blur clears D, ignores a late repeat, but accepts a fresh press without rearming',()=>{
|
|
const f=fixture();f.key('KeyD');assert.deepEqual(f.demands.at(-1),{left:1,right:-1});
|
|
f.win.emit('blur');assert.equal(f.pauses(),1);assert.equal(f.stops(),0);assert.deepEqual(f.states.at(-1),[]);
|
|
assert.deepEqual(f.demands.at(-1),{left:0,right:0});const count=f.demands.length;
|
|
f.key('KeyD',true);assert.equal(f.demands.length,count);
|
|
f.key('KeyW');assert.deepEqual(f.demands.at(-1),{left:1,right:1});assert.equal(f.binding.valid(),true);f.binding.dispose();
|
|
});
|
|
test('heartbeat validity catches missing blur event using document focus',()=>{
|
|
const f=fixture();f.key('KeyD');f.loseFocus();assert.equal(f.binding.valid(),false);assert.equal(f.pauses(),1);
|
|
f.restoreFocus();assert.equal(f.binding.valid(),true);f.binding.dispose();
|
|
});
|
|
test('focus polling catches missing event or moving to another control',()=>{
|
|
const f=fixture();f.key('KeyW');f.doc.activeElement={};f.advance(50);assert.equal(f.pauses(),1);f.binding.dispose();
|
|
});
|
|
test('lost keyup AND missing focus notifications still expire repeated D',()=>{
|
|
const f=fixture();f.key('KeyD');f.advance(500);f.key('KeyD',true);f.advance(299);assert.equal(f.pauses(),0);
|
|
f.advance(1);assert.equal(f.pauses(),1);assert.deepEqual(f.states.at(-1),[]);f.key('KeyD',true);assert.equal(f.pauses(),1);f.binding.dispose();
|
|
});
|
|
test('first press allows OS repeat delay but never an indefinite hold',()=>{
|
|
const f=fixture();f.key('KeyW');f.advance(999);assert.equal(f.pauses(),0);f.advance(1);assert.equal(f.pauses(),1);f.binding.dispose();
|
|
});
|
|
test('continuous repeat renews hold; keyup immediately sends neutral',()=>{
|
|
const f=fixture();f.key('KeyW');f.advance(600);
|
|
for(let i=0;i<30;i++){f.key('KeyW',true);f.advance(100);}
|
|
assert.equal(f.pauses(),0);f.up('KeyW');assert.deepEqual(f.demands.at(-1),{left:0,right:0});f.advance(1200);assert.equal(f.pauses(),0);f.binding.dispose();
|
|
});
|
|
test('diagonal input survives single-key OS repeat; release recomputes demand',()=>{
|
|
const f=fixture();f.key('KeyW');f.key('KeyA');
|
|
for(let i=0;i<20;i++){f.key('KeyA',true);f.advance(100);}
|
|
assert.equal(f.pauses(),0);assert.deepEqual(f.demands.at(-1),{left:0,right:1});
|
|
f.up('KeyA');assert.deepEqual(f.demands.at(-1),{left:1,right:1});f.up('KeyW');f.binding.dispose();
|
|
});
|
|
test('repeat without a fresh press and untrusted events never command motion',()=>{
|
|
const f=fixture();f.key('KeyD',true);f.win.emit('keydown',{code:'KeyW',isTrusted:false,target:f.scope});assert.equal(f.demands.length,0);f.binding.dispose();
|
|
});
|
|
test('focus inside the view is allowed; leaving or hiding it stops',()=>{
|
|
for(const event of ['focusout','visibilitychange','pagehide','outside']){
|
|
const f=fixture();f.key('KeyW');f.doc.emit('focusout',{target:f.scope,relatedTarget:f.child});assert.equal(f.pauses(),0);
|
|
if(event==='focusout')f.doc.emit(event,{target:f.scope,relatedTarget:null});
|
|
else if(event==='visibilitychange'){f.doc.hidden=true;f.doc.emit(event);}
|
|
else if(event==='pagehide')f.win.emit(event);
|
|
else f.doc.emit('pointerdown',{target:{}});
|
|
assert.equal(event==='pagehide'?f.stops():f.pauses(),1,event);f.binding.dispose();
|
|
}
|
|
});
|
|
test('pointer capture lost or cancelled releases every input',()=>{
|
|
for(const mode of ['arcade','tank']){
|
|
const f=fixture(mode);f.binding.pointerDown(1,mode==='arcade'?'KeyW':'KeyQ');f.binding.pointerCancel(1);
|
|
assert.equal(f.pauses(),1);assert.deepEqual(f.states.at(-1),[]);f.binding.dispose();
|
|
}
|
|
});
|
|
test('document pointerup releases even if button handler does not receive it',()=>{
|
|
const f=fixture();f.binding.pointerDown(1,'KeyD');f.doc.emit('pointerup',{pointerId:1});assert.deepEqual(f.demands.at(-1),{left:0,right:0});assert.equal(f.pauses(),0);f.binding.dispose();
|
|
});
|
|
test('modifier shortcuts pause; cleanup removes listeners, timer and held demand',()=>{
|
|
const f=fixture();f.key('KeyD');f.win.emit('keydown',{code:'Tab',metaKey:true,target:f.scope});assert.equal(f.pauses(),1);f.binding.dispose();
|
|
assert.deepEqual(f.demands.at(-1),{left:0,right:0});assert.equal(f.win.timers.size,0);
|
|
assert.equal([...f.win.listeners.values(),...f.doc.listeners.values()].reduce((sum,x)=>sum+x.size,0),0);
|
|
});
|
|
|
|
test('Space and Escape remain explicit session stops',()=>{
|
|
for(const code of ['Space','Escape']){const f=fixture();f.key('KeyW');f.key(code);assert.equal(f.stops(),1);assert.equal(f.binding.valid(),false);f.binding.dispose();}
|
|
});
|