'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,用户看到的就是按钮坏了。 * * 处置:数字输入一律不写原生约束,改用 `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\);/); });