fix(payment): 添加卡密页清理依赖求值死代码并锁定显隐契约

render_add_form() 渲染 19 行表单,其中 17 行带 data-dep;4 条导入提示
(导入卡密 / 导入会员兑换卡密 / 自定义卡密导入 / 自定义卡密导入-单密码模式)
的显隐依赖已由服务端预隐藏与服务端默认值修正落地,本次仅做收尾。

变更:
- 删除行内脚本 IIFE 末尾的 evalDeps() 死调用。该调用位于表单之前的
  解析期(脚本在表单前输出),此刻 document 中尚无任何 tr[data-dep],
  属于必然空操作;初始求值统一交由 DOMContentLoaded 分支处理。
- 补充注释说明「脚本在表单前输出 → 解析期取不到行 → 必须等 DOM 就绪」,
  避免后人误加回解析期调用。
- .gitignore 追加 /tests/admin/charge-cards-dependency.test.cjs 白名单。

新增契约测试 tests/admin/charge-cards-dependency.test.cjs(5 用例,全过):
从源码抽取 field_row/submessage_row 的 data-dep,用 Node vm 加载真实行内
脚本并以最小 DOM 桩驱动,断言:
1) 依赖键须在 is_known_dep_key 与 default_form_value 成对登记;
2) 服务端预判与前端求值使用同一组操作符且覆盖表单实际用到的全部操作符;
3) 两类行渲染均接入服务端预隐藏,避免首帧铺开;
4) 行内脚本不再保留解析期空操作求值;
5) 导入提示跟随「卡密类型 × 添加方式」切换(balance/credits/vip/custom
   及单密码模式矩阵),且不同时铺开、切回 auto 全部收起。

负向验证:将 balance,credits 篡改为 balance,credits,vip 后测试转为
pass 4 / fail 1,护栏确实生效。

验证:node --test 两个 admin 契约测试 9/9 通过;php -l 无语法错误;
dev 与运行副本内容一致(行尾归一后逐字节相同)。
This commit is contained in:
2026-09-17 11:03:34 +08:00
parent 1cbc0c6ed8
commit 5243bbf0d4
3 changed files with 250 additions and 2 deletions
@@ -0,0 +1,246 @@
'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);
const rows = addRows.map((row) => {
const json = JSON.stringify(row.deps);
return { label: row.label, deps: row.deps, el: { style: {}, getAttribute: (n) => (n === 'data-dep' ? json : null) } };
});
const handlers = {};
const document = {
readyState: 'loading',
querySelectorAll(sel) {
if (sel === '.aksrt-cardpass-form [name]') return inputs;
if (sel === '.aksrt-cardpass-form tr[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 = () => rows.filter((row) => row.el.style.display !== 'none').map((row) => row.label);
return {
setRadio, setChecked, visible,
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\)/);
});
test('行内脚本不再保留解析期的空操作求值(死代码)', () => {
assert.doesNotMatch(script, /evalDeps\(\);\s*\}\)\(\);/);
assert.match(script, /document\.addEventListener\('change'/);
});
test('添加卡密:导入提示跟随「卡密类型 / 添加方式」切换,且不同时铺开', () => {
const form = createForm();
assert.equal(form.count, 17, '添加卡密的依赖行数量发生变化,请同步本测试');
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('自定义卡号位数'), '高级选项勾选后应显示自定义位数');
});