/** * 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, //); 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 : \}/); // 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;/); });