690 lines
29 KiB
JavaScript
690 lines
29 KiB
JavaScript
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-thumbnails');
|
|
|
|
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',
|
|
default_content: '{"type":"text","value":"Template headline"}',
|
|
read_only: 'true',
|
|
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',
|
|
default_content: { type: 'text', value: 'Template headline' },
|
|
read_only: true,
|
|
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',
|
|
default_content: {},
|
|
read_only: false,
|
|
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, /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/);
|
|
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 and slide region outlines do not impose a twelve-pixel minimum', () => {
|
|
const css = fs.readFileSync(require.resolve('../src/web/public/css/theme-custom.css'), 'utf8');
|
|
const rule = css.match(/^\.designer-rect\s*\{([^}]+)\}/m);
|
|
assert.ok(rule);
|
|
assert.match(rule[1], /min-width:\s*0\s*;/);
|
|
assert.match(rule[1], /min-height:\s*0\s*;/);
|
|
});
|
|
|
|
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('window.pulseRegionAnimationControls.clampAdvancedField(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 advancedFields = fs.readFileSync(require.resolve('../src/web/views/signage/templates/animation-advanced-fields.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, /signage\/templates\/animation-advanced-fields/);
|
|
assert.match(modal, /data-animation-modal-title>Advanced animations:/);
|
|
assert.match(advancedFields, /Finish before transition \(ms\).*data-animation-modal-step="outro"/);
|
|
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 %/);
|
|
assert.match(advancedFields, /data-animation-modal-field="duration_ms"/);
|
|
assert.match(advancedFields, /max="200"/);
|
|
assert.match(advancedFields, /data-animation-modal-field="iterations"/);
|
|
assert.match(advancedFields, /max="999"/);
|
|
assert.ok(script.includes('window.pulseRegionAnimationControls.populateAdvanced(animationAdvancedModal, config)'));
|
|
assert.ok(script.includes('window.pulseRegionAnimationControls.readAdvanced(animationAdvancedModal, getAnimationConfig(activeAnimationCard))'));
|
|
assert.ok(script.includes('MAX_ANIMATION_REPEAT_COUNT = 999'));
|
|
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('region name sorting is case-insensitive, stable, and used by the template editor', () => {
|
|
const context = vm.createContext({ window: {} });
|
|
const utilsScript = fs.readFileSync(require.resolve('../src/web/public/js/templates/template-designer-utils.js'), 'utf8');
|
|
vm.runInContext(utilsScript, context);
|
|
const items = [
|
|
{ name: 'Zulu' },
|
|
{ name: 'alpha' },
|
|
{ name: 'Alpha' },
|
|
{ name: 'bravo' }
|
|
];
|
|
const sorted = Array.from(context.window.templateDesignerUtils.sortByName(items, item => item.name));
|
|
|
|
assert.deepEqual(sorted, [items[1], items[2], items[3], items[0]]);
|
|
const designerSource = fs.readFileSync(require.resolve('../src/web/public/js/templates/template-designer.js'), 'utf8');
|
|
assert.match(designerSource, /utils\.sortByName\(cards, getRegionName\)/);
|
|
});
|
|
|
|
test('template region selector sorts options and preserves the selected card index', () => {
|
|
const designerSource = fs.readFileSync(require.resolve('../src/web/public/js/templates/template-designer.js'), 'utf8');
|
|
const match = designerSource.match(/ function renderRegionSidebar\(\) \{[^]*?\n \}/);
|
|
assert.ok(match);
|
|
|
|
const cards = [
|
|
{ name: 'Zulu', hidden: false },
|
|
{ name: 'alpha', hidden: false },
|
|
{ name: 'Bravo', hidden: false }
|
|
];
|
|
const regionSelect = {
|
|
options: [],
|
|
disabled: false,
|
|
value: '',
|
|
set innerHTML(_value) { this.options = []; },
|
|
appendChild(option) { this.options.push(option); }
|
|
};
|
|
const regionList = { innerHTML: '' };
|
|
const context = vm.createContext({
|
|
cards,
|
|
regionSelect,
|
|
regionList,
|
|
selectedIndex: -1,
|
|
utils: { sortByName(items, getName) { return Array.from(items).sort((left, right) => getName(left).localeCompare(getName(right), undefined, { sensitivity: 'base' })); } },
|
|
getCards: () => cards,
|
|
getRegionName: card => card.name,
|
|
document: { createElement: () => ({}) },
|
|
updateCanvasSizeLock() {},
|
|
validateRegionNames() {},
|
|
updatePreviewCardVisibility() {}
|
|
});
|
|
|
|
vm.runInContext(match[0], context);
|
|
context.renderRegionSidebar();
|
|
assert.deepEqual(regionSelect.options.map(option => [option.textContent, option.value]), [
|
|
['alpha', '1'],
|
|
['Bravo', '2'],
|
|
['Zulu', '0']
|
|
]);
|
|
assert.equal(regionSelect.value, '1');
|
|
assert.deepEqual(cards.map(card => card.hidden), [true, false, true]);
|
|
|
|
context.selectedIndex = 0;
|
|
context.renderRegionSidebar();
|
|
assert.equal(regionSelect.value, '0');
|
|
assert.deepEqual(cards.map(card => card.hidden), [false, true, true]);
|
|
});
|
|
|
|
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), 1);
|
|
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);
|
|
});
|
|
|
|
test('template snap toggle places its switch after the label', () => {
|
|
const template = fs.readFileSync(require.resolve('../src/web/views/signage/templates/form.hbs'), 'utf8');
|
|
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);
|
|
});
|
|
|
|
test('template animation preview applies configured delays to the CSS animation property', () => {
|
|
const script = fs.readFileSync(require.resolve('../src/web/public/js/templates/template-designer.js'), 'utf8');
|
|
assert.doesNotMatch(script, /var stagger = 90/);
|
|
assert.match(script, /var delay = mode === 'out' \? 0 : Math\.max\(0, Number\(step && step\.delay_ms\) \|\| 0\);/);
|
|
const match = script.match(/ function applyAnimateCssPreview\([^]*?\n }/);
|
|
assert.ok(match);
|
|
const context = vm.createContext({});
|
|
vm.runInContext(match[0] + '\nthis.applyAnimateCssPreview = applyAnimateCssPreview;', context);
|
|
const values = {};
|
|
const rect = {
|
|
classList: { add() {}, remove() {} },
|
|
style: {
|
|
setProperty(name, value) { values[name] = value; },
|
|
removeProperty(name) { delete values[name]; }
|
|
}
|
|
};
|
|
|
|
context.applyAnimateCssPreview(rect, 'fadeIn', { duration_ms: 1000, delay_ms: 425 }, 'intro');
|
|
|
|
assert.equal(values['animation-delay'], '425ms');
|
|
context.applyAnimateCssPreview(rect, 'fadeOut', { duration_ms: 1000, delay_ms: 425 }, 'out');
|
|
assert.equal(values['animation-delay'], undefined);
|
|
});
|
|
|
|
test('template outro preview aligns regions using computed CSS animation duration', () => {
|
|
const script = fs.readFileSync(require.resolve('../src/web/public/js/templates/template-designer.js'), 'utf8');
|
|
assert.match(script, /getComputedAnimationDurationMs\(rect, durationMs\) \* iterations/);
|
|
assert.match(script, /mode === 'out' \? Math\.max\(0, longestTimingMs - entry\.runtimeMs\) : entry\.delay/);
|
|
assert.match(script, /Number\(rect\.getAttribute\('data-index'\)\)/);
|
|
|
|
const helper = script.match(/ function getComputedAnimationDurationMs\([^]*?\n }/);
|
|
assert.ok(helper);
|
|
const context = vm.createContext({ window: { getComputedStyle() { return { animationDuration: '1s, 2.5s' }; } } });
|
|
vm.runInContext(helper[0] + '\nthis.getComputedAnimationDurationMs = getComputedAnimationDurationMs;', context);
|
|
assert.equal(context.getComputedAnimationDurationMs({}, 900), 2500);
|
|
}); |