158 lines
10 KiB
JavaScript
158 lines
10 KiB
JavaScript
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'));
|
|
});
|