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>
This commit is contained in:
1 parent
3c07ef7a8d
commit
93e83e1835
7 files changed
+470
-92
No files matched your search
@@ -0,0 +1,172 @@
|
||||
'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 };
|
||||
@@ -0,0 +1,177 @@
|
||||
'use strict';
|
||||
/**
|
||||
* 文章页点赞/收藏的点击 UX 对齐参考主题:
|
||||
* 计数行内加载点 → 服务端回包 → 选中态 + lovestar 动画 + 提示。
|
||||
*/
|
||||
const assert = require('node:assert/strict');
|
||||
const test = require('node:test');
|
||||
const { runScenario } = require('./article-actions-harness.cjs');
|
||||
|
||||
const flush = () => new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
test('点赞成功:请求中显示行内加载点,成功后更新计数并播放动画', async () => {
|
||||
const state = runScenario('like', {
|
||||
responses: [{ success: true, data: { active: true, count: 8 } }]
|
||||
});
|
||||
const { likeBtn, toasts } = state;
|
||||
|
||||
likeBtn.click();
|
||||
// 请求尚未返回时:按钮禁用 + 计数换成加载点。
|
||||
assert.equal(likeBtn.disabled, true, '请求中应禁用按钮,避免重复提交');
|
||||
const pending = likeBtn.querySelectorAll('count')[0];
|
||||
assert.match(pending.innerHTML, /loading zts/, '请求中计数应替换为行内加载点');
|
||||
|
||||
await flush();
|
||||
|
||||
assert.equal(likeBtn.disabled, false, '请求结束后按钮恢复');
|
||||
assert.ok(likeBtn.classList.contains('actived'), '成功后应带参考主题的 actived 类');
|
||||
assert.ok(likeBtn.classList.contains('action-animation'), '成功后应触发 lovestar 动画');
|
||||
assert.equal(pending.textContent, '8', '计数应更新为服务端返回值');
|
||||
assert.equal(pending.dataset.baseCount, '8');
|
||||
assert.deepEqual(toasts, [{ msg: '已赞!感谢您的支持', type: 'success' }]);
|
||||
});
|
||||
|
||||
test('重复点赞(服务端状态未变化):不播放动画,给出 warning 提示', async () => {
|
||||
const state = runScenario('like-duplicate', {
|
||||
// 首次成功点亮,第二次服务端返回相同状态与计数。
|
||||
responses: [
|
||||
{ success: true, data: { active: true, count: 8 } },
|
||||
{ success: true, data: { active: true, count: 8 } }
|
||||
]
|
||||
});
|
||||
const { likeBtn, toasts } = state;
|
||||
|
||||
likeBtn.click();
|
||||
await flush();
|
||||
assert.ok(likeBtn.classList.contains('action-animation'));
|
||||
|
||||
// 清掉第一次的动画类,验证第二次点击不会再被重新加上。
|
||||
likeBtn.classList.remove('action-animation');
|
||||
likeBtn.click();
|
||||
await flush();
|
||||
|
||||
assert.equal(likeBtn.classList.contains('action-animation'), false, '状态未变化时不应重放动画');
|
||||
assert.ok(likeBtn.classList.contains('actived'), '仍保持已赞状态');
|
||||
assert.deepEqual(toasts[1], { msg: '已赞过此文章了!', type: 'warning' });
|
||||
});
|
||||
|
||||
test('取消点赞:移除 actived 与动画类,计数回退', async () => {
|
||||
const state = runScenario('unlike', {
|
||||
responses: [
|
||||
{ success: true, data: { active: true, count: 8 } },
|
||||
{ success: true, data: { active: false, count: 7 } }
|
||||
]
|
||||
});
|
||||
const { likeBtn, toasts } = state;
|
||||
|
||||
likeBtn.click();
|
||||
await flush();
|
||||
likeBtn.click();
|
||||
await flush();
|
||||
|
||||
assert.equal(likeBtn.classList.contains('actived'), false);
|
||||
assert.equal(likeBtn.classList.contains('action-animation'), false);
|
||||
assert.equal(likeBtn.querySelectorAll('count')[0].textContent, '7');
|
||||
assert.deepEqual(toasts[1], { msg: '点赞已取消', type: 'warning' });
|
||||
});
|
||||
|
||||
test('收藏与点赞共用同一套交互,收藏文案沿用参考主题', async () => {
|
||||
const state = runScenario('favorite', {
|
||||
responses: [{ success: true, data: { active: true, count: 4 } }]
|
||||
});
|
||||
const { favBtn, toasts } = state;
|
||||
|
||||
favBtn.click();
|
||||
assert.equal(favBtn.disabled, true);
|
||||
assert.match(favBtn.querySelectorAll('count')[0].innerHTML, /loading zts/);
|
||||
|
||||
await flush();
|
||||
|
||||
assert.ok(favBtn.classList.contains('actived'));
|
||||
assert.ok(favBtn.classList.contains('action-animation'));
|
||||
assert.equal(favBtn.querySelectorAll('count')[0].textContent, '4');
|
||||
assert.deepEqual(toasts, [{ msg: '已收藏此文章', type: 'success' }]);
|
||||
});
|
||||
|
||||
test('请求失败:还原计数内容并恢复按钮,提示 danger', async () => {
|
||||
const state = runScenario('failure', {
|
||||
responses: [{ success: false, data: { message: '请先登录' } }]
|
||||
});
|
||||
const { likeBtn, toasts } = state;
|
||||
|
||||
likeBtn.click();
|
||||
await flush();
|
||||
|
||||
assert.equal(likeBtn.disabled, false);
|
||||
assert.equal(likeBtn.classList.contains('actived'), false);
|
||||
assert.equal(likeBtn.querySelectorAll('count')[0].textContent, '7', '失败后应还原原计数');
|
||||
assert.deepEqual(toasts, [{ msg: '请先登录', type: 'danger' }]);
|
||||
});
|
||||
|
||||
test('服务端计数被并发修正:即使状态未变也更新计数并播放动画', async () => {
|
||||
const state = runScenario('concurrent', {
|
||||
responses: [
|
||||
{ success: true, data: { active: true, count: 8 } },
|
||||
// 别人也点了赞:状态仍是已赞,但计数变成 12。
|
||||
{ success: true, data: { active: true, count: 12 } }
|
||||
]
|
||||
});
|
||||
const { likeBtn, toasts } = state;
|
||||
|
||||
likeBtn.click();
|
||||
await flush();
|
||||
likeBtn.classList.remove('action-animation');
|
||||
|
||||
likeBtn.click();
|
||||
await flush();
|
||||
|
||||
assert.equal(likeBtn.querySelectorAll('count')[0].textContent, '12', '应同步服务端的真实计数');
|
||||
assert.ok(likeBtn.classList.contains('action-animation'), '计数变化时仍应播放动画');
|
||||
assert.deepEqual(toasts[1], { msg: '已赞!感谢您的支持', type: 'success' });
|
||||
});
|
||||
|
||||
test('游客收藏被拦:还原计数并拉起站点登录弹窗', async () => {
|
||||
const { el } = require('./article-actions-harness.cjs');
|
||||
let loginClicks = 0;
|
||||
const state = runScenario('guest-favorite', {
|
||||
responses: [{ success: false, data: { message: '请先登录', login: true } }]
|
||||
}, {
|
||||
decorate(state) {
|
||||
const loginTrigger = el('a', { class: 'signin-loader' });
|
||||
loginTrigger.addEventListener('click', () => { loginClicks += 1; });
|
||||
state.body.appendChild(loginTrigger);
|
||||
}
|
||||
});
|
||||
|
||||
state.favBtn.click();
|
||||
await flush();
|
||||
|
||||
assert.equal(state.favBtn.disabled, false);
|
||||
assert.equal(state.favBtn.classList.contains('actived'), false);
|
||||
assert.equal(state.favBtn.querySelectorAll('count')[0].textContent, '3', '失败后应还原原计数');
|
||||
assert.deepEqual(state.toasts, [{ msg: '请先登录', type: 'danger' }]);
|
||||
assert.equal(loginClicks, 1, '游客收藏应打开登录弹窗,与参考主题一致');
|
||||
});
|
||||
|
||||
test('页面内多个同款按钮(正文 + 底部栏)保持状态同步', async () => {
|
||||
const { el } = require('./article-actions-harness.cjs');
|
||||
const state = runScenario('mirror', {
|
||||
responses: [{ success: true, data: { active: true, count: 9 } }]
|
||||
}, {
|
||||
// 绑定前就存在的镜像按钮(正文 + 底部栏),与真实页面一致。
|
||||
decorate(state) {
|
||||
const mirror = el('button', { class: 'tabbar-item action-like' });
|
||||
mirror.dataset.postId = '42';
|
||||
mirror.dataset.initialActive = '0';
|
||||
mirror.appendChild(el('count', { 'data-stat-count': 'likes', 'data-base-count': '7' }, '7'));
|
||||
state.body.appendChild(mirror);
|
||||
state.mirror = mirror;
|
||||
}
|
||||
});
|
||||
|
||||
state.likeBtn.click();
|
||||
await flush();
|
||||
|
||||
assert.ok(state.mirror.classList.contains('actived'), '镜像按钮应同步选中态');
|
||||
assert.equal(state.mirror.querySelectorAll('count')[0].textContent, '9');
|
||||
});
|
||||
Reference in new issue
Block a user