/** * 余额充值弹窗壳契约(第 19 轮 Zibll 对齐 + 第 21 轮全页不可点击回归护栏)。 * * 关键约束:本主题的弹窗壳是服务端静态输出,与 Zibll(refresh_modal JS * 按需创建、且其 CSS 无 bootstrap .modal 基础规则)架构不同。若弹窗壳在 * 静止态没有被显式隐藏,main.css 的 .flex{display:flex}(后加载、与 * bootstrap.min.css 的 .modal{display:none} 同特异性、后者胜)会让 * `class="modal flex jc fade"` 壳永久 display:flex 铺满全屏,叠加 .fade * 的 opacity:0 形成隐形遮罩 → 整页所有按钮不可点击(2026-09-17 实测故障)。 * * 因此 credits.css 必须以 (0,2,0) 特异性压制: * .modal[data-aksrt-charge-modal] { display: none; } * .modal[data-aksrt-charge-modal].in { display: flex; } * 展示态由 credits.js 自包含开关的内联 display:flex 驱动(不依赖 * jQuery/Bootstrap 运行时;block 会破坏 .flex.jc 双轴居中—— * .modal.flex .modal-dialog{margin:0} 在 block 下使对话框顶在左上角), * 关闭后内联样式撤除即回到隐藏规则。 */ const test = require('node:test'); const assert = require('node:assert'); const fs = require('node:fs'); const path = require('node:path'); const root = path.join(__dirname, '..', '..'); const read = (...seg) => fs.readFileSync(path.join(root, ...seg), 'utf8'); const wallet = read('inc', 'helpers', 'user-center-renderers', 'credits-wallet.php'); const creditsJs = read('assets', 'js', 'credits.js'); const creditsCss = read('assets', 'css', 'routes', 'user-center', 'credits.css'); const balanceRest = read('src', 'Credits', 'BalanceServiceRest.php'); test('弹窗壳为 Zibll refresh_modal 同构静态壳并带 data 钩子', () => { assert.match(wallet, /class="modal flex jc fade"[^>]*data-aksrt-charge-modal/); assert.match(wallet, /data-aksrt-charge-form/); assert.match(wallet, /data-dismiss="modal"/); assert.match(wallet, /initiate-pay/); }); test('弹窗壳静止态必须被 credits.css 显式隐藏(.flex 覆盖回归护栏)', () => { // (0,2,0) 特异性压制 main.css 的 .flex{display:flex}。 assert.match(creditsCss, /\.modal\[data-aksrt-charge-modal\]\s*\{\s*display:\s*none;/); // 打开态必须是 flex 而非 block:.modal.flex .modal-dialog{margin:0} 在 // block 下使对话框顶在左上角(无头 Chrome 几何实证),flex 下由壳的 // .flex.jc 双轴居中(Zibll 生产环境即此形态)。 assert.match(creditsCss, /\.modal\[data-aksrt-charge-modal\]\.in\s*\{\s*display:\s*flex;/); assert.doesNotMatch(creditsCss, /\.modal\[data-aksrt-charge-modal\]\.in\s*\{\s*display:\s*block;/); // 购买积分弹窗壳同构(服务端静态输出 .modal.flex.jc.fade),静止态同样 // 必须显式隐藏,否则隐形遮罩拦截整页点击(2026-09-18 实测回归)。 const balance = read('inc', 'helpers', 'user-center-renderers', 'credits-balance.php'); assert.match(balance, /class="modal flex jc fade"[^>]*data-aksrt-points-pay-modal/); assert.match(creditsCss, /\.modal\[data-aksrt-points-pay-modal\]\s*\{\s*display:\s*none;/); assert.match(creditsCss, /\.modal\[data-aksrt-points-pay-modal\]\.in\s*\{\s*display:\s*flex;/); }); test('开关自包含:不依赖 jQuery/Bootstrap modal 运行时', () => { assert.match(creditsJs, /function openChargeModal/); assert.match(creditsJs, /function closeChargeModal/); assert.match(creditsJs, /data-aksrt-charge-open/); // 动态创建遮罩层(bootstrap.min.css 已有 .modal-backdrop 规则)。 assert.match(creditsJs, /modal-backdrop fade in/); // 充值弹窗开关不得回退到 jQuery modal 运行时(时序异常时弹窗打不开)。 assert.doesNotMatch(creditsJs, /\.modal\(\s*['"]show['"]/); assert.doesNotMatch(creditsJs, /hidden\.bs\.modal/); // 打开前移除旧缓存 hidden 标记;关闭走 300ms fade-out 收尾。 assert.match(creditsJs, /removeAttribute\('hidden'\)/); assert.match(creditsJs, /void modal\.offsetWidth/); // 打开态内联 display 必须是 flex(block 会破坏 .flex.jc 双轴居中)。 assert.match(creditsJs, /modal\.style\.display = 'flex'/); assert.doesNotMatch(creditsJs, /modal\.style\.display = 'block'/); }); /** * 余额充值提交契约(预设档位被 REST 参数层拦截的回归护栏)。 * * custom_price 只在 package=custom 时被消费,选中预设档位时前端必带该键且值为 * 空串。WP REST 的 default 仅在「键完全缺失」时生效:键存在而值为空串时 default * 不参与,空串直接进 pattern 校验 → 请求在参数层就被拦下(rest_invalid_param * 「无效参数:custom_price」),业务回调根本不执行。 * * 因此参数声明只保留 type/default/清洗,金额格式与区间的唯一裁决留在 * create_charge_order()(AssetAmount::balance + custom_limit 区间校验)。 */ test('充值请求参数:custom_price 空串放行,金额格式只由业务层裁决', () => { const chargeRoute = balanceRest.slice( balanceRest.indexOf("'/pay/balance/charge'"), balanceRest.indexOf("'/pay/transfer'"), ); assert.match(chargeRoute, /'custom_price'\s*=>\s*\['type'\s*=>\s*'string',\s*'default'\s*=>\s*''/); assert.doesNotMatch( chargeRoute, /custom_price'[\s\S]*?'pattern'/, 'custom_price must not carry a second amount rule: the empty string is a valid "not applicable" value', ); const createCallback = balanceRest.slice( balanceRest.indexOf('public function rest_charge_create'), balanceRest.indexOf('public function rest_transfer'), ); assert.match(createCallback, /\$custom_price\s*=\s*\(string\) \$request->get_param\('custom_price'\)/); assert.match(createCallback, /\$payment_method\s*=\s*\(string\) \$request->get_param\('payment_method'\)/); // 手工重读请求体会让声明的 default/sanitize_callback 形同虚设(第二套取值口径)。 assert.doesNotMatch(createCallback, /get_json_params\(\)|->get_params\(\)/); }); test('充值弹窗:选中预设档位清空自定义金额,提交路径无重复读取', () => { // 选中预设档位即放弃自定义金额;否则输入框里残留的数字会在提交时按「合法自定义 // 金额」把 package 顶回 custom,用户刚点的档位被静默忽略。 assert.match(creditsJs, /if \(value !== 'custom' && customInput\) \{[\s\S]{0,60}customInput\.value = '';/); // 支付方式在提交处理器开头已读取,选档位不会改动它,故充值弹窗内只应存在一处读取 // (积分购买弹窗是同构的另一套初始化,各自一处,不在此断言范围)。 const chargeInit = creditsJs.slice( creditsJs.indexOf('function initBalanceCharge'), creditsJs.indexOf('function initTransfer'), ); assert.strictEqual( [...chargeInit.matchAll(/form\.querySelector\('input\[name="payment_method"\]'\)/g)].length, 1, 'the submit handler must not re-read payment_method (dead duplicate guard)', ); });