feat(control-station): add rover scene telemetry and guarded keyboard control

This commit is contained in:
DCCONSTRUCTIONS
2026-09-25 16:37:57 +03:00
parent 63cbb08ea0
commit 6d772b29fe
36 changed files with 3536 additions and 21 deletions
@@ -0,0 +1,57 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {readFileSync} from 'node:fs';
import ts from 'typescript';
const code=ts.transpileModule(readFileSync(new URL('../../../packages/sensor-ui/src/boardLayout.ts',import.meta.url),'utf8'),{compilerOptions:{module:ts.ModuleKind.ESNext,target:ts.ScriptTarget.ES2022}}).outputText;
const {createBoardLayoutStore,defaultBoardLayout}=await import('data:text/javascript;base64,'+Buffer.from(code).toString('base64'));
const tick=()=>new Promise(resolve=>setImmediate(resolve));
function server(){
let value=structuredClone(defaultBoardLayout);const calls=[];
return {calls,read:async()=>structuredClone(value),patch:async(section,open)=>{
calls.push({section,open});await tick();
value={...value,revision:value.revision+1,open_sections:defaultBoardLayout.open_sections.filter(id=>id===section?open:value.open_sections.includes(id))};
return structuredClone(value);
}};
}
test('rapid toggles persist after navigation and preserve all-closed state',async()=>{
const api=server();const store=createBoardLayoutStore(api);await store.load();
const unsubscribe=store.subscribe(()=>{});
store.change(['settings','devices']);store.change(['devices']);store.change([]);
unsubscribe();
while(store.getSnapshot().saving)await tick();
const reopened=createBoardLayoutStore(api);await reopened.load();
assert.deepEqual(reopened.getSnapshot().value.open_sections,[]);
assert.equal(api.calls.length,3);
});
test('a later toggle wins while an older patch is still in flight',async()=>{
const api=server();const store=createBoardLayoutStore(api);await store.load();
store.change(['settings','devices']);store.change(['computer','settings','devices']);
while(store.getSnapshot().saving)await tick();
assert.deepEqual((await api.read()).open_sections,defaultBoardLayout.open_sections);
});
test('different vehicles and simultaneous section changes do not clobber one another',async()=>{
const api=server(),other=server();const a=createBoardLayoutStore(api),b=createBoardLayoutStore(api),c=createBoardLayoutStore(other);
await Promise.all([a.load(),b.load(),c.load()]);
a.change(['settings','devices']);b.change(['computer','devices']);
while(a.getSnapshot().saving||b.getSnapshot().saving)await tick();
assert.deepEqual((await api.read()).open_sections,['devices']);
assert.deepEqual(c.getSnapshot().value.open_sections,defaultBoardLayout.open_sections);
});
test('failed save reports failure, rolls back, and explicit reload recovers',async()=>{
const api=server();let fail=true;
const store=createBoardLayoutStore({...api,patch:(...args)=>fail?Promise.reject(new Error('offline')):api.patch(...args)});
await store.load();store.change([]);
while(store.getSnapshot().saving)await tick();
assert.ok(store.getSnapshot().error);
assert.deepEqual(store.getSnapshot().value.open_sections,defaultBoardLayout.open_sections);
fail=false;await store.load();store.change(['devices']);
while(store.getSnapshot().saving)await tick();
assert.equal(store.getSnapshot().error,null);
assert.deepEqual((await api.read()).open_sections,['devices']);
});
test('invalid layout cannot overwrite stored preferences',async()=>{
let writes=0;
const store=createBoardLayoutStore({read:async()=>({...defaultBoardLayout,open_sections:['motor-start']}),patch:async()=>{writes++;return defaultBoardLayout;}});
await store.load();store.change([]);
assert.equal(store.getSnapshot().ready,false);assert.ok(store.getSnapshot().error);assert.equal(writes,0);
});
@@ -0,0 +1,157 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {build} from 'esbuild';
async function moduleAt(path) {
const result = await build({entryPoints:[new URL(path,import.meta.url).pathname],bundle:true,write:false,format:'esm',platform:'node'});
return import('data:text/javascript;base64,'+Buffer.from(result.outputFiles[0].contents).toString('base64'));
}
const {defaultProfile:base,mix,motorDemands,parseProfile}=await moduleAt('../../../packages/rover-control/src/profile.ts');
const {AuthorityModel}=await moduleAt('../../../packages/rover-control/src/authority.ts');
const axes=(leftY=0,rightY=0,leftX=0,rightX=0)=>({leftY,rightY,leftX,rightX});
const profile={...base,deadband:0};
const arcade={...profile,mode:'arcade'};
const policy={maxAgeMs:100,neutralMs:200,maxCommandMs:100,monitoredAxes:['leftY','rightY','leftX','rightX']};
const motors=['a','b','c','d'];
function frame(now,values=axes(),extra={}) {
return {now,link:{state:'live',at:now},
axes:Object.fromEntries(Object.entries(values).map(([key,value])=>[key,{value,at:now,sequence:now}])),
drives:Object.fromEntries(motors.map(id=>[id,{at:now,healthy:true,stopped:true}])),...extra};
}
function ready(p=base) {
const model=new AuthorityModel(p,motors,policy,'boot-A');
for(let t=0;t<=200;t+=50)model.step(frame(t));
return model;
}
function core() {
const model=ready();
const {token}=model.step(frame(250,axes(),{requestCore:{owner:'autonomy',sequence:1,at:250}}));
assert.ok(token);return {model,token};
}
const command=(token,at,sequence=0)=>({token,at,expires:at+75,sequence,demand:{left:.5,right:.5}});
test('tank keeps the two motor sides independent, including reversal',()=>{
assert.deepEqual(mix(profile,axes(.8,-.4)),{left:.8,right:-.4});
assert.deepEqual(mix(profile,axes(0,.4)),{left:0,right:.4});
});
test('arcade cardinal, diagonal and reverse vectors preserve yaw sign',()=>{
for(const [y,x,left,right] of [[0,0,0,0],[1,0,1,1],[-1,0,-1,-1],[0,1,1,-1],[0,-1,-1,1],[1,1,1,0],[1,-1,0,1],[-1,1,0,-1],[-1,-1,-1,0]])
assert.deepEqual(mix(arcade,axes(0,y,0,x)),{left,right});
assert.deepEqual(mix(arcade,axes(0,.5,0,.5)),{left:.5,right:0});
});
test('selected stick defines the axes, not the number or position of motors',()=>{
assert.deepEqual(mix({...arcade,stick:'left'},axes(.5,-1,.5,-1)),{left:.5,right:0});
});
test('deadband is continuous and rescaled; response and output scale are separate',()=>{
assert.deepEqual(mix(base,axes(-.064,.074)),{left:0,right:0});
assert.deepEqual(mix({...profile,deadband:.2,response:'squared',outputScale:.5},axes(.6,-1)),{left:.12499999999999997,right:-.5});
assert.ok(Math.abs(mix(base,axes(.150001)).left)<.000002);
});
test('every point in the input square stays bounded and changes sign symmetrically',()=>{
for(let i=-20;i<=20;i++)for(let j=-20;j<=20;j++){
const a=mix({...arcade,outputScale:.8},axes(0,i/20,0,j/20));
const b=mix({...arcade,outputScale:.8},axes(0,-i/20,0,-j/20));
assert.ok(Math.abs(a.left)<=.8+1e-12 && Math.abs(a.right)<=.8+1e-12);
assert.ok(Math.abs(a.left+b.left)<1e-12 && Math.abs(a.right+b.right)<1e-12);
}
});
test('profile JSON rejects unknown fields, malformed numbers and unknown schema',()=>{
assert.deepEqual(parseProfile(JSON.parse(JSON.stringify(base))),base);
for(const bad of [null,[],{...base,schema:'future'},{...base,revision:1.5},{...base,revision:-1},{...base,deadband:NaN},{...base,outputScale:1.1},{...base,writeVesc:true}])assert.throws(()=>parseProfile(bad));
for(const value of [NaN,Infinity,1.01,undefined])assert.throws(()=>mix(profile,{...axes(),leftY:value}));
});
test('2, 4 and 10 motors route by identity and explicit direction, never USB position',()=>{
for(const n of [2,4,10]){
const bindings=Array.from({length:n},(_,i)=>({uuid:i.toString(16).padStart(24,'0'),side:i<n/2?'left':'right',forwardSign:i%2?1:-1}));
const result=motorDemands({left:.3,right:-.6},bindings);
assert.equal(Object.keys(result).length,n);
assert.deepEqual(result,motorDemands({left:.3,right:-.6},bindings.toReversed()));
}
const b={uuid:'0'.repeat(24),side:'left',forwardSign:1};
assert.throws(()=>motorDemands({left:1,right:1},[b]));
assert.throws(()=>motorDemands({left:1,right:1},[b,{...b,side:'right'}]));
});
test('boot with held stick never permits motion; stable stopped neutral required',()=>{
const model=new AuthorityModel(base,motors,policy,'boot');
for(let t=0;t<1000;t+=50){const d=model.step(frame(t,axes(1)));assert.equal(d.state,'hold');assert.deepEqual(d.demand,{left:0,right:0});}
for(let t=1000;t<1200;t+=50)assert.equal(model.step(frame(t)).state,'hold');
assert.equal(model.step(frame(1200)).state,'rc-ready');
assert.equal(model.step(frame(1250,axes(1))).state,'rc-manual');
});
test('first RC gesture stops Core immediately, held packets never count as second gesture',()=>{
const {model,token}=core();
assert.equal(model.step(frame(300,axes(),{command:command(token,300)})).state,'core');
const first=model.step(frame(350,axes(.5),{command:command(token,350,1)}));
assert.equal(first.reason,'rc-takeover');assert.ok(first.stopAll&&first.flushMotionQueue&&first.cancelMotionTasks);
for(let t=400;t<1500;t+=50)assert.deepEqual(model.step(frame(t,axes(.5))).demand,{left:0,right:0});
for(let t=1500;t<=1700;t+=50)model.step(frame(t));
const second=model.step(frame(1750,axes(.5)));
assert.equal(second.state,'rc-manual');assert.ok(second.demand.left>0);
assert.equal(model.step(frame(1800)).state,'rc-manual');
assert.equal(model.step(frame(1850,axes(0,.5),{command:command(token,1850,2)})).state,'rc-manual');
});
test('neutral of one channel or a single moving member never completes rearming',()=>{
for(const kind of ['axis','drive']){
const {model}=core();model.step(frame(300,axes(1)));
for(let t=350;t<=1500;t+=50){const f=frame(t,kind==='axis'?axes(0,.2):axes());if(kind==='drive')f.drives.d.stopped=false;assert.equal(model.step(f).state,'hold');}
}
});
test('loss of any of four controllers stops all sides and invalidates Core',()=>{
for(const id of motors){const {model,token}=core();const f=frame(300,axes(),{command:command(token,300)});delete f.drives[id];const d=model.step(f);assert.equal(d.reason,'drive-unverified');assert.ok(d.cancelMotionTasks);assert.deepEqual(d.demand,{left:0,right:0});}
});
test('unknown/lost radio cannot be treated as neutral even with fresh zero PWM reads',()=>{
for(const state of ['lost','unknown']){const {model,token}=core();const d=model.step(frame(300,axes(),{link:{state,at:300},command:command(token,300)}));assert.equal(d.reason,'receiver-unverified');assert.ok(d.stopAll);}
});
test('old decoded PPM, future samples, modified repeats and sequence rollback are rejected',()=>{
for(const patch of [{at:0},{at:351},{sequence:249},{value:.4,at:250,sequence:250},{value:NaN}]){
const {model}=core();const f=frame(350);Object.assign(f.axes.leftY,patch);assert.equal(model.step(f).reason,'axis-invalid');
}
});
test('one repeated neutral sample cannot qualify stable neutral',()=>{
const model=new AuthorityModel(base,motors,{...policy,neutralMs:50},'boot');
model.step(frame(0));const f=frame(50);f.axes.leftY={at:0,sequence:0,value:0};
assert.equal(model.step(f).state,'hold');
});
test('receiver reconnect while held stays stopped until neutral and a new gesture',()=>{
const model=ready();model.step(frame(250,axes(.5)));model.step(frame(300,axes(),{link:{state:'lost',at:300}}));
for(let t=350;t<1000;t+=50)assert.equal(model.step(frame(t,axes(.5))).state,'hold');
for(let t=1000;t<=1200;t+=50)model.step(frame(t));
assert.equal(model.step(frame(1250,axes(.5))).state,'rc-manual');
});
test('Core expiry/replay/wrong boot token/missing commands all require rearming',()=>{
for(const change of [c=>({...c,expires:300}),c=>({...c,expires:500}),c=>({...c,token:'old-boot:1'}),()=>undefined,c=>({...c,demand:{left:Infinity,right:0}})]){
const {model,token}=core();assert.equal(model.step(frame(300,axes(),{command:change(command(token,300))})).reason,'core-command-invalid');
}
const {model,token}=core();model.step(frame(300,axes(),{command:command(token,300,1)}));
assert.equal(model.step(frame(350,axes(),{command:command(token,350,1)})).reason,'core-command-invalid');
});
test('clock rewind and missing observation interval cannot bypass the stop gate',()=>{
for(const time of [249,500,NaN]){const {model,token}=core();const d=model.step(frame(time,axes(),{command:command(token,time)}));assert.equal(d.state,'hold');assert.ok(d.cancelMotionTasks);}
});
test('premature or replayed Core request does not acquire authority after neutral',()=>{
const model=new AuthorityModel(base,motors,policy,'boot');
for(let t=0;t<=300;t+=50){const d=model.step(frame(t,axes(),{requestCore:{owner:'remote',sequence:1,at:t}}));assert.notEqual(d.state,'core');}
assert.equal(model.step(frame(350,axes(),{requestCore:{owner:'remote',sequence:2,at:350}})).state,'core');
});
test('reboot cannot restore authority from serialized profile or old grant',()=>{
const {token}=core(), model=ready();
assert.equal(model.step(frame(250,axes(),{command:command(token,250)})).state,'rc-ready');
const next=new AuthorityModel(base,motors,policy,'boot-B');
for(let t=0;t<=200;t+=50)next.step(frame(t));
const grant=next.step(frame(250,axes(),{requestCore:{owner:'remote',sequence:1,at:250}}));
assert.notEqual(grant.token,token);
assert.equal(next.step(frame(300,axes(),{command:command(token,300)})).state,'hold');
});
test('profile limits do not mask RC takeover intent at zero output scale',()=>{
const model=ready({...base,outputScale:0});const grant=model.step(frame(250,axes(),{requestCore:{owner:'remote',sequence:1,at:250}}));
assert.ok(grant.token);assert.equal(model.step(frame(300,axes(.5))).reason,'rc-takeover');
});
test('non-driving stick still takes over in Arcade and every monitored axis must return',()=>{
const model=ready({...base,mode:'arcade',stick:'right'});
model.step(frame(250,axes(),{requestCore:{owner:'autonomy',sequence:1,at:250}}));
assert.equal(model.step(frame(300,axes(.5))).reason,'rc-takeover');
for(let t=350;t<800;t+=50)assert.equal(model.step(frame(t,axes(0,0,.5))).state,'hold');
const f=frame(800);delete f.axes.leftX;assert.equal(model.step(f).reason,'axis-invalid');
assert.throws(()=>new AuthorityModel(base,motors,{...policy,monitoredAxes:['leftY']},'boot'));
});
@@ -0,0 +1,49 @@
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/useRoverControl.ts',import.meta.url).pathname],bundle:true,write:false,platform:'node',format:'esm',plugins:[{name:'hook-fixture',setup(b){
b.onResolve({filter:/^react$/},()=>({path:'react',namespace:'fixture'}));
b.onLoad({filter:/.*/,namespace:'fixture'},()=>({contents:`export const useRef=v=>({current:v});export const useState=v=>[v,()=>{}];export const useCallback=f=>f;export const useEffect=f=>globalThis.roverEffects.push(f);`}));
}}]});
const {useRoverControl}=await import('data:text/javascript;base64,'+Buffer.from(result.outputFiles[0].contents).toString('base64'));
const flush=()=>new Promise(resolve=>setImmediate(resolve));
async function fixture(run){
const originals=Object.fromEntries(['window','document','fetch','roverEffects','setInterval','clearInterval'].map(k=>[k,globalThis[k]]));
let focus=true,hidden=false;const calls=[],timers=[];globalThis.roverEffects=[];
globalThis.window={addEventListener(){},removeEventListener(){}};
globalThis.document={get hidden(){return hidden;},hasFocus:()=>focus,addEventListener(){},removeEventListener(){}};
globalThis.setInterval=(f,ms)=>{const timer={f,ms};timers.push(timer);return timer;};globalThis.clearInterval=()=>{};
globalThis.fetch=async(url,init)=>{const body=init.body?JSON.parse(init.body):null;calls.push({url,body});return {ok:true,json:async()=>url.endsWith('/arm')?{session_id:'fixture-session'}:{fresh:true,controlling:true,snapshot:{state:'ready'}}};};
const c=useRoverControl('fixture',true);const cleanup=globalThis.roverEffects.map(f=>f());
try{await flush();await c.arm(30,2000);await flush();await run({c,calls,timers,blur:()=>{focus=false;},focus:()=>{focus=true;},hide:()=>{hidden=true;}});}
finally{cleanup.forEach(f=>f?.());await flush();for(const [key,value]of Object.entries(originals)){if(value===undefined)delete globalThis[key];else globalThis[key]=value;}}
}
test('command heartbeat sends neutral on lost blur, keeps session, and accepts new focused input',async()=>fixture(async f=>{
f.c.setInputGuard(()=>true);f.c.setDemand({left:1,right:-1});await flush();
assert.equal(f.calls.at(-1).body.left,1);f.blur();f.timers.find(t=>t.ms===100).f();await flush();
assert.equal(f.calls.at(-1).body.stop,false);assert.equal(f.calls.at(-1).body.left,0);
f.c.setDemand({left:1,right:1});await flush();assert.equal(f.calls.at(-1).body.left,0);
f.focus();f.timers.find(t=>t.ms===100).f();await flush();assert.equal(f.calls.at(-1).body.left,0);
f.c.setDemand({left:1,right:1});await flush();assert.equal(f.calls.at(-1).body.left,1);
assert.equal(f.calls.filter(x=>x.url.endsWith('/arm')).length,1);
}));
test('expired physical input prevents heartbeat from renewing remembered demand',async()=>fixture(async f=>{
let valid=true;f.c.setInputGuard(()=>valid);f.c.setDemand({left:1,right:-1});await flush();valid=false;
f.timers.find(t=>t.ms===100).f();await flush();assert.equal(f.calls.at(-1).body.stop,false);
}));
test('no registered input scope cannot issue motion',async()=>fixture(async f=>{
f.c.setDemand({left:1,right:1});await flush();assert.equal(f.calls.at(-1).body.stop,false);
assert.equal(f.calls.some(x=>x.body?.left===1),false);
}));
test('hidden document sends only neutral without a visibility event',async()=>fixture(async f=>{
f.c.setInputGuard(()=>true);f.hide();f.c.setDemand({left:1,right:1});await flush();
assert.equal(f.calls.at(-1).body.stop,false);assert.equal(f.calls.some(x=>x.body?.left===1),false);
}));
test('explicit pause clears demand without revoking session; explicit stop still revokes it',async()=>fixture(async f=>{
f.c.setInputGuard(()=>true);f.c.setDemand({left:1,right:-1});await flush();f.c.pauseInput();await flush();
assert.equal(f.calls.at(-1).body.stop,false);assert.equal(f.calls.at(-1).body.left,0);
f.c.setDemand({left:1,right:1});await flush();assert.equal(f.calls.at(-1).body.left,1);
f.c.stop();await flush();assert.equal(f.calls.at(-1).body.stop,true);
const count=f.calls.length;f.c.setDemand({left:1,right:1});await flush();assert.equal(f.calls.length,count);
}));
@@ -0,0 +1,49 @@
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/useRoverControl.ts',import.meta.url).pathname],bundle:true,write:false,platform:'node',format:'esm',plugins:[{name:'hook-fixture',setup(b){
b.onResolve({filter:/^react$/},()=>({path:'react',namespace:'fixture'}));
b.onLoad({filter:/.*/,namespace:'fixture'},()=>({contents:`export const useRef=v=>({current:v});export const useState=v=>[v,()=>{}];export const useCallback=f=>f;export const useEffect=f=>globalThis.roverEffects.push(f);`}));
}}]});
const {useRoverControl}=await import('data:text/javascript;base64,'+Buffer.from(result.outputFiles[0].contents).toString('base64'));
const flush=()=>new Promise(resolve=>setImmediate(resolve));
const deferred=()=>{let resolve;const promise=new Promise(r=>resolve=r);return {promise,resolve};};
const response=value=>({ok:true,json:async()=>value});
const idle={fresh:true,controlling:false,snapshot:{supported:true,state:'observing'}};
async function fixture(run){
const originals=Object.fromEntries(['window','document','fetch','roverEffects','setInterval','clearInterval'].map(k=>[k,globalThis[k]]));
const calls=[],timers=[];globalThis.roverEffects=[];
globalThis.window={addEventListener(){},removeEventListener(){}};globalThis.document={hidden:false,hasFocus:()=>true,addEventListener(){},removeEventListener(){}};
globalThis.setInterval=(f,ms)=>{timers.push({f,ms});return timers.length;};globalThis.clearInterval=()=>{};
let armResponse=null,nextPoll=null;
globalThis.fetch=async(url,init)=>{const body=init.body?JSON.parse(init.body):null;calls.push({url,body});
if(url.endsWith('/arm'))return armResponse?armResponse.promise:response({session_id:'new-session'});
if(!body)return nextPoll?nextPoll.promise:response(idle);
return response({accepted_sequence:body.sequence});
};
const c=useRoverControl('fixture',true),cleanup=globalThis.roverEffects.map(f=>f());
try{await flush();await run({c,calls,poll:()=>timers.find(t=>t.ms===200).f(),heartbeat:()=>timers.find(t=>t.ms===100).f(),delayArm:()=>armResponse=deferred(),delayPoll:()=>nextPoll=deferred()});}
finally{cleanup.forEach(f=>f?.());await flush();for(const [key,value]of Object.entries(originals)){if(value===undefined)delete globalThis[key];else globalThis[key]=value;}}
}
test('idle poll begun during pending arm cannot revoke newly acknowledged session',async()=>fixture(async f=>{
const a=f.delayArm();const arming=f.c.arm(30,2000);const p=f.delayPoll();f.poll();
a.resolve(response({session_id:'new-session'}));assert.equal(await arming,true);await flush();
p.resolve(response(idle));await flush();f.heartbeat();await flush();
assert.equal(f.calls.filter(x=>x.body?.stop===true).length,0,'old idle poll revoked the new session');
assert.equal(f.calls.at(-1).body.session_id,'new-session');
}));
test('failed poll begun during pending arm cannot revoke newly acknowledged session',async()=>fixture(async f=>{
const a=f.delayArm();const arming=f.c.arm(30,2000);const p=f.delayPoll();f.poll();
a.resolve(response({session_id:'new-session'}));assert.equal(await arming,true);await flush();
p.resolve({ok:false,json:async()=>({detail:'stale read failure'})});await flush();
assert.equal(f.calls.filter(x=>x.body?.stop===true).length,0,'old failed poll revoked the new session');
}));
test('a current poll still revokes control when server reports lease lost',async()=>fixture(async f=>{
assert.equal(await f.c.arm(30,2000),true);await flush();f.poll();await flush();
assert.equal(f.calls.at(-1).body.stop,true);
}));
test('duplicate arm clicks while request pending create exactly one lease request',async()=>fixture(async f=>{
const a=f.delayArm();const first=f.c.arm(30,2000),second=f.c.arm(30,2000);
a.resolve(response({session_id:'new-session'}));await Promise.all([first,second]);await flush();
assert.equal(f.calls.filter(x=>x.url.endsWith('/arm')).length,1);
}));
@@ -0,0 +1,85 @@
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();}
});
@@ -0,0 +1,23 @@
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/roverInput.ts',import.meta.url).pathname],bundle:true,write:false,platform:'node',format:'esm'});
const {keyDemand,roverSettings}=await import('data:text/javascript;base64,'+Buffer.from(result.outputFiles[0].contents).toString('base64'));
const keys=(...k)=>new Set(k.map(v=>'Key'+v));
test('arcade mixes directions and opposite keys cancel',()=>{
assert.deepEqual(keyDemand('arcade',keys('W')),{left:1,right:1});
assert.deepEqual(keyDemand('arcade',keys('A')),{left:-1,right:1});
assert.deepEqual(keyDemand('arcade',keys('W','A')),{left:0,right:1});
assert.deepEqual(keyDemand('arcade',keys('W','S','A','D')),{left:0,right:0});
});
test('tank sides and backwards remain independent',()=>{
assert.deepEqual(keyDemand('tank',keys('Q','D')),{left:1,right:-1});
assert.deepEqual(keyDemand('tank',keys('A','D')),{left:-1,right:-1});
assert.deepEqual(keyDemand('tank',keys('Q','A')),{left:0,right:0});
});
test('invalid stored settings never grant motion or invalid limits',()=>{
assert.equal(roverSettings({version:1,currentA:Infinity}).currentA,30);
assert.equal(roverSettings({version:1,maxErpm:999999}).maxErpm,3000);
assert.equal(roverSettings({version:1,model:'https://other/model.glb'}).model,'');
assert.deepEqual(keyDemand('arcade',new Set()),{left:0,right:0});
});
+70
View File
@@ -0,0 +1,70 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {readFileSync} from 'node:fs';
import ts from 'typescript';
const source=readFileSync(new URL('../../../plugins/vesc/frontend/src/model.ts',import.meta.url),'utf8');
const code=ts.transpileModule(source,{compilerOptions:{module:ts.ModuleKind.ESNext}}).outputText;
const {vescLabel,testInput,speedInput,rotationResult,driveTestIds}=await import('data:text/javascript;base64,'+Buffer.from(code).toString('base64'));
const controller={online:true,prepared:true,verified:true,vesc_status:{identity:{uuid:'synthetic'},readable:true}};
test('speed mode requires board capability and reports measured rotation time',()=>{
assert.ok(speedInput('1200',undefined).error);
const bounds={min_erpm:300,max_erpm:3000,duration_basis:'measured_speed'};
assert.equal(speedInput('1200',bounds).error,null);
for(const value of ['', 'NaN','299','3001'])assert.ok(speedInput(value,bounds).error);
const text=rotationResult({outcome:'stopped',rotation_s:2.5,release_confirmed:true,limits_restored:true});
assert.match(text,/2,5 с/);
assert.match(text,/Проверка остановлена/);
assert.doesNotMatch(text,/время вращения набрано/);
});
test('USB discovery and model preparation never claim a verified VESC',()=>{
assert.equal(vescLabel({...controller,prepared:false},true).label,'Требуется подготовка');
assert.equal(vescLabel({...controller,vesc_status:{identity:null,readable:false}},true).tone,'warning');
});
test('fresh read capability is distinct from unsupported firmware and offline state',()=>{
assert.equal(vescLabel(controller,true).label,'Готов к чтению');
assert.equal(vescLabel(controller,false).label,'Нет связи');
assert.equal(vescLabel({...controller,online:false},true).label,'Нет связи');
assert.equal(vescLabel({...controller,vesc_status:{identity:{uuid:'synthetic'},readable:false}},true).label,'Прошивка не поддерживается');
});
test('test fields accept entered values only inside the board capability bounds',()=>{
const bounds={min_current_a:0.5,max_current_a:5,min_duration_s:0.5,max_duration_s:10};
assert.equal(testInput('3.7','7.2',bounds).valid,true);
assert.equal(testInput('5','10',bounds).valid,true);
for(const [amps,seconds] of [['','5'],['5',''],['60','10'],['5','11'],['NaN','1'],['Infinity','1']])assert.equal(testInput(amps,seconds,bounds).valid,false);
assert.equal(testInput('2','1.5',undefined).valid,false);
const overlong=testInput('5','15',bounds);
assert.equal(overlong.valid,false);
assert.equal(overlong.currentError,null);
assert.equal(overlong.durationError,'Длительность должна быть от 0,5 до 10 с.');
assert.equal(testInput('5','10',bounds).durationError,null);
});
// Capabilities come from the installed board: a newer Core must retain old bounds.
test('extended board accepts 30 A / 30 s without changing older board bounds',()=>{
const old={min_current_a:0.5,max_current_a:5,min_duration_s:0.5,max_duration_s:10};
const expanded={...old,max_current_a:30,max_duration_s:30,continuous_current:true};
assert.equal(testInput('30','30',expanded).valid,true);
assert.equal(testInput('30.1','30',expanded).valid,false);
assert.equal(testInput('30','30.1',expanded).valid,false);
assert.equal(testInput('30','30',old).valid,false);
});
test('group spin requires a complete unique profile containing the selected controller',()=>{
const profile={layout:'1x1',revision:3,bindings:{'left.1':{device_id:'left',uuid:'a'},'right.1':{device_id:'right',uuid:'b'}}};
assert.deepEqual(driveTestIds(profile,'left'),['left','right']);
assert.deepEqual(driveTestIds(profile,'unassigned'),[]);
assert.deepEqual(driveTestIds({...profile,bindings:{'left.1':profile.bindings['left.1']}},'left'),[]);
assert.deepEqual(driveTestIds({...profile,bindings:{...profile.bindings,'right.1':profile.bindings['left.1']}},'left'),[]);
assert.deepEqual(driveTestIds(undefined,'left'),[]);
});
test('four-wheel profile preserves front and rear membership for common testing',()=>{
const ids=['lf','lr','rf','rr'];
const bindings=Object.fromEntries(['left.1','left.2','right.1','right.2'].map((slot,i)=>[slot,{device_id:ids[i],uuid:ids[i]}]));
assert.deepEqual(driveTestIds({layout:'2x2',revision:4,bindings},'rf'),ids);
});