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.
This commit is contained in:
@@ -7673,6 +7673,26 @@ test("spatial controls explain the bounded K1 calibration wait before point data
|
||||
}
|
||||
});
|
||||
|
||||
test("planning status extends only its current authoritative acquiring session", () => {
|
||||
const state = runtimeState();
|
||||
const spatialActivity = {sessionId: "data-session-001", label: "Привязка к эталону", detail: "Ожидание на месте.", busy: true};
|
||||
const render = (current, activity = spatialActivity, controllerPatch = {}) => renderToStaticMarkup(createElement(K1SpatialControlsView, {
|
||||
controller: {...acquisitionController(current), ...controllerPatch}, spatialActivity: activity,
|
||||
}));
|
||||
assert.match(render(state), /Привязка к эталону/);
|
||||
assert.match(render(state), /Ожидание на месте/);
|
||||
assert.doesNotMatch(render(state, {...spatialActivity, sessionId: "other-capture"}), /Привязка к эталону/);
|
||||
for (const acquisitionState of ["awaiting_external_start", "starting", "awaiting_external_stop", "stopping", "finalizing"]) {
|
||||
const current = structuredClone(state); current.acquisition.state = acquisitionState;
|
||||
assert.doesNotMatch(render(current), /Привязка к эталону/);
|
||||
}
|
||||
const lost = structuredClone(state); lost.connection_supervisor = supervisor({control: true, data: false, dataPlaneState: "stalled"});
|
||||
assert.doesNotMatch(render(lost), /Привязка к эталону/);
|
||||
assert.doesNotMatch(render(state, spatialActivity, {physicalStopInFlight: true}), /Привязка к эталону/);
|
||||
const cleanup = structuredClone(state); cleanup.acquisition.cleanup_pending = true;
|
||||
assert.doesNotMatch(render(cleanup), /Привязка к эталону/);
|
||||
});
|
||||
|
||||
test("contour health never promotes selection or replay metrics to live authority", () => {
|
||||
const selectedOnly = {
|
||||
phase: "starting",
|
||||
|
||||
@@ -804,7 +804,7 @@ test("E40 reports historical visible evaluation with bounded camera-LiDAR case r
|
||||
});
|
||||
|
||||
test("the primary point-cloud viewer restores from fullscreen on Escape", async () => {
|
||||
const workspacesSource = await readFile(workspacesUrl, "utf8");
|
||||
const workspacesSource = await readFile(new URL("../src/workspaces/spatial/SpatialWorkspace.tsx", import.meta.url), "utf8");
|
||||
|
||||
assert.match(workspacesSource, /if \(!pointCloudFocused\) return;/);
|
||||
assert.match(workspacesSource, /event\.key !== "Escape"/);
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { before, after, test } from 'node:test';
|
||||
import { createServer } from 'vite';
|
||||
let server, api;
|
||||
before(async () => { server = await createServer({ appType: 'custom', logLevel: 'silent', server: { middlewareMode: true } }); api = await server.ssrLoadModule('/src/core/missions/planner.ts'); });
|
||||
after(async () => { await server?.close(); });
|
||||
const source = { schema_version:'missioncore.planning-source/v1', session_id:'A', generation:'a'.repeat(64), units:'m', poses:Array.from({length:5}, (_, i) => ({index:i, position:[i*3,i*4,0], distance_m:i*5})) };
|
||||
test('route reverses source order without changing geometry or source', () => {
|
||||
const result = api.selectedPoses(source, 1, 3, 'reverse');
|
||||
assert.deepEqual(result.map(p => p.index), [3,2,1]); assert.equal(api.routeLength(result), 10); assert.equal(source.poses[0].index,0);
|
||||
});
|
||||
test('bounded section uses travelled distance, including out-and-back', () => {
|
||||
assert.equal(api.endAtDistance(source, 1, 7),3); assert.equal(api.endAtDistance(source, 1, 30),4); assert.deepEqual(api.selectedPoses(source,4,2,'forward'),[]);
|
||||
});
|
||||
test('catalog excludes derived LAB parent and nonspatial sessions from zone binding', () => {
|
||||
const item={replayable:true,modalities:['point-cloud','trajectory']}; assert.equal(api.canSelectSession(item),true);
|
||||
assert.equal(api.canSelectSession({...item,lab:{}}),false); assert.equal(api.canSelectSession({...item,modalities:['video']}),false);
|
||||
});
|
||||
test('source contract rejects a different session and nonfinite coordinates', () => {
|
||||
assert.equal(api.validatePlanningSource(source,'A'),source); assert.throws(() => api.validatePlanningSource(source,'B'));
|
||||
assert.throws(() => api.validatePlanningSource({...source,poses:[{index:0,position:[0,0,NaN],distance_m:0},source.poses[1]]},'A'));
|
||||
});
|
||||
|
||||
test('meter input selects the correct ordered pose and respects the selected branch', () => {
|
||||
assert.equal(api.indexAtDistance(source, 10), 2);
|
||||
assert.equal(api.indexAtDistance(source, 10, 3), 3);
|
||||
assert.equal(api.indexAtDistance(source, 100), 4);
|
||||
});
|
||||
|
||||
test('live distance has no upper cap and endpoint selection does not overshoot typed metres', () => {
|
||||
for (const n of [3, 30, 50, 100, 200, 300, 10000]) assert.equal(api.canStartPlanningRoute(n, 'scanner'), true);
|
||||
for (const n of [2.9, NaN, Infinity]) assert.equal(api.canStartPlanningRoute(n, 'scanner'), false);
|
||||
assert.equal(api.canStartPlanningRoute(50, 'recording'), false);
|
||||
assert.equal(api.indexAtDistance(source, 9.9, 1, 4, true), 1);
|
||||
assert.equal(api.indexAtDistance(source, 10, 1, 4, true), 2);
|
||||
assert.equal(api.indexAtDistance(source, 1, 1, 4, true), 1);
|
||||
});
|
||||
|
||||
test('planning match never promotes stopped or stale evidence', async()=>{
|
||||
const {planningMatchCurrent}=await server.ssrLoadModule('/src/core/missions/planningPresentation.ts');
|
||||
const t={state:'running',tracking_state:'tracking',stale:false,frame_age_s:0,result_age_s:2,result:{status:'candidate'}};
|
||||
assert.equal(planningMatchCurrent(t),true);
|
||||
for(const patch of [{state:'completed'},{state:'waiting'},{tracking_state:'acquiring'},{tracking_state:'lost'},{tracking_state:undefined},{stale:true},{frame_age_s:8},{result_age_s:8},{result_age_s:null},{result:{status:'rejected'}}])assert.equal(planningMatchCurrent({...t,...patch}),false);
|
||||
assert.equal(planningMatchCurrent(t,true),false);
|
||||
});
|
||||
|
||||
test('planning failure is visible even when its last sample is stale', async()=>{
|
||||
const {planningStatus}=await server.ssrLoadModule('/src/core/missions/planningPresentation.ts');
|
||||
const t={state:'error',message:'Разрыв координат',stale:true,frame_age_s:null,result_age_s:null,result:null};
|
||||
assert.deepEqual(planningStatus(t),{label:'Совмещение остановлено',tone:'danger',message:'Разрыв координат',pulse:true});
|
||||
assert.equal(planningStatus({...t,state:'waiting'}).label,'Ожидание данных');
|
||||
});
|
||||
|
||||
test('planning activity continues scanner preparation without presenting a prior as tracking', async()=>{
|
||||
const {planningStatus,planningActivity}=await server.ssrLoadModule('/src/core/missions/planningPresentation.ts');
|
||||
const t={state:'running',query_session_id:'B',tracking_state:'acquiring',stale:false,frame_age_s:0,result_age_s:null,result:null,message:'Ожидание на месте.'};
|
||||
assert.equal(planningActivity({...t,planning_phase:'waiting-cloud'}),undefined);
|
||||
for(const [planning_phase,label] of [['collecting','Накопление данных'],['searching','Поиск положения на маршруте'],['refreshing','Подтверждение привязки'],['validating','Подтверждение привязки']]){
|
||||
assert.deepEqual(planningActivity({...t,planning_phase}),{sessionId:'B',label,detail:'',busy:true});
|
||||
assert.equal(planningStatus({...t,planning_phase}).tone,'neutral');
|
||||
}
|
||||
const live={...t,planning_phase:'tracking',tracking_state:'tracking',result_age_s:0,result:{status:'candidate'},message:'Можно начинать проверочный проход.'};
|
||||
assert.equal(planningActivity(live).busy,false);
|
||||
assert.equal(planningStatus(live).tone,'success');
|
||||
const stale=planningStatus({...live,frame_age_s:9});
|
||||
assert.equal(stale.label,'Привязка потеряна');assert.doesNotMatch(stale.message,/Можно начинать/);
|
||||
assert.equal(planningActivity({...t,planning_phase:'searching'},'Нет связи').busy,false);
|
||||
assert.equal(planningActivity({...live,query_session_id:null}),undefined);
|
||||
assert.equal(planningActivity({...live,state:'completed'}),undefined);
|
||||
const recovery=planningStatus({...t,planning_phase:'recovering',tracking_established:true});
|
||||
assert.equal(recovery.pulse,true);
|
||||
assert.equal(recovery.tone,'danger');
|
||||
assert.match(recovery.message,/запись продолжается/);
|
||||
});
|
||||
|
||||
test('project archive restores exact run identity and never promotes a failed live probe', async()=>{
|
||||
const {defaultPlanningProject,planningProjectStatus}=await server.ssrLoadModule('/src/core/missions/planningProjects.ts');
|
||||
const items=[{key:'live:failed',kind:'live',state:'error',result_status:null},{key:'recorded:second',kind:'recorded',state:'ready',result_status:'candidate'},{key:'recorded:first',kind:'recorded',state:'ready',result_status:'candidate'}];
|
||||
assert.equal(defaultPlanningProject(items,null),'recorded:second');
|
||||
assert.equal(defaultPlanningProject(items,'recorded:first'),'recorded:first');
|
||||
assert.equal(defaultPlanningProject(items,'missing'),'recorded:second');
|
||||
assert.equal(planningProjectStatus(items[0]),'Без результата совмещения');
|
||||
assert.equal(defaultPlanningProject([],null),'');
|
||||
});
|
||||
|
||||
|
||||
test('failed initial binding never claims previously established tracking was lost', async()=>{
|
||||
const {planningStatus,planningActivity}=await server.ssrLoadModule('/src/core/missions/planningPresentation.ts');
|
||||
const t={state:'running',query_session_id:'B',planning_phase:'lost',tracking_state:'lost',tracking_established:false,
|
||||
stale:true,frame_age_s:null,result_age_s:null,result:null,message:'Начальный поиск не завершён.'};
|
||||
const failed=planningStatus(t);
|
||||
assert.equal(failed.label,'Маршрут не синхронизирован');
|
||||
assert.equal(failed.message,'Начальный поиск не завершён.');
|
||||
assert.deepEqual(planningActivity(t),{sessionId:'B',label:failed.label,detail:failed.message,busy:false});
|
||||
assert.equal(planningStatus({...t,tracking_established:true}).label,'Привязка потеряна');
|
||||
assert.equal(planningStatus({...t,state:'completed'}).label,'Исследование завершено');
|
||||
});
|
||||
|
||||
test('freshness of displayed alignment fences green independently of the fit age',async()=>{
|
||||
const {planningMatchCurrent,planningStatus}=await server.ssrLoadModule('/src/core/missions/planningPresentation.ts');
|
||||
const t={state:'running',tracking_state:'tracking',planning_phase:'tracking',tracking_established:true,
|
||||
stale:false,frame_age_s:.2,result_age_s:4,result:{status:'candidate'},message:'Привязка подтверждена.',presentation_state:'live'};
|
||||
assert.equal(planningMatchCurrent(t),true);
|
||||
assert.equal(planningMatchCurrent({...t,presentation_state:'historical'}),false);
|
||||
assert.equal(planningMatchCurrent({...t,frame_age_s:2.1}),false);
|
||||
assert.match(planningStatus({...t,presentation_state:'historical'}).message,/последней принятой/);
|
||||
assert.match(planningStatus({...t,state:'completed',presentation_state:'historical'}).message,/не текущее положение/);
|
||||
});
|
||||
@@ -1066,7 +1066,7 @@ test("recording preparation never presents phase heartbeats as fake percentages"
|
||||
|
||||
test("unified recorded AI view keeps the raw camera mounted but does not cover overlays", async () => {
|
||||
const workspaceSource = await readFile(
|
||||
new URL("../src/workspaces/Workspaces.tsx", import.meta.url),
|
||||
new URL("../src/workspaces/spatial/SpatialWorkspace.tsx", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
|
||||
@@ -27,7 +27,7 @@ async function read(relativePath) {
|
||||
test("Observatory is the third independent Polygon workspace", () => {
|
||||
assert.deepEqual(
|
||||
productModel.workspacesForRoot("polygon").map(({ id }) => id),
|
||||
["lab-archive", "simulations", "observatory", "local-device", "spatial-scene"],
|
||||
["lab-archive", "simulations", "observatory", "local-device", "spatial-scene", "mission-planner"],
|
||||
);
|
||||
assert.deepEqual(
|
||||
productModel.workspaceById("observatory"),
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import {test} from 'node:test';
|
||||
import {readFileSync} from 'node:fs';
|
||||
|
||||
test('planning live scene reuses the canonical vertical height range inside the existing stage',()=>{
|
||||
const source=readFileSync(new URL('../src/components/missions/PlanningLiveScene.tsx',import.meta.url),'utf8');
|
||||
assert.match(source,/RangeControl orientation="vertical" limitSide="left" label="Срез"/);
|
||||
assert.match(source,/className="session-overview__height"/);
|
||||
assert.match(source,/ceiling_m:ceilingRef\.current/);
|
||||
assert.match(source,/formatLimit=\{value=>value\.toFixed\(1\)\.replace\('\.',','\)\}/);
|
||||
assert.match(source,/heightBounds=null/);
|
||||
assert.match(source,/const CLIP_CEILING_M=80/);
|
||||
assert.match(source,/max:CLIP_CEILING_M/);
|
||||
assert.match(source,/setBounds\(next\);setCeiling\(null\);/);
|
||||
});
|
||||
@@ -0,0 +1,31 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import {before,after,test} from 'node:test';
|
||||
import {createServer} from 'vite';
|
||||
|
||||
let server,createReporter;
|
||||
before(async()=>{
|
||||
server=await createServer({appType:'custom',logLevel:'silent',server:{middlewareMode:true}});
|
||||
({createPlanningPresentationReporter:createReporter}=await server.ssrLoadModule(
|
||||
'/src/core/missions/planningPresentationTelemetry.ts'));
|
||||
});
|
||||
after(async()=>{await server?.close();});
|
||||
const settle=()=>new Promise(resolve=>setImmediate(resolve));
|
||||
|
||||
test('presentation observations batch separately from the scene channel and retain the proxy boundary',async()=>{
|
||||
let timer,posted;
|
||||
const reporter=createReporter({url:'/observations',schedule:callback=>{timer=callback;return 1;},cancel:()=>{},
|
||||
fetcher:async(url,init)=>{posted={url,init};return new Response(null,{status:204});},
|
||||
});
|
||||
reporter.record({presentation:'live',cloudAgeMs:200,fitAgeMs:100,requestMs:40,
|
||||
cloudRevision:3,cloudSequence:4,displayEpoch:'5d8c5d13-dfde-4ab6-9da0-475ab1f22c12'},
|
||||
{rerunAdmissionMs:2,firstAnimationFrameMs:12,secondAnimationFrameMs:28});
|
||||
assert.ok(timer);timer();await settle();
|
||||
assert.equal(posted.url,'/observations');
|
||||
const body=JSON.parse(posted.init.body);
|
||||
assert.equal(body.schema_version,'missioncore.planning-browser-presentation/v1');
|
||||
assert.deepEqual(body.samples,[{cloud_revision:3,cloud_sequence:4,
|
||||
display_epoch:'5d8c5d13-dfde-4ab6-9da0-475ab1f22c12',request_ms:40,
|
||||
rerun_admission_ms:2,first_animation_frame_ms:12,second_animation_frame_ms:28,
|
||||
frame_timeout:false,source_to_second_animation_frame_upper_bound_ms:270}]);
|
||||
reporter.dispose();
|
||||
});
|
||||
@@ -0,0 +1,50 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { before, after, test } from 'node:test';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { createServer } from 'vite';
|
||||
let server, api;
|
||||
before(async () => {
|
||||
server = await createServer({appType: 'custom', logLevel: 'silent', server: {middlewareMode: true}});
|
||||
api = await server.ssrLoadModule('/src/core/missions/planningProjects.ts');
|
||||
});
|
||||
after(async () => { await server?.close(); });
|
||||
const item = {key: 'live:a', id: 'a', name: 'same name', kind: 'live', state: 'completed', revision: 2};
|
||||
|
||||
test('only terminal runs or drafts admit catalog deletion', () => {
|
||||
for (const state of ['completed', 'cancelled', 'error', 'interrupted']) assert.equal(api.planningProjectDeletable({...item, state}), true);
|
||||
for (const state of ['running', 'waiting', 'preparing', 'new-unknown']) assert.equal(api.planningProjectDeletable({...item, state}), false);
|
||||
assert.equal(api.planningProjectDeletable({...item, kind: 'draft', state: 'draft'}), true);
|
||||
assert.equal(api.planningProjectDeletable({...item, kind: 'recorded', state: 'ready'}), true);
|
||||
});
|
||||
|
||||
test('delete sends exact kind/id/revision and requires a matching receipt', async t => {
|
||||
const calls = [];
|
||||
let receipt = {key: item.key, deleted: true};
|
||||
t.mock.method(globalThis, 'fetch', async (...args) => { calls.push(args); return {ok: true, json: async () => receipt}; });
|
||||
await api.deletePlanningProject(item);
|
||||
assert.equal(calls[0][0], '/api/v1/mission-planner/projects/live/a');
|
||||
assert.equal(calls[0][1].method, 'DELETE');
|
||||
assert.deepEqual(JSON.parse(calls[0][1].body), {revision: 2});
|
||||
receipt = {key: 'live:another-same-name', deleted: true};
|
||||
await assert.rejects(api.deletePlanningProject(item), /не подтверждено/);
|
||||
const count = calls.length;
|
||||
await assert.rejects(api.deletePlanningProject({...item, state: 'running'}), /завершите/);
|
||||
assert.equal(calls.length, count);
|
||||
});
|
||||
|
||||
test('server refusal stays an error, never a successful deletion', async t => {
|
||||
t.mock.method(globalThis, 'fetch', async () => ({ok: false, json: async () => ({detail: 'Проект изменён.'})}));
|
||||
await assert.rejects(api.deletePlanningProject(item), /Проект изменён/);
|
||||
});
|
||||
|
||||
test('UI uses canonical row actions and modal, and stale loads cannot resurrect a deleted item', async () => {
|
||||
const component = await readFile(new URL('../src/components/missions/PlanningProjectSelect.tsx', import.meta.url), 'utf8');
|
||||
const hook = await readFile(new URL('../src/core/missions/usePlanningProjects.ts', import.meta.url), 'utf8');
|
||||
assert.match(component, /<Select/); assert.match(component, /<ConfirmationModal/);
|
||||
assert.match(component, /disabled: !planningProjectDeletable\(item\)/);
|
||||
assert.match(component, /await onRemove\(target\); setTarget\(null\)/);
|
||||
assert.match(component, /<ToastStack/);
|
||||
assert.match(hook, /removedKeys.current.has\(item.key\)/);
|
||||
assert.match(hook, /removedKeys.current.has\(key\)/);
|
||||
assert.match(hook, /if \(selectedKey.current === project.key\) select\(''\)/);
|
||||
});
|
||||
@@ -0,0 +1,114 @@
|
||||
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"/);
|
||||
});
|
||||
@@ -0,0 +1,35 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import {before,after,test} from 'node:test';
|
||||
import {createServer} from 'vite';
|
||||
import {createElement} from 'react';
|
||||
import {renderToStaticMarkup} from 'react-dom/server';
|
||||
import {readFileSync} from 'node:fs';
|
||||
|
||||
let server,Tool,Actions;
|
||||
before(async()=>{
|
||||
server=await createServer({appType:'custom',logLevel:'silent',server:{middlewareMode:true}});
|
||||
({PlanningSceneToolWindow:Tool}=await server.ssrLoadModule('/src/components/missions/PlanningSceneToolWindow.tsx'));
|
||||
({SpatialToolbarActions:Actions}=await server.ssrLoadModule(new URL('../../../packages/spatial-ui/src/SpatialToolbarActions.tsx',import.meta.url).pathname));
|
||||
});
|
||||
after(async()=>{await server?.close();});
|
||||
|
||||
test('scene tools use the bounded modeless window with move, resize and expand controls',()=>{
|
||||
const html=renderToStaticMarkup(createElement(Tool,{boundsRef:{current:null},title:'Слои',onClose:()=>{}},'CONTROLS'));
|
||||
assert.match(html,/nodedc-workspace-window/);
|
||||
assert.match(html,/aria-modal="false"/);
|
||||
assert.match(html,/Переместить инструмент/);
|
||||
assert.match(html,/Изменить размер инструмента/);
|
||||
assert.match(html,/Развернуть инструмент/);
|
||||
assert.doesNotMatch(html,/nodedc-overlay/);
|
||||
});
|
||||
|
||||
test('spatial toolbar omits source and duplicate planning navigation',()=>{
|
||||
const html=renderToStaticMarkup(createElement(Actions,{openLayers:()=>{},openDisplay:()=>{},activeTool:'layers'}));
|
||||
assert.match(html,/Слои/);assert.match(html,/Отображение/);
|
||||
assert.match(html,/aria-pressed="true"/);assert.doesNotMatch(html,/Движок|Планирование/);
|
||||
const workspace=readFileSync(new URL('../src/workspaces/missions/PlanningSpatialWorkspace.tsx',import.meta.url),'utf8');
|
||||
assert.doesNotMatch(workspace,/<Window\s|openSource=|openView\('mission-planner'\)/);
|
||||
assert.match(workspace,/tools:boundsRef=>tool&&<PlanningSceneToolWindow/);
|
||||
const viewer=readFileSync(new URL('../src/components/missions/PlanningLiveScene.tsx',import.meta.url),'utf8');
|
||||
assert.match(viewer,/\},\[runId,retry\]\)/); // Presentation never owns viewer lifetime.
|
||||
});
|
||||
@@ -324,7 +324,7 @@ test("Polygon exposes one dataset surface and keeps legacy links compatible", ()
|
||||
assert.equal(workspaceById("datasets").kind, "datasets");
|
||||
assert.deepEqual(
|
||||
workspacesForRoot("polygon").map(({ id }) => id),
|
||||
["lab-archive", "simulations", "observatory", "local-device", "spatial-scene"],
|
||||
["lab-archive", "simulations", "observatory", "local-device", "spatial-scene", "mission-planner"],
|
||||
);
|
||||
assert.equal(
|
||||
workspacesForRoot("system").some(({ id }) => id === "polygon-run"),
|
||||
|
||||
@@ -29,7 +29,7 @@ test("top navigation has no Center and Park owns contour health first", () => {
|
||||
assert.equal(productModel.workspaceById("contour-health")?.root, "fleet");
|
||||
assert.deepEqual(
|
||||
productModel.workspacesForRoot("polygon").map(({ id }) => id),
|
||||
["lab-archive", "simulations", "observatory", "local-device", "spatial-scene"],
|
||||
["lab-archive", "simulations", "observatory", "local-device", "spatial-scene", "mission-planner"],
|
||||
);
|
||||
assert.equal(productModel.workspaceById("spatial-scene")?.root, "polygon");
|
||||
assert.equal(productModel.workspacesForRoot("observation").some(({ id }) => id === "spatial-scene"), false);
|
||||
|
||||
@@ -295,7 +295,7 @@ test("loading and error overlays fully conceal recorded camera pixels", async ()
|
||||
|
||||
test("point-cloud fullscreen keeps the admitted recorded camera worker mounted", async () => {
|
||||
const source = await readFile(
|
||||
new URL("../src/workspaces/Workspaces.tsx", import.meta.url),
|
||||
new URL("../src/workspaces/spatial/SpatialWorkspace.tsx", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
assert.match(source, /\{visibleMediaSources\.map\(\(source, index\) => \(/);
|
||||
|
||||
@@ -320,7 +320,7 @@ test("one live document owns one native Rerun receiver", async () => {
|
||||
|
||||
test("raw replay exercises the same streaming receiver lifecycle as a live scan", async () => {
|
||||
const source = await readFile(
|
||||
new URL("../src/workspaces/Workspaces.tsx", import.meta.url),
|
||||
new URL("../src/workspaces/spatial/SpatialWorkspace.tsx", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
assert.match(
|
||||
@@ -343,7 +343,7 @@ test("raw replay exercises the same streaming receiver lifecycle as a live scan"
|
||||
);
|
||||
assert.match(
|
||||
source,
|
||||
/const recordedSource = Boolean\(recordedReplay\) \|\| \/\\\.rrd/,
|
||||
/const recordedSource = !visualProfile && \(Boolean\(recordedReplay\) \|\| \/\\\.rrd/,
|
||||
);
|
||||
assert.doesNotMatch(
|
||||
source,
|
||||
@@ -353,7 +353,7 @@ test("raw replay exercises the same streaming receiver lifecycle as a live scan"
|
||||
|
||||
test("pending K1 STOP keeps the live Rerun source mounted until local capture ends", async () => {
|
||||
const source = await readFile(
|
||||
new URL("../src/workspaces/Workspaces.tsx", import.meta.url),
|
||||
new URL("../src/workspaces/spatial/SpatialWorkspace.tsx", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
assert.match(
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { before, after, test } from 'node:test';
|
||||
import { createServer } from 'vite';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
let server, api;
|
||||
before(async () => {
|
||||
server = await createServer({ appType: 'custom', logLevel: 'silent', server: { middlewareMode: true } });
|
||||
api = await server.ssrLoadModule('/src/core/observation/sessionOverview.ts');
|
||||
});
|
||||
after(async () => { await server?.close(); });
|
||||
test('height slice delegates endpoint labels and unit-bearing value to the shared range', async () => {
|
||||
const source = await readFile(new URL('../src/components/observation/SessionOverviewScene.tsx', import.meta.url), 'utf8');
|
||||
assert.match(source, /RangeControl orientation="vertical" limitSide="left" label="Срез"/);
|
||||
assert.match(source, /formatLimit=\{value => value\.toFixed\(1\)\.replace\('\.', ','\)\}/);
|
||||
assert.match(source, /formatValue=\{value => `\$\{value\.toFixed\(1\)\.replace\('\.', ','\)\} м`\}/);
|
||||
assert.doesNotMatch(source, /<span>\{(?:high|low)\.toFixed\(1\)\} м<\/span>/);
|
||||
});
|
||||
test('interval chart retains the largest pause and rejects invalid values', () => {
|
||||
const result = api.overviewChartPoints([[0, .1], [30, 1.2], [60, .1], [NaN, 5]], 100, 50);
|
||||
assert.equal(result.xmax, 60);
|
||||
assert.equal(result.ymax, 1.32);
|
||||
assert.equal(result.points.split(' ').length, 3);
|
||||
assert.doesNotMatch(result.points, /NaN/);
|
||||
});
|
||||
test('session overview rejects a result belonging to a different session', async () => {
|
||||
const original = globalThis.fetch;
|
||||
globalThis.fetch = async () => ({ ok: true, json: async () => ({ schema_version: 'missioncore.session-overview/v1', state: 'ready', session: { session_id: 'other' } }) });
|
||||
try { await assert.rejects(api.fetchSessionOverview('selected', new AbortController().signal), /некорректные/); }
|
||||
finally { globalThis.fetch = original; }
|
||||
});
|
||||
@@ -0,0 +1,50 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { before, after, test } from 'node:test';
|
||||
import { createElement } from 'react';
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
import { createServer } from 'vite';
|
||||
|
||||
let server, SpatialScene;
|
||||
before(async () => {
|
||||
server = await createServer({ appType: 'custom', logLevel: 'silent', server: { middlewareMode: true } });
|
||||
({ SpatialScene } = await server.ssrLoadModule(new URL('../../../packages/spatial-ui/src/SpatialScene.tsx', import.meta.url).pathname));
|
||||
});
|
||||
after(async () => { await server?.close(); });
|
||||
|
||||
const render = (focused = false) => renderToStaticMarkup(createElement(SpatialScene, {
|
||||
viewportRef: { current: null }, primaryFocused: focused, toolbar: null, renderer: null,
|
||||
sourceControls: createElement('div', { className: focused ? 'scene-focus-exit' : 'scene-source-controls' }, 'SOURCE_CONTROLS'),
|
||||
status: { label: 'Накопление данных', tone: 'neutral', message: 'Сканер неподвижен.' },
|
||||
metrics: createElement('div', null, 'METRICS'),
|
||||
}));
|
||||
|
||||
test('scene tools, visual engine and metrics share one top-left flow in that order', () => {
|
||||
const markup = render();
|
||||
const stack = markup.indexOf('class="scene-information"');
|
||||
const controls = markup.indexOf('SOURCE_CONTROLS');
|
||||
const status = markup.indexOf('ВИЗУАЛЬНЫЙ ДВИЖОК');
|
||||
const metrics = markup.indexOf('METRICS');
|
||||
assert.ok(stack < controls && controls < status && status < metrics);
|
||||
assert.equal((markup.match(/SOURCE_CONTROLS/g) ?? []).length, 1);
|
||||
assert.doesNotMatch(markup, /scene-status--top-left/);
|
||||
});
|
||||
|
||||
test('focus exit remains viewport-owned outside the hidden information stack', () => {
|
||||
const markup = render(true);
|
||||
assert.ok(markup.indexOf('scene-focus-exit') < markup.indexOf('scene-information'));
|
||||
assert.match(markup, /class="scene-information" aria-hidden="true"/);
|
||||
assert.equal((markup.match(/SOURCE_CONTROLS/g) ?? []).length, 1);
|
||||
});
|
||||
|
||||
test('scene layout uses flow, retains compact metrics and removes only the calibration perimeter', async () => {
|
||||
const css = await readFile(new URL('../../../packages/spatial-ui/src/spatial.css', import.meta.url), 'utf8');
|
||||
const responsive = await readFile(new URL('../src/styles/responsive.css', import.meta.url), 'utf8');
|
||||
const calibration = await readFile(new URL('../../../plugins/xgrids-k1/frontend/src/components/K1SpatialSession.css', import.meta.url), 'utf8');
|
||||
assert.match(css, /\.scene-information \{[^}]*position: absolute;[^}]*display: grid;/);
|
||||
assert.match(css, /\.scene-information > \.scene-source-controls \{ position: static; pointer-events: auto;/);
|
||||
assert.match(css, /\.scene-information\[aria-hidden="true"\] \{ display: none;/);
|
||||
assert.doesNotMatch(css.match(/\.scene-metrics \{[^}]*\}/)?.[0] ?? '', /top:|right:|position: absolute/);
|
||||
assert.doesNotMatch(responsive, /\.scene-metrics\s*\{\s*display: none/);
|
||||
assert.match(calibration, /\.xgrids-k1-spatial-controls \{[^}]*border: 0;/);
|
||||
});
|
||||
Reference in New Issue
Block a user