Files
pulse-signage/test/templates.test.js
T
2026-09-13 23:47:11 +01:00

326 lines
13 KiB
JavaScript

const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
require('../src/common');
const {
buildTemplatePayload,
extractTemplateRegions,
normalizeBackgroundGradient
} = require('../src/data/templates');
const renderTemplateAddPage = require('../src/web/routes/signage/templates/add');
const renderTemplateEditPage = require('../src/web/routes/signage/templates/edit');
const { buildDuplicateTemplateName, buildDuplicateTemplate } = require('../src/web/routes/signage/templates/duplicate');
const { buildThumbnailPreviewPayload } = require('../src/web/lib/media/slide-thumbnail-preview');
function readCssDeclarations(css, selector) {
const escapedSelector = selector.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const match = css.match(new RegExp(escapedSelector + '\\s*\\{([^}]*)\\}'));
assert.ok(match, `CSS rule not found: ${selector}`);
return Object.fromEntries(match[1].split(';').map((declaration) => declaration.trim().split(':')).filter(([property, value]) => property && value).map(([property, ...value]) => [property.trim(), value.join(':').trim()]));
}
test('extractTemplateRegions normalizes JSON regions and filters invalid rows', () => {
const regions = extractTemplateRegions({
regions_json: JSON.stringify([
{
region_name: 'Hero',
region_type: 'TEXT',
lock_ratio: '16 : 9',
animation_json: '{"intro":{"preset":"fadeIn"},"out":{"preset":"bounceOut"}}',
x: '10',
y: '20',
width: '320',
height: '180',
z_index: '2'
},
{
region_name: ' ',
region_type: 'image'
}
])
});
assert.deepEqual(regions, [{
region_key: 'Hero',
region_type: 'TEXT',
label: 'Hero',
lock_ratio: '16:9',
animation_json: {
intro: { preset: 'fadeIn' },
outro: { preset: 'bounceOut' },
loop: { preset: 'none' }
},
x: 10,
y: 20,
width: 320,
height: 180,
z_index: 2
}]);
});
test('buildTemplatePayload resolves canvas size and rejects duplicate region names', async () => {
const pool = {
async query(sql, params) {
if (sql.includes('FROM c_canvas_sizes WHERE id = ?')) {
assert.deepEqual(params, [4]);
return [[{ id: 4, name: 'HD', width: 1280, height: 720 }]];
}
throw new Error(`unexpected query: ${sql}`);
}
};
const payload = await buildTemplatePayload(pool, {
body: {
name: ' Main Template ',
canvas_size_id: '4',
background_color: 'not-a-color',
regions_json: JSON.stringify([
{ region_name: 'Header', region_type: 'text', lock_ratio: '4:3' }
])
},
files: []
}, null);
assert.deepEqual(payload, {
name: 'Main Template',
canvasSizeId: 4,
canvasSizeWidth: 1280,
canvasSizeHeight: 720,
backgroundImagePath: null,
backgroundColor: '#111111',
backgroundGradient: null,
regions: [{
region_key: 'Header',
region_type: 'text',
label: 'Header',
lock_ratio: '4:3',
animation_json: {
intro: { preset: 'none' },
outro: { preset: 'none' },
loop: { preset: 'none' }
},
x: 0,
y: 0,
width: 100,
height: 100,
z_index: 0
}]
});
await assert.rejects(
() => buildTemplatePayload(pool, {
body: {
name: 'Dupes',
regions_json: JSON.stringify([
{ region_name: 'One' },
{ region_name: 'one' }
])
},
files: []
}, null),
(error) => error && error.statusCode === 400 && error.message === 'Region names must be unique on this template.'
);
});
test('buildDuplicateTemplate copies template fields and strips row identity', () => {
const duplicate = buildDuplicateTemplate({
id: 9,
name: 'Main Template',
canvas_size_id: 4,
canvas_size_width: 1280,
canvas_size_height: 720,
background_color: '#112233',
background_image_path: '/media/uploads/bg.png',
regions: [{ id: 22, template_id: 9, region_key: 'Hero', label: 'Hero', region_type: 'text' }]
}, buildDuplicateTemplateName('Main Template'));
assert.equal(duplicate.id, null);
assert.equal(duplicate.name, 'Copy of Main Template');
assert.equal(duplicate.canvas_size_id, 4);
assert.equal(duplicate.background_color, '#112233');
assert.equal(duplicate.background_image_path, '/media/uploads/bg.png');
assert.deepEqual(duplicate.region_usage, []);
assert.equal(duplicate.regions.length, 1);
assert.equal(duplicate.regions[0].id, undefined);
assert.equal(duplicate.regions[0].template_id, undefined);
assert.equal(duplicate.regions[0].region_key, 'Hero');
});
test('template create page renders the background remove control', () => {
const html = renderTemplateAddPage(null, '', [
{ id: 1, name: 'HD', width: 1280, height: 720 }
], { permissionKeys: ['templates.create'] });
assert.match(html, /id="remove-background-image"/);
assert.match(html, /name="remove_background_image"/);
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, /placeholder="type_1"/);
assert.match(html, /Save and Close/);
assert.match(html, /Save and New/);
assert.match(html, /data-confirm-unsaved="You've made changes\. Are you sure you want to leave this page\?"/);
});
test('template edit page confirms deletion', () => {
const html = renderTemplateEditPage({
id: 9,
name: 'Main Template',
canvas_size_id: 1,
canvas_size_width: 1280,
canvas_size_height: 720,
background_image_path: '',
background_color: '#111111',
inUse: false,
regions: [],
region_usage: []
}, {
canvasSizes: [{ id: 1, name: 'HD', width: 1280, height: 720 }]
}, '', { permissionKeys: ['templates.delete'] });
assert.match(html, /data-delete-action-url="\/templates\/9\/delete"/);
assert.match(html, /data-confirm-message="Delete this template\?"/);
assert.match(html, /data-confirm-unsaved="You've made changes\. Are you sure you want to leave this page\?"/);
assert.doesNotMatch(html, /id="delete-template-form"/);
});
test('template preview preserves the live canvas aspect and visual settings', () => {
const template = {
id: 12,
name: 'Portrait-ish display',
canvas_size_id: 3,
canvas_size_width: 1440,
canvas_size_height: 900,
background_image_path: '/media/uploads/background.png',
background_color: '#123456',
background_gradient: JSON.stringify({
type: 'linear',
angle: 135,
stops: [
{ color: '#123456', position: 0 },
{ color: '#abcdef', position: 100 }
]
}),
regions: [{
region_key: 'hero',
region_type: 'text',
label: 'Hero',
x: 144,
y: 90,
width: 720,
height: 450,
z_index: 2
}],
region_usage: []
};
const editorHtml = renderTemplateEditPage(template, {
canvasSizes: [{ id: 3, name: 'Custom', width: 1440, height: 900 }]
}, '', { permissionKeys: ['templates.update'] });
const livePreview = buildThumbnailPreviewPayload({
template,
content: { hero: { type: 'text', value: 'Preview content' } }
}, { baseUrl: 'https://signage.test' });
assert.match(editorHtml, /style="aspect-ratio: 1440 \/ 900;"/);
assert.deepEqual(
{ width: 1440, height: 900 },
{ width: livePreview.canvasWidth, height: livePreview.canvasHeight }
);
assert.equal(livePreview.backgroundColor, template.background_color);
assert.equal(livePreview.backgroundGradient, template.background_gradient);
assert.equal(livePreview.backgroundImagePath, template.background_image_path);
assert.match(livePreview.html, /left:10%;top:10%;width:50%;height:50%;z-index:2;/);
});
test('template preview and live output keep matching visual CSS contracts', () => {
const previewCss = fs.readFileSync(require.resolve('../src/web/public/css/theme-custom.css'), 'utf8');
const playerCss = fs.readFileSync(require.resolve('../src/player/public/css/player.css'), 'utf8');
const layoutProperties = ['position', 'overflow', 'box-sizing'];
const mediaProperties = ['width', 'height', 'object-fit', 'display'];
assert.deepEqual(
Object.fromEntries(layoutProperties.map((property) => [property, readCssDeclarations(previewCss, '.slide-preview-region')[property]])),
Object.fromEntries(layoutProperties.map((property) => [property, readCssDeclarations(playerCss, '.template-region')[property]]))
);
assert.deepEqual(
Object.fromEntries(mediaProperties.map((property) => [property, readCssDeclarations(previewCss, '.slide-preview-image')[property]])),
Object.fromEntries(mediaProperties.map((property) => [property, readCssDeclarations(playerCss, '.template-region.image img')[property]]))
);
});
test('template list template includes duplicate action', () => {
const template = fs.readFileSync(require.resolve('../src/web/views/signage/templates/list.hbs'), 'utf8');
assert.match(template, /\/templates\/\{\{id\}\}\/duplicate/);
assert.match(template, />Dupe<\/a>/);
});
test('template animation controls mark the form dirty', () => {
const script = fs.readFileSync(require.resolve('../src/web/public/js/templates/template-designer.js'), 'utf8');
assert.match(script, /animationPresetInputs[\s\S]*markTemplateFormDirty\(\);/);
assert.ok(script.includes("animationAdvancedModal.addEventListener('input'"));
assert.ok(script.includes('clampAnimationModalField(event.target);'));
assert.ok(script.includes("animationAdvancedModal.addEventListener('change'"));
assert.ok(script.includes("animationModalApplyButton.addEventListener('click', function () {"));
assert.ok(script.includes("saveAnimationModal();"));
assert.ok(script.includes("animationModalResetButton.addEventListener('click', function () {"));
assert.ok(script.includes("writeAnimationConfig(activeAnimationCard, normalizeAnimationConfig('{}'));"));
assert.ok(script.includes("markTemplateFormDirty();"));
});
test('template animation durations use percent in the advanced modal', () => {
const modal = fs.readFileSync(require.resolve('../src/web/views/signage/templates/animation-advanced-modal.hbs'), 'utf8');
const script = fs.readFileSync(require.resolve('../src/web/public/js/templates/template-designer.js'), 'utf8');
const utilsScript = fs.readFileSync(require.resolve('../src/web/public/js/templates/template-designer-utils.js'), 'utf8');
assert.match(modal, /Duration %/);
assert.match(modal, /data-animation-modal-field="duration_ms"/);
assert.match(modal, /max="200"/);
assert.match(modal, /data-animation-modal-field="iterations"/);
assert.match(modal, /max="999"/);
assert.ok(script.includes('function getAnimationDurationPercent(durationMs)'));
assert.ok(script.includes('function getAnimationDurationMs(durationPercent)'));
assert.ok(script.includes('durationInput.value = String(getAnimationDurationPercent(step.duration_ms));'));
assert.ok(script.includes("nextStep.duration_ms = getAnimationDurationMs(durationInput ? durationInput.value : '');"));
assert.ok(script.includes('MAX_ANIMATION_REPEAT_COUNT = 999'));
assert.match(script, /Math\.min\(MAX_ANIMATION_REPEAT_COUNT, Math\.max\(1, readNumberField\(iterationsInput \? iterationsInput\.value : ''\) \|\| 1\)\)/);
assert.match(utilsScript, /MAX_ANIMATION_REPEAT_COUNT = 999/);
assert.match(utilsScript, /clamp\(Math\.round\(Number\(value\.iterations\)\), 1, MAX_ANIMATION_REPEAT_COUNT\)/);
});
test('template designer generates default region names per type', () => {
const script = fs.readFileSync(require.resolve('../src/web/public/js/templates/template-designer.js'), 'utf8');
assert.ok(script.includes('function getNextDefaultRegionName(regionType)'));
assert.ok(script.includes('var name = getNextDefaultRegionName(type);'));
assert.ok(script.includes("var name = getNextDefaultRegionName('text');"));
assert.ok(script.includes('Math.max(count, suffix)'));
});
test('template designer warns when invalid region names block save', () => {
const script = fs.readFileSync(require.resolve('../src/web/public/js/templates/template-designer.js'), 'utf8');
assert.ok(script.includes("templateForm.addEventListener('invalid'"));
assert.ok(script.includes('function notifyRegionNameValidationError(message)'));
assert.ok(script.includes('regionNameValidationToastShown = false;'));
});
test('normalizeBackgroundGradient accepts linear colors and clamps the angle', () => {
assert.equal(normalizeBackgroundGradient(JSON.stringify({
type: 'radial',
colors: ['#123456', '#abcdef', '#fedcba'],
angle: 400
})), JSON.stringify({
type: 'linear',
stops: [
{ color: '#123456', position: 0 },
{ color: '#abcdef', position: 50 },
{ color: '#fedcba', position: 100 }
],
angle: 360
}));
assert.equal(normalizeBackgroundGradient('{}'), null);
});