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

777 lines
33 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 = combinator === '>' ? tokens.pop() : combinator;
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 postsMarkup(document, unveiled = false) {
const root = document.createElement('div');
root.className = 'widget-main-post style-list';
const pager = document.createElement('div');
pager.className = 'widget-ajaxpager ' + (unveiled ? 'lazyloaded' : 'lazyload');
pager.setAttribute('data-widget-items', '');
pager.setAttribute('data-widget-lazy', '');
pager.setAttribute('data-widget', 'posts');
pager.setAttribute('data-instance', 'aksrt_widget_posts-2');
pager.setAttribute('data-page', '1');
pager.setAttribute('aria-busy', 'true');
pager.innerHTML = '<div class="post_ajax_loader is-loading"><div class="posts-item placeholder">Loading</div></div>';
root.appendChild(pager);
return { root };
}
function createRuntime({ fetch, pages, posts = false, unveiled = false, restUrl = '/wp-json/aksrt/v1/widgets/', observe = false } = {}) {
const document = new MiniDocument();
const { root: widget, nav, tabOne, tabTwo } = posts ? postsMarkup(document, unveiled) : 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, i18n: { error: 'error', retry: 'retry' } };
const fetchStub = (url) => {
requests.push(url);
if (fetch) return fetch(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) });
};
let notifyMutation;
const Observer = observe ? class {
constructor(callback) { notifyMutation = callback; }
observe() {}
} : undefined;
const window = {
aksrtWidgets: config,
location: { href: 'https://example.test/' },
matchMedia: () => ({ matches: false }),
jQuery,
};
if (observe) window.MutationObserver = Observer;
vm.runInNewContext(fs.readFileSync(path.join(root, 'assets/widgets/frontend.js'), 'utf8'), {
window,
document,
fetch: fetchStub,
Promise,
Array,
Number,
String,
Object,
Math,
encodeURIComponent,
URL,
MutationObserver: Observer,
});
return { document, widget, nav, tabOne, tabTwo, requests, autoFun, notifyMutation };
}
const tick = () => new Promise((resolve) => setImmediate(resolve));
const pageRequests = (requests) => requests.map((url) => Number(new URL(url, 'https://example.test').searchParams.get('page')));
function pendingFetch() {
const pending = [];
return {
pending,
fetch(url) {
return new Promise((resolve, reject) => pending.push({
url,
resolve(data) { resolve({ ok: true, json: () => Promise.resolve(data) }); },
reject,
}));
},
};
}
function addPageLink(document, pager, page) {
const link = document.createElement('a');
link.className = 'page-numbers';
link.setAttribute('data-widget-page', String(page));
pager.querySelector('.ajax-pag').appendChild(link);
return link;
}
function assertOnlyLoader(pager, loader, message) {
assert.equal(pager.children.length, 1, message);
assert.equal(pager.firstChild, loader);
}
test('numbered pagination replaces the old page with its skeleton before the response arrives', async () => {
const transport = pendingFetch();
const runtime = createRuntime({ fetch: transport.fetch });
const pager = runtime.widget.querySelector('#panel-one [data-widget-items]');
const originalPost = pager.querySelector('[data-post-id="post-one"]');
const loader = runtime.document.createElement('div');
loader.className = 'post_ajax_loader';
pager.appendChild(loader);
addPageLink(runtime.document, pager, 2).click();
assert.equal(pager.querySelectorAll('[data-post-id]').length, 0, 'old rows must stop occupying space while the replacement is pending');
assert.equal(originalPost.parentNode, null);
assert.equal(pager.querySelector('.ajax-pag'), null, 'old page controls leave the loading page');
assertOnlyLoader(pager, loader);
assert.equal(loader.classList.contains('is-loading'), true);
assert.equal(pager.getAttribute('aria-busy'), 'true');
assert.equal(pager.dataset.page, '1', 'the current page changes only after a successful response');
transport.pending[0].resolve({
html: '<posts class="posts-mini ajax-item" data-post-id="post-two">Page two</posts>',
page: 2,
paginate_html: '<div class="ajax-pag"><a class="page-numbers" data-widget-page="3">3</a></div>',
});
await tick();
assert.deepEqual(pager.querySelectorAll('[data-post-id]').map((node) => node.dataset.postId), ['post-two']);
assert.equal(pager.querySelector('.post_ajax_loader'), loader);
assert.equal(loader.classList.contains('is-loading'), false);
assert.equal(pager.getAttribute('aria-busy'), 'false');
assert.equal(pager.dataset.page, '2');
pager.querySelector('[data-widget-page="3"]').click();
assertOnlyLoader(pager, loader, 'the next replacement reuses the same skeleton in the cleared page');
assert.equal(loader.classList.contains('is-loading'), true);
transport.pending[1].resolve({ html: '', page: 3, paginate_html: '' });
await tick();
assert.equal(pager.dataset.page, '3');
assert.equal(loader.classList.contains('is-loading'), false);
});
test('a failed numbered page restores the old content and retries the requested replacement', async () => {
const transport = pendingFetch();
const runtime = createRuntime({ fetch: transport.fetch });
const pager = runtime.widget.querySelector('#panel-one [data-widget-items]');
const originalPost = pager.querySelector('[data-post-id="post-one"]');
const originalPagination = pager.querySelector('.ajax-pag');
const link = addPageLink(runtime.document, pager, 2);
link.click();
const loader = pager.querySelector('.post_ajax_loader');
link.click();
assert.deepEqual(pageRequests(runtime.requests), [2], 'a detached trigger cannot start a duplicate request');
transport.pending[0].reject(new Error('temporary failure'));
await tick();
assert.equal(pager.querySelector('[data-post-id="post-one"]'), originalPost, 'failure restores the original row node');
assert.equal(pager.querySelector('.ajax-pag'), originalPagination, 'failure restores the original pagination controls');
assert.equal(pager.dataset.page, '1');
assert.equal(pager.getAttribute('aria-busy'), 'false');
assert.equal(loader.classList.contains('is-loading'), false);
const retry = pager.querySelector('[data-widget-retry]');
assert.equal(retry.dataset.retryPage, '2');
assert.equal(retry.dataset.retryAppend, '0');
retry.click();
assert.deepEqual(pageRequests(runtime.requests), [2, 2]);
assertOnlyLoader(pager, loader, 'retry clears the restored page before showing its replacement skeleton');
transport.pending[1].resolve({
html: '<posts class="posts-mini ajax-item" data-post-id="post-two">Page two</posts>',
page: 2,
paginate_html: '',
});
await tick();
assert.deepEqual(pager.querySelectorAll('[data-post-id]').map((node) => node.dataset.postId), ['post-two']);
assert.equal(pager.querySelector('[data-widget-retry]'), null);
assert.equal(pager.dataset.page, '2');
});
test('load-more keeps existing rows and places its pending skeleton after them', async () => {
const transport = pendingFetch();
const runtime = createRuntime({ fetch: transport.fetch });
const pager = runtime.widget.querySelector('#panel-one [data-widget-items]');
const originalPost = pager.querySelector('[data-post-id="post-one"]');
pager.querySelector('[data-next-page="2"]').click();
const loader = pager.querySelector('.post_ajax_loader');
assert.equal(pager.querySelector('[data-post-id="post-one"]'), originalPost);
assert.equal(pager.children.indexOf(loader), pager.children.indexOf(originalPost) + 1);
assert.equal(loader.classList.contains('is-loading'), true);
transport.pending[0].resolve({
html: '<posts class="posts-mini ajax-item" data-post-id="post-two">Page two</posts>',
page: 2,
paginate_html: '',
});
await tick();
assert.deepEqual(pager.querySelectorAll('[data-post-id]').map((node) => node.dataset.postId), ['post-one', 'post-two']);
assert.equal(pager.querySelector('.post_ajax_loader'), loader);
assert.equal(pager.children.indexOf(loader), 2);
assert.equal(loader.classList.contains('is-loading'), false);
});
test('jump pagination clears the page during loading and keeps the skeleton scoped to that tab', async () => {
const transport = pendingFetch();
const runtime = createRuntime({ fetch: transport.fetch });
const pager = runtime.widget.querySelector('#panel-one [data-widget-items]');
const otherPager = runtime.widget.querySelector('#panel-two [data-widget-items]');
const otherChildren = otherPager.children.slice();
const { button } = addJumpControl(runtime.document, pager, { value: '4' });
button.click();
const loader = pager.querySelector('.post_ajax_loader');
assertOnlyLoader(pager, loader);
assert.equal(loader.classList.contains('is-loading'), true);
assert.equal(otherPager.children.length, otherChildren.length);
otherChildren.forEach((child, index) => assert.equal(otherPager.children[index], child, 'another tab keeps its existing DOM'));
assert.equal(otherPager.querySelector('.is-loading'), null);
transport.pending[0].resolve({ html: '<posts class="posts-mini ajax-item" data-post-id="post-four">Page four</posts>', page: 4, paginate_html: '' });
await tick();
assert.deepEqual(pager.querySelectorAll('[data-post-id]').map((node) => node.dataset.postId), ['post-four']);
assert.equal(pager.dataset.page, '4');
});
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) => {
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');
});
test('article widget waits for its own lazy reveal and fetches the first page once', async () => {
const transport = pendingFetch();
const runtime = createRuntime({ posts: true, fetch: transport.fetch });
const pager = runtime.widget.querySelector('[data-widget-items]');
assert.equal(runtime.requests.length, 0, 'offscreen widgets keep their placeholder without fetching');
assert.ok(pager.querySelector('.post_ajax_loader.is-loading'));
pager.querySelector('.placeholder').dispatch('lazybeforeunveil');
assert.equal(runtime.requests.length, 0, 'a descendant image reveal does not load the pager');
pager.dispatch('lazybeforeunveil');
pager.dispatch('lazybeforeunveil');
assert.deepEqual(pageRequests(runtime.requests), [1]);
const url = new URL(runtime.requests[0]);
assert.equal(url.pathname, '/wp-json/aksrt/v1/widgets/posts/items');
assert.equal(url.searchParams.get('instance'), 'aksrt_widget_posts-2');
transport.pending[0].resolve({ html: '<posts class="posts-item list ajax-item" data-post-id="first">First article</posts>', page: 1, paginate_html: '' });
await tick();
assert.ok(pager.querySelector('[data-post-id="first"]'));
assert.equal(pager.dataset.loaded, '1');
assert.equal(pager.getAttribute('aria-busy'), 'false');
assert.equal(pager.querySelector('.post_ajax_loader').classList.contains('is-loading'), false);
pager.dispatch('lazybeforeunveil');
assert.equal(runtime.requests.length, 1, 'a completed first page is never fetched again by lazySizes');
});
test('article widget loads when lazySizes unveiled it before the widget client initialized', async () => {
const runtime = createRuntime({ posts: true, unveiled: true });
assert.deepEqual(pageRequests(runtime.requests), [1]);
await tick();
const pager = runtime.widget.querySelector('[data-widget-items]');
assert.equal(pager.dataset.loaded, '1');
assert.ok(pager.querySelector('[data-post-id]'));
});
test('article widget failure exposes a retry that replaces the initial skeleton', async () => {
const transport = pendingFetch();
const runtime = createRuntime({ posts: true, fetch: transport.fetch });
const pager = runtime.widget.querySelector('[data-widget-items]');
pager.dispatch('lazybeforeunveil');
transport.pending[0].reject(new Error('offline'));
await tick();
assert.equal(pager.getAttribute('aria-busy'), 'false');
assert.equal(pager.querySelector('.post_ajax_loader').classList.contains('is-loading'), false);
pager.querySelector('[data-widget-retry]').click();
assert.deepEqual(pageRequests(runtime.requests), [1, 1]);
transport.pending[1].resolve({ html: '<posts class="posts-item card ajax-item" data-post-id="retried">Retried</posts>', page: 1, paginate_html: '' });
await tick();
assert.ok(pager.querySelector('[data-post-id="retried"]'));
assert.equal(pager.querySelector('[data-widget-retry]'), null);
});
test('article REST addresses preserve the route for plain WordPress permalinks', async () => {
const runtime = createRuntime({ posts: true, unveiled: true, restUrl: 'https://example.test/blog/?rest_route=/aksrt/v1/widgets/' });
const url = new URL(runtime.requests[0]);
assert.equal(url.pathname, '/blog/');
assert.equal(url.searchParams.get('rest_route'), '/aksrt/v1/widgets/posts/items');
assert.equal(url.searchParams.get('instance'), 'aksrt_widget_posts-2');
assert.equal(url.searchParams.get('page'), '1');
await tick();
assert.equal(runtime.widget.querySelector('[data-widget-items]').dataset.loaded, '1');
});
test('a pager inserted as the mutation root still receives its lazy listener', async () => {
const runtime = createRuntime({ observe: true });
const pager = postsMarkup(runtime.document).root.querySelector('[data-widget-items]');
pager.nodeType = 1;
runtime.document.body.appendChild(pager);
runtime.notifyMutation([{ addedNodes: [pager] }]);
pager.dispatch('lazybeforeunveil');
await tick();
assert.deepEqual(pageRequests(runtime.requests), [1]);
assert.equal(pager.dataset.loaded, '1');
});