800 lines
47 KiB
JavaScript
800 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(element) {
|
|
return { animationDuration: element.dataset.regionKey === 'fixed_brand' ? '400ms' : '100ms' };
|
|
},
|
|
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: {
|
|
outro: { preset: 'fadeOut', duration_ms: 100 },
|
|
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');
|
|
assert.equal(secondPreviewRegion.style.values['animation-delay'], '300ms');
|
|
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 });
|
|
}
|
|
});
|
|
} |