Release v2.15.5
This commit is contained in:
1 parent
0e77998e92
commit
2fd059d35a
18 files changed
+385
-30
No files matched your search
@@ -2,6 +2,24 @@
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
## 2.15.5 - 2026-10-17
|
||||
|
||||
### Added
|
||||
|
||||
- Added an All timetable display mode that includes past items while respecting date filters and the item limit.
|
||||
|
||||
### Changed
|
||||
|
||||
- Renamed the Both timetable display mode to Current & Upcoming without changing existing selections.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Browser requests now show the shared formatted error page for plain-text HTTP errors.
|
||||
- Template region position and stacking fields now update the preview immediately.
|
||||
- Region inputs no longer submit the template form when Enter is pressed.
|
||||
- Region width and height can be cleared and retyped without minimum-size clamping until the field is committed.
|
||||
- Restored remove controls when selecting image or video media from the Media Library.
|
||||
|
||||
## 2.15.4 - 2026-10-16
|
||||
|
||||
### Added
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "pulse-signage-player",
|
||||
"version": "2.15.4",
|
||||
"version": "2.15.5",
|
||||
"private": false,
|
||||
"description": "Pulse Signage player application bundle",
|
||||
"engines": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "pulse-signage-web",
|
||||
"version": "2.15.4",
|
||||
"version": "2.15.5",
|
||||
"private": false,
|
||||
"description": "Pulse Signage web and bridge application bundle",
|
||||
"engines": {
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "pulse-signage",
|
||||
"version": "2.15.4",
|
||||
"version": "2.15.5",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "pulse-signage",
|
||||
"version": "2.15.4",
|
||||
"version": "2.15.5",
|
||||
"dependencies": {
|
||||
"@sparticuz/chromium": "^149.0.0",
|
||||
"animate.css": "^4.1.1",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "pulse-signage",
|
||||
"version": "2.15.4",
|
||||
"version": "2.15.5",
|
||||
"private": false,
|
||||
"description": "Pulse Signage application with MySQL and media storage",
|
||||
"engines": {
|
||||
|
||||
@@ -24,7 +24,7 @@ function normalizeTimeZone(value, fallback) {
|
||||
|
||||
function normalizeDisplayMode(value) {
|
||||
const mode = String(value || 'upcoming').trim().toLowerCase();
|
||||
if (mode === 'current' || mode === 'both') {
|
||||
if (mode === 'current' || mode === 'both' || mode === 'all') {
|
||||
return mode;
|
||||
}
|
||||
return 'upcoming';
|
||||
|
||||
@@ -80,6 +80,9 @@ function getVisibleEntries(groupId, displayMode, maxItems, groups, config) {
|
||||
var mode = String(displayMode || 'upcoming').trim().toLowerCase();
|
||||
|
||||
entries = entries.filter(function (entry) {
|
||||
if (mode === 'all') {
|
||||
return true;
|
||||
}
|
||||
if (mode === 'current') {
|
||||
return isLive(entry, now);
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ const {
|
||||
renderEditorJsContent,
|
||||
sanitizeFontFamily,
|
||||
sanitizeFontSize,
|
||||
sanitizeRichText,
|
||||
sanitizeTextColor
|
||||
} = require('#src/player/render-helpers');
|
||||
const { createRequestAuthHeaders, createPageAuthToken } = require('#src/request-auth');
|
||||
@@ -21,6 +22,27 @@ const placeholderUtils = (() => {
|
||||
return sandbox.window.placeholderUtils || {};
|
||||
})();
|
||||
|
||||
const timetableScript = new vm.Script(fs.readFileSync(require.resolve('#src/player/regions/timetable'), 'utf8'));
|
||||
|
||||
function buildTimetableRegionMarkup(region, regionContent, options) {
|
||||
let timetableRenderer;
|
||||
const sandbox = {
|
||||
window: {
|
||||
placeholderUtils: placeholderUtils,
|
||||
pulsePlayerRegionTypes: {
|
||||
register(_type, renderer) { timetableRenderer = renderer; }
|
||||
}
|
||||
},
|
||||
initialData: { timetableGroups: Array.isArray(options && options.timetableGroups) ? options.timetableGroups : [] },
|
||||
escapeHtml: escapeHtml,
|
||||
sanitizeRichText: sanitizeRichText
|
||||
};
|
||||
timetableScript.runInNewContext(sandbox);
|
||||
return timetableRenderer.renderRegion(region, regionContent)
|
||||
.replace('class="template-region timetable"', 'class="slide-preview-region slide-preview-timetable-region"')
|
||||
.replace('class="template-region-text-scale"', 'class="slide-preview-text-content"');
|
||||
}
|
||||
|
||||
const SYSTEM_CHROMIUM_PATHS = [
|
||||
process.env.PUPPETEER_EXECUTABLE_PATH,
|
||||
process.env.CHROMIUM_PATH,
|
||||
@@ -233,6 +255,10 @@ function buildRegionInnerHtml(region, regionContent, baseUrl, options) {
|
||||
const regionType = String(regionContent.type || region.region_type || 'text').trim().toLowerCase();
|
||||
const rawValue = normalizeRenderableValue(regionContent && regionContent.value !== undefined ? regionContent.value : '');
|
||||
|
||||
if (regionType === 'timetable') {
|
||||
return buildTimetableRegionMarkup(region, regionContent, options);
|
||||
}
|
||||
|
||||
if (regionType === 'image') {
|
||||
const src = resolveAssetUrl(baseUrl, rawValue);
|
||||
return src
|
||||
|
||||
@@ -25,9 +25,10 @@ module.exports = function registerErrorPageMiddleware(app, pages) {
|
||||
|
||||
res.send = function (body) {
|
||||
const statusCode = Number(res.statusCode) || 200;
|
||||
const isHtml = typeof body === 'string' && /^\s*</.test(body);
|
||||
const isStringBody = typeof body === 'string';
|
||||
const isHtml = isStringBody && /^\s*</.test(body);
|
||||
|
||||
if (statusCode >= 400 && statusCode < 600 && !isHtml && wantsHtmlErrorPage(req, res)) {
|
||||
if (statusCode >= 400 && statusCode < 600 && isStringBody && !isHtml && wantsHtmlErrorPage(req, res)) {
|
||||
return send.call(res, createErrorPage(pages, req, statusCode, body));
|
||||
}
|
||||
|
||||
|
||||
@@ -188,9 +188,17 @@
|
||||
var previewBox = region.querySelector('.slide-image-region-preview-box');
|
||||
if (previewBox) {
|
||||
var safePath = escapeHtml(path);
|
||||
previewBox.innerHTML = type === 'video'
|
||||
? '<div class="slide-image-region-preview-shell"><video class="slide-image-region-preview" src="' + safePath + '" muted playsinline preload="metadata"></video></div>'
|
||||
: '<div class="slide-image-region-preview-shell"><img class="slide-image-region-preview" src="' + safePath + '" alt="Selected media preview" /></div>';
|
||||
var isVideo = type === 'video';
|
||||
var isQrImage = String(hidden.name || '').indexOf('existing_region_qr_image_') === 0;
|
||||
var mediaLabel = isVideo ? 'video' : isQrImage ? 'QR image' : 'image';
|
||||
var removeAction = isVideo ? 'region-video' : isQrImage ? 'region-qr-image' : 'region-image';
|
||||
var regionId = escapeHtml(region.getAttribute('data-region-id') || '');
|
||||
previewBox.innerHTML = '<div class="slide-image-region-preview-shell" data-remove-' + removeAction + '="' + regionId + '" role="button" tabindex="0" aria-label="Remove ' + mediaLabel + '">' +
|
||||
(isVideo
|
||||
? '<video class="slide-image-region-preview" src="' + safePath + '" muted playsinline preload="metadata"></video>'
|
||||
: '<img class="slide-image-region-preview" src="' + safePath + '" alt="Selected ' + mediaLabel + ' preview" />') +
|
||||
'<span class="slide-image-region-preview-remove" aria-hidden="true"><i class="bi bi-trash3" aria-hidden="true"></i></span>' +
|
||||
'</div>';
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -139,6 +139,9 @@
|
||||
var mode = String(displayMode || 'upcoming').trim().toLowerCase();
|
||||
|
||||
entries = entries.filter(function (entry) {
|
||||
if (mode === 'all') {
|
||||
return true;
|
||||
}
|
||||
if (mode === 'current') {
|
||||
return isLive(entry, now);
|
||||
}
|
||||
@@ -337,7 +340,8 @@
|
||||
'<select id="region_timetable_display_mode_' + region.id + '" name="region_timetable_display_mode_' + region.id + '" class="form-select">' +
|
||||
'<option value="upcoming"' + (currentDisplayMode === 'upcoming' ? ' selected' : '') + '>Upcoming</option>' +
|
||||
'<option value="current"' + (currentDisplayMode === 'current' ? ' selected' : '') + '>Current</option>' +
|
||||
'<option value="both"' + (currentDisplayMode === 'both' ? ' selected' : '') + '>Both</option>' +
|
||||
'<option value="both"' + (currentDisplayMode === 'both' ? ' selected' : '') + '>Current & Upcoming</option>' +
|
||||
'<option value="all"' + (currentDisplayMode === 'all' ? ' selected' : '') + '>All</option>' +
|
||||
'</select>' +
|
||||
'</div>' +
|
||||
'<div class="col-6 col-lg-2">' +
|
||||
|
||||
@@ -1232,8 +1232,18 @@
|
||||
return Boolean(getRegionLockRatio(card));
|
||||
}
|
||||
|
||||
function syncLockedDimensions(card, changedField) {
|
||||
normalizeRegionCard(card, changedField);
|
||||
function syncLockedDimensions(card, changedField, isEditing) {
|
||||
if (isEditing) {
|
||||
var current = readCard(card);
|
||||
var aspect = parseLockRatio(current.lock_ratio);
|
||||
if (aspect && changedField === 'width' && current.width > 0) {
|
||||
writeCard(card, { height: Math.max(12, Math.round(current.width / aspect.ratio)) });
|
||||
} else if (aspect && changedField === 'height' && current.height > 0) {
|
||||
writeCard(card, { width: Math.max(12, Math.round(current.height * aspect.ratio)) });
|
||||
}
|
||||
} else {
|
||||
normalizeRegionCard(card, changedField);
|
||||
}
|
||||
updateRegionLockBadge(card);
|
||||
markTemplateFormDirty();
|
||||
requestOverlayRender();
|
||||
@@ -1327,9 +1337,6 @@
|
||||
card.dataset.regionKey = String(region.region_key || region.label || getRegionName(card) || '').trim();
|
||||
setRegionRemovalState(card);
|
||||
var nameInput = card.querySelector('[name="region_name[]"]');
|
||||
var lockRatioInput = card.querySelector('[name="region_lock_ratio[]"]');
|
||||
var widthInput = card.querySelector('[name="region_width[]"]');
|
||||
var heightInput = card.querySelector('[name="region_height[]"]');
|
||||
var animationPresetInputs = card.querySelectorAll('[data-animation-preset]');
|
||||
var animationResetButtons = card.querySelectorAll('[data-animation-reset-button]');
|
||||
var animationAdvancedButton = card.querySelector('[data-animation-advanced-button]');
|
||||
@@ -1365,21 +1372,24 @@
|
||||
}
|
||||
});
|
||||
}
|
||||
if (lockRatioInput) {
|
||||
lockRatioInput.addEventListener('input', function () {
|
||||
syncLockedDimensions(card, 'width');
|
||||
['x', 'y', 'z', 'width', 'height', 'lock_ratio'].forEach(function (field) {
|
||||
var input = card.querySelector('[name="region_' + field + '[]"]');
|
||||
if (!input) {
|
||||
return;
|
||||
}
|
||||
var changedField = field === 'lock_ratio' ? 'width' : field;
|
||||
input.addEventListener('input', function () {
|
||||
syncLockedDimensions(card, changedField, true);
|
||||
});
|
||||
}
|
||||
if (widthInput) {
|
||||
widthInput.addEventListener('input', function () {
|
||||
syncLockedDimensions(card, 'width');
|
||||
input.addEventListener('change', function () {
|
||||
syncLockedDimensions(card, changedField);
|
||||
});
|
||||
}
|
||||
if (heightInput) {
|
||||
heightInput.addEventListener('input', function () {
|
||||
syncLockedDimensions(card, 'height');
|
||||
});
|
||||
}
|
||||
});
|
||||
card.addEventListener('keydown', function (event) {
|
||||
if (event.key === 'Enter' && event.target && event.target.tagName === 'INPUT') {
|
||||
event.preventDefault();
|
||||
}
|
||||
});
|
||||
card.addEventListener('click', function (event) {
|
||||
if (event.target && event.target.closest && event.target.closest('[data-region-remove-button]')) {
|
||||
return;
|
||||
|
||||
@@ -81,6 +81,7 @@ module.exports = function registerSlidesRoutes(app, deps) {
|
||||
const apiSources = (apiData.apiSources || []).map(function (source) {
|
||||
return Object.assign({}, source, { responseJson: common.parseJsonSafe ? common.parseJsonSafe(source.last_response_json) : null });
|
||||
});
|
||||
const timetableData = typeof common.fetchTimetablesData === 'function' ? await common.fetchTimetablesData(pool) : { timetableGroups: [] };
|
||||
const weatherData = typeof common.fetchWeatherLocationsData === 'function' ? await common.fetchWeatherLocationsData(pool) : { weatherLocations: [] };
|
||||
const weatherLocations = (weatherData.weatherLocations || []).map(function (location) {
|
||||
const snapshot = common.parseJsonSafe ? common.parseJsonSafe(location.last_response_json) : null;
|
||||
@@ -136,6 +137,7 @@ module.exports = function registerSlidesRoutes(app, deps) {
|
||||
fontStylesheetHref: getFontStylesheetHref(mediaDir),
|
||||
weatherIconSets: require('#src/web/lib/media/weather-icon-library').readWeatherIconSets(mediaDir),
|
||||
apiSources: apiSources,
|
||||
timetableGroups: timetableData.timetableGroups,
|
||||
getItem: getItem,
|
||||
getCachedImagePath: getCachedImagePath
|
||||
});
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
|
||||
const registerErrorPageMiddleware = require('../src/web/middleware/error-page');
|
||||
|
||||
function setupMiddleware(request) {
|
||||
let middleware = null;
|
||||
const pages = {
|
||||
renderErrorPage(options) {
|
||||
return '<html>formatted ' + options.statusCode + ': ' + options.message + '</html>';
|
||||
}
|
||||
};
|
||||
registerErrorPageMiddleware({
|
||||
use(value) {
|
||||
middleware = value;
|
||||
}
|
||||
}, pages);
|
||||
|
||||
const response = {
|
||||
statusCode: 200,
|
||||
body: null,
|
||||
status(code) {
|
||||
this.statusCode = code;
|
||||
return this;
|
||||
},
|
||||
get() {
|
||||
return '';
|
||||
},
|
||||
send(body) {
|
||||
this.body = body;
|
||||
return this;
|
||||
},
|
||||
sendStatus(code) {
|
||||
this.statusCode = code;
|
||||
this.body = 'status ' + code;
|
||||
return this;
|
||||
}
|
||||
};
|
||||
|
||||
middleware(request, response, function () {});
|
||||
return response;
|
||||
}
|
||||
|
||||
test('plain browser error responses use the shared formatted page', () => {
|
||||
const response = setupMiddleware({
|
||||
originalUrl: '/slides/180/edit',
|
||||
currentUser: { id: 1 },
|
||||
accepts() { return 'html'; },
|
||||
get() { return ''; }
|
||||
});
|
||||
|
||||
response.status(404).send('Slide not found');
|
||||
|
||||
assert.match(response.body, /^<html>formatted 404: Slide not found<\/html>$/);
|
||||
});
|
||||
|
||||
test('API errors retain their response body', () => {
|
||||
const response = setupMiddleware({
|
||||
originalUrl: '/api/slides/180',
|
||||
accepts() { return 'html'; },
|
||||
get() { return ''; }
|
||||
});
|
||||
|
||||
response.status(404).send('Slide not found');
|
||||
|
||||
assert.equal(response.body, 'Slide not found');
|
||||
});
|
||||
|
||||
test('object error responses retain their JSON payload', () => {
|
||||
const response = setupMiddleware({
|
||||
originalUrl: '/admin/action',
|
||||
accepts() { return 'html'; },
|
||||
get() { return ''; }
|
||||
});
|
||||
const payload = { error: 'Request failed' };
|
||||
|
||||
response.status(400).send(payload);
|
||||
|
||||
assert.equal(response.body, payload);
|
||||
});
|
||||
|
||||
test('already-rendered HTML errors are left intact', () => {
|
||||
const response = setupMiddleware({
|
||||
originalUrl: '/reset-password',
|
||||
accepts() { return 'html'; },
|
||||
get() { return ''; }
|
||||
});
|
||||
|
||||
response.status(400).send('<html>custom form error</html>');
|
||||
|
||||
assert.equal(response.body, '<html>custom form error</html>');
|
||||
});
|
||||
|
||||
test('sendStatus errors use the shared formatted page for browser requests', () => {
|
||||
const response = setupMiddleware({
|
||||
originalUrl: '/missing',
|
||||
accepts() { return 'html'; },
|
||||
get() { return ''; }
|
||||
});
|
||||
|
||||
response.sendStatus(404);
|
||||
|
||||
assert.match(response.body, /^<html>formatted 404:/);
|
||||
});
|
||||
@@ -4,6 +4,7 @@ const fs = require('node:fs');
|
||||
|
||||
const slideFormEditorSource = fs.readFileSync(require.resolve('../src/web/public/js/slides/slide-form-editor.js'), 'utf8');
|
||||
const slideFormSource = fs.readFileSync(require.resolve('../src/web/public/js/slides/slide-form.js'), 'utf8');
|
||||
const mediaPickerSource = fs.readFileSync(require.resolve('../src/web/public/js/media-picker.js'), 'utf8');
|
||||
const slideThumbnailPreviewSource = fs.readFileSync(require.resolve('../src/web/lib/media/slide-thumbnail-preview.js'), 'utf8');
|
||||
const slideThumbnailsSource = fs.readFileSync(require.resolve('../src/web/lib/media/slide-thumbnails.js'), 'utf8');
|
||||
const slideHtmlRegionSource = fs.readFileSync(require.resolve('../src/web/public/js/regions/type/html.js'), 'utf8');
|
||||
@@ -73,6 +74,12 @@ test('slide form queues editor image cleanup on save and close', () => {
|
||||
assert.ok(slideFormSource.includes("regionMediaController.queueUploadCleanup(slideFormEditorController.getImageUploadCleanupPaths())"));
|
||||
});
|
||||
|
||||
test('media library selections keep image and video remove controls', () => {
|
||||
assert.ok(mediaPickerSource.includes("var removeAction = isVideo ? 'region-video' : isQrImage ? 'region-qr-image' : 'region-image';"));
|
||||
assert.ok(mediaPickerSource.includes('data-remove-\' + removeAction'));
|
||||
assert.ok(mediaPickerSource.includes('slide-image-region-preview-remove'));
|
||||
});
|
||||
|
||||
test('slide thumbnail previews treat image-only text as visible content', () => {
|
||||
assert.ok(slideThumbnailPreviewSource.includes('/<img\\b/i.test(raw)'));
|
||||
assert.ok(slideThumbnailsSource.includes('/<img\\b/i.test(raw)'));
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const vm = require('node:vm');
|
||||
|
||||
require('../src/common');
|
||||
|
||||
@@ -332,6 +333,116 @@ test('template designer warns when invalid region names block save', () => {
|
||||
assert.ok(script.includes('regionNameValidationToastShown = false;'));
|
||||
});
|
||||
|
||||
function createRegionCardHarness(lockRatio = '') {
|
||||
const script = fs.readFileSync(require.resolve('../src/web/public/js/templates/template-designer.js'), 'utf8');
|
||||
const fields = {};
|
||||
const cardListeners = {};
|
||||
const initialValues = { name: 'Region', type: 'text', key: 'Region', label: 'Region', x: '80', y: '80', z: '1', width: '300', height: '120', lock_ratio: lockRatio, animation_json: '{}' };
|
||||
for (const [field, value] of Object.entries(initialValues)) {
|
||||
fields[`[name="region_${field}[]"]`] = {
|
||||
value,
|
||||
listeners: {},
|
||||
addEventListener(type, listener) { this.listeners[type] = listener; }
|
||||
};
|
||||
}
|
||||
fields['[data-region-remove-button]'] = { addEventListener() {} };
|
||||
const card = {
|
||||
dataset: {},
|
||||
querySelector(selector) { return fields[selector] || null; },
|
||||
querySelectorAll() { return []; },
|
||||
addEventListener(type, listener) { cardListeners[type] = listener; }
|
||||
};
|
||||
const previews = [];
|
||||
let dirtyCount = 0;
|
||||
const context = vm.createContext({
|
||||
window: {},
|
||||
regionCardTemplate: { content: { firstElementChild: { cloneNode: () => card } } },
|
||||
populateRegionCard() {},
|
||||
setRegionRemovalState() {},
|
||||
getRegionName: () => 'Region',
|
||||
updateRegionLockBadge() {},
|
||||
markTemplateFormDirty() { dirtyCount += 1; },
|
||||
requestOverlayRender() { previews.push(context.readCard(card)); },
|
||||
getCanvasSize: () => ({ width: 1920, height: 1080 }),
|
||||
normalizeLockRatio: (value) => value
|
||||
});
|
||||
vm.runInContext(fs.readFileSync(require.resolve('../src/web/public/js/templates/template-designer-utils.js'), 'utf8'), context);
|
||||
Object.assign(context, {
|
||||
readCard: context.window.templateDesignerUtils.readCard,
|
||||
writeCard: context.window.templateDesignerUtils.writeCard,
|
||||
parseLockRatio: context.window.templateDesignerUtils.parseLockRatio,
|
||||
clamp: context.window.templateDesignerUtils.clamp
|
||||
});
|
||||
for (const name of ['fitRegionToCanvas', 'normalizeRegionCard', 'syncLockedDimensions', 'makeRegionCard']) {
|
||||
const match = script.match(new RegExp(' function ' + name + '\\([^]*?\\n }'));
|
||||
assert.ok(match, `Missing designer function: ${name}`);
|
||||
vm.runInContext(match[0], context);
|
||||
}
|
||||
context.makeRegionCard({ region_key: 'Region' });
|
||||
return {
|
||||
previews,
|
||||
get dirtyCount() { return dirtyCount; },
|
||||
edit(field, value, type = 'input') {
|
||||
const input = fields[`[name="region_${field}[]"]`];
|
||||
input.value = value;
|
||||
input.listeners[type]();
|
||||
},
|
||||
value(field) { return Number(fields[`[name="region_${field}[]"]`].value); },
|
||||
rawValue(field) { return fields[`[name="region_${field}[]"]`].value; },
|
||||
keydown: (event) => cardListeners.keydown(event)
|
||||
};
|
||||
}
|
||||
|
||||
test('region coordinate and stacking fields immediately refresh the preview and mark it dirty', () => {
|
||||
const harness = createRegionCardHarness();
|
||||
harness.edit('x', '140');
|
||||
harness.edit('y', '220');
|
||||
harness.edit('z', '3');
|
||||
assert.equal(harness.previews.length, 3);
|
||||
assert.equal(harness.previews.at(-1).x, 140);
|
||||
assert.equal(harness.previews.at(-1).y, 220);
|
||||
assert.equal(harness.previews.at(-1).z_index, 3);
|
||||
assert.equal(harness.dirtyCount, 3);
|
||||
});
|
||||
|
||||
test('region dimensions allow clearing and typing below the minimum until committed', () => {
|
||||
for (const field of ['width', 'height']) {
|
||||
const harness = createRegionCardHarness();
|
||||
for (const value of ['', '1', '10', '100']) {
|
||||
harness.edit(field, value);
|
||||
assert.equal(harness.rawValue(field), value);
|
||||
}
|
||||
harness.edit(field, '1', 'change');
|
||||
assert.equal(harness.value(field), 12);
|
||||
harness.edit(field, '9999', 'change');
|
||||
assert.equal(harness.value(field), field === 'width' ? 1920 : 1080);
|
||||
}
|
||||
});
|
||||
|
||||
test('locked region dimensions update their counterpart without replacing partial input', () => {
|
||||
const harness = createRegionCardHarness('2:1');
|
||||
harness.edit('width', '');
|
||||
assert.equal(harness.rawValue('width'), '');
|
||||
harness.edit('width', '1');
|
||||
assert.equal(harness.rawValue('width'), '1');
|
||||
harness.edit('width', '400');
|
||||
assert.equal(harness.value('height'), 200);
|
||||
harness.edit('height', '150');
|
||||
assert.equal(harness.value('width'), 300);
|
||||
harness.edit('height', '150', 'change');
|
||||
assert.equal(harness.value('height'), 150);
|
||||
assert.equal(harness.value('width'), 300);
|
||||
});
|
||||
|
||||
test('Enter in region inputs prevents implicit saves without blocking buttons or textarea newlines', () => {
|
||||
const harness = createRegionCardHarness();
|
||||
for (const [key, tagName, expected] of [['Enter', 'INPUT', true], ['Tab', 'INPUT', false], ['Enter', 'BUTTON', false], ['Enter', 'TEXTAREA', false]]) {
|
||||
let prevented = false;
|
||||
harness.keydown({ key, target: { tagName }, preventDefault() { prevented = true; } });
|
||||
assert.equal(prevented, expected);
|
||||
}
|
||||
});
|
||||
|
||||
test('normalizeBackgroundGradient accepts linear colors and clamps the angle', () => {
|
||||
assert.equal(normalizeBackgroundGradient(JSON.stringify({
|
||||
type: 'radial',
|
||||
|
||||
@@ -130,6 +130,34 @@ test('timetable date ranges agree in preview and player, before limiting items',
|
||||
const config = { value: '{{title}}', timetable_group_id: 1, display_mode: 'upcoming', date_filter: 'between', date_from: '2099-08-20T00:00:00Z' };
|
||||
assert.doesNotMatch(previewModule.renderPreview({}, config, { timetableGroups: groups }), /Live/);
|
||||
assert.doesNotMatch(playerModule.renderRegion({}, config), /Live/);
|
||||
|
||||
groups[0].entries = [
|
||||
{ id: 5, title: 'Future', start_datetime: '2099-08-20T10:00:00Z' },
|
||||
groups[0].entries[0],
|
||||
{ id: 6, title: 'Past', start_datetime: '2000-08-20T10:00:00Z', end_datetime: '2000-08-20T11:00:00Z' }
|
||||
];
|
||||
const displayCases = [
|
||||
[{ display_mode: 'upcoming' }, ['Future']],
|
||||
[{ display_mode: 'current' }, ['Live']],
|
||||
[{ display_mode: 'both' }, ['Live', 'Future']],
|
||||
[{ display_mode: 'all' }, ['Past', 'Live', 'Future']],
|
||||
[{ display_mode: 'all', max_items: 1 }, ['Past']],
|
||||
[{ display_mode: 'all', date_filter: 'before', date_to: '2001-01-01T00:00:00Z' }, ['Past']],
|
||||
[{ display_mode: 'all', date_filter: 'after', date_from: '2099-01-01T00:00:00Z' }, ['Future']],
|
||||
[{ display_mode: 'all', date_filter: 'between', date_from: '2000-01-01T00:00:00Z', date_to: '2001-01-01T00:00:00Z' }, ['Past']]
|
||||
];
|
||||
for (const [settings, titles] of displayCases) {
|
||||
const content = { value: '{{title}}', timetable_group_id: 1, max_items: 5, ...settings };
|
||||
const outputs = [previewModule.renderPreview({}, content, { timetableGroups: groups }), playerModule.renderRegion({}, content)];
|
||||
for (const output of outputs) {
|
||||
for (const title of ['Past', 'Live', 'Future']) {
|
||||
assert.equal(output.includes(title), titles.includes(title), JSON.stringify(settings));
|
||||
}
|
||||
for (let index = 1; index < titles.length; index++) {
|
||||
assert.ok(output.indexOf(titles[index - 1]) < output.indexOf(titles[index]));
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('timetable date controls default to all and pass unsaved date ranges to preview', () => {
|
||||
@@ -158,6 +186,9 @@ test('timetable date controls default to all and pass unsaved date ranges to pre
|
||||
assert.equal(context.date_from, '');
|
||||
assert.equal(context.date_to, fields['[name="region_date_to_47"]'].value);
|
||||
assert.doesNotMatch(html, /Use the selected group and display mode/);
|
||||
assert.match(html, /<option value="both">Current & Upcoming<\/option>/);
|
||||
const allMode = module.renderEditorCard({ region, current: { display_mode: 'all' }, timetableGroups: [] });
|
||||
assert.match(allMode, /<option value="all" selected>All<\/option>/);
|
||||
for (const mode of ['before', 'after']) {
|
||||
const current = { ...saved, date_filter: mode };
|
||||
const markup = module.renderEditorCard({ region, current, timetableGroups: [] });
|
||||
|
||||
@@ -2,6 +2,35 @@ const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
|
||||
const { buildSlidePayload } = require('../src/data/slides');
|
||||
const { normalizeDisplayMode } = require('../src/data/timetables');
|
||||
|
||||
test('thumbnail timetable regions resolve entries instead of displaying placeholders', () => {
|
||||
const { buildThumbnailPreviewPayload } = require('../src/web/lib/media/slide-thumbnails');
|
||||
const slide = {
|
||||
template: { regions: [{ region_key: 'agenda', region_type: 'timetable', width: 600, height: 300 }] },
|
||||
content: { agenda: { value: '<p>{{index}}: {{title}} at {{start.time(HH:mm,"Europe/Amsterdam")}}</p>', timetable_group_id: 7, display_mode: 'all', max_items: 1 } }
|
||||
};
|
||||
const options = { timetableGroups: [{ id: 7, timezone: 'Europe/Amsterdam', entries: [
|
||||
{ id: 2, title: 'Later', start_datetime: '2099-01-01T12:00:00Z' },
|
||||
{ id: 1, title: 'Opening & welcome', start_datetime: '2099-01-01T10:00:00Z' }
|
||||
] }] };
|
||||
const payload = buildThumbnailPreviewPayload(slide, options);
|
||||
assert.match(payload.html, /1: Opening & welcome at 11:00/);
|
||||
assert.doesNotMatch(payload.html, /\{\{|Later/);
|
||||
slide.content.agenda.date_filter = 'after';
|
||||
slide.content.agenda.date_from = '2099-01-01T11:00:00Z';
|
||||
const filtered = buildThumbnailPreviewPayload(slide, options);
|
||||
assert.match(filtered.html, /1: Later at 13:00/);
|
||||
assert.doesNotMatch(filtered.html, /Opening|\{\{/);
|
||||
assert.doesNotMatch(buildThumbnailPreviewPayload(slide, {}).html, /\{\{|Opening|Later/);
|
||||
});
|
||||
|
||||
test('timetable display mode normalization accepts all and preserves existing modes', () => {
|
||||
for (const mode of ['upcoming', 'current', 'both', 'all']) {
|
||||
assert.equal(normalizeDisplayMode(mode.toUpperCase()), mode);
|
||||
}
|
||||
assert.equal(normalizeDisplayMode('invalid'), 'upcoming');
|
||||
});
|
||||
|
||||
test('buildSlidePayload normalizes timetable region fields', async () => {
|
||||
const pool = {
|
||||
@@ -65,6 +94,7 @@ test('timetable date settings persist, clear, default to all, and reject invalid
|
||||
return JSON.parse(payload.contentJson).timetable;
|
||||
}
|
||||
assert.equal((await save({})).date_filter, 'all');
|
||||
assert.equal((await save({ region_timetable_display_mode_47: 'all' })).display_mode, 'all');
|
||||
assert.equal((await save({}, existing)).date_from, '2099-08-20T10:00:00.000Z');
|
||||
assert.equal((await save({ region_date_from_47: '' }, existing)).date_from, '');
|
||||
const all = await save({ region_date_filter_47: 'all' }, existing);
|
||||
|
||||
Reference in new issue
Block a user