Files
Jyotisha/frontend/tests/personal-report-progress.test.ts
T
Jesse_ChenandClaude Fable 5 848e39e61f feat(reports): show chapter progress while a report is being written (BUG-601)
The worker already persists a phase ladder and durable per-section rows;
the waiting screen parsed the progress fields and rendered none of them.
Chapter progress now drives the screen: one cell per chapter rather than
a percentage bar, since the job percent jumps 0->30 and 90->100.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016P5RoqzmUQEbeC2qjAkeGr
2026-09-09 03:30:15 +00:00

272 lines
13 KiB
TypeScript

/**
* Report generation progress: derivation, copy and the render contract.
*
* The rules under test are the ones that make this feature correct rather than
* merely present — which chapter is named as "being written", that a blocked
* chapter still advances the bar, and that nothing moves on a timer.
*/
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
import {
classifyReportProgressStage,
describeReportProgress,
deriveSectionProgressState,
REPORT_PROGRESS_STALL_MS,
REPORT_THEME_LABELS,
reportProgressSignature,
reportThemeLabel,
type ReportSectionProgress,
} from "../src/lib/personal-report-progress.ts";
const pageSource = readFileSync(
new URL("../src/components/personal-report/personal-report-page.tsx", import.meta.url),
"utf8",
);
const panelSource = readFileSync(
new URL("../src/components/personal-report/personal-report-progress-panel.tsx", import.meta.url),
"utf8",
);
const routeCoreSource = readFileSync(
new URL("../src/lib/personal-report-route-core.ts", import.meta.url),
"utf8",
);
const routeSource = readFileSync(
new URL("../src/app/api/reports/[reportId]/route.ts", import.meta.url),
"utf8",
);
const documentViewSource = readFileSync(
new URL("../src/components/personal-report/personal-report-document-view.tsx", import.meta.url),
"utf8",
);
function sections(
...entries: readonly (readonly [string, ReportSectionProgress["state"]])[]
): readonly ReportSectionProgress[] {
return entries.map(([id, state]) => ({ id, state }));
}
/**
* Source with comments stripped. These assertions are about what the code
* does, not about which words the file is allowed to explain itself with.
*/
function codeOf(source: string): string {
return source.replace(/\/\*[\s\S]*?\*\//g, "").replace(/^\s*\/\/.*$/gm, "");
}
const panelCode = codeOf(panelSource);
const pageCode = codeOf(pageSource);
test("a claimed-but-unfinished chapter is the one being written, not the next one by count", () => {
// start_personal_report_section bumps attempt_count and leaves status pending.
assert.equal(deriveSectionProgressState("pending", 1), "writing");
assert.equal(deriveSectionProgressState("pending", 2), "writing");
// Untouched rows are merely queued.
assert.equal(deriveSectionProgressState("pending", 0), "waiting");
assert.equal(deriveSectionProgressState("ready", 1), "done");
assert.equal(deriveSectionProgressState("blocked", 2), "failed");
});
test("the written chapter is named from row state, so alphabetical listing cannot shift it", () => {
// list() orders by section_id ascending; write order is the plan's order.
// "career" and "health" are done, "marriage" is mid-write, and it is NOT
// the entry at index finished+1 ("timing") — counting forward would lie.
const view = describeReportProgress({
phase: "section:health",
sections: sections(
["career", "done"],
["health", "done"],
["marriage", "writing"],
["timing", "waiting"],
["wealth", "waiting"],
),
});
const writing = view.chapters.filter((chapter) => chapter.state === "writing");
assert.equal(writing.length, 1);
assert.equal(writing[0]!.id, "marriage");
assert.equal(writing[0]!.label, "婚恋");
assert.notEqual(writing[0]!.id, "timing");
});
test("the phase names the chapter that just finished, and is never rendered as the active one", () => {
// phase is section:career while career is already done and wealth is live.
const view = describeReportProgress({
phase: "section:career",
sections: sections(["career", "done"], ["wealth", "writing"]),
});
const career = view.chapters.find((chapter) => chapter.id === "career");
assert.equal(career?.state, "done", "the phase's own chapter is finished, not in progress");
assert.equal(career?.statusText, "已完成");
// Nothing in the panel or page derives the active chapter from the phase string.
assert.doesNotMatch(panelSource, /progressPhase|section:/);
assert.doesNotMatch(pageSource, /startsWith\("section:"\)/);
});
test("a blocked chapter still advances the count; finishing is not the same as succeeding", () => {
const view = describeReportProgress({
phase: "section:wealth",
sections: sections(
["career", "done"],
["wealth", "failed"],
["marriage", "writing"],
["timing", "waiting"],
),
});
assert.equal(view.finished, 2, "done + failed both count as finished work");
assert.equal(view.total, 4);
assert.equal(view.headline, "已完成 2 / 4 章");
assert.equal(view.hasFailure, true);
const failed = view.chapters.find((chapter) => chapter.id === "wealth");
assert.equal(failed?.statusText, "写作失败");
});
test("an all-ready run reports every chapter done and no failure", () => {
const view = describeReportProgress({
phase: "section:timing",
sections: sections(["career", "done"], ["timing", "done"]),
});
assert.equal(view.finished, 2);
assert.equal(view.total, 2);
assert.equal(view.hasFailure, false);
assert.equal(view.headline, "已完成 2 / 2 章");
});
test("a stalled chapter says it is slow without claiming to know the attempt", () => {
const live = sections(["career", "done"], ["wealth", "writing"]);
const calm = describeReportProgress({ phase: "section:career", sections: live });
assert.equal(calm.chapters.find((chapter) => chapter.id === "wealth")?.statusText, "正在写");
const slow = describeReportProgress({ phase: "section:career", sections: live, stalled: true });
const wealth = slow.chapters.find((chapter) => chapter.id === "wealth");
assert.equal(wealth?.statusText, "用时较长,仍在写");
// attemptCount never reaches the client, so we must not imply a count.
assert.doesNotMatch(wealth?.statusText ?? "", /第\s*\d+\s*次|重试|尝试/);
// Finished chapters are unaffected by the stall.
assert.equal(slow.chapters.find((chapter) => chapter.id === "career")?.statusText, "已完成");
assert.equal(REPORT_PROGRESS_STALL_MS, 90_000);
});
test("the three stages come from the worker's own phase ladder", () => {
assert.equal(classifyReportProgressStage("queued"), "preparing");
assert.equal(classifyReportProgressStage("loading_context"), "preparing");
assert.equal(classifyReportProgressStage("generating_report"), "preparing");
assert.equal(classifyReportProgressStage("section:career"), "writing");
assert.equal(classifyReportProgressStage("persisting_report"), "finishing");
assert.equal(classifyReportProgressStage(undefined), "preparing");
// retry_wait and suspended are job-level waits, not chapter progress.
assert.equal(classifyReportProgressStage("retry_wait"), "preparing");
assert.equal(classifyReportProgressStage("suspended"), "preparing");
});
test("each stage renders its own copy and never invents a chapter count", () => {
const preparing = describeReportProgress({ phase: "loading_context" });
assert.equal(preparing.stage, "preparing");
assert.equal(preparing.headline, "正在准备你的星盘证据");
assert.equal(preparing.chapters.length, 0);
assert.equal(preparing.total, 0);
const finishing = describeReportProgress({
phase: "persisting_report",
sections: sections(["career", "done"]),
});
assert.equal(finishing.stage, "finishing");
assert.equal(finishing.headline, "正在整理成文");
// A writing phase whose section rows are missing must fall back to
// preparing rather than render "已完成 0 / 0 章".
const noRows = describeReportProgress({ phase: "section:career" });
assert.equal(noRows.stage, "preparing");
assert.equal(noRows.headline, "正在准备你的星盘证据");
});
test("the progress signature changes only when observable progress changes", () => {
const base = { phase: "section:career", percent: 41, sections: sections(["career", "done"], ["wealth", "writing"]) };
assert.equal(reportProgressSignature(base), reportProgressSignature({ ...base }));
// A retry leaves phase, percent and every chapter state untouched: the
// signature holds, which is what eventually trips the slow notice.
assert.equal(
reportProgressSignature(base),
reportProgressSignature({ ...base, sections: sections(["career", "done"], ["wealth", "writing"]) }),
);
assert.notEqual(
reportProgressSignature(base),
reportProgressSignature({ ...base, sections: sections(["career", "done"], ["wealth", "done"]) }),
);
assert.notEqual(reportProgressSignature(base), reportProgressSignature({ ...base, percent: 52 }));
});
test("chapter labels are the shared report theme labels, defined once", () => {
assert.equal(reportThemeLabel("career"), "事业");
assert.equal(reportThemeLabel("marriage"), "婚恋");
assert.equal(reportThemeLabel("timing"), "应期");
// Unknown ids degrade to the raw id rather than a fabricated name.
assert.equal(reportThemeLabel("unlisted_theme"), "unlisted_theme");
assert.ok(Object.keys(REPORT_THEME_LABELS).length >= 7);
// The document view must consume the shared map, not keep a second copy.
assert.match(documentViewSource, /import \{ reportThemeLabel \} from "@\/lib\/personal-report-progress";/);
assert.doesNotMatch(documentViewSource, /const THEME_LABELS/);
});
test("the bar is one cell per chapter, not a percentage, and nothing animates on a timer", () => {
assert.match(panelSource, /progress\.chapters\.map/);
// A cell per chapter means the bar and the list cannot disagree.
assert.match(panelSource, /className="report-progress-track"/);
assert.doesNotMatch(panelCode, /progressPercent|percent|width:|style=/);
assert.doesNotMatch(panelCode, /setInterval|setTimeout|requestAnimationFrame|transition|animate/);
// The page must not smooth or extrapolate either.
assert.doesNotMatch(pageCode, /setInterval\(/);
// No estimated time remaining anywhere.
assert.doesNotMatch(panelCode + pageCode, /预计|剩余|大约还/);
});
test("the waiting screen keeps its spinner and elapsed clock, and swaps in chapters only while writing", () => {
assert.match(pageSource, /<InlineSpinner className="text-primary" size=\{32\} \/>/);
assert.match(pageSource, /PERSONAL_REPORT_GENERATING_COPY/);
assert.match(pageSource, /已等待 \{formatWaitedDuration\(waitedMs\)\}/);
assert.match(pageSource, /const writing = progress\?\.stage === "writing";/);
assert.match(pageSource, /\{writing \? null : <InlineSpinner/);
assert.match(pageSource, /\{progress && writing && <PersonalReportProgressPanel progress=\{progress\} \/>\}/);
// The chapter list lives in its own component; the page stays free of it.
assert.doesNotMatch(pageSource, /章节/);
});
test("the stall clock rides the existing tick instead of a second timer or an effect", () => {
assert.match(pageSource, /\(waitStartedAt \+ waitedMs\) - progressMark\.at >= REPORT_PROGRESS_STALL_MS/);
// Mark updates happen in the fetch callback, never in render or an effect
// (the react-hooks lint rule rejects sync setState inside effects).
assert.match(pageSource, /setProgressMark\(\(mark\) => \(/);
assert.match(pageSource, /mark && mark\.signature === signature \? mark : \{ signature, at: Date\.now\(\) \}/);
// Leaving the generating phase clears the mark so a resumed wait restarts clean.
assert.match(pageSource, /setProgressMark\(null\);/);
});
test("only chapter id and state cross the wire; backend bookkeeping stays server-side", () => {
// The route reads attemptCount, the core maps it, and neither forwards it.
assert.match(routeSource, /attemptCount: row\.attemptCount/);
assert.match(routeCoreSource, /deriveSectionProgressState\(section\.status, section\.attemptCount \?\? 0\)/);
assert.match(routeCoreSource, /id: section\.sectionId,/);
// The client-side shape carries nothing else.
assert.doesNotMatch(pageSource, /attemptCount|maxAttempts|lastErrorCode|leaseToken|jobId/);
assert.doesNotMatch(panelSource, /attemptCount|maxAttempts|lastErrorCode|leaseToken|jobId|payload/);
});
test("section rows are read for generating and failed reports, never on the ready path", () => {
assert.match(
routeCoreSource,
/\(row\.status === "failed" \|\| row\.status === "generating"\) && deps\.listSections/,
);
assert.match(routeCoreSource, /const sectionProgress: readonly ReportSectionProgress\[\] \| null = row\.status === "generating"/);
// The generating/failed response is the one that carries them.
assert.match(routeCoreSource, /reportView\(row, job, failure, sectionProgress\)/);
// reportView omits the key entirely when there is nothing to report.
assert.match(routeCoreSource, /\.\.\.\(sections && sections\.length > 0 \? \{ sections \} : \{\}\)/);
});
test("malformed chapter rows are dropped rather than rendered", () => {
assert.match(pageSource, /function readSectionProgress/);
assert.match(pageSource, /const known = SECTION_PROGRESS_STATES\.find\(\(candidate\) => candidate === state\);/);
assert.match(pageSource, /return parsed\.length > 0 \? parsed : undefined;/);
});