Files
Jyotisha/frontend/tests/report-list-paging-20260930.test.tsx
T

178 lines
11 KiB
TypeScript

// 我的报告: keyset pages of 10 with 「加载更多」 (BUG-1124) and entering the page
// joins the sidebar's prefetch instead of discarding it (BUG-1123).
// Fictional list metadata only (AGENTS §8).
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
import React from "react";
import { AppRouterContext, type AppRouterInstance } from "next/dist/shared/lib/app-router-context.shared-runtime";
import { SidebarProvider } from "../src/components/ui/sidebar";
import { applyFirstReportPage, PersonalReportCenter } from "../src/components/personal-report/personal-report-center";
import {
appendReportPage,
clampReportLimit,
encodeReportCursor,
mergeFirstReportPage,
nextReportCursor,
parseReportCursor,
REPORT_PAGE_SIZE,
reportCursorFilter,
} from "../src/lib/report-cursor";
import {
fetchMoreReports,
invalidateReportsPage,
peekReportsPage,
prefetchSecondaryPage,
refreshReportsPage,
writeReportsPage,
type ReportListItem,
} from "../src/lib/secondary-page-data";
import { createClientLifecycleHarness } from "./react-client-lifecycle-test-support";
Object.assign(globalThis, { React });
type Harness = ReturnType<typeof createClientLifecycleHarness>;
const buttons = (h: Harness, text: string) => h.elements().filter(node => node.tagName === "BUTTON" && node.text === text);
function deferred<T>() { let resolve!: (value: T) => void; const promise = new Promise<T>(done => { resolve = done; }); return { promise, resolve }; }
const uuid = (n: number) => `00000000-0000-4000-8000-${String(n).padStart(12, "0")}`;
/** Report n was created n days before 2026-09-30; higher n is older. */
function report(n: number, extra: Partial<ReportListItem> = {}): ReportListItem {
const created = new Date(Date.UTC(2026, 8, 30) - n * 86_400_000).toISOString();
return { id: uuid(n), reportType: "personal_full", presentationMode: "default", depth: "standard", themes: ["career"], status: "ready", failureCode: null, createdAt: created, completedAt: created, ...extra };
}
const route = readFileSync(new URL("../src/app/api/reports/route.ts", import.meta.url), "utf8");
const page = readFileSync(new URL("../src/app/(app)/reports/page.tsx", import.meta.url), "utf8");
const css = readFileSync(new URL("../src/app/globals.css", import.meta.url), "utf8");
const router = { bfcacheId: "test", push() {}, replace() {}, refresh() {}, prefetch() {}, back() {}, forward() {} } as AppRouterInstance;
test("cursor helpers: 10 by default, clamped, (created_at, id) keyset, Date rows from self-hosted postgres", () => {
assert.equal(REPORT_PAGE_SIZE, 10);
assert.equal(clampReportLimit(null), 10);
assert.equal(clampReportLimit("abc"), 10);
assert.equal(clampReportLimit("0"), 1);
assert.equal(clampReportLimit("999"), 50);
const cursor = encodeReportCursor("2026-09-01T00:00:00.123456+00:00", uuid(3));
assert.deepEqual(parseReportCursor(cursor), { createdAt: "2026-09-01T00:00:00.123456+00:00", id: uuid(3) });
assert.equal(parseReportCursor("not-a-date," + uuid(3)), null);
assert.equal(parseReportCursor("2026-09-01T00:00:00Z,drop table"), null);
assert.equal(
reportCursorFilter({ createdAt: "2026-09-01T00:00:00Z", id: uuid(3) }),
`created_at.lt."2026-09-01T00:00:00Z",and(created_at.eq."2026-09-01T00:00:00Z",id.lt."${uuid(3)}")`,
);
const rows = Array.from({ length: 11 }, (_, i) => ({ id: uuid(i), created_at: new Date(Date.UTC(2026, 8, 30 - i)) }));
assert.equal(nextReportCursor(rows.slice(0, 10), 10), null, "exactly one page: no more");
assert.equal(nextReportCursor(rows, 10), encodeReportCursor(rows[9]!.created_at.toISOString(), uuid(9)));
});
test("GET /api/reports pages by (created_at desc, id desc), fetches limit + 1 and returns nextCursor", () => {
assert.match(route, /clampReportLimit\(url\.searchParams\.get\("limit"\)\)/);
assert.match(route, /parseReportCursor\(beforeRaw\)/);
assert.match(route, /if \(beforeRaw && !cursor\) \{\s*return NextResponse\.json\(\{ error: "报告列表请求无效" \}, \{ status: 400 \}\);/);
assert.match(route, /\.order\("created_at", \{ ascending: false \}\)\s*\.order\("id", \{ ascending: false \}\)\s*\.limit\(limit \+ 1\)/);
assert.match(route, /if \(cursor\) listQuery = listQuery\.or\(reportCursorFilter\(cursor\)\)/);
assert.match(route, /nextReportCursor\(Array\.isArray\(pageRows\) \? pageRows : \[\], limit\)/);
assert.match(route, /pageRows\.slice\(0, limit\)/);
assert.match(route, /return NextResponse\.json\(\{ reports, nextCursor \}\);/);
assert.doesNotMatch(route, /\.limit\(20\)/, "the fixed 20 made older reports unreachable (BUG-1124)");
});
test("a fresh first page replaces the list until 加载更多 was used, then merges over the opened pages", () => {
const first = [1, 2, 3].map(n => report(n));
const opened = [1, 2, 3, 4, 5, 6].map(n => report(n));
assert.deepEqual(applyFirstReportPage({ reports: opened, nextCursor: "c6", extended: false }, { reports: first, nextCursor: "c3" }), { reports: first, nextCursor: "c3", extended: false });
// A new report on top pushes 3 below the first page; 3 stays, nothing is lost, the cursor still points past 6.
const polled = [0, 1, 2].map(n => report(n, n === 0 ? { status: "generating" } : {}));
const merged = applyFirstReportPage({ reports: opened, nextCursor: "c6", extended: true }, { reports: polled, nextCursor: "c2" });
assert.deepEqual(merged.reports.map(r => r.id), [0, 1, 2, 3, 4, 5, 6].map(uuid));
assert.equal(merged.nextCursor, "c6");
assert.equal(merged.extended, true);
// The first page's copy of a row wins (status changed while polling).
assert.equal(mergeFirstReportPage([report(1, { status: "failed" })], [report(1), report(2)])[0]!.status, "failed");
// Everything now fits on the first page: the list is exactly that page.
assert.deepEqual(applyFirstReportPage({ reports: opened, nextCursor: "c6", extended: true }, { reports: first, nextCursor: null }), { reports: first, nextCursor: null, extended: false });
assert.deepEqual(appendReportPage([report(1), report(2)], [report(2), report(3)]).map(r => r.id), [1, 2, 3].map(uuid));
});
test("the client asks for 10 at a time and 加载更多 sends the cursor", async () => {
const { bindCurrentSubjectAccount } = await import("../src/lib/current-subject.ts");
bindCurrentSubjectAccount("fictional-report-owner", []);
const fetchBefore = globalThis.fetch; const urls: string[] = [];
globalThis.fetch = (async (url: RequestInfo | URL) => { urls.push(String(url)); return Response.json({ reports: [report(1)], nextCursor: "2026-09-29T00:00:00.000Z," + uuid(1) }); }) as typeof fetch;
try {
invalidateReportsPage();
const first = await refreshReportsPage();
assert.equal(first.kind, "ready");
assert.equal(first.kind === "ready" ? first.nextCursor : "", "2026-09-29T00:00:00.000Z," + uuid(1));
assert.deepEqual(first.kind === "ready" ? first.reports.map(r => r.id) : [], [uuid(1)]);
const more = await fetchMoreReports("2026-09-29T00:00:00.000Z," + uuid(1));
assert.equal(more.kind, "ready");
assert.match(urls[0]!, /^\/api\/reports\?subject=self&limit=10$/);
assert.equal(new URL(urls[1]!, "http://x").searchParams.get("before"), "2026-09-29T00:00:00.000Z," + uuid(1));
assert.equal(new URL(urls[1]!, "http://x").searchParams.get("limit"), "10");
} finally { globalThis.fetch = fetchBefore; invalidateReportsPage(); }
});
test("entering 我的报告 joins the sidebar prefetch: one GET, not two (BUG-1123)", async () => {
const { bindCurrentSubjectAccount } = await import("../src/lib/current-subject.ts");
bindCurrentSubjectAccount("fictional-report-owner", []);
const h = createClientLifecycleHarness(); const fetchBefore = globalThis.fetch;
const response = deferred<Response>(); let gets = 0;
globalThis.fetch = (async (url: RequestInfo | URL) => {
if (String(url).startsWith("/api/reports")) { gets += 1; return response.promise; }
return Response.json({}, { status: 401 });
}) as typeof fetch;
try {
invalidateReportsPage();
prefetchSecondaryPage("/reports");
assert.equal(gets, 1);
await h.render(<AppRouterContext.Provider value={router}><SidebarProvider open><PersonalReportCenter /></SidebarProvider></AppRouterContext.Provider>);
await h.idle();
response.resolve(Response.json({ reports: [report(1)], nextCursor: null }));
await h.idle();
assert.equal(gets, 1, "the in-flight prefetch is reused");
assert.equal(peekReportsPage()?.kind, "ready");
assert.match(h.container.text, /标准深度/);
assert.deepEqual(h.errors, []);
} finally { globalThis.fetch = fetchBefore; invalidateReportsPage(); await h.close(); }
});
test("加载更多 appends the next 10, says 加载中 while waiting and disappears at the end", async () => {
const { bindCurrentSubjectAccount } = await import("../src/lib/current-subject.ts");
bindCurrentSubjectAccount("fictional-report-owner", []);
const h = createClientLifecycleHarness(); const fetchBefore = globalThis.fetch;
const firstPage = Array.from({ length: 10 }, (_, i) => report(i + 1));
const secondPage = [report(11), report(12)];
const more = deferred<Response>(); const befores: (string | null)[] = [];
writeReportsPage({ kind: "ready", reports: firstPage, nextCursor: "c10" });
globalThis.fetch = (async (url: RequestInfo | URL) => {
const u = new URL(String(url), "http://x");
if (u.pathname === "/api/reports") {
befores.push(u.searchParams.get("before"));
if (u.searchParams.get("before")) return more.promise;
return Response.json({ reports: firstPage, nextCursor: "c10" });
}
return Response.json({}, { status: 401 });
}) as typeof fetch;
try {
await h.render(<AppRouterContext.Provider value={router}><SidebarProvider open><PersonalReportCenter /></SidebarProvider></AppRouterContext.Provider>);
await h.idle();
assert.equal(h.elements().filter(n => n.tagName === "ARTICLE").length, 10);
const [button] = buttons(h, "加载更多");
assert.ok(button, "加载更多 is under the list while more exist");
await h.event(button);
assert.equal(buttons(h, "加载中").length, 1);
more.resolve(Response.json({ reports: secondPage, nextCursor: null }));
await h.idle();
assert.deepEqual(befores, [null, "c10"]);
assert.equal(h.elements().filter(n => n.tagName === "ARTICLE").length, 12);
assert.equal(buttons(h, "加载更多").length, 0, "hidden when there is nothing older");
assert.deepEqual(h.errors, []);
} finally { globalThis.fetch = fetchBefore; invalidateReportsPage(); await h.close(); }
});
test("/reports is a static shell and the button has a rule", () => {
assert.doesNotMatch(page, /force-dynamic/);
assert.match(css, /\.report-center-more \{[^}]*margin: var\(--space-4\) auto 0/);
assert.match(css, /@media \(max-width: 767px\) \{\s*\.report-center-more \{ width: 100%; \}/);
});