Files
wordpress-theme-aksrt/tests/admin/pay-pages-pagination.test.cjs
T
kate522andClaude Fable 5 d98f8d7975 feat(admin): redesign withdrawal review page and drop dead withdrawal code
- Split PayWithdrawals into shell + PayWithdrawalsList flat sibling trait
- Add process/policy callout, open/paid/fee stats, status views with counts
- Filter by source and payout method; reset keeps the status view
- Show net payout with gross/fee breakdown, copyable payee, note column
- Replace inline select form with per-row actions and a confirm dialog;
  reject requires a reason; double-submit guard; retry reuses the
  idempotency key; redirect back preserving filters and paging
- Withdrawal: add get_status_counts() and method filter; get_stats() now
  returns open_gross / paid_net / paid_fee
- Remove unused REST routes (/payment/withdrawals, /transition),
  admin_withdrawals(), update_status(), has_open_withdrawal(),
  count_open(), resolve_gross_amount()

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-10-31 21:06:52 +08:00

236 lines
12 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';
/**
* 支付中心列表页分页契约(四按钮式,统一到 AdminListPagination)。
*
* 背景(本次收敛的缺陷):
* 1) 各页面的翻页基准 URL 写成 add_query_arg('paged', '%#%') —— 只有两个参数时
* WordPress 会落到 $_SERVER['REQUEST_URI'],链接绑在当前请求上,随请求形态漂移,
* 列条件也容易丢。现在统一由 AdminListPagination 以 page 参数 + 当前筛选参数显式拼装。
* 2) 五个页面各复制一份几乎相同的 render_pagination(),其中「订单明细」与「提现审核」
* 的那份从未被调用(死代码),两页各自又内联了一份 paginate_links。
* 3) 页码越界(手工改 URL、或筛选后总数变小)会让 offset 落到数据之外:表格空掉
* 且分页条不输出,只能手改 URL 退出。现在每页回退到最后一页重查一次。
*
* 本契约固化:
* - 七个页面全部改走 AdminListPagination::render(),不再有各自的翻页实现;
* - 渲染标记是卡密管理那一套:« ‹ 第 X 页,共 Y 页 › » 四枚按钮 + 「每页 X 共Y」计数,
* 没有数字页码链接;只要总数 > 0 就渲染,单页时四枚按钮全 disabled;
* - 每页条数默认 20 且来自共享常量,不新增「每页显示」控件;
* - 每页都在查询前夹取页码,越界时重查,杜绝「空表且没有分页条」的死胡同;
* - 翻页链接显式带上当前筛选参数,不依赖 REQUEST_URI。
*/
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 = (rel) => fs.readFileSync(path.join(root, rel), 'utf8');
const helper = read('src/Admin/Native/AdminListPagination.php');
// 卡密管理已拆成 flat sibling traits;分页相关逻辑现落在 List trait 内,故把壳类与三个
// trait 拼接后再做契约断言(顺序:壳类 → List → Assets → Forms)。
const chargeCards = [
'src/Admin/Native/PayChargeCards.php',
'src/Admin/Native/PayChargeCardsList.php',
'src/Admin/Native/PayChargeCardsAssets.php',
'src/Admin/Native/PayChargeCardsForms.php',
].map(read).join('\n');
const pages = {
orders: ['PayOrders', 'PayOrdersList', 'PayOrdersDetail'].map((f) => read(`src/Admin/Native/${f}.php`)).join('\n'),
products: ['PayProducts', 'PayProductsList', 'PayProductsEditor'].map((f) => read(`src/Admin/Native/${f}.php`)).join('\n'),
coupons: read('src/Admin/Native/PayCoupons.php'),
income: ['PayIncome', 'PayIncomeList'].map((f) => read(`src/Admin/Native/${f}.php`)).join('\n'),
withdrawals: ['PayWithdrawals', 'PayWithdrawalsList'].map((f) => read(`src/Admin/Native/${f}.php`)).join('\n'),
chargeCards,
};
const labels = {
orders: '订单明细',
products: '付费文章',
coupons: '优惠码',
income: '创作分成',
withdrawals: '提现审核',
chargeCards: '卡密管理',
};
const count = (src, pattern) => (src.match(pattern) || []).length;
const emptyStates = {
orders: '暂无订单',
products: '暂无付费文章',
coupons: '暂无优惠码',
income: '暂无分成记录',
withdrawals: '暂无提现记录',
chargeCards: '未找到对应内容',
};
test('六个页面全部改走共享分页助手', () => {
for (const [key, src] of Object.entries(pages)) {
assert.ok(
src.includes('AdminListPagination::render('),
`${labels[key]} 未使用 AdminListPagination::render()`
);
}
// 每个页面恰好一处;售后页已整体移出支付中心(工单后台在 users.php)。
for (const key of Object.keys(pages)) {
assert.equal(count(pages[key], /AdminListPagination::render\(/g), 1, `${labels[key]} 只应有一处`);
}
});
test('不再有各页面自己的翻页实现,也没有 REQUEST_URI 基准 URL', () => {
// 只查六个页面:共享助手的 docblock 会引用这些反例,不能把它算进来。
for (const [key, src] of Object.entries(pages)) {
const label = labels[key];
assert.doesNotMatch(src, /private function render_pagenavi\(/, `${label} 仍保留私有 render_pagenavi()`);
assert.doesNotMatch(src, /private function render_pagination\(/, `${label} 仍保留私有 render_pagination()`);
assert.doesNotMatch(src, /public const DEFAULT_PER_PAGE/, `${label} 仍保留自己的每页条数常量`);
assert.doesNotMatch(src, /self::DEFAULT_PER_PAGE/, `${label} 仍引用自己的每页条数常量`);
// 两个参数的 add_query_arg('paged', ...) 会退回 $_SERVER['REQUEST_URI']。
assert.doesNotMatch(src, /add_query_arg\('paged',\s*'[^']*'\s*\)/, `${label} 仍以 REQUEST_URI 为基准`);
assert.doesNotMatch(src, /echo paginate_links\(/, `${label} 仍直接调用 paginate_links`);
}
});
test('每页条数默认 20 来自共享常量,且无「每页显示」控件', () => {
assert.match(helper, /public const DEFAULT_PER_PAGE = 20;/);
for (const [key, src] of Object.entries(pages)) {
assert.ok(
src.includes('AdminListPagination::DEFAULT_PER_PAGE'),
`${labels[key]} 未使用共享每页条数`
);
}
// 明确约定:任何页面都不新增「每页显示」下拉/按钮。
for (const [key, src] of Object.entries(pages)) {
assert.doesNotMatch(src, /per_page_selector/, `${labels[key]} 出现 per_page 选择器`);
assert.doesNotMatch(src, /PER_PAGE_CHOICES/, `${labels[key]} 出现 PER_PAGE_CHOICES`);
assert.doesNotMatch(src, /aksrt-perpage-form/, `${labels[key]} 出现每页条数表单`);
assert.doesNotMatch(src, /name="ice_perpage"/, `${labels[key]} 出现 ice_perpage 输入框`);
assert.doesNotMatch(src, /order-header-right/, `${labels[key]} 出现工具栏右侧每页控件`);
}
});
test('每页都在查询前夹取页码,越界时重查一次', () => {
// 六个页面统一按 $page_num / $pages 夹取;卡密管理沿用自己既有的 $paged / $max_page 回退。
for (const key of ['orders', 'products', 'coupons', 'income', 'withdrawals']) {
assert.match(pages[key], /if \(\$page_num > \$pages\) \{/, `${labels[key]} 缺少页码夹取`);
}
assert.match(
pages.chargeCards,
/if \(!\$items && \$count_all > 0 && \$paged > \$max_page\)/,
'卡密管理缺少越界回退'
);
// 订单明细先用状态计数得出总数、夹取页码,再只查一次列表——不需要越界重查。
assert.equal(count(pages.orders, /\$items\s*=\s*\$repo->get_orders_admin\(/g), 1, '订单明细只应查询一次列表');
assert.ok(
pages.orders.indexOf('get_orders_status_counts(') < pages.orders.indexOf('if ($page_num > $pages) {')
&& pages.orders.indexOf('if ($page_num > $pages) {') < pages.orders.indexOf('$repo->get_orders_admin('),
'订单明细必须在查询列表前完成计数与页码夹取'
);
// 创作分成同样先按状态计数夹取页码,再只查一次列表。
assert.equal(count(pages.income, /\$svc->get_list\(/g), 1, '创作分成只应查询一次列表');
assert.ok(
pages.income.indexOf('get_status_counts(') < pages.income.indexOf('if ($page_num > $pages) {')
&& pages.income.indexOf('if ($page_num > $pages) {') < pages.income.indexOf('$svc->get_list('),
'创作分成必须在查询列表前完成计数与页码夹取'
);
// 提现审核同样先按状态计数夹取页码,再只查一次列表。
assert.equal(count(pages.withdrawals, /\$svc->get_all\(/g), 1, '提现审核只应查询一次列表');
assert.ok(
pages.withdrawals.indexOf('get_status_counts(') < pages.withdrawals.indexOf('if ($page_num > $pages) {')
&& pages.withdrawals.indexOf('if ($page_num > $pages) {') < pages.withdrawals.indexOf('$svc->get_all('),
'提现审核必须在查询列表前完成计数与页码夹取'
);
const requeries = {
products: [/\$query\s*=\s*\$this->query_paid_posts\(/g],
coupons: [/\$svc->get_list\(/g],
chargeCards: [/\$svc->get_list\(/g],
};
for (const [key, patterns] of Object.entries(requeries)) {
for (const pattern of patterns) {
assert.equal(count(pages[key], pattern), 2, `${labels[key]} 越界后必须重查(${pattern})`);
}
}
});
test('分页条晚于空态分支渲染', () => {
for (const [key, src] of Object.entries(pages)) {
const marker = emptyStates[key];
assert.ok(src.includes(marker), `${labels[key]} 找不到空态标记 ${marker}`);
assert.ok(
src.indexOf('AdminListPagination::render(') > src.indexOf(marker),
`${labels[key]} 的分页条必须晚于空态分支`
);
}
});
test('提现审核不把一次性动作参数带进翻页链接', () => {
// 只取 $query_args 数组本体:上下文的注释与行循环里的 $retry_id 用法都不算。
const start = pages.withdrawals.indexOf('$query_args = array_filter([');
assert.ok(start !== -1, '提现审核缺少 $query_args 白名单');
const slice = pages.withdrawals.slice(
start,
pages.withdrawals.indexOf('], static function', start)
);
assert.doesNotMatch(slice, /retry_id/, 'retry_id 不得进翻页链接');
assert.doesNotMatch(slice, /retry_key/, 'retry_key 不得进翻页链接');
for (const key of ['status', 's', 'user_id', 'date_from', 'date_to']) {
assert.match(slice, new RegExp(`'${key}'\\s*=>`), `缺少筛选参数 ${key}`);
}
});
test('共享助手自身:页码夹取、四按钮标记与显式基准 URL', () => {
assert.match(helper, /namespace AKSRT\\Admin\\Native;/);
assert.match(helper, /defined\('ABSPATH'\) \|\| exit;/);
// 总数为 0 或每页条数非法时不渲染;单页照样渲染(与原版 render_pagenavi 一致)。
assert.match(helper, /if \(\$total_count <= 0 \|\| \$per_page <= 0\) \{\s*return;\s*\}/);
assert.doesNotMatch(helper, /\$total_pages <= 1/, '单页也必须渲染分页条');
assert.match(helper, /\$total_pages\s*=\s*max\(1, \(int\) ceil\(\$total_count \/ \$per_page\)\);/);
assert.match(helper, /\$current_page = min\(max\(1, \$current_page\), \$total_pages\);/);
// 基准 URL 显式拼装:add_query_arg 必带第三个参数(两参数写法会退回 REQUEST_URI)。
// 宿主菜单文件可传入(users.php 子菜单保持菜单高亮),默认 admin.php。
assert.match(helper, /string \$parent_file = 'admin\.php'/);
assert.match(helper, /\$base\s*=\s*add_query_arg\(array_merge\(\['page' => \$page_slug\], \$query_args\), admin_url\(\$parent_file\)\);/);
assert.match(helper, /\$page_url = static function \(int \$page\) use \(\$base\): string \{/);
assert.match(helper, /return add_query_arg\('paged', \$page, \$base\);/);
assert.doesNotMatch(helper, /add_query_arg\('paged',\s*'[^']*'\s*\)/);
// 卡密管理式四按钮标记。
assert.match(helper, /<div class="tablenav bottom">/);
assert.match(helper, /<div class="tablenav-pages">/);
assert.match(helper, /<span class="displaying-num">/);
assert.match(helper, /每页 %1\$s 共%2\$s/);
assert.match(helper, /<span class="pagination-links">/);
assert.match(helper, /<span class="paging-input">/);
assert.match(helper, /第 %1\$s 页,共 %2\$s 页/);
assert.match(helper, /<span class="total-pages">/);
for (const cls of ['first-page', 'prev-page', 'next-page', 'last-page']) {
assert.match(helper, new RegExp(`class="${cls} button`), `缺少 ${cls} 按钮`);
}
assert.match(helper, /\$on_first = \$current_page <= 1;/);
assert.match(helper, /\$on_last\s*=\s*\$current_page >= \$total_pages;/);
assert.equal(count(helper, /\$on_first \? '#' :/g), 2, '第一页/上一页都要按 on_first 落到 #');
assert.equal(count(helper, /\$on_last \? '#' :/g), 2, '下一页/最后一页都要按 on_last 落到 #');
// 禁用的翻页按钮不可点击,否则 <a href="#"> 会把页面滚回顶部。
assert.match(helper, /\.tablenav-pages a\.disabled \{ pointer-events: none; \}/);
assert.match(helper, /private static \$assets_printed = false;/);
assert.match(helper, /private static function print_assets\(\): void \{/);
// 不再使用 WP 原生 paginate_links。
assert.doesNotMatch(helper, /echo paginate_links\(/);
});