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

238 lines
8.6 KiB
JavaScript

import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
import { after, before, test } from "node:test";
import { createServer } from "vite";
let server;
let fetchRecordedMediaArchive;
let recordedMediaPresentationState;
let recordedMediaSeekableCoverage;
let recordedMediaFragmentUrl;
let recordedMediaDecodeStartSequence;
let recordedMediaSegmentAppendOrder;
before(async () => {
server = await createServer({
appType: "custom",
logLevel: "silent",
server: { middlewareMode: true },
});
({
fetchRecordedMediaArchive,
recordedMediaPresentationState,
recordedMediaSeekableCoverage,
recordedMediaFragmentUrl,
recordedMediaDecodeStartSequence,
recordedMediaSegmentAppendOrder,
} = await server.ssrLoadModule("/src/components/RecordedFmp4Player.tsx"));
});
after(async () => {
await server?.close();
});
function fixture({ byteLength = 36_000_000_000 } = {}) {
const segmentCount = 1_501;
const manifestUrl = "/api/v1/observation-sessions/session-1/media/camera-1/manifest";
const generation = "a".repeat(64);
const streamUrl = manifestUrl.replace(
"/manifest",
`/epochs/1/recording.mp4?generation=${generation}`,
);
const manifest = {
schema_version: "missioncore.observation-recorded-media/v4",
source_id: "recorded.camera.camera-1",
generation_sha256: generation,
byte_length: byteLength,
timeline_start_seconds: 0,
timeline_end_seconds: 36_000,
synchronization: "host-arrival-best-effort",
epochs: [{
ordinal: 1,
timeline_start_seconds: 0,
timeline_end_seconds: 36_000,
media_type: 'video/mp4; codecs="avc1.640028"',
byte_length: byteLength,
stream_url: streamUrl,
segment_count: segmentCount,
random_access_sequences: [1, 1491, 1501],
segment_end_times_seconds: Array.from(
{ length: segmentCount },
(_value, index) => ((index + 1) * 36_000) / segmentCount,
),
}],
};
const source = {
id: manifest.source_id,
label: "Записанная камера",
modality: "video",
manifestUrl,
manifestGenerationSha256: generation,
byteLength,
mediaType: "video/mp4",
timelineStartSeconds: 0,
timelineEndSeconds: 36_000,
seekable: true,
synchronization: "host-arrival-best-effort",
};
return { source, manifest, generation, streamUrl };
}
function jsonResponse(payload, generation) {
return new Response(JSON.stringify(payload), {
status: 200,
headers: {
"Content-Type": "application/json",
ETag: `"sha256:${generation}"`,
},
});
}
test("multi-hour camera admission fetches only its compact generation-bound manifest", async () => {
const { source, manifest, generation, streamUrl } = fixture();
const requested = [];
const archive = await fetchRecordedMediaArchive(source, {
fetcher: async (input, request = {}) => {
requested.push(String(input));
assert.equal(new Headers(request.headers).get("If-Match"), `"sha256:${generation}"`);
return jsonResponse(manifest, generation);
},
});
assert.deepEqual(requested, [source.manifestUrl]);
assert.equal(archive.byteLength, 36_000_000_000);
assert.equal(archive.manifest.epochs[0].streamUrl, streamUrl);
assert.equal(archive.manifest.epochs[0].segmentCount, 1_501);
assert.deepEqual(archive.manifest.epochs[0].randomAccessSequences, [1, 1491, 1501]);
});
test("first camera manifest request rejects a replaced generation", async () => {
const { source, manifest, generation } = fixture();
const replacementGeneration = "d".repeat(64);
await assert.rejects(
fetchRecordedMediaArchive(source, {
fetcher: async (_input, request = {}) => {
assert.equal(new Headers(request.headers).get("If-Match"), `"sha256:${generation}"`);
return jsonResponse(
{ ...manifest, generation_sha256: replacementGeneration },
replacementGeneration,
);
},
}),
/несовместим|заменён/,
);
});
test("camera manifest fails closed when epoch bytes do not match launch bytes", async () => {
const { source, manifest, generation } = fixture();
const mismatched = {
...manifest,
epochs: [{ ...manifest.epochs[0], byte_length: manifest.byte_length - 1 }],
};
await assert.rejects(
fetchRecordedMediaArchive(source, {
fetcher: async () => jsonResponse(mismatched, generation),
}),
/не совпадает/,
);
});
test("camera presentation gate opens only for the seekable 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("recorded player keeps full-archive range fallback and uses bounded generation-bound fragments", async () => {
const source = await readFile(
new URL("../src/components/RecordedFmp4Player.tsx", import.meta.url),
"utf8",
);
assert.match(source, /video\.src\s*=\s*descriptor\.streamUrl/);
assert.doesNotMatch(source, /new Blob\(/);
assert.match(source, /new MediaSource\(\)/);
assert.match(source, /RECORDED_MEDIA_SEGMENTS_AHEAD = 36/);
assert.match(source, /pumpRecordedSegmentWindow/);
assert.match(source, /waitForRecordedVideoTarget/);
assert.match(source, /removeRecordedMediaRange/);
assert.equal(recordedMediaDecodeStartSequence([1, 1491, 1501], 1500), 1491);
assert.deepEqual(
recordedMediaSegmentAppendOrder(new Set([1491, 1492]), 1491, 1500),
[1493, 1494, 1495, 1496, 1497, 1498, 1499, 1500],
);
const { manifest, generation } = fixture();
const epoch = {
ordinal: 1,
timelineStartSeconds: 0,
timelineEndSeconds: 36_000,
mediaType: manifest.epochs[0].media_type,
byteLength: manifest.epochs[0].byte_length,
streamUrl: manifest.epochs[0].stream_url,
segmentCount: manifest.epochs[0].segment_count,
randomAccessSequences: manifest.epochs[0].random_access_sequences,
segmentEndTimesSeconds: manifest.epochs[0].segment_end_times_seconds,
};
assert.equal(
recordedMediaFragmentUrl(epoch, generation, "init"),
`/api/v1/observation-sessions/session-1/media/camera-1/epochs/1/init.mp4?generation=${generation}`,
);
assert.equal(
recordedMediaFragmentUrl(epoch, generation, 17),
`/api/v1/observation-sessions/session-1/media/camera-1/epochs/1/segments/17.m4s?generation=${generation}`,
);
assert.throws(
() => recordedMediaFragmentUrl(epoch, "b".repeat(64), 17),
/не привязан/,
);
});
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,
);
});
test("point-cloud fullscreen keeps the admitted recorded camera worker mounted", async () => {
const source = await readFile(
new URL("../src/workspaces/Workspaces.tsx", import.meta.url),
"utf8",
);
assert.match(source, /\{visibleMediaSources\.map\(\(source, index\) => \(/);
assert.match(source, /hidden=\{pointCloudFocused \|\| unifiedPerception\}/);
assert.doesNotMatch(
source,
/\{!pointCloudFocused \? visibleMediaSources\.map/,
);
assert.doesNotMatch(
source,
/\(pointCloudFocused \|\| !observationLayout\.visibleSourceIds\.has\(source\.id\)\)/,
);
});