feat(fleet): preserve operator VESC integration before final driver merge

This commit is contained in:
DCCONSTRUCTIONS
2026-09-25 16:40:46 +03:00
parent dad11b47d7
commit 71a648fec8
128 changed files with 22390 additions and 59 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,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();}
});
+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);
});