import { describe, expect, it } from "vitest";
import { PAGINATION } from "@/lib/config";
import {
buildPageItems,
pageRange,
totalPageCount,
type PageItem,
} from "@/lib/pagination/page-items";
/**
* ページ送りの純粋関数の期待値表(指示書AA 段階1)
*
* ★この表がユーザー要件の合否そのもの★:
* 「2ページ目以降ができたらその数だけ表示選択できるようにしてください」
* 「3ページ目を押したら有りません、みたいなのはだめです」
* = ★描画する数字は1つ残らず実在するページであること★((D) の不変条件テスト)。
*
* ★期待値表 → テスト → 実装 の順で書いている★(`CLAUDE.md` §0-2 / §6-2 / §9.4)。
*/
/** 期待値表を読みやすく書くためのヘルパ(`0` を「…」の意味に使う) */
const ELLIPSIS = 0;
function expand(items: PageItem[]): number[] {
return items.map((i) => (i.kind === "page" ? i.page : ELLIPSIS));
}
describe("totalPageCount — 総ページ数", () => {
// | # | total | perPage | 期待 | 意図 |
const table: Array<[string, number, number, number]> = [
["A-1 0件は0ページ(1ページではない)", 0, 50, 0],
["A-2 最小", 1, 50, 1],
["A-3 ちょうど per_page の倍数", 50, 50, 1],
["A-4 端数あり(+1件で2ページ)", 51, 50, 2],
["A-5 ちょうど倍数", 100, 50, 2],
["A-6 端数あり", 101, 50, 3],
["A-7 fixtures 相当", 33, 50, 1],
["A-8 本番の1日ぶん(docs/spec.md:45)", 360, 50, 8],
["A-9 大量ページ", 11150, 50, 223],
["A-10 perPage が変わっても効く", 10, 5, 2],
["A-11 防御: Infinity を返さない", 10, 0, 0],
["A-12 防御: perPage が負", 10, -1, 0],
["A-13 防御: total が負", -1, 50, 0],
];
for (const [name, total, perPage, expected] of table) {
it(name, () => {
expect(totalPageCount(total, perPage)).toBe(expected);
});
}
});
describe("pageRange — 「全{total}件中 {from}〜{to}件」の from / to", () => {
// | # | total | page | perPage | 期待 |
const table: Array<[string, number, number, number, number, number]> = [
["B-1 0件で from:1 にしない", 0, 1, 50, 0, 0],
["B-2 最小", 1, 1, 50, 1, 1],
["B-3 ちょうど1ページ", 50, 1, 50, 1, 50],
["B-4 1ページ目", 51, 1, 50, 1, 50],
["B-5 端数の最終ページ", 51, 2, 50, 51, 51],
["B-6 ちょうど倍数の最終ページ", 100, 2, 50, 51, 100],
["B-7 本番相当", 360, 8, 50, 351, 360],
["B-8 存在しないページは最終ページに丸める", 51, 3, 50, 51, 51],
["B-9 0以下は1ページ目に丸める", 51, 0, 50, 1, 50],
// ★指示書AE 段階3-1 ②★ — `clamp()` だけだと NaN が素通りして `from`/`to` が NaN になる
["B-10 防御: NaN は1ページ目に丸める", 51, Number.NaN, 50, 1, 50],
["B-11 防御: 非整数は切り捨てる", 120, 2.9, 50, 51, 100],
["B-12 防御: Infinity は最終ページ", 51, Number.POSITIVE_INFINITY, 50, 51, 51],
["B-13 防御: -Infinity は1ページ目", 51, Number.NEGATIVE_INFINITY, 50, 1, 50],
];
for (const [name, total, page, perPage, from, to] of table) {
it(name, () => {
expect(pageRange(total, page, perPage)).toEqual({ from, to });
});
}
});
describe("buildPageItems — 描画するページ番号の並び", () => {
// ★現行 maxVisiblePages = 7 での期待値★(`0` は「…」)
// | # | page | totalPages | 期待 |
const table: Array<[string, number, number, number[]]> = [
["C-1 0件", 1, 0, []],
["C-2 1ページ(部品側では出さない)", 1, 1, [1]],
["C-3 2ページ(ユーザー要件の最小ケース)", 1, 2, [1, 2]],
["C-4 現在ページが末尾", 2, 2, [1, 2]],
["C-5 省略しない上限ちょうど", 1, 7, [1, 2, 3, 4, 5, 6, 7]],
["C-6 現在ページが中央", 4, 7, [1, 2, 3, 4, 5, 6, 7]],
["C-7 省略が始まる最初のケース", 1, 8, [1, 2, 3, 4, 5, ELLIPSIS, 8]],
["C-8 先頭寄りの境界", 4, 8, [1, 2, 3, 4, 5, ELLIPSIS, 8]],
["C-9 末尾寄りに切り替わる境界", 5, 8, [1, ELLIPSIS, 4, 5, 6, 7, 8]],
["C-10 現在ページが末尾", 8, 8, [1, ELLIPSIS, 4, 5, 6, 7, 8]],
["C-11 9ページ・先頭", 1, 9, [1, 2, 3, 4, 5, ELLIPSIS, 9]],
["C-12 中央(「…」が2つ出る最小ケース)", 5, 9, [1, ELLIPSIS, 4, 5, 6, ELLIPSIS, 9]],
["C-13 9ページ・末尾", 9, 9, [1, ELLIPSIS, 5, 6, 7, 8, 9]],
["C-14 大量ページ・先頭", 1, 223, [1, 2, 3, 4, 5, ELLIPSIS, 223]],
["C-15 先頭寄りの境界", 4, 223, [1, 2, 3, 4, 5, ELLIPSIS, 223]],
["C-16 中央に切り替わる境界", 5, 223, [1, ELLIPSIS, 4, 5, 6, ELLIPSIS, 223]],
[
"C-17 大量ページ・中央",
112,
223,
[1, ELLIPSIS, 111, 112, 113, ELLIPSIS, 223],
],
[
"C-18 末尾寄りに切り替わる直前",
219,
223,
[1, ELLIPSIS, 218, 219, 220, ELLIPSIS, 223],
],
["C-19 末尾寄りの境界", 220, 223, [1, ELLIPSIS, 219, 220, 221, 222, 223]],
["C-20 大量ページ・末尾", 223, 223, [1, ELLIPSIS, 219, 220, 221, 222, 223]],
["C-21 範囲外(0)は page=1 扱い", 0, 5, [1, 2, 3, 4, 5]],
["C-22 範囲外(99)は page=5 扱い", 99, 5, [1, 2, 3, 4, 5]],
// ★指示書AE 段階3-1 ②★ — `page` 側にだけ `Number.isFinite` ガードが無かった。
// ★NaN のまま比較すると全部 false になり、先頭寄り・末尾寄りのどちらにも入らず
// 中央の枝へ落ちて `NaN±side` を描画してしまう★
["C-23 防御: NaN は page=1 扱い", Number.NaN, 9, [1, 2, 3, 4, 5, ELLIPSIS, 9]],
[
"C-24 防御: 非整数は切り捨て(5.9 → 5)",
5.9,
9,
[1, ELLIPSIS, 4, 5, 6, ELLIPSIS, 9],
],
[
"C-25 防御: Infinity は最終ページ扱い",
Number.POSITIVE_INFINITY,
9,
[1, ELLIPSIS, 5, 6, 7, 8, 9],
],
[
"C-26 防御: -Infinity は1ページ目扱い",
Number.NEGATIVE_INFINITY,
9,
[1, 2, 3, 4, 5, ELLIPSIS, 9],
],
];
for (const [name, page, totalPages, expected] of table) {
it(name, () => {
expect(expand(buildPageItems(page, totalPages))).toEqual(expected);
});
}
it("「…」に page を持たせない(押せる対象にしない)", () => {
const gap = buildPageItems(112, 223).find((i) => i.kind === "ellipsis");
expect(gap).toBeDefined();
expect(Object.keys(gap!).sort()).toEqual(["key", "kind"]);
});
it("同一描画内で「…」の key が重複しない(Reactのkey用)", () => {
const keys = buildPageItems(112, 223)
.filter((i) => i.kind === "ellipsis")
.map((i) => (i as { key: string }).key);
expect(keys.length).toBe(2);
expect(new Set(keys).size).toBe(2);
});
});
describe("buildPageItems — 不変条件(★ユーザー要件の機械的な固定★)", () => {
const W = PAGINATION.maxVisiblePages;
it("D-10 PAGINATION.maxVisiblePages は奇数かつ7以上", () => {
expect(W % 2).toBe(1);
expect(W).toBeGreaterThanOrEqual(7);
});
// ★timeout を明示する理由★: 45,150通り × expect 約4回 ≒ 20万回の検証で、
// vitest の既定 5000ms に対し余裕が15〜20%しかなく、★並行実行で実際に落ちた実績がある★。
// ★これは「実在しないページ番号を描かない」というユーザー要件を固定する唯一のテスト★。
// ★CIで不安定だからという理由で網羅範囲を削られないよう、時間だけを広げる★(中身は変えない)。
it("D-1〜D-9 総ページ数1..300 × 全ページ位置の総当たり", { timeout: 30_000 }, () => {
for (let totalPages = 1; totalPages <= 300; totalPages++) {
for (let page = 1; page <= totalPages; page++) {
const items = buildPageItems(page, totalPages);
const where = `page=${page}/totalPages=${totalPages}`;
const nums = items
.filter((i) => i.kind === "page")
.map((i) => (i as { page: number }).page);
// ★D-1★ 描画する数字はすべて実在するページ
// (「3ページ目を押したら有りません」の禁止)
for (const n of nums) {
if (n < 1 || n > totalPages) {
throw new Error(`D-1 存在しないページ ${n} を描画した: ${where}`);
}
}
// D-2 / D-3 / D-4
expect(nums, `D-2 先頭ページを含む: ${where}`).toContain(1);
expect(nums, `D-3 最終ページを含む: ${where}`).toContain(totalPages);
expect(nums, `D-4 現在ページを含む: ${where}`).toContain(page);
// D-5 狭義単調増加
for (let i = 1; i < nums.length; i++) {
if (nums[i] <= nums[i - 1]) {
throw new Error(`D-5 数字が単調増加でない: ${where}`);
}
}
// D-6 / D-7 / D-8 「…」の位置と隠す量
for (let i = 0; i < items.length; i++) {
if (items[i].kind !== "ellipsis") continue;
if (i === 0 || i === items.length - 1) {
throw new Error(`D-8 「…」で始まる/終わる: ${where}`);
}
const prev = items[i - 1];
const next = items[i + 1];
if (prev.kind !== "page" || next.kind !== "page") {
throw new Error(`D-7 「…」が連続した: ${where}`);
}
const hidden = next.page - prev.page - 1;
if (hidden < 2) {
throw new Error(
`D-6 「…」が${hidden}ページしか隠していない: ${where}`
);
}
}
// D-9 幅がガタつかない
const expectedLength = totalPages <= W ? totalPages : W;
expect(items.length, `D-9 要素数: ${where}`).toBe(expectedLength);
}
}
});
});