feat(observation): expose offline sources with transparent viewport headers

This commit is contained in:
DCCONSTRUCTIONS
2026-09-25 19:14:21 +03:00
parent db3b1d7b67
commit 8283f9ba85
13 changed files with 124 additions and 30 deletions
@@ -1,12 +1,13 @@
export interface ObservationLayout {
version:1; order:string[]; hidden:string[]; layers:Record<string,string>;
splits:Record<string,number>; arrangement:'auto'|'columns'|'rows';
catalogVisible?:string[]; splits:Record<string,number>; arrangement:'auto'|'columns'|'rows';
}
export const emptyObservationLayout=():ObservationLayout=>({version:1,order:[],hidden:[],layers:{},splits:{},arrangement:'auto'});
const record=(value:unknown):Record<string,unknown>=>value!==null&&typeof value==='object'&&!Array.isArray(value)?value as Record<string,unknown>:{};
export function decodeObservationLayout(value:unknown):ObservationLayout {
const source=record(value),result=emptyObservationLayout();if(source.version!==1)return result;
const ids=(value:unknown)=>Array.isArray(value)?[...new Set(value.filter((v):v is string=>typeof v==='string'&&v.length<512))].slice(0,128):[];
if(source.catalogVisible!==undefined)result.catalogVisible=ids(source.catalogVisible);
result.order=ids(source.order);result.hidden=ids(source.hidden);
result.layers=Object.fromEntries(Object.entries(record(source.layers)).filter(([k,v])=>k.length<512&&typeof v==='string'&&v.length<128).slice(0,128)) as Record<string,string>;
result.splits=Object.fromEntries(Object.entries(record(source.splits)).filter(([k,v])=>k.length<4096&&typeof v==='number'&&Number.isFinite(v)).slice(0,256).map(([k,v])=>[k,Math.min(80,Math.max(20,v as number))]));
@@ -7,6 +7,10 @@ import {decodeObservationLayout,emptyObservationLayout,moveObservation,orderedOb
import {createIsolatedRerunHost} from '../../../components/rerun/isolatedRerunHost';
import {CameraObservation} from '../../../../../../packages/sensor-ui/src/CameraObservation';
import {cameraObservationViews,observationKey,type SensorObservationView,type ObservationHeaderTargets} from '../../../../../../packages/sensor-ui/src/observation';
import {unboundObservationViews} from '../../../../../../packages/sensor-ui/src/observationCatalog';
import {sensorContribution} from '../../../../../../packages/sensor-ui/src/extensions';
import {ObservationDeviceSettings} from './ObservationDeviceSettings';
import {ObservationPanelHeader} from './ObservationPanelHeader';
import {ObservationDeck,ObservationMount} from './ObservationDeck';
import {ObservationMap} from './ObservationMap';
import {useObservationInventory} from './useObservationInventory';
@@ -19,62 +23,65 @@ export function BoardObservationCenter({vehicleID,name,enabled,back,configure,he
const {registry}=useDevicePluginHost(),transport=useMemo(()=>createFleetSensorTransport(vehicleID),[vehicleID]);
const inventory=useObservationInventory(transport),storageKey=`missioncore.observation.v1:${vehicleID}`;
const [layout,setLayout]=useState(()=>{try{return decodeObservationLayout(JSON.parse(localStorage.getItem(storageKey)??'null'));}catch{return emptyObservationLayout();}});
const [layersOpen,setLayersOpen]=useState(false),[focused,setFocused]=useState<string|null>(null);
const [settingsDevice,setSettingsDevice]=useState<string|null>(null);
const [layersOpen,setLayersOpen]=useState(false);
const [full,setFull]=useState<string|null>(null),[expanded,setExpanded]=useState(false);
const frame=useRef<HTMLDivElement>(null),[portal]=useState(()=>document.createElement('div'));
useLayoutEffect(()=>{(expanded?document.body:frame.current!).append(portal);return()=>portal.remove();},[expanded,portal]);
const mounts=useRef(new Map<string,HTMLElement>()),media=useRef(new Map<string,HTMLElement>()),headers=useRef(new Map<string,ObservationHeaderTargets>());
const groups=(inventory?.items??[]).flatMap(device=>{
const contribution=registry.sensorContributions.find(item=>item.kind===device.kind)?.observation;
const contribution=sensorContribution(registry.sensorContributions,device)?.observation;
const views=contribution?.views(device)??cameraObservationViews(device);
return views.length?[{device,views,Session:contribution?.Session??CameraObservation}]:[];
});
const sources:{key:string;deviceID?:string;view:SensorObservationView}[]=groups.flatMap(group=>group.views.map(view=>({key:observationKey(group.device.id,view.id),deviceID:group.device.id,view})));
const sources:{key:string;deviceID?:string;catalog?:string;view:SensorObservationView}[]=groups.flatMap(group=>group.views.map(view=>({key:observationKey(group.device.id,view.id),deviceID:group.device.id,view})));
sources.push(...unboundObservationViews(inventory?.items??[],registry.sensorContributions));
sources.push({key:'map',view:{id:'map',label:'Карта'}},{key:'rover',view:{id:'rover',label:'3D View аппарата'}},{key:'telemetry',view:{id:'telemetry',label:'Телеметрия'}});
for(const source of sources){if(!mounts.current.has(source.key)){mounts.current.set(source.key,document.createElement('div'));media.current.set(source.key,document.createElement('div'));headers.current.set(source.key,{statusTarget:document.createElement('div'),actionsTarget:document.createElement('div')});mounts.current.get(source.key)!.className='observation-panel-mount';media.current.get(source.key)!.className='observation-media-mount';}}
const ids=orderedObservationIDs(layout,sources.map(source=>source.key)),visible=ids.filter(id=>!layout.hidden.includes(id));
const ids=orderedObservationIDs(layout,sources.map(source=>source.key)),visible=ids.filter(id=>!layout.hidden.includes(id)&&(!sources.find(source=>source.key===id)?.catalog||layout.catalogVisible?.includes(id)));
const effectiveFull=full&&visible.includes(full)?full:null;
const rover=useRoverControl(vehicleID,enabled&&(visible.includes('rover')||visible.includes('telemetry')));
const drivingVisible=visible.includes('rover')&&(!effectiveFull||effectiveFull==='rover')&&!layersOpen;
const drivingVisible=visible.includes('rover')&&(!effectiveFull||effectiveFull==='rover')&&!layersOpen&&!settingsDevice;
useEffect(()=>{if(!drivingVisible)rover.stop();},[drivingVisible,rover.stop]);
const active=sources.find(source=>source.key===focused);
const configuredDevice=inventory?.items.find(device=>device.id===settingsDevice);
useEffect(()=>{try{localStorage.setItem(storageKey,JSON.stringify(layout));}catch{/* The workspace remains usable without browser storage. */}},[storageKey,layout]);
useEffect(()=>{const escape=(event:KeyboardEvent)=>{if(event.key!=='Escape'||layersOpen||document.querySelector('[role=dialog], [role=listbox]'))return;if(effectiveFull){setFull(null);event.stopImmediatePropagation();}else if(expanded){setExpanded(false);event.stopImmediatePropagation();}};window.addEventListener('keydown',escape,true);return()=>window.removeEventListener('keydown',escape,true);},[effectiveFull,expanded,layersOpen]);
const setVisible=(id:string,show:boolean)=>setLayout(value=>({...value,hidden:show?value.hidden.filter(key=>key!==id):[...new Set([...value.hidden,id])]}));
const setVisible=(id:string,show:boolean)=>setLayout(value=>({...value,catalogVisible:show?[...new Set([...(value.catalogVisible??[]),...(sources.find(source=>source.key===id)?.catalog?[id]:[])])]:(value.catalogVisible??[]).filter(key=>key!==id),hidden:show?value.hidden.filter(key=>key!==id):[...new Set([...value.hidden,id])]}));
const layerFor=(key:string,view:SensorObservationView)=>view.layers?.find(layer=>layer.value===layout.layers[key])?.value??view.layers?.[0]?.value??view.id;
return <>{headerToolsHost&&createPortal(<IconButton label="К аппаратам" onClick={back}><Icon name="chevron-left"/></IconButton>,headerToolsHost)}<div ref={frame}/>{createPortal(<section className={`observation-center${expanded?' observation-center--expanded':''}`} aria-label={`Центр наблюдения и управления · ${name}`}>
<header className="observation-toolbar"><div><h2>Центр наблюдения и управления</h2><span>{name}</span></div><div className="observation-actions">{(!headerToolsHost||expanded)&&<IconButton label="К аппаратам" onClick={back}><Icon name="chevron-left"/></IconButton>}<Button onClick={configure}><Icon name="sliders"/>Конфигуратор</Button><Button onClick={()=>{setFocused(null);setLayersOpen(true);}}><Icon name="grid"/>Доступные слои</Button><IconButton label={expanded?'Восстановить размер центра':'Развернуть центр наблюдения и управления'} onClick={()=>setExpanded(value=>!value)}><Icon name={expanded?'minimize':'expand'}/></IconButton></div></header>
<header className="observation-toolbar"><div><h2>Центр наблюдения и управления</h2><span>{name}</span></div><div className="observation-actions">{(!headerToolsHost||expanded)&&<IconButton label="К аппаратам" onClick={back}><Icon name="chevron-left"/></IconButton>}<Button onClick={configure}><Icon name="sliders"/>Конфигуратор</Button><Button onClick={()=>{setLayersOpen(true);}}><Icon name="grid"/>Доступные слои</Button><IconButton label={expanded?'Восстановить размер центра':'Развернуть центр наблюдения и управления'} onClick={()=>setExpanded(value=>!value)}><Icon name={expanded?'minimize':'expand'}/></IconButton></div></header>
<div className="observation-summary"><StatusBadge tone={enabled&&inventory&&inventory.fresh!==false?'success':'neutral'}>{!inventory?'Получение источников':!enabled||inventory.fresh===false?'Нет свежих данных с БК':`Устройства: ${groups.length} · Окна: ${visible.length}`}</StatusBadge>{effectiveFull&&<Button onClick={()=>setFull(null)}>Все окна</Button>}</div>
<DragDropRoot onDragEnd={({activeId,overId})=>{if(overId)setLayout(value=>moveObservation(value,ids,activeId,overId));}}>
<div className="observation-deck">{!inventory?<LoadingRegion loading label="Получение визуальных источников"/>:visible.length?<ObservationDeck ids={effectiveFull?[effectiveFull]:visible} mounts={mounts.current} layout={layout} onSplit={(key,value)=>setLayout(current=>({...current,splits:{...current.splits,[key]:value}}))}/>:<div className="observation-empty"><p>Все окна скрыты.</p><Button onClick={()=>{setFocused(null);setLayersOpen(true);}}>Доступные слои</Button></div>}</div>
<div className="observation-deck">{!inventory?<LoadingRegion loading label="Получение визуальных источников"/>:visible.length?<ObservationDeck ids={effectiveFull?[effectiveFull]:visible} mounts={mounts.current} layout={layout} onSplit={(key,value)=>setLayout(current=>({...current,splits:{...current.splits,[key]:value}}))}/>:<div className="observation-empty"><p>Все окна скрыты.</p><Button onClick={()=>{setLayersOpen(true);}}>Доступные слои</Button></div>}</div>
{sources.filter(source=>visible.includes(source.key)).map(source=>createPortal(
<DropZone id={source.key} className="observation-panel">
<DraggableItem id={source.key} className="observation-panel-drag">{({handle})=>
<GlassSurface tone="soft" radius="panel" padding="none" materialRim={false} className="observation-panel__surface">
<header className="observation-panel__header">
<GlassSurface tone="soft" radius="panel" padding="none" materialRim={false} className="observation-panel__surface observation-panel__surface--overlay">
<ObservationPanelHeader>
{handle}
<div className="observation-panel__title"><h3 title={source.view.label}>{source.view.label}</h3><ObservationMount className="observation-header-slot" element={headers.current.get(source.key)!.statusTarget}/></div>
<div className="observation-actions">
{source.view.layers&&<Select className="observation-layer-select" label={`Слой · ${source.view.label}`} value={layerFor(source.key,source.view)} options={source.view.layers} onChange={value=>setLayout(current=>({...current,layers:{...current.layers,[source.key]:value}}))} variant="inline"/>}
<ObservationMount className="observation-header-slot" element={headers.current.get(source.key)!.actionsTarget}/>
{source.deviceID&&<IconButton label={`Настройки · ${source.view.label}`} onClick={()=>{setFocused(source.key);setLayersOpen(true);}}><Icon name="settings"/></IconButton>}
{source.deviceID&&<IconButton label={`Настройки · ${source.view.label}`} onClick={()=>setSettingsDevice(source.deviceID!)}><Icon name="settings"/></IconButton>}
<IconButton label={`${effectiveFull===source.key?'Восстановить':'Развернуть'} · ${source.view.label}`} onClick={()=>setFull(value=>value===source.key?null:source.key)}><Icon name={effectiveFull===source.key?'minimize':'expand'}/></IconButton>
<IconButton label={`Скрыть · ${source.view.label}`} onClick={()=>setVisible(source.key,false)}><Icon name="close"/></IconButton>
</div>
</header>
</ObservationPanelHeader>
<ObservationMount element={media.current.get(source.key)!}/>
</GlassSurface>
}</DraggableItem>
</DropZone>,mounts.current.get(source.key)!,source.key))}
</DragDropRoot>
{groups.map(({device,views,Session})=>visible.some(key=>views.some(view=>key===observationKey(device.id,view.id)))&&<Session key={device.id} device={device} transport={transport} enabled={enabled&&inventory?.fresh!==false&&device.online} createRerunHost={createIsolatedRerunHost} views={views.map(view=>({id:view.id,layer:layerFor(observationKey(device.id,view.id),view),target:media.current.get(observationKey(device.id,view.id))!,...headers.current.get(observationKey(device.id,view.id))!}))}/>)}
{sources.filter(source=>source.catalog&&visible.includes(source.key)).map(source=>createPortal(<div className="observation-source"><div className="observation-source__notice"><p>{source.catalog} не подключён к аппарату.</p><Button onClick={configure}>Подключить в конфигураторе</Button></div></div>,media.current.get(source.key)!,source.key))}
{configuredDevice&&<ObservationDeviceSettings key={configuredDevice.id} device={configuredDevice} transport={transport} contributions={registry.sensorContributions} enabled={enabled&&inventory?.fresh!==false&&configuredDevice.online} close={()=>setSettingsDevice(null)} configure={configure}/>}
{visible.includes('map')&&createPortal(<ObservationMap vehicleID={vehicleID} header={headers.current.get('map')!}/>,media.current.get('map')!,'map-session')}
{visible.includes('rover')&&createPortal(<RoverView vehicleID={vehicleID} controller={rover} header={headers.current.get('rover')!} active={drivingVisible}/>,media.current.get('rover')!,'rover-session')}
{visible.includes('telemetry')&&createPortal(<RoverTelemetry state={rover.state}/>,media.current.get('telemetry')!,'telemetry-session')}
<Window open={layersOpen} onClose={()=>setLayersOpen(false)} title={active?active.view.label:'Доступные слои'} size="md"><div className="observation-settings">
{!active&&<Select label="Расположение окон" value={layout.arrangement} options={[{value:'auto',label:'Автоматически'},{value:'columns',label:'В ряд'},{value:'rows',label:'Друг под другом'}]} onChange={value=>setLayout(current=>({...current,arrangement:value as typeof layout.arrangement}))}/>}
{ids.filter(id=>!active||id===active.key).map(id=>{const source=sources.find(item=>item.key===id)!;return <div className="observation-layer" key={id}><Switch label={source.view.label} checked={visible.includes(id)} onChange={value=>setVisible(id,value)}/><Select label={`Позиция · ${source.view.label}`} value={String(ids.indexOf(id))} options={ids.map((_,i)=>({value:String(i),label:`Окно ${i+1}`}))} onChange={value=>setLayout(current=>moveObservation(current,ids,id,ids[Number(value)]))}/></div>;})}
{active?.view.layers&&<Select label="Слой источника" value={layerFor(active.key,active.view)} options={active.view.layers} onChange={value=>setLayout(current=>({...current,layers:{...current.layers,[active.key]:value}}))}/>}
<Window open={layersOpen} onClose={()=>setLayersOpen(false)} title="Доступные слои" size="md"><div className="observation-settings">
<Select label="Расположение окон" value={layout.arrangement} options={[{value:'auto',label:'Автоматически'},{value:'columns',label:'В ряд'},{value:'rows',label:'Друг под другом'}]} onChange={value=>setLayout(current=>({...current,arrangement:value as typeof layout.arrangement}))}/>
{ids.map(id=>{const source=sources.find(item=>item.key===id)!;return <div className="observation-layer" key={id}><Switch label={source.catalog?`${source.view.label} · Источник не подключён`:source.view.label} checked={visible.includes(id)} onChange={value=>setVisible(id,value)}/><Select label={`Позиция · ${source.view.label}`} value={String(ids.indexOf(id))} options={ids.map((_,i)=>({value:String(i),label:`Окно ${i+1}`}))} onChange={value=>setLayout(current=>moveObservation(current,ids,id,ids[Number(value)]))}/></div>;})}
<Button onClick={()=>{setLayout(emptyObservationLayout());setFull(null);}}>Восстановить расположение</Button>
</div></Window>
</section>,portal)}</>;
@@ -14,5 +14,5 @@ export function ObservationDeck({ids,mounts,layout,onSplit,depth=0}:{ids:string[
const defaultColumns=depth===0&&layout.arrangement==='auto'&&!layout.order.some(id=>id==='rover'||id==='telemetry')&&ids.some(id=>!['map','rover','telemetry'].includes(id));
const cameraCount=ids.filter(id=>!['map','rover','telemetry'].includes(id)).length;
const mid=defaultColumns&&cameraCount<ids.length?cameraCount:layout.arrangement==='auto'&&ids[0]==='map'&&ids.includes('rover')?1:Math.ceil(ids.length/2),key=JSON.stringify(ids),orientation=layout.arrangement==='rows'?'horizontal':layout.arrangement==='columns'?'vertical':depth%2?'horizontal':'vertical';
return <SplitPane className="observation-split" orientation={orientation} primarySize={layout.splits[key]??50} onPrimarySizeChange={value=>onSplit(key,value)} separatorLabel="Изменить размеры окон" primary={<ObservationDeck ids={ids.slice(0,mid)} mounts={mounts} layout={layout} onSplit={onSplit} depth={depth+1}/>} secondary={<ObservationDeck ids={ids.slice(mid)} mounts={mounts} layout={layout} onSplit={onSplit} depth={depth+1}/>}/>;
return <SplitPane separatorAppearance="invisible" className="observation-split" orientation={orientation} primarySize={layout.splits[key]??50} onPrimarySizeChange={value=>onSplit(key,value)} separatorLabel="Изменить размеры окон" primary={<ObservationDeck ids={ids.slice(0,mid)} mounts={mounts} layout={layout} onSplit={onSplit} depth={depth+1}/>} secondary={<ObservationDeck ids={ids.slice(mid)} mounts={mounts} layout={layout} onSplit={onSplit} depth={depth+1}/>}/>;
}
@@ -0,0 +1,17 @@
import {useState} from 'react';
import {Button,Window} from '@nodedc/ui-react';
import {SensorDetail} from '../../../../../../packages/sensor-ui/src/SensorDetail';
import {sensorContribution,type SensorUiContribution} from '../../../../../../packages/sensor-ui/src/extensions';
import type {Sensor,SensorTransport} from '../../../../../../packages/sensor-ui/src/contracts';
import {createIsolatedRerunHost} from '../../../components/rerun/isolatedRerunHost';
export function ObservationDeviceSettings({device,transport,contributions,enabled,close,configure}:{device:Sensor;transport:SensorTransport;contributions:readonly SensorUiContribution[];enabled:boolean;close:()=>void;configure:()=>void}) {
const [error,setError]=useState('');
const Detail=sensorContribution(contributions,device)?.Detail??SensorDetail;
return <Window open title={`Настройки · ${device.name}`} size="lg" placement="end" draggable closeOnBackdrop={false} lockBodyScroll={false} trapFocus={false} onClose={close}>
{!enabled&&<p>Нет связи с устройством. Настройки оборудования будут доступны после подключения.</p>}
{!device.prepared&&<Button onClick={configure}>Подготовить устройство в конфигураторе</Button>}
{error&&<p role="alert">{error}</p>}
<Detail device={device} transport={transport} enabled={enabled} back={close} refresh={async()=>{await transport.inventory();}} failure={reason=>setError(reason instanceof Error?reason.message:reason?String(reason):'')} createRerunHost={createIsolatedRerunHost}/>
</Window>;
}
@@ -0,0 +1,13 @@
import {useLayoutEffect,useRef,type ReactNode} from 'react';
/** Reserve the actual overlay height for text/status content, including wrapped controls. */
export function ObservationPanelHeader({children}:{children:ReactNode}) {
const ref=useRef<HTMLElement>(null);
useLayoutEffect(()=>{
const header=ref.current!;
const measure=()=>header.parentElement?.style.setProperty('--observation-overlay-header-space',`${header.getBoundingClientRect().height}px`);
measure();const observer=new ResizeObserver(measure);observer.observe(header);
return()=>observer.disconnect();
},[]);
return <header ref={ref} className="observation-panel__header">{children}</header>;
}
@@ -9,6 +9,9 @@
.observation-mount, .observation-panel-mount, .observation-media-mount, .observation-panel, .observation-split { height:100%; min-height:0; min-width:0; }
.observation-panel__surface { display:flex; flex-direction:column; height:100%; overflow:hidden; container-type:inline-size; }
.observation-panel__surface > .observation-mount { flex:1; }
.observation-panel__surface--overlay { position:relative; --observation-overlay-header-space:62px; }
.observation-panel__surface--overlay > .observation-panel__header { position:absolute; inset:0 0 auto; z-index:1; background:transparent; pointer-events:none; }
.observation-panel__surface--overlay > .observation-panel__header > * { pointer-events:auto; }
.observation-panel__header { display:flex; align-items:center; gap:var(--nodedc-space-2); padding:var(--nodedc-space-2); flex-shrink:0; }
.observation-panel__header h3 { min-width:0; font-size:var(--nodedc-font-size-sm); overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
.observation-source { position:relative; display:flex; flex-direction:column; height:100%; min-height:0; }
@@ -21,7 +24,7 @@
.observation-source__media { position:relative; flex:1; min-height:0; overflow:hidden; display:flex; align-items:center; justify-content:center; }
.observation-source__media video, .observation-source__media canvas { width:100%; height:100%; object-fit:contain; }
.observation-source__media[data-live="false"] > *, .observation-source__media [data-live="false"] { visibility:hidden; }
.observation-source__notice { position:absolute; inset:30% var(--nodedc-space-4) auto; text-align:center; color:var(--nodedc-text-secondary); font-size:calc(var(--nodedc-font-size-lg) * .7); }
.observation-source__notice { position:absolute; inset:max(30%, calc(var(--observation-overlay-header-space, 0px) + var(--nodedc-space-3))) var(--nodedc-space-4) auto; text-align:center; color:var(--nodedc-text-secondary); font-size:calc(var(--nodedc-font-size-lg) * .7); }
.observation-rerun, .observation-rerun > div, .observation-map > div { width:100%; height:100%; }
.observation-settings { display:flex; flex-direction:column; gap:var(--nodedc-space-4); }
.observation-layer { display:grid; grid-template-columns:minmax(0, 1fr) 50%; align-items:center; gap:var(--nodedc-space-3); }
@@ -43,3 +46,5 @@
.observation-panel__title { min-width:80px; }
.observation-panel__header > .observation-actions { max-width:100%; flex-wrap:wrap; justify-content:flex-end; margin-left:auto; }
}
.observation-panel__surface--overlay .rover-telemetry { padding-top:calc(var(--observation-overlay-header-space) + var(--nodedc-space-3)); }
@@ -1,11 +1,12 @@
import assert from 'node:assert/strict';
import {after,before,test} from 'node:test';
import {createServer} from 'vite';
let server,layout,source,fanout;
let server,layout,source,fanout,catalog;
before(async()=>{
server=await createServer({appType:'custom',logLevel:'silent',server:{middlewareMode:true}});
layout=await server.ssrLoadModule('/src/core/fleet/observationLayout.ts');
source=await server.ssrLoadModule('../../packages/sensor-ui/src/observation.ts');
catalog=await server.ssrLoadModule('../../packages/sensor-ui/src/observationCatalog.ts');
({observationRerun:fanout}=await server.ssrLoadModule('@xgrids-k1/frontend/sensors/observationRerun.ts'));
});
after(async()=>{await server?.close();});
@@ -41,3 +42,21 @@ test('two Rerun views share bytes, apply independent blueprints once and dispose
assert.equal(sent[0].length,2);assert.deepEqual(started,[0,1]);host.dispose();assert.deepEqual(closed,[0,1]);
}finally{globalThis.fetch=fetchBefore;}
});
test('known offline D455 remains selectable; connected capabilities override the catalog',()=>{
const offline={name:'RealSense D455',model:'RealSense D455',online:false,layers:[]};
assert.deepEqual(source.cameraObservationViews(offline)[0].layers.map(v=>v.value),['color','depth','infrared1','infrared2','points','motion']);
assert.deepEqual(source.cameraObservationViews({...offline,online:true,layers:['depth']})[0].layers.map(v=>v.value),['depth']);
assert.deepEqual(source.cameraObservationViews({...offline,model:'unknown'}),[]);
});
test('unbound catalog views have no device authority; connected instances replace templates without aliasing',()=>{
const plugin={kind:'k1',observation:{catalog:{label:'K1',views:[{id:'points',label:'Points'},{id:'camera',label:'Camera'}]}}};
const entries=catalog.unboundObservationViews([],[plugin]);
assert.equal(entries.length,2);assert.notEqual(entries[0].key,entries[1].key);
assert.equal(entries[0].deviceID,undefined);assert.equal(entries[0].device,undefined);
assert.deepEqual(catalog.unboundObservationViews([{id:'one',kind:'k1'}],[plugin]),[]);
assert.deepEqual(catalog.unboundObservationViews([],[plugin,plugin]),[]);
const persisted=layout.decodeObservationLayout({...layout.emptyObservationLayout(),catalogVisible:[entries[0].key,entries[0].key,null]});
assert.deepEqual(persisted.catalogVisible,[entries[0].key]);
});