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 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(/]*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], /