Files
pulse-signage/test/region-layout.test.js
T
lzstealth 8ee59e97d3
Publish Docker Image / build-and-push-existing-registry (./build/Dockerfile, web, pulse-signage-web) (push) Successful in 1m32s
Publish Docker Image / build-and-push-existing-registry (./build/Dockerfile.player, player, pulse-signage-player) (push) Successful in 36s
Wait for full animation preview duration
2026-10-26 23:02:09 +00:00

796 lines
47 KiB
JavaScript

const test = require('node:test');
const assert = require('node:assert/strict');
const { normalizeLayout, applyLayout, snapBox } = require('../src/web/public/js/shared/region-layout');
const fs = require('node:fs');
const vm = require('node:vm');
require('../src/common');
const { buildSlidePayload } = require('../src/data/slides');
const { buildThumbnailPreviewMarkup } = require('../src/web/lib/media/slide-thumbnails');
const { buildTemplateLayoutUpdate, buildTemplateAnimationUpdate } = require('../src/data/templates');
const { getTemplateLayoutSnapshot, getTemplateAnimationSnapshot, retainLayoutAnimation } = require('../src/web/public/js/shared/region-layout');
const canvas = { width: 1920, height: 1080 };
test('shared layout updates preserve region identity and animations and reject stale edits', function () {
const template = { canvas_size_width: 1920, canvas_size_height: 1080, regions: [{
id: 11, region_key: 'hero', region_type: 'text', label: 'Hero',
x: 20, y: 30, width: 400, height: 120, z_index: 2, lock_ratio: null,
animation_json: JSON.stringify({ intro: { preset: 'fadeIn' } })
}] };
const body = {
baseline: getTemplateLayoutSnapshot(template),
regions: [{ region_key: 'hero', x: 200, y: 300, width: 600, height: 200, z_index: 4, lock_ratio: '3:1', animation_json: '{}' }]
};
const updated = buildTemplateLayoutUpdate(template, body);
assert.equal(updated[0].x, 200);
assert.equal(updated[0].id, 11);
assert.equal(updated[0].region_type, 'text');
assert.equal(updated[0].animation_json, template.regions[0].animation_json);
assert.equal(template.regions[0].x, 20);
assert.throws(() => buildTemplateLayoutUpdate(template, { ...body, baseline: {} }), { statusCode: 409 });
assert.throws(() => buildTemplateLayoutUpdate(template, { ...body, regions: [] }), { statusCode: 400 });
assert.throws(() => buildTemplateLayoutUpdate(template, { ...body, regions: [{ ...body.regions[0], region_key: 'unknown' }] }), { statusCode: 400 });
});
test('slide layout overrides do not mutate templates or sibling slides', function () {
const region = { region_key: 'title', x: 20, y: 30, width: 400, height: 120 };
const content = { layout: { x: 200, y: 100, width: 600, height: 150 } };
assert.deepEqual(applyLayout(region, content, canvas), { ...region, ...content.layout });
assert.deepEqual(applyLayout(region, {}, canvas), region);
assert.equal(region.x, 20);
assert.equal(region.width, 400);
});
test('layout geometry rejects invalid fields and clamps overrides to the canvas', function () {
assert.equal(normalizeLayout({ x: '10', y: 0, width: 10, height: 20 }, canvas), null);
assert.equal(normalizeLayout({ x: NaN, y: 0, width: 10, height: 20 }, canvas), null);
assert.equal(normalizeLayout({ x: 1, y: 2 }, canvas), null);
assert.deepEqual(normalizeLayout({ x: -10, y: 1000, width: 9999, height: 200 }, canvas), {
x: 0, y: 1000, width: 1920, height: 80
});
});
test('region overrides retain stacking, ratio and complete animation settings', async function () {
const animation = { intro: { preset: 'fadeIn', duration_ms: 1200, delay_ms: 250 }, outro: { preset: 'zoomOut' }, loop: { preset: 'pulse', iterations: 4 } };
const layout = { x: 200, y: 300, width: 500, height: 100, z_index: 8, lock_ratio: '5:1', animation_json: animation };
const legacyLayout = { ...layout, animation_json: JSON.stringify(animation) };
assert.deepEqual(normalizeLayout(layout, canvas), layout);
assert.deepEqual(normalizeLayout(legacyLayout, canvas), layout);
const region = { x: 0, y: 0, width: 100, height: 100, z_index: 1, lock_ratio: '', animation_json: '{}' };
const applied = applyLayout(region, { layout: legacyLayout }, canvas);
assert.equal(applied.z_index, 8);
assert.deepEqual(applied.animation_json, animation);
assert.equal(region.z_index, 1);
const payload = await buildSlidePayload(createPool(), { body: {
title: 'Full settings', template_id: '7', region_layouts_json: JSON.stringify({ hero: legacyLayout })
} }, null);
assert.deepEqual(JSON.parse(payload.contentJson).hero.layout, layout);
assert.equal(normalizeLayout({ ...layout, lock_ratio: '1:0' }, canvas), null);
assert.equal(normalizeLayout({ ...layout, animation_json: 'broken' }, canvas), null);
});
test('moving regions snaps to canvas centers and neighboring edges', function () {
const centered = snapBox({ x: 858, y: 102, width: 200, height: 80 }, [], canvas, 5);
assert.equal(centered.box.x, 860);
assert.deepEqual(centered.guides, [{ axis: 'x', position: 960 }]);
const aligned = snapBox({ x: 397, y: 301, width: 100, height: 90 }, [
{ x: 100, y: 200, width: 300, height: 100 }
], canvas, 5);
assert.equal(aligned.box.x, 400);
assert.equal(aligned.box.y, 300);
});
test('dragging template regions ignores nullable non-geometric metadata', function () {
const region = {
x: 858, y: 100, width: 200, height: 80,
z_index: 2, lock_ratio: null, animation_json: null
};
const moved = snapBox(region, [], canvas, 5);
assert.deepEqual(moved.box, { x: 860, y: 100, width: 200, height: 80 });
assert.deepEqual(moved.guides, [{ axis: 'x', position: 960 }]);
assert.equal(region.animation_json, null);
const resized = snapBox({ ...region, width: 1058 }, [], canvas, 5, 'se');
assert.equal(resized.box.width, 1062);
});
test('resizing snaps only the moving edges and keeps the opposite corner fixed', function () {
const resized = snapBox({ x: 100, y: 100, width: 1817, height: 977 }, [], canvas, 5, 'se');
assert.deepEqual(resized.box, { x: 100, y: 100, width: 1820, height: 980 });
const unchanged = snapBox({ x: 100, y: 100, width: 200, height: 200 }, [], canvas, 0, 'se');
assert.deepEqual(unchanged.box, { x: 100, y: 100, width: 200, height: 200 });
});
test('locked aspect ratios remain intact while snapping and resizing near canvas bounds', function () {
const snapped = snapBox({ x: 100, y: 100, width: 1817, height: 908.5 }, [], canvas, 5, 'se', 2);
assert.equal(snapped.box.width, 1820);
assert.equal(snapped.box.height, 910);
const constrained = snapBox({ x: 100, y: 800, width: 900, height: 900 }, [], canvas, -1, 'se', 1);
assert.deepEqual(constrained.box, { x: 100, y: 800, width: 280, height: 280 });
assert.deepEqual(constrained.guides, []);
});
function createPool(regionType = 'text') {
return {
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: regionType,
x: 10, y: 20, width: 300, height: 100
}]];
throw new Error('Unexpected query: ' + sql);
}
};
}
test('slide saves persist overrides, preserve omitted layout fields, and allow explicit reset', async function () {
const layout = { x: 200, y: 300, width: 500, height: 100 };
const body = { title: 'Example', template_id: '7', region_text_11: 'Hello', region_layouts_json: JSON.stringify({ hero: layout }) };
const payload = await buildSlidePayload(createPool(), { body }, null);
assert.deepEqual(JSON.parse(payload.contentJson).hero.layout, layout);
const existing = { template_id: 7, content_json: payload.contentJson };
delete body.region_layouts_json;
const preserved = await buildSlidePayload(createPool(), { body }, existing);
assert.deepEqual(JSON.parse(preserved.contentJson).hero.layout, layout);
body.region_layouts_json = '{}';
const reset = await buildSlidePayload(createPool(), { body }, existing);
assert.equal(Object.hasOwn(JSON.parse(reset.contentJson).hero, 'layout'), false);
body.region_layouts_json = JSON.stringify({ hero: { x: 'invalid' } });
await assert.rejects(buildSlidePayload(createPool(), { body }, existing), { statusCode: 400 });
body.region_layouts_json = '[]';
await assert.rejects(buildSlidePayload(createPool(), { body }, existing), { statusCode: 400 });
});
test('QR content rebuilding retains slide layout overrides', async function () {
const layout = { x: 40, y: 60, width: 200, height: 200 };
const payload = await buildSlidePayload(createPool('qr-code'), { body: {
title: 'QR example', template_id: '7', region_qr_code_11: 'https://example.com',
region_qr_preview_11: 'data:image/png;base64,example',
region_layouts_json: JSON.stringify({ hero: layout })
} }, null);
assert.deepEqual(JSON.parse(payload.contentJson).hero.layout, layout);
});
test('thumbnail markup uses the slide override without changing template geometry', function () {
const region = { region_key: 'hero', region_type: 'image', x: 0, y: 0, width: 1920, height: 1080 };
const html = buildThumbnailPreviewMarkup({
template: { regions: [region], canvas_size_width: 1920, canvas_size_height: 1080 },
content: { hero: { type: 'image', value: '/media/photo.png', layout: { x: 960, y: 540, width: 960, height: 540 } } }
}, 'http://localhost');
assert.match(html, /left:50%;top:50%;width:50%;height:50%/);
assert.equal(region.x, 0);
});
test('player caches separate slide overrides for a shared template', function () {
const context = {
window: { innerWidth: 1920, innerHeight: 1080, pulseRegionLayout: { applyLayout } },
currentPlaylistSignature: 'revision', templateLayoutCache: {}, templateRenderPlanCache: {},
syncRenderCacheViewport() {},
getTemplateCanvasSize() { return canvas; },
fitCanvasSize() { return canvas; }
};
vm.runInNewContext(fs.readFileSync(require.resolve('../src/player/rendering-shared.js'), 'utf8'), context);
vm.runInNewContext(fs.readFileSync(require.resolve('../src/player/public/js/player-page-rendering.js'), 'utf8'), context);
const template = { id: 7, regions: [
{
region_key: 'hero', x: 0, y: 0, width: 100, height: 100, z_index: 1,
animation_json: { intro: { preset: 'zoomIn', duration_ms: 800 } }
},
{
region_key: 'footer', x: 100, y: 100, width: 200, height: 50, z_index: 2,
animation_json: { loop: { preset: 'pulse', iterations: 2 } }
}
] };
const first = context.getTemplateRenderPlan(template, { hero: { layout: {
x: 960, y: 0, width: 400, height: 100, z_index: 9,
animation_json: { intro: { preset: 'fadeIn', duration_ms: 1400 } }
} } });
const sibling = context.getTemplateRenderPlan(template, {});
assert.notEqual(first, sibling);
assert.match(first.layout.regions[0].baseStyle, /left:50%/);
assert.match(first.layout.regions[0].baseStyle, /z-index:9/);
assert.equal(first.layout.regions[0].animationJson.intro.duration_ms, 1400);
assert.equal(first.layout.regions[1].animationJson.loop.preset, 'pulse');
assert.equal(first.layout.regions[1].animationJson.loop.iterations, 2);
assert.match(sibling.layout.regions[0].baseStyle, /left:0%/);
assert.equal(sibling.layout.regions[0].animationJson.intro.preset, 'zoomIn');
assert.equal(sibling.layout.regions[0].animationJson.intro.duration_ms, 800);
assert.equal(template.regions[0].animation_json.intro.preset, 'zoomIn');
assert.equal(template.regions[0].x, 0);
});
test('shared template action requires template update permission and loads the modal helper', async function () {
const renderSlide = require('../src/web/routes/signage/slides/edit');
const slide = { id: 1, title: 'Example', template_id: 7, content: {} };
const withPermission = await renderSlide({}, slide, '', { permissionKeys: ['slides.update', 'templates.update'] });
assert.match(withPermission, /id="save-layout-to-template"/);
assert.match(withPermission, /Apply to this slide/);
assert.doesNotMatch(withPermission, /slide-shared-template|edit-shared-slide-template/);
assert.match(withPermission, /\/assets\/js\/lib\/modal\.js/);
assert.match(withPermission, /modal-xl modal-dialog-centered modal-dialog-scrollable slide-layout-dialog/);
assert.doesNotMatch(withPermission, /modal-fullscreen/);
assert.match(withPermission, /name="region_z\[\]"/);
const layoutModal = withPermission.split('id="slide-layout-modal"')[1].split('id="slide-animation-modal"')[0];
assert.doesNotMatch(layoutModal, /data-animation-preset|animation-modal-field/);
assert.match(withPermission, /id="adjust-slide-animations"/);
assert.equal((withPermission.match(/id="adjust-slide-animations"/g) || []).length, 1);
assert.match(withPermission, /id="slide-animation-modal"/);
assert.match(withPermission, /data-animation-preset="intro"/);
assert.equal((withPermission.match(/id="slide-animation-modal"/g) || []).length, 1);
assert.match(withPermission, /data-slide-animation-preview="intro"/);
assert.match(withPermission, /data-slide-animation-preview="outro"/);
assert.match(withPermission, /data-slide-animation-preview="loop"/);
assert.match(withPermission, /id="slide-animation-preview"/);
assert.match(withPermission, /shared\/region-animation-controls\.js/);
for (const script of [
'/assets/js/templates/animation-presets.js',
'/assets/js/templates/template-designer-utils.js',
'/assets/js/shared/region-animation-controls.js',
'/assets/js/shared/qr-code-svg.js',
'/assets/vendor/qr-code-styling/qr-code-styling-loader.js'
]) {
assert.equal(withPermission.split(script).length - 1, 1, script + ' should load once');
}
const animationModal = withPermission.split('id="slide-animation-modal"')[1].split('id="slide-image-cropper-modal"')[0];
assert.match(animationModal, /Advanced animations:/);
assert.equal((withPermission.match(/id="animation-advanced-modal"/g) || []).length, 1);
assert.doesNotMatch(withPermission, /id="slide-animation-advanced"/);
const mainAnimationModal = animationModal.split('id="animation-advanced-modal"')[0];
assert.doesNotMatch(mainAnimationModal, /data-animation-modal-field/);
assert.match(animationModal, /Reset region animations to template/);
assert.match(animationModal, /Reset all animations to template/);
assert.ok(animationModal.indexOf('data-slide-animation-preview="intro"') < animationModal.indexOf('for="slide-animation-region"'));
const resetButtons = Array.from(withPermission.matchAll(/<button[^>]*id="slide-(?:layout-reset(?:-all)?|animation-reset-(?:region|all))"[^>]*>([\s\S]*?)<\/button>/g));
assert.equal(resetButtons.length, 4);
resetButtons.forEach((match) => assert.match(match[1], /<i class="bi bi-arrow-counterclockwise me-1" aria-hidden="true"><\/i>/));
assert.match(animationModal, /id="save-animations-to-template"/);
assert.match(animationModal, /Apply to this slide/);
assert.equal((withPermission.match(/id="slide-animation-apply"/g) || []).length, 1);
assert.equal((withPermission.match(/id="slide-animation-save-status"/g) || []).length, 1);
const pageHeader = withPermission.split('class="page-header"')[1].split('</div>')[0];
assert.doesNotMatch(pageHeader, /slide-animation-apply|slide-animation-save-status|Animation save actions/);
assert.match(pageHeader, /Build slide content and preview it against the selected template\./);
assert.equal((withPermission.match(/id="adjust-slide-animations"/g) || []).length, 1);
assert.doesNotMatch(animationModal, /save-layout-to-template|slide-layout-snap/);
const withoutTemplatePermission = await renderSlide({}, slide, '', { permissionKeys: ['slides.update'] });
assert.doesNotMatch(withoutTemplatePermission, /id="save-animations-to-template"/);
const animationEditorSource = fs.readFileSync(require.resolve('../src/web/public/js/slides/slide-animation-editor.js'), 'utf8');
assert.match(animationEditorSource, /getAnimationOverrides:|options\.getAnimationOverrides\(\)/);
assert.match(animationEditorSource, /modal\.addEventListener\('hidden\.bs\.modal'/);
assert.match(animationEditorSource, /var disabled = !available \|\| \(previewMode && previewMode !== mode\)/);
assert.match(animationEditorSource, /classList\.toggle\('btn-info', !disabled\)/);
assert.match(withPermission, /class="btn btn-outline-primary flex-shrink-0" id="adjust-slide-layout"[^>]*title="Adjust layout"[^>]*aria-label="Adjust layout"/);
assert.match(withPermission, /id="open-preview-popup"[^>]*>[\s\S]*?Open popup preview<\/button>\s*<button[^>]*id="adjust-slide-layout"/);
assert.doesNotMatch(withPermission, /id="adjust-slide-layout"[^>]*>[\s\S]*?Adjust layout<\/button>/);
const withoutPermission = await renderSlide({}, slide, '', { permissionKeys: ['slides.update'] });
assert.doesNotMatch(withoutPermission, /id="save-layout-to-template"/);
});
test('layout and animation modals use the same preview and properties column sizing', function () {
const css = fs.readFileSync(require.resolve('../src/web/public/css/theme-custom.css'), 'utf8');
assert.match(css, /\.slide-layout-body\s*\{[^}]*grid-template-columns:\s*minmax\(0,\s*1fr\)\s+21rem;/);
assert.match(css, /\.slide-animation-body\s*\{[^}]*grid-template-columns:\s*minmax\(0,\s*1fr\)\s+21rem;/);
assert.match(css, /\.slide-animation-body\s*\{[^}]*gap:\s*1\.25rem;/);
assert.match(css, /\.region-field-grid--animation\s+\[data-animation-preset\]\[hidden\]\s*\{\s*display:\s*none\s*!important;/);
assert.match(css, /\.slide-animation-preview-panel\s*\{[^}]*min-height:\s*0;/);
assert.match(css, /\.slide-animation-properties\s*\{[^}]*grid-template-rows:\s*auto minmax\(0,\s*1fr\);[^}]*min-height:\s*0;/);
assert.match(css, /#slide-animation-preview\s*\{[^}]*container-type:\s*size;[^}]*height:\s*100%;/);
assert.match(css, /#slide-animation-preview \.slide-preview-stage\s*\{[^}]*width:\s*min\(100cqw,\s*calc\(100cqh\s*\*\s*var\(--slide-layout-ratio,/);
const form = fs.readFileSync(require.resolve('../src/web/views/signage/slides/form.hbs'), 'utf8');
const previewPanel = form.split('<section class="slide-animation-preview-panel">')[1].split('</section>')[0];
assert.doesNotMatch(previewPanel, /data-slide-animation-preview=/);
const toolbar = form.split('<div class="slide-animation-properties">')[1].split('<div class="slide-animation-controls">')[0];
assert.equal((toolbar.match(/data-slide-animation-preview=/g) || []).length, 3);
assert.match(css, /@media \(max-width: 767\.98px\)[\s\S]*?\.slide-animation-preview-panel\s*\{[^}]*height:\s*min\(55dvh,\s*32rem\);/);
});
test('animation settings leave space below labels', function () {
const css = fs.readFileSync(require.resolve('../src/web/public/css/theme-custom.css'), 'utf8');
assert.match(css, /\.slide-animation-controls \.region-field-grid--animation label\s*\{[^}]*display:\s*grid;[^}]*gap:\s*0\.5rem;/);
});
test('shared animation controls round-trip advanced settings with template normalization', function () {
const animationPresets = require('../src/web/public/js/templates/animation-presets.js');
assert.ok(animationPresets.advanced.outro.some((preset) => preset.value === 'hinge'));
assert.ok(!animationPresets.advanced.attentionSeekers.some((preset) => preset.value === 'hinge'));
const context = { window: {} };
for (const file of ['templates/animation-presets.js', 'templates/template-designer-utils.js', 'shared/region-animation-controls.js']) {
vm.runInNewContext(fs.readFileSync(require.resolve('../src/web/public/js/' + file), 'utf8'), context);
}
const fields = [
{ dataset: { animationModalStep: 'intro', animationModalField: 'preset' }, value: 'fadeIn' },
{ dataset: { animationModalStep: 'intro', animationModalField: 'duration_ms' }, value: '150' },
{ dataset: { animationModalStep: 'intro', animationModalField: 'delay_ms' }, value: '300' },
{ dataset: { animationModalStep: 'loop', animationModalField: 'preset' }, value: 'pulse' },
{ dataset: { animationModalStep: 'loop', animationModalField: 'iterations' }, value: '4' }
];
const container = { querySelectorAll() { return fields; } };
const controls = context.window.pulseRegionAnimationControls;
const config = controls.readAdvanced(container, { intro: { easing: 'ease-in', fill_mode: 'both' } });
assert.equal(config.intro.duration_ms, 1500);
assert.equal(config.intro.delay_ms, 300);
assert.equal(config.intro.easing, 'ease-in');
assert.equal(config.intro.fill_mode, 'both');
assert.equal(config.loop.iterations, 4);
controls.populateAdvanced(container, config);
assert.equal(Number(fields[1].value), 150);
fields[1].value = '999';
fields[2].value = '-200';
fields[4].value = '2000';
const clamped = controls.readAdvanced(container, config);
assert.equal(clamped.intro.duration_ms, 2000);
assert.equal(clamped.intro.delay_ms, 0);
assert.equal(clamped.loop.iterations, 999);
fields[1].value = '999';
fields[4].value = '2000';
controls.clampAdvancedField(fields[1]);
controls.clampAdvancedField(fields[4]);
assert.equal(fields[1].value, '200');
assert.equal(fields[4].value, '999');
});
test('slide advanced popup applies changes, cancels drafts and preserves the parent animation draft', function () {
function element() {
return { hidden: false, value: '', disabled: false, dataset: {}, handlers: {},
addEventListener(type, handler) { this.handlers[type] = handler; },
querySelectorAll() { return []; }, replaceChildren() {}, appendChild() {}, focus() {},
setAttribute() {}, removeAttribute() {},
style: { values: {}, setProperty(name, value) { this.values[name] = value; }, removeProperty(name) { delete this.values[name]; } },
classList: { add() {}, remove() {}, toggle() {} } };
}
const ids = Object.fromEntries([
'slide-animation-modal', 'adjust-slide-animations', 'slide-animation-region',
'slide-animation-fields', 'animation-advanced-modal', 'slide-animation-preview',
'slide-animation-save-status', 'slide-animation-reset-region',
'slide-animation-reset-all', 'slide-animation-apply'
].map(id => [id, element()]));
const fields = ['intro', 'outro', 'loop'].flatMap(step => ['preset', 'duration_ms', 'delay_ms'].map(field => ({
dataset: { animationModalStep: step, animationModalField: field }, value: ''
})));
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(' '); }
};
const secondPreviewRegion = element();
secondPreviewRegion.className = 'slide-preview-region';
secondPreviewRegion.dataset.regionKey = 'fixed_brand_second';
advanced.querySelectorAll = () => fields;
ids['slide-animation-modal'].querySelectorAll = selector => selector === '[data-slide-animation-preview]' ? animationPreviewButtons : [];
ids['slide-animation-preview'].querySelectorAll = () => [readonlyPreviewRegion, secondPreviewRegion];
advanced.querySelector = selector => selector === '[data-animation-modal-apply]' ? advancedApply
: selector === '[data-animation-modal-reset]' ? advancedReset : element();
const hidden = { value: '{}' };
const presets = ['intro', 'outro', 'loop'].map(step => ({
...element(), dataset: { animationPreset: step }, selectedIndex: 0,
options: [{ value: 'none' }, { value: 'fadeIn' }]
}));
const resets = ['intro', 'outro', 'loop'].map(step => ({ ...element(), dataset: { animationResetButton: step } }));
const advancedButton = element();
const card = ids['slide-animation-fields'];
card.querySelectorAll = selector => selector === '[data-animation-preset]' ? presets : resets;
card.querySelector = selector => {
if (selector === '[name="region_animation_json[]"]') return hidden;
if (selector === '[data-animation-advanced-button]') return advancedButton;
return resets.find(button => selector.includes('"' + button.dataset.animationResetButton + '"'));
};
const modalInstances = new Map();
const scrollBarCalls = [];
const previewTimers = [];
const context = { window: { innerWidth: 1200,
setTimeout(_callback, delay) { previewTimers.push(delay); return previewTimers.length; }, clearTimeout() {},
getComputedStyle() {
return { animationDuration: '400ms' };
},
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) {
if (!modalInstances.has(modal)) modalInstances.set(modal, {
_scrollBar: {
hide() { scrollBarCalls.push(['hide', modal]); },
reset() { scrollBarCalls.push(['reset', modal]); }
},
_focustrap: { activate() {}, deactivate() {} }
});
return modalInstances.get(modal);
}
},
pulseRegionLayout: { getTemplateAnimationSnapshot() { return []; } } },
document: { body: element(), getElementById(id) { return ids[id] || null; }, querySelector() { return null; }, querySelectorAll() { return []; }, createElement: element }
};
for (const file of ['templates/animation-presets.js', 'templates/template-designer-utils.js', 'shared/region-animation-controls.js']) {
vm.runInNewContext(fs.readFileSync(require.resolve('../src/web/public/js/' + file), 'utf8'), context);
}
const source = fs.readFileSync(require.resolve('../src/web/public/js/slides/slide-animation-editor.js'), 'utf8');
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' } } },
{ region_key: 'fixed_brand', read_only: true, animation_json: {
outro: { preset: 'hinge', duration_ms: 200, delay_ms: 500 },
loop: { preset: 'pulse', duration_ms: 200, delay_ms: 240, iterations: 1 }
} },
{ region_key: 'fixed_brand_second', 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();
fields[1].value = '999';
ids['animation-advanced-modal'].handlers.input({ target: fields[1] });
assert.equal(fields[1].value, '200');
assert.equal(animationPreviewButtons[2].disabled, false);
animationPreviewButtons[2].handlers.click();
assert.match(readonlyPreviewRegion.className, /animate__pulse/);
assert.equal(readonlyPreviewRegion.style.values['animation-delay'], '240ms');
assert.equal(secondPreviewRegion.style.values['animation-delay'], '0ms');
animationPreviewButtons[2].handlers.click();
animationPreviewButtons[1].handlers.click();
assert.equal(readonlyPreviewRegion.style.values['animation-delay'], '0ms');
animationPreviewButtons[1].handlers.click();
assert.equal(previewTimers[previewTimers.length - 1], 580);
const resetRegion = ids['slide-animation-reset-region'];
const resetAll = ids['slide-animation-reset-all'];
assert.equal(resetRegion.disabled, true);
assert.equal(resetAll.disabled, true);
presets[0].value = 'fadeIn';
presets[0].handlers.change();
assert.equal(resetRegion.disabled, false);
assert.equal(resetAll.disabled, false);
resetRegion.handlers.click();
assert.equal(JSON.parse(hidden.value).intro.preset, 'bounceIn');
assert.equal(resetRegion.disabled, true);
assert.equal(resetAll.disabled, true);
presets[0].value = 'fadeIn';
presets[0].handlers.change();
assert.equal(resetAll.disabled, false);
resetAll.handlers.click();
assert.equal(JSON.parse(hidden.value).intro.preset, 'bounceIn');
assert.equal(resetRegion.disabled, true);
assert.equal(resetAll.disabled, true);
assert.equal(applied, undefined);
advancedButton.handlers.click();
assert.equal(advanced.shown, true);
assert.equal(ids['slide-animation-modal'].shown, true);
assert.equal(ids['slide-animation-modal'].inert, true);
assert.equal(fields[0].value, 'bounceIn');
fields[4].value = '150';
advancedApply.handlers.click();
assert.equal(ids['slide-animation-modal'].shown, true);
assert.equal(ids['slide-animation-modal'].inert, false);
assert.equal(resetRegion.disabled, false);
assert.equal(resetAll.disabled, false);
resets[0].handlers.click({ preventDefault() {} });
advancedButton.handlers.click();
assert.equal(fields[0].value, 'none');
fields[0].value = 'bounceIn';
context.window.pulseModal.hide(advanced);
advancedButton.handlers.click();
assert.equal(fields[0].value, 'none');
advancedReset.handlers.click();
assert.equal(fields[3].value, 'none');
context.window.pulseModal.hide(advanced);
ids['slide-animation-apply'].handlers.click();
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']]
]);
});
function createSharedLayoutRouteHarness(failUpdate, allowSlideUpdate, imageOptions) {
const registerContentRoutes = require('../src/web/routes/admin/content');
const handlers = {};
const registrations = [];
const calls = [];
const savedContent = { hero: {
type: 'text', value: 'Saved headline', font_size: 40,
layout: { x: 90, y: 80, width: 500, height: 200, animation_json: '{"loop":{"preset":"pulse"}}' }
}, other: { type: 'text', value: 'Keep this content' } };
const template = { id: 7, name: 'Shared layout', canvas_size_width: 1920, canvas_size_height: 1080, regions: [{
id: 11, region_key: 'hero', region_type: 'text', x: 10, y: 20, width: 300, height: 100,
z_index: 1, lock_ratio: null, animation_json: '{"intro":{"preset":"fadeIn"}}'
}] };
if (imageOptions) {
template.regions[0].region_type = 'image';
template.regions[0].width = 300;
template.regions[0].height = 200;
if (imageOptions.defaultContent) template.regions[0].default_content = imageOptions.defaultContent;
savedContent.hero = imageOptions.content;
}
const connection = {
async beginTransaction() { calls.push(['begin']); },
async commit() { calls.push(['commit']); },
async rollback() { calls.push(['rollback']); },
release() { calls.push(['release']); },
async query(sql, params) {
calls.push([sql, params]);
if (sql.startsWith('SELECT id, template_id, content_json')) return [[{ id: 42, template_id: 7, content_json: JSON.stringify(savedContent) }]];
if (sql.startsWith('SELECT id, content_json FROM c_slides')) return [[{ id: 42, content_json: JSON.stringify(savedContent) }]];
if (imageOptions && sql.startsWith('UPDATE c_template_regions SET x')) {
Object.assign(template.regions[0], { x: params[0], y: params[1], width: params[2], height: params[3] });
}
if (failUpdate && sql.startsWith('UPDATE c_template_regions')) throw new Error('Write failed');
if (sql.startsWith('UPDATE c_template_regions SET animation_json')) {
template.regions.find(region => region.id === params[2]).animation_json = params[0];
return [{ affectedRows: 1 }];
}
return [[]];
}
};
const noop = async () => {};
registerContentRoutes({
get(route, ...actions) { handlers['GET ' + route] = actions; registrations.push('GET ' + route); },
post(route, ...actions) { handlers['POST ' + route] = actions; registrations.push('POST ' + route); }
}, {
uploadDir: imageOptions && imageOptions.uploadDir,
pool: { async getConnection() { return connection; }, async query(sql, params) { calls.push([sql, params]); return [[{ slide_count: 3 }]]; } },
common: { fetchTemplateById: async () => imageOptions ? JSON.parse(JSON.stringify(template)) : template, parseJsonSafe: JSON.parse }, pages: {},
upload: { any: () => noop, single: () => noop }, setAuthMessageCookie: noop,
fetchScreensBySlideId: async () => [], fetchScreensByTemplateId: async () => [{ slug: 'lobby' }],
collectUploadReferencesFromSlide: () => [], collectUploadReferencesFromTemplate: () => [],
collectUploadReferencesFromPayload: payload => imageOptions ? require('../src/data/content-references').collectContentReferences('slide', 42, JSON.parse(payload.contentJson)).filter(reference => reference.reference_type === 'media').map(reference => reference.reference_value) : [], removeUnusedUploadFiles: noop,
syncPlaylistUploadsOnChange: async value => calls.push(['sync', value]), getAuditUserId: () => 9, redirectAfterSave: noop,
notifyPlayerScreens: async (...args) => calls.push(['notify', ...args]), broadcastDashboardState: noop,
getSlideDeleteBlockMessage: noop, getTemplateDeleteBlockMessage: noop, getCanvasSizeDeleteBlockMessage: noop,
hasAnyPermission: () => Boolean(allowSlideUpdate),
recordRequestAuditEvent: async (_pool, _req, event) => calls.push(['audit', event]),
backgroundTaskQueue: { enqueueTask: async task => calls.push(['thumbnail', task]) },
requirePermission(key) { return Object.assign(function () {}, { permission: key }); }
});
return { handlers, calls, template, savedContent, registrations };
}
test('shared layout route is permission protected and updates existing rows without touching content or animation', async function () {
const { handlers, calls, template, registrations } = createSharedLayoutRouteHarness();
assert.equal(registrations.filter(route => route === 'GET /templates/:id/animations').length, 1);
assert.equal(registrations.filter(route => route === 'POST /templates/:id/animations').length, 1);
const route = handlers['POST /templates/:id/layout'];
assert.equal(route[0].permission, 'templates.update');
assert.equal(handlers['GET /templates/:id/layout'][0].permission, 'templates.update');
let result;
await route.at(-1)({ params: { id: '7' }, body: {
baseline: getTemplateLayoutSnapshot(template),
regions: [{ region_key: 'hero', x: 120, y: 220, width: 400, height: 200, z_index: 5, lock_ratio: '2:1' }]
} }, { json(value) { result = value; } }, error => { throw error; });
assert.equal(result.template.regions[0].id, 11);
assert.equal(result.template.regions[0].animation_json, template.regions[0].animation_json);
assert.equal(result.slideCount, 3);
assert.ok(calls.some(call => call[0] === 'commit'));
assert.ok(calls.some(call => call[0] === 'thumbnail'));
assert.ok(calls.some(call => call[0] === 'notify'));
assert.ok(calls.some(call => call[0] === 'audit'));
const auditChanges = calls.find(call => call[0] === 'audit')[1].details.changes;
assert.deepEqual(auditChanges['regions.hero.x'], { from: 10, to: 120 });
assert.equal(auditChanges.regions, undefined);
const writes = calls.filter(call => /^UPDATE|DELETE|INSERT/.test(call[0]));
assert.equal(writes.length, 2);
assert.ok(writes.every(call => !/content_json|animation_json|DELETE|INSERT/.test(call[0])));
});
test('shared layout route rolls back conflicts and failed writes', async function () {
for (const failUpdate of [false, true]) {
const { handlers, calls, template } = createSharedLayoutRouteHarness(failUpdate);
let status;
let errorMessage;
await handlers['POST /templates/:id/layout'].at(-1)({ params: { id: '7' }, body: {
baseline: failUpdate ? getTemplateLayoutSnapshot(template) : {},
regions: [{ region_key: 'hero', x: 120, y: 220, width: 400, height: 200, z_index: 5, lock_ratio: '' }]
} }, {
status(value) { status = value; return this; },
json(value) { errorMessage = value.error; }
}, error => { errorMessage = error.message; });
if (!failUpdate) assert.equal(status, 409);
assert.ok(errorMessage);
assert.ok(calls.some(call => call[0] === 'rollback'));
assert.ok(calls.some(call => call[0] === 'release'));
assert.ok(!calls.some(call => ['commit', 'notify', 'thumbnail', 'audit'].includes(call[0])));
}
});
test('promoted slide layouts inherit geometry while retaining any animation override', function () {
const original = { x: 20, y: 30, width: 400, height: 120, animation_json: '{"intro":{"preset":"fadeIn"}}' };
const inherited = retainLayoutAnimation(original);
const animation = { intro: { preset: 'fadeIn' } };
assert.deepEqual(inherited, { animation_json: animation });
const region = { x: 100, y: 200, width: 600, height: 300, animation_json: '{}' };
assert.deepEqual(applyLayout(region, { layout: inherited }, canvas), { ...region, animation_json: animation });
assert.equal(retainLayoutAnimation({ x: 20, y: 30, width: 400, height: 120 }), null);
assert.equal(normalizeLayout({}, canvas), null);
});
test('shared save clears only the current slide geometry and preserves saved content and animations', async function () {
const { handlers, calls, template, savedContent } = createSharedLayoutRouteHarness(false, true);
await handlers['POST /templates/:id/layout'].at(-1)({ params: { id: '7' }, body: {
slideId: 42, baseline: getTemplateLayoutSnapshot(template),
regions: [{ region_key: 'hero', x: 120, y: 220, width: 400, height: 200, z_index: 5, lock_ratio: '' }]
} }, { json() {} }, error => { throw error; });
const writes = calls.filter(call => call[0].startsWith('UPDATE c_slides'));
assert.equal(writes.length, 1);
const persisted = JSON.parse(writes[0][1][0]);
assert.deepEqual(persisted.hero, { ...savedContent.hero, layout: { animation_json: { loop: { preset: 'pulse' } } } });
assert.deepEqual(persisted.other, savedContent.other);
assert.equal(calls.find(call => call[0] === 'audit')[1].details.promotedSlideId, 42);
assert.deepEqual(writes[0][1].slice(2), [42, 7]);
assert.ok(calls.findIndex(call => call[0] === 'commit') > calls.findIndex(call => call === writes[0]));
});
test('clearing saved slide geometry additionally requires slide update permission', async function () {
const { handlers, calls } = createSharedLayoutRouteHarness(false, false);
let status;
await handlers['POST /templates/:id/layout'].at(-1)({ params: { id: '7' }, body: { slideId: 42 } }, {
status(value) { status = value; return this; }, json() {}
}, error => { throw error; });
assert.equal(status, 403);
assert.equal(calls.length, 0);
});
test('shared animation updates normalize every region and reject stale or incomplete sets', function () {
const template = { regions: [
{ id: 11, region_key: 'hero', animation_json: '{"intro":{"preset":"fadeIn"}}' },
{ id: 12, region_key: 'footer', animation_json: '{}' }
] };
const body = {
baseline: getTemplateAnimationSnapshot(template),
animations: [
{ region_key: 'hero', animation_json: { intro: { preset: 'zoomIn', duration_ms: 800 } } },
{ region_key: 'footer', animation_json: { loop: { preset: 'pulse', iterations: 3 } } }
]
};
const updated = buildTemplateAnimationUpdate(template, body);
assert.deepEqual(updated.map(region => region.id), [11, 12]);
assert.equal(updated[0].animation_json.intro.preset, 'zoomIn');
assert.equal(updated[0].animation_json.intro.duration_ms, 800);
assert.equal(updated[1].animation_json.loop.iterations, 3);
assert.equal(template.regions[0].animation_json, '{"intro":{"preset":"fadeIn"}}');
assert.throws(() => buildTemplateAnimationUpdate(template, { ...body, baseline: [] }), { statusCode: 409 });
assert.throws(() => buildTemplateAnimationUpdate(template, { ...body, animations: body.animations.slice(0, 1) }), { statusCode: 400 });
});
test('shared animation route is protected and updates template animations without replacing rows or slide content', async function () {
const { handlers, calls, template, savedContent } = createSharedLayoutRouteHarness(false, true);
const route = handlers['POST /templates/:id/animations'];
assert.equal(route[0].permission, 'templates.update');
let response;
await route.at(-1)({ params: { id: '7' }, currentUser: { id: 1 }, body: {
slideId: 42,
baseline: getTemplateAnimationSnapshot(template),
animations: [{ region_key: 'hero', animation_json: { intro: { preset: 'zoomIn', duration_ms: 800 } } }]
} }, { json(value) { response = value; } }, error => { throw error; });
assert.equal(response.template.regions[0].id, 11);
assert.equal(response.template.regions[0].animation_json.intro.preset, 'zoomIn');
const contentUpdate = calls.find(call => call[0].startsWith('UPDATE c_slides'));
const content = JSON.parse(contentUpdate[1][0]);
assert.equal(content.hero.value, savedContent.hero.value);
assert.deepEqual(content.hero.layout, { x: 90, y: 80, width: 500, height: 200 });
assert.deepEqual(content.other, savedContent.other);
assert.ok(calls.some(call => call[0].startsWith('UPDATE c_template_regions SET animation_json')));
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/);
});
test('shared layout save regenerates cropped playback images and syncs the new file', async () => {
const fs = require('node:fs/promises');
const path = require('node:path');
const uploadDir = await fs.mkdtemp(path.join(require('node:os').tmpdir(), 'pulse-layout-crop-'));
try {
await require('sharp')({ create: { width: 1200, height: 800, channels: 3, background: '#ff0000' } }).png().toFile(path.join(uploadDir, 'original.png'));
const content = { type: 'image', value: '/media/uploads/small.png', original_value: '/media/uploads/original.png', crop: { x: 0, y: 0, width: 1200, height: 800 } };
const { handlers, calls, template } = createSharedLayoutRouteHarness(false, true, { uploadDir, content });
await handlers['POST /templates/:id/layout'].at(-1)({ params: { id: '7' }, body: {
baseline: getTemplateLayoutSnapshot(template),
regions: [{ region_key: 'hero', x: 0, y: 0, width: 900, height: 600, z_index: 1, lock_ratio: '' }]
} }, { json() {} }, error => { throw error; });
const write = calls.find(call => call[0].startsWith('UPDATE c_slides SET content_json'));
const saved = JSON.parse(write[1][0]).hero;
assert.equal(saved.original_value, content.original_value);
assert.deepEqual(saved.crop, content.crop);
const metadata = await require('sharp')(path.join(uploadDir, path.basename(saved.value))).metadata();
assert.equal(metadata.width, 900);
assert.equal(metadata.height, 600);
assert.ok(calls.some(call => call[0].startsWith('UPDATE c_media_assets SET is_published')));
assert.ok(calls.some(call => call[0] === 'sync' && call[1].nextUploadRefs.has(saved.value)));
assert.ok(calls.some(call => call[0].startsWith('INSERT IGNORE INTO c_content_references')));
} finally {
await fs.rm(uploadDir, { recursive: true, force: true });
}
});
for (const uncropped of [false, true]) {
test('shared image layouts resize ' + (uncropped ? 'uncropped' : 'cropped') + ' defaults but retain explicit slide geometry', async () => {
const fs = require('node:fs/promises');
const path = require('node:path');
const uploadDir = await fs.mkdtemp(path.join(require('node:os').tmpdir(), 'pulse-default-crop-'));
try {
await require('sharp')({ create: { width: 1200, height: 800, channels: 3, background: '#00ff00' } }).png().toFile(path.join(uploadDir, 'original.png'));
const original = { type: 'image', value: '/media/uploads/old.png', original_value: '/media/uploads/original.png', crop: uncropped ? null : { x: 0, y: 0, width: 1200, height: 800 } };
const { handlers, calls, template } = createSharedLayoutRouteHarness(false, true, {
uploadDir, defaultContent: { ...original }, content: { ...original, layout: { x: 0, y: 0, width: 150, height: 100 } }
});
await handlers['POST /templates/:id/layout'].at(-1)({ params: { id: '7' }, body: {
baseline: getTemplateLayoutSnapshot(template),
regions: [{ region_key: 'hero', x: 0, y: 0, width: 900, height: 600, z_index: 1, lock_ratio: '' }]
} }, { json() {} }, error => { throw error; });
const defaultWrite = calls.find(call => call[0].startsWith('UPDATE c_template_regions SET default_content_json'));
const defaultImage = JSON.parse(defaultWrite[1][0]);
const defaultMetadata = await require('sharp')(path.join(uploadDir, path.basename(defaultImage.value))).metadata();
assert.equal(defaultMetadata.width, 900);
assert.equal(defaultMetadata.height, 600);
const slideWrite = calls.find(call => call[0].startsWith('UPDATE c_slides SET content_json'));
const slideImage = JSON.parse(slideWrite[1][0]).hero;
const slideMetadata = await require('sharp')(path.join(uploadDir, path.basename(slideImage.value))).metadata();
assert.equal(slideMetadata.width, 150);
assert.equal(slideMetadata.height, 100);
assert.equal(slideImage.layout.width, 150);
assert.equal(defaultImage.original_value, original.original_value);
assert.deepEqual(defaultImage.crop, original.crop);
} finally {
await fs.rm(uploadDir, { recursive: true, force: true });
}
});
}