Files
wordpress-theme-aksrt/tests/widget-system/section-navs-runtime.test.cjs
T
kate522andClaude Opus 5 35bb96e82f refactor: rename the theme brand and every identifier from AKSRT to Jio
Full-project rename. `git grep -i aksrt` is now empty apart from the
DBMigrator 3.3.0 changelog note that documents the rename itself.

Code identifiers
- PHP namespace AKSRT\ -> Jio\ (composer PSR-4 + 378 declarations)
- global constants AKSRT_* -> JIO_* (VERSION/DIR/URI, PAY_TYPE_*, CAPTCHA_*)
- procedural functions, hooks, options, meta keys, table names,
  cookies, query vars, ajax actions and nonces: aksrt_ -> jio_
- text domain aksrt -> jio; Theme Name -> Jio
- JS globals: aksrtData/aksrtAdminData/... -> jioData/jioAdminData/...,
  window.AKSRTCaptcha & friends -> window.JioCaptcha, Aksrt* -> Jio*
- Vue components Aks*.vue -> Jio*.vue (15 files renamed + imports)
- CSS classes/data-attrs/custom properties: aksrt- -> jio-
- REST namespace aksrt/v1 -> jio/v1 (incl. payment gateway callbacks)

Storage layer
- DB tables {prefix}aksrt_* -> {prefix}jio_*; SCHEMA_VERSION 3.2.0 -> 3.3.0
  so dbDelta creates the new names on install
- widget id_base aksrt_widget_* -> jio_widget_*
- uninstall.php drop list rebuilt from the actual migrator tables
  (dropped 3 stale entries, added the 17 missing ones)

Removed
- hardcoded https://api.aksrt.com/version.json default update endpoint;
  get_endpoint() now reads the 「更新端点」 setting and do_check() no-ops
  when it is empty

Files renamed: inc/helpers/aksrt-*.php -> jio-*.php (54),
assets/admin/src/components/form/Aks*.vue -> Jio*.vue (15).
Build artifacts regenerated: assets/css/build, assets/js/runtime,
assets/admin/build.

Verified: audit-psr4-paths errors=0, audit-admin-contracts errors=0
(warnings=56, unchanged baseline), 813/813 widget-system tests,
44/44 phpunit, php -l clean across all PHP.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-11-01 20:05:04 +08:00

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.jioSectionNav, undefined);
const third = addHeading(runtime.document, rootNode, 'h2', 'Three');
runtime.autoFun();
assert.equal(box.dataset.jioSectionNav, '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.jioSectionNav, 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);
});