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
272 lines
13 KiB
TypeScript
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;/);
|
|
});
|