Files
Jyotisha/frontend/tests/personal-report-progress.test.ts
T
jesse-uxGrok (agent loop)Cursor
e8793bb0d2 fix(synastry): fill section evidence and stop the English job from sticking
The review found empty sections and an English job that stayed running. Take evidence from reader briefs, retry one section, and settle before marking the report ready.

Co-Authored-By: Grok (agent loop)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-19 12:55:17 +08:00

270 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",
);
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(describeReportProgress({ phase: "computing_charts" }).headline, "正在排两人的星盘");
assert.equal(describeReportProgress({ phase: "writing_report" }).headline, "正在写关系画像");
assert.equal(describeReportProgress({ phase: "checking_report" }).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);
// 原值: document-view 源码必须 import reportThemeLabel,且不得再声明 const THEME_LABELS
// 新值: 删掉这两条源码断言。上面的 reportThemeLabel / REPORT_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;/);
});