Files
NODEDC_MISSION_CORE/apps/control-station/test/planningSceneStream.test.mjs
T
DCCONSTRUCTIONS e515ab1b8c feat(planning): consolidate recorded-route localization and spatial scene
Preserve the completed teach-and-repeat laboratory stage: reference preparation, cascaded acquisition, local tracking and recovery, recording lifecycle, replay qualification, and persistent Rerun scene controls. Document the open grid-picking regression and Rerun upgrade contract. No autonomous driving or loop-closure optimization is claimed.
2026-09-21 08:47:19 +03:00

115 lines
6.3 KiB
JavaScript

import assert from 'node:assert/strict';
import {before,after,test} from 'node:test';
import {createServer} from 'vite';
import {readFileSync} from 'node:fs';
let server,start;
before(async()=>{server=await createServer({appType:'custom',logLevel:'silent',server:{middlewareMode:true}});({startPlanningSceneStream:start}=await server.ssrLoadModule('/src/core/missions/planningSceneStream.ts'));});
after(async()=>{await server?.close();});
const settle=()=>new Promise(resolve=>setImmediate(resolve));
function fixture(){
let sequence=0,state={active:true,revision:1,options:{mode:'3d'}},clock=0;
const timers=new Map(),requests=[],applied=[],errors=[];
const stop=start({url:'/scene',snapshot:()=>state,now:()=>clock,
schedule:(fn,ms)=>{const id=++sequence;timers.set(id,{fn,ms});return id;},cancel:id=>timers.delete(id),
fetcher:(url,init)=>new Promise((resolve,reject)=>requests.push({url,init,resolve,reject})),
apply:bytes=>applied.push(bytes),error:()=>errors.push(true)});
return {timers,requests,applied,errors,stop,setState:value=>{state=value;},setClock:value=>{clock=value;},
next:()=>{const [id,timer]=[...timers].find(([,t])=>t.ms!==2500);timers.delete(id);timer.fn();},
answer:(index,cursor='next',status=200)=>requests[index].resolve(new Response(status===204?null:new Uint8Array([1,2]),{status,headers:{'X-Planning-Scene-Cursor':cursor}}))};
}
test('one pending fetch, native cadence, no-op response and cancellation',async()=>{
const f=fixture();assert.equal(f.requests.length,1);
assert.equal(f.timers.size,1); // deadline only: no overlapping polling interval
f.setClock(35);f.answer(0);await settle();
assert.equal(f.applied.length,1);assert.equal([...f.timers.values()][0].ms,65);
f.next();assert.match(f.requests[1].url,/cursor=next/);
f.answer(1,'next',204);await settle();assert.equal(f.applied.length,1);
f.next();f.stop();assert.equal(f.requests[2].init.signal.aborted,true);
f.answer(2);await settle();assert.equal(f.applied.length,1);assert.equal(f.timers.size,0);
});
test('a changed mode or ended run discards the in-flight response and rebases',async()=>{
for(const next of [{active:true,revision:2,options:{mode:'top'}},{active:false,revision:2,options:{mode:'3d'}}]){
const f=fixture();f.setState(next);f.answer(0);await settle();
assert.equal(f.applied.length,0);f.next();assert.match(f.requests[1].url,/base=true/);
f.answer(1);await settle();assert.equal(f.applied.length,1);f.stop();
}
});
test('failure hides stale evidence, repairs geometry but retains admitted camera cursor',async()=>{
const f=fixture();f.answer(0);await settle();f.next();
f.requests[1].reject(new Error('offline'));await settle();
assert.equal(f.errors.length,1);assert.equal([...f.timers.values()][0].ms,500);
f.next();assert.match(f.requests[2].url,/base=true/);assert.match(f.requests[2].url,/cursor=next/);
f.stop();f.answer(2);await settle();assert.equal(f.applied.length,1);
});
test('display changes and discarded responses retain camera identity; reset intent reaches server',async()=>{
const f=fixture();f.answer(0,'admitted-camera');await settle();f.next();
f.setState({active:true,revision:1,options:{mode:'3d',ceiling_m:3,reset:0}});
f.answer(1,'discarded-camera');await settle();
assert.equal(f.applied.length,1);f.next();
assert.match(f.requests[2].url,/cursor=admitted-camera/);
assert.match(f.requests[2].url,/base=true/);
assert.match(f.requests[2].url,/reset=0/);
f.answer(2,'clipped');await settle();
f.setState({active:true,revision:1,options:{mode:'3d',ceiling_m:3,reset:1}});
f.next();assert.match(f.requests[3].url,/reset=1/);
f.answer(3,'reset');await settle();f.stop();
});
test('unchanged terminal views stop transfer but notice a later revision',async()=>{
const f=fixture();f.setState({active:false,revision:2,options:{mode:'3d'}});
f.answer(0);await settle();f.next();f.answer(1);await settle();f.next();
assert.equal(f.requests.length,2);
f.setState({active:false,revision:3,options:{mode:'3d'}});f.next();
assert.equal(f.requests.length,3);f.stop();f.answer(2);await settle();
});
test('cloud or fit expiring during delivery cannot revive green',async()=>{
for(const [cloud,fit] of [['1.8','1'],['.1','7.8'],['invalid','1']]){
const f=fixture();f.setClock(300);
f.requests[0].resolve(new Response(new Uint8Array([1]),{headers:{
'X-Planning-Scene-Cursor':'next','X-Planning-Presentation':'live',
'X-Planning-Cloud-Age':cloud,'X-Planning-Fit-Age':fit}}));
await settle();assert.equal(f.applied.length,0);assert.equal(f.errors.length,1);f.stop();
}
});
test('a live response waits for bounded browser delivery and passes its exact receipt identity',async()=>{
let release,delivery;
let sequence=0,state={active:true,revision:1,options:{mode:'3d'}},clock=0;
const timers=new Map(),requests=[];
const stop=start({url:'/scene',snapshot:()=>state,now:()=>clock,
schedule:(fn,ms)=>{const id=++sequence;timers.set(id,{fn,ms});return id;},cancel:id=>timers.delete(id),
fetcher:(url,init)=>new Promise(resolve=>requests.push({url,init,resolve})),
apply:(_bytes,value)=>{delivery=value;return new Promise(resolve=>{release=resolve;});},error:assert.fail,
});
clock=40;
requests[0].resolve(new Response(new Uint8Array([1]),{headers:{
'X-Planning-Scene-Cursor':'next','X-Planning-Presentation':'live',
'X-Planning-Cloud-Age':'.2','X-Planning-Fit-Age':'.1',
'X-Planning-Cloud-Revision':'3','X-Planning-Cloud-Sequence':'4',
'X-Planning-Display-Epoch':'5d8c5d13-dfde-4ab6-9da0-475ab1f22c12',
'X-Planning-Height-Min':'-1.2','X-Planning-Height-Max':'51.5',
}}));
await settle();
assert.deepEqual(delivery,{presentation:'live',cloudAgeMs:200,fitAgeMs:100,requestMs:40,
cloudRevision:3,cloudSequence:4,displayEpoch:'5d8c5d13-dfde-4ab6-9da0-475ab1f22c12',
heightMinM:-1.2,heightMaxM:51.5});
assert.equal(timers.size,1);
assert.equal([...timers.values()][0].ms,2500);
release();await settle();
assert.equal([...timers.values()][0].ms,60);
stop();
});
test('profile scene can expand after native capture ends and retains Escape',()=>{
const source=readFileSync(new URL('../src/workspaces/spatial/SpatialWorkspace.tsx',import.meta.url),'utf8');
assert.match(source,/pointCloudVisible && \(visualProfile \|\| sourceUrl.trim\(\)\)/);
assert.match(source,/<IconButton\s+className="scene-source-control"\s+label="Развернуть облако точек"/);
assert.match(source,/event.key !== "Escape"/);
});