230 lines
7.5 KiB
JavaScript
230 lines
7.5 KiB
JavaScript
import assert from "node:assert/strict";
|
|
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 observationPresentationSourceAfterLayoutApply;
|
|
|
|
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"));
|
|
({ observationPresentationSourceAfterLayoutApply } = await server.ssrLoadModule(
|
|
"/src/core/observation/useObservationLayout.ts",
|
|
));
|
|
});
|
|
|
|
after(async () => {
|
|
await server?.close();
|
|
});
|
|
|
|
function wireProfile(overrides = {}) {
|
|
return {
|
|
version: 1,
|
|
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,
|
|
},
|
|
tool_windows: {
|
|
sources_open: true,
|
|
display_open: false,
|
|
layers_open: true,
|
|
order: ["sources", "layers", "display"],
|
|
},
|
|
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 performs a lossless strict wire/camel/wire round trip", () => {
|
|
const wire = wireProfile();
|
|
const profile = decodeObservationWorkspaceLayoutProfile(wire);
|
|
|
|
assert.deepEqual(profile.visibleSourceIds, ["spatial.point-cloud.live", "camera.left"]);
|
|
assert.deepEqual(profile.toolWindows.order, ["sources", "layers", "display"]);
|
|
assert.equal(profile.sceneSettings.pointSize, 2.5);
|
|
assert.deepEqual(encodeObservationWorkspaceLayoutProfile(profile), wire);
|
|
});
|
|
|
|
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: 2 })),
|
|
/Неподдерживаемая версия/,
|
|
);
|
|
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", "sources", "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("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",
|
|
);
|
|
});
|