'use strict'; /** * Minimal DOM shim + fake fetch, so article.js can be exercised end-to-end: * pending spinner, animation replay, duplicate no-op and error rollback. */ const fs = require('node:fs'); const path = require('node:path'); const vm = require('node:vm'); const root = path.resolve(__dirname, '..', '..'); function makeClassList(el) { return { _set: new Set(), add(c) { this._set.add(c); el.className = [...this._set].join(' '); }, remove(c) { this._set.delete(c); el.className = [...this._set].join(' '); }, toggle(c, force) { const on = force === undefined ? !this._set.has(c) : !!force; if (on) this.add(c); else this.remove(c); return on; }, contains(c) { return this._set.has(c); } }; } function el(tag, attrs = {}, inner = '') { const node = { tagName: String(tag).toUpperCase(), nodeType: 1, children: [], dataset: {}, style: {}, disabled: false, innerHTML: inner, textContent: inner, className: attrs.class || '', id: attrs.id || '', offsetWidth: 100, _attrs: { ...attrs }, _listeners: {}, matches(sel) { return selectorMatches(this, sel); }, querySelector(sel) { return this.querySelectorAll(sel)[0] || null; }, querySelectorAll(sel) { const out = []; const walk = (n) => { for (const child of n.children) { if (selectorMatches(child, sel)) out.push(child); walk(child); } }; walk(this); return out; }, appendChild(child) { this.children.push(child); child.parentNode = this; return child; }, setAttribute(k, v) { this._attrs[k] = String(v); if (k === 'class') this.className = String(v); }, getAttribute(k) { return this._attrs[k] === undefined ? null : this._attrs[k]; }, removeAttribute(k) { delete this._attrs[k]; }, addEventListener(type, fn) { (this._listeners[type] = this._listeners[type] || []).push(fn); }, click() { if (this.disabled) return; (this._listeners.click || []).forEach((fn) => fn({ target: this, preventDefault() {} })); } }; node.classList = makeClassList(node); node.classList._set = new Set((attrs.class || '').split(/\s+/).filter(Boolean)); // 把 data-* 属性映射到 dataset,与浏览器行为一致。 for (const [name, value] of Object.entries(attrs)) { if (!name.startsWith('data-')) continue; const key = name.slice(5).replace(/-([a-z])/g, (_, c) => c.toUpperCase()); node.dataset[key] = String(value); } return node; } function selectorMatches(node, sel) { // supports: tag, .class, [attr], [attr="v"], and simple compounds return sel.split(/\s+/).every((part) => { const tagMatch = part.match(/^[a-zA-Z][\w-]*/); if (tagMatch && node.tagName !== tagMatch[0].toUpperCase()) return false; const classes = [...part.matchAll(/\.([\w-]+)/g)].map((m) => m[1]); if (classes.some((c) => !node.classList.contains(c))) return false; const attrs = [...part.matchAll(/\[([\w-]+)(?:="([^"]*)")?\]/g)]; for (const [, name, value] of attrs) { if (name.startsWith('data-')) { const key = name.slice(5).replace(/-([a-z])/g, (_, c) => c.toUpperCase()); if (node.dataset[key] === undefined) return false; if (value !== undefined && String(node.dataset[key]) !== value) return false; } else if (value !== undefined) { if (String(node._attrs[name]) !== value) return false; } else if (node._attrs[name] === undefined) return false; } return true; }); } function buildArticle() { const body = el('body'); const doc = { nodeType: 9, readyState: 'complete', documentElement: el('html'), head: el('head'), body, _listeners: {}, addEventListener(type, fn) { (this._listeners[type] = this._listeners[type] || []).push(fn); }, querySelector(sel) { return body.querySelector(sel); }, querySelectorAll(sel) { return body.querySelectorAll(sel); }, getElementById(id) { for (const node of [body, ...body.querySelectorAll('[id]')]) { if (String(node.id) === String(id)) return node; } return null; }, createElement(tag) { return el(tag); } }; body.parentNode = doc; const likeBtn = el('button', { class: 'action action-like', id: 'action-like' }); likeBtn.dataset.postId = '42'; likeBtn.dataset.initialActive = '0'; likeBtn.appendChild(el('svg', { class: 'icon' })); const likeCount = el('count', { 'data-stat-count': 'likes', 'data-base-count': '7' }, '7'); likeBtn.appendChild(likeCount); const favBtn = el('button', { class: 'action action-favorite', id: 'action-favorite' }); favBtn.dataset.postId = '42'; favBtn.dataset.initialActive = '0'; favBtn.appendChild(el('svg', { class: 'icon' })); const favCount = el('count', { 'data-stat-count': 'favorites', 'data-base-count': '3' }, '3'); favBtn.appendChild(favCount); body.appendChild(likeBtn); body.appendChild(favBtn); return { doc, body, likeBtn, likeCount, favBtn, favCount }; } function runScenario(name, { responses }, options = {}) { const { doc, body, likeBtn, favBtn } = buildArticle(); const toasts = []; const queue = responses.slice(); const context = { console, URLSearchParams, Promise, setTimeout, clearTimeout, document: doc, notyf: (msg, type) => toasts.push({ msg, type }), jioData: { ajaxUrl: 'https://example.test/admin-ajax.php', nonce: 'n' }, fetch: () => { const next = queue.shift() || { success: true, data: { active: true, count: 8 } }; return Promise.resolve({ json: () => Promise.resolve(next) }); }, window: {}, jQuery: undefined }; context.window = context; context.window.document = doc; context.window.notyf = context.notyf; const state = { doc, body, likeBtn, favBtn, toasts, queue }; // 允许在脚本执行(绑定)之前插入额外节点,模拟真实的正文 + 底部栏并存。 if (typeof options.decorate === 'function') options.decorate(state); const source = fs.readFileSync(path.join(root, 'assets/js/core/article.js'), 'utf8'); vm.runInNewContext(source, context, { filename: 'article.js' }); return state; } module.exports = { el, buildArticle, runScenario, root, makeClassList, selectorMatches };