Add onboarding weather and template gradients
Publish Docker Image / build-and-push-existing-registry (./build/Dockerfile, web, pulse-signage-web) (push) Successful in 1m53s
Publish Docker Image / build-and-push-existing-registry (./build/Dockerfile.player, player, pulse-signage-player) (push) Successful in 34s

This commit is contained in:
2026-08-28 20:05:23 +01:00
parent aa07a78912
commit 3960931ebe
80 changed files with 12750 additions and 519 deletions
+12 -3
View File
@@ -32,7 +32,7 @@ test('move client rebinding redirects the live player to the target screen', asy
return [[{ id: 12, name: 'Source Screen', slug: 'source-screen' }]];
}
if (sql.includes('SELECT d.client_name, s.slug AS current_screen_slug')) {
return [[{ client_name: 'Lobby Client', current_screen_slug: 'source-screen' }]];
return [[{ client_name: 'Stored Client Name', current_screen_slug: 'source-screen' }]];
}
if (sql.includes('SELECT id, name, slug FROM d_screens WHERE slug = ?') && params && params[0] === 'target-screen') {
return [[{ id: 27, name: 'Target Screen', slug: 'target-screen' }]];
@@ -126,6 +126,7 @@ test('move client rebinding redirects the live player to the target screen', asy
body: {
command: 'moveclient',
deviceId: 'device-123',
clientId: 'tab-123',
clientName: 'Lobby Client',
targetScreenSlug: 'target-screen',
connectionId: 'conn-1',
@@ -136,8 +137,16 @@ test('move client rebinding redirects the live player to the target screen', asy
assert.equal(response.statusCode, 200);
assert.equal(response.body.ok, true);
assert.equal(response.body.targetScreenSlug, 'target-screen');
assert.equal(response.body.playerUrl, 'http://remote-player.example/screen/target-screen');
assert.equal(response.body.playerUrl, 'http://remote-player.example/screen/target-screen');
assert.equal(calls.some((entry) => entry.kind === 'query'
&& entry.sql.includes('SET screen_id = ?, modified_at = CURRENT_TIMESTAMP')
&& entry.params[0] === 27
&& entry.params[1] === 'device-123'), false);
assert.equal(calls.some((entry) => entry.kind === 'query'
&& entry.sql.includes('SET client_name = ?, screen_id = ?, modified_at = CURRENT_TIMESTAMP')
&& entry.params[0] === 'Lobby Client'
&& entry.params[1] === 27
&& entry.params[2] === 'tab-123'), true);
assert.equal(calls.some((entry) => entry.kind === 'broadcastDashboardState'), true);
assert.equal(calls.some((entry) => entry.kind === 'forwardPlayerCommand' && entry.slug === 'source-screen' && entry.payload && entry.payload.command === 'redirect'), false);
assert.equal(calls.some((entry) => entry.kind === 'forwardPlayerCommandToBaseUrl' && entry.baseUrl === 'http://remote-player.example'), true);
+7
View File
@@ -15,4 +15,11 @@ test('async save runs success hooks before redirecting close or new saves', () =
assert.ok(adminPageScript.includes('if (typeof settings.afterSuccess === \'function\')'));
assert.ok(adminPageScript.includes('clearFormDirty(form);'));
assert.ok(adminPageScript.includes("if (submitterValue === 'close' || submitterValue === 'new')"));
});
test('data-source toggles update in place instead of reloading the edit form', () => {
assert.ok(adminPageScript.includes('function updateDataSourceToggle(form, response)'));
assert.ok(adminPageScript.includes('data-async-data-source-toggle'));
assert.ok(adminPageScript.includes("showToast(message || (willEnable ? 'Data source enabled.' : 'Data source disabled.'), 'success')"));
assert.ok(!adminPageScript.includes("/^\\/data-sources\\/(?:api-sources|rss-feeds|weather)\\/\\d+$/.test(actionPath);"));
});
+20
View File
@@ -0,0 +1,20 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const { pruneStaleOnboardingDevices } = require('../src/db/common');
test('prunes old unbound onboarding devices without deleting completed bindings', async () => {
let queryText = '';
const pool = {
async query(sql) {
queryText = sql;
return [[]];
}
};
await pruneStaleOnboardingDevices(pool);
assert.match(queryText, /modified_at < \(CURRENT_TIMESTAMP - INTERVAL 1 MINUTE\)/);
assert.match(queryText, /WHERE screen_id IS NULL/);
assert.doesNotMatch(queryText, /d_players\.identifier = d_onboarding_devices\.device_id/);
});
+6 -1
View File
@@ -11,7 +11,8 @@ const {
normalizeSlide,
renderEditorJsContent,
renderHtmlRegionContent,
sanitizeRichText
sanitizeRichText,
getAnnouncementIconsDataScript
} = require('../src/player/render-helpers');
test('mediaKind classifies player media by extension', () => {
@@ -89,6 +90,10 @@ test('timetable region registers the timetable type', () => {
assert.ok(timetableRegionSource.includes("sanitizeRichText(substituteTimetableVariables(value"));
});
test('player announcement bootstrap includes catalog-only icons', () => {
assert.match(getAnnouncementIconsDataScript(), /bank/);
});
test('shared iframe renderers size preview content explicitly', () => {
const playerHtmlRegionSource = fs.readFileSync(require.resolve('../src/player/regions/html.js'), 'utf8');
const playerWebpageRegionSource = fs.readFileSync(require.resolve('../src/player/regions/webpage.js'), 'utf8');
+79 -14
View File
@@ -97,6 +97,84 @@ function createPlayerRouteOptions(overrides) {
}, overrides);
}
test('local player serves a neutral shell for direct screen URLs', async () => {
const { app, handlers } = createAppAndHandlers();
registerPlayerRoutes(app, {
app,
pool: {
async query(sql) {
if (String(sql).includes('FROM d_onboarding_devices d')) {
return [[{ slug: 'target-screen' }]];
}
return [[]];
}
},
common: { renderPlayerPage() { return 'online'; } },
...createPlayerRouteOptions({
playerDeviceId: 'player-local'
})
});
const movedResponse = createResponse();
await handlers['/screen/:slug']({ params: { slug: 'target-screen' }, query: { clientId: 'tab-local' }, headers: {} }, movedResponse);
assert.equal(movedResponse.statusCode, 200);
assert.equal(movedResponse.body, 'online');
const oldResponse = createResponse();
await handlers['/screen/:slug']({ params: { slug: 'old-screen' }, query: { clientId: 'tab-local' }, headers: {} }, oldResponse);
assert.equal(oldResponse.statusCode, 200);
assert.equal(oldResponse.body, 'online');
});
test('remote player serves a neutral shell for direct screen URLs', async () => {
const { app, handlers } = createAppAndHandlers();
const originalFetch = global.fetch;
global.fetch = async function (url) {
if (String(url).includes('/api/onboarding/status')) {
return {
status: 200,
headers: { get() { return 'application/json'; } },
async json() {
return { screenId: 27, screenSlug: 'target-screen' };
}
};
}
return {
status: 200,
headers: { get() { return 'application/json'; } },
async json() {
return { screen: { slug: 'target-screen' } };
}
};
};
try {
registerPlayerRoutes(app, {
app,
pool: null,
common: { renderPlayerPage() { return 'online'; } },
...createPlayerRouteOptions({
bridgeBaseUrl: 'http://bridge.test',
playerDeviceId: 'player-remote'
})
});
const movedResponse = createResponse();
await handlers['/screen/:slug']({ params: { slug: 'target-screen' }, query: { clientId: 'tab-remote' }, headers: {} }, movedResponse);
await new Promise((resolve) => setImmediate(resolve));
assert.equal(movedResponse.statusCode, 200);
assert.equal(movedResponse.body, 'online');
const oldResponse = createResponse();
await handlers['/screen/:slug']({ params: { slug: 'old-screen' }, query: { clientId: 'tab-remote' }, headers: {} }, oldResponse);
await new Promise((resolve) => setImmediate(resolve));
assert.equal(oldResponse.statusCode, 200);
assert.equal(oldResponse.body, 'online');
} finally {
global.fetch = originalFetch;
}
});
test('screen route reports the request origin for the player public base url', async () => {
const { app, handlers } = createAppAndHandlers();
let reportedBaseUrl = null;
@@ -249,20 +327,7 @@ test('screen route renders the shell when playlist data is missing', async () =>
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'
}
}
]);
assert.deepEqual(renderCalls, [{ slug: 'test2', data: null }]);
});
test('playlist api route returns 404, etag, and 304 responses', async () => {
+1 -1
View File
@@ -44,7 +44,7 @@ test('pending migrations are empty when the schema already matches the app versi
}
]);
const pendingMigrations = await getPendingMigrations(pool, { currentVersion: '2.8.9' });
const pendingMigrations = await getPendingMigrations(pool, { currentVersion: '2.10.1' });
assert.equal(pendingMigrations.length, 0);
});
+20 -1
View File
@@ -6,7 +6,8 @@ require('../src/common');
const {
buildTemplatePayload,
extractTemplateRegions
extractTemplateRegions,
normalizeBackgroundGradient
} = require('../src/data/templates');
const renderTemplateAddPage = require('../src/web/routes/signage/templates/add');
const renderTemplateEditPage = require('../src/web/routes/signage/templates/edit');
@@ -81,6 +82,7 @@ test('buildTemplatePayload resolves canvas size and rejects duplicate region nam
canvasSizeHeight: 720,
backgroundImagePath: null,
backgroundColor: '#111111',
backgroundGradient: null,
regions: [{
region_key: 'Header',
region_type: 'text',
@@ -229,4 +231,21 @@ test('template designer warns when invalid region names block save', () => {
assert.ok(script.includes("templateForm.addEventListener('invalid'"));
assert.ok(script.includes('function notifyRegionNameValidationError(message)'));
assert.ok(script.includes('regionNameValidationToastShown = false;'));
});
test('normalizeBackgroundGradient accepts linear colors and clamps the angle', () => {
assert.equal(normalizeBackgroundGradient(JSON.stringify({
type: 'radial',
colors: ['#123456', '#abcdef', '#fedcba'],
angle: 400
})), JSON.stringify({
type: 'linear',
stops: [
{ color: '#123456', position: 0 },
{ color: '#abcdef', position: 50 },
{ color: '#fedcba', position: 100 }
],
angle: 360
}));
assert.equal(normalizeBackgroundGradient('{}'), null);
});
+49
View File
@@ -0,0 +1,49 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const { buildDuplicateWeatherLocationName, buildDuplicateWeatherLocation } = require('../src/web/routes/data-sources/weather/duplicate');
test('weather location duplicate helper copies settings and resets runtime state', () => {
const location = {
id: 7,
name: 'London',
location_label: 'London, UK',
latitude: 51.5074,
longitude: -0.1278,
timezone: 'Europe/London',
provider: 'open-meteo',
temperature_unit: 'celsius',
wind_unit: 'kmh',
precipitation_unit: 'mm',
update_interval_value: 30,
update_interval_unit: 'minutes',
last_pulled_at: '2026-08-28T10:00:00.000Z',
last_pull_error: 'Bad response',
last_response_status: 500,
last_response_content_type: 'application/json',
last_response_json: '{"ok":false}'
};
const duplicate = buildDuplicateWeatherLocation(location, buildDuplicateWeatherLocationName(location.name));
assert.equal(duplicate.id, null);
assert.equal(duplicate.name, 'Copy of London');
assert.equal(duplicate.location_label, 'London, UK');
assert.equal(duplicate.latitude, 51.5074);
assert.equal(duplicate.longitude, -0.1278);
assert.equal(duplicate.provider, 'open-meteo');
assert.equal(duplicate.update_interval_value, 30);
assert.equal(duplicate.last_pulled_at, null);
assert.equal(duplicate.last_pull_error, '');
assert.equal(duplicate.last_response_status, null);
assert.equal(duplicate.last_response_json, '');
});
test('weather list template includes duplicate action', () => {
const template = fs.readFileSync(path.join(__dirname, '..', 'src', 'web', 'views', 'data-sources', 'weather', 'list.hbs'), 'utf8');
assert.match(template, /\/data-sources\/weather\/{\{id\}\}\/duplicate/);
assert.match(template, />Dupe<\/a>/);
});
+38
View File
@@ -0,0 +1,38 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const { fetchWeatherLocationForecast } = require('../src/data/weather');
test('Open-Meteo hourly forecast starts at the current hour and spans 24 hours', async () => {
const originalFetch = global.fetch;
const originalDateNow = Date.now;
let requestUrl;
global.fetch = async function (url) {
requestUrl = new URL(url);
return { ok: true, json: async function () { return { hourly: { time: [] } }; } };
};
Date.now = function () { return new Date('2026-08-28T12:34:00Z').getTime(); };
try {
await fetchWeatherLocationForecast({ query: async function () { return [[]]; } }, {
latitude: 51.5,
longitude: -0.1,
timezone: 'Europe/London',
provider: 'open-meteo'
});
} finally {
global.fetch = originalFetch;
Date.now = originalDateNow;
}
assert.equal(requestUrl.searchParams.get('forecast_hours'), '24');
assert.equal(requestUrl.searchParams.get('forecast_days'), '7');
});
test('weather forecast preview only shows the fetch hint without a snapshot', () => {
const template = fs.readFileSync(path.join(__dirname, '..', 'src', 'web', 'views', 'data-sources', 'weather', 'forecast-preview.hbs'), 'utf8');
assert.match(template, /\{\{#unless weatherPreview\.hasSnapshot\}\}.*Daily and hourly forecasts will appear here after the first successful fetch\..*\{\{\/unless\}\}/);
});
+4 -9
View File
@@ -7,7 +7,7 @@ require('../src/common');
const renderDashboardPage = require('../src/web/routes/signage/dashboard');
test('dashboard onboarding link uses the player base url', () => {
test('dashboard pairing link uses the clients pairing permission', () => {
const html = renderDashboardPage(
{
screens: [
@@ -22,11 +22,11 @@ test('dashboard onboarding link uses the player base url', () => {
connectedPlayersCount: 0
},
'',
{ id: 1, permissions: ['screens.read', 'clients.read'] }
{ id: 1, permissions: ['screens.read', 'clients.read', 'pairing.allow'] }
);
assert.match(html, /href="http:\/\/player\.local"/);
assert.doesNotMatch(html, /http:\/\/player\.local\//);
assert.match(html, /href="\/pairing"/);
assert.doesNotMatch(html, /http:\/\/player\.local/);
});
test('dashboard screen snapshot omits player links and duplicate connection counts', () => {
@@ -200,7 +200,6 @@ test('dashboard move client button opens the move modal for the selected row', (
addEventListener() {}
};
const connectionInput = { value: '' };
const deviceInput = { value: '' };
const clientNameInput = { value: '' };
const playerBaseUrlInput = { value: '' };
const targetSelect = { value: 'alpha' };
@@ -263,9 +262,6 @@ test('dashboard move client button opens the move modal for the selected row', (
if (selector === '[data-client-move-connection-id]') {
return connectionInput;
}
if (selector === '[data-client-move-device-id]') {
return deviceInput;
}
if (selector === '[data-client-move-client-name]') {
return clientNameInput;
}
@@ -338,7 +334,6 @@ test('dashboard move client button opens the move modal for the selected row', (
assert.equal(targetSelect.options[0].disabled, true);
assert.equal(targetSelect.options[1].disabled, false);
assert.equal(connectionInput.value, 'conn-123');
assert.equal(deviceInput.value, 'device-123');
assert.equal(clientNameInput.value, 'Lobby Client');
assert.equal(playerBaseUrlInput.value, 'http://player.local');
});
+3 -3
View File
@@ -26,8 +26,8 @@ test('client row keys prefer the client name', () => {
loadScript(path.join(__dirname, '..', 'src', 'web', 'public', 'js', 'web-ui-helpers.js'), sandbox);
const helpers = sandbox.window.webUiHelpers;
assert.equal(helpers.getClientRowKey({ client_name: 'Conference Left', screen_slug: 'alpha', deviceId: 'device-123', id: 'conn-1', clientId: 'client-1' }), 'Conference Left');
assert.equal(helpers.getClientRowKey({ client_name: 'Conference Right', screen_slug: 'beta', deviceId: 'device-123', id: 'conn-2', clientId: 'client-2' }), 'Conference Right');
assert.equal(helpers.getClientRowKey({ screen_slug: 'alpha', id: 'conn-1', clientId: 'client-1' }), 'alpha');
assert.equal(helpers.getClientRowKey({ client_name: 'Conference Left', screen_slug: 'alpha', deviceId: 'device-123', id: 'conn-1', clientId: 'client-1' }), 'conn-1');
assert.equal(helpers.getClientRowKey({ client_name: 'Conference Right', screen_slug: 'beta', deviceId: 'device-123', id: 'conn-2', clientId: 'client-2' }), 'conn-2');
assert.equal(helpers.getClientRowKey({ screen_slug: 'alpha', id: 'conn-1', clientId: 'client-1' }), 'conn-1');
assert.equal(helpers.getClientRowKey({ clientId: 'client-1' }), 'client-1');
});