fix(payment): 修复添加卡密页「确认提交」按钮静默失效

数字输入(auto_num / auto_price / auto_rand_number_limit /
auto_rand_password_limit)此前被平铺成原生 HTML5 约束属性
(min/max/step),叠加两个问题后表现为按钮点了毫无反应:

1) 数值彼此不自洽:参考主题 CSF spinner 的 default=20 / min=1 /
   step=10 合法格点是 1,11,21…,默认值 20 当场就非法;
   auto_rand_number_limit 同理(默认 20、格点 1,6,11…)。
2) 这些行是条件显隐的(field_row 输出 <tr data-dep>,不满足依赖时
   display:none)。提交时浏览器仍会校验隐藏控件,而隐藏控件无法聚焦
   去弹提示气泡,于是 Chrome 只往控制台丢一句
   "An invalid form control ... is not focusable" 便静默拒绝提交——
   不跳转、不报错、无 notice,用户看到的就是按钮坏了。

处置:数字输入改用 data-min/data-max/data-step + step="any"
(与参考主题 CSF_Field_spinner::render() 语义一致),不再写任何原生
约束属性;auto_price 一并去掉 min="0",面额下限改由服务端夹取兜底
(max(0.0, ...)),避免「去掉客户端下限」变成放行负面额。

验证:
- 无头 Chrome 探针:btn.click() 触发的 submit 事件 0 → 1,
  参与校验控件 23 个、无效 3 个 → 0 个;
- 服务端扫描页面 25 个 input:带原生约束的 0 个;
- 服务端面额夹取:-5 拒绝、0.001 拒绝、12.5 正常生成 1 张;
- 新增 tests/admin/charge-cards-form-constraints.test.cjs(4 例),
  并做负向对照(重引入 min/step 后 2 pass / 2 fail);
- tests/admin 四个套件合计 22 pass / 0 fail。
This commit is contained in:
2026-09-17 13:19:05 +08:00
parent 52d790c654
commit 481f40378a
3 changed files with 126 additions and 2 deletions
@@ -0,0 +1,102 @@
'use strict';
/**
* 「卡密管理 → 添加卡密」表单约束契约。
*
* 背景(已修复的缺陷):
* 表格里的数字输入(auto_num / auto_price / auto_rand_number_limit / auto_rand_password_limit)
* 一度被平铺成原生 HTML5 约束属性(min / max / step)。两个后果叠加,表现为
* **「确认提交」按钮点了毫无反应**:
*
* 1) 数值彼此不自洽。参考主题 codestar-framework 的 spinner 字段语义是
* default=20 / min=1 / max=1000 / step=10,合法格点其实是 1,11,21…,
* 于是**默认值 20 当场就非法**;auto_rand_number_limit 同理(默认 20、格点 1,6,11…)。
* 2) 这些行是**条件显隐**的(`field_row()` 输出 <tr data-dep>,不满足依赖时
* display:none)。提交瞬间浏览器仍会校验隐藏控件,而隐藏控件又无法聚焦去弹提示气泡,
* 于是 Chrome 只往控制台丢一句 "An invalid form control with name=... is not focusable",
* 然后**静默拒绝提交** —— 页面不跳转、不报错、无 notice,用户看到的就是按钮坏了。
*
* 处置:数字输入一律不写原生约束,改用 `data-min/data-max/data-step` + `step="any"`
* (与参考主题 CSF_Field_spinner::render() 一致),范围由服务端夹取兜底。
*
* 本契约固化三件事:
* - spinner() 只输出 data-* 约束与 step="any",绝不输出原生 min/max/step;
* - 添加表单内不得出现任何带原生约束属性的输入(min/max/pattern/required、数值 step);
* - 表单也不得用 novalidate 掩盖问题(那会连带关掉可见字段的原生提示)。
* 另附服务端兜底断言:面额不接受负数。
*/
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const test = require('node:test');
const root = path.resolve(__dirname, '..', '..');
const page = fs.readFileSync(path.join(root, 'src/Admin/Native/PayChargeCards.php'), 'utf8');
const slice = (from, to) => {
const start = page.indexOf(from);
const end = page.indexOf(to);
if (start === -1 || end === -1 || end <= start) {
throw new Error(`切片失败:${from} → ${to}`);
}
return page.slice(start, end);
};
const spinner = slice('private function spinner(', 'private function page_notice(');
const addForm = slice('private function render_add_form(', 'private function render_export_form(');
const handleAdd = slice('private function handle_add(', 'private function handle_import(');
/** 判定一段源码里是否出现原生约束属性(min/max/pattern/required、数值 step)。 */
const nativeConstraints = (src) => {
const found = [];
if (/\smin="/.test(src)) found.push('min');
if (/\smax="/.test(src)) found.push('max');
if (/\srequired\b/.test(src)) found.push('required');
if (/\spattern="/.test(src)) found.push('pattern');
const step = /\sstep="([^"]*)"/.exec(src);
if (step && step[1] !== 'any') found.push(`step=${step[1]}`);
return found;
};
test('spinner() 只输出 data-* 约束与 step="any",不写原生 min/max/step', () => {
// 正值:语义完整的 data-* 三元组 + step="any"。
assert.match(spinner, /step="any"/, 'spinner 必须写 step="any"');
assert.match(spinner, /data-min="[^"]*"/);
assert.match(spinner, /data-max="[^"]*"/);
assert.match(spinner, /data-step="[^"]*"/);
// 反值:原生约束属性一个都不能有(这是「按钮点不动」的直接成因)。
assert.doesNotMatch(spinner, /\smin="/, 'spinner 不得写原生 min');
assert.doesNotMatch(spinner, /\smax="/, 'spinner 不得写原生 max');
assert.doesNotMatch(spinner, /\sstep="(?!any")/, 'spinner 的 step 只能是 any');
assert.doesNotMatch(spinner, /\brequired\b/);
});
test('整个后台页源码内没有任何输入携带原生约束属性', () => {
// 输入标签常被字符串拼接拆开(render_add_form 里的 input 就是拼接出来的),
// 逐串取标签并不可靠;直接对整段源码扫 HTML 属性形态,覆盖 spinner()/button_set()
// 等所有渲染助手,避免只查 render_add_form 而漏掉助手内部生成的控件。
const numbers = [...page.matchAll(/type="number"/g)].length;
assert.ok(numbers >= 1, `页面应至少有一个数字输入,实际 ${numbers} 个`);
assert.deepEqual(
nativeConstraints(page),
[],
'条件显隐行里的原生约束会让浏览器静默拒绝提交'
);
// 数字输入仍须明确 step="any",否则浏览器会按默认 step=1 校验,同样可能拦下提交。
const stepsAny = [...page.matchAll(/type="number"[^>]*step="any"/g)].length;
assert.equal(stepsAny, numbers, '每个数字输入都要写 step="any"');
});
test('表单不用 novalidate 掩盖问题,且提交按钮仍是原生 submit', () => {
assert.doesNotMatch(addForm, /novalidate/i, '不得通过关闭校验来绕过,应去掉约束本身');
assert.match(addForm, /submit_button\(\s*__\('确认提交',\s*'aksrt'\)/);
assert.match(addForm, /wp_nonce_field\(self::NONCE\)/);
});
test('面额下限由服务端兜底夹取(spinner 不再有原生 min)', () => {
assert.match(handleAdd, /\$auto_price\s*=\s*max\(0\.0,\s*\$auto_price\);/);
});