Files
wordpress-theme-aksrt/tests/widget-system/section-navs-runtime.test.cjs
T
kate522andClaude Opus 5 427968a721 chore(branding): remove leftover upstream theme identifiers
The theme was renamed Zibll/子比 → AKSRT → Jio, but upstream branding
survived the sweeps in CSS classes, the SVG sprite prefix, a persisted
widget field ID, JS events, admin Vue class names, test infrastructure
and docblocks.

Renames:
- zib-widget / zib-widget-wrap / zib-slider → jio-widget-box /
  jio-widget-box-wrap / jio-slider-box (jio-widget is taken by the modern
  widget shell, jio-box-* by the card primitives)
- zib-partners-scroll + zps-* → jio-partners-scroll + jps-*
- zib-combo-info-card + zci-* → jio-combo-info-card + jci-*
- zibsvg- → jiosvg-, including the exported picker helpers and the
  fa-zibsvg- family-prefix guard
- zibpay_type → pay_type; the dual-read fallback in PostQuery collapses
  to a single key
- [zibajax="submit"] dropped (no PHP emitter); zib_ajax.success →
  jio_ajax.success
- aks-* admin Vue classes → jio-*
- zib_palette() → palette()

User-facing vendor traces are gone: the floating "more" button default no
longer ships a zibll.com link or 子比主题 marketing copy, the notice
widget ships no preset promo entries, and the four 官网教程 links are
removed from the post-list admin schema.

Test infrastructure renamed to neutral names (docker-compose.reference.yml,
Caddyfile, article-baseline.json, modern-widgets-contract.test.cjs) with
their DB/volume/env/slug identifiers following.

Kept intentionally, documented in tests/fixtures/README.md: the golden
fixture reference fingerprint (a SHA-256 record of an external directory,
read by validate-widget-golden / generate-widget-golden-fixture /
audit-widget-system), the doesNotMatch guards that prove the old tokens are
gone, and the reference theme's own meta key in the visual fixtures.

No backward-compat or data migration: saved widget configs holding old
icon keys or the old field ID silently lose them, per the project's
rewrite-over-compatibility rule.

Verified: 132/132 node contract tests, php tests/widget-system/run.php
(146 assertions), audit-admin-contracts, audit-psr4-paths and
validate-widget-golden all green; tracked bundles regenerated and in sync.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-11-02 00:06:54 +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('.jio-widget-box'));
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);
});