'use strict'; /** * 「卡密管理」分页契约。 * * 背景(已修复的缺陷): * 1) 翻页链接此前写作 add_query_arg('paged', N) —— 只有两个参数时 WordPress 会落到 * $_SERVER['REQUEST_URI'],链接绑在当前请求上,随请求形态漂移,列条件也容易丢。 * 2) render_pagenavi() 的 URL 推导是一串 if/elseif。页码越界时(例如 per_page 缩小后 * URL 里还留着 paged=2,而总数只剩 1 页)三个分支一个都不命中,上一页/下一页 * 同时停在 '#':按钮既不打 disabled 也点不动,还停在「第 1 页」状态无法自洽。 * 极端情况下 offset 落到数据之外,表格直接变空态并且连分页条都不输出,只能手改 URL 退出。 * 3) ice_perpage 只在服务端被读取,界面上没有任何入口能改每页条数。条数小于默认每页条数时 * 永远只有一页,四个按钮恒为 disabled —— 看上去就是「分页是坏的」。 * * 本契约固化三件事: * - 页码先夹到 [1, total_pages],禁用状态由夹取后的页码推导; * - 翻页链接基于显式基准 URL(page_url + 当前筛选参数),不再依赖 REQUEST_URI; * - 列表页提供「每页显示」选择器(name=ice_perpage),并把筛选参数带进翻页链接。 */ 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 page = fs.readFileSync(path.join(root, 'src/Admin/Native/PayChargeCards.php'), 'utf8'); const count = (src, pattern) => (src.match(pattern) || []).length; const slice = (from, to) => { const start = page.indexOf(from); const end = page.indexOf(to); if (start === -1 || end === -1 || end <= start) { throw new Error(`切片失败:${from} → ${to}`); } return page.slice(start, end); }; const list = slice('private function render_list(', 'private function render_add_form('); const args = slice('private function list_query_args(', 'private function per_page_selector('); const perpage = slice('private function per_page_selector(', 'private function render_pagenavi('); const pager = slice('private function render_pagenavi(', 'private function print_assets('); test('翻页链接使用显式基准 URL,不再依赖 REQUEST_URI', () => { // 反面写法:只有两个参数的 add_query_arg —— 会退回到 $_SERVER['REQUEST_URI']。 assert.doesNotMatch(page, /add_query_arg\('paged',\s*[^,)]+\)/); assert.match(pager, /\$base\s*=\s*\$query_args\s*\?\s*add_query_arg\(\$query_args,\s*\$this->page_url\)\s*:\s*\$this->page_url;/); assert.match(pager, /add_query_arg\('paged',\s*\$page,\s*\$base\)/); assert.equal(count(pager, /add_query_arg\('paged'/g), 1, '翻页 URL 只应有一个构造点'); }); test('页码夹取到有效范围,禁用状态由夹取后的页码推导', () => { assert.match(pager, /\$total_pages\s*=\s*max\(1,\s*\(int\)\s*ceil\(\$total_count \/ \$number_per_page\)\)/); assert.match(pager, /\$current_page\s*=\s*min\(max\(1,\s*\(int\)\s*\(\$_GET\['paged'\] \?\? 1\)\),\s*\$total_pages\)/); // 越界时 if/elseif 全不命中的分支结构不得回归。 assert.match(pager, /\$on_first\s*=\s*\$current_page <= 1;/); assert.match(pager, /\$on_last\s*=\s*\$current_page >= \$total_pages;/); assert.equal(count(pager, /\$on_first \? '#' :/g), 2, '第一页/上一页都要按 on_first 落到 #'); assert.equal(count(pager, /\$on_last \? '#' :/g), 2, '下一页/最后一页都要按 on_last 落到 #'); assert.doesNotMatch(pager, /\$current_page == \$total_pages\b/); assert.doesNotMatch(pager, /\$prev_page_url\s*=\s*'#';/); }); test('越界页码回退到最后一页,不再出现无分页条的死胡同', () => { assert.match(list, /\$max_page\s*=\s*max\(1,\s*\(int\)\s*\(\$result\['max_pages'\] \?\? 1\)\)/); assert.match(list, /if \(!\$items && \$count_all > 0 && \$paged > \$max_page\)/); assert.match(list, /\$paged\s*=\s*\$max_page;/); // 回退之后必须重查,否则表格依旧是空的。 assert.equal(count(list, /\$svc->get_list\(/g), 2); // 分页条必须在空态分支之后渲染,否则空态 return 会把分页条一起吞掉。 assert.ok(list.indexOf('render_pagenavi(') > list.indexOf('未找到对应内容'), '分页渲染必须晚于空态处理'); }); test('每页条数由界面提供,且与筛选参数保持一致', () => { assert.match(page, /public const DEFAULT_PER_PAGE = 30;/); assert.match(list, /\(int\) \(\$_REQUEST\['ice_perpage'\] \?\? self::DEFAULT_PER_PAGE\)/); assert.match(perpage, /method="get"/); assert.match(perpage, /name="ice_perpage"/); assert.match(perpage, /name="page" value="' \. esc_attr\(self::PAGE_SLUG\)/); assert.match(perpage, /selected\(\$per_page, \$choice, false\)/); assert.match(perpage, /onchange="this\.form\.submit\(\);"/); // 非预设值也要出现在下拉里,否则当前状态无法回显、一提交就被重置。 assert.match(perpage, /if \(!in_array\(\$per_page, \$choices, true\)\) \{/); assert.match(list, /\$query_args = \$this->list_query_args\(/); assert.match(list, /per_page_selector\(\$per_page, \$query_args\)/); assert.match(list, /render_pagenavi\(\$count_all, \$per_page, \$query_args\)/); }); test('筛选参数白名单化拼装,排序与每页条数随链接带走', () => { for (const key of ['status', 'type', 'other', 's', 'orderby', 'desc', 'ice_perpage']) { assert.match(args, new RegExp(`\\$args\\['${key}'\\] =`), `缺少 ${key}`); } // 默认值不进 URL,避免链接里塞满无意义参数。 assert.match(args, /\$orderby !== 'modified_time'/); assert.match(args, /\$order === 'ASC'/); assert.match(args, /\$per_page !== self::DEFAULT_PER_PAGE/); // paged 由 render_pagenavi 单独追加,不能混进基准参数里(否则会被自己覆盖)。 assert.doesNotMatch(args, /\$args\['paged'\]/); }); test('禁用的翻页按钮不可点击,避免点空还滚回页首', () => { assert.match(page, /\.tablenav-pages a\.disabled \{ pointer-events: none; \}/); });