'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()` 输出 ,不满足依赖时 * 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/); });