fix(lab): separate direct device launches from planning profiles
This commit is contained in:
@@ -69,6 +69,9 @@ import {
|
|||||||
type SceneSettings,
|
type SceneSettings,
|
||||||
} from "./sceneSettings";
|
} from "./sceneSettings";
|
||||||
import { DeviceWorkspace } from "./workspaces/DeviceWorkspace";
|
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 { WorkspaceRenderer } from "./workspaces/Workspaces";
|
||||||
import { useLaboratoryAnnotationHeader } from "./components/laboratory/useLaboratoryAnnotationHeader";
|
import { useLaboratoryAnnotationHeader } from "./components/laboratory/useLaboratoryAnnotationHeader";
|
||||||
import "./styles/scene-windows.css";
|
import "./styles/scene-windows.css";
|
||||||
@@ -151,6 +154,7 @@ export default function App() {
|
|||||||
polygonDatasetRoute.active ? "data" : null,
|
polygonDatasetRoute.active ? "data" : null,
|
||||||
);
|
);
|
||||||
const [environmentSettingsOpen, setEnvironmentSettingsOpen] = useState(false);
|
const [environmentSettingsOpen, setEnvironmentSettingsOpen] = useState(false);
|
||||||
|
const [launchProfile, setLaunchProfile] = useState<WorkspaceLaunchProfile>('direct');
|
||||||
const [sourceUrl, setSourceUrl] = useState("");
|
const [sourceUrl, setSourceUrl] = useState("");
|
||||||
const [workspaceHeaderToolsHost, setWorkspaceHeaderToolsHost] = useState<HTMLDivElement | null>(null);
|
const [workspaceHeaderToolsHost, setWorkspaceHeaderToolsHost] = useState<HTMLDivElement | null>(null);
|
||||||
const [recordedReplay, setRecordedReplay] = useState<ObservationSessionReplayLaunch | 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);
|
const definition = workspaceById(viewId);
|
||||||
if (!definition) return;
|
if (!definition) return;
|
||||||
|
setLaunchProfile(current => workspaceLaunchProfile(current, definition.kind, profile));
|
||||||
setActiveRoot(definition.root);
|
setActiveRoot(definition.root);
|
||||||
workspace.openView(viewId);
|
workspace.openView(viewId);
|
||||||
};
|
};
|
||||||
@@ -805,16 +810,26 @@ export default function App() {
|
|||||||
}
|
}
|
||||||
utilityActions={contentActions}
|
utilityActions={contentActions}
|
||||||
onClose={workspace.closeView}
|
onClose={workspace.closeView}
|
||||||
|
>
|
||||||
|
<PlanningCaptureGuard
|
||||||
|
enabled={launchProfile === 'direct' && (activeDefinition.kind === 'device' || activeDefinition.kind === 'spatial')}
|
||||||
|
onResume={() => openView('local-device', 'planning')}
|
||||||
>
|
>
|
||||||
{activeDefinition.kind === "device" ? (
|
{activeDefinition.kind === "device" ? (
|
||||||
|
launchProfile === 'planning' ? <PlanningConnectionWindow>
|
||||||
<DeviceWorkspace
|
<DeviceWorkspace
|
||||||
onOpenSpatialScene={() => openView("spatial-scene")}
|
onOpenSpatialScene={() => openView("spatial-scene", 'planning')}
|
||||||
|
onActivateAutomaticSpatialSource={activateAutomaticSpatialSource}
|
||||||
|
/>
|
||||||
|
</PlanningConnectionWindow> : <DeviceWorkspace
|
||||||
|
onOpenSpatialScene={() => openView("spatial-scene", 'direct')}
|
||||||
onActivateAutomaticSpatialSource={activateAutomaticSpatialSource}
|
onActivateAutomaticSpatialSource={activateAutomaticSpatialSource}
|
||||||
/>
|
/>
|
||||||
) : activeDefinition.kind === "recordings" && sessionOverview.open && recordedReplay && replayPresented ? (
|
) : activeDefinition.kind === "recordings" && sessionOverview.open && recordedReplay && replayPresented ? (
|
||||||
<SessionOverviewWorkspace key={recordedReplay.sessionId} sessionId={recordedReplay.sessionId} />
|
<SessionOverviewWorkspace key={recordedReplay.sessionId} sessionId={recordedReplay.sessionId} />
|
||||||
) : (
|
) : (
|
||||||
<WorkspaceRenderer
|
<WorkspaceRenderer
|
||||||
|
launchProfile={launchProfile}
|
||||||
definition={activeDefinition}
|
definition={activeDefinition}
|
||||||
fleetCreateRequest={fleetCreateRequest}
|
fleetCreateRequest={fleetCreateRequest}
|
||||||
headerToolsHost={workspaceHeaderToolsHost}
|
headerToolsHost={workspaceHeaderToolsHost}
|
||||||
@@ -858,6 +873,7 @@ export default function App() {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
</PlanningCaptureGuard>
|
||||||
</ApplicationPanel>
|
</ApplicationPanel>
|
||||||
) : null}
|
) : 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 p=usePlanningTest(); const [open,setOpen]=useState(true);
|
||||||
const {selection,registry,selectModel,selectionTransitionPending}=useDevicePluginHost();
|
const {selection,registry,selectModel,selectionTransitionPending}=useDevicePluginHost();
|
||||||
useEffect(()=>{
|
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);
|
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);
|
if(models.length===1)void selectModel(models[0].model.id);
|
||||||
},[p.selected,p.test?.plugin_id,selection,registry,selectModel,selectionTransitionPending]);
|
},[p.test?.plugin_id,p.test?.state,selection,registry,selectModel,selectionTransitionPending]);
|
||||||
if(!p.selected||!p.test)return <>{children}</>;
|
if(!p.test)return <>{children}</>;
|
||||||
const t=p.test, terminal=planningTestTerminal(t);
|
const t=p.test, terminal=planningTestTerminal(t);
|
||||||
const details=<><StatusBadge tone="neutral">Профиль · Планирование</StatusBadge>
|
const details=<><StatusBadge tone="neutral">Профиль · Планирование</StatusBadge>
|
||||||
<p>{t.draft.name} · эталон {t.draft.zone.label} · {t.draft.route.length_m.toFixed(1)} м</p>
|
<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 endpoint = plannerBase+'/live-tests';
|
||||||
const terminal = new Set(['completed','cancelled','error','interrupted']);
|
const terminal = new Set(['completed','cancelled','error','interrupted']);
|
||||||
const Context = createContext<{
|
const Context = createContext<{
|
||||||
test: PlanningLiveTest|null; history:PlanningRunSummary[]; select:(id:string)=>Promise<void>; selected: boolean; busy:boolean; error:string|null;
|
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>; resume:()=>void;
|
begin:(draft:Draft)=>Promise<PlanningLiveTest|null>; finish:()=>Promise<void>; retryInitialization:()=>Promise<void>;
|
||||||
}|null>(null);
|
}|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}) {
|
export function PlanningTestProvider({children}:{children:ReactNode}) {
|
||||||
const [test,setTest]=useState<PlanningLiveTest|null>(null);
|
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 [busy,setBusy]=useState(false),[error,setError]=useState<string|null>(null);
|
||||||
const [pollError,setPollError]=useState<string|null>(null);
|
const [pollError,setPollError]=useState<string|null>(null);
|
||||||
const [history,setHistory]=useState<PlanningRunSummary[]>([]);
|
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]);
|
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)=>{
|
const select=useCallback(async(id:string)=>{
|
||||||
generation.current+=1;setBusy(true);setError(null);
|
generation.current+=1;setBusy(true);setError(null);
|
||||||
try{setTest(await plannerRequest<PlanningLiveTest>(endpoint+'/'+id+'/select',{method:'POST'}));setDismissed(null);}
|
try{setTest(await plannerRequest<PlanningLiveTest>(endpoint+'/'+id+'/select',{method:'POST'}));return true;}
|
||||||
catch(e){setError(e instanceof Error?e.message:'Не удалось открыть исследование.');}
|
catch(e){setError(e instanceof Error?e.message:'Не удалось открыть исследование.');return false;}
|
||||||
finally{generation.current+=1;setBusy(false);}
|
finally{generation.current+=1;setBusy(false);}
|
||||||
},[]);
|
},[]);
|
||||||
useEffect(()=>{
|
useEffect(()=>{
|
||||||
@@ -48,7 +47,7 @@ export function PlanningTestProvider({children}:{children:ReactNode}) {
|
|||||||
},[]);
|
},[]);
|
||||||
const begin=useCallback(async(draft:Draft)=>{
|
const begin=useCallback(async(draft:Draft)=>{
|
||||||
generation.current+=1;setBusy(true);setError(null);
|
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;}
|
catch(e){setError(e instanceof Error?e.message:'Не удалось подготовить тест.');return null;}
|
||||||
finally{generation.current+=1;setBusy(false);}
|
finally{generation.current+=1;setBusy(false);}
|
||||||
},[]);
|
},[]);
|
||||||
@@ -64,8 +63,12 @@ export function PlanningTestProvider({children}:{children:ReactNode}) {
|
|||||||
catch(e){setError(e instanceof Error?e.message:'Не удалось переинициализировать привязку.');}
|
catch(e){setError(e instanceof Error?e.message:'Не удалось переинициализировать привязку.');}
|
||||||
finally{generation.current+=1;setBusy(false);}
|
finally{generation.current+=1;setBusy(false);}
|
||||||
},[test]);
|
},[test]);
|
||||||
const resume=useCallback(()=>{setDismissed(null);sessionStorage.removeItem('planning-dismissed');},[]);
|
return <Context.Provider value={{test,history,select,busy,error:error??pollError,begin,finish,retryInitialization}}>{children}</Context.Provider>;
|
||||||
return <Context.Provider value={{test,history,select,selected:!!test&&dismissed!==test.id,busy,error:error??pollError,begin,finish,retryInitialization,resume}}>{children}</Context.Provider>;
|
|
||||||
}
|
}
|
||||||
export function usePlanningTest(){const value=useContext(Context);if(!value)throw new Error('PlanningTestProvider missing');return value;}
|
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);}
|
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 { Button, GlassSurface, Icon, StatusBadge } from "@nodedc/ui-react";
|
||||||
|
|
||||||
import { useDevicePluginHost } from "../core/device-plugins/DevicePluginHost";
|
import { useDevicePluginHost } from "../core/device-plugins/DevicePluginHost";
|
||||||
@@ -41,7 +40,7 @@ function ModelCard({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function DeviceConnectionBody({
|
export function DeviceWorkspace({
|
||||||
onOpenSpatialScene,
|
onOpenSpatialScene,
|
||||||
onActivateAutomaticSpatialSource,
|
onActivateAutomaticSpatialSource,
|
||||||
}: {
|
}: {
|
||||||
@@ -129,7 +128,3 @@ function DeviceConnectionBody({
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function DeviceWorkspace(props: {onOpenSpatialScene:()=>void;onActivateAutomaticSpatialSource:()=>void}) {
|
|
||||||
return <PlanningConnectionWindow><DeviceConnectionBody {...props}/></PlanningConnectionWindow>;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import {SpatialWorkspace} from "./spatial/SpatialWorkspace";
|
import {SpatialWorkspace} from "./spatial/SpatialWorkspace";
|
||||||
import { usePlanningTest } from "../core/missions/PlanningTestContext";
|
|
||||||
import { PlanningSpatialWorkspace } from "./missions/PlanningSpatialWorkspace";
|
import { PlanningSpatialWorkspace } from "./missions/PlanningSpatialWorkspace";
|
||||||
import { MissionPlannerWorkspace } from "./missions/MissionPlannerWorkspace";
|
import { MissionPlannerWorkspace } from "./missions/MissionPlannerWorkspace";
|
||||||
import { VehiclesWorkspace } from "./fleet/VehiclesWorkspace";
|
import { VehiclesWorkspace } from "./fleet/VehiclesWorkspace";
|
||||||
@@ -416,10 +415,9 @@ function RecordingsWorkspace(props: WorkspaceRendererProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function WorkspaceRenderer(props: WorkspaceRendererProps) {
|
export function WorkspaceRenderer(props: WorkspaceRendererProps) {
|
||||||
const planning = usePlanningTest();
|
|
||||||
switch (props.definition.kind) {
|
switch (props.definition.kind) {
|
||||||
case "spatial":
|
case "spatial":
|
||||||
return planning.selected ? <PlanningSpatialWorkspace {...props}/> : <SpatialWorkspace {...props} />;
|
return props.launchProfile === 'planning' ? <PlanningSpatialWorkspace {...props}/> : <SpatialWorkspace {...props} />;
|
||||||
case "recordings":
|
case "recordings":
|
||||||
return <RecordingsWorkspace {...props} />;
|
return <RecordingsWorkspace {...props} />;
|
||||||
case "cameras":
|
case "cameras":
|
||||||
|
|||||||
@@ -14,9 +14,10 @@ import type {
|
|||||||
} from "../core/runtime/contracts";
|
} from "../core/runtime/contracts";
|
||||||
import type { WorkspaceDefinition } from "../productModel";
|
import type { WorkspaceDefinition } from "../productModel";
|
||||||
import type { SceneSettings } from "../sceneSettings";
|
import type { SceneSettings } from "../sceneSettings";
|
||||||
|
import type { WorkspaceLaunchProfile } from "../core/observation/workspaceLaunch";
|
||||||
|
|
||||||
export interface WorkspaceNavigation {
|
export interface WorkspaceNavigation {
|
||||||
openView: (viewId: string) => void;
|
openView: (viewId: string, profile?: WorkspaceLaunchProfile) => void;
|
||||||
openSource: () => void;
|
openSource: () => void;
|
||||||
openDisplay: () => void;
|
openDisplay: () => void;
|
||||||
openLayers: () => void;
|
openLayers: () => void;
|
||||||
@@ -35,6 +36,7 @@ export interface LaboratoryViewAction {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface WorkspaceRendererProps {
|
export interface WorkspaceRendererProps {
|
||||||
|
launchProfile?: WorkspaceLaunchProfile;
|
||||||
fleetCreateRequest?: number;
|
fleetCreateRequest?: number;
|
||||||
headerToolsHost?: HTMLElement | null;
|
headerToolsHost?: HTMLElement | null;
|
||||||
definition: WorkspaceDefinition;
|
definition: WorkspaceDefinition;
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import { PlanningProjectSelect } from '../../components/missions/PlanningProject
|
|||||||
import '../../styles/session-overview.css';
|
import '../../styles/session-overview.css';
|
||||||
import '../../styles/mission-planner.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 live=usePlanningTest(), p=useMissionPlanner(), projects=usePlanningProjects();
|
||||||
const t=useRegistrationTest(p.saved);
|
const t=useRegistrationTest(p.saved);
|
||||||
const [creating,setCreating]=useState(false), [settingsOpen,setSettingsOpen]=useState(false);
|
const [creating,setCreating]=useState(false), [settingsOpen,setSettingsOpen]=useState(false);
|
||||||
@@ -49,7 +49,7 @@ export function MissionPlannerWorkspace({openView,headerToolsHost}:{openView:(id
|
|||||||
if(!draft)return;
|
if(!draft)return;
|
||||||
if(mode==='scanner') {
|
if(mode==='scanner') {
|
||||||
const next=await live.begin(draft);
|
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 {
|
} else {
|
||||||
const result=await t.run(draft);
|
const result=await t.run(draft);
|
||||||
if(result){setCreating(false);projects.select('recorded:'+result.id);setSettingsOpen(true);}
|
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">
|
className="planning-project__inspector">
|
||||||
{editing?<PlanningProjectSettings p={p} t={t} mode={mode} setMode={setMode} onStart={()=>void start()} starting={starting}/>
|
{editing?<PlanningProjectSettings p={p} t={t} mode={mode} setMode={setMode} onStart={()=>void start()} starting={starting}/>
|
||||||
:project?<PlanningProjectResult project={project}/>:<p>Выберите проект или создайте новый кнопкой «+».</p>}
|
: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>}
|
{live.error&&editing&&<p role="alert">{live.error}</p>}
|
||||||
</WorkspaceWindow>}
|
</WorkspaceWindow>}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ export interface SpatialWorkspaceProfile {
|
|||||||
activity?: import("../../core/device-plugins/contracts").SpatialActivityPresentation;
|
activity?: import("../../core/device-plugins/contracts").SpatialActivityPresentation;
|
||||||
}
|
}
|
||||||
export function SpatialWorkspace({
|
export function SpatialWorkspace({
|
||||||
|
launchProfile = 'direct',
|
||||||
state,
|
state,
|
||||||
sourceUrl,
|
sourceUrl,
|
||||||
requestedPlaybackSeconds,
|
requestedPlaybackSeconds,
|
||||||
@@ -441,7 +442,7 @@ export function SpatialWorkspace({
|
|||||||
model={spatialControls.model}
|
model={spatialControls.model}
|
||||||
spatialActivity={visualProfile?.activity}
|
spatialActivity={visualProfile?.activity}
|
||||||
host={{
|
host={{
|
||||||
openSpatialScene: () => navigation.openView("spatial-scene"),
|
openSpatialScene: () => navigation.openView("spatial-scene", launchProfile),
|
||||||
activateAutomaticSpatialSource: navigation.activateAutomaticSpatialSource,
|
activateAutomaticSpatialSource: navigation.activateAutomaticSpatialSource,
|
||||||
}}
|
}}
|
||||||
/>):null}
|
/>):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
|
providers, and passing typed controllers to a workspace. It must not absorb
|
||||||
domain API calls, per-LAB renderers, or new visual primitives.
|
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 ownership
|
||||||
|
|
||||||
CSS follows the same feature boundary:
|
CSS follows the same feature boundary:
|
||||||
|
|||||||
Reference in New Issue
Block a user