Files
wordpress-theme-aksrt/tests/widget-system/tab-posts-runtime.test.cjs
T

531 lines
21 KiB
JavaScript

'use strict';
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const vm = require('node:vm');
const test = require('node:test');
const root = path.resolve(__dirname, '..', '..');
class MiniElement {
constructor(tagName = 'div') {
this.tagName = String(tagName).toUpperCase();
this.parentNode = null;
this.children = [];
this.attributes = {};
this.dataset = {};
this.listeners = {};
this.offsetLeft = 0;
this.offsetWidth = 0;
this.scrollWidth = 0;
this.clientWidth = 0;
this.scrollLeft = 0;
this.rectLeft = 0;
this.rectWidth = 0;
this.scrollCalls = [];
this._text = '';
this._classes = new Set();
this.classList = {
add: (...tokens) => tokens.forEach((token) => this._classes.add(token)),
remove: (...tokens) => tokens.forEach((token) => this._classes.delete(token)),
contains: (token) => this._classes.has(token),
toggle: (token, force) => {
const shouldAdd = force === undefined ? !this._classes.has(token) : Boolean(force);
if (shouldAdd) this._classes.add(token);
else this._classes.delete(token);
return shouldAdd;
},
};
}
get className() { return Array.from(this._classes).join(' '); }
set className(value) { this._classes = new Set(String(value || '').split(/\s+/).filter(Boolean)); }
get id() { return this.getAttribute('id') || ''; }
set id(value) { this.setAttribute('id', value); }
get max() { return this.getAttribute('max') || ''; }
set max(value) { this.setAttribute('max', value); }
get value() { return this._value || ''; }
set value(value) { this._value = String(value); }
get parentElement() { return this.parentNode && this.parentNode.tagName !== '#DOCUMENT' ? this.parentNode : null; }
get firstChild() { return this.children[0] || null; }
get textContent() { return this._text + this.children.map((child) => child.textContent).join(''); }
set textContent(value) { this._text = String(value); this.replaceChildren(); }
get innerHTML() { return this.children.map((child) => child.outerHTML || child.textContent).join(''); }
set innerHTML(markup) {
this.replaceChildren();
parseMarkup(String(markup || ''), this);
}
setAttribute(name, value) {
const text = String(value);
this.attributes[name] = text;
if (name === 'class') this.className = text;
if (name.startsWith('data-')) this.dataset[toDatasetKey(name.slice(5))] = text;
}
getAttribute(name) { return Object.hasOwn(this.attributes, name) ? this.attributes[name] : null; }
hasAttribute(name) { return Object.hasOwn(this.attributes, name); }
appendChild(child) {
if (child.parentNode) child.remove();
child.parentNode = this;
this.children.push(child);
return child;
}
insertBefore(child, anchor) {
if (!anchor || anchor.parentNode !== this) return this.appendChild(child);
if (child.parentNode) child.remove();
child.parentNode = this;
this.children.splice(this.children.indexOf(anchor), 0, child);
return child;
}
replaceChildren(...nodes) {
this.children.forEach((child) => { child.parentNode = null; });
this.children = [];
nodes.forEach((node) => this.appendChild(node));
}
remove() {
if (!this.parentNode) return;
const siblings = this.parentNode.children;
const index = siblings.indexOf(this);
if (index !== -1) siblings.splice(index, 1);
this.parentNode = null;
}
addEventListener(type, callback) {
(this.listeners[type] ||= []).push(callback);
}
dispatch(type, properties = {}) {
const event = {
type,
target: this,
defaultPrevented: false,
preventDefault() { this.defaultPrevented = true; },
stopPropagation() { this.propagationStopped = true; },
...properties,
};
let node = this;
while (node) {
for (const listener of node.listeners[type] || []) listener(event);
if (event.propagationStopped) break;
node = node.parentNode;
}
return event;
}
click() { return this.dispatch('click'); }
focus() { this.ownerDocument.activeElement = this; }
closest(selector) {
for (let node = this; node; node = node.parentNode) {
if (matchesCompound(node, selector)) return node;
}
return null;
}
matches(selector) { return matchesCompound(this, selector); }
querySelectorAll(selector) {
const directOnly = selector.startsWith(':scope > ');
const query = directOnly ? selector.slice(9) : selector;
const nodes = directOnly ? this.children.slice() : descendants(this);
return nodes.filter((node) => matchesSelector(node, query));
}
querySelector(selector) { return this.querySelectorAll(selector)[0] || null; }
insertAdjacentHTML(position, markup) {
const fragment = this.ownerDocument.createElement('div');
fragment.innerHTML = markup;
const nodes = fragment.children.slice();
if (position === 'afterbegin') nodes.reverse().forEach((node) => this.insertBefore(node, this.firstChild));
else if (position === 'beforebegin' && this.parentNode) nodes.forEach((node) => this.parentNode.insertBefore(node, this));
else nodes.forEach((node) => this.appendChild(node));
}
scrollTo(options) { this.scrollCalls.push(options); }
getBoundingClientRect() { return { left: this.rectLeft, width: this.rectWidth || this.offsetWidth }; }
}
class MiniDocument extends MiniElement {
constructor() {
super('#document');
this.ownerDocument = this;
this.readyState = 'complete';
this.body = this.createElement('body');
this.appendChild(this.body);
this.activeElement = null;
}
createElement(tagName) {
const element = new MiniElement(tagName);
element.ownerDocument = this;
return element;
}
}
function toDatasetKey(value) { return value.replace(/-([a-z])/g, (_match, letter) => letter.toUpperCase()); }
function descendants(node) {
return node.children.flatMap((child) => [child, ...descendants(child)]);
}
function matchesCompound(node, selector) {
if (!selector || selector === '*') return true;
const tag = selector.match(/^[a-z][a-z0-9-]*/i);
if (tag && node.tagName.toLowerCase() !== tag[0].toLowerCase()) return false;
const id = selector.match(/#([a-zA-Z_][\w-]*)/);
if (id && node.getAttribute('id') !== id[1]) return false;
for (const [, className] of selector.matchAll(/\.([a-zA-Z_][\w-]*)/g)) {
if (!node.classList.contains(className)) return false;
}
for (const [, name, expected] of selector.matchAll(/\[([\w-]+)(?:=["']?([^\]"']+)["']?)?\]/g)) {
if (!node.hasAttribute(name)) return false;
if (expected !== undefined && node.getAttribute(name) !== expected) return false;
}
return true;
}
function matchesSelector(node, selector) {
const tokens = selector.replace(/>/g, ' > ').trim().split(/\s+/);
let cursor = node;
if (!matchesCompound(cursor, tokens.pop())) return false;
while (tokens.length) {
const combinator = tokens.pop();
const previous = tokens.pop();
if (combinator === '>') {
cursor = cursor.parentNode;
if (!cursor || !matchesCompound(cursor, previous)) return false;
} else {
cursor = cursor.parentNode;
while (cursor && !matchesCompound(cursor, previous)) cursor = cursor.parentNode;
if (!cursor) return false;
}
}
return true;
}
function parseMarkup(markup, parent) {
const tokens = markup.match(/<!--[\s\S]*?-->|<[^>]+>|[^<]+/g) || [];
const stack = [parent];
const voidTags = new Set(['area', 'base', 'br', 'col', 'embed', 'hr', 'img', 'input', 'link', 'meta', 'param', 'source', 'track', 'wbr']);
for (const token of tokens) {
if (token.startsWith('<!--') || token.trim() === '') continue;
if (token.startsWith('</')) {
if (stack.length > 1) stack.pop();
continue;
}
if (!token.startsWith('<')) {
const textNode = parent.ownerDocument.createElement('#text');
textNode._text = token;
stack[stack.length - 1].appendChild(textNode);
continue;
}
const open = token.match(/^<([a-z][\w-]*)([\s\S]*?)\/?\s*>$/i);
if (!open) continue;
const node = parent.ownerDocument.createElement(open[1]);
for (const [, name, quoted, bare] of open[2].matchAll(/([\w-]+)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+)))?/g)) {
node.setAttribute(name, quoted ?? bare ?? '');
}
stack[stack.length - 1].appendChild(node);
if (!voidTags.has(node.tagName.toLowerCase()) && !token.endsWith('/>')) stack.push(node);
}
}
function tabMarkup(document, { firstPage = 1, firstSegmentLoaded = true } = {}) {
const root = document.createElement('div');
root.className = 'widget-tab-post style-mini posts-mini-lists zib-widget';
root.setAttribute('data-widget', 'tab_posts');
root.setAttribute('data-instance', 'widget-7');
const nav = document.createElement('ul');
nav.className = 'list-inline scroll-x no-scrollbar';
nav.setAttribute('role', 'tablist');
nav.clientWidth = 260;
nav.scrollWidth = 420;
const tabOne = addTab(document, nav, 0, 'tab-one', 'panel-one', true);
const tabTwo = addTab(document, nav, 1, 'tab-two', 'panel-two', false);
nav.rectLeft = 20;
tabOne.rectLeft = 100;
tabOne.rectWidth = 100;
tabTwo.rectLeft = 380;
tabTwo.rectWidth = 100;
root.appendChild(nav);
const content = document.createElement('div');
content.className = 'tab-content';
content.appendChild(addPane(document, 'panel-one', 'tab-one', 0, firstPage, firstSegmentLoaded, true));
content.appendChild(addPane(document, 'panel-two', 'tab-two', 1, 1, false, false));
root.appendChild(content);
return { root, nav, tabOne, tabTwo };
}
function addTab(document, nav, segment, id, panelId, selected) {
const item = document.createElement('li');
if (selected) item.classList.add('active');
const tab = document.createElement('a');
tab.setAttribute('role', 'tab');
tab.setAttribute('id', id);
tab.setAttribute('aria-controls', panelId);
tab.setAttribute('aria-selected', selected ? 'true' : 'false');
tab.setAttribute('tabindex', selected ? '0' : '-1');
tab.setAttribute('data-segment', String(segment));
item.appendChild(tab);
nav.appendChild(item);
return tab;
}
function addPane(document, id, tabId, segment, page, loaded, active) {
const pane = document.createElement('div');
pane.className = 'tab-pane fade' + (active ? ' active in' : '');
pane.setAttribute('id', id);
pane.setAttribute('role', 'tabpanel');
pane.setAttribute('aria-labelledby', tabId);
const pager = document.createElement('div');
pager.className = 'widget-ajaxpager';
pager.setAttribute('data-widget-items', '');
pager.setAttribute('data-widget', 'tab_posts');
pager.setAttribute('data-instance', 'widget-7');
pager.setAttribute('data-segment', String(segment));
pager.setAttribute('data-page', String(page));
if (loaded) pager.setAttribute('data-loaded', '1');
if (active && loaded) {
pager.innerHTML = '<posts class="posts-mini ajax-item" data-post-id="post-one"><div class="posts-mini-con"><h2 class="item-heading"><a>Page one</a></h2></div></posts>';
pager.appendChild(nextPage(document, 2));
} else {
const loader = document.createElement('div');
loader.className = 'post_ajax_loader';
pager.appendChild(loader);
}
pane.appendChild(pager);
return pane;
}
function nextPage(document, page) {
const pagination = document.createElement('div');
pagination.className = 'ajax-pag';
const link = document.createElement('a');
link.setAttribute('data-next-page', String(page));
pagination.appendChild(link);
return pagination;
}
function addJumpControl(document, pager, { max = 20, value = '4' } = {}) {
const pagination = document.createElement('div');
pagination.className = 'ajax-pag';
const wrapper = document.createElement('span');
wrapper.className = 'pag-jump';
const input = document.createElement('input');
input.setAttribute('data-widget-jump', '');
input.setAttribute('type', 'text');
input.max = max;
input.value = value;
const button = document.createElement('button');
button.setAttribute('data-widget-jump-go', '');
wrapper.appendChild(input);
wrapper.appendChild(button);
pagination.appendChild(wrapper);
pager.appendChild(pagination);
return { input, button };
}
function createRuntime({ fetch, pages } = {}) {
const document = new MiniDocument();
const { root: widget, nav, tabOne, tabTwo } = tabMarkup(document);
document.body.appendChild(widget);
const autoFun = [];
function jQuery() {
return {
on(event, callback) { if (event === 'auto_fun') autoFun.push(callback); return this; },
trigger(event) { if (event === 'auto_fun') autoFun.forEach((callback) => callback()); return this; },
};
}
const requests = [];
const config = { restUrl: '/wp-json/aksrt/v1', i18n: { error: 'error', retry: 'retry' } };
const fetchStub = fetch || ((url) => {
requests.push(url);
const page = Number(new URL(url, 'https://example.test').searchParams.get('page'));
const segment = new URL(url, 'https://example.test').searchParams.get('segment');
const response = (pages && pages[`${segment}:${page}`]) || {
html: `<posts class="posts-mini ajax-item" data-post-id="post-${segment}-${page}"><div class="posts-mini-con"><h2 class="item-heading"><a>Returned ${segment}/${page}</a></h2></div></posts>`,
page,
paginate_html: page < 3 ? `<div class="ajax-pag"><a data-next-page="${page + 1}">more</a></div>` : '',
};
return Promise.resolve({ ok: true, json: () => Promise.resolve(response) });
});
const window = {
aksrtWidgets: config,
matchMedia: () => ({ matches: false }),
jQuery,
};
vm.runInNewContext(fs.readFileSync(path.join(root, 'assets/widgets/frontend.js'), 'utf8'), {
window,
document,
fetch: fetchStub,
Promise,
Array,
Number,
String,
Object,
Math,
encodeURIComponent,
MutationObserver: undefined,
});
return { document, widget, nav, tabOne, tabTwo, requests, autoFun };
}
const tick = () => new Promise((resolve) => setImmediate(resolve));
const pageRequests = (requests) => requests.map((url) => Number(new URL(url, 'https://example.test').searchParams.get('page')));
test('tab panes cache results and keep their current page after switching away and back', async () => {
const runtime = createRuntime();
const paneOne = runtime.widget.querySelector('#panel-one');
const paneTwo = runtime.widget.querySelector('#panel-two');
const pagerOne = paneOne.querySelector('[data-widget-items]');
const pagerTwo = paneTwo.querySelector('[data-widget-items]');
runtime.tabTwo.click();
await tick();
assert.deepEqual(pageRequests(runtime.requests), [1]);
const pageTwoMore = pagerTwo.querySelector('[data-next-page="2"]');
assert.ok(pageTwoMore, 'the returned REST markup is parsed and inserted into the selected pane');
pageTwoMore.click();
await tick();
assert.deepEqual(pageRequests(runtime.requests), [1, 2]);
assert.equal(pagerTwo.dataset.page, '2');
runtime.tabOne.click();
runtime.tabTwo.click();
await tick();
assert.deepEqual(pageRequests(runtime.requests), [1, 2], 'a cached pane does not issue another first-page request');
assert.equal(pagerOne.dataset.page, '1', 'switching tabs preserves the initial pane page');
assert.equal(pagerTwo.dataset.page, '2', 'switching back preserves the loaded pane page');
assert.equal(pagerTwo.querySelectorAll('[data-post-id]').length, 2, 'returned page markup remains in the cached pane');
});
test('arrow keys move tab focus, activate the adjacent pane, and center an overflowing nav item', async () => {
const runtime = createRuntime();
const keyEvent = runtime.tabOne.dispatch('keydown', { key: 'ArrowRight' });
await tick();
assert.equal(keyEvent.defaultPrevented, true);
assert.equal(runtime.document.activeElement, runtime.tabTwo);
assert.equal(runtime.tabTwo.getAttribute('aria-selected'), 'true');
assert.equal(runtime.widget.querySelector('#panel-two').classList.contains('active'), true);
assert.equal(runtime.nav.scrollCalls.length, 1);
assert.equal(runtime.nav.scrollCalls[0].left, 280);
assert.equal(runtime.nav.scrollCalls[0].behavior, 'smooth');
assert.deepEqual(pageRequests(runtime.requests), [1]);
});
test('overflowing tab centering includes the nav current scroll offset', () => {
const runtime = createRuntime();
runtime.nav.scrollLeft = 35;
runtime.tabTwo.click();
assert.equal(runtime.nav.scrollCalls.length, 1);
assert.equal(runtime.nav.scrollCalls[0].left, 315, 'the target uses viewport bounds plus current nav scrollLeft');
});
test('a failed load-more can retry page two and append it without replacing page one', async () => {
let pageTwoAttempts = 0;
const runtime = createRuntime({ fetch: (url) => {
runtime.requests.push(url);
const page = Number(new URL(url, 'https://example.test').searchParams.get('page'));
if (page === 2 && pageTwoAttempts++ === 0) return Promise.reject(new Error('temporary failure'));
return Promise.resolve({
ok: true,
json: () => Promise.resolve({
html: '<posts class="posts-mini ajax-item" data-post-id="post-two"><h2 class="item-heading"><a>Page two</a></h2></posts>',
page,
paginate_html: '',
}),
});
} });
const pager = runtime.widget.querySelector('#panel-one [data-widget-items]');
pager.querySelector('[data-next-page="2"]').click();
await tick();
const retry = pager.querySelector('[data-widget-retry]');
assert.ok(retry, 'a failed append exposes the runtime retry control');
assert.equal(retry.dataset.retryPage, '2');
assert.equal(retry.dataset.retryAppend, '1');
retry.click();
await tick();
assert.deepEqual(pageRequests(runtime.requests), [2, 2], 'retry repeats the failed page rather than resetting to page one');
assert.deepEqual(pager.querySelectorAll('[data-post-id]').map((node) => node.dataset.postId), ['post-one', 'post-two']);
assert.equal(pager.dataset.page, '2');
assert.equal(runtime.autoFun.length, 1, 'successful markup insertion initializes widgets through auto_fun');
});
test('jump button replaces the selected pane, keeps its loader, and stops the legacy click handler', async () => {
const runtime = createRuntime();
const pager = runtime.widget.querySelector('#panel-one [data-widget-items]');
const loader = runtime.document.createElement('div');
loader.className = 'post_ajax_loader';
pager.insertBefore(loader, pager.querySelector('.ajax-pag'));
const { button } = addJumpControl(runtime.document, pager, { value: '4' });
let legacyClicks = 0;
runtime.document.body.addEventListener('click', () => { legacyClicks += 1; });
const click = button.dispatch('click');
await tick();
assert.equal(click.defaultPrevented, true);
assert.equal(click.propagationStopped, true);
assert.equal(legacyClicks, 0, 'the legacy pag_jump_go document handler cannot process the same click');
assert.deepEqual(pageRequests(runtime.requests), [4]);
assert.equal(pager.dataset.page, '4');
assert.equal(pager.querySelector('[data-post-id="post-0-4"]') !== null, true, 'the returned page markup replaces the active pane items');
assert.equal(pager.querySelector('[data-post-id="post-one"]'), null);
assert.equal(pager.querySelector('.post_ajax_loader'), loader, 'replacement preserves the loader node for subsequent requests');
});
test('Enter submits a valid jump and stops the legacy key handler', async () => {
const runtime = createRuntime();
const pager = runtime.widget.querySelector('#panel-one [data-widget-items]');
const loader = runtime.document.createElement('div');
loader.className = 'post_ajax_loader';
pager.insertBefore(loader, pager.querySelector('.ajax-pag'));
const { input } = addJumpControl(runtime.document, pager, { value: '5' });
let legacyKeydowns = 0;
runtime.document.body.addEventListener('keydown', () => { legacyKeydowns += 1; });
const keydown = input.dispatch('keydown', { key: 'Enter' });
await tick();
assert.equal(keydown.defaultPrevented, true);
assert.equal(keydown.propagationStopped, true);
assert.equal(legacyKeydowns, 0, 'the legacy pag_jump_go key handler cannot submit twice');
assert.deepEqual(pageRequests(runtime.requests), [5]);
assert.equal(pager.dataset.page, '5');
assert.ok(pager.querySelector('[data-post-id="post-0-5"]'));
assert.equal(pager.querySelector('.post_ajax_loader'), loader);
});
test('jump input clamps pages to max and rejects invalid or current pages without fetching', async () => {
const runtime = createRuntime();
const pager = runtime.widget.querySelector('#panel-one [data-widget-items]');
const { input } = addJumpControl(runtime.document, pager, { max: 8, value: '99' });
const inputEvent = input.dispatch('input');
assert.equal(input.value, '8');
assert.equal(inputEvent.propagationStopped, true);
input.value = 'not-a-page';
input.dispatch('input');
assert.equal(input.value, '');
input.dispatch('keydown', { key: 'Enter' });
assert.equal(runtime.requests.length, 0, 'invalid input does not request a page');
input.value = '1';
const currentPage = input.dispatch('keydown', { key: 'Enter' });
await tick();
assert.equal(currentPage.defaultPrevented, true);
assert.equal(runtime.requests.length, 0, 'submitting the already selected page does not request again');
assert.equal(runtime.document.activeElement, input, 'invalid and current page values return focus to the input');
});