Files
wordpress-theme-aksrt/tests/admin/charge-cards-form-constraints.test.cjs
kate522 c1c21faf7e fix(payment): 生成数量限定为 1-1000 整数并移除 spinner 死属性
生成数量不再受 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 一致。
2026-09-17 13:38:40 +08:00

167 lines
7.8 KiB
JavaScript
Raw Permalink 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,用户看到的就是按钮坏了。
*
* 处置:数字输入只保留 `step="any"`(省掉它会让 type="number" 退回默认 step=1 整数网格,
* 同样触发上述静默拒绝),**不写任何原生约束属性**。
* 中途曾用 `data-min/data-max/data-step` 承载范围,但那三个属性在本项目里**没有任何消费方**
* (全仓检索确认,属死代码),已删除;范围改为服务端**单点**约束:
* 生成数量见 `handle_add()` 的 (0, 1000] 判定,卡号/密码位数见 `ChargeCard::generate_cards()`。
*
* 本契约固化四件事:
* - spinner() 只输出 `step="any"`,既不写原生 min/max/step,也不写无人消费的 data-*;
* - 整个后台页源码内不得出现任何带原生约束属性的输入(min/max/pattern/required、数值 step);
* - 表单不得用 novalidate 掩盖问题(那会连带关掉可见字段的原生提示);
* - 生成数量与面额的边界由 handle_add() 单点表达。
*/
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');
/**
* 去掉 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 = code.indexOf(from);
const end = code.indexOf(to);
if (start === -1 || end === -1 || end <= start) {
throw new Error(`切片失败:${from} → ${to}`);
}
return code.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() 只写 step="any",不写原生约束也不写无人消费的 data-*', () => {
assert.match(spinner, /step="any"/, 'spinner 必须写 step="any"');
// 反值 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 = [...code.matchAll(/type="number"/g)].length;
assert.ok(numbers >= 1, `页面应至少有一个数字输入,实际 ${numbers} 个`);
assert.deepEqual(
nativeConstraints(code),
[],
'条件显隐行里的原生约束会让浏览器静默拒绝提交'
);
// 数字输入仍须明确 step="any",否则浏览器会按默认 step=1 校验,同样可能拦下提交。
const stepsAny = [...code.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('生成数量限定为 (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/);
});