'use strict'; /** * 「卡密管理 → 添加卡密 / 导出卡密」依赖显隐契约。 * * 这两个表单的依赖显隐由两层协作完成,两层必须对「键 → 默认值 → 操作符」保持完全一致: * 1) 服务端预判:deps_visible() + is_known_dep_key() + default_form_value(),渲染期就把不满足 * 依赖的 标成 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(/'\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('')) .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('自定义卡号位数'), '高级选项勾选后应显示自定义位数'); });