feat(control-station): add atomic recorded-session playback
This commit is contained in:
@@ -0,0 +1,832 @@
|
||||
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 decodeObservationSessionCatalog;
|
||||
let decodeObservationSessionReplay;
|
||||
let decodeObservationSessionPreparation;
|
||||
let fetchObservationSessionCatalog;
|
||||
let replayObservationSession;
|
||||
let fetchObservationSessionPreparation;
|
||||
let cancelObservationSessionPreparation;
|
||||
let ObservationSessionApiError;
|
||||
let ObservationSessionContractError;
|
||||
let decodeObservationRecordedMediaManifest;
|
||||
let createObservationReplayCoordinator;
|
||||
let resolveObservationSessionReplay;
|
||||
let waitForObservationReplayPreparation;
|
||||
let storeObservationReplayPreparation;
|
||||
let loadObservationReplayPreparation;
|
||||
let observationSessionVisualState;
|
||||
let observationSessionVisualLabel;
|
||||
|
||||
before(async () => {
|
||||
server = await createServer({
|
||||
appType: "custom",
|
||||
logLevel: "silent",
|
||||
server: { middlewareMode: true },
|
||||
});
|
||||
({
|
||||
decodeObservationSessionCatalog,
|
||||
decodeObservationSessionReplay,
|
||||
decodeObservationSessionPreparation,
|
||||
fetchObservationSessionCatalog,
|
||||
replayObservationSession,
|
||||
fetchObservationSessionPreparation,
|
||||
cancelObservationSessionPreparation,
|
||||
ObservationSessionApiError,
|
||||
ObservationSessionContractError,
|
||||
decodeObservationRecordedMediaManifest,
|
||||
} = await server.ssrLoadModule("/src/core/observation/sessionArchive.ts"));
|
||||
({
|
||||
createObservationReplayCoordinator,
|
||||
resolveObservationSessionReplay,
|
||||
waitForObservationReplayPreparation,
|
||||
storeObservationReplayPreparation,
|
||||
loadObservationReplayPreparation,
|
||||
} = await server.ssrLoadModule(
|
||||
"/src/core/observation/useObservationSessions.ts",
|
||||
));
|
||||
({ observationSessionVisualState, observationSessionVisualLabel } = await server.ssrLoadModule(
|
||||
"/src/components/ObservationSessionSelect.tsx",
|
||||
));
|
||||
});
|
||||
|
||||
after(async () => {
|
||||
await server?.close();
|
||||
});
|
||||
|
||||
function session(overrides = {}) {
|
||||
return {
|
||||
id: "20260716T205632Z_viewer_live",
|
||||
label: "K1 · наблюдение 16 июля",
|
||||
started_at_utc: "2026-07-16T20:56:32.635Z",
|
||||
completed_at_utc: "2026-07-16T21:20:43.379Z",
|
||||
status: "ready",
|
||||
modalities: ["point-cloud", "pose"],
|
||||
duration_seconds: 1_450.744,
|
||||
replayable: true,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function replay(overrides = {}) {
|
||||
const sessionId = overrides.session_id ?? "session-20260716T205632Z";
|
||||
const sourceUrl = overrides.source_url ??
|
||||
`/api/v1/observation-sessions/${encodeURIComponent(sessionId)}/recording.rrd`;
|
||||
const sha256 = overrides.sha256 ?? "a".repeat(64);
|
||||
return {
|
||||
schema_version: "missioncore.observation-session-replay/v2",
|
||||
launch: {
|
||||
kind: "rerun-recording",
|
||||
session_id: sessionId,
|
||||
source_url: sourceUrl,
|
||||
viewer_source_url: overrides.viewer_source_url ?? `${sourceUrl}?generation=${sha256}`,
|
||||
media_type: "application/vnd.rerun.rrd",
|
||||
timeline: "session_time",
|
||||
timeline_start_seconds: 0,
|
||||
timeline_end_seconds: 1_450.744,
|
||||
seekable: true,
|
||||
byte_length: 123_456,
|
||||
sha256,
|
||||
playback: { speed: 1, loop: false },
|
||||
media_sources: [],
|
||||
...overrides,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function preparation(overrides = {}) {
|
||||
const sessionId = overrides.session_id ?? "session-20260716T205632Z";
|
||||
return {
|
||||
schema_version: "missioncore.observation-session-preparation/v1",
|
||||
preparation: {
|
||||
preparation_id: "prepare-20260717T131400Z",
|
||||
session_id: sessionId,
|
||||
state: "queued",
|
||||
progress: null,
|
||||
updated_at_utc: "2026-07-17T10:14:00Z",
|
||||
status_url: `/api/v1/observation-sessions/${encodeURIComponent(sessionId)}/recording-preparation`,
|
||||
cancellable: true,
|
||||
...overrides,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const preparationEtag = '"prepare-20260717T131400Z"';
|
||||
|
||||
test("session catalog decodes canonical snake_case into a path-free camelCase model", () => {
|
||||
const catalog = decodeObservationSessionCatalog({ items: [session()] });
|
||||
|
||||
assert.deepEqual(catalog.items, [{
|
||||
id: "20260716T205632Z_viewer_live",
|
||||
label: "K1 · наблюдение 16 июля",
|
||||
startedAtUtc: "2026-07-16T20:56:32.635Z",
|
||||
completedAtUtc: "2026-07-16T21:20:43.379Z",
|
||||
status: "ready",
|
||||
modalities: ["point-cloud", "pose"],
|
||||
durationSeconds: 1_450.744,
|
||||
replayable: true,
|
||||
preparation: null,
|
||||
}]);
|
||||
assert.equal("raw_capture" in catalog.items[0], false);
|
||||
assert.equal("path" in catalog.items[0], false);
|
||||
});
|
||||
|
||||
test("session catalog exposes authoritative background preparation state", () => {
|
||||
const catalog = decodeObservationSessionCatalog({
|
||||
items: [session({
|
||||
preparation: {
|
||||
preparation_id: "prepare-catalog-1",
|
||||
state: "exporting",
|
||||
progress: 0.42,
|
||||
updated_at_utc: "2026-07-17T10:14:00Z",
|
||||
cancellable: true,
|
||||
retryable: false,
|
||||
},
|
||||
})],
|
||||
});
|
||||
assert.deepEqual(catalog.items[0].preparation, {
|
||||
preparationId: "prepare-catalog-1",
|
||||
state: "exporting",
|
||||
progress: 0.42,
|
||||
updatedAtUtc: "2026-07-17T10:14:00Z",
|
||||
cancellable: true,
|
||||
retryable: false,
|
||||
error: null,
|
||||
});
|
||||
});
|
||||
|
||||
test("saved-session rows use only authoritative ready, processing and error states", () => {
|
||||
const makeDecoded = (state, overrides = {}) => decodeObservationSessionCatalog({
|
||||
items: [session({
|
||||
status: "interrupted",
|
||||
preparation: state === null ? null : {
|
||||
preparation_id: `prepare-${state}`,
|
||||
state,
|
||||
progress: state === "ready" ? 1 : 0.5,
|
||||
updated_at_utc: "2026-07-17T10:14:00Z",
|
||||
cancellable: ["queued", "validating", "exporting", "finalizing"].includes(state),
|
||||
retryable: state === "failed",
|
||||
...(state === "failed" ? { error: "export failed" } : {}),
|
||||
},
|
||||
...overrides,
|
||||
})],
|
||||
}).items[0];
|
||||
|
||||
assert.equal(observationSessionVisualState(makeDecoded("ready")), "ready");
|
||||
for (const state of ["queued", "validating", "exporting", "finalizing"]) {
|
||||
assert.equal(observationSessionVisualState(makeDecoded(state)), "processing");
|
||||
}
|
||||
assert.equal(observationSessionVisualState(makeDecoded("failed")), "error");
|
||||
assert.equal(observationSessionVisualState(makeDecoded("cancelled")), "error");
|
||||
assert.equal(
|
||||
observationSessionVisualState(makeDecoded("failed", { status: "recording" })),
|
||||
"error",
|
||||
);
|
||||
assert.equal(observationSessionVisualState(makeDecoded(null)), "error");
|
||||
assert.equal(
|
||||
observationSessionVisualState(makeDecoded(null, { status: "recording" })),
|
||||
"processing",
|
||||
);
|
||||
assert.equal(
|
||||
observationSessionVisualState(makeDecoded("ready", { replayable: false })),
|
||||
"error",
|
||||
);
|
||||
assert.equal(
|
||||
observationSessionVisualState(makeDecoded("ready"), { failed: true }),
|
||||
"error",
|
||||
);
|
||||
assert.equal(
|
||||
observationSessionVisualState(makeDecoded("failed"), { pending: true, failed: true }),
|
||||
"error",
|
||||
);
|
||||
assert.equal(observationSessionVisualLabel("ready"), "Готово");
|
||||
assert.equal(observationSessionVisualLabel("processing"), "Обработка");
|
||||
assert.equal(observationSessionVisualLabel("error"), "Ошибка");
|
||||
});
|
||||
|
||||
test("saved-session lamps use green or dim gray only, never warning or danger colors", async () => {
|
||||
const css = await readFile(
|
||||
new URL("../src/styles/observation-sessions.css", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
const start = css.indexOf(".observation-session-option i {");
|
||||
const end = css.indexOf(".observation-session-option__state", start);
|
||||
assert.notEqual(start, -1);
|
||||
assert.notEqual(end, -1);
|
||||
const lampRules = css.slice(start, end);
|
||||
assert.match(lampRules, /data-session-visual-state="ready"[\s\S]*--nodedc-success-rgb/);
|
||||
assert.match(lampRules, /data-session-visual-state="processing"[\s\S]*animation:/);
|
||||
assert.match(lampRules, /data-session-visual-state="error"[\s\S]*--nodedc-text-muted/);
|
||||
assert.match(lampRules, /data-session-visual-state="error"[\s\S]*opacity:\s*0\.48/);
|
||||
assert.doesNotMatch(lampRules, /\b(?:warning|danger|yellow|red)\b/i);
|
||||
});
|
||||
|
||||
test("session catalog sorts newest first and uses id as a deterministic tie-breaker", () => {
|
||||
const catalog = decodeObservationSessionCatalog({
|
||||
items: [
|
||||
session({ id: "same-b", started_at_utc: "2026-07-16T19:00:00Z", completed_at_utc: null }),
|
||||
session({ id: "newest", started_at_utc: "2026-07-17T00:00:00Z", completed_at_utc: null }),
|
||||
session({ id: "same-a", started_at_utc: "2026-07-16T19:00:00Z", completed_at_utc: null }),
|
||||
],
|
||||
});
|
||||
|
||||
assert.deepEqual(catalog.items.map(({ id }) => id), ["newest", "same-a", "same-b"]);
|
||||
});
|
||||
|
||||
test("session catalog rejects raw storage fields, unsafe ids and duplicate ids", () => {
|
||||
assert.throws(
|
||||
() => decodeObservationSessionCatalog({
|
||||
items: [session({ raw_capture: "sessions/private/mqtt.raw.k1mqtt" })],
|
||||
}),
|
||||
ObservationSessionContractError,
|
||||
);
|
||||
assert.throws(
|
||||
() => decodeObservationSessionCatalog({ items: [session({ id: "../private" })] }),
|
||||
ObservationSessionContractError,
|
||||
);
|
||||
assert.throws(
|
||||
() => decodeObservationSessionCatalog({ items: [session(), session()] }),
|
||||
/повторяющийся id/,
|
||||
);
|
||||
});
|
||||
|
||||
test("session catalog rejects invalid temporal, status and duration values", () => {
|
||||
assert.throws(
|
||||
() => decodeObservationSessionCatalog({
|
||||
items: [session({ started_at_utc: "16 July 2026" })],
|
||||
}),
|
||||
/ISO-датой/,
|
||||
);
|
||||
assert.throws(
|
||||
() => decodeObservationSessionCatalog({ items: [session({ status: "complete" })] }),
|
||||
/неизвестное состояние/,
|
||||
);
|
||||
assert.throws(
|
||||
() => decodeObservationSessionCatalog({ items: [session({ duration_seconds: -1 })] }),
|
||||
/неотрицательным числом/,
|
||||
);
|
||||
assert.throws(
|
||||
() => decodeObservationSessionCatalog({
|
||||
items: [session({ completed_at_utc: "2026-07-16T19:00:00Z" })],
|
||||
}),
|
||||
/раньше времени начала/,
|
||||
);
|
||||
});
|
||||
|
||||
test("catalog API preserves HTTP detail without accepting a malformed success body", async () => {
|
||||
await assert.rejects(
|
||||
fetchObservationSessionCatalog({
|
||||
fetcher: async () => new Response(JSON.stringify({ detail: "storage unavailable" }), {
|
||||
status: 503,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}),
|
||||
}),
|
||||
(error) => error instanceof ObservationSessionApiError &&
|
||||
error.status === 503 && error.message === "storage unavailable",
|
||||
);
|
||||
|
||||
await assert.rejects(
|
||||
fetchObservationSessionCatalog({
|
||||
fetcher: async () => new Response(JSON.stringify({ items: "not-an-array" }), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}),
|
||||
}),
|
||||
ObservationSessionContractError,
|
||||
);
|
||||
});
|
||||
|
||||
test("replay API accepts only a same-origin seekable recording descriptor", async () => {
|
||||
const calls = [];
|
||||
const launch = await replayObservationSession("session-20260716T205632Z", {
|
||||
fetcher: async (input, init) => {
|
||||
calls.push({ input: String(input), init });
|
||||
return new Response(JSON.stringify(replay()), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(calls.length, 1);
|
||||
assert.equal(calls[0].input, "/api/v1/observation-sessions/session-20260716T205632Z/replay");
|
||||
assert.equal(calls[0].init.method, "POST");
|
||||
assert.equal(launch.kind, "ready");
|
||||
assert.equal(launch.launch.timeline, "session_time");
|
||||
assert.equal(
|
||||
launch.launch.sourceUrl,
|
||||
"/api/v1/observation-sessions/session-20260716T205632Z/recording.rrd",
|
||||
);
|
||||
await assert.rejects(
|
||||
replayObservationSession("../private", { fetcher: async () => new Response(null) }),
|
||||
ObservationSessionContractError,
|
||||
);
|
||||
});
|
||||
|
||||
test("preparation contract is strict, session-scoped and same-origin", async () => {
|
||||
const sessionId = "session-20260716T205632Z";
|
||||
const decoded = decodeObservationSessionPreparation(preparation(), sessionId);
|
||||
assert.equal(decoded.sessionId, sessionId);
|
||||
assert.equal(decoded.state, "queued");
|
||||
assert.equal(decoded.progress, null);
|
||||
|
||||
assert.throws(
|
||||
() => decodeObservationSessionPreparation(
|
||||
preparation({ status_url: "https://invalid.test/status" }),
|
||||
sessionId,
|
||||
),
|
||||
/канонический same-origin/,
|
||||
);
|
||||
assert.throws(
|
||||
() => decodeObservationSessionPreparation(preparation({ debug_path: "/tmp/raw" }), sessionId),
|
||||
/неизвестные поля/,
|
||||
);
|
||||
assert.throws(
|
||||
() => decodeObservationSessionPreparation(preparation({ progress: 1.1 }), sessionId),
|
||||
/недопустимое число/,
|
||||
);
|
||||
await assert.rejects(
|
||||
replayObservationSession(sessionId, {
|
||||
fetcher: async () => new Response(JSON.stringify(preparation()), {
|
||||
status: 202,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}),
|
||||
}),
|
||||
/preparation ETag/,
|
||||
);
|
||||
});
|
||||
|
||||
test("202 preparation polls through explicit phases and reveals launch only when ready", async () => {
|
||||
const sessionId = "session-20260716T205632Z";
|
||||
const calls = [];
|
||||
const phases = [];
|
||||
let poll = 0;
|
||||
let clock = 0;
|
||||
let previousViewerMounted = true;
|
||||
const launch = await resolveObservationSessionReplay(sessionId, {
|
||||
signal: new AbortController().signal,
|
||||
requestTimeoutMs: 1_000,
|
||||
heartbeatStallMs: 5_000,
|
||||
maximumWaitMs: 10_000,
|
||||
initialPollIntervalMs: 50,
|
||||
maximumPollIntervalMs: 50,
|
||||
now: () => clock,
|
||||
sleep: async (milliseconds) => {
|
||||
assert.equal(previousViewerMounted, true);
|
||||
clock += milliseconds;
|
||||
},
|
||||
onUpdate: (value) => {
|
||||
assert.equal(previousViewerMounted, true);
|
||||
phases.push([value.state, value.progress]);
|
||||
},
|
||||
fetcher: async (input, init) => {
|
||||
calls.push([String(input), init.method, new Headers(init.headers).get("If-Match")]);
|
||||
if (init.method === "POST") {
|
||||
return new Response(JSON.stringify(preparation()), {
|
||||
status: 202,
|
||||
headers: { "Content-Type": "application/json", ETag: preparationEtag },
|
||||
});
|
||||
}
|
||||
poll += 1;
|
||||
if (poll === 1) {
|
||||
return new Response(JSON.stringify(preparation({
|
||||
state: "exporting",
|
||||
progress: 0.5,
|
||||
updated_at_utc: "2026-07-17T10:14:01Z",
|
||||
})), {
|
||||
status: 202,
|
||||
headers: { "Content-Type": "application/json", ETag: preparationEtag },
|
||||
});
|
||||
}
|
||||
return new Response(JSON.stringify(replay({ session_id: sessionId })), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json", ETag: preparationEtag },
|
||||
});
|
||||
},
|
||||
});
|
||||
// This is the point where App is allowed to unmount the old viewer.
|
||||
previousViewerMounted = false;
|
||||
assert.equal(launch.sessionId, sessionId);
|
||||
assert.deepEqual(phases, [["queued", null], ["exporting", 0.5]]);
|
||||
assert.deepEqual(calls.map((entry) => entry[1]), ["POST", "GET", "GET"]);
|
||||
assert.deepEqual(calls.map((entry) => entry[2]), [null, preparationEtag, preparationEtag]);
|
||||
});
|
||||
|
||||
test("status-ready response must match the preparation ETag before launch is accepted", async () => {
|
||||
const decoded = decodeObservationSessionPreparation(
|
||||
preparation({ state: "finalizing", progress: 0.95 }),
|
||||
"session-20260716T205632Z",
|
||||
);
|
||||
let receivedIfMatch;
|
||||
await assert.rejects(
|
||||
fetchObservationSessionPreparation(decoded, {
|
||||
fetcher: async (_input, init) => {
|
||||
receivedIfMatch = new Headers(init.headers).get("If-Match");
|
||||
return new Response(JSON.stringify(replay()), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json", ETag: '"replacement-job"' },
|
||||
});
|
||||
},
|
||||
}),
|
||||
/актуальный preparation ETag/,
|
||||
);
|
||||
assert.equal(receivedIfMatch, preparationEtag);
|
||||
});
|
||||
|
||||
test("preparation cancellation is conditional on the same preparation ETag", async () => {
|
||||
const decoded = decodeObservationSessionPreparation(
|
||||
preparation({ state: "exporting", progress: 0.5 }),
|
||||
"session-20260716T205632Z",
|
||||
);
|
||||
let request;
|
||||
const result = await cancelObservationSessionPreparation(decoded, {
|
||||
fetcher: async (input, init) => {
|
||||
request = {
|
||||
input: String(input),
|
||||
method: init.method,
|
||||
ifMatch: new Headers(init.headers).get("If-Match"),
|
||||
};
|
||||
return new Response(null, { status: 204 });
|
||||
},
|
||||
});
|
||||
assert.equal(result, null);
|
||||
assert.deepEqual(request, {
|
||||
input: decoded.statusUrl,
|
||||
method: "DELETE",
|
||||
ifMatch: preparationEtag,
|
||||
});
|
||||
});
|
||||
|
||||
test("preparation polling fails explicitly when the server heartbeat stalls", async () => {
|
||||
const decoded = decodeObservationSessionPreparation(
|
||||
preparation({ state: "exporting", progress: 0.1 }),
|
||||
"session-20260716T205632Z",
|
||||
);
|
||||
let clock = 0;
|
||||
await assert.rejects(
|
||||
waitForObservationReplayPreparation(decoded, {
|
||||
signal: new AbortController().signal,
|
||||
requestTimeoutMs: 1_000,
|
||||
heartbeatStallMs: 250,
|
||||
maximumWaitMs: 5_000,
|
||||
initialPollIntervalMs: 150,
|
||||
maximumPollIntervalMs: 150,
|
||||
now: () => clock,
|
||||
sleep: async (milliseconds) => { clock += milliseconds; },
|
||||
fetcher: async () => new Response(JSON.stringify(preparation({
|
||||
state: "exporting",
|
||||
progress: 0.1,
|
||||
})), {
|
||||
status: 202,
|
||||
headers: { "Content-Type": "application/json", ETag: preparationEtag },
|
||||
}),
|
||||
}),
|
||||
/перестала обновляться/,
|
||||
);
|
||||
});
|
||||
|
||||
test("queued preparation can wait behind the bounded backend worker without false heartbeat failure", async () => {
|
||||
const decoded = decodeObservationSessionPreparation(
|
||||
preparation({ state: "queued", progress: 0 }),
|
||||
"session-20260716T205632Z",
|
||||
);
|
||||
let clock = 0;
|
||||
let polls = 0;
|
||||
const launch = await waitForObservationReplayPreparation(decoded, {
|
||||
signal: new AbortController().signal,
|
||||
requestTimeoutMs: 1_000,
|
||||
heartbeatStallMs: 250,
|
||||
maximumWaitMs: 5_000,
|
||||
initialPollIntervalMs: 150,
|
||||
maximumPollIntervalMs: 150,
|
||||
now: () => clock,
|
||||
sleep: async (milliseconds) => { clock += milliseconds; },
|
||||
fetcher: async () => {
|
||||
polls += 1;
|
||||
if (polls <= 2) {
|
||||
return new Response(JSON.stringify(preparation({ state: "queued", progress: 0 })), {
|
||||
status: 202,
|
||||
headers: { "Content-Type": "application/json", ETag: preparationEtag },
|
||||
});
|
||||
}
|
||||
return new Response(JSON.stringify(replay()), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json", ETag: preparationEtag },
|
||||
});
|
||||
},
|
||||
});
|
||||
assert.equal(clock, 450);
|
||||
assert.equal(launch.sessionId, "session-20260716T205632Z");
|
||||
});
|
||||
|
||||
test("terminal preparation failure is explicit and retryable", async () => {
|
||||
const sessionId = "session-20260716T205632Z";
|
||||
const response = await replayObservationSession(sessionId, {
|
||||
fetcher: async () => new Response(JSON.stringify(preparation({
|
||||
state: "failed",
|
||||
progress: 0.4,
|
||||
cancellable: false,
|
||||
retryable: true,
|
||||
error: "Повреждён индекс исходной записи.",
|
||||
})), {
|
||||
status: 409,
|
||||
headers: { "Content-Type": "application/json", ETag: preparationEtag },
|
||||
}),
|
||||
});
|
||||
assert.equal(response.kind, "preparing");
|
||||
assert.equal(response.preparation.retryable, true);
|
||||
assert.equal(response.preparation.error, "Повреждён индекс исходной записи.");
|
||||
});
|
||||
|
||||
test("pending preparation survives reload only through its strict canonical contract", () => {
|
||||
const values = new Map();
|
||||
const storage = {
|
||||
get length() { return values.size; },
|
||||
clear: () => values.clear(),
|
||||
getItem: (key) => values.get(key) ?? null,
|
||||
key: (index) => [...values.keys()][index] ?? null,
|
||||
removeItem: (key) => values.delete(key),
|
||||
setItem: (key, value) => values.set(key, String(value)),
|
||||
};
|
||||
const decoded = decodeObservationSessionPreparation(
|
||||
preparation({ state: "exporting", progress: 0.7 }),
|
||||
"session-20260716T205632Z",
|
||||
);
|
||||
storeObservationReplayPreparation(decoded, storage);
|
||||
assert.deepEqual(loadObservationReplayPreparation(storage), decoded);
|
||||
|
||||
const key = storage.key(0);
|
||||
storage.setItem(key, JSON.stringify({ ...preparation(), raw_path: "/private/raw" }));
|
||||
assert.equal(loadObservationReplayPreparation(storage), null);
|
||||
assert.equal(storage.length, 0);
|
||||
});
|
||||
|
||||
test("replay API forwards cancellation and preserves AbortError semantics", async () => {
|
||||
const controller = new AbortController();
|
||||
let receivedSignal;
|
||||
const pending = replayObservationSession("session-20260716T205632Z", {
|
||||
signal: controller.signal,
|
||||
fetcher: async (_input, init) => {
|
||||
receivedSignal = init.signal;
|
||||
return await new Promise((_resolve, reject) => {
|
||||
init.signal.addEventListener("abort", () => {
|
||||
reject(new DOMException("cancelled", "AbortError"));
|
||||
}, { once: true });
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
controller.abort();
|
||||
assert.equal(receivedSignal, controller.signal);
|
||||
await assert.rejects(pending, (error) => error?.name === "AbortError");
|
||||
});
|
||||
|
||||
test("replay coordinator makes rapid saved-session selection latest-request-wins", () => {
|
||||
const coordinator = createObservationReplayCoordinator();
|
||||
const first = coordinator.begin();
|
||||
assert.equal(first.isCurrent(), true);
|
||||
|
||||
const second = coordinator.begin();
|
||||
assert.equal(first.signal.aborted, true);
|
||||
assert.equal(first.isCurrent(), false);
|
||||
assert.equal(first.finish(), false);
|
||||
assert.equal(second.isCurrent(), true);
|
||||
assert.equal(second.finish(), true);
|
||||
assert.equal(second.isCurrent(), false);
|
||||
|
||||
const third = coordinator.begin();
|
||||
coordinator.cancel();
|
||||
assert.equal(third.signal.aborted, true);
|
||||
assert.equal(third.isCurrent(), false);
|
||||
});
|
||||
|
||||
test("switching saved sessions aborts only local polling and never cancels shared backend work", async () => {
|
||||
const hookSource = await readFile(
|
||||
new URL("../src/core/observation/useObservationSessions.ts", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
const selectorSource = await readFile(
|
||||
new URL("../src/components/ObservationSessionSelect.tsx", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
assert.doesNotMatch(hookSource, /cancelObservationSessionPreparation|method:\s*["']DELETE/);
|
||||
assert.doesNotMatch(selectorSource, /cancelReplay|>\s*Отменить\s*</);
|
||||
});
|
||||
|
||||
test("replay descriptor rejects path leaks, mismatched sessions and non-seekable data", () => {
|
||||
assert.throws(
|
||||
() => decodeObservationSessionReplay(replay({ source_url: "file:///private/session.rrd" })),
|
||||
/same-origin/,
|
||||
);
|
||||
assert.throws(
|
||||
() => decodeObservationSessionReplay(replay({ source_url: "/api/v1/observation-sessions/other/recording.rrd" })),
|
||||
/same-origin/,
|
||||
);
|
||||
assert.throws(
|
||||
() => decodeObservationSessionReplay(replay({ seekable: false })),
|
||||
/seekable/,
|
||||
);
|
||||
assert.throws(
|
||||
() => decodeObservationSessionReplay(replay({
|
||||
viewer_source_url: "/api/v1/observation-sessions/session-20260716T205632Z/recording.rrd",
|
||||
})),
|
||||
/канонически привязан/,
|
||||
);
|
||||
assert.throws(
|
||||
() => decodeObservationSessionReplay(replay({
|
||||
viewer_source_url: `https://foreign.test/recording.rrd?generation=${"a".repeat(64)}`,
|
||||
})),
|
||||
/канонически привязан/,
|
||||
);
|
||||
assert.throws(
|
||||
() => decodeObservationSessionReplay(replay({ raw_path: "/private/raw.k1mqtt" })),
|
||||
/неизвестные поля/,
|
||||
);
|
||||
assert.throws(
|
||||
() => decodeObservationSessionReplay(replay({ byte_length: Number.MAX_SAFE_INTEGER + 1 })),
|
||||
/недопустимое число/,
|
||||
);
|
||||
assert.throws(
|
||||
() => decodeObservationSessionReplay(replay({ playback: { speed: 101, loop: false } })),
|
||||
/недопустимое число/,
|
||||
);
|
||||
});
|
||||
|
||||
test("replay decodes opaque recorded cameras and their same-origin fMP4 manifest", () => {
|
||||
const sessionId = "session-20260716T205632Z";
|
||||
const source = {
|
||||
id: "recorded.camera.5a5a5a5a5a5a5a5a",
|
||||
label: "Записанная камера 1",
|
||||
modality: "video",
|
||||
manifest_url: `/api/v1/observation-sessions/${sessionId}/media/recorded-video-5a5a5a5a5a5a5a5a/manifest`,
|
||||
manifest_generation_sha256: "c".repeat(64),
|
||||
byte_length: 3_266,
|
||||
media_type: "video/mp4",
|
||||
timeline_start_seconds: 0.25,
|
||||
timeline_end_seconds: 20,
|
||||
seekable: true,
|
||||
synchronization: "host-arrival-best-effort",
|
||||
};
|
||||
const decoded = decodeObservationSessionReplay(replay({
|
||||
session_id: sessionId,
|
||||
timeline_end_seconds: 20,
|
||||
media_sources: [source],
|
||||
}));
|
||||
assert.equal(
|
||||
decoded.viewerSourceUrl,
|
||||
`/api/v1/observation-sessions/${sessionId}/recording.rrd?generation=${"a".repeat(64)}`,
|
||||
);
|
||||
assert.equal(decoded.mediaSources.length, 1);
|
||||
assert.equal(decoded.mediaSources[0].id, source.id);
|
||||
assert.equal(decoded.mediaSources[0].manifestUrl, source.manifest_url);
|
||||
assert.equal(
|
||||
decoded.mediaSources[0].manifestGenerationSha256,
|
||||
source.manifest_generation_sha256,
|
||||
);
|
||||
|
||||
const manifest = decodeObservationRecordedMediaManifest({
|
||||
schema_version: "missioncore.observation-recorded-media/v2",
|
||||
source_id: source.id,
|
||||
generation_sha256: "c".repeat(64),
|
||||
byte_length: 3_266,
|
||||
timeline_start_seconds: 0.25,
|
||||
timeline_end_seconds: 20,
|
||||
synchronization: "host-arrival-best-effort",
|
||||
epochs: [{
|
||||
ordinal: 1,
|
||||
timeline_start_seconds: 0.25,
|
||||
timeline_end_seconds: 20,
|
||||
media_type: 'video/mp4; codecs="avc1.640028"',
|
||||
init_url: source.manifest_url.replace("/manifest", "/epochs/1/init.mp4"),
|
||||
init_byte_length: 128,
|
||||
init_sha256: "a".repeat(64),
|
||||
segment_count: 12,
|
||||
segment_url_prefix: source.manifest_url.replace("/manifest", "/epochs/1/segments/"),
|
||||
segments: Array.from({ length: 12 }, (_, index) => ({
|
||||
sequence: index + 1,
|
||||
url: source.manifest_url.replace("/manifest", `/epochs/1/segments/${index + 1}.m4s`),
|
||||
byte_length: 256 + index,
|
||||
sha256: "b".repeat(64),
|
||||
})),
|
||||
}],
|
||||
}, decoded.mediaSources[0]);
|
||||
assert.equal(manifest.epochs[0].segmentCount, 12);
|
||||
assert.equal(manifest.epochs[0].segments.length, 12);
|
||||
assert.equal(manifest.epochs[0].timelineStartSeconds, 0.25);
|
||||
assert.throws(
|
||||
() => decodeObservationRecordedMediaManifest({
|
||||
...{
|
||||
schema_version: "missioncore.observation-recorded-media/v1",
|
||||
source_id: source.id,
|
||||
generation_sha256: "c".repeat(64),
|
||||
byte_length: 3_266,
|
||||
timeline_start_seconds: 0.25,
|
||||
timeline_end_seconds: 20,
|
||||
synchronization: "host-arrival-best-effort",
|
||||
epochs: [],
|
||||
},
|
||||
}, decoded.mediaSources[0]),
|
||||
/несовместим/,
|
||||
);
|
||||
assert.throws(
|
||||
() => decodeObservationRecordedMediaManifest({
|
||||
schema_version: "missioncore.observation-recorded-media/v2",
|
||||
source_id: source.id,
|
||||
generation_sha256: "c".repeat(64),
|
||||
byte_length: 3_267,
|
||||
timeline_start_seconds: 0.25,
|
||||
timeline_end_seconds: 20,
|
||||
synchronization: "host-arrival-best-effort",
|
||||
epochs: [],
|
||||
}, decoded.mediaSources[0]),
|
||||
/несовместим|launch descriptor/,
|
||||
);
|
||||
});
|
||||
|
||||
test("recorded camera contracts reject foreign origins, path escapes and unknown fields", () => {
|
||||
const sessionId = "session-20260716T205632Z";
|
||||
const base = {
|
||||
id: "recorded.camera.5a5a5a5a5a5a5a5a",
|
||||
label: "Записанная камера 1",
|
||||
modality: "video",
|
||||
manifest_url: `/api/v1/observation-sessions/${sessionId}/media/recorded-video-5a5a5a5a5a5a5a5a/manifest`,
|
||||
manifest_generation_sha256: "c".repeat(64),
|
||||
byte_length: 384,
|
||||
media_type: "video/mp4",
|
||||
timeline_start_seconds: 0,
|
||||
timeline_end_seconds: 20,
|
||||
seekable: true,
|
||||
synchronization: "host-arrival-best-effort",
|
||||
};
|
||||
assert.throws(
|
||||
() => decodeObservationSessionReplay(replay({
|
||||
session_id: sessionId,
|
||||
timeline_end_seconds: 20,
|
||||
media_sources: [{ ...base, manifest_url: "https://invalid.test/private" }],
|
||||
})),
|
||||
/same-origin/,
|
||||
);
|
||||
assert.throws(
|
||||
() => decodeObservationSessionReplay(replay({
|
||||
session_id: sessionId,
|
||||
timeline_end_seconds: 20,
|
||||
media_sources: [{ ...base, path: "/private/archive" }],
|
||||
})),
|
||||
/неизвестные поля/,
|
||||
);
|
||||
assert.throws(
|
||||
() => decodeObservationSessionReplay(replay({
|
||||
session_id: sessionId,
|
||||
timeline_end_seconds: 20,
|
||||
media_sources: [{ ...base, manifest_generation_sha256: undefined }],
|
||||
})),
|
||||
/immutable generation/,
|
||||
);
|
||||
assert.throws(
|
||||
() => decodeObservationSessionReplay(replay({
|
||||
session_id: sessionId,
|
||||
timeline_end_seconds: 20,
|
||||
media_sources: [{ ...base, manifest_generation_sha256: "not-a-digest" }],
|
||||
})),
|
||||
/immutable generation/,
|
||||
);
|
||||
const decoded = decodeObservationSessionReplay(replay({
|
||||
session_id: sessionId,
|
||||
timeline_end_seconds: 20,
|
||||
media_sources: [base],
|
||||
}));
|
||||
assert.throws(
|
||||
() => decodeObservationRecordedMediaManifest({
|
||||
schema_version: "missioncore.observation-recorded-media/v2",
|
||||
source_id: base.id,
|
||||
generation_sha256: "c".repeat(64),
|
||||
byte_length: 384,
|
||||
timeline_start_seconds: 0,
|
||||
timeline_end_seconds: 20,
|
||||
synchronization: "host-arrival-best-effort",
|
||||
epochs: [{
|
||||
ordinal: 1,
|
||||
timeline_start_seconds: 0,
|
||||
timeline_end_seconds: 20,
|
||||
media_type: 'video/mp4; codecs="avc1.640028"',
|
||||
init_url: "file:///private/init.mp4",
|
||||
init_byte_length: 128,
|
||||
init_sha256: "a".repeat(64),
|
||||
segment_count: 1,
|
||||
segment_url_prefix: "file:///private/segments/",
|
||||
segments: [{
|
||||
sequence: 1,
|
||||
url: "file:///private/segments/1.m4s",
|
||||
byte_length: 256,
|
||||
sha256: "b".repeat(64),
|
||||
}],
|
||||
}],
|
||||
}, decoded.mediaSources[0]),
|
||||
/небезопасный API URL/,
|
||||
);
|
||||
});
|
||||
@@ -1,6 +1,8 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { after, before, test } from "node:test";
|
||||
|
||||
import { createElement } from "react";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { createServer } from "vite";
|
||||
|
||||
let server;
|
||||
@@ -11,6 +13,20 @@ let shouldRestartObservationSource;
|
||||
let consumeCameraLeaseRetry;
|
||||
let resetCameraLeaseRetryBudget;
|
||||
let initialObservationWindowRect;
|
||||
let ObservationTimeline;
|
||||
let shouldCaptureWorkspacePointer;
|
||||
let normalizeTimelineRange;
|
||||
let timelineOffsetSeconds;
|
||||
let formatTimelineDuration;
|
||||
let normalizeAccumulationSeconds;
|
||||
let formatAccumulationDuration;
|
||||
let resolveRerunSourceUrl;
|
||||
let resolveRecordedBlueprintUrl;
|
||||
let fetchRecordedBlueprintRrd;
|
||||
let isRecordedPlaybackFullyBuffered;
|
||||
let recordedObservationSources;
|
||||
let selectRecordedMediaEpoch;
|
||||
let recordedMediaLocalTime;
|
||||
|
||||
before(async () => {
|
||||
server = await createServer({
|
||||
@@ -30,9 +46,32 @@ before(async () => {
|
||||
({ consumeCameraLeaseRetry, resetCameraLeaseRetryBudget } = await server.ssrLoadModule(
|
||||
"/src/components/MseFmp4WebSocketPlayer.tsx",
|
||||
));
|
||||
({ initialObservationWindowRect } = await server.ssrLoadModule(
|
||||
({ initialObservationWindowRect, shouldCaptureWorkspacePointer } = await server.ssrLoadModule(
|
||||
"/src/components/FloatingObservationWindow.tsx",
|
||||
));
|
||||
({
|
||||
ObservationTimeline,
|
||||
normalizeTimelineRange,
|
||||
timelineOffsetSeconds,
|
||||
formatTimelineDuration,
|
||||
normalizeAccumulationSeconds,
|
||||
formatAccumulationDuration,
|
||||
} =
|
||||
await server.ssrLoadModule("/src/components/ObservationTimeline.tsx"));
|
||||
({
|
||||
resolveRerunSourceUrl,
|
||||
resolveRecordedBlueprintUrl,
|
||||
fetchRecordedBlueprintRrd,
|
||||
isRecordedPlaybackFullyBuffered,
|
||||
} = await server.ssrLoadModule(
|
||||
"/src/components/RerunViewport.tsx",
|
||||
));
|
||||
({ recordedObservationSources } = await server.ssrLoadModule(
|
||||
"/src/core/observation/recordedObservationSources.ts",
|
||||
));
|
||||
({ selectRecordedMediaEpoch, recordedMediaLocalTime } = await server.ssrLoadModule(
|
||||
"/src/components/RecordedFmp4Player.tsx",
|
||||
));
|
||||
});
|
||||
|
||||
test("observation camera windows tile from the bottom-right above the live timeline", () => {
|
||||
@@ -75,6 +114,251 @@ test("observation camera tiling remains in bounds on a constrained viewport", ()
|
||||
assert.ok(rects[1].y + rects[1].height <= rects[0].y);
|
||||
});
|
||||
|
||||
test("floating observation interaction captures resize and movable header pointers", () => {
|
||||
const target = (matches) => ({ closest: (selector) => matches.includes(selector) });
|
||||
assert.equal(
|
||||
shouldCaptureWorkspacePointer(0, target([".nodedc-workspace-window__resize"])),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
shouldCaptureWorkspacePointer(0, target([".nodedc-workspace-window__head"])),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
shouldCaptureWorkspacePointer(
|
||||
0,
|
||||
target([".nodedc-workspace-window__head", "button, input, select, textarea, a"]),
|
||||
),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
shouldCaptureWorkspacePointer(2, target([".nodedc-workspace-window__resize"])),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("recorded observation timeline clamps relative seek time without epoch precision in the UI", () => {
|
||||
const range = normalizeTimelineRange({
|
||||
min: 0,
|
||||
max: 12_500_000_000,
|
||||
});
|
||||
assert.ok(range);
|
||||
assert.equal(timelineOffsetSeconds(range, range.min + 2_250_000_000), 2.25);
|
||||
assert.equal(timelineOffsetSeconds(range, range.min - 1), 0);
|
||||
assert.equal(timelineOffsetSeconds(range, range.max + 1), 12.5);
|
||||
assert.equal(formatTimelineDuration(62.125), "01:02.125");
|
||||
});
|
||||
|
||||
test("recorded observation timeline rejects empty and non-finite ranges", () => {
|
||||
assert.equal(normalizeTimelineRange(null), null);
|
||||
assert.equal(normalizeTimelineRange({ min: 10, max: 10 }), null);
|
||||
assert.equal(normalizeTimelineRange({ min: Number.NaN, max: 10 }), null);
|
||||
});
|
||||
|
||||
test("accumulation control normalizes UI values and distinguishes a single frame", () => {
|
||||
assert.equal(normalizeAccumulationSeconds(-3), 0);
|
||||
assert.equal(normalizeAccumulationSeconds(12.6), 13);
|
||||
assert.equal(normalizeAccumulationSeconds(999), 120);
|
||||
assert.equal(normalizeAccumulationSeconds(Number.NaN), 0);
|
||||
assert.equal(formatAccumulationDuration(0), "Кадр");
|
||||
assert.equal(formatAccumulationDuration(12), "12 с");
|
||||
});
|
||||
|
||||
test("spatial timeline renders synchronized accumulation and playback controls", () => {
|
||||
const markup = renderToStaticMarkup(createElement(ObservationTimeline, {
|
||||
active: true,
|
||||
sourceCount: 2,
|
||||
mode: "recorded",
|
||||
seekable: true,
|
||||
rangeNs: { min: 0, max: 20_000_000_000 },
|
||||
currentNs: 5_000_000_000,
|
||||
accumulationSeconds: 12,
|
||||
onAccumulationChange: () => undefined,
|
||||
onAccumulationCommit: () => undefined,
|
||||
onSeek: () => undefined,
|
||||
}));
|
||||
|
||||
assert.match(markup, /data-accumulation="true"/);
|
||||
assert.match(markup, /Накопление/);
|
||||
assert.match(markup, /aria-label="Окно накопления облака точек"/);
|
||||
assert.match(markup, /aria-valuetext="12 с"/);
|
||||
assert.match(markup, /aria-label="Позиция воспроизведения"/);
|
||||
assert.equal((markup.match(/type="range"/g) ?? []).length, 2);
|
||||
});
|
||||
|
||||
test("Rerun expands only root-relative session recordings onto the current origin", () => {
|
||||
assert.equal(
|
||||
resolveRerunSourceUrl(
|
||||
" /api/v1/observation-sessions/session-1/recording.rrd ",
|
||||
"http://127.0.0.1:5174",
|
||||
),
|
||||
"http://127.0.0.1:5174/api/v1/observation-sessions/session-1/recording.rrd",
|
||||
);
|
||||
assert.equal(
|
||||
resolveRerunSourceUrl("rerun+http://127.0.0.1:9877/proxy", "http://127.0.0.1:5174"),
|
||||
"rerun+http://127.0.0.1:9877/proxy",
|
||||
);
|
||||
assert.equal(
|
||||
resolveRerunSourceUrl("//different-authority.invalid/session.rrd", "http://127.0.0.1:5174"),
|
||||
"//different-authority.invalid/session.rrd",
|
||||
);
|
||||
});
|
||||
|
||||
test("recorded blueprint endpoint is derived only from canonical same-origin RRD sources", () => {
|
||||
assert.equal(
|
||||
resolveRecordedBlueprintUrl(
|
||||
"/api/v1/observation-sessions/session-1/recording.rrd",
|
||||
"http://127.0.0.1:5174",
|
||||
),
|
||||
"http://127.0.0.1:5174/api/v1/observation-sessions/session-1/blueprint.rrd",
|
||||
);
|
||||
assert.equal(
|
||||
resolveRecordedBlueprintUrl(
|
||||
"https://outside.invalid/api/v1/observation-sessions/session-1/recording.rrd",
|
||||
"http://127.0.0.1:5174",
|
||||
),
|
||||
null,
|
||||
);
|
||||
assert.equal(
|
||||
resolveRecordedBlueprintUrl(
|
||||
"/api/v1/observation-sessions/../recording.rrd",
|
||||
"http://127.0.0.1:5174",
|
||||
),
|
||||
null,
|
||||
);
|
||||
});
|
||||
|
||||
test("recorded replay becomes ready only after the complete declared timeline is buffered", () => {
|
||||
assert.equal(isRecordedPlaybackFullyBuffered(null, 20), false);
|
||||
assert.equal(
|
||||
isRecordedPlaybackFullyBuffered({ min: 0, max: 19_500_000_000 }, 20),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
isRecordedPlaybackFullyBuffered({ min: 0, max: 19_999_500_000 }, 20),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
isRecordedPlaybackFullyBuffered({ min: 0, max: 1 }, undefined),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
isRecordedPlaybackFullyBuffered({ min: 0, max: 20_000_000_000 }, Number.NaN),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("recorded blueprint fetch is bounded, strict and sends only display settings", async () => {
|
||||
const calls = [];
|
||||
const payload = Uint8Array.from([0x52, 0x52, 0x46, 0x32, 0x01]);
|
||||
const result = await fetchRecordedBlueprintRrd(
|
||||
"http://127.0.0.1:5174/api/v1/observation-sessions/session-1/blueprint.rrd",
|
||||
{
|
||||
accumulationSeconds: 24,
|
||||
showGrid: false,
|
||||
showPoints: true,
|
||||
showTrajectory: false,
|
||||
pointSize: 4.5,
|
||||
palette: "custom",
|
||||
customColor: "#35d7c1",
|
||||
},
|
||||
{ applicationId: "nodedc_mission_core_recorded", recordingId: "recording-001" },
|
||||
{
|
||||
origin: "http://127.0.0.1:5174",
|
||||
fetcher: async (input, init) => {
|
||||
calls.push({ input: String(input), init, body: JSON.parse(String(init.body)) });
|
||||
return new Response(payload, {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/vnd.rerun.rrd" },
|
||||
});
|
||||
},
|
||||
},
|
||||
);
|
||||
assert.deepEqual([...result], [...payload]);
|
||||
assert.equal(calls[0].init.method, "POST");
|
||||
assert.equal(calls[0].init.credentials, "same-origin");
|
||||
assert.deepEqual(calls[0].body, {
|
||||
application_id: "nodedc_mission_core_recorded",
|
||||
recording_id: "recording-001",
|
||||
accumulation_seconds: 24,
|
||||
show_grid: false,
|
||||
show_points: true,
|
||||
show_trajectory: false,
|
||||
point_size: 4.5,
|
||||
palette: "custom",
|
||||
custom_color: "#35d7c1",
|
||||
});
|
||||
|
||||
await assert.rejects(
|
||||
fetchRecordedBlueprintRrd(
|
||||
"https://outside.invalid/api/v1/observation-sessions/session-1/blueprint.rrd",
|
||||
{
|
||||
accumulationSeconds: 24,
|
||||
showGrid: false,
|
||||
showPoints: true,
|
||||
showTrajectory: false,
|
||||
pointSize: 4.5,
|
||||
palette: "custom",
|
||||
customColor: "#35d7c1",
|
||||
},
|
||||
{ applicationId: "nodedc_mission_core_recorded", recordingId: "recording-001" },
|
||||
{ origin: "http://127.0.0.1:5174", fetcher: async () => new Response(payload) },
|
||||
),
|
||||
/Unsafe recorded blueprint request/,
|
||||
);
|
||||
});
|
||||
|
||||
test("recorded replay creates an isolated source catalog without live device bindings", () => {
|
||||
const sources = recordedObservationSources({
|
||||
kind: "rerun-recording",
|
||||
sessionId: "session-1",
|
||||
sourceUrl: "/api/v1/observation-sessions/session-1/recording.rrd",
|
||||
viewerSourceUrl: `/api/v1/observation-sessions/session-1/recording.rrd?generation=${"a".repeat(64)}`,
|
||||
mediaType: "application/vnd.rerun.rrd",
|
||||
timeline: "session_time",
|
||||
timelineStartSeconds: 0,
|
||||
timelineEndSeconds: 20,
|
||||
seekable: true,
|
||||
byteLength: 123,
|
||||
sha256: "a".repeat(64),
|
||||
playback: { speed: 1, loop: false },
|
||||
mediaSources: [{
|
||||
id: "recorded.camera.abc123",
|
||||
label: "Записанная камера 1",
|
||||
modality: "video",
|
||||
manifestUrl: "/api/v1/observation-sessions/session-1/media/recorded-video-abc123/manifest",
|
||||
manifestGenerationSha256: "c".repeat(64),
|
||||
byteLength: 1_024,
|
||||
mediaType: "video/mp4",
|
||||
timelineStartSeconds: 0.25,
|
||||
timelineEndSeconds: 20,
|
||||
seekable: true,
|
||||
synchronization: "host-arrival-best-effort",
|
||||
}],
|
||||
});
|
||||
assert.deepEqual(sources.map(({ modality }) => modality), ["point-cloud", "video"]);
|
||||
assert.equal(sources[1].delivery.kind, "recorded-fmp4-manifest");
|
||||
assert.equal(sources[1].delivery.manifestGenerationSha256, "c".repeat(64));
|
||||
assert.deepEqual(sources[1].binding, {});
|
||||
assert.equal(sources.some(({ provider }) => provider.pluginId.includes("xgrids")), false);
|
||||
assert.equal(JSON.stringify(sources).includes("192.168"), false);
|
||||
});
|
||||
|
||||
test("recorded camera epoch selection and shared-clock offset are deterministic", () => {
|
||||
const epochs = [
|
||||
{ ordinal: 1, timelineStartSeconds: 0.25, timelineEndSeconds: 9 },
|
||||
{ ordinal: 2, timelineStartSeconds: 10, timelineEndSeconds: 20 },
|
||||
];
|
||||
assert.equal(selectRecordedMediaEpoch(epochs, 0), null);
|
||||
assert.equal(selectRecordedMediaEpoch(epochs, 0.25).ordinal, 1);
|
||||
assert.equal(selectRecordedMediaEpoch(epochs, 9), epochs[0]);
|
||||
assert.equal(selectRecordedMediaEpoch(epochs, 9.9), null);
|
||||
assert.equal(selectRecordedMediaEpoch(epochs, 10).ordinal, 2);
|
||||
assert.equal(recordedMediaLocalTime(10, 12.5), 2.5);
|
||||
assert.equal(recordedMediaLocalTime(10, 8), 0);
|
||||
assert.equal(recordedMediaLocalTime(10, 30, 4), 4);
|
||||
});
|
||||
|
||||
after(async () => {
|
||||
await server?.close();
|
||||
});
|
||||
|
||||
@@ -0,0 +1,303 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { createHash } from "node:crypto";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { after, before, test } from "node:test";
|
||||
|
||||
import { createServer } from "vite";
|
||||
|
||||
let server;
|
||||
let fetchVerifiedRecordedMediaArchive;
|
||||
let recordedMediaPresentationState;
|
||||
let recordedMediaSeekableCoverage;
|
||||
let appendRecordedMediaBuffer;
|
||||
|
||||
before(async () => {
|
||||
server = await createServer({
|
||||
appType: "custom",
|
||||
logLevel: "silent",
|
||||
server: { middlewareMode: true },
|
||||
});
|
||||
({
|
||||
fetchVerifiedRecordedMediaArchive,
|
||||
recordedMediaPresentationState,
|
||||
recordedMediaSeekableCoverage,
|
||||
appendRecordedMediaBuffer,
|
||||
} = await server.ssrLoadModule("/src/components/RecordedFmp4Player.tsx"));
|
||||
});
|
||||
|
||||
after(async () => {
|
||||
await server?.close();
|
||||
});
|
||||
|
||||
function digest(payload) {
|
||||
return createHash("sha256").update(payload).digest("hex");
|
||||
}
|
||||
|
||||
function fixture() {
|
||||
const manifestUrl = "/api/v1/observation-sessions/session-1/media/camera-1/manifest";
|
||||
const init = Buffer.from("canonical-init");
|
||||
const first = Buffer.from("canonical-first-fragment");
|
||||
const second = Buffer.from("canonical-second-fragment");
|
||||
const generation = "a".repeat(64);
|
||||
const segmentPrefix = manifestUrl.replace("/manifest", "/epochs/1/segments/");
|
||||
const manifest = {
|
||||
schema_version: "missioncore.observation-recorded-media/v2",
|
||||
source_id: "recorded.camera.camera-1",
|
||||
generation_sha256: generation,
|
||||
byte_length: init.byteLength + first.byteLength + second.byteLength,
|
||||
timeline_start_seconds: 0,
|
||||
timeline_end_seconds: 20,
|
||||
synchronization: "host-arrival-best-effort",
|
||||
epochs: [{
|
||||
ordinal: 1,
|
||||
timeline_start_seconds: 0,
|
||||
timeline_end_seconds: 20,
|
||||
media_type: 'video/mp4; codecs="avc1.640028"',
|
||||
init_url: manifestUrl.replace("/manifest", "/epochs/1/init.mp4"),
|
||||
init_byte_length: init.byteLength,
|
||||
init_sha256: digest(init),
|
||||
segment_count: 2,
|
||||
segment_url_prefix: segmentPrefix,
|
||||
segments: [first, second].map((payload, index) => ({
|
||||
sequence: index + 1,
|
||||
url: `${segmentPrefix}${index + 1}.m4s`,
|
||||
byte_length: payload.byteLength,
|
||||
sha256: digest(payload),
|
||||
})),
|
||||
}],
|
||||
};
|
||||
const source = {
|
||||
id: manifest.source_id,
|
||||
label: "Записанная камера",
|
||||
modality: "video",
|
||||
manifestUrl,
|
||||
manifestGenerationSha256: generation,
|
||||
byteLength: manifest.byte_length,
|
||||
mediaType: "video/mp4",
|
||||
timelineStartSeconds: 0,
|
||||
timelineEndSeconds: 20,
|
||||
seekable: true,
|
||||
synchronization: "host-arrival-best-effort",
|
||||
};
|
||||
return { source, manifest, generation, init, first, second };
|
||||
}
|
||||
|
||||
function jsonResponse(payload, generation) {
|
||||
return new Response(JSON.stringify(payload), {
|
||||
status: 200,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
ETag: `"sha256:${generation}"`,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function mediaResponse(payload, sha = digest(payload), length = payload.byteLength) {
|
||||
return new Response(payload, {
|
||||
status: 200,
|
||||
headers: {
|
||||
"Content-Length": String(length),
|
||||
ETag: `"sha256:${sha}"`,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function deferred() {
|
||||
let resolve;
|
||||
let reject;
|
||||
const promise = new Promise((resolvePromise, rejectPromise) => {
|
||||
resolve = resolvePromise;
|
||||
reject = rejectPromise;
|
||||
});
|
||||
return { promise, resolve, reject };
|
||||
}
|
||||
|
||||
async function flushAsync() {
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
}
|
||||
|
||||
test("recorded camera archive stays pending until every canonical byte is verified", async () => {
|
||||
const { source, manifest, generation, init, first, second } = fixture();
|
||||
const finalSegment = deferred();
|
||||
const requested = [];
|
||||
let manifestRequests = 0;
|
||||
const fetcher = async (input, request = {}) => {
|
||||
const url = String(input);
|
||||
requested.push(url);
|
||||
const headers = new Headers(request.headers);
|
||||
if (url === source.manifestUrl) {
|
||||
manifestRequests += 1;
|
||||
assert.equal(headers.get("If-Match"), `"sha256:${generation}"`);
|
||||
return jsonResponse(manifest, generation);
|
||||
}
|
||||
const epoch = manifest.epochs[0];
|
||||
if (url === epoch.init_url) {
|
||||
assert.equal(headers.get("If-Match"), `"sha256:${epoch.init_sha256}"`);
|
||||
return mediaResponse(init);
|
||||
}
|
||||
if (url === epoch.segments[0].url) return mediaResponse(first);
|
||||
if (url === epoch.segments[1].url) return finalSegment.promise;
|
||||
throw new Error(`Unexpected URL ${url}`);
|
||||
};
|
||||
|
||||
let settled = false;
|
||||
const archivePromise = fetchVerifiedRecordedMediaArchive(source, { fetcher })
|
||||
.then((archive) => {
|
||||
settled = true;
|
||||
return archive;
|
||||
});
|
||||
await flushAsync();
|
||||
|
||||
assert.equal(settled, false, "init and a partial segment prefix must not publish the camera");
|
||||
assert.deepEqual(requested.slice(0, 4), [
|
||||
source.manifestUrl,
|
||||
manifest.epochs[0].init_url,
|
||||
manifest.epochs[0].segments[0].url,
|
||||
manifest.epochs[0].segments[1].url,
|
||||
]);
|
||||
|
||||
finalSegment.resolve(mediaResponse(second));
|
||||
const archive = await archivePromise;
|
||||
assert.equal(manifestRequests, 2, "the immutable generation is revalidated after transfer");
|
||||
assert.equal(archive.byteLength, init.byteLength + first.byteLength + second.byteLength);
|
||||
assert.equal(archive.epochs[0].segments.length, 2);
|
||||
});
|
||||
|
||||
test("first camera manifest request is launch-generation bound and rejects replacement", async () => {
|
||||
const { source, manifest, generation } = fixture();
|
||||
const replacementGeneration = "d".repeat(64);
|
||||
let requests = 0;
|
||||
const fetcher = async (input, request = {}) => {
|
||||
requests += 1;
|
||||
assert.equal(String(input), source.manifestUrl);
|
||||
assert.equal(
|
||||
new Headers(request.headers).get("If-Match"),
|
||||
`"sha256:${generation}"`,
|
||||
);
|
||||
return jsonResponse(
|
||||
{ ...manifest, generation_sha256: replacementGeneration },
|
||||
replacementGeneration,
|
||||
);
|
||||
};
|
||||
|
||||
await assert.rejects(
|
||||
fetchVerifiedRecordedMediaArchive(source, { fetcher }),
|
||||
/несовместим|заменён/,
|
||||
);
|
||||
assert.equal(requests, 1);
|
||||
});
|
||||
|
||||
test("recorded camera archive fails closed on truncation despite plausible headers", async () => {
|
||||
const { source, manifest, generation, init, first, second } = fixture();
|
||||
const fetcher = async (input) => {
|
||||
const url = String(input);
|
||||
if (url === source.manifestUrl) return jsonResponse(manifest, generation);
|
||||
const epoch = manifest.epochs[0];
|
||||
if (url === epoch.init_url) return mediaResponse(init);
|
||||
if (url === epoch.segments[0].url) return mediaResponse(first);
|
||||
if (url === epoch.segments[1].url) {
|
||||
return mediaResponse(second.subarray(0, second.byteLength - 1), digest(second), second.byteLength);
|
||||
}
|
||||
throw new Error(`Unexpected URL ${url}`);
|
||||
};
|
||||
|
||||
await assert.rejects(
|
||||
fetchVerifiedRecordedMediaArchive(source, { fetcher }),
|
||||
/усечён/,
|
||||
);
|
||||
});
|
||||
|
||||
test("recorded camera archive fails closed when bytes are replaced under an old ETag", async () => {
|
||||
const { source, manifest, generation, init, first, second } = fixture();
|
||||
const replacement = Buffer.from(second.map((value) => value ^ 0xff));
|
||||
assert.equal(replacement.byteLength, second.byteLength);
|
||||
const fetcher = async (input) => {
|
||||
const url = String(input);
|
||||
if (url === source.manifestUrl) return jsonResponse(manifest, generation);
|
||||
const epoch = manifest.epochs[0];
|
||||
if (url === epoch.init_url) return mediaResponse(init);
|
||||
if (url === epoch.segments[0].url) return mediaResponse(first);
|
||||
if (url === epoch.segments[1].url) {
|
||||
return mediaResponse(replacement, digest(second), second.byteLength);
|
||||
}
|
||||
throw new Error(`Unexpected URL ${url}`);
|
||||
};
|
||||
|
||||
await assert.rejects(
|
||||
fetchVerifiedRecordedMediaArchive(source, { fetcher }),
|
||||
/SHA-256/,
|
||||
);
|
||||
});
|
||||
|
||||
test("camera presentation gate opens only for the completely appended selected epoch", () => {
|
||||
assert.equal(recordedMediaPresentationState("loading", null, "g1", false), "loading");
|
||||
assert.equal(recordedMediaPresentationState("ready", null, "g1", false), "loading");
|
||||
assert.equal(recordedMediaPresentationState("ready", "g2", "g1", false), "loading");
|
||||
assert.equal(recordedMediaPresentationState("ready", "g1", "g1", false), "ready");
|
||||
assert.equal(recordedMediaPresentationState("ready", "g1", null, true), "waiting");
|
||||
assert.equal(recordedMediaPresentationState("error", "g1", "g1", false), "error");
|
||||
assert.equal(recordedMediaPresentationState("ready", "g1", "g1", false, "loading"), "loading");
|
||||
assert.equal(recordedMediaPresentationState("ready", "g1", null, true, "loading"), "loading");
|
||||
assert.equal(recordedMediaPresentationState("ready", "g1", "g1", false, "error"), "error");
|
||||
});
|
||||
|
||||
test("decoded duration and seekable range cover the complete declared epoch", () => {
|
||||
assert.equal(recordedMediaSeekableCoverage(20, 20, 20), true);
|
||||
assert.equal(recordedMediaSeekableCoverage(19, 19, 20), true);
|
||||
assert.equal(recordedMediaSeekableCoverage(18.99, 20, 20), false);
|
||||
assert.equal(recordedMediaSeekableCoverage(20, 18.99, 20), false);
|
||||
assert.equal(recordedMediaSeekableCoverage(20, 20, 20, 1, 1), true);
|
||||
assert.equal(recordedMediaSeekableCoverage(20, 20, 20, 1, 1.01), false);
|
||||
});
|
||||
|
||||
test("aborting SourceBuffer append removes every temporary listener", async () => {
|
||||
const listeners = new Map();
|
||||
const sourceBuffer = {
|
||||
addEventListener(type, listener) {
|
||||
const bucket = listeners.get(type) ?? new Set();
|
||||
bucket.add(listener);
|
||||
listeners.set(type, bucket);
|
||||
},
|
||||
removeEventListener(type, listener) {
|
||||
listeners.get(type)?.delete(listener);
|
||||
},
|
||||
appendBuffer() {},
|
||||
};
|
||||
const abort = new AbortController();
|
||||
const pending = appendRecordedMediaBuffer(
|
||||
sourceBuffer,
|
||||
new ArrayBuffer(8),
|
||||
abort.signal,
|
||||
);
|
||||
assert.equal(listeners.get("updateend")?.size, 1);
|
||||
assert.equal(listeners.get("error")?.size, 1);
|
||||
abort.abort();
|
||||
await assert.rejects(pending, (error) => error?.name === "AbortError");
|
||||
assert.equal(listeners.get("updateend")?.size, 0);
|
||||
assert.equal(listeners.get("error")?.size, 0);
|
||||
});
|
||||
|
||||
test("verified camera archives remain immutable for safe player remount", async () => {
|
||||
const source = await readFile(
|
||||
new URL("../src/components/RecordedFmp4Player.tsx", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
assert.doesNotMatch(source, /\.init\s*=\s*new ArrayBuffer/);
|
||||
assert.doesNotMatch(source, /\.segments\.length\s*=\s*0/);
|
||||
});
|
||||
|
||||
test("loading and error overlays fully conceal recorded camera pixels", async () => {
|
||||
const css = await readFile(
|
||||
new URL("../src/styles/observation.css", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
assert.match(
|
||||
css,
|
||||
/\.recorded-media-player:not\(\[data-state="ready"\]\) \.observation-media__asset\s*\{[^}]*visibility:\s*hidden/s,
|
||||
);
|
||||
assert.match(
|
||||
css,
|
||||
/\.recorded-media-player__notice\s*\{[^}]*inset:\s*0;[^}]*background:\s*#070809/s,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,175 @@
|
||||
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 admission;
|
||||
let rerunPresentationStatus;
|
||||
let recordedMediaPresentationState;
|
||||
|
||||
before(async () => {
|
||||
server = await createServer({
|
||||
appType: "custom",
|
||||
logLevel: "silent",
|
||||
server: { middlewareMode: true },
|
||||
});
|
||||
admission = await server.ssrLoadModule(
|
||||
"/src/core/observation/recordedSessionAdmission.ts",
|
||||
);
|
||||
({ rerunPresentationStatus } = await server.ssrLoadModule(
|
||||
"/src/components/RerunViewport.tsx",
|
||||
));
|
||||
({ recordedMediaPresentationState } = await server.ssrLoadModule(
|
||||
"/src/components/RecordedFmp4Player.tsx",
|
||||
));
|
||||
});
|
||||
|
||||
after(async () => {
|
||||
await server?.close();
|
||||
});
|
||||
|
||||
function camera(phase, byteLength = 1_024) {
|
||||
return { phase, byteLength, message: null };
|
||||
}
|
||||
|
||||
test("recorded session admits RRD and every declared camera as one atomic generation", () => {
|
||||
const ids = ["camera.left", "camera.right"];
|
||||
const oneCamera = {
|
||||
"camera.left": camera("ready"),
|
||||
"camera.right": camera("pending"),
|
||||
};
|
||||
const partialGate = admission.recordedSessionAdmissionPhase("ready", ids, oneCamera);
|
||||
assert.equal(partialGate, "loading");
|
||||
assert.equal(rerunPresentationStatus("ready", partialGate, true), "loading");
|
||||
assert.equal(
|
||||
recordedMediaPresentationState("ready", "generation", "generation", false, partialGate),
|
||||
"loading",
|
||||
);
|
||||
|
||||
const completeGate = admission.recordedSessionAdmissionPhase("ready", ids, {
|
||||
...oneCamera,
|
||||
"camera.right": camera("ready"),
|
||||
});
|
||||
assert.equal(completeGate, "ready");
|
||||
assert.equal(rerunPresentationStatus("ready", completeGate, true), "ready");
|
||||
assert.equal(
|
||||
recordedMediaPresentationState("ready", "generation", "generation", false, completeGate),
|
||||
"ready",
|
||||
);
|
||||
});
|
||||
|
||||
test("any RRD or camera failure closes the complete recorded session", () => {
|
||||
const ids = ["camera.left", "camera.right"];
|
||||
const cameras = {
|
||||
"camera.left": camera("ready"),
|
||||
"camera.right": camera("error"),
|
||||
};
|
||||
assert.equal(admission.recordedSessionAdmissionPhase("ready", ids, cameras), "error");
|
||||
assert.equal(admission.recordedSessionAdmissionPhase("error", ids, {
|
||||
...cameras,
|
||||
"camera.right": camera("ready"),
|
||||
}), "error");
|
||||
assert.equal(rerunPresentationStatus("ready", "error", true), "error");
|
||||
assert.equal(
|
||||
recordedMediaPresentationState("ready", "generation", "generation", false, "error"),
|
||||
"error",
|
||||
);
|
||||
});
|
||||
|
||||
test("camera admission enforces independent 16/128/512 MiB limits before fetching", () => {
|
||||
const {
|
||||
MAX_RECORDED_CAMERA_SOURCES,
|
||||
MAX_RECORDED_MEDIA_SOURCE_BYTES,
|
||||
MAX_RECORDED_SESSION_CAMERA_BYTES,
|
||||
recordedCameraDescriptorPreflight,
|
||||
} = admission;
|
||||
assert.equal(MAX_RECORDED_CAMERA_SOURCES, 16);
|
||||
assert.equal(MAX_RECORDED_MEDIA_SOURCE_BYTES, 128 * 1024 * 1024);
|
||||
assert.equal(MAX_RECORDED_SESSION_CAMERA_BYTES, 512 * 1024 * 1024);
|
||||
assert.equal(recordedCameraDescriptorPreflight(
|
||||
Array.from({ length: 4 }, (_, index) => ({
|
||||
id: `camera.${index}`,
|
||||
byteLength: MAX_RECORDED_MEDIA_SOURCE_BYTES,
|
||||
})),
|
||||
), "ready");
|
||||
assert.equal(recordedCameraDescriptorPreflight(
|
||||
Array.from({ length: 17 }, (_, index) => ({ id: `camera.${index}`, byteLength: 1 })),
|
||||
), "error");
|
||||
assert.equal(recordedCameraDescriptorPreflight([
|
||||
{ id: "camera.large", byteLength: MAX_RECORDED_MEDIA_SOURCE_BYTES + 1 },
|
||||
]), "error");
|
||||
assert.equal(recordedCameraDescriptorPreflight(
|
||||
Array.from({ length: 5 }, (_, index) => ({
|
||||
id: `camera.${index}`,
|
||||
byteLength: 110 * 1024 * 1024,
|
||||
})),
|
||||
), "error");
|
||||
assert.equal(recordedCameraDescriptorPreflight([
|
||||
{ id: "camera.duplicate", byteLength: 1 },
|
||||
{ id: "camera.duplicate", byteLength: 1 },
|
||||
]), "error");
|
||||
});
|
||||
|
||||
test("camera preparation remains single-flight and retains the loading permit", () => {
|
||||
const ids = ["camera.first", "camera.preferred"];
|
||||
const cameras = {
|
||||
"camera.first": camera("loading"),
|
||||
"camera.preferred": camera("pending"),
|
||||
};
|
||||
assert.deepEqual(
|
||||
admission.nextRecordedCameraPreparationIds(
|
||||
ids,
|
||||
cameras,
|
||||
new Set(["camera.preferred"]),
|
||||
),
|
||||
["camera.first"],
|
||||
);
|
||||
assert.deepEqual(
|
||||
admission.nextRecordedCameraPreparationIds(ids, {
|
||||
...cameras,
|
||||
"camera.first": camera("ready"),
|
||||
}),
|
||||
["camera.preferred"],
|
||||
);
|
||||
});
|
||||
|
||||
test("new render workers close readiness and stale callbacks cannot reopen it", () => {
|
||||
const ready = { ...camera("ready"), workerGeneration: 1 };
|
||||
const remounted = admission.mergeRecordedCameraAdmission(ready, {
|
||||
...camera("loading"),
|
||||
workerGeneration: 2,
|
||||
});
|
||||
assert.equal(remounted.phase, "loading");
|
||||
assert.equal(remounted.workerGeneration, 2);
|
||||
assert.equal(admission.mergeRecordedCameraAdmission(remounted, {
|
||||
...camera("ready"),
|
||||
workerGeneration: 1,
|
||||
}), remounted);
|
||||
const admitted = admission.mergeRecordedCameraAdmission(remounted, {
|
||||
...camera("ready"),
|
||||
workerGeneration: 2,
|
||||
});
|
||||
assert.equal(admitted.phase, "ready");
|
||||
const failed = admission.mergeRecordedCameraAdmission(admitted, {
|
||||
...camera("error"),
|
||||
workerGeneration: 2,
|
||||
});
|
||||
assert.equal(failed.phase, "error");
|
||||
assert.equal(admission.mergeRecordedCameraAdmission(failed, {
|
||||
...camera("loading"),
|
||||
workerGeneration: 3,
|
||||
}), failed);
|
||||
});
|
||||
|
||||
test("recorded player is not mounted for a camera without a scheduler permit", async () => {
|
||||
const source = await readFile(
|
||||
new URL("../src/components/ObservationSources.tsx", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
const placeholder = source.indexOf("if (!prepareRecorded)");
|
||||
const player = source.indexOf("<RecordedFmp4Player", placeholder);
|
||||
assert.ok(placeholder >= 0 && player > placeholder);
|
||||
assert.match(source.slice(placeholder, player), /return\s*\(/);
|
||||
});
|
||||
@@ -0,0 +1,154 @@
|
||||
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 createRecordedOpenWatchdog;
|
||||
let recordedOpenWatchdogTimeoutMs;
|
||||
let rerunViewerInitialSource;
|
||||
let resolveRecordedViewerSourceUrl;
|
||||
|
||||
before(async () => {
|
||||
server = await createServer({
|
||||
appType: "custom",
|
||||
logLevel: "silent",
|
||||
server: { middlewareMode: true },
|
||||
});
|
||||
({
|
||||
createRecordedOpenWatchdog,
|
||||
recordedOpenWatchdogTimeoutMs,
|
||||
rerunViewerInitialSource,
|
||||
resolveRecordedViewerSourceUrl,
|
||||
} = await server.ssrLoadModule("/src/components/RerunViewport.tsx"));
|
||||
});
|
||||
|
||||
after(async () => {
|
||||
await server?.close();
|
||||
});
|
||||
|
||||
const sourceUrl = "/api/v1/observation-sessions/session-atomic/recording.rrd";
|
||||
const sha256 = "a".repeat(64);
|
||||
const viewerSourceUrl = `${sourceUrl}?generation=${sha256}`;
|
||||
|
||||
test("only the canonical digest-bound generation URL reaches the native Rerun receiver", () => {
|
||||
const descriptor = {
|
||||
sourceUrl,
|
||||
viewerSourceUrl,
|
||||
byteLength: 246_331_680,
|
||||
sha256,
|
||||
};
|
||||
const resolved = resolveRecordedViewerSourceUrl(descriptor, "http://mission-core.test");
|
||||
assert.equal(resolved, `http://mission-core.test${viewerSourceUrl}`);
|
||||
assert.equal(rerunViewerInitialSource(resolved), resolved);
|
||||
|
||||
for (const unsafeViewerSourceUrl of [
|
||||
sourceUrl,
|
||||
`${sourceUrl}?generation=${"b".repeat(64)}`,
|
||||
`${viewerSourceUrl}&extra=true`,
|
||||
`https://foreign.test${viewerSourceUrl}`,
|
||||
]) {
|
||||
assert.throws(
|
||||
() => resolveRecordedViewerSourceUrl({
|
||||
...descriptor,
|
||||
viewerSourceUrl: unsafeViewerSourceUrl,
|
||||
}, "http://mission-core.test"),
|
||||
/Unsafe recorded RRD viewer descriptor/,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("recorded admission watchdog is size-aware and exits an incomplete load", () => {
|
||||
const smallDelay = recordedOpenWatchdogTimeoutMs(4);
|
||||
const currentDelay = recordedOpenWatchdogTimeoutMs(246_331_680);
|
||||
const largeDelay = recordedOpenWatchdogTimeoutMs(805_687_122);
|
||||
assert.ok(smallDelay >= 120_000);
|
||||
assert.ok(currentDelay >= smallDelay);
|
||||
assert.ok(largeDelay > currentDelay);
|
||||
assert.ok(largeDelay <= 1_800_000);
|
||||
assert.ok(largeDelay > 12_000);
|
||||
|
||||
let scheduledCallback;
|
||||
let scheduledDelay;
|
||||
let timeoutCount = 0;
|
||||
const cancelled = [];
|
||||
const watchdog = createRecordedOpenWatchdog({
|
||||
byteLength: 246_331_680,
|
||||
schedule(callback, timeoutMs) {
|
||||
scheduledCallback = callback;
|
||||
scheduledDelay = timeoutMs;
|
||||
return 17;
|
||||
},
|
||||
cancel(handle) {
|
||||
cancelled.push(handle);
|
||||
},
|
||||
onTimeout() {
|
||||
timeoutCount += 1;
|
||||
},
|
||||
});
|
||||
watchdog.arm();
|
||||
assert.equal(watchdog.pending(), true);
|
||||
assert.equal(scheduledDelay, currentDelay);
|
||||
scheduledCallback();
|
||||
assert.equal(timeoutCount, 1);
|
||||
assert.equal(watchdog.pending(), false);
|
||||
assert.deepEqual(cancelled, []);
|
||||
});
|
||||
|
||||
test("complete recorded admission clears its watchdog", () => {
|
||||
let timeoutCount = 0;
|
||||
const cancelled = [];
|
||||
const watchdog = createRecordedOpenWatchdog({
|
||||
byteLength: 805_687_122,
|
||||
schedule() {
|
||||
return 23;
|
||||
},
|
||||
cancel(handle) {
|
||||
cancelled.push(handle);
|
||||
},
|
||||
onTimeout() {
|
||||
timeoutCount += 1;
|
||||
},
|
||||
});
|
||||
watchdog.arm();
|
||||
watchdog.clear();
|
||||
assert.equal(watchdog.pending(), false);
|
||||
assert.equal(timeoutCount, 0);
|
||||
assert.deepEqual(cancelled, [23]);
|
||||
});
|
||||
|
||||
test("recorded RRD bytes are never split across LogChannel.send_rrd calls", async () => {
|
||||
const source = await readFile(
|
||||
new URL("../src/components/RerunViewport.tsx", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
assert.doesNotMatch(source, /streamVerifiedRecordedRrd/);
|
||||
assert.doesNotMatch(source, /missioncore\/recorded-recording/);
|
||||
assert.doesNotMatch(source, /recordedChannel/);
|
||||
assert.match(source, /viewer\.start\(\s*rerunViewerInitialSource\(resolvedSource\)/s);
|
||||
assert.match(
|
||||
source,
|
||||
/recordingOpened = true;[\s\S]*if \(!isRecordedSource\) clearLiveRecordingOpenTimer\(\);/,
|
||||
);
|
||||
assert.match(
|
||||
source,
|
||||
/viewerStartResolved = true;\s*if \(isRecordedSource && !recordedSceneAdmitted\) recordedOpenWatchdog\?\.arm\(\);/,
|
||||
);
|
||||
assert.match(
|
||||
source,
|
||||
/if \(readyToRender && !readyPublished\)[\s\S]*clearRecordedAdmissionWatchdog\(\);/,
|
||||
);
|
||||
});
|
||||
|
||||
test("the complete vendor canvas host is hidden during partial and failed admission", async () => {
|
||||
const css = await readFile(new URL("../src/styles/spatial.css", import.meta.url), "utf8");
|
||||
assert.match(
|
||||
css,
|
||||
/\.rerun-viewport:is\(\[data-status="loading"\], \[data-status="error"\]\)\s+\.rerun-viewport__canvas\s*\{[^}]*visibility:\s*hidden;[^}]*pointer-events:\s*none;/s,
|
||||
);
|
||||
assert.doesNotMatch(
|
||||
css,
|
||||
/data-status="loading"[^}]*\.rerun-viewport__canvas\s*>\s*div/s,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,96 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { after, before, test } from "node:test";
|
||||
|
||||
import { createServer } from "vite";
|
||||
|
||||
let server;
|
||||
let attemptRecordedAutoplay;
|
||||
let createLatestAnimationFrameEmitter;
|
||||
|
||||
before(async () => {
|
||||
server = await createServer({
|
||||
appType: "custom",
|
||||
logLevel: "silent",
|
||||
server: { middlewareMode: true },
|
||||
});
|
||||
({ attemptRecordedAutoplay, createLatestAnimationFrameEmitter } =
|
||||
await server.ssrLoadModule("/src/components/RerunViewport.tsx"));
|
||||
});
|
||||
|
||||
after(async () => {
|
||||
await server?.close();
|
||||
});
|
||||
|
||||
test("recorded autoplay reports success only after seek and play both succeed", () => {
|
||||
let seekAttempts = 0;
|
||||
let playAttempts = 0;
|
||||
const seekToStart = () => {
|
||||
seekAttempts += 1;
|
||||
};
|
||||
const startPlaying = () => {
|
||||
playAttempts += 1;
|
||||
if (playAttempts === 1) throw new Error("receiver is not ready yet");
|
||||
};
|
||||
|
||||
assert.equal(attemptRecordedAutoplay(seekToStart, startPlaying), false);
|
||||
assert.equal(attemptRecordedAutoplay(seekToStart, startPlaying), true);
|
||||
assert.equal(seekAttempts, 2);
|
||||
assert.equal(playAttempts, 2);
|
||||
});
|
||||
|
||||
test("recorded autoplay does not try play when the seek itself fails", () => {
|
||||
let playAttempts = 0;
|
||||
|
||||
assert.equal(attemptRecordedAutoplay(
|
||||
() => {
|
||||
throw new Error("timeline is not ready yet");
|
||||
},
|
||||
() => {
|
||||
playAttempts += 1;
|
||||
},
|
||||
), false);
|
||||
assert.equal(playAttempts, 0);
|
||||
});
|
||||
|
||||
test("time updates coalesce to the latest value once per animation frame", () => {
|
||||
let nextHandle = 1;
|
||||
const frames = new Map();
|
||||
const cancelled = [];
|
||||
const emitted = [];
|
||||
const emitter = createLatestAnimationFrameEmitter({
|
||||
emit(value) {
|
||||
emitted.push(value);
|
||||
},
|
||||
requestFrame(callback) {
|
||||
const handle = nextHandle;
|
||||
nextHandle += 1;
|
||||
frames.set(handle, callback);
|
||||
return handle;
|
||||
},
|
||||
cancelFrame(handle) {
|
||||
cancelled.push(handle);
|
||||
frames.delete(handle);
|
||||
},
|
||||
});
|
||||
|
||||
emitter.push(1);
|
||||
emitter.push(2);
|
||||
emitter.push(3);
|
||||
assert.equal(frames.size, 1);
|
||||
assert.deepEqual(emitted, []);
|
||||
|
||||
const firstFrame = frames.get(1);
|
||||
frames.delete(1);
|
||||
firstFrame(0);
|
||||
assert.deepEqual(emitted, [3]);
|
||||
|
||||
emitter.push(4);
|
||||
assert.equal(frames.size, 1);
|
||||
emitter.cancel();
|
||||
assert.deepEqual(cancelled, [2]);
|
||||
assert.deepEqual(emitted, [3]);
|
||||
|
||||
emitter.push(5);
|
||||
assert.equal(frames.size, 0);
|
||||
assert.deepEqual(emitted, [3]);
|
||||
});
|
||||
@@ -0,0 +1,170 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { after, before, test } from "node:test";
|
||||
|
||||
import { createServer } from "vite";
|
||||
|
||||
let server;
|
||||
let canPublishRecordedPlaybackController;
|
||||
let createRecordedAutoplayGate;
|
||||
let isRecordedPlaybackReady;
|
||||
let isRecordedPlaybackPresentationReady;
|
||||
let isUsableRecordedPlaybackRange;
|
||||
let recordedPlaybackBufferState;
|
||||
let recordedPlaybackRangeWhenReady;
|
||||
|
||||
before(async () => {
|
||||
server = await createServer({
|
||||
appType: "custom",
|
||||
logLevel: "silent",
|
||||
server: { middlewareMode: true },
|
||||
});
|
||||
({
|
||||
canPublishRecordedPlaybackController,
|
||||
createRecordedAutoplayGate,
|
||||
isRecordedPlaybackReady,
|
||||
isRecordedPlaybackPresentationReady,
|
||||
isUsableRecordedPlaybackRange,
|
||||
recordedPlaybackBufferState,
|
||||
recordedPlaybackRangeWhenReady,
|
||||
} = await server.ssrLoadModule("/src/components/RerunViewport.tsx"));
|
||||
});
|
||||
|
||||
after(async () => {
|
||||
await server?.close();
|
||||
});
|
||||
|
||||
test("a first frame reports buffer telemetry but is not ready for presentation", () => {
|
||||
assert.equal(isUsableRecordedPlaybackRange(null), false);
|
||||
assert.equal(isUsableRecordedPlaybackRange({ min: Number.NaN, max: 0 }), false);
|
||||
assert.equal(isUsableRecordedPlaybackRange({ min: 2, max: 1 }), false);
|
||||
assert.equal(isUsableRecordedPlaybackRange({ min: 0, max: 0 }), true);
|
||||
|
||||
const firstFrame = recordedPlaybackBufferState({ min: 0, max: 0 }, 20);
|
||||
assert.deepEqual(firstFrame, {
|
||||
bufferedEndNs: 0,
|
||||
expectedEndNs: 20_000_000_000,
|
||||
bufferProgress: 0,
|
||||
fullyBuffered: false,
|
||||
});
|
||||
assert.equal(isRecordedPlaybackReady(true, true, firstFrame), false);
|
||||
assert.equal(recordedPlaybackRangeWhenReady({ min: 0, max: 0 }, firstFrame, true), null);
|
||||
});
|
||||
|
||||
test("host timeline remains unmounted until the verified recording is fully ready", () => {
|
||||
const partial = {
|
||||
recordingId: "partial",
|
||||
timeline: "session_time",
|
||||
rangeNs: null,
|
||||
currentNs: 0,
|
||||
playing: false,
|
||||
bufferedEndNs: 5,
|
||||
expectedEndNs: 10,
|
||||
bufferProgress: 0.5,
|
||||
fullyBuffered: false,
|
||||
};
|
||||
assert.equal(isRecordedPlaybackPresentationReady("loading", partial), false);
|
||||
assert.equal(isRecordedPlaybackPresentationReady("ready", partial), false);
|
||||
assert.equal(isRecordedPlaybackPresentationReady("error", partial), false);
|
||||
assert.equal(isRecordedPlaybackPresentationReady("ready", {
|
||||
...partial,
|
||||
rangeNs: { min: 0, max: 10 },
|
||||
bufferedEndNs: 10,
|
||||
bufferProgress: 1,
|
||||
fullyBuffered: true,
|
||||
}), true);
|
||||
});
|
||||
|
||||
test("buffer progress grows independently and preserves the full-buffer tolerance", () => {
|
||||
assert.deepEqual(recordedPlaybackBufferState({ min: 0, max: 5_000_000_000 }, 20), {
|
||||
bufferedEndNs: 5_000_000_000,
|
||||
expectedEndNs: 20_000_000_000,
|
||||
bufferProgress: 0.25,
|
||||
fullyBuffered: false,
|
||||
});
|
||||
|
||||
const complete = recordedPlaybackBufferState({ min: 0, max: 19_999_500_000 }, 20);
|
||||
assert.equal(complete.bufferProgress, 0.999975);
|
||||
assert.equal(complete.fullyBuffered, true);
|
||||
assert.equal(isRecordedPlaybackReady(false, true, complete), false);
|
||||
assert.equal(isRecordedPlaybackReady(true, false, complete), false);
|
||||
assert.equal(isRecordedPlaybackReady(true, true, complete), true);
|
||||
assert.equal(
|
||||
recordedPlaybackRangeWhenReady({ min: 0, max: 19_999_500_000 }, complete, false),
|
||||
null,
|
||||
);
|
||||
assert.deepEqual(
|
||||
recordedPlaybackRangeWhenReady({ min: 0, max: 19_999_500_000 }, complete, true),
|
||||
{ min: 0, max: 19_999_500_000 },
|
||||
);
|
||||
|
||||
const wrongDeclaredStart = recordedPlaybackBufferState(
|
||||
{ min: 5_000_000_000, max: 20_000_000_000 },
|
||||
20,
|
||||
0,
|
||||
);
|
||||
assert.equal(wrongDeclaredStart.bufferProgress, 1);
|
||||
assert.equal(wrongDeclaredStart.fullyBuffered, false);
|
||||
assert.equal(isRecordedPlaybackReady(true, true, wrongDeclaredStart), false);
|
||||
});
|
||||
|
||||
test("recorded autoplay waits for the full range and then runs exactly once", () => {
|
||||
const gate = createRecordedAutoplayGate();
|
||||
const seeks = [];
|
||||
let plays = 0;
|
||||
const seek = (value) => seeks.push(value);
|
||||
const play = () => {
|
||||
plays += 1;
|
||||
};
|
||||
|
||||
assert.equal(gate.attempt(false, true, true, { min: 0, max: 0 }, seek, play), false);
|
||||
assert.equal(gate.attempt(true, false, true, { min: 0, max: 500_000_000 }, seek, play), false);
|
||||
assert.equal(gate.attempt(true, true, false, { min: 0, max: 20_000_000_000 }, seek, play), false);
|
||||
assert.deepEqual(seeks, []);
|
||||
assert.equal(plays, 0);
|
||||
assert.equal(gate.attempt(true, true, true, { min: 0, max: 20_000_000_000 }, seek, play), true);
|
||||
assert.equal(gate.attempt(true, true, true, { min: 0, max: 20_000_000_000 }, seek, play), false);
|
||||
assert.deepEqual(seeks, [0]);
|
||||
assert.equal(plays, 1);
|
||||
assert.equal(gate.attempted(), true);
|
||||
});
|
||||
|
||||
test("a failed vendor autoplay attempt is consumed instead of rewinding later", () => {
|
||||
const gate = createRecordedAutoplayGate();
|
||||
let seeks = 0;
|
||||
let plays = 0;
|
||||
|
||||
assert.equal(gate.attempt(
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
{ min: 0, max: 1 },
|
||||
() => {
|
||||
seeks += 1;
|
||||
},
|
||||
() => {
|
||||
plays += 1;
|
||||
throw new Error("receiver raced its first frame");
|
||||
},
|
||||
), false);
|
||||
assert.equal(gate.attempt(
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
{ min: 0, max: 2 },
|
||||
() => {
|
||||
seeks += 1;
|
||||
},
|
||||
() => {
|
||||
plays += 1;
|
||||
},
|
||||
), false);
|
||||
assert.equal(seeks, 1);
|
||||
assert.equal(plays, 1);
|
||||
});
|
||||
|
||||
test("recorded playback controller stays unpublished until the aggregate gate is ready", () => {
|
||||
assert.equal(canPublishRecordedPlaybackController(false, "ready"), false);
|
||||
assert.equal(canPublishRecordedPlaybackController(true, "loading"), false);
|
||||
assert.equal(canPublishRecordedPlaybackController(true, "error"), false);
|
||||
assert.equal(canPublishRecordedPlaybackController(true, "ready"), true);
|
||||
});
|
||||
@@ -0,0 +1,178 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { after, before, test } from "node:test";
|
||||
|
||||
import { createServer } from "vite";
|
||||
|
||||
let server;
|
||||
let createLatestAsyncCommitter;
|
||||
|
||||
before(async () => {
|
||||
server = await createServer({
|
||||
appType: "custom",
|
||||
logLevel: "silent",
|
||||
server: { middlewareMode: true },
|
||||
});
|
||||
({ createLatestAsyncCommitter } = await server.ssrLoadModule(
|
||||
"/src/core/runtime/latestAsyncCommitter.ts",
|
||||
));
|
||||
});
|
||||
|
||||
after(async () => {
|
||||
await server?.close();
|
||||
});
|
||||
|
||||
async function eventually(predicate, timeoutMs = 1_000) {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
while (!predicate()) {
|
||||
if (Date.now() >= deadline) throw new Error("Timed out waiting for async commit queue");
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
}
|
||||
}
|
||||
|
||||
test("viewer settings commits are serialized and intermediate slider values collapse", async () => {
|
||||
const calls = [];
|
||||
const resolvers = [];
|
||||
const settled = [];
|
||||
const committer = createLatestAsyncCommitter({
|
||||
commit(value) {
|
||||
calls.push(value);
|
||||
return new Promise((resolve) => resolvers.push(resolve));
|
||||
},
|
||||
onSettled(result) {
|
||||
settled.push(result);
|
||||
},
|
||||
});
|
||||
|
||||
committer.enqueue({ accumulationSeconds: 1 });
|
||||
committer.enqueue({ accumulationSeconds: 2 });
|
||||
committer.enqueue({ accumulationSeconds: 12 });
|
||||
|
||||
assert.deepEqual(calls, [{ accumulationSeconds: 1 }]);
|
||||
assert.equal(committer.isBusy(), true);
|
||||
resolvers.shift()(true);
|
||||
await eventually(() => calls.length === 2);
|
||||
|
||||
assert.deepEqual(calls[1], { accumulationSeconds: 12 });
|
||||
assert.equal(settled[0].superseded, true);
|
||||
resolvers.shift()(true);
|
||||
await eventually(() => !committer.isBusy());
|
||||
|
||||
assert.deepEqual(settled.map(({ value, applied, superseded }) => ({
|
||||
value,
|
||||
applied,
|
||||
superseded,
|
||||
})), [
|
||||
{ value: { accumulationSeconds: 1 }, applied: true, superseded: true },
|
||||
{ value: { accumulationSeconds: 12 }, applied: true, superseded: false },
|
||||
]);
|
||||
});
|
||||
|
||||
test("viewer settings commit failures settle as rejected without wedging the queue", async () => {
|
||||
const settled = [];
|
||||
const committer = createLatestAsyncCommitter({
|
||||
async commit(value) {
|
||||
if (value === "broken") throw new Error("offline");
|
||||
return true;
|
||||
},
|
||||
onSettled(result) {
|
||||
settled.push(result);
|
||||
},
|
||||
});
|
||||
|
||||
committer.enqueue("broken");
|
||||
await eventually(() => settled.length === 1);
|
||||
committer.enqueue("healthy");
|
||||
await eventually(() => settled.length === 2);
|
||||
|
||||
assert.deepEqual(settled.map(({ value, applied }) => ({ value, applied })), [
|
||||
{ value: "broken", applied: false },
|
||||
{ value: "healthy", applied: true },
|
||||
]);
|
||||
assert.equal(committer.isBusy(), false);
|
||||
});
|
||||
|
||||
test("waitForIdle resolves only after the running commit and latest queued value settle", async () => {
|
||||
const calls = [];
|
||||
const resolvers = [];
|
||||
const committer = createLatestAsyncCommitter({
|
||||
commit(value) {
|
||||
calls.push(value);
|
||||
return new Promise((resolve) => resolvers.push(resolve));
|
||||
},
|
||||
});
|
||||
|
||||
committer.enqueue("initial");
|
||||
committer.enqueue("latest");
|
||||
let idle = false;
|
||||
const idlePromise = committer.waitForIdle().then(() => {
|
||||
idle = true;
|
||||
});
|
||||
|
||||
await Promise.resolve();
|
||||
assert.equal(idle, false);
|
||||
resolvers.shift()(true);
|
||||
await eventually(() => calls.length === 2);
|
||||
assert.deepEqual(calls, ["initial", "latest"]);
|
||||
assert.equal(idle, false);
|
||||
|
||||
resolvers.shift()(true);
|
||||
await idlePromise;
|
||||
assert.equal(idle, true);
|
||||
assert.equal(committer.isBusy(), false);
|
||||
});
|
||||
|
||||
test("waitForIdle observes work enqueued by onSettled and resolves immediately when idle", async () => {
|
||||
const calls = [];
|
||||
let committer;
|
||||
committer = createLatestAsyncCommitter({
|
||||
async commit(value) {
|
||||
calls.push(value);
|
||||
return true;
|
||||
},
|
||||
onSettled({ value }) {
|
||||
if (value === "first") committer.enqueue("follow-up");
|
||||
},
|
||||
});
|
||||
|
||||
await committer.waitForIdle();
|
||||
committer.enqueue("first");
|
||||
await committer.waitForIdle();
|
||||
|
||||
assert.deepEqual(calls, ["first", "follow-up"]);
|
||||
assert.equal(committer.isBusy(), false);
|
||||
});
|
||||
|
||||
test("layout serialization can flush a staged setting and read the confirmed latest value", async () => {
|
||||
let staged = { pointSize: 2 };
|
||||
let confirmed = { pointSize: 1 };
|
||||
let resolveCommit;
|
||||
const committer = createLatestAsyncCommitter({
|
||||
commit() {
|
||||
return new Promise((resolve) => {
|
||||
resolveCommit = resolve;
|
||||
});
|
||||
},
|
||||
onSettled({ value, applied }) {
|
||||
if (applied) confirmed = value;
|
||||
},
|
||||
});
|
||||
|
||||
const saveLayout = async () => {
|
||||
committer.enqueue(staged);
|
||||
await committer.waitForIdle();
|
||||
return { sceneSettings: confirmed };
|
||||
};
|
||||
|
||||
const savePromise = saveLayout();
|
||||
staged = { pointSize: 9 };
|
||||
let saved = false;
|
||||
void savePromise.then(() => {
|
||||
saved = true;
|
||||
});
|
||||
await Promise.resolve();
|
||||
assert.equal(saved, false);
|
||||
|
||||
resolveCommit(true);
|
||||
const layout = await savePromise;
|
||||
assert.deepEqual(layout, { sceneSettings: { pointSize: 2 } });
|
||||
});
|
||||
@@ -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