Release v2.18.0: template default content
This commit is contained in:
1 parent
48e8f3eb4b
commit
24f1603b43
54 files changed
+1421
-151
No files matched your search
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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/);
|
||||
});
|
||||
@@ -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')));
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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()
|
||||
);
|
||||
});
|
||||
|
||||
Reference in new issue
Block a user