Files
wordpress-theme-aksrt/tests/admin/charge-cards-dependency.test.cjs
T
kate522 670e271ff6 fix(payment): 添加卡密页提示块自带依赖状态,脱离所在行也不再铺开
用户复报「四条导入提示仍然全部铺开」。其 DevTools 实测给出关键判据:
同一页面上 formClass=1 / submsg=4 / depRows=17 与线上一致,但四条提示的
父级 <tr> 为 null、computed display 为 block —— 也就是说提示块虽在 DOM 中,
却不在任何表格行内。此时依赖状态只挂在 <tr> 上,隐藏与切换都无处落地,
于是四条提示永久可见、切换卡密类型/添加方式也没有反应。

修复:把 data-dep 与初始隐藏状态同时挂到内层提示块上,求值器改为
「谁带 data-dep 就管谁」,行与提示块各自落自己的 display。

变更(src/Admin/Native/PayChargeCards.php):
- submessage_row():<tr> 与内层 <div class="aksrt-submsg"> 共用同一份
  data-dep;初始隐藏状态合并进提示块原有的 margin/padding 内联样式,
  不产生重复的 style 属性。原「只给 <tr> 加 style」的写法保留给行本身。
- evalDeps():选择器由 '.aksrt-cardpass-form tr[data-dep]' 放宽为
  '.aksrt-cardpass-form [data-dep]',并改用 el.style.display 落值。

测试(tests/admin/charge-cards-dependency.test.cjs):
- DOM 桩按真实结构为每条提示建「行 + 提示块」两个元素,新增用例
  「提示块与所在行同层落 display」断言两层始终同状态(默认 8 个元素全隐藏、
  命中依赖时仅该提示的两层可见、切回默认两层同时收起)。
- 契约用例新增三条静态断言:提示块必须自带 data-dep、必须自带初始隐藏状态、
  求值选择器必须同时覆盖行与提示块。
- 负向验证:把提示块的 data-dep 去掉后跑测试 → pass 5 / fail 1,护栏生效。

验证:
- node --test 两个 admin 契约测试 10/10 通过;php -l 无语法错误;
- 线上抓取:tr_total=19 / tr_with_dep=17 / div_with_dep=4 / display_none=18;
  四条提示的 div 均带 data-dep="…" 与 display:none。
- 真实浏览器模拟「提示块被搬出所在行」(foster-parent 到 <table> 之前)后:
  默认态四条全部 none;切到「会员兑换 + 导入卡密」时,只有已脱离行的
  「导入会员兑换卡密」变为可见(ownStyle 中的 display:none 被移除),
  其余三条维持 none;切回默认四条全部收 none。
2026-09-17 11:41:05 +08:00

284 lines
13 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 === '.aksrt-cardpass-form [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 与初始隐藏状态:脱离所在行时(表格解析器把非法节点
// foster-parent 到 <table> 之前)仍能被求值并保持隐藏。
assert.match(page, /<td colspan="2"><div'\s*\.\s*\$dep_attr/, '提示块必须自带 data-dep');
assert.match(page, /style="margin:6px 0;padding:6px 12px;'\s*\.\s*\$hidden/, '提示块必须自带初始隐藏状态');
assert.match(script, /querySelectorAll\('\.aksrt-cardpass-form \[data-dep\]'\)/, '求值选择器必须同时覆盖行与提示块');
});
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']), '切回默认后两层必须同时收起');
});