'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 = ' & 说明'; 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); });