Add onboarding weather and template gradients
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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);"));
|
||||
});
|
||||
@@ -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/);
|
||||
});
|
||||
@@ -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
@@ -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 () => {
|
||||
|
||||
@@ -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
@@ -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);
|
||||
});
|
||||
@@ -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>/);
|
||||
});
|
||||
@@ -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\}\}/);
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
Reference in New Issue
Block a user