WIP: Phase 3B playback engine handoff
This commit is contained in:
@@ -97,6 +97,7 @@ async function connectPollingSocket(baseUrl) {
|
||||
);
|
||||
|
||||
return {
|
||||
initialEvents: socketEvents(connectedPayload),
|
||||
async poll() {
|
||||
const response = await fetch(endpoint(sid), {
|
||||
signal: AbortSignal.timeout(3_000),
|
||||
@@ -115,6 +116,65 @@ async function connectPollingSocket(baseUrl) {
|
||||
};
|
||||
}
|
||||
|
||||
test("Socket.IO sends an authoritative playback snapshot on connect and after mutations", async (t) => {
|
||||
const runtime = createRuntime({
|
||||
config: testConfig(),
|
||||
logger: silentLogger,
|
||||
storage: testStorage(),
|
||||
});
|
||||
await runtime.start();
|
||||
t.after(() => runtime.stop("test cleanup"));
|
||||
const baseUrl = `http://127.0.0.1:${runtime.address.port}`;
|
||||
const socket = await connectPollingSocket(baseUrl);
|
||||
t.after(() => socket.close().catch(() => {}));
|
||||
|
||||
let initial = socket.initialEvents.find(
|
||||
([eventName]) => eventName === "playback:state",
|
||||
);
|
||||
if (!initial) {
|
||||
initial = socketEvents(await socket.poll()).find(
|
||||
([eventName]) => eventName === "playback:state",
|
||||
);
|
||||
}
|
||||
assert.ok(initial, "a full playback snapshot must be sent on connection");
|
||||
assert.equal(initial[1].protocolVersion, 1);
|
||||
assert.equal(initial[1].outputId, "global");
|
||||
const initialRevision = initial[1].playbackRevision;
|
||||
|
||||
const read = await jsonRequest(baseUrl, "/api/playback");
|
||||
assert.equal(read.response.status, 200);
|
||||
assert.equal(read.body.playbackRevision, initialRevision);
|
||||
const reread = await jsonRequest(baseUrl, "/api/playback");
|
||||
assert.equal(reread.body.playbackRevision, initialRevision);
|
||||
|
||||
const changed = await jsonRequest(baseUrl, "/api/playback/mode", {
|
||||
method: "PUT",
|
||||
body: { mode: "off" },
|
||||
});
|
||||
assert.equal(changed.response.status, 200);
|
||||
assert.equal(changed.body.mode, "off");
|
||||
assert.equal(changed.body.playbackRevision, initialRevision + 1);
|
||||
|
||||
const events = socketEvents(await socket.poll());
|
||||
const playbackEvents = events.filter(
|
||||
([eventName]) => eventName === "playback:state",
|
||||
);
|
||||
assert.equal(playbackEvents.length, 1);
|
||||
assert.deepEqual(playbackEvents[0][1], changed.body);
|
||||
|
||||
const unchanged = await jsonRequest(baseUrl, "/api/playback/mode", {
|
||||
method: "PUT",
|
||||
body: { mode: "off" },
|
||||
});
|
||||
assert.equal(unchanged.body.playbackRevision, changed.body.playbackRevision);
|
||||
assert.equal(
|
||||
runtime.database
|
||||
.prepare("SELECT revision FROM playback_state WHERE output_id = 'global'")
|
||||
.get().revision,
|
||||
changed.body.playbackRevision,
|
||||
);
|
||||
});
|
||||
|
||||
test("Socket.IO publishes playlists:changed only after successful semantic mutations", async (t) => {
|
||||
const runtime = createRuntime({
|
||||
config: testConfig(),
|
||||
@@ -144,10 +204,12 @@ test("Socket.IO publishes playlists:changed only after successful semantic mutat
|
||||
});
|
||||
assert.equal(created.response.status, 201);
|
||||
let events = socketEvents(await socket.poll());
|
||||
assert.equal(events.length, 1);
|
||||
assert.equal(events[0][0], "playlists:changed");
|
||||
assert.equal(events[0][1].reason, "created");
|
||||
assert.equal(events[0][1].playlistId, created.body.id);
|
||||
let playlistEvents = events.filter(
|
||||
([eventName]) => eventName === "playlists:changed",
|
||||
);
|
||||
assert.equal(playlistEvents.length, 1);
|
||||
assert.equal(playlistEvents[0][1].reason, "created");
|
||||
assert.equal(playlistEvents[0][1].playlistId, created.body.id);
|
||||
|
||||
const duplicate = await jsonRequest(baseUrl, "/api/playlists", {
|
||||
method: "POST",
|
||||
@@ -163,7 +225,10 @@ test("Socket.IO publishes playlists:changed only after successful semantic mutat
|
||||
body: { defaultDurationMs: 8_000 },
|
||||
});
|
||||
events = socketEvents(await socket.poll());
|
||||
assert.deepEqual(events, [
|
||||
playlistEvents = events.filter(
|
||||
([eventName]) => eventName === "playlists:changed",
|
||||
);
|
||||
assert.deepEqual(playlistEvents, [
|
||||
[
|
||||
"playlists:changed",
|
||||
{
|
||||
@@ -180,8 +245,11 @@ test("Socket.IO publishes playlists:changed only after successful semantic mutat
|
||||
});
|
||||
assert.equal(second.response.status, 201);
|
||||
events = socketEvents(await socket.poll());
|
||||
assert.equal(events.length, 1);
|
||||
assert.equal(events[0][1].reason, "created");
|
||||
playlistEvents = events.filter(
|
||||
([eventName]) => eventName === "playlists:changed",
|
||||
);
|
||||
assert.equal(playlistEvents.length, 1);
|
||||
assert.equal(playlistEvents[0][1].reason, "created");
|
||||
|
||||
const activated = await jsonRequest(
|
||||
baseUrl,
|
||||
@@ -191,14 +259,20 @@ test("Socket.IO publishes playlists:changed only after successful semantic mutat
|
||||
assert.equal(activated.response.status, 200);
|
||||
assert.equal(activated.body.activation, "active");
|
||||
events = socketEvents(await socket.poll());
|
||||
assert.equal(events.length, 1);
|
||||
assert.equal(events[0][0], "playlists:changed");
|
||||
assert.equal(events[0][1].reason, "activated");
|
||||
assert.equal(events[0][1].playlistId, created.body.id);
|
||||
playlistEvents = events.filter(
|
||||
([eventName]) => eventName === "playlists:changed",
|
||||
);
|
||||
assert.equal(playlistEvents.length, 1);
|
||||
assert.equal(playlistEvents[0][1].reason, "activated");
|
||||
assert.equal(playlistEvents[0][1].playlistId, created.body.id);
|
||||
assert.equal(
|
||||
events[0][1].playbackRevision,
|
||||
playlistEvents[0][1].playbackRevision,
|
||||
activated.body.playback.revision,
|
||||
);
|
||||
assert.equal(
|
||||
events.filter(([eventName]) => eventName === "playback:state").length,
|
||||
1,
|
||||
);
|
||||
|
||||
const activeDelete = await jsonRequest(
|
||||
baseUrl,
|
||||
@@ -208,3 +282,102 @@ test("Socket.IO publishes playlists:changed only after successful semantic mutat
|
||||
assert.equal(activeDelete.response.status, 409);
|
||||
assert.equal(activeDelete.body.error.code, "PLAYLIST_ACTIVE");
|
||||
});
|
||||
|
||||
test("activation invalidations come from authoritative pending-state transitions", async (t) => {
|
||||
const runtime = createRuntime({
|
||||
config: testConfig(),
|
||||
logger: silentLogger,
|
||||
storage: testStorage(),
|
||||
});
|
||||
await runtime.start();
|
||||
t.after(() => runtime.stop("test cleanup"));
|
||||
const timestamp = Date.now();
|
||||
const insertAsset = runtime.database.prepare(`
|
||||
INSERT INTO assets (
|
||||
id, source_sha256, display_name, original_filename, status,
|
||||
display_sha256, display_content_type, display_byte_size,
|
||||
display_width, display_height, created_at, updated_at
|
||||
) VALUES (?, ?, ?, ?, 'ready', ?, 'image/webp', 1, 1920, 1080, ?, ?)
|
||||
`);
|
||||
const insertPlaylist = runtime.database.prepare(`
|
||||
INSERT INTO playlists (id, name, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?)
|
||||
`);
|
||||
const insertEntry = runtime.database.prepare(`
|
||||
INSERT INTO playlist_entries (
|
||||
id, playlist_id, asset_id, position, created_at, updated_at
|
||||
) VALUES (?, ?, ?, 0, ?, ?)
|
||||
`);
|
||||
insertAsset.run(
|
||||
"asset-old",
|
||||
"a".repeat(64),
|
||||
"Old asset",
|
||||
"old.png",
|
||||
"c".repeat(64),
|
||||
timestamp,
|
||||
timestamp,
|
||||
);
|
||||
insertAsset.run(
|
||||
"asset-new",
|
||||
"b".repeat(64),
|
||||
"New asset",
|
||||
"new.png",
|
||||
"d".repeat(64),
|
||||
timestamp,
|
||||
timestamp,
|
||||
);
|
||||
insertPlaylist.run("playlist-old", "Old", timestamp, timestamp);
|
||||
insertPlaylist.run("playlist-new", "New", timestamp, timestamp);
|
||||
insertEntry.run(
|
||||
"entry-old",
|
||||
"playlist-old",
|
||||
"asset-old",
|
||||
timestamp,
|
||||
timestamp,
|
||||
);
|
||||
insertEntry.run(
|
||||
"entry-new",
|
||||
"playlist-new",
|
||||
"asset-new",
|
||||
timestamp,
|
||||
timestamp,
|
||||
);
|
||||
|
||||
const baseUrl = `http://127.0.0.1:${runtime.address.port}`;
|
||||
const socket = await connectPollingSocket(baseUrl);
|
||||
t.after(() => socket.close().catch(() => {}));
|
||||
|
||||
const activateOld = await jsonRequest(
|
||||
baseUrl,
|
||||
"/api/playlists/playlist-old/activate",
|
||||
{ method: "POST" },
|
||||
);
|
||||
assert.equal(activateOld.body.activation, "active");
|
||||
await socket.poll();
|
||||
|
||||
const requestNew = await jsonRequest(
|
||||
baseUrl,
|
||||
"/api/playlists/playlist-new/activate",
|
||||
{ method: "POST" },
|
||||
);
|
||||
assert.equal(requestNew.body.activation, "pending");
|
||||
let invalidations = socketEvents(await socket.poll()).filter(
|
||||
([eventName]) => eventName === "playlists:changed",
|
||||
);
|
||||
assert.equal(invalidations.length, 1);
|
||||
assert.equal(invalidations[0][1].reason, "activation_requested");
|
||||
assert.equal(invalidations[0][1].pendingPlaylistId, "playlist-new");
|
||||
|
||||
const cancel = await jsonRequest(
|
||||
baseUrl,
|
||||
"/api/playlists/playlist-old/activate",
|
||||
{ method: "POST" },
|
||||
);
|
||||
assert.equal(cancel.body.activation, "already_active");
|
||||
invalidations = socketEvents(await socket.poll()).filter(
|
||||
([eventName]) => eventName === "playlists:changed",
|
||||
);
|
||||
assert.equal(invalidations.length, 1);
|
||||
assert.equal(invalidations[0][1].reason, "activation_canceled");
|
||||
assert.equal(invalidations[0][1].pendingPlaylistId, null);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user