Files
wordpress-theme-aksrt/tests/widget-system/modal-stack.test.cjs
T
kate522andClaude Opus 5 35bb96e82f refactor: rename the theme brand and every identifier from AKSRT to Jio
Full-project rename. `git grep -i aksrt` is now empty apart from the
DBMigrator 3.3.0 changelog note that documents the rename itself.

Code identifiers
- PHP namespace AKSRT\ -> Jio\ (composer PSR-4 + 378 declarations)
- global constants AKSRT_* -> JIO_* (VERSION/DIR/URI, PAY_TYPE_*, CAPTCHA_*)
- procedural functions, hooks, options, meta keys, table names,
  cookies, query vars, ajax actions and nonces: aksrt_ -> jio_
- text domain aksrt -> jio; Theme Name -> Jio
- JS globals: aksrtData/aksrtAdminData/... -> jioData/jioAdminData/...,
  window.AKSRTCaptcha & friends -> window.JioCaptcha, Aksrt* -> Jio*
- Vue components Aks*.vue -> Jio*.vue (15 files renamed + imports)
- CSS classes/data-attrs/custom properties: aksrt- -> jio-
- REST namespace aksrt/v1 -> jio/v1 (incl. payment gateway callbacks)

Storage layer
- DB tables {prefix}aksrt_* -> {prefix}jio_*; SCHEMA_VERSION 3.2.0 -> 3.3.0
  so dbDelta creates the new names on install
- widget id_base aksrt_widget_* -> jio_widget_*
- uninstall.php drop list rebuilt from the actual migrator tables
  (dropped 3 stale entries, added the 17 missing ones)

Removed
- hardcoded https://api.aksrt.com/version.json default update endpoint;
  get_endpoint() now reads the 「更新端点」 setting and do_check() no-ops
  when it is empty

Files renamed: inc/helpers/aksrt-*.php -> jio-*.php (54),
assets/admin/src/components/form/Aks*.vue -> Jio*.vue (15).
Build artifacts regenerated: assets/css/build, assets/js/runtime,
assets/admin/build.

Verified: audit-psr4-paths errors=0, audit-admin-contracts errors=0
(warnings=56, unchanged baseline), 813/813 widget-system tests,
44/44 phpunit, php -l clean across all PHP.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-11-01 20:05:04 +08:00

319 lines
13 KiB
JavaScript

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('jio-modal-runtime__content')) {
this.dialog = new Element(this.ownerDocument);
const content = new Element(this.ownerDocument);
content.className = 'jio-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 === '.jio-modal-runtime__dialog') return this.dialog || null;
if (selector === '.jio-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,
JioModalTelemetry: { start: () => '', mark() {} },
JioModalState: { create: () => ({}) },
JioModalCache: {
acquire(key, request) {
const promise = request();
return { promise, release() {} };
},
invalidate() {},
},
JioRequest: {
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.JioModalRuntime, 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('jio-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('jio-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('jio-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('jio-modal-runtime-open'), true);
runtime.close('control');
flushTimers(timers);
assert.equal(runtime.getActive(), null);
assert.equal(document.documentElement.classList.contains('jio-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('jio-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-jio-modal-url': '/badge',
'data-jio-modal-new': 'true',
'data-jio-modal-loading': 'spinner',
'data-jio-modal-swipe-close': 'true',
};
const element = { getAttribute: (name) => attrs[name] || null };
const document = {
addEventListener(type, listener) { this.listener = listener; },
};
let options;
const window = { JioModalRuntime: { 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="jio-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, '.jio-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);
});