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
This commit is contained in:
Jesse_Chen
2026-09-09 03:30:15 +00:00
co-authored by Claude Fable 5
parent 301eae4b76
commit 848e39e61f
9 changed files with 684 additions and 18 deletions
+21
View File
@@ -478,5 +478,26 @@ Agent 的 live 标记只有 `InlineSpinner` 一种。曾经并存的 canvas 小
首页只揭幕一次。揭幕前的加载屏分两阶段:先取账户、模型目录与会话列表,再并行取当前会话消息、推荐问题、今日星语与校正入口摘要,并预热校正分包;全部就绪或 4 秒预算到期(`BOOTSTRAP_PREPARE_TIMEOUT_MS`)才揭幕。揭幕后不得再出现任何阻塞等待或组件级 spinner:推荐问题未到显示安全默认问题,今日星语未到显示静态文案「今天的星语还没写出来。」(不带 `aria-busy`),校正卡用无摘要文案,内容到达后静默替换。切换到消息尚未缓存的会话时消息区留白并只给 `sr-only` 文案,不转圈;揭幕后按侧栏顺序后台预取最近 5 条会话(`SESSION_PREFETCH_COUNT`)让常见切换零等待。轨道环消失后不得再换一套动效继续等。
### 报告生成等待态
个人报告要写几分钟,属于"等到有实质进度可报"的一类,因此在上表三类之外单独规定:**只有准备阶段用 `InlineSpinner`,进入写作阶段后换成分章进度,不再转圈。**
三个阶段跟随 worker 自己的 phase 阶梯(`queued` / `loading_context` / `generating_report` → `section:<id>` → `persisting_report`):
| 阶段 | 屏幕上 | 文案 |
| --- | --- | --- |
| 准备 | `InlineSpinner` | 正在准备你的星盘证据 |
| 写作 | 分章进度条 + 章节清单,无 spinner | 已完成 N / M 章 |
| 收尾 | `InlineSpinner` | 正在整理成文 |
硬规定:
- **进度条按章分格,一格一章,不画百分比。** job 的 percent 在 0→30 和 90→100 是瞬间跳变,只有中段跟随真实工作量;画成线性条等于演出后端没做的动作。格数恒等于章数,条与清单不可能互相矛盾。
- **不做时间插值,不显示预计剩余时间。** 界面上没有任何按定时器推进的东西;后端没动,屏幕就不动。章节耗时被重试放大到两倍以上,报不准比不报更伤。
- **正在写的那一章由行状态判定,不由 phase 名判定。** `section:<id>` 命名的是刚写完的那一章;且章节列表按 `section_id` 字典序返回,不是写作顺序,所以"已完成数 + 1"也会指错。唯一正确的判据是 `pending` 且已被认领。
- **停滞满 90 秒**(`REPORT_PROGRESS_STALL_MS`)当前章文案改为「用时较长,仍在写」。不写第几次尝试——`attemptCount` 不出服务端。
- **写作失败的章仍然计入完成数**,进度条照常前进;走到头不等于全部成功,收尾按 `summarizePersonalReportFailure()` 的白名单文案说明。
- 可显示的只有章节名与章节状态(用户交付物的结构)。`attemptCount`、原始错误码、lease、job id、payload 一律不出服务端——与 BUG-043 的边界一致:那条禁止的是把后台评分状态渲染成用户要管理的面板,本处是只读等待屏上的交付物结构。
Admin 的 antd `<Spin>` 是独立设计系统,不在此表。
@@ -57,9 +57,16 @@ async function resolvePersistenceForUser() {
userId: user.id,
persistence,
jobs: createSupabasePersonalReportJobService(supabase),
listSections: async (ownerId: string, requestId: string) => {
// sectionId and attemptCount are consumed by resolveReportRead to derive
// reader-visible chapter state; neither is forwarded to the browser raw.
listSections: async (ownerId: string, requestId: string) => {
const rows = await sections.list(ownerId, requestId);
return rows.map((row) => ({ status: row.status, lastErrorCode: row.lastErrorCode }));
return rows.map((row) => ({
sectionId: row.sectionId,
status: row.status,
attemptCount: row.attemptCount,
lastErrorCode: row.lastErrorCode,
}));
},
loadLongformAppendix: async (input: Readonly<{ userId: string; reportId: string }>) => {
const appendixRead = await supabase
+59
View File
@@ -3775,6 +3775,65 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class
text-wrap: pretty;
}
/* Report generation progress: one cell per chapter, never a percentage.
See frontend/DESIGN.md · 报告生成等待态. */
.personal-report-state p.personal-report-progress-headline {
color: var(--color-ink);
font-family: var(--font-display);
font-size: var(--type-display-sm);
font-weight: 400;
letter-spacing: -.3px;
font-variant-numeric: tabular-nums;
}
.report-progress {
display: flex;
flex-direction: column;
gap: var(--space-4);
width: min(20rem, 100%);
}
.report-progress-track {
display: flex;
gap: 4px;
margin: 0;
padding: 0;
list-style: none;
}
.report-progress-track li {
flex: 1 1 0;
height: 4px;
border-radius: 2px;
background: var(--color-border);
}
.report-progress-track li[data-state="done"] { background: var(--color-ink); }
.report-progress-track li[data-state="failed"] { background: var(--color-ink-tertiary); }
.report-progress-track li[data-state="writing"] { background: var(--color-ink-secondary); }
.report-progress-chapters {
display: flex;
flex-direction: column;
gap: var(--space-2);
margin: 0;
padding: 0;
list-style: none;
font-size: var(--type-body-sm);
line-height: 1.5;
}
.report-progress-chapters li {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: var(--space-4);
color: var(--color-ink-secondary);
}
.report-progress-chapters li[data-state="done"] { color: var(--color-ink); }
.report-progress-chapters li[data-state="writing"] { color: var(--color-ink); }
.report-progress-chapter-name { text-align: left; }
.report-progress-chapter-status {
color: var(--color-ink-tertiary);
text-align: right;
white-space: nowrap;
}
.report-progress-chapters li[data-state="waiting"] { color: var(--color-ink-tertiary); }
.personal-report-document {
width: min(900px, 100%);
margin: var(--space-8) auto 0;
@@ -25,6 +25,7 @@ import {
type ReportDocument,
type ReportDocumentV2,
} from "@/lib/personal-report-contract";
import { reportThemeLabel } from "@/lib/personal-report-progress";
const CLAIM_STATUS_LABELS: Record<ClaimStatus, string> = {
multi_system_consensus: "多系统一致",
@@ -61,15 +62,6 @@ const DEPTH_LABELS: Record<ReportDocumentV2["depth"], string> = {
research: "研究",
};
const THEME_LABELS: Record<string, string> = {
general: "综合",
career: "事业",
wealth: "财富",
marriage: "婚恋",
health: "健康",
education: "教育",
timing: "应期",
};
const ACTION_PRIORITY_LABELS: Record<ReportDocumentV2["actionNotes"][number]["priority"], string> = {
now: "现在",
@@ -152,7 +144,7 @@ function planetPlacement(chart: ChartV1 | undefined, aliases: readonly string[])
}
function themeLabel(theme: string): string {
return THEME_LABELS[theme] ?? theme;
return reportThemeLabel(theme);
}
function chartEvidenceRefs(chart: ReportChart): readonly string[] {
@@ -19,10 +19,18 @@ import { InlineSpinner } from "@/components/inline-spinner";
import { ReportActions } from "./report-actions";
import { PersonalReportMarkdownView } from "./personal-report-markdown-view";
import { PersonalReportProgressPanel } from "./personal-report-progress-panel";
import {
PERSONAL_REPORT_GENERATING_COPY,
PERSONAL_REPORT_LEGACY_PLACEHOLDER,
} from "@/lib/personal-report-longform-copy";
import {
describeReportProgress,
REPORT_PROGRESS_STALL_MS,
reportProgressSignature,
type ReportSectionProgress,
type ReportSectionProgressState,
} from "@/lib/personal-report-progress";
import { Button } from "@/components/ui/button";
import { useVisibilityAwarePoll } from "@/hooks/use-visibility-aware-poll";
@@ -30,7 +38,12 @@ export type ReportLoadState =
| { phase: "loading" }
| { phase: "unauthorized" }
| { phase: "not-found" }
| { phase: "generating"; progressPercent?: number; progressPhase?: string }
| {
phase: "generating";
progressPercent?: number;
progressPhase?: string;
sections?: readonly ReportSectionProgress[];
}
| { phase: "timed-out" }
| { phase: "failed"; failureCode: string | null; failureSummary?: string | null; appendixLastErrorCode?: string | null }
| { phase: "invalid"; message: string }
@@ -52,6 +65,31 @@ export interface ReportEnvelopeView {
completedAt: string | null;
progressPercent?: number;
progressPhase?: string;
sections?: readonly ReportSectionProgress[];
}
const SECTION_PROGRESS_STATES: readonly ReportSectionProgressState[] = [
"done",
"failed",
"writing",
"waiting",
];
/**
* Chapter rows are only trusted in the shape the route promises; anything else
* is dropped rather than rendered as a mystery row.
*/
function readSectionProgress(value: unknown): readonly ReportSectionProgress[] | undefined {
if (!Array.isArray(value)) return undefined;
const parsed = value.flatMap((entry) => {
if (!isRecord(entry)) return [];
const { id, state } = entry;
if (typeof id !== "string" || id.length === 0) return [];
if (typeof state !== "string") return [];
const known = SECTION_PROGRESS_STATES.find((candidate) => candidate === state);
return known ? [{ id, state: known }] : [];
});
return parsed.length > 0 ? parsed : undefined;
}
/**
@@ -96,10 +134,12 @@ export function classifyReportEnvelope(statusCode: number, json: unknown): Repor
case "generating": {
const progressPercent = typeof view.progressPercent === "number" ? view.progressPercent : undefined;
const progressPhase = typeof view.progressPhase === "string" ? view.progressPhase : undefined;
const sections = readSectionProgress(view.sections);
return {
phase: "generating",
...(progressPercent === undefined ? {} : { progressPercent }),
...(progressPhase === undefined ? {} : { progressPhase }),
...(sections === undefined ? {} : { sections }),
};
}
case "failed": {
@@ -152,6 +192,10 @@ export function PersonalReportPage({ reportId }: { reportId: string }) {
const [state, setState] = useState<ReportLoadState>({ phase: "loading" });
const [waitStartedAt, setWaitStartedAt] = useState<number | null>(null);
const [waitedMs, setWaitedMs] = useState(0);
// When observable progress last changed. Only a real change to the phase,
// percent or chapter states moves this, so a chapter being retried keeps
// the old mark and eventually trips the slow notice.
const [progressMark, setProgressMark] = useState<{ signature: string; at: number } | null>(null);
const cancelledRef = useRef(false);
const load = useCallback(() => {
@@ -170,9 +214,18 @@ export function PersonalReportPage({ reportId }: { reportId: string }) {
const next = classifyReportEnvelope(response.status, json);
if (next.phase === "generating") {
setWaitStartedAt((startedAt) => startedAt ?? Date.now());
const signature = reportProgressSignature({
phase: next.progressPhase,
percent: next.progressPercent,
sections: next.sections,
});
setProgressMark((mark) => (
mark && mark.signature === signature ? mark : { signature, at: Date.now() }
));
} else {
setWaitStartedAt(null);
setWaitedMs(0);
setProgressMark(null);
}
setState(next);
})
@@ -191,6 +244,7 @@ export function PersonalReportPage({ reportId }: { reportId: string }) {
const keepWaiting = useCallback(() => {
setWaitStartedAt(Date.now());
setWaitedMs(0);
setProgressMark(null);
setState({ phase: "generating" });
void load();
}, [load]);
@@ -229,12 +283,27 @@ export function PersonalReportPage({ reportId }: { reportId: string }) {
});
if (state.phase === "loading" || state.phase === "generating") {
// The stall clock rides the existing one-second tick: waitStartedAt +
// waitedMs is "now" without a second timer or an effect.
const stalled = state.phase === "generating"
&& waitStartedAt !== null
&& progressMark !== null
&& (waitStartedAt + waitedMs) - progressMark.at >= REPORT_PROGRESS_STALL_MS;
const progress = state.phase === "generating"
? describeReportProgress({
phase: state.progressPhase,
sections: state.sections,
stalled,
})
: null;
const writing = progress?.stage === "writing";
return (
<main className="personal-report-state">
<InlineSpinner className="text-primary" size={32} />
<p role="status">
{generating ? PERSONAL_REPORT_GENERATING_COPY : "正在加载报告…"}
{writing ? null : <InlineSpinner className="text-primary" size={32} />}
<p role="status" className={writing ? "personal-report-progress-headline" : undefined}>
{generating ? (progress?.headline ?? PERSONAL_REPORT_GENERATING_COPY) : "正在加载报告…"}
</p>
{progress && writing && <PersonalReportProgressPanel progress={progress} />}
{generating && (
<>
<p>已等待 {formatWaitedDuration(waitedMs)}</p>
@@ -0,0 +1,35 @@
/**
* Chapter progress for a report that is still being written.
*
* One cell per chapter — the bar is the chapter list in compact form, so the
* two can never disagree. Deliberately not a percentage bar: the job's percent
* jumps 0→30 and 90→100 in single steps, and only the middle band tracks real
* work, so a linear percentage would read as motion the backend has not made.
*
* Nothing animates on a timer. A chapter under retry looks stalled here
* because it is stalled.
*/
"use client";
import type { ReportProgressView } from "@/lib/personal-report-progress";
export function PersonalReportProgressPanel({ progress }: { progress: ReportProgressView }) {
return (
<div className="report-progress">
<ol className="report-progress-track" aria-hidden="true">
{progress.chapters.map((chapter) => (
<li key={chapter.id} data-state={chapter.state} />
))}
</ol>
<ul className="report-progress-chapters">
{progress.chapters.map((chapter) => (
<li key={chapter.id} data-state={chapter.state}>
<span className="report-progress-chapter-name">{chapter.label}</span>
<span className="report-progress-chapter-status">{chapter.statusText}</span>
</li>
))}
</ul>
</div>
);
}
@@ -0,0 +1,183 @@
/**
* Personal-report generation progress: the shared vocabulary between the API
* route (which reads durable section rows) and the waiting screen.
*
* The waiting screen exists because a full report takes minutes. Everything
* here is derived from state the worker already persists — nothing is
* interpolated, smoothed, or extrapolated. If the backend has not moved, the
* screen does not move either.
*
* Privacy boundary, drawn against BUG-043 (a rectification surface that
* re-rendered backend evidence state as a panel the user had to manage):
* chapter names and per-chapter state are the reader's own deliverable
* structure on a read-only waiting screen, so they may be shown. Attempt
* counts, raw error codes, lease tokens, job ids and payloads are backend
* state and must never cross into the response.
* `deriveSectionProgressState` is the one place attemptCount is read, and it
* runs on the server.
*/
/** Chapter titles shown to the reader; shared with the report document view. */
export const REPORT_THEME_LABELS: Readonly<Record<string, string>> = {
general: "综合",
career: "事业",
wealth: "财富",
marriage: "婚恋",
health: "健康",
education: "教育",
timing: "应期",
};
export function reportThemeLabel(theme: string): string {
return REPORT_THEME_LABELS[theme] ?? theme;
}
/**
* Per-chapter state as the reader sees it.
* - `done` section row is `ready`
* - `failed` section row is `blocked` (attempts exhausted); still counts as
* finished work, so the bar advances rather than stalling
* - `writing` row is `pending` but has been claimed at least once
* - `waiting` row is `pending` and untouched
*/
export type ReportSectionProgressState = "done" | "failed" | "writing" | "waiting";
export type ReportSectionProgress = Readonly<{
id: string;
state: ReportSectionProgressState;
}>;
/**
* Server-side mapping from a durable section row to reader-visible state.
*
* `start_personal_report_section` bumps `attempt_count` and leaves `status`
* at `pending`, so a claimed-but-unfinished chapter is exactly
* `pending && attemptCount > 0`. That makes "which chapter is being written"
* an observed fact rather than an inference from the completed count — the
* section list arrives ordered by section_id, not by write order, so counting
* forward from the finished chapters would name the wrong one.
*/
export function deriveSectionProgressState(
status: string,
attemptCount: number,
): ReportSectionProgressState {
if (status === "ready") return "done";
if (status === "blocked") return "failed";
return attemptCount > 0 ? "writing" : "waiting";
}
/**
* Stage of the generation run, read from the job's progress phase.
* The worker ladder is queued(0) → loading_context(10) → generating_report(30)
* → section:<id>(30..85, one step per finished chapter) → persisting_report(90)
* → ready(100).
*/
export type ReportProgressStage = "preparing" | "writing" | "finishing";
export function classifyReportProgressStage(phase: string | undefined): ReportProgressStage {
if (phase === "persisting_report") return "finishing";
if (phase !== undefined && phase.startsWith("section:")) return "writing";
return "preparing";
}
/**
* How long one unchanged progress signature may last before the screen says
* so. A chapter that fails is retried (max_attempts 2) with no change to the
* job's phase or percent, so this is the only signal the reader gets that a
* chapter is taking two passes. We say it is slow; we do not claim to know
* which attempt it is, because attemptCount never reaches the client.
*/
export const REPORT_PROGRESS_STALL_MS = 90_000;
export type ReportProgressChapter = Readonly<{
id: string;
label: string;
state: ReportSectionProgressState;
statusText: string;
}>;
export type ReportProgressView = Readonly<{
stage: ReportProgressStage;
/** Sentence for the status line; always present. */
headline: string;
/** Empty until the run reaches the writing stage. */
chapters: readonly ReportProgressChapter[];
finished: number;
total: number;
/** True when at least one chapter is blocked; the run can still finish. */
hasFailure: boolean;
}>;
const STATE_TEXT: Readonly<Record<ReportSectionProgressState, string>> = {
done: "已完成",
failed: "写作失败",
writing: "正在写",
waiting: "待写",
};
/**
* Build the reader-facing view. `stalled` is passed in rather than computed
* from a clock so this stays a pure function the tests can drive.
*/
export function describeReportProgress(input: Readonly<{
phase?: string;
sections?: readonly ReportSectionProgress[];
stalled?: boolean;
}>): ReportProgressView {
const stage = classifyReportProgressStage(input.phase);
const sections = input.sections ?? [];
const chapters = sections.map((section) => ({
id: section.id,
label: reportThemeLabel(section.id),
state: section.state,
statusText: section.state === "writing" && input.stalled
? "用时较长,仍在写"
: STATE_TEXT[section.state],
}));
const finished = chapters.filter(
(chapter) => chapter.state === "done" || chapter.state === "failed",
).length;
const total = chapters.length;
const hasFailure = chapters.some((chapter) => chapter.state === "failed");
if (stage === "finishing") {
return { stage, headline: "正在整理成文", chapters, finished, total, hasFailure };
}
if (stage === "writing" && total > 0) {
return {
stage,
headline: `已完成 ${finished} / ${total} 章`,
chapters,
finished,
total,
hasFailure,
};
}
// Either the run has not reached the chapters yet, or the section rows are
// not readable this poll. Both are "preparing" as far as the reader is
// concerned; we never invent a chapter count.
return {
stage: "preparing",
headline: "正在准备你的星盘证据",
chapters: [],
finished: 0,
total: 0,
hasFailure: false,
};
}
/**
* A signature that changes exactly when observable progress changes. The
* waiting screen restarts its stall timer on a new signature, so a chapter
* being retried (phase and percent unchanged) is what trips the slow notice.
*/
export function reportProgressSignature(input: Readonly<{
phase?: string;
percent?: number;
sections?: readonly ReportSectionProgress[];
}>): string {
const sections = (input.sections ?? [])
.map((section) => `${section.id}:${section.state}`)
.join(",");
return `${input.phase ?? ""}|${input.percent ?? ""}|${sections}`;
}
+31 -2
View File
@@ -26,6 +26,10 @@ import {
} from "./personal-report-generation";
import { REPORT_STABLE_CODES } from "./personal-report-codes";
import { summarizePersonalReportFailure } from "./personal-report-failure-summary";
import {
deriveSectionProgressState,
type ReportSectionProgress,
} from "./personal-report-progress";
import type { ReportBillingPort } from "./personal-report-billing";
import { checkSameOrigin } from "./personal-report-entitlement";
import type { PersonalReportJobRecord, PersonalReportJobService } from "./personal-report-job-service-core";
@@ -138,6 +142,7 @@ export function reportView(
row: PersonalReportRecord,
job?: PersonalReportJobRecord | null,
failure?: ReturnType<typeof summarizePersonalReportFailure> | null,
sections?: readonly ReportSectionProgress[] | null,
) {
return {
id: row.id,
@@ -150,6 +155,7 @@ export function reportView(
createdAt: row.createdAt,
completedAt: row.completedAt,
...(job ? { progressPercent: job.progressPercent, progressPhase: job.progressPhase } : {}),
...(sections && sections.length > 0 ? { sections } : {}),
...(failure?.summary ? { failureSummary: failure.summary } : {}),
...(failure?.innerReason ? { innerReason: failure.innerReason } : {}),
...(failure && failure.lastErrorCodes.length > 0 ? { sectionErrorCodes: failure.lastErrorCodes } : {}),
@@ -648,8 +654,15 @@ export type ReportReadCoreDeps = Readonly<{
document: unknown,
) => { ok: true; document: unknown } | { ok: false };
jobs?: Pick<PersonalReportJobService, "getOwnedByRequestId">;
/**
* Durable section rows. `sectionId` and `attemptCount` are read here to
* derive reader-visible chapter state and never leave the server as-is;
* see personal-report-progress.ts for the boundary.
*/
listSections?: (userId: string, requestId: string) => Promise<readonly Readonly<{
sectionId?: string;
status: string;
attemptCount?: number;
lastErrorCode: string | null;
}>[]>;
loadLongformMarkdown?: (input: Readonly<{
@@ -688,14 +701,30 @@ export async function resolveReportRead(deps: ReportReadCoreDeps): Promise<Repor
const appendix = deps.loadLongformAppendix
? await deps.loadLongformAppendix({ userId: deps.userId, reportId: deps.reportId })
: null;
// Section rows back two different readers: the failure summary, and the
// chapter progress the waiting screen shows while the run is still going.
// A ready report needs neither, so it keeps its single-query read path.
const sectionRows = (row.status === "failed" || row.status === "generating") && deps.listSections
? await deps.listSections(deps.userId, row.requestId)
: [];
const failure = row.status === "failed"
? summarizePersonalReportFailure({
themeCount: row.requestedThemes.length,
sections: deps.listSections ? await deps.listSections(deps.userId, row.requestId) : [],
sections: sectionRows,
failureCode: row.failureCode,
appendixLastErrorCode: appendix?.lastErrorCode ?? null,
})
: null;
const sectionProgress: readonly ReportSectionProgress[] | null = row.status === "generating"
? sectionRows.flatMap((section) => (
typeof section.sectionId === "string" && section.sectionId.length > 0
? [{
id: section.sectionId,
state: deriveSectionProgressState(section.status, section.attemptCount ?? 0),
}]
: []
))
: null;
if (row.status === "ready") {
const markdownFromAppendix = appendix?.status === "ready" && appendix.markdown?.trim()
? appendix.markdown
@@ -742,7 +771,7 @@ export async function resolveReportRead(deps: ReportReadCoreDeps): Promise<Repor
body: { report: reportView(row, job), reportDocument: validated.document },
};
}
return { status: 200, body: { report: reportView(row, job, failure) } };
return { status: 200, body: { report: reportView(row, job, failure, sectionProgress) } };
}
export type ReportDeleteCoreDeps = Readonly<{
@@ -0,0 +1,271 @@
/**
* 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;/);
});