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:imotorDemands({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')); });