fix(user): align badge modals with reference theme

This commit is contained in:
kate522 committed 2026-10-29 17:38:10 +08:00
1 parent 8e9d3a7e49
commit 1c501ff713
17 files changed
+931 -313

No files matched your search

+318
View File
@@ -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, /类别 &lt;svg onload=alert\(1\)&gt;/);
assert.match(html, /手动未得 &lt;b&gt;危险&lt;\/b&gt;/);
assert.match(cases.grant_admin.html, /说明 &lt;script&gt;bad\(\)&lt;\/script&gt;/);
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 &lt;img src=x onerror=alert\(1\)&gt;/);
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'));
});