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, /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); });