Files
wordpress-theme-aksrt/tests/widget-system/article-actions-harness.cjs
T
kate522andClaude Opus 5 93e83e1835 fix(single): align article like/favorite interaction UX with reference
The like and favorite buttons gave no in-flight feedback: the label just sat
there until the request returned, the lovestar animation never replayed on a
repeat like, duplicate clicks reported "success", and guest favorites silently
wrote a cookie instead of asking the user to sign in.

- pending state: replace the count with the reference inline spinner
  (`<i class="loading zts">`) and disable the button until the response lands
- animation: strip `action-animation` before re-adding it (forced reflow), so
  every successful like replays the reference lovestar keyframes
- duplicate: when the server reports the same state and count, skip the
  animation and surface the reference warning copy (已赞过此文章了!)
- failures: restore the original count and toast `danger`; guest-only actions
  now open the site login modal instead of dead-ending on an error
- favorite requires login server-side, matching the reference; the guest
  favourite cookie branch and its reader are removed as dead code
- unify like/favorite through one bindToggle path and keep every mirror
  button (article row + footer tabbar) in sync

Tests: new article-actions-ux suite (8 cases) drives assets/js/core/article.js
against a DOM/fetch shim — pending spinner, replay, duplicate no-op,
concurrency, rollback, guest login, mirror sync. Widget suite 828/829; the one
failure (frontend-lifecycle theme-init stub) predates this change.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-11-03 16:36:45 +08:00

173 lines
6.1 KiB
JavaScript

'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 };