Files
wordpress-theme-aksrt/tests/widget-system/ticket-schema-contract.test.cjs
T
kate522andClaude Opus 5 d32bdc0581 fix(ticket): register the ticket schema inside the users sections array
/users/ticket loaded in the SPA but the form never did: the console showed
"未知的设置分组: ticket" from /settings/schema/users/ticket. The ticket section
was composed one array level too high in SchemaUsers::build() — as a sibling of
'sections' rather than inside it — so SchemaProvider::get_sections(), which reads
only $schema['sections'], never saw it and the REST handler 404'd.

Every other axis was wired correctly: the route, the menu entry, users/ticket.js
and SchemaUsersTicket itself. That is why only this page failed while sibling
/users/* pages worked. Move the entry inside sections.

The contract test could not catch this because it asserted only that the compose
call appears somewhere in the file. Replace it with a bracket-balanced scan that
locates the sections array bounds and asserts the compose call falls inside, so
nesting mistakes are visible.

Also update the two ticket contract tests that still read the trait from
src/Ticket/TicketRepository-traits/, its pre-flattening path. They assert on the
trait's contents, so they were silently skipping the very file the earlier fatal
was about.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-26 14:16:25 +08:00

183 lines
9.7 KiB
JavaScript

'use strict';
/**
* 「工单中心」配置契约:
* 1) PHP SchemaUsersTicket 分区与 SPA JS schema 的键、顺序、类型、默认值 1:1 对齐;
* 2) SPA 页面已注册(/users/ticket 可达),且挂的是 schemaPage 而非自定义页;
* 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 as a schema page 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');
});