const test = require('node:test'); const assert = require('node:assert/strict'); const fs = require('node:fs'); const path = require('node:path'); const vm = require('node:vm'); const { buildSlidePayload } = require('../src/data/slides'); function loadApiRenderer(apiSources) { let renderer; const window = { placeholderUtils: {}, pulsePlayerRegionTypes: { register: function (_type, module) { renderer = module; } }, pulsePlayerRenderingShared: { normalizeRegionValue: function (value) { return String(value || ''); } } }; const context = { window, initialData: { apiSources: apiSources }, escapeHtml: String, sanitizeFontFamily: String, sanitizeFontSize: function (value) { return Number(value) || 16; }, sanitizeTextColor: String, sanitizeRichText: String, renderEditorJsContent: String }; vm.runInNewContext(fs.readFileSync(path.join(__dirname, '../src/web/public/js/shared/placeholder-utils.js'), 'utf8'), context); vm.runInNewContext(fs.readFileSync(path.join(__dirname, '../src/player/regions/api.js'), 'utf8'), context); return renderer; } function loadApiEditor() { let editor; const window = { pulseRegionTypes: { register: function (_type, module) { editor = module; } }, pulseRegionUtils: { escapeHtml: String, sanitizePreviewHtml: String, sanitizeFontFamily: String, sanitizeTextColor: String } }; const context = { window }; vm.runInNewContext(fs.readFileSync(path.join(__dirname, '../src/web/public/js/shared/placeholder-utils.js'), 'utf8'), context); vm.runInNewContext(fs.readFileSync(path.join(__dirname, '../src/web/public/js/regions/type/api.js'), 'utf8'), context); return editor; } test('API region sort settings persist in slide content', async () => { const pool = { async query(sql) { if (sql.includes('FROM c_templates st')) { return [[{ id: 8, name: 'Template 8', canvas_size_id: 1, canvas_size_width: 1920, canvas_size_height: 1080 }]]; } if (sql.includes('FROM c_template_regions')) { return [[{ id: 23, template_id: 8, region_key: 'apiRegion', region_type: 'api', label: 'API' }]]; } return [[]]; } }; const payload = await buildSlidePayload(pool, { body: { title: 'API Slide', template_id: '8', region_api_source_id_23: '11', region_api_items_path_23: 'results', region_api_sort_mode_23: 'field', region_api_sort_by_23: 'stats.kills', region_api_sort_direction_23: 'desc', region_text_23: '{{name}}' }, files: [] }, null); const content = JSON.parse(payload.contentJson).apiRegion; assert.equal(content.sort_by, 'stats.kills'); assert.equal(content.sort_direction, 'desc'); assert.equal(content.items_path, 'results'); assert.equal(content.item_number, 1); }); test('API field sorting defaults to ascending', async () => { const pool = { async query(sql) { if (sql.includes('FROM c_templates st')) { return [[{ id: 8, name: 'Template 8', canvas_size_id: 1, canvas_size_width: 1920, canvas_size_height: 1080 }]]; } if (sql.includes('FROM c_template_regions')) { return [[{ id: 23, template_id: 8, region_key: 'apiRegion', region_type: 'api', label: 'API' }]]; } return [[]]; } }; const payload = await buildSlidePayload(pool, { body: { title: 'API Slide', template_id: '8', region_api_source_id_23: '11', region_api_sort_mode_23: 'field', region_api_sort_by_23: 'stats.kills', region_text_23: '{{name}}' }, files: [] }, null); assert.equal(JSON.parse(payload.contentJson).apiRegion.sort_direction, 'asc'); }); test('player API region selects the requested item after sorting', () => { const renderer = loadApiRenderer([{ id: 11, responseJson: { results: [ { name: 'Ari', stats: { kills: 8 } }, { name: 'Bea', stats: { kills: 12 } }, { name: 'Cam', stats: { kills: 4 } } ] } }]); const rendered = renderer.renderRegion({ pixelWidth: 400, pixelHeight: 100, canvasScale: 1, baseStyle: '' }, { type: 'api', source_id: 11, item_number: 2, items_path: 'results', sort_by: 'stats.kills', sort_direction: 'desc', value: '{{0.name}} / {{1.name}} / {{name}}' }); assert.match(rendered, /Bea \/ Ari \/ Ari/); assert.doesNotMatch(rendered, /Cam/); }); test('API editor preview resolves zero-based indexed placeholders from sorted results', () => { const editor = loadApiEditor(); const preview = editor.renderPreview({ id: 23, region_key: 'apiRegion', fontFamily: 'Arial', fontSize: 20, fontColor: '#000000' }, { source_id: 11, item_number: 2, items_path: 'results', sort_by: 'stats.kills', sort_direction: 'desc', value: '{{0.name}} / {{1.name}} / {{name}}' }, { sources: [{ id: 11, responseJson: { results: [ { name: 'Ari', stats: { kills: 8 } }, { name: 'Bea', stats: { kills: 12 } }, { name: 'Cam', stats: { kills: 4 } } ] } }], existingContent: {} }); assert.match(preview, /Bea \/ Ari \/ Ari/); assert.doesNotMatch(preview, /Cam/); }); test('image and icon placeholder bounds are hidden in the popup but remain in the editor', () => { const editor = loadApiEditor(); const preview = editor.renderPreview({ id: 23, region_key: 'apiRegion', fontFamily: 'Arial', fontSize: 20, fontColor: '#000000' }, { source_id: 11, items_path: 'results', value: '{{image.image(120,80)}}' }, { sources: [{ id: 11, responseJson: { results: [{ image: 'https://example.com/image.png' }] } }], existingContent: {} }); const popupStyles = fs.readFileSync(path.join(__dirname, '../src/web/views/signage/slides/popup-preview.hbs'), 'utf8'); const rssSource = fs.readFileSync(path.join(__dirname, '../src/web/public/js/regions/type/rss.js'), 'utf8'); const weatherSource = fs.readFileSync(path.join(__dirname, '../src/web/public/js/regions/type/weather.js'), 'utf8'); const weatherIconsSource = fs.readFileSync(path.join(__dirname, '../src/web/public/js/shared/weather-icons.js'), 'utf8'); assert.match(preview, /class="api-image-placeholder-bounds slide-preview-image-placeholder-bounds"/); assert.match(preview, /border:1px dashed/); assert.match(rssSource, /class="slide-preview-image-placeholder-bounds"/); assert.match(weatherSource, /class="slide-preview-image-placeholder-bounds"/); assert.match(weatherIconsSource, /class="slide-preview-image-placeholder-bounds"/); assert.match(popupStyles, /#popup-preview-canvas \.slide-preview-image-placeholder-bounds,[\s\S]*?#popup-preview-canvas \.api-image-placeholder-bounds\s*\{\s*border:\s*0 !important;[\s\S]*?outline:\s*0 !important;[\s\S]*?box-shadow:\s*none !important;/); }); test('API region plain placeholders work with a single object response', () => { const renderer = loadApiRenderer([{ id: 11, responseJson: { name: 'Pikachu', type: 'Electric' } }]); const rendered = renderer.renderRegion({ pixelWidth: 400, pixelHeight: 100, canvasScale: 1, baseStyle: '' }, { type: 'api', source_id: 11, value: '{{name}} - {{type}}' }); assert.match(rendered, /Pikachu - Electric/); }); test('API region results panel exposes its open control without resize buttons', () => { const editor = loadApiEditor(); const markup = editor.renderEditorCard({ region: { id: 23 }, current: { value: '{{name}}' }, config: { source_id: 11, item_number: 1, items_path: '', sort_by: '', sort_direction: 'asc', default_items_path: '' }, sourceOptions: '', placeholderFields: [], placeholderChips: '', sampleDataPreview: '[]', fontSize: 32 }); assert.match(markup, /data-api-sample-toggle[^>]*aria-expanded="false"/); assert.doesNotMatch(markup, /data-api-sample-resize/); assert.match(markup, /data-api-sample-data-panel hidden/); assert.match(markup, /btn-outline-secondary w-100" data-api-advanced-toggle aria-expanded="false"/); assert.match(markup, /data-api-advanced-options hidden/); assert.match(markup, />Order results]*hidden/); assert.match(expandedMarkup, /name="region_api_sort_by_24"[^>]*value="stats\.kills"/); }); test('API results preview always includes the first result completely', () => { let source = fs.readFileSync(path.join(__dirname, '../src/web/public/js/slides/slide-form-regions.js'), 'utf8'); source = source.replace("import { createSlideFormPreviewHelpers } from '/assets/js/slides/slide-form-preview.js';", '').replace('export function createSlideFormRegionHelpers', 'function createSlideFormRegionHelpers'); const context = { window: { placeholderUtils: require('../src/web/public/js/shared/placeholder-utils'), placeholderChips: { renderChip: function (value) { return String(value); } } }, createSlideFormPreviewHelpers: function () { return {}; } }; vm.runInNewContext(source + '\nthis.createSlideFormRegionHelpers = createSlideFormRegionHelpers;', context); const longDescription = 'x'.repeat(1500); const helpers = context.createSlideFormRegionHelpers({ apiSources: [ { id: 11, responseJson: { results: [{ name: 'Long result', products: [{ description: longDescription }] }, { name: 'Second result' }] } }, { id: 12, responseJson: { name: 'Single object' } } ], getRegionTypeModule: function () { return null; } }); const markup = helpers.buildApiSampleDataMarkup(11, 'results', '', 'asc'); const firstItemMarkup = markup.match(/data-api-sample-data-output>([\s\S]*?)<\/div>/); assert.ok(firstItemMarkup); assert.ok(firstItemMarkup[1].includes(longDescription)); assert.match(firstItemMarkup[1], /\}\s*$/); assert.equal((markup.match(/class="api-region-sample-preview(?:\s|\")/g) || []).length, 1); assert.match(markup, /Example result \(first item\)/); assert.match(markup, /data-json-toggle-collapse-all/); assert.match(markup, /data-json-toggle-expand-all/); assert.match(markup, /data-json-toggle-panel/); assert.doesNotMatch(markup, /Second result|Remaining results/); const objectMarkup = helpers.buildApiSampleDataMarkup(12, '', '', 'asc'); assert.match(objectMarkup, /Example result