fix(lab): separate direct device launches from planning profiles
This commit is contained in:
@@ -69,6 +69,9 @@ import {
|
||||
type SceneSettings,
|
||||
} from "./sceneSettings";
|
||||
import { DeviceWorkspace } from "./workspaces/DeviceWorkspace";
|
||||
import { PlanningConnectionWindow } from "./components/missions/PlanningConnectionWindow";
|
||||
import { PlanningCaptureGuard } from "./components/missions/PlanningCaptureGuard";
|
||||
import { workspaceLaunchProfile, type WorkspaceLaunchProfile } from "./core/observation/workspaceLaunch";
|
||||
import { WorkspaceRenderer } from "./workspaces/Workspaces";
|
||||
import { useLaboratoryAnnotationHeader } from "./components/laboratory/useLaboratoryAnnotationHeader";
|
||||
import "./styles/scene-windows.css";
|
||||
@@ -151,6 +154,7 @@ export default function App() {
|
||||
polygonDatasetRoute.active ? "data" : null,
|
||||
);
|
||||
const [environmentSettingsOpen, setEnvironmentSettingsOpen] = useState(false);
|
||||
const [launchProfile, setLaunchProfile] = useState<WorkspaceLaunchProfile>('direct');
|
||||
const [sourceUrl, setSourceUrl] = useState("");
|
||||
const [workspaceHeaderToolsHost, setWorkspaceHeaderToolsHost] = useState<HTMLDivElement | null>(null);
|
||||
const [recordedReplay, setRecordedReplay] = useState<ObservationSessionReplayLaunch | null>(null);
|
||||
@@ -431,9 +435,10 @@ export default function App() {
|
||||
}
|
||||
};
|
||||
|
||||
const openView = (viewId: string) => {
|
||||
const openView = (viewId: string, profile?: WorkspaceLaunchProfile) => {
|
||||
const definition = workspaceById(viewId);
|
||||
if (!definition) return;
|
||||
setLaunchProfile(current => workspaceLaunchProfile(current, definition.kind, profile));
|
||||
setActiveRoot(definition.root);
|
||||
workspace.openView(viewId);
|
||||
};
|
||||
@@ -806,15 +811,25 @@ export default function App() {
|
||||
utilityActions={contentActions}
|
||||
onClose={workspace.closeView}
|
||||
>
|
||||
<PlanningCaptureGuard
|
||||
enabled={launchProfile === 'direct' && (activeDefinition.kind === 'device' || activeDefinition.kind === 'spatial')}
|
||||
onResume={() => openView('local-device', 'planning')}
|
||||
>
|
||||
{activeDefinition.kind === "device" ? (
|
||||
<DeviceWorkspace
|
||||
onOpenSpatialScene={() => openView("spatial-scene")}
|
||||
launchProfile === 'planning' ? <PlanningConnectionWindow>
|
||||
<DeviceWorkspace
|
||||
onOpenSpatialScene={() => openView("spatial-scene", 'planning')}
|
||||
onActivateAutomaticSpatialSource={activateAutomaticSpatialSource}
|
||||
/>
|
||||
</PlanningConnectionWindow> : <DeviceWorkspace
|
||||
onOpenSpatialScene={() => openView("spatial-scene", 'direct')}
|
||||
onActivateAutomaticSpatialSource={activateAutomaticSpatialSource}
|
||||
/>
|
||||
) : activeDefinition.kind === "recordings" && sessionOverview.open && recordedReplay && replayPresented ? (
|
||||
<SessionOverviewWorkspace key={recordedReplay.sessionId} sessionId={recordedReplay.sessionId} />
|
||||
) : (
|
||||
<WorkspaceRenderer
|
||||
launchProfile={launchProfile}
|
||||
definition={activeDefinition}
|
||||
fleetCreateRequest={fleetCreateRequest}
|
||||
headerToolsHost={workspaceHeaderToolsHost}
|
||||
@@ -858,6 +873,7 @@ export default function App() {
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</PlanningCaptureGuard>
|
||||
</ApplicationPanel>
|
||||
) : null}
|
||||
/>
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import type {ReactNode} from 'react';
|
||||
import {Button, StatusBadge} from '@nodedc/ui-react';
|
||||
import {planningAwaitsCapture, usePlanningTest} from '../../core/missions/PlanningTestContext';
|
||||
|
||||
/** Composition-level handoff; never sends a device or acquisition command. */
|
||||
export function PlanningCaptureGuard({enabled, onResume, children}: {
|
||||
enabled: boolean;
|
||||
onResume: () => void;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const planning = usePlanningTest();
|
||||
if (!enabled || !planningAwaitsCapture(planning.test)) return <>{children}</>;
|
||||
return <div className="mission-planner__check">
|
||||
<StatusBadge tone="warning">Исследование ожидает новую запись</StatusBadge>
|
||||
<p>{planning.test!.draft.name}</p>
|
||||
<p>Для самостоятельной съёмки завершите подготовленное исследование. Запись и подключение сканера не будут остановлены.</p>
|
||||
{planning.error && <p role="alert">{planning.error}</p>}
|
||||
<div className="mission-planner__actions">
|
||||
<Button loading={planning.busy} onClick={() => void planning.finish()}>Завершить исследование</Button>
|
||||
<Button disabled={planning.busy} onClick={onResume}>Вернуться к исследованию</Button>
|
||||
</div>
|
||||
</div>;
|
||||
}
|
||||
@@ -8,11 +8,11 @@ export function PlanningConnectionWindow({children}:{children:ReactNode}){
|
||||
const p=usePlanningTest(); const [open,setOpen]=useState(true);
|
||||
const {selection,registry,selectModel,selectionTransitionPending}=useDevicePluginHost();
|
||||
useEffect(()=>{
|
||||
if(!p.selected||!p.test||selection||selectionTransitionPending)return;
|
||||
if(!p.test||planningTestTerminal(p.test)||selection||selectionTransitionPending)return;
|
||||
const models=registry.models.filter(m=>m.plugin.manifest.metadata.id===p.test!.plugin_id);
|
||||
if(models.length===1)void selectModel(models[0].model.id);
|
||||
},[p.selected,p.test?.plugin_id,selection,registry,selectModel,selectionTransitionPending]);
|
||||
if(!p.selected||!p.test)return <>{children}</>;
|
||||
},[p.test?.plugin_id,p.test?.state,selection,registry,selectModel,selectionTransitionPending]);
|
||||
if(!p.test)return <>{children}</>;
|
||||
const t=p.test, terminal=planningTestTerminal(t);
|
||||
const details=<><StatusBadge tone="neutral">Профиль · Планирование</StatusBadge>
|
||||
<p>{t.draft.name} · эталон {t.draft.zone.label} · {t.draft.route.length_m.toFixed(1)} м</p>
|
||||
|
||||
@@ -18,14 +18,13 @@ export interface PlanningRunSummary {id:string;name:string;state:string;query_se
|
||||
const endpoint = plannerBase+'/live-tests';
|
||||
const terminal = new Set(['completed','cancelled','error','interrupted']);
|
||||
const Context = createContext<{
|
||||
test: PlanningLiveTest|null; history:PlanningRunSummary[]; select:(id:string)=>Promise<void>; selected: boolean; busy:boolean; error:string|null;
|
||||
begin:(draft:Draft)=>Promise<PlanningLiveTest|null>; finish:()=>Promise<void>; retryInitialization:()=>Promise<void>; resume:()=>void;
|
||||
test: PlanningLiveTest|null; history:PlanningRunSummary[]; select:(id:string)=>Promise<boolean>; busy:boolean; error:string|null;
|
||||
begin:(draft:Draft)=>Promise<PlanningLiveTest|null>; finish:()=>Promise<void>; retryInitialization:()=>Promise<void>;
|
||||
}|null>(null);
|
||||
|
||||
/** Server owns the run; navigation and reload retain its frozen reference. */
|
||||
/** Server owns the run and its frozen reference, never the workspace launch profile. */
|
||||
export function PlanningTestProvider({children}:{children:ReactNode}) {
|
||||
const [test,setTest]=useState<PlanningLiveTest|null>(null);
|
||||
const [dismissed,setDismissed]=useState<string|null>(null);
|
||||
const [busy,setBusy]=useState(false),[error,setError]=useState<string|null>(null);
|
||||
const [pollError,setPollError]=useState<string|null>(null);
|
||||
const [history,setHistory]=useState<PlanningRunSummary[]>([]);
|
||||
@@ -33,8 +32,8 @@ export function PlanningTestProvider({children}:{children:ReactNode}) {
|
||||
useEffect(()=>{let disposed=false;void plannerRequest<{items:PlanningRunSummary[]}>(endpoint).then(r=>{if(!disposed)setHistory(r.items);}).catch(()=>{});return()=>{disposed=true;};},[test?.id,test?.state]);
|
||||
const select=useCallback(async(id:string)=>{
|
||||
generation.current+=1;setBusy(true);setError(null);
|
||||
try{setTest(await plannerRequest<PlanningLiveTest>(endpoint+'/'+id+'/select',{method:'POST'}));setDismissed(null);}
|
||||
catch(e){setError(e instanceof Error?e.message:'Не удалось открыть исследование.');}
|
||||
try{setTest(await plannerRequest<PlanningLiveTest>(endpoint+'/'+id+'/select',{method:'POST'}));return true;}
|
||||
catch(e){setError(e instanceof Error?e.message:'Не удалось открыть исследование.');return false;}
|
||||
finally{generation.current+=1;setBusy(false);}
|
||||
},[]);
|
||||
useEffect(()=>{
|
||||
@@ -48,7 +47,7 @@ export function PlanningTestProvider({children}:{children:ReactNode}) {
|
||||
},[]);
|
||||
const begin=useCallback(async(draft:Draft)=>{
|
||||
generation.current+=1;setBusy(true);setError(null);
|
||||
try {const next=await plannerRequest<PlanningLiveTest>(endpoint,{method:'POST',body:JSON.stringify({draft_id:draft.id,revision:draft.revision})});setTest(next);setDismissed(null);sessionStorage.removeItem('planning-dismissed');return next;}
|
||||
try {const next=await plannerRequest<PlanningLiveTest>(endpoint,{method:'POST',body:JSON.stringify({draft_id:draft.id,revision:draft.revision})});setTest(next);return next;}
|
||||
catch(e){setError(e instanceof Error?e.message:'Не удалось подготовить тест.');return null;}
|
||||
finally{generation.current+=1;setBusy(false);}
|
||||
},[]);
|
||||
@@ -64,8 +63,12 @@ export function PlanningTestProvider({children}:{children:ReactNode}) {
|
||||
catch(e){setError(e instanceof Error?e.message:'Не удалось переинициализировать привязку.');}
|
||||
finally{generation.current+=1;setBusy(false);}
|
||||
},[test]);
|
||||
const resume=useCallback(()=>{setDismissed(null);sessionStorage.removeItem('planning-dismissed');},[]);
|
||||
return <Context.Provider value={{test,history,select,selected:!!test&&dismissed!==test.id,busy,error:error??pollError,begin,finish,retryInitialization,resume}}>{children}</Context.Provider>;
|
||||
return <Context.Provider value={{test,history,select,busy,error:error??pollError,begin,finish,retryInitialization}}>{children}</Context.Provider>;
|
||||
}
|
||||
export function usePlanningTest(){const value=useContext(Context);if(!value)throw new Error('PlanningTestProvider missing');return value;}
|
||||
export function planningTestTerminal(test:PlanningLiveTest){return terminal.has(test.state);}
|
||||
|
||||
/** A prepared consumer owns the next capture until explicitly finished or used. */
|
||||
export function planningAwaitsCapture(test:PlanningLiveTest|null){
|
||||
return !!test&&!planningTestTerminal(test)&&!test.query_session_id;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
/** Entry intent belongs to application navigation, not a retained server job. */
|
||||
export type WorkspaceLaunchProfile = 'direct' | 'planning';
|
||||
|
||||
export function workspaceLaunchProfile(
|
||||
current: WorkspaceLaunchProfile,
|
||||
kind: string,
|
||||
requested: WorkspaceLaunchProfile = 'direct',
|
||||
): WorkspaceLaunchProfile {
|
||||
return kind === 'device' || kind === 'spatial' ? requested : current;
|
||||
}
|
||||
@@ -1,4 +1,3 @@
|
||||
import { PlanningConnectionWindow } from "../components/missions/PlanningConnectionWindow";
|
||||
import { Button, GlassSurface, Icon, StatusBadge } from "@nodedc/ui-react";
|
||||
|
||||
import { useDevicePluginHost } from "../core/device-plugins/DevicePluginHost";
|
||||
@@ -41,7 +40,7 @@ function ModelCard({
|
||||
);
|
||||
}
|
||||
|
||||
function DeviceConnectionBody({
|
||||
export function DeviceWorkspace({
|
||||
onOpenSpatialScene,
|
||||
onActivateAutomaticSpatialSource,
|
||||
}: {
|
||||
@@ -129,7 +128,3 @@ function DeviceConnectionBody({
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function DeviceWorkspace(props: {onOpenSpatialScene:()=>void;onActivateAutomaticSpatialSource:()=>void}) {
|
||||
return <PlanningConnectionWindow><DeviceConnectionBody {...props}/></PlanningConnectionWindow>;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import {SpatialWorkspace} from "./spatial/SpatialWorkspace";
|
||||
import { usePlanningTest } from "../core/missions/PlanningTestContext";
|
||||
import { PlanningSpatialWorkspace } from "./missions/PlanningSpatialWorkspace";
|
||||
import { MissionPlannerWorkspace } from "./missions/MissionPlannerWorkspace";
|
||||
import { VehiclesWorkspace } from "./fleet/VehiclesWorkspace";
|
||||
@@ -416,10 +415,9 @@ function RecordingsWorkspace(props: WorkspaceRendererProps) {
|
||||
}
|
||||
|
||||
export function WorkspaceRenderer(props: WorkspaceRendererProps) {
|
||||
const planning = usePlanningTest();
|
||||
switch (props.definition.kind) {
|
||||
case "spatial":
|
||||
return planning.selected ? <PlanningSpatialWorkspace {...props}/> : <SpatialWorkspace {...props} />;
|
||||
return props.launchProfile === 'planning' ? <PlanningSpatialWorkspace {...props}/> : <SpatialWorkspace {...props} />;
|
||||
case "recordings":
|
||||
return <RecordingsWorkspace {...props} />;
|
||||
case "cameras":
|
||||
|
||||
@@ -14,9 +14,10 @@ import type {
|
||||
} from "../core/runtime/contracts";
|
||||
import type { WorkspaceDefinition } from "../productModel";
|
||||
import type { SceneSettings } from "../sceneSettings";
|
||||
import type { WorkspaceLaunchProfile } from "../core/observation/workspaceLaunch";
|
||||
|
||||
export interface WorkspaceNavigation {
|
||||
openView: (viewId: string) => void;
|
||||
openView: (viewId: string, profile?: WorkspaceLaunchProfile) => void;
|
||||
openSource: () => void;
|
||||
openDisplay: () => void;
|
||||
openLayers: () => void;
|
||||
@@ -35,6 +36,7 @@ export interface LaboratoryViewAction {
|
||||
}
|
||||
|
||||
export interface WorkspaceRendererProps {
|
||||
launchProfile?: WorkspaceLaunchProfile;
|
||||
fleetCreateRequest?: number;
|
||||
headerToolsHost?: HTMLElement | null;
|
||||
definition: WorkspaceDefinition;
|
||||
|
||||
@@ -15,7 +15,7 @@ import { PlanningProjectSelect } from '../../components/missions/PlanningProject
|
||||
import '../../styles/session-overview.css';
|
||||
import '../../styles/mission-planner.css';
|
||||
|
||||
export function MissionPlannerWorkspace({openView,headerToolsHost}:{openView:(id:string)=>void;headerToolsHost?:HTMLElement|null}) {
|
||||
export function MissionPlannerWorkspace({openView,headerToolsHost}:{openView:(id:string,profile?:'planning')=>void;headerToolsHost?:HTMLElement|null}) {
|
||||
const live=usePlanningTest(), p=useMissionPlanner(), projects=usePlanningProjects();
|
||||
const t=useRegistrationTest(p.saved);
|
||||
const [creating,setCreating]=useState(false), [settingsOpen,setSettingsOpen]=useState(false);
|
||||
@@ -49,7 +49,7 @@ export function MissionPlannerWorkspace({openView,headerToolsHost}:{openView:(id
|
||||
if(!draft)return;
|
||||
if(mode==='scanner') {
|
||||
const next=await live.begin(draft);
|
||||
if(next){projects.select('live:'+next.id);openView('local-device');}
|
||||
if(next){projects.select('live:'+next.id);openView('local-device','planning');}
|
||||
} else {
|
||||
const result=await t.run(draft);
|
||||
if(result){setCreating(false);projects.select('recorded:'+result.id);setSettingsOpen(true);}
|
||||
@@ -98,7 +98,7 @@ export function MissionPlannerWorkspace({openView,headerToolsHost}:{openView:(id
|
||||
className="planning-project__inspector">
|
||||
{editing?<PlanningProjectSettings p={p} t={t} mode={mode} setMode={setMode} onStart={()=>void start()} starting={starting}/>
|
||||
:project?<PlanningProjectResult project={project}/>:<p>Выберите проект или создайте новый кнопкой «+».</p>}
|
||||
{project?.kind==='live'&&planningProjectPending(project)&&<Button disabled={live.busy} onClick={async()=>{await live.select(project.id);live.resume();openView(project.state==='preparing'||project.state==='waiting'?'local-device':'spatial-scene');}}>Открыть текущий проход</Button>}
|
||||
{project?.kind==='live'&&planningProjectPending(project)&&<Button disabled={live.busy} onClick={async()=>{if(await live.select(project.id))openView(project.state==='preparing'||project.state==='waiting'?'local-device':'spatial-scene','planning');}}>Открыть текущий проход</Button>}
|
||||
{live.error&&editing&&<p role="alert">{live.error}</p>}
|
||||
</WorkspaceWindow>}
|
||||
</div>
|
||||
|
||||
@@ -39,6 +39,7 @@ export interface SpatialWorkspaceProfile {
|
||||
activity?: import("../../core/device-plugins/contracts").SpatialActivityPresentation;
|
||||
}
|
||||
export function SpatialWorkspace({
|
||||
launchProfile = 'direct',
|
||||
state,
|
||||
sourceUrl,
|
||||
requestedPlaybackSeconds,
|
||||
@@ -441,7 +442,7 @@ export function SpatialWorkspace({
|
||||
model={spatialControls.model}
|
||||
spatialActivity={visualProfile?.activity}
|
||||
host={{
|
||||
openSpatialScene: () => navigation.openView("spatial-scene"),
|
||||
openSpatialScene: () => navigation.openView("spatial-scene", launchProfile),
|
||||
activateAutomaticSpatialSource: navigation.activateAutomaticSpatialSource,
|
||||
}}
|
||||
/>):null}
|
||||
|
||||
@@ -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/);
|
||||
});
|
||||
@@ -113,6 +113,45 @@ Owns shell-level orchestration: selected root/workspace, global panels, runtime
|
||||
providers, and passing typed controllers to a workspace. It must not absorb
|
||||
domain API calls, per-LAB renderers, or new visual primitives.
|
||||
|
||||
## Shared LAB launch profiles — 2026-09-21
|
||||
|
||||
Test devices and Spatial scene are shared infrastructure, not children of the
|
||||
planner. `DeviceWorkspace` imports only the generic device host and its UI
|
||||
contracts; it must render without a `PlanningTestProvider`.
|
||||
|
||||
- Direct navigation to either surface selects the `direct` launch profile.
|
||||
A page reload also starts with `direct`, even if the server retains a completed
|
||||
or interrupted planning run. Model selection remains the device host's state.
|
||||
- A successful planner start or explicit reopen passes `planning` through
|
||||
`WorkspaceNavigation.openView`. The shell composes `PlanningConnectionWindow`
|
||||
only for that entry. Device connection and spatial-control callbacks preserve
|
||||
the originating profile when opening the scene.
|
||||
- `PlanningTestProvider` owns run data and polling, not navigation selection.
|
||||
Reading `/live-tests/active` must never change the launch profile. An unsuccessful
|
||||
explicit run selection must not open a stale run's connection or scene.
|
||||
- A nonterminal planning consumer that has not bound a query session still waits
|
||||
for the next capture. The composition-level `PlanningCaptureGuard` requires an
|
||||
explicit return to that study or completion of the study before direct capture;
|
||||
it does not send scanner, network, or recording commands. It waits for a terminal
|
||||
server state, not merely acknowledgment of the stop request. Completed runs and
|
||||
runs already bound to a recording do not claim a later direct launch.
|
||||
|
||||
Changing presentation does not delete evidence, end acquisition, or restart an
|
||||
experiment. The planner continues to consume an explicitly started recording;
|
||||
ordinary acquisition requires neither a reference route nor a planner draft.
|
||||
|
||||
Regression coverage: `test/workspaceLaunch.test.mjs`, including independent model
|
||||
catalog rendering, explicit handoff, terminal-run isolation, failed selection,
|
||||
and both shared-scene entry paths.
|
||||
|
||||
Acceptance on the canonical operator service `127.0.0.1:8000`: architecture
|
||||
checks, TypeScript, all 882 frontend tests and production build passed. In-app
|
||||
browser QA confirmed direct entry with retained completed evidence, catalog →
|
||||
XGRIDS connection, the unchanged connection-method selector and Escape,
|
||||
normal/expanded layouts, ordinary scene entry, and planner → direct-device
|
||||
navigation. No BLE discovery, provisioning, acquisition or new field run was
|
||||
performed during this UI acceptance.
|
||||
|
||||
## CSS ownership
|
||||
|
||||
CSS follows the same feature boundary:
|
||||
|
||||
Reference in New Issue
Block a user