fix(user): align badge modals with reference theme
This commit is contained in:
1 parent
8e9d3a7e49
commit
1c501ff713
17 files changed
+931
-313
No files matched your search
@@ -0,0 +1,318 @@
|
||||
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 root = path.resolve(__dirname, '..', '..');
|
||||
|
||||
class ClassList {
|
||||
constructor(owner) { this.owner = owner; this.values = new Set(); }
|
||||
add(value) { this.values.add(value); }
|
||||
remove(value) { this.values.delete(value); }
|
||||
contains(value) { return this.values.has(value); }
|
||||
}
|
||||
|
||||
class Element {
|
||||
constructor(document) {
|
||||
this.ownerDocument = document;
|
||||
this.parentNode = null;
|
||||
this.children = [];
|
||||
this.attributes = {};
|
||||
this.style = { paddingRight: '' };
|
||||
this.classList = new ClassList(this);
|
||||
this.listeners = {};
|
||||
this.className = '';
|
||||
this.id = '';
|
||||
this._innerHTML = '';
|
||||
}
|
||||
set innerHTML(value) {
|
||||
this._innerHTML = value;
|
||||
if (value.includes('aksrt-modal-runtime__content')) {
|
||||
this.dialog = new Element(this.ownerDocument);
|
||||
const content = new Element(this.ownerDocument);
|
||||
content.className = 'aksrt-modal-runtime__content';
|
||||
content.parentNode = this;
|
||||
this.content = content;
|
||||
}
|
||||
}
|
||||
get innerHTML() { return this._innerHTML; }
|
||||
setAttribute(name, value) { this.attributes[name] = String(value); }
|
||||
removeAttribute(name) { delete this.attributes[name]; }
|
||||
getAttribute(name) { return this.attributes[name] || null; }
|
||||
addEventListener(type, listener) { (this.listeners[type] ||= []).push(listener); }
|
||||
querySelector(selector) {
|
||||
if (selector === '.aksrt-modal-runtime__dialog') return this.dialog || null;
|
||||
if (selector === '.aksrt-modal-runtime__content') return this.content || null;
|
||||
return null;
|
||||
}
|
||||
querySelectorAll() { return []; }
|
||||
appendChild(child) { child.parentNode = this; this.children.push(child); return child; }
|
||||
removeChild(child) {
|
||||
this.children = this.children.filter((item) => item !== child);
|
||||
child.parentNode = null;
|
||||
return child;
|
||||
}
|
||||
contains(node) {
|
||||
return node === this || this.children.some((child) => child.contains(node)) || this.content?.contains(node) || false;
|
||||
}
|
||||
focus() { this.ownerDocument.activeElement = this; }
|
||||
dispatch(type, event) { (this.listeners[type] || []).forEach((listener) => listener(event)); }
|
||||
}
|
||||
|
||||
function createRuntime() {
|
||||
const timers = [];
|
||||
const document = {
|
||||
activeElement: null,
|
||||
listeners: {},
|
||||
documentElement: new Element(null),
|
||||
body: new Element(null),
|
||||
createElement() { return new Element(this); },
|
||||
getElementById(id) { return this.body.children.find((item) => item.id === id) || null; },
|
||||
querySelectorAll() { return []; },
|
||||
contains(node) { return this.body.contains(node); },
|
||||
addEventListener(type, listener) { (this.listeners[type] ||= []).push(listener); },
|
||||
dispatchEvent() {},
|
||||
};
|
||||
document.documentElement.ownerDocument = document;
|
||||
document.body.ownerDocument = document;
|
||||
const requests = [];
|
||||
let swipeConfig = null;
|
||||
const jquery = () => ({
|
||||
trigger() {},
|
||||
on() { return this; },
|
||||
minitouch(options) { swipeConfig = options; return this; },
|
||||
});
|
||||
jquery.fn = { minitouch() {} };
|
||||
const window = {
|
||||
document,
|
||||
innerWidth: 1200,
|
||||
matchMedia: () => ({ matches: false }),
|
||||
getComputedStyle: (element) => ({ paddingRight: '5px', transitionDuration: element.transitionDuration || '' }),
|
||||
setTimeout(callback, delay) { callback.delay = delay; timers.push(callback); return timers.length; },
|
||||
AbortController,
|
||||
jQuery: jquery,
|
||||
AksrtModalTelemetry: { start: () => '', mark() {} },
|
||||
AksrtModalState: { create: () => ({}) },
|
||||
AksrtModalCache: {
|
||||
acquire(key, request) {
|
||||
const promise = request();
|
||||
return { promise, release() {} };
|
||||
},
|
||||
invalidate() {},
|
||||
},
|
||||
AksrtRequest: {
|
||||
url: (url) => url,
|
||||
request(url, options) {
|
||||
const pending = {};
|
||||
pending.promise = new Promise((resolve, reject) => { pending.resolve = resolve; pending.reject = reject; });
|
||||
requests.push({ url, options, pending });
|
||||
return pending.promise;
|
||||
},
|
||||
},
|
||||
};
|
||||
const context = { window, document, CustomEvent: class { constructor(type, options) { this.type = type; this.detail = options?.detail; } }, Date, Promise, String, Number, Object, Array, Math, Error, AbortController };
|
||||
vm.runInNewContext(fs.readFileSync(path.join(root, 'assets/js/core/modal-runtime.js'), 'utf8'), context);
|
||||
return { runtime: window.AksrtModalRuntime, document, window, requests, timers, getSwipeConfig: () => swipeConfig };
|
||||
}
|
||||
|
||||
function flushTimers(timers) {
|
||||
while (timers.length) timers.shift()();
|
||||
}
|
||||
|
||||
test('nested remote dialogs preserve the parent, restore focus, and hold one page scroll lock', async () => {
|
||||
const { runtime, document, requests, timers } = createRuntime();
|
||||
const parent = runtime.open({ html: '<div>overview</div>' });
|
||||
const trigger = document.createElement('a');
|
||||
parent.content.appendChild(trigger);
|
||||
trigger.focus();
|
||||
|
||||
const childLoad = runtime.openRemote({ url: '/badge', cache: false, newModal: true });
|
||||
const child = runtime.getActive();
|
||||
assert.equal(parent.classList.contains('is-open'), true);
|
||||
assert.equal(parent.classList.contains('aksrt-modal-runtime--parent'), true);
|
||||
assert.equal(parent.getAttribute('aria-modal'), 'false');
|
||||
assert.equal(parent.getAttribute('aria-hidden'), 'true');
|
||||
assert.equal(child.getAttribute('aria-modal'), 'true');
|
||||
assert.equal(document.documentElement.classList.contains('aksrt-modal-runtime-open'), true);
|
||||
|
||||
requests[0].pending.resolve({ text: '<div>details</div>' });
|
||||
await childLoad;
|
||||
runtime.close('control');
|
||||
flushTimers(timers);
|
||||
|
||||
assert.equal(runtime.getActive(), parent);
|
||||
assert.equal(parent.classList.contains('aksrt-modal-runtime--parent'), false);
|
||||
assert.equal(parent.getAttribute('aria-modal'), 'true');
|
||||
assert.equal(parent.getAttribute('aria-hidden'), null);
|
||||
assert.equal(parent.getAttribute('inert'), null);
|
||||
assert.equal(document.activeElement, trigger);
|
||||
assert.equal(document.documentElement.classList.contains('aksrt-modal-runtime-open'), true);
|
||||
|
||||
runtime.close('control');
|
||||
flushTimers(timers);
|
||||
assert.equal(runtime.getActive(), null);
|
||||
assert.equal(document.documentElement.classList.contains('aksrt-modal-runtime-open'), false);
|
||||
});
|
||||
|
||||
test('replacing a nested top dialog keeps the original parent and does not reload it', async () => {
|
||||
const { runtime, requests, timers } = createRuntime();
|
||||
const parent = runtime.open({ html: '<div>overview</div>' });
|
||||
const firstLoad = runtime.openRemote({ url: '/badge/one', cache: false, newModal: true });
|
||||
const firstChild = runtime.getActive();
|
||||
requests[0].pending.resolve({ text: '<div>one</div>' });
|
||||
await firstLoad;
|
||||
|
||||
const secondLoad = runtime.openRemote({ url: '/badge/two', cache: false });
|
||||
const secondChild = runtime.getActive();
|
||||
requests[1].pending.resolve({ text: '<div>two</div>' });
|
||||
await secondLoad;
|
||||
|
||||
assert.notEqual(secondChild, firstChild);
|
||||
assert.equal(runtime.getActive(), secondChild);
|
||||
assert.equal(parent.classList.contains('aksrt-modal-runtime--parent'), true);
|
||||
assert.equal(requests.length, 2);
|
||||
runtime.close('escape');
|
||||
flushTimers(timers);
|
||||
assert.equal(runtime.getActive(), parent);
|
||||
});
|
||||
|
||||
test('an unfinished parent request can update its hidden content or error while a child is active', async () => {
|
||||
const { runtime, requests, timers } = createRuntime();
|
||||
const parentLoad = runtime.openRemote({ url: '/overview', cache: false });
|
||||
const parent = runtime.getActive();
|
||||
const childLoad = runtime.openRemote({ url: '/badge', cache: false, newModal: true });
|
||||
const child = runtime.getActive();
|
||||
|
||||
requests[0].pending.resolve({ text: '<div>loaded overview</div>' });
|
||||
await parentLoad;
|
||||
assert.equal(parent.content.innerHTML, '<div>loaded overview</div>');
|
||||
assert.equal(runtime.getActive(), child);
|
||||
|
||||
requests[1].pending.resolve({ text: '<div>details</div>' });
|
||||
await childLoad;
|
||||
runtime.close('control');
|
||||
flushTimers(timers);
|
||||
assert.equal(runtime.getActive(), parent);
|
||||
assert.equal(parent.content.innerHTML, '<div>loaded overview</div>');
|
||||
|
||||
const errorLoad = runtime.openRemote({ url: '/overview-error', cache: false });
|
||||
const errorParent = runtime.getActive();
|
||||
const anotherLoad = runtime.openRemote({ url: '/still-loading', cache: false, newModal: true });
|
||||
const top = runtime.getActive();
|
||||
requests[2].pending.reject(new Error('overview unavailable'));
|
||||
await errorLoad;
|
||||
assert.match(errorParent.content.innerHTML, /overview unavailable/);
|
||||
assert.equal(runtime.getActive(), top);
|
||||
requests[3].pending.resolve({ text: '<div>top</div>' });
|
||||
await anotherLoad;
|
||||
runtime.close('control');
|
||||
flushTimers(timers);
|
||||
assert.equal(runtime.getActive(), errorParent);
|
||||
});
|
||||
|
||||
test('replacing a nested child inherits its parent focus target and only the top layer handles keyboard or clicks', () => {
|
||||
const { runtime, document, timers } = createRuntime();
|
||||
const outsideTrigger = document.createElement('a');
|
||||
document.body.appendChild(outsideTrigger);
|
||||
outsideTrigger.focus();
|
||||
const parent = runtime.open({ html: '<div>overview</div>' });
|
||||
const parentTrigger = document.createElement('a');
|
||||
parent.content.appendChild(parentTrigger);
|
||||
parentTrigger.focus();
|
||||
runtime.open({ html: '<div>first child</div>', newModal: true });
|
||||
const firstChild = runtime.getActive();
|
||||
runtime.open({ html: '<div>replacement child</div>' });
|
||||
const replacement = runtime.getActive();
|
||||
let parentPrevented = false;
|
||||
parent.dispatch('keydown', { key: 'Escape', preventDefault() { parentPrevented = true; } });
|
||||
parent.dispatch('click', { target: parent, preventDefault() {} });
|
||||
assert.equal(runtime.getActive(), replacement);
|
||||
assert.equal(parentPrevented, false);
|
||||
|
||||
const first = document.createElement('button');
|
||||
const last = document.createElement('button');
|
||||
replacement.querySelectorAll = () => [first, last];
|
||||
last.focus();
|
||||
let childPrevented = false;
|
||||
replacement.dispatch('keydown', { key: 'Tab', shiftKey: false, preventDefault() { childPrevented = true; } });
|
||||
assert.equal(childPrevented, true);
|
||||
assert.equal(document.activeElement, first);
|
||||
assert.notEqual(firstChild, replacement);
|
||||
|
||||
runtime.close('control');
|
||||
flushTimers(timers);
|
||||
assert.equal(runtime.getActive(), parent);
|
||||
assert.equal(document.activeElement, parentTrigger);
|
||||
});
|
||||
|
||||
test('Escape from the child closes only that layer and aborts its request', async () => {
|
||||
const { runtime, requests, timers } = createRuntime();
|
||||
const parent = runtime.open({ html: '<div>overview</div>' });
|
||||
const childLoad = runtime.openRemote({ url: '/badge', cache: false, newModal: true });
|
||||
const child = runtime.getActive();
|
||||
let prevented = false;
|
||||
child.dispatch('keydown', { key: 'Escape', preventDefault() { prevented = true; } });
|
||||
flushTimers(timers);
|
||||
requests[0].pending.resolve({ text: '<div>late details</div>' });
|
||||
await childLoad;
|
||||
|
||||
assert.equal(prevented, true);
|
||||
assert.equal(requests[0].options.signal.aborted, true);
|
||||
assert.equal(runtime.getActive(), parent);
|
||||
assert.equal(requests.length, 1);
|
||||
});
|
||||
|
||||
test('remote trigger forwards the explicit new modal flag', () => {
|
||||
const source = fs.readFileSync(path.join(root, 'assets/js/core/modal-triggers.js'), 'utf8');
|
||||
const attrs = {
|
||||
'data-aksrt-modal-url': '/badge',
|
||||
'data-aksrt-modal-new': 'true',
|
||||
'data-aksrt-modal-loading': 'spinner',
|
||||
'data-aksrt-modal-swipe-close': 'true',
|
||||
};
|
||||
const element = { getAttribute: (name) => attrs[name] || null };
|
||||
const document = {
|
||||
addEventListener(type, listener) { this.listener = listener; },
|
||||
};
|
||||
let options;
|
||||
const window = { AksrtModalRuntime: { openRemote(value) { options = value; } } };
|
||||
vm.runInNewContext(source, { window, document, parseInt, Object });
|
||||
document.listener({
|
||||
target: { closest: () => element },
|
||||
preventDefault() {},
|
||||
});
|
||||
|
||||
assert.equal(options.newModal, true);
|
||||
assert.equal(options.loading, 'spinner');
|
||||
assert.equal(options.swipeClose, true);
|
||||
});
|
||||
|
||||
test('bottom dialogs use the shared spinner and minitouch swipe handle', () => {
|
||||
const { runtime, getSwipeConfig, timers } = createRuntime();
|
||||
const shell = runtime.open({ bottom: true, swipeClose: true, loading: 'spinner' });
|
||||
const dialog = shell.innerHTML;
|
||||
|
||||
assert.match(dialog, /class="aksrt-modal-runtime__loading flex jc"/);
|
||||
assert.match(dialog, /class="loading-circle" viewBox="0 0 50 50"/);
|
||||
assert.match(dialog, /class="touch-close" aria-hidden="true"/);
|
||||
assert.equal(getSwipeConfig().direction, 'bottom');
|
||||
assert.equal(getSwipeConfig().selector, '.aksrt-modal-runtime__dialog');
|
||||
assert.equal(getSwipeConfig().start_selector, '.modal-colorful-header,.touch-close,.touch');
|
||||
assert.equal(getSwipeConfig().depreciation, 50);
|
||||
|
||||
getSwipeConfig().onEnd();
|
||||
flushTimers(timers);
|
||||
assert.equal(runtime.getActive(), null);
|
||||
});
|
||||
|
||||
test('closing a dialog preserves its exit animation before removing its DOM', () => {
|
||||
const { runtime, timers } = createRuntime();
|
||||
const shell = runtime.open({ html: '<div>overview</div>' });
|
||||
shell.dialog.transitionDuration = '0.4s, 50ms';
|
||||
runtime.close('control');
|
||||
assert.equal(timers.at(-1).delay, 400);
|
||||
assert.ok(shell.parentNode);
|
||||
flushTimers(timers);
|
||||
assert.equal(shell.parentNode, null);
|
||||
});
|
||||
@@ -0,0 +1,95 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const test = require('node:test');
|
||||
const vm = require('node:vm');
|
||||
|
||||
const source = fs.readFileSync(path.join(__dirname, '../../assets/js/user-badge-actions.js'), 'utf8');
|
||||
|
||||
function fixture(type = 'wear') {
|
||||
const modal = {};
|
||||
const classes = new Set();
|
||||
const attributes = { 'data-aksrt-badge-action': type, 'data-slug': 'helper', 'data-user-id': '42' };
|
||||
const button = {
|
||||
innerHTML: '佩戴此徽章',
|
||||
classList: {
|
||||
contains: (name) => classes.has(name),
|
||||
add: (name) => classes.add(name),
|
||||
remove: (name) => classes.delete(name),
|
||||
},
|
||||
closest: () => modal,
|
||||
getAttribute: (name) => attributes[name] || null,
|
||||
setAttribute: (name, value) => { attributes[name] = value; },
|
||||
removeAttribute: (name) => { delete attributes[name]; },
|
||||
};
|
||||
let listener;
|
||||
let resolve;
|
||||
let active = modal;
|
||||
const requests = [];
|
||||
const notices = [];
|
||||
const closed = [];
|
||||
const document = { addEventListener: (type, callback) => { listener = callback; } };
|
||||
const window = {
|
||||
aksrtData: { nonce: 'nonce', ajaxUrl: '/admin-ajax.php' },
|
||||
notyf: (...args) => notices.push(args),
|
||||
fetch: (url, options) => {
|
||||
requests.push({ url, options });
|
||||
return new Promise((done) => { resolve = done; });
|
||||
},
|
||||
AksrtModalRuntime: { getActive: () => active, close: (reason) => closed.push(reason) },
|
||||
};
|
||||
vm.runInNewContext(source, { window, document, URLSearchParams, Error });
|
||||
return {
|
||||
button, requests, notices, closed,
|
||||
click: () => listener({ target: { closest: () => button }, preventDefault() {} }),
|
||||
complete: async (json) => {
|
||||
resolve({ json: async () => json });
|
||||
await new Promise((done) => setImmediate(done));
|
||||
},
|
||||
changeModal: () => { active = {}; },
|
||||
};
|
||||
}
|
||||
|
||||
test('wear submits once, shows progress, and closes only its dialog without reloading', async () => {
|
||||
const ui = fixture();
|
||||
ui.click();
|
||||
ui.click();
|
||||
assert.equal(ui.requests.length, 1);
|
||||
const body = new URLSearchParams(ui.requests[0].options.body);
|
||||
assert.equal(body.get('action'), 'aksrt_user_medal_wear');
|
||||
assert.equal(body.get('nonce'), 'nonce');
|
||||
assert.equal(body.get('slug'), 'helper');
|
||||
assert.equal(body.has('id'), false);
|
||||
assert.match(ui.button.innerHTML, /loading/);
|
||||
assert.equal(ui.button.getAttribute('aria-busy'), 'true');
|
||||
assert.equal(ui.notices[0][1], 'load');
|
||||
await ui.complete({ success: true, data: { message: '已佩戴徽章' } });
|
||||
assert.deepEqual(ui.closed, ['badge-action']);
|
||||
assert.equal(ui.button.innerHTML, '佩戴此徽章');
|
||||
assert.equal(ui.button.getAttribute('aria-disabled'), null);
|
||||
assert.equal(ui.notices.at(-1)[1], 'success');
|
||||
});
|
||||
|
||||
test('grant and revoke submit the selected user; rejected actions keep the dialog usable', async () => {
|
||||
for (const type of ['grant', 'revoke']) {
|
||||
const ui = fixture(type);
|
||||
ui.click();
|
||||
const body = new URLSearchParams(ui.requests[0].options.body);
|
||||
assert.equal(body.get('id'), '42');
|
||||
assert.equal(body.get('action'), type === 'grant' ? 'aksrt_user_medal_manually_add' : 'aksrt_user_medal_manually_remove');
|
||||
await ui.complete({ success: false, data: { message: '暂无此权限' } });
|
||||
assert.equal(ui.closed.length, 0);
|
||||
assert.equal(ui.button.getAttribute('aria-busy'), null);
|
||||
assert.equal(ui.button.classList.contains('is-loading'), false);
|
||||
assert.equal(ui.button.innerHTML, '佩戴此徽章');
|
||||
assert.equal(ui.notices.at(-1)[0], '暂无此权限');
|
||||
}
|
||||
});
|
||||
|
||||
test('a late successful mutation cannot close a different dialog', async () => {
|
||||
const ui = fixture();
|
||||
ui.click();
|
||||
ui.changeModal();
|
||||
await ui.complete({ success: true });
|
||||
assert.equal(ui.closed.length, 0);
|
||||
});
|
||||
@@ -24,10 +24,14 @@ test('badges use server-rendered admin-ajax modal actions only', () => {
|
||||
assert.match(badges, /wp_ajax_nopriv_aksrt_user_medal_info_modal/);
|
||||
assert.match(badges, /wp_ajax_nopriv_aksrt_single_medal_info_modal/);
|
||||
assert.match(identity, /data-aksrt-modal-cache="false"/);
|
||||
assert.match(identity, /data-aksrt-modal-loading="spinner"/);
|
||||
assert.match(identity, /data-aksrt-modal-swipe-close="true"/);
|
||||
assert.match(identity, /admin-ajax\.php/);
|
||||
assert.match(identity, /aksrt-badge-modal--overview/);
|
||||
assert.match(identity, /aksrt-badge-modal--grant/);
|
||||
assert.match(badges, /aksrt-badge-modal--single/);
|
||||
assert.match(badges, /data-aksrt-modal-loading="spinner"/);
|
||||
assert.match(badges, /data-aksrt-modal-swipe-close="true"/);
|
||||
assert.doesNotMatch(identity, /data-aksrt-modal-class="full-sm"/);
|
||||
assert.doesNotMatch(badges, /data-aksrt-modal-class="modal-mini"/);
|
||||
assert.match(scripts, /user-badge-actions\.js/);
|
||||
@@ -49,14 +53,15 @@ test('badge mutations enforce the public nonce, login, ownership, and manual gra
|
||||
assert.match(badges, /'show_unearned'\s*=>\s*\$show_unearned/);
|
||||
});
|
||||
|
||||
test('badge actions close the HTML dialog and reload the server-rendered page', () => {
|
||||
test('badge actions close their HTML dialog without reloading the page', () => {
|
||||
const actions = read('assets/js/user-badge-actions.js');
|
||||
|
||||
assert.match(actions, /aksrt_user_medal_wear/);
|
||||
assert.match(actions, /aksrt_user_medal_manually_add/);
|
||||
assert.match(actions, /aksrt_user_medal_manually_remove/);
|
||||
assert.match(actions, /AksrtModalRuntime\.close\('badge-action'\)/);
|
||||
assert.match(actions, /window\.location\.reload\(\)/);
|
||||
assert.match(actions, /getActive\(\) === modal/);
|
||||
assert.doesNotMatch(actions, /window\.location\.reload|setTimeout/);
|
||||
assert.doesNotMatch(actions, /AksrtRequest|fetchJson|restUrl/);
|
||||
});
|
||||
|
||||
@@ -77,7 +82,8 @@ test('the retired badge rollout and JSON modal CSS are absent', () => {
|
||||
|
||||
assert.doesNotMatch(sources, /frontend_modal_rollout_badge/);
|
||||
assert.doesNotMatch(sources, /\.uc-badges-/);
|
||||
assert.match(sources, /grid-template-columns:\s*repeat\(5, 1fr\)/);
|
||||
assert.match(sources, /grid-template-columns:\s*repeat\(4, 1fr\)/);
|
||||
assert.match(sources, /grid-template-columns:\s*repeat\(3, 1fr\)/);
|
||||
assert.doesNotMatch(sources, /grid-template-columns|\.medal-card__|\.medal-modal__|\.medal-grant-item/);
|
||||
const main = read('assets/css/main.css');
|
||||
assert.match(main, /\.medal-list\s*\{\s*width:\s*calc\(20% - 14px\);\s*margin:\s*10px 7px/);
|
||||
assert.match(main, /\.medal-list\s*\{\s*width:\s*calc\(25% - 14px\)/);
|
||||
});
|
||||
@@ -0,0 +1,137 @@
|
||||
<?php
|
||||
/**
|
||||
* Render the private badge modal templates against a small WordPress fixture.
|
||||
*
|
||||
* @package AKSRT
|
||||
*/
|
||||
|
||||
namespace AKSRT\Settings {
|
||||
final class Settings {
|
||||
public static array $settings = [];
|
||||
|
||||
public static function get_user_identity_settings(): array {
|
||||
return self::$settings;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
namespace {
|
||||
define('ABSPATH', __DIR__ . '/');
|
||||
$GLOBALS['aksrt_test_current_user'] = 1;
|
||||
$GLOBALS['aksrt_test_meta'] = [
|
||||
1 => [
|
||||
'aksrt_user_badges' => [
|
||||
'manual-owned' => '2026-01-02 03:04:05',
|
||||
'auto-owned' => '2026-02-03 04:05:06',
|
||||
],
|
||||
'aksrt_wear_badge' => 'manual-owned',
|
||||
],
|
||||
2 => [
|
||||
'aksrt_user_badges' => [
|
||||
'manual-owned' => '2026-03-04 05:06:07',
|
||||
'auto-owned' => '2026-04-05 06:07:08',
|
||||
],
|
||||
'aksrt_wear_badge' => 'auto-owned',
|
||||
],
|
||||
];
|
||||
$GLOBALS['aksrt_test_users'] = [
|
||||
1 => (object) ['ID' => 1, 'display_name' => 'Self <script>alert(1)</script>'],
|
||||
2 => (object) ['ID' => 2, 'display_name' => 'Other <img src=x onerror=alert(1)>'],
|
||||
9 => (object) ['ID' => 9, 'display_name' => 'Admin'],
|
||||
];
|
||||
$GLOBALS['aksrt_test_null_calls'] = [];
|
||||
|
||||
function sanitize_key($value): string { return strtolower(preg_replace('/[^a-z0-9_\-]/', '', (string) $value)); }
|
||||
function esc_html($value): string { return htmlspecialchars((string) $value, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); }
|
||||
function esc_attr($value): string { return esc_html($value); }
|
||||
function esc_url($value): string { return htmlspecialchars((string) $value, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); }
|
||||
function esc_html__($text, $domain = null): string { return esc_html($text); }
|
||||
function esc_attr__($text, $domain = null): string { return esc_attr($text); }
|
||||
function __($text, $domain = null): string { return (string) $text; }
|
||||
function wp_kses_post($text): string { return (string) $text; }
|
||||
function wp_unslash($value) { return $value; }
|
||||
function absint($value): int { return abs((int) $value); }
|
||||
function admin_url($path = ''): string { return 'https://example.test/wp-admin/' . ltrim($path, '/'); }
|
||||
function add_query_arg($args, $url): string { return $url . '?' . http_build_query($args); }
|
||||
function get_current_user_id(): int { return (int) $GLOBALS['aksrt_test_current_user']; }
|
||||
function get_userdata($user_id) { return $GLOBALS['aksrt_test_users'][(int) $user_id] ?? false; }
|
||||
function get_the_author_meta($field, $user_id) { $user = get_userdata($user_id); return $user ? ($user->{$field} ?? '') : ''; }
|
||||
function get_user_meta($user_id, $key, $single = false) { return $GLOBALS['aksrt_test_meta'][(int) $user_id][$key] ?? ''; }
|
||||
function user_can($user_id, $capability): bool { return (int) $user_id === 9 && $capability === 'manage_options'; }
|
||||
function aksrt_get_avatar_html($subject, $size = 96, $attrs = []): string {
|
||||
return '<img class="' . esc_attr($attrs['class'] ?? 'avatar-img') . '" width="' . (int) $size . '" height="' . (int) $size . '" src="avatar-' . (int) $subject . '.png" alt="">';
|
||||
}
|
||||
function aksrt_get_avatar_box($subject, string $class = 'avatar-img', bool $link = true, bool $vip = true): string {
|
||||
return '<span class="' . esc_attr($class) . '"><img class="avatar" width="38" height="38" src="avatar-' . (int) $subject . '.png" alt=""></span>';
|
||||
}
|
||||
function get_theme_file_uri($path = ''): string { return 'https://example.test/theme' . $path; }
|
||||
function aksrt_get_null($text = null, $margin = 60, $img = 'null.svg', $class = '', $width = 280, $height = 0): string {
|
||||
$GLOBALS['aksrt_test_null_calls'][] = [$text, $margin, $img];
|
||||
return '<div class="aksrt-test-null" data-margin="' . (int) $margin . '" data-image="' . esc_attr($img) . '">' . esc_html($text) . '</div>';
|
||||
}
|
||||
|
||||
require dirname(__DIR__, 2) . '/src/User/Badges.php';
|
||||
|
||||
\AKSRT\Settings\Settings::$settings = [
|
||||
'medal' => [
|
||||
'enabled' => true,
|
||||
'public_display' => true,
|
||||
'categories' => [
|
||||
[
|
||||
'cat_name' => '类别 <svg onload=alert(1)>',
|
||||
'items' => [
|
||||
['slug' => 'manual-new', 'name' => '手动未得 <b>危险</b>', 'desc' => '说明 <script>bad()</script>', 'icon' => '/manual-new.svg', 'get_type' => 'manually_add'],
|
||||
['slug' => 'auto-owned', 'name' => '自动已得', 'desc' => '自动描述', 'icon' => '/auto-owned.svg', 'get_type' => 'new_post'],
|
||||
['slug' => 'manual-owned', 'name' => '手动已得', 'desc' => '手动说明', 'icon' => '/manual-owned.svg', 'get_type' => 'manually_add'],
|
||||
['slug' => 'auto-new', 'name' => '自动未得', 'desc' => '自动未得说明', 'icon' => '/auto-new.svg', 'get_type' => 'new_post'],
|
||||
],
|
||||
],
|
||||
],
|
||||
'items_by_slug' => [
|
||||
['slug' => 'manual-new', 'name' => '手动未得 <b>危险</b>', 'desc' => '说明 <script>bad()</script>', 'icon' => '/manual-new.svg', 'get_type' => 'manually_add'],
|
||||
['slug' => 'auto-owned', 'name' => '自动已得', 'desc' => '自动描述', 'icon' => '/auto-owned.svg', 'get_type' => 'new_post'],
|
||||
['slug' => 'manual-owned', 'name' => '手动已得', 'desc' => '手动说明', 'icon' => '/manual-owned.svg', 'get_type' => 'manually_add'],
|
||||
['slug' => 'auto-new', 'name' => '自动未得', 'desc' => '自动未得说明', 'icon' => '/auto-new.svg', 'get_type' => 'new_post'],
|
||||
],
|
||||
],
|
||||
];
|
||||
|
||||
function render_private(string $method, array $args): string {
|
||||
$reflection = new \ReflectionMethod(\AKSRT\User\Badges::class, $method);
|
||||
return (string) $reflection->invokeArgs(null, $args);
|
||||
}
|
||||
|
||||
function reset_badge_caches(): void {
|
||||
\AKSRT\User\Badges::invalidate_cache();
|
||||
$property = new \ReflectionProperty(\AKSRT\User\Badges::class, 'dictionary_cache');
|
||||
$property->setValue(null, null);
|
||||
}
|
||||
|
||||
function render_case(int $viewer, string $template, int $user_id, string $slug = ''): array {
|
||||
$GLOBALS['aksrt_test_current_user'] = $viewer;
|
||||
reset_badge_caches();
|
||||
$args = $template === 'single' ? [$user_id, $slug] : [$user_id];
|
||||
if ($template === 'grant') $args = [$user_id];
|
||||
return [
|
||||
'html' => render_private($template === 'grant' ? 'modal_grant_html' : ($template === 'single' ? 'modal_single_html' : 'modal_info_html'), $args),
|
||||
'null_calls' => $GLOBALS['aksrt_test_null_calls'],
|
||||
];
|
||||
}
|
||||
|
||||
$output = [
|
||||
'self' => render_case(1, 'info', 1),
|
||||
'other' => render_case(1, 'info', 2),
|
||||
'admin_other' => render_case(9, 'info', 2),
|
||||
'admin_self' => render_case(9, 'info', 9),
|
||||
'single_owned_self' => render_case(1, 'single', 1, 'auto-owned'),
|
||||
'single_unowned_self' => render_case(1, 'single', 1, 'manual-new'),
|
||||
'single_wearing_self' => render_case(1, 'single', 1, 'manual-owned'),
|
||||
'single_manual_admin' => render_case(9, 'single', 2, 'manual-owned'),
|
||||
'single_auto_admin' => render_case(9, 'single', 2, 'auto-owned'),
|
||||
'single_auto_unowned_admin' => render_case(9, 'single', 2, 'auto-new'),
|
||||
'grant_admin' => render_case(9, 'grant', 2),
|
||||
];
|
||||
$output['illustrated_empty'] = render_case(1, 'single', 1, 'missing');
|
||||
$output['null_calls'] = $GLOBALS['aksrt_test_null_calls'];
|
||||
echo json_encode($output, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR);
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const { spawnSync } = require('node:child_process');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const test = require('node:test');
|
||||
|
||||
const root = path.resolve(__dirname, '..', '..');
|
||||
const harness = path.join(__dirname, 'user-badges-modal-harness.php');
|
||||
|
||||
function render() {
|
||||
const result = spawnSync('php', [harness], { encoding: 'utf8', cwd: root });
|
||||
assert.equal(result.status, 0, `PHP modal harness failed:\n${result.stderr || result.stdout}`);
|
||||
return JSON.parse(result.stdout);
|
||||
}
|
||||
|
||||
function assertBefore(html, first, second, message, from = 0) {
|
||||
const firstIndex = html.indexOf(first, from);
|
||||
const secondIndex = html.indexOf(second, from);
|
||||
assert.notEqual(firstIndex, -1, `missing ${first}`);
|
||||
assert.notEqual(secondIndex, -1, `missing ${second}`);
|
||||
assert.ok(firstIndex < secondIndex, message);
|
||||
}
|
||||
|
||||
test('overview matches the reference header, safely renders labels and places owned badges first', () => {
|
||||
const cases = render();
|
||||
const html = cases.self.html;
|
||||
|
||||
assert.match(html, /modal-colorful-header colorful-bg c-blue/);
|
||||
assert.match(html, /class="avatar-img"[^>]*>[\s\S]*?width="38"/);
|
||||
assert.doesNotMatch(html, /<a[^>]*>\s*<img[^>]*avatar-img/);
|
||||
assert.match(html, /medal-list-box[^>]*flex[^>]*at[^>]*hh|medal-list-box[^>]*hh[^>]*flex/);
|
||||
const listStart = html.indexOf('mini-scrollbar scroll-y max-vh5');
|
||||
assertBefore(html, '自动已得', '手动已得', 'owned medals keep the reference category order', listStart);
|
||||
assertBefore(html, '手动已得', '手动未得', 'owned medals render before unearned medals', listStart);
|
||||
assert.match(html, /类别 <svg onload=alert\(1\)>/);
|
||||
assert.match(html, /手动未得 <b>危险<\/b>/);
|
||||
assert.match(cases.grant_admin.html, /说明 <script>bad\(\)<\/script>/);
|
||||
assert.doesNotMatch(html, /<script>bad\(\)<\/script>|<svg onload=alert\(1\)>|<b>危险<\/b>/);
|
||||
assert.match(html, /data-aksrt-modal-height="201"|data-height="201"/);
|
||||
assert.match(html, /data-aksrt-modal-new="true"|new="new"/);
|
||||
assert.match(html, /data-aksrt-modal-mobile-bottom="true"|mobile-bottom="true"/);
|
||||
});
|
||||
|
||||
test('own and other-user overviews apply the ownership visibility rule for ordinary users and administrators', () => {
|
||||
const cases = render();
|
||||
for (const key of ['other', 'admin_other']) {
|
||||
const html = cases[key].html;
|
||||
assert.match(html, /自动已得/);
|
||||
assert.match(html, /手动已得/);
|
||||
assert.doesNotMatch(html, /手动未得|自动未得/);
|
||||
}
|
||||
assert.match(cases.other.html, /Other <img src=x onerror=alert\(1\)>/);
|
||||
assert.doesNotMatch(cases.other.html, /<img src=x onerror=alert\(1\)>/);
|
||||
assert.match(cases.admin_self.html, /授予徽章/);
|
||||
assert.match(cases.admin_self.html, /data-aksrt-modal-height="240"|data-height="240"/);
|
||||
assert.match(cases.admin_self.html, /data-aksrt-modal-mobile-bottom="true"|mobile-bottom="true"/);
|
||||
});
|
||||
|
||||
test('single-badge details show ownership state and only the permitted action', () => {
|
||||
const cases = render();
|
||||
assert.match(cases.single_owned_self.html, /2026-02-03 04:05:06 获得/);
|
||||
assert.match(cases.single_owned_self.html, /data-aksrt-badge-action="wear"/);
|
||||
assert.match(cases.single_unowned_self.html, /暂未获得/);
|
||||
assert.doesNotMatch(cases.single_unowned_self.html, /data-aksrt-badge-action=/);
|
||||
assert.match(cases.single_wearing_self.html, /当前佩戴此徽章/);
|
||||
assert.doesNotMatch(cases.single_wearing_self.html, /data-aksrt-badge-action="wear"/);
|
||||
|
||||
assert.match(cases.single_manual_admin.html, /收回此徽章/);
|
||||
assert.match(cases.single_manual_admin.html, /data-aksrt-badge-action="revoke"/);
|
||||
assert.doesNotMatch(cases.single_manual_admin.html, /2026-03-04 05:06:07 获得|暂未获得/);
|
||||
assert.doesNotMatch(cases.single_auto_admin.html, /data-aksrt-badge-action="revoke"|收回此徽章/);
|
||||
assert.match(cases.single_auto_unowned_admin.html, /参数传入错误或没有此徽章/);
|
||||
assert.doesNotMatch(cases.single_auto_unowned_admin.html, /data-aksrt-badge-action=/);
|
||||
assert.match(cases.single_owned_self.html, /data-aksrt-modal-close/);
|
||||
});
|
||||
|
||||
test('grant modal preserves configured manual-badge order, actions, mobile sizing, and illustrated empty state', () => {
|
||||
const cases = render();
|
||||
const html = cases.grant_admin.html;
|
||||
|
||||
assert.match(html, /手动未得/);
|
||||
assert.match(html, /手动已得/);
|
||||
assert.doesNotMatch(html, /自动已得|自动未得/);
|
||||
assertBefore(html, '手动未得', '手动已得', 'grant catalog follows the configured order');
|
||||
assert.match(html, /data-aksrt-badge-action="grant"/);
|
||||
assert.match(html, /data-aksrt-badge-action="revoke"/);
|
||||
const css = fs.readFileSync(path.join(root, 'assets/css/main.css'), 'utf8');
|
||||
assert.match(css, /\.medal-list\s*\{[^}]*width:\s*calc\(20%\s*-\s*14px\)[^}]*margin:\s*10px\s+7px/s);
|
||||
assert.match(css, /@media\s*\(max-width:\s*767px\)[\s\S]*?\.medal-list\s*\{[^}]*width:\s*calc\(25%\s*-\s*14px\)/);
|
||||
assert.match(css, /\.avatar-img\s*\{[^}]*--this-size:\s*38px/s);
|
||||
const badgeCss = fs.readFileSync(path.join(root, 'assets/css/frontend/medal.css'), 'utf8');
|
||||
assert.doesNotMatch(badgeCss, /\.medal-list-box\s*\{|\.medal-card__name|\.medal-card \.medal-icon/);
|
||||
|
||||
const empty = cases.illustrated_empty;
|
||||
assert.match(empty.html, /aksrt-test-null/);
|
||||
assert.ok(empty.null_calls.some((call) => call[1] === 30 && call[2] === 'null-2.svg'));
|
||||
});
|
||||
Reference in new issue
Block a user