Files
NODEDC_MISSION_CORE/apps/control-station/test/workspaceLayout.test.mjs
T

434 lines
15 KiB
JavaScript

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 decodeObservationWorkspaceLayoutProfile;
let encodeObservationWorkspaceLayoutProfile;
let fetchObservationWorkspaceLayoutProfile;
let normalizeObservationWindowRect;
let projectObservationLayoutSnapshot;
let saveObservationWorkspaceLayoutProfile;
let WorkspaceLayoutApiError;
let WorkspaceLayoutContractError;
let admitLiveDefaultPresentations;
let automaticLivePresentationIdentity;
let livePresentationCloseFence;
let restoredLayoutMayAdmitLiveDefault;
let observationPresentationSourceAfterLayoutApply;
let visibleSourceIdsAfterRecordedCatalogActivation;
before(async () => {
server = await createServer({
appType: "custom",
logLevel: "silent",
server: { middlewareMode: true },
});
({
decodeObservationWorkspaceLayoutProfile,
encodeObservationWorkspaceLayoutProfile,
fetchObservationWorkspaceLayoutProfile,
normalizeObservationWindowRect,
projectObservationLayoutSnapshot,
saveObservationWorkspaceLayoutProfile,
WorkspaceLayoutApiError,
WorkspaceLayoutContractError,
} = await server.ssrLoadModule("/src/core/observation/workspaceLayout.ts"));
({
admitLiveDefaultPresentations,
automaticLivePresentationIdentity,
livePresentationCloseFence,
restoredLayoutMayAdmitLiveDefault,
observationPresentationSourceAfterLayoutApply,
visibleSourceIdsAfterRecordedCatalogActivation,
} = await server.ssrLoadModule("/src/core/observation/useObservationLayout.ts"));
});
after(async () => {
await server?.close();
});
function wireProfile(overrides = {}) {
return {
version: 2,
revision: 7,
workspace_id: "observation.spatial",
scene_settings: {
projection: "3d",
point_size: 2.5,
color_mode: "intensity",
palette: "turbo",
custom_color: "#35d7c1",
accumulation_seconds: 12,
show_points: true,
show_trajectory: true,
show_grid: true,
show_labels: false,
show_camera_frustums: true,
},
visible_source_ids: ["spatial.point-cloud.live", "camera.left"],
active_floating_source_id: "camera.left",
window_rects: {
"camera.left": { x: 0.1, y: 0.1, width: 0.4, height: 0.4 },
"camera.right": { x: 0.55, y: 0.1, width: 0.4, height: 0.4 },
},
viewport_size: { width: 1_000, height: 500 },
...overrides,
};
}
test("workspace layout v2 excludes transient tool-window state", () => {
const wire = wireProfile();
const profile = decodeObservationWorkspaceLayoutProfile(wire);
assert.deepEqual(profile.visibleSourceIds, ["spatial.point-cloud.live", "camera.left"]);
assert.equal(profile.sceneSettings.pointSize, 2.5);
assert.deepEqual(encodeObservationWorkspaceLayoutProfile(profile), wire);
assert.equal("tool_windows" in encodeObservationWorkspaceLayoutProfile(profile), false);
});
test("workspace layout rejects unknown fields, transient transport data and invalid schema values", () => {
assert.throws(
() => decodeObservationWorkspaceLayoutProfile({
...wireProfile(),
source_url: "ws://192.168.68.52:9877",
}),
WorkspaceLayoutContractError,
);
assert.throws(
() => decodeObservationWorkspaceLayoutProfile(wireProfile({ version: 1 })),
/Неподдерживаемая версия/,
);
assert.throws(
() => decodeObservationWorkspaceLayoutProfile(wireProfile({ revision: 1.5 })),
/revision/,
);
assert.throws(
() => decodeObservationWorkspaceLayoutProfile(wireProfile({
visible_source_ids: ["../camera"],
active_floating_source_id: null,
})),
/стабильный идентификатор/,
);
assert.throws(
() => decodeObservationWorkspaceLayoutProfile(wireProfile({
window_rects: { "camera.left": { x: 0.8, y: 0, width: 0.4, height: 0.5 } },
})),
/выходит за нормализованные границы/,
);
assert.throws(
() => decodeObservationWorkspaceLayoutProfile({
...wireProfile(),
tool_windows: {
sources_open: true,
display_open: true,
layers_open: true,
order: ["sources", "display", "layers"],
},
}),
/неверная схема/,
);
assert.throws(
() => decodeObservationWorkspaceLayoutProfile(wireProfile({
scene_settings: { ...wireProfile().scene_settings, point_size: Number.POSITIVE_INFINITY },
})),
/point_size/,
);
});
test("restored desired layout survives an empty catalog and reveals only known stable sources later", () => {
const profile = decodeObservationWorkspaceLayoutProfile(wireProfile());
const snapshot = {
visibleSourceIds: profile.visibleSourceIds,
activeFloatingSourceId: profile.activeFloatingSourceId,
windowRects: profile.windowRects,
viewportSize: profile.viewportSize,
};
const empty = projectObservationLayoutSnapshot(snapshot, new Set(), { width: 500, height: 1_000 });
assert.deepEqual(empty, {
visibleSourceIds: [],
activeFloatingSourceId: null,
windowRects: {},
});
assert.deepEqual(snapshot.visibleSourceIds, ["spatial.point-cloud.live", "camera.left"]);
const cameraAppeared = projectObservationLayoutSnapshot(
snapshot,
new Set(["camera.left"]),
{ width: 500, height: 1_000 },
);
assert.deepEqual(cameraAppeared.visibleSourceIds, ["camera.left"]);
assert.equal(cameraAppeared.activeFloatingSourceId, "camera.left");
assert.deepEqual(cameraAppeared.windowRects["camera.left"], {
x: 50,
y: 100,
width: 200,
height: 400,
});
});
test("window rectangles normalize once and project proportionally into a different viewport", () => {
assert.deepEqual(
normalizeObservationWindowRect(
{ x: 100, y: 50, width: 400, height: 200 },
{ width: 1_000, height: 500 },
),
{ x: 0.1, y: 0.1, width: 0.4, height: 0.4 },
);
});
test("fullscreen presentation survives the viewport resize it causes", () => {
assert.equal(
observationPresentationSourceAfterLayoutApply("camera.left", "preserve"),
"camera.left",
);
assert.equal(
observationPresentationSourceAfterLayoutApply("camera.left", "reset"),
null,
);
});
test("opening a recorded catalog reveals its sealed cameras beside the point cloud", () => {
const source = (id, modality, transport = "recording") => ({
id,
modality,
transport,
availability: "available",
previewUrl: null,
delivery: modality === "video" ? { kind: "recorded-fmp4-manifest" } : null,
activation: null,
capabilities: {
defaultVisible: true,
overlay: modality === "video",
},
});
const sources = [
source("recorded.spatial.primary", "point-cloud"),
source("recorded.camera.left", "video"),
source("recorded.camera.right", "video"),
source("live.camera", "video", "websocket"),
];
assert.deepEqual(
visibleSourceIdsAfterRecordedCatalogActivation(
["recorded.spatial.primary"],
sources,
),
["recorded.spatial.primary", "recorded.camera.left", "recorded.camera.right"],
);
});
test("a sequential live acquisition re-arms the same camera without reopening a deliberate close", () => {
const pointCloud = {
id: "k1:sensor.lidar.primary",
sourceId: "sensor.lidar.primary",
modality: "point-cloud",
availability: "streaming",
transport: "rerun-grpc",
previewUrl: "grpc://127.0.0.1:9876/proxy",
delivery: null,
activation: null,
binding: {
deviceId: "k1-a",
deviceSessionId: "device-session-reused",
acquisitionId: "acquisition-a",
},
capabilities: { defaultVisible: true, overlay: false },
};
const camera = (acquisitionId, deliveryId) => ({
id: "k1:sensor.camera.right",
sourceId: "sensor.camera.right",
modality: "video",
availability: "streaming",
transport: "websocket",
previewUrl: null,
delivery: {
id: deliveryId,
kind: "mse-fmp4-websocket",
url: "/camera-preview/reused",
mediaType: 'video/mp4; codecs="avc1.641028"',
},
activation: {
groupId: "k1:device-session-reused:camera.preview.decoder",
maxActive: 1,
selected: true,
controllable: true,
},
binding: {
deviceId: "k1-a",
deviceSessionId: "device-session-reused",
acquisitionId,
},
capabilities: { defaultVisible: true, overlay: true },
});
const firstCamera = camera("acquisition-a", "camera-preview-2");
const first = admitLiveDefaultPresentations(
[pointCloud.id],
[pointCloud, firstCamera],
new Set(),
new Set(),
);
assert.deepEqual(first.visibleIds, [pointCloud.id, firstCamera.id]);
assert.deepEqual(first.admittedIdentities, [
automaticLivePresentationIdentity(firstCamera),
]);
const sameAcquisitionNewDelivery = camera("acquisition-a", "camera-preview-3");
const closedInFirstAcquisition = new Set([
livePresentationCloseFence(firstCamera),
]);
const afterDeliberateClose = admitLiveDefaultPresentations(
[pointCloud.id],
[pointCloud, sameAcquisitionNewDelivery],
new Set(first.admittedIdentities),
closedInFirstAcquisition,
);
assert.deepEqual(afterDeliberateClose.visibleIds, [pointCloud.id]);
assert.deepEqual(afterDeliberateClose.admittedIdentities, []);
const nextAcquisitionSameDelivery = camera("acquisition-b", "camera-preview-3");
assert.notEqual(
automaticLivePresentationIdentity(nextAcquisitionSameDelivery),
automaticLivePresentationIdentity(sameAcquisitionNewDelivery),
);
const second = admitLiveDefaultPresentations(
[pointCloud.id],
[
{ ...pointCloud, binding: { ...pointCloud.binding, acquisitionId: "acquisition-b" } },
nextAcquisitionSameDelivery,
],
new Set(first.admittedIdentities),
closedInFirstAcquisition,
);
assert.deepEqual(second.visibleIds, [pointCloud.id, nextAcquisitionSameDelivery.id]);
assert.deepEqual(second.admittedIdentities, [
automaticLivePresentationIdentity(nextAcquisitionSameDelivery),
]);
});
test("a restored layout admits a selected delivery after reload and only same-acquisition successors", () => {
const camera = (acquisitionId, deliveryId) => ({
id: "k1:sensor.camera.right",
sourceId: "sensor.camera.right",
modality: "video",
availability: "streaming",
transport: "websocket",
previewUrl: null,
delivery: {
id: deliveryId,
kind: "mse-fmp4-websocket",
url: `/camera-preview/${deliveryId}`,
mediaType: 'video/mp4; codecs="avc1.641028"',
},
activation: {
groupId: "k1:device-session-reused:camera.preview.decoder",
maxActive: 1,
selected: true,
controllable: true,
},
binding: {
deviceId: "k1-a",
deviceSessionId: "device-session-reused",
acquisitionId,
},
capabilities: { defaultVisible: true, overlay: true },
});
const original = camera("acquisition-a", "camera-preview-2");
const successor = camera("acquisition-a", "camera-preview-3");
const unrelated = camera("acquisition-b", "camera-preview-3");
const presented = new Set([livePresentationCloseFence(original)]);
assert.equal(restoredLayoutMayAdmitLiveDefault([original], new Set()), true);
assert.equal(restoredLayoutMayAdmitLiveDefault([successor], presented), true);
assert.equal(restoredLayoutMayAdmitLiveDefault([unrelated], presented), false);
assert.equal(restoredLayoutMayAdmitLiveDefault([{
...original,
activation: { ...original.activation, selected: false },
}], new Set()), false);
const admitted = admitLiveDefaultPresentations(
[],
[successor],
new Set([automaticLivePresentationIdentity(original)]),
new Set(),
);
assert.deepEqual(admitted.visibleIds, [successor.id]);
assert.deepEqual(admitted.admittedIdentities, [
automaticLivePresentationIdentity(successor),
]);
const deliberatelyClosed = admitLiveDefaultPresentations(
[],
[successor],
new Set([automaticLivePresentationIdentity(original)]),
presented,
);
assert.deepEqual(deliberatelyClosed.visibleIds, []);
assert.deepEqual(deliberatelyClosed.admittedIdentities, []);
});
test("workspace layout API uses the canonical endpoint and optimistic revision", async () => {
const calls = [];
const current = decodeObservationWorkspaceLayoutProfile(wireProfile());
const saved = await saveObservationWorkspaceLayoutProfile(current, {
fetcher: async (input, init) => {
calls.push({ input: String(input), init, body: JSON.parse(String(init.body)) });
return new Response(JSON.stringify(wireProfile({ revision: 8 })), {
status: 200,
headers: { "Content-Type": "application/json" },
});
},
});
assert.equal(calls.length, 1);
assert.equal(calls[0].input, "/api/v1/workspace-layouts/observation.spatial");
assert.equal(calls[0].init.method, "PUT");
assert.equal(calls[0].init.headers["If-Match"], '"7"');
assert.equal(calls[0].body.revision, 7);
assert.equal(calls[0].body.workspace_id, "observation.spatial");
assert.equal(saved.revision, 8);
});
test("workspace layout API treats 404 as no profile and exposes revision conflicts", async () => {
assert.equal(
await fetchObservationWorkspaceLayoutProfile({
fetcher: async () => new Response(null, { status: 404 }),
}),
null,
);
const current = decodeObservationWorkspaceLayoutProfile(wireProfile());
await assert.rejects(
saveObservationWorkspaceLayoutProfile(current, {
fetcher: async () => new Response(JSON.stringify({ detail: "revision mismatch" }), {
status: 412,
headers: { "Content-Type": "application/json" },
}),
}),
(error) => error instanceof WorkspaceLayoutApiError &&
error.conflict && error.status === 412 && error.message === "revision mismatch",
);
});
test("scene tool windows are route-scoped and toolbar actions are not duplicated", async () => {
const appSource = await readFile(new URL("../src/App.tsx", import.meta.url), "utf8");
const utilityStart = appSource.indexOf("const contentActions");
const utilityEnd = appSource.indexOf("const header", utilityStart);
const utilitySource = appSource.slice(utilityStart, utilityEnd);
assert.match(appSource, /open=\{sceneWorkspaceActive && sourceWindowOpen\}/);
assert.match(appSource, /open=\{sceneWorkspaceActive && displayWindowOpen\}/);
assert.match(appSource, /open=\{sceneWorkspaceActive && layerInspectorOpen\}/);
assert.match(
appSource,
/if \(sceneWorkspaceActive\) return;[\s\S]*setSceneWindowOrder\(\[\]\)/,
);
assert.doesNotMatch(utilitySource, /Настроить визуальный движок/);
assert.doesNotMatch(utilitySource, /Настроить отображение/);
assert.doesNotMatch(utilitySource, /Открыть слои/);
});