Files
wordpress-theme-aksrt/tests/admin/charge-cards-form-constraints.test.cjs
T
kate522 481f40378a 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。
2026-09-17 13:19:05 +08:00

103 lines
5.1 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'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\);/);
});