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.
115 lines
6.3 KiB
JavaScript
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"/);
|
|
});
|