Release 2.16.0
Publish Docker Image / build-and-push-existing-registry (./build/Dockerfile, web, pulse-signage-web) (push) Successful in 1m46s
Publish Docker Image / build-and-push-existing-registry (./build/Dockerfile.player, player, pulse-signage-player) (push) Successful in 33s

This commit is contained in:
lzstealth committed 2026-10-20 18:52:36 +01:00
1 parent 821593dd8f
commit 3514d514a3
36 files changed
+2329 -346

No files matched your search

+484
View File
@@ -0,0 +1,484 @@
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-thumbnail-preview');
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 animation settings/);
assert.match(animationModal, /id="slide-animation-advanced"[^>]*hidden/);
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;/);
});
test('advanced animation section uses compact heading and card spacing', function () {
const css = fs.readFileSync(require.resolve('../src/web/public/css/theme-custom.css'), 'utf8');
assert.match(css, /#slide-animation-advanced\s*\{\s*gap:\s*0\.5rem\s*!important;/);
assert.match(css, /\.slide-animation-advanced-fields \.row\s*\{[^}]*gap:\s*0\.5rem;/);
});
test('shared animation controls round-trip advanced settings with template normalization', function () {
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);
});
function createSharedLayoutRouteHarness(failUpdate, allowSlideUpdate) {
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"}}'
}] };
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 (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); }
}, {
pool: { async getConnection() { return connection; }, async query() { return [[{ slide_count: 3 }]]; } },
common: { fetchTemplateById: async () => template, parseJsonSafe: JSON.parse }, pages: {},
upload: { any: () => noop, single: () => noop }, setAuthMessageCookie: noop,
fetchScreensBySlideId: async () => [], fetchScreensByTemplateId: async () => [{ slug: 'lobby' }],
collectUploadReferencesFromSlide: () => [], collectUploadReferencesFromTemplate: () => [],
collectUploadReferencesFromPayload: () => [], removeUnusedUploadFiles: noop,
syncPlaylistUploadsOnChange: noop, 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 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.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'));
});
+38
View File
@@ -1,6 +1,7 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const vm = require('node:vm');
const slideFormEditorSource = fs.readFileSync(require.resolve('../src/web/public/js/slides/slide-form-editor.js'), 'utf8');
const slideFormSource = fs.readFileSync(require.resolve('../src/web/public/js/slides/slide-form.js'), 'utf8');
@@ -108,4 +109,41 @@ test('webpage preview updates only through the explicit button', () => {
assert.ok(slideWebpageRegionSource.includes('data-webpage-preview-update'));
assert.ok(slideFormSource.includes('input[type="url"][name^="region_webpage_"]'));
assert.ok(slideFormSource.includes('requestPreviewRender();'));
});
test('HTML editor shares bounded size controls without changing its content', async () => {
const source = { dataset: {}, style: {}, value: '<div>Keep this HTML</div>', getAttribute: () => '708' };
let clickHandler;
const templateFields = {
addEventListener: (event, handler) => { clickHandler = handler; },
querySelector: (selector) => selector.includes('.html-editor-source') ? source : null,
querySelectorAll: (selector) => selector === '.html-editor-source' ? [source] : []
};
const sandbox = { window: {} };
vm.runInNewContext(slideFormEditorSource.replace('export function', 'function'), sandbox);
const controller = sandbox.createSlideFormEditorController({ templateFields });
await controller.renderEditors();
assert.equal(source.style.height, '360px');
function resize(action, count = 1) {
const button = {
getAttribute: () => action,
closest: () => ({ getAttribute: () => '708' })
};
for (let index = 0; index < count; index += 1) {
clickHandler({ target: { closest: () => button }, preventDefault() {} });
}
}
resize('increase');
assert.equal(source.style.height, '440px');
resize('decrease');
assert.equal(source.style.height, '360px');
resize('decrease', 20);
assert.equal(source.style.height, '240px');
resize('increase', 20);
assert.equal(source.style.height, '960px');
assert.equal(source.value, '<div>Keep this HTML</div>');
assert.match(slideHtmlRegionSource, /style="resize:none;"/);
assert.match(slideHtmlRegionSource, /sandboxed iframe preview\.<\/div>'\s*\+\s*'<div class="btn-group/);
});
+28 -10
View File
@@ -275,6 +275,14 @@ test('template preview preserves the live canvas aspect and visual settings', ()
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');
@@ -315,20 +323,23 @@ test('template animation controls mark the form dirty', () => {
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');
const sharedControls = fs.readFileSync(require.resolve('../src/web/public/js/shared/region-animation-controls.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.match(modal, /signage\/templates\/animation-advanced-fields/);
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(sharedControls.includes('Math.round(Number(value) / 10)'));
assert.ok(sharedControls.includes('Math.round(Number(value)) || 100)) * 10'));
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(sharedControls, /Math\.min\(999, Math\.max\(1, Math\.round\(Number\(value\)\) \|\| 1\)\)/);
assert.match(utilsScript, /MAX_ANIMATION_REPEAT_COUNT = 999/);
assert.match(utilsScript, /clamp\(Math\.round\(Number\(value\.iterations\)\), 1, MAX_ANIMATION_REPEAT_COUNT\)/);
});
@@ -488,4 +499,11 @@ test('normalizeBackgroundGradient accepts linear colors and clamps the angle', (
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/);
});