328 lines
14 KiB
JavaScript
328 lines
14 KiB
JavaScript
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, /<img[^>]+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, /<iframe\b|popup-preview|about:blank/);
|
|
assert.match(stylesheet, /^\.slide-list-thumbnail-button:focus-visible\s*\{/m);
|
|
const previewFrameRule = stylesheet.match(/^\.slide-list-preview-frame\s*\{([^}]+)\}/m)[1];
|
|
assert.match(previewFrameRule, /background:\s*var\(--bs-body-bg\);/);
|
|
assert.match(previewFrameRule, /aspect-ratio:\s*var\(--slide-preview-aspect-ratio, 16 \/ 9\);/);
|
|
assert.match(stylesheet, /#slide-preview-modal \.modal-content\s*\{\s*overflow:\s*hidden;/);
|
|
});
|
|
|
|
test('standalone slide thumbnail columns shrink to their content and keep thumbnails centered', () => {
|
|
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 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() {},
|
|
setTimeout(handler) { events.timer = handler; return 1; }
|
|
}
|
|
};
|
|
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 };
|
|
}
|
|
|
|
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.events.timer();
|
|
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');
|
|
});
|