feat(control-station): add atomic recorded-session playback
This commit is contained in:
@@ -0,0 +1,229 @@
|
||||
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",
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user