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(/]*>[\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\)/); });