Files
wordpress-theme-aksrt/tests/admin/ticket-native-render-harness.php
T
kate522andClaude Fable 5 d86a85e6a6 feat(admin): replace ticket dialogs with in-page views
Ticket handling on users.php?page=aksrt_tickets no longer uses dialogs.
The list, the workspace (?id=N) and the assisted create form (?view=new)
are server-rendered page views that carry the list filters and page
for going back. Writes still go through the ticket REST routes; on
success the page reloads with a native notice, and unsent drafts in
other forms survive the reload via sessionStorage. Uncertain or
conflicting writes lock every form and only offer a reload.

Remove dead code: UsersMenusTicketsDialogs, the aksrt_ticket_view AJAX
read and its nonce, dialog CSS/JS (backdrop, scroll lock, focus return,
read races) and the dialog runtime test. Fix the create link carrying
an unclamped page number on out-of-range list pages.

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

151 lines
9.0 KiB
PHP

<?php
/** Runs the real native renderers and query builder without a WordPress installation. */
namespace {
define('ABSPATH', __DIR__);
define('ARRAY_A', 'ARRAY_A');
function __($text, $domain = '') { return $text; }
function esc_html($text) { return htmlspecialchars((string) $text, ENT_QUOTES, 'UTF-8'); }
function esc_attr($text) { return esc_html($text); }
function esc_html__($text, $domain = '') { return esc_html($text); }
function esc_attr__($text, $domain = '') { return esc_attr($text); }
function esc_url($url) { return preg_match('~^(https?://|#)~', (string) $url) ? esc_attr($url) : ''; }
function wpautop($text) { return '<p>' . str_replace("\n\n", '</p><p>', $text) . '</p>'; }
function sanitize_key($text) { return preg_replace('/[^a-z0-9_\-]/', '', strtolower((string) $text)); }
function sanitize_text_field($text) { return trim(strip_tags((string) $text)); }
function wp_unslash($text) { return stripslashes((string) $text); }
function absint($value) { return abs((int) $value); }
function selected($a, $b, $echo = true) { return (string) $a === (string) $b ? ' selected="selected"' : ''; }
function number_format_i18n($number) { return number_format($number); }
function admin_url($path) { return 'https://example.test/wp-admin/' . $path; }
function rest_url($path) { return 'https://example.test/wp-json/' . $path; }
function get_current_user_id() { return 7; }
function wp_get_current_user() { return (object) ['ID' => 7, 'display_name' => 'Operator']; }
function get_users($args) { return [(object) ['ID' => 7, 'display_name' => 'Operator'], (object) ['ID' => 8, 'display_name' => 'Second']]; }
function get_edit_user_link($id) { return admin_url('user-edit.php?user_id=' . $id); }
function aksrt_get_avatar_url($id, $size) { return 'https://example.test/avatar/' . $id; }
function add_query_arg($args, $url, $base = null) {
if (!is_array($args)) { $args = [$args => $url]; $url = $base; }
$parts = parse_url($url);
parse_str($parts['query'] ?? '', $query);
return $parts['scheme'] . '://' . $parts['host'] . ($parts['path'] ?? '') . '?' . http_build_query(array_merge($query, $args));
}
}
namespace AKSRT\Ticket {
class TicketSettings {
public static bool $orders = true;
public static function title_max() { return 100; }
public static function content_max() { return 1000; }
public static function reply_max() { return 500; }
public static function order_link_enabled() { return self::$orders; }
}
class TicketTypeService {
public static bool $empty = false;
public static function all() { return self::$empty ? [] : [['id' => 1, 'name' => 'Support <script>']]; }
}
class TicketStatusService {
public const KEY_PENDING = 'pending';
public const KEY_USER_REPLIED = 'user_replied';
public static function all() {
return [
['status_key' => 'pending', 'name' => '待接单', 'is_closed' => 0, 'color' => '#f0a020'],
['status_key' => 'handling', 'name' => '处理中', 'is_closed' => 0, 'color' => '#18a058'],
['status_key' => 'closed', 'name' => '已关闭', 'is_closed' => 1, 'color' => '#999999'],
['status_key' => 'archived', 'name' => '已归档', 'is_closed' => 1, 'color' => '#999999'],
];
}
public static function map() { return array_column(self::all(), null, 'status_key'); }
public static function can_transition($from, $to) { return $from !== 'closed' && !($from === 'pending' && $to === 'closed'); }
}
class TicketPrereplyService {
public static function all() { return [['content' => 'Preset <script>alert(1)</script>']]; }
}
class TicketNoteService {
public static function for_ticket($id) { return [['admin_name' => 'Admin <img>', 'created_at' => '2026-10-31', 'content' => 'Secret <script>alert(1)</script>']]; }
}
class TicketRepository {
public static array $queries = [];
public static array $counts = [];
public static int $reads = 0;
public static int $replies_read = 0;
public static function get_instance() { static $repo; return $repo ??= new self(); }
public function sample($id = 21) {
return [
'id' => $id, 'ticket_num' => 'TK-000' . $id, 'title' => 'Question <svg onload=alert(1)>',
'user_id' => 12, 'user_name' => 'User <b>x</b>', 'admin_id' => 7, 'admin_name' => 'Operator',
'ticket_type_id' => 1, 'ticket_type_name' => 'Support <script>',
'status' => $id === 99 ? 'closed' : 'handling', 'status_label' => $id === 99 ? '已关闭' : '处理中', 'status_color' => '#18a058', 'is_closed' => $id === 99,
'content' => 'Description <script>alert(1)</script>', 'attachment' => ['https://example.test/image.jpg'],
'post_time' => '2026-10-30 12:00:00', 'last_reply_time' => '', 'post_admin_id' => 7,
'order_number' => 'ORDER-1', 'urge_count' => 1, 'last_urge_time' => '2026-10-31 11:00:00',
];
}
public function find($id) { self::$reads++; return $id === 404 ? null : $this->sample($id); }
public function paginate($args, $admin) {
self::$queries[] = $args;
return ['items' => $args['offset'] > 20 ? [] : [$this->sample()], 'total' => 21];
}
public function statistic($args, $admin) { self::$counts[] = $args; return ['pending' => 2, 'handling' => 4, 'closed' => 6, 'archived' => 3]; }
public function replies($id) {
self::$replies_read++;
return [
['author_type' => 'system', 'content' => '催单', 'created_at' => '2026-10-31'],
['author_type' => 'user', 'author_id' => 12, 'author_name' => 'User', 'content' => 'Reply <script>', 'created_at' => '2026-10-31', 'attachment' => [], 'quote' => ['is_deleted' => true, 'content' => 'Deleted']],
];
}
}
}
namespace AKSRT\Core {
class DBMigrator { public static function table($name) { return 'wp_aksrt_' . $name; } }
}
namespace {
$root = dirname(__DIR__, 2);
foreach (['NativeUi', 'AdminListPagination', 'UsersMenusTicketsList', 'UsersMenusTicketsCreate', 'UsersMenusTicketsDetail', 'UsersMenusTicketsCenter'] as $file) {
require $root . '/src/Admin/Native/' . $file . '.php';
}
require $root . '/src/Ticket/TicketQuery.php';
class TicketPage {
use \AKSRT\Admin\Native\UsersMenusTicketsCenter;
public const CAPABILITY = 'manage_options';
private function ensure_cap() {}
}
class QueryProbe {
use \AKSRT\Ticket\TicketQuery;
public function where($args) { return $this->ticket_where($args, true); }
public function counts($args) { return $this->ticket_statistic($args, true); }
}
class FakeDb {
public array $params = [];
public string $sql = '';
public function esc_like($text) { return addcslashes($text, '_%\\'); }
public function prepare($sql, $params) { $this->sql = $sql; $this->params = $params; return $sql; }
public function get_results($sql, $format) { return [['status' => 'closed', 'total' => 2]]; }
}
function render_page(TicketPage $page, array $get): string {
$_GET = $get;
ob_start();
$page->render_tickets_page();
return (string) ob_get_clean();
}
$repo = \AKSRT\Ticket\TicketRepository::class;
$page = new TicketPage();
$list = render_page($page, ['status' => 'handling', 'ticket_type_id' => 1, 's' => 'needle', 'paged' => 99]);
$initial = ['queries' => $repo::$queries, 'counts' => $repo::$counts, 'detail_reads' => $repo::$reads, 'reply_reads' => $repo::$replies_read];
$list_queries = count($repo::$queries);
$open = render_page($page, ['id' => 21, 'status' => 'pending', 's' => 'not in list', 'paged' => 3, 'aksrt_ticket_msg' => 'reply']);
$closed = render_page($page, ['id' => 99]);
$missing = render_page($page, ['id' => 404]);
$detail_list_queries = count($repo::$queries) - $list_queries;
$create = render_page($page, ['view' => 'new', 'status' => 'pending', 'paged' => 2]);
$bogus_notice = render_page($page, ['id' => 21, 'aksrt_ticket_msg' => '<script>']);
\AKSRT\Ticket\TicketTypeService::$empty = true;
$no_types = render_page($page, ['view' => 'new']);
\AKSRT\Ticket\TicketTypeService::$empty = false;
\AKSRT\Ticket\TicketSettings::$orders = false;
$no_orders = render_page($page, ['view' => 'new']);
$GLOBALS['wpdb'] = new FakeDb();
$query = new QueryProbe();
$where = $query->where([]);
$statistic = $query->counts(['status' => ['pending', 'handling', 'closed', 'archived'], 'ticket_type_id' => 1, 'keywords' => 'needle']);
echo json_encode(compact('list', 'initial', 'open', 'closed', 'missing', 'detail_list_queries', 'create', 'bogus_notice', 'no_types', 'no_orders', 'where', 'statistic') + ['count_sql' => $GLOBALS['wpdb']->sql, 'count_params' => $GLOBALS['wpdb']->params], JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
}