From c1c21faf7e681421eea958b049fc017a1309d6de Mon Sep 17 00:00:00 2001 From: unknown Date: Thu, 17 Sep 2026 13:38:40 +0800 Subject: [PATCH] =?UTF-8?q?fix(payment):=20=E7=94=9F=E6=88=90=E6=95=B0?= =?UTF-8?q?=E9=87=8F=E9=99=90=E5=AE=9A=E4=B8=BA=201-1000=20=E6=95=B4?= =?UTF-8?q?=E6=95=B0=E5=B9=B6=E7=A7=BB=E9=99=A4=20spinner=20=E6=AD=BB?= =?UTF-8?q?=E5=B1=9E=E6=80=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 生成数量不再受 step 网格约束,只要求「正整数且不超过 1000」即 (0, 1000]。 - 删除 spinner() 产出的 data-min / data-max / data-step:这三个属性在本 项目全仓**没有任何消费方**(逐文件检索确认),属死代码;原生 min/max/step 又会让条件显隐行触发 HTML5 校验并静默阻止提交(上一轮已定案)。参数随之 收敛:spinner(string $name, int $value, string $unit),3 处调用点同步。 - 保留 step="any":省掉它 type="number" 会退回默认 step=1 的整数网格,把 非整数输入判为非法,同样触发隐藏行的静默拒绝。 - 生成数量的范围收敛为 handle_add() 里的单一区间判定 `$auto_num < 1 || $auto_num > 1000`,一次覆盖 非数字 / 0 / 负数 / 超上限, 不再依赖下层静默夹取,也不另设分支;字段说明补上「1 - 1000 的整数」。 验证: - 容器内真实 HTTP 边界:0 / -5 / 1001 / abc 全部拒绝,1000 / 1 / 3.9 通过 (3.9 按整数截断)。其中 1000 这个上边界用「type=vip + 空时长」探到 —— 数量校验通过后即报「请填写兑换会员参数」,零副作用,未真的生成 1000 张。 正向生成 3 张后已清理,表回到原始 10 条。 - 渲染出的表单内原生约束属性 0 个、data-* 0 个;4 个 number 输入全部 step="any"。 - 契约测试:把「spinner 输出 data-*」的断言反转为「不得出现」,新增签名与 调用点实参断言、(0,1000] 区间断言、字段说明断言;整文件属性扫描前先剥 PHP 注释,否则文档注释里的 type="number" 字面量会污染计数。 - 负向对照:重新加回 data-min / 退回 !$auto_num / 签名加回 step 参数, 三例均精确落到对应用例(各 5 pass / 1 fail),还原后字节一致、6/6 通过。 - tests/admin 四套件 24 pass / 0 fail;php -l 通过;两副本 SHA256 一致。 --- src/Admin/Native/PayChargeCards.php | 43 ++++---- .../charge-cards-form-constraints.test.cjs | 104 ++++++++++++++---- 2 files changed, 105 insertions(+), 42 deletions(-) diff --git a/src/Admin/Native/PayChargeCards.php b/src/Admin/Native/PayChargeCards.php index 1aa8b69b..592b48f9 100644 --- a/src/Admin/Native/PayChargeCards.php +++ b/src/Admin/Native/PayChargeCards.php @@ -334,9 +334,9 @@ class PayChargeCards { // 自动生成 $this->field_row( __('生成数量', 'aksrt'), - $this->spinner('auto_num', 20, 1, 1000, 10, __('张', 'aksrt')), + $this->spinner('auto_num', 20, __('张', 'aksrt')), [['key' => 'add_type', 'op' => '==', 'val' => 'auto']], - __('需要生成多少张卡密(单次生成数量太多可能会对服务器性能造成影响)', 'aksrt') + __('需要生成多少张卡密(1 - 1000 的整数,单次生成数量太多可能会对服务器性能造成影响)', 'aksrt') ); $this->field_row( __('标识', 'aksrt'), @@ -401,7 +401,7 @@ class PayChargeCards { ); $this->field_row( __('自定义卡号位数', 'aksrt'), - $this->spinner('auto_rand_number_limit', 20, 1, 50, 5, __('位数', 'aksrt')), + $this->spinner('auto_rand_number_limit', 20, __('位数', 'aksrt')), [ ['key' => 'add_type', 'op' => '==', 'val' => 'auto'], ['key' => 'auto_top_s', 'op' => 'checked'], @@ -409,7 +409,7 @@ class PayChargeCards { ); $this->field_row( __('自定义密码位数', 'aksrt'), - $this->spinner('auto_rand_password_limit', 35, 1, 50, 5, __('位数', 'aksrt')), + $this->spinner('auto_rand_password_limit', 35, __('位数', 'aksrt')), [ ['key' => 'add_type', 'op' => '==', 'val' => 'auto'], ['key' => 'auto_top_s', 'op' => 'checked'], @@ -691,10 +691,11 @@ class PayChargeCards { return; } - // 自动生成。 + // 自动生成。数量限定为 (0, 1000] 的整数:非数字 / 空值经 (int) 转换归零,负数与超上限 + // 一并落在区间外,因此用一个区间判定即可覆盖全部非法输入,不另设分支。 $auto_num = isset($_REQUEST['auto_num']) ? (int) $_REQUEST['auto_num'] : 0; - if (!$auto_num) { - $this->page_notice(__('错误!', 'aksrt'), __('请输入需要生成的数量', 'aksrt'), 'error'); + if ($auto_num < 1 || $auto_num > 1000) { + $this->page_notice(__('错误!', 'aksrt'), __('生成数量需为 1 - 1000 的整数', 'aksrt'), 'error'); return; } @@ -1003,23 +1004,21 @@ class PayChargeCards { } /** - * 数字输入(对齐参考主题 CSF spinner 字段的语义)。 + * 数字输入(参考主题 CSF spinner / number 字段的原生替代)。 * - * **min / max / step 只放在 data-* 上,绝不写成原生约束属性。** - * 参考主题 `CSF_Field_spinner::render()` 渲染的就是 - * `data-min/data-max/data-step` + `step="any"`,原生 min/max/step 一个都不写。 - * 移植时把它们平铺成原生属性,会引入两类问题: - * 1) 这些值彼此不自洽 —— 例如 default=20 / min=1 / step=10 的合法格点是 1,11,21…, - * 写成原生 step 会让**默认值当场非法**(20 不在格点上); - * 2) 本表单的行是条件显隐的。HTML5 约束校验会检查**隐藏**控件,而隐藏控件无法聚焦、 - * 浏览器弹不出提示气泡,于是直接**静默拒绝提交**(只有控制台一句 - * "An invalid form control ... is not focusable"),页面毫无反应 —— - * 用户看到的就是「确认提交按钮不起作用」。 - * 取值范围由服务端兜底:`ChargeCard::generate_cards()` 内已对数量与位数做 max/min 夹取。 + * **一律不写原生 min / max / step 约束属性,只写 `step="any"`:** + * - 本表单的行是条件显隐的(`field_row()` 会给不满足依赖的 `` 打上 `display:none`)。 + * HTML5 约束校验会检查**隐藏**控件,而隐藏控件无法聚焦、浏览器弹不出提示气泡, + * 于是直接**静默拒绝提交**(控制台只有一句 "An invalid form control ... is not focusable"), + * 页面毫无反应 —— 用户看到的就是「确认提交按钮不起作用」。 + * - `step="any"` 不能省:`type="number"` 缺省 `step=1`,会退回整数网格,把非整数输入判为非法, + * 同样触发上面的静默拒绝。 + * + * 取值范围不在标记里表达,由服务端单点约束:生成数量见 `handle_add()` 的 (0, 1000] 判定, + * 卡号 / 密码位数见 `ChargeCard::generate_cards()`。 */ - private function spinner(string $name, int $value, int $min, int $max, int $step, string $unit): string { - return '' + private function spinner(string $name, int $value, string $unit): string { + return '' . ' ' . esc_html($unit) . ''; } diff --git a/tests/admin/charge-cards-form-constraints.test.cjs b/tests/admin/charge-cards-form-constraints.test.cjs index ba770b57..1b5fe9ce 100644 --- a/tests/admin/charge-cards-form-constraints.test.cjs +++ b/tests/admin/charge-cards-form-constraints.test.cjs @@ -16,14 +16,17 @@ * 于是 Chrome 只往控制台丢一句 "An invalid form control with name=... is not focusable", * 然后**静默拒绝提交** —— 页面不跳转、不报错、无 notice,用户看到的就是按钮坏了。 * - * 处置:数字输入一律不写原生约束,改用 `data-min/data-max/data-step` + `step="any"` - * (与参考主题 CSF_Field_spinner::render() 一致),范围由服务端夹取兜底。 + * 处置:数字输入只保留 `step="any"`(省掉它会让 type="number" 退回默认 step=1 整数网格, + * 同样触发上述静默拒绝),**不写任何原生约束属性**。 + * 中途曾用 `data-min/data-max/data-step` 承载范围,但那三个属性在本项目里**没有任何消费方** + * (全仓检索确认,属死代码),已删除;范围改为服务端**单点**约束: + * 生成数量见 `handle_add()` 的 (0, 1000] 判定,卡号/密码位数见 `ChargeCard::generate_cards()`。 * - * 本契约固化三件事: - * - spinner() 只输出 data-* 约束与 step="any",绝不输出原生 min/max/step; - * - 添加表单内不得出现任何带原生约束属性的输入(min/max/pattern/required、数值 step); - * - 表单也不得用 novalidate 掩盖问题(那会连带关掉可见字段的原生提示)。 - * 另附服务端兜底断言:面额不接受负数。 + * 本契约固化四件事: + * - spinner() 只输出 `step="any"`,既不写原生 min/max/step,也不写无人消费的 data-*; + * - 整个后台页源码内不得出现任何带原生约束属性的输入(min/max/pattern/required、数值 step); + * - 表单不得用 novalidate 掩盖问题(那会连带关掉可见字段的原生提示); + * - 生成数量与面额的边界由 handle_add() 单点表达。 */ const assert = require('node:assert/strict'); @@ -34,13 +37,54 @@ const test = require('node:test'); const root = path.resolve(__dirname, '..', '..'); const page = fs.readFileSync(path.join(root, 'src/Admin/Native/PayChargeCards.php'), 'utf8'); +/** + * 去掉 PHP 注释后的源码(字符串字面量原样保留)。 + * + * 本组用例按「HTML 属性形态」扫描源码,而文档注释里不可避免地要举例说明 + * `type="number"` / `step="any"` 这类属性,直接全文件扫描会被注释文字污染计数。 + * 这里先剥注释:注释只影响阅读,真正会渲染出去的标记都在字符串字面量与内联 HTML 里。 + */ +function stripPhpComments(src) { + let out = ''; + for (let i = 0; i < src.length; i++) { + const c = src[i]; + if (c === "'" || c === '"') { + const quote = c; + out += c; + for (i++; i < src.length; i++) { + out += src[i]; + if (src[i] === '\\') { out += src[++i]; continue; } + if (src[i] === quote) break; + } + continue; + } + if (c === '/' && src[i + 1] === '*') { + i += 2; + while (i < src.length && !(src[i] === '*' && src[i + 1] === '/')) i++; + i++; + out += ' '; + continue; + } + if ((c === '/' && src[i + 1] === '/') || c === '#') { + while (i < src.length && src[i] !== '\n') i++; + out += ' '; + continue; + } + out += c; + } + return out; +} + +/** 去除注释后的源码;所有「属性形态」断言都基于它。 */ +const code = stripPhpComments(page); + const slice = (from, to) => { - const start = page.indexOf(from); - const end = page.indexOf(to); + const start = code.indexOf(from); + const end = code.indexOf(to); if (start === -1 || end === -1 || end <= start) { throw new Error(`切片失败:${from} → ${to}`); } - return page.slice(start, end); + return code.slice(start, end); }; const spinner = slice('private function spinner(', 'private function page_notice('); @@ -59,35 +103,46 @@ const nativeConstraints = (src) => { return found; }; -test('spinner() 只输出 data-* 约束与 step="any",不写原生 min/max/step', () => { - // 正值:语义完整的 data-* 三元组 + step="any"。 +test('spinner() 只写 step="any",不写原生约束也不写无人消费的 data-*', () => { assert.match(spinner, /step="any"/, 'spinner 必须写 step="any"'); - assert.match(spinner, /data-min="[^"]*"/); - assert.match(spinner, /data-max="[^"]*"/); - assert.match(spinner, /data-step="[^"]*"/); - // 反值:原生约束属性一个都不能有(这是「按钮点不动」的直接成因)。 + // 反值 1:原生约束属性一个都不能有(这是「按钮点不动」的直接成因)。 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/); + + // 反值 2:data-* 三元组没有消费方,不得作为「范围语义」再被加回来。 + assert.doesNotMatch(spinner, /data-min/, 'data-min 无任何消费方,属死代码'); + assert.doesNotMatch(spinner, /data-max/); + assert.doesNotMatch(spinner, /data-step/); +}); + +test('spinner() 签名与调用点只传 name / value / unit', () => { + assert.match(spinner, /private function spinner\(string \$name, int \$value, string \$unit\)/); + + const calls = [...code.matchAll(/->spinner\(([^)]*)\)/g)].map((m) => m[1]); + assert.ok(calls.length >= 3, `spinner 调用点应至少 3 处,实际 ${calls.length}`); + for (const args of calls) { + assert.match(args, /^'[^']*',\s*\d+,\s*__\(/, 'spinner 实参应为 (name, value, unit):' + args); + } }); test('整个后台页源码内没有任何输入携带原生约束属性', () => { // 输入标签常被字符串拼接拆开(render_add_form 里的 input 就是拼接出来的), // 逐串取标签并不可靠;直接对整段源码扫 HTML 属性形态,覆盖 spinner()/button_set() // 等所有渲染助手,避免只查 render_add_form 而漏掉助手内部生成的控件。 - const numbers = [...page.matchAll(/type="number"/g)].length; + const numbers = [...code.matchAll(/type="number"/g)].length; assert.ok(numbers >= 1, `页面应至少有一个数字输入,实际 ${numbers} 个`); assert.deepEqual( - nativeConstraints(page), + nativeConstraints(code), [], '条件显隐行里的原生约束会让浏览器静默拒绝提交' ); // 数字输入仍须明确 step="any",否则浏览器会按默认 step=1 校验,同样可能拦下提交。 - const stepsAny = [...page.matchAll(/type="number"[^>]*step="any"/g)].length; + const stepsAny = [...code.matchAll(/type="number"[^>]*step="any"/g)].length; assert.equal(stepsAny, numbers, '每个数字输入都要写 step="any"'); }); @@ -97,6 +152,15 @@ test('表单不用 novalidate 掩盖问题,且提交按钮仍是原生 submit' assert.match(addForm, /wp_nonce_field\(self::NONCE\)/); }); -test('面额下限由服务端兜底夹取(spinner 不再有原生 min)', () => { +test('生成数量限定为 (0, 1000] 整数,面额不接受负数', () => { + // 生成数量:单点区间判定,覆盖非数字/0/负数/超上限,不另设分支。 + assert.match(handleAdd, /\$auto_num\s*<\s*1\s*\|\|\s*\$auto_num\s*>\s*1000/, '缺少 (0,1000] 区间判定'); + assert.doesNotMatch(handleAdd, /\$auto_num\s*=\s*max\(/, '不得在下层静默夹取代替显式判定'); + + // 面额:去掉客户端 min 后由服务端夹取下限。 assert.match(handleAdd, /\$auto_price\s*=\s*max\(0\.0,\s*\$auto_price\);/); }); + +test('生成数量字段说明写明合法范围', () => { + assert.match(addForm, /1 - 1000/); +});