Files
wordpress-theme-aksrt/tests/widget-system/ticket-native-admin-contract.test.cjs
T
kate522andClaude Fable 5 b74381cfa2 feat(ticket): redesign native ticket admin page UI/UX
- Fix empty status/department selects in the detail panel (printf leak)
- Replace dead 代客提交 button with a working collapsible create form
- Error-aware notices; redirect keeps filters and the open ticket
- Status views with counts, incl. 未关闭 default and 已关闭 view
- Two-column detail: conversation + attachments + prereplies | info,
  status/department/assignee select, internal notes; closed = read-only
- Split detail panel into UsersMenusTicketsDetail trait; page-scoped
  tickets.css / admin-tickets.js replace inline styles
- AdminListPagination accepts parent file so users.php keeps menu highlight
- Drop native loading="lazy" per lazyload contract

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-10-27 14:04:48 +08:00

185 lines
9.3 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';
/**
* 工单后台(users.php → aksrt_tickets)契约:
* 1) 子菜单已注册,trait 已组合,写操作走 admin_post 且每个都带 nonce;
* 2) 分页用共享 AdminListPagination,不写死 limit;
* 3) 默认状态与「仍有工单」两类删除被拒(兜底在 TicketConfigRest,SPA 工单中心消费);
* 4) SPA 深链 redirect 指向 users.php?page=aksrt_tickets;
* 5) 前端 JS 与 PHP 的 data-* 挂载点对齐。
* 部门 / 状态 / 预设回复的配置已迁至 SPA「工单中心」,本页只剩工单列表。
*/
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');
test('ticket submenu is registered under users.php', () => {
const menus = read('src/Admin/Native/UsersMenus.php');
assert.ok(menus.includes("use UsersMenusTicketsCenter;"), 'trait not composed');
assert.ok(menus.includes("'aksrt_tickets'"), 'submenu slug missing');
assert.ok(menus.includes("'users.php'"), 'submenu must hang off users.php');
assert.ok(menus.includes('[$this, \'render_tickets_page\']'), 'render callback missing');
assert.ok(menus.includes("add_action('admin_post_aksrt_ticket_update', [\$this, 'handle_ticket_update'])"),
'write handler not registered');
// 后台待接单提示。
assert.match(menus, /count_pending_tickets\(\)/);
assert.match(menus, /TicketSettings::enabled\(\)/);
});
test('write actions are nonce-protected and dispatched by action name', () => {
const center = read('src/Admin/Native/UsersMenusTicketsCenter.php');
assert.match(center, /check_admin_referer\('aksrt_ticket_' \. \$action \. '_' \. \$id\);/);
assert.ok(center.includes('current_user_can(self::CAPABILITY)'), 'capability check missing');
// 部门 / 状态 / 预设回复已迁至 SPA 工单中心,本页只剩工单自身的四个动作。
for (const action of ['status', 'reply', 'note', 'create']) {
assert.ok(center.includes(`case '${action}':`), `dispatch case ${action} missing`);
}
// nonce 以 action + id 为单位,行内表单各自带自己的 nonce:
// 代客提交在 List trait,回复 / 状态 / 备注在 Detail trait。
// 删除类操作已迁至 SPA(n-dialog 二次确认),本页不再有 onsubmit=confirm 的小表单。
const list = read('src/Admin/Native/UsersMenusTicketsList.php');
const detail = read('src/Admin/Native/UsersMenusTicketsDetail.php');
assert.ok(list.includes("wp_nonce_field('aksrt_ticket_create_0')"), 'create nonce missing');
for (const hook of [
'aksrt_ticket_reply_',
'aksrt_ticket_status_',
'aksrt_ticket_note_',
]) {
assert.ok(detail.includes(hook), `nonce prefix ${hook} missing`);
}
assert.ok(center.includes('use UsersMenusTicketsDetail;'), 'detail trait not composed');
assert.ok(!detail.includes('ticket_tab'), 'ticket_tab hidden input must be gone');
// Tab 已移除,三个行内表单不再输出 ticket_tab 隐藏域。
assert.ok(!list.includes('ticket_tab'), 'ticket_tab hidden input must be gone');
assert.ok(!center.includes('onsubmit="return confirm('), 'config delete forms must be gone');
});
test('the native page carries no config tab and deep-links the SPA instead', () => {
const center = read('src/Admin/Native/UsersMenusTicketsCenter.php');
// Tab 结构已移除:不再有 nav-tab-wrapper,也不读 ?tab=。
assert.ok(!center.includes('nav-tab-wrapper'), 'tab wrapper must be gone');
assert.ok(!center.includes("'config'"), 'config tab must be gone');
assert.ok(!/\$_GET\['tab'\]/.test(center), 'tab param must not be read');
// 配置 Tab 的渲染 trait 与删除按钮 helper 一并移除。
assert.ok(!center.includes('UsersMenusTicketsConfig'), 'config trait must not be composed');
assert.ok(!center.includes('render_ticket_config_tab'), 'config renderer must be gone');
assert.ok(!center.includes('ticket_delete_button'), 'config-only delete helper must be gone');
// 六类配置写操作全部迁至 TicketConfigRest,本页不再分发。
for (const action of ['type', 'status_def', 'prereply', 'prereply_delete', 'type_delete', 'status_delete']) {
assert.ok(!center.includes(`case '${action}':`), `config action ${action} must be gone`);
}
// 页面顶部提供跳往 SPA「工单中心」的快捷入口。
assert.match(center, /admin_url\('admin\.php\?page=aksrt-settings#\/users\/ticket'\)/);
assert.ok(!fs.existsSync(path.join(root, 'src/Admin/Native/UsersMenusTicketsConfig.php')),
'the config trait file must be deleted');
});
test('the ticket list paginates through the shared helper', () => {
const list = read('src/Admin/Native/UsersMenusTicketsList.php');
assert.ok(list.includes('AdminListPagination::render('), 'shared pagination helper not used');
assert.ok(!/limit => 100/.test(list), 'hard-coded limit must not come back');
// 分页条必须晚于空态分支。
assert.ok(
list.indexOf('AdminListPagination::render(') > list.indexOf('暂无工单'),
'pagination must render after the empty-state branch'
);
// 每页条数来自常量(常量定义在父 trait UsersMenusTicketsCenter 中)。
const center = read('src/Admin/Native/UsersMenusTicketsCenter.php');
assert.match(center, /private const TICKET_PER_PAGE = 20;/);
assert.match(list, /'limit' => self::TICKET_PER_PAGE,/);
});
test('detail panel renders replies, quote blocks and internal notes', () => {
const detail = read('src/Admin/Native/UsersMenusTicketsDetail.php');
const body = detail.slice(detail.indexOf('private function render_ticket_detail_panel'));
assert.ok(detail.includes('private function render_ticket_detail_panel'), 'detail renderer missing');
// 选项必须拼进返回值而不是直接 echo(历史 bug:printf 导致下拉框为空)。
assert.ok(!/\bprintf\(/.test(detail), 'detail panel must not echo via printf');
assert.match(body, /TicketNoteService::for_ticket/);
assert.match(body, /repo->replies\(\$id\)/);
// 引用块:原回复已删除时显示占位。
assert.match(body, /!empty\(\$quote\['is_deleted'\]\) \? __\('原回复已删除', 'aksrt'\)/);
// 回复与备注各自独立表单 + nonce。
assert.match(body, /wp_nonce_field\('aksrt_ticket_reply_' \. \$id, '_wpnonce', true, false\)/);
assert.match(body, /wp_nonce_field\('aksrt_ticket_note_' \. \$id, '_wpnonce', true, false\)/);
// 已关闭工单仍可查看,但不再展示迁移表单之外的写入口由前端按钮控制。
assert.match(body, /aksrt-ticket-detail__closed/);
});
test('SPA deep-links the native ticket page instead of hosting it', () => {
const router = read('assets/admin/src/router/index.js');
assert.ok(router.includes("{ path: '/users/tickets', title: '工单管理', nativeAdminPath: 'users.php?page=aksrt_tickets' }"),
'NATIVE_OPS_REDIRECTS must point at the native ticket page');
});
test('frontend hooks match the renderer and the JS module', () => {
const renderer = read('inc/helpers/user-center-renderers/profile-tickets.php');
// 面板脚本 + 共享工具层(请求 / 弹窗 / 上传)共同构成前端模块。
const js = read('assets/js/ticket-center.js') + '\n' + read('assets/js/ticket-center-core.js');
for (const hook of [
'data-aksrt-ticket-center',
'data-aksrt-ticket-create',
'data-aksrt-ticket-stats',
'data-aksrt-ticket-stat=',
'data-aksrt-ticket-list',
'data-aksrt-ticket-pagination',
'data-aksrt-tickets-search',
'data-aksrt-ticket-filter=',
]) {
assert.ok(renderer.includes(hook), `renderer missing ${hook}`);
}
// JS 侧的挂载点与 REST 路径必须和 PHP 对齐。
for (const hook of [
'data-aksrt-ticket-center',
'data-aksrt-ticket-create',
'data-aksrt-ticket-stat="',
'data-aksrt-ticket-list',
'data-aksrt-ticket-pagination',
'data-aksrt-tickets-search',
'[data-aksrt-ticket-filter]',
'data-aksrt-ticket-view=',
'data-aksrt-ticket-page=',
'data-aksrt-ticket-quote=',
'data-aksrt-ticket-images',
]) {
assert.ok(js.includes(hook), `ticket-center.js missing ${hook}`);
}
assert.ok(js.includes("'/tickets'"), 'list/create path missing');
assert.ok(js.includes("'/tickets/statistic'"), 'statistic path missing');
assert.ok(js.includes("'/tickets/meta'"), 'meta path missing');
assert.ok(js.includes("'/tickets/' + ticketId"), 'detail path missing');
assert.ok(js.includes("'/tickets/' + ticket.id + '/reply'"), 'reply path missing');
assert.ok(js.includes("'/tickets/' + ticket.id + '/urge'"), 'urge path missing');
assert.ok(js.includes("'/tickets/' + ticket.id + '/close'"), 'close path missing');
assert.ok(js.includes("'/ticket/upload-attachment'"), 'upload path missing');
// 人机验证在提交与回复前各过一次。
assert.match(js, /runCaptcha\('ticket_create'\)/);
assert.match(js, /runCaptcha\('ticket_reply'\)/);
});
test('ticket CSS ships as its own route bundle', () => {
const css = read('assets/css/routes/tickets.css');
assert.ok(css.includes('.aksrt-tickets-page'), 'page root rule missing');
assert.ok(css.includes('.aksrt-ticket-badge'), 'status badge rule missing');
assert.ok(css.includes('.aksrt-ticket-detail'), 'detail rule missing');
// 徽章颜色由状态行内变量驱动,不写死。
assert.ok(css.includes('--aksrt-ticket-color'), 'badge color variable missing');
});