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