Files
wordpress-theme-aksrt/tests/admin/charge-cards-form-constraints.test.cjs
T
kate522andClaude Opus 5 35bb96e82f refactor: rename the theme brand and every identifier from AKSRT to Jio
Full-project rename. `git grep -i aksrt` is now empty apart from the
DBMigrator 3.3.0 changelog note that documents the rename itself.

Code identifiers
- PHP namespace AKSRT\ -> Jio\ (composer PSR-4 + 378 declarations)
- global constants AKSRT_* -> JIO_* (VERSION/DIR/URI, PAY_TYPE_*, CAPTCHA_*)
- procedural functions, hooks, options, meta keys, table names,
  cookies, query vars, ajax actions and nonces: aksrt_ -> jio_
- text domain aksrt -> jio; Theme Name -> Jio
- JS globals: aksrtData/aksrtAdminData/... -> jioData/jioAdminData/...,
  window.AKSRTCaptcha & friends -> window.JioCaptcha, Aksrt* -> Jio*
- Vue components Aks*.vue -> Jio*.vue (15 files renamed + imports)
- CSS classes/data-attrs/custom properties: aksrt- -> jio-
- REST namespace aksrt/v1 -> jio/v1 (incl. payment gateway callbacks)

Storage layer
- DB tables {prefix}aksrt_* -> {prefix}jio_*; SCHEMA_VERSION 3.2.0 -> 3.3.0
  so dbDelta creates the new names on install
- widget id_base aksrt_widget_* -> jio_widget_*
- uninstall.php drop list rebuilt from the actual migrator tables
  (dropped 3 stale entries, added the 17 missing ones)

Removed
- hardcoded https://api.aksrt.com/version.json default update endpoint;
  get_endpoint() now reads the 「更新端点」 setting and do_check() no-ops
  when it is empty

Files renamed: inc/helpers/aksrt-*.php -> jio-*.php (54),
assets/admin/src/components/form/Aks*.vue -> Jio*.vue (15).
Build artifacts regenerated: assets/css/build, assets/js/runtime,
assets/admin/build.

Verified: audit-psr4-paths errors=0, audit-admin-contracts errors=0
(warnings=56, unchanged baseline), 813/813 widget-system tests,
44/44 phpunit, php -l clean across all PHP.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-11-01 20:05:04 +08:00

175 lines
8.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,用户看到的就是按钮坏了。
*
* 处置:数字输入只保留 `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, '..', '..');
// PayChargeCards 拆成 flat sibling traits;按原有相邻顺序拼接壳类与 trait 后再扫描/切片。
const read = (rel) => fs.readFileSync(path.join(root, rel), 'utf8');
const page = [
'src/Admin/Native/PayChargeCards.php',
'src/Admin/Native/PayChargeCardsList.php',
'src/Admin/Native/PayChargeCardsAssets.php',
'src/Admin/Native/PayChargeCardsForms.php',
].map(read).join('\n');
/**
* 去掉 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*'jio'\)/);
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/);
});