Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01N4f2nya58RoRu4yEmJgRGE
178 lines
11 KiB
TypeScript
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%; \}/);
|
|
});
|