'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', 'ticket_title_max', 'ticket_content_max', 'ticket_reply_max', ]; 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\)/); // 字数上限:标题 10..200(VARCHAR(200)),描述 / 回复 100..16000(TEXT 65535 字节 / utf8mb4)。 assert.match(body, /max\(10, min\(200, \(int\) \(self::all\(\)\['ticket_title_max'\] \?\? 200\)\)\)/); assert.match(body, /max\(100, min\(16000, \(int\) \(self::all\(\)\['ticket_content_max'\] \?\? 5000\)\)\)/); assert.match(body, /max\(100, min\(16000, \(int\) \(self::all\(\)\['ticket_reply_max'\] \?\? 3000\)\)\)/); // 公告文案走 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'); });