const fs = require('node:fs'); const path = require('node:path'); const vm = require('node:vm'); const test = require('node:test'); const assert = require('node:assert/strict'); test('slide table thumbnails size to the canvas ratio and show the whole image', () => { const stylesheet = fs.readFileSync(path.join(__dirname, '..', 'src', 'web', 'public', 'css', 'theme-custom.css'), 'utf8'); const thumbRule = stylesheet.match(/^\.playlist-slide-thumb\s*\{([^}]+)\}/m)[1]; const imageRule = stylesheet.match(/^\.playlist-slide-thumb-image\s*\{([^}]+)\}/m)[1]; const placeholderRule = stylesheet.match(/^\.playlist-slide-thumb-placeholder\s*\{([^}]+)\}/m)[1]; assert.match(thumbRule, /width:\s*min\(5\.5rem, calc\(5\.5rem \* \(var\(--playlist-slide-thumb-aspect-ratio, 16 \/ 9\)\)\)\);/); assert.match(thumbRule, /aspect-ratio:\s*var\(--playlist-slide-thumb-aspect-ratio, 16 \/ 9\);/); assert.match(thumbRule, /min-height:\s*0;/); assert.match(thumbRule, /flex:\s*0 0 auto;/); assert.match(imageRule, /object-fit:\s*contain;/); assert.match(placeholderRule, /min-width:\s*0;/); assert.match(placeholderRule, /min-height:\s*0;/); }); test('slide list thumbnails open and release the shared saved-image preview modal', () => { const listTemplate = fs.readFileSync(path.join(__dirname, '..', 'src', 'web', 'views', 'signage', 'slides', 'list.hbs'), 'utf8'); const previewTemplate = fs.readFileSync(path.join(__dirname, '..', 'src', 'web', 'views', 'shared', 'slide-preview-modal.hbs'), 'utf8'); const stylesheet = fs.readFileSync(path.join(__dirname, '..', 'src', 'web', 'public', 'css', 'theme-custom.css'), 'utf8'); assert.match(listTemplate, /class="playlist-slide-thumb slide-list-thumbnail-button"[^>]*data-bs-toggle="modal"/); assert.match(listTemplate, /data-slide-preview-url="\{\{thumbnail_path\}\}"/); assert.match(listTemplate, /data-canvas-width="\{\{canvas_width\}\}" data-canvas-height="\{\{canvas_height\}\}"/); assert.match(listTemplate, /\{\{> slide-preview-modal\}\}/); assert.match(previewTemplate, /currentAspectRatio = canvasWidth > 0 && canvasHeight > 0 \? canvasWidth \/ canvasHeight : 16 \/ 9/); assert.match(listTemplate, /aria-label="Preview slide: \{\{title\}\}"/); assert.match(previewTemplate, /]+data-slide-preview-image hidden/); assert.match(previewTemplate, /image\.src = thumbnailUrl\.replace/); assert.match(previewTemplate, /image\.src = thumbnailUrl;/); assert.match(previewTemplate, /modal\.addEventListener\('hidden\.bs\.modal', function \(\) \{\s*thumbnailUrl = '';\s*image\.hidden = true;\s*image\.removeAttribute\('src'\)/); assert.doesNotMatch(previewTemplate, / { const stylesheet = fs.readFileSync(path.join(__dirname, '..', 'src', 'web', 'public', 'css', 'theme-custom.css'), 'utf8'); const columnRule = stylesheet.match(/^td\[data-label="Thumbnail"\] > \.playlist-slide-thumb\s*\{([^}]+)\}/m)[1]; const cellRule = stylesheet.match(/^\.card \.table td\[data-label="Thumbnail"\]\s*\{([^}]+)\}/m)[1]; assert.match(columnRule, /margin-inline:\s*auto;/); assert.match(cellRule, /width:\s*1%;/); assert.match(cellRule, /padding-inline:\s*0\.5rem;/); assert.match(cellRule, /white-space:\s*nowrap;/); }); test('table header actions retain the same right inset as the pagination footer', () => { const stylesheet = fs.readFileSync(path.join(__dirname, '..', 'src', 'web', 'public', 'css', 'theme-custom.css'), 'utf8'); const toolsRule = stylesheet.match(/\[data-table-search-container\] > \.card-header:has\(> \.card-tools\) > \.card-tools\s*\{([^}]+)\}/)[1]; assert.match(toolsRule, /margin-right:\s*0;/); }); test('mobile responsive tables keep descriptive columns wide enough for phrases', () => { const stylesheet = fs.readFileSync(path.join(__dirname, '..', 'src', 'web', 'public', 'css', 'theme-custom.css'), 'utf8'); const mobileRule = stylesheet.match(/@media \(max-width: 767\.98px\)\s*\{\s*\.table-responsive > table\.table > tbody > tr > td:is\(([\s\S]*?)\)\s*\{([^}]+)\}/); assert.ok(mobileRule); for (const label of ['Name', 'Title', 'Template', 'Description', 'Client', 'Current Screen', 'Current Slide', 'Slides']) { assert.ok(mobileRule[1].includes('[data-label="' + label + '"]')); } assert.match(mobileRule[2], /min-width:\s*14rem;/); assert.match(mobileRule[2], /overflow-wrap:\s*break-word;/); }); function createPaginationHarness(scrollHeight) { const cardStyle = { properties: {}, setProperty(name, value) { this.properties[name] = String(value); }, removeProperty(name) { delete this.properties[name]; } }; const card = { style: cardStyle, scrollHeight, getBoundingClientRect() { return { top: 100 }; } }; const bodyClassList = { toggled: null, toggle(name, value) { this.toggled = { name, value }; } }; const sandbox = { document: { body: { classList: bodyClassList }, querySelector(selector) { return selector === '[data-table-pagination-card]' ? card : null; }, querySelectorAll(selector) { return selector === '[data-table-pagination-card]' ? [card] : []; } }, window: null, URL, console, setTimeout, clearTimeout }; sandbox.window = Object.assign(sandbox, { innerHeight: 280, addEventListener() {}, clearTimeout, setTimeout, requestAnimationFrame(callback) { callback(); } }); const scriptPath = path.join(__dirname, '..', 'src', 'web', 'public', 'js', 'table', 'table-search.js'); const script = fs.readFileSync(scriptPath, 'utf8'); vm.runInNewContext(script, sandbox, { filename: scriptPath }); return { cardStyle, bodyClassList }; } test('table pagination cards skip the minimum height for short content', () => { const { cardStyle, bodyClassList } = createPaginationHarness(200); assert.equal(cardStyle.properties['--table-pagination-card-min-height'], undefined); assert.equal(cardStyle.properties['--table-pagination-card-max-height'], '320px'); assert.deepEqual(bodyClassList.toggled, { name: 'table-pagination-page', value: true }); }); test('table pagination cards keep the minimum height for tall content', () => { const { cardStyle, bodyClassList } = createPaginationHarness(640); assert.equal(cardStyle.properties['--table-pagination-card-min-height'], '320px'); assert.equal(cardStyle.properties['--table-pagination-card-max-height'], '320px'); assert.deepEqual(bodyClassList.toggled, { name: 'table-pagination-page', value: true }); }); function createNavigationHarness() { const events = {}; const requests = []; const navigations = []; const history = []; const body = { outerHTML: 'original rows' }; const footer = { outerHTML: 'original footer' }; const attributes = {}; const inputEvents = {}; const timers = new Map(); let nextTimerId = 0; const scroller = { scrollTop: 90 }; const input = { value: '', getAttribute(name) { return attributes[name] || null; }, setAttribute(name, value) { attributes[name] = value; }, closest(selector) { return selector === '[data-table-search-container]' ? container : null; }, addEventListener(name, handler) { inputEvents[name] = handler; }, focus() {} }; const table = { tBodies: [body] }; const container = { querySelector(selector) { return { '[data-table-searchable]': table, '.card-footer': footer, '[data-table-search]': input, '.table-responsive': scroller }[selector] || null; }, querySelectorAll(selector) { return selector === '[data-table-search]' ? [input] : []; }, setAttribute(name, value) { attributes[name] = value; }, removeAttribute(name) { delete attributes[name]; } }; const sandbox = { URL, document: { title: 'Tables', querySelector() { return null; }, querySelectorAll(selector) { return selector === '[data-table-search-container]' ? [container] : []; }, addEventListener(name, handler) { events[name] = handler; } }, DOMParser: class { parseFromString(text) { const nextContainer = { querySelector(selector) { if (selector === '[data-table-searchable]') return { tBodies: [{ outerHTML: text + ' rows' }] }; if (selector === '.card-footer') return { outerHTML: text + ' footer' }; return null; } }; return { querySelector() { return text === 'missing' ? null : nextContainer; }, querySelectorAll() { return text === 'missing' ? [] : [nextContainer]; } }; } }, fetch(url, options) { return new Promise((resolve, reject) => requests.push({ url, options, resolve, reject })); }, window: { location: { href: 'http://localhost/slides?search=demo&sort=name', assign(url) { navigations.push(url); } }, history: { state: { preserved: true } }, addEventListener(name, handler) { events[name] = handler; }, clearTimeout(id) { timers.delete(id); }, setTimeout(handler) { const id = ++nextTimerId; timers.set(id, handler); return id; } } }; for (const method of ['pushState', 'replaceState']) { sandbox.window.history[method] = (state, title, url) => { history.push({ method, state, url }); sandbox.window.location.href = new URL(url, sandbox.window.location.href).href; }; } const scriptPath = path.join(__dirname, '..', 'src', 'web', 'public', 'js', 'table', 'table-search.js'); vm.runInNewContext(fs.readFileSync(scriptPath, 'utf8'), sandbox); sandbox.window.initTableSearches(container); function click(options = {}) { const link = { href: 'http://localhost/slides?search=demo&sort=name&page=2', hasAttribute() { return false; }, closest(selector) { if (selector === '[data-table-search-container]') return container; return options.disabled ? {} : null; } }; const event = { button: 0, target: { closest() { return link; } }, preventDefault() { this.defaultPrevented = true; }, ...options }; events.click(event); return event; } async function respond(index, text, ok = true) { requests[index].resolve({ ok, text: async () => text }); await new Promise(resolve => setImmediate(resolve)); } return { sandbox, container, events, requests, navigations, history, body, footer, attributes, input, inputEvents, scroller, click, respond, runTimers() { const pending = Array.from(timers.values()); timers.clear(); pending.forEach(handler => handler()); } }; } test('pagination replaces only table results and pushes a URL preserving search and sort', async () => { const harness = createNavigationHarness(); assert.equal(harness.click().defaultPrevented, true); assert.equal(harness.attributes['aria-busy'], 'true'); assert.equal(harness.requests[0].options.headers['X-Requested-With'], 'XMLHttpRequest'); await harness.respond(0, 'page two'); assert.equal(harness.body.outerHTML, 'page two rows'); assert.equal(harness.footer.outerHTML, 'page two footer'); assert.equal(harness.history[0].method, 'pushState'); assert.equal(harness.history[0].url, '/slides?search=demo&sort=name&page=2'); assert.equal(harness.history[0].state.preserved, true); assert.equal(harness.attributes['aria-busy'], undefined); assert.equal(harness.scroller.scrollTop, 0); assert.deepEqual(harness.navigations, []); }); test('pagination leaves modified clicks native and ignores disabled links', () => { const harness = createNavigationHarness(); assert.equal(harness.click({ ctrlKey: true }).defaultPrevented, undefined); assert.equal(harness.click({ button: 1 }).defaultPrevented, undefined); assert.equal(harness.click({ disabled: true }).defaultPrevented, true); assert.equal(harness.requests.length, 0); }); test('Back and Forward fetch the URL table state without adding history entries', async () => { const harness = createNavigationHarness(); harness.sandbox.window.location.href = 'http://localhost/slides?search=other&page=3'; harness.events.popstate(); await harness.respond(0, 'restored'); assert.equal(harness.body.outerHTML, 'restored rows'); assert.equal(harness.input.value, 'other'); assert.equal(harness.history.length, 0); }); test('a newer request wins over slower results and stale failures', async () => { const harness = createNavigationHarness(); harness.click(); harness.events.popstate(); await harness.respond(1, 'newest'); await harness.respond(0, 'old'); assert.equal(harness.body.outerHTML, 'newest rows'); harness.click(); harness.events.popstate(); await harness.respond(3, 'latest'); await harness.respond(2, 'failed', false); assert.equal(harness.body.outerHTML, 'latest rows'); assert.deepEqual(harness.navigations, []); }); test('failed or missing table results fall back to normal navigation without updating history', async () => { for (const response of [{ text: 'error', ok: false }, { text: 'missing', ok: true }]) { const harness = createNavigationHarness(); harness.click(); await harness.respond(0, response.text, response.ok); assert.equal(harness.navigations[0], harness.requests[0].url); assert.equal(harness.history.length, 0); assert.equal(harness.attributes['aria-busy'], undefined); } }); test('typing invalidates pending pagination and search still resets to page one', async () => { const harness = createNavigationHarness(); harness.click(); harness.input.value = 'new query'; harness.inputEvents.input(); await harness.respond(0, 'outdated'); assert.equal(harness.body.outerHTML, 'original rows'); assert.equal(harness.input.value, 'new query'); harness.runTimers(); assert.equal(new URL(harness.requests[1].url).searchParams.get('page'), null); await harness.respond(1, 'searched'); assert.equal(harness.body.outerHTML, 'searched rows'); assert.equal(harness.history[0].method, 'replaceState'); }); test('blur flushes a pending search once and does not repeat an active search', async () => { const pendingHarness = createNavigationHarness(); pendingHarness.input.value = 'button race'; pendingHarness.inputEvents.input(); pendingHarness.inputEvents.change(); assert.equal(pendingHarness.requests.length, 1); pendingHarness.runTimers(); assert.equal(pendingHarness.requests.length, 1); const activeHarness = createNavigationHarness(); activeHarness.input.value = 'button race'; activeHarness.inputEvents.input(); activeHarness.runTimers(); assert.equal(activeHarness.requests.length, 1); activeHarness.inputEvents.change(); assert.equal(activeHarness.requests.length, 1); await activeHarness.respond(0, 'searched'); });