Add template animation editor
This commit is contained in:
@@ -0,0 +1,396 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
|
||||
require('../src/common');
|
||||
|
||||
const { registerPlayerRoutes } = require('../src/player/routes');
|
||||
|
||||
function createAppAndHandlers() {
|
||||
const handlers = {};
|
||||
const app = {
|
||||
use() {},
|
||||
get(path, ...routeHandlers) {
|
||||
handlers[path] = function (req, res) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const runHandler = (index) => {
|
||||
const handler = routeHandlers[index];
|
||||
if (!handler) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
|
||||
let nextCalled = false;
|
||||
const next = function () {
|
||||
nextCalled = true;
|
||||
return runHandler(index + 1);
|
||||
};
|
||||
|
||||
let result;
|
||||
try {
|
||||
result = handler(req, res, next);
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
return;
|
||||
}
|
||||
|
||||
Promise.resolve(result).then(function (value) {
|
||||
if (!nextCalled) {
|
||||
resolve(value);
|
||||
}
|
||||
}, reject);
|
||||
};
|
||||
|
||||
runHandler(0);
|
||||
});
|
||||
};
|
||||
},
|
||||
post() {},
|
||||
put() {},
|
||||
delete() {}
|
||||
};
|
||||
|
||||
return { app, handlers };
|
||||
}
|
||||
|
||||
function createResponse() {
|
||||
const headers = {};
|
||||
return {
|
||||
headers,
|
||||
statusCode: 200,
|
||||
body: null,
|
||||
ended: false,
|
||||
set(name, value) {
|
||||
headers[name] = value;
|
||||
return this;
|
||||
},
|
||||
status(code) {
|
||||
this.statusCode = code;
|
||||
return this;
|
||||
},
|
||||
json(value) {
|
||||
this.body = value;
|
||||
return this;
|
||||
},
|
||||
send(value) {
|
||||
this.body = value;
|
||||
return this;
|
||||
},
|
||||
end() {
|
||||
this.ended = true;
|
||||
return this;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function createPlayerRouteOptions(overrides) {
|
||||
return Object.assign({
|
||||
mediaDir: 'e:\\Projects Git\\pulse-signage\\media',
|
||||
assetDir: 'e:\\Projects Git\\pulse-signage\\src\\player\\public',
|
||||
playerRuntime: { broadcastAnnouncementRefresh() {}, snapshotConnections() { return []; } },
|
||||
playerPlaylistService: { async buildScreenPlaylist() { return { screen: { slug: 'test2' } }; } },
|
||||
rtmpStreamService: {
|
||||
async getSessionStatus() { return { ready: false, live: false, session: {} }; },
|
||||
async getManifestFilePath() { return null; },
|
||||
async getSegmentFilePath() { return null; }
|
||||
},
|
||||
playerIdentifier: ''
|
||||
}, overrides);
|
||||
}
|
||||
|
||||
test('screen route falls back to offline rendering when playlist build fails', async () => {
|
||||
const { app, handlers } = createAppAndHandlers();
|
||||
const renderCalls = [];
|
||||
const originalConsoleError = console.error;
|
||||
console.error = () => {};
|
||||
const pool = {
|
||||
async query() {
|
||||
return [[{ id: 1 }]];
|
||||
},
|
||||
async getConnection() {
|
||||
return {
|
||||
async beginTransaction() {},
|
||||
async query() {},
|
||||
async commit() {},
|
||||
async rollback() {},
|
||||
release() {}
|
||||
};
|
||||
}
|
||||
};
|
||||
const common = {
|
||||
renderPlayerPage(slug, data) {
|
||||
renderCalls.push({ slug, data });
|
||||
return data ? 'online' : 'offline';
|
||||
}
|
||||
};
|
||||
|
||||
registerPlayerRoutes(app, {
|
||||
app,
|
||||
pool,
|
||||
common,
|
||||
...createPlayerRouteOptions({
|
||||
playerPlaylistService: {
|
||||
async buildScreenPlaylist() {
|
||||
const error = new Error('db unavailable');
|
||||
error.code = 'ECONNREFUSED';
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
const handler = handlers['/screen/:slug'];
|
||||
assert.equal(typeof handler, 'function');
|
||||
|
||||
const res = createResponse();
|
||||
|
||||
try {
|
||||
await handler({ params: { slug: 'test2' } }, res);
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
|
||||
assert.equal(res.headers['X-Player-Offline'], '1');
|
||||
assert.equal(res.body, 'offline');
|
||||
assert.deepEqual(renderCalls, [
|
||||
{ slug: 'test2', data: null }
|
||||
]);
|
||||
} finally {
|
||||
console.error = originalConsoleError;
|
||||
}
|
||||
});
|
||||
|
||||
test('screen route renders the shell when playlist data is missing', async () => {
|
||||
const { app, handlers } = createAppAndHandlers();
|
||||
const renderCalls = [];
|
||||
const pool = {
|
||||
async query() {
|
||||
return [[{ id: 1 }]];
|
||||
},
|
||||
async getConnection() {
|
||||
return {
|
||||
async beginTransaction() {},
|
||||
async query() {},
|
||||
async commit() {},
|
||||
async rollback() {},
|
||||
release() {}
|
||||
};
|
||||
}
|
||||
};
|
||||
const common = {
|
||||
renderPlayerPage(slug, data) {
|
||||
renderCalls.push({ slug, data });
|
||||
return '<html><body><div id="app"><div class="empty">Loading screen...</div></div></body></html>';
|
||||
}
|
||||
};
|
||||
|
||||
registerPlayerRoutes(app, {
|
||||
app,
|
||||
pool,
|
||||
common,
|
||||
...createPlayerRouteOptions({
|
||||
playerPlaylistService: {
|
||||
async buildScreenPlaylist() {
|
||||
return {
|
||||
screen: { id: 7, slug: 'test2' },
|
||||
playlist: null,
|
||||
slides: [],
|
||||
rssFeeds: [],
|
||||
apiSources: [],
|
||||
timetableGroups: [],
|
||||
revision: 'abc123'
|
||||
};
|
||||
}
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
const handler = handlers['/screen/:slug'];
|
||||
const res = createResponse();
|
||||
|
||||
await handler({ params: { slug: 'test2' } }, res);
|
||||
|
||||
assert.equal(res.statusCode, 200);
|
||||
assert.match(String(res.body), /Loading screen/);
|
||||
assert.deepEqual(renderCalls, [
|
||||
{
|
||||
slug: 'test2',
|
||||
data: {
|
||||
screen: { id: 7, slug: 'test2' },
|
||||
playlist: null,
|
||||
slides: [],
|
||||
rssFeeds: [],
|
||||
apiSources: [],
|
||||
timetableGroups: [],
|
||||
revision: 'abc123'
|
||||
}
|
||||
}
|
||||
]);
|
||||
});
|
||||
|
||||
test('playlist api route returns 404, etag, and 304 responses', async () => {
|
||||
const { app, handlers } = createAppAndHandlers();
|
||||
const playlistData = {
|
||||
screen: { id: 7, slug: 'test2' },
|
||||
playlist: { id: 22 },
|
||||
slides: [],
|
||||
rssFeeds: [],
|
||||
apiSources: [],
|
||||
timetableGroups: [],
|
||||
revision: 'rev-123'
|
||||
};
|
||||
|
||||
registerPlayerRoutes(app, {
|
||||
app,
|
||||
pool: { async query() { return [[{ id: 1 }]]; } },
|
||||
common: { renderPlayerPage() { return ''; } },
|
||||
...createPlayerRouteOptions({
|
||||
playerPlaylistService: {
|
||||
async buildScreenPlaylist(slug) {
|
||||
if (slug === 'missing') {
|
||||
return { screen: null, playlist: null, slides: [], rssFeeds: [], apiSources: [], timetableGroups: [], revision: 'none' };
|
||||
}
|
||||
return playlistData;
|
||||
}
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
const handler = handlers['/api/screens/:slug/playlist'];
|
||||
assert.equal(typeof handler, 'function');
|
||||
|
||||
const missingRes = createResponse();
|
||||
await handler({ params: { slug: 'missing' }, headers: {} }, missingRes);
|
||||
assert.equal(missingRes.statusCode, 404);
|
||||
assert.deepEqual(missingRes.body, { error: 'Screen not found' });
|
||||
|
||||
const okRes = createResponse();
|
||||
await handler({ params: { slug: 'test2' }, headers: {} }, okRes);
|
||||
assert.equal(okRes.statusCode, 200);
|
||||
assert.equal(okRes.headers.ETag, '"rev-123"');
|
||||
assert.deepEqual(okRes.body, playlistData);
|
||||
|
||||
const notModifiedRes = createResponse();
|
||||
await handler({ params: { slug: 'test2' }, headers: { 'if-none-match': '"rev-123"' } }, notModifiedRes);
|
||||
assert.equal(notModifiedRes.statusCode, 304);
|
||||
assert.equal(notModifiedRes.ended, true);
|
||||
});
|
||||
|
||||
test('announcement route returns 503 for transient failures and 304 on matching etag', async () => {
|
||||
const { app, handlers } = createAppAndHandlers();
|
||||
const announcement = {
|
||||
id: 9,
|
||||
modified_at: '2026-08-03T00:00:00.000Z',
|
||||
expires_at: '2026-08-04T00:00:00.000Z',
|
||||
enabled: true
|
||||
};
|
||||
|
||||
registerPlayerRoutes(app, {
|
||||
app,
|
||||
pool: { async query() { return [[{ id: 1 }]]; } },
|
||||
common: {
|
||||
async fetchActiveAnnouncement() {
|
||||
const error = new Error('db unavailable');
|
||||
error.code = 'ECONNREFUSED';
|
||||
throw error;
|
||||
},
|
||||
renderPlayerPage() { return ''; }
|
||||
},
|
||||
...createPlayerRouteOptions({
|
||||
rtmpStreamService: {
|
||||
async getSessionStatus() { return { ready: false, live: false, session: {} }; },
|
||||
async getManifestFilePath() { return null; },
|
||||
async getSegmentFilePath() { return null; }
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
const handler = handlers['/api/screens/:slug/announcement'];
|
||||
const unavailableRes = createResponse();
|
||||
await handler({ params: { slug: 'test2' }, headers: {} }, unavailableRes);
|
||||
assert.equal(unavailableRes.statusCode, 503);
|
||||
assert.deepEqual(unavailableRes.body, { error: 'Announcement state unavailable.' });
|
||||
|
||||
registerPlayerRoutes(app, {
|
||||
app,
|
||||
pool: { async query() { return [[{ id: 1 }]]; } },
|
||||
common: {
|
||||
async fetchActiveAnnouncement() {
|
||||
return announcement;
|
||||
},
|
||||
renderPlayerPage() { return ''; }
|
||||
},
|
||||
...createPlayerRouteOptions({
|
||||
rtmpStreamService: {
|
||||
async getSessionStatus() { return { ready: false, live: false, session: {} }; },
|
||||
async getManifestFilePath() { return null; },
|
||||
async getSegmentFilePath() { return null; }
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
const okHandler = handlers['/api/screens/:slug/announcement'];
|
||||
const okRes = createResponse();
|
||||
await okHandler({ params: { slug: 'test2' }, headers: {} }, okRes);
|
||||
assert.equal(okRes.statusCode, 200);
|
||||
assert.equal(okRes.headers.ETag, '"9:2026-08-03T00:00:00.000Z:2026-08-04T00:00:00.000Z:1"');
|
||||
assert.deepEqual(okRes.body, { announcement, revision: '9:2026-08-03T00:00:00.000Z:2026-08-04T00:00:00.000Z:1' });
|
||||
|
||||
const notModifiedRes = createResponse();
|
||||
await okHandler({ params: { slug: 'test2' }, headers: { 'if-none-match': '"9:2026-08-03T00:00:00.000Z:2026-08-04T00:00:00.000Z:1"' } }, notModifiedRes);
|
||||
assert.equal(notModifiedRes.statusCode, 304);
|
||||
assert.equal(notModifiedRes.ended, true);
|
||||
});
|
||||
|
||||
test('rtmp session route returns not-ready and ready payloads', async () => {
|
||||
const { app, handlers } = createAppAndHandlers();
|
||||
const sessionCalls = [];
|
||||
|
||||
registerPlayerRoutes(app, {
|
||||
app,
|
||||
pool: { async query() { return [[{ id: 1 }]]; } },
|
||||
common: { renderPlayerPage() { return ''; } },
|
||||
...createPlayerRouteOptions({
|
||||
rtmpStreamService: {
|
||||
async getSessionStatus(source, useMutedOutput) {
|
||||
sessionCalls.push({ source, useMutedOutput });
|
||||
if (source === 'ready') {
|
||||
return {
|
||||
ready: true,
|
||||
live: true,
|
||||
session: { key: 'abc', playlistUrl: 'http://example.com/live.m3u8', disableAudio: useMutedOutput }
|
||||
};
|
||||
}
|
||||
return {
|
||||
ready: false,
|
||||
live: false,
|
||||
timedOut: true,
|
||||
stderr: 'starting',
|
||||
session: {}
|
||||
};
|
||||
},
|
||||
async getManifestFilePath() { return null; },
|
||||
async getSegmentFilePath() { return null; }
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
const handler = handlers['/api/rtmp/session'];
|
||||
|
||||
const unavailableRes = createResponse();
|
||||
await handler({ headers: {}, query: { source: 'cold', disableAudio: 'true' } }, unavailableRes);
|
||||
assert.equal(unavailableRes.statusCode, 503);
|
||||
assert.deepEqual(unavailableRes.body, { ready: false, live: false, timedOut: true, stderr: 'starting' });
|
||||
|
||||
const readyRes = createResponse();
|
||||
await handler({ headers: {}, query: { source: 'ready', disableAudio: 'yes' } }, readyRes);
|
||||
assert.equal(readyRes.statusCode, 200);
|
||||
assert.deepEqual(readyRes.body, {
|
||||
key: 'abc',
|
||||
playlistUrl: 'http://example.com/live.m3u8',
|
||||
disableAudio: true,
|
||||
ready: true,
|
||||
live: true
|
||||
});
|
||||
assert.deepEqual(sessionCalls, [
|
||||
{ source: 'cold', useMutedOutput: true },
|
||||
{ source: 'ready', useMutedOutput: true }
|
||||
]);
|
||||
});
|
||||
Reference in New Issue
Block a user