Files
NODEDC_MISSION_CORE/apps/control-station/test/planningWholeRecording.test.mjs
T

77 lines
5.4 KiB
JavaScript

import assert from 'node:assert/strict';
import {before,after,test} from 'node:test';
import React from 'react';
import {renderToStaticMarkup} from 'react-dom/server';
import {createServer} from 'vite';
import {readFile} from 'node:fs/promises';
let server,usePlanner,Settings;
before(async()=>{
server=await createServer({appType:'custom',logLevel:'silent',server:{middlewareMode:true}});
({useMissionPlanner:usePlanner}=await server.ssrLoadModule('/src/core/missions/useMissionPlanner.ts'));
({PlanningProjectSettings:Settings}=await server.ssrLoadModule('/src/components/missions/PlanningProjectSettings.tsx'));
});
after(async()=>{await server?.close();});
// Bounded hook dispatcher: execute the real state/effect transitions, without
// mounting another browser or replacing the production hook with a fake.
function harness(){
const slots=[];let index=0,pending=[];
const hooks={
useState(initial){const i=index++;if(!slots[i])slots[i]={value:initial};return [slots[i].value,v=>{slots[i].value=typeof v==='function'?v(slots[i].value):v;}];},
useEffect(fn,deps){const i=index++,old=slots[i];if(!old||deps.some((d,n)=>!Object.is(d,old.deps[n])))pending.push(()=>{old?.cleanup?.();slots[i]={deps,cleanup:fn()};});},
useMemo(fn){return fn();},useCallback(fn){return fn;},
};
const render=()=>{index=0;const internal=React.__CLIENT_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE,old=internal.H;internal.H=hooks;try{return usePlanner();}finally{internal.H=old;}};
return {render,async settle(){let state;for(let n=0;n<5;n++){state=render();const effects=pending;pending=[];effects.forEach(fn=>fn());await new Promise(resolve=>setImmediate(resolve));}return render();},close(){slots.forEach(s=>s.cleanup?.());}};
}
const source={schema_version:'missioncore.planning-source/v1',session_id:'ring',generation:'a'.repeat(64),label:'Ring',units:'m',path_m:580,
poses:Array.from({length:60},(_,index)=>({index,position:[index*10,0,0],distance_m:index*10}))};
test('same recording reselection cannot collapse the reference or disable a named project',async()=>{
const oldFetch=globalThis.fetch,requests=[];const h=harness();
globalThis.fetch=async(url,options={})=>{
requests.push({url,body:options.body&&JSON.parse(options.body)});
if(options.method==='POST')return {ok:true,json:async()=>({id:'draft',revision:1,name:'Seam',zone:{session_id:'ring',generation:source.generation},route:{start_index:0,end_index:59,direction:'forward'}})};
return {ok:true,json:async()=>url.includes('/sources/')?source:{items:[],next_cursor:null}};
};
try{
let p=await h.settle();p.chooseSource('ring');p.setName('Seam');p=await h.settle();
assert.equal(p.poses.length,60);assert.equal(p.ready,true);
for(let i=0;i<4;i++){p.chooseSource('ring');p=await h.settle();assert.equal(p.poses.length,60);assert.equal(p.ready,true);}
await p.save();const body=requests.find(r=>r.body)?.body;
assert.equal(body.whole_recording,true);assert.equal(body.start_index,undefined);assert.equal(body.end_index,undefined);
assert.ok(requests.some(r=>r.url.includes('scope=standalone')));
p=h.render();p.newDraft();p=await h.settle();p.chooseSource('ring');p.setName('Next');p=await h.settle();
assert.equal(p.poses.length,60);assert.equal(p.ready,true);
assert.equal(p.setStart,undefined);assert.equal(p.setEnd,undefined);
}finally{h.close();globalThis.fetch=oldFetch;}
});
test('product removes reference crop controls and Data requests independent captures',async()=>{
const settings=await readFile(new URL('../src/components/missions/PlanningProjectSettings.tsx',import.meta.url),'utf8');
assert.doesNotMatch(settings,/Участок эталона|Начало участка|Конец участка|30 м от начала участка|p\.set(Start|End)/);
assert.match(settings,/Вся запись/);
const selector=await readFile(new URL('../src/components/ObservationSessionSelect.tsx',import.meta.url),'utf8');
assert.match(selector,/scope: "standalone"/);
});
test('planner offers one direct live start without a repeated-pass mode or catalogue',async()=>{
const p={name:'Ring',poses:source.poses,source,sessionId:'ring',options:[{value:'ring',label:'Ring'}],direction:'forward',ready:true};
const render=(overrides={},starting=false)=>renderToStaticMarkup(React.createElement(Settings,{p:{...p,...overrides},starting,onStart:()=>{}}));
const ready=render();
assert.doesNotMatch(ready,/Повторный проход|Из записи|Источник повторного прохода|Повторная запись|После запуска/);
const startButton=html=>[...html.matchAll(/<button\b[^>]*>[\s\S]*?<\/button>/g)].map(m=>m[0]).find(button=>button.includes('Начать новый проход'));
assert.ok(startButton(ready));
assert.doesNotMatch(startButton(ready),/disabled=/);
for(const state of [{ready:false},{busy:true},{poses:source.poses.slice(0,1)}])assert.match(startButton(render(state)),/disabled=/);
assert.match(startButton(render({},true)),/disabled=/);
let started=0;
const tree=Settings({p,starting:false,onStart:()=>{started++;}});
tree.props.children[1].props.children[1].props.onClick();
assert.equal(started,1);
const workspace=await readFile(new URL('../src/workspaces/missions/MissionPlannerWorkspace.tsx',import.meta.url),'utf8');
assert.doesNotMatch(workspace,/useRegistrationTest|setMode|t\.run\(/);
assert.match(workspace,/live\.begin\(draft\)/);
});