Release v2.18.0: template default content
Publish Docker Image / build-and-push-existing-registry (./build/Dockerfile, web, pulse-signage-web) (push) Successful in 2m2s
Publish Docker Image / build-and-push-existing-registry (./build/Dockerfile.player, player, pulse-signage-player) (push) Successful in 31s

This commit is contained in:
lzstealth committed 2026-10-24 18:03:38 +01:00
1 parent 48e8f3eb4b
commit 24f1603b43
54 files changed
+1421 -151

No files matched your search

+20
View File
@@ -1,6 +1,7 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const path = require('node:path');
const fs = require('node:fs');
const Handlebars = require('handlebars');
const { registerHandlebars } = require('../src/web/handlebars');
@@ -69,4 +70,23 @@ test('save action buttons keep the new action wired for create pages', () => {
assert.match(html, /name="save_action" value="new">Save and New<\/button>/);
assert.match(html, /name="save_action" value="close">Save and Close<\/button>/);
});
test('playlist slide picker follows confirm then cancel button conventions', () => {
const source = fs.readFileSync(path.join(__dirname, '..', 'src', 'web', 'views', 'signage', 'playlists', 'form.hbs'), 'utf8');
assert.match(source, /<button type="button" class="btn btn-success" id="playlist-confirm-add-slides" disabled>Add selected slides<\/button>\s*<button type="button" class="btn btn-warning" data-bs-dismiss="modal">Cancel<\/button>/);
});
test('playlist schedule actions are separate spaced buttons', () => {
const source = fs.readFileSync(path.join(__dirname, '..', 'src', 'web', 'views', 'signage', 'playlists', 'slide-config.hbs'), 'utf8');
assert.match(source, /<div class="card-footer d-flex justify-content-end gap-2">\s*<button type="submit" class="btn btn-primary">Save schedule<\/button>\s*<button type="button" class="btn btn-warning" data-schedule-cancel/);
assert.doesNotMatch(source, /\bbtn-group\b/);
});
test('playlist schedule footer excludes the clearfix spacer from its flex actions', () => {
const source = fs.readFileSync(path.join(__dirname, '..', 'src', 'web', 'public', 'css', 'theme-custom.css'), 'utf8');
assert.match(source, /^\.slide-schedule-card > form > \.card-footer::after\s*\{\s*content: none;\s*\}/m);
});
+5 -1
View File
@@ -8,7 +8,7 @@ function payload(requests, auth) {
return buildApiGroupPayload(Object.assign({ name: 'Tiltify', auth_method: 'none', requests_json: JSON.stringify(requests) }, auth));
}
function createPool(existing, slides, groupEnabled = 1) {
function createPool(existing, slides, groupEnabled = 1, templateDefaults = []) {
const calls = [];
const connection = {
async beginTransaction() { calls.push(['begin']); },
@@ -20,6 +20,7 @@ function createPool(existing, slides, groupEnabled = 1) {
if (sql.includes('SELECT id, enabled FROM i_api_source_groups WHERE id')) return [[{ id: 5, enabled: groupEnabled }]];
if (sql.includes('SELECT id FROM i_api_source_groups WHERE name')) return [[]];
if (sql.includes('SELECT id FROM i_api_sources')) return [existing.map(id => ({ id }))];
if (sql.includes('SELECT default_content_json FROM c_template_regions')) return [templateDefaults.map(value => ({ default_content_json: value }))];
if (sql.includes('SELECT content_json')) return [(slides || []).map(value => ({ content_json: value }))];
if (sql.startsWith('INSERT INTO i_api_sources')) return [{ insertId: 99 }];
if (sql.startsWith('INSERT INTO i_api_source_groups')) return [{ insertId: 10 }];
@@ -64,6 +65,9 @@ test('request ownership, slide usage, and delete permission are enforced before
const used = createPool([5, 6], [JSON.stringify({ region: { type: 'api', source_id: 6 } })]);
await assert.rejects(saveApiGroup(used, 5, payload([request]), 1, true), /still used/);
assert.ok(!used.calls.some(([sql]) => sql.startsWith('UPDATE')));
const usedByTemplate = createPool([5, 6], [], 1, [JSON.stringify({ source_id: 6 })]);
await assert.rejects(saveApiGroup(usedByTemplate, 5, payload([request]), 1, true), /still used/);
assert.ok(!usedByTemplate.calls.some(([sql]) => sql.startsWith('UPDATE')));
await assert.rejects(saveApiGroup(createPool([5, 6]), 5, payload([request]), 1, false), /permission/);
const unused = createPool([5, 6]);
const result = await saveApiGroup(unused, 5, payload([request]), 1, true);
+37
View File
@@ -185,6 +185,43 @@ test('fonts route sorts by format and status', async () => {
}
});
test('template default font references mark fonts in use and block deletion', async () => {
const originals = {
loadFontLibrary: fontLibrary.loadFontLibrary,
deleteFontFile: fontLibrary.deleteFontFile
};
const { app, handlers } = createHandlers();
const font = { id: 'alpha.woff2', family: 'Alpha Sans', name: 'Alpha Sans', fileName: 'alpha.woff2', format: 'woff2', enabled: true };
let deleteCount = 0;
fontLibrary.loadFontLibrary = function () { return { fonts: [font], stylesheetHref: '' }; };
fontLibrary.deleteFontFile = async function () { deleteCount += 1; return font; };
registerFontRoutes(app, {
pool: {
async query(sql) {
if (sql.includes('FROM c_template_regions')) return [[{ match_count: 1 }]];
return [[{ match_count: 0 }]];
}
},
pages: { renderFontsPage(data) { return JSON.stringify(data); } },
upload: { single() { return function (_req, _res, next) { next(); }; } },
uploadDir: 'unused',
backgroundTaskQueue: null
});
try {
const response = { body: '', send(value) { this.body = value; return this; }, redirect(value) { this.location = value; return this; } };
const req = { query: {}, currentUser: { permissions: ['fonts.read', 'fonts.delete'] }, params: { id: font.id } };
await handlers['GET /settings/fonts'][1](req, response, error => { throw error; });
assert.equal(JSON.parse(response.body).fonts[0].inUse, true);
await handlers['POST /settings/fonts/:id/delete'].at(-1)(req, response, error => { throw error; });
assert.match(decodeURIComponent(response.location), /font is still used by slides or template defaults/);
assert.equal(deleteCount, 0);
} finally {
fontLibrary.loadFontLibrary = originals.loadFontLibrary;
fontLibrary.deleteFontFile = originals.deleteFontFile;
}
});
test('fonts page renders the upload card above the table card', () => {
const html = renderFontsPage({
fonts: [
+7 -4
View File
@@ -166,7 +166,7 @@ test('media library backfills existing image and video uploads', async () => {
}
});
test('media library counts slide and template references', async () => {
test('media library counts slide, template background, and region default references', async () => {
const queries = [];
const pool = {
async query(sql, params) {
@@ -174,16 +174,19 @@ test('media library counts slide and template references', async () => {
if (sql.includes('FROM c_slides')) {
return [[{ ref_count: 2 }]];
}
return [[{ ref_count: 1 }]];
if (sql.includes('FROM c_templates')) return [[{ ref_count: 1 }]];
return [[{ ref_count: 3 }]];
}
};
assert.equal(await countMediaAssetReferences(pool, '/media/uploads/shared.png'), 3);
assert.equal(queries.length, 2);
assert.equal(await countMediaAssetReferences(pool, '/media/uploads/shared.png'), 6);
assert.equal(queries.length, 3);
assert.equal(queries[0].params[0], '/media/uploads/shared.png');
assert.equal(queries[1].params[0], '/media/uploads/shared.png');
assert.equal(queries[2].params[0], '/media/uploads/shared.png');
assert.match(queries[0].sql, /LOCATE/);
assert.match(queries[0].sql, /CAST\(COALESCE\(content_json, ''\) AS CHAR\)/);
assert.match(queries[2].sql, /default_content_json/);
});
test('media library upload and delete routes audit successful asset changes', async () => {
+6 -2
View File
@@ -57,7 +57,7 @@ test('buildScreenPlaylist assembles slides, templates, and derived values', asyn
id: 101,
title: 'Intro',
template_id: 33,
content_json: '{"videoRegion":{"type":"video","duration_seconds":12.3456},"backupVideo":{"type":"video","duration_seconds":20.1111},"textRegion":{"type":"text","value":"Hello"},"qrRegion":{"type":"qr-code","value":"https://example.com"}}',
content_json: '{"videoRegion":{"type":"video","duration_seconds":12.3456},"backupVideo":{"type":"video","duration_seconds":20.1111},"textRegion":{"type":"text","value":"Hello"},"brandTagline":{"type":"text","value":"Old slide value"},"qrRegion":{"type":"qr-code","value":"https://example.com"}}',
modified_at: '2026-08-03T00:00:01.000Z',
position: 1,
duration_seconds: 9,
@@ -106,7 +106,10 @@ test('buildScreenPlaylist assembles slides, templates, and derived values', asyn
}
if (sql.includes('FROM c_template_regions')) {
assert.deepEqual(params, [[33, 33]]);
return [[{ id: 900, template_id: 33, region_key: 'textRegion', region_type: 'text', label: 'Text Region', font_family: 'Arial', x: 10, y: 20, width: 300, height: 200, z_index: 1, modified_at: '2026-08-03T00:00:03.000Z' }]];
return [[
{ id: 900, template_id: 33, region_key: 'textRegion', region_type: 'text', label: 'Text Region', font_family: 'Arial', x: 10, y: 20, width: 300, height: 200, z_index: 1, modified_at: '2026-08-03T00:00:03.000Z' },
{ id: 901, template_id: 33, region_key: 'brandTagline', region_type: 'text', label: 'Brand tagline', default_content: { type: 'text', value: 'Template-owned value' }, read_only: 1, x: 10, y: 20, width: 300, height: 200, z_index: 2 }
]];
}
throw new Error(`unexpected query: ${sql}`);
}
@@ -136,6 +139,7 @@ test('buildScreenPlaylist assembles slides, templates, and derived values', asyn
assert.equal(payload.slides[0].content.videoRegion.disable_audio, true);
assert.equal(payload.slides[0].content.backupVideo.disable_audio, true);
assert.equal(payload.slides[0].content.videoRegion.cache_bust, '2026-08-03T00:00:01.000Z');
assert.equal(payload.slides[0].content.brandTagline.value, 'Template-owned value');
assert.equal(payload.slides[1].disable_audio, true);
assert.equal(payload.slides[1].content.rtmpRegion.disable_audio, true);
assert.equal(typeof payload.slides[0].content.qrRegion.qr_preview, 'string');
+50
View File
@@ -105,6 +105,56 @@ test('playlist group colours differ, survive reorder, and clear on ungrouped sli
assert.equal(firstMember.style['--playlist-group-bg'], undefined);
});
test('schedule rules use the same native AdminLTE icons as slide regions', () => {
const root = path.join(__dirname, '..', 'src', 'web');
const sources = [
fs.readFileSync(path.join(root, 'views', 'signage', 'playlists', 'schedule-rule-card.hbs'), 'utf8'),
fs.readFileSync(path.join(root, 'public', 'js', 'playlists', 'playlist-schedule.js'), 'utf8'),
fs.readFileSync(path.join(root, 'public', 'js', 'slides', 'slide-form-regions.js'), 'utf8')
];
for (const source of sources) {
assert.match(source, /<i class="bi bi-dash-lg" aria-hidden="true" data-lte-icon="collapse"><\/i>/);
assert.match(source, /<i class="bi bi-plus-lg" aria-hidden="true" data-lte-icon="expand"><\/i>/);
assert.doesNotMatch(source, /data-schedule-rule-collapse-icon|getCollapseRuleIcon/);
}
});
test('schedule rule accessibility and drafts follow completed collapse and expand events', () => {
const source = fs.readFileSync(path.join(__dirname, '..', 'src', 'web', 'public', 'js', 'playlists', 'playlist-schedule.js'), 'utf8');
const syncSource = source.match(/function syncRuleCollapseButtonState\(card\) \{[\s\S]*?\n \}/)[0];
const handlerSource = source.match(/function handleRuleCollapseComplete\(event\) \{[\s\S]*?\n \}/)[0];
const listeners = {};
const attributes = {};
const label = { textContent: '' };
const button = {
setAttribute(name, value) { attributes[name] = value; },
querySelector() { return label; }
};
let collapsed = false;
let persisted = 0;
const card = {
classList: { contains() { return collapsed; } },
closest() { return card; }
};
const sandbox = {
getCollapseRuleButton() { return button; },
getRuleNumberNode() { return { textContent: '1' }; },
persistScheduleDraft() { persisted += 1; },
scheduleRuleList: { addEventListener(name, handler) { listeners[name] = handler; } }
};
const registrations = source.match(/ scheduleRuleList.addEventListener\('collapsed\.lte\.card-widget'[^\n]+\n scheduleRuleList.addEventListener\('expanded\.lte\.card-widget'[^\n]+/)[0];
require('node:vm').runInNewContext(syncSource + '\n' + handlerSource + '\n' + registrations, sandbox);
for (const isCollapsed of [true, false, true]) {
collapsed = isCollapsed;
listeners[isCollapsed ? 'collapsed.lte.card-widget' : 'expanded.lte.card-widget']({ target: card });
assert.equal(attributes['aria-expanded'], String(!isCollapsed));
assert.equal(attributes['aria-label'], (isCollapsed ? 'Expand' : 'Collapse') + ' rule 1');
assert.equal(label.textContent, attributes['aria-label']);
}
assert.equal(persisted, 3);
});
test('removing a playlist group removes all of its member rows', () => {
const source = fs.readFileSync(path.join(__dirname, '..', 'src', 'web', 'public', 'js', 'playlists', 'playlist-schedule.js'), 'utf8');
const functionSource = source.match(/function removePlaylistGroup\(header\) \{[\s\S]*?\n \}/)[0];
+72 -3
View File
@@ -330,7 +330,7 @@ test('slide advanced popup applies changes, cancels drafts and preserves the par
addEventListener(type, handler) { this.handlers[type] = handler; },
querySelectorAll() { return []; }, replaceChildren() {}, appendChild() {}, focus() {},
setAttribute() {}, removeAttribute() {},
style: { setProperty() {} }, classList: { add() {}, remove() {} } };
style: { setProperty() {}, removeProperty() {} }, classList: { add() {}, remove() {}, toggle() {} } };
}
const ids = Object.fromEntries([
'slide-animation-modal', 'adjust-slide-animations', 'slide-animation-region',
@@ -344,7 +344,17 @@ test('slide advanced popup applies changes, cancels drafts and preserves the par
const advanced = ids['animation-advanced-modal'];
const advancedApply = element();
const advancedReset = element();
const animationPreviewButtons = ['intro', 'outro', 'loop'].map(mode => Object.assign(element(), { dataset: { slideAnimationPreview: mode }, querySelector: () => element() }));
const readonlyPreviewRegion = element();
readonlyPreviewRegion.className = 'slide-preview-region';
readonlyPreviewRegion.dataset.regionKey = 'fixed_brand';
readonlyPreviewRegion.classList = {
add(...classNames) { readonlyPreviewRegion.className += ' ' + classNames.join(' '); },
remove(...classNames) { readonlyPreviewRegion.className = readonlyPreviewRegion.className.split(/\s+/).filter(name => !classNames.includes(name)).join(' '); }
};
advanced.querySelectorAll = () => fields;
ids['slide-animation-modal'].querySelectorAll = selector => selector === '[data-slide-animation-preview]' ? animationPreviewButtons : [];
ids['slide-animation-preview'].querySelectorAll = () => [readonlyPreviewRegion];
advanced.querySelector = selector => selector === '[data-animation-modal-apply]' ? advancedApply
: selector === '[data-animation-modal-reset]' ? advancedReset : element();
const hidden = { value: '{}' };
@@ -363,7 +373,7 @@ test('slide advanced popup applies changes, cancels drafts and preserves the par
};
const modalInstances = new Map();
const scrollBarCalls = [];
const context = { window: { innerWidth: 1200, pulseModal: {
const context = { window: { innerWidth: 1200, setTimeout() { return 1; }, clearTimeout() {}, pulseModal: {
show(modal) { this.getOrCreate(modal)._scrollBar.hide(); modal.shown = true; },
hide(modal) { this.getOrCreate(modal)._scrollBar.reset(); modal.shown = false; modal.handlers['hidden.bs.modal'](); },
getOrCreate(modal) {
@@ -387,12 +397,19 @@ test('slide advanced popup applies changes, cancels drafts and preserves the par
vm.runInNewContext(source.replace('export function', 'function'), context);
let applied;
context.createSlideAnimationEditor({ stage: { parentNode: { insertBefore() {} }, nextSibling: null },
getTemplate() { return { regions: [{ region_key: 'hero', animation_json: { intro: { preset: 'bounceIn' }, outro: { preset: 'fadeOut' } } }] }; },
getTemplate() { return { regions: [
{ region_key: 'hero', animation_json: { intro: { preset: 'bounceIn' }, outro: { preset: 'fadeOut' } } },
{ region_key: 'fixed_brand', read_only: true, animation_json: { loop: { preset: 'pulse', duration_ms: 200, iterations: 1 } } }
] }; },
getAnimationOverrides() { return {}; }, requestPreviewRender() {}, markEdited() {},
setAnimationOverrides(value) { applied = value; }
});
ids['slide-animation-region'].value = 'hero';
ids['adjust-slide-animations'].handlers.click();
assert.equal(animationPreviewButtons[2].disabled, false);
animationPreviewButtons[2].handlers.click();
assert.match(readonlyPreviewRegion.className, /animate__pulse/);
animationPreviewButtons[2].handlers.click();
const resetRegion = ids['slide-animation-reset-region'];
const resetAll = ids['slide-animation-reset-all'];
assert.equal(resetRegion.disabled, true);
@@ -438,6 +455,7 @@ test('slide advanced popup applies changes, cancels drafts and preserves the par
assert.equal(JSON.parse(applied.hero).intro.preset, 'none');
assert.equal(JSON.parse(applied.hero).outro.preset, 'fadeOut');
assert.equal(JSON.parse(applied.hero).outro.duration_ms, 1500);
assert.equal(applied.fixed_brand, undefined);
assert.deepEqual(scrollBarCalls, [
['hide', ids['slide-animation-modal']],
['reset', ids['slide-animation-modal']]
@@ -618,4 +636,55 @@ test('shared animation route is protected and updates template animations withou
assert.ok(calls.some(call => call[0] === 'commit'));
assert.ok(calls.some(call => call[0] === 'notify'));
assert.ok(calls.some(call => call[0] === 'thumbnail'));
});
test('editable regions inherit template defaults until a slide supplies an override', async function () {
const pool = {
async query(sql) {
if (sql.includes('FROM c_templates')) return [[{ id: 7, canvas_size_width: 1920, canvas_size_height: 1080 }]];
if (sql.includes('FROM c_template_regions')) return [[{
id: 11, template_id: 7, region_key: 'hero', region_type: 'text', default_content: { type: 'text', value: 'Template default' }, read_only: 0,
x: 10, y: 20, width: 300, height: 100
}]];
throw new Error('Unexpected query: ' + sql);
}
};
const initial = await buildSlidePayload(pool, { body: { title: 'Example', template_id: '7' } }, null);
assert.equal(JSON.parse(initial.contentJson).hero.value, 'Template default');
const overridden = await buildSlidePayload(pool, { body: { title: 'Example', template_id: '7', region_text_11: 'Slide override' } }, null);
assert.equal(JSON.parse(overridden.contentJson).hero.value, 'Slide override');
});
test('read-only regions are excluded from slide content and layout overrides', async function () {
const pool = {
async query(sql) {
if (sql.includes('FROM c_templates')) return [[{ id: 7, canvas_size_width: 1920, canvas_size_height: 1080 }]];
if (sql.includes('FROM c_template_regions')) return [[{
id: 11, template_id: 7, region_key: 'brand', region_type: 'text', default_content: { type: 'text', value: 'Fixed brand' }, read_only: 1,
x: 10, y: 20, width: 300, height: 100
}]];
throw new Error('Unexpected query: ' + sql);
}
};
const result = await buildSlidePayload(pool, { body: {
title: 'Example', template_id: '7', region_text_11: 'Client override',
region_layouts_json: JSON.stringify({ brand: {
x: 100, y: 100, width: 200, height: 80,
animation_json: { intro: { preset: 'bounceIn' } }
} })
} }, null);
assert.deepEqual(JSON.parse(result.contentJson), {});
});
test('thumbnail markup uses template defaults for read-only regions', function () {
const html = buildThumbnailPreviewMarkup({
template: {
regions: [{ region_key: 'brand', region_type: 'image', read_only: 1, default_content: { value: '/media/brand.png' }, x: 0, y: 0, width: 100, height: 100 }],
canvas_size_width: 100,
canvas_size_height: 100
},
content: { brand: { value: '/media/old-brand.png' } }
}, 'http://localhost');
assert.match(html, /\/media\/brand\.png/);
assert.doesNotMatch(html, /old-brand/);
});
+12
View File
@@ -203,4 +203,16 @@ test('a partial timetable schema does not fast-forward migration detection', asy
assert.ok(pendingMigrations.some(function (migration) { return migration.version === '2.6.16'; }));
assert.ok(pendingMigrations.some(function (migration) { return migration.version === '2.6.17'; }));
assert.ok(pendingMigrations.some(function (migration) { return migration.version === '2.6.18'; }));
});
test('template region defaults migration upgrades a 2.17.5 installation', async () => {
const pool = createPool([]);
const pending = await getPendingMigrations(pool, { currentVersion: '2.17.5' });
const regionContentMigration = pending.find(migration => migration.version === '2.18.0');
assert.ok(regionContentMigration);
await regionContentMigration.run(pool);
assert.ok(pool.queries.some(([sql]) => String(sql).includes('ALTER TABLE c_template_regions ADD COLUMN default_content_json JSON NULL')));
assert.ok(pool.queries.some(([sql]) => String(sql).includes('ALTER TABLE c_template_regions ADD COLUMN read_only TINYINT(1) NOT NULL DEFAULT 0')));
});
+31
View File
@@ -5,6 +5,7 @@ const vm = require('node:vm');
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 slideFormTemplateSource = fs.readFileSync(require.resolve('../src/web/views/signage/slides/form.hbs'), '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');
@@ -79,6 +80,12 @@ 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'));
assert.ok(mediaPickerSource.includes("document.querySelectorAll('.modal.show')"));
assert.ok(mediaPickerSource.includes('nestedParentFocusTrap.deactivate()'));
assert.ok(mediaPickerSource.includes('pickerZIndex - 5'));
const pickerModal = fs.readFileSync(require.resolve('../src/web/views/shared/media-picker-modal.hbs'), 'utf8');
assert.ok(pickerModal.indexOf('data-media-picker-confirm') < pickerModal.indexOf('data-media-picker-cancel'));
assert.match(pickerModal, /class="btn btn-warning" data-media-picker-cancel>Cancel/);
});
test('slide thumbnail previews treat image-only text as visible content', () => {
@@ -146,4 +153,28 @@ test('HTML editor shares bounded size controls without changing its content', as
assert.equal(source.value, '<div>Keep this HTML</div>');
assert.match(slideHtmlRegionSource, /style="resize:none;"/);
assert.match(slideHtmlRegionSource, /sandboxed iframe preview\.<\/div>'\s*\+\s*'<div class="btn-group/);
});
test('slide template chooser searches and previews templates while preserving the submitted select', () => {
assert.equal((slideFormTemplateSource.match(/modalId="template-picker-modal"/g) || []).length, 1);
assert.match(slideFormTemplateSource, /name="template_id" id="template-select" class="visually-hidden"/);
assert.match(slideFormTemplateSource, /id="template-picker-open"/);
assert.match(slideFormTemplateSource, /template-picker-current"><strong id="template-picker-selected-name"/);
assert.doesNotMatch(slideFormTemplateSource, /id="template-picker-selected-size"/);
assert.match(slideFormTemplateSource, /modalId="template-picker-modal"/);
assert.match(slideFormTemplateSource, /id="template-picker-search"/);
assert.match(slideFormTemplateSource, /id="template-picker-canvas-size"/);
assert.match(slideFormTemplateSource, /All canvas sizes/);
assert.match(slideFormTemplateSource, /id="template-picker-grid"/);
assert.doesNotMatch(slideFormTemplateSource, /id="template-picker-none"|>No template<\/button>/);
assert.ok(slideFormSource.includes('function createTemplatePickerCard(template)'));
assert.ok(slideFormSource.includes('function populateTemplateCanvasSizes()'));
assert.doesNotMatch(slideFormSource, /createTemplatePickerCard\(null\)/);
assert.ok(slideFormSource.includes('function renderTemplatePicker()'));
assert.ok(slideFormSource.includes("templateSelect.dispatchEvent(new Event('change', { bubbles: true }))"));
});
test('popup preview is disabled and guarded until a template is selected', () => {
assert.match(slideFormSource, /if \(openPreviewPopupButton\) openPreviewPopupButton\.disabled = !template;/);
assert.match(slideFormSource, /function openPreviewPopup\(\) \{\s*if \(!getSelectedTemplate\(\)\)\s*\{\s*return;/);
});
@@ -0,0 +1,41 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const { getDataSourceUsageMaps: getRssUsage } = require('../src/web/routes/data-sources/rss-feeds/routes');
const { getDataSourceUsageMaps: getTimetableUsage } = require('../src/web/routes/data-sources/timetables/routes');
const { getWeatherLocationUsageIds } = require('../src/web/routes/data-sources/weather');
const { isWeatherIconSetUsed } = require('../src/web/routes/settings/weather-icons');
function createPool(slideContent, templateContent) {
return {
async query(sql) {
if (sql.includes('FROM c_slides')) return [[{ content_json: JSON.stringify(slideContent) }]];
return [[{ default_content_json: JSON.stringify(templateContent) }]];
}
};
}
const common = {
parseJsonSafe(value) {
if (typeof value === 'string') return JSON.parse(value);
return value;
}
};
test('data-source and weather-icon usage includes template default content', async function () {
const pool = createPool(
{ slide: { feed_id: 1, timetable_group_id: 3, weather_location_id: 5, weather_icon_set_id: 'classic' } },
{ feed_id: 2, timetable_group_id: 4, weather_location_id: 6, weather_icon_set_id: 'template-icons' }
);
const rssUsage = await getRssUsage(pool, common);
const timetableUsage = await getTimetableUsage(pool, common);
const weatherUsage = await getWeatherLocationUsageIds(pool, common);
assert.deepEqual(Array.from(rssUsage).sort((a, b) => a - b), [1, 2]);
assert.deepEqual(Array.from(timetableUsage).sort((a, b) => a - b), [3, 4]);
assert.deepEqual(Array.from(weatherUsage).sort((a, b) => a - b), [5, 6]);
assert.equal(await isWeatherIconSetUsed(pool, 'classic'), true);
assert.equal(await isWeatherIconSetUsed(pool, 'template-icons'), true);
assert.equal(await isWeatherIconSetUsed(pool, 'unused'), false);
});
+79
View File
@@ -29,6 +29,8 @@ test('extractTemplateRegions normalizes JSON regions and filters invalid rows',
region_name: 'Hero',
region_type: 'TEXT',
lock_ratio: '16 : 9',
default_content: '{"type":"text","value":"Template headline"}',
read_only: 'true',
animation_json: '{"intro":{"preset":"fadeIn"},"out":{"preset":"bounceOut"}}',
x: '10',
y: '20',
@@ -48,6 +50,8 @@ test('extractTemplateRegions normalizes JSON regions and filters invalid rows',
region_type: 'TEXT',
label: 'Hero',
lock_ratio: '16:9',
default_content: { type: 'text', value: 'Template headline' },
read_only: true,
animation_json: {
intro: { preset: 'fadeIn' },
outro: { preset: 'bounceOut' },
@@ -112,6 +116,8 @@ test('buildTemplatePayload resolves canvas size and rejects duplicate region nam
region_type: 'text',
label: 'Header',
lock_ratio: '4:3',
default_content: {},
read_only: false,
animation_json: {
intro: { preset: 'none' },
outro: { preset: 'none' },
@@ -198,6 +204,26 @@ test('template create page renders the background remove control', () => {
assert.match(html, /data-media-picker-hidden="existing-background-image-path"/);
assert.match(html, /data-media-picker-file-input="background-image"/);
assert.match(html, /id="background-image"/);
assert.match(html, /data-region-default-content/);
assert.match(html, /data-region-default-content-button/);
assert.match(html, /data-region-read-only/);
assert.match(html, /form-check form-switch d-inline-flex align-items-center gap-2 mb-0 ps-0 text-nowrap flex-shrink-0/);
assert.match(html, /form-check-input m-0 flex-shrink-0/);
assert.match(html, /<span class="form-check-label">Template-only<\/span>/);
assert.match(html, /title="Hidden in the slide editor\. Edit this content on the template\."/);
assert.match(html, /title="Edit template default content"><i class="bi bi-pencil-square me-1" aria-hidden="true"><\/i>Edit default/);
assert.match(html, /id="region-default-content-modal"/);
assert.match(html, /modal-xl modal-dialog-centered modal-dialog-scrollable template-default-content-dialog/);
assert.match(html, /id="region-default-content-editor-host"/);
assert.match(html, /id="region-default-content-preview-canvas"/);
assert.match(html, /d-flex align-items-center gap-3 border-top/);
assert.ok(html.indexOf('id="region-default-content-editor-host"') < html.indexOf('id="region-default-content-preview-stage"'));
assert.ok(html.indexOf('id="region-default-content-save"') < html.lastIndexOf('>Cancel</button>'));
assert.match(html, /class="btn btn-warning" data-bs-dismiss="modal">Cancel<\/button>/);
assert.match(html, /template-default-content-editor\.js/);
assert.match(html, /id="template-region-editor-data"/);
assert.match(html, /vendor\/tinymce\/tinymce\.min\.js/);
assert.ok(html.indexOf('name="region_width[]"') < html.indexOf('data-region-default-content-button'));
assert.match(html, /placeholder="type_1"/);
assert.match(html, /name="region_z\[\]" min="1" step="1" value="1" required/);
assert.match(html, /Save and Close/);
@@ -329,6 +355,8 @@ test('template animation durations use percent in the advanced modal', () => {
assert.match(modal, /signage\/templates\/animation-advanced-fields/);
assert.match(modal, /data-animation-modal-title>Advanced animations:/);
assert.ok(modal.indexOf('data-animation-modal-apply') < modal.lastIndexOf('>Cancel</button>'));
assert.match(modal, /class="btn btn-warning" data-bs-dismiss="modal">Cancel<\/button>/);
assert.ok(script.includes("title.textContent = 'Advanced animations: ' + (label ? label.textContent : 'Region');"));
assert.doesNotMatch(modal, /modalBackdropStatic=true/);
assert.match(advancedFields, /Duration %/);
@@ -505,4 +533,55 @@ test('template snap toggle places its switch after the label', () => {
const css = fs.readFileSync(require.resolve('../src/web/public/css/theme-custom.css'), 'utf8');
assert.match(template, /class="form-check form-switch template-snap-control[^>]*"><label for="template-layout-snap"[^>]*>Snap to guides<\/label><input type="checkbox" id="template-layout-snap"/);
assert.match(css, /\.template-snap-control \.form-check-input\s*\{[^}]*order:\s*2/);
});
test('template default content adapter reuses slide region helpers and TinyMCE controller', () => {
const source = fs.readFileSync(require.resolve('../src/web/public/js/templates/template-default-content-editor.js'), 'utf8');
assert.match(source, /createSlideFormRegionHelpers/);
assert.match(source, /getRegionEditorCardHtml/);
assert.match(source, /getPreviewRegionContent/);
assert.match(source, /template-default-content-preview-label/);
assert.match(source, /createSlideFormEditorController/);
});
test('template designer restores region default content and read-only state from saved data', () => {
const source = fs.readFileSync(require.resolve('../src/web/public/js/templates/template-designer.js'), 'utf8');
const match = source.match(/ function populateRegionCard\([^]*?\n }/);
assert.ok(match);
const fields = {
'[data-region-chip]': {},
'[data-region-title]': {},
'[name="region_name[]"]': {},
'[name="region_type[]"]': {},
'[name="region_key[]"]': {},
'[name="region_label[]"]': {},
'[data-region-default-content]': {},
'[data-region-read-only]': {},
'[name="region_lock_ratio[]"]': {},
'[name="region_x[]"]': {},
'[name="region_y[]"]': {},
'[name="region_z[]"]': {},
'[name="region_width[]"]': {},
'[name="region_height[]"]': {}
};
const context = vm.createContext({
getRegionChipLabel: () => 'HTML',
normalizeLockRatio: value => value || '',
writeAnimationConfig() {},
valueOrDefault: (value, fallback) => value === undefined || value === null || value === '' ? fallback : value,
updateRegionLockRatioState() {},
updateRegionLockBadge() {}
});
vm.runInContext(match[0], context);
context.populateRegionCard({ querySelector: selector => fields[selector] || null }, {
region_key: 'brand_edge', region_type: 'html', label: 'brand_edge',
default_content: { type: 'html', value: '<div>Template-owned</div>' },
read_only: 1, x: 0, y: 0, width: 1920, height: 7, z_index: 1
});
assert.deepEqual(JSON.parse(fields['[data-region-default-content]'].value), {
type: 'html', value: '<div>Template-owned</div>'
});
assert.equal(fields['[data-region-read-only]'].checked, true);
});
+4 -2
View File
@@ -22,12 +22,14 @@ test('upload reference collection includes WYSIWYG images across slide regions',
clock: { value: '<p><img src="http://localhost:8080/media/uploads/clock-image.png"></p>' },
html: { value: '<p><img src="/media/uploads/html-image.png"></p>' },
rss: { value: '<p><img src="/media/uploads/rss-image.png"></p>' }
})
}),
regions: [{ default_content: { value: '<p><img src="/media/uploads/template-image.png"></p>' } }]
})).sort(),
[
'/media/uploads/clock-image.png',
'/media/uploads/html-image.png',
'/media/uploads/rss-image.png'
'/media/uploads/rss-image.png',
'/media/uploads/template-image.png'
].sort()
);
});