四条导入提示里的 <li> 原先裸挂在 <div class="aksrt-submsg"> 下,没有列表容器: list-style-position 默认 outside,圆点画在内容盒左侧的 padding 区里,而 <li> 自身没有 padding,于是圆点溢出到提示块的文字边界之外(真实后台 CSS 下实测 −8px, 视觉上紧贴提示块左侧色条)。 改动: - 四条提示的 <li> 各用一个 <ul class="aksrt-submsg-list"> 包起来(同时修掉 <li> 缺少列表父元素的非法标记)。 - .aksrt-submsg-list 显式声明 margin / padding-left / list-style,不依赖 UA 或后台对 ul 的默认值,避免别处的重置再次把圆点挤出去。 - 新增契约用例:提示内的 <li> 必须在 <ul class="aksrt-submsg-list"> 中、标签成对、 每条提示只有一个列表容器,且该类的 CSS 必须带 padding-left 与 list-style。 实测(真实 Chrome + 真实后台 common/forms/list-tables CSS):圆点左缘由 「文字边界 −8px(溢出)」变为「文字边界 +12px(界内)」,条目文字缩进 20px。
340 lines
16 KiB
JavaScript
340 lines
16 KiB
JavaScript
'use strict';
|
||
|
||
/**
|
||
* 「卡密管理 → 添加卡密 / 导出卡密」依赖显隐契约。
|
||
*
|
||
* 这两个表单的依赖显隐由两层协作完成,两层必须对「键 → 默认值 → 操作符」保持完全一致:
|
||
* 1) 服务端预判:deps_visible() + is_known_dep_key() + default_form_value(),渲染期就把不满足
|
||
* 依赖的 <tr> 标成 display:none,避免首帧把导入提示全部铺开;
|
||
* 2) 前端求值:行内脚本 evalDeps(),在 DOMContentLoaded 与 change 时求值。
|
||
*
|
||
* 历史故障(本测试要锁死的行为):
|
||
* - 判定键未登记 → 服务端把该条件当作「满足」,与 JS 结果错配(例如自定义卡密的两条导入提示同时铺开);
|
||
* - 字段名拼进 CSS 选择器 → 含方括号的 vip_exchange[time] 让选择器抛异常并被 catch 吞掉,
|
||
* 依赖行永久停在初始状态。
|
||
*
|
||
* 这里不重写求值逻辑:直接抽取并执行源码里的行内脚本,用真实 dep 数组驱动它。
|
||
*/
|
||
|
||
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 root = path.resolve(__dirname, '..', '..');
|
||
const page = fs.readFileSync(path.join(root, 'src/Admin/Native/PayChargeCards.php'), 'utf8');
|
||
|
||
/** 依赖条目:checked / unchecked 不写 val,与 wp_json_encode 的输出保持一致。 */
|
||
const DEP_ENTRY = /\['key'\s*=>\s*'([^']*)'\s*,\s*'op'\s*=>\s*'([^']*)'(?:\s*,\s*'val'\s*=>\s*'([^']*)')?\]/g;
|
||
|
||
/** 取某个方法/函数的完整花括号体。 */
|
||
function functionBody(source, signature) {
|
||
const start = source.indexOf(signature);
|
||
assert.ok(start >= 0, '未找到:' + signature);
|
||
const open = source.indexOf('{', start);
|
||
let depth = 0;
|
||
for (let i = open; i < source.length; i++) {
|
||
if (source[i] === '{') depth++;
|
||
else if (source[i] === '}' && --depth === 0) return source.slice(open, i + 1);
|
||
}
|
||
throw new Error('花括号不闭合:' + signature);
|
||
}
|
||
|
||
/** 扫描 `->name(...)` 调用,返回按源码顺序排列的调用文本。 */
|
||
function extractCalls(source, marker) {
|
||
const calls = [];
|
||
let from = 0;
|
||
for (;;) {
|
||
const at = source.indexOf(marker, from);
|
||
if (at < 0) break;
|
||
let depth = 0;
|
||
let quote = '';
|
||
let i = at + marker.length - 1; // marker 末尾的 '(' 本身
|
||
for (; i < source.length; i++) {
|
||
const c = source[i];
|
||
if (quote) {
|
||
if (c === '\\') { i++; continue; }
|
||
if (c === quote) quote = '';
|
||
continue;
|
||
}
|
||
if (c === "'" || c === '"') { quote = c; continue; }
|
||
if (c === '(') depth++;
|
||
else if (c === ')' && --depth === 0) break;
|
||
}
|
||
calls.push({ at, text: source.slice(at, i + 1) });
|
||
from = i + 1;
|
||
}
|
||
return calls;
|
||
}
|
||
|
||
/** 收集一行渲染调用携带的依赖规则与显示标签。 */
|
||
function rowsWithDeps(source) {
|
||
const markers = [
|
||
{ marker: '->field_row(', label: (t) => (t.match(/__\(\s*'([^']*)'\s*,\s*'aksrt'\s*\)/) || [])[1] },
|
||
{
|
||
marker: '->submessage_row(',
|
||
label: (t) => (t.match(/<b>'\s*\.\s*esc_html__\(\s*'([^']*)'\s*,\s*'aksrt'\s*\)/) || [])[1],
|
||
},
|
||
];
|
||
const calls = [];
|
||
for (const { marker, label } of markers) {
|
||
for (const call of extractCalls(source, marker)) {
|
||
const deps = [...call.text.matchAll(DEP_ENTRY)].map((m) => {
|
||
const dep = { key: m[1], op: m[2] };
|
||
if (m[3] !== undefined) dep.val = m[3];
|
||
return dep;
|
||
});
|
||
if (deps.length) calls.push({ at: call.at, deps, label: label(call.text) || '(无标题)' });
|
||
}
|
||
}
|
||
return calls.sort((a, b) => a.at - b.at);
|
||
}
|
||
|
||
const addFormEnd = page.indexOf('private function render_export_form(');
|
||
const addRows = rowsWithDeps(page).filter((row) => row.at < addFormEnd);
|
||
const allRows = rowsWithDeps(page);
|
||
|
||
/**
|
||
* 行内脚本(添加卡密与导出卡密共用同一段 print_assets 输出)。
|
||
* 脚本里有 PHP 插值(如复制提示文案),执行前先按渲染结果替换掉。
|
||
*/
|
||
const script = page
|
||
.slice(page.indexOf('<script>') + '<script>'.length, page.indexOf('</script>'))
|
||
.replace(/<\?php[\s\S]*?\?>/g, '');
|
||
|
||
const HINTS = ['导入卡密', '导入会员兑换卡密', '自定义卡密导入', '自定义卡密导入(单密码模式)'];
|
||
|
||
/**
|
||
* 用最小 DOM 桩加载源码里的行内脚本,并按「添加卡密」表单的 dep 数组建行。
|
||
* 选择器字符串与源码一一对应:脚本改了选择器,这里会因取不到行而直接失败。
|
||
*/
|
||
function createForm() {
|
||
const inputs = [];
|
||
const addInput = (name, type, value, checked) => inputs.push({
|
||
type, value, checked: !!checked, getAttribute: (n) => (n === 'name' ? name : null),
|
||
});
|
||
['balance', 'vip', 'credits', 'custom'].forEach((v) => addInput('type', 'radio', v, v === 'balance'));
|
||
['auto', 'import'].forEach((v) => addInput('add_type', 'radio', v, v === 'auto'));
|
||
addInput('auto_top_s', 'checkbox', '1', false);
|
||
addInput('only_pass_s', 'checkbox', '1', false);
|
||
addInput('vip_exchange[time]', 'text', '', false);
|
||
|
||
// 每个依赖行对应一个 <tr>;提示行(submessage_row)还会把同一份 data-dep 与隐藏状态
|
||
// 额外挂在 <td> 内的提示块上,因此这里也为它建第二个元素(模拟提示块脱离行的情况)。
|
||
const rows = [];
|
||
for (const row of addRows) {
|
||
const json = JSON.stringify(row.deps);
|
||
const makeEl = () => ({ style: {}, getAttribute: (n) => (n === 'data-dep' ? json : null) });
|
||
rows.push({ label: row.label, deps: row.deps, el: makeEl() });
|
||
if (HINTS.includes(row.label)) rows.push({ label: row.label, deps: row.deps, el: makeEl() });
|
||
}
|
||
|
||
const handlers = {};
|
||
const document = {
|
||
readyState: 'loading',
|
||
querySelectorAll(sel) {
|
||
if (sel === '.aksrt-cardpass-form [name]') return inputs;
|
||
if (sel === '[data-dep]') return rows.map((row) => row.el);
|
||
return [];
|
||
},
|
||
addEventListener(type, fn) { (handlers[type] = handlers[type] || []).push(fn); },
|
||
};
|
||
|
||
vm.runInNewContext(script, { document, navigator: {}, console });
|
||
|
||
const fire = (type, event) => (handlers[type] || []).forEach((fn) => fn(event || {}));
|
||
const setRadio = (name, value) => inputs.forEach((el) => {
|
||
if (el.getAttribute('name') === name) el.checked = el.value === value;
|
||
});
|
||
const setChecked = (name, on) => inputs.forEach((el) => {
|
||
if (el.getAttribute('name') === name) el.checked = !!on;
|
||
});
|
||
|
||
fire('DOMContentLoaded');
|
||
const visible = () => [...new Set(rows.filter((row) => row.el.style.display !== 'none').map((row) => row.label))];
|
||
/** 提示的「行 + 提示块」两层各自的显示状态,用于验证两层不会分叉。 */
|
||
const hintLayers = () => rows
|
||
.filter((row) => HINTS.includes(row.label))
|
||
.map((row) => row.label + ':' + (row.el.style.display === 'none' ? 'off' : 'on'));
|
||
|
||
return {
|
||
setRadio, setChecked, visible, hintLayers,
|
||
change() { fire('change', { target: { closest: () => ({}) } }); },
|
||
hintVisible: () => visible().filter((label) => HINTS.includes(label)),
|
||
count: rows.length,
|
||
};
|
||
}
|
||
|
||
test('依赖键必须在 is_known_dep_key 与 default_form_value 中成对登记', () => {
|
||
const body = functionBody(page, 'private function is_known_dep_key(');
|
||
const known = new Set([...body.matchAll(/'([^']+)'/g)].map((m) => m[1]));
|
||
const defaults = new Set(
|
||
[...functionBody(page, 'private function default_form_value(').matchAll(/\bcase\s+'([^']+)':/g)].map((m) => m[1])
|
||
);
|
||
|
||
assert.ok(allRows.length > 0, '未解析到任何依赖行');
|
||
const used = new Set(allRows.flatMap((row) => row.deps.map((d) => d.key)));
|
||
assert.ok(used.size >= 6, '依赖键数量异常:' + used.size);
|
||
|
||
for (const key of used) {
|
||
assert.ok(known.has(key), '依赖键未登记到 is_known_dep_key():' + key);
|
||
assert.ok(defaults.has(key), '依赖键缺少 default_form_value() 的显式分支:' + key);
|
||
}
|
||
});
|
||
|
||
test('两层求值器实现同一组操作符,且覆盖表单用到的全部操作符', () => {
|
||
const serverOps = new Set([...functionBody(page, 'private function deps_visible(').matchAll(/case\s+'([^']+)':/g)].map((m) => m[1]));
|
||
const clientOps = new Set([...script.matchAll(/case\s+'([^']+)':/g)].map((m) => m[1]));
|
||
|
||
for (const op of ['==', '!=', 'any', 'checked', 'unchecked']) {
|
||
assert.ok(serverOps.has(op), 'deps_visible() 缺少操作符:' + op);
|
||
assert.ok(clientOps.has(op), 'evalDeps() 缺少操作符:' + op);
|
||
}
|
||
for (const row of allRows) {
|
||
for (const dep of row.deps) {
|
||
assert.ok(serverOps.has(dep.op), '表单使用了服务端未实现的操作符:' + dep.op);
|
||
assert.ok(clientOps.has(dep.op), '表单使用了前端未实现的操作符:' + dep.op);
|
||
}
|
||
}
|
||
});
|
||
|
||
test('两种行渲染都接入服务端预隐藏,避免首帧铺开', () => {
|
||
assert.match(page, /private function field_row\([\s\S]*?\$hidden\s*=\s*\(\$deps && !\$this->deps_visible\(\$deps\)\)/);
|
||
assert.match(page, /private function submessage_row\([\s\S]*?\$hidden\s*=\s*\$this->deps_visible\(\$deps\)/);
|
||
assert.match(script, /document\.addEventListener\('DOMContentLoaded', evalDeps\)/);
|
||
// 提示块必须自带 data-dep 与初始隐藏状态:即使提示块脱离所在行(WP 的
|
||
// wp-admin/js/common.js 会把页面上的 notice 搬到页头,见下一条用例),
|
||
// 它仍能被求值并保持隐藏。
|
||
assert.match(page, /<td colspan="2"><div'\s*\.\s*\$dep_attr/, '提示块必须自带 data-dep');
|
||
assert.match(page, /class="aksrt-submsg"'\s*\.\s*\$hidden/, '提示块必须自带初始隐藏状态');
|
||
// 求值范围不限域:提示块被移出表格/表单子树后仍必须被求值。
|
||
assert.match(script, /querySelectorAll\('\[data-dep\]'\)/, '求值必须全文档查找 data-dep');
|
||
assert.doesNotMatch(script, /querySelectorAll\('\.aksrt-cardpass-form \[data-dep\]'\)/, '求值不得限域在表单内');
|
||
});
|
||
|
||
test('导入提示不得使用 WP 的 .notice 类(否则被 common.js 搬到页面顶部)', () => {
|
||
// wp-admin/js/common.js 在 DOM 就绪时执行:
|
||
// $('div.updated, div.error, div.notice').not('.inline, .below-h2').insertAfter($headerEnd)
|
||
// 会把页面上所有非 inline 的 notice 搬到 .wp-header-end(不存在时取 .wrap 内首个 h1/h2)之后。
|
||
// 提示块一旦带 .notice,就会脱离「添加方式—卡密数据」之间的字段区跑到页面顶部,
|
||
// 且搬迁发生在 evalDeps 之后,行内显隐逻辑无法纠正(历史故障:提示一直显示在页面最上方)。
|
||
assert.match(
|
||
page,
|
||
/private function submessage_row\([\s\S]*?class="aksrt-submsg"/,
|
||
'提示块只应带自有类名 aksrt-submsg'
|
||
);
|
||
assert.doesNotMatch(
|
||
page,
|
||
/class="aksrt-submsg[^"]*\bnotice\b/,
|
||
'提示块不得带 notice 类:会被 common.js 搬到页面顶部'
|
||
);
|
||
// 外观改由自有类名承担,删掉样式规则会让提示失去底色与左侧色条。
|
||
assert.match(
|
||
page,
|
||
/\.aksrt-submsg \{[^}]*background:[^}]*border-left:/,
|
||
'.aksrt-submsg 必须自带 background 与 border-left 外观'
|
||
);
|
||
// 允许(且要求)真正需要置顶的提示保留 notice:确认本用例没有误伤其它通知输出。
|
||
assert.match(page, /class="notice notice-error"/, '页面级错误通知仍应使用 notice');
|
||
});
|
||
|
||
test('导入提示的条目列表由 <ul> 承载,圆点不越出文字边界', () => {
|
||
// list-style-position 默认 outside:圆点画在内容盒左侧的 padding 区里。
|
||
// <li> 若没有列表容器提供缩进(历史上的写法是裸 <li> 直接挂在 div 下),
|
||
// padding 区为空,圆点就会溢出到提示块的文字边界之外。
|
||
const calls = extractCalls(page, '->submessage_row(');
|
||
assert.ok(calls.length >= 4, '未解析到足够的 submessage_row 调用:' + calls.length);
|
||
|
||
let checked = 0;
|
||
for (const call of calls) {
|
||
const opens = [...call.text.matchAll(/<li>/g)].length;
|
||
if (!opens) continue;
|
||
checked++;
|
||
assert.match(
|
||
call.text,
|
||
/<ul class="aksrt-submsg-list">/,
|
||
'提示内的 <li> 必须包在 <ul class="aksrt-submsg-list"> 中'
|
||
);
|
||
assert.equal([...call.text.matchAll(/<ul class="aksrt-submsg-list">/g)].length, 1, '每条提示只应有一个条目列表');
|
||
assert.equal(opens, [...call.text.matchAll(/<\/li>/g)].length, '<li> 标签必须成对');
|
||
assert.equal([...call.text.matchAll(/<\/ul>/g)].length, 1, '</ul> 必须成对');
|
||
}
|
||
assert.equal(checked, 4, '四条导入提示都应带条目列表');
|
||
|
||
assert.match(page, /\.aksrt-submsg-list \{[^}]*padding-left:/, '.aksrt-submsg-list 必须给出 padding-left,圆点才有落点');
|
||
assert.match(page, /\.aksrt-submsg-list \{[^}]*list-style:\s*disc/, '.aksrt-submsg-list 应显式声明圆点样式,不依赖 UA 默认值');
|
||
});
|
||
|
||
test('行内脚本不再保留解析期的空操作求值(死代码)', () => {
|
||
assert.doesNotMatch(script, /evalDeps\(\);\s*\}\)\(\);/);
|
||
assert.match(script, /document\.addEventListener\('change'/);
|
||
});
|
||
|
||
test('添加卡密:导入提示跟随「卡密类型 / 添加方式」切换,且不同时铺开', () => {
|
||
const form = createForm();
|
||
assert.equal(form.count, 21, '添加卡密的依赖元素数量发生变化(13 行 + 4 提示块×2),请同步本测试');
|
||
assert.deepEqual(form.hintVisible(), [], '默认(系统自动生成)下不应显示任何导入提示');
|
||
assert.ok(form.visible().includes('生成数量'));
|
||
assert.ok(!form.visible().includes('卡密数据'));
|
||
|
||
const cases = [
|
||
{ type: 'balance', hint: '导入卡密' },
|
||
{ type: 'credits', hint: '导入卡密' },
|
||
{ type: 'vip', hint: '导入会员兑换卡密' },
|
||
];
|
||
for (const { type, hint } of cases) {
|
||
form.setRadio('type', type);
|
||
form.setRadio('add_type', 'import');
|
||
form.change();
|
||
assert.deepEqual(form.hintVisible(), [hint], type + ' + 导入卡密 只应出现对应提示');
|
||
assert.ok(form.visible().includes('卡密数据'), type + ' + 导入卡密 应显示卡密数据');
|
||
assert.ok(!form.visible().includes('生成数量'), type + ' + 导入卡密 不应显示自动生成字段');
|
||
}
|
||
|
||
form.setRadio('type', 'custom');
|
||
form.change();
|
||
assert.deepEqual(form.hintVisible(), ['自定义卡密导入']);
|
||
assert.ok(form.visible().includes('单密码模式'));
|
||
|
||
form.setChecked('only_pass_s', true);
|
||
form.change();
|
||
assert.deepEqual(form.hintVisible(), ['自定义卡密导入(单密码模式)']);
|
||
|
||
form.setChecked('only_pass_s', false);
|
||
form.change();
|
||
assert.deepEqual(form.hintVisible(), ['自定义卡密导入']);
|
||
|
||
form.setRadio('add_type', 'auto');
|
||
form.setRadio('type', 'balance');
|
||
form.change();
|
||
assert.deepEqual(form.hintVisible(), [], '切回系统自动生成后导入提示必须全部收起');
|
||
|
||
form.setChecked('auto_top_s', true);
|
||
form.change();
|
||
assert.ok(form.visible().includes('自定义卡号位数'), '高级选项勾选后应显示自定义位数');
|
||
});
|
||
|
||
test('提示块与所在行同层落 display,提示块脱离行也不会失效', () => {
|
||
const form = createForm();
|
||
const HINT_LABELS = ['导入卡密', '导入会员兑换卡密', '自定义卡密导入', '自定义卡密导入(单密码模式)'];
|
||
const layersFor = (labels) => HINT_LABELS.flatMap((l) => [l + ':' + (labels.includes(l) ? 'on' : 'off'), l + ':' + (labels.includes(l) ? 'on' : 'off')]);
|
||
|
||
// 默认态(系统自动生成 + 余额充值):两层共 8 个元素必须全部隐藏。
|
||
assert.deepEqual(
|
||
form.hintLayers(),
|
||
HINT_LABELS.flatMap((l) => [l + ':off', l + ':off']),
|
||
'默认态下每条提示的行与提示块都必须隐藏'
|
||
);
|
||
|
||
form.setRadio('type', 'vip');
|
||
form.setRadio('add_type', 'import');
|
||
form.change();
|
||
assert.deepEqual(form.hintLayers(), layersFor(['导入会员兑换卡密']), '只有命中依赖的那条提示的两层应当显示');
|
||
|
||
form.setRadio('add_type', 'auto');
|
||
form.setRadio('type', 'balance');
|
||
form.change();
|
||
assert.deepEqual(form.hintLayers(), HINT_LABELS.flatMap((l) => [l + ':off', l + ':off']), '切回默认后两层必须同时收起');
|
||
});
|