'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, /jio-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 Jio\\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, /