434 lines
15 KiB
JavaScript
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, /Открыть слои/);
|
|
});
|