Files
wordpress-theme-aksrt/tests/widget-system/ticket-schema-contract.test.cjs
T
kate522andClaude Fable 5 e7ca0ee5df refactor(ticket): move the config tab into the SPA ticket center
users.php → 工单管理 的「配置」Tab 只管理部门 / 状态 / 预设回复三块
DB 字典,与 SPA「工单中心」已有的 8 个标量配置分裂成两处;且同一套
CRUD 在 admin_post 与 TicketConfigRest 里各写了一遍,REST 侧此前
没有任何调用方。

- 删除原生 config tab:UsersMenusTicketsConfig.php 整份移除,
  UsersMenusTicketsCenter 由 512 行降到 251 行(六个写处理方法、
  分发分支、十条提示码与 ticket_delete_button 一并清理)
- SPA /users/ticket 改为 customPage:内嵌 SchemaPage 承载原有 8 字段,
  三块字典改为 n-data-table + n-modal + n-dialog,取色器/管理员多选/
  拖拽排序替代原生手填表单
- TicketConfigRest 成为三块字典的唯一实现,新增 api/modules/tickets.js
  封装 aksrt/v1/admin/tickets*
- 双向深链:原生页跳 SPA 配置页,SPA 页跳回原生工单列表

契约测试同步更新,并新增 ticket-spa-config-contract 守护接线。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-10-26 19:48:58 +08:00

