const test = require('node:test'); const assert = require('node:assert/strict'); const fs = require('node:fs'); const vm = require('node:vm'); 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('extractTemplateRegions keeps JSON and form z-index values at positive integers', () => { const values = [-5, 0, '', undefined, 'invalid', 'Infinity', 1, '4', 2.6]; const expected = [1, 1, 1, 1, 1, 1, 1, 4, 3]; const jsonRegions = extractTemplateRegions({ regions_json: JSON.stringify(values.map((value, index) => ({ region_name: `Region ${index}`, z_index: value }))) }); const formRegions = extractTemplateRegions({ 'region_key[]': values.map((value, index) => `Region ${index}`), 'region_z[]': values }); assert.deepEqual(jsonRegions.map((region) => region.z_index), expected); assert.deepEqual(formRegions.map((region) => region.z_index), expected); }); 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: 1 }] }); 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('disabled canvas sizes cannot be assigned to new templates but existing assignments can be retained', async () => { const pool = { async query() { return [[{ id: 4, name: 'Legacy', width: 1280, height: 720, enabled: 0 }]]; } }; const req = { body: { name: 'Legacy template', canvas_size_id: '4', regions_json: JSON.stringify([{ region_name: 'Body', region_type: 'text' }]) }, files: [] }; await assert.rejects( () => buildTemplatePayload(pool, req, null), (error) => error && error.statusCode === 400 && error.message === 'Canvas size is disabled.' ); const payload = await buildTemplatePayload(pool, req, { canvas_size_id: 4 }); assert.equal(payload.canvasSizeId, 4); }); 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, /name="region_z\[\]" min="1" step="1" value="1" required/); 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.equal(readCssDeclarations(playerCss, '.template-stage').isolation, 'isolate'); 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;')); }); 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 stacking stays above the background while typing and clamps on commit', () => { const harness = createRegionCardHarness(); for (const value of ['-5', '0', '']) { harness.edit('z', value); assert.equal(harness.previews.at(-1).z_index, 1); harness.edit('z', value, 'change'); assert.equal(harness.value('z'), 1); } harness.edit('z', '4', 'change'); assert.equal(harness.value('z'), 4); assert.equal(harness.previews.at(-1).z_index, 4); }); 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', 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); });