diff --git a/.gitignore b/.gitignore index c458d63a..428aa1bf 100644 --- a/.gitignore +++ b/.gitignore @@ -58,6 +58,7 @@ !/tests/admin/ /tests/admin/* !/tests/admin/charge-cards-page-contract.test.cjs +!/tests/admin/charge-cards-dependency.test.cjs !/tests/front-submit/ /tests/front-submit/* !/tests/front-submit/video-cover-contract.test.cjs diff --git a/src/Admin/Native/PayChargeCards.php b/src/Admin/Native/PayChargeCards.php index 7e518f8b..018b993c 100644 --- a/src/Admin/Native/PayChargeCards.php +++ b/src/Admin/Native/PayChargeCards.php @@ -1096,7 +1096,9 @@ class PayChargeCards { document.addEventListener('change', function (e) { if (e.target.closest('.aksrt-cardpass-form')) evalDeps(); }); - // 初始按默认值求值一次:服务端已预隐藏,这里兜底校正。 + // 唯一的初始求值入口:本脚本在表单之前输出,此刻解析尚未到达表单, + // 直接求值必然取不到任何行(空操作);因此必须等 DOM 就绪后按默认值校正一次。 + // 服务端已按默认值预隐藏,这里只做兜底校正。 if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', evalDeps); } else { @@ -1130,7 +1132,6 @@ class PayChargeCards { legacy(); } }); - evalDeps(); })(); 标成 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('自定义卡号位数'), '高级选项勾选后应显示自定义位数'); +});