fix(lab): separate direct device launches from planning profiles

This commit is contained in:
DCCONSTRUCTIONS
2026-09-21 09:19:14 +03:00
parent 76dc9f19c9
commit c804d89b18
12 changed files with 222 additions and 29 deletions
@@ -0,0 +1,106 @@
import assert from 'node:assert/strict';
import React, {createElement} from 'react';
import {renderToStaticMarkup} from 'react-dom/server';
import {readFileSync} from 'node:fs';
import {before, after, test} from 'node:test';
import {createServer} from 'vite';
let server, resolve, awaitsCapture, Guard, Provider, Device, Host;
before(async () => {
server = await createServer({appType:'custom', logLevel:'silent', server:{middlewareMode:true}});
({workspaceLaunchProfile:resolve} = await server.ssrLoadModule('/src/core/observation/workspaceLaunch.ts'));
({planningAwaitsCapture:awaitsCapture, PlanningTestProvider:Provider} = await server.ssrLoadModule('/src/core/missions/PlanningTestContext.tsx'));
({PlanningCaptureGuard:Guard} = await server.ssrLoadModule('/src/components/missions/PlanningCaptureGuard.tsx'));
({DeviceWorkspace:Device} = await server.ssrLoadModule('/src/workspaces/DeviceWorkspace.tsx'));
({DevicePluginHostProvider:Host} = await server.ssrLoadModule('/src/core/device-plugins/DevicePluginHost.tsx'));
});
after(async () => { await server?.close(); });
test('direct entry overrides an earlier planning profile for both shared surfaces', () => {
for (const kind of ['device','spatial']) {
assert.equal(resolve('planning',kind),'direct');
assert.equal(resolve('direct',kind),'direct');
assert.equal(resolve('direct',kind,'planning'),'planning');
assert.equal(resolve('planning',kind,'planning'),'planning');
}
assert.equal(resolve('planning','missions'),'planning');
assert.equal(resolve('direct','missions'),'direct');
});
test('a prepared consumer cannot silently claim a direct capture; old/bound runs do not block it', () => {
assert.equal(awaitsCapture(null),false);
for (const state of ['completed','cancelled','interrupted','error']) {
for (const query_session_id of [null,'recording']) assert.equal(awaitsCapture({state,query_session_id}),false);
}
for (const state of ['preparing','waiting','running']) {
assert.equal(awaitsCapture({state,query_session_id:null}),true);
assert.equal(awaitsCapture({state,query_session_id:'recording'}),false);
}
});
function withHooks(hooks, fn) {
// Same bounded pre-effect harness used by observatoryHooks.test.mjs.
const internals = React.__CLIENT_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE;
const previous = internals.H;
internals.H = hooks;
try { return fn(); } finally { internals.H = previous; }
}
test('retained completed run renders the ordinary connection, without issuing any commands', () => {
let calls = 0;
const child = createElement('div',null,'ORDINARY DEVICE');
const tree = withHooks({useContext:()=>({test:{state:'completed',query_session_id:'old'},finish:()=>{calls++;}})},
() => Guard({enabled:true,onResume:()=>{calls++;},children:child}));
assert.match(renderToStaticMarkup(tree),/ORDINARY DEVICE/);
assert.equal(calls,0);
});
test('pending run handoff is explicit and only finishes the research, not the scanner', () => {
let stopped = 0, resumed = 0;
const context = {test:{state:'waiting',query_session_id:null,draft:{name:'PENDING'}},busy:false,error:null,finish:()=>{stopped++;}};
const child = createElement('div',null,'ORDINARY DEVICE');
const tree = withHooks({useContext:()=>context}, () => Guard({enabled:true,onResume:()=>{resumed++;},children:child}));
assert.doesNotMatch(renderToStaticMarkup(tree),/ORDINARY DEVICE/);
assert.equal(stopped,0);
const actions = tree.props.children.at(-1).props.children;
actions[0].props.onClick(); assert.equal(stopped,1); assert.equal(resumed,0);
actions[1].props.onClick(); assert.equal(resumed,1);
const planningTree = withHooks({useContext:()=>context}, () => Guard({enabled:false,onResume:()=>{},children:child}));
assert.match(renderToStaticMarkup(planningTree),/ORDINARY DEVICE/);
});
test('generic device catalog renders without any PlanningTestProvider', () => {
const html = renderToStaticMarkup(createElement(Host,{plugins:[]},
createElement(Device,{onOpenSpatialScene:()=>{},onActivateAutomaticSpatialSource:()=>{}})));
assert.match(html,/Выберите модель устройства/);
assert.doesNotMatch(html,/Профиль · Планирование|Подключение сканера · Планирование/);
});
test('failed planner selection does not grant a successful launch', async () => {
const writes = [];
const context = withHooks({
useState:initial=>[typeof initial==='function'?initial():initial,value=>writes.push(value)],
useRef:value=>({current:value}),useCallback:fn=>fn,useEffect:()=>{},
}, () => Provider({children:null}).props.value);
const originalFetch = globalThis.fetch;
try {
globalThis.fetch = async () => new Response(JSON.stringify({detail:'Выбранный проход недоступен'}),{status:409});
assert.equal(await context.select('missing'),false);
assert.ok(writes.includes('Выбранный проход недоступен'));
assert.equal('selected' in context,false);
assert.equal('resume' in context,false);
} finally { globalThis.fetch = originalFetch; }
});
test('composition owns explicit profile propagation; server polling cannot restore it', () => {
const source = path => readFileSync(new URL('../src/'+path,import.meta.url),'utf8');
assert.doesNotMatch(source('workspaces/DeviceWorkspace.tsx'),/Planning|missions\//);
assert.doesNotMatch(source('core/missions/PlanningTestContext.tsx'),/dismissed|sessionStorage|launchProfile/);
assert.match(source('App.tsx'),/useState<WorkspaceLaunchProfile>\('direct'\)/);
assert.match(source('App.tsx'),/openView\("spatial-scene", 'planning'\)/);
assert.match(source('App.tsx'),/openView\("spatial-scene", 'direct'\)/);
assert.match(source('workspaces/spatial/SpatialWorkspace.tsx'),/openView\("spatial-scene", launchProfile\)/);
assert.match(source('workspaces/Workspaces.tsx'),/props\.launchProfile === 'planning'/);
assert.match(source('workspaces/missions/MissionPlannerWorkspace.tsx'),/openView\('local-device','planning'\)/);
assert.match(source('workspaces/missions/MissionPlannerWorkspace.tsx'),/if\(await live.select\(project.id\)\)openView/);
});