- DEFAULT_PER_PAGE 由 30 改为 20。 - 删除 per_page_selector() 及其表单样式(.aksrt-perpage-form / .order-header-right), 工具栏恢复为「过滤链接 + 搜索表单」两段式。 ice_perpage 仍作为 URL 参数被尊重,只是不再提供界面入口。 - 保留上一提交的分页健壮性修复:页码夹取到 [1, total_pages]、翻页链接用显式基准 URL、 越界页码回退并重查、禁用按钮 pointer-events:none。 - 契约测试同步改写:断言默认值为 20,并显式禁止「每页显示」控件回归。 验证:默认态渲染为「每页 20 共10」,页面内不存在 aksrt-perpage-form; ice_perpage=5 仍能翻到第 2 页且链接携带该参数;paged=2 越界仍回退到第 1 页的 10 行; node --test 三套 18/18;php -l 通过;无头 Chrome 截图确认工具栏无多余控件。
115 lines
6.3 KiB
JavaScript
115 lines
6.3 KiB
JavaScript
'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 —— 看上去就是「分页是坏的」。
|
||
* 处置:默认值定为 20;**不在工具栏增加「每页显示」控件**(用户明确要求),
|
||
* ice_perpage 仅作为 URL 参数被尊重。
|
||
*
|
||
* 本契约固化四件事:
|
||
* - 页码先夹到 [1, total_pages],禁用状态由夹取后的页码推导;
|
||
* - 翻页链接基于显式基准 URL(page_url + 当前筛选参数),不再依赖 REQUEST_URI;
|
||
* - 越界页码回退到最后一页并重查,不留空态死胡同;
|
||
* - 每页条数默认 20,且工具栏不得新增「每页显示」控件。
|
||
*/
|
||
|
||
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 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('每页条数默认 20,工具栏不新增「每页显示」控件', () => {
|
||
assert.match(page, /public const DEFAULT_PER_PAGE = 20;/);
|
||
assert.match(list, /\(int\) \(\$_REQUEST\['ice_perpage'\] \?\? self::DEFAULT_PER_PAGE\)/);
|
||
|
||
// 明确约定:不在列表工具栏增加「每页显示」下拉/按钮。任何一处回来都视为回退。
|
||
assert.doesNotMatch(page, /per_page_selector/);
|
||
assert.doesNotMatch(page, /PER_PAGE_CHOICES/);
|
||
assert.doesNotMatch(page, /aksrt-perpage-form/);
|
||
assert.doesNotMatch(page, /name="ice_perpage"/);
|
||
assert.doesNotMatch(page, /order-header-right/);
|
||
|
||
// 但 ice_perpage 仍作为 URL 参数被尊重(无界面入口,供 URL / 程序化调用)。
|
||
assert.match(args, /\$args\['ice_perpage'\] =/);
|
||
|
||
assert.match(list, /\$query_args = \$this->list_query_args\(/);
|
||
assert.match(list, /render_pagenavi\(\$count_all, \$per_page, \$query_args\)/);
|
||
// 工具栏恢复成「过滤链接 + 搜索表单」两段式。
|
||
assert.match(list, /echo '<div class="order-header"><ul class="subsubsub">' \. \$but_html \. '<\/ul>' \. \$search \. '<\/div>';/);
|
||
});
|
||
|
||
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; \}/);
|
||
});
|