183 lines
9.7 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';
/**
* 「工单中心」配置契约:
* 1) PHP SchemaUsersTicket 分区与 SPA JS schema 的键、顺序、类型、默认值 1:1 对齐;
* 2) SPA 页面已注册(/users/ticket 可达),承载 schema 表单 + 三块字典 CRUD;
* 3) 运行时 TicketSettings 缺键回退 schema 默认,数值边界被夹取;
* 4) 前台入口三处均已注册:Center.php 面板定义 + sidebar-nav.php 侧栏项 +
* aksrt-user-center.php 的 built_in_tabs 与 view map。
*/
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 read = (file) => fs.readFileSync(path.join(root, file), 'utf8');
const FIELD_ORDER = [
'ticket_s',
'ticket_num_prefix',
'ticket_notice_open',
'ticket_notice_text',
'ticket_attachment_s',
'ticket_attachment_max',
'ticket_order_link_s',
'ticket_urge_interval',
];
const GROUPS = [
{ key: 'main', title: '工单功能' },
{ key: 'form', title: '提交设置' },
];
test('ticket schema keys match between PHP and SPA JS', () => {
const php = read('src/Admin/Schema/Users/SchemaUsersTicket.php');
const js = read('assets/admin/src/schema/sections/users/ticket.js');
for (const key of FIELD_ORDER) {
assert.ok(php.includes(`'key' => '${key}'`), `PHP schema missing ${key}`);
assert.ok(js.includes(`key: '${key}'`), `JS schema missing ${key}`);
}
// 顺序:两组各自的字段顺序必须一致(前端分组渲染依赖它)。
for (const group of GROUPS) {
const phpGroup = php.slice(php.indexOf(`'key' => '${group.key}'`));
const jsGroup = js.slice(js.indexOf(`key: '${group.key}'`));
const phpKeys = [...phpGroup.matchAll(/'key' => '([a-z_]+)'/g)].map((m) => m[1]);
const jsKeys = [...jsGroup.matchAll(/key: '([a-z_]+)'/g)].map((m) => m[1]);
assert.deepEqual(jsKeys, phpKeys, `group ${group.key} field order drifted`);
}
// 类型逐字段对齐。PHP 里 textarea 带 sanitize_type,取值要跳过它,
// 因此显式匹配 'type' => '...'(前后都是引号边界,不会撞上 sanitize_type)。
for (const key of FIELD_ORDER) {
const phpType = new RegExp(`'key' => '${key}'[^\\]]*'type' => '([a-z]+)'`).exec(php);
const jsType = new RegExp(`key: '${key}'[^}]*?\\btype: '([a-z]+)'`).exec(js);
assert.ok(phpType && jsType, `type not found for ${key}`);
assert.equal(jsType[1], phpType[1], `${key} type drifted`);
}
// sanitize_type 也必须一致:公告内容允许 HTML,两侧都要声明。
assert.ok(php.includes("'key' => 'ticket_notice_text', 'label' => '公告内容', 'type' => 'textarea', 'sanitize_type' => 'html'"),
'PHP notice text must declare sanitize_type html');
assert.ok(js.includes("key: 'ticket_notice_text', label: '公告内容', type: 'textarea', sanitize_type: 'html'"),
'JS notice text must declare sanitize_type html');
});
test('ticket schema is registered under users', () => {
const js = read('assets/admin/src/schema/sections/users/ticket.js');
assert.equal(js.includes("key: 'users.ticket'"), true, 'schema key must be users.ticket');
const index = read('assets/admin/src/schema/sections/users/index.js');
assert.ok(index.includes("import { ticketSchema } from './ticket.js'"), 'schema not imported');
assert.ok(/ticket:\s*ticketSchema/.test(index), 'schema not exported in usersSchemas');
const constants = read('assets/admin/src/constants/sections/users.js');
assert.ok(constants.includes("key: 'users-ticket'"), 'menu page key missing');
assert.ok(constants.includes("route: '/users/ticket'"), 'menu route missing');
assert.ok(constants.includes("section: 'ticket'"), 'menu section missing');
assert.ok(constants.includes("canonicalDomain: 'users'"), 'menu canonical domain missing');
// PHP 侧:分区由 SchemaUsers 组装,SchemaUsersTicket 提供内容。
const users = read('src/Admin/Schema/SchemaUsers.php');
assert.ok(users.includes("'ticket' => Users\\SchemaUsersTicket::build()"), 'PHP section not composed');
// 组装位置必须在 'sections' => [ ... ] 内部。SchemaProvider::get_sections() 只读
// $schema['sections'],分区挂在同级会让 /settings/schema/users/ticket 404
// (SPA 报「未知的设置分组」)。定位 sections 开括号后用配平扫描找闭括号,
// 确认 compose 调用落在区间内——单纯 includes() 查不出层级错误。
const sectionsAt = users.indexOf("'sections' => [");
assert.ok(sectionsAt !== -1, 'sections array not found');
const composeAt = users.indexOf("'ticket' => Users\\SchemaUsersTicket::build()");
assert.ok(composeAt > sectionsAt, 'ticket must be composed after sections opens');
let depth = 0;
let sectionsEnd = -1;
for (let i = users.indexOf('[', sectionsAt); i < users.length; i++) {
if (users[i] === '[') depth++;
else if (users[i] === ']') {
depth--;
if (depth === 0) {
sectionsEnd = i;
break;
}
}
}
assert.ok(sectionsEnd !== -1, 'unbalanced sections array');
assert.ok(composeAt < sectionsEnd, 'ticket section must be INSIDE the sections array');
const ticket = read('src/Admin/Schema/Users/SchemaUsersTicket.php');
assert.ok(ticket.includes("'key' => 'ticket'"), 'PHP section key missing');
assert.ok(ticket.includes("'option_key' => 'aksrt_settings'"), 'PHP option key missing');
});
test('TicketSettings falls back to schema defaults and clamps numerics', () => {
const settings = read('src/Ticket/TicketSettings.php');
// 只取方法体,避免 docblock 与注释里的同形文字参与断言。
const body = settings.slice(settings.indexOf('public static function all()'));
// 缺键回退:enabled 只在显式关闭时为 false(与 schema default true 对齐)。
assert.match(body, /!isset\(\$s\['ticket_s'\]\) \|\| !empty\(\$s\['ticket_s'\]\)/);
assert.match(body, /self::all\(\)\['ticket_num_prefix'\] \?\? 'T'/);
assert.match(body, /self::all\(\)\['ticket_notice_text'\] \?\? ''\)/);
assert.match(body, /self::all\(\)\['ticket_attachment_max'\] \?\? 5\)/);
assert.match(body, /self::all\(\)\['ticket_urge_interval'\] \?\? 15\)/);
// 数值夹取:附件上限 1..9,催单冷却 1..1440。
assert.match(body, /max\(1, min\(9, \(int\)/);
assert.match(body, /max\(1, min\(1440, \(int\)/);
// 公告文案走 kses(后台允许 HTML)。
assert.match(body, /wp_kses_post/);
// 分区名与 schema key 一致(常量在 all() 之前声明,故对整份文件断言)。
assert.match(settings, /const SECTION = 'ticket'/);
assert.match(body, /read_domain\('users', self::SECTION\)/);
});
test('ticket panel is registered across the user-center wiring', () => {
const center = read('src/User/Center.php');
assert.ok(center.includes("'tickets' => ["), 'panel definition missing');
assert.ok(center.includes("'label' => __('工单中心', 'aksrt')"), 'panel label missing');
assert.match(center, /'tickets'\s*=>\s*\[[\s\S]*?'priority' => 49/, 'panel priority should sit next to orders (48)');
// 开关关闭时整块移除。
assert.match(center, /\$ticket_on = class_exists\('\\AKSRT\\Ticket\\TicketSettings'\)/);
assert.match(center, /if \(!\$ticket_on\) \{\s*\n\s*\$order = array_values\(array_filter\(\$order, static fn\(string \$key\): bool => \$key !== 'tickets'\)\);/);
assert.match(center, /if \(\$key === 'tickets' && !\$ticket_on\) \{\s*\n\s*continue;/);
const sidebar = read('inc/helpers/user-center-renderers/sidebar-nav.php');
assert.ok(sidebar.includes("'label' => __('工单中心', 'aksrt')"), 'sidebar label missing');
assert.ok(sidebar.includes("'icon' => 'manual-service'"), 'sidebar icon should reuse the existing headset sprite');
assert.ok(sidebar.includes("'tab' => 'tickets'"), 'sidebar tab key missing');
const helper = read('inc/helpers/aksrt-user-center.php');
assert.ok(helper.includes("'tickets'"), 'built_in_tabs missing tickets');
assert.match(helper, /'tickets'\s*=> 'tickets',/, 'view map missing tickets => tickets');
const renderer = read('inc/helpers/user-center-renderers/profile-tickets.php');
assert.ok(renderer.includes('data-aksrt-ticket-center'), 'panel root hook missing');
assert.ok(renderer.includes('data-aksrt-ticket-create'), 'create button hook missing');
assert.ok(renderer.includes("aksrt_render_captcha('ticket_create')"), 'create captcha mount missing');
assert.ok(renderer.includes("aksrt_render_captcha('ticket_reply')"), 'reply captcha mount missing');
});
test('ticket frontend assets are enqueued only when the panel exists', () => {
const scripts = read('src/Core/ScriptsPart1b.php');
assert.match(scripts, /\$has_tickets_panel = false;/, 'panel flag not initialised');
assert.match(scripts, /\$has_tickets_panel = isset\(\$dashboard_tabs\['tickets'\]\);/, 'panel flag not derived');
assert.ok(scripts.includes("'aksrt-tickets'"), 'ticket CSS handle missing');
assert.ok(scripts.includes("'aksrt-ticket-center'"), 'ticket JS handle missing');
assert.ok(scripts.includes('/assets/js/ticket-center.js'), 'ticket JS path missing');
assert.ok(scripts.includes('/assets/css/routes/tickets.css'), 'ticket CSS path missing');
// 两个入队都必须在 $has_tickets_panel 条件内。
const cssIndex = scripts.indexOf("'aksrt-tickets'");
const jsIndex = scripts.indexOf("'aksrt-ticket-center'");
assert.ok(scripts.lastIndexOf('if ($has_tickets_panel) {', cssIndex) > scripts.indexOf("'aksrt-orders'"),
'ticket CSS must be gated on its own panel flag');
assert.ok(jsIndex > cssIndex, 'ticket JS should be enqueued alongside its CSS');
const localize = read('src/Core/ScriptsPart1c.php');
assert.ok(localize.includes("'ticket' => (function () {"), 'localize key missing');
assert.ok(!localize.includes("'afterSale'"), 'stale afterSale localize must be gone');
});