feat(k1): complete primary acquisition lifecycle
This commit is contained in:
@@ -18,15 +18,15 @@ device adapter, но структура интерфейса от него не
|
||||
| --- | --- | --- |
|
||||
| Mission Core fixed shell | Реализован | Header, навигация по разделам, рабочая поверхность, окна и инспекторы работают в одном приложении. |
|
||||
| Device plugin registry | Реализован, v1alpha1 + v1alpha2 | До выбора модели provider остаётся inert и не делает I/O. v1alpha1 сохраняет одну модель; v1alpha2 допускает одну или несколько моделей и требует profile coverage каждой. Custom `device.connection` UI key и backend factory подключаются одним reviewed import в composition root. |
|
||||
| Plugin-owned connection UI | Реализован | XGRIDS provisioning, acquisition/replay, diagnostics, metrics и scoped styles физически находятся в `plugins/xgrids-k1/frontend`; generic Control Station предоставляет только frontend SDK, model catalog и host slot. |
|
||||
| Plugin-owned device UI | Реализован | XGRIDS provisioning, project/acquisition/replay, diagnostics, metrics, optional spatial controls и scoped styles физически находятся в `plugins/xgrids-k1/frontend`; generic Control Station предоставляет frontend SDK, model catalog и host slots. |
|
||||
| Локальный control plane | Реализован | React получает состояние и выполняет операции через FastAPI REST и WebSocket на loopback. |
|
||||
| K1 BLE → Wi-Fi | Реализован | Реальный BLE-поиск всех видимых устройств и одна подтверждённая provisioning-запись выбранному устройству. |
|
||||
| K1 live/replay MQTT | Реализован | Read-only приём, raw-first сохранение, декодирование облака точек и позы, реальные метрики. |
|
||||
| K1 live/replay MQTT | Реализован | Read-only приём, обязательное локальное имя проекта, raw-first сохранение, декодирование облака/позы и device-reported scan time/distance/speed. |
|
||||
| Автоматический MQTT → Rerun | Реализован | Первый live/adapter file-replay поднимает process-wide `RecordingStream` и gRPC/proxy на TCP 9876; следующие такие сессии переиспользуют его. Saved observation replay использует отдельный immutable HTTP RRD path. |
|
||||
| Встроенный Rerun Viewer | Реализован | Self-hosted npm-компонент автоматически открывает текущий gRPC source внутри Control Station; внешний viewer не используется. |
|
||||
| Контролы сцены → Rerun | Реализованы для текущей геометрии | Работают размер и видимость точек, атрибут цвета, палитра, окно накопления, траектория, сетка, host timeline и сохранение/восстановление spatial layout. Проекция и семантические слои ещё не подключены. |
|
||||
| Сохранённые observation sessions | Реализованы для point/pose | Три последние сессии, background preparation, cache v7, generation-bound RRD, atomic admission, autoplay, play/pause/seek и controlled switching больших записей. |
|
||||
| K1 camera preview и archive | Live реализован; recorded contract реализован | Обе RTSP/H.264 камеры физически приняты в live UI. Новые acquisition-owned fMP4 archives не зависят от browser windows; recorded player подключён, но реальная архивная K1 camera-session ещё не прошла physical acceptance. |
|
||||
| Сохранённые observation sessions | Реализованы для point/pose/device metrics | Три последние сессии, background preparation, capture-clock-bound cache v9, generation-bound RRD, atomic admission, autoplay, play/pause/seek и controlled switching больших записей. |
|
||||
| K1 camera preview и archive | Live реализован; recorded contract реализован | Обе RTSP/H.264 камеры физически приняты в live UI. Новые acquisition-owned fMP4 archives не зависят от browser windows; recorded player подключён, но point-cloud + one-camera archived playback ещё не прошёл physical acceptance. |
|
||||
| Legacy Foxglove module | Только regression | Модуль и тесты сохранены для сравнения декодирования. Текущий live/replay runtime не запускает Foxglove WebSocket и не использует TCP 8765. |
|
||||
| Карты и миссии | Интерфейсный каркас | Реальные map/mission backends и vehicle control ещё не подключены. |
|
||||
|
||||
@@ -38,9 +38,10 @@ device adapter, но структура интерфейса от него не
|
||||
|
||||
```text
|
||||
K1 MQTT :1883, read-only
|
||||
└── raw .k1mqtt + metadata + SHA-256 сохраняются первыми
|
||||
└── bounded latest-wins preview queue (32 сообщения)
|
||||
└── явно внедрённый K1 protobuf/LZ4 normalizer
|
||||
└── raw .k1mqtt + metadata + durable clock origin сохраняются первыми
|
||||
├── injected ModelingReport observer -> device scan time/distance/speed
|
||||
└── bounded latest-wins visual preview queue (4 сообщения)
|
||||
└── явно внедрённый K1 protobuf/LZ4 point/pose normalizer
|
||||
└── transport-neutral DecodedPointCloudView / DecodedPoseView
|
||||
└── Rerun Points3D + Transform3D + LineStrips3D
|
||||
└── gRPC/proxy TCP 9876
|
||||
@@ -55,7 +56,7 @@ Mission Core Control Station ←→ REST /api/v1/device-plugins/*
|
||||
|
||||
sealed/recovered observation session
|
||||
└── SQLite catalog + bounded background preparation
|
||||
└── atomic RRD cache v7 + recorded-media manifest v2
|
||||
└── capture-clock-bound atomic RRD cache v9 + recorded-media manifest v2
|
||||
└── generation-bound same-origin HTTP
|
||||
└── aggregate admission
|
||||
└── Rerun native receiver + recorded fMP4 player
|
||||
@@ -177,15 +178,21 @@ production-сборки запускается командой `npm run preview
|
||||
оператор.
|
||||
6. Ввести SSID и пароль существующей сети и явно запустить подключение. Это одна
|
||||
reviewed provisioning-запись без автоматических повторов.
|
||||
7. Запустить live-приём по определённому адресу K1 либо replay локального
|
||||
`.k1mqtt`/проверенного TSV. Физическое сканирование K1 запускается и
|
||||
останавливается подтверждённым двойным нажатием кнопки устройства.
|
||||
8. Backend автоматически поднимет Rerun gRPC на TCP 9876 и опубликует адрес в
|
||||
7. Ввести обязательное название проекта. Оно NFKC-нормализуется, trim-ится,
|
||||
проверяется на control/surrogate characters и лимит 96 Unicode characters,
|
||||
сохраняется как display metadata и не используется в filesystem path.
|
||||
8. Запустить локальный live-приём по определённому адресу K1 либо replay
|
||||
локального `.k1mqtt`/проверенного TSV. Физическое сканирование K1 запускается
|
||||
и останавливается подтверждённым двойным нажатием кнопки устройства.
|
||||
9. Backend автоматически поднимет Rerun gRPC на TCP 9876 и опубликует адрес в
|
||||
state. Ручной source вводить не требуется.
|
||||
9. Открыть **Наблюдение → Пространственная сцена**. Реальные облако и траектория,
|
||||
частота, число точек, задержка и пропуски preview появятся после прихода
|
||||
сообщений K1.
|
||||
10. После нормального stop или recovery открыть **Сохранённые сессии**. Дождаться
|
||||
10. Открыть **Наблюдение → Пространственная сцена**. Plugin-owned spatial block
|
||||
показывает local acquisition phase, stop local reception и полученные от K1
|
||||
scan time/distance/speed; облако, траектория и preview metrics появляются
|
||||
только после реальных сообщений.
|
||||
11. Физически остановить K1, дождаться steady green, затем остановить локальный
|
||||
приём из device workflow или spatial block, чтобы запечатать evidence.
|
||||
12. После нормального stop или recovery открыть **Сохранённые сессии**. Дождаться
|
||||
состояния **Готово**, выбрать запись и использовать host timeline. Evidence
|
||||
сохраняется автоматически; disk action сохраняет только workspace layout.
|
||||
|
||||
@@ -212,6 +219,11 @@ https://example.internal/recording.rrd
|
||||
- версия RRD должна быть совместима с версией Web Viewer;
|
||||
- ручной URL хранится только в состоянии текущей страницы и имеет приоритет над
|
||||
автоматическим source;
|
||||
- nonterminal acquisition fail-closed блокирует manual source apply/reset,
|
||||
saved session switch и persisted replay reattach до завершения текущего
|
||||
приёма; automatic-source action очищает старый source и открывает сцену только
|
||||
после successful start, failed start сохраняет текущую сцену, а guard не
|
||||
останавливает устройство;
|
||||
- ошибка запуска показывается в viewport, без подстановки фиктивных данных.
|
||||
|
||||
После готовности Viewer выбор Rerun entity возвращает `entityPath` и имя view в
|
||||
@@ -282,10 +294,10 @@ facts и старые presentation-поля `phase`, `message`, `devices`,
|
||||
| `src/App.tsx` | Fixed shell, выбор разделов и окна source/display/layers/layout. |
|
||||
| `src/productModel.ts` | Архитектурные разделы, рабочие поверхности и уровни готовности. |
|
||||
| `src/core/device-plugins/` | Vendor-neutral manifest parser, registry, lifecycle, plugin host и public frontend SDK surface. |
|
||||
| `src/core/runtime/` | Нормализованное состояние активного устройства и spatial source. |
|
||||
| `src/core/runtime/` | Нормализованное состояние активного устройства, spatial source и fail-closed source-switch guard. |
|
||||
| `src/composition/devicePlugins.ts` | Единственный allowlist импортов конкретных device plugins. |
|
||||
| `src/workspaces/DeviceWorkspace.tsx` | Generic выбор модели и `device.connection` slot. |
|
||||
| `../../plugins/xgrids-k1/frontend/` | Plugin-owned K1 BLE/Wi-Fi, acquisition/replay UI, API client, runtime mapper и scoped styles. |
|
||||
| `../../plugins/xgrids-k1/frontend/` | Plugin-owned K1 BLE/Wi-Fi, project/acquisition/replay UI, optional spatial controls, API client, runtime mapper и scoped styles. |
|
||||
| `src/workspaces/Workspaces.tsx` | Оперативный обзор, spatial viewport и остальные продуктовые поверхности. |
|
||||
| `src/components/RerunViewport.tsx` | Live/recorded lifecycle WebViewer, native RRD open, atomic admission, playback и selection events. |
|
||||
| `src/components/ObservationSessionSelect.tsx` | Три последние сессии, состояния `Готово` / `Обработка` / `Ошибка`. |
|
||||
@@ -312,16 +324,26 @@ facts и старые presentation-поля `phase`, `message`, `devices`,
|
||||
случайные GATT writes и автоматические повторы запрещены.
|
||||
- MQTT live/replay не публикует команды устройству. Запуск и остановка
|
||||
физического сканирования остаются за кнопкой K1.
|
||||
- Exact start/stop codec, response correlator и device-status state machine
|
||||
остаются inert: они не импортируют MQTT transport. Header derivation известен,
|
||||
но legitimate OpenAPI key provisioning и durable post-stop save gate не
|
||||
доказаны, поэтому `vendor_writes_enabled=false`.
|
||||
- Live-сессии сначала сохраняют сырые сообщения и camera segments, затем
|
||||
формируют disposable preview. При перегрузке preview может быть отброшен,
|
||||
native evidence сохраняется. MQTT durability имеет bounded group-commit RPO,
|
||||
camera durability — текущий незавершённый fragment RPO; это не zero-loss claim.
|
||||
- Новая session summary hash-bind-ит durable clock origin и атомарно переключает
|
||||
pointer с provisional `mqtt.timeline.json` на content-addressed
|
||||
`mqtt.timeline.session-<sha256>.json`: только owner-sealed `session` scope
|
||||
может рекламировать combined media replay. Cache v9 создаёт реальные
|
||||
origin/end RRD rows; provisional `transport` scope fails closed.
|
||||
- Timeline `capture_time` сохраняет Unix-время приёма сообщения Mac, а окно
|
||||
накопления viewer использует session-local `stream_time`. `capture_time` — не
|
||||
доказанный timestamp сенсора K1 и не photon-to-screen latency.
|
||||
- Панорамный камерный поток в MQTT report topics отсутствует; отдельные
|
||||
left/right RTSP preview доступны через generic camera windows. LiDAR и camera
|
||||
сейчас синхронизированы только по host arrival, не по доказанным sensor clocks.
|
||||
Физический archived playback point cloud + одна выбранная камера ещё не принят.
|
||||
- `.runtime/`, canonical evidence roots и legacy `sessions/` игнорируются Git и
|
||||
могут содержать адреса, изображения, идентификаторы, траекторию и карту
|
||||
помещения. В репозиторий попадают только код, тесты, redacted manifests и
|
||||
|
||||
@@ -30,6 +30,10 @@ import { ObservationSessionSelect } from "./components/ObservationSessionSelect"
|
||||
import { useDevicePluginHost } from "./core/device-plugins/DevicePluginHost";
|
||||
import { useMissionRuntime } from "./core/runtime/MissionRuntimeContext";
|
||||
import type { ViewerSettings } from "./core/runtime/contracts";
|
||||
import {
|
||||
SPATIAL_SOURCE_SWITCH_BLOCKED_REASON,
|
||||
isSpatialSourceSwitchBlocked,
|
||||
} from "./core/runtime/acquisitionGuard";
|
||||
import {
|
||||
createLatestAsyncCommitter,
|
||||
type LatestAsyncCommitter,
|
||||
@@ -144,6 +148,12 @@ export default function App() {
|
||||
const [layoutSaveNotice, setLayoutSaveNotice] = useState<string | null>(null);
|
||||
const [sceneSettings, setSceneSettings] = useState<SceneSettings>(defaultSceneSettings);
|
||||
const [displayDraft, setDisplayDraft] = useState<SceneSettings>(defaultSceneSettings);
|
||||
const sourceSwitchBlocked = isSpatialSourceSwitchBlocked(runtime.state);
|
||||
const sourceSwitchBlockedReason = sourceSwitchBlocked
|
||||
? SPATIAL_SOURCE_SWITCH_BLOCKED_REASON
|
||||
: null;
|
||||
const sourceSwitchBlockedRef = useRef(sourceSwitchBlocked);
|
||||
sourceSwitchBlockedRef.current = sourceSwitchBlocked;
|
||||
const appliedProfileKeyRef = useRef<string | null>(null);
|
||||
const sceneSettingsRef = useRef<SceneSettings>(defaultSceneSettings);
|
||||
const displayDraftRef = useRef<SceneSettings>(defaultSceneSettings);
|
||||
@@ -423,6 +433,9 @@ export default function App() {
|
||||
};
|
||||
|
||||
const beginRecordedReplaySwitch = useCallback(async () => {
|
||||
if (sourceSwitchBlockedRef.current) {
|
||||
throw new Error(SPATIAL_SOURCE_SWITCH_BLOCKED_REASON);
|
||||
}
|
||||
// useObservationSessions calls this only after backend preparation has
|
||||
// produced a validated launch descriptor. Keep the old scene mounted
|
||||
// before this point; now perform one controlled receiver teardown before
|
||||
@@ -437,6 +450,9 @@ export default function App() {
|
||||
}, []);
|
||||
|
||||
const acceptRecordedReplay = useCallback((launch: ObservationSessionReplayLaunch) => {
|
||||
if (sourceSwitchBlockedRef.current) {
|
||||
throw new Error(SPATIAL_SOURCE_SWITCH_BLOCKED_REASON);
|
||||
}
|
||||
setRecordedReplay(launch);
|
||||
setSourceUrl(launch.sourceUrl);
|
||||
setSourceDraft(launch.sourceUrl);
|
||||
@@ -447,6 +463,17 @@ export default function App() {
|
||||
if (outcome !== "accepted") setReplayTransitioning(false);
|
||||
}, []);
|
||||
|
||||
const activateAutomaticSpatialSource = useCallback(() => {
|
||||
// A plugin-owned live/file-replay start must release any host-selected
|
||||
// archive or manual URL before the new acquisition becomes non-terminal.
|
||||
// This transition is an internal start boundary, not an operator source
|
||||
// switch, so it intentionally does not consult the acquisition guard.
|
||||
setReplayTransitioning(false);
|
||||
setRecordedReplay(null);
|
||||
setSourceUrl("");
|
||||
setSourceDraft("");
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
// ObservationSessionSelect owns the cancellable request and unmounts when
|
||||
// the operator leaves the spatial workspace. Its unmount cannot safely
|
||||
@@ -637,7 +664,8 @@ export default function App() {
|
||||
<div className="observation-header-tools">
|
||||
<ObservationSessionSelect
|
||||
limit={3}
|
||||
disabled={runtime.pendingAction !== null}
|
||||
disabled={runtime.pendingAction !== null || sourceSwitchBlocked}
|
||||
blockedReason={sourceSwitchBlockedReason}
|
||||
onReplayBegin={beginRecordedReplaySwitch}
|
||||
onReplayAccepted={(_session, launch) => acceptRecordedReplay(launch)}
|
||||
onReplaySettled={(_session, outcome) => settleRecordedReplaySwitch(outcome)}
|
||||
@@ -667,6 +695,7 @@ export default function App() {
|
||||
{activeDefinition.kind === "device" ? (
|
||||
<DeviceWorkspace
|
||||
onOpenSpatialScene={() => openView("spatial-scene")}
|
||||
onActivateAutomaticSpatialSource={activateAutomaticSpatialSource}
|
||||
/>
|
||||
) : (
|
||||
<WorkspaceRenderer
|
||||
@@ -682,11 +711,18 @@ export default function App() {
|
||||
stageDisplayPatch({ accumulationSeconds })}
|
||||
onAccumulationCommit={flushDisplaySettings}
|
||||
observationLayout={observationLayout}
|
||||
spatialControls={selection?.SpatialControlsView
|
||||
? {
|
||||
View: selection.SpatialControlsView,
|
||||
model: selection.model,
|
||||
}
|
||||
: null}
|
||||
navigation={{
|
||||
openView,
|
||||
openSource,
|
||||
openDisplay,
|
||||
openLayers,
|
||||
activateAutomaticSpatialSource,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
@@ -713,7 +749,10 @@ export default function App() {
|
||||
<WindowFooterActions>
|
||||
<Button
|
||||
variant="ghost"
|
||||
disabled={sourceSwitchBlocked}
|
||||
title={sourceSwitchBlockedReason ?? undefined}
|
||||
onClick={() => {
|
||||
if (sourceSwitchBlockedRef.current) return;
|
||||
setSourceDraft("");
|
||||
setSourceUrl("");
|
||||
setRecordedReplay(null);
|
||||
@@ -724,8 +763,10 @@ export default function App() {
|
||||
<Button
|
||||
variant="primary"
|
||||
shape="pill"
|
||||
disabled={!sourceDraft.trim()}
|
||||
disabled={sourceSwitchBlocked || !sourceDraft.trim()}
|
||||
title={sourceSwitchBlockedReason ?? undefined}
|
||||
onClick={() => {
|
||||
if (sourceSwitchBlockedRef.current) return;
|
||||
setSourceUrl(sourceDraft.trim());
|
||||
setRecordedReplay(null);
|
||||
}}
|
||||
@@ -766,9 +807,10 @@ export default function App() {
|
||||
hint="необязательно"
|
||||
value={sourceDraft}
|
||||
onChange={(event) => setSourceDraft(event.target.value)}
|
||||
disabled={sourceSwitchBlocked}
|
||||
spellCheck={false}
|
||||
placeholder="rerun+http://127.0.0.1:9876/proxy"
|
||||
description="Пустое значение использует автоматический локальный источник. Ручной адрес нужен для другого gRPC-потока или записи RRD."
|
||||
description={sourceSwitchBlockedReason ?? "Пустое значение использует автоматический локальный источник. Ручной адрес нужен для другого gRPC-потока или записи RRD."}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
|
||||
@@ -93,12 +93,14 @@ function sessionDescription(session: ObservationSessionSummary): string {
|
||||
export function ObservationSessionSelect({
|
||||
limit = 3,
|
||||
disabled = false,
|
||||
blockedReason = null,
|
||||
onReplayBegin,
|
||||
onReplayAccepted,
|
||||
onReplaySettled,
|
||||
}: {
|
||||
limit?: number;
|
||||
disabled?: boolean;
|
||||
blockedReason?: string | null;
|
||||
onReplayBegin?: (
|
||||
session: ObservationSessionSummary,
|
||||
launch: ObservationSessionReplayLaunch,
|
||||
@@ -114,6 +116,7 @@ export function ObservationSessionSelect({
|
||||
}) {
|
||||
const sessions = useObservationSessions({
|
||||
limit,
|
||||
replayEnabled: blockedReason === null,
|
||||
onReplayBegin,
|
||||
onReplayAccepted,
|
||||
onReplaySettled,
|
||||
@@ -123,6 +126,7 @@ export function ObservationSessionSelect({
|
||||
: sessions.state === "loading"
|
||||
? "Загружаем сессии…"
|
||||
: "Сохранённые сессии";
|
||||
const presentedTriggerCopy = blockedReason ?? triggerCopy;
|
||||
|
||||
return (
|
||||
<Dropdown
|
||||
@@ -147,10 +151,11 @@ export function ObservationSessionSelect({
|
||||
aria-expanded={open}
|
||||
aria-controls={surfaceId}
|
||||
disabled={disabled}
|
||||
title={blockedReason ?? undefined}
|
||||
onClick={toggle}
|
||||
>
|
||||
<Icon name="database" size={15} />
|
||||
<span>{triggerCopy}</span>
|
||||
<span>{presentedTriggerCopy}</span>
|
||||
{sessions.state === "ready" ? <small>{sessions.items.length}</small> : null}
|
||||
<Icon name="chevron-down" size={14} />
|
||||
</button>
|
||||
|
||||
@@ -94,6 +94,7 @@ export function isDevicePluginManifestV1Alpha2(
|
||||
|
||||
export interface DevicePluginHostActions {
|
||||
openSpatialScene: () => void;
|
||||
activateAutomaticSpatialSource: () => void;
|
||||
}
|
||||
|
||||
export interface DevicePluginConnectionProps {
|
||||
@@ -109,10 +110,12 @@ export interface DeviceUiPlugin {
|
||||
children: ReactNode;
|
||||
}>;
|
||||
connectionViews: Readonly<Record<string, ComponentType<DevicePluginConnectionProps>>>;
|
||||
SpatialControlsView?: ComponentType<DevicePluginConnectionProps>;
|
||||
}
|
||||
|
||||
export interface RegisteredDeviceModel {
|
||||
plugin: DeviceUiPlugin;
|
||||
model: DeviceModelDefinition;
|
||||
ConnectionView: ComponentType<DevicePluginConnectionProps>;
|
||||
SpatialControlsView?: ComponentType<DevicePluginConnectionProps>;
|
||||
}
|
||||
|
||||
@@ -90,7 +90,14 @@ export function createDevicePluginRegistry(
|
||||
`Плагин ${manifest.metadata.id} не реализует UI ${model.ui.componentKey}.`,
|
||||
);
|
||||
}
|
||||
models.push({ plugin, model, ConnectionView });
|
||||
models.push({
|
||||
plugin,
|
||||
model,
|
||||
ConnectionView,
|
||||
...(plugin.SpatialControlsView
|
||||
? { SpatialControlsView: plugin.SpatialControlsView }
|
||||
: {}),
|
||||
});
|
||||
}
|
||||
|
||||
if (isDevicePluginManifestV1Alpha2(manifest)) {
|
||||
|
||||
@@ -101,9 +101,11 @@ export function createObservationReplayCoordinator(): ObservationReplayCoordinat
|
||||
};
|
||||
},
|
||||
cancel() {
|
||||
sequence += 1;
|
||||
active?.abort();
|
||||
active = null;
|
||||
// Keep ownership until the cancelled attempt reaches `finish()`. This
|
||||
// lets its finally block settle a replacement that already passed
|
||||
// onReplayBegin, while `isCurrent()` still fails immediately because the
|
||||
// signal is aborted. A later `begin()` replaces and invalidates it.
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -359,11 +361,13 @@ export function clearObservationReplayPreparation(
|
||||
|
||||
export function useObservationSessions({
|
||||
limit = 3,
|
||||
replayEnabled = true,
|
||||
onReplayBegin,
|
||||
onReplayAccepted,
|
||||
onReplaySettled,
|
||||
}: {
|
||||
limit?: number;
|
||||
replayEnabled?: boolean;
|
||||
/** Called only after the archive is ready, immediately before replacing the old viewer. */
|
||||
onReplayBegin?: (
|
||||
session: ObservationSessionSummary,
|
||||
@@ -388,11 +392,23 @@ export function useObservationSessions({
|
||||
const mounted = useRef(true);
|
||||
const catalogSequence = useRef(0);
|
||||
const reattachStarted = useRef(false);
|
||||
const replayEnabledRef = useRef(replayEnabled);
|
||||
replayEnabledRef.current = replayEnabled;
|
||||
const preparationPollSequence = useRef(0);
|
||||
const replayCoordinator = useRef<ObservationReplayCoordinator | null>(null);
|
||||
if (replayCoordinator.current === null) {
|
||||
replayCoordinator.current = createObservationReplayCoordinator();
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (replayEnabled) return;
|
||||
// Cancels only this browser's polling/selection attempt. The shared
|
||||
// backend preparation remains untouched and can be selected again later.
|
||||
reattachStarted.current = true;
|
||||
replayCoordinator.current?.cancel();
|
||||
setReplayingSessionId(null);
|
||||
setReplayProgress(null);
|
||||
}, [replayEnabled]);
|
||||
const safeLimit = Number.isFinite(limit) ? Math.max(0, Math.floor(limit)) : 3;
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
@@ -466,6 +482,7 @@ export function useObservationSessions({
|
||||
session: ObservationSessionSummary,
|
||||
resumedPreparation?: ObservationSessionPreparation,
|
||||
) => {
|
||||
if (!replayEnabledRef.current) return false;
|
||||
const attempt = replayCoordinator.current!.begin();
|
||||
setReplayingSessionId(session.id);
|
||||
setFailedSessionId(null);
|
||||
@@ -502,14 +519,26 @@ export function useObservationSessions({
|
||||
signal: attempt.signal,
|
||||
onUpdate,
|
||||
});
|
||||
if (!mounted.current || !attempt.isCurrent()) return false;
|
||||
if (
|
||||
!mounted.current ||
|
||||
!attempt.isCurrent() ||
|
||||
!replayEnabledRef.current
|
||||
) return false;
|
||||
|
||||
// The current scene stays mounted throughout preparation. Only now that
|
||||
// the launch descriptor exists do we release the previous viewer.
|
||||
await onReplayBegin?.(session, launch);
|
||||
if (!mounted.current || !attempt.isCurrent()) return false;
|
||||
if (
|
||||
!mounted.current ||
|
||||
!attempt.isCurrent() ||
|
||||
!replayEnabledRef.current
|
||||
) return false;
|
||||
await onReplayAccepted?.(session, launch);
|
||||
if (!mounted.current || !attempt.isCurrent()) return false;
|
||||
if (
|
||||
!mounted.current ||
|
||||
!attempt.isCurrent() ||
|
||||
!replayEnabledRef.current
|
||||
) return false;
|
||||
outcome = "accepted";
|
||||
try {
|
||||
clearObservationReplayPreparation();
|
||||
@@ -543,6 +572,7 @@ export function useObservationSessions({
|
||||
}, [onReplayAccepted, onReplayBegin, onReplaySettled]);
|
||||
|
||||
const replay = useCallback(async (sessionId: string) => {
|
||||
if (!replayEnabledRef.current) return false;
|
||||
const session = items.find((candidate) => candidate.id === sessionId);
|
||||
if (!session || !session.replayable) return false;
|
||||
reattachStarted.current = true;
|
||||
@@ -550,7 +580,7 @@ export function useObservationSessions({
|
||||
}, [executeReplay, items]);
|
||||
|
||||
useEffect(() => {
|
||||
if (state !== "ready" || reattachStarted.current) return;
|
||||
if (!replayEnabled || state !== "ready" || reattachStarted.current) return;
|
||||
reattachStarted.current = true;
|
||||
let stored: ObservationSessionPreparation | null = null;
|
||||
try {
|
||||
@@ -569,7 +599,7 @@ export function useObservationSessions({
|
||||
return;
|
||||
}
|
||||
void executeReplay(session, stored);
|
||||
}, [executeReplay, items, state]);
|
||||
}, [executeReplay, items, replayEnabled, state]);
|
||||
|
||||
const retry = useCallback(async () => {
|
||||
if (!failedSessionId) return false;
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import type { MissionRuntimeState } from "./contracts";
|
||||
|
||||
const TERMINAL_ACQUISITION_STATES = new Set([
|
||||
"completed",
|
||||
"failed",
|
||||
"aborted",
|
||||
"interrupted",
|
||||
]);
|
||||
|
||||
export const SPATIAL_SOURCE_SWITCH_BLOCKED_REASON =
|
||||
"Завершите текущий приём перед сменой источника.";
|
||||
|
||||
/**
|
||||
* An acquisition snapshot is blocking unless its state is explicitly known to
|
||||
* be terminal. Unknown future states therefore fail closed.
|
||||
*/
|
||||
export function isSpatialSourceSwitchBlocked(
|
||||
state: MissionRuntimeState | null | undefined,
|
||||
): boolean {
|
||||
const acquisition = state?.acquisition;
|
||||
return Boolean(
|
||||
acquisition &&
|
||||
(acquisition.cleanupPending === true ||
|
||||
!TERMINAL_ACQUISITION_STATES.has(acquisition.state)),
|
||||
);
|
||||
}
|
||||
@@ -29,6 +29,9 @@ export interface StreamMetrics {
|
||||
frameRateHz?: number | null;
|
||||
pointCount?: number | null;
|
||||
droppedPreviewFrames?: number | null;
|
||||
elapsedSeconds?: number | null;
|
||||
routeDistanceMeters?: number | null;
|
||||
speedMetersPerSecond?: number | null;
|
||||
}
|
||||
|
||||
export interface ActiveDeviceSnapshot {
|
||||
@@ -55,6 +58,7 @@ export interface RuntimeAcquisitionSnapshot {
|
||||
state: string;
|
||||
stateRevision: number;
|
||||
operatorInstructions: readonly string[];
|
||||
cleanupPending?: boolean;
|
||||
}
|
||||
|
||||
export interface RuntimeOperationSnapshot {
|
||||
|
||||
@@ -8,6 +8,15 @@
|
||||
gap: 0.45rem;
|
||||
}
|
||||
|
||||
.scene-device-controls {
|
||||
position: absolute;
|
||||
z-index: 30;
|
||||
top: 0.85rem;
|
||||
left: 50%;
|
||||
max-width: calc(100% - 9rem);
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
|
||||
.scene-source-picker__trigger,
|
||||
.scene-source-control,
|
||||
.scene-focus-exit {
|
||||
|
||||
@@ -168,6 +168,11 @@
|
||||
left: 0.6rem;
|
||||
}
|
||||
|
||||
.scene-device-controls {
|
||||
top: 0.6rem;
|
||||
max-width: calc(100% - 8rem);
|
||||
}
|
||||
|
||||
.scene-adapter-note {
|
||||
right: 0.75rem;
|
||||
left: 0.75rem;
|
||||
|
||||
@@ -40,7 +40,13 @@ function ModelCard({
|
||||
);
|
||||
}
|
||||
|
||||
export function DeviceWorkspace({ onOpenSpatialScene }: { onOpenSpatialScene: () => void }) {
|
||||
export function DeviceWorkspace({
|
||||
onOpenSpatialScene,
|
||||
onActivateAutomaticSpatialSource,
|
||||
}: {
|
||||
onOpenSpatialScene: () => void;
|
||||
onActivateAutomaticSpatialSource: () => void;
|
||||
}) {
|
||||
const {
|
||||
registry,
|
||||
selection,
|
||||
@@ -115,7 +121,10 @@ export function DeviceWorkspace({ onOpenSpatialScene }: { onOpenSpatialScene: ()
|
||||
</div>
|
||||
<ConnectionView
|
||||
model={selection.model}
|
||||
host={{ openSpatialScene: onOpenSpatialScene }}
|
||||
host={{
|
||||
openSpatialScene: onOpenSpatialScene,
|
||||
activateAutomaticSpatialSource: onActivateAutomaticSpatialSource,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,4 +1,11 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type ComponentType,
|
||||
} from "react";
|
||||
import {
|
||||
Button,
|
||||
GlassSurface,
|
||||
@@ -21,6 +28,10 @@ import type {
|
||||
RecordedCameraAdmissionState,
|
||||
} from "../core/observation/recordedSessionAdmission";
|
||||
import type { ObservationLayoutController } from "../core/observation/useObservationLayout";
|
||||
import type {
|
||||
DeviceModelDefinition,
|
||||
DevicePluginConnectionProps,
|
||||
} from "../core/device-plugins/contracts";
|
||||
import type {
|
||||
BackendStatus,
|
||||
MissionRuntimeState,
|
||||
@@ -98,6 +109,7 @@ export interface WorkspaceNavigation {
|
||||
openSource: () => void;
|
||||
openDisplay: () => void;
|
||||
openLayers: () => void;
|
||||
activateAutomaticSpatialSource: () => void;
|
||||
}
|
||||
|
||||
export interface WorkspaceRendererProps {
|
||||
@@ -113,6 +125,10 @@ export interface WorkspaceRendererProps {
|
||||
onAccumulationCommit: () => void;
|
||||
observationLayout: ObservationLayoutController;
|
||||
navigation: WorkspaceNavigation;
|
||||
spatialControls: {
|
||||
View: ComponentType<DevicePluginConnectionProps>;
|
||||
model: DeviceModelDefinition;
|
||||
} | null;
|
||||
}
|
||||
|
||||
function OverviewWorkspace({
|
||||
@@ -271,6 +287,7 @@ function SpatialWorkspace({
|
||||
onAccumulationCommit,
|
||||
observationLayout,
|
||||
navigation,
|
||||
spatialControls,
|
||||
}: WorkspaceRendererProps) {
|
||||
const [viewerStatus, setViewerStatus] = useState<RerunViewportStatus>(sourceUrl ? "loading" : "idle");
|
||||
const [viewerMessage, setViewerMessage] = useState("");
|
||||
@@ -435,6 +452,18 @@ function SpatialWorkspace({
|
||||
<EmptySpatialStage settings={sceneSettings} />
|
||||
)}
|
||||
|
||||
{spatialControls && !recordedSource ? (
|
||||
<div className="scene-device-controls">
|
||||
<spatialControls.View
|
||||
model={spatialControls.model}
|
||||
host={{
|
||||
openSpatialScene: () => navigation.openView("spatial-scene"),
|
||||
activateAutomaticSpatialSource: navigation.activateAutomaticSpatialSource,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{pointCloudFocused ? (
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -10,6 +10,9 @@ let xgridsK1Manifest;
|
||||
let xgridsK1Actions;
|
||||
let xgridsK1Api;
|
||||
let lifecycle;
|
||||
let projectName;
|
||||
let automaticSourceStart;
|
||||
let presentation;
|
||||
|
||||
before(async () => {
|
||||
server = await createServer({
|
||||
@@ -29,6 +32,15 @@ before(async () => {
|
||||
lifecycle = await server.ssrLoadModule(
|
||||
"@xgrids-k1/frontend/lifecycle.ts",
|
||||
);
|
||||
projectName = await server.ssrLoadModule(
|
||||
"@xgrids-k1/frontend/projectName.ts",
|
||||
);
|
||||
automaticSourceStart = await server.ssrLoadModule(
|
||||
"@xgrids-k1/frontend/automaticSourceStart.ts",
|
||||
);
|
||||
presentation = await server.ssrLoadModule(
|
||||
"@xgrids-k1/frontend/presentation.ts",
|
||||
);
|
||||
({ xgridsK1Api } = await server.ssrLoadModule(
|
||||
"@xgrids-k1/frontend/api.ts",
|
||||
));
|
||||
@@ -279,6 +291,100 @@ test("prepared acquisition resumes without another prepare and remains recoverab
|
||||
assert.equal(lifecycle.recoverableAcquisition(prepared)?.acquisition_id, "acq-1");
|
||||
});
|
||||
|
||||
test("project name is canonicalized and rejected outside the bounded safe contract", () => {
|
||||
assert.deepEqual(projectName.validateProjectName(" Mission 01 "), {
|
||||
value: "Mission 01",
|
||||
error: null,
|
||||
});
|
||||
assert.match(projectName.validateProjectName("line\nbreak").error, /управляющие/);
|
||||
assert.match(projectName.validateProjectName("\ud800").error, /управляющие/);
|
||||
assert.match(projectName.validateProjectName("x".repeat(97)).error, /не длиннее 96/);
|
||||
assert.match(projectName.validateProjectName(" \t ").error, /Введите название/);
|
||||
});
|
||||
|
||||
test("automatic spatial source replaces the old scene only after a successful start", async () => {
|
||||
const failedEvents = [];
|
||||
assert.equal(await automaticSourceStart.runAutomaticSpatialSourceStart(
|
||||
async () => {
|
||||
failedEvents.push("start");
|
||||
return false;
|
||||
},
|
||||
() => failedEvents.push("activate"),
|
||||
() => failedEvents.push("open"),
|
||||
), false);
|
||||
assert.deepEqual(failedEvents, ["start"]);
|
||||
|
||||
const successfulEvents = [];
|
||||
assert.equal(await automaticSourceStart.runAutomaticSpatialSourceStart(
|
||||
async () => {
|
||||
successfulEvents.push("start");
|
||||
return true;
|
||||
},
|
||||
() => successfulEvents.push("activate"),
|
||||
() => successfulEvents.push("open"),
|
||||
), true);
|
||||
assert.deepEqual(successfulEvents, ["start", "activate", "open"]);
|
||||
});
|
||||
|
||||
test("vendor commands fail closed unless the profile and acquisition both enable them", () => {
|
||||
const capability = {
|
||||
compatibility: {
|
||||
vendor_writes_enabled: true,
|
||||
permitted_mode: "active-control",
|
||||
},
|
||||
};
|
||||
assert.equal(lifecycle.isVendorWriteCapable(capability), true);
|
||||
assert.equal(lifecycle.isVendorWriteCapable({
|
||||
compatibility: { vendor_writes_enabled: true, permitted_mode: "read-only" },
|
||||
}), false);
|
||||
assert.equal(lifecycle.isSoftwareCommandedAcquisition({
|
||||
...capability,
|
||||
acquisition: { control_mode: "operator-manual" },
|
||||
}), false);
|
||||
assert.equal(lifecycle.isSoftwareCommandedAcquisition({
|
||||
...capability,
|
||||
acquisition: { control_mode: "plugin-commanded" },
|
||||
}), true);
|
||||
});
|
||||
|
||||
test("device modeling telemetry maps only finite non-negative values", () => {
|
||||
assert.deepEqual(presentation.deviceTelemetry({
|
||||
device_elapsed_seconds: 12.5,
|
||||
device_route_distance_meters: 8.25,
|
||||
device_speed_meters_per_second: 0.75,
|
||||
}), {
|
||||
elapsedSeconds: 12.5,
|
||||
routeDistanceMeters: 8.25,
|
||||
speedMetersPerSecond: 0.75,
|
||||
});
|
||||
assert.deepEqual(presentation.deviceTelemetry({
|
||||
device_elapsed_seconds: -1,
|
||||
device_route_distance_meters: Number.NaN,
|
||||
device_speed_meters_per_second: Number.POSITIVE_INFINITY,
|
||||
}), {
|
||||
elapsedSeconds: null,
|
||||
routeDistanceMeters: null,
|
||||
speedMetersPerSecond: null,
|
||||
});
|
||||
});
|
||||
|
||||
test("spatial K1 action failures have an explicit retry-safe presentation", () => {
|
||||
assert.equal(presentation.spatialActionFailure(null), null);
|
||||
assert.equal(presentation.spatialActionFailure(" "), null);
|
||||
assert.deepEqual(presentation.spatialActionFailure(" stop failed "), {
|
||||
title: "Действие K1 не выполнено",
|
||||
detail: "stop failed",
|
||||
});
|
||||
assert.equal(lifecycle.shouldRenderSpatialControls({
|
||||
source_mode: "live",
|
||||
acquisition: { state: "failed", cleanup_pending: true },
|
||||
}), true);
|
||||
assert.equal(lifecycle.shouldRenderSpatialControls({
|
||||
source_mode: "idle",
|
||||
acquisition: { state: "failed", cleanup_pending: false },
|
||||
}), false);
|
||||
});
|
||||
|
||||
test("replay ignores a stale failed live acquisition", () => {
|
||||
const replay = {
|
||||
source_mode: "replay",
|
||||
@@ -343,6 +449,7 @@ test("device mutations send explicit nested compatibility attestation", async ()
|
||||
idempotency_key: "network-provision:test",
|
||||
});
|
||||
await xgridsK1Api.prepareAcquisition({
|
||||
project_name: "Mission 01",
|
||||
compatibility_attestation: attestation,
|
||||
});
|
||||
} finally {
|
||||
@@ -355,4 +462,5 @@ test("device mutations send explicit nested compatibility attestation", async ()
|
||||
assert.deepEqual(provisioning.input.compatibility_attestation, attestation);
|
||||
assert.equal(provisioning.input.idempotency_key, "network-provision:test");
|
||||
assert.deepEqual(prepare.input.compatibility_attestation, attestation);
|
||||
assert.equal(prepare.input.project_name, "Mission 01");
|
||||
});
|
||||
|
||||
@@ -39,7 +39,13 @@ after(async () => {
|
||||
await server?.close();
|
||||
});
|
||||
|
||||
function syntheticPlugin({ pluginId, modelId, componentKey, connectionView }) {
|
||||
function syntheticPlugin({
|
||||
pluginId,
|
||||
modelId,
|
||||
componentKey,
|
||||
connectionView,
|
||||
spatialControlsView,
|
||||
}) {
|
||||
return {
|
||||
manifest: {
|
||||
apiVersion: "missioncore.nodedc/v1alpha2",
|
||||
@@ -69,6 +75,7 @@ function syntheticPlugin({ pluginId, modelId, componentKey, connectionView }) {
|
||||
},
|
||||
RuntimeProvider: ({ children }) => children,
|
||||
connectionViews: { [componentKey]: connectionView },
|
||||
...(spatialControlsView ? { SpatialControlsView: spatialControlsView } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -98,6 +105,35 @@ test("each device plugin contributes its own connection pipeline component", ()
|
||||
);
|
||||
});
|
||||
|
||||
test("registry exposes an optional model-scoped spatial controls contribution", () => {
|
||||
const connectionView = () => null;
|
||||
const spatialControlsView = () => null;
|
||||
const registry = createDevicePluginRegistry([
|
||||
syntheticPlugin({
|
||||
pluginId: "synthetic.spatial",
|
||||
modelId: "synthetic.spatial.sensor",
|
||||
componentKey: "spatial.connection",
|
||||
connectionView,
|
||||
spatialControlsView,
|
||||
}),
|
||||
syntheticPlugin({
|
||||
pluginId: "synthetic.connection-only",
|
||||
modelId: "synthetic.connection-only.sensor",
|
||||
componentKey: "connection-only.connection",
|
||||
connectionView,
|
||||
}),
|
||||
]);
|
||||
|
||||
assert.equal(
|
||||
registry.resolveModel("synthetic.spatial.sensor").SpatialControlsView,
|
||||
spatialControlsView,
|
||||
);
|
||||
assert.equal(
|
||||
registry.resolveModel("synthetic.connection-only.sensor").SpatialControlsView,
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
test("XGRIDS frontend is physically plugin-owned and split by operator pipeline", () => {
|
||||
if (existsSync(legacyPluginRoot)) {
|
||||
assert.deepEqual(readdirSync(legacyPluginRoot), []);
|
||||
@@ -108,10 +144,22 @@ test("XGRIDS frontend is physically plugin-owned and split by operator pipeline"
|
||||
"styles.css",
|
||||
"components/K1ProvisioningPipeline.tsx",
|
||||
"components/K1AcquisitionPipeline.tsx",
|
||||
"components/K1SpatialControls.tsx",
|
||||
"components/K1Diagnostics.tsx",
|
||||
"projectName.ts",
|
||||
]) {
|
||||
assert.equal(existsSync(join(pluginFrontendRoot, relativePath)), true, relativePath);
|
||||
}
|
||||
|
||||
const spatialControls = readFileSync(
|
||||
join(pluginFrontendRoot, "components/K1SpatialControls.tsx"),
|
||||
"utf8",
|
||||
);
|
||||
assert.match(spatialControls, /shouldRenderSpatialControls\(state\)/);
|
||||
assert.match(spatialControls, /cleanup_pending/);
|
||||
assert.match(spatialControls, /spatialActionFailure/);
|
||||
assert.match(spatialControls, /role="alert"/);
|
||||
assert.match(spatialControls, /Повторить остановку/);
|
||||
});
|
||||
|
||||
test("generic Control Station has one composition import and no K1 implementation knowledge", () => {
|
||||
|
||||
@@ -603,6 +603,25 @@ test("replay coordinator makes rapid saved-session selection latest-request-wins
|
||||
coordinator.cancel();
|
||||
assert.equal(third.signal.aborted, true);
|
||||
assert.equal(third.isCurrent(), false);
|
||||
assert.equal(third.finish(), true);
|
||||
assert.equal(third.finish(), false);
|
||||
});
|
||||
|
||||
test("an acquisition guard cancellation keeps settlement ownership", () => {
|
||||
const coordinator = createObservationReplayCoordinator();
|
||||
const archiveSwitch = coordinator.begin();
|
||||
let replacementBlanked = false;
|
||||
let replacementSettled = false;
|
||||
|
||||
// Models the point after onReplayBegin has released the previous viewer.
|
||||
replacementBlanked = true;
|
||||
coordinator.cancel();
|
||||
|
||||
assert.equal(archiveSwitch.signal.aborted, true);
|
||||
assert.equal(archiveSwitch.isCurrent(), false);
|
||||
if (archiveSwitch.finish()) replacementSettled = true;
|
||||
assert.equal(replacementBlanked, true);
|
||||
assert.equal(replacementSettled, true);
|
||||
});
|
||||
|
||||
test("switching saved sessions aborts only local polling and never cancels shared backend work", async () => {
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { after, before, test } from "node:test";
|
||||
|
||||
import { createServer } from "vite";
|
||||
|
||||
let server;
|
||||
let guard;
|
||||
|
||||
before(async () => {
|
||||
server = await createServer({
|
||||
appType: "custom",
|
||||
logLevel: "silent",
|
||||
server: { middlewareMode: true },
|
||||
});
|
||||
guard = await server.ssrLoadModule("/src/core/runtime/acquisitionGuard.ts");
|
||||
});
|
||||
|
||||
after(async () => {
|
||||
await server?.close();
|
||||
});
|
||||
|
||||
function runtimeState(acquisitionState, cleanupPending = false) {
|
||||
return {
|
||||
phase: "idle",
|
||||
sourceMode: "idle",
|
||||
acquisition: acquisitionState === null
|
||||
? null
|
||||
: {
|
||||
acquisitionId: "acq-1",
|
||||
deviceId: "device-1",
|
||||
deviceSessionId: "device-session-1",
|
||||
compatibilityProfileId: "profile-1",
|
||||
controlMode: "operator-manual",
|
||||
state: acquisitionState,
|
||||
stateRevision: 1,
|
||||
operatorInstructions: [],
|
||||
cleanupPending,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test("every nonterminal acquisition phase blocks saved and manual source replacement", () => {
|
||||
for (const state of [
|
||||
"preparing",
|
||||
"prepared",
|
||||
"awaiting_external_start",
|
||||
"starting",
|
||||
"acquiring",
|
||||
"awaiting_external_stop",
|
||||
"stopping",
|
||||
"finalizing",
|
||||
]) {
|
||||
assert.equal(guard.isSpatialSourceSwitchBlocked(runtimeState(state)), true, state);
|
||||
}
|
||||
});
|
||||
|
||||
test("terminal or absent acquisitions allow an explicit source selection", () => {
|
||||
for (const state of ["completed", "failed", "aborted", "interrupted"]) {
|
||||
assert.equal(guard.isSpatialSourceSwitchBlocked(runtimeState(state)), false, state);
|
||||
}
|
||||
assert.equal(guard.isSpatialSourceSwitchBlocked(runtimeState(null)), false);
|
||||
assert.equal(guard.isSpatialSourceSwitchBlocked(null), false);
|
||||
});
|
||||
|
||||
test("an unknown acquisition state fails closed", () => {
|
||||
assert.equal(
|
||||
guard.isSpatialSourceSwitchBlocked(runtimeState("future_vendor_phase")),
|
||||
true,
|
||||
);
|
||||
assert.match(guard.SPATIAL_SOURCE_SWITCH_BLOCKED_REASON, /Завершите текущий приём/);
|
||||
});
|
||||
|
||||
test("terminal acquisition with retained cleanup remains source-switch blocked", () => {
|
||||
assert.equal(guard.isSpatialSourceSwitchBlocked(runtimeState("failed", true)), true);
|
||||
assert.equal(guard.isSpatialSourceSwitchBlocked(runtimeState("failed", false)), false);
|
||||
});
|
||||
|
||||
test("saved-session and manual source controls share the acquisition guard", async () => {
|
||||
const appSource = await readFile(new URL("../src/App.tsx", import.meta.url), "utf8");
|
||||
const sessionSelectSource = await readFile(
|
||||
new URL("../src/components/ObservationSessionSelect.tsx", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
const observationHookSource = await readFile(
|
||||
new URL("../src/core/observation/useObservationSessions.ts", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
assert.match(appSource, /blockedReason=\{sourceSwitchBlockedReason\}/);
|
||||
assert.match(appSource, /disabled=\{sourceSwitchBlocked \|\| !sourceDraft\.trim\(\)\}/);
|
||||
assert.match(appSource, /if \(sourceSwitchBlockedRef\.current\) return;/);
|
||||
assert.match(sessionSelectSource, /replayEnabled: blockedReason === null/);
|
||||
assert.match(observationHookSource, /!replayEnabledRef\.current/);
|
||||
});
|
||||
Reference in New Issue
Block a user