Files
Jyotisha/frontend/tests/report-polling-contract.test.ts
T
Jesse_Chen 0ae3e2d796
Independent Staging Quality Gate / validate (push) Has been cancelled
Independent Staging Quality Gate / publish (push) Has been cancelled
feat(reports): generate personal reports by section
2026-08-30 22:59:17 +08:00

150 lines
7.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
import {
formatWaitedDuration,
POLL_BUDGET_MS,
pollIntervalForElapsed,
} from "../src/components/personal-report/personal-report-page.tsx";
const pageSource = readFileSync(
new URL("../src/components/personal-report/personal-report-page.tsx", import.meta.url),
"utf8",
);
const centerSource = readFileSync(
new URL("../src/components/personal-report/personal-report-center.tsx", import.meta.url),
"utf8",
);
const hookSource = readFileSync(
new URL("../src/hooks/use-visibility-aware-poll.ts", import.meta.url),
"utf8",
);
test("an exhausted poll budget becomes an explicit state instead of an endless spinner", () => {
assert.match(pageSource, /\| \{ phase: "timed-out" \}/, "ReportLoadState carries an explicit timed-out phase");
assert.match(pageSource, /setState\(\(current\) => \(current\.phase === "generating" \? \{ phase: "timed-out" \} : current\)\)/);
assert.match(pageSource, /if \(state\.phase === "timed-out"\)/, "the timed-out phase has its own render branch");
// The old silent give-up must be gone.
assert.doesNotMatch(pageSource, /MAX_POLLS/);
assert.doesNotMatch(pageSource, /polls >= /);
});
test("the timed-out branch is honest and offers both resume and the report center", () => {
const branchAt = pageSource.indexOf('if (state.phase === "timed-out")');
assert.ok(branchAt >= 0, "timed-out branch must exist");
const branchEnd = pageSource.indexOf('if (state.phase === "unauthorized")');
assert.ok(branchEnd > branchAt, "the timed-out branch must be self-contained");
const branch = pageSource.slice(branchAt, branchEnd);
assert.match(branch, /生成时间超出预期/);
assert.match(branch, /已等待 \{formatWaitedDuration\(waitedMs\)\}/);
assert.match(branch, /页面已暂停自动刷新。/, "we must say polling stopped rather than imply it continues");
assert.match(branch, /报告仍在后台生成/);
assert.match(branch, /继续等待/);
assert.match(branch, /onClick=\{\(\) => keepWaiting\(\)\}/);
assert.match(branch, /href="\/reports"/);
assert.match(branch, /返回报告中心/);
assert.doesNotMatch(branch, /[!]/, "copy carries no exclamation marks");
});
test("keepWaiting restarts the wait clock and immediately refetches", () => {
const resumeAt = pageSource.indexOf("const keepWaiting = useCallback(");
assert.ok(resumeAt >= 0);
const resume = pageSource.slice(resumeAt, resumeAt + 320);
assert.match(resume, /setWaitStartedAt\(Date\.now\(\)\)/);
assert.match(resume, /setWaitedMs\(0\)/);
assert.match(resume, /setState\(\{ phase: "generating" \}\)/);
assert.match(resume, /void load\(\)/);
});
test("the wait budget is an explicit wall clock well beyond the old 120s", () => {
assert.equal(POLL_BUDGET_MS, 8 * 60 * 1000);
assert.ok(POLL_BUDGET_MS > 120_000, "120s was too short for report generation");
assert.match(pageSource, /export const POLL_BUDGET_MS = 8 \* 60 \* 1000;/);
});
test("polling backs off without slowing the first minute", () => {
assert.equal(pollIntervalForElapsed(0), 3000);
assert.equal(pollIntervalForElapsed(59_999), 3000);
assert.equal(pollIntervalForElapsed(60_000), 6000);
assert.equal(pollIntervalForElapsed(179_999), 6000);
assert.equal(pollIntervalForElapsed(180_000), 10_000);
assert.equal(pollIntervalForElapsed(360_000), 15_000);
assert.equal(pollIntervalForElapsed(POLL_BUDGET_MS), 15_000);
let previous = 0;
for (let elapsed = 0; elapsed <= POLL_BUDGET_MS; elapsed += 1000) {
const interval = pollIntervalForElapsed(elapsed);
assert.ok(interval >= previous, `interval must never shrink at ${elapsed}ms`);
assert.ok(interval >= 3000 && interval <= 15_000, `interval stays within 3s..15s at ${elapsed}ms`);
previous = interval;
}
// A full budget must cost far fewer requests than a flat 3s interval would.
let requests = 0;
for (let elapsed = 0; elapsed < POLL_BUDGET_MS; elapsed += pollIntervalForElapsed(elapsed)) {
requests += 1;
}
assert.ok(requests < POLL_BUDGET_MS / 3000, "backoff must reduce the request count");
assert.ok(requests < 80, `a full wait should stay under 80 requests, got ${requests}`);
assert.match(pageSource, /intervalMs: pollIntervalForElapsed\(waitedMs\)/);
});
test("elapsed wait is rendered in Simplified Chinese minutes and seconds", () => {
assert.equal(formatWaitedDuration(0), "0 秒");
assert.equal(formatWaitedDuration(9_400), "9 秒");
assert.equal(formatWaitedDuration(60_000), "1 分 0 秒");
assert.equal(formatWaitedDuration(130_000), "2 分 10 秒");
assert.equal(formatWaitedDuration(-5), "0 秒");
assert.match(pageSource, /已等待 \{formatWaitedDuration\(waitedMs\)\}/);
// Original assertion required the fixed generating copy. Task 4 intentionally
// replaces it with phase-aware progress text while retaining the spinner.
assert.match(pageSource, /<InlineSpinner className="text-primary" size=\{32\} \/>/);
assert.match(pageSource, /generating \? \(progressLabel \?\? "报告正在生成中,请稍候…"\)/);
assert.match(pageSource, /已等待 \{formatWaitedDuration\(waitedMs\)\}/);
});
test("the shared hook pauses on hidden, refreshes on visible and always cleans up", () => {
assert.match(hookSource, /export function useVisibilityAwarePoll/);
assert.match(hookSource, /document\.hidden/);
assert.match(hookSource, /document\.addEventListener\("visibilitychange", handleVisibilityChange\)/);
assert.match(hookSource, /document\.removeEventListener\("visibilitychange", handleVisibilityChange\)/);
assert.match(hookSource, /if \(document\.hidden\) \{\s*stop\(\);\s*return;\s*\}/);
assert.match(hookSource, /if \(refreshOnVisible\) \{\s*pollRef\.current\(\);\s*\}/);
assert.match(hookSource, /window\.clearInterval\(timer\)/);
// The callback lives in a ref so an inline closure cannot restart the interval every render.
assert.match(hookSource, /const pollRef = useRef\(onPoll\)/);
assert.match(hookSource, /pollRef\.current = onPoll;/);
assert.match(hookSource, /\}, \[enabled, intervalMs, refreshOnVisible\]\);/);
// Reusable: no report-specific imports.
assert.doesNotMatch(hookSource, /personal-report|\/api\/reports/);
});
test("both report surfaces poll through the visibility-aware hook and nothing else", () => {
for (const [name, source] of [["page", pageSource], ["center", centerSource]] as const) {
assert.match(
source,
/import \{ useVisibilityAwarePoll \} from "@\/hooks\/use-visibility-aware-poll";/,
`${name} imports the shared hook`,
);
assert.match(source, /useVisibilityAwarePoll\(\{/, `${name} calls the shared hook`);
assert.doesNotMatch(source, /setInterval\(/, `${name} must not hand-roll an interval poll`);
assert.doesNotMatch(source, /addEventListener\("visibilitychange"/, `${name} must not duplicate the listener`);
}
assert.match(centerSource, /enabled: hasGenerating,/);
assert.match(centerSource, /intervalMs: LIST_POLL_INTERVAL_MS,/);
assert.match(centerSource, /const LIST_POLL_INTERVAL_MS = 3000;/);
assert.match(pageSource, /enabled: generating,/);
});
test("cancellation guards survive the rewrite and no poll runs outside the generating phase", () => {
assert.match(pageSource, /const cancelledRef = useRef\(false\)/);
assert.match(pageSource, /if \(cancelledRef\.current\) \{\s*return;\s*\}/);
assert.match(pageSource, /cancelledRef\.current = true;/);
assert.match(centerSource, /const cancelled = useRef\(false\)/);
assert.match(centerSource, /if \(cancelled\.current\) return;/);
assert.match(centerSource, /cancelled\.current = true;/);
assert.match(pageSource, /const generating = state\.phase === "generating";/);
});