Files
wordpress-theme-aksrt/tests/admin/charge-cards-dependency.test.cjs
T
kate522 1e5286d36b fix(payment): 导入提示的条目列表改用 <ul> 承载,圆点不再越出文字边界
四条导入提示里的 <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。
2026-09-17 12:28:09 +08:00

340 lines
16 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'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']), '切回默认后两层必须同时收起');
});