Files
wordpress-theme-aksrt/tests/widget-system/section-navs-runtime.test.cjs
T

334 lines
14 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'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 { MiniDocument } = require('../helpers/mini-dom.cjs');
const root = path.resolve(__dirname, '..', '..');
const source = fs.readFileSync(path.join(root, 'assets/js/section-navs.js'), 'utf8');
function createRuntime({ readyState = 'complete', reducedMotion = false, hash = '', navFixed = false, pageYOffset = 0 } = {}) {
const document = new MiniDocument();
document.readyState = readyState;
if (navFixed) document.body.classList.add('nav-fixed');
const listeners = {};
const timers = [];
const frames = [];
const scrollCalls = [];
const collapseCalls = [];
const autoFun = [];
const window = {
location: { hash },
pageYOffset,
scrollY: pageYOffset,
matchMedia: () => ({ matches: reducedMotion }),
addEventListener(type, callback) { (listeners[type] ||= []).push(callback); },
dispatch(type) { (listeners[type] || []).forEach((callback) => callback({ type })); },
requestAnimationFrame(callback) { frames.push(callback); },
setTimeout(callback, delay) { timers.push({ callback, delay }); },
scrollTo(options) { scrollCalls.push(options); },
};
function jQuery(target) {
if (target === document) {
return {
off(event) {
for (const key of Object.keys(autoFun)) delete autoFun[key];
target._removedEvent = event;
return this;
},
on(event, callback) { autoFun.push({ event, callback }); return this; },
trigger(event) {
autoFun.filter((entry) => entry.event.split('.')[0] === event).forEach((entry) => entry.callback());
return this;
},
};
}
return {
parents(selector) {
const matches = [];
for (let node = target.parentElement; node; node = node.parentElement) {
if (node.matches(selector)) matches.push(node);
}
return {
find(childSelector) {
const children = matches.flatMap((node) => node.querySelectorAll(childSelector));
return { collapse(action) { collapseCalls.push({ children, action }); } };
},
};
},
};
}
vm.runInNewContext(source, { document, window, jQuery, WeakMap, JSON, Math, encodeURIComponent, decodeURIComponent });
return {
document,
window,
autoFun: () => jQuery(document).trigger('auto_fun'),
dispatchReady: () => document.dispatch('DOMContentLoaded'),
flushTimers() { while (timers.length) timers.shift().callback(); },
timerDelays: () => timers.map((timer) => timer.delay),
flushFrames() { while (frames.length) frames.shift()(); },
scrollCalls,
collapseCalls,
};
}
function addBox(document, title = '文章目录') {
const box = document.createElement('div');
box.className = 'posts-nav-box';
box.setAttribute('data-title', title);
document.body.appendChild(box);
return box;
}
function addHeading(document, rootNode, tag, text, { id = '', className = '', top = 200, offsetTop = 9999 } = {}) {
const heading = document.createElement(tag);
heading.textContent = text;
heading.rectTop = top;
heading.offsetTop = offsetTop;
if (id) heading.id = id;
if (className) heading.className = className;
rootNode.appendChild(heading);
return heading;
}
function articleRoot(document, { noNav = false } = {}) {
const rootNode = document.createElement('div');
rootNode.setAttribute('data-nav', 'posts');
if (noNav) rootNode.className = 'no-nav';
document.body.appendChild(rootNode);
return rootNode;
}
test('initializes both before and after DOM ready and recovers when auto_fun first sees no article content', () => {
const early = createRuntime({ readyState: 'complete' });
const earlyBox = addBox(early.document);
const earlyRoot = articleRoot(early.document);
['One', 'Two', 'Three'].forEach((text) => addHeading(early.document, earlyRoot, 'h2', text));
early.autoFun();
assert.equal(earlyBox.querySelectorAll('ul.bl.relative.nav').length, 1);
const late = createRuntime({ readyState: 'loading' });
const lateBox = addBox(late.document);
const lateRoot = articleRoot(late.document);
['One', 'Two', 'Three'].forEach((text) => addHeading(late.document, lateRoot, 'h2', text));
assert.equal(lateBox.querySelector('.posts-nav-lists'), null);
late.dispatchReady();
assert.equal(lateBox.querySelectorAll('ul.bl.relative.nav').length, 1);
const delayed = createRuntime({ readyState: 'complete' });
const delayedBox = addBox(delayed.document);
delayed.autoFun();
assert.equal(delayedBox.querySelector('.posts-nav-lists'), null);
const delayedRoot = articleRoot(delayed.document);
['One', 'Two', 'Three'].forEach((text) => addHeading(delayed.document, delayedRoot, 'h2', text));
delayed.autoFun();
assert.equal(delayedBox.querySelectorAll('ul.bl.relative.nav').length, 1);
});
test('short or missing headings preserve pre-existing box content but clear this script’s stale TOC', () => {
const runtime = createRuntime();
const box = addBox(runtime.document);
const external = runtime.document.createElement('div');
external.className = 'other-widget-content';
external.textContent = 'keep me';
box.appendChild(external);
const rootNode = articleRoot(runtime.document);
const first = addHeading(runtime.document, rootNode, 'h2', 'One');
const second = addHeading(runtime.document, rootNode, 'h2', 'Two');
runtime.autoFun();
assert.equal(box.firstChild, external);
assert.equal(box.querySelector('.other-widget-content').textContent, 'keep me');
assert.equal(box.querySelector('.posts-nav-lists'), null);
assert.equal(box.dataset.aksrtSectionNav, undefined);
const third = addHeading(runtime.document, rootNode, 'h2', 'Three');
runtime.autoFun();
assert.equal(box.dataset.aksrtSectionNav, '1');
assert.ok(box.querySelector('.posts-nav-lists'));
third.remove();
runtime.autoFun();
assert.equal(box.querySelector('.posts-nav-lists'), null);
assert.equal(box.children.length, 0);
assert.equal(box.dataset.aksrtSectionNav, undefined);
assert.ok(first.parentNode && second.parentNode);
});
test('renders reference list shape for three real headings and excludes no-nav and item-heading', () => {
const runtime = createRuntime();
const box = addBox(runtime.document, '目录');
const rootNode = articleRoot(runtime.document);
addHeading(runtime.document, rootNode, 'h2', 'Post card title', { className: 'item-heading' });
const excluded = runtime.document.createElement('div');
excluded.className = 'no-nav';
rootNode.appendChild(excluded);
addHeading(runtime.document, excluded, 'h2', 'Excluded section');
const headings = [
addHeading(runtime.document, rootNode, 'h1', 'Main section'),
addHeading(runtime.document, rootNode, 'h2', 'Nested section'),
addHeading(runtime.document, rootNode, 'h3', 'Final section'),
];
runtime.autoFun();
const list = box.querySelector('ul.bl.relative.nav');
assert.ok(list);
assert.equal(list.parentElement.className, 'posts-nav-lists scroll-y mini-scrollbar list-unstyled');
assert.equal(list.querySelectorAll('li').length, 3);
assert.deepEqual(list.querySelectorAll('a').map((link) => link.textContent), ['Main section', 'Nested section', 'Final section']);
assert.equal(box.querySelector('.title-theme').textContent, '目录');
assert.ok(box.querySelector('.zib-widget'));
headings.forEach((heading) => assert.match(heading.id, /^wznav_/));
});
test('multiple directory copies synchronize current location using viewport position', () => {
const runtime = createRuntime();
const firstBox = addBox(runtime.document, '目录一');
const secondBox = addBox(runtime.document, '目录二');
const rootNode = articleRoot(runtime.document);
const headings = [
addHeading(runtime.document, rootNode, 'h2', 'First', { top: 140, offsetTop: 0 }),
addHeading(runtime.document, rootNode, 'h2', 'Second', { top: 100, offsetTop: 5000 }),
addHeading(runtime.document, rootNode, 'h2', 'Third', { top: 900, offsetTop: 10 }),
];
runtime.autoFun();
runtime.window.dispatch('scroll');
runtime.flushFrames();
for (const box of [firstBox, secondBox]) {
const active = box.querySelector('.posts-nav-lists li.active a');
assert.equal(active.textContent, 'Second');
assert.equal(active.getAttribute('aria-current'), 'location');
assert.equal(box.querySelectorAll('.posts-nav-lists a[aria-current="location"]').length, 1);
}
assert.equal(headings[2].offsetTop, 10, 'offsetTop intentionally disagrees with viewport position');
});
test('a hidden accordion heading does not take the current highlight from visible content', () => {
const runtime = createRuntime();
const box = addBox(runtime.document);
const rootNode = articleRoot(runtime.document);
addHeading(runtime.document, rootNode, 'h2', 'Visible current', { top: 80 });
const hidden = addHeading(runtime.document, rootNode, 'h2', 'Hidden panel heading', { top: 10 });
hidden.visible = false;
addHeading(runtime.document, rootNode, 'h2', 'Visible later', { top: 240 });
runtime.autoFun();
assert.equal(box.querySelector('.posts-nav-lists li.active a').textContent, 'Visible current');
const links = box.querySelectorAll('.posts-nav-lists a');
assert.equal(links[1].parentElement.classList.contains('active'), false);
assert.equal(links[1].getAttribute('aria-current'), null);
});
test('encodes unusual heading IDs and treats heading text as text instead of executable markup', () => {
const runtime = createRuntime();
const box = addBox(runtime.document);
const rootNode = articleRoot(runtime.document);
const label = '<img src=x onerror=alert(1)> & 说明';
const special = 'part id / ?#雪';
const [one, two, three] = [
addHeading(runtime.document, rootNode, 'h2', label, { id: special }),
addHeading(runtime.document, rootNode, 'h2', 'Second', { id: 'duplicate-id' }),
addHeading(runtime.document, rootNode, 'h2', 'Third', { id: 'duplicate-id' }),
];
runtime.autoFun();
const link = box.querySelector('a[href^="#"]');
assert.equal(link.getAttribute('href'), '#' + encodeURIComponent(special));
assert.equal(link.textContent, label);
assert.equal(link.querySelector('img'), null);
assert.equal(one.id, special);
assert.equal(two.id, 'duplicate-id');
assert.notEqual(three.id, two.id);
assert.equal(runtime.document.getElementById(three.id), three);
});
test('long directories add accessible group toggles and hide then reveal the group rows', () => {
const runtime = createRuntime();
const originalCreate = runtime.document.createElement.bind(runtime.document);
runtime.document.createElement = (tag) => {
const element = originalCreate(tag);
if (String(tag).toLowerCase() === 'ul') element.scrollHeight = 500;
return element;
};
const box = addBox(runtime.document);
const rootNode = articleRoot(runtime.document);
addHeading(runtime.document, rootNode, 'h1', 'Chapter one');
addHeading(runtime.document, rootNode, 'h2', 'Section one');
addHeading(runtime.document, rootNode, 'h3', 'Detail one');
addHeading(runtime.document, rootNode, 'h1', 'Chapter two');
addHeading(runtime.document, rootNode, 'h2', 'Section two');
runtime.autoFun();
const firstItem = box.querySelector('.n-H2');
assert.equal(box.querySelector('ul.bl').scrollHeight, 500);
const toggle = firstItem.querySelector('.nav-toggle-collapse');
assert.ok(toggle);
assert.equal(toggle.tagName, 'BUTTON');
assert.equal(toggle.getAttribute('aria-expanded'), 'false');
assert.equal(firstItem.nextElementSibling.classList.contains('yc'), true);
toggle.click();
assert.equal(toggle.getAttribute('aria-expanded'), 'true');
assert.equal(firstItem.nextElementSibling.classList.contains('yc'), false);
assert.equal(toggle.classList.contains('fa-rotate-90'), true);
toggle.click();
assert.equal(toggle.getAttribute('aria-expanded'), 'false');
assert.equal(firstItem.nextElementSibling.classList.contains('yc'), true);
});
test('TOC click reveals collapsed content, exits mobile menu, and subtracts fixed header plus 20px', () => {
const runtime = createRuntime({ navFixed: true, pageYOffset: 400 });
addBox(runtime.document);
const rootNode = articleRoot(runtime.document);
const panel = runtime.document.createElement('div');
panel.className = 'panel';
rootNode.appendChild(panel);
const collapse = runtime.document.createElement('div');
collapse.className = 'collapse';
panel.appendChild(collapse);
const heading = addHeading(runtime.document, panel, 'h2', 'Open this section', { top: 300 });
addHeading(runtime.document, rootNode, 'h2', 'Second');
addHeading(runtime.document, rootNode, 'h2', 'Third');
const header = runtime.document.createElement('header');
header.className = 'header';
header.offsetHeight = 60;
runtime.document.body.appendChild(header);
runtime.document.body.classList.add('mobile-navbar-show');
const readMore = runtime.document.createElement('button');
readMore.className = 'read-more-open';
let readMoreClicks = 0;
readMore.addEventListener('click', () => { readMoreClicks++; });
runtime.document.body.appendChild(readMore);
runtime.autoFun();
const link = runtime.document.querySelector('.posts-nav-lists a');
const event = link.click();
assert.equal(event.defaultPrevented, true);
assert.deepEqual(runtime.collapseCalls.map((call) => call.action), ['show']);
assert.equal(runtime.collapseCalls[0].children[0], collapse);
assert.equal(readMoreClicks, 1);
assert.equal(runtime.document.body.classList.contains('mobile-navbar-show'), false);
assert.equal(runtime.scrollCalls[0].top, 620);
assert.equal(runtime.scrollCalls[0].behavior, 'smooth');
assert.equal(heading.rectTop, 300);
});
test('hash navigation decodes special IDs and reduced motion disables smooth scrolling', () => {
const special = 'section / id?#';
const runtime = createRuntime({ hash: '#' + encodeURIComponent(special), reducedMotion: true, pageYOffset: 50 });
addBox(runtime.document);
const rootNode = articleRoot(runtime.document);
const target = addHeading(runtime.document, rootNode, 'h2', 'Target', { id: special, top: 100 });
addHeading(runtime.document, rootNode, 'h2', 'Second');
addHeading(runtime.document, rootNode, 'h2', 'Third');
runtime.autoFun();
runtime.flushTimers();
assert.equal(runtime.scrollCalls[0].top, 150);
assert.equal(runtime.scrollCalls[0].behavior, 'auto');
assert.equal(target.id, special);
